| /* |
| * Copyright 2025 Rive |
| */ |
| |
| #include "common/render_context_null.hpp" |
| #include "rive/renderer/rive_renderer.hpp" |
| #include "rive/shapes/paint/color.hpp" |
| #include "rive/math/raw_path.hpp" |
| #include "gradient.hpp" |
| #include <catch.hpp> |
| |
| using namespace rive; |
| using namespace rive::gpu; |
| |
| static RenderContext::FrameDescriptor s_frameDescriptor = { |
| .renderTargetWidth = 100, |
| .renderTargetHeight = 100, |
| }; |
| |
| // Helper to create a rectangular RawPath |
| static RawPath& make_rect(const AABB& bounds) |
| { |
| static RawPath path; |
| path.rewind(); |
| path.addRect(bounds); |
| return path; |
| } |
| |
| // Helper to create an oval RawPath |
| static RawPath& make_oval(const AABB& bounds) |
| { |
| static RawPath path; |
| path.rewind(); |
| path.addOval(bounds); |
| return path; |
| } |
| |
| // Helper to create a line path |
| static RawPath& make_line(float x1, float y1, float x2, float y2) |
| { |
| static RawPath path; |
| path.rewind(); |
| path.moveTo(x1, y1); |
| path.lineTo(x2, y2); |
| return path; |
| } |
| |
| // Helper to flush a frame |
| static void flushFrame(RenderContext* ctx) |
| { |
| auto renderTarget = |
| ctx->static_impl_cast<RenderContextNULL>()->makeRenderTarget( |
| s_frameDescriptor.renderTargetWidth, |
| s_frameDescriptor.renderTargetHeight); |
| ctx->flush({.renderTarget = renderTarget.get()}); |
| } |
| |
| // Test that modulateOpacity stacks correctly with save/restore. |
| TEST_CASE("modulate-opacity-save-restore", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| // Initial opacity should be 1.0 |
| CHECK(renderer.currentModulatedOpacity() == 1.0f); |
| |
| renderer.save(); |
| renderer.modulateOpacity(0.5f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.5f)); |
| |
| // Nested save/restore should multiply opacity |
| renderer.save(); |
| renderer.modulateOpacity(0.5f); |
| // Now effective opacity should be 0.5 * 0.5 = 0.25 |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.25f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| // After restore, opacity should be back to 0.5 |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.5f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| // After final restore, opacity should be back to 1.0 |
| CHECK(renderer.currentModulatedOpacity() == Approx(1.0f)); |
| renderer.drawPath(path.get(), paint.get()); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test that multiple calls to modulateOpacity within the same save level |
| // multiply together. |
| TEST_CASE("modulate-opacity-multiple-calls", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| renderer.save(); |
| renderer.modulateOpacity(0.5f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.5f)); |
| renderer.modulateOpacity(0.5f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.25f)); |
| renderer.modulateOpacity(0.5f); |
| // Effective opacity should be 0.5 * 0.5 * 0.5 = 0.125 |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.125f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test that opacity of 1.0 doesn't modify anything (fast path). |
| TEST_CASE("modulate-opacity-identity", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| // Calling modulateOpacity(1.0f) should have no effect |
| renderer.save(); |
| renderer.modulateOpacity(1.0f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(1.0f)); |
| renderer.modulateOpacity(1.0f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(1.0f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test that opacity of 0 results in fully transparent draw. |
| TEST_CASE("modulate-opacity-zero", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| renderer.save(); |
| renderer.modulateOpacity(0.0f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.0f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test Gradient::getModulated returns the same gradient when opacity is 1.0. |
| TEST_CASE("gradient-modulated-identity", "[RiveRenderer][opacity][gradient]") |
| { |
| ColorInt colors[] = {0xffff0000, 0xff00ff00, 0xff0000ff}; |
| float stops[] = {0.0f, 0.5f, 1.0f}; |
| |
| auto gradient = Gradient::MakeLinear(0, 0, 100, 0, colors, stops, 3); |
| REQUIRE(gradient != nullptr); |
| |
| // Modulating with 1.0 should return the same gradient instance |
| auto modulated = gradient->getModulated(1.0f); |
| CHECK(modulated.get() == gradient.get()); |
| } |
| |
| // Test Gradient::getModulated creates a new gradient with modulated colors. |
| TEST_CASE("gradient-modulated-colors", "[RiveRenderer][opacity][gradient]") |
| { |
| ColorInt colors[] = {0xffff0000, 0xff00ff00}; |
| float stops[] = {0.0f, 1.0f}; |
| |
| auto gradient = Gradient::MakeLinear(0, 0, 100, 0, colors, stops, 2); |
| REQUIRE(gradient != nullptr); |
| |
| // Modulating with 0.5 should create a new gradient |
| auto modulated = gradient->getModulated(0.5f); |
| CHECK(modulated.get() != gradient.get()); |
| |
| // The modulated gradient should have reduced alpha |
| // 255 * 0.5 = 127.5, which rounds to 128 |
| const ColorInt* modColors = modulated->colors(); |
| CHECK(colorAlpha(modColors[0]) == 128); |
| CHECK(colorAlpha(modColors[1]) == 128); |
| } |
| |
| // Test Gradient::getModulated caches the last-used modulated gradient. |
| TEST_CASE("gradient-modulated-caching", "[RiveRenderer][opacity][gradient]") |
| { |
| ColorInt colors[] = {0xffff0000, 0xff00ff00}; |
| float stops[] = {0.0f, 1.0f}; |
| |
| auto gradient = Gradient::MakeLinear(0, 0, 100, 0, colors, stops, 2); |
| REQUIRE(gradient != nullptr); |
| |
| // First call with 0.5 creates a new modulated gradient |
| auto modulated1 = gradient->getModulated(0.5f); |
| CHECK(modulated1.get() != gradient.get()); |
| |
| // Second call with same opacity should return the cached version |
| auto modulated2 = gradient->getModulated(0.5f); |
| CHECK(modulated2.get() == modulated1.get()); |
| |
| // Different opacity should create a different gradient (replaces cache) |
| auto modulated3 = gradient->getModulated(0.25f); |
| CHECK(modulated3.get() != modulated1.get()); |
| |
| // Calling with 0.25 again returns cached version |
| auto modulated4 = gradient->getModulated(0.25f); |
| CHECK(modulated4.get() == modulated3.get()); |
| |
| // Calling with 0.5 again creates a new gradient (cache was replaced) |
| auto modulated5 = gradient->getModulated(0.5f); |
| CHECK(modulated5.get() != modulated1.get()); // New instance |
| CHECK(modulated5.get() != modulated3.get()); // Different from 0.25 version |
| } |
| |
| // Test that modulated gradients preserve stops. |
| TEST_CASE("gradient-modulated-preserves-stops", |
| "[RiveRenderer][opacity][gradient]") |
| { |
| ColorInt colors[] = {0xffff0000, 0xff00ff00, 0xff0000ff}; |
| float stops[] = {0.0f, 0.3f, 1.0f}; |
| |
| auto gradient = Gradient::MakeLinear(0, 0, 100, 0, colors, stops, 3); |
| REQUIRE(gradient != nullptr); |
| |
| auto modulated = gradient->getModulated(0.5f); |
| REQUIRE(modulated != nullptr); |
| |
| // Count should be preserved |
| CHECK(modulated->count() == gradient->count()); |
| |
| // Stops should be preserved (may be normalized but relative order kept) |
| const float* modStops = modulated->stops(); |
| CHECK(modStops[0] <= modStops[1]); |
| CHECK(modStops[1] <= modStops[2]); |
| } |
| |
| // Test radial gradient modulation. |
| TEST_CASE("gradient-radial-modulated", "[RiveRenderer][opacity][gradient]") |
| { |
| ColorInt colors[] = {0x80ff0000, 0x80ffffff}; |
| float stops[] = {0.0f, 1.0f}; |
| |
| auto gradient = Gradient::MakeRadial(50, 50, 50, colors, stops, 2); |
| REQUIRE(gradient != nullptr); |
| CHECK(gradient->paintType() == PaintType::radialGradient); |
| |
| auto modulated = gradient->getModulated(0.5f); |
| REQUIRE(modulated != nullptr); |
| CHECK(modulated->paintType() == PaintType::radialGradient); |
| |
| // Original alpha was 0x80 (128), modulating by 0.5 gives ~64 |
| const ColorInt* modColors = modulated->colors(); |
| CHECK(colorAlpha(modColors[0]) == 64); |
| CHECK(colorAlpha(modColors[1]) == 64); |
| } |
| |
| // Test that drawing with gradient and modulated opacity works. |
| TEST_CASE("draw-path-gradient-modulated", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| |
| ColorInt colors[] = {0xffff0000, 0xff0000ff}; |
| float stops[] = {0.0f, 1.0f}; |
| auto gradient = ctx->makeLinearGradient(0, 0, 100, 0, colors, stops, 2); |
| |
| auto paint = ctx->makeRenderPaint(); |
| paint->shader(gradient); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| // Draw with modulated opacity |
| renderer.save(); |
| renderer.modulateOpacity(0.5f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.5f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| // Draw without modulated opacity |
| CHECK(renderer.currentModulatedOpacity() == Approx(1.0f)); |
| renderer.drawPath(path.get(), paint.get()); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test deeply nested opacity modulation. |
| TEST_CASE("modulate-opacity-deeply-nested", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| // Create deep nesting of opacity modulation |
| const int depth = 10; |
| for (int i = 0; i < depth; ++i) |
| { |
| renderer.save(); |
| renderer.modulateOpacity(0.9f); |
| } |
| |
| // Effective opacity is 0.9^10 ≈ 0.3486784401 |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.3486784401f)); |
| renderer.drawPath(path.get(), paint.get()); |
| |
| // Unwind the stack |
| for (int i = 0; i < depth; ++i) |
| { |
| renderer.restore(); |
| } |
| |
| // Now draw at full opacity |
| CHECK(renderer.currentModulatedOpacity() == Approx(1.0f)); |
| renderer.drawPath(path.get(), paint.get()); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test that modulateOpacity works correctly with clipping. |
| TEST_CASE("modulate-opacity-with-clipping", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto clipPath = |
| ctx->makeRenderPath(make_oval({10, 10, 90, 90}), FillRule::nonZero); |
| auto drawPath = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| renderer.save(); |
| renderer.clipPath(clipPath.get()); |
| renderer.modulateOpacity(0.5f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.5f)); |
| renderer.drawPath(drawPath.get(), paint.get()); |
| renderer.restore(); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test that modulateOpacity works with transforms. |
| TEST_CASE("modulate-opacity-with-transform", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 50, 50}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| renderer.save(); |
| renderer.transform(Mat2D::fromTranslate(25, 25)); |
| renderer.transform(Mat2D::fromScale(0.5f, 0.5f)); |
| renderer.modulateOpacity(0.75f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.75f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test gradient single-entry cache behavior with repeated access. |
| TEST_CASE("gradient-modulated-cache-repeated-access", |
| "[RiveRenderer][opacity][gradient]") |
| { |
| ColorInt colors[] = {0xffff0000, 0xff00ff00}; |
| float stops[] = {0.0f, 1.0f}; |
| |
| auto gradient = Gradient::MakeLinear(0, 0, 100, 0, colors, stops, 2); |
| REQUIRE(gradient != nullptr); |
| |
| // Create a modulated gradient with opacity 0.5 |
| auto modulated05 = gradient->getModulated(0.5f); |
| CHECK(modulated05 != nullptr); |
| |
| // Access same opacity multiple times - should return cached version |
| for (int i = 0; i < 10; ++i) |
| { |
| auto m = gradient->getModulated(0.5f); |
| CHECK(m.get() == modulated05.get()); |
| } |
| |
| // Switch to different opacity - replaces cache |
| auto modulated025 = gradient->getModulated(0.25f); |
| CHECK(modulated025 != nullptr); |
| CHECK(modulated025.get() != modulated05.get()); |
| |
| // Repeated access to new opacity returns cached version |
| for (int i = 0; i < 10; ++i) |
| { |
| auto m = gradient->getModulated(0.25f); |
| CHECK(m.get() == modulated025.get()); |
| } |
| } |
| |
| // Test that stroke paths work with modulated opacity. |
| TEST_CASE("modulate-opacity-stroke", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_line(10, 50, 90, 50), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->style(RenderPaintStyle::stroke); |
| paint->thickness(5.0f); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| renderer.save(); |
| renderer.modulateOpacity(0.5f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.5f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test edge case: very small opacity values. |
| TEST_CASE("modulate-opacity-very-small", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| renderer.save(); |
| renderer.modulateOpacity(0.001f); |
| CHECK(renderer.currentModulatedOpacity() == Approx(0.001f)); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| flushFrame(ctx.get()); |
| } |
| |
| // Test that negative opacity clamps to 0. |
| TEST_CASE("modulate-opacity-negative-clamps-to-zero", "[RiveRenderer][opacity]") |
| { |
| auto ctx = RenderContextNULL::MakeContext(); |
| ctx->beginFrame(s_frameDescriptor); |
| |
| auto path = |
| ctx->makeRenderPath(make_rect({0, 0, 100, 100}), FillRule::nonZero); |
| auto paint = ctx->makeRenderPaint(); |
| paint->color(0xffffffff); |
| |
| RiveRenderer renderer(ctx.get()); |
| |
| // Negative opacity should clamp to 0 |
| renderer.save(); |
| renderer.modulateOpacity(-0.5f); |
| CHECK(renderer.currentModulatedOpacity() == 0.0f); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| |
| // Very negative value should also clamp to 0 |
| renderer.save(); |
| renderer.modulateOpacity(-100.0f); |
| CHECK(renderer.currentModulatedOpacity() == 0.0f); |
| renderer.restore(); |
| |
| // After restore, opacity should be back to 1.0 |
| CHECK(renderer.currentModulatedOpacity() == 1.0f); |
| |
| renderer.save(); |
| renderer.modulateOpacity(-0.5f); |
| renderer.modulateOpacity(-0.5f); |
| CHECK(renderer.currentModulatedOpacity() == 0.0f); |
| renderer.drawPath(path.get(), paint.get()); |
| renderer.restore(); |
| // After restore, opacity should be back to 1.0 |
| CHECK(renderer.currentModulatedOpacity() == 1.0f); |
| |
| flushFrame(ctx.get()); |
| } |