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>
This commit is contained in:
bodymovin
2026-01-13 01:13:05 +00:00
parent c511795426
commit d920ee0efd
24 changed files with 800 additions and 31 deletions

View File

@@ -1 +1 @@
620000211e23da8be6c8603bf69f2b4d682817fd
99ca3a30cc79d88ccc740e9f1a90f3690660e2e7

View File

@@ -7,6 +7,7 @@
#include "rive/renderer.hpp"
#include "utils/auto_cf.hpp"
#include <vector>
#if defined(RIVE_BUILD_FOR_OSX)
#include <ApplicationServices/ApplicationServices.h>
@@ -21,6 +22,7 @@ class CGRenderer : public Renderer
{
protected:
CGContextRef m_ctx;
std::vector<float> m_opacityStack{1.0f};
public:
CGRenderer(CGContextRef ctx, int width, int height);
@@ -29,6 +31,7 @@ public:
void save() override;
void restore() override;
void transform(const Mat2D& transform) override;
void modulateOpacity(float opacity) override;
void clipPath(RenderPath* path) override;
void drawPath(RenderPath* path, RenderPaint* paint) override;
void drawImage(const RenderImage*,

View File

@@ -379,21 +379,38 @@ CGRenderer::CGRenderer(CGContextRef ctx, int width, int height) : m_ctx(ctx)
CGRenderer::~CGRenderer() { CGContextRestoreGState(m_ctx); }
void CGRenderer::save() { CGContextSaveGState(m_ctx); }
void CGRenderer::save()
{
CGContextSaveGState(m_ctx);
m_opacityStack.push_back(m_opacityStack.back());
}
void CGRenderer::restore() { CGContextRestoreGState(m_ctx); }
void CGRenderer::restore()
{
CGContextRestoreGState(m_ctx);
if (m_opacityStack.size() > 1)
{
m_opacityStack.pop_back();
}
}
void CGRenderer::transform(const Mat2D& m)
{
CGContextConcatCTM(m_ctx, convert(m));
}
void CGRenderer::modulateOpacity(float opacity)
{
m_opacityStack.back() = std::max(0.0f, m_opacityStack.back() * opacity);
}
void CGRenderer::drawPath(RenderPath* path, RenderPaint* paint)
{
LITE_RTTI_CAST_OR_RETURN(cgpaint, CGRenderPaint*, paint);
LITE_RTTI_CAST_OR_RETURN(cgpath, CGRenderPath*, path);
cgpaint->apply(m_ctx);
CGContextSetAlpha(m_ctx, m_opacityStack.back());
CGContextBeginPath(m_ctx);
CGContextAddPath(m_ctx, cgpath->path());
@@ -407,8 +424,9 @@ void CGRenderer::drawPath(RenderPath* path, RenderPaint* paint)
CGContextSaveGState(m_ctx);
CGContextClip(m_ctx);
// so the gradient modulates with the color's alpha
CGContextSetAlpha(m_ctx, cgpaint->opacity());
// so the gradient modulates with the color's alpha and the modulated
// opacity
CGContextSetAlpha(m_ctx, cgpaint->opacity() * m_opacityStack.back());
sh->draw(m_ctx);
CGContextRestoreGState(m_ctx);
@@ -438,9 +456,10 @@ void CGRenderer::drawImage(const RenderImage* image,
LITE_RTTI_CAST_OR_RETURN(cgimg, const CGRenderImage*, image);
auto bounds = CGRectMake(0, 0, image->width(), image->height());
float finalOpacity = std::max(0.0f, opacity * m_opacityStack.back());
CGContextSaveGState(m_ctx);
CGContextSetAlpha(m_ctx, opacity);
CGContextSetAlpha(m_ctx, finalOpacity);
CGContextSetBlendMode(m_ctx, convert(blendMode));
cgimg->applyLocalMatrix(m_ctx);
CGContextDrawImage(m_ctx, bounds, cgimg->m_image);
@@ -482,13 +501,15 @@ void CGRenderer::drawImageMesh(const RenderImage* image,
auto pts = cgvertices->vecs();
auto uvs = cguvcoords->vecs();
float finalOpacity = std::max(0.0f, opacity * m_opacityStack.back());
// We use the path to set the clip for each triangle. Since calling
// CGContextClip() resets the path, we only need to this once at
// the beginning.
CGContextBeginPath(m_ctx);
CGContextSaveGState(m_ctx);
CGContextSetAlpha(m_ctx, opacity);
CGContextSetAlpha(m_ctx, finalOpacity);
CGContextSetBlendMode(m_ctx, convert(blendMode));
CGContextSetShouldAntialias(m_ctx, false);

View File

@@ -220,6 +220,12 @@ public:
BlendMode,
float opacity) = 0;
// Modulate the opacity of subsequent draw calls. The opacity is stacked
// multiplicatively (e.g., modulateOpacity(0.5) followed by
// modulateOpacity(0.2) = 0.1 effective opacity). The modulated opacity is
// captured by save() and restored by restore().
virtual void modulateOpacity(float opacity) = 0;
// helpers
void translate(float x, float y);

View File

@@ -16,6 +16,7 @@ class RenderPaint;
class ShapePaintMutator;
class Feather;
class ShapePaintContainer;
class TransformComponent;
class ShapePaint : public ShapePaintBase,
public EffectsContainer,
public PathProvider
@@ -83,6 +84,8 @@ public:
void update(ComponentDirt value) override;
virtual ShapePaintType paintType() const = 0;
TransformComponent* parentTransformComponent() const;
private:
Feather* m_feather = nullptr;
};

View File

@@ -30,6 +30,7 @@ public:
BlendMode,
float) override
{}
void modulateOpacity(float) override {}
};
} // namespace rive

