mirror of
https://github.com/rive-app/rive-cpp.git
synced 2026-01-18 13:11:19 +01:00
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>
519 lines
16 KiB
C++
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());
|
|
}
|