Files
rive-cpp/tests/unit_tests/renderer/modulate_opacity_test.cpp
bodymovin d920ee0efd fix(scripting): search first parent transform component to build scri… (#11443) 99ca3a30cc
fix(scripting): search first parent transform component to build script node

feature: modulate opacity (#11427) 128d9d61e0
* feature: modulate opacity

* fix: clang-format

* fix: rust renderer has a no-op modulateOpacity

* fix: no-op modulateOpacity for canvas android

* feature: modulate opacity on android canvas

* fix: rcp ref

* fix: missing override

* fix: gms

* fix: make flutter_renderer match cg one

* fix: josh pr feedback

* fix: remove CG transparency layer

* fix: save modulated gradient up-front

* fix: store only one gradient ref

* fix: remove specific constructor

* fix: use GradDataArray!

* fix: expose currentModulatedOpacity

* fix: cg_factory modulated opacity value

* fix: modulate negative opacity test

* fix: verify double modulate negative also clamps

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
Co-authored-by: hernan <hernan@rive.app>
2026-01-13 01:13:05 +00:00

519 lines
16 KiB
C++

/*
* 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());
}