View File

@@ -195,6 +195,7 @@ public:
rcp<const RiveRenderPath>,
FillRule,
const RiveRenderPaint*,
float modulatedOpacity,
RawPath* scratchPath);
// Determines how coverage is calculated for antialiasing and feathers.
@@ -216,6 +217,7 @@ public:
rcp<const RiveRenderPath>,
FillRule,
const RiveRenderPaint*,
float modulatedOpacity,
CoverageType,
const RenderContext::FrameDescriptor&);
@@ -356,7 +358,7 @@ protected:
const RiveRenderPath* const m_pathRef;
const FillRule m_pathFillRule; // Fill rule can mutate on RenderPath.
const Gradient* m_gradientRef;
const Gradient* m_gradientRef; // Already modulated if opacity != 1.0
const gpu::PaintType m_paintType;
const CoverageType m_coverageType;
float m_strokeRadius = 0;

View File

@@ -47,6 +47,7 @@ public:
uint32_t indexCount,
BlendMode,
float opacity) override;
void modulateOpacity(float opacity) override;
// Determines if a path is an axis-aligned rectangle that can be represented
// by rive::AABB.
@@ -62,6 +63,10 @@ public:
{
return m_stack.back().clipRectMatrix;
}
float currentModulatedOpacity() const
{
return m_stack.back().modulatedOpacity;
}
#endif
private:
@@ -93,6 +98,7 @@ private:
Mat2D clipRectMatrix;
const gpu::ClipRectInverseMatrix* clipRectInverseMatrix = nullptr;
bool clipIsEmpty = false;
float modulatedOpacity = 1.0f;
};
std::vector<RenderState> m_stack{1};

View File

