blob: 6c5c20c88e09d94f855b81165a752658b1e781d5 [file]
/*
* Copyright 2026 Rive
*/
// Sampler canvases replay after the canvases they sample regardless of
// record order. Pure byte math, no GPU.
#include "deferred_test_sink.hpp"
#include "rive/renderer/cmd/canvas_schedule.hpp"
#include "rive/renderer/cmd/deferred_replayer.hpp"
#include "rive/renderer/render_canvas.hpp"
#include <catch.hpp>
using namespace rive;
using namespace rive::cmd;
using Target = DeferredSegment::Target;
namespace
{
// A hand-built 2D stream plus its canvas segments.
struct StreamBuilder
{
std::vector<uint8_t> bytes;
std::vector<DeferredSegment> segments;
template <typename POD> void append(RenderCmd c, const POD& pod)
{
bytes.push_back(static_cast<uint8_t>(c));
const uint8_t* p = reinterpret_cast<const uint8_t*>(&pod);
bytes.insert(bytes.end(), p, p + sizeof(POD));
}
// Records a canvas bracket holding the given flagged image samples.
void canvasRange(
uint64_t canvasId,
std::initializer_list<uint64_t> sampledCanvasIds)
{
uint32_t begin = static_cast<uint32_t>(bytes.size());
// Noise the walker must skip.
DrawPathPOD path = {};
append(RenderCmd::drawPath, path);
for (uint64_t sampled : sampledCanvasIds)
{
DrawImagePOD draw = {};
draw.image = kCanvasHandleFlag | static_cast<RenderHandle>(sampled);
append(RenderCmd::drawImage, draw);
}
segments.push_back(
{Target::canvas, canvasId, begin, static_cast<uint32_t>(bytes.size())});
}
// Records a canvas bracket filling a path whose paint samples a canvas,
// the way the recorder names it after the draw.
void canvasRangeDrawingPaintSampling(uint64_t canvasId, uint64_t sampled)
{
uint32_t begin = static_cast<uint32_t>(bytes.size());
DrawPathPOD draw = {};
append(RenderCmd::drawPath, draw);
append(
RenderCmd::canvasSample,
ResIdPOD{kCanvasHandleFlag | static_cast<RenderHandle>(sampled)});
segments.push_back(
{Target::canvas, canvasId, begin, static_cast<uint32_t>(bytes.size())});
}
// A foreign image draw that is not a written canvas (host image).
void canvasRangeSamplingForeign(uint64_t canvasId, uint32_t foreignIndex)
{
uint32_t begin = static_cast<uint32_t>(bytes.size());
DrawImagePOD draw = {};
draw.image = kCanvasHandleFlag | foreignIndex;
append(RenderCmd::drawImage, draw);
segments.push_back(
{Target::canvas, canvasId, begin, static_cast<uint32_t>(bytes.size())});
}
CanvasSchedule schedule() const
{
return scheduleCanvases(
Span<const uint8_t>(bytes.data(), bytes.size()),
segments);
}
};
} // namespace
TEST_CASE("in-order sampler keeps record order", "[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRange(1, {}); // A writes
b.canvasRange(2, {1}); // B samples A, recorded after
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2});
CHECK_FALSE(s.hadCycle);
CHECK_FALSE(s.multiWriteFallback);
}
TEST_CASE("reader recorded before its writer reorders", "[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRange(2, {1}); // B samples A but records first
b.canvasRange(1, {}); // A writes
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2});
CHECK_FALSE(s.hadCycle);
}
TEST_CASE(
"reversed three-canvas chain schedules writer first",
"[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRange(3, {2}); // C samples B
b.canvasRange(2, {1}); // B samples A
b.canvasRange(1, {}); // A writes last in record order
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2, 3});
}
TEST_CASE("cycle demotes to record order and flags", "[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRange(1, {2}); // A samples B
b.canvasRange(2, {1}); // B samples A
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2});
CHECK(s.hadCycle);
}
TEST_CASE("self sample is a demoted edge, not a reorder", "[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRange(1, {1});
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1});
CHECK(s.hadCycle);
}
TEST_CASE("sampling an unwritten id adds no edge", "[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRangeSamplingForeign(1, 7); // host image or unwritten canvas
b.canvasRange(2, {});
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2});
CHECK_FALSE(s.hadCycle);
}
TEST_CASE(
"read between two writes of one canvas falls back",
"[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRange(1, {}); // A@v1
b.canvasRange(2, {1}); // B samples A mid-frame
b.canvasRange(1, {}); // A writes again
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2});
CHECK(s.multiWriteFallback);
}
TEST_CASE("drawImageMesh creates edges like drawImage", "[cmd][canvas-dag]")
{
StreamBuilder b;
uint32_t begin = static_cast<uint32_t>(b.bytes.size());
DrawImageMeshPOD mesh = {};
mesh.image = kCanvasHandleFlag | 1u;
b.append(RenderCmd::drawImageMesh, mesh);
b.segments.push_back(
{Target::canvas, 2, begin, static_cast<uint32_t>(b.bytes.size())});
b.canvasRange(1, {});
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2});
}
TEST_CASE("canvasSample creates edges like drawImage", "[cmd][canvas-dag]")
{
// Image-in-paint: the canvas reaches the draw through its paint, named by
// the canvasSample after it.
StreamBuilder b;
b.canvasRangeDrawingPaintSampling(2, 1); // B samples A but records first
b.canvasRange(1, {});
auto s = b.schedule();
REQUIRE(s.order == std::vector<uint64_t>{1, 2});
CHECK_FALSE(s.hadCycle);
}
TEST_CASE(
"independent canvases keep record order among themselves",
"[cmd][canvas-dag]")
{
StreamBuilder b;
b.canvasRange(3, {});
b.canvasRange(1, {5}); // samples a later writer
b.canvasRange(4, {});
b.canvasRange(5, {});
auto s = b.schedule();
// 5 must precede 1; 3 and 4 stay put relative to everyone they can.
REQUIRE(s.order == std::vector<uint64_t>{3, 4, 5, 1});
}
namespace
{
struct FakeTarget : gpu::RenderTarget
{
FakeTarget() : RenderTarget(8, 8) {}
};
// Logs canvas frame open order; canvas draws drop against the null renderer.
class OrderSink : public deferred_test::TestSink
{
public:
std::vector<gpu::RenderCanvas*> opened;
Renderer* beginCanvasContent(gpu::RenderCanvas* canvas, uint32_t) override
{
opened.push_back(canvas);
return nullptr;
}
};
rcp<gpu::RenderCanvas> fakeCanvas()
{
auto canvas = make_rcp<gpu::RenderCanvas>(8, 8);
canvas->setBacking(nullptr, make_rcp<FakeTarget>());
return canvas;
}
// The image ids the recorder named with canvasSample, in record order.
std::vector<RenderHandle> canvasSamples(DeferredSession& session)
{
std::vector<RenderHandle> ids;
RenderCommandReader r(
session.commandBuffer().commandBytes(),
session.commandBuffer().blobBytes());
uint8_t type;
while (r.next(type))
{
auto cmd = static_cast<RenderCmd>(type);
if (cmd == RenderCmd::canvasSample)
{
ids.push_back(r.read<ResIdPOD>().id);
}
else
{
r.skip(payloadSizeOf(cmd));
}
}
return ids;
}
} // namespace
TEST_CASE(
"replay opens the sampled canvas before its reader despite record "
"order",
"[cmd][canvas-dag]")
{
DeferredSession session(rive::ore::ReplayCaps{});
auto canvasA = fakeCanvas();
auto canvasB = fakeCanvas();
// B samples A but records first, exactly as a script may issue it.
Renderer* b = session.beginCanvasContent(canvasB.get(), 0);
b->drawImage(canvasA->renderImage(), {}, BlendMode::srcOver, 1.0f);
session.endCanvasContent(canvasB.get());
Renderer* a = session.beginCanvasContent(canvasA.get(), 0);
auto paint = session.makeRenderPaint();
auto path = session.makeEmptyRenderPath();
a->drawPath(path.get(), paint.get());
session.endCanvasContent(canvasA.get());
session.closeOpenRange();
auto frame = snapshotFrame(session);
OrderSink sink;
DeferredReplayer replayer;
replayer.replayFrame(frame, sink);
REQUIRE(sink.opened.size() == 2);
CHECK(sink.opened[0] == canvasA.get());
CHECK(sink.opened[1] == canvasB.get());
}
TEST_CASE(
"replay opens a canvas before a reader whose paint samples it",
"[cmd][canvas-dag]")
{
DeferredSession session(rive::ore::ReplayCaps{});
auto canvasA = fakeCanvas();
auto canvasB = fakeCanvas();
// B fills a path modulated by A's image but records first.
auto modulated = session.makeRenderPaint();
modulated->modulatedImage(
canvasA->renderImage(),
ImageSampler::LinearClamp(),
Mat2D());
auto path = session.makeEmptyRenderPath();
Renderer* b = session.beginCanvasContent(canvasB.get(), 0);
b->drawPath(path.get(), modulated.get());
session.endCanvasContent(canvasB.get());
Renderer* a = session.beginCanvasContent(canvasA.get(), 0);
auto paint = session.makeRenderPaint();
a->drawPath(path.get(), paint.get());
session.endCanvasContent(canvasA.get());
session.closeOpenRange();
auto frame = snapshotFrame(session);
OrderSink sink;
DeferredReplayer replayer;
replayer.replayFrame(frame, sink);
REQUIRE(sink.opened.size() == 2);
CHECK(sink.opened[0] == canvasA.get());
CHECK(sink.opened[1] == canvasB.get());
}
TEST_CASE(
"a canvas draw names the canvas its paint samples at the draw",
"[cmd][canvas-dag]")
{
DeferredSession session(rive::ore::ReplayCaps{});
auto canvasA = fakeCanvas();
auto canvasB = fakeCanvas();
auto canvasC = fakeCanvas();
auto paint = session.makeRenderPaint();
auto path = session.makeEmptyRenderPath();
auto modulate = [&](gpu::RenderCanvas* canvas) {
paint->modulatedImage(
canvas != nullptr ? canvas->renderImage() : nullptr,
ImageSampler::LinearClamp(),
Mat2D());
};
RenderHandle idA = session.canvases().imageDrawId(canvasA->renderImage());
Renderer* b = session.beginCanvasContent(canvasB.get(), 0);
modulate(canvasA.get());
b->drawPath(path.get(), paint.get()); // samples A
modulate(canvasC.get()); // repointed after the draw
modulate(nullptr);
b->drawPath(path.get(), paint.get()); // cleared, samples nothing
session.endCanvasContent(canvasB.get());
// Canvases replay before the screen, so a screen draw needs no edge.
modulate(canvasA.get());
session.screenRenderer()->drawPath(path.get(), paint.get());
session.closeOpenRange();
CHECK(canvasSamples(session) == std::vector<RenderHandle>{idA});
}
TEST_CASE("a canvas only frame still opens a screen frame", "[cmd][canvas-dag]")
{
DeferredSession session(rive::ore::ReplayCaps{});
auto canvas = fakeCanvas();
Renderer* c = session.beginCanvasContent(canvas.get(), 0);
auto paint = session.makeRenderPaint();
auto path = session.makeEmptyRenderPath();
c->drawPath(path.get(), paint.get());
session.endCanvasContent(canvas.get());
session.closeOpenRange();
auto frame = snapshotFrame(session);
OrderSink sink;
DeferredReplayer replayer;
replayer.replayFrame(frame, sink);
REQUIRE(sink.opened.size() == 1);
// The screen frame is where the host's clear and present live, so a frame
// that only fills canvases still owes its target one.
CHECK(sink.openedTargets() == 1);
}
TEST_CASE(
"canvas content ended with saves open ends balanced and records "
"nothing after",
"[cmd][canvas-dag]")
{
DeferredSession session(rive::ore::ReplayCaps{});
auto canvas = fakeCanvas();
auto paint = session.makeRenderPaint();
auto path = session.makeEmptyRenderPath();
Renderer* c = session.beginCanvasContent(canvas.get(), 0);
c->save();
c->save();
session.endCanvasContent(canvas.get());
// A drawer still holding the renderer, as an artboard draw visit whose
// script ended the frame does.
c->restore();
c->drawPath(path.get(), paint.get());
c->restore();
// The canvas's next frame records again.
CHECK(session.beginCanvasContent(canvas.get(), 0) == c);
c->drawPath(path.get(), paint.get());
session.endCanvasContent(canvas.get());
session.closeOpenRange();
auto stream = session.commandBuffer().commandBytes();
CommandReader<RenderCmd> reader(stream, session.commandBuffer().blobBytes());
int ranges = 0;
int draws = 0;
int depth = 0;
RenderCmd type;
while (reader.next(type))
{
if (type == RenderCmd::canvasContentBegin)
{
ranges++;
depth = 0;
}
else if (type == RenderCmd::save)
{
depth++;
}
else if (type == RenderCmd::restore)
{
depth--;
CHECK(depth >= 0);
}
else if (type == RenderCmd::drawPath)
{
draws++;
}
else if (type == RenderCmd::canvasContentEnd)
{
CHECK(depth == 0);
}
reader.skip(payloadSizeOf(type));
}
CHECK(ranges == 2);
CHECK(draws == 1);
}
TEST_CASE(
"a frame that only creates resources opens no screen frame",
"[cmd][canvas-dag]")
{
DeferredSession session(rive::ore::ReplayCaps{});
// Creates land outside every renderer. Attributing them would open a
// target that drew nothing, which is why they stay unattributed.
auto paint = session.makeRenderPaint();
auto path = session.makeEmptyRenderPath();
session.closeOpenRange();
auto frame = snapshotFrame(session);
REQUIRE_FALSE(frame.commands.empty());
OrderSink sink;
DeferredReplayer replayer;
replayer.replayFrame(frame, sink);
CHECK(sink.openedTargets() == 0);
}