| /* |
| * 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())}); |
| } |
| |
| // 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("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) {} |
| }; |
| |
| struct FakeImage : RiveRenderImage |
| { |
| FakeImage() : RiveRenderImage(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() |
| { |
| return make_rcp<gpu::RenderCanvas>(make_rcp<FakeImage>(), |
| make_rcp<FakeTarget>()); |
| } |
| } // namespace |
| |
| TEST_CASE("replay opens the sampled canvas before its reader despite record " |
| "order", |
| "[cmd][canvas-dag]") |
| { |
| DeferredSession session(nullptr); |
| 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("a canvas only frame still opens a screen frame", "[cmd][canvas-dag]") |
| { |
| DeferredSession session(nullptr); |
| 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("a frame that only creates resources opens no screen frame", |
| "[cmd][canvas-dag]") |
| { |
| DeferredSession session(nullptr); |
| // 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); |
| } |