@@ -422,6 +422,7 @@ DrawUniquePtr PathDraw::Make(RenderContext* context,
rcp<const RiveRenderPath> path,
FillRule fillRule,
const RiveRenderPaint* paint,
float modulatedOpacity,
RawPath* scratchPath)
{
RIVE_PROF_SCOPE();
@@ -512,6 +513,7 @@ DrawUniquePtr PathDraw::Make(RenderContext* context,
std::move(path),
fillRule,
paint,
modulatedOpacity,
coverageType,
context->frameDescriptor());
if (doTriangulation)
@@ -536,6 +538,7 @@ PathDraw::PathDraw(IAABB pixelBounds,
rcp<const RiveRenderPath> path,
FillRule initialFillRule,
const RiveRenderPaint* paint,
float modulatedOpacity,
CoverageType coverageType,
const RenderContext::FrameDescriptor& frameDesc) :
Draw(pixelBounds,
@@ -547,7 +550,7 @@ PathDraw::PathDraw(IAABB pixelBounds,
m_pathRef(path.release()),
m_pathFillRule(frameDesc.clockwiseFillOverride ? FillRule::clockwise
: initialFillRule),
m_gradientRef(safe_ref(paint->getGradient())),
m_gradientRef(paint->getGradientWithOpacity(modulatedOpacity).release()),
m_paintType(paint->getType()),
m_coverageType(coverageType)
{
@@ -685,6 +688,27 @@ PathDraw::PathDraw(IAABB pixelBounds,
m_simplePaintValue = paint->getSimpleValue();
// Apply modulated opacity to the paint value.
// Gradient modulation is handled upfront in the gradient initialization.
if (modulatedOpacity != 1.0f)
{
switch (m_paintType)
{
case gpu::PaintType::solidColor:
m_simplePaintValue.color =
colorModulateOpacity(m_simplePaintValue.color,
modulatedOpacity);
break;
case gpu::PaintType::image:
m_simplePaintValue.imageOpacity *= modulatedOpacity;
break;
case gpu::PaintType::linearGradient:
case gpu::PaintType::radialGradient:
case gpu::PaintType::clipUpdate:
break;
}
}
if (m_coverageType == CoverageType::atlas)
{
// Reserve two triangles for our on-screen rectangle that reads coverage
@@ -1364,11 +1388,13 @@ bool PathDraw::allocateResources(RenderContext::LogicalFlush* flush)
// Allocate a gradient if needed. Do this first since it's more expensive to
// fail after setting up an atlas draw than a gradient draw.
if (m_gradientRef != nullptr &&
!flush->allocateGradient(m_gradientRef,
&m_simplePaintValue.colorRampLocation))
if (m_gradientRef != nullptr)
{
return false;
if (!flush->allocateGradient(m_gradientRef,
&m_simplePaintValue.colorRampLocation))
{
return false;
}
}
// Allocate a coverage buffer range or atlas region if needed.

View File

@@ -188,4 +188,39 @@ bool Gradient::isOpaque() const
return m_isOpaque == gpu::TriState::yes;
}
rcp<Gradient> Gradient::getModulated(float opacity) const
{
// Fast path: no modulation needed
if (opacity == 1.0f)
{
return ref_rcp(const_cast<Gradient*>(this));
}
// Check single-entry cache
if (m_lastModulatedOpacity == opacity && m_lastModulatedGradient)
{
return m_lastModulatedGradient;
}
// Create new modulated gradient
GradDataArray<ColorInt> newColors(m_count);
for (size_t i = 0; i < m_count; ++i)
{
newColors[i] = colorModulateOpacity(m_colors[i], opacity);
}
GradDataArray<float> newStops(m_stops.get(), m_count);
m_lastModulatedGradient = rcp(new Gradient(m_paintType,
std::move(newColors),
std::move(newStops),
m_count,
m_coeffs[0],
m_coeffs[1],
m_coeffs[2]));
m_lastModulatedOpacity = opacity;
return m_lastModulatedGradient;
}
} // namespace rive::gpu

View File

@@ -10,6 +10,7 @@
namespace rive::gpu
{
// Copies an array of colors or stops for a gradient.
// Stores the data locally if there are 4 values or fewer.
// Spills onto the heap if there are >4 values.
@@ -25,6 +26,13 @@ public:
memcpy(m_data, data, count * sizeof(T));
}
// Allocate without initializing (caller must fill in data).
explicit GradDataArray(size_t count)
{
m_data =
count <= m_localData.size() ? m_localData.data() : new T[count];
}
GradDataArray(GradDataArray&& other)
{
if (other.m_data == other.m_localData.data())
@@ -83,6 +91,11 @@ public:
size_t count() const { return m_count; }
bool isOpaque() const;
// Get or create a modulated variant of this gradient.
// Caches the last-used modulated gradient for efficient reuse when the same
// opacity is requested multiple times (e.g., multiple draws in one frame).
rcp<Gradient> getModulated(float opacity) const;
private:
Gradient(PaintType paintType,
GradDataArray<ColorInt>&& colors, // [count]
@@ -107,6 +120,11 @@ private:
size_t m_count;
std::array<float, 3> m_coeffs;
mutable gpu::TriState m_isOpaque = gpu::TriState::unknown;
// Single-entry cache for last-used modulated gradient
mutable rcp<Gradient> m_lastModulatedGradient;
mutable float m_lastModulatedOpacity =
-1.0f; // -1 as sentinel (valid range is 0..1)
};
} // namespace rive::gpu

View File

@@ -31,6 +31,15 @@ void RiveRenderPaint::shader(rcp<RenderShader> shader)
m_imageTexture.reset();
}
rcp<gpu::Gradient> RiveRenderPaint::getGradientWithOpacity(float opacity) const
{
if (m_gradient)
{
return m_gradient->getModulated(opacity);
}
return nullptr;
}
void RiveRenderPaint::image(rcp<gpu::Texture> imageTexture, float opacity)
{
m_paintType = gpu::PaintType::image;

View File

@@ -45,6 +45,7 @@ public:
bool getIsStroked() const { return m_stroked; }
ColorInt getColor() const { return m_simpleValue.color; }
const gpu::Gradient* getGradient() const { return m_gradient.get(); }
rcp<gpu::Gradient> getGradientWithOpacity(float opacity) const;
gpu::Texture* getImageTexture() const { return m_imageTexture.get(); }
ImageSampler getImageSampler() const { return m_imageSampler; }
float getImageOpacity() const { return m_simpleValue.imageOpacity; }

View File

@@ -108,6 +108,12 @@ void RiveRenderer::transform(const Mat2D& matrix)
m_stack.back().matrix = m_stack.back().matrix * matrix;
}
void RiveRenderer::modulateOpacity(float opacity)
{
m_stack.back().modulatedOpacity =
std::max(0.0f, m_stack.back().modulatedOpacity * opacity);
}
void RiveRenderer::drawPath(RenderPath* renderPath, RenderPaint* renderPaint)
{
RIVE_PROF_SCOPE()
@@ -161,6 +167,7 @@ void RiveRenderer::drawPath(RenderPath* renderPath, RenderPaint* renderPaint)
matrixMaxScale),
path->getFillRule(),
paint,
m_stack.back().modulatedOpacity,
&m_scratchPath));
return;
}
@@ -171,6 +178,7 @@ void RiveRenderer::drawPath(RenderPath* renderPath, RenderPaint* renderPaint)
ref_rcp(path),
path->getFillRule(),
paint,
m_stack.back().modulatedOpacity,
&m_scratchPath));
}
@@ -343,6 +351,10 @@ void RiveRenderer::drawImage(const RenderImage* renderImage,
return;
}
// Apply modulated opacity (clamp to prevent negative values)
float finalOpacity =
std::max(0.0f, opacity * m_stack.back().modulatedOpacity);
// Scale the view matrix so we can draw this image as the rect [0, 0, 1, 1].
save();
scale(image->width(), image->height());
@@ -362,7 +374,7 @@ void RiveRenderer::drawImage(const RenderImage* renderImage,
blendMode,
std::move(imageTexture),
imageSampler,
opacity)));
finalOpacity)));
}
}
else
@@ -378,7 +390,7 @@ void RiveRenderer::drawImage(const RenderImage* renderImage,
}
RiveRenderPaint paint;
paint.image(std::move(imageTexture), opacity);
paint.image(std::move(imageTexture), finalOpacity);
paint.blendMode(blendMode);
paint.imageSampler(imageSampler);
drawPath(m_unitRectPath.get(), &paint);
@@ -418,6 +430,10 @@ void RiveRenderer::drawImageMesh(const RenderImage* renderImage,
return;
}
// Apply modulated opacity (clamp to prevent negative values)
float finalOpacity =
std::max(0.0f, opacity * m_stack.back().modulatedOpacity);
clipAndPushDraw(gpu::DrawUniquePtr(
m_context->make<gpu::ImageMeshDraw>(gpu::Draw::FULLSCREEN_PIXEL_BOUNDS,
m_stack.back().matrix,
@@ -428,7 +444,7 @@ void RiveRenderer::drawImageMesh(const RenderImage* renderImage,
std::move(uvCoords_f32),
std::move(indices_u16),
indexCount,
opacity)));
finalOpacity)));
}
void RiveRenderer::clipAndPushDraw(gpu::DrawUniquePtr draw)
@@ -573,12 +589,14 @@ RiveRenderer::ApplyClipResult RiveRenderer::applyClip(gpu::Draw* draw)
RiveRenderPaint clipUpdatePaint;
clipUpdatePaint.clipUpdate(/*clip THIS clipDraw against:*/ lastClipID);
gpu::DrawUniquePtr clipDraw = gpu::PathDraw::Make(m_context,
clip.matrix,
clip.path,
clip.fillRule,
&clipUpdatePaint,
&m_scratchPath);
gpu::DrawUniquePtr clipDraw =
gpu::PathDraw::Make(m_context,
clip.matrix,
clip.path,
clip.fillRule,
&clipUpdatePaint,
1.0f, // no opacity modulation for clips
&m_scratchPath);
if (clipDraw == nullptr)
{

View File

@@ -6,6 +6,7 @@
#define _RIVE_SKIA_RENDERER_HPP_
#include "rive/renderer.hpp"
#include <vector>
class SkCanvas;
@@ -15,12 +16,14 @@ class SkiaRenderer : public Renderer
{
protected:
SkCanvas* m_Canvas;
std::vector<float> m_opacityStack{1.0f};
public:
SkiaRenderer(SkCanvas* canvas) : m_Canvas(canvas) {}
void save() override;
void restore() override;
void transform(const Mat2D& transform) override;
void modulateOpacity(float opacity) override;
void clipPath(RenderPath* path) override;
void drawPath(RenderPath* path, RenderPaint* paint) override;
void drawImage(const RenderImage*,

View File

@@ -15,6 +15,7 @@
#include "include/core/SkVertices.h"
#include "include/effects/SkGradientShader.h"
#include "include/effects/SkImageFilters.h"
#include "include/core/SkColorFilter.h"
#include "rive/math/vec2d.hpp"
#include "rive/shapes/paint/color.hpp"
@@ -218,19 +219,70 @@ void SkiaRenderPaint::shader(rcp<RenderShader> rsh)
m_Paint.setShader(sksh ? sksh->shader : nullptr);
}
void SkiaRenderer::save() { m_Canvas->save(); }
void SkiaRenderer::restore() { m_Canvas->restore(); }
void SkiaRenderer::save()
{
m_Canvas->save();
m_opacityStack.push_back(m_opacityStack.back());
}
void SkiaRenderer::restore()
{
m_Canvas->restore();
if (m_opacityStack.size() > 1)
{
m_opacityStack.pop_back();
}
}
void SkiaRenderer::transform(const Mat2D& transform)
{
m_Canvas->concat(ToSkia::convert(transform));
}
void SkiaRenderer::modulateOpacity(float opacity)
{
m_opacityStack.back() = std::max(0.0f, m_opacityStack.back() * opacity);
}
void SkiaRenderer::drawPath(RenderPath* path, RenderPaint* paint)
{
LITE_RTTI_CAST_OR_RETURN(skiaRenderPath, SkiaRenderPath*, path);
LITE_RTTI_CAST_OR_RETURN(skiaRenderPaint, SkiaRenderPaint*, paint);
SkiaRenderPaint::OverrideStrokeParamsForFeather ospff(skiaRenderPaint);
m_Canvas->drawPath(skiaRenderPath->path(), skiaRenderPaint->paint());
float modulatedOpacity = m_opacityStack.back();
if (modulatedOpacity != 1.0f)
{
// Apply modulated opacity using a color filter on the paint.
// This is more efficient than saveLayer as it doesn't allocate
// an offscreen buffer.
// We scale all color components (RGBA) by opacity since Skia uses
// pre-multiplied alpha.
SkPaint modulatedPaint(skiaRenderPaint->paint());
float matrix[20] = {
// clang-format off
modulatedOpacity, 0, 0, 0, 0, // R
0, modulatedOpacity, 0, 0, 0, // G
0, 0, modulatedOpacity, 0, 0, // B
0, 0, 0, modulatedOpacity, 0, // A
// clang-format on
};
auto opacityFilter = SkColorFilters::Matrix(matrix);
auto existingFilter = modulatedPaint.refColorFilter();
if (existingFilter)
{
modulatedPaint.setColorFilter(
opacityFilter->makeComposed(existingFilter));
}
else
{
modulatedPaint.setColorFilter(opacityFilter);
}
m_Canvas->drawPath(skiaRenderPath->path(), modulatedPaint);
}
else
{
m_Canvas->drawPath(skiaRenderPath->path(), skiaRenderPaint->paint());
}
}
void SkiaRenderer::clipPath(RenderPath* path)
@@ -245,8 +297,9 @@ void SkiaRenderer::drawImage(const RenderImage* image,
float opacity)
{
LITE_RTTI_CAST_OR_RETURN(skiaImage, const SkiaRenderImage*, image);
float finalOpacity = std::max(0.0f, opacity * m_opacityStack.back());
SkPaint paint;
paint.setAlphaf(opacity);
paint.setAlphaf(finalOpacity);
paint.setBlendMode(ToSkia::convert(blendMode));
m_Canvas->drawImage(skiaImage->skImage(), 0.0f, 0.0f, gSampling, &paint);
}
@@ -301,8 +354,9 @@ void SkiaRenderer::drawImageMesh(const RenderImage* image,
gSampling,
&scaleM);
float finalOpacity = std::max(0.0f, opacity * m_opacityStack.back());
SkPaint paint;
paint.setAlphaf(opacity);
paint.setAlphaf(finalOpacity);
paint.setBlendMode(ToSkia::convert(blendMode));
paint.setShader(shader);

View File

@@ -53,10 +53,9 @@ void ScriptedPathEffect::updateEffect(PathProvider* pathProvider,
// Stack: [self, "update", self]
lua_newrive<ScriptedPathData>(state, source->rawPath());
// Stack: [self, "update", self, pathData]
lua_newrive<ScriptedNode>(
state,
nullptr,
shapePaint->parent()->as<TransformComponent>());
lua_newrive<ScriptedNode>(state,
nullptr,
shapePaint->parentTransformComponent());
auto scriptedNode = lua_torive<ScriptedNode>(state, -1);
scriptedNode->shapePaint(shapePaint);
// Stack: [self, "update", self, pathData, node]

View File

@@ -2,6 +2,7 @@
#include "rive/shapes/shape_paint_container.hpp"
#include "rive/shapes/paint/feather.hpp"
#include "rive/artboard.hpp"
#include "rive/transform_component.hpp"
#include "rive/factory.hpp"
#include "rive/shapes/paint/fill.hpp"
#include "rive/profiler/profiler_macros.h"
@@ -186,4 +187,18 @@ void ShapePaint::addStrokeEffect(StrokeEffect* effect)
{
effect->addPathProvider(this);
EffectsContainer::addStrokeEffect(effect);
}
TransformComponent* ShapePaint::parentTransformComponent() const
{
auto _parent = parent();
while (_parent)
{
if (_parent->is<TransformComponent>())
{
return _parent->as<TransformComponent>();
}
_parent = _parent->parent();
}
return nullptr;
}

View File

@@ -9,6 +9,7 @@
#include "rive/tess/sub_path.hpp"
#include "rive/math/mat2d.hpp"
#include "rive/math/mat4.hpp"
#include <algorithm>
#include <vector>
#include <list>
@@ -19,6 +20,7 @@ struct RenderState
{
Mat2D transform;
std::vector<SubPath> clipPaths;
float modulatedOpacity = 1.0f;
};
class TessRenderer : public Renderer
@@ -42,7 +44,9 @@ public:
void save() override;
void restore() override;
void transform(const Mat2D& transform) override;
void modulateOpacity(float opacity) override;
const Mat2D& transform() { return m_Stack.back().transform; }
float modulatedOpacity() const { return m_Stack.back().modulatedOpacity; }
void clipPath(RenderPath* path) override;
void drawImage(const RenderImage*,
ImageSampler,

View File

@@ -28,6 +28,12 @@ void TessRenderer::transform(const Mat2D& transform)
stackMat = stackMat * transform;
}
void TessRenderer::modulateOpacity(float opacity)
{
m_Stack.back().modulatedOpacity =
std::max(0.0f, m_Stack.back().modulatedOpacity * opacity);
}
void TessRenderer::clipPath(RenderPath* path)
{

View File

@@ -64,6 +64,7 @@ public:
void save() override {}
void restore() override {}
void transform(const Mat2D& matrix) override {}
void modulateOpacity(float) override {}
void drawPath(RenderPath* path, RenderPaint* paint) override
{
auto renderPath = static_cast<RiveRenderPath*>(path);

View File

@@ -42,6 +42,7 @@ public:
? FillRule::clockwise
: FillRule::nonZero,
paint,
1.0f, // modulatedOpacity
SelectCoverageType(paint,
1,
context->platformFeatures(),

View File

@@ -0,0 +1,518 @@
/*
* 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());
}

View File

@@ -50,6 +50,7 @@ enum class SerializeOp : unsigned char
frame = 28,
frameSize = 29,
modulateOpacity = 30,
};
@@ -120,6 +121,8 @@ static const char* opToName(SerializeOp op)
return "frame";
case SerializeOp::frameSize:
return "frameSize";
case SerializeOp::modulateOpacity:
return "modulateOpacity";
}
return "???";
}
@@ -558,6 +561,12 @@ public:
}
}
void modulateOpacity(float opacity) override
{
m_writer->writeVarUint((uint32_t)SerializeOp::modulateOpacity);
m_writer->writeFloat(opacity);
}
void drawPath(RenderPath* path, RenderPaint* paint) override
{
m_writer->writeVarUint((uint32_t)SerializeOp::drawPath);
@@ -1312,6 +1321,16 @@ bool advancedMatch(std::vector<uint8_t>& fileA, std::vector<uint8_t>& fileB)
{
return false;
}
break;
case SerializeOp::modulateOpacity:
if (!floatMatches(opA,
"modulateopacity_value",
readerA,
readerB))
{
return false;
}
break;
}
}
if (!readerB.reachedEnd())