feat(deferred): one session per render context, multi target replay, and the context tier deleted (#13368) e9f24d0296 * feat(deferred): one session per render context with multi target replay, the shared context tier deleted, and script GPU work routed from the import factory * fix(editor): paint the stage background while a new file's scripts compile, the vm swap skip was keeping the previous file's frame on the shared texture * fix(web): detach the deferred session before a pooled renderer is released, deleting a bound renderer left the session replaying into dead GL state * fix(editor): retry the shared texture clear until it lands or the new stage paints, opening from the file browser skipped it while the canvas was off screen * fix(editor): hold the shared texture clear until the canvas is displayed with real size, a frame recorded before then replays with no screen to present to * fix(editor): clear the shared texture when leaving for the file browser and let a transferred canvas clear while detached, the worker draws its bitmap regardless of the DOM * fix(web): present every screen target on the web sink, a frame recorded before a repoint carried the prior texture's target and static content went blank when it was refused * chore: move the investigation scripts and write ups to archive/deferred-investigation-scripts, the suite carries the conclusions as asserts * test(gm): the deferred GMs check themselves against their immediate frame in process, each pair was two identical goldens standing in for one parity assert, 487 baselines gone * chore: drop the working notes and dated benchmark reports from the tree, the archive branch keeps them * docs: pull the deferred rendering write up out of the tree, it lives on the archive branch * chore: pull the benchmark suite, its skill, driver, and bench only assets out of this landing, the archive branch carries them for the follow up * ci: run the example integration tests by directory again, the target list script left with the benchmark suite * ci: claim parity gm families so one worker runs each, and fetch the parity rivs over lfs in the unit test jobs * test(gm): seed browserstack baselines for the canvas dag gms from a metal render, the device rebaseline flow can refresh them if a threshold trips * refactor(runtime): drop the file level instance factory params, every dart call site passed null since the session became the import factory * refactor(deferred): drop the unused dart gpu census mirror, the investigation tooling that read it moved to the archive branch * refactor(native): drop exports with zero callers, riveShutdownGPUScripting was the only path freeing the context session so wiring real process exit teardown is a conscious follow-up * refactor(deferred): fold the dispose helper into its only caller and stop exporting it, nothing outside the package disposed a session * refactor(cmd): drop accessors nothing reads, the live counter's asserting recorder destructor is long gone * refactor(cmd): guard the 2d producer with the recording thread check, make id reuse unconditional, and drop the inert ore replay marker opcode and the canvas hook seam nothing plugs into * fix(web): an explicit released flag for isDisposed and an eager context for immediate consumers, a lazy texture read as disposed from birth and the pool bound stopped meaning live contexts * fix(cmd): read past the canvas id without naming it, the hook deletion left the variable unused and ci builds with werror * fix(tests): a lost context makes isReady false again, the parity rivs ship as plain assets so devices get real bytes, and the corpus walks use filesystem so msvc builds * fix(cmd): the 2d recording thread bind sat unreachable after a lambda return, move it into the constructor body * fix(deferred): probe the whole deferred abi before enabling it, make a failed worker renderer terminal instead of forever pending, and assert when a neighbor's frame reaches a native sink * chore(deferred): name the slot store as the worker bind publication point, pin factory immutability on the widget, and give the editor clear retries one generation * chore: move the factory immutability note above the condition it explains * test(gm): allow the golden diff tolerance on atomic backends in the parity compare, their raster order differs run to run and exactness failed frames no two of theirs ever matched * test(goldens): rebaseline Hero_v2 on vulkan atomic, our vulkan prepass fixes move 72 pixels by up to 14 and the shift reproduced twice, candidate eyeballed at full size * refactor(canvas): replace the deferBacking bool and sticky recording flag with makeDeferredRenderCanvas, only GL overrides it * chore(ps5): restore the agc canvas overloads to master, the base signature no longer forces the reshape Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head index 6143029..5ee99bb 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -e17fbf4aa8f011ec64e94b982b4e7aa7e9874010 +e9f24d0296d8f2513b0ac1287b9c5fb5f1314f7f
diff --git a/dev/defs/assets/script_asset.json b/dev/defs/assets/script_asset.json index 419ff1a..cb95eef 100644 --- a/dev/defs/assets/script_asset.json +++ b/dev/defs/assets/script_asset.json
@@ -56,7 +56,7 @@ "int": 1022, "string": "serializedimplementedmethods" }, - "description": "Bitfield of the optional script methods (init/draw/drawCanvas/advance/etc.) the editor detected by executing the generator. Bit layout matches OptionalScriptedMethods (bits 0-20). The runtime reads these directly instead of detecting at load. Defaults to all bits set ((1<<21)-1 = 2097151) so files exported before this property existed behave as 'implements everything' and rely on graceful dispatch (each callback no-ops when the method isn't actually present).", + "description": "Bitfield of the optional script methods (init/draw/advance/etc.) the editor detected by executing the generator. Bit layout matches OptionalScriptedMethods (bits 0-20). The runtime reads these directly instead of detecting at load. Defaults to all bits set ((1<<21)-1 = 2097151) so files exported before this property existed behave as 'implements everything' and rely on graceful dispatch (each callback no-ops when the method isn't actually present).", "coop": false, "exportsToRuntimeConditionally": true, "journal": false
diff --git a/include/rive/artboard.hpp b/include/rive/artboard.hpp index 9873159..dbe1d4c 100644 --- a/include/rive/artboard.hpp +++ b/include/rive/artboard.hpp
@@ -337,20 +337,10 @@ Drawable* firstDrawable() { return m_FirstDrawable; }; void addScriptedObject(ScriptedObject* object); - void drawCanvases(); - void internalDrawCanvases(); - /// Poll async work (image decodes, etc.) so promises resolve before /// script callbacks run. Called at the top of advance(). void pollAsyncWork(); -#ifdef WITH_RIVE_SCRIPTING - /// Returns the lua_State* (as void*) for the first drawCanvas scripted - /// object in this artboard or any nested artboard, recursively. Returns - /// nullptr if no drawCanvas scripts exist. Used by the Dart FFI layer to - /// open a GPU frame before calling drawCanvases(). - void* findDrawCanvasLuauState() const; -#endif void drawInternal(Renderer* renderer); void draw(Renderer* renderer) override; void addToRenderPath(RenderPath* path, const Mat2D& transform); @@ -544,13 +534,16 @@ // provided. int defaultStateMachineIndex() const; - /// Make an instance of this artboard. - template <typename T = ArtboardInstance> std::unique_ptr<T> instance() const + /// Make an instance of this artboard. A non null factory reroutes the + /// instance's render resource creation (a deferred session facade); + /// nested instances inherit it. + template <typename T = ArtboardInstance> + std::unique_ptr<T> instance(Factory* factory = nullptr) const { std::unique_ptr<T> artboardClone(new T); artboardClone->copy(*this); - artboardClone->m_Factory = m_Factory; + artboardClone->m_Factory = factory != nullptr ? factory : m_Factory; artboardClone->m_FrameOrigin = m_FrameOrigin; artboardClone->m_DataContext = m_DataContext; artboardClone->m_IsInstance = true; @@ -592,6 +585,14 @@ artboardClone->m_StateMachines.push_back(stateMachine); } + if (factory != nullptr && factory != m_Factory) + { + // Nested clones instanced off the file level source during the + // clone loop; redo them on the override factory before + // initialize wires animations to them. + artboardClone->reinstanceNestedArtboards(factory); + } + if (artboardClone->initialize() != StatusCode::Ok) { artboardClone = nullptr; @@ -601,6 +602,8 @@ return artboardClone; } + void reinstanceNestedArtboards(Factory* factory); + /// Returns true if the artboard is an instance of another bool isInstance() const { return m_IsInstance; }
diff --git a/include/rive/assets/script_asset.hpp b/include/rive/assets/script_asset.hpp index 5ec6ddb..a7f0493 100644 --- a/include/rive/assets/script_asset.hpp +++ b/include/rive/assets/script_asset.hpp
@@ -87,7 +87,8 @@ static const int m_resizesBit = 1 << 12; static const int m_listenerPerforms = 1 << 13; static const int m_listenerPerformsAction = 1 << 14; - static const int m_drawsCanvasBit = 1 << 15; + // Bit 15 was drawCanvas; the callback is gone but the wire bit stays + // reserved so older exports keep their layout. static const int m_wantsKeyboardInputBit = 1 << 16; static const int m_wantsTextInputBit = 1 << 17; static const int m_wantsGamepadConnect = 1 << 18; @@ -168,10 +169,6 @@ { return (m_implementedMethods & m_dataReverseConvertsBit) != 0; } - bool drawsCanvas() - { - return (m_implementedMethods & m_drawsCanvasBit) != 0; - } bool wantsKeyboardInput() { return (m_implementedMethods & m_wantsKeyboardInputBit) != 0;
diff --git a/include/rive/factory.hpp b/include/rive/factory.hpp index dbee874..1c9fc72 100644 --- a/include/rive/factory.hpp +++ b/include/rive/factory.hpp
@@ -23,6 +23,10 @@ { class Context; } +namespace cmd +{ +class DeferredCanvasHost; +} class Factory { @@ -69,6 +73,19 @@ // shifting existing vtable slots. virtual ore::Context* ore() { return nullptr; } + // The GPU render context an import through this factory should give its + // scripts, as a Factory so this header stays free of gpu types. A render + // context answers with itself; a recording session answers with the one it + // records for, which on web is null until a render texture attaches, so + // callers that deferred an allocation ask again rather than caching the + // null they saw at import. Null means the importer cannot route GPU + // scripting. + virtual Factory* renderContext() { return nullptr; } + + // Set when script canvas work must record rather than issue. Null means + // scripts draw straight to the driver. + virtual cmd::DeferredCanvasHost* deferredCanvasHost() { return nullptr; } + rcp<Font> decodeFont(Span<const uint8_t>); rcp<AudioSource> decodeAudio(Span<const uint8_t>);
diff --git a/include/rive/file.hpp b/include/rive/file.hpp index c662e79..1325033 100644 --- a/include/rive/file.hpp +++ b/include/rive/file.hpp
@@ -40,6 +40,7 @@ class ViewModelRuntime; class BindableArtboard; class ScriptingVM; +class ScriptingContext; class ScriptedInterpolator; /// @@ -129,7 +130,6 @@ Span<const rcp<FileAsset>> assets() const; - // Instances std::unique_ptr<ArtboardInstance> artboardDefault() const; std::unique_ptr<ArtboardInstance> artboardAt(size_t index) const; std::unique_ptr<ArtboardInstance> artboardNamed(std::string name) const; @@ -315,6 +315,7 @@ void makeScriptingVM(); void cleanupScriptingVM(); void registerScripts(); + void routeScriptingToImportFactory(ScriptingContext* context); #endif rcp<ViewModelInstance> copyViewModelInstance(
diff --git a/include/rive/lua/rive_lua_libs.hpp b/include/rive/lua/rive_lua_libs.hpp index cb0fba7..faa7eb9 100644 --- a/include/rive/lua/rive_lua_libs.hpp +++ b/include/rive/lua/rive_lua_libs.hpp
@@ -72,6 +72,10 @@ class ScriptedObject; class StateMachineInstance; class TransformComponent; +namespace cmd +{ +class DeferredCanvasHost; +} enum class LuaAtoms : int16_t { // Vector @@ -327,7 +331,6 @@ resize, canvas, gpuCanvas, - drawCanvas, features, shader, format, @@ -813,6 +816,12 @@ lua_State* m_L = nullptr; int m_imageRef = LUA_NOREF; gpu::RenderContext* renderCtx = nullptr; // needed for resize() + // Size a resize() asked for while no device existed. Web attaches one per + // render texture after layout has already run, and a generator resizes + // once, so the request is held here and honoured on the first access after + // a device appears. Zero once satisfied. + uint32_t pendingWidth = 0; + uint32_t pendingHeight = 0; }; #endif // RIVE_ORE @@ -834,12 +843,22 @@ lua_State* m_L = nullptr; int m_imageRef = LUA_NOREF; gpu::RenderContext* renderCtx = nullptr; + // See ScriptedGPUCanvas::pendingWidth. + uint32_t pendingWidth = 0; + uint32_t pendingHeight = 0; CanvasState m_state = CanvasState::Idle; // Allocated on beginFrame(), deleted on endFrame(). Wraps renderCtx. + // Null in deferred mode, content records into the stream instead. RiveRenderer* m_riveRenderer = nullptr; + // Set on beginFrame() when a deferred host is recording, endFrame() + // routes through it instead of the real flush. Null means immediate. + cmd::DeferredCanvasHost* m_deferredHost = nullptr; // Lua registry ref to the ScriptedRenderer pushed by beginFrame(), // kept alive until endFrame() so the Lua renderer stays valid. int m_rendererRef = LUA_NOREF; + // Registry ref while the frame is open so post-error cleanup can close + // it and GC cannot collect it mid-frame. + int m_openFrameRef = LUA_NOREF; }; #endif // RIVE_CANVAS @@ -1523,6 +1542,9 @@ // Finishes any ORE render pass left open at script return and reports it // as a Lua error. Defined in src/lua/renderer/lua_gpu.cpp. void rive_lua_closeOrphanRenderPass(lua_State* state); +// Ends any Canvas frame an errored script left open, which would otherwise +// corrupt the deferred stream. Defined in src/lua/renderer/lua_gpu.cpp. +void rive_lua_closeOrphanCanvasFrames(lua_State* state); #endif class ScriptingContext @@ -1598,11 +1620,19 @@ std::string m_bare; }; - // Ore GPU context for this VM, derived from the render factory. Null when - // there is no render context, or it is not GPU-backed. Returned as void* so - // callers that include ore headers cast to ore::Context*. + // Ore GPU context for this VM, void* so callers cast to ore::Context*. + // A deferred host can override it via setOreContext to record instead. void* oreContext() const { + if (m_oreContextOverride != nullptr) + return m_oreContextOverride; + // A recording construction factory owns the context this VM's GPU work + // has to record into, and it has one before any device exists. + if (m_factory != nullptr) + { + if (auto* recording = m_factory->ore()) + return recording; + } return m_renderContext ? m_renderContext->ore() : nullptr; } @@ -1611,7 +1641,37 @@ // construction factory(). A RenderContext is a Factory, so callers needing // gpu APIs cast down to gpu::RenderContext*. void setRenderContext(Factory* ctx) { m_renderContext = ctx; } - Factory* renderContext() const { return m_renderContext; } + Factory* renderContext() const + { + if (m_renderContext != nullptr) + return m_renderContext; + // Nothing handed this VM a device. A recording factory may still have + // been given one after import, so ask instead of reporting the null we + // saw while the scripts were running. + return m_factory != nullptr ? m_factory->renderContext() : nullptr; + } + + // True when renderContext() resolved through the recording factory rather + // than a device handed to this VM. That device belongs to whoever attached + // it, so canvas backings must be deferred to it instead of allocated here. + bool renderContextIsLateBound() const { return m_renderContext == nullptr; } + + // Point scripts at a DeferredOreContext so their GPU work records + // instead of touching the driver. Null restores the default. + void setOreContext(void* ctx) { m_oreContextOverride = ctx; } + // Raw override, for transferring deferred routing across a context swap. + void* oreContextOverride() const { return m_oreContextOverride; } + + // When set, Canvas:beginFrame records into the deferred stream instead + // of issuing to the real RenderContext. Null means immediate. + void setDeferredCanvasHost(cmd::DeferredCanvasHost* host) + { + m_deferredCanvasHost = host; + } + cmd::DeferredCanvasHost* deferredCanvasHost() const + { + return m_deferredCanvasHost; + } // WorkPool for async operations (image decode, etc.). // Lazily created on first access. Shared across all contexts via a @@ -1633,10 +1693,24 @@ void setOreFrameOpen(bool open) { m_oreFrameOpen = open; } bool oreFrameOpen() const { return m_oreFrameOpen; } - // True while Artboard::drawCanvases() is actively walking scripted - // objects to invoke their drawCanvas() Lua callbacks. - void setCanvasDrawingPhase(bool value) { m_canvasDrawingPhase = value; } - bool canvasDrawingPhase() const { return m_canvasDrawingPhase; } + // Open canvas frames as registry refs so the post-pcall cleanup can + // close frames an errored script abandoned. + void registerOpenCanvasFrame(int ref) { m_openCanvasFrames.push_back(ref); } + void unregisterOpenCanvasFrame(int ref) + { + for (size_t i = 0; i < m_openCanvasFrames.size(); i++) + { + if (m_openCanvasFrames[i] == ref) + { + m_openCanvasFrames.erase(m_openCanvasFrames.begin() + i); + return; + } + } + } + std::vector<int> takeOpenCanvasFrames() + { + return std::move(m_openCanvasFrames); + } // When set, context:gpuCanvas() always returns a deferred (texture-less) // canvas regardless of requested size, never calling makeRenderCanvas. @@ -1666,10 +1740,12 @@ private: Factory* m_renderContext = nullptr; + void* m_oreContextOverride = nullptr; // deferred host's DeferredOreContext + cmd::DeferredCanvasHost* m_deferredCanvasHost = nullptr; uint64_t m_ownerId = 0; bool m_oreFrameOpen = false; - bool m_canvasDrawingPhase = false; bool m_gpuCanvasDeferOnly = false; + std::vector<int> m_openCanvasFrames; intptr_t m_prevGLContext = 0; #ifdef __EMSCRIPTEN__ int m_glHandle = 0; @@ -1735,6 +1811,16 @@ #endif }; +#ifdef RIVE_CANVAS +// Allocates a script canvas backing, deferring when a session is recording +// or the device was late bound, since either way the replay worker owns the +// texture. +rcp<gpu::RenderCanvas> allocScriptRenderCanvas(gpu::RenderContext* rc, + ScriptingContext* ctx, + uint32_t width, + uint32_t height); +#endif + class ScopedScriptedObjectContext { public: @@ -1763,32 +1849,6 @@ ScriptedObject* m_previous; }; -class ScopedCanvasDrawingPhase -{ -public: - ScopedCanvasDrawingPhase(ScriptingContext* context) : - m_context(context), - m_previous(context == nullptr ? false : context->canvasDrawingPhase()) - { - if (m_context != nullptr) - { - m_context->setCanvasDrawingPhase(true); - } - } - - ~ScopedCanvasDrawingPhase() - { - if (m_context != nullptr) - { - m_context->setCanvasDrawingPhase(m_previous); - } - } - -private: - ScriptingContext* m_context; - bool m_previous; -}; - class ScriptedDataValue { public:
diff --git a/include/rive/math/raw_path.hpp b/include/rive/math/raw_path.hpp index d567f08..a93b4f0 100644 --- a/include/rive/math/raw_path.hpp +++ b/include/rive/math/raw_path.hpp
@@ -25,6 +25,15 @@ class RawPath { public: + RawPath() = default; + + // Bulk copy for deserialization, arrays trusted self-consistent. Contour + // bookkeeping stays default since the path is consumed, not built. + RawPath(Span<const PathVerb> verbs, Span<const Vec2D> points) : + m_Points(points.data(), points.data() + points.size()), + m_Verbs(verbs.data(), verbs.data() + verbs.size()) + {} + bool operator==(const RawPath& o) const; bool operator!=(const RawPath& o) const { return !(*this == o); }
diff --git a/include/rive/scripted/scripted_drawable.hpp b/include/rive/scripted/scripted_drawable.hpp index 0076862..7b5584b 100644 --- a/include/rive/scripted/scripted_drawable.hpp +++ b/include/rive/scripted/scripted_drawable.hpp
@@ -23,6 +23,7 @@ public: #ifdef WITH_RIVE_SCRIPTING void didHydrateScriptInputs() override; + void didReinit() override; #endif void draw(Renderer* renderer) override; void update(ComponentDirt value) override; @@ -73,6 +74,9 @@ private: bool m_isAdvanceActive = true; + // One zero step after a reinit so advance driven content, like gpu + // canvas fills, re-records while paused. + bool m_forceAdvance = false; }; class HitScriptedDrawable : public HitComponent
diff --git a/include/rive/scripted/scripted_object.hpp b/include/rive/scripted/scripted_object.hpp index db4769a..5ecbd09 100644 --- a/include/rive/scripted/scripted_object.hpp +++ b/include/rive/scripted/scripted_object.hpp
@@ -66,7 +66,6 @@ void setViewModelInput(std::string name, ViewModelInstanceValue* value); void trigger(std::string name); bool scriptAdvance(float elapsedSeconds); - void scriptDrawCanvas(); void scriptUpdate(); void reinit(); #ifdef WITH_RIVE_SCRIPTING @@ -125,6 +124,9 @@ #ifdef WITH_RIVE_SCRIPTING /// Called after hydrateScriptInputs() succeeds; virtual void didHydrateScriptInputs() {} + // A reinit replaced script state, like a VM swap on editor pause; hosts + // re-record content the old state produced. + virtual void didReinit() {} #endif }; } // namespace rive
diff --git a/include/utils/serialize_ops.hpp b/include/utils/serialize_ops.hpp new file mode 100644 index 0000000..c73b7cb --- /dev/null +++ b/include/utils/serialize_ops.hpp
@@ -0,0 +1,126 @@ +/* + * Copyright 2026 Rive + */ + +#ifndef _RIVE_SERIALIZE_OPS_HPP_ +#define _RIVE_SERIALIZE_OPS_HPP_ + +#include "rive/core/binary_reader.hpp" +#include "rive/core/binary_writer.hpp" +#include "rive/math/raw_path.hpp" +#include <vector> + +namespace rive +{ +// Wire opcodes shared by SerializingFactory and replaySerializedCommands so +// the two ends of the .sriv format cannot drift apart. +enum class SerializeOp : uint32_t +{ + makeRenderBuffer = 0, + makeLinearGradient = 1, + makeRadialGradient = 2, + makeRenderPath = 3, + makeRenderPaint = 5, + decodeImage = 6, + save = 7, + restore = 8, + transform = 9, + drawPath = 10, + clipPath = 11, + drawImage = 12, + drawImageMesh = 13, + + // RenderBuffer + setVertexBufferData = 14, + setIndexBufferData = 15, + + // RenderPath + addRawPath = 16, + rewind = 17, + fillRule = 18, + + // RenderPaint + style = 20, + color = 21, + thickness = 22, + join = 23, + cap = 24, + feather = 25, + blendMode = 26, + shader = 27, + + frame = 28, + frameSize = 29, + modulateOpacity = 30, +}; + +inline void serializeRawPath(BinaryWriter* writer, const RawPath& path) +{ + auto verbs = path.verbs(); + auto points = path.points(); + writer->writeVarUint((uint64_t)verbs.size()); + for (auto verb : verbs) + { + writer->writeVarUint((uint64_t)verb); + } + writer->writeVarUint((uint64_t)points.size()); + for (auto point : points) + { + writer->writeFloat(point.x); + writer->writeFloat(point.y); + } +} + +inline RawPath deserializeRawPath(BinaryReader& reader) +{ + RawPath path; + size_t verbCount = static_cast<size_t>(reader.readVarUint64()); + std::vector<PathVerb> verbs(verbCount); + for (size_t i = 0; i < verbCount; ++i) + verbs[i] = static_cast<PathVerb>(reader.readVarUint64()); + size_t pointCount = static_cast<size_t>(reader.readVarUint64()); + std::vector<Vec2D> pts(pointCount); + for (size_t i = 0; i < pointCount; ++i) + { + pts[i].x = reader.readFloat32(); + pts[i].y = reader.readFloat32(); + } + size_t p = 0; + // A truncated stream can promise more points than it delivers. + auto have = [&](size_t n) { return p + n <= pts.size(); }; + for (PathVerb v : verbs) + { + switch (v) + { + case PathVerb::move: + if (!have(1)) + return path; + path.move(pts[p++]); + break; + case PathVerb::line: + if (!have(1)) + return path; + path.line(pts[p++]); + break; + case PathVerb::quad: + if (!have(2)) + return path; + path.quad(pts[p], pts[p + 1]); + p += 2; + break; + case PathVerb::cubic: + if (!have(3)) + return path; + path.cubic(pts[p], pts[p + 1], pts[p + 2]); + p += 3; + break; + case PathVerb::close: + path.close(); + break; + } + } + return path; +} +} // namespace rive + +#endif
diff --git a/include/utils/serialized_replay.hpp b/include/utils/serialized_replay.hpp new file mode 100644 index 0000000..9230b2f --- /dev/null +++ b/include/utils/serialized_replay.hpp
@@ -0,0 +1,36 @@ +/* + * Copyright 2026 Rive + */ + +#ifndef _RIVE_SERIALIZED_REPLAY_HPP_ +#define _RIVE_SERIALIZED_REPLAY_HPP_ + +#include "rive/factory.hpp" +#include "rive/renderer.hpp" +#include "rive/span.hpp" +#include <cstdint> +#include <functional> + +// Replays a SerializingFactory SRIV stream against a real Factory and +// Renderer. The stream records at the Factory and Renderer abstraction level +// keyed by object id, so it is renderer implementation agnostic. Frame marker +// ops invoke hooks so a host can drive begin and flush around each frame. +namespace rive +{ + +struct SerializedReplayHooks +{ + std::function<void()> onFrame = nullptr; + std::function<void(uint32_t width, uint32_t height)> onFrameSize = nullptr; +}; + +// Returns false on a bad header, unknown opcode, or truncated stream. The +// partial replay up to that point still happened. +bool replaySerializedCommands(Span<const uint8_t> stream, + Factory* factory, + Renderer* renderer, + const SerializedReplayHooks& hooks = {}); + +} // namespace rive + +#endif
diff --git a/include/utils/serializing_factory.hpp b/include/utils/serializing_factory.hpp index e180427..9516200 100644 --- a/include/utils/serializing_factory.hpp +++ b/include/utils/serializing_factory.hpp
@@ -54,6 +54,12 @@ void save(const char* filename); bool matches(const char* filename); + // Recorded SRIV stream for replay via serialized_replay.hpp. + Span<const uint8_t> bytes() const + { + return Span<const uint8_t>(m_buffer.data(), m_buffer.size()); + } + private: void saveTarnished(const char* filename);
diff --git a/renderer/include/rive/renderer/cmd/canvas_schedule.hpp b/renderer/include/rive/renderer/cmd/canvas_schedule.hpp new file mode 100644 index 0000000..cf56ac3 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/canvas_schedule.hpp
@@ -0,0 +1,181 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/deferred_session.hpp" + +#include <cstring> +#include <vector> + +// Orders canvas replay so samplers follow the canvases they sample, +// regardless of record order. +namespace rive::cmd +{ + +struct CanvasSchedule +{ + // Canvas ids in replay group order. Record order when no edge disagrees. + std::vector<uint64_t> order; + // A dependency cycle (or self sample) was demoted to previous frame + // sampling; the demoted edge keeps record order. + bool hadCycle = false; + // A read landed between two writes of the same canvas; grouped-ranges + // replay cannot honor the middle state, so the frame keeps record order. + bool multiWriteFallback = false; +}; + +// Reads the image handle that leads every draw POD which can sample a canvas. +inline RenderHandle drawnImageHandle(const uint8_t* pod) +{ + RenderHandle h; + memcpy(&h, pod, sizeof(h)); + return h; +} + +inline CanvasSchedule scheduleCanvases( + Span<const uint8_t> commands, + const std::vector<DeferredSegment>& segments) +{ + CanvasSchedule result; + + // Written canvases, grouped; node index doubles as record-order rank. + struct Node + { + uint64_t canvasId; + uint32_t firstBegin; // earliest range start, the record-order key + uint32_t lastBegin; // latest range start, for the sandwich test + }; + std::vector<Node> nodes; + auto nodeFor = [&](uint64_t id) -> int { + for (size_t i = 0; i < nodes.size(); i++) + { + if (nodes[i].canvasId == id) + { + return static_cast<int>(i); + } + } + return -1; + }; + for (const DeferredSegment& s : segments) + { + if (s.target != DeferredSegment::Target::canvas) + { + continue; + } + int n = nodeFor(s.targetId); + if (n < 0) + { + nodes.push_back({s.targetId, s.begin, s.begin}); + } + else + { + nodes[n].lastBegin = s.begin; + } + } + if (nodes.empty()) + { + return result; + } + + // reader depends on written canvas: edges[reader] holds node indices. + std::vector<std::vector<int>> deps(nodes.size()); + for (const DeferredSegment& s : segments) + { + if (s.target != DeferredSegment::Target::canvas) + { + continue; + } + int reader = nodeFor(s.targetId); + uint32_t pos = s.begin; + while (pos < s.end && pos < commands.size()) + { + RenderCmd c = static_cast<RenderCmd>(commands[pos]); + if (c > RenderCmd::lastRenderCmd) + { + break; // corrupt range; the decoder will warn at replay + } + uint32_t payload = static_cast<uint32_t>(payloadSizeOf(c)); + if (c == RenderCmd::drawImage || c == RenderCmd::drawImageMesh) + { + RenderHandle h = drawnImageHandle(commands.data() + pos + 1); + if (h != kInvalidRenderHandle && (h & kCanvasHandleFlag)) + { + int sampled = nodeFor(h & kCanvasHandleMask); + if (sampled == reader && sampled >= 0) + { + result.hadCycle = true; // self sample: previous frame + } + else if (sampled >= 0) + { + // A read between two writes of the sampled canvas + // has no honorable grouped schedule. + if (nodes[sampled].firstBegin < pos && + nodes[sampled].lastBegin > pos) + { + result.multiWriteFallback = true; + } + deps[reader].push_back(sampled); + } + } + } + pos += 1 + payload; + } + } + + if (result.multiWriteFallback) + { + for (const Node& n : nodes) + { + result.order.push_back(n.canvasId); + } + return result; + } + + // Kahn with record-order preference; a stuck round emits the earliest + // recorded remaining node, demoting its unsatisfied edges (cycle case). + std::vector<bool> done(nodes.size(), false); + while (result.order.size() < nodes.size()) + { + int pick = -1; + for (size_t i = 0; i < nodes.size(); i++) + { + if (done[i]) + { + continue; + } + bool ready = true; + for (int d : deps[i]) + { + if (!done[d]) + { + ready = false; + break; + } + } + if (ready) + { + pick = static_cast<int>(i); + break; + } + } + if (pick < 0) + { + result.hadCycle = true; + for (size_t i = 0; i < nodes.size(); i++) + { + if (!done[i]) + { + pick = static_cast<int>(i); + break; + } + } + } + done[pick] = true; + result.order.push_back(nodes[pick].canvasId); + } + return result; +} + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/command_stream.hpp b/renderer/include/rive/renderer/cmd/command_stream.hpp new file mode 100644 index 0000000..ed24361 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/command_stream.hpp
@@ -0,0 +1,165 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/span.hpp" +#include <cassert> +#include <cstddef> +#include <cstdint> +#include <cstdio> +#include <cstring> +#include <type_traits> +#include <vector> + +// Wire primitives shared by the 2D and Ore command streams: a flat pointer +// free byte stream plus a blob arena, and its bounds checked reader. The +// vocabularies differ per stream; the byte layout rules live only here. +namespace rive::cmd +{ + +// Per site stderr throttle so a per frame failure cannot flood the log. +#define RIVE_WARN_THROTTLED(...) \ + do \ + { \ + static int riveWarnCount = 0; \ + if ((riveWarnCount++ % 120) == 0) \ + { \ + fprintf(stderr, __VA_ARGS__); \ + } \ + } while (0) + +// A stream is written from its start and read from its start: every stream is +// reset each frame, so a blob offset is just an index into the arena. +class CommandByteStream +{ +public: + // Copy bytes into the blob arena and return the offset for the command + // POD. Null data with a nonzero size would record unappended bytes, so it + // asserts. Offsets are 64 bit because the wire PODs carrying them are. + uint64_t appendBlob(const void* data, uint32_t size) + { + assert(data != nullptr || size == 0); + // Blobs start 8 aligned so typed reads of the contents are aligned. + m_blobs.resize((m_blobs.size() + 7) & ~size_t(7), 0); + uint64_t offset = m_blobs.size(); + if (size != 0 && data != nullptr) + { + appendBytes(m_blobs, data, size); + } + return offset; + } + + bool empty() const { return m_commands.empty(); } + + Span<const uint8_t> commandBytes() const + { + return Span<const uint8_t>(m_commands.data(), m_commands.size()); + } + Span<const uint8_t> blobBytes() const + { + return Span<const uint8_t>(m_blobs.data(), m_blobs.size()); + } + +protected: + // Appends are the hottest thing recording does, and vector::insert drags + // its general mid-range machinery through every one of them. + static void appendBytes(std::vector<uint8_t>& dst, + const void* data, + size_t size) + { + size_t end = dst.size(); + dst.resize(end + size); + memcpy(dst.data() + end, data, size); + } + + void writeRaw(const void* data, size_t size) + { + appendBytes(m_commands, data, size); + } + + void clearBytes() // keeps capacity + { + m_commands.clear(); + m_blobs.clear(); + } + + std::vector<uint8_t> m_commands; + std::vector<uint8_t> m_blobs; +}; + +// Sequential bounds checked reader: an overrunning read latches and ends the +// walk, an out of range blobAt returns an empty span. +template <typename Opcode> class CommandReader +{ +public: + CommandReader(Span<const uint8_t> commands, Span<const uint8_t> blobs) : + m_commands(commands), m_blobs(blobs) + {} + + bool next(Opcode& outType) + { + size_t remaining = m_commands.size() - m_pos; + if (m_overrun || remaining < sizeof(Opcode)) + { + // Leftover bytes too short for an opcode are a truncated stream, + // not a clean end. + if (remaining != 0) + { + m_overrun = true; + } + return false; + } + std::memcpy(&outType, m_commands.data() + m_pos, sizeof(Opcode)); + m_pos += sizeof(Opcode); + return true; + } + + template <typename POD> POD read() + { + static_assert(std::is_trivially_copyable<POD>::value); + POD pod{}; + if (m_commands.size() - m_pos < sizeof(POD)) + { + m_overrun = true; + return pod; + } + std::memcpy(&pod, m_commands.data() + m_pos, sizeof(POD)); + m_pos += sizeof(POD); + return pod; + } + + // Advance past a payload without reading it (filtered walks). + void skip(size_t bytes) + { + if (m_commands.size() - m_pos < bytes) + { + m_overrun = true; + return; + } + m_pos += bytes; + } + + Span<const uint8_t> blobAt(uint64_t offset, uint32_t size) const + { + // Compare in 64 bit before any narrowing, size_t is 32 bit on wasm. + if (offset + size > static_cast<uint64_t>(m_blobs.size())) + { + return Span<const uint8_t>(nullptr, 0); + } + return Span<const uint8_t>(m_blobs.data() + static_cast<size_t>(offset), + size); + } + + size_t position() const { return m_pos; } + bool overrun() const { return m_overrun; } + +private: + Span<const uint8_t> m_commands; + Span<const uint8_t> m_blobs; + size_t m_pos = 0; + bool m_overrun = false; +}; + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_canvas_host.hpp b/renderer/include/rive/renderer/cmd/deferred_canvas_host.hpp new file mode 100644 index 0000000..92016bc --- /dev/null +++ b/renderer/include/rive/renderer/cmd/deferred_canvas_host.hpp
@@ -0,0 +1,38 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include <cstdint> + +// Hook the scripting layer uses when a deferred host is recording. Stored on +// the ScriptingContext as a forward declared pointer so the script headers +// stay decoupled from the cmd layer; DeferredSession implements it. +namespace rive +{ +class Renderer; +namespace gpu +{ +class RenderCanvas; +} + +namespace cmd +{ + +class DeferredCanvasHost +{ +public: + virtual ~DeferredCanvasHost() = default; + + // Emits the content begin bracket and returns a recording renderer owned + // by the host and valid until endCanvasContent. clearColor is ARGB. + virtual Renderer* beginCanvasContent(gpu::RenderCanvas* canvas, + uint32_t clearColor) = 0; + + // Emits the content end bracket and releases the renderer. + virtual void endCanvasContent(gpu::RenderCanvas* canvas) = 0; +}; + +} // namespace cmd +} // namespace rive
diff --git a/renderer/include/rive/renderer/cmd/deferred_render_factory.hpp b/renderer/include/rive/renderer/cmd/deferred_render_factory.hpp new file mode 100644 index 0000000..c77b57a --- /dev/null +++ b/renderer/include/rive/renderer/cmd/deferred_render_factory.hpp
@@ -0,0 +1,498 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/factory.hpp" +#include "rive/renderer.hpp" +#include "rive/shapes/paint/image_sampler.hpp" +#include "rive/renderer/cmd/foreign_image_registry.hpp" +#include "rive/renderer/cmd/id_allocator.hpp" +#include "rive/renderer/cmd/deferred_render_resource.hpp" +#include "rive/renderer/cmd/render_command_buffer.hpp" +#include "rive/renderer/cmd/render_commands.hpp" +#include <cassert> +#include <cstdio> +#ifdef RIVE_DECODERS +#include "rive/decoders/bitmap_decoder.hpp" +#endif + +// DeferredFactory and DeferredRenderer are the 2D recording front end. make* +// assigns a dense id and records a creation command; draws reference resources +// by id. Nothing touches the GPU; the render side replays the single ordered +// stream against a real Factory and Renderer. +namespace rive::cmd +{ + +// Cheap encoded image dimension sniff. Builds without RIVE_DECODERS still +// need record time width and height for layout. Defined in +// src/deferred_cmd.cpp. +bool sniffImageSize(Span<const uint8_t> b, int& w, int& h); + +class DeferredFactory : public Factory +{ +public: + DeferredFactory() + { + // Stream order consumes each create destroy pair, so a recycled id + // never aliases a live one. + registerRecorder(&m_buffer); + } + + ~DeferredFactory() + { + // Unregister first so resources a straggling wrapper still holds no-op + // their release instead of writing into a dead recorder. + unregisterRecorder(&m_buffer); + // Apply destroys queued from other threads before the stream dies. + m_buffer.drainDestroys(); + } + + // Creates, mutations, draws, and destroys all record into one ordered + // stream so a single replay pass suffices and id reuse stays correct. + std::unique_ptr<Renderer> makeRenderer( + ForeignImageRegistry* canvases = nullptr); + const RenderCommandBuffer& commandBuffer() const { return m_buffer; } + RenderCommandBuffer& commandBuffer() { return m_buffer; } + + // Clear the ordered stream for the next frame; consumer keeps resources. + void resetFrame() + { + m_buffer.reset(); + // Drain cross thread GC destroys into the new frame's stream head. + m_buffer.drainDestroys(); + } + + rcp<RenderPath> makeRenderPath(RawPath& path, FillRule fr) override + { + auto a = m_pathIds.alloc(); + auto verbs = path.verbs(); + auto points = path.points(); + uint64_t verbsOff = m_buffer.appendBlob( + verbs.data(), + static_cast<uint32_t>(verbs.size() * sizeof(PathVerb))); + uint64_t pointsOff = m_buffer.appendBlob( + points.data(), + static_cast<uint32_t>(points.size() * sizeof(Vec2D))); + m_buffer.append(static_cast<uint8_t>(RenderCmd::makePath), + MakePathPOD{a.id, + a.generation, + verbsOff, + pointsOff, + static_cast<uint32_t>(verbs.size()), + static_cast<uint32_t>(points.size()), + static_cast<uint32_t>(fr)}); + return make_rcp<DeferredRenderPath>(a.id, + a.generation, + &m_buffer, + &m_pathIds); + } + + rcp<RenderPath> makeEmptyRenderPath() override + { + auto a = m_pathIds.alloc(); + m_buffer.append(static_cast<uint8_t>(RenderCmd::makeEmptyPath), + MakeIdPOD{a.id, a.generation}); + return make_rcp<DeferredRenderPath>(a.id, + a.generation, + &m_buffer, + &m_pathIds); + } + + rcp<RenderPaint> makeRenderPaint() override + { + auto a = m_paintIds.alloc(); + m_buffer.append(static_cast<uint8_t>(RenderCmd::makePaint), + MakeIdPOD{a.id, a.generation}); + return make_rcp<DeferredRenderPaint>(a.id, + a.generation, + &m_buffer, + &m_paintIds); + } + + rcp<RenderShader> makeLinearGradient(float sx, + float sy, + float ex, + float ey, + const ColorInt colors[], + const float stops[], + size_t count) override + { + auto a = m_shaderIds.alloc(); + GradientBlobs g = appendGradientStops(colors, stops, count); + m_buffer.append(static_cast<uint8_t>(RenderCmd::makeLinearGradient), + LinearGradientPOD{a.id, + a.generation, + sx, + sy, + ex, + ey, + g.colorsOffset, + g.stopsOffset, + static_cast<uint32_t>(count)}); + return make_rcp<DeferredRenderShader>(a.id, + a.generation, + &m_buffer, + &m_shaderIds); + } + rcp<RenderShader> makeRadialGradient(float cx, + float cy, + float radius, + const ColorInt colors[], + const float stops[], + size_t count) override + { + auto a = m_shaderIds.alloc(); + GradientBlobs g = appendGradientStops(colors, stops, count); + m_buffer.append(static_cast<uint8_t>(RenderCmd::makeRadialGradient), + RadialGradientPOD{a.id, + a.generation, + cx, + cy, + radius, + static_cast<uint32_t>(count), + g.colorsOffset, + g.stopsOffset}); + return make_rcp<DeferredRenderShader>(a.id, + a.generation, + &m_buffer, + &m_shaderIds); + } + + rcp<RenderBuffer> makeRenderBuffer(RenderBufferType type, + RenderBufferFlags flags, + size_t sizeInBytes) override + { + auto a = m_bufferIds.alloc(); + m_buffer.append(static_cast<uint8_t>(RenderCmd::makeBuffer), + MakeBufferPOD{a.id, + a.generation, + static_cast<uint8_t>(type), + static_cast<uint8_t>(flags), + static_cast<uint32_t>(sizeInBytes)}); + // The resident buffer keeps its data across frames on the consumer. + return make_rcp<DeferredRenderBuffer>(a.id, + a.generation, + type, + flags, + sizeInBytes, + &m_buffer, + &m_bufferIds); + } + + rcp<RenderImage> decodeImage(Span<const uint8_t> bytes) override + { + auto a = m_imageIds.alloc(); + // Decode dims at record time so the artboard can read them during + // advance; the render side uploads the real texture. + int w = 0, h = 0; +#ifdef RIVE_DECODERS + if (auto bm = Bitmap::decode(bytes.data(), bytes.size())) + { + w = static_cast<int>(bm->width()); + h = static_cast<int>(bm->height()); + } +#endif + // wasm builds decode in the browser, so sniff dims from the header. + if (w == 0 || h == 0) + { + sniffImageSize(bytes, w, h); + } +#ifndef NDEBUG + // Unknown dims silently break layout far from here, so warn once. + if (w == 0 || h == 0) + { + static bool warned = false; + if (!warned) + { + warned = true; + fprintf( + stderr, + "DeferredFactory::decodeImage: image dims unknown at " + "record time (decode failed, or built without " + "RIVE_DECODERS); size-dependent layout will be wrong\n"); + } + } +#endif + uint64_t off = m_buffer.appendBlob(bytes.data(), + static_cast<uint32_t>(bytes.size())); + m_buffer.append(static_cast<uint8_t>(RenderCmd::decodeImage), + DecodeImagePOD{a.id, + a.generation, + off, + static_cast<uint32_t>(bytes.size()), + static_cast<uint32_t>(w), + static_cast<uint32_t>(h)}); + return make_rcp<DeferredRenderImage>(a.id, + a.generation, + w, + h, + &m_buffer, + &m_imageIds); + } + +private: + // colors[count] (ColorInt) and stops[count] (float), each its own blob. + struct GradientBlobs + { + uint64_t colorsOffset; + uint64_t stopsOffset; + }; + GradientBlobs appendGradientStops(const ColorInt colors[], + const float stops[], + size_t count) + { + GradientBlobs g; + g.colorsOffset = m_buffer.appendBlob( + colors, + static_cast<uint32_t>(count * sizeof(ColorInt))); + g.stopsOffset = + m_buffer.appendBlob(stops, + static_cast<uint32_t>(count * sizeof(float))); + return g; + } + + RenderCommandBuffer + m_buffer; // ordered: creates + mutations + draws + destroys + // One reusable id space per resource type (Dawn-style free list + gen). + IdAllocator<RenderHandle> m_pathIds; + IdAllocator<RenderHandle> m_paintIds; + IdAllocator<RenderHandle> m_shaderIds; + IdAllocator<RenderHandle> m_imageIds; + IdAllocator<RenderHandle> m_bufferIds; +}; + +// Draws are attributed to the renderer that issues them, not their stream +// position, so scripts can interleave canvases and the screen freely. +class DeferredRouteHost +{ +public: + virtual void routeTo(uint64_t target) = 0; + +protected: + ~DeferredRouteHost() = default; +}; +// Canvases and screens share one route target space: a canvas uses its +// unflagged canvas id, a screen sets this flag over its target id. One session +// records for every screen target its render context drives, so a screen needs +// an identity a canvas id cannot alias. +constexpr uint64_t kScreenTargetFlag = 1ull << 63; +constexpr uint64_t kScreenTarget = kScreenTargetFlag; // screen target 0 +constexpr uint64_t screenTarget(uint64_t id) { return kScreenTargetFlag | id; } +constexpr bool isScreenTarget(uint64_t target) +{ + return (target & kScreenTargetFlag) != 0; +} +constexpr uint64_t screenTargetId(uint64_t target) +{ + return target & ~kScreenTargetFlag; +} + +class DeferredRenderer : public Renderer +{ +public: + explicit DeferredRenderer(RenderCommandBuffer* buffer, + ForeignImageRegistry* canvases = nullptr, + DeferredRouteHost* routeHost = nullptr, + uint64_t routeTarget = kScreenTarget) : + m_buffer(buffer), + m_canvases(canvases), + m_routeHost(routeHost), + m_routeTarget(routeTarget) + {} + + void save() override + { + route(); + m_buffer->appendType(static_cast<uint8_t>(RenderCmd::save)); + } + void restore() override + { + route(); + m_buffer->appendType(static_cast<uint8_t>(RenderCmd::restore)); + } + void transform(const Mat2D& m) override + { + route(); + m_buffer->append( + static_cast<uint8_t>(RenderCmd::transform), + TransformPOD{m.xx(), m.xy(), m.yx(), m.yy(), m.tx(), m.ty()}); + } + void drawPath(RenderPath* path, RenderPaint* paint) override + { + DeferredRenderPath::flushScratchOf(path); + RenderHandle pathId = DeferredRenderPath::idOfPath(path); + RenderHandle paintId = idOfPaint(paint); + if (pathId == kInvalidRenderHandle || paintId == kInvalidRenderHandle) + { + // A foreign draw is dropped at replay anyway; recording it would + // flood the stream every frame. + warnForeign("drawPath"); + return; + } + auto* dp = lite_rtti_cast<DeferredRenderPath*>(path); + auto* dpt = lite_rtti_cast<DeferredRenderPaint*>(paint); + dp->markDrawn(); + dpt->markDrawn(); + route(); + m_buffer->append( + static_cast<uint8_t>(RenderCmd::drawPath), + DrawPathPOD{pathId, paintId, dp->version(), dpt->version()}); + } + void clipPath(RenderPath* path) override + { + DeferredRenderPath::flushScratchOf(path); + auto* dp = lite_rtti_cast<DeferredRenderPath*>(path); + if (dp != nullptr) + { + dp->markDrawn(); + } + route(); + m_buffer->append(static_cast<uint8_t>(RenderCmd::clipPath), + ClipPathPOD{DeferredRenderPath::idOfPath(path), + dp != nullptr ? dp->version() : 0}); + } + void modulateOpacity(float opacity) override + { + route(); + m_buffer->append(static_cast<uint8_t>(RenderCmd::modulateOpacity), + OpacityPOD{opacity}); + } + + void drawImage(const RenderImage* image, + ImageSampler s, + BlendMode blend, + float opacity) override + { + // Canvas images get a flagged id from the registry on first sight. + RenderHandle id = idOfImage(image); + if (id == kInvalidRenderHandle && m_canvases) + { + id = m_canvases->imageDrawId(const_cast<RenderImage*>(image)); + } + if (id == kInvalidRenderHandle) + { + // Foreign image is dropped at replay anyway, skip recording. + warnForeign("drawImage"); + return; + } + route(); + m_buffer->append(static_cast<uint8_t>(RenderCmd::drawImage), + DrawImagePOD{id, + static_cast<uint8_t>(s.wrapX), + static_cast<uint8_t>(s.wrapY), + static_cast<uint8_t>(s.filter), + static_cast<uint8_t>(blend), + opacity}); + } + void drawImageMesh(const RenderImage* image, + ImageSampler s, + rcp<RenderBuffer> vertices, + rcp<RenderBuffer> uvCoords, + rcp<RenderBuffer> indices, + uint32_t vertexCount, + uint32_t indexCount, + BlendMode blend, + float opacity) override + { + RenderHandle imgId = idOfImage(image); + if (imgId == kInvalidRenderHandle && m_canvases) + { + // A real image decoded outside the session (the host's decode + // wrap makes that legitimate) rides to replay via the registry. + imgId = m_canvases->imageDrawId(const_cast<RenderImage*>(image)); + } + RenderHandle vId = idOfBuffer(vertices.get()); + RenderHandle uvId = idOfBuffer(uvCoords.get()); + RenderHandle idxId = idOfBuffer(indices.get()); + bool foreign = + imgId == kInvalidRenderHandle || vId == kInvalidRenderHandle || + uvId == kInvalidRenderHandle || idxId == kInvalidRenderHandle; + if (foreign) + { + // Foreign mesh is dropped at replay anyway, skip recording. + warnForeign("drawImageMesh"); + return; + } + auto* dv = lite_rtti_cast<DeferredRenderBuffer*>(vertices.get()); + auto* duv = lite_rtti_cast<DeferredRenderBuffer*>(uvCoords.get()); + auto* di = lite_rtti_cast<DeferredRenderBuffer*>(indices.get()); + dv->markDrawn(); + duv->markDrawn(); + di->markDrawn(); + route(); + m_buffer->append(static_cast<uint8_t>(RenderCmd::drawImageMesh), + DrawImageMeshPOD{imgId, + vId, + uvId, + idxId, + dv->version(), + duv->version(), + di->version(), + vertexCount, + indexCount, + static_cast<uint8_t>(s.wrapX), + static_cast<uint8_t>(s.wrapY), + static_cast<uint8_t>(s.filter), + static_cast<uint8_t>(blend), + opacity}); + } + +private: + // A foreign resource means the caller mixed factories; that is always a + // bug worth surfacing. + static void warnForeign(const char* what) + { + static int warned = 0; + if (warned < 16) + { + warned = warned + 1; + fprintf(stderr, + "rive deferred: %s with a foreign resource (made by a " + "different factory), draw will be dropped\n", + what); + } + } + + static RenderHandle idOfPaint(const RenderPaint* p) + { + auto* d = + lite_rtti_cast<DeferredRenderPaint*>(const_cast<RenderPaint*>(p)); + return d ? d->id() : kInvalidRenderHandle; + } + static RenderHandle idOfImage(const RenderImage* i) + { + auto* d = + lite_rtti_cast<DeferredRenderImage*>(const_cast<RenderImage*>(i)); + return d ? d->id() : kInvalidRenderHandle; + } + static RenderHandle idOfBuffer(const RenderBuffer* b) + { + auto* d = + lite_rtti_cast<DeferredRenderBuffer*>(const_cast<RenderBuffer*>(b)); + return d ? d->id() : kInvalidRenderHandle; + } + // Attribute the coming op to this recorder's target; standalone recorders + // have no host and skip it. + void route() + { + if (m_routeHost != nullptr) + { + m_routeHost->routeTo(m_routeTarget); + } + } + + RenderCommandBuffer* m_buffer; + ForeignImageRegistry* m_canvases; + DeferredRouteHost* m_routeHost; + uint64_t m_routeTarget; +}; + +inline std::unique_ptr<Renderer> DeferredFactory::makeRenderer( + ForeignImageRegistry* canvases) +{ + return std::make_unique<DeferredRenderer>(&m_buffer, canvases); +} + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_render_resource.hpp b/renderer/include/rive/renderer/cmd/deferred_render_resource.hpp new file mode 100644 index 0000000..564005f --- /dev/null +++ b/renderer/include/rive/renderer/cmd/deferred_render_resource.hpp
@@ -0,0 +1,487 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer.hpp" +#include "rive/command_path.hpp" +#include "rive/math/raw_path.hpp" +#include "rive/math/mat2d.hpp" +#include "utils/lite_rtti.hpp" +#include "rive/renderer/cmd/render_command_buffer.hpp" +#include "rive/renderer/cmd/render_commands.hpp" +#include "rive/renderer/cmd/id_allocator.hpp" +#include "rive/renderer/cmd/live_recorder_registry.hpp" +#include <cstdio> +#include <mutex> + +// Deferred 2D resources: lite_rtti subclasses of the real Factory types that +// carry a dense creation id and record their mutations into a shared +// RenderCommandBuffer. The render side recreates the real resource from the +// stream and replays the mutations in order. +namespace rive::cmd +{ + +// The destroy lands in stream order after the draws that referenced the id, +// making reuse safe. A release after the session died no-ops. +inline void releaseDeferred(RenderCommandBuffer* commands, + IdAllocator<RenderHandle>* allocator, + ResourceKind kind, + RenderHandle id, + uint32_t generation) +{ + std::lock_guard<std::mutex> lock(recorderRegistryMutex()); + if (liveRecorders().count(commands) == 0) + { + return; // the session died first, nothing to record into + } + // Destructors run on any thread, so queue rather than append; the + // recording thread drains at the frame boundary. + commands->queueDestroy(static_cast<uint8_t>(kind), + id, + generation, + allocator); +} + +// Common deferred resource state: the dense creation id other streams +// reference it by, and the destroy recorded at destruction. +class DeferredResourceBase +{ +public: + DeferredResourceBase(ResourceKind kind, + RenderHandle id, + uint32_t generation, + RenderCommandBuffer* buffer, + IdAllocator<RenderHandle>* allocator) : + m_id(id), + m_buffer(buffer), + m_generation(generation), + m_allocator(allocator), + m_kind(kind) + {} + RenderHandle id() const { return m_id; } + +protected: + ~DeferredResourceBase() + { + releaseDeferred(m_buffer, m_allocator, m_kind, m_id, m_generation); + } + + RenderHandle m_id; + RenderCommandBuffer* m_buffer; + + ResourceKind kind() const { return m_kind; } + +private: + uint32_t m_generation; + IdAllocator<RenderHandle>* m_allocator; + ResourceKind m_kind; +}; + +// Adds the drawn-version pinning: draws pin the version they recorded +// against, so a mutation of a resource drawn this frame bumps to a new +// version; the first mutation of a new frame reuses the live replay object. +class VersionedDeferredResource : public DeferredResourceBase +{ +public: + using DeferredResourceBase::DeferredResourceBase; + + uint32_t version() const { return m_version; } + void markDrawn() { m_drawnFrame = m_buffer->frameId(); } + +protected: + void bump() + { + if (m_drawnFrame != m_buffer->frameId()) + { + return; // not drawn this frame: mutate the live object in place + } + m_version++; + m_drawnFrame = kNeverDrawn; + m_buffer->append( + static_cast<uint8_t>(RenderCmd::resourceNewVersion), + ResourceVersionPOD{static_cast<uint8_t>(kind()), m_id, m_version}); + } + +private: + uint32_t m_version = 0; + static constexpr uint32_t kNeverDrawn = ~0u; + uint32_t m_drawnFrame = kNeverDrawn; +}; + +// RenderShader is only ever a gradient, so a deferred shader is just an id. +class DeferredRenderShader + : public LITE_RTTI_OVERRIDE(RenderShader, DeferredRenderShader), + public DeferredResourceBase +{ +public: + DeferredRenderShader(RenderHandle id, + uint32_t generation, + RenderCommandBuffer* commands, + IdAllocator<RenderHandle>* allocator) : + DeferredResourceBase(ResourceKind::shader, + id, + generation, + commands, + allocator) + {} +}; + +class DeferredRenderPaint + : public LITE_RTTI_OVERRIDE(RenderPaint, DeferredRenderPaint), + public VersionedDeferredResource +{ +public: + DeferredRenderPaint(RenderHandle id, + uint32_t generation, + RenderCommandBuffer* buffer, + IdAllocator<RenderHandle>* allocator) : + VersionedDeferredResource(ResourceKind::paint, + id, + generation, + buffer, + allocator) + {} + + void style(RenderPaintStyle v) override + { + if (absorbed(m_state.style, static_cast<uint8_t>(v))) + { + return; + } + emitU8(RenderCmd::paintStyle, m_state.style); + } + void color(ColorInt v) override + { + if (absorbed(m_state.color, v) && m_colorKnown) + { + return; + } + m_colorKnown = true; + bump(); + m_buffer->append(t(RenderCmd::paintColor), + PaintColorPOD{m_id, m_state.color}); + } + void thickness(float v) override + { + if (absorbed(m_state.thickness, v)) + { + return; + } + emitFloat(RenderCmd::paintThickness, m_state.thickness); + } + void join(StrokeJoin v) override + { + if (absorbed(m_state.join, static_cast<uint8_t>(v))) + { + return; + } + emitU8(RenderCmd::paintJoin, m_state.join); + } + void cap(StrokeCap v) override + { + if (absorbed(m_state.cap, static_cast<uint8_t>(v))) + { + return; + } + emitU8(RenderCmd::paintCap, m_state.cap); + } + void feather(float v) override + { + if (absorbed(m_state.feather, v)) + { + return; + } + emitFloat(RenderCmd::paintFeather, m_state.feather); + } + void blendMode(BlendMode v) override + { + if (absorbed(m_state.blendMode, static_cast<uint8_t>(v))) + { + return; + } + emitU8(RenderCmd::paintBlendMode, m_state.blendMode); + } + void shader( + rcp<RenderShader> s) override; // defined below (needs the helper) + void invalidateStroke() override + { + // Stroked shapes invalidate every frame their path moves, and the + // consumer only rebuilds the stroke when it draws, so repeats before + // the next draw say nothing new. It carries no state either, so it + // must not drag a version bump and a state rewrite behind it. + if (m_strokeInvalidated) + { + return; + } + m_strokeInvalidated = true; + m_buffer->append(t(RenderCmd::paintInvalidateStroke), ResIdPOD{m_id}); + } + + void markDrawn() + { + VersionedDeferredResource::markDrawn(); + m_strokeInvalidated = false; + } + +private: + // Must be the backend's defaults, because a property nothing ever changes + // away from them is never written and the replay object keeps its own. + struct State + { + ColorInt color = 0xFF000000; + float thickness = 1; + float feather = 0; + uint8_t style = 1; // fill; a fresh paint is unstroked until told + uint8_t join = 0; // miter + uint8_t cap = 0; // butt + uint8_t blendMode = 3; // srcOver + }; + + static uint8_t t(RenderCmd c) { return static_cast<uint8_t>(c); } + // Absorbs the new value into the shadow; true when the consumer already + // has it and the append can be skipped. + template <typename T> bool absorbed(T& field, T v) + { + if (field == v) + { + return true; + } + field = v; + return false; + } + void emitU8(RenderCmd c, uint8_t v) + { + bump(); + m_buffer->append(t(c), PaintU8POD{m_id, v}); + } + void emitFloat(RenderCmd c, float v) + { + bump(); + m_buffer->append(t(c), PaintFloatPOD{m_id, v}); + } + + State m_state; + // Held as an rcp so an animated gradient outlives the setter: comparing + // ids alone would alias a recycled one. + rcp<RenderShader> m_shader; + bool m_colorKnown = true; + bool m_strokeInvalidated = false; +}; + +class DeferredRenderPath + : public LITE_RTTI_OVERRIDE(RenderPath, DeferredRenderPath), + public VersionedDeferredResource +{ +public: + DeferredRenderPath(RenderHandle id, + uint32_t generation, + RenderCommandBuffer* buffer, + IdAllocator<RenderHandle>* allocator) : + VersionedDeferredResource(ResourceKind::path, + id, + generation, + buffer, + allocator) + {} + + void rewind() override + { + bump(); + m_scratch.rewind(); // discards any pending per-verb geometry + m_buffer->append(t(RenderCmd::pathRewind), ResIdPOD{m_id}); + } + void fillRule(FillRule v) override + { + // ShapePaint re-sets the fill rule before every fill draw; a no-op + // set must not bump a drawn path to a new version. + if (m_haveFillRule && v == m_fillRule) + { + return; + } + m_haveFillRule = true; + m_fillRule = v; + bump(); + m_buffer->append(t(RenderCmd::pathFillRule), + PathFillRulePOD{m_id, static_cast<uint8_t>(v)}); + } + + // Per verb builders are never hit by app content but the interface + // requires them; accumulate into a scratch RawPath flushed on next use. + void moveTo(float x, float y) override { m_scratch.moveTo(x, y); } + void lineTo(float x, float y) override { m_scratch.lineTo(x, y); } + void cubicTo(float ox, float oy, float ix, float iy, float x, float y) + override + { + m_scratch.cubicTo(ox, oy, ix, iy, x, y); + } + void close() override { m_scratch.close(); } + + void addRenderPath(const RenderPath* path, const Mat2D& m) override + { + bump(); + flushScratch(); + flushScratchOf(path); // src geometry must be complete in the stream + RenderHandle src = idOfPath(path); + m_buffer->append(t(RenderCmd::pathAddRenderPath), + PathAddPathPOD{m_id, + src, + m.xx(), + m.xy(), + m.yx(), + m.yy(), + m.tx(), + m.ty()}); + } + void addRawPath(const RawPath& path) override + { + flushScratch(); // preserve order: pending per-verb before this bulk add + recordAddRawPath(path); + } + + // Emit any pending per verb geometry as one addRawPath. Public so the + // renderer can flush before a draw or clip. + void flushScratch() + { + if (m_scratch.empty()) + { + return; + } + recordAddRawPath(m_scratch); + m_scratch.rewind(); + } + static void flushScratchOf(const RenderPath* p) + { + if (auto* d = + lite_rtti_cast<DeferredRenderPath*>(const_cast<RenderPath*>(p))) + { + d->flushScratch(); + } + } + + // kInvalid when the path is not one of ours. + static RenderHandle idOfPath(const RenderPath* p) + { + auto* d = + lite_rtti_cast<DeferredRenderPath*>(const_cast<RenderPath*>(p)); + return d ? d->id() : kInvalidRenderHandle; + } + +private: + static uint8_t t(RenderCmd c) { return static_cast<uint8_t>(c); } + // Replay seeds a bumped path version from the outgoing one, so appends + // land on prior geometry and a rewind clears the seed via its own + // recorded command. + void recordAddRawPath(const RawPath& path) + { + bump(); + auto verbs = path.verbs(); + auto points = path.points(); + uint64_t verbsOff = m_buffer->appendBlob( + verbs.data(), + static_cast<uint32_t>(verbs.size() * sizeof(PathVerb))); + uint64_t pointsOff = m_buffer->appendBlob( + points.data(), + static_cast<uint32_t>(points.size() * sizeof(Vec2D))); + m_buffer->append(t(RenderCmd::pathAddRawPath), + PathRawPOD{verbsOff, + pointsOff, + m_id, + static_cast<uint32_t>(verbs.size()), + static_cast<uint32_t>(points.size())}); + } + + RawPath m_scratch; // pending CommandPath per-verb geometry + FillRule m_fillRule = FillRule::nonZero; + bool m_haveFillRule = false; +}; + +inline void DeferredRenderPaint::shader(rcp<RenderShader> s) +{ + if (m_shader.get() == s.get()) + { + return; + } + m_shader = std::move(s); + RenderHandle id = kInvalidRenderHandle; // null clears the shader + if (auto* d = lite_rtti_cast<DeferredRenderShader*>(m_shader.get())) + { + id = d->id(); + } + // Backends are free to disturb the solid color when the shader moves + // (RiveRenderPaint does), so stop trusting the shadowed one. + m_colorKnown = false; + bump(); + m_buffer->append(t(RenderCmd::paintShader), PaintShaderPOD{m_id, id}); +} + +// Carries dims decoded at record time so the artboard can read them during +// advance; the real GPU image is uploaded on the render side. +class DeferredRenderImage + : public LITE_RTTI_OVERRIDE(RenderImage, DeferredRenderImage), + public DeferredResourceBase +{ +public: + DeferredRenderImage(RenderHandle id, + uint32_t generation, + int width, + int height, + RenderCommandBuffer* commands, + IdAllocator<RenderHandle>* allocator) : + DeferredResourceBase(ResourceKind::image, + id, + generation, + commands, + allocator) + { + m_Width = width; + m_Height = height; + } +}; + +// map() hands out a scratch buffer; unmap() records its bytes as a bufferData +// command replayed on the render side. +class DeferredRenderBuffer + : public LITE_RTTI_OVERRIDE(RenderBuffer, DeferredRenderBuffer), + public VersionedDeferredResource +{ +public: + DeferredRenderBuffer(RenderHandle id, + uint32_t generation, + RenderBufferType type, + RenderBufferFlags flags, + size_t sizeInBytes, + RenderCommandBuffer* buffer, + IdAllocator<RenderHandle>* allocator) : + LITE_RTTI_OVERRIDE(RenderBuffer, + DeferredRenderBuffer)(type, flags, sizeInBytes), + VersionedDeferredResource(ResourceKind::buffer, + id, + generation, + buffer, + allocator) + {} + +protected: + void* onMap() override + { + m_scratch.resize(sizeInBytes()); + return m_scratch.data(); + } + void onUnmap() override + { + bump(); + uint64_t off = + m_buffer->appendBlob(m_scratch.data(), + static_cast<uint32_t>(m_scratch.size())); + m_buffer->append( + static_cast<uint8_t>(RenderCmd::bufferData), + BufferDataPOD{off, m_id, static_cast<uint32_t>(m_scratch.size())}); + } + +private: + std::vector<uint8_t> m_scratch; +}; + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_replayer.hpp b/renderer/include/rive/renderer/cmd/deferred_replayer.hpp new file mode 100644 index 0000000..9d8a62c --- /dev/null +++ b/renderer/include/rive/renderer/cmd/deferred_replayer.hpp
@@ -0,0 +1,372 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/canvas_schedule.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "rive/renderer/cmd/gpu_census.hpp" +#include "rive/renderer/cmd/render_replay.hpp" +#include "rive/renderer/ore/cmd/ore_replay.hpp" +#include <algorithm> +#include <deque> +#include <mutex> +#include <unordered_map> +#include <vector> + +// DeferredReplayer consumes a recorded deferred frame, replaying the 2D and +// Ore streams against real resources through a DeferredFrameSink. Reusing one +// replayer keeps resources resident frame to frame. It makes no thread +// assumptions; the sink is the threading seam. +namespace rive::cmd +{ + +// Host supplied GPU frame operations, kept behind an interface so the replayer +// stays backend and window agnostic. +class DeferredFrameSink +{ +public: + virtual ~DeferredFrameSink() = default; + + // The real Factory resources replay against. + virtual Factory* factory() = 0; + + // Defaults to the factory's ore context; hosts whose factory does not own + // one override it. + virtual ore::Context* oreContext() { return factory()->ore(); } + + // Open the screen frame for one render target and return its renderer. + // A session drives every target its render context owns, so replay asks + // once per target it recorded for. Left open for the caller to present. + virtual Renderer* beginScreenFrame(uint64_t target) = 0; + + // Which target claims work no screen segment attributed, such as an Ore + // only or canvas only frame. A sink serving one texture answers with that + // texture's target, since target 0 may belong to a different one. + virtual uint64_t defaultScreenTarget() { return 0; } + + // Bracket the recorded Ore passes with the backend's Ore frame. + virtual void beginOreFrame() {} + virtual void endOreFrame() {} + // Backend state fix up after an Ore frame, such as a GL state invalidate. + virtual void afterOreFrame() {} + + // Open the canvas's own frame and return the renderer its draws route + // into, or null to drop them. endCanvasContent flushes it. + virtual Renderer* beginCanvasContent(gpu::RenderCanvas* /*canvas*/, + uint32_t /*clearColor*/) + { + return nullptr; + } + virtual void endCanvasContent() {} +}; + +// Immutable snapshot of one recorded frame so the producer can record the next +// frame while this one replays on a render thread. +struct DeferredFrame +{ + std::vector<uint8_t> commands, blobs; // 2D ordered stream + std::vector<uint8_t> oreCommands, oreBlobs; // Ore ordered stream + std::vector<rcp<RenderImage>> canvasImages; // unflagged canvas id -> image + std::unordered_map<RenderHandle, rcp<gpu::RenderCanvas>> contentCanvases; + std::vector<rcp<rive::gpu::GPUResource>> oreReals; // unflagged real id + // Assembled scheduler segments; byte ranges index the streams above. + std::vector<DeferredSegment> segments; +}; + +inline DeferredFrame snapshotFrame(DeferredSession& session) +{ + // An errored script can leave a canvas range open; close it before the + // bytes are copied. + session.closeOpenRange(); + auto copy = [](Span<const uint8_t> s) { + return std::vector<uint8_t>(s.data(), s.data() + s.size()); + }; + DeferredFrame f; + f.commands = copy(session.commandBuffer().commandBytes()); + f.blobs = copy(session.commandBuffer().blobBytes()); + f.oreCommands = copy(session.oreContext().stream().commandBytes()); + f.oreBlobs = copy(session.oreContext().stream().blobBytes()); + f.canvasImages = session.canvases().images(); + f.contentCanvases = session.contentCanvases(); + f.oreReals = session.oreContext().realResources(); + f.segments = session.schedulerSegments(); + return f; +} + +// Snapshot and clear as one step. A session hands over exactly one frame per +// rendered frame however many targets it drove, so the reset that ends the +// recording window belongs with the capture that reads it, not with any one +// target's flush. +inline DeferredFrame takeFrame(DeferredSession& session) +{ + DeferredFrame frame = snapshotFrame(session); + session.resetFrame(); + return frame; +} + +class DeferredReplayer +{ +public: + // Inline zero copy form: replay straight out of the session. Leaves the + // screen frame open for the caller to present. + void replayFrame(DeferredSession& session, DeferredFrameSink& sink) + { + session.closeOpenRange(); + replay( + session.commandBuffer().commandBytes(), + session.commandBuffer().blobBytes(), + session.oreContext().stream().commandBytes(), + session.oreContext().stream().blobBytes(), + [&](RenderHandle id) { return session.canvasImageAt(id); }, + [&](RenderHandle id) { return session.contentCanvasAt(id); }, + session.oreContext().realResources(), + sink, + session.schedulerSegments()); + } + + // Snapshot form: replay an owned frame, independent of the session. + void replayFrame(const DeferredFrame& frame, DeferredFrameSink& sink) + { + replay( + toSpan(frame.commands), + toSpan(frame.blobs), + toSpan(frame.oreCommands), + toSpan(frame.oreBlobs), + [&](RenderHandle id) -> RenderImage* { + return id < frame.canvasImages.size() + ? frame.canvasImages[id].get() + : nullptr; + }, + [&](RenderHandle id) -> gpu::RenderCanvas* { + auto it = frame.contentCanvases.find(id); + return it == frame.contentCanvases.end() ? nullptr + : it->second.get(); + }, + frame.oreReals, + sink, + frame.segments); + } + + // Drop the resident tables so the next replay recreates everything. + void reset() + { + m_2d = ResourceTable{}; + m_ore = ore::cmd::OreResident{}; + } + + ResourceTable& table() { return m_2d; } + + // What the resident tables are holding on the GPU. Walks on demand and + // costs the record and replay paths nothing, but reads the tables, so the + // caller owes it a quiescent replayer. + GpuCensus gpuCensus() const { return takeGpuCensus(m_2d, m_ore); } + +private: + static Span<const uint8_t> toSpan(const std::vector<uint8_t>& v) + { + return Span<const uint8_t>(v.data(), v.size()); + } + + template <typename CanvasImageFn, typename ContentCanvasFn> + void replay(Span<const uint8_t> commands, + Span<const uint8_t> blobs, + Span<const uint8_t> oreCommands, + Span<const uint8_t> oreBlobs, + CanvasImageFn canvasImage, + ContentCanvasFn contentCanvas, + const std::vector<rcp<rive::gpu::GPUResource>>& oreReals, + DeferredFrameSink& sink, + const std::vector<DeferredSegment>& segments) + { + m_stats = ReplayStats{}; + m_2d.clearVersionAliases(); + ReplayHooks hooks; + hooks.stats = &m_stats; + hooks.canvasImage = canvasImage; + // A canvas's content may span several ranges; its real frame opens at + // the first range and flushes once its group ends below. + Renderer* openContentRenderer = nullptr; + RenderHandle openContentId = kInvalidRenderHandle; + hooks.beginCanvasContent = [&](RenderHandle id, + uint32_t clearColor) -> Renderer* { + if (id == openContentId) + { + return openContentRenderer; // later range, frame already open + } + gpu::RenderCanvas* canvas = contentCanvas(id); + openContentRenderer = + canvas ? sink.beginCanvasContent(canvas, clearColor) : nullptr; + openContentId = id; + return openContentRenderer; + }; + // A stale ore replay marker in the 2D stream replays as a no-op. + + auto subSpan = [](Span<const uint8_t> s, uint32_t begin, uint32_t end) { + return Span<const uint8_t>(s.data() + begin, end - begin); + }; + + // The whole Ore stream replays as one frame. Splitting it per pass + // would risk breaking a create use destroy resource lifecycle. + auto replayOre = [&]() { + if (oreCommands.empty()) + { + return; + } + ore::Context* realOre = sink.oreContext(); + if (realOre == nullptr) + { + // One shot content like a canvas wrap never re-records, so a + // skipped stream is permanent loss, never silent. + RIVE_WARN_THROTTLED( + "rive deferred: no ore context, dropping %zu ore command " + "bytes (canvas content will be lost)\n", + oreCommands.size()); + return; + } + sink.beginOreFrame(); + ore::cmd::replayOreStream( + *realOre, + oreCommands, + oreBlobs, + m_ore, + [&](ore::cmd::ResourceHandle h) -> rive::gpu::GPUResource* { + ore::cmd::ResourceHandle i = + h & ore::cmd::kRealResourceMask; + return i < oreReals.size() ? oreReals[i].get() : nullptr; + }, + [&](uint32_t canvasId) -> gpu::RenderCanvas* { + return contentCanvas(canvasId); + }, + [&](uint32_t imageId) -> RenderImage* { + // Resident 2D image, created by the hoisted create pass + // or an earlier frame. + return m_2d.images.get(imageId); + }); + sink.endOreFrame(); + sink.afterOreFrame(); + }; + + // Creates and mutations replay first over the whole stream in record + // order; draws pin the version they recorded against, so the segment + // partition below cannot misorder state or break mint order. + hooks.filter = ReplayFilter::resources; + replayRenderCommands(sink.factory(), + nullptr, + commands, + blobs, + m_2d, + hooks); + hooks.filter = ReplayFilter::draws; + + // Partition the 2D segments canvas before screen; the backend allows + // one open frame at a time. Ore passes must run inside the screen + // frame right after it opens or the ramp upload finds no texture + // bound. + std::vector<const DeferredSegment*> screenSegments; + std::unordered_map<uint64_t, std::vector<const DeferredSegment*>> + canvasRanges; + for (const DeferredSegment& s : segments) + { + if (s.target == DeferredSegment::Target::screen) + { + screenSegments.push_back(&s); + continue; + } + canvasRanges[s.targetId].push_back(&s); + } + // Canvas groups replay in dependency order, so a sampler sees this + // frame's content regardless of record order. Cycles keep record + // order on the back edge: previous frame sampling, by contract. + CanvasSchedule schedule = scheduleCanvases(commands, segments); + if (schedule.hadCycle) + { + RIVE_WARN_THROTTLED("rive deferred: canvas sample cycle, the " + "back edge samples the previous frame\n"); + } + const std::vector<uint64_t>& canvasOrder = schedule.order; + // Ore replays once for the whole session frame, inside the first + // screen frame opened. The scripting GPU surface is canvas scoped - + // render passes exist only as canvas:beginRenderPass, with no screen + // or frame target type - so a script writes canvases and never + // screens, and its output reaches a target indirectly through 2D + // draws that sample canvas textures. Running it in whichever screen + // frame opens first therefore orders it ahead of every target's draws + // without attributing it to any one of them. The decision is owned + // here, at session frame scope, so per target sinks cannot disagree + // about whether Ore ran at all. + bool oreReplayed = false; + std::unordered_map<uint64_t, Renderer*> openScreens; + auto openScreenAndOre = [&](uint64_t target) -> Renderer* { + auto entry = openScreens.try_emplace(target, nullptr); + if (entry.second) + { + entry.first->second = sink.beginScreenFrame(target); + } + if (!oreReplayed) + { + replayOre(); + oreReplayed = true; + } + return entry.first->second; + }; + for (uint64_t canvasId : canvasOrder) + { + for (const DeferredSegment* seg : canvasRanges[canvasId]) + { + replayRenderCommands(sink.factory(), + nullptr, + subSpan(commands, seg->begin, seg->end), + blobs, + m_2d, + hooks); + } + if (openContentRenderer != nullptr) + { + sink.endCanvasContent(); // flush once per canvas + } + openContentRenderer = nullptr; + openContentId = kInvalidRenderHandle; + } + for (const DeferredSegment* seg : screenSegments) + { + // Target's frame open, Ore inside the first of them, then draw. + Renderer* screen = openScreenAndOre(seg->targetId); + replayRenderCommands(sink.factory(), + screen, + subSpan(commands, seg->begin, seg->end), + blobs, + m_2d, + hooks); + } + // Work no screen segment claimed still falls to the default target: + // ore has nowhere else to run, and a canvas only frame still owes the + // host the screen frame its clear and present live in. + if (openScreens.empty() && + (!oreCommands.empty() || !canvasRanges.empty())) + { + openScreenAndOre(sink.defaultScreenTarget()); + } + // Destroys replay last so a destroy recorded in a screen gap cannot + // free a resource a reordered canvas segment still draws. + hooks.filter = ReplayFilter::destroys; + replayRenderCommands(sink.factory(), + nullptr, + commands, + blobs, + m_2d, + hooks); + } + + ResourceTable m_2d; // resident 2D resources + ore::cmd::OreResident m_ore; // resident Ore resources + ReplayStats m_stats; + +public: + // Draws dropped in the last replayFrame. Nonzero means mixed factory + // recording or a replay bug the host should surface. + uint32_t droppedDraws() const { return m_stats.droppedDraws; } +}; + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/deferred_session.hpp b/renderer/include/rive/renderer/cmd/deferred_session.hpp new file mode 100644 index 0000000..ddf6edb --- /dev/null +++ b/renderer/include/rive/renderer/cmd/deferred_session.hpp
@@ -0,0 +1,413 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/foreign_image_registry.hpp" +#include "rive/renderer/cmd/deferred_canvas_host.hpp" +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/render_replay.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_context.hpp" +#include "rive/renderer/render_canvas.hpp" +#include <algorithm> +#include <unordered_map> +#include <vector> + +// DeferredSession owns a deferred frame: the 2D stream (it is a +// DeferredFactory), the Ore context, and the shared canvas registry. +// Everything records into one ordered 2D stream, so a single replay pass is +// byte identical by construction with no command reordering. +namespace rive::cmd +{ + +// One scheduler segment: a canvas or screen run plus the byte range +// [begin, end) in the 2D stream. Replay runs every canvas segment before any +// screen segment while preserving record order within each phase. +struct DeferredSegment +{ + enum class Target : uint8_t + { + canvas, // offscreen, runs first + screen, // main target, runs last + }; + Target target; + // Canvas id for a canvas run, screen target id for a screen one. A session + // serves several screen targets, so a screen run names the one it feeds. + uint64_t targetId; + uint32_t begin; + uint32_t end; +}; + +class DeferredSession : public DeferredFactory, + public DeferredCanvasHost, + public DeferredRouteHost +{ +public: + // realOre may be null on web; it late binds via bindRealOre. + explicit DeferredSession(ore::Context* realOre) : m_ore(realOre) + { + // Register wrapped canvases under the shared 2D id space so the + // consumer can perform the real wrap at replay. + m_ore.canvasIdProvider = [this](gpu::RenderCanvas* canvas) -> uint32_t { + RenderHandle id = m_canvases.imageDrawId(canvas->renderImage()) & + kCanvasHandleMask; + m_contentCanvases[id] = ref_rcp(canvas); + return id; + }; + // The 2D stream shares the ore stream's single writer contract. + commandBuffer().bindRecordingThread(); + // Lets view() on a canvas backed image resolve its canvas id off the + // registry. + m_ore.canvasRegistry = &m_canvases; + } + + ore::cmd::DeferredOreContext& oreContext() { return m_ore; } + void bindRealOre(ore::Context* real) { m_ore.bindReal(real); } + + // Cross session image sharing lives here, not in an id space: the + // registry carries a real rcp<RenderImage> through the frame snapshot, so + // any number of sessions can name the same image without a shared table + // to resolve it against, and re-registering per frame keeps the retained + // set bounded. Everything else a render context decodes is already shared + // because one session records it all. + ForeignImageRegistry& canvases() { return m_canvases; } + + // Hosts that import through this session get the recording ore context + // automatically. + rive::ore::Context* ore() override { return &m_ore; } + + // The context this session records for. Scripts imported through the + // session talk to it directly for GPU state while their canvas work + // records, so it has to be the real thing, not the session. Web has none + // while the file imports, so the host binds the attaching texture's one + // here alongside bindRealOre and a script that deferred its canvas backing + // reads it again when the real size arrives. + void bindRenderContext(Factory* renderContext) + { + m_renderContext = renderContext; + } + Factory* renderContext() override { return m_renderContext; } + cmd::DeferredCanvasHost* deferredCanvasHost() override { return this; } + + // Routed so a screen draw issued while a canvas range is open lands in a + // screen range, not the canvas's. + std::unique_ptr<Renderer> makeScreenRenderer(uint64_t target = 0) + { + return std::make_unique<DeferredRenderer>(&commandBuffer(), + &m_canvases, + this, + screenTarget(target)); + } + + // Stable screen recorder for FFI hosts that hold a raw pointer across + // frames, one per render target this session drives. + Renderer* screenRenderer(uint64_t target = 0) + { + auto& recorder = m_screenRenderers[target]; + if (recorder == nullptr) + { + recorder = makeScreenRenderer(target); + } + return recorder.get(); + } + + // ---- Render targets ---- + // A host claims an id for its lifetime; ids are reused so a long lived + // context churning textures does not grow the recorder map forever. The + // first claim is 0, which is what a single host session records today. + uint64_t acquireScreenTarget() + { + if (!m_freeScreenTargets.empty()) + { + uint64_t id = m_freeScreenTargets.back(); + m_freeScreenTargets.pop_back(); + return id; + } + return m_nextScreenTarget++; + } + // The host is gone, so its recorder is too. Callers drain first, and a + // queued frame holds bytes rather than the recorder. + void releaseScreenTarget(uint64_t target) + { + m_screenRenderers.erase(target); + m_freeScreenTargets.push_back(target); + } + // Hosts holding an id right now. Sizes anything that has to scale with the + // targets sharing this session, such as the consumer's queue bound. + size_t attachedTargetCount() const + { + return static_cast<size_t>(m_nextScreenTarget) - + m_freeScreenTargets.size(); + } + + // ---- Frame boundary ---- + // A session serves every target its render context drives, so the frame + // is a session-wide window: it opens when the first target starts + // recording and closes when the last one finishes. Ending the window per + // host would reset the stream underneath a target still recording. + void beginTargetFrame(uint64_t target) + { + if (std::find(m_openTargets.begin(), m_openTargets.end(), target) == + m_openTargets.end()) + { + m_openTargets.push_back(target); + } + } + // True once this closes the last open target, meaning the caller may take + // the frame. A target that never opened one closes nothing. + bool endTargetFrame(uint64_t target) + { + auto it = std::find(m_openTargets.begin(), m_openTargets.end(), target); + if (it == m_openTargets.end()) + { + return m_openTargets.empty(); + } + m_openTargets.erase(it); + return m_openTargets.empty(); + } + // A host that stops recording without finishing, such as one paused + // mid frame, must not pin the window shut for everyone else. + void abandonTargetFrame(uint64_t target) + { + auto it = std::find(m_openTargets.begin(), m_openTargets.end(), target); + if (it != m_openTargets.end()) + { + m_openTargets.erase(it); + } + } + + // ---- DeferredRouteHost ---- + // Splits the stream into per target scheduler ranges as the issuing + // renderer changes. + void routeTo(uint64_t target) override + { + if (m_activeRouted && target == m_activeTarget) + { + return; + } + closeActiveRange(); + m_activeTarget = target; + m_activeRouted = true; + m_activeBegin = streamSize(); + if (isScreenTarget(target)) + { + m_openScreen = screenTargetId(target); + m_hasOpenScreen = true; + return; + } + RenderHandle id = static_cast<RenderHandle>(target); + commandBuffer().append( + static_cast<uint8_t>(RenderCmd::canvasContentBegin), + CanvasContentPOD{id | kCanvasHandleFlag, m_canvasClear[target]}); + } + + // Snapshots call this too since an errored script can leave a range open. + void closeOpenRange() + { + closeActiveRange(); + reopenUnroutedRange(); + } + + // ---- DeferredCanvasHost ---- + // A canvas may record as several interleaved ranges; replay groups them + // back into one real canvas frame. + Renderer* beginCanvasContent(gpu::RenderCanvas* canvas, + uint32_t clearColor) override + { + RenderHandle id = + m_canvases.imageDrawId(canvas->renderImage()) & kCanvasHandleMask; + m_contentCanvases[id] = ref_rcp(canvas); + m_canvasClear[id] = clearColor; + auto& recorder = m_canvasRenderers[id]; + if (recorder == nullptr) + { + recorder = std::make_unique<DeferredRenderer>(&commandBuffer(), + &m_canvases, + this, + id); + } + // Open the range now so a clear-only frame still clears at replay. + routeTo(id); + return recorder.get(); + } + void endCanvasContent(gpu::RenderCanvas*) override + { + // Back to the screen whose recording the canvas interrupted, so the + // bytes that follow are not credited to a target that drew nothing. + if (m_hasOpenScreen) + { + routeTo(screenTarget(m_openScreen)); + return; + } + closeActiveRange(); + reopenUnroutedRange(); + } + + // Replay bindings are per frame so the retained set stays bounded; each + // frame's draws re-register what they reference. + void resetFrame() + { + DeferredFactory::resetFrame(); + m_ore.resetFrame(); + m_canvases.reset(); + m_contentCanvases.clear(); + m_canvasRenderers.clear(); + m_canvasClear.clear(); + m_activeTarget = kScreenTarget; + m_activeRouted = false; + m_activeBegin = 0; + m_openScreen = 0; + m_hasOpenScreen = false; + m_hasOreMarker = false; + m_segments.clear(); + } + + // Physical bytes this session's producer streams hold. Computed on + // demand so recording pays nothing; the frame boundary drains it, so it + // only means anything read before a snapshot. + uint64_t streamBytes() const + { + return commandBuffer().commandBytes().size() + + commandBuffer().blobBytes().size() + + m_ore.stream().commandBytes().size() + + m_ore.stream().blobBytes().size(); + } + + // Nothing recorded: the host keeps its last presented frame up. Pending + // ore content counts, since one shot content like a canvas wrap never + // re-records and must not park behind the gate. + bool recordedThisFrame() const + { + return m_hasOreMarker || !commandBuffer().empty() || + !m_ore.stream().empty(); + } + + // Closed segments in record order, canvas and screen alike. + const std::vector<DeferredSegment>& recordedSegments() const + { + return m_segments; + } + // Full scheduler input: the closed segments plus the range still open. + std::vector<DeferredSegment> schedulerSegments() const + { + std::vector<DeferredSegment> all = m_segments; + if (m_activeRouted && isScreenTarget(m_activeTarget) && + streamSize() > m_activeBegin) + { + all.push_back({DeferredSegment::Target::screen, + screenTargetId(m_activeTarget), + m_activeBegin, + streamSize()}); + } + return all; + } + + // Ore replays via segment scheduling; the frame only needs to know Ore + // content exists so an otherwise empty frame still replays. + void recordOreReplayMarker() { m_hasOreMarker = true; } + + // Render thread lookups for the replay hooks. + gpu::RenderCanvas* contentCanvasAt(RenderHandle id) const + { + auto it = m_contentCanvases.find(id); + return it == m_contentCanvases.end() ? nullptr : it->second.get(); + } + RenderImage* canvasImageAt(RenderHandle id) const + { + return m_canvases.imageAt(id); + } + // Retained content canvas bindings for the consumer snapshot. + const std::unordered_map<RenderHandle, rcp<gpu::RenderCanvas>>& + contentCanvases() const + { + return m_contentCanvases; + } + +private: + uint32_t streamSize() const + { + return static_cast<uint32_t>(commandBuffer().commandBytes().size()); + } + + // Reopen the range no target has claimed. Bytes appended outside any + // renderer - resource creates, drained destroys - belong to no target: + // they replay from the whole stream in the create and destroy passes, so + // crediting them to a screen would open that target's frame in a frame + // where only other targets drew. + void reopenUnroutedRange() + { + m_activeTarget = screenTarget(m_openScreen); + m_activeRouted = m_hasOpenScreen; + m_activeBegin = streamSize(); + } + + // Push the open range as a segment, closing a canvas one's bracket first. + // An empty screen range is dropped: replaying it would open its target's + // frame to draw nothing. + void closeActiveRange() + { + if (!m_activeRouted) + { + return; + } + if (isScreenTarget(m_activeTarget)) + { + if (streamSize() > m_activeBegin) + { + m_segments.push_back({DeferredSegment::Target::screen, + screenTargetId(m_activeTarget), + m_activeBegin, + streamSize()}); + } + return; + } + RenderHandle id = static_cast<RenderHandle>(m_activeTarget); + commandBuffer().append( + static_cast<uint8_t>(RenderCmd::canvasContentEnd), + ResIdPOD{id | kCanvasHandleFlag}); + // Push order of m_segments defines record order across both streams. + m_segments.push_back({DeferredSegment::Target::canvas, + m_activeTarget, + m_activeBegin, + streamSize()}); + } + + ore::cmd::DeferredOreContext m_ore; + // Set on texture attach, read by the producer's scripts. Written and read + // on different threads in the worker build, exactly as m_ore's real + // binding already is. + Factory* m_renderContext = nullptr; + ForeignImageRegistry m_canvases; + // Canvas id to real canvas, retained so it lives to replay. Per frame. + std::unordered_map<RenderHandle, rcp<gpu::RenderCanvas>> m_contentCanvases; + // Alive until resetFrame so scripted renderers stay valid across + // interleaved canvas frames. + std::unordered_map<uint64_t, std::unique_ptr<DeferredRenderer>> + m_canvasRenderers; + // First beginFrame's clear color; replay clears only when it opens the + // real frame. + std::unordered_map<uint64_t, uint32_t> m_canvasClear; + // Deliberately outlives resetFrame, unlike m_canvasRenderers: FFI hosts + // take these raw and keep drawing through them frame after frame. + std::unordered_map<uint64_t, std::unique_ptr<Renderer>> m_screenRenderers; + bool m_hasOreMarker = false; + // Scheduler segments recorded this frame, in script issue order. + std::vector<DeferredSegment> m_segments; + uint64_t m_activeTarget = kScreenTarget; // target of the open range + // False while the open range belongs to no target, which is how a frame + // starts and where creates outside any renderer land. + bool m_activeRouted = false; + uint32_t m_activeBegin = 0; // open range's start offset + // Screen target a closing canvas range hands the stream back to, unset + // until a screen actually records. + uint64_t m_openScreen = 0; + bool m_hasOpenScreen = false; + // Render targets attached to this session, and the ones still recording + // this frame. + uint64_t m_nextScreenTarget = 0; + std::vector<uint64_t> m_freeScreenTargets; + std::vector<uint64_t> m_openTargets; +}; + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/foreign_image_registry.hpp b/renderer/include/rive/renderer/cmd/foreign_image_registry.hpp new file mode 100644 index 0000000..75ca018 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/foreign_image_registry.hpp
@@ -0,0 +1,63 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer.hpp" +#include "rive/renderer/cmd/render_handle.hpp" +#include <cassert> +#include <unordered_map> +#include <vector> + +// Foreign image registry: any RenderImage a deferred drawImage references that +// is not a decoded DeferredRenderImage gets a flagged id here on first sight, +// so the stream carries an id, not a pointer. Entries are retained so they +// live to replay; the registry is per frame, keeping the set bounded. +namespace rive::cmd +{ + +class ForeignImageRegistry +{ +public: + // The flagged draw id for a foreign image; registers it on first sight. + RenderHandle imageDrawId(RenderImage* image) + { + auto it = m_imageToId.find(image); + RenderHandle id; + if (it != m_imageToId.end()) + { + id = it->second; + } + else + { + id = static_cast<RenderHandle>(m_images.size()); + // The unflagged id must fit under the flag bit. + assert(id <= kCanvasHandleMask); + m_images.push_back(ref_rcp(image)); + m_imageToId[image] = id; + } + return kCanvasHandleFlag | id; + } + + // Replay time lookup of the real image by unflagged id. + RenderImage* imageAt(RenderHandle id) const + { + return id < m_images.size() ? m_images[id].get() : nullptr; + } + + // Retained id indexed images for the consumer snapshot. + const std::vector<rcp<RenderImage>>& images() const { return m_images; } + + void reset() + { + m_images.clear(); + m_imageToId.clear(); + } + +private: + std::vector<rcp<RenderImage>> m_images; + std::unordered_map<RenderImage*, RenderHandle> m_imageToId; +}; + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/gpu_census.hpp b/renderer/include/rive/renderer/cmd/gpu_census.hpp new file mode 100644 index 0000000..239bba0 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/gpu_census.hpp
@@ -0,0 +1,161 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/render_replay.hpp" +#include "rive/renderer/ore/cmd/ore_make_replay.hpp" +#include "rive/renderer/ore/ore_buffer.hpp" +#include "rive/renderer/ore/ore_texture.hpp" +#include "rive/renderer/ore/ore_types.hpp" + +// What deferred replay is holding resident on the GPU side, walked out of the +// resident tables on demand. +// +// This is a levels counter, not an events counter: nothing is accumulated +// while recording or replaying, so the record and replay paths pay nothing at +// all for it and it needs no build flag. The cost is one linear walk of the +// resident tables, at the moment a caller asks. Callers must walk while the +// tables are quiescent - see DeferredConsumer::gpuCensus, which drains first. +namespace rive::cmd +{ + +// Bytes are the nominal footprint of the resource as declared: texels times +// bytes per texel, buffer sizes as requested. Driver padding, alignment and +// any backend side scratch are not visible from here and are excluded, so a +// total is a floor on real GPU residency. It is the right shape for asking +// whether two arrangements hold the same resources, which is what it is for. +struct GpuCensus +{ + // 2D resources. + uint64_t imageBytes = 0; // RenderImage, assumed 4 bytes per texel + uint64_t bufferBytes = 0; // RenderBuffer, exact + // Ore (scripting GPU) resources. + uint64_t oreTextureBytes = 0; // ore::Texture, exact for uncompressed + uint64_t oreBufferBytes = 0; // ore::Buffer, exact + + // Live objects per table, so table shape is visible next to the bytes. + // Paths, paints and shaders carry GPU cost that is not a declared + // allocation (tessellation, gradient ramps), so they are counted but not + // sized. + uint32_t images = 0; + uint32_t buffers = 0; + uint32_t paths = 0; + uint32_t paints = 0; + uint32_t shaders = 0; + uint32_t oreTextures = 0; + uint32_t oreBuffers = 0; + uint32_t oreOther = 0; // views, samplers, pipelines, bind groups + + // Slots ever minted, live or freed. slots - live is the hole count the + // never-compacting tables carry. + uint32_t slots2d = 0; + uint32_t slotsOre = 0; + + uint64_t totalBytes() const + { + return imageBytes + bufferBytes + oreTextureBytes + oreBufferBytes; + } + + uint32_t liveObjects() const + { + return images + buffers + paths + paints + shaders + oreTextures + + oreBuffers + oreOther; + } +}; + +// Texels across every mip level, array layer and MSAA sample. Returns 0 for a +// block compressed format rather than guessing a block size. +inline uint64_t oreTextureNominalBytes(const ore::Texture& t) +{ + uint32_t bpt = ore::textureFormatBytesPerTexel(t.format()); + if (bpt == 0) + { + return 0; + } + uint64_t texels = 0; + uint32_t w = t.width(), h = t.height(); + // numMipmaps counts the full chain including level 0. + for (uint32_t level = 0; level < std::max<uint32_t>(t.numMipmaps(), 1); + ++level) + { + texels += uint64_t(w) * h; + if (w == 1 && h == 1) + { + break; + } + w = std::max<uint32_t>(w >> 1, 1); + h = std::max<uint32_t>(h >> 1, 1); + } + return texels * std::max<uint32_t>(t.depthOrArrayLayers(), 1) * + std::max<uint32_t>(t.sampleCount(), 1) * bpt; +} + +template <typename T> +static uint32_t countLive(const Resident<T>& r, uint32_t& slots) +{ + slots += static_cast<uint32_t>(r.objects.size()); + uint32_t live = 0; + for (const rcp<T>& o : r.objects) + { + live += (o != nullptr); + } + return live; +} + +inline GpuCensus takeGpuCensus(const ResourceTable& t2d, + const ore::cmd::OreResident& ore) +{ + GpuCensus c; + c.paths = countLive(t2d.paths, c.slots2d); + c.paints = countLive(t2d.paints, c.slots2d); + c.shaders = countLive(t2d.shaders, c.slots2d); + c.buffers = countLive(t2d.buffers, c.slots2d); + c.images = countLive(t2d.images, c.slots2d); + + for (const rcp<RenderImage>& img : t2d.images.objects) + { + if (img != nullptr) + { + // No format on the 2D interface; every backend path here is + // 32 bit color. + c.imageBytes += uint64_t(img->width()) * img->height() * 4; + } + } + for (const rcp<RenderBuffer>& buf : t2d.buffers.objects) + { + if (buf != nullptr) + { + c.bufferBytes += buf->sizeInBytes(); + } + } + + c.slotsOre = static_cast<uint32_t>(ore.objects.size()); + for (size_t i = 0; i < ore.objects.size(); ++i) + { + rive::gpu::GPUResource* o = ore.objects[i].get(); + if (o == nullptr) + { + continue; + } + switch (ore.kinds[i]) + { + case ore::cmd::OreKind::texture: + c.oreTextures++; + c.oreTextureBytes += + oreTextureNominalBytes(*static_cast<ore::Texture*>(o)); + break; + case ore::cmd::OreKind::buffer: + c.oreBuffers++; + c.oreBufferBytes += static_cast<ore::Buffer*>(o)->size(); + break; + default: + c.oreOther++; + break; + } + } + return c; +} + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/handle_flags.hpp b/renderer/include/rive/renderer/cmd/handle_flags.hpp new file mode 100644 index 0000000..9bca7e6 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/handle_flags.hpp
@@ -0,0 +1,16 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include <cstdint> + +// Handle bit partition shared by the 2D and Ore streams: the top bit flags a +// foreign table (canvas image or already real resource), and minted ids stay +// below it. The id allocator enforces that. +namespace rive::cmd +{ +constexpr uint32_t kHandleForeignFlag = 0x80000000u; +constexpr uint32_t kHandleForeignMask = 0x7fffffffu; +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/id_allocator.hpp b/renderer/include/rive/renderer/cmd/id_allocator.hpp new file mode 100644 index 0000000..cf914b5 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/id_allocator.hpp
@@ -0,0 +1,59 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/handle_flags.hpp" +#include <cassert> +#include <cstddef> +#include <cstdint> +#include <vector> + +// Dense reusable id allocation for the deferred resource tables. A free list +// carries each returned id's next generation, so stale commands are caught by +// generation mismatch on the consumer. An id retires when its generation would +// overflow. Lives in rive, not cmd, shared by the 2D and Ore layers. +namespace rive +{ + +template <typename Id> class IdAllocator +{ +public: + struct Allocation + { + Id id; + uint32_t generation; + }; + + // Reuse a returned id (generation already bumped) or mint a fresh one at 0. + Allocation alloc() + { + if (!m_free.empty()) + { + Allocation a = m_free.back(); + m_free.pop_back(); + return a; + } + // The top bit is reserved for flagged foreign ids, so a minted id + // must never reach it. + assert(m_next < cmd::kHandleForeignFlag); + return {static_cast<Id>(m_next++), 0u}; + } + + // The caller passes the generation it held, so the allocator needs no per + // id state. Retire the id if the next generation would overflow. + void release(Id id, uint32_t generation) + { + if (generation != 0xffffffffu) + { + m_free.push_back({id, generation + 1u}); + } + } + +private: + std::vector<Allocation> m_free; + uint32_t m_next = 0; // high-water for fresh ids +}; + +} // namespace rive
diff --git a/renderer/include/rive/renderer/cmd/live_recorder_registry.hpp b/renderer/include/rive/renderer/cmd/live_recorder_registry.hpp new file mode 100644 index 0000000..d832191 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/live_recorder_registry.hpp
@@ -0,0 +1,37 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include <mutex> +#include <unordered_set> + +// A recording context can die before stragglers recorded against it. The live +// set lets late destructors no-op instead of writing into a freed recorder; +// the mutex serializes them against teardown. +namespace rive::cmd +{ + +inline std::mutex& recorderRegistryMutex() +{ + static std::mutex m; + return m; +} +inline std::unordered_set<const void*>& liveRecorders() +{ + static std::unordered_set<const void*> s; + return s; +} +inline void registerRecorder(const void* recorder) +{ + std::lock_guard<std::mutex> lock(recorderRegistryMutex()); + liveRecorders().insert(recorder); +} +inline void unregisterRecorder(const void* recorder) +{ + std::lock_guard<std::mutex> lock(recorderRegistryMutex()); + liveRecorders().erase(recorder); +} + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/recording_thread.hpp b/renderer/include/rive/renderer/cmd/recording_thread.hpp new file mode 100644 index 0000000..e24a3f2 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/recording_thread.hpp
@@ -0,0 +1,54 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include <cassert> +#ifndef NDEBUG +#include <thread> +#endif + +// Debug only thread affinity for the deferred producer streams. +// +// Recording is single threaded by construction: every widget records +// synchronously from the Dart UI isolate. The producer state that assumption +// buys -- the command byte vectors, the id free list -- carries no mutex and +// no atomic, so a second recorder appending concurrently would corrupt them +// with no diagnostic at all. This makes that violation loud. +// +// The off thread work the deferred design does allow never touches producer +// state directly, and must not be checked: cross thread destroys queue under +// OreCommandBuffer's destroy mutex, late destructors serialize on +// recorderRegistryMutex, the producer/consumer handoff has DeferredInFlight's, +// and session teardown drains the destroy queue from whichever thread the host +// posted the delete to. +namespace rive::cmd +{ + +// Unbound until a recorder claims it, so a command buffer that is not a +// deferred producer -- an inline pass buffer, a backend's pending frame -- +// keeps its existing freedom to live wherever its owner does. +class RecordingThread +{ +public: +#ifdef NDEBUG + void bind() {} + void check() const {} +#else + void bind() { m_id = std::this_thread::get_id(); } + + void check() const + { + assert( + (m_id == std::thread::id() || std::this_thread::get_id() == m_id) && + "deferred recording is single threaded: this stream, its id " + "allocator, and its keep alive table are all unlocked"); + } + +private: + std::thread::id m_id; +#endif +}; + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_command_buffer.hpp b/renderer/include/rive/renderer/cmd/render_command_buffer.hpp new file mode 100644 index 0000000..61921ad --- /dev/null +++ b/renderer/include/rive/renderer/cmd/render_command_buffer.hpp
@@ -0,0 +1,111 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/span.hpp" +#include "rive/renderer/cmd/command_stream.hpp" +#include "rive/renderer/cmd/id_allocator.hpp" +#include "rive/renderer/cmd/recording_thread.hpp" +#include "rive/renderer/cmd/render_commands.hpp" +#include <cassert> +#include <cstdint> +#include <mutex> +#include <vector> + +// RenderCommandBuffer is a flat pointer free byte stream plus a blob arena. +// Everything is referenced by dense resource id, never a pointer, so the bytes +// cross a SharedArrayBuffer to a worker zero copy. It holds no live resources; +// the render side recreates everything from the stream by id. +namespace rive::cmd +{ + +class RenderCommandBuffer : public CommandByteStream +{ +public: + // A session's producer binds; a backend owned buffer leaves it unbound. + // Debug only, see RecordingThread. + void bindRecordingThread() { m_recordingThread.bind(); } + + // Append [type byte][POD]. POD must be trivially copyable. + template <typename POD> void append(uint8_t type, const POD& pod) + { + static_assert(std::is_trivially_copyable<POD>::value, + "command POD must be trivially copyable"); + m_recordingThread.check(); + writeRaw(&type, sizeof(type)); + writeRaw(&pod, sizeof(pod)); + } + + // Append just a type byte (commands with no payload). + void appendType(uint8_t type) + { + m_recordingThread.check(); + writeRaw(&type, sizeof(type)); + } + + // GC finalizers destroy on worker threads, so destroys queue and drain on + // the recording thread. + struct PendingDestroy + { + uint8_t kind; + RenderHandle id; + uint32_t generation; + IdAllocator<RenderHandle>* allocator; + }; + + void queueDestroy(uint8_t kind, + RenderHandle id, + uint32_t generation, + IdAllocator<RenderHandle>* allocator) + { + std::lock_guard<std::mutex> lock(m_destroyMutex); + m_pendingDestroys.push_back({kind, id, generation, allocator}); + } + + // The id goes straight back, so the next create in this same stream may + // retake it. Safe because a consumer replays whole frames in stream order + // and every resident slot is generation checked: the retake's create + // stamps a new generation, so this destroy no-ops when it replays and a + // snapshot recorded before it still resolves its own generation out of its + // own byte copy. + void drainDestroys() + { + std::vector<PendingDestroy> pending; + { + std::lock_guard<std::mutex> lock(m_destroyMutex); + pending.swap(m_pendingDestroys); + } + for (const auto& p : pending) + { + append(static_cast<uint8_t>(RenderCmd::destroyResource), + DestroyResourcePOD{p.kind, p.id, p.generation}); + if (p.allocator != nullptr) + { + p.allocator->release(p.id, p.generation); + } + } + } + + void reset() + { + clearBytes(); + m_frameId++; + } + + // Which frame is recording. Resources stamp draws with it so only a + // mutation of a resource drawn THIS frame bumps a version; the first + // mutation of a new frame reuses the live replay object in place. + uint32_t frameId() const { return m_frameId; } + +private: + RecordingThread m_recordingThread; + std::mutex m_destroyMutex; + std::vector<PendingDestroy> m_pendingDestroys; + uint32_t m_frameId = 0; +}; + +using RenderCommandReader = CommandReader<uint8_t>; + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_commands.hpp b/renderer/include/rive/renderer/cmd/render_commands.hpp new file mode 100644 index 0000000..1c778ff --- /dev/null +++ b/renderer/include/rive/renderer/cmd/render_commands.hpp
@@ -0,0 +1,350 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/render_handle.hpp" +#include <cstddef> +#include <cstdint> + +// The 2D command vocabulary recorded into a RenderCommandBuffer. One +// interleaved stream in record order; replay walks it once, so a resource is +// always created and configured before use. All POD, no pointers. +namespace rive::cmd +{ + +enum class RenderCmd : uint8_t +{ + // ---- resource creation ---- + // Each make* carries an explicit id and generation so ids can be reused + // without the consumer table growing unboundedly. + makePath, // MakePathPOD (+ rawpath blob) + makeEmptyPath, // MakeIdPOD + makePaint, // MakeIdPOD + makeLinearGradient, // LinearGradientPOD (+ colors[]+stops[] blob) + makeRadialGradient, // RadialGradientPOD (+ colors[]+stops[] blob) + decodeImage, // DecodeImagePOD (+ encoded-bytes blob) + makeBuffer, // MakeBufferPOD + bufferData, // BufferDataPOD (+ data blob), a map()/unmap() write + + // The consumer releases table[id] only when the generation matches, so a + // stale destroy after slot reuse is harmless. + destroyResource, // DestroyResourcePOD (kind, id, generation) + + // ---- path mutations ---- + // Per verb builder calls accumulate into a scratch RawPath flushed as + // pathAddRawPath, so there are no per verb commands in the stream. + pathRewind, // ResId + pathFillRule, // PathFillRulePOD + pathAddRawPath, // PathRawPOD (+ rawpath blob) + pathAddRenderPath, // PathAddPathPOD + + // ---- paint mutations ---- + paintStyle, // PaintU8POD + paintColor, // PaintColorPOD + paintThickness, // PaintFloatPOD + paintJoin, // PaintU8POD + paintCap, // PaintU8POD + paintFeather, // PaintFloatPOD + paintBlendMode, // PaintU8POD + paintShader, // PaintShaderPOD + paintInvalidateStroke, // ResId + + // ---- renderer draws ---- + save, // no payload + restore, // no payload + transform, // TransformPOD + drawPath, // DrawPathPOD + clipPath, // ClipPathPOD + drawImage, // DrawImagePOD + drawImageMesh, // DrawImageMeshPOD + modulateOpacity, // OpacityPOD + + // ---- render target scheduling ---- + // Canvas content records inline between these brackets; replay redirects + // it into the canvas's own frame since PLS frames cannot nest. + canvasContentBegin, // CanvasContentPOD (canvas id, clear color) + canvasContentEnd, // ResId (canvas id) + // draws that sample it. Opens the screen frame. + + // A drawn resource was mutated again this frame: draws pin the version + // they saw, replay materializes the outgoing version before applying + // later mutations. + resourceNewVersion, // ResourceVersionPOD + + // Keep equal to the last real opcode so replay rejects corrupt type bytes + // instead of desyncing. + lastRenderCmd = resourceNewVersion, +}; + +// A bare resource id payload. +struct ResIdPOD +{ + RenderHandle id; +}; + +// Which id space a destroyed resource belongs to; an id alone is ambiguous. +enum class ResourceKind : uint8_t +{ + path, + paint, + shader, + image, + buffer, +}; + +struct DestroyResourcePOD +{ + uint8_t kind; // ResourceKind + RenderHandle id; + uint32_t generation; +}; + +struct ResourceVersionPOD +{ + uint8_t kind; // ResourceKind + RenderHandle id; + uint32_t version; +}; + +// make* that carries only its id. +struct MakeIdPOD +{ + RenderHandle id; + uint32_t generation; +}; + +// Wire PODs stream raw through writeRaw, so layouts carry explicit pad +// fields wherever the 8-aligned 64 bit offsets would otherwise introduce +// implicit (uninitialized) padding. +struct MakePathPOD +{ + RenderHandle id; + uint32_t generation; + uint64_t blobOffset; // verbs + uint64_t pointsOffset; // points (own blob, 8-aligned) + uint32_t verbCount; + uint32_t pointCount; + uint32_t fillRule; + uint32_t pad; +}; +static_assert(sizeof(MakePathPOD) == 10 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +struct LinearGradientPOD +{ + RenderHandle id; + uint32_t generation; + float sx, sy, ex, ey; + uint64_t blobOffset; // colors + uint64_t stopsOffset; // stops (own blob, 8-aligned) + uint32_t count; + uint32_t pad; +}; +static_assert(sizeof(LinearGradientPOD) == 12 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +struct RadialGradientPOD +{ + RenderHandle id; + uint32_t generation; + float cx, cy, radius; + uint32_t count; + uint64_t blobOffset; // colors + uint64_t stopsOffset; // stops (own blob, 8-aligned) +}; +static_assert(sizeof(RadialGradientPOD) == 10 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +struct PathFillRulePOD +{ + RenderHandle path; + uint8_t fillRule; +}; + +struct PathRawPOD +{ + uint64_t blobOffset; // verbs + uint64_t pointsOffset; // points (own blob, 8-aligned) + RenderHandle path; + uint32_t verbCount; + uint32_t pointCount; + uint32_t pad; +}; +static_assert(sizeof(PathRawPOD) == 8 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +struct PathAddPathPOD +{ + RenderHandle path; + RenderHandle src; + float xx, xy, yx, yy, tx, ty; // Mat2D +}; + +struct PaintU8POD +{ + RenderHandle paint; + uint8_t value; +}; + +struct PaintColorPOD +{ + RenderHandle paint; + uint32_t color; +}; + +struct PaintFloatPOD +{ + RenderHandle paint; + float value; +}; + +struct PaintShaderPOD +{ + RenderHandle paint; + RenderHandle shader; // kInvalidRenderHandle clears the shader +}; + +struct TransformPOD +{ + float xx, xy, yx, yy, tx, ty; // Mat2D +}; + +struct DrawPathPOD +{ + RenderHandle path; + RenderHandle paint; + uint32_t pathVersion; + uint32_t paintVersion; +}; + +struct ClipPathPOD +{ + RenderHandle path; + uint32_t version; +}; + +struct DecodeImagePOD +{ + RenderHandle id; + uint32_t generation; + uint64_t blobOffset; + uint32_t byteCount; + uint32_t width; + uint32_t height; + uint32_t pad; +}; +static_assert(sizeof(DecodeImagePOD) == 8 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +struct MakeBufferPOD +{ + RenderHandle id; + uint32_t generation; + uint8_t bufferType; // RenderBufferType + uint8_t flags; // RenderBufferFlags + uint32_t sizeInBytes; +}; + +struct BufferDataPOD +{ + uint64_t blobOffset; + RenderHandle buffer; + uint32_t size; +}; +static_assert(sizeof(BufferDataPOD) == 4 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +struct DrawImagePOD +{ + RenderHandle image; + uint8_t wrapX, wrapY, filter; // ImageSampler + uint8_t blendMode; + float opacity; +}; + +struct DrawImageMeshPOD +{ + RenderHandle image; + RenderHandle vertices, uvCoords, indices; + uint32_t vertexVersion, uvVersion, indexVersion; + uint32_t vertexCount, indexCount; + uint8_t wrapX, wrapY, filter; // ImageSampler + uint8_t blendMode; + float opacity; +}; + +struct OpacityPOD +{ + float opacity; +}; + +struct CanvasContentPOD +{ + RenderHandle + canvasId; // flagged canvas id (kCanvasHandleFlag), as in drawImage + uint32_t clearColor; // ARGB; the canvas frame's clear (script-controlled) +}; + +// Opcode to payload table, one X(opcode, POD) per command; void means no +// payload. Every size a skip or filter walk uses derives from here, so a new +// command cannot desync them. Blobs ride separately and never affect sizes. +#define RIVE_RENDER_CMD_TABLE(X) \ + X(makePath, MakePathPOD) \ + X(makeEmptyPath, MakeIdPOD) \ + X(makePaint, MakeIdPOD) \ + X(makeLinearGradient, LinearGradientPOD) \ + X(makeRadialGradient, RadialGradientPOD) \ + X(decodeImage, DecodeImagePOD) \ + X(makeBuffer, MakeBufferPOD) \ + X(bufferData, BufferDataPOD) \ + X(destroyResource, DestroyResourcePOD) \ + X(pathRewind, ResIdPOD) \ + X(pathFillRule, PathFillRulePOD) \ + X(pathAddRawPath, PathRawPOD) \ + X(pathAddRenderPath, PathAddPathPOD) \ + X(paintStyle, PaintU8POD) \ + X(paintColor, PaintColorPOD) \ + X(paintThickness, PaintFloatPOD) \ + X(paintJoin, PaintU8POD) \ + X(paintCap, PaintU8POD) \ + X(paintFeather, PaintFloatPOD) \ + X(paintBlendMode, PaintU8POD) \ + X(paintShader, PaintShaderPOD) \ + X(paintInvalidateStroke, ResIdPOD) \ + X(save, void) \ + X(restore, void) \ + X(transform, TransformPOD) \ + X(drawPath, DrawPathPOD) \ + X(clipPath, ClipPathPOD) \ + X(drawImage, DrawImagePOD) \ + X(drawImageMesh, DrawImageMeshPOD) \ + X(modulateOpacity, OpacityPOD) \ + X(canvasContentBegin, CanvasContentPOD) \ + X(canvasContentEnd, ResIdPOD) \ + X(resourceNewVersion, ResourceVersionPOD) + +namespace detail +{ +template <typename POD> constexpr size_t payloadSizeOfPOD() +{ + return sizeof(POD); +} +template <> constexpr size_t payloadSizeOfPOD<void>() { return 0; } +} // namespace detail + +constexpr size_t payloadSizeOf(RenderCmd c) +{ + switch (c) + { +#define RIVE_RENDER_CMD_SIZE_CASE(cmd, POD) \ + case RenderCmd::cmd: \ + return detail::payloadSizeOfPOD<POD>(); + RIVE_RENDER_CMD_TABLE(RIVE_RENDER_CMD_SIZE_CASE) +#undef RIVE_RENDER_CMD_SIZE_CASE + } + return 0; +} + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_handle.hpp b/renderer/include/rive/renderer/cmd/render_handle.hpp new file mode 100644 index 0000000..05d1fc7 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/render_handle.hpp
@@ -0,0 +1,20 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/handle_flags.hpp" +#include <cstdint> + +// Dense resource id for the deferred 2D command stream. +namespace rive::cmd +{ +using RenderHandle = uint32_t; +constexpr RenderHandle kInvalidRenderHandle = ~0u; + +// A canvas drawImage carries this flag; the low bits index the canvas table. +// Test after kInvalidRenderHandle, which also has the high bit set. +constexpr RenderHandle kCanvasHandleFlag = kHandleForeignFlag; +constexpr RenderHandle kCanvasHandleMask = kHandleForeignMask; +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/cmd/render_replay.hpp b/renderer/include/rive/renderer/cmd/render_replay.hpp new file mode 100644 index 0000000..08fa7d5 --- /dev/null +++ b/renderer/include/rive/renderer/cmd/render_replay.hpp
@@ -0,0 +1,296 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/factory.hpp" +#include <cstdio> +#include "rive/renderer.hpp" +#include "rive/shapes/paint/image_sampler.hpp" +#include "rive/math/raw_path.hpp" +#include "rive/math/mat2d.hpp" +#include "rive/renderer/cmd/render_command_buffer.hpp" +#include "rive/renderer/cmd/render_commands.hpp" +#include <cassert> +#include <cstring> +#include <functional> +#include <unordered_map> +#include <vector> + +// Replays a recorded 2D command stream against a real Factory and Renderer. +// Creation commands rebuild real resources into dense per type vectors, +// mutations replay in order, and draws drive the real renderer. +namespace rive::cmd +{ + +// Bulk copy is safe since the bytes came from a valid RawPath at record time. +inline RawPath rebuildRawPath(Span<const uint8_t> verbBytes, + Span<const uint8_t> pointBytes) +{ + return RawPath( + Span<const PathVerb>( + reinterpret_cast<const PathVerb*>(verbBytes.data()), + verbBytes.size() / sizeof(PathVerb)), + Span<const Vec2D>(reinterpret_cast<const Vec2D*>(pointBytes.data()), + pointBytes.size() / sizeof(Vec2D))); +} + +// Resolves a flagged canvas id to the real canvas's RenderImage at replay. +// Null when the stream draws no canvases. +using CanvasImageResolver = std::function<RenderImage*(RenderHandle canvasId)>; + +// Draw drop accounting for verification. +struct ReplayStats +{ + uint32_t droppedDraws = 0; +}; + +namespace replay_detail +{ +inline void logDroppedDraw(uint8_t type, uint32_t h0, uint32_t h1) +{ + RIVE_WARN_THROTTLED("rive replay: dropped draw opcode=%u handles=%u,%u " + "(unresolved at replay)\n", + type, + h0, + h1); +} +} // namespace replay_detail + +// Which command classes a walk executes. The filter only gates side effects; +// reads always consume payloads so the walk stays in sync. +enum class ReplayFilter : uint8_t +{ + all, + resources, // creates, mutations, version bumps, in record order + draws, // draws, brackets, markers, in scheduled segment order + destroys, // destroyResource +}; + +// Render thread hooks for a deferred runtime frame. A bare replay leaves them +// null and everything draws into renderer. +struct ReplayHooks +{ + ReplayFilter filter = ReplayFilter::all; + CanvasImageResolver canvasImage = nullptr; + // Open the real canvas's frame and return the renderer that draws into + // it; a null return safely drops that canvas's content. + std::function<Renderer*(RenderHandle canvasId, uint32_t clearColor)> + beginCanvasContent = nullptr; + // Nonzero dropped draws mean mixed factory recording or a replay bug. + ReplayStats* stats = nullptr; +}; + +// One resident resource type: a dense vector indexed by id, each slot carrying +// the real object and its creation generation. Never compacts. +template <typename T> struct Resident +{ + std::vector<rcp<T>> objects; + std::vector<uint32_t> generations; + // Draws pin the exact version they recorded against; older versions of a + // slot live in per frame aliases keyed (id << 32) | version. + std::vector<uint32_t> versions; + std::unordered_map<uint64_t, rcp<T>> versionAliases; + + void set(RenderHandle id, rcp<T> obj, uint32_t generation) + { + if (id > objects.size()) + { + // The producer mints ids sequentially, so a fresh id may only + // append; anything further ahead is a corrupt stream. + assert(false); + return; + } + if (id == objects.size()) + { + objects.push_back(std::move(obj)); + generations.push_back(generation); + versions.push_back(0); + return; + } + // A retaken id must not resolve to the old take's versions. + dropVersionAliases(id); + objects[id] = std::move(obj); + generations[id] = generation; + versions[id] = 0; + } + void dropVersionAliases(RenderHandle id) + { + if (versionAliases.empty()) + { + return; + } + for (auto it = versionAliases.begin(); it != versionAliases.end();) + { + if (static_cast<RenderHandle>(it->first >> 32) == id) + { + it = versionAliases.erase(it); + } + else + { + ++it; + } + } + } + // Move the live object under its old version and make obj the live one. + void newVersion(RenderHandle id, uint32_t version, rcp<T> obj) + { + if (id >= objects.size()) + { + return; + } + versionAliases[(uint64_t(id) << 32) | versions[id]] = + std::move(objects[id]); + objects[id] = std::move(obj); + versions[id] = version; + } + void destroy(RenderHandle id, uint32_t generation) + { + if (id < objects.size() && generations[id] == generation) + { + objects[id] = nullptr; + dropVersionAliases(id); + } + } + T* get(RenderHandle id) const + { + return id < objects.size() ? objects[id].get() : nullptr; + } + T* get(RenderHandle id, uint32_t version) const + { + if (id >= objects.size()) + { + return nullptr; + } + if (version == versions[id]) + { + return objects[id].get(); + } + auto it = versionAliases.find((uint64_t(id) << 32) | version); + return it == versionAliases.end() ? nullptr : it->second.get(); + } + // Owning rcp for APIs that take one. + rcp<T> shared(RenderHandle id) const + { + return id < objects.size() ? objects[id] : nullptr; + } + rcp<T> shared(RenderHandle id, uint32_t version) const + { + if (id >= objects.size()) + { + return nullptr; + } + if (version == versions[id]) + { + return objects[id]; + } + auto it = versionAliases.find((uint64_t(id) << 32) | version); + return it == versionAliases.end() ? nullptr : it->second; + } +}; + +// The materialized 2D resources, persisted across frames so resources stay +// resident on the render thread. +// CPU shadows of mutable state, so a version bump can materialize a fresh +// object carrying the outgoing state without cloning backend objects. +struct PaintShadow +{ + // Must be the backend's defaults: a property the producer never sends is + // one it knows the fresh object already carries. + uint8_t style = 1; // fill; a fresh paint is unstroked until told + ColorInt color = 0xFF000000; + float thickness = 1; + uint8_t join = 0; + uint8_t cap = 0; + float feather = 0; + uint8_t blendMode = 3; // srcOver + RenderHandle shader = kInvalidRenderHandle; +}; +struct BufferShadow +{ + uint8_t type = 0; + uint16_t flags = 0; + uint32_t size = 0; +}; + +struct ResourceTable +{ + Resident<RenderPath> paths; + Resident<RenderPaint> paints; + Resident<RenderShader> shaders; + Resident<RenderImage> images; + Resident<RenderBuffer> buffers; + std::vector<PaintShadow> paintShadows; + std::vector<uint8_t> pathFillRules; + std::vector<BufferShadow> bufferShadows; + + // Generation checked so a stale destroy after slot reuse is a no-op. + void destroy(ResourceKind kind, RenderHandle id, uint32_t generation) + { + switch (kind) + { + case ResourceKind::path: + paths.destroy(id, generation); + break; + case ResourceKind::paint: + paints.destroy(id, generation); + break; + case ResourceKind::shader: + shaders.destroy(id, generation); + break; + case ResourceKind::image: + images.destroy(id, generation); + break; + case ResourceKind::buffer: + buffers.destroy(id, generation); + break; + } + } + + // Old versions only live within one frame's replay. + void clearVersionAliases() + { + paths.versionAliases.clear(); + paints.versionAliases.clear(); + shaders.versionAliases.clear(); + images.versionAliases.clear(); + buffers.versionAliases.clear(); + } +}; + +// Span form: replays raw stream bytes so a snapshot copy can replay without a +// RenderCommandBuffer. Defined in src/deferred_cmd.cpp. +void replayRenderCommands(Factory* factory, + Renderer* renderer, + Span<const uint8_t> commands, + Span<const uint8_t> blobs, + ResourceTable& table, + const ReplayHooks& hooks = {}); + +// Buffer form. +inline void replayRenderCommands(Factory* factory, + Renderer* renderer, + const RenderCommandBuffer& cmd, + ResourceTable& table, + const ReplayHooks& hooks = {}) +{ + replayRenderCommands(factory, + renderer, + cmd.commandBytes(), + cmd.blobBytes(), + table, + hooks); +} + +// Convenience form with a throwaway table. +inline void replayRenderCommands(Factory* factory, + Renderer* renderer, + const RenderCommandBuffer& cmd, + const ReplayHooks& hooks = {}) +{ + ResourceTable table; + replayRenderCommands(factory, renderer, cmd, table, hooks); +} + +} // namespace rive::cmd
diff --git a/renderer/include/rive/renderer/gl/gl_utils.hpp b/renderer/include/rive/renderer/gl/gl_utils.hpp index 1590f8a..5165ec5 100644 --- a/renderer/include/rive/renderer/gl/gl_utils.hpp +++ b/renderer/include/rive/renderer/gl/gl_utils.hpp
@@ -73,11 +73,52 @@ void LinkProgram(GLuint program, DebugPrintErrorAndAbort = DebugPrintErrorAndAbort::yes); +// Threaded wasm reaches one heap from several contexts, and a GL name means +// nothing outside the context that made it: every context numbers from 1. +#ifdef __EMSCRIPTEN_PTHREADS__ +#define RIVE_GL_NAMES_ARE_PER_CONTEXT +#endif + +enum class GLObjectType +{ + buffer, + texture, + framebuffer, + renderbuffer, + vertexArray, + shader, + program, +}; + +using GLContextID = int; + +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT +GLContextID CurrentContextID(); + +// Deletes the names other threads left behind for whichever context is current. +void ReclaimAbandonedNames(); + +// Process wide, for tests: names left to their owning context, and names that +// owner has since deleted. +uint32_t AbandonedNameCount(); +uint32_t ReclaimedNameCount(); +#else +// A process with a single context can always delete what it created. +constexpr GLContextID CurrentContextID() { return 0; } +inline void ReclaimAbandonedNames() {} +#endif + class GLObject { public: GLObject() = default; - GLObject(GLObject&& rhs) : m_id(std::exchange(rhs.m_id, 0)) {} + GLObject(GLObject&& rhs) : + m_id(std::exchange(rhs.m_id, 0)) +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT + , + m_context(rhs.m_context) +#endif + {} GLObject(const GLObject&) = delete; GLObject& operator=(const GLObject&) = delete; @@ -87,14 +128,24 @@ protected: explicit GLObject(GLuint adoptedID) : m_id(adoptedID) {} + // Deletes m_id, on the context that created it. + void destroy(GLObjectType); + // Deletes m_id and takes over rhs's name and the context it belongs to. + void adopt(GLObjectType, GLObject&& rhs); + // Deletes m_id and takes over a name generated on the current context. + void adoptName(GLObjectType, GLuint adoptedID); + GLuint m_id = 0; +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT + GLContextID m_context = CurrentContextID(); +#endif }; class Buffer : public GLObject { public: Buffer() { glGenBuffers(1, &m_id); } - ~Buffer() { glDeleteBuffers(1, &m_id); } + ~Buffer() { destroy(GLObjectType::buffer); } }; class Texture : public GLObject @@ -104,25 +155,16 @@ Texture(Texture&& rhs) : GLObject(std::move(rhs)) {} Texture& operator=(Texture&& rhs) { - reset(std::exchange(rhs.m_id, 0)); + adopt(GLObjectType::texture, std::move(rhs)); return *this; } - ~Texture() { reset(0); } + ~Texture() { destroy(GLObjectType::texture); } static Texture Zero() { return Texture(0); } static Texture Adopt(GLuint id) { return Texture(id); } private: explicit Texture(GLuint adoptedID) : GLObject(adoptedID) {} - - void reset(GLuint adoptedID) - { - if (m_id != 0) - { - glDeleteTextures(1, &m_id); - } - m_id = adoptedID; - } }; class Framebuffer : public GLObject @@ -132,24 +174,15 @@ Framebuffer(Framebuffer&& rhs) : GLObject(std::move(rhs)) {} Framebuffer& operator=(Framebuffer&& rhs) { - reset(std::exchange(rhs.m_id, 0)); + adopt(GLObjectType::framebuffer, std::move(rhs)); return *this; } - ~Framebuffer() { reset(0); } + ~Framebuffer() { destroy(GLObjectType::framebuffer); } static Framebuffer Zero() { return Framebuffer(0); } private: explicit Framebuffer(GLuint adoptedID) : GLObject(adoptedID) {} - - void reset(GLuint adoptedID) - { - if (m_id != 0) - { - glDeleteFramebuffers(1, &m_id); - } - m_id = adoptedID; - } }; class Renderbuffer : public GLObject @@ -159,31 +192,22 @@ Renderbuffer(Renderbuffer&& rhs) : GLObject(std::move(rhs)) {} Renderbuffer& operator=(Renderbuffer&& rhs) { - reset(std::exchange(rhs.m_id, 0)); + adopt(GLObjectType::renderbuffer, std::move(rhs)); return *this; } - ~Renderbuffer() { reset(0); } + ~Renderbuffer() { destroy(GLObjectType::renderbuffer); } static Renderbuffer Zero() { return Renderbuffer(0); } private: explicit Renderbuffer(GLuint adoptedID) : GLObject(adoptedID) {} - - void reset(GLuint adoptedID) - { - if (m_id != 0) - { - glDeleteRenderbuffers(1, &m_id); - } - m_id = adoptedID; - } }; class VAO : public GLObject { public: VAO() { glGenVertexArrays(1, &m_id); } - ~VAO() { glDeleteVertexArrays(1, &m_id); } + ~VAO() { destroy(GLObjectType::vertexArray); } }; class Shader : public GLObject @@ -193,10 +217,10 @@ Shader(Shader&& rhs) : GLObject(std::move(rhs)) {} Shader& operator=(Shader&& rhs) { - reset(std::exchange(rhs.m_id, 0)); + adopt(GLObjectType::shader, std::move(rhs)); return *this; } - ~Shader() { reset(0); } + ~Shader() { destroy(GLObjectType::shader); } void compile(GLenum type, const char* source, @@ -213,11 +237,7 @@ void reset(GLuint adoptedID = 0) { - if (m_id != 0) - { - glDeleteShader(m_id); - } - m_id = adoptedID; + adoptName(GLObjectType::shader, adoptedID); } }; @@ -227,12 +247,12 @@ Program() : GLObject(glCreateProgram()) {} Program& operator=(Program&& rhs) { - reset(std::exchange(rhs.m_id, 0)); + adopt(GLObjectType::program, std::move(rhs)); m_vertexShader = std::move(rhs.m_vertexShader); m_fragmentShader = std::move(rhs.m_fragmentShader); return *this; } - ~Program() { reset(0); } + ~Program() { destroy(GLObjectType::program); } void compileAndAttachShader(GLenum type, const char* source, @@ -254,8 +274,6 @@ private: explicit Program(GLuint adoptedID) : GLObject(adoptedID) {} - void reset(GLuint adoptedProgramID); - glutils::Shader m_vertexShader; glutils::Shader m_fragmentShader; };
diff --git a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp index b89d89e..6342292 100644 --- a/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp +++ b/renderer/include/rive/renderer/gl/render_context_gl_impl.hpp
@@ -9,7 +9,10 @@ #include "rive/renderer/gl/gl_utils.hpp" #include "rive/renderer/render_context_helper_impl.hpp" +#include <atomic> +#include <mutex> #include <unordered_map> +#include <vector> namespace rive { @@ -69,6 +72,15 @@ rcp<RenderCanvas> makeRenderCanvas(uint32_t width, uint32_t height) override; + // Creates a shell canvas with no texture; the deferred replay worker + // backs it on its own context via ensureDeferredCanvasBacking. + rcp<RenderCanvas> makeDeferredRenderCanvas(uint32_t width, + uint32_t height) override; + + // Back a deferred canvas with a texture on this context so it reads + // coherently here. No-op for an already backed canvas. + void ensureDeferredCanvasBacking(gpu::RenderCanvas* canvas); + std::unique_ptr<rive::ore::Context> makeOreContext() override; // GL-only: returns a Y-flipped companion of a Rive 2D RenderCanvas @@ -105,6 +117,10 @@ void registerCanvasTarget(GLuint sourceTex); void unregisterCanvasTarget(GLuint sourceTex); + // A deferred canvas is dropped by the thread that recorded it, so its entry + // and the FBOs in it come down on this context's own thread instead. + void releaseCanvasTarget(GLuint sourceTex); + // Looks up an existing mirror for `sourceTex` and allocates one if // none exists yet. Returns nullptr if `sourceTex` was never registered // (i.e. is not a canvas target — caller should fall through to a @@ -186,6 +202,13 @@ #endif private: +#ifdef RIVE_CANVAS + // Shared canvas wiring; `tex` of 0 makes an unbacked shell canvas. + rcp<RenderCanvas> wrapCanvasBacking(uint32_t width, + uint32_t height, + GLuint tex); +#endif + class DrawProgram; // Manages how we implement pixel local storage in shaders. @@ -575,6 +598,14 @@ }; std::unordered_map<GLuint, CanvasMirrorEntry> m_canvasMirrors; friend class CanvasMirrorTextureGLImpl; + + // Canvas targets released off this context's thread, drained by flush. + std::mutex m_releasedCanvasTargetMutex; + std::vector<GLuint> m_releasedCanvasTargets; + std::atomic<bool> m_hasReleasedCanvasTargets{false}; + const glutils::GLContextID m_glContext = glutils::CurrentContextID(); + + void drainReleasedCanvasTargets(); #endif }; } // namespace rive::gpu
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_command_buffer.hpp b/renderer/include/rive/renderer/ore/cmd/ore_command_buffer.hpp new file mode 100644 index 0000000..6857596 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_command_buffer.hpp
@@ -0,0 +1,189 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/command_stream.hpp" +#include "rive/renderer/cmd/recording_thread.hpp" +#include "rive/renderer/ore/cmd/ore_commands.hpp" +#include "rive/renderer/gpu_resource.hpp" +#include "rive/refcnt.hpp" +#include "rive/span.hpp" +#include <cassert> +#include <cstdint> +#include <cstring> +#include <functional> +#include <mutex> +#include <unordered_map> +#include "rive/renderer/cmd/id_allocator.hpp" +#include <vector> + +// Records the ore RenderPass call stream into a flat byte stream (see +// ore_commands.hpp). Upload payloads live in a companion blob arena. Both +// vectors are reused across frames via reset, keeping capacity. +namespace rive::ore::cmd +{ + +class OreCommandBuffer : public rive::cmd::CommandByteStream +{ +public: + // Claim this stream for the calling thread; every append after it must + // come from the same one. Deferred producers call it, an inline or + // backend owned buffer leaves it unbound. Debug only, see RecordingThread. + void bindRecordingThread() { m_recordingThread.bind(); } + + // When set, capture returns the provider's stable handle instead of a + // buffer local keep alive index and does not retain the resource here. + // Only reached for a resource that is not one of the recorder's own + // deferred objects; those report their handle themselves. + std::function<ResourceHandle(rive::gpu::GPUResource*)> realHandleProvider; + + // Dedups by pointer so repeated binds cost one ref. Callers must have + // ruled out a deferred object first: this retains res and hands back an + // index into the keep alive table, which replay reads as a real resource. + ResourceHandle capture(rive::gpu::GPUResource* res) + { + if (res == nullptr) + { + return kInvalidHandle; + } + m_recordingThread.check(); + if (realHandleProvider) + { + return realHandleProvider(res); + } + auto it = m_resourceIds.find(res); + if (it != m_resourceIds.end()) + { + return it->second; + } + ResourceHandle h = static_cast<ResourceHandle>(m_keepAlive.size()); + m_keepAlive.push_back(rive::ref_rcp(res)); + m_resourceIds.emplace(res, h); + return h; + } + + template <typename POD> void append(CommandType type, const POD& pod) + { + m_recordingThread.check(); + appendUnchecked(type, pod); + } + + void appendOpcode(CommandType type) + { + m_recordingThread.check(); + writeRaw(&type, sizeof(type)); + } + + // Payload with no opcode, e.g. a make descriptor after its header. + template <typename POD> void appendPayload(const POD& pod) + { + static_assert(std::is_trivially_copyable<POD>::value); + m_recordingThread.check(); + writeRaw(&pod, sizeof(pod)); + } + + // absent records a null source as distinct from an empty payload. + BlobRef appendBlobRef(const void* data, uint32_t size, bool absent) + { + if (absent) + { + return kNoBlob; + } + return {appendBlob(data, size), size, 0}; + } + // A null pointer maps to absent. + BlobRef appendStringRef(const char* s) + { + if (s == nullptr) + { + return kNoBlob; + } + uint32_t len = static_cast<uint32_t>(std::strlen(s)) + 1; // include NUL + return appendBlobRef(s, len, false); + } + + // Dart finalizers destroy on GC threads, so destroys queue and drain on + // the recording thread. The erase is generation checked. + struct PendingDestroy + { + ResourceHandle handle; + uint32_t generation; + rive::IdAllocator<ResourceHandle>* allocator; + }; + +private: + template <typename POD> void appendUnchecked(CommandType type, const POD& p) + { + static_assert(std::is_trivially_copyable<POD>::value); + writeRaw(&type, sizeof(type)); + writeRaw(&p, sizeof(p)); + } + + void applyDestroy(const PendingDestroy& p) + { + // Unchecked: the last drain of a session's life runs from wherever + // the host posted its teardown, which on threaded wasm is the replay + // worker rather than the recording thread. + appendUnchecked(CommandType::destroyResource, + DestroyResourcePOD{p.handle, p.generation}); + if (p.allocator != nullptr) + { + p.allocator->release(p.handle, p.generation); + } + } + +public: + void queueDestroy(const PendingDestroy& pending) + { + std::lock_guard<std::mutex> lock(m_destroyMutex); + m_pendingDestroys.push_back(pending); + } + + // The id goes straight back, so the next create in this same stream may + // retake it. Safe because a consumer replays whole frames in stream order + // and every resident slot is generation checked: the retake's create + // stamps a new generation, so this destroy no-ops when it replays and a + // snapshot recorded before it still resolves its own generation out of its + // own byte copy. + void drainDestroys() + { + std::vector<PendingDestroy> pending; + { + std::lock_guard<std::mutex> lock(m_destroyMutex); + pending.swap(m_pendingDestroys); + } + for (const auto& p : pending) + { + applyDestroy(p); + } + } + + // Keeps capacity for reuse across frames. + void reset() + { + m_recordingThread.check(); + clearBytes(); + m_keepAlive.clear(); + m_resourceIds.clear(); + } + + const std::vector<rcp<rive::gpu::GPUResource>>& keepAlive() const + { + return m_keepAlive; + } + +private: + rive::cmd::RecordingThread m_recordingThread; + std::mutex m_destroyMutex; + std::vector<PendingDestroy> m_pendingDestroys; + std::vector<rcp<rive::gpu::GPUResource>> m_keepAlive; + std::unordered_map<rive::gpu::GPUResource*, ResourceHandle> m_resourceIds; +}; + +// Sequential reader shared by backend replay, the silver comparator, and a +// viewer. +using OreCommandReader = rive::cmd::CommandReader<CommandType>; + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_command_silver.hpp b/renderer/include/rive/renderer/ore/cmd/ore_command_silver.hpp new file mode 100644 index 0000000..f67e4b9 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_command_silver.hpp
@@ -0,0 +1,431 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/core/binary_reader.hpp" +#include "rive/core/vector_binary_writer.hpp" +#include <cmath> +#include <cstdio> +#include <string> +#include <vector> + +// Portable field wise silver form of a recorded ore command stream. +// serializeSilver emits ints as varuints and floats via writeFloat, so the +// layout is independent of POD padding and arch. silverMatch compares two +// streams, the GPU free regression guard for recording fidelity. +namespace rive::ore::cmd +{ + +// "ORES" + version. Distinct from the in-memory "ORECMD" stream. +constexpr uint8_t kSilverMagic[4] = {'O', 'R', 'E', 'S'}; +constexpr uint64_t kSilverVersion = 1; +constexpr float kSilverEpsilon = 0.0001f; + +inline void serializeSilver(const OreCommandBuffer& buffer, + std::vector<uint8_t>& out) +{ + VectorBinaryWriter writer(&out); + writer.write(kSilverMagic, sizeof(kSilverMagic)); + writer.writeVarUint(kSilverVersion); + + OreCommandReader reader(buffer.commandBytes(), buffer.blobBytes()); + CommandType type; + while (reader.next(type)) + { + writer.writeVarUint(static_cast<uint32_t>(type)); + switch (type) + { + case CommandType::beginRenderPass: + { + auto cmd = reader.read<BeginRenderPassCmd>(); + writer.writeVarUint(cmd.colorCount); + for (uint32_t i = 0; i < cmd.colorCount; ++i) + { + const ColorAttachmentPOD& c = cmd.colors[i]; + writer.writeVarUint(c.view); + writer.writeVarUint(c.resolveTarget); + writer.writeVarUint(static_cast<uint32_t>(c.loadOp)); + writer.writeVarUint(static_cast<uint32_t>(c.storeOp)); + writer.writeFloat(c.clearR); + writer.writeFloat(c.clearG); + writer.writeFloat(c.clearB); + writer.writeFloat(c.clearA); + } + const DepthStencilAttachmentPOD& d = cmd.depthStencil; + writer.writeVarUint(d.view); + writer.writeVarUint(static_cast<uint32_t>(d.depthLoadOp)); + writer.writeVarUint(static_cast<uint32_t>(d.depthStoreOp)); + writer.writeFloat(d.depthClearValue); + writer.writeVarUint(static_cast<uint32_t>(d.stencilLoadOp)); + writer.writeVarUint(static_cast<uint32_t>(d.stencilStoreOp)); + writer.writeVarUint(d.stencilClearValue); + break; + } + case CommandType::setPipeline: + { + auto cmd = reader.read<SetPipelineCmd>(); + writer.writeVarUint(cmd.pipeline); + break; + } + case CommandType::setVertexBuffer: + { + auto cmd = reader.read<SetVertexBufferCmd>(); + writer.writeVarUint(cmd.slot); + writer.writeVarUint(cmd.buffer); + writer.writeVarUint(cmd.offset); + break; + } + case CommandType::setIndexBuffer: + { + auto cmd = reader.read<SetIndexBufferCmd>(); + writer.writeVarUint(cmd.buffer); + writer.writeVarUint(static_cast<uint32_t>(cmd.format)); + writer.writeVarUint(cmd.offset); + break; + } + case CommandType::setBindGroup: + { + auto cmd = reader.read<SetBindGroupCmd>(); + writer.writeVarUint(cmd.groupIndex); + writer.writeVarUint(cmd.bindGroup); + writer.writeVarUint(cmd.dynamicOffsetCount); + Span<const uint8_t> blob = + reader.blobAt(cmd.dynamicOffsetStart, + cmd.dynamicOffsetCount * sizeof(uint32_t)); + for (uint32_t i = 0; i < cmd.dynamicOffsetCount; ++i) + { + uint32_t off; + std::memcpy(&off, + blob.data() + i * sizeof(uint32_t), + sizeof(uint32_t)); + writer.writeVarUint(off); + } + break; + } + case CommandType::setViewport: + { + auto cmd = reader.read<SetViewportCmd>(); + writer.writeFloat(cmd.x); + writer.writeFloat(cmd.y); + writer.writeFloat(cmd.width); + writer.writeFloat(cmd.height); + writer.writeFloat(cmd.minDepth); + writer.writeFloat(cmd.maxDepth); + break; + } + case CommandType::setScissorRect: + { + auto cmd = reader.read<SetScissorRectCmd>(); + writer.writeVarUint(cmd.x); + writer.writeVarUint(cmd.y); + writer.writeVarUint(cmd.width); + writer.writeVarUint(cmd.height); + break; + } + case CommandType::setStencilReference: + { + auto cmd = reader.read<SetStencilReferenceCmd>(); + writer.writeVarUint(cmd.ref); + break; + } + case CommandType::setBlendColor: + { + auto cmd = reader.read<SetBlendColorCmd>(); + writer.writeFloat(cmd.r); + writer.writeFloat(cmd.g); + writer.writeFloat(cmd.b); + writer.writeFloat(cmd.a); + break; + } + case CommandType::draw: + { + auto cmd = reader.read<DrawCmd>(); + writer.writeVarUint(cmd.vertexCount); + writer.writeVarUint(cmd.instanceCount); + writer.writeVarUint(cmd.firstVertex); + writer.writeVarUint(cmd.firstInstance); + break; + } + case CommandType::drawIndexed: + { + auto cmd = reader.read<DrawIndexedCmd>(); + writer.writeVarUint(cmd.indexCount); + writer.writeVarUint(cmd.instanceCount); + writer.writeVarUint(cmd.firstIndex); + // baseVertex is signed, round trip its bit pattern. + writer.writeVarUint(static_cast<uint32_t>(cmd.baseVertex)); + writer.writeVarUint(cmd.firstInstance); + break; + } + case CommandType::finish: + break; + + // Lifecycle opcodes do not appear in the pass only streams compared + // today, but the reader must still advance; emit only the identity. + case CommandType::makeBuffer: + case CommandType::makeTexture: + case CommandType::makeSampler: + case CommandType::makeShaderModule: + case CommandType::makeBindGroupLayout: + case CommandType::makeTextureView: + case CommandType::makePipeline: + case CommandType::makeBindGroup: + { + auto m = reader.read<MakeResourcePOD>(); + reader.skip(orePayloadSizeOf(type) - sizeof(MakeResourcePOD)); + writer.writeVarUint(m.id); + writer.writeVarUint(m.generation); + break; + } + case CommandType::bufferUpdate: + { + auto cmd = reader.read<BufferUpdatePOD>(); + writer.writeVarUint(cmd.handle); + writer.writeVarUint(cmd.offset); + writer.writeVarUint(cmd.bytes.size); + break; + } + case CommandType::textureUpload: + { + auto cmd = reader.read<TextureUploadPOD>(); + writer.writeVarUint(cmd.handle); + writer.writeVarUint(cmd.bytes.size); + break; + } + case CommandType::destroyResource: + { + auto cmd = reader.read<DestroyResourcePOD>(); + writer.writeVarUint(cmd.handle); + writer.writeVarUint(cmd.generation); + break; + } + case CommandType::wrapCanvasView: + { + auto cmd = reader.read<WrapCanvasViewPOD>(); + writer.writeVarUint(cmd.id); + writer.writeVarUint(cmd.generation); + writer.writeVarUint(cmd.canvasId); + break; + } + } + } +} + +namespace silver_detail +{ +inline bool varMatch(const char* field, + BinaryReader& a, + BinaryReader& b, + uint64_t* out = nullptr) +{ + uint64_t va = a.readVarUint64(); + uint64_t vb = b.readVarUint64(); + if (va != vb) + { + fprintf(stderr, + "ore silver: %s differs %llu != %llu\n", + field, + static_cast<unsigned long long>(va), + static_cast<unsigned long long>(vb)); + return false; + } + if (out != nullptr) + { + *out = va; + } + return true; +} + +inline bool floatMatch(const char* field, BinaryReader& a, BinaryReader& b) +{ + float va = a.readFloat32(); + float vb = b.readFloat32(); + if (std::fabs(va - vb) > kSilverEpsilon) + { + fprintf(stderr, "ore silver: %s differs %f != %f\n", field, va, vb); + return false; + } + return true; +} +} // namespace silver_detail + +// Compares within the float epsilon and reports the first divergence. +inline bool silverMatch(const std::vector<uint8_t>& expected, + const std::vector<uint8_t>& actual) +{ + using namespace silver_detail; + BinaryReader a(Span<const uint8_t>(expected.data(), expected.size())); + BinaryReader b(Span<const uint8_t>(actual.data(), actual.size())); + + for (uint32_t i = 0; i < sizeof(kSilverMagic); ++i) + { + if (a.readByte() != kSilverMagic[i] || b.readByte() != kSilverMagic[i]) + { + fprintf(stderr, "ore silver: bad magic\n"); + return false; + } + } + if (!varMatch("version", a, b)) + { + return false; + } + + while (!a.reachedEnd()) + { + if (b.reachedEnd()) + { + fprintf(stderr, "ore silver: actual stream is shorter\n"); + return false; + } + uint64_t op = 0; + if (!varMatch("opcode", a, b, &op)) + { + return false; + } + switch (static_cast<CommandType>(op)) + { + case CommandType::beginRenderPass: + { + uint64_t colorCount = 0; + if (!varMatch("colorCount", a, b, &colorCount)) + { + return false; + } + for (uint64_t i = 0; i < colorCount; ++i) + { + if (!varMatch("color.view", a, b) || + !varMatch("color.resolveTarget", a, b) || + !varMatch("color.loadOp", a, b) || + !varMatch("color.storeOp", a, b) || + !floatMatch("color.clearR", a, b) || + !floatMatch("color.clearG", a, b) || + !floatMatch("color.clearB", a, b) || + !floatMatch("color.clearA", a, b)) + { + return false; + } + } + if (!varMatch("ds.view", a, b) || + !varMatch("ds.depthLoadOp", a, b) || + !varMatch("ds.depthStoreOp", a, b) || + !floatMatch("ds.depthClearValue", a, b) || + !varMatch("ds.stencilLoadOp", a, b) || + !varMatch("ds.stencilStoreOp", a, b) || + !varMatch("ds.stencilClearValue", a, b)) + { + return false; + } + break; + } + case CommandType::setPipeline: + if (!varMatch("pipeline", a, b)) + { + return false; + } + break; + case CommandType::setVertexBuffer: + if (!varMatch("vb.slot", a, b) || + !varMatch("vb.buffer", a, b) || + !varMatch("vb.offset", a, b)) + { + return false; + } + break; + case CommandType::setIndexBuffer: + if (!varMatch("ib.buffer", a, b) || + !varMatch("ib.format", a, b) || + !varMatch("ib.offset", a, b)) + { + return false; + } + break; + case CommandType::setBindGroup: + { + uint64_t count = 0; + if (!varMatch("bg.groupIndex", a, b) || + !varMatch("bg.bindGroup", a, b) || + !varMatch("bg.dynamicOffsetCount", a, b, &count)) + { + return false; + } + for (uint64_t i = 0; i < count; ++i) + { + if (!varMatch("bg.dynamicOffset", a, b)) + { + return false; + } + } + break; + } + case CommandType::setViewport: + if (!floatMatch("vp.x", a, b) || !floatMatch("vp.y", a, b) || + !floatMatch("vp.width", a, b) || + !floatMatch("vp.height", a, b) || + !floatMatch("vp.minDepth", a, b) || + !floatMatch("vp.maxDepth", a, b)) + { + return false; + } + break; + case CommandType::setScissorRect: + if (!varMatch("sc.x", a, b) || !varMatch("sc.y", a, b) || + !varMatch("sc.width", a, b) || !varMatch("sc.height", a, b)) + { + return false; + } + break; + case CommandType::setStencilReference: + if (!varMatch("stencilRef", a, b)) + { + return false; + } + break; + case CommandType::setBlendColor: + if (!floatMatch("blend.r", a, b) || + !floatMatch("blend.g", a, b) || + !floatMatch("blend.b", a, b) || + !floatMatch("blend.a", a, b)) + { + return false; + } + break; + case CommandType::draw: + if (!varMatch("draw.vertexCount", a, b) || + !varMatch("draw.instanceCount", a, b) || + !varMatch("draw.firstVertex", a, b) || + !varMatch("draw.firstInstance", a, b)) + { + return false; + } + break; + case CommandType::drawIndexed: + if (!varMatch("drawIndexed.indexCount", a, b) || + !varMatch("drawIndexed.instanceCount", a, b) || + !varMatch("drawIndexed.firstIndex", a, b) || + !varMatch("drawIndexed.baseVertex", a, b) || + !varMatch("drawIndexed.firstInstance", a, b)) + { + return false; + } + break; + case CommandType::finish: + break; + default: + fprintf(stderr, + "ore silver: unknown opcode %llu\n", + static_cast<unsigned long long>(op)); + return false; + } + } + if (!b.reachedEnd()) + { + fprintf(stderr, "ore silver: actual stream is longer\n"); + return false; + } + return true; +} + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp b/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp new file mode 100644 index 0000000..54b2536 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp
@@ -0,0 +1,277 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/ore_types.hpp" +#include "rive/renderer/ore/cmd/ore_handle.hpp" +#include "rive/renderer/ore/cmd/ore_resource_commands.hpp" +#include <cstddef> +#include <cstdint> + +// Recorded form of the ore RenderPass interface, written as [opcode][POD] +// into a flat byte stream. Structs hold no pointers, so a recorded buffer is +// movable across threads and doubles as the silver artifact. Resources are +// referenced by ResourceHandle. +namespace rive::ore::cmd +{ + +enum class CommandType : uint32_t +{ + beginRenderPass, + setPipeline, + setVertexBuffer, + setIndexBuffer, + setBindGroup, + setViewport, + setScissorRect, + setStencilReference, + setBlendColor, + draw, + drawIndexed, + finish, + + // Resource lifecycle interleaved in stream order, so a create precedes + // every use and id reuse is safe on the consumer. + makeBuffer, + makeTexture, + makeSampler, + makeShaderModule, + makeBindGroupLayout, + makeTextureView, + makePipeline, + makeBindGroup, + bufferUpdate, + textureUpload, + destroyResource, + // Reserve a canvas view; the consumer wraps at replay. No device touch on + // record. + wrapCanvasView, +}; + +// Precedes each make descriptor; the consumer stores the real resource at +// {id, generation}. +struct MakeResourcePOD +{ + ResourceHandle id; + uint32_t generation; +}; + +struct BufferUpdatePOD +{ + ResourceHandle handle; + uint32_t offset; + BlobRef bytes; +}; + +// Fields are fixed width so the layout is identical on 32 bit wasm and 64 bit +// native. +struct TextureUploadPOD +{ + ResourceHandle handle; + uint32_t bytesPerRow; + uint32_t rowsPerImage; + uint32_t mipLevel; + uint32_t layer; + uint32_t x, y, z; + uint32_t width, height, depth; + uint32_t pad; // keeps the 8-aligned BlobRef free of implicit padding + BlobRef bytes; +}; +static_assert(sizeof(TextureUploadPOD) == 16 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +// Selects how the consumer wraps a reserved canvas view at replay. +enum class WrapCanvasViewMode : uint32_t +{ + colorView = 0, // the canvas's own render target view + sampleView = 1, // sampling wrap, on GL needs the top up mirror + imageView = 2, // decoded image, canvasId carries the 2D image id +}; + +struct WrapCanvasViewPOD +{ + ResourceHandle id; + uint32_t generation; + uint32_t canvasId; // canvas id, or the 2D image id for imageView + uint32_t mode; // WrapCanvasViewMode +}; + +// The consumer clears the slot only when the generation matches, so a stale +// destroy for a recycled id is ignored. +struct DestroyResourcePOD +{ + ResourceHandle handle; + uint32_t generation; +}; + +// resolveTarget == kInvalidHandle means none. +struct ColorAttachmentPOD +{ + ResourceHandle view; + ResourceHandle resolveTarget; + LoadOp loadOp; + StoreOp storeOp; + float clearR; + float clearG; + float clearB; + float clearA; +}; + +// view == kInvalidHandle means no depth stencil attachment. +struct DepthStencilAttachmentPOD +{ + ResourceHandle view; + LoadOp depthLoadOp; + StoreOp depthStoreOp; + float depthClearValue; + LoadOp stencilLoadOp; + StoreOp stencilStoreOp; + uint32_t stencilClearValue; +}; + +// Fixed 4 slot color array keeps the command a flat POD. +struct BeginRenderPassCmd +{ + uint32_t colorCount; + ColorAttachmentPOD colors[4]; + DepthStencilAttachmentPOD depthStencil; +}; + +struct SetPipelineCmd +{ + ResourceHandle pipeline; +}; + +struct SetVertexBufferCmd +{ + uint32_t slot; + ResourceHandle buffer; + uint32_t offset; +}; + +struct SetIndexBufferCmd +{ + ResourceHandle buffer; + IndexFormat format; + uint32_t offset; +}; + +// Dynamic offsets live in the blob arena at dynamicOffsetStart. +struct SetBindGroupCmd +{ + uint32_t groupIndex; + ResourceHandle bindGroup; + uint64_t dynamicOffsetStart; + uint32_t dynamicOffsetCount; + uint32_t pad; +}; +static_assert(sizeof(SetBindGroupCmd) == 6 * sizeof(uint32_t), + "wire POD must be pointer-free and padding-free"); + +struct SetViewportCmd +{ + float x; + float y; + float width; + float height; + float minDepth; + float maxDepth; +}; + +struct SetScissorRectCmd +{ + uint32_t x; + uint32_t y; + uint32_t width; + uint32_t height; +}; + +struct SetStencilReferenceCmd +{ + uint32_t ref; +}; + +struct SetBlendColorCmd +{ + float r; + float g; + float b; + float a; +}; + +struct DrawCmd +{ + uint32_t vertexCount; + uint32_t instanceCount; + uint32_t firstVertex; + uint32_t firstInstance; +}; + +struct DrawIndexedCmd +{ + uint32_t indexCount; + uint32_t instanceCount; + uint32_t firstIndex; + int32_t baseVertex; + uint32_t firstInstance; +}; + +// Intentionally no union node type; readers switch on CommandType and memcpy +// the POD, avoiding the union's worst case padding. + +// Opcode to payload table, one X(opcode, POD, DescPOD) per command; void +// means no payload in that slot. make* carries MakeResourcePOD then its +// descriptor. Every size a skip walk uses derives from here, so a new command +// cannot desync it. Blobs ride separately and never affect sizes. +#define RIVE_ORE_CMD_TABLE(X) \ + X(beginRenderPass, BeginRenderPassCmd, void) \ + X(setPipeline, SetPipelineCmd, void) \ + X(setVertexBuffer, SetVertexBufferCmd, void) \ + X(setIndexBuffer, SetIndexBufferCmd, void) \ + X(setBindGroup, SetBindGroupCmd, void) \ + X(setViewport, SetViewportCmd, void) \ + X(setScissorRect, SetScissorRectCmd, void) \ + X(setStencilReference, SetStencilReferenceCmd, void) \ + X(setBlendColor, SetBlendColorCmd, void) \ + X(draw, DrawCmd, void) \ + X(drawIndexed, DrawIndexedCmd, void) \ + X(finish, void, void) \ + X(makeBuffer, MakeResourcePOD, BufferDescPOD) \ + X(makeTexture, MakeResourcePOD, TextureDescPOD) \ + X(makeSampler, MakeResourcePOD, SamplerDescPOD) \ + X(makeShaderModule, MakeResourcePOD, ShaderModuleDescPOD) \ + X(makeBindGroupLayout, MakeResourcePOD, BindGroupLayoutDescPOD) \ + X(makeTextureView, MakeResourcePOD, TextureViewDescPOD) \ + X(makePipeline, MakeResourcePOD, PipelineDescPOD) \ + X(makeBindGroup, MakeResourcePOD, BindGroupDescPOD) \ + X(bufferUpdate, BufferUpdatePOD, void) \ + X(textureUpload, TextureUploadPOD, void) \ + X(destroyResource, DestroyResourcePOD, void) \ + X(wrapCanvasView, WrapCanvasViewPOD, void) + +namespace detail +{ +template <typename POD> constexpr size_t orePayloadSizeOfPOD() +{ + return sizeof(POD); +} +template <> constexpr size_t orePayloadSizeOfPOD<void>() { return 0; } +} // namespace detail + +constexpr size_t orePayloadSizeOf(CommandType c) +{ + switch (c) + { +#define RIVE_ORE_CMD_SIZE_CASE(cmd, POD, DESC) \ + case CommandType::cmd: \ + return detail::orePayloadSizeOfPOD<POD>() + \ + detail::orePayloadSizeOfPOD<DESC>(); + RIVE_ORE_CMD_TABLE(RIVE_ORE_CMD_SIZE_CASE) +#undef RIVE_ORE_CMD_SIZE_CASE + } + return 0; +} + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp b/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp new file mode 100644 index 0000000..26e80b6 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp
@@ -0,0 +1,546 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_resource.hpp" +#include "rive/renderer/ore/cmd/ore_make_recording.hpp" +#include "rive/renderer/ore/cmd/ore_make_replay.hpp" +#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp" +#include "rive/renderer/ore/cmd/ore_replay.hpp" +#include "rive/renderer/cmd/id_allocator.hpp" +#include "rive/renderer/cmd/foreign_image_registry.hpp" +#include "rive/renderer/cmd/live_recorder_registry.hpp" +#include "rive/renderer/ore/ore_context.hpp" +#include "utils/lite_rtti.hpp" +#include <cassert> +#include <unordered_map> +#include <vector> + +// Ore Context used while recording in deferred mode. make* and beginRenderPass +// record into one ordered stream with no GPU work; replayFrame materializes it +// on a real context. Stream order makes id reuse safe, so the allocator can +// recycle handles without a recycled id aliasing a live resource. +namespace rive::ore::cmd +{ + +class DeferredOreContext : public Context +{ +public: + // real may be null at construction and bound later via bindReal. + // Recording never touches it, but its capabilities are the ones a script + // must see, so they are copied in as soon as there is a device to copy. + explicit DeferredOreContext(Context* real) : Context(nullptr), m_real(real) + { + adoptRealFeatures(); + m_render.realHandleProvider = [this](rive::gpu::GPUResource* r) { + return realHandleFor(r); + }; + m_render.bindRecordingThread(); + rive::cmd::registerRecorder(&m_render); + rive::cmd::registerRecorder(&m_ids); + } + + ~DeferredOreContext() override + { + // Unregister first so a late finalizer release no-ops instead of + // writing into a dead recorder. + rive::cmd::unregisterRecorder(&m_render); + rive::cmd::unregisterRecorder(&m_ids); + // Drain cross thread destroys before the stream dies. + m_render.drainDestroys(); + } + + // make*: record a create and return a deferred object that records its own + // writes and destruction into the same stream. + + rcp<Buffer> makeBuffer(const BufferDesc& desc) override + { + auto a = m_ids.alloc(); + recordMakeBuffer(m_render, a.id, a.generation, desc); + return make_rcp<DeferredBuffer>(a.id, + a.generation, + &m_render, + &m_ids, + desc.size, + desc.usage); + } + + rcp<Texture> makeTexture(const TextureDesc& desc) override + { + auto a = m_ids.alloc(); + recordMakeTexture(m_render, a.id, a.generation, desc); + return make_rcp<DeferredTexture>(a.id, + a.generation, + &m_render, + &m_ids, + desc); + } + + rcp<TextureView> makeTextureView(const TextureViewDesc& desc) override + { + auto a = m_ids.alloc(); + recordMakeTextureView(m_render, + a.id, + a.generation, + desc, + handleFor(desc.texture)); + return make_rcp<DeferredTextureView>(a.id, + a.generation, + &m_render, + &m_ids, + ref_rcp(desc.texture), + desc); + } + + rcp<Sampler> makeSampler(const SamplerDesc& desc) override + { + auto a = m_ids.alloc(); + recordMakeSampler(m_render, a.id, a.generation, desc); + return make_rcp<DeferredSampler>(a.id, a.generation, &m_render, &m_ids); + } + + rcp<ShaderModule> makeShaderModule(const ShaderModuleDesc& desc) override + { + auto a = m_ids.alloc(); + recordMakeShaderModule(m_render, a.id, a.generation, desc); + auto obj = make_rcp<DeferredShaderModule>(a.id, + a.generation, + &m_render, + &m_ids); + // Parse the binding map so record time validation and layout + // derivation match the real backend. + if (desc.bindingMapBytes != nullptr && desc.bindingMapSize > 0) + { + obj->applyBindingMapFromDesc(desc); + } + return obj; + } + + rcp<BindGroupLayout> makeBindGroupLayout( + const BindGroupLayoutDesc& desc) override + { + auto a = m_ids.alloc(); + recordMakeBindGroupLayout(m_render, a.id, a.generation, desc); + return make_rcp<DeferredBindGroupLayout>(a.id, + a.generation, + &m_render, + &m_ids); + } + + rcp<Pipeline> makePipeline(const PipelineDesc& desc, + std::string* /*outError*/ = nullptr) override + { + std::vector<ResourceHandle> bgls(desc.bindGroupLayoutCount); + for (uint32_t i = 0; i < desc.bindGroupLayoutCount; ++i) + { + bgls[i] = handleFor(desc.bindGroupLayouts[i]); + } + auto a = m_ids.alloc(); + recordMakePipeline( + m_render, + a.id, + a.generation, + desc, + handleFor(desc.vertexModule), + handleFor(desc.fragmentModule), + Span<const ResourceHandle>(bgls.data(), bgls.size())); + return make_rcp<DeferredPipeline>(a.id, + a.generation, + &m_render, + &m_ids, + desc); + } + + rcp<BindGroup> makeBindGroup(const BindGroupDesc& desc) override + { + std::vector<ResourceHandle> ubos(desc.uboCount), + texs(desc.textureCount), samps(desc.samplerCount); + for (uint32_t i = 0; i < desc.uboCount; ++i) + { + ubos[i] = handleFor(desc.ubos[i].buffer); + } + for (uint32_t i = 0; i < desc.textureCount; ++i) + { + texs[i] = handleFor(desc.textures[i].view); + } + for (uint32_t i = 0; i < desc.samplerCount; ++i) + { + samps[i] = handleFor(desc.samplers[i].sampler); + } + auto a = m_ids.alloc(); + recordMakeBindGroup( + m_render, + a.id, + a.generation, + desc, + handleFor(desc.layout), + Span<const ResourceHandle>(ubos.data(), ubos.size()), + Span<const ResourceHandle>(texs.data(), texs.size()), + Span<const ResourceHandle>(samps.data(), samps.size())); + return make_rcp<DeferredBindGroup>(a.id, + a.generation, + &m_render, + &m_ids); + } + + std::unique_ptr<RenderPass> beginRenderPass( + const RenderPassDesc& desc, + std::string* /*outError*/ = nullptr) override + { + return std::make_unique<RenderPassRecording>(this, &m_render, desc); + } + + // Maps a canvas to its shared canvas id for replay. Set by the + // DeferredSession; when set wrapCanvasTexture never touches the device. + std::function<uint32_t(gpu::RenderCanvas*)> canvasIdProvider; + + // Late binding for hosts whose real context outlives session creation. + void bindReal(Context* real) + { + bool late = m_real == nullptr && real != nullptr; + m_real = real; + adoptRealFeatures(); + if (late) + { + checkUnboundAssumptions(); + } + } + + // A script must not branch on a capability this context cannot know. It + // knows one only once there is a real device to ask. + bool featuresKnown() const override { return m_real != nullptr; } + + bool isRecording() const override { return true; } + + // Maps a canvas backed image to its shared canvas id. Set by the owning + // session, null on sessionless GMs. + rive::cmd::ForeignImageRegistry* canvasRegistry = nullptr; + + // Proxy view a reserved canvas returns at record time. The proxy format + // must match the real backing or checkPipelineCompat rejects every + // pipeline bound against it, and RenderPassRecording::setPipeline drops + // the command rather than appending it. Unbound there is nothing to ask, + // so the base class default stands and checkUnboundAssumptions fires if a + // late bound backend turns out to override it. + static constexpr TextureFormat kUnboundCanvasFormat = + TextureFormat::rgba8unorm; + rcp<TextureView> makeReservedCanvasView(ResourceHandle id, + uint32_t generation, + uint32_t width, + uint32_t height) + { + TextureDesc texDesc{}; + texDesc.width = width; + texDesc.height = height; + texDesc.format = m_real != nullptr ? m_real->canvasTargetFormat() + : kUnboundCanvasFormat; + texDesc.type = TextureType::texture2D; + texDesc.renderTarget = true; + texDesc.numMipmaps = 1; + texDesc.sampleCount = 1; + auto proxyTex = + make_rcp<DeferredTexture>(0u, 0u, nullptr, nullptr, texDesc); + TextureViewDesc viewDesc{}; + viewDesc.texture = proxyTex.get(); + viewDesc.dimension = TextureViewDimension::texture2D; + viewDesc.baseMipLevel = 0; + viewDesc.mipCount = 1; + viewDesc.baseLayer = 0; + viewDesc.layerCount = 1; + return make_rcp<DeferredTextureView>(id, + generation, + &m_render, + &m_ids, + std::move(proxyTex), + viewDesc); + } + + rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* c) override + { + if (!canvasIdProvider) + { + // Sessionless GM fallback: wrap the real host canvas directly. + assert(m_real != nullptr); + return m_real->wrapCanvasTexture(c); + } + // Reserve now; the consumer wraps at replay. + uint32_t canvasId = canvasIdProvider(c); + auto a = m_ids.alloc(); + recordWrapCanvasView(m_render, + a.id, + a.generation, + canvasId, + WrapCanvasViewMode::colorView); + return makeReservedCanvasView(a.id, + a.generation, + c->width(), + c->height()); + } + + // Same reserve as wrapCanvasTexture but tagged sampleView so the consumer + // does the backend sampling wrap at replay. + rcp<TextureView> recordWrapCanvasImage(RenderImage* image, + uint32_t width, + uint32_t height) override + { + assert(canvasRegistry != nullptr); + uint32_t canvasId = + canvasRegistry->imageDrawId(image) & rive::cmd::kCanvasHandleMask; + auto a = m_ids.alloc(); + recordWrapCanvasView(m_render, + a.id, + a.generation, + canvasId, + WrapCanvasViewMode::sampleView); + return makeReservedCanvasView(a.id, a.generation, width, height); + } + + // Decoded image view: the consumer resolves the resident image and wraps + // its texture at replay. + rcp<TextureView> recordWrapImageView(uint32_t imageId, + uint32_t width, + uint32_t height) override + { + auto a = m_ids.alloc(); + recordWrapCanvasView(m_render, + a.id, + a.generation, + imageId, + WrapCanvasViewMode::imageView); + return makeReservedCanvasView(a.id, a.generation, width, height); + } + rcp<TextureView> wrapRiveTexture(gpu::Texture* t, + uint32_t w, + uint32_t h) override + { + // Every script GPU op must be deferred. Hitting this while recording + // means a caller wraps a texture without its own reserve path. + fprintf(stderr, + "rive deferred: TRIPWIRE wrapRiveTexture hit immediately " + "during recording (a script GPU op is not deferred)\n"); + assert(false && "wrapRiveTexture must be deferred while recording"); + if (m_real == nullptr) + { + return nullptr; + } + return m_real->wrapRiveTexture(t, w, h); + } + // Selects the RSTB variant a script loads and records, so unlike features + // there is no way to refuse: returning nothing loads no shader at all. + // Web is the only host that binds late and web is GL, so the fallback is + // an assumption about one host rather than a guess about any device, and + // checkUnboundAssumptions fires if a late bind ever contradicts it. + static constexpr ShaderTarget kUnboundShaderTarget = ShaderTarget::glsl; + ShaderTarget shaderTarget() const override + { + return m_real != nullptr ? m_real->shaderTarget() + : kUnboundShaderTarget; + } + + // No GPU at record time. + + void beginFrame(const FrameDescriptor&) override {} + void endFrame() override {} + void waitForGPU() override {} + + // Resident table the consumer persists across frames. + using RealTable = OreResident; + + // Creates are idempotent, so a frame can replay repeatedly without + // recompiling. Persistent table for streaming, throwaway for single shot. + void replayFrame(Context& realCtx, + RealTable& table, + const OreCanvasResolve& canvasAt = {}) + { + replayOreStream( + realCtx, + m_render, + table, + [this](ResourceHandle h) { return resolveReal(h); }, + canvasAt); + } + + // Single shot replay against a throwaway table, used by goldens. + void replay(Context& realCtx) + { + RealTable table; + replayFrame(realCtx, table); + } + + struct StreamBytes + { + size_t commands, blobs; + size_t total() const { return commands + blobs; } + }; + StreamBytes streamBytes() const + { + return {m_render.commandBytes().size(), m_render.blobBytes().size()}; + } + + // What this context references r by, the lookup every recorded cross + // reference resolves through. A deferred object of ours answers for + // itself: a live object cannot be stale about its own handle, so no + // address the allocator recycles can ever speak for the dead object that + // used to occupy it. Anything else is a real resource, retained by the + // frame and addressed by a flagged index. + ResourceHandle handleFor(Buffer* b) + { + return handleForAs<DeferredBuffer>(b); + } + ResourceHandle handleFor(Texture* t) + { + return handleForAs<DeferredTexture>(t); + } + ResourceHandle handleFor(TextureView* v) + { + return handleForAs<DeferredTextureView>(v); + } + ResourceHandle handleFor(Sampler* s) + { + return handleForAs<DeferredSampler>(s); + } + ResourceHandle handleFor(ShaderModule* m) + { + return handleForAs<DeferredShaderModule>(m); + } + ResourceHandle handleFor(BindGroupLayout* l) + { + return handleForAs<DeferredBindGroupLayout>(l); + } + ResourceHandle handleFor(Pipeline* p) + { + return handleForAs<DeferredPipeline>(p); + } + ResourceHandle handleFor(BindGroup* g) + { + return handleForAs<DeferredBindGroup>(g); + } + + // The consumer keeps its resident table. Real bindings are re-captured + // each frame, keeping the retained set bounded by what one frame binds. + void resetFrame() + { + m_render.reset(); + // Cross thread destroys drain on the recording thread, landing at the + // new frame's stream head. + m_render.drainDestroys(); + m_realPtrToHandle.clear(); + m_realResources.clear(); + } + + // Real bindings captured by this frame's stream, indexed by unflagged id. + const std::vector<rcp<rive::gpu::GPUResource>>& realResources() const + { + return m_realResources; + } + + const OreCommandBuffer& stream() const { return m_render; } + +private: + // The replay device's capabilities are the ones a recording script has to + // see: it is the device the recorded branch will run on. Copied rather + // than forwarded so features() stays a non-virtual field read, and the + // copy cannot go stale because a backend measures its Features once, in + // its Make. + void adoptRealFeatures() + { + if (m_real != nullptr) + { + m_features = m_real->features(); + } + } + + // Everything answered before a late bind was answered without a device. + // features() refused rather than guessing, but the shader target and the + // canvas format had to answer something, and a script has already loaded + // and recorded against both. If the device that just arrived disagrees, + // the stream is already wrong in a way replay cannot detect: a mismatched + // canvas format makes checkPipelineCompat drop every setPipeline, leaving + // draws with no pipeline bound. + void checkUnboundAssumptions() + { + if (m_real->shaderTarget() != kUnboundShaderTarget) + { + fprintf(stderr, + "rive deferred: TRIPWIRE late bound backend consumes " + "shader target %u, but recording already loaded %u\n", + static_cast<unsigned>(m_real->shaderTarget()), + static_cast<unsigned>(kUnboundShaderTarget)); + assert(false && "late bind changed the recorded shader target"); + } + if (m_real->canvasTargetFormat() != kUnboundCanvasFormat) + { + fprintf(stderr, + "rive deferred: TRIPWIRE late bound backend allocates " + "canvases as format %u, but recording already reserved " + "canvas views as %u\n", + static_cast<unsigned>(m_real->canvasTargetFormat()), + static_cast<unsigned>(kUnboundCanvasFormat)); + assert(false && "late bind changed the recorded canvas format"); + } + } + + // Resolves a flagged real id to its retained real object. + rive::gpu::GPUResource* resolveReal(ResourceHandle h) + { + ResourceHandle i = h & kRealResourceMask; + return i < m_realResources.size() ? m_realResources[i].get() : nullptr; + } + + // A deferred object reports its own handle, but only if it records into + // a stream this context writes: a reference is an index into the table + // that stream feeds, and a foreign one would name whatever this context + // happens to hold at that id. Those take the real path, as they did when + // this was a map lookup that simply failed to find them. + template <typename DeferredT, typename T> ResourceHandle handleForAs(T* r) + { + if (r == nullptr) + { + return kInvalidHandle; + } + if (auto* d = rive::lite_rtti_cast<DeferredT*>(r)) + { + if (d->recordsInto(&m_render)) + { + return d->clientHandle(); + } + } + return realHandleFor(r); + } + + // An already real resource gets a flagged id and is retained so it lives + // to replay. + ResourceHandle realHandleFor(rive::gpu::GPUResource* r) + { + if (r == nullptr) + { + return kInvalidHandle; + } + auto rit = m_realPtrToHandle.find(r); + if (rit != m_realPtrToHandle.end()) + { + return rit->second; + } + // The unflagged index must fit under the flag bit. + assert(m_realResources.size() <= kRealResourceMask); + ResourceHandle h = kRealResourceFlag | + static_cast<ResourceHandle>(m_realResources.size()); + m_realResources.push_back(rive::ref_rcp(r)); + m_realPtrToHandle.emplace(r, h); + return h; + } + + Context* m_real; + OreCommandBuffer m_render; // the one ordered stream + // Reusable id space shared by every resource type. + rive::IdAllocator<ResourceHandle> m_ids; + // Already real resources this frame bound, retained until replay and + // addressed by flagged id. Cleared every resetFrame. + PtrHandleMap m_realPtrToHandle; + std::vector<rcp<rive::gpu::GPUResource>> m_realResources; +}; + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_deferred_render_pass.hpp b/renderer/include/rive/renderer/ore/cmd/ore_deferred_render_pass.hpp new file mode 100644 index 0000000..b064025 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_render_pass.hpp
@@ -0,0 +1,70 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp" +#include "rive/renderer/ore/cmd/ore_replay.hpp" +#include "rive/renderer/ore/ore_context.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include <memory> + +// Single threaded record then replay inline. InlineDeferredRenderPass records +// every call into an owned OreCommandBuffer and finish() drains it back +// through the live immediate path, so output is byte identical to immediate +// mode and callers need no changes. +namespace rive::ore::cmd +{ + +// Orders the owned buffer before the recording base that writes into it. +struct OwnedOreCommandBuffer +{ + OreCommandBuffer buffer; +}; + +class InlineDeferredRenderPass : private OwnedOreCommandBuffer, + public RenderPassRecording +{ +public: + InlineDeferredRenderPass(Context* context, const RenderPassDesc& desc) : + RenderPassRecording(context, &buffer, desc) + {} + + void finish() override + { + if (m_finished) + { + return; + } + // The base latches m_finished before we replay: replay reenters + // beginRenderPass, whose finishActiveRenderPass would otherwise call + // this again. + RenderPassRecording::finish(); + replayCommandBuffer(*m_context, buffer); + } +}; + +// Single decision point between recording and the live immediate pass. +inline std::unique_ptr<RenderPass> beginRenderPassRecordingOrImmediate( + Context& ctx, + const RenderPassDesc& desc, + std::string* outError = nullptr) +{ + if (ctx.deferredRecording()) + { + if (ctx.usesDeferredFrameReplay()) + { + // The backend replays the pending frame once at endFrame. + return std::make_unique<RenderPassRecording>(&ctx, + &ctx.pendingFrame(), + desc); + } + // No frame boundary drain on this backend, replay the pass inline. + return std::make_unique<InlineDeferredRenderPass>(&ctx, desc); + } + return ctx.beginRenderPass(desc, outError); +} + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp b/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp new file mode 100644 index 0000000..2428f47 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp
@@ -0,0 +1,229 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_handle.hpp" +#include "rive/renderer/ore/cmd/ore_replay.hpp" +#include "rive/renderer/ore/cmd/ore_make_recording.hpp" +#include "rive/renderer/cmd/id_allocator.hpp" +#include "rive/renderer/cmd/live_recorder_registry.hpp" +#include "rive/renderer/ore/ore_buffer.hpp" +#include "rive/renderer/ore/ore_texture.hpp" +#include "rive/renderer/ore/ore_sampler.hpp" +#include "rive/renderer/ore/ore_shader_module.hpp" +#include "rive/renderer/ore/ore_bind_group_layout.hpp" +#include "rive/renderer/ore/ore_pipeline.hpp" +#include "rive/renderer/ore/ore_bind_group.hpp" +#include <cassert> +#include <functional> +#include <unordered_map> +#include <vector> + +// Client handle resource objects. A make* returns one immediately with no GPU +// object, carrying its handle, generation, and the descriptor facts validation +// needs. Replay creates the real object at the same handle; destruction +// records a destroy and returns the id for reuse with a bumped generation. +namespace rive::ore::cmd +{ + +// Pointer keyed handles for resources this recorder did not create: real +// backend objects a frame binds, deduped so repeated binds cost one ref. +// Deferred objects are never in here, they answer for themselves. +using PtrHandleMap = + std::unordered_map<rive::gpu::GPUResource*, ResourceHandle>; + +// Common mixin. The DeferredOreContext owns the stream and allocator and +// outlives every resource, so the back pointers stay valid. +class DeferredResource +{ +public: + DeferredResource(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator) : + m_clientHandle(handle), + m_generation(generation), + m_stream(stream), + m_allocator(allocator) + {} + // Asking the live object is what makes a lookup immune to address + // recycling: an address only names this handle for as long as this object + // occupies it. + ResourceHandle clientHandle() const { return m_clientHandle; } + + // A handle resolves against the table this resource's stream feeds, so a + // reader that writes a different stream must not use it. + bool recordsInto(const OreCommandBuffer* stream) const + { + return m_stream != nullptr && m_stream == stream; + } + +protected: + ~DeferredResource() + { + // Destructors run on any thread, possibly after the owning context + // died, so stragglers no-op and live destroys queue for the drain. + std::lock_guard<std::mutex> lock(rive::cmd::recorderRegistryMutex()); + if (m_stream != nullptr) + { + if (rive::cmd::liveRecorders().count(m_stream) == 0) + { + return; // the context died first, nothing to record into + } + m_stream->queueDestroy({m_clientHandle, m_generation, m_allocator}); + return; + } + if (m_allocator != nullptr && + rive::cmd::liveRecorders().count(m_allocator) == 0) + { + return; + } + if (m_allocator != nullptr) + { + m_allocator->release(m_clientHandle, m_generation); + } + } + OreCommandBuffer* stream() const { return m_stream; } + +private: + ResourceHandle m_clientHandle; + uint32_t m_generation; + OreCommandBuffer* m_stream; + rive::IdAllocator<ResourceHandle>* m_allocator; +}; + +class DeferredBuffer : public LITE_RTTI_OVERRIDE(Buffer, DeferredBuffer), + public DeferredResource +{ +public: + DeferredBuffer(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator, + uint32_t size, + BufferUsage usage) : + LITE_RTTI_OVERRIDE(Buffer, DeferredBuffer)(size, usage), + DeferredResource(handle, generation, stream, allocator) + {} + + // Recorded, replayed on the real buffer before the passes. + void update(const void* data, uint32_t size, uint32_t offset) override + { + if (stream() != nullptr) + { + recordBufferUpdate(*stream(), clientHandle(), data, size, offset); + } + } +}; + +// The remaining types carry the descriptor so record time validation works +// with no GPU object. + +class DeferredTexture : public LITE_RTTI_OVERRIDE(Texture, DeferredTexture), + public DeferredResource +{ +public: + DeferredTexture(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator, + const TextureDesc& desc) : + LITE_RTTI_OVERRIDE(Texture, DeferredTexture)(desc), + DeferredResource(handle, generation, stream, allocator) + {} + + // Recorded, replayed on the real texture before the passes. + void upload(const TextureDataDesc& data) override + { + if (stream() != nullptr) + { + recordTextureUpload(*stream(), clientHandle(), data); + } + } +}; + +class DeferredTextureView + : public LITE_RTTI_OVERRIDE(TextureView, DeferredTextureView), + public DeferredResource +{ +public: + DeferredTextureView(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator, + rcp<Texture> texture, + const TextureViewDesc& desc) : + LITE_RTTI_OVERRIDE(TextureView, DeferredTextureView)(std::move(texture), + desc), + DeferredResource(handle, generation, stream, allocator) + {} +}; + +class DeferredSampler : public LITE_RTTI_OVERRIDE(Sampler, DeferredSampler), + public DeferredResource +{ +public: + DeferredSampler(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator) : + DeferredResource(handle, generation, stream, allocator) + {} +}; + +class DeferredShaderModule + : public LITE_RTTI_OVERRIDE(ShaderModule, DeferredShaderModule), + public DeferredResource +{ +public: + DeferredShaderModule(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator) : + DeferredResource(handle, generation, stream, allocator) + {} +}; + +class DeferredBindGroupLayout + : public LITE_RTTI_OVERRIDE(BindGroupLayout, DeferredBindGroupLayout), + public DeferredResource +{ +public: + DeferredBindGroupLayout(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator) : + DeferredResource(handle, generation, stream, allocator) + {} +}; + +class DeferredPipeline : public LITE_RTTI_OVERRIDE(Pipeline, DeferredPipeline), + public DeferredResource +{ +public: + DeferredPipeline(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator, + const PipelineDesc& desc) : + LITE_RTTI_OVERRIDE(Pipeline, DeferredPipeline)(desc), + DeferredResource(handle, generation, stream, allocator) + {} +}; + +class DeferredBindGroup + : public LITE_RTTI_OVERRIDE(BindGroup, DeferredBindGroup), + public DeferredResource +{ +public: + DeferredBindGroup(ResourceHandle handle, + uint32_t generation, + OreCommandBuffer* stream, + rive::IdAllocator<ResourceHandle>* allocator) : + DeferredResource(handle, generation, stream, allocator) + {} +}; + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_handle.hpp b/renderer/include/rive/renderer/ore/cmd/ore_handle.hpp new file mode 100644 index 0000000..dd28c4e --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_handle.hpp
@@ -0,0 +1,23 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/handle_flags.hpp" +#include <cstdint> + +// Shared handle type for the deferred command streams. A render command +// handle indexes the OreCommandBuffer keep alive table; a resource command +// handle is the client id of a deferred created resource. +namespace rive::ore::cmd +{ +using ResourceHandle = uint32_t; +constexpr ResourceHandle kInvalidHandle = ~0u; + +// Flags a resource that already exists at record time and so is not in the +// creation stream; the low bits index a side table of the real objects. Test +// after kInvalidHandle, which also has the high bit set. +constexpr ResourceHandle kRealResourceFlag = rive::cmd::kHandleForeignFlag; +constexpr ResourceHandle kRealResourceMask = rive::cmd::kHandleForeignMask; +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_make_recording.hpp b/renderer/include/rive/renderer/ore/cmd/ore_make_recording.hpp new file mode 100644 index 0000000..44470c5 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_make_recording.hpp
@@ -0,0 +1,302 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_commands.hpp" +#include "rive/renderer/ore/ore_types.hpp" +#include <vector> + +// Records the make* family into the ordered Ore command stream. The caller +// owns id allocation so id and generation ride explicitly in the header. +// Replay lives in ore_make_replay.hpp. +namespace rive::ore::cmd +{ + +inline void recordMakeBuffer(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const BufferDesc& desc) +{ + BufferDescPOD pod{}; + pod.usage = desc.usage; + pod.size = desc.size; + pod.immutable = desc.immutable; + pod.data = + cb.appendBlobRef(desc.data, desc.data ? desc.size : 0, !desc.data); + pod.label = cb.appendStringRef(desc.label); + cb.append(CommandType::makeBuffer, MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordMakeTexture(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const TextureDesc& desc) +{ + TextureDescPOD pod{}; + pod.width = desc.width; + pod.height = desc.height; + pod.depthOrArrayLayers = desc.depthOrArrayLayers; + pod.format = desc.format; + pod.type = desc.type; + pod.renderTarget = desc.renderTarget; + pod.numMipmaps = desc.numMipmaps; + pod.sampleCount = desc.sampleCount; + pod.label = cb.appendStringRef(desc.label); + cb.append(CommandType::makeTexture, MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordMakeSampler(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const SamplerDesc& desc) +{ + SamplerDescPOD pod{}; + pod.minFilter = desc.minFilter; + pod.magFilter = desc.magFilter; + pod.mipmapFilter = desc.mipmapFilter; + pod.wrapU = desc.wrapU; + pod.wrapV = desc.wrapV; + pod.wrapW = desc.wrapW; + pod.compare = desc.compare; + pod.minLod = desc.minLod; + pod.maxLod = desc.maxLod; + pod.maxAnisotropy = desc.maxAnisotropy; + pod.label = cb.appendStringRef(desc.label); + cb.append(CommandType::makeSampler, MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordMakeShaderModule(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const ShaderModuleDesc& desc) +{ + ShaderModuleDescPOD pod{}; + pod.code = cb.appendBlobRef(desc.code, desc.codeSize, !desc.code); + pod.language = desc.language; + pod.stage = desc.stage; + pod.label = cb.appendStringRef(desc.label); + pod.hlslSource = cb.appendBlobRef(desc.hlslSource, + desc.hlslSourceSize, + !desc.hlslSource); + pod.hlslEntryPoint = cb.appendStringRef(desc.hlslEntryPoint); + pod.bindingMapBytes = cb.appendBlobRef(desc.bindingMapBytes, + desc.bindingMapSize, + !desc.bindingMapBytes); + pod.glFixupBytes = cb.appendBlobRef(desc.glFixupBytes, + desc.glFixupSize, + !desc.glFixupBytes); + pod.shaderAssetId = desc.shaderAssetId; + cb.append(CommandType::makeShaderModule, MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordMakeBindGroupLayout(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const BindGroupLayoutDesc& desc) +{ + BindGroupLayoutDescPOD pod{}; + pod.groupIndex = desc.groupIndex; + pod.entryCount = desc.entryCount; + pod.entries = + cb.appendBlobRef(desc.entries, + desc.entryCount * sizeof(BindGroupLayoutEntry), + desc.entries == nullptr); + pod.label = cb.appendStringRef(desc.label); + cb.append(CommandType::makeBindGroupLayout, + MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordMakeTextureView(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const TextureViewDesc& desc, + ResourceHandle textureHandle) +{ + TextureViewDescPOD pod{}; + pod.texture = textureHandle; + pod.dimension = desc.dimension; + pod.aspect = desc.aspect; + pod.baseMipLevel = desc.baseMipLevel; + pod.mipCount = desc.mipCount; + pod.baseLayer = desc.baseLayer; + pod.layerCount = desc.layerCount; + cb.append(CommandType::makeTextureView, MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordMakePipeline(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const PipelineDesc& desc, + ResourceHandle vertexModule, + ResourceHandle fragmentModule, + Span<const ResourceHandle> bindGroupLayouts) +{ + std::vector<VertexBufferLayoutPOD> vbPods(desc.vertexBufferCount); + for (uint32_t i = 0; i < desc.vertexBufferCount; ++i) + { + const VertexBufferLayout& vb = desc.vertexBuffers[i]; + vbPods[i].stride = vb.stride; + vbPods[i].stepMode = vb.stepMode; + vbPods[i].attributeCount = vb.attributeCount; + vbPods[i].attributes = + cb.appendBlobRef(vb.attributes, + vb.attributeCount * sizeof(VertexAttribute), + vb.attributes == nullptr); + } + + PipelineDescPOD pod{}; + pod.vertexModule = vertexModule; + pod.vertexEntryPoint = cb.appendStringRef(desc.vertexEntryPoint); + pod.fragmentModule = fragmentModule; + pod.fragmentEntryPoint = cb.appendStringRef(desc.fragmentEntryPoint); + pod.vertexBufferCount = desc.vertexBufferCount; + pod.vertexBuffers = cb.appendBlobRef( + vbPods.data(), + static_cast<uint32_t>(vbPods.size() * sizeof(VertexBufferLayoutPOD)), + vbPods.empty()); + pod.topology = desc.topology; + pod.indexFormat = desc.indexFormat; + pod.cullMode = desc.cullMode; + pod.winding = desc.winding; + for (uint32_t i = 0; i < 4; ++i) + { + pod.colorTargets[i] = desc.colorTargets[i]; + } + pod.colorCount = desc.colorCount; + pod.depthStencil = desc.depthStencil; + pod.stencilFront = desc.stencilFront; + pod.stencilBack = desc.stencilBack; + pod.stencilReadMask = desc.stencilReadMask; + pod.stencilWriteMask = desc.stencilWriteMask; + pod.sampleCount = desc.sampleCount; + pod.bindGroupLayoutCount = static_cast<uint32_t>(bindGroupLayouts.size()); + pod.bindGroupLayouts = cb.appendBlobRef( + bindGroupLayouts.data(), + static_cast<uint32_t>(bindGroupLayouts.size() * sizeof(ResourceHandle)), + bindGroupLayouts.empty()); + pod.label = cb.appendStringRef(desc.label); + cb.append(CommandType::makePipeline, MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordMakeBindGroup(OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + const BindGroupDesc& desc, + ResourceHandle layout, + Span<const ResourceHandle> uboBuffers, + Span<const ResourceHandle> texViews, + Span<const ResourceHandle> sampSamplers) +{ + std::vector<UBOEntryPOD> ubos(desc.uboCount); + for (uint32_t i = 0; i < desc.uboCount; ++i) + { + ubos[i].slot = desc.ubos[i].slot; + ubos[i].buffer = i < uboBuffers.size() ? uboBuffers[i] : kInvalidHandle; + ubos[i].offset = desc.ubos[i].offset; + ubos[i].size = desc.ubos[i].size; + } + std::vector<TexEntryPOD> texs(desc.textureCount); + for (uint32_t i = 0; i < desc.textureCount; ++i) + { + texs[i].slot = desc.textures[i].slot; + texs[i].view = i < texViews.size() ? texViews[i] : kInvalidHandle; + } + std::vector<SampEntryPOD> samps(desc.samplerCount); + for (uint32_t i = 0; i < desc.samplerCount; ++i) + { + samps[i].slot = desc.samplers[i].slot; + samps[i].sampler = + i < sampSamplers.size() ? sampSamplers[i] : kInvalidHandle; + } + + BindGroupDescPOD pod{}; + pod.layout = layout; + pod.uboCount = desc.uboCount; + pod.ubos = cb.appendBlobRef( + ubos.data(), + static_cast<uint32_t>(ubos.size() * sizeof(UBOEntryPOD)), + ubos.empty()); + pod.textureCount = desc.textureCount; + pod.textures = cb.appendBlobRef( + texs.data(), + static_cast<uint32_t>(texs.size() * sizeof(TexEntryPOD)), + texs.empty()); + pod.samplerCount = desc.samplerCount; + pod.samplers = cb.appendBlobRef( + samps.data(), + static_cast<uint32_t>(samps.size() * sizeof(SampEntryPOD)), + samps.empty()); + pod.label = cb.appendStringRef(desc.label); + cb.append(CommandType::makeBindGroup, MakeResourcePOD{id, generation}); + cb.appendPayload(pod); +} + +inline void recordBufferUpdate(OreCommandBuffer& cb, + ResourceHandle handle, + const void* data, + uint32_t size, + uint32_t offset) +{ + BufferUpdatePOD pod{}; + pod.handle = handle; + pod.offset = offset; + pod.bytes = cb.appendBlobRef(data, size, data == nullptr); + cb.append(CommandType::bufferUpdate, pod); +} + +inline void recordTextureUpload(OreCommandBuffer& cb, + ResourceHandle handle, + const TextureDataDesc& desc) +{ + uint32_t rows = desc.rowsPerImage ? desc.rowsPerImage : desc.height; + uint32_t size = desc.bytesPerRow * rows; + TextureUploadPOD pod{}; + pod.handle = handle; + pod.bytesPerRow = desc.bytesPerRow; + pod.rowsPerImage = desc.rowsPerImage; + pod.mipLevel = desc.mipLevel; + pod.layer = desc.layer; + pod.x = desc.x; + pod.y = desc.y; + pod.z = desc.z; + pod.width = desc.width; + pod.height = desc.height; + pod.depth = desc.depth; + pod.bytes = + cb.appendBlobRef(desc.data, size, desc.data == nullptr || size == 0); + cb.append(CommandType::textureUpload, pod); +} + +inline void recordWrapCanvasView( + OreCommandBuffer& cb, + ResourceHandle id, + uint32_t generation, + uint32_t canvasId, + WrapCanvasViewMode mode = WrapCanvasViewMode::colorView) +{ + cb.append(CommandType::wrapCanvasView, + WrapCanvasViewPOD{id, + generation, + canvasId, + static_cast<uint32_t>(mode)}); +} + +inline void recordDestroyResource(OreCommandBuffer& cb, + ResourceHandle handle, + uint32_t generation) +{ + cb.append(CommandType::destroyResource, + DestroyResourcePOD{handle, generation}); +} + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp b/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp new file mode 100644 index 0000000..a1f29c7 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp
@@ -0,0 +1,559 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_commands.hpp" +#include "rive/renderer/ore/ore_context.hpp" +#include "rive/renderer/rive_render_image.hpp" +#include "rive/renderer/ore/ore_buffer.hpp" +#include "rive/renderer/ore/ore_texture.hpp" +#include <cassert> +#include <functional> +#include <vector> + +// Consumer half of the ordered ore stream: the resident table plus the +// lifecycle replay arms. Pass arms live in ore_replay.hpp. The table is dense +// and bounded by the live high water mark; a stale destroy for a recycled id +// is dropped by the generation check. +namespace rive::ore::cmd +{ + +// Slot type tag: one allocator serves every resource type, so an id race +// under churn can put a differently typed object in a referenced slot. A +// kind checked lookup turns that into an unresolved dependency instead of a +// garbage cast. +enum class OreKind : uint8_t +{ + none, + buffer, + texture, + textureView, + sampler, + shaderModule, + bindGroupLayout, + pipeline, + bindGroup, +}; + +struct OreResident +{ + std::vector<rcp<rive::gpu::GPUResource>> objects; + std::vector<uint32_t> generations; + std::vector<OreKind> kinds; + + void set(ResourceHandle id, + rcp<rive::gpu::GPUResource> obj, + uint32_t generation, + OreKind kind) + { + if (id > objects.size()) + { + // The producer mints ids sequentially, so a fresh id may only + // append. Anything further ahead is a corrupt stream. + assert(false); + return; + } + if (id == objects.size()) + { + objects.push_back(std::move(obj)); + generations.push_back(generation); + kinds.push_back(kind); + return; + } + objects[id] = std::move(obj); + generations[id] = generation; + kinds[id] = kind; + } + void destroy(ResourceHandle id, uint32_t generation) + { + if (id < objects.size() && generations[id] == generation) + { + objects[id] = nullptr; + } + } + rive::gpu::GPUResource* get(ResourceHandle id) const + { + return id < objects.size() ? objects[id].get() : nullptr; + } + rive::gpu::GPUResource* getAs(ResourceHandle id, OreKind kind) const + { + return id < objects.size() && kinds[id] == kind ? objects[id].get() + : nullptr; + } + // Make replay skips live slots, so repeated replays do not recompile. + bool alive(ResourceHandle id, uint32_t generation) const + { + return id < objects.size() && objects[id] != nullptr && + generations[id] == generation; + } +}; + +// A flagged id is an already real resource; any other id indexes the resident +// table. +using OreHandleResolve = std::function<rive::gpu::GPUResource*(ResourceHandle)>; + +// Kind checked resolve used by makes and passes; real flagged ids stay +// unchecked since the real side table is typed by construction. +using OreKindResolve = + std::function<rive::gpu::GPUResource*(ResourceHandle, OreKind)>; + +// Resolves a shared canvas id to its real RenderCanvas at replay. +using OreCanvasResolve = std::function<rive::gpu::RenderCanvas*(uint32_t)>; + +// Resolves a 2D image id to its resident RenderImage at replay. +using OreImageResolve = std::function<rive::RenderImage*(uint32_t)>; + +// Resolve a stream reference: a real flagged id hits the caller's side table, +// a bare id the resident table. +inline rive::gpu::GPUResource* resolveOre(const OreResident& session, + const OreHandleResolve& real, + ResourceHandle h, + OreKind kind) +{ + if (h == kInvalidHandle) + { + return nullptr; + } + if (h & kRealResourceFlag) + { + return real ? real(h) : nullptr; + } + return session.getAs(h, kind); +} + +// Returns false for a pass command so the caller's pass switch takes it. +inline bool replayOreLifecycle(Context& ctx, + OreResident& table, + CommandType type, + OreCommandReader& reader, + const OreKindResolve& resolve, + const OreCanvasResolve& canvasAt = {}, + const OreImageResolve& imageAt = {}) +{ + auto blob = [&](BlobRef ref) -> Span<const uint8_t> { + return ref.absent() ? Span<const uint8_t>(nullptr, 0) + : reader.blobAt(ref.offset, ref.size); + }; + auto cstr = [&](BlobRef ref) -> const char* { + return ref.absent() ? nullptr + : reinterpret_cast<const char*>(blob(ref).data()); + }; + auto bytesOf = [&](BlobRef ref) -> const void* { + return ref.absent() ? nullptr : blob(ref).data(); + }; + + // A dependency that should resolve but comes back null was churned under a + // straddling frame. Skip the make since a null crashes some backends. + bool unresolvedDep = false; + auto req = [&](ResourceHandle h, OreKind kind) -> rive::gpu::GPUResource* { + auto* r = resolve(h, kind); + if (r == nullptr && h != kInvalidHandle) + { + unresolvedDep = true; + } + return r; + }; + // The null slot keeps the dense table aligned with minted ids; skipping + // the set would discard every later make behind the hole. + auto skipUnresolvedMake = + [&](ResourceHandle id, uint32_t generation, const char* what) -> bool { + RIVE_WARN_THROTTLED("rive ore replay: skip make %s id=%u gen=%u " + "(unresolved dep, churn)\n", + what, + id, + generation); + table.set(id, nullptr, generation, OreKind::none); + return true; // empty slot, downstream draws drop + }; + + switch (type) + { + case CommandType::makeBuffer: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<BufferDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + BufferDesc d{}; + d.usage = pod.usage; + d.size = pod.size; + d.immutable = pod.immutable; + d.data = bytesOf(pod.data); + d.label = cstr(pod.label); + table.set(m.id, ctx.makeBuffer(d), m.generation, OreKind::buffer); + return true; + } + case CommandType::makeTexture: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<TextureDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + TextureDesc d{}; + d.width = pod.width; + d.height = pod.height; + d.depthOrArrayLayers = pod.depthOrArrayLayers; + d.format = pod.format; + d.type = pod.type; + d.renderTarget = pod.renderTarget; + d.numMipmaps = pod.numMipmaps; + d.sampleCount = pod.sampleCount; + d.label = cstr(pod.label); + table.set(m.id, ctx.makeTexture(d), m.generation, OreKind::texture); + return true; + } + case CommandType::makeSampler: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<SamplerDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + SamplerDesc d{}; + d.minFilter = pod.minFilter; + d.magFilter = pod.magFilter; + d.mipmapFilter = pod.mipmapFilter; + d.wrapU = pod.wrapU; + d.wrapV = pod.wrapV; + d.wrapW = pod.wrapW; + d.compare = pod.compare; + d.minLod = pod.minLod; + d.maxLod = pod.maxLod; + d.maxAnisotropy = pod.maxAnisotropy; + d.label = cstr(pod.label); + table.set(m.id, ctx.makeSampler(d), m.generation, OreKind::sampler); + return true; + } + case CommandType::makeShaderModule: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<ShaderModuleDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + ShaderModuleDesc d{}; + d.code = bytesOf(pod.code); + d.codeSize = static_cast<uint32_t>(blob(pod.code).size()); + d.language = pod.language; + d.stage = pod.stage; + d.label = cstr(pod.label); + d.hlslSource = cstr(pod.hlslSource); + d.hlslSourceSize = + static_cast<uint32_t>(blob(pod.hlslSource).size()); + d.hlslEntryPoint = cstr(pod.hlslEntryPoint); + d.bindingMapBytes = + static_cast<const uint8_t*>(bytesOf(pod.bindingMapBytes)); + d.bindingMapSize = + static_cast<uint32_t>(blob(pod.bindingMapBytes).size()); + d.glFixupBytes = + static_cast<const uint8_t*>(bytesOf(pod.glFixupBytes)); + d.glFixupSize = + static_cast<uint32_t>(blob(pod.glFixupBytes).size()); + d.shaderAssetId = pod.shaderAssetId; + table.set(m.id, + ctx.makeShaderModule(d), + m.generation, + OreKind::shaderModule); + return true; + } + case CommandType::makeBindGroupLayout: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<BindGroupLayoutDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + BindGroupLayoutDesc d{}; + d.groupIndex = pod.groupIndex; + d.entryCount = pod.entryCount; + d.entries = reinterpret_cast<const BindGroupLayoutEntry*>( + bytesOf(pod.entries)); + d.label = cstr(pod.label); + table.set(m.id, + ctx.makeBindGroupLayout(d), + m.generation, + OreKind::bindGroupLayout); + return true; + } + case CommandType::makeTextureView: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<TextureViewDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + TextureViewDesc d{}; + d.texture = + static_cast<Texture*>(req(pod.texture, OreKind::texture)); + d.dimension = pod.dimension; + d.aspect = pod.aspect; + d.baseMipLevel = pod.baseMipLevel; + d.mipCount = pod.mipCount; + d.baseLayer = pod.baseLayer; + d.layerCount = pod.layerCount; + if (unresolvedDep) + return skipUnresolvedMake(m.id, m.generation, "textureView"); + table.set(m.id, + ctx.makeTextureView(d), + m.generation, + OreKind::textureView); + return true; + } + case CommandType::makePipeline: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<PipelineDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + Span<const uint8_t> vbBlob = blob(pod.vertexBuffers); + const VertexBufferLayoutPOD* vbPods = + reinterpret_cast<const VertexBufferLayoutPOD*>(vbBlob.data()); + std::vector<VertexBufferLayout> vbs(pod.vertexBufferCount); + for (uint32_t i = 0; i < pod.vertexBufferCount; ++i) + { + vbs[i].stride = vbPods[i].stride; + vbs[i].stepMode = vbPods[i].stepMode; + vbs[i].attributeCount = vbPods[i].attributeCount; + vbs[i].attributes = reinterpret_cast<const VertexAttribute*>( + blob(vbPods[i].attributes).data()); + } + Span<const uint8_t> bglBlob = blob(pod.bindGroupLayouts); + const ResourceHandle* bglHandles = + reinterpret_cast<const ResourceHandle*>(bglBlob.data()); + std::vector<BindGroupLayout*> bgls(pod.bindGroupLayoutCount); + for (uint32_t i = 0; i < pod.bindGroupLayoutCount; ++i) + { + bgls[i] = static_cast<BindGroupLayout*>( + req(bglHandles[i], OreKind::bindGroupLayout)); + } + + PipelineDesc d{}; + d.vertexModule = static_cast<ShaderModule*>( + req(pod.vertexModule, OreKind::shaderModule)); + d.vertexEntryPoint = cstr(pod.vertexEntryPoint); + d.fragmentModule = static_cast<ShaderModule*>( + req(pod.fragmentModule, OreKind::shaderModule)); + d.fragmentEntryPoint = cstr(pod.fragmentEntryPoint); + d.vertexBuffers = vbs.empty() ? nullptr : vbs.data(); + d.vertexBufferCount = pod.vertexBufferCount; + d.topology = pod.topology; + d.indexFormat = pod.indexFormat; + d.cullMode = pod.cullMode; + d.winding = pod.winding; + for (uint32_t i = 0; i < 4; ++i) + { + d.colorTargets[i] = pod.colorTargets[i]; + } + d.colorCount = pod.colorCount; + d.depthStencil = pod.depthStencil; + d.stencilFront = pod.stencilFront; + d.stencilBack = pod.stencilBack; + d.stencilReadMask = pod.stencilReadMask; + d.stencilWriteMask = pod.stencilWriteMask; + d.sampleCount = pod.sampleCount; + d.bindGroupLayouts = bgls.empty() ? nullptr : bgls.data(); + d.bindGroupLayoutCount = pod.bindGroupLayoutCount; + d.label = cstr(pod.label); + if (unresolvedDep) + return skipUnresolvedMake(m.id, m.generation, "pipeline"); + std::string pipelineError; + auto realPipeline = ctx.makePipeline(d, &pipelineError); + if (realPipeline == nullptr) + { + RIVE_WARN_THROTTLED( + "rive ore replay: makePipeline id=%u gen=%u failed: %s\n", + m.id, + m.generation, + pipelineError.c_str()); + } + table.set(m.id, + std::move(realPipeline), + m.generation, + OreKind::pipeline); + return true; + } + case CommandType::makeBindGroup: + { + auto m = reader.read<MakeResourcePOD>(); + auto pod = reader.read<BindGroupDescPOD>(); + if (table.alive(m.id, m.generation)) + { + return true; + } + const UBOEntryPOD* uboPods = + reinterpret_cast<const UBOEntryPOD*>(blob(pod.ubos).data()); + std::vector<BindGroupDesc::UBOEntry> ubos(pod.uboCount); + for (uint32_t i = 0; i < pod.uboCount; ++i) + { + ubos[i].slot = uboPods[i].slot; + ubos[i].buffer = static_cast<Buffer*>( + req(uboPods[i].buffer, OreKind::buffer)); + ubos[i].offset = uboPods[i].offset; + ubos[i].size = uboPods[i].size; + } + const TexEntryPOD* texPods = + reinterpret_cast<const TexEntryPOD*>(blob(pod.textures).data()); + std::vector<BindGroupDesc::TexEntry> texs(pod.textureCount); + for (uint32_t i = 0; i < pod.textureCount; ++i) + { + texs[i].slot = texPods[i].slot; + texs[i].view = static_cast<TextureView*>( + req(texPods[i].view, OreKind::textureView)); + } + const SampEntryPOD* sampPods = + reinterpret_cast<const SampEntryPOD*>( + blob(pod.samplers).data()); + std::vector<BindGroupDesc::SampEntry> samps(pod.samplerCount); + for (uint32_t i = 0; i < pod.samplerCount; ++i) + { + samps[i].slot = sampPods[i].slot; + samps[i].sampler = static_cast<Sampler*>( + req(sampPods[i].sampler, OreKind::sampler)); + } + + BindGroupDesc d{}; + d.layout = static_cast<BindGroupLayout*>( + req(pod.layout, OreKind::bindGroupLayout)); + d.ubos = ubos.empty() ? nullptr : ubos.data(); + d.uboCount = pod.uboCount; + d.textures = texs.empty() ? nullptr : texs.data(); + d.textureCount = pod.textureCount; + d.samplers = samps.empty() ? nullptr : samps.data(); + d.samplerCount = pod.samplerCount; + d.label = cstr(pod.label); + if (unresolvedDep) + return skipUnresolvedMake(m.id, m.generation, "bindGroup"); + auto realBindGroup = ctx.makeBindGroup(d); + if (realBindGroup == nullptr) + { + RIVE_WARN_THROTTLED( + "rive ore replay: makeBindGroup id=%u gen=%u returned " + "null\n", + m.id, + m.generation); + } + table.set(m.id, + std::move(realBindGroup), + m.generation, + OreKind::bindGroup); + return true; + } + case CommandType::bufferUpdate: + { + auto pod = reader.read<BufferUpdatePOD>(); + Span<const uint8_t> b = blob(pod.bytes); + if (auto* buf = static_cast<Buffer*>(table.get(pod.handle))) + { + buf->update(b.data(), + static_cast<uint32_t>(b.size()), + pod.offset); + } + return true; + } + case CommandType::textureUpload: + { + auto pod = reader.read<TextureUploadPOD>(); + Span<const uint8_t> b = blob(pod.bytes); + TextureDataDesc d{}; + d.data = b.empty() ? nullptr : b.data(); + d.bytesPerRow = pod.bytesPerRow; + d.rowsPerImage = pod.rowsPerImage; + d.mipLevel = pod.mipLevel; + d.layer = pod.layer; + d.x = pod.x; + d.y = pod.y; + d.z = pod.z; + d.width = pod.width; + d.height = pod.height; + d.depth = pod.depth; + if (auto* tex = static_cast<Texture*>(table.get(pod.handle))) + { + tex->upload(d); + } + return true; + } + case CommandType::wrapCanvasView: + { + // The consumer performs the real wrap reserved at record time. + auto pod = reader.read<WrapCanvasViewPOD>(); + if (table.alive(pod.id, pod.generation)) + { + return true; + } + if (pod.mode == + static_cast<uint32_t>(WrapCanvasViewMode::imageView)) + { + // A null image, still decoding or churned, leaves the slot + // empty so downstream draws drop instead of binding a null. + rive::RenderImage* image = + imageAt ? imageAt(pod.canvasId) : nullptr; + auto* riveImage = + image ? lite_rtti_cast<rive::RiveRenderImage*>(image) + : nullptr; + rive::gpu::Texture* tex = + riveImage ? riveImage->getTexture() : nullptr; + rcp<TextureView> wrapped; + if (tex != nullptr) + { + wrapped = ctx.wrapRiveTexture(tex, + image->width(), + image->height()); + } + else + { + skipUnresolvedMake(pod.id, pod.generation, "wrapImageView"); + } + table.set(pod.id, + std::move(wrapped), + pod.generation, + OreKind::textureView); + return true; + } + rive::gpu::RenderCanvas* canvas = + canvasAt ? canvasAt(pod.canvasId) : nullptr; + assert(canvas != nullptr); + rcp<TextureView> wrapped; + if (canvas != nullptr) + { + wrapped = pod.mode == static_cast<uint32_t>( + WrapCanvasViewMode::sampleView) + ? ctx.wrapCanvasSampleView(canvas) + : ctx.wrapCanvasTexture(canvas); + } + table.set(pod.id, + std::move(wrapped), + pod.generation, + OreKind::textureView); + return true; + } + case CommandType::destroyResource: + { + auto pod = reader.read<DestroyResourcePOD>(); + table.destroy(pod.handle, pod.generation); + return true; + } + default: + return false; // a pass command + } +} + +// Consumes one command's payload without executing it, for tooling. +inline void skipOreCommand(CommandType type, OreCommandReader& reader) +{ + reader.skip(orePayloadSizeOf(type)); +} + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_render_pass_recording.hpp b/renderer/include/rive/renderer/ore/cmd/ore_render_pass_recording.hpp new file mode 100644 index 0000000..35ff3ef --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_render_pass_recording.hpp
@@ -0,0 +1,196 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_resource.hpp" +#include "rive/renderer/ore/ore_context.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include "utils/lite_rtti.hpp" + +// Deferred mode ore::RenderPass. Each virtual runs the base class validation +// then appends the matching command to an OreCommandBuffer for later replay. +// Validation stays on the recording thread and failing commands are not +// appended, so replay only ever sees structurally valid streams. +namespace rive::ore::cmd +{ + +class RenderPassRecording : public RenderPass +{ +public: + // Mirrors a backend beginRenderPass so the validators see attachments. + RenderPassRecording(Context* context, + OreCommandBuffer* cmd, + const RenderPassDesc& desc) : + RenderPass(context), m_cmd(cmd) + { + populateAttachmentMetadata(desc); + + BeginRenderPassCmd begin{}; + begin.colorCount = desc.colorCount; + for (uint32_t i = 0; i < desc.colorCount && i < 4; ++i) + { + const ColorAttachment& src = desc.colorAttachments[i]; + ColorAttachmentPOD& dst = begin.colors[i]; + dst.view = idOf(src.view); + dst.resolveTarget = idOf(src.resolveTarget); + dst.loadOp = src.loadOp; + dst.storeOp = src.storeOp; + dst.clearR = src.clearColor.r; + dst.clearG = src.clearColor.g; + dst.clearB = src.clearColor.b; + dst.clearA = src.clearColor.a; + } + const DepthStencilAttachment& ds = desc.depthStencil; + begin.depthStencil.view = idOf(ds.view); + begin.depthStencil.depthLoadOp = ds.depthLoadOp; + begin.depthStencil.depthStoreOp = ds.depthStoreOp; + begin.depthStencil.depthClearValue = ds.depthClearValue; + begin.depthStencil.stencilLoadOp = ds.stencilLoadOp; + begin.depthStencil.stencilStoreOp = ds.stencilStoreOp; + begin.depthStencil.stencilClearValue = ds.stencilClearValue; + m_cmd->append(CommandType::beginRenderPass, begin); + } + + void setPipeline(Pipeline* pipeline) override + { + if (!checkPipelineCompat(pipeline)) + { + return; + } + m_cmd->append(CommandType::setPipeline, SetPipelineCmd{idOf(pipeline)}); + } + + void setVertexBuffer(uint32_t slot, + Buffer* buffer, + uint32_t offset = 0) override + { + m_cmd->append(CommandType::setVertexBuffer, + SetVertexBufferCmd{slot, idOf(buffer), offset}); + } + + void setIndexBuffer(Buffer* buffer, + IndexFormat format, + uint32_t offset = 0) override + { + m_cmd->append(CommandType::setIndexBuffer, + SetIndexBufferCmd{idOf(buffer), format, offset}); + } + + void setBindGroup(uint32_t groupIndex, + BindGroup* bg, + const uint32_t* dynamicOffsets = nullptr, + uint32_t dynamicOffsetCount = 0) override + { + // Hold a strong reference so GC cannot free the group before replay. + if (groupIndex < kMaxBindGroups) + { + m_boundGroups[groupIndex] = ref_rcp(bg); + } + + SetBindGroupCmd c{}; + c.groupIndex = groupIndex; + c.bindGroup = idOf(bg); + c.dynamicOffsetCount = dynamicOffsetCount; + c.dynamicOffsetStart = + dynamicOffsetCount > 0 + ? m_cmd->appendBlob(dynamicOffsets, + dynamicOffsetCount * sizeof(uint32_t)) + : 0; + m_cmd->append(CommandType::setBindGroup, c); + } + + void setViewport(float x, + float y, + float width, + float height, + float minDepth = 0.0f, + float maxDepth = 1.0f) override + { + m_cmd->append(CommandType::setViewport, + SetViewportCmd{x, y, width, height, minDepth, maxDepth}); + } + + void setScissorRect(uint32_t x, + uint32_t y, + uint32_t width, + uint32_t height) override + { + m_cmd->append(CommandType::setScissorRect, + SetScissorRectCmd{x, y, width, height}); + } + + void setStencilReference(uint32_t ref) override + { + m_cmd->append(CommandType::setStencilReference, + SetStencilReferenceCmd{ref}); + } + + void setBlendColor(float r, float g, float b, float a) override + { + m_cmd->append(CommandType::setBlendColor, SetBlendColorCmd{r, g, b, a}); + } + + void draw(uint32_t vertexCount, + uint32_t instanceCount = 1, + uint32_t firstVertex = 0, + uint32_t firstInstance = 0) override + { + m_cmd->append( + CommandType::draw, + DrawCmd{vertexCount, instanceCount, firstVertex, firstInstance}); + } + + void drawIndexed(uint32_t indexCount, + uint32_t instanceCount = 1, + uint32_t firstIndex = 0, + int32_t baseVertex = 0, + uint32_t firstInstance = 0) override + { + m_cmd->append(CommandType::drawIndexed, + DrawIndexedCmd{indexCount, + instanceCount, + firstIndex, + baseVertex, + firstInstance}); + } + + void finish() override + { + if (m_finished) + { + return; + } + m_cmd->appendOpcode(CommandType::finish); + m_finished = true; + for (uint32_t i = 0; i < kMaxBindGroups; ++i) + { + m_boundGroups[i] = nullptr; + } + } + +private: + // A deferred object self reports its creation id; a real resource falls + // back to the buffer's keep alive capture. + template <typename DeferredT, typename T> ResourceHandle idOfAs(T* r) + { + if (auto* d = lite_rtti_cast<DeferredT*>(r)) + { + return d->clientHandle(); + } + return m_cmd->capture(r); + } + ResourceHandle idOf(Buffer* b) { return idOfAs<DeferredBuffer>(b); } + ResourceHandle idOf(Pipeline* p) { return idOfAs<DeferredPipeline>(p); } + ResourceHandle idOf(TextureView* v) + { + return idOfAs<DeferredTextureView>(v); + } + ResourceHandle idOf(BindGroup* g) { return idOfAs<DeferredBindGroup>(g); } + + OreCommandBuffer* m_cmd; +}; + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_replay.hpp b/renderer/include/rive/renderer/ore/cmd/ore_replay.hpp new file mode 100644 index 0000000..694015e --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_replay.hpp
@@ -0,0 +1,340 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_make_replay.hpp" +#include "rive/renderer/ore/ore_context.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include <cassert> +#include <functional> +#include <memory> + +// Replays a recorded ore command stream against a live Context. Replay drives +// the same virtuals as immediate mode, so one implementation covers every +// backend and the pixels match immediate mode by construction. +namespace rive::ore::cmd +{ + +// Maps a captured resource to the object replay should use. Identity when +// passes captured real resources; deferred objects remap to the real object. +using ResourceRemap = + std::function<rive::gpu::GPUResource*(rive::gpu::GPUResource*)>; + +// Resolves a render command handle to the object replay should use, so the +// deferred objects recorded against can be discarded after recording. +using HandleResolver = std::function<rive::gpu::GPUResource*(ResourceHandle)>; + +// Returns false for a lifecycle opcode. dropDraws poisons the open pass when +// a handle fails to resolve so its draws drop instead of using garbage. +inline bool replayPassCommand(Context& ctx, + std::unique_ptr<RenderPass>& pass, + bool& dropDraws, + CommandType type, + OreCommandReader& reader, + const OreKindResolve& resolve) +{ + auto churned = [&](const char* what, ResourceHandle h) { + dropDraws = true; + RIVE_WARN_THROTTLED("rive ore replay: %s handle %u churned, dropping " + "pass draws\n", + what, + h); + }; + switch (type) + { + case CommandType::beginRenderPass: + { + auto c = reader.read<BeginRenderPassCmd>(); + RenderPassDesc desc{}; + desc.colorCount = c.colorCount; + for (uint32_t i = 0; i < c.colorCount && i < 4; ++i) + { + const ColorAttachmentPOD& src = c.colors[i]; + ColorAttachment& dst = desc.colorAttachments[i]; + dst.view = static_cast<TextureView*>( + resolve(src.view, OreKind::textureView)); + dst.resolveTarget = static_cast<TextureView*>( + resolve(src.resolveTarget, OreKind::textureView)); + dst.loadOp = src.loadOp; + dst.storeOp = src.storeOp; + dst.clearColor = {src.clearR, + src.clearG, + src.clearB, + src.clearA}; + } + const DepthStencilAttachmentPOD& ds = c.depthStencil; + desc.depthStencil.view = static_cast<TextureView*>( + resolve(ds.view, OreKind::textureView)); + desc.depthStencil.depthLoadOp = ds.depthLoadOp; + desc.depthStencil.depthStoreOp = ds.depthStoreOp; + desc.depthStencil.depthClearValue = ds.depthClearValue; + desc.depthStencil.stencilLoadOp = ds.stencilLoadOp; + desc.depthStencil.stencilStoreOp = ds.stencilStoreOp; + desc.depthStencil.stencilClearValue = ds.stencilClearValue; + dropDraws = false; + for (uint32_t i = 0; i < c.colorCount && i < 4; ++i) + { + if (desc.colorAttachments[i].view == nullptr && + c.colors[i].view != kInvalidHandle) + { + churned("render pass view", c.colors[i].view); + break; + } + } + if (dropDraws) + { + break; // pass stays null, its commands no-op + } + pass = ctx.beginRenderPass(desc); + break; + } + case CommandType::setPipeline: + { + auto c = reader.read<SetPipelineCmd>(); + auto* pipeline = + static_cast<Pipeline*>(resolve(c.pipeline, OreKind::pipeline)); + if (pipeline == nullptr && c.pipeline != kInvalidHandle) + { + churned("pipeline", c.pipeline); + } + else if (pass) + { + pass->setPipeline(pipeline); + } + break; + } + case CommandType::setVertexBuffer: + { + auto c = reader.read<SetVertexBufferCmd>(); + auto* buffer = + static_cast<Buffer*>(resolve(c.buffer, OreKind::buffer)); + if (buffer == nullptr && c.buffer != kInvalidHandle) + { + churned("vertex buffer", c.buffer); + } + else if (pass) + { + pass->setVertexBuffer(c.slot, buffer, c.offset); + } + break; + } + case CommandType::setIndexBuffer: + { + auto c = reader.read<SetIndexBufferCmd>(); + auto* buffer = + static_cast<Buffer*>(resolve(c.buffer, OreKind::buffer)); + if (buffer == nullptr && c.buffer != kInvalidHandle) + { + churned("index buffer", c.buffer); + } + else if (pass) + { + pass->setIndexBuffer(buffer, c.format, c.offset); + } + break; + } + case CommandType::setBindGroup: + { + auto c = reader.read<SetBindGroupCmd>(); + const uint32_t* dynamicOffsets = nullptr; + if (c.dynamicOffsetCount > 0) + { + Span<const uint8_t> blob = + reader.blobAt(c.dynamicOffsetStart, + c.dynamicOffsetCount * sizeof(uint32_t)); + dynamicOffsets = reinterpret_cast<const uint32_t*>(blob.data()); + } + auto* bindGroup = static_cast<BindGroup*>( + resolve(c.bindGroup, OreKind::bindGroup)); + if (bindGroup == nullptr && c.bindGroup != kInvalidHandle) + { + churned("bind group", c.bindGroup); + } + else if (pass) + { + pass->setBindGroup(c.groupIndex, + bindGroup, + dynamicOffsets, + c.dynamicOffsetCount); + } + break; + } + case CommandType::setViewport: + { + auto c = reader.read<SetViewportCmd>(); + if (pass) + { + pass->setViewport(c.x, + c.y, + c.width, + c.height, + c.minDepth, + c.maxDepth); + } + break; + } + case CommandType::setScissorRect: + { + auto c = reader.read<SetScissorRectCmd>(); + if (pass) + { + pass->setScissorRect(c.x, c.y, c.width, c.height); + } + break; + } + case CommandType::setStencilReference: + { + auto c = reader.read<SetStencilReferenceCmd>(); + if (pass) + { + pass->setStencilReference(c.ref); + } + break; + } + case CommandType::setBlendColor: + { + auto c = reader.read<SetBlendColorCmd>(); + if (pass) + { + pass->setBlendColor(c.r, c.g, c.b, c.a); + } + break; + } + case CommandType::draw: + { + auto c = reader.read<DrawCmd>(); + if (pass && !dropDraws) + { + pass->draw(c.vertexCount, + c.instanceCount, + c.firstVertex, + c.firstInstance); + } + break; + } + case CommandType::drawIndexed: + { + auto c = reader.read<DrawIndexedCmd>(); + if (pass && !dropDraws) + { + pass->drawIndexed(c.indexCount, + c.instanceCount, + c.firstIndex, + c.baseVertex, + c.firstInstance); + } + break; + } + case CommandType::finish: + { + if (pass) + { + pass->finish(); + pass.reset(); + } + break; + } + default: + return false; // lifecycle opcode, handled elsewhere + } + return true; +} + +// Replays a passes only stream, resolving every handle via resolveHandle. +inline void replayCommandBufferResolved(Context& ctx, + const OreCommandBuffer& cmd, + const HandleResolver& resolveHandle) +{ + // Handles here index the buffer's own typed keep alive table, so the + // kind is already guaranteed and only the null check applies. + auto resolve = [&](ResourceHandle h, OreKind) -> rive::gpu::GPUResource* { + return h == kInvalidHandle ? nullptr : resolveHandle(h); + }; + OreCommandReader reader(cmd.commandBytes(), cmd.blobBytes()); + std::unique_ptr<RenderPass> pass; + bool dropDraws = false; + CommandType type; + while (reader.next(type)) + { + if (!replayPassCommand(ctx, pass, dropDraws, type, reader, resolve)) + { + // The payload was not consumed, so later reads would desync. + assert(false); + break; + } + } +} + +// Replays the single ordered stream. Flagged ids resolve via real; id reuse +// is safe because destroys are consumed in stream order. +inline void replayOreStream(Context& ctx, + Span<const uint8_t> commands, + Span<const uint8_t> blobs, + OreResident& table, + const OreHandleResolve& real = nullptr, + const OreCanvasResolve& canvasAt = {}, + const OreImageResolve& imageAt = {}) +{ + auto resolve = [&](ResourceHandle h, + OreKind kind) -> rive::gpu::GPUResource* { + return resolveOre(table, real, h, kind); + }; + OreCommandReader reader(commands, blobs); + std::unique_ptr<RenderPass> pass; + bool dropDraws = false; + CommandType type; + while (reader.next(type)) + { + if (!replayOreLifecycle(ctx, + table, + type, + reader, + resolve, + canvasAt, + imageAt) && + !replayPassCommand(ctx, pass, dropDraws, type, reader, resolve)) + { + // The payload was not consumed, so later reads would desync. + assert(false); + break; + } + } +} + +inline void replayOreStream(Context& ctx, + const OreCommandBuffer& cmd, + OreResident& table, + const OreHandleResolve& real = nullptr, + const OreCanvasResolve& canvasAt = {}, + const OreImageResolve& imageAt = {}) +{ + replayOreStream(ctx, + cmd.commandBytes(), + cmd.blobBytes(), + table, + real, + canvasAt, + imageAt); +} + +// Standalone buffer: handles index the buffer's own keep alive table, then +// run through an optional remap. +inline void replayCommandBuffer(Context& ctx, + const OreCommandBuffer& cmd, + const ResourceRemap& remap = nullptr) +{ + const std::vector<rcp<rive::gpu::GPUResource>>& keep = cmd.keepAlive(); + replayCommandBufferResolved( + ctx, + cmd, + [&keep, &remap](ResourceHandle h) -> rive::gpu::GPUResource* { + rive::gpu::GPUResource* r = + h < keep.size() ? keep[h].get() : nullptr; + return (remap && r) ? remap(r) : r; + }); +} + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_resource_commands.hpp b/renderer/include/rive/renderer/ore/cmd/ore_resource_commands.hpp new file mode 100644 index 0000000..5bfb051 --- /dev/null +++ b/renderer/include/rive/renderer/ore/cmd/ore_resource_commands.hpp
@@ -0,0 +1,172 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/ore/ore_types.hpp" +#include "rive/renderer/ore/cmd/ore_handle.hpp" +#include <cstdint> + +// Recorded form of the ore Context make* calls. References between resources +// are recorded as client handles, so recording order is a valid creation order +// by construction. Variable length data lives in a companion blob arena. +namespace rive::ore::cmd +{ + +// size == kAbsent means the source field was null, distinct from an empty but +// present payload. Offsets are 64 bit so never-reset streams outlive 4 GiB of +// cumulative appends. +struct BlobRef +{ + uint64_t offset; + uint32_t size; + uint32_t pad; // explicit so the wire layout carries no implicit padding + static constexpr uint32_t kAbsent = ~0u; + bool absent() const { return size == kAbsent; } +}; +constexpr BlobRef kNoBlob = {0, BlobRef::kAbsent, 0}; + +struct BufferDescPOD +{ + BufferUsage usage; + uint32_t size; + bool immutable; + BlobRef data; // initial contents, or absent + BlobRef label; // null-terminated, or absent +}; + +struct TextureDescPOD +{ + uint32_t width; + uint32_t height; + uint32_t depthOrArrayLayers; + TextureFormat format; + TextureType type; + bool renderTarget; + uint32_t numMipmaps; + uint32_t sampleCount; + BlobRef label; +}; + +struct SamplerDescPOD +{ + Filter minFilter; + Filter magFilter; + Filter mipmapFilter; + WrapMode wrapU; + WrapMode wrapV; + WrapMode wrapW; + CompareFunction compare; + float minLod; + float maxLod; + uint32_t maxAnisotropy; + BlobRef label; +}; + +// The size fields are recovered from each blob's size at replay. +struct ShaderModuleDescPOD +{ + BlobRef code; + ShaderLanguage language; + ShaderStage stage; + BlobRef label; + BlobRef hlslSource; // D3D11 runtime-compile source, or absent + BlobRef hlslEntryPoint; // null-terminated, or absent + BlobRef bindingMapBytes; + BlobRef glFixupBytes; + uint32_t shaderAssetId; +}; + +struct BindGroupLayoutDescPOD +{ + uint32_t groupIndex; + BlobRef entries; // entryCount * sizeof(BindGroupLayoutEntry), or absent + uint32_t entryCount; + BlobRef label; +}; + +struct TextureViewDescPOD +{ + ResourceHandle texture; + TextureViewDimension dimension; + TextureAspect aspect; + uint32_t baseMipLevel; + uint32_t mipCount; + uint32_t baseLayer; + uint32_t layerCount; +}; + +struct VertexBufferLayoutPOD +{ + uint32_t stride; + VertexStepMode stepMode; + uint32_t attributeCount; + BlobRef attributes; // attributeCount * sizeof(VertexAttribute) +}; + +// Module and layout references are client handles. +struct PipelineDescPOD +{ + ResourceHandle vertexModule; + BlobRef vertexEntryPoint; // null-terminated string + ResourceHandle fragmentModule; + BlobRef fragmentEntryPoint; + + BlobRef vertexBuffers; // vertexBufferCount * sizeof(VertexBufferLayoutPOD) + uint32_t vertexBufferCount; + + PrimitiveTopology topology; + IndexFormat indexFormat; + CullMode cullMode; + FaceWinding winding; + + ColorTargetState colorTargets[4]; + uint32_t colorCount; + + DepthStencilState depthStencil; + StencilFaceState stencilFront; + StencilFaceState stencilBack; + uint8_t stencilReadMask; + uint8_t stencilWriteMask; + + uint32_t sampleCount; + + BlobRef bindGroupLayouts; // bindGroupLayoutCount * sizeof(ResourceHandle) + uint32_t bindGroupLayoutCount; + + BlobRef label; +}; + +// BindGroupDesc entries with resource pointers replaced by client handles. +struct UBOEntryPOD +{ + uint32_t slot; + ResourceHandle buffer; + uint32_t offset; + uint32_t size; +}; +struct TexEntryPOD +{ + uint32_t slot; + ResourceHandle view; +}; +struct SampEntryPOD +{ + uint32_t slot; + ResourceHandle sampler; +}; + +struct BindGroupDescPOD +{ + ResourceHandle layout; + BlobRef ubos; // uboCount * sizeof(UBOEntryPOD) + uint32_t uboCount; + BlobRef textures; // textureCount * sizeof(TexEntryPOD) + uint32_t textureCount; + BlobRef samplers; // samplerCount * sizeof(SampEntryPOD) + uint32_t samplerCount; + BlobRef label; +}; + +} // namespace rive::ore::cmd
diff --git a/renderer/include/rive/renderer/ore/ore_context.hpp b/renderer/include/rive/renderer/ore/ore_context.hpp index b3eed5c..5b8fd72 100644 --- a/renderer/include/rive/renderer/ore/ore_context.hpp +++ b/renderer/include/rive/renderer/ore/ore_context.hpp
@@ -6,10 +6,13 @@ #include <cstdarg> #include <cstdio> +#include <cstdlib> #include <memory> #include <string> #include <vector> #include "rive/refcnt.hpp" +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/rive_render_image.hpp" #include "rive/renderer/ore/ore_types.hpp" #include "rive/renderer/ore/ore_buffer.hpp" #include "rive/renderer/ore/ore_texture.hpp" @@ -18,6 +21,12 @@ #include "rive/renderer/ore/ore_pipeline.hpp" #include "rive/renderer/ore/ore_bind_group.hpp" #include "rive/renderer/ore/ore_render_pass.hpp" +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" + +namespace rive +{ +class RenderImage; +} namespace rive::gpu { @@ -107,6 +116,52 @@ virtual void waitForGPU() = 0; virtual rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) = 0; + + // Color format makeRenderCanvas allocates. A backend that allocates + // anything other than rgba8 must override or canvas draws fail the + // pipeline compat check at replay. + // + // An override also has to reach the recorder before any pass records + // against a canvas, which a host that binds its real context late cannot + // do. DeferredOreContext tripwires on a late bind whose override + // disagrees with the default it already recorded. + virtual TextureFormat canvasTargetFormat() const + { + return TextureFormat::rgba8unorm; + } + + // True only for the deferred recording context. Callers that would touch + // the driver immediately take a recording path instead. + virtual bool isRecording() const { return false; } + + // Recording form of Image:view on a canvas backed image. Only the + // deferred context implements this, gated by isRecording. + virtual rcp<TextureView> recordWrapCanvasImage(RenderImage* /*image*/, + uint32_t /*width*/, + uint32_t /*height*/) + { + return nullptr; + } + + // Recording form of Image:view on a decoded image. Only the deferred + // context implements this. + virtual rcp<TextureView> recordWrapImageView(uint32_t /*imageId*/, + uint32_t /*width*/, + uint32_t /*height*/) + { + return nullptr; + } + + // Sampling wrap of a 2D canvas for Image:view. GL overrides to insert its + // Y flip mirror. + virtual rcp<TextureView> wrapCanvasSampleView(gpu::RenderCanvas* canvas) + { + auto* image = canvas->renderImage(); + return wrapRiveTexture(image->getTexture(), + canvas->width(), + canvas->height()); + } + virtual rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex, uint32_t width, uint32_t height) = 0; @@ -114,11 +169,20 @@ // Which RSTB shader variant this backend consumes. virtual ShaderTarget shaderTarget() const = 0; + // Whether features() describes a device that will actually run the work. + // Only a recording context with no replay device bound yet answers false: + // its m_features still holds Features' own initializers, which read as a + // real low end device and are indistinguishable from one. A caller that + // would branch on a capability must ask this first, because a recorded + // branch replays on the device it guessed wrong about. + virtual bool featuresKnown() const { return true; } + // ------------------------------------------------------------------------ // Cross-cutting state and accessors. Non-virtual; live on this base // because they are uniform across backends. // ------------------------------------------------------------------------ + // Only meaningful when featuresKnown(). const Features& features() const { return m_features; } // Active render pass tracking — used by Lua bindings to auto-finish @@ -126,6 +190,19 @@ RenderPass* activeRenderPass() const { return m_activeRenderPass; } void setActiveRenderPass(RenderPass* pass) { m_activeRenderPass = pass; } + // When on, the render pass entry point records and replays instead of + // issuing immediately. Seeded from the RIVE_ORE_DEFER env var. + bool deferredRecording() const { return m_deferredRecording; } + void setDeferredRecording(bool deferred) { m_deferredRecording = deferred; } + + // True when the backend replays the accumulated pendingFrame at endFrame. + // False falls back to per pass inline replay, which is byte identical. + virtual bool usesDeferredFrameReplay() const { return false; } + + // Per frame stream deferred passes record into; the backend drains it at + // endFrame. + cmd::OreCommandBuffer& pendingFrame() { return m_pendingFrame; } + // Called at the top of every backend's beginRenderPass(). If a prior pass // is still open, finish it — matches the Lua binding's auto-finish // contract and means backends that enforce one-encoder-at-a-time (Metal, @@ -171,7 +248,11 @@ protected: Context(rcp<rive::gpu::GPUResourceManager> manager) : m_manager(std::move(manager)) - {} + { +#ifndef NO_GETENV + m_deferredRecording = getenv("RIVE_ORE_DEFER") != nullptr; +#endif + } Features m_features; @@ -183,6 +264,10 @@ // Last validation error from setPipeline() / setBindGroup(). std::string m_lastError; + bool m_deferredRecording = false; + + cmd::OreCommandBuffer m_pendingFrame; + // Back-pointer to the GPUResourceManager for GPUResource lifecycle // this is actually owned by the render context impl that created the given // ore context but its held here for convenience of ore resources that need
diff --git a/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp b/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp index b4103dd..4419f40 100644 --- a/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp +++ b/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp
@@ -94,6 +94,21 @@ uint32_t w, uint32_t h); + // Recording a copy directly would land on the host command list even while + // it is closed between frames, so uploads stage here until one is live. + struct D3D12PendingTextureUpload + { + rcp<Texture> texture; + rcp<Buffer> staging; + D3D12_PLACED_SUBRESOURCE_FOOTPRINT footprint; + UINT subresource; + UINT dstX; + UINT dstY; + UINT dstZ; + }; + void d3d12QueuePendingTextureUpload(D3D12PendingTextureUpload pending); + void d3d12FlushPendingTextureUploads(); + Microsoft::WRL::ComPtr<ID3D12Device> m_d3dDevice; // Microsoft::WRL::ComPtr<ID3D12CommandQueue> m_d3dQueue; // Active command list for the current frame. Points at m_d3dOwnedCmdList @@ -101,6 +116,8 @@ // mode. All recording code reads through this pointer, so the two modes // share one code path. ID3D12GraphicsCommandList* m_d3dCmdList = nullptr; + // Drained at the next beginFrame or beginRenderPass. + std::vector<D3D12PendingTextureUpload> m_d3dPendingUploads; // resource-creation time. Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> m_d3dCpuSrvHeap; Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> m_d3dCpuRtvHeap;
diff --git a/renderer/include/rive/renderer/ore/ore_context_gl.hpp b/renderer/include/rive/renderer/ore/ore_context_gl.hpp index 905c72b..06a68d3 100644 --- a/renderer/include/rive/renderer/ore/ore_context_gl.hpp +++ b/renderer/include/rive/renderer/ore/ore_context_gl.hpp
@@ -6,6 +6,8 @@ #include "rive/renderer/ore/ore_context.hpp" +#include <unordered_map> + // Note: load_gles_extensions.hpp (glad) is intentionally NOT included here. // The private GL state only needs 'int' (GLint is always int), keeping this // header free of glad so it can be included without glad in the search path. @@ -20,7 +22,9 @@ class ContextGL : public Context { public: - static std::unique_ptr<ContextGL> Make(); + // renderContextImpl is the RenderContextGLImpl that owns this context's + // canvases, needed for the Y flip import mirror. Null on standalone GMs. + static std::unique_ptr<ContextGL> Make(void* renderContextImpl = nullptr); ~ContextGL() override; @@ -43,7 +47,13 @@ void endFrame() override; void waitForGPU() override; + // GL stays on per pass inline replay: it has no command buffer so no + // natural frame boundary drain, and the ore frame is not reliably driven. + // TODO: whole frame GL deferral. + bool usesDeferredFrameReplay() const override { return false; } + rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override; + rcp<TextureView> wrapCanvasSampleView(gpu::RenderCanvas* canvas) override; rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex, uint32_t width, uint32_t height) override; @@ -58,7 +68,12 @@ friend class BindGroupGL; friend class TextureGL; - ContextGL() : Context(nullptr) {} + explicit ContextGL(void* renderContextImpl) : + Context(nullptr), m_renderContextImpl(renderContextImpl) + {} + + // Borrowed RenderContextGLImpl, void* to avoid the header dependency. + void* m_renderContextImpl = nullptr; // GL state tracking for save/restore at frame boundaries. // NOTE: GL_ELEMENT_ARRAY_BUFFER is intentionally excluded — it is VAO
diff --git a/renderer/include/rive/renderer/ore/ore_context_metal.hpp b/renderer/include/rive/renderer/ore/ore_context_metal.hpp index e23a199..6e169ab 100644 --- a/renderer/include/rive/renderer/ore/ore_context_metal.hpp +++ b/renderer/include/rive/renderer/ore/ore_context_metal.hpp
@@ -45,6 +45,9 @@ void endFrame() override; void waitForGPU() override; + // Metal drains the recorded frame in endFrame before commit. + bool usesDeferredFrameReplay() const override { return true; } + rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override; rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex, uint32_t width,
diff --git a/renderer/include/rive/renderer/ore/ore_pipeline.hpp b/renderer/include/rive/renderer/ore/ore_pipeline.hpp index 80eec86..e6b8633 100644 --- a/renderer/include/rive/renderer/ore/ore_pipeline.hpp +++ b/renderer/include/rive/renderer/ore/ore_pipeline.hpp
@@ -68,6 +68,7 @@ { m_layouts[i] = ref_rcp(desc.bindGroupLayouts[i]); } + ownVertexLayout(); } Pipeline(rcp<rive::gpu::GPUResourceManager> manager, @@ -96,9 +97,51 @@ { m_layouts[i] = ref_rcp(desc.bindGroupLayouts[i]); } + ownVertexLayout(); } PipelineDesc m_desc; + +private: + // The desc's vertex layout points into caller memory the deferred replay + // frees right after makePipeline, so deep copy it into owned storage. + std::vector<VertexBufferLayout> m_ownedVertexBuffers; + std::vector<VertexAttribute> m_ownedAttributes; + + void ownVertexLayout() + { + if (m_desc.vertexBufferCount == 0 || m_desc.vertexBuffers == nullptr) + { + m_desc.vertexBuffers = nullptr; + m_desc.vertexBufferCount = 0; + return; + } + // Reserve up front so the vector never reallocates while we repoint + // into it. + size_t total = 0; + for (uint32_t i = 0; i < m_desc.vertexBufferCount; ++i) + { + total += m_desc.vertexBuffers[i].attributeCount; + } + m_ownedAttributes.reserve(total); + m_ownedVertexBuffers.assign(m_desc.vertexBuffers, + m_desc.vertexBuffers + + m_desc.vertexBufferCount); + for (uint32_t i = 0; i < m_desc.vertexBufferCount; ++i) + { + const VertexBufferLayout& src = m_desc.vertexBuffers[i]; + size_t start = m_ownedAttributes.size(); + if (src.attributes != nullptr && src.attributeCount > 0) + { + m_ownedAttributes.insert(m_ownedAttributes.end(), + src.attributes, + src.attributes + src.attributeCount); + } + m_ownedVertexBuffers[i].attributes = + src.attributeCount > 0 ? &m_ownedAttributes[start] : nullptr; + } + m_desc.vertexBuffers = m_ownedVertexBuffers.data(); + } }; } // namespace rive::ore
diff --git a/renderer/include/rive/renderer/render_context.hpp b/renderer/include/rive/renderer/render_context.hpp index de79b0b..7f4dbe7 100644 --- a/renderer/include/rive/renderer/render_context.hpp +++ b/renderer/include/rive/renderer/render_context.hpp
@@ -305,10 +305,18 @@ // Creates a RenderCanvas: a GPU texture usable as both a render target // (for rendering into) and a render image (for compositing into draws). rcp<RenderCanvas> makeRenderCanvas(uint32_t width, uint32_t height); + + // Like makeRenderCanvas, but on GL the deferred replay worker lazily + // allocates the texture on its own context instead of this one. + rcp<RenderCanvas> makeDeferredRenderCanvas(uint32_t width, uint32_t height); + rive::ore::Context* ore() override; rive::ore::Context* getOreContext() { return ore(); } #endif + // Importing straight through a render context routes scripts to it. + Factory* renderContext() override { return this; } + private: friend class Draw; friend class PathDraw;
diff --git a/renderer/include/rive/renderer/render_context_impl.hpp b/renderer/include/rive/renderer/render_context_impl.hpp index b89946c..803d87f 100644 --- a/renderer/include/rive/renderer/render_context_impl.hpp +++ b/renderer/include/rive/renderer/render_context_impl.hpp
@@ -81,6 +81,15 @@ return nullptr; } + // Deferred allocation is only distinct on GL, where the replay worker + // must own the texture on its own context. Everywhere else the device + // is shared and eager allocation is correct. + virtual rcp<RenderCanvas> makeDeferredRenderCanvas(uint32_t width, + uint32_t height) + { + return makeRenderCanvas(width, height); + } + // If canvas is enabled then the backend Impl MUST implement this. virtual std::unique_ptr<rive::ore::Context> makeOreContext() = 0; #endif
diff --git a/renderer/path_fiddle/fiddle_context_d3d12.cpp b/renderer/path_fiddle/fiddle_context_d3d12.cpp index 2a426a0..5948a7a 100644 --- a/renderer/path_fiddle/fiddle_context_d3d12.cpp +++ b/renderer/path_fiddle/fiddle_context_d3d12.cpp
@@ -25,6 +25,27 @@ using namespace rive; using namespace rive::gpu; +// Set once a callback prints each debug-layer message as it posts, so a +// mid-frame break surfaces its reason before the process dies. +static bool s_d3d12MessageCallbackActive = false; + +#ifdef DEBUG +static void __stdcall D3D12MessageCallback(D3D12_MESSAGE_CATEGORY category, + D3D12_MESSAGE_SEVERITY severity, + D3D12_MESSAGE_ID id, + LPCSTR description, + void*) +{ + fprintf(stderr, + "[D3D12 debug @ live] sev=%d id=%d cat=%d: %s\n", + static_cast<int>(severity), + static_cast<int>(id), + static_cast<int>(category), + description); + fflush(stderr); +} +#endif + // Drain the D3D12 debug-layer info queue and print any stored messages to // stderr. Modeled on Dawn's AppendDebugLayerMessagesToError // (dawn/src/dawn/native/d3d12/DeviceD3D12.cpp). VERIFY_OK aborts on the bare @@ -41,6 +62,12 @@ { return; } + // The live callback already printed each message as it posted. + if (s_d3d12MessageCallbackActive) + { + infoQueue->ClearStoredMessages(); + return; + } UINT64 numMessages = infoQueue->GetNumStoredMessages(); for (UINT64 i = 0; i < numMessages; ++i) { @@ -780,6 +807,26 @@ return nullptr; } +#ifdef DEBUG + // Print debug-layer messages as they post; the frame-boundary drain misses + // a mid-frame break that kills the process first. + { + ComPtr<ID3D12InfoQueue1> infoQueue1; + if (SUCCEEDED(device->QueryInterface(IID_PPV_ARGS(&infoQueue1)))) + { + DWORD cookie = 0; + if (SUCCEEDED(infoQueue1->RegisterMessageCallback( + &D3D12MessageCallback, + D3D12_MESSAGE_CALLBACK_FLAG_NONE, + nullptr, + &cookie))) + { + s_d3d12MessageCallbackActive = true; + } + } + } +#endif + if (fiddleOptions.disableRasterOrdering) { contextOptions.disableRasterizerOrderedViews = true;
diff --git a/renderer/src/deferred_cmd.cpp b/renderer/src/deferred_cmd.cpp new file mode 100644 index 0000000..53bc2d6 --- /dev/null +++ b/renderer/src/deferred_cmd.cpp
@@ -0,0 +1,675 @@ +/* + * Copyright 2026 Rive + */ + +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/render_replay.hpp" + +// Out of line home for the large deferred stream bodies, so every TU that +// touches the deferred headers does not recompile them. +namespace rive::cmd +{ + +bool sniffImageSize(Span<const uint8_t> b, int& w, int& h) +{ + const uint8_t* d = b.data(); + size_t n = b.size(); + auto be32 = [&](size_t i) { + return (d[i] << 24) | (d[i + 1] << 16) | (d[i + 2] << 8) | d[i + 3]; + }; + // PNG: 8-byte sig, then IHDR with width/height as big-endian u32 at 16/20. + if (n >= 24 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G') + { + w = static_cast<int>(be32(16)); + h = static_cast<int>(be32(20)); + return true; + } + // GIF: "GIF87a"/"GIF89a", then width/height little-endian u16 at 6/8. + if (n >= 10 && d[0] == 'G' && d[1] == 'I' && d[2] == 'F') + { + w = d[6] | (d[7] << 8); + h = d[8] | (d[9] << 8); + return true; + } + // WEBP: RIFF....WEBP; VP8 / VP8L / VP8X carry dims at known offsets. + if (n >= 30 && d[0] == 'R' && d[1] == 'I' && d[2] == 'F' && d[3] == 'F' && + d[8] == 'W' && d[9] == 'E' && d[10] == 'B' && d[11] == 'P') + { + if (d[12] == 'V' && d[13] == 'P' && d[14] == '8' && d[15] == ' ') + { + w = (d[26] | (d[27] << 8)) & 0x3fff; + h = (d[28] | (d[29] << 8)) & 0x3fff; + return true; + } + if (d[12] == 'V' && d[13] == 'P' && d[14] == '8' && d[15] == 'L') + { + uint32_t bits = + d[21] | (d[22] << 8) | (d[23] << 16) | (d[24] << 24); + w = static_cast<int>((bits & 0x3fff) + 1); + h = static_cast<int>(((bits >> 14) & 0x3fff) + 1); + return true; + } + if (d[12] == 'V' && d[13] == 'P' && d[14] == '8' && d[15] == 'X') + { + w = (d[24] | (d[25] << 8) | (d[26] << 16)) + 1; + h = (d[27] | (d[28] << 8) | (d[29] << 16)) + 1; + return true; + } + } + // JPEG: walk markers to an SOF (0xC0..0xCF, excluding non-SOF) for dims. + if (n >= 4 && d[0] == 0xff && d[1] == 0xd8) + { + size_t i = 2; + while (i + 9 < n) + { + if (d[i] != 0xff) + { + i++; + continue; + } + uint8_t marker = d[i + 1]; + // SOF0..SOF15 carry dims; skip DHT(C4)/DAA(C8)/DAC(CC) which don't. + if (marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 && + marker != 0xc8 && marker != 0xcc) + { + h = (d[i + 5] << 8) | d[i + 6]; + w = (d[i + 7] << 8) | d[i + 8]; + return true; + } + uint32_t seg = (d[i + 2] << 8) | d[i + 3]; + i += 2 + seg; + } + } + return false; +} + +void replayRenderCommands(Factory* factory, + Renderer* renderer, + Span<const uint8_t> commands, + Span<const uint8_t> blobs, + ResourceTable& table, + const ReplayHooks& hooks) +{ + auto& paths = table.paths; + auto& paints = table.paints; + auto& shaders = table.shaders; + auto& images = table.images; + auto& buffers = table.buffers; + + auto filterAllows = [](ReplayFilter f, RenderCmd c) { + if (f == ReplayFilter::all) + { + return true; + } + switch (c) + { + case RenderCmd::save: + case RenderCmd::restore: + case RenderCmd::transform: + case RenderCmd::drawPath: + case RenderCmd::clipPath: + case RenderCmd::drawImage: + case RenderCmd::drawImageMesh: + case RenderCmd::modulateOpacity: + case RenderCmd::canvasContentBegin: + case RenderCmd::canvasContentEnd: + return f == ReplayFilter::draws; + case RenderCmd::destroyResource: + return f == ReplayFilter::destroys; + default: + return f == ReplayFilter::resources; + } + }; + + RenderCommandReader reader(commands, blobs); + auto path = [&](RenderHandle h) -> RenderPath* { return paths.get(h); }; + auto paint = [&](RenderHandle h) -> RenderPaint* { return paints.get(h); }; + auto image = [&](RenderHandle h) -> RenderImage* { return images.get(h); }; + auto sampler = [](uint8_t wx, uint8_t wy, uint8_t f) { + return ImageSampler{static_cast<ImageWrap>(wx), + static_cast<ImageWrap>(wy), + static_cast<ImageFilter>(f)}; + }; + + // Draws route into cur: the screen by default, or the active canvas + // between content brackets. Null drops the draw. + Renderer* cur = renderer; + + uint8_t type; + uint8_t prevType = 255; + size_t prevPos = 0; + while (reader.next(type)) + { + if (type > static_cast<uint8_t>(RenderCmd::lastRenderCmd)) + { + // Unknown opcode: its payload was not consumed, so every later + // read would desync. Stop. + fprintf(stderr, + "rive replay ABORT: opcode %u at byte %zu of %zu, last " + "good opcode %u at byte %zu\n", + type, + reader.position() - 1, + commands.size(), + prevType, + prevPos); + assert(false); + break; + } + prevType = type; + prevPos = reader.position() - 1; + const auto cmd = static_cast<RenderCmd>(type); + if (!filterAllows(hooks.filter, cmd)) + { + reader.skip(payloadSizeOf(cmd)); + continue; + } + switch (cmd) + { + case RenderCmd::makePath: + { + auto c = reader.read<MakePathPOD>(); + RawPath raw = rebuildRawPath( + reader.blobAt(c.blobOffset, + c.verbCount * + static_cast<uint32_t>(sizeof(PathVerb))), + reader.blobAt(c.pointsOffset, + c.pointCount * + static_cast<uint32_t>(sizeof(Vec2D)))); + paths.set( + c.id, + factory->makeRenderPath(raw, + static_cast<FillRule>(c.fillRule)), + c.generation); + if (c.id >= table.pathFillRules.size()) + { + table.pathFillRules.resize(c.id + 1); + } + table.pathFillRules[c.id] = c.fillRule; + break; + } + case RenderCmd::makeEmptyPath: + { + auto c = reader.read<MakeIdPOD>(); + paths.set(c.id, factory->makeEmptyRenderPath(), c.generation); + if (c.id >= table.pathFillRules.size()) + { + table.pathFillRules.resize(c.id + 1); + } + table.pathFillRules[c.id] = 0; + break; + } + case RenderCmd::makePaint: + { + auto c = reader.read<MakeIdPOD>(); + paints.set(c.id, factory->makeRenderPaint(), c.generation); + if (c.id >= table.paintShadows.size()) + { + table.paintShadows.resize(c.id + 1); + } + table.paintShadows[c.id] = PaintShadow{}; + break; + } + case RenderCmd::makeLinearGradient: + { + auto c = reader.read<LinearGradientPOD>(); + const ColorInt* colors = reinterpret_cast<const ColorInt*>( + reader + .blobAt(c.blobOffset, + c.count * + static_cast<uint32_t>(sizeof(ColorInt))) + .data()); + const float* stops = reinterpret_cast<const float*>( + reader + .blobAt(c.stopsOffset, + c.count * static_cast<uint32_t>(sizeof(float))) + .data()); + if (colors == nullptr || stops == nullptr) + { + break; // blob out of range (corrupt stream) + } + shaders.set(c.id, + factory->makeLinearGradient(c.sx, + c.sy, + c.ex, + c.ey, + colors, + stops, + c.count), + c.generation); + break; + } + case RenderCmd::makeRadialGradient: + { + auto c = reader.read<RadialGradientPOD>(); + const ColorInt* colors = reinterpret_cast<const ColorInt*>( + reader + .blobAt(c.blobOffset, + c.count * + static_cast<uint32_t>(sizeof(ColorInt))) + .data()); + const float* stops = reinterpret_cast<const float*>( + reader + .blobAt(c.stopsOffset, + c.count * static_cast<uint32_t>(sizeof(float))) + .data()); + if (colors == nullptr || stops == nullptr) + { + break; // blob out of range (corrupt stream) + } + shaders.set(c.id, + factory->makeRadialGradient(c.cx, + c.cy, + c.radius, + colors, + stops, + c.count), + c.generation); + break; + } + case RenderCmd::decodeImage: + { + auto c = reader.read<DecodeImagePOD>(); + images.set(c.id, + factory->decodeImage( + reader.blobAt(c.blobOffset, c.byteCount)), + c.generation); + break; + } + case RenderCmd::makeBuffer: + { + auto c = reader.read<MakeBufferPOD>(); + buffers.set(c.id, + factory->makeRenderBuffer( + static_cast<RenderBufferType>(c.bufferType), + static_cast<RenderBufferFlags>(c.flags), + c.sizeInBytes), + c.generation); + if (c.id >= table.bufferShadows.size()) + { + table.bufferShadows.resize(c.id + 1); + } + table.bufferShadows[c.id] = {static_cast<uint8_t>(c.bufferType), + static_cast<uint16_t>(c.flags), + c.sizeInBytes}; + break; + } + case RenderCmd::bufferData: + { + auto c = reader.read<BufferDataPOD>(); + Span<const uint8_t> src = reader.blobAt(c.blobOffset, c.size); + if (auto* b = buffers.get(c.buffer)) + { + if (src.size() == c.size) + { + void* dst = b->map(); + if (dst) + { + std::memcpy(dst, src.data(), c.size); + } + b->unmap(); + } + } + break; + } + case RenderCmd::destroyResource: + { + auto c = reader.read<DestroyResourcePOD>(); + table.destroy(static_cast<ResourceKind>(c.kind), + c.id, + c.generation); + break; + } + case RenderCmd::resourceNewVersion: + { + // A drawn resource was mutated again: alias the outgoing + // version for the draws that pinned it and continue on a + // fresh object carrying the shadowed state. + auto c = reader.read<ResourceVersionPOD>(); + switch (static_cast<ResourceKind>(c.kind)) + { + case ResourceKind::paint: + { + auto fresh = factory->makeRenderPaint(); + if (fresh != nullptr && + c.id < table.paintShadows.size()) + { + const PaintShadow& sh = table.paintShadows[c.id]; + fresh->style( + static_cast<RenderPaintStyle>(sh.style)); + fresh->color(sh.color); + fresh->thickness(sh.thickness); + fresh->join(static_cast<StrokeJoin>(sh.join)); + fresh->cap(static_cast<StrokeCap>(sh.cap)); + fresh->feather(sh.feather); + fresh->blendMode( + static_cast<BlendMode>(sh.blendMode)); + if (sh.shader != kInvalidRenderHandle) + { + fresh->shader(shaders.shared(sh.shader)); + } + } + paints.newVersion(c.id, c.version, std::move(fresh)); + break; + } + case ResourceKind::path: + { + // Seed from the outgoing version so a non rewind + // mutation appends onto prior geometry; a rewind bump + // clears the seed via its own recorded command. + auto fresh = factory->makeEmptyRenderPath(); + if (fresh != nullptr) + { + if (auto* outgoing = paths.get(c.id)) + { + fresh->addRenderPath(outgoing, Mat2D()); + } + if (c.id < table.pathFillRules.size()) + { + fresh->fillRule(static_cast<FillRule>( + table.pathFillRules[c.id])); + } + } + paths.newVersion(c.id, c.version, std::move(fresh)); + break; + } + case ResourceKind::buffer: + { + rcp<RenderBuffer> fresh; + if (c.id < table.bufferShadows.size()) + { + const BufferShadow& sh = table.bufferShadows[c.id]; + fresh = factory->makeRenderBuffer( + static_cast<RenderBufferType>(sh.type), + static_cast<RenderBufferFlags>(sh.flags), + sh.size); + } + buffers.newVersion(c.id, c.version, std::move(fresh)); + break; + } + default: + break; // shaders and images never mutate + } + break; + } + + case RenderCmd::pathRewind: + { + auto c = reader.read<ResIdPOD>(); + if (auto* p = path(c.id)) + { + p->rewind(); + } + break; + } + case RenderCmd::pathFillRule: + { + auto c = reader.read<PathFillRulePOD>(); + if (auto* p = path(c.path)) + { + p->fillRule(static_cast<FillRule>(c.fillRule)); + table.pathFillRules[c.path] = c.fillRule; + } + break; + } + case RenderCmd::pathAddRawPath: + { + auto c = reader.read<PathRawPOD>(); + RawPath raw = rebuildRawPath( + reader.blobAt(c.blobOffset, + c.verbCount * + static_cast<uint32_t>(sizeof(PathVerb))), + reader.blobAt(c.pointsOffset, + c.pointCount * + static_cast<uint32_t>(sizeof(Vec2D)))); + if (auto* p = path(c.path)) + { + p->addRawPath(raw); + } + break; + } + case RenderCmd::pathAddRenderPath: + { + auto c = reader.read<PathAddPathPOD>(); + RenderPath* src = paths.get(c.src); + if (auto* p = path(c.path)) + { + if (src) + p->addRenderPath( + src, + Mat2D(c.xx, c.xy, c.yx, c.yy, c.tx, c.ty)); + } + break; + } + + case RenderCmd::paintStyle: + { + auto c = reader.read<PaintU8POD>(); + if (auto* pt = paint(c.paint)) + { + pt->style(static_cast<RenderPaintStyle>(c.value)); + table.paintShadows[c.paint].style = c.value; + } + break; + } + case RenderCmd::paintColor: + { + auto c = reader.read<PaintColorPOD>(); + if (auto* pt = paint(c.paint)) + { + pt->color(c.color); + table.paintShadows[c.paint].color = c.color; + } + break; + } + case RenderCmd::paintThickness: + { + auto c = reader.read<PaintFloatPOD>(); + if (auto* pt = paint(c.paint)) + { + pt->thickness(c.value); + table.paintShadows[c.paint].thickness = c.value; + } + break; + } + case RenderCmd::paintJoin: + { + auto c = reader.read<PaintU8POD>(); + if (auto* pt = paint(c.paint)) + { + pt->join(static_cast<StrokeJoin>(c.value)); + table.paintShadows[c.paint].join = c.value; + } + break; + } + case RenderCmd::paintCap: + { + auto c = reader.read<PaintU8POD>(); + if (auto* pt = paint(c.paint)) + { + pt->cap(static_cast<StrokeCap>(c.value)); + table.paintShadows[c.paint].cap = c.value; + } + break; + } + case RenderCmd::paintFeather: + { + auto c = reader.read<PaintFloatPOD>(); + if (auto* pt = paint(c.paint)) + { + pt->feather(c.value); + table.paintShadows[c.paint].feather = c.value; + } + break; + } + case RenderCmd::paintBlendMode: + { + auto c = reader.read<PaintU8POD>(); + if (auto* pt = paint(c.paint)) + { + pt->blendMode(static_cast<BlendMode>(c.value)); + table.paintShadows[c.paint].blendMode = c.value; + } + break; + } + case RenderCmd::paintShader: + { + auto c = reader.read<PaintShaderPOD>(); + if (auto* pt = paint(c.paint)) + { + pt->shader(shaders.shared(c.shader)); + table.paintShadows[c.paint].shader = c.shader; + } + break; + } + case RenderCmd::paintInvalidateStroke: + { + auto c = reader.read<ResIdPOD>(); + if (auto* pt = paint(c.id)) + { + pt->invalidateStroke(); + } + break; + } + + case RenderCmd::save: + if (cur) + { + cur->save(); + } + break; + case RenderCmd::restore: + if (cur) + { + cur->restore(); + } + break; + case RenderCmd::transform: + { + auto c = reader.read<TransformPOD>(); + if (cur) + { + cur->transform(Mat2D(c.xx, c.xy, c.yx, c.yy, c.tx, c.ty)); + } + break; + } + case RenderCmd::drawPath: + { + auto c = reader.read<DrawPathPOD>(); + RenderPath* p = paths.get(c.path, c.pathVersion); + RenderPaint* pt = paints.get(c.paint, c.paintVersion); + if (cur && p && pt) + { + cur->drawPath(p, pt); + } + else if (cur != nullptr && hooks.stats != nullptr) + { + hooks.stats->droppedDraws = hooks.stats->droppedDraws + 1; + replay_detail::logDroppedDraw(type, c.path, c.paint); + } + break; + } + case RenderCmd::clipPath: + { + auto c = reader.read<ClipPathPOD>(); + if (cur) + { + if (auto* p = paths.get(c.path, c.version)) + cur->clipPath(p); + } + break; + } + case RenderCmd::drawImage: + { + auto c = reader.read<DrawImagePOD>(); + RenderImage* im = + (c.image & kCanvasHandleFlag) + ? (hooks.canvasImage + ? hooks.canvasImage(c.image & kCanvasHandleMask) + : nullptr) + : image(c.image); + if (cur && im) + { + cur->drawImage(im, + sampler(c.wrapX, c.wrapY, c.filter), + static_cast<BlendMode>(c.blendMode), + c.opacity); + } + else if (cur != nullptr && hooks.stats != nullptr) + { + hooks.stats->droppedDraws = hooks.stats->droppedDraws + 1; + replay_detail::logDroppedDraw(type, c.image, 0); + } + break; + } + case RenderCmd::drawImageMesh: + { + auto c = reader.read<DrawImageMeshPOD>(); + RenderImage* im = + (c.image & kCanvasHandleFlag) + ? (hooks.canvasImage + ? hooks.canvasImage(c.image & kCanvasHandleMask) + : nullptr) + : image(c.image); + rcp<RenderBuffer> vb = + buffers.shared(c.vertices, c.vertexVersion); + rcp<RenderBuffer> uv = buffers.shared(c.uvCoords, c.uvVersion); + rcp<RenderBuffer> ib = + buffers.shared(c.indices, c.indexVersion); + if (cur && im && vb && uv && ib) + { + cur->drawImageMesh(im, + sampler(c.wrapX, c.wrapY, c.filter), + vb, + uv, + ib, + c.vertexCount, + c.indexCount, + static_cast<BlendMode>(c.blendMode), + c.opacity); + } + else if (cur != nullptr && hooks.stats != nullptr) + { + hooks.stats->droppedDraws = hooks.stats->droppedDraws + 1; + replay_detail::logDroppedDraw(type, c.image, 0); + } + break; + } + case RenderCmd::modulateOpacity: + { + auto c = reader.read<OpacityPOD>(); + if (cur) + { + cur->modulateOpacity(c.opacity); + } + break; + } + + case RenderCmd::canvasContentBegin: + { + auto c = reader.read<CanvasContentPOD>(); + cur = hooks.beginCanvasContent + ? hooks.beginCanvasContent(c.canvasId & + kCanvasHandleMask, + c.clearColor) + : nullptr; + break; + } + case RenderCmd::canvasContentEnd: + { + reader.read<ResIdPOD>(); // advance past the canvas id + cur = renderer; // back to the screen; null drops draws + break; + } + } + } + if (reader.overrun()) + { + fprintf(stderr, + "rive replay ABORT: payload overrun at byte %zu of %zu\n", + reader.position(), + commands.size()); + assert(false); + } +} + +} // namespace rive::cmd
diff --git a/renderer/src/gl/gl_utils.cpp b/renderer/src/gl/gl_utils.cpp index 48a932a..9502798 100644 --- a/renderer/src/gl/gl_utils.cpp +++ b/renderer/src/gl/gl_utils.cpp
@@ -3,6 +3,7 @@ */ #include "rive/renderer/gl/gl_utils.hpp" +#include "rive/rive_types.hpp" #include "rive/shapes/paint/image_sampler.hpp" #include <stdio.h> @@ -12,6 +13,13 @@ #include "generated/shaders/glsl.glsl.hpp" +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT +#include <atomic> +#include <mutex> +#include <unordered_map> +#include <emscripten/html5.h> +#endif + #ifdef BYPASS_EMSCRIPTEN_SHADER_PARSER #include <emscripten/emscripten.h> #include <emscripten/html5.h> @@ -31,6 +39,137 @@ namespace glutils { +static void delete_name(GLObjectType type, GLuint id) +{ + switch (type) + { + case GLObjectType::buffer: + glDeleteBuffers(1, &id); + return; + case GLObjectType::texture: + glDeleteTextures(1, &id); + return; + case GLObjectType::framebuffer: + glDeleteFramebuffers(1, &id); + return; + case GLObjectType::renderbuffer: + glDeleteRenderbuffers(1, &id); + return; + case GLObjectType::vertexArray: + glDeleteVertexArrays(1, &id); + return; + case GLObjectType::shader: + glDeleteShader(id); + return; + case GLObjectType::program: + glDeleteProgram(id); + return; + } + RIVE_UNREACHABLE(); +} + +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT +namespace +{ +struct AbandonedName +{ + GLObjectType type; + GLuint id; +}; + +std::mutex g_abandonedMutex; +std::unordered_map<GLContextID, std::vector<AbandonedName>> g_abandonedNames; +std::atomic<uint32_t> g_abandonedCount{0}; +std::atomic<uint32_t> g_reclaimedCount{0}; +} // namespace + +GLContextID CurrentContextID() +{ + return static_cast<GLContextID>(emscripten_webgl_get_current_context()); +} + +static void abandon_name(GLObjectType type, GLuint id, GLContextID owner) +{ + { + std::lock_guard<std::mutex> lock(g_abandonedMutex); + g_abandonedNames[owner].push_back({type, id}); + } + g_abandonedCount.fetch_add(1, std::memory_order_relaxed); +} + +void ReclaimAbandonedNames() +{ + if (g_abandonedCount.load(std::memory_order_acquire) == + g_reclaimedCount.load(std::memory_order_relaxed)) + { + return; + } + std::vector<AbandonedName> mine; + { + std::lock_guard<std::mutex> lock(g_abandonedMutex); + auto it = g_abandonedNames.find(CurrentContextID()); + if (it == g_abandonedNames.end()) + { + return; + } + mine.swap(it->second); + g_abandonedNames.erase(it); + } + for (const AbandonedName& name : mine) + { + delete_name(name.type, name.id); + } + g_reclaimedCount.fetch_add(static_cast<uint32_t>(mine.size()), + std::memory_order_release); +} + +uint32_t AbandonedNameCount() +{ + return g_abandonedCount.load(std::memory_order_relaxed); +} + +uint32_t ReclaimedNameCount() +{ + return g_reclaimedCount.load(std::memory_order_relaxed); +} +#endif + +void GLObject::destroy(GLObjectType type) +{ + if (m_id == 0) + { + return; + } +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT + if (m_context != CurrentContextID()) + { + abandon_name(type, m_id, m_context); + m_id = 0; + return; + } +#endif + delete_name(type, m_id); + m_id = 0; +} + +void GLObject::adopt(GLObjectType type, GLObject&& rhs) +{ + destroy(type); + m_id = std::exchange(rhs.m_id, 0); +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT + m_context = rhs.m_context; +#endif +} + +void GLObject::adoptName(GLObjectType type, GLuint adoptedID) +{ + destroy(type); + m_id = adoptedID; +#ifdef RIVE_GL_NAMES_ARE_PER_CONTEXT + m_context = CurrentContextID(); +#endif +} + void CompileAndAttachShader(GLuint program, GLenum type, const char* source, @@ -242,17 +381,6 @@ #endif } -void Program::reset(GLuint adoptedProgramID) -{ - m_fragmentShader.reset(); - m_vertexShader.reset(); - if (m_id != 0) - { - glDeleteProgram(m_id); - } - m_id = adoptedProgramID; -} - void Program::compileAndAttachShader(GLuint type, const char* defines[], size_t numDefines,
diff --git a/renderer/src/gl/render_context_gl_impl.cpp b/renderer/src/gl/render_context_gl_impl.cpp index 1177ff3..017b7c4 100644 --- a/renderer/src/gl/render_context_gl_impl.cpp +++ b/renderer/src/gl/render_context_gl_impl.cpp
@@ -686,6 +686,9 @@ static_cast<uintptr_t>(static_cast<GLuint>(m_texture))); } + // Lets deferred replay back a canvas with a worker context texture. + void setGLTexture(GLuint id) { m_texture = glutils::Texture::Adopt(id); } + protected: glutils::Texture m_texture; }; @@ -714,10 +717,29 @@ { if (m_owner != nullptr) { - m_owner->unregisterCanvasTarget(m_glID); + m_owner->releaseCanvasTarget(m_glID); } } + // Deferred replay backs an id 0 canvas with a worker texture so all reads + // resolve coherently on the worker. The registry entry for that texture + // belongs to the context that allocated it, which on threaded web is the + // worker's impl and not the producer this texture was constructed with, so + // the owner moves with the backing. Otherwise the destructor unregisters + // from the producer: the worker keeps a stale entry wrapRiveTexture can + // resurrect, and the producer loses whatever it held under the same GL + // name, GL names being per context and both starting from 1. + void rebindBacking(RenderContextGLImpl* owner, GLuint id) + { + if (m_owner != nullptr && m_glID != 0) + { + m_owner->releaseCanvasTarget(m_glID); + } + setGLTexture(id); + m_owner = owner; + m_glID = id; + } + private: RenderContextGLImpl* m_owner; GLuint m_glID; @@ -875,15 +897,10 @@ } #ifdef RIVE_CANVAS -rcp<RenderCanvas> RenderContextGLImpl::makeRenderCanvas(uint32_t width, - uint32_t height) +rcp<RenderCanvas> RenderContextGLImpl::wrapCanvasBacking(uint32_t width, + uint32_t height, + GLuint tex) { - GLuint tex; - glGenTextures(1, &tex); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, tex); - glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height); - // Wrap as a CanvasSourceTextureGLImpl so the registry entry is // unregistered automatically when the source texture is destroyed. // The texture takes ownership of `tex` (RAII via glutils::Texture). @@ -900,6 +917,21 @@ auto renderTarget = make_rcp<TextureRenderTargetGL>(width, height); renderTarget->setTargetTexture(tex); + return make_rcp<RenderCanvas>(std::move(renderImage), + std::move(renderTarget)); +} + +rcp<RenderCanvas> RenderContextGLImpl::makeRenderCanvas(uint32_t width, + uint32_t height) +{ + GLuint tex; + glGenTextures(1, &tex); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, tex); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width, height); + + auto canvas = wrapCanvasBacking(width, height, tex); + // GL renders into the canvas with row 0 = visual bottom (framebuffer // bottom-up convention). Register the source GLuint with the mirror // registry so wrapRiveTexture (ore_context_gl.cpp) can detect it @@ -909,13 +941,47 @@ // See dev/ore_canvas_import_invariant.md. registerCanvasTarget(tex); - return make_rcp<RenderCanvas>(std::move(renderImage), - std::move(renderTarget)); + return canvas; +} + +rcp<RenderCanvas> RenderContextGLImpl::makeDeferredRenderCanvas(uint32_t width, + uint32_t height) +{ + // No GPU work here; the replay worker owns the canvas texture, backs it + // on first use, and registers the mirror target then. + return wrapCanvasBacking(width, height, 0); +} + +void RenderContextGLImpl::ensureDeferredCanvasBacking(gpu::RenderCanvas* canvas) +{ + // Set the texture on both the render target and image source so every + // read resolves coherently here. + auto* rt = static_cast<gpu::TextureRenderTargetGL*>(canvas->renderTarget()); + if (rt->externalTextureID() != 0) + { + return; + } + + GLuint tex; + glGenTextures(1, &tex); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, tex); + glTexStorage2D(GL_TEXTURE_2D, + 1, + GL_RGBA8, + canvas->width(), + canvas->height()); + glBindTexture(GL_TEXTURE_2D, 0); + + rt->setTargetTexture(tex); + static_cast<CanvasSourceTextureGLImpl*>(canvas->renderImage()->getTexture()) + ->rebindBacking(this, tex); + registerCanvasTarget(tex); } std::unique_ptr<rive::ore::Context> RenderContextGLImpl::makeOreContext() { - return rive::ore::ContextGL::Make(); + return rive::ore::ContextGL::Make(this); } // ──────────────────────────────────────────────────────────────────────────── @@ -971,6 +1037,36 @@ m_canvasMirrors.erase(it); } +void RenderContextGLImpl::releaseCanvasTarget(GLuint sourceTex) +{ + if (m_glContext == glutils::CurrentContextID()) + { + unregisterCanvasTarget(sourceTex); + return; + } + std::lock_guard<std::mutex> lock(m_releasedCanvasTargetMutex); + m_releasedCanvasTargets.push_back(sourceTex); + m_hasReleasedCanvasTargets.store(true, std::memory_order_release); +} + +void RenderContextGLImpl::drainReleasedCanvasTargets() +{ + if (!m_hasReleasedCanvasTargets.load(std::memory_order_acquire)) + { + return; + } + std::vector<GLuint> released; + { + std::lock_guard<std::mutex> lock(m_releasedCanvasTargetMutex); + released.swap(m_releasedCanvasTargets); + m_hasReleasedCanvasTargets.store(false, std::memory_order_release); + } + for (GLuint sourceTex : released) + { + unregisterCanvasTarget(sourceTex); + } +} + rcp<RiveRenderImage> RenderContextGLImpl::getOrCreateCanvasMirror( GLuint sourceTex, uint32_t width, @@ -2320,6 +2416,13 @@ assert(desc.interlockMode != gpu::InterlockMode::clockwiseAtomic); auto renderTarget = static_cast<RenderTargetGL*>(desc.renderTarget); + // This context is current on its own thread here, the only place names it + // owns can be deleted. +#ifdef RIVE_CANVAS + drainReleasedCanvasTargets(); +#endif + glutils::ReclaimAbandonedNames(); + // All programs use the same set of per-flush uniforms. glBindBufferRange(GL_UNIFORM_BUFFER, FLUSH_UNIFORM_BUFFER_IDX,
diff --git a/renderer/src/metal/render_context_metal_impl.mm b/renderer/src/metal/render_context_metal_impl.mm index 19b82cf..d1cae74 100644 --- a/renderer/src/metal/render_context_metal_impl.mm +++ b/renderer/src/metal/render_context_metal_impl.mm
@@ -1015,7 +1015,12 @@ std::unique_ptr<rive::ore::Context> RenderContextMetalImpl::makeOreContext() { - assert(m_commandQueue); + // A deferred session can request the ore context before the first render + // texture lazily sets the command queue, so mint one here. + if (m_commandQueue == nil) + { + m_commandQueue = [m_gpu newCommandQueue]; + } return rive::ore::ContextMetal::Make(m_gpu, m_commandQueue); } #endif
diff --git a/renderer/src/ore/d3d11/ore_context_d3d11.cpp b/renderer/src/ore/d3d11/ore_context_d3d11.cpp index 50490c8..60c12f9 100644 --- a/renderer/src/ore/d3d11/ore_context_d3d11.cpp +++ b/renderer/src/ore/d3d11/ore_context_d3d11.cpp
@@ -1401,6 +1401,22 @@ new TextureViewD3D11(std::move(texture), viewDesc)); // Borrow the existing RTV from the D3D render target (AddRefs via ComPtr). view->m_d3dRTV = d3dTarget->targetRTV(); + + // SRV so a later pass can sample the canvas after rendering into it; + // without it a bind group samples an unbound view and reads black. + D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc{}; + srvDesc.Format = d3dDesc.Format; + srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + srvDesc.Texture2D.MipLevels = 1; + srvDesc.Texture2D.MostDetailedMip = 0; + ComPtr<ID3D11Device> device; + m_d3d11Context->GetDevice(device.GetAddressOf()); + if (FAILED(device->CreateShaderResourceView( + d3dTex, + &srvDesc, + view->m_d3dSRV.ReleaseAndGetAddressOf()))) + return nullptr; + return view; }
diff --git a/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp b/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp index d17aa53..d7e43d5 100644 --- a/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp +++ b/renderer/src/ore/d3d11/ore_render_pass_d3d11.cpp
@@ -128,6 +128,13 @@ { validate(); auto* pipeline = static_cast<PipelineD3D11*>(inPipeline); + // A recompile or play stop can destroy the pipeline under a straddling + // deferred frame; drop the bind so draw skips instead of dereferencing. + if (pipeline == nullptr) + { + m_currentPipeline = nullptr; + return; + } if (!checkPipelineCompat(pipeline)) return; m_currentPipeline = ref_rcp(pipeline); @@ -155,6 +162,8 @@ { validate(); auto buffer = static_cast<BufferD3D11*>(inBuffer); + if (buffer == nullptr) // destroyed under a straddling deferred frame + return; UINT stride = (m_currentPipeline && slot < m_currentPipeline->desc().vertexBufferCount) ? m_currentPipeline->desc().vertexBuffers[slot].stride @@ -170,6 +179,8 @@ { validate(); auto buffer = static_cast<BufferD3D11*>(inBuffer); + if (buffer == nullptr) // destroyed under a straddling deferred frame + return; m_d3d11IndexFormat = oreIndexFormatToDXGI(format); m_d3d11IndexOffset = offset; m_d3d11Context->IASetIndexBuffer(buffer->m_d3d11Buffer.Get(), @@ -184,7 +195,8 @@ { validate(); auto bg = static_cast<BindGroupD3D11*>(inBg); - assert(bg != nullptr); + if (bg == nullptr) // destroyed under a straddling deferred frame + return; // Hold a strong reference so the BindGroup stays alive until finish(). m_boundGroups[groupIndex] = ref_rcp(bg); @@ -363,6 +375,8 @@ uint32_t firstInstance) { validate(); + if (m_currentPipeline == nullptr) // dropped under a straddling frame + return; if (instanceCount > 1 || firstInstance != 0) { m_d3d11Context->DrawInstanced(vertexCount, @@ -383,6 +397,8 @@ uint32_t firstInstance) { validate(); + if (m_currentPipeline == nullptr) // dropped under a straddling frame + return; if (instanceCount > 1 || firstInstance != 0 || baseVertex != 0) { m_d3d11Context->DrawIndexedInstanced(indexCount,
diff --git a/renderer/src/ore/d3d12/ore_context_d3d12.cpp b/renderer/src/ore/d3d12/ore_context_d3d12.cpp index a340a2e..deaf5ce 100644 --- a/renderer/src/ore/d3d12/ore_context_d3d12.cpp +++ b/renderer/src/ore/d3d12/ore_context_d3d12.cpp
@@ -584,6 +584,9 @@ ID3D12DescriptorHeap* heaps[] = {m_d3dGpuSrvHeap.Get(), m_d3dGpuSamplerHeap.Get()}; m_d3dCmdList->SetDescriptorHeaps(2, heaps); + + // Record uploads staged while no frame was open onto this frame's list. + d3d12FlushPendingTextureUploads(); #endif } @@ -591,6 +594,76 @@ void ContextD3D12::endFrame() {} +#if defined(ORE_BACKEND_D3D12) +void ContextD3D12::d3d12QueuePendingTextureUpload( + D3D12PendingTextureUpload pending) +{ + m_d3dPendingUploads.push_back(std::move(pending)); +} + +void ContextD3D12::d3d12FlushPendingTextureUploads() +{ + if (m_d3dPendingUploads.empty()) + return; + // Only reachable with a live list: beginFrame and beginRenderPass drain. + assert(m_d3dCmdList != nullptr); + + for (auto& pu : m_d3dPendingUploads) + { + auto* tex = lite_rtti_cast<TextureD3D12*>(pu.texture.get()); + auto* staging = lite_rtti_cast<BufferD3D12*>(pu.staging.get()); + if (tex == nullptr || staging == nullptr || + tex->m_d3dTexture == nullptr) + continue; + + if (tex->m_d3dCurrentState != D3D12_RESOURCE_STATE_COPY_DEST) + { + D3D12_RESOURCE_BARRIER barrier = {}; + barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + barrier.Transition.pResource = tex->m_d3dTexture.Get(); + barrier.Transition.StateBefore = tex->m_d3dCurrentState; + barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_COPY_DEST; + barrier.Transition.Subresource = + D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + m_d3dCmdList->ResourceBarrier(1, &barrier); + tex->m_d3dCurrentState = D3D12_RESOURCE_STATE_COPY_DEST; + } + + D3D12_TEXTURE_COPY_LOCATION dstLoc = {}; + dstLoc.pResource = tex->m_d3dTexture.Get(); + dstLoc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; + dstLoc.SubresourceIndex = pu.subresource; + + D3D12_TEXTURE_COPY_LOCATION srcLoc = {}; + srcLoc.pResource = staging->m_d3dBuffer.Get(); + srcLoc.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT; + srcLoc.PlacedFootprint = pu.footprint; + + m_d3dCmdList->CopyTextureRegion(&dstLoc, + pu.dstX, + pu.dstY, + pu.dstZ, + &srcLoc, + nullptr); + + // Leave it sample-ready so render passes need no explicit barrier. + D3D12_RESOURCE_BARRIER barrier = {}; + barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + barrier.Transition.pResource = tex->m_d3dTexture.Get(); + barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST; + barrier.Transition.StateAfter = + D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; + barrier.Transition.Subresource = + D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + m_d3dCmdList->ResourceBarrier(1, &barrier); + tex->m_d3dCurrentState = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; + } + + // Purgatory keeps the staging buffers alive until safeFrameNumber. + m_d3dPendingUploads.clear(); +} +#endif + // ============================================================================ // d3d12FlushUploads + GPU-visible heap allocation helpers (called by // RenderPass and d3d12* helpers) @@ -1551,6 +1624,9 @@ std::string* outError) { #if defined(ORE_BACKEND_D3D12) + // Drain uploads staged mid-frame before the pass reads the textures. + d3d12FlushPendingTextureUploads(); + std::unique_ptr<RenderPassD3D12> pass(new RenderPassD3D12(this)); pass->m_d3dCmdList = m_d3dCmdList; pass->m_d3dDevice = m_d3dDevice.Get(); @@ -1801,6 +1877,28 @@ auto view = rcp<TextureViewD3D12>( new TextureViewD3D12(m_manager, std::move(texture), viewDesc)); + // SRV so a later pass can sample the canvas after rendering into it; + // without it a bind group copies a null descriptor and the debug layer + // faults. + if (m_d3dCpuSrvAllocated < 1024) + { + D3D12_CPU_DESCRIPTOR_HANDLE srvHandle = + m_d3dCpuSrvHeap->GetCPUDescriptorHandleForHeapStart(); + srvHandle.ptr += (SIZE_T)m_d3dCpuSrvAllocated++ * m_d3dSrvDescSize; + + D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {}; + srvDesc.Format = dxgiFmt; + srvDesc.Shader4ComponentMapping = + D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; + srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; + srvDesc.Texture2D.MipLevels = 1; + + m_d3dDevice->CreateShaderResourceView(d3dTex->resource(), + &srvDesc, + srvHandle); + view->m_d3dSrvHandle = srvHandle; + } + // Create the RTV in our CPU RTV heap. D3D12_CPU_DESCRIPTOR_HANDLE handle = m_d3dCpuRtvHeap->GetCPUDescriptorHandleForHeapStart();
diff --git a/renderer/src/ore/d3d12/ore_texture_d3d12.cpp b/renderer/src/ore/d3d12/ore_texture_d3d12.cpp index 1d3c710..c905483 100644 --- a/renderer/src/ore/d3d12/ore_texture_d3d12.cpp +++ b/renderer/src/ore/d3d12/ore_texture_d3d12.cpp
@@ -214,26 +214,6 @@ } m_uploadBuffer->m_d3dBuffer->Unmap(0, nullptr); - // If the texture is not in COPY_DEST state, transition it. - if (m_d3dCurrentState != D3D12_RESOURCE_STATE_COPY_DEST) - { - D3D12_RESOURCE_BARRIER barrier = {}; - barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; - barrier.Transition.pResource = m_d3dTexture.Get(); - barrier.Transition.StateBefore = m_d3dCurrentState; - barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_COPY_DEST; - barrier.Transition.Subresource = - D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; - ctx->m_d3dCmdList->ResourceBarrier(1, &barrier); - m_d3dCurrentState = D3D12_RESOURCE_STATE_COPY_DEST; - } - - // Copy the whole staged region to (x, y, z). No src box needed. - D3D12_TEXTURE_COPY_LOCATION dst_loc = {}; - dst_loc.pResource = m_d3dTexture.Get(); - dst_loc.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; - dst_loc.SubresourceIndex = subresource; - D3D12_PLACED_SUBRESOURCE_FOOTPRINT footprint = {}; footprint.Offset = 0; footprint.Footprint.Format = texDesc.Format; @@ -242,32 +222,17 @@ footprint.Footprint.Depth = depth; footprint.Footprint.RowPitch = static_cast<UINT>(dstRowPitch); - D3D12_TEXTURE_COPY_LOCATION src_loc = {}; - src_loc.pResource = m_uploadBuffer->m_d3dBuffer.Get(); - src_loc.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT; - src_loc.PlacedFootprint = footprint; - - ctx->m_d3dCmdList->CopyTextureRegion(&dst_loc, - data.x, - data.y, - data.z, - &src_loc, - nullptr); - - // Transition to PIXEL_SHADER_RESOURCE so it's ready to sample without - // an explicit barrier in the render pass. - { - D3D12_RESOURCE_BARRIER barrier = {}; - barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; - barrier.Transition.pResource = m_d3dTexture.Get(); - barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_COPY_DEST; - barrier.Transition.StateAfter = - D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; - barrier.Transition.Subresource = - D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; - ctx->m_d3dCmdList->ResourceBarrier(1, &barrier); - m_d3dCurrentState = D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE; - } + // Callers stage uploads before an Ore frame, when the host command list is + // closed, so queue the copy to record onto a live list once one opens. + ctx->d3d12QueuePendingTextureUpload({ + ref_rcp(this), + m_uploadBuffer, + footprint, + subresource, + data.x, + data.y, + data.z, + }); #else (void)data; #endif
diff --git a/renderer/src/ore/gl/ore_context_gl.cpp b/renderer/src/ore/gl/ore_context_gl.cpp index 52da6a4..267f2a9 100644 --- a/renderer/src/ore/gl/ore_context_gl.cpp +++ b/renderer/src/ore/gl/ore_context_gl.cpp
@@ -13,6 +13,7 @@ #include "ore_shader_module_gl.hpp" #include "ore_texture_gl.hpp" #include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/gl/render_context_gl_impl.hpp" #include "rive/rive_types.hpp" #include <algorithm> @@ -185,9 +186,9 @@ ContextGL::~ContextGL() {} -std::unique_ptr<ContextGL> ContextGL::Make() +std::unique_ptr<ContextGL> ContextGL::Make(void* renderContextImpl) { - auto ctx = std::unique_ptr<ContextGL>(new ContextGL()); + auto ctx = std::unique_ptr<ContextGL>(new ContextGL(renderContextImpl)); Features& f = ctx->m_features; @@ -296,6 +297,8 @@ void ContextGL::endFrame() { + // GL uses per pass inline replay, so no whole frame buffer to drain here. + // Restore saved state. Each `RenderPass::finish()` already restores // its own captured VAO in-place, so by the time we get here only the // program / array-buffer / framebuffer bindings need restoring — @@ -1147,10 +1150,18 @@ // wrapCanvasTexture // ============================================================================ +// During deferred replay the canvas texture from the main context is invalid +// here, so back the canvas with a worker owned texture first. rcp<TextureView> ContextGL::wrapCanvasTexture(gpu::RenderCanvas* canvas) { assert(canvas != nullptr); + if (m_renderContextImpl != nullptr) + { + static_cast<gpu::RenderContextGLImpl*>(m_renderContextImpl) + ->ensureDeferredCanvasBacking(canvas); + } + auto* glTarget = static_cast<gpu::TextureRenderTargetGL*>(canvas->renderTarget()); GLuint texID = glTarget->externalTextureID(); @@ -1218,4 +1229,47 @@ return rcp<TextureViewGL>(new TextureViewGL(std::move(texture), viewDesc)); } +// GL renders the canvas bottom up while WGSL samples top down, so import +// through a Y flip mirror the view retains to keep the borrowed id valid. +rcp<TextureView> ContextGL::wrapCanvasSampleView(gpu::RenderCanvas* canvas) +{ + assert(canvas != nullptr); + + if (m_renderContextImpl != nullptr) + { + static_cast<gpu::RenderContextGLImpl*>(m_renderContextImpl) + ->ensureDeferredCanvasBacking(canvas); + } + + auto* image = canvas->renderImage(); + gpu::Texture* sourceTex = image->getTexture(); + + gpu::Texture* texToWrap = sourceTex; + rcp<RenderImage> mirror; + if (m_renderContextImpl != nullptr) + { + auto* glImpl = + static_cast<gpu::RenderContextGLImpl*>(m_renderContextImpl); + mirror = glImpl->getCanvasImportMirror(sourceTex, + canvas->width(), + canvas->height()); + if (mirror != nullptr) + { + auto* mirrorRive = lite_rtti_cast<RiveRenderImage*>(mirror.get()); + if (mirrorRive != nullptr && mirrorRive->getTexture() != nullptr) + { + texToWrap = mirrorRive->getTexture(); + } + } + } + + auto view = wrapRiveTexture(texToWrap, canvas->width(), canvas->height()); + if (mirror != nullptr && view != nullptr) + { + static_cast<TextureViewGL*>(view.get()) + ->retainCanvasMirror(std::move(mirror)); + } + return view; +} + } // namespace rive::ore
diff --git a/renderer/src/ore/gl/ore_texture_gl.hpp b/renderer/src/ore/gl/ore_texture_gl.hpp index b74dd45..e3100cb 100644 --- a/renderer/src/ore/gl/ore_texture_gl.hpp +++ b/renderer/src/ore/gl/ore_texture_gl.hpp
@@ -1,5 +1,6 @@ #pragma once #include "rive/renderer/ore/ore_texture.hpp" +#include "rive/renderer/rive_render_image.hpp" namespace rive::ore { @@ -28,8 +29,16 @@ {} ~TextureViewGL() override; + // The canvas import mirror owns the GL texture this view borrows, so the + // view must keep it alive. Null for ordinary views. + void retainCanvasMirror(rcp<RenderImage> mirror) + { + m_retainedCanvasMirror = std::move(mirror); + } + private: friend class ContextGL; unsigned int m_glTextureView = 0; // GLenum; 0 means use base texture + rcp<RenderImage> m_retainedCanvasMirror; }; } // namespace rive::ore
diff --git a/renderer/src/ore/metal/ore_context_metal.mm b/renderer/src/ore/metal/ore_context_metal.mm index 29642f5..0c305b3 100644 --- a/renderer/src/ore/metal/ore_context_metal.mm +++ b/renderer/src/ore/metal/ore_context_metal.mm
@@ -12,6 +12,7 @@ #include "ore_texture_metal.hpp" #include "rive/renderer/render_canvas.hpp" #include "rive/renderer/metal/render_context_metal_impl.h" +#include "rive/renderer/ore/cmd/ore_replay.hpp" #include "rive/rive_types.hpp" #include <string> @@ -1153,6 +1154,7 @@ m_mtlCommandBuffer = [m_mtlQueue commandBuffer]; // Serial of the command buffer about to be recorded. ++m_currentSerial; + m_pendingFrame.reset(); } void ContextMetal::waitForGPU() @@ -1167,6 +1169,14 @@ { if (m_mtlCommandBuffer) { + // Drain the recorded frame before commit. Keyed on a non empty + // recording rather than the flag so a mid frame toggle still drains. + if (!m_pendingFrame.empty()) + { + cmd::replayCommandBuffer(*this, m_pendingFrame); + m_pendingFrame.reset(); + } + // Capture deferred BindGroups in a `__block` vector that the // completion handler clears once the GPU is done with the // command buffer. Pre-fix the next `beginFrame()` cleared
diff --git a/renderer/src/ore/metal/ore_render_pass_metal.mm b/renderer/src/ore/metal/ore_render_pass_metal.mm index eeec330..9db3da1 100644 --- a/renderer/src/ore/metal/ore_render_pass_metal.mm +++ b/renderer/src/ore/metal/ore_render_pass_metal.mm
@@ -263,6 +263,10 @@ uint32_t firstInstance) { validate(); + if (m_currentPipeline == nullptr) + { + return; // setPipeline was rejected (see lastError), drawing would crash + } [m_mtlEncoder drawPrimitives:m_mtlPrimitiveType vertexStart:firstVertex vertexCount:vertexCount @@ -277,6 +281,10 @@ uint32_t firstInstance) { validate(); + if (m_currentPipeline == nullptr || m_mtlIndexBuffer == nil) + { + return; // rejected pipeline or missing index buffer, see lastError + } assert(m_mtlIndexBuffer != nil && "Must call setIndexBuffer before drawIndexed");
diff --git a/renderer/src/render_context.cpp b/renderer/src/render_context.cpp index 66cec3c..86efe5c 100644 --- a/renderer/src/render_context.cpp +++ b/renderer/src/render_context.cpp
@@ -181,6 +181,12 @@ { return m_impl->makeRenderCanvas(width, height); } + +rcp<RenderCanvas> RenderContext::makeDeferredRenderCanvas(uint32_t width, + uint32_t height) +{ + return m_impl->makeDeferredRenderCanvas(width, height); +} rive::ore::Context* RenderContext::ore() { if (m_oreContext == nullptr)
diff --git a/renderer/src/rive_render_path.cpp b/renderer/src/rive_render_path.cpp index d9267f2..3a9e437 100644 --- a/renderer/src/rive_render_path.cpp +++ b/renderer/src/rive_render_path.cpp
@@ -99,6 +99,7 @@ void RiveRenderPath::addRenderPathBackwards(const RenderPath* path, const Mat2D& transform) { + assert(m_rawPathMutationLockCount == 0); auto riveRenderPath = static_cast<const RiveRenderPath*>(path); RawPath::Iter transformedPathIter = m_rawPath.addPathBackwards(riveRenderPath->m_rawPath, &transform); @@ -112,7 +113,9 @@ void RiveRenderPath::addRawPath(const RawPath& path) { + assert(m_rawPathMutationLockCount == 0); m_rawPath.addPath(path, nullptr); + m_dirt = kAllDirt; } const AABB& RiveRenderPath::getBounds() const
diff --git a/src/artboard.cpp b/src/artboard.cpp index cfeb248..bf21b2b 100644 --- a/src/artboard.cpp +++ b/src/artboard.cpp
@@ -261,6 +261,30 @@ return true; } +void Artboard::reinstanceNestedArtboards(Factory* factory) +{ + for (auto object : m_Objects) + { + if (object == nullptr || !object->is<NestedArtboard>()) + { + continue; + } + auto nested = object->as<NestedArtboard>(); + Artboard* current = nested->sourceArtboard(); + if (current == nullptr || !current->isInstance() || + current->m_artboardSource == nullptr) + { + continue; + } + auto replacement = + current->m_artboardSource->instance<ArtboardInstance>(factory); + if (replacement != nullptr) + { + nested->referencedArtboard(replacement.release()); + } + } +} + StatusCode Artboard::initialize() { StatusCode code; @@ -892,25 +916,6 @@ void Artboard::pollAsyncWork() { rive_pollAsyncWork(); } -void Artboard::drawCanvases() -{ -#ifdef WITH_RIVE_SCRIPTING - if (m_scriptingVM) - { - auto* L = m_scriptingVM->state(); - if (L != nullptr) - { - auto* context = - static_cast<ScriptingContext*>(lua_getthreaddata(L)); - ScopedCanvasDrawingPhase phase(context); - internalDrawCanvases(); - return; - } - } -#endif - internalDrawCanvases(); -} - void Artboard::advanceScriptedViewModels() { #ifdef WITH_RIVE_SCRIPTING @@ -924,53 +929,6 @@ #endif } -void Artboard::internalDrawCanvases() -{ - for (auto obj : m_ScriptedObjects) - { - obj->scriptDrawCanvas(); - } - for (auto artboardHost : m_ArtboardHosts) - { - for (int i = 0; i < artboardHost->artboardCount(); i++) - { - auto* nested = artboardHost->artboardInstance(i); - if (nested != nullptr) - { - nested->internalDrawCanvases(); - } - } - } -} - -#ifdef WITH_RIVE_SCRIPTING -void* Artboard::findDrawCanvasLuauState() const -{ - for (auto* obj : m_ScriptedObjects) - { - if (obj->drawsCanvas()) - { - return obj->state(); - } - } - for (auto* host : m_ArtboardHosts) - { - for (int i = 0; i < host->artboardCount(); i++) - { - auto* nested = host->artboardInstance(i); - if (nested != nullptr) - { - if (auto* state = nested->findDrawCanvasLuauState()) - { - return state; - } - } - } - } - return nullptr; -} -#endif - Core* Artboard::resolve(uint32_t id) const { if (id >= static_cast<int>(m_Objects.size())) @@ -1620,7 +1578,6 @@ void Artboard::draw(Renderer* renderer) { sm_frameId++; - drawCanvases(); drawInternal(renderer); }
diff --git a/src/assets/script_asset.cpp b/src/assets/script_asset.cpp index b1df3dc..2e92d41 100644 --- a/src/assets/script_asset.cpp +++ b/src/assets/script_asset.cpp
@@ -106,7 +106,7 @@ { generatorFunctionRef(ref); // Force re-verification on next init so that method detection (e.g. - // drawCanvas) reflects the newly compiled script. + // draw) reflects the newly compiled script. m_initted = false; } } @@ -155,6 +155,21 @@ // actually a function on the returned table). OptionalScriptedMethods::implementedMethods( serializedImplementedMethods() & methodMask); +#ifdef WITH_RIVE_TOOLS + // Bit 15 was the removed drawCanvas callback: the editor detected it + // on this script, so its canvas work never runs until moved into + // draw. Legacy all-bits exports stay silent; the shipping runtime + // stays quiet entirely, the editor console carries the migration. + static const uint32_t kRetiredDrawCanvasBit = 1 << 15; + static const uint32_t kAllMethodsDefault = (1 << 21) - 1; + if (serializedImplementedMethods() != kAllMethodsDefault && + (serializedImplementedMethods() & kRetiredDrawCanvasBit) != 0) + { + fprintf(stderr, + "rive: script implements drawCanvas, which is no longer " + "called; move its body into draw\n"); + } +#endif m_initted = true; } object->implementedMethods(implementedMethods());
diff --git a/src/file.cpp b/src/file.cpp index 05af200..5ed3472 100644 --- a/src/file.cpp +++ b/src/file.cpp
@@ -697,6 +697,7 @@ ScriptingVM* vm = m_scriptingVM.get(); if (vm != nullptr) { + routeScriptingToImportFactory(vm->context()); // Set up the Data global (view model constructors) on the active // VM, whether it was created here or supplied externally (e.g. by // the CommandServer). Skip it when the VM's owner builds Data @@ -736,6 +737,48 @@ } } +// Scripts reach the GPU through their ScriptingContext, and nothing else in +// the import path hands them one, so a script that opened a gpuCanvas used to +// fail on every runtime host. The factory the file imported through knows the +// context it draws to and whether that work has to record, so route from it +// here, ahead of performRegistration since registration can run script bodies. +// +// Only fills pointers the caller left null: the editor routes at workspace +// level instead, because it swaps the whole ScriptingContext on every +// recompile and per-VM routing would silently come undone. A caller that +// already chose a context outranks the factory's default. +void File::routeScriptingToImportFactory(ScriptingContext* context) +{ + if (context == nullptr || m_factory == nullptr) + { + return; + } + // Each pointer routes on its own. renderContext() and oreContext() read + // through factory fallbacks so they self-heal after a late device bind, + // but the canvas host has no fallback: bailing on a factory with no + // device yet would lose recording for good. Test the raw member, not + // renderContext() - the fallback would report the factory's own context + // and skip a caller that never chose one. + if (context->renderContextIsLateBound()) + { + if (Factory* renderContext = m_factory->renderContext()) + { + context->setRenderContext(renderContext); + } + } + if (context->deferredCanvasHost() == nullptr) + { + if (cmd::DeferredCanvasHost* host = m_factory->deferredCanvasHost()) + { + // Recording: script canvas frames become commands, and the ore + // context has to be the session's recorder rather than the render + // context's real one that setRenderContext would otherwise imply. + context->setDeferredCanvasHost(host); + context->setOreContext(m_factory->ore()); + } + } +} + void File::makeScriptingVM() { cleanupScriptingVM();
diff --git a/src/lua/lua_artboards.cpp b/src/lua/lua_artboards.cpp index 68939f1..3e7ac2e 100644 --- a/src/lua/lua_artboards.cpp +++ b/src/lua/lua_artboards.cpp
@@ -101,14 +101,6 @@ return 0; } -static int artboard_draw_canvas(lua_State* L) -{ - auto scriptedArtboard = lua_torive<ScriptedArtboard>(L, 1); - scriptedArtboard->artboard()->internalDrawCanvases(); - - return 0; -} - bool ScriptedArtboard::advance(float seconds) { auto machine = stateMachine(); @@ -226,8 +218,6 @@ { case (int)LuaAtoms::draw: return artboard_draw(L); - case (int)LuaAtoms::drawCanvas: - return artboard_draw_canvas(L); case (int)LuaAtoms::advance: return artboard_advance(L); case (int)LuaAtoms::instance:
diff --git a/src/lua/lua_scripted_context.cpp b/src/lua/lua_scripted_context.cpp index a62ed9e..d9a4389 100644 --- a/src/lua/lua_scripted_context.cpp +++ b/src/lua/lua_scripted_context.cpp
@@ -27,11 +27,27 @@ // Pushes a GPU features table onto the Lua stack. Queries the ORE context // when available, otherwise returns conservative defaults. Always returns 1. +// +// Errors instead of answering when the context is recording and does not yet +// know its replay device. Conservative defaults would be the wrong answer to +// give: they are indistinguishable from a real low end device, so a script +// cannot tell it is being guessed at, and the branch it picks is written into +// a stream that replays flawlessly on hardware that contradicts it. Failing at +// the read is the only signal that fits through this API. int lua_push_gpu_features(lua_State* L) { #if defined(RIVE_CANVAS) && defined(RIVE_ORE) auto* oreCtx = static_cast<ore::Context*>( static_cast<ScriptingContext*>(lua_getthreaddata(L))->oreContext()); + if (oreCtx != nullptr && !oreCtx->featuresKnown()) + { + luaL_error(L, + "context.features is not available yet: this script is " + "recording for a GPU device that has not been attached, so " + "no capability can be reported without guessing at it. " + "Read features from a method that runs after the first " + "frame instead of at module scope"); + } if (oreCtx != nullptr) { const auto& f = oreCtx->features(); @@ -376,24 +392,39 @@ static_cast<ScriptingContext*>(lua_getthreaddata(L)); auto* renderCtx = static_cast<gpu::RenderContext*>( scriptingCtx->renderContext()); + auto* handle = lua_newrive<ScriptedCanvas>(L); + handle->m_L = L; + handle->renderCtx = renderCtx; + + // A size-less canvas allocates nothing, so it needs no device. + // Checked before the context, or a layout script that does not + // know its size at init is refused for a device it will only + // need at resize(). + if (cw == 0 || ch == 0) + { + return 1; + } if (renderCtx == nullptr) { + // A recording session binds its device after import, and + // generators size their canvas at construction, before any + // texture exists. Record the request; satisfyPending + // materializes it on first use once the device arrives. + if (scriptingCtx->deferredCanvasHost() != nullptr) + { + handle->pendingWidth = cw; + handle->pendingHeight = ch; + return 1; + } luaL_error( L, "context:canvas() requires a RenderContext — call " "setRenderContext() first"); return 0; } - auto* handle = lua_newrive<ScriptedCanvas>(L); - handle->m_L = L; - handle->renderCtx = renderCtx; - if (cw == 0 || ch == 0) - { - return 1; - } - - auto canvas = renderCtx->makeRenderCanvas(cw, ch); + auto canvas = + allocScriptRenderCanvas(renderCtx, scriptingCtx, cw, ch); if (!canvas) { luaL_error( @@ -456,8 +487,27 @@ } auto* gpuRenderCtx = static_cast<gpu::RenderContext*>( gpuScriptingCtx->renderContext()); + auto* handle = lua_newrive<ScriptedGPUCanvas>(L); + handle->m_L = L; + handle->renderCtx = gpuRenderCtx; + + // The documented size-less contract: no descriptor means no + // backing texture, so nothing here touches a device. Checked + // ahead of the contexts, or a layout script that learns its + // size at resize() is refused for a device it does not use. + if (gw == 0 || gh == 0) + { + return 1; + } if (gpuRenderCtx == nullptr) { + // Same late-device contract as canvas() above. + if (gpuScriptingCtx->deferredCanvasHost() != nullptr) + { + handle->pendingWidth = gw; + handle->pendingHeight = gh; + return 1; + } luaL_error( L, "context:gpuCanvas() requires a RenderContext — call " @@ -474,16 +524,11 @@ "scriptingWorkspaceSetOreContext() before requestVM()"); return 0; } - auto* handle = lua_newrive<ScriptedGPUCanvas>(L); - handle->m_L = L; - handle->renderCtx = gpuRenderCtx; - if (gw == 0 || gh == 0) - { - return 1; - } - - auto canvas = gpuRenderCtx->makeRenderCanvas(gw, gh); + auto canvas = allocScriptRenderCanvas(gpuRenderCtx, + gpuScriptingCtx, + gw, + gh); if (!canvas) { luaL_error(
diff --git a/src/lua/renderer/lua_gpu.cpp b/src/lua/renderer/lua_gpu.cpp index 66121df..c4be279 100644 --- a/src/lua/renderer/lua_gpu.cpp +++ b/src/lua/renderer/lua_gpu.cpp
@@ -6,7 +6,10 @@ #include "rive/renderer/ore/ore_context.hpp" #include "rive/renderer/ore/ore_rstb_entry_container.hpp" #include "rive/renderer/ore/ore_render_pass.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_render_pass.hpp" #include "rive/renderer/ore/ore_shader_module.hpp" +#include "rive/renderer/cmd/deferred_canvas_host.hpp" +#include "rive/renderer/cmd/deferred_render_resource.hpp" #include "rive/renderer/render_canvas.hpp" #include "rive/renderer/render_context.hpp" #include "rive/renderer/render_context_impl.hpp" @@ -18,6 +21,7 @@ #include "rive/shapes/paint/color.hpp" #include <algorithm> +#include <cassert> #include <cstring> #include <stdio.h> #include <string> @@ -505,6 +509,18 @@ static_cast<ScriptingContext*>(lua_getthreaddata(L))->oreContext()); } +/// Whether a capability gate below can be decided at all. A recording context +/// with no replay device attached yet holds Features' own initializers, which +/// deny nearly everything; gating on those would reject an operation the +/// replay device very likely supports. These gates are diagnostics — the real +/// backend is the authority — so an undecidable one lets the call through +/// rather than inventing a refusal, which is the same fiction as inventing a +/// capability, only in the direction that breaks working content. +static bool features_are_known(Context* oreCtx) +{ + return oreCtx != nullptr && oreCtx->featuresKnown(); +} + /// RSTB ShaderTarget the active ore backend consumes. static ShaderTarget currentShaderTarget(Context* oreCtx) { @@ -611,6 +627,7 @@ desc.glFixupBytes = fx.empty() ? nullptr : fx.data(); desc.glFixupSize = static_cast<uint32_t>(fx.size()); } + auto mod = oreCtx->makeShaderModule(desc); if (!mod) return false; @@ -993,7 +1010,7 @@ "sampleCount must be a power of two (got %u)", sampleCount); auto* ctx = getOreContext(L); - if (ctx) + if (features_are_known(ctx)) { uint32_t maxSamples = ctx->features().maxSamples; if (sampleCount > maxSamples) @@ -1048,7 +1065,7 @@ // Gate float render targets: without the matching capability they make an // incomplete FBO that renders black. Sampled-only float textures are fine. // 16-bit floats need half-float, 32-bit and packed need full float. - if (desc.renderTarget) + if (desc.renderTarget && features_are_known(ctx)) { FloatColorClass fc = floatColorClass(desc.format); const Features& feat = ctx->features(); @@ -1424,7 +1441,8 @@ "two in [1, 16] (got %u)", a); } - if (a > 1 && !getOreContext(L)->features().anisotropicFiltering) + if (a > 1 && features_are_known(getOreContext(L)) && + !getOreContext(L)->features().anisotropicFiltering) { luaL_error(L, "GPUSampler.new: maxAnisotropy=%u requires " @@ -2541,7 +2559,8 @@ lua_isnumber(L, 4) ? static_cast<uint32_t>(lua_tonumber(L, 4)) : 0; uint32_t firstInstance = lua_isnumber(L, 5) ? static_cast<uint32_t>(lua_tonumber(L, 5)) : 0; - if (firstInstance > 0 && !getOreContext(L)->features().drawBaseInstance) + if (firstInstance > 0 && features_are_known(getOreContext(L)) && + !getOreContext(L)->features().drawBaseInstance) { luaL_error(L, "draw: firstInstance=%u requires the drawBaseInstance " @@ -2569,7 +2588,8 @@ lua_isnumber(L, 5) ? static_cast<int32_t>(lua_tointeger(L, 5)) : 0; uint32_t firstInstance = lua_isnumber(L, 6) ? static_cast<uint32_t>(lua_tonumber(L, 6)) : 0; - if (baseVertex != 0 && !getOreContext(L)->features().drawBaseInstance) + if (baseVertex != 0 && features_are_known(getOreContext(L)) && + !getOreContext(L)->features().drawBaseInstance) { luaL_error(L, "drawIndexed: baseVertex=%d requires the " @@ -2577,7 +2597,8 @@ "does not support", baseVertex); } - if (firstInstance > 0 && !getOreContext(L)->features().drawBaseInstance) + if (firstInstance > 0 && features_are_known(getOreContext(L)) && + !getOreContext(L)->features().drawBaseInstance) { luaL_error(L, "drawIndexed: firstInstance=%u requires the " @@ -2726,10 +2747,13 @@ auto* scriptingContext = static_cast<ScriptingContext*>(lua_getthreaddata(L)); - if (scriptingContext == nullptr || !scriptingContext->canvasDrawingPhase()) + // Recording brackets the pass; an immediate context cannot nest a pass + // inside the open screen frame. + if (scriptingContext == nullptr || !oreCtx->isRecording()) { luaL_error(L, - "GPUCanvas:beginRenderPass() called outside drawing phase"); + "GPUCanvas:beginRenderPass() requires the deferred " + "recorder"); } luaL_checktype(L, 2, LUA_TTABLE); @@ -2986,7 +3010,8 @@ } auto* rp = lua_newrive<ScriptedGPURenderPass>(L); - rp->pass = oreCtx->beginRenderPass(passDesc); + // Records in deferred mode, returns the live backend pass in immediate. + rp->pass = ore::cmd::beginRenderPassRecordingOrImmediate(*oreCtx, passDesc); rp->m_context = oreCtx; rp->m_finished = false; rp->sampleCount = @@ -2997,42 +3022,60 @@ return 1; } -// Recreate the underlying RenderCanvas at a new size, then re-wrap its backing -// texture for use in ORE render passes. The handle's `.image` ref continues to -// point to the updated canvas image. Resizing to zero in either dimension -// drops the backing texture and leaves the canvas in a deferred state. -static int gpucanvashandle_resize(lua_State* L) +rcp<gpu::RenderCanvas> rive::allocScriptRenderCanvas(gpu::RenderContext* rc, + ScriptingContext* ctx, + uint32_t width, + uint32_t height) { - auto* self = lua_torive<ScriptedGPUCanvas>(L, 1); - uint32_t w = static_cast<uint32_t>(luaL_checkunsigned(L, 2)); - uint32_t h = static_cast<uint32_t>(luaL_checkunsigned(L, 3)); - - if (self->renderCtx == nullptr) + assert(rc != nullptr); + assert(ctx != nullptr); + if (ctx->deferredCanvasHost() != nullptr || ctx->renderContextIsLateBound()) { - luaL_error(L, "GPUCanvas: renderCtx not initialized"); + return rc->makeDeferredRenderCanvas(width, height); + } + return rc->makeRenderCanvas(width, height); +} + +// The device a canvas should allocate against right now, which is not +// necessarily the one that existed when the handle was made: web builds one per +// render texture and attaches it after the file has imported. +static gpu::RenderContext* liveRenderContext(ScriptingContext* scriptingCtx) +{ + if (scriptingCtx == nullptr) + { + return nullptr; + } + return static_cast<gpu::RenderContext*>(scriptingCtx->renderContext()); +} + +// Allocates the backing for a pending size, if there is one and a device has +// turned up to allocate it against. Errors only on a device that is present and +// refuses; a device that has not arrived yet leaves the request pending, which +// is what makes a size-less canvas usable on a host that attaches late. +static void gpucanvas_satisfyPending(lua_State* L, ScriptedGPUCanvas* self) +{ + if (self->pendingWidth == 0 || self->pendingHeight == 0) + { + return; + } + auto* scriptingCtx = static_cast<ScriptingContext*>(lua_getthreaddata(L)); + auto* renderCtx = liveRenderContext(scriptingCtx); + if (renderCtx == nullptr) + { + return; } auto* oreCtx = getOreContext(L); if (oreCtx == nullptr) { - luaL_error(L, "GPUCanvas: GPU context not initialized"); + return; } - - if (w == 0 || h == 0) - { - if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF) - { - lua_unref(self->m_L, self->m_imageRef); - self->m_imageRef = LUA_NOREF; - } - self->canvas = nullptr; - self->oreColorView = nullptr; - return 0; - } + self->renderCtx = renderCtx; + uint32_t w = self->pendingWidth, h = self->pendingHeight; // Allocate and wrap the new backing BEFORE touching the existing // canvas/view/imageRef. If either step throws (via luaL_error), the // canvas keeps its previous, still-valid backing. - auto newCanvas = self->renderCtx->makeRenderCanvas(w, h); + auto newCanvas = allocScriptRenderCanvas(renderCtx, scriptingCtx, w, h); if (!newCanvas) { luaL_error(L, "GPUCanvas:resize() failed to create RenderCanvas"); @@ -3050,19 +3093,62 @@ } self->canvas = std::move(newCanvas); self->oreColorView = std::move(newColorView); + self->pendingWidth = 0; + self->pendingHeight = 0; auto* img = lua_newrive<ScriptedImage>(L); img->image = ref_rcp(static_cast<RenderImage*>(self->canvas->renderImage())); self->m_imageRef = lua_ref(L, -1); lua_pop(L, 1); // pop image +} +// Recreate the underlying RenderCanvas at a new size, then re-wrap its backing +// texture for use in ORE render passes. The handle's `.image` ref continues to +// point to the updated canvas image. Resizing to zero in either dimension +// drops the backing texture and leaves the canvas in a deferred state. +static int gpucanvashandle_resize(lua_State* L) +{ + auto* self = lua_torive<ScriptedGPUCanvas>(L, 1); + uint32_t w = static_cast<uint32_t>(luaL_checkunsigned(L, 2)); + uint32_t h = static_cast<uint32_t>(luaL_checkunsigned(L, 3)); + + if (w == 0 || h == 0) + { + if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF) + { + lua_unref(self->m_L, self->m_imageRef); + self->m_imageRef = LUA_NOREF; + } + self->canvas = nullptr; + self->oreColorView = nullptr; + self->pendingWidth = 0; + self->pendingHeight = 0; + return 0; + } + + // Generators call resize() every frame, so recreating on an unchanged + // size would churn a new texture per frame and stall the render thread. + if (self->canvas != nullptr && self->canvas->width() == w && + self->canvas->height() == h) + { + return 0; + } + + // A generator resizes once, when layout hands it the real size. On web that + // still precedes the render texture's attach, so the request is recorded + // and satisfied on first use rather than refused for a device the contract + // did not require at init either. + self->pendingWidth = w; + self->pendingHeight = h; + gpucanvas_satisfyPending(L, self); return 0; } static int gpucanvashandle_colorview(lua_State* L) { auto* self = lua_torive<ScriptedGPUCanvas>(L, 1); + gpucanvas_satisfyPending(L, self); if (!self->oreColorView) { luaL_error(L, @@ -3074,11 +3160,15 @@ return 1; } +// A canvas has a size from the moment resize() is called; only the texture may +// still be pending. Reporting zero here instead would break the generator that +// sizes its depth and MSAA attachments off canvas.width the same frame. static void gpucanvashandle_direct_width(void* udata, void* result) { auto* self = (ScriptedGPUCanvas*)udata; lua_userdatadirectfield_setnumber(result, - self->canvas ? self->canvas->width() : 0); + self->canvas ? self->canvas->width() + : self->pendingWidth); } static void gpucanvashandle_direct_height(void* udata, void* result) @@ -3086,7 +3176,7 @@ auto* self = (ScriptedGPUCanvas*)udata; lua_userdatadirectfield_setnumber(result, self->canvas ? self->canvas->height() - : 0); + : self->pendingHeight); } static int gpucanvashandle_index(lua_State* L) @@ -3098,6 +3188,9 @@ luaL_typeerrorL(L, 2, lua_typename(L, LUA_TSTRING)); } auto* self = lua_torive<ScriptedGPUCanvas>(L, 1); + // Every field below reads the backing, and a generator reads them each + // frame, so this is where a size pending on a late device is honoured. + gpucanvas_satisfyPending(L, self); switch (atom) { case (int)LuaAtoms::image: @@ -3109,10 +3202,14 @@ lua_pushnil(L); return 1; case (int)LuaAtoms::width: - lua_pushnumber(L, self->canvas ? self->canvas->width() : 0); + lua_pushnumber(L, + self->canvas ? self->canvas->width() + : self->pendingWidth); return 1; case (int)LuaAtoms::height: - lua_pushnumber(L, self->canvas ? self->canvas->height() : 0); + lua_pushnumber(L, + self->canvas ? self->canvas->height() + : self->pendingHeight); return 1; case (int)LuaAtoms::format: // Realized canvas reports its texture format. Deferred canvas @@ -3160,6 +3257,51 @@ // Canvas (2D Rive renderer canvas) // ============================================================================ +// The 2D counterpart of gpucanvas_satisfyPending, for the same reason: a +// size-less canvas is legal, and on web the device shows up after layout has +// already handed the generator its real size. +static void canvas_satisfyPending(lua_State* L, ScriptedCanvas* self) +{ + if (self->pendingWidth == 0 || self->pendingHeight == 0) + { + return; + } + auto* scriptingCtx = static_cast<ScriptingContext*>(lua_getthreaddata(L)); + auto* renderCtx = liveRenderContext(scriptingCtx); + if (renderCtx == nullptr) + { + return; + } + self->renderCtx = renderCtx; + + // Allocate the new backing BEFORE touching the existing canvas/imageRef. + // If allocation throws (via luaL_error), the canvas keeps its previous, + // still-valid backing. + auto newCanvas = allocScriptRenderCanvas(renderCtx, + scriptingCtx, + self->pendingWidth, + self->pendingHeight); + if (!newCanvas) + { + luaL_error(L, "Canvas:resize() failed to create RenderCanvas"); + } + + if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF) + { + lua_unref(self->m_L, self->m_imageRef); + self->m_imageRef = LUA_NOREF; + } + self->canvas = std::move(newCanvas); + self->pendingWidth = 0; + self->pendingHeight = 0; + + auto* img = lua_newrive<ScriptedImage>(L); + img->image = + ref_rcp(static_cast<RenderImage*>(self->canvas->renderImage())); + self->m_imageRef = lua_ref(L, -1); + lua_pop(L, 1); +} + // Recreate the underlying RenderCanvas at a new size. Must not be called // between beginFrame() and endFrame(). Resizing to zero in either dimension // drops the backing texture and leaves the canvas in a deferred state. @@ -3169,10 +3311,6 @@ uint32_t w = static_cast<uint32_t>(luaL_checkunsigned(L, 2)); uint32_t h = static_cast<uint32_t>(luaL_checkunsigned(L, 3)); - if (self->renderCtx == nullptr) - { - luaL_error(L, "Canvas: renderCtx not initialized"); - } if (self->m_state != CanvasState::Idle) { luaL_error(L, "Canvas:resize() called during an active frame"); @@ -3186,31 +3324,21 @@ self->m_imageRef = LUA_NOREF; } self->canvas = nullptr; + self->pendingWidth = 0; + self->pendingHeight = 0; return 0; } - // Allocate the new backing BEFORE touching the existing canvas/imageRef. - // If makeRenderCanvas throws (via luaL_error), the canvas keeps its - // previous, still-valid backing. - auto newCanvas = self->renderCtx->makeRenderCanvas(w, h); - if (!newCanvas) + // Resizing to an unchanged size would churn a new texture per frame. + if (self->canvas != nullptr && self->canvas->width() == w && + self->canvas->height() == h) { - luaL_error(L, "Canvas:resize() failed to create RenderCanvas"); + return 0; } - if (self->m_L != nullptr && self->m_imageRef != LUA_NOREF) - { - lua_unref(self->m_L, self->m_imageRef); - self->m_imageRef = LUA_NOREF; - } - self->canvas = std::move(newCanvas); - - auto* img = lua_newrive<ScriptedImage>(L); - img->image = - ref_rcp(static_cast<RenderImage*>(self->canvas->renderImage())); - self->m_imageRef = lua_ref(L, -1); - lua_pop(L, 1); - + self->pendingWidth = w; + self->pendingHeight = h; + canvas_satisfyPending(L, self); return 0; } @@ -3223,15 +3351,20 @@ static int canvashandle_beginframe(lua_State* L) { auto* self = lua_torive<ScriptedCanvas>(L, 1); + canvas_satisfyPending(L, self); if (self->renderCtx == nullptr) { luaL_error(L, "Canvas: renderCtx not initialized"); } auto* scriptingContext = static_cast<ScriptingContext*>(lua_getthreaddata(L)); - if (scriptingContext == nullptr || !scriptingContext->canvasDrawingPhase()) + // Recording brackets the content instead of opening a real frame; an + // immediate context cannot nest one inside the open screen frame. + Context* recordingOre = getOreContext(L); + if (scriptingContext == nullptr || recordingOre == nullptr || + !recordingOre->isRecording()) { - luaL_error(L, "Canvas:beginFrame() called outside drawing phase"); + luaL_error(L, "Canvas:beginFrame() requires the deferred recorder"); } if (self->m_state != CanvasState::Idle) { @@ -3261,16 +3394,35 @@ lua_pop(L, 1); } - self->renderCtx->beginFrame(desc); - - // Allocate a RiveRenderer that issues into this render context. - // Deleted in endFrame() (or in the destructor if endFrame is never called). - self->m_riveRenderer = new RiveRenderer(self->renderCtx); + // A deferred host hands back a recorder instead of opening a real + // RenderContext frame, the real canvas frame opens at replay. + Renderer* renderer = nullptr; + if (auto* host = scriptingContext->deferredCanvasHost()) + { + self->m_deferredHost = host; + renderer = + host->beginCanvasContent(self->canvas.get(), desc.clearColor); + } + else + { + self->renderCtx->beginFrame(desc); + // Allocate a RiveRenderer that issues into this render context. Deleted + // in endFrame() (or in the destructor if endFrame is never called). + self->m_riveRenderer = new RiveRenderer(self->renderCtx); + renderer = self->m_riveRenderer; + } self->m_state = CanvasState::Rendering; - // Push a non-owning ScriptedRenderer wrapping our RiveRenderer and keep a + // Track the open frame on the context so the post-error cleanup can close + // it if the script never reaches endFrame. The ref also pins the canvas. + lua_pushvalue(L, 1); + self->m_openFrameRef = lua_ref(L, -1); + lua_pop(L, 1); + scriptingContext->registerOpenCanvasFrame(self->m_openFrameRef); + + // Push a non-owning ScriptedRenderer wrapping our renderer and keep a // registry ref so the Lua object stays alive until endFrame(). - lua_newrive<ScriptedRenderer>(L, self->m_riveRenderer); + lua_newrive<ScriptedRenderer>(L, renderer); lua_pushvalue(L, -1); self->m_rendererRef = lua_ref(L, -1); lua_pop(L, 1); // pop the extra copy used for ref; original stays on stack @@ -3278,14 +3430,18 @@ return 1; // returns the ScriptedRenderer } -// Flush all pending Rive draw calls for this frame to the canvas render target, -// then release the renderer. Must be called after beginFrame(). -static int canvashandle_endframe(lua_State* L) +// The body of Canvas:endFrame, shared with the post-error orphan cleanup. +static void canvasEndFrameImpl(lua_State* L, ScriptedCanvas* self) { - auto* self = lua_torive<ScriptedCanvas>(L, 1); - if (self->m_state != CanvasState::Rendering) + if (self->m_openFrameRef != LUA_NOREF) { - luaL_error(L, "Canvas:endFrame() called without beginFrame()"); + auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L)); + if (context != nullptr) + { + context->unregisterOpenCanvasFrame(self->m_openFrameRef); + } + lua_unref(L, self->m_openFrameRef); + self->m_openFrameRef = LUA_NOREF; } // Null out the ScriptedRenderer's pointer so it can no longer issue draws. @@ -3303,6 +3459,15 @@ self->m_rendererRef = LUA_NOREF; } + // Close the content bracket, the real canvas frame flushes at replay. + if (self->m_deferredHost != nullptr) + { + self->m_deferredHost->endCanvasContent(self->canvas.get()); + self->m_deferredHost = nullptr; + self->m_state = CanvasState::Idle; + return; + } + // Create a command buffer, flush the render context into the canvas // render target, then commit. Without a proper command buffer the // buffer ring mutex would never be unlocked (the completion handler @@ -3320,7 +3485,18 @@ delete self->m_riveRenderer; self->m_riveRenderer = nullptr; self->m_state = CanvasState::Idle; +} +// Flush all pending Rive draw calls for this frame to the canvas render target, +// then release the renderer. Must be called after beginFrame(). +static int canvashandle_endframe(lua_State* L) +{ + auto* self = lua_torive<ScriptedCanvas>(L, 1); + if (self->m_state != CanvasState::Rendering) + { + luaL_error(L, "Canvas:endFrame() called without beginFrame()"); + } + canvasEndFrameImpl(L, self); return 0; } @@ -3328,7 +3504,8 @@ { auto* self = (ScriptedCanvas*)udata; lua_userdatadirectfield_setnumber(result, - self->canvas ? self->canvas->width() : 0); + self->canvas ? self->canvas->width() + : self->pendingWidth); } static void canvashandle_direct_height(void* udata, void* result) @@ -3336,7 +3513,7 @@ auto* self = (ScriptedCanvas*)udata; lua_userdatadirectfield_setnumber(result, self->canvas ? self->canvas->height() - : 0); + : self->pendingHeight); } static int canvashandle_index(lua_State* L) @@ -3348,6 +3525,7 @@ luaL_typeerrorL(L, 2, lua_typename(L, LUA_TSTRING)); } auto* self = lua_torive<ScriptedCanvas>(L, 1); + canvas_satisfyPending(L, self); switch (atom) { case (int)LuaAtoms::image: @@ -3359,10 +3537,14 @@ lua_pushnil(L); return 1; case (int)LuaAtoms::width: - lua_pushnumber(L, self->canvas ? self->canvas->width() : 0); + lua_pushnumber(L, + self->canvas ? self->canvas->width() + : self->pendingWidth); return 1; case (int)LuaAtoms::height: - lua_pushnumber(L, self->canvas ? self->canvas->height() : 0); + lua_pushnumber(L, + self->canvas ? self->canvas->height() + : self->pendingHeight); return 1; } luaL_error(L, "'%s' is not a valid index of Canvas", key); @@ -3629,20 +3811,6 @@ return 0; } - // Safe cast — returns nullptr if the image isn't GPU-backed. - auto* riveImage = lite_rtti_cast<RiveRenderImage*>(self->image.get()); - if (!riveImage) - { - luaL_error(L, "Image is not a GPU-backed RiveRenderImage"); - return 0; - } - gpu::Texture* sourceGpuTex = riveImage->getTexture(); - if (!sourceGpuTex) - { - luaL_error(L, "Image GPU texture not available"); - return 0; - } - // Get ore::Context from scripting context. auto* ctx = static_cast<ScriptingContext*>(lua_getthreaddata(L)); auto* oreCtx = static_cast<ore::Context*>(ctx->oreContext()); @@ -3652,8 +3820,47 @@ return 0; } - if (!self->cachedOreView) + if (!self->cachedOreView && oreCtx->isRecording()) { + // Image:view() must not touch the driver while recording, so record + // by resource id and let the consumer wrap at replay. + if (auto* deferredImage = + lite_rtti_cast<rive::cmd::DeferredRenderImage*>( + self->image.get())) + { + self->cachedOreView = + oreCtx->recordWrapImageView(deferredImage->id(), + self->image->width(), + self->image->height()); + } + else + { + self->cachedOreView = + oreCtx->recordWrapCanvasImage(self->image.get(), + self->image->width(), + self->image->height()); + } + if (!self->cachedOreView) + { + luaL_error(L, "Image:view() recording failed"); + return 0; + } + } + else if (!self->cachedOreView) + { + // Immediate mode requires a live GPU backed image. + auto* riveImage = lite_rtti_cast<RiveRenderImage*>(self->image.get()); + if (!riveImage) + { + luaL_error(L, "Image is not a GPU-backed RiveRenderImage"); + return 0; + } + gpu::Texture* sourceGpuTex = riveImage->getTexture(); + if (!sourceGpuTex) + { + luaL_error(L, "Image GPU texture not available"); + return 0; + } // GL canvas-import boundary: on GL/WebGL, sampling a Rive 2D // RenderCanvas as a WGSL texture requires a Y-flipped companion // because PLS renders the canvas bottom-up while WGSL expects @@ -3728,6 +3935,39 @@ context->printError(L); lua_pop(L, 1); } + +void rive_lua_closeOrphanCanvasFrames(lua_State* L) +{ + auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L)); + if (context == nullptr) + { + return; + } + auto refs = context->takeOpenCanvasFrames(); + if (refs.empty()) + { + return; + } + for (int ref : refs) + { + rive_lua_pushRef(L, ref); + if (!lua_isnil(L, -1)) + { + auto* canvas = lua_torive<ScriptedCanvas>(L, -1); + if (canvas != nullptr && canvas->m_state == CanvasState::Rendering) + { + // Also releases the refs, including this one. + canvasEndFrameImpl(L, canvas); + } + } + lua_pop(L, 1); + } + lua_pushstring(L, + "Canvas frame left open at script return. " + "Call canvas:endFrame() before returning."); + context->printError(L); + lua_pop(L, 1); +} } // namespace rive #endif // RIVE_CANVAS && RIVE_ORE
diff --git a/src/lua/rive_lua_libs.cpp b/src/lua/rive_lua_libs.cpp index 3ac39e6..41ac42e 100644 --- a/src/lua/rive_lua_libs.cpp +++ b/src/lua/rive_lua_libs.cpp
@@ -293,7 +293,6 @@ {"canvas", (int16_t)LuaAtoms::canvas}, {"gpuCanvas", (int16_t)LuaAtoms::gpuCanvas}, {"features", (int16_t)LuaAtoms::features}, - {"drawCanvas", (int16_t)LuaAtoms::drawCanvas}, {"shader", (int16_t)LuaAtoms::shader}, {"format", (int16_t)LuaAtoms::format}, {"andThen", (int16_t)LuaAtoms::andThen}, @@ -453,6 +452,7 @@ int ret = context->pCall(state, nargs, nresults); #ifdef RIVE_ORE rive_lua_closeOrphanRenderPass(state); + rive_lua_closeOrphanCanvasFrames(state); #endif return ret; } @@ -468,6 +468,7 @@ int ret = context->pCall(state, nargs, nresults); #ifdef RIVE_ORE rive_lua_closeOrphanRenderPass(state); + rive_lua_closeOrphanCanvasFrames(state); #endif return ret; }
diff --git a/src/nested_artboard.cpp b/src/nested_artboard.cpp index 15fcbe6..b72b945 100644 --- a/src/nested_artboard.cpp +++ b/src/nested_artboard.cpp
@@ -280,7 +280,11 @@ if (artboard != nullptr) { - auto artboardInstance = artboard->instance(); + // The host's factory so a databound swap, possibly from another + // file, keeps the nested content on the hosting instance's session. + auto artboardInstance = artboard->instance<ArtboardInstance>( + this->artboard() != nullptr ? this->artboard()->factory() + : nullptr); if (artboard->stateMachineCount() > 0) {
diff --git a/src/scripted/scripted_drawable.cpp b/src/scripted/scripted_drawable.cpp index 810f6ad..895e650 100644 --- a/src/scripted/scripted_drawable.cpp +++ b/src/scripted/scripted_drawable.cpp
@@ -16,6 +16,16 @@ addDirt(ComponentDirt::Paint); } +void ScriptedDrawable::didReinit() +{ + // A paused editor never ticks scripts, so advance and update driven + // content, like gpu canvas fills, would stay blank until play; force one + // zero step and an update to re-record it. + m_isAdvanceActive = true; + m_forceAdvance = true; + addDirt(ComponentDirt::Paint | ComponentDirt::ScriptUpdate); +} + void ScriptedDrawable::draw(Renderer* renderer) { if (!draws() || m_vm == nullptr) @@ -376,7 +386,9 @@ bool ScriptedDrawable::advanceComponent(float elapsedSeconds, AdvanceFlags flags) { - if (elapsedSeconds == 0) + bool forced = m_forceAdvance; + m_forceAdvance = false; + if (elapsedSeconds == 0 && !forced) { return false; }
diff --git a/src/scripted/scripted_layout.cpp b/src/scripted/scripted_layout.cpp index 20bb6b7..7c331a5 100644 --- a/src/scripted/scripted_layout.cpp +++ b/src/scripted/scripted_layout.cpp
@@ -43,7 +43,9 @@ LUA_OK) { // Stack: [self, status] - fprintf(stderr, "resize failed\n"); + fprintf(stderr, + "resize failed: %s\n", + lua_tostring(L, -1) ? lua_tostring(L, -1) : "?"); rive_lua_pop(L, 1); } // Stack: [self]
diff --git a/src/scripted/scripted_object.cpp b/src/scripted/scripted_object.cpp index 2eff633..45791e0 100644 --- a/src/scripted/scripted_object.cpp +++ b/src/scripted/scripted_object.cpp
@@ -203,29 +203,6 @@ return result; } -void ScriptedObject::scriptDrawCanvas() -{ - lua_State* L = state(); - if (!drawsCanvas() || L == nullptr) - { - return; - } - rive_lua_pushRef(L, m_self); - if (static_cast<lua_Type>(lua_getfield(L, -1, "drawCanvas")) != - LUA_TFUNCTION) - { - rive_lua_pop(L, 2); // non-function field + self - return; - } - lua_pushvalue(L, -2); - if (static_cast<lua_Status>(rive_lua_pcall(L, 1, 0)) != LUA_OK) - { - rive_lua_pop(L, 1); - return; - } - rive_lua_pop(L, 1); -} - void ScriptedObject::scriptUpdate() { lua_State* L = state(); @@ -520,8 +497,6 @@ bool ScriptedObject::scriptAdvance(float elapsedSeconds) { return false; } -void ScriptedObject::scriptDrawCanvas() {} - void ScriptedObject::scriptUpdate() {} void ScriptedObject::scriptDispose() {} @@ -536,6 +511,7 @@ scriptAsset()->initScriptedObject(this); #ifdef WITH_RIVE_SCRIPTING hydrateScriptInputs(); + didReinit(); #endif } }
diff --git a/src/shapes/mesh.cpp b/src/shapes/mesh.cpp index 633704b..7dbffdb 100644 --- a/src/shapes/mesh.cpp +++ b/src/shapes/mesh.cpp
@@ -86,13 +86,11 @@ Core* Mesh::clone() const { - auto factory = artboard()->factory(); auto clone = static_cast<Mesh*>(MeshBase::clone()); clone->m_VertexRenderBufferDirty = true; - clone->m_VertexRenderBuffer = - factory->makeRenderBuffer(RenderBufferType::vertex, - RenderBufferFlags::none, - m_Vertices.size() * sizeof(Vec2D)); + // The vertex buffer is created lazily at first draw so it lands on the + // instance's factory, not the source artboard's. UV and index buffers + // are immutable and shared across instances. clone->m_UVRenderBuffer = m_UVRenderBuffer; clone->m_IndexRenderBuffer = m_IndexRenderBuffer; return clone; @@ -178,6 +176,14 @@ BlendMode blendMode, float opacity) { + if (m_VertexRenderBufferDirty && m_VertexRenderBuffer == nullptr && + !m_Vertices.empty()) + { + m_VertexRenderBuffer = artboard()->factory()->makeRenderBuffer( + RenderBufferType::vertex, + RenderBufferFlags::none, + m_Vertices.size() * sizeof(Vec2D)); + } if (m_VertexRenderBufferDirty && m_VertexRenderBuffer != nullptr) { Vec2D* mappedVertices =
diff --git a/tests/gm/gmmain.cpp b/tests/gm/gmmain.cpp index 2640c2c..b66c7e8 100644 --- a/tests/gm/gmmain.cpp +++ b/tests/gm/gmmain.cpp
@@ -26,6 +26,16 @@ static bool verbose = false; static int loopCount = 1; std::vector<std::tuple<std::function<GM*(void)>, std::string>> gmRegistry; +// Deferred parity families. Each runs its immediate GM and every deferred +// variant in-process and requires byte identical frames, so the machinery +// carries no goldens of its own; the scenes' pixels are the renderer GMs' job. +std::vector<std::tuple<std::vector<std::function<GM*(void)>>, std::string>> + parityRegistry; +static int parityFailures = 0; +// Zero demands byte identical frames. Atomic backends rasterize in a +// nondeterministic order run to run, so they get the same small tolerance the +// golden diffs allow instead of exactness no two of their frames ever had. +static int parityMaxChannelDiff = 0; extern "C" void gms_build_registry() { // Only call gms_build_registry() once! @@ -35,6 +45,24 @@ extern GM* RIVE_MACRO_CONCAT(make_, NAME)(); \ gmRegistry.emplace_back(RIVE_MACRO_CONCAT(make_, NAME), #NAME); +#define MAKE_PARITY_GM2(NAME, IMM, VAR) \ + extern GM* RIVE_MACRO_CONCAT(make_, IMM)(); \ + extern GM* RIVE_MACRO_CONCAT(make_, VAR)(); \ + parityRegistry.emplace_back( \ + std::vector<std::function<GM*(void)>>{RIVE_MACRO_CONCAT(make_, IMM), \ + RIVE_MACRO_CONCAT(make_, VAR)}, \ + #NAME); + +#define MAKE_PARITY_GM3(NAME, IMM, VAR1, VAR2) \ + extern GM* RIVE_MACRO_CONCAT(make_, IMM)(); \ + extern GM* RIVE_MACRO_CONCAT(make_, VAR1)(); \ + extern GM* RIVE_MACRO_CONCAT(make_, VAR2)(); \ + parityRegistry.emplace_back( \ + std::vector<std::function<GM*(void)>>{RIVE_MACRO_CONCAT(make_, IMM), \ + RIVE_MACRO_CONCAT(make_, VAR1), \ + RIVE_MACRO_CONCAT(make_, VAR2)}, \ + #NAME); + // Add slow GMs first so they get more time to run in a multiprocess // execution. MAKE_GM(hittest_nonZero) @@ -190,6 +218,11 @@ MAKE_GM(render_canvas_persistence) MAKE_GM(render_canvas_prepass) MAKE_GM(render_canvas_prepass_multi) +#ifdef WITH_RIVE_SCRIPTING + MAKE_GM(canvas_dag_chain) + MAKE_GM(canvas_dag_chain_reversed) + MAKE_GM(canvas_dag_cycle) +#endif #if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) || \ defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) || \ defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) || \ @@ -215,8 +248,32 @@ MAKE_GM(ore_layout_reuse) MAKE_GM(ore_layout_mismatch) MAKE_GM(ore_depth_only_pipeline) + MAKE_PARITY_GM3(ore_deferred_replay, + ore_deferred_replay_immediate, + ore_deferred_replay, + ore_deferred_replay_inline) + MAKE_PARITY_GM2(ore_deferred_multipass, + ore_deferred_multipass_immediate, + ore_deferred_multipass) + MAKE_PARITY_GM3(ore_deferred_resource, + ore_deferred_resource_immediate, + ore_deferred_resource, + ore_deferred_resource_unified) + MAKE_PARITY_GM2(ore_deferred_context, + ore_deferred_context_immediate, + ore_deferred_context) + MAKE_PARITY_GM2(render_deferred_canvas, + render_deferred_canvas_immediate, + render_deferred_canvas) #endif #endif + // 2D only so these are not gated on Ore. + MAKE_PARITY_GM2(serialized_replay_2d, + serialized_replay_2d_immediate, + serialized_replay_2d) + MAKE_PARITY_GM2(render_deferred_2d, + render_deferred_2d_immediate, + render_deferred_2d) } static void dump_gm(GM* gm, const std::string& name) @@ -249,6 +306,61 @@ } } +static void run_parity_gm(const std::vector<std::function<GM*(void)>>& makers, + const std::string& name) +{ + if (verbose) + { + printf("[gms] Running parity %s...\n", name.c_str()); + } + std::vector<uint8_t> immediate; + std::vector<uint8_t> variant; + for (size_t i = 0; i < makers.size(); ++i) + { + std::unique_ptr<GM> gm(makers[i]()); + if (!gm) + { + return; + } + TestingWindow::Get()->resize(gm->width(), gm->height()); + gm->onceBeforeDraw(); + std::vector<uint8_t>* out = i == 0 ? &immediate : &variant; + out->clear(); + gm->run(name.c_str(), out); + if (i == 0) + { + continue; + } + bool match = variant.size() == immediate.size(); + int worst = 0; + if (match && parityMaxChannelDiff > 0) + { + for (size_t b = 0; b < variant.size(); ++b) + { + int diff = std::abs(static_cast<int>(variant[b]) - + static_cast<int>(immediate[b])); + worst = std::max(worst, diff); + } + match = worst <= parityMaxChannelDiff; + } + else if (match) + { + match = variant == immediate; + } + if (!match) + { + parityFailures++; + fprintf(stderr, + "[gms] PARITY FAILED: %s variant %zu does not match the " + "immediate frame (worst channel diff %d, allowed %d)\n", + name.c_str(), + i, + worst, + parityMaxChannelDiff); + } + } +} + static bool contains(const std::string& str, const std::string& substr) { auto pos = str.find(substr, 0); @@ -310,6 +422,25 @@ emscripten_sleep(1); #endif } + + for (const auto& [makers, name] : parityRegistry) + { + if (match.size() && !contains(name, match)) + { + continue; + } + // Claimed like any GM so one worker runs each family; they store no + // golden, the claim only partitions the work. + if (!TestHarness::Instance().claimGMTest(name)) + { + continue; + } + run_parity_gm(makers, name); + TestingWindow::Get()->onceAfterGM(); +#ifdef __EMSCRIPTEN__ + emscripten_sleep(1); +#endif + } } static bool is_arg(const char arg[], @@ -522,6 +653,7 @@ visibility = TestingWindow::Visibility::fullscreen; } #endif + parityMaxChannelDiff = backendParams.atomic ? 8 : 0; TestingWindow::Init(backend, backendParams, visibility, platformWindow); #ifndef RIVE_UNREAL // unreal calls this directly instead gms_build_registry(); @@ -529,7 +661,15 @@ dumpGMs(std::string(match), interactive); + if (parityFailures != 0) + { + fprintf(stderr, "[gms] %d parity failures\n", parityFailures); + fflush(stderr); + abort(); + } + gmRegistry.clear(); + parityRegistry.clear(); TestingWindow::Destroy(); // Exercise our PLS teardown process now that // we're done. TestHarness::Instance().shutdown();
diff --git a/tests/gm/ore_deferred_context.cpp b/tests/gm/ore_deferred_context.cpp new file mode 100644 index 0000000..3a1283c --- /dev/null +++ b/tests/gm/ore_deferred_context.cpp
@@ -0,0 +1,132 @@ +/* + * Copyright 2026 Rive + * + * Builds the same triangle through the same API on the real ore Context and on + * a DeferredOreContext whose replay creates the real resources. The goldens + * must be byte identical. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "ore_gm_helper.hpp" +#if ORE_GM_HAS_BACKEND +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/ore/ore_buffer.hpp" +#include "rive/renderer/ore/ore_pipeline.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_context.hpp" +#endif + +using namespace rivegm; +using namespace rive; +using namespace rive::gpu; +#if ORE_GM_HAS_BACKEND +using namespace rive::ore; +// Disambiguates from rive::cmd. +#endif + +class OreDeferredContextGM : public GM +{ +public: + OreDeferredContextGM(bool deferred) : GM(256, 256), m_deferred(deferred) {} + + ColorInt clearColor() const override { return 0xff000000; } + + void onDraw(rive::Renderer* originalRenderer) override + { + auto renderContext = TestingWindow::Get()->renderContext(); + if (!renderContext || !m_ore.ensureContext(renderContext)) + return; + +#if ORE_GM_HAS_BACKEND + auto& realCtx = *renderContext->getOreContext(); + auto canvas = renderContext->makeRenderCanvas(256, 256); + if (!canvas) + return; + + // Same call sites for both modes, only ctx differs. The deferred + // context delegates wrapCanvasTexture to the real one. + ore::cmd::DeferredOreContext dctx(&realCtx); + Context& ctx = m_deferred ? static_cast<Context&>(dctx) : realCtx; + + auto colorTarget = ctx.wrapCanvasTexture(canvas.get()); + if (!colorTarget) + return; + + auto shader = ore_gm::loadShader(ctx, ore_gm::kTriangle); + if (!shader.vsModule) + return; + + BufferDesc bd{}; + bd.usage = BufferUsage::vertex; + bd.size = sizeof(ore_gm::kTriVertices); + bd.data = ore_gm::kTriVertices; + bd.label = "ore_deferred_context_vb"; + auto vb = ctx.makeBuffer(bd); + if (!vb) + return; + + ore_gm::TrianglePipeline tri(shader, + colorTarget->texture()->format(), + "ore_deferred_context_pipeline"); + auto pipeline = ctx.makePipeline(tri.desc); + if (!pipeline) + { + fprintf(stderr, + "[ore_deferred_context] pipeline failed: %s\n", + realCtx.lastError().c_str()); + return; + } + + ColorAttachment ca{}; + ca.view = colorTarget.get(); + ca.loadOp = LoadOp::clear; + ca.storeOp = StoreOp::store; + ca.clearColor = {0.1f, 0.1f, 0.15f, 1.0f}; + RenderPassDesc rpDesc{}; + rpDesc.colorAttachments[0] = ca; + rpDesc.colorCount = 1; + rpDesc.label = "ore_deferred_context_pass"; + + auto issuePass = [&](Context& c) { + auto pass = c.beginRenderPass(rpDesc); + pass->setPipeline(pipeline.get()); + pass->setVertexBuffer(0, vb.get()); + pass->setViewport(0, 0, 256, 256); + pass->draw(3); + pass->finish(); + }; + + if (m_deferred) + { + issuePass(dctx); + m_ore.beginFrame(renderContext); + dctx.replay(realCtx); + m_ore.endFrame(renderContext); + } + else + { + m_ore.beginFrame(renderContext); + issuePass(realCtx); // immediate GPU work needs an active frame + m_ore.endFrame(renderContext); + } + + ore_gm::invalidateGLStateAfterOre(renderContext); + + originalRenderer->save(); + originalRenderer->drawImage(canvas->renderImage(), + {.filter = ImageFilter::nearest}, + BlendMode::srcOver, + 1); + originalRenderer->restore(); +#endif + } + +private: + bool m_deferred; + ore_gm::OreGMContext m_ore; +}; + +GMREGISTER(ore_deferred_context_immediate, + return new OreDeferredContextGM(false)) +GMREGISTER(ore_deferred_context, return new OreDeferredContextGM(true))
diff --git a/tests/gm/ore_deferred_context.mm b/tests/gm/ore_deferred_context.mm new file mode 100644 index 0000000..5c02442 --- /dev/null +++ b/tests/gm/ore_deferred_context.mm
@@ -0,0 +1,2 @@ +// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple. +#include "ore_deferred_context.cpp"
diff --git a/tests/gm/ore_deferred_multipass.cpp b/tests/gm/ore_deferred_multipass.cpp new file mode 100644 index 0000000..2c6d2ab --- /dev/null +++ b/tests/gm/ore_deferred_multipass.cpp
@@ -0,0 +1,227 @@ +/* + * Copyright 2026 Rive + * + * Renders a triangle into canvas A then samples A into canvas B, immediately + * and via one recorded command buffer. Sequential replay must preserve the + * pass dependency. The goldens must be byte identical. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "ore_gm_helper.hpp" +#if ORE_GM_HAS_BACKEND +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/ore/ore_buffer.hpp" +#include "rive/renderer/ore/ore_sampler.hpp" +#include "rive/renderer/ore/ore_bind_group.hpp" +#include "rive/renderer/ore/ore_pipeline.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp" +#include "rive/renderer/ore/cmd/ore_replay.hpp" +#endif + +using namespace rivegm; +using namespace rive; +using namespace rive::gpu; +#if ORE_GM_HAS_BACKEND +using namespace rive::ore; +// Disambiguates from rive::cmd. +#endif + +enum class MPMode +{ + kImmediate, + kDeferred, +}; + +class OreDeferredMultipassGM : public GM +{ +public: + OreDeferredMultipassGM(MPMode mode) : GM(256, 256), m_mode(mode) {} + + ColorInt clearColor() const override { return 0xff000000; } + + void onDraw(rive::Renderer* originalRenderer) override + { + auto renderContext = TestingWindow::Get()->renderContext(); + if (!renderContext || !m_ore.ensureContext(renderContext)) + return; + +#if ORE_GM_HAS_BACKEND + auto& ctx = *renderContext->getOreContext(); + + // Canvas A is the producer and B the consumer. + auto canvasA = renderContext->makeRenderCanvas(256, 256); + auto canvasB = renderContext->makeRenderCanvas(256, 256); + if (!canvasA || !canvasB) + return; + auto targetA = ctx.wrapCanvasTexture(canvasA.get()); + auto targetB = ctx.wrapCanvasTexture(canvasB.get()); + if (!targetA || !targetB) + return; + + // Pass 1 resources. + BufferDesc bd{}; + bd.usage = BufferUsage::vertex; + bd.size = sizeof(ore_gm::kTriVertices); + bd.data = ore_gm::kTriVertices; + bd.label = "ore_deferred_multipass_vb"; + auto vb = ctx.makeBuffer(bd); + if (!vb) + return; + + auto triShader = ore_gm::loadShader(ctx, ore_gm::kTriangle); + if (!triShader.vsModule) + return; + + ore_gm::TrianglePipeline tri(triShader, + targetA->texture()->format(), + "ore_deferred_multipass_tri"); + auto triPipeline = ctx.makePipeline(tri.desc); + if (!triPipeline) + { + fprintf(stderr, + "[ore_deferred_multipass] tri pipeline failed: %s\n", + ctx.lastError().c_str()); + return; + } + + // Pass 2 resources. + SamplerDesc sampDesc{}; + sampDesc.minFilter = Filter::nearest; + sampDesc.magFilter = Filter::nearest; + auto sampler = ctx.makeSampler(sampDesc); + + auto imgShader = ore_gm::loadShader(ctx, ore_gm::kImageView); + if (!imgShader.vsModule) + return; + auto layout1 = + ore_gm::makeLayoutFromShader(ctx, imgShader.vsModule.get(), 1); + auto layout2 = + ore_gm::makeLayoutFromShader(ctx, imgShader.vsModule.get(), 2); + BindGroupLayout* layouts[] = {nullptr, layout1.get(), layout2.get()}; + + PipelineDesc imgPd{}; + imgPd.vertexModule = imgShader.vsModule.get(); + imgPd.fragmentModule = imgShader.psModule.get(); + imgPd.vertexEntryPoint = imgShader.vsEntryPoint; + imgPd.fragmentEntryPoint = imgShader.fsEntryPoint; + imgPd.vertexBufferCount = 0; + imgPd.topology = PrimitiveTopology::triangleList; + imgPd.colorTargets[0].format = targetB->texture()->format(); + imgPd.colorCount = 1; + imgPd.depthStencil.depthCompare = CompareFunction::always; + imgPd.depthStencil.depthWriteEnabled = false; + imgPd.bindGroupLayouts = layouts; + imgPd.bindGroupLayoutCount = 3; + imgPd.label = "ore_deferred_multipass_img"; + auto imgPipeline = ctx.makePipeline(imgPd); + if (!imgPipeline) + { + fprintf(stderr, + "[ore_deferred_multipass] img pipeline failed: %s\n", + ctx.lastError().c_str()); + return; + } + + BindGroupDesc texBGDesc{}; + texBGDesc.layout = layout1.get(); + BindGroupDesc::TexEntry texEntry{}; + texEntry.slot = 0; + texEntry.view = targetA.get(); + texBGDesc.textures = &texEntry; + texBGDesc.textureCount = 1; + auto texBG = ctx.makeBindGroup(texBGDesc); + + BindGroupDesc sampBGDesc{}; + sampBGDesc.layout = layout2.get(); + BindGroupDesc::SampEntry sampEntry{}; + sampEntry.slot = 0; + sampEntry.sampler = sampler.get(); + sampBGDesc.samplers = &sampEntry; + sampBGDesc.samplerCount = 1; + auto sampBG = ctx.makeBindGroup(sampBGDesc); + + ColorAttachment caA{}; + caA.view = targetA.get(); + caA.loadOp = LoadOp::clear; + caA.storeOp = StoreOp::store; + caA.clearColor = {0.1f, 0.1f, 0.15f, 1.0f}; + RenderPassDesc rpA{}; + rpA.colorAttachments[0] = caA; + rpA.colorCount = 1; + rpA.label = "ore_deferred_multipass_passA"; + + ColorAttachment caB{}; + caB.view = targetB.get(); + caB.loadOp = LoadOp::clear; + caB.storeOp = StoreOp::store; + caB.clearColor = {0, 0, 0, 1}; + RenderPassDesc rpB{}; + rpB.colorAttachments[0] = caB; + rpB.colorCount = 1; + rpB.label = "ore_deferred_multipass_passB"; + + m_ore.beginFrame(renderContext); + + if (m_mode == MPMode::kDeferred) + { + ore::cmd::OreCommandBuffer cmdBuf; + { + ore::cmd::RenderPassRecording p1(&ctx, &cmdBuf, rpA); + p1.setPipeline(triPipeline.get()); + p1.setVertexBuffer(0, vb.get()); + p1.setViewport(0, 0, 256, 256); + p1.draw(3); + p1.finish(); + + ore::cmd::RenderPassRecording p2(&ctx, &cmdBuf, rpB); + p2.setPipeline(imgPipeline.get()); + p2.setBindGroup(1, texBG.get()); + p2.setBindGroup(2, sampBG.get()); + p2.setViewport(0, 0, 256, 256); + p2.draw(6); + p2.finish(); + } + ore::cmd::replayCommandBuffer(ctx, cmdBuf); + } + else + { + auto p1 = ctx.beginRenderPass(rpA); + p1->setPipeline(triPipeline.get()); + p1->setVertexBuffer(0, vb.get()); + p1->setViewport(0, 0, 256, 256); + p1->draw(3); + p1->finish(); + + auto p2 = ctx.beginRenderPass(rpB); + p2->setPipeline(imgPipeline.get()); + p2->setBindGroup(1, texBG.get()); + p2->setBindGroup(2, sampBG.get()); + p2->setViewport(0, 0, 256, 256); + p2->draw(6); + p2->finish(); + } + + m_ore.endFrame(renderContext); + ore_gm::invalidateGLStateAfterOre(renderContext); + + originalRenderer->save(); + originalRenderer->drawImage(canvasB->renderImage(), + {.filter = ImageFilter::nearest}, + BlendMode::srcOver, + 1); + originalRenderer->restore(); +#endif + } + +private: + MPMode m_mode; + ore_gm::OreGMContext m_ore; +}; + +GMREGISTER(ore_deferred_multipass_immediate, + return new OreDeferredMultipassGM(MPMode::kImmediate)) +GMREGISTER(ore_deferred_multipass, + return new OreDeferredMultipassGM(MPMode::kDeferred))
diff --git a/tests/gm/ore_deferred_multipass.mm b/tests/gm/ore_deferred_multipass.mm new file mode 100644 index 0000000..1ffe6d4 --- /dev/null +++ b/tests/gm/ore_deferred_multipass.mm
@@ -0,0 +1,2 @@ +// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple. +#include "ore_deferred_multipass.cpp"
diff --git a/tests/gm/ore_deferred_replay.cpp b/tests/gm/ore_deferred_replay.cpp new file mode 100644 index 0000000..268a3c7 --- /dev/null +++ b/tests/gm/ore_deferred_replay.cpp
@@ -0,0 +1,158 @@ +/* + * Copyright 2026 Rive + * + * Renders one triangle immediately, via record and replay, and via the + * context's inline deferred flag. The goldens must be byte identical. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "ore_gm_helper.hpp" +#if ORE_GM_HAS_BACKEND +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/ore/ore_buffer.hpp" +#include "rive/renderer/ore/ore_pipeline.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp" +#include "rive/renderer/ore/cmd/ore_replay.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_render_pass.hpp" +#endif + +using namespace rivegm; +using namespace rive; +using namespace rive::gpu; +#if ORE_GM_HAS_BACKEND +using namespace rive::ore; +// Disambiguates from rive::cmd. +#endif + +// kInlineDeferred drives the context deferred flag through the same chooser +// the Lua beginRenderPass call site uses. +enum class ReplayMode +{ + kImmediate, + kRecordReplay, + kInlineDeferred, +}; + +class OreDeferredReplayGM : public GM +{ +public: + OreDeferredReplayGM(ReplayMode mode) : GM(256, 256), m_mode(mode) {} + + ColorInt clearColor() const override { return 0xff000000; } + + void onDraw(rive::Renderer* originalRenderer) override + { + auto renderContext = TestingWindow::Get()->renderContext(); + if (!renderContext || !m_ore.ensureContext(renderContext)) + return; + +#if ORE_GM_HAS_BACKEND + auto& ctx = *renderContext->getOreContext(); + auto canvas = renderContext->makeRenderCanvas(256, 256); + if (!canvas) + return; + auto colorTarget = ctx.wrapCanvasTexture(canvas.get()); + if (!colorTarget) + return; + + BufferDesc bd{}; + bd.usage = BufferUsage::vertex; + bd.size = sizeof(ore_gm::kTriVertices); + bd.data = ore_gm::kTriVertices; + bd.label = "ore_deferred_replay_vb"; + auto vb = ctx.makeBuffer(bd); + if (!vb) + return; + + auto shader = ore_gm::loadShader(ctx, ore_gm::kTriangle); + if (!shader.vsModule) + return; + + ore_gm::TrianglePipeline tri(shader, + colorTarget->texture()->format(), + "ore_deferred_replay_pipeline"); + auto pipeline = ctx.makePipeline(tri.desc); + if (!pipeline) + { + fprintf(stderr, + "[ore_deferred_replay] pipeline failed: %s\n", + ctx.lastError().c_str()); + return; + } + + ColorAttachment ca{}; + ca.view = colorTarget.get(); + ca.loadOp = LoadOp::clear; + ca.storeOp = StoreOp::store; + ca.clearColor = {0.1f, 0.1f, 0.15f, 1.0f}; + + RenderPassDesc rpDesc{}; + rpDesc.colorAttachments[0] = ca; + rpDesc.colorCount = 1; + rpDesc.label = "ore_deferred_replay_pass"; + + m_ore.beginFrame(renderContext); + + if (m_mode == ReplayMode::kRecordReplay) + { + // Same calls as the immediate branch below. + ore::cmd::OreCommandBuffer cmdBuf; + { + ore::cmd::RenderPassRecording rec(&ctx, &cmdBuf, rpDesc); + rec.setPipeline(pipeline.get()); + rec.setVertexBuffer(0, vb.get()); + rec.setViewport(0, 0, 256, 256); + rec.draw(3); + rec.finish(); + } + ore::cmd::replayCommandBuffer(ctx, cmdBuf); + } + else if (m_mode == ReplayMode::kInlineDeferred) + { + // finish records and then inline replays. + ctx.setDeferredRecording(true); + auto pass = + ore::cmd::beginRenderPassRecordingOrImmediate(ctx, rpDesc); + pass->setPipeline(pipeline.get()); + pass->setVertexBuffer(0, vb.get()); + pass->setViewport(0, 0, 256, 256); + pass->draw(3); + pass->finish(); + ctx.setDeferredRecording(false); + } + else + { + auto pass = ctx.beginRenderPass(rpDesc); + pass->setPipeline(pipeline.get()); + pass->setVertexBuffer(0, vb.get()); + pass->setViewport(0, 0, 256, 256); + pass->draw(3); + pass->finish(); + } + + m_ore.endFrame(renderContext); + ore_gm::invalidateGLStateAfterOre(renderContext); + + originalRenderer->save(); + originalRenderer->drawImage(canvas->renderImage(), + {.filter = ImageFilter::nearest}, + BlendMode::srcOver, + 1); + originalRenderer->restore(); +#endif + } + +private: + ReplayMode m_mode; + ore_gm::OreGMContext m_ore; +}; + +GMREGISTER(ore_deferred_replay_immediate, + return new OreDeferredReplayGM(ReplayMode::kImmediate)) +GMREGISTER(ore_deferred_replay, + return new OreDeferredReplayGM(ReplayMode::kRecordReplay)) +GMREGISTER(ore_deferred_replay_inline, + return new OreDeferredReplayGM(ReplayMode::kInlineDeferred))
diff --git a/tests/gm/ore_deferred_replay.mm b/tests/gm/ore_deferred_replay.mm new file mode 100644 index 0000000..e3eeca7 --- /dev/null +++ b/tests/gm/ore_deferred_replay.mm
@@ -0,0 +1,2 @@ +// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple. +#include "ore_deferred_replay.cpp"
diff --git a/tests/gm/ore_deferred_resource.cpp b/tests/gm/ore_deferred_resource.cpp new file mode 100644 index 0000000..23f7e33 --- /dev/null +++ b/tests/gm/ore_deferred_resource.cpp
@@ -0,0 +1,164 @@ +/* + * Copyright 2026 Rive + * + * Renders the same triangle from an immediate vertex buffer, a replay created + * buffer, and a deferred buffer remapped at unified replay, all through the + * DeferredOreContext. The goldens must be byte identical. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "ore_gm_helper.hpp" +#if ORE_GM_HAS_BACKEND +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/ore/ore_buffer.hpp" +#include "rive/renderer/ore/ore_pipeline.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_context.hpp" +#include <memory> +#endif + +using namespace rivegm; +using namespace rive; +using namespace rive::gpu; +#if ORE_GM_HAS_BACKEND +using namespace rive::ore; +// Disambiguates from rive::cmd. +#endif + +// kReplayBuffer creates the buffer via replay then draws immediately. +// kUnified records the whole pass against a deferred buffer and a single +// replay creates the real buffer and remaps it. +enum class ResMode +{ + kImmediate, + kReplayBuffer, + kUnified, +}; + +class OreDeferredResourceGM : public GM +{ +public: + OreDeferredResourceGM(ResMode mode) : GM(256, 256), m_mode(mode) {} + + ColorInt clearColor() const override { return 0xff000000; } + + void onDraw(rive::Renderer* originalRenderer) override + { + auto renderContext = TestingWindow::Get()->renderContext(); + if (!renderContext || !m_ore.ensureContext(renderContext)) + return; + +#if ORE_GM_HAS_BACKEND + auto& ctx = *renderContext->getOreContext(); + auto canvas = renderContext->makeRenderCanvas(256, 256); + if (!canvas) + return; + auto colorTarget = ctx.wrapCanvasTexture(canvas.get()); + if (!colorTarget) + return; + + BufferDesc bd{}; + bd.usage = BufferUsage::vertex; + bd.size = sizeof(ore_gm::kTriVertices); + bd.data = ore_gm::kTriVertices; + bd.label = "ore_deferred_resource_vb"; + + auto shader = ore_gm::loadShader(ctx, ore_gm::kTriangle); + if (!shader.vsModule) + return; + + ore_gm::TrianglePipeline tri(shader, + colorTarget->texture()->format(), + "ore_deferred_resource_pipeline"); + auto pipeline = ctx.makePipeline(tri.desc); + if (!pipeline) + { + fprintf(stderr, + "[ore_deferred_resource] pipeline failed: %s\n", + ctx.lastError().c_str()); + return; + } + + ColorAttachment ca{}; + ca.view = colorTarget.get(); + ca.loadOp = LoadOp::clear; + ca.storeOp = StoreOp::store; + ca.clearColor = {0.1f, 0.1f, 0.15f, 1.0f}; + RenderPassDesc rpDesc{}; + rpDesc.colorAttachments[0] = ca; + rpDesc.colorCount = 1; + rpDesc.label = "ore_deferred_resource_pass"; + + if (m_mode == ResMode::kUnified) + { + // The pass is recorded before any real buffer exists; the real + // pipeline and target resolve by flagged index at replay. + ore::cmd::DeferredOreContext dctx(&ctx); + auto vb = dctx.makeBuffer(bd); + { + auto pass = dctx.beginRenderPass(rpDesc); + pass->setPipeline(pipeline.get()); + pass->setVertexBuffer(0, vb.get()); + pass->setViewport(0, 0, 256, 256); + pass->draw(3); + pass->finish(); + } + + m_ore.beginFrame(renderContext); + dctx.replay(ctx); + m_ore.endFrame(renderContext); + } + else + { + rcp<Buffer> vb; + ore::cmd::OreResident table; + std::unique_ptr<ore::cmd::DeferredOreContext> dctx; + if (m_mode == ResMode::kReplayBuffer) + { + dctx = std::make_unique<ore::cmd::DeferredOreContext>(&ctx); + auto deferredVb = dctx->makeBuffer(bd); + dctx->replayFrame(ctx, table); + auto* real = table.get( + static_cast<ore::cmd::DeferredBuffer*>(deferredVb.get()) + ->clientHandle()); + vb = ref_rcp(static_cast<Buffer*>(real)); + } + else + { + vb = ctx.makeBuffer(bd); + } + if (!vb) + return; + + m_ore.beginFrame(renderContext); + auto pass = ctx.beginRenderPass(rpDesc); + pass->setPipeline(pipeline.get()); + pass->setVertexBuffer(0, vb.get()); + pass->setViewport(0, 0, 256, 256); + pass->draw(3); + pass->finish(); + m_ore.endFrame(renderContext); + } + ore_gm::invalidateGLStateAfterOre(renderContext); + + originalRenderer->save(); + originalRenderer->drawImage(canvas->renderImage(), + {.filter = ImageFilter::nearest}, + BlendMode::srcOver, + 1); + originalRenderer->restore(); +#endif + } + +private: + ResMode m_mode; + ore_gm::OreGMContext m_ore; +}; + +GMREGISTER(ore_deferred_resource_immediate, + return new OreDeferredResourceGM(ResMode::kImmediate)) +GMREGISTER(ore_deferred_resource, + return new OreDeferredResourceGM(ResMode::kReplayBuffer)) +GMREGISTER(ore_deferred_resource_unified, + return new OreDeferredResourceGM(ResMode::kUnified))
diff --git a/tests/gm/ore_deferred_resource.mm b/tests/gm/ore_deferred_resource.mm new file mode 100644 index 0000000..a6e1f3a --- /dev/null +++ b/tests/gm/ore_deferred_resource.mm
@@ -0,0 +1,2 @@ +// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple. +#include "ore_deferred_resource.cpp"
diff --git a/tests/gm/ore_gm_helper.hpp b/tests/gm/ore_gm_helper.hpp index ebe1617..909ced1 100644 --- a/tests/gm/ore_gm_helper.hpp +++ b/tests/gm/ore_gm_helper.hpp
@@ -13,16 +13,23 @@ #include "rive/renderer/render_context.hpp" #include <array> #include <cassert> +#include <cstddef> #include <cstdio> #include <cstring> #include <unordered_map> -// Include Ore headers when any backend is compiled. -// Multiple backends may be active simultaneously (e.g. Metal + GL on macOS). +// True when any Ore backend is compiled. Multiple backends may be active +// simultaneously (e.g. Metal + GL on macOS). Source of truth for every GM. #if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) || \ defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) || \ defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) || \ defined(ORE_BACKEND_RHI) +#define ORE_GM_HAS_BACKEND 1 +#else +#define ORE_GM_HAS_BACKEND 0 +#endif + +#if ORE_GM_HAS_BACKEND #include "rive/renderer/ore/ore_context.hpp" #include <memory> #endif @@ -67,10 +74,7 @@ #include "rive/renderer/vulkan/render_context_vulkan_impl.hpp" #endif -#if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) || \ - defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) || \ - defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) || \ - defined(ORE_BACKEND_RHI) +#if ORE_GM_HAS_BACKEND #include "ore_gm_shaders.rstb.hpp" #include "rive/renderer/ore/ore_rstb_entry_container.hpp" #include "rive/assets/shader_asset.hpp" @@ -141,10 +145,7 @@ bool ensureContext(rive::gpu::RenderContext* renderContext) { -#if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) || \ - defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) || \ - defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) || \ - defined(ORE_BACKEND_RHI) +#if ORE_GM_HAS_BACKEND if (!renderContext || !isOreBackendActive()) return false; @@ -314,10 +315,7 @@ // ShaderTarget constants (must match RSTB format): // 0=WGSL, 1=GLSL_ES3, 2=MSL, 3=HLSL_SM5, 5=SPIR-V -#if defined(ORE_BACKEND_METAL) || defined(ORE_BACKEND_D3D11) || \ - defined(ORE_BACKEND_D3D12) || defined(ORE_BACKEND_GL) || \ - defined(ORE_BACKEND_WGPU) || defined(ORE_BACKEND_VK) || \ - defined(ORE_BACKEND_RHI) +#if ORE_GM_HAS_BACKEND // Keeps GM shader asset ids clear of riv asset ids and 0 (unset). constexpr uint32_t kOreGMShaderAssetIdBase = 0x80000000u; @@ -731,6 +729,53 @@ return ctx.makeBindGroupLayout(desc); } +// Shared triangle pass used by the deferred GMs. +struct TriVertex +{ + float x, y; + float r, g, b, a; +}; + +inline constexpr TriVertex kTriVertices[] = { + {0.0f, 0.6f, 1.0f, 0.2f, 0.2f, 1.0f}, + {-0.6f, -0.6f, 0.2f, 1.0f, 0.2f, 1.0f}, + {0.6f, -0.6f, 0.2f, 0.2f, 1.0f, 1.0f}, +}; + +// desc points into attrs and layout, so this object must stay alive through +// makePipeline and cannot be copied. +struct TrianglePipeline +{ + TrianglePipeline(const OreGMShaderResult& shader, + rive::ore::TextureFormat targetFormat, + const char* label) + { + layout.stride = sizeof(TriVertex); + layout.stepMode = rive::ore::VertexStepMode::vertex; + layout.attributes = attrs; + layout.attributeCount = 2; + desc.vertexModule = shader.vsModule.get(); + desc.fragmentModule = shader.psModule.get(); + desc.vertexEntryPoint = shader.vsEntryPoint; + desc.fragmentEntryPoint = shader.fsEntryPoint; + desc.vertexBuffers = &layout; + desc.vertexBufferCount = 1; + desc.topology = rive::ore::PrimitiveTopology::triangleList; + desc.colorTargets[0].format = targetFormat; + desc.colorCount = 1; + desc.label = label; + } + TrianglePipeline(const TrianglePipeline&) = delete; + TrianglePipeline& operator=(const TrianglePipeline&) = delete; + + rive::ore::VertexAttribute attrs[2] = { + {rive::ore::VertexFormat::float2, offsetof(TriVertex, x), 0}, + {rive::ore::VertexFormat::float4, offsetof(TriVertex, r), 1}, + }; + rive::ore::VertexBufferLayout layout{}; + rive::ore::PipelineDesc desc{}; +}; + #endif // ORE_BACKEND_* } // namespace ore_gm
diff --git a/tests/gm/ore_gm_shaders.rstb b/tests/gm/ore_gm_shaders.rstb new file mode 100644 index 0000000..efae04c --- /dev/null +++ b/tests/gm/ore_gm_shaders.rstb Binary files differ
diff --git a/tests/gm/ore_render_deferred_canvas.cpp b/tests/gm/ore_render_deferred_canvas.cpp new file mode 100644 index 0000000..a0ce638 --- /dev/null +++ b/tests/gm/ore_render_deferred_canvas.cpp
@@ -0,0 +1,148 @@ +/* + * Copyright 2026 Rive + * + * Ore clears a canvas and the 2D screen draws that canvas image, immediately + * and through a DeferredSession drained by the shared DeferredReplayer. The + * canvas image travels the stream as a shared id, never a pointer. The goldens + * must be byte identical. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "ore_gm_helper.hpp" +#if ORE_GM_HAS_BACKEND +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/ore/ore_render_pass.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_context.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "rive/renderer/cmd/deferred_replayer.hpp" +#endif + +using namespace rivegm; +using namespace rive; +using namespace rive::gpu; +#if ORE_GM_HAS_BACKEND +using namespace rive::ore; +// Disambiguates from rive::cmd. + +// The GM is handed an already open screen renderer, so beginScreenFrame just +// returns it. +class GMCanvasSink : public rive::cmd::DeferredFrameSink +{ +public: + GMCanvasSink(rive::gpu::RenderContext* rc, + rive::Renderer* screen, + ore_gm::OreGMContext* oreCtx) : + m_rc(rc), m_screen(screen), m_ore(oreCtx) + {} + rive::Factory* factory() override + { + return TestingWindow::Get()->factory(); + } + // The GM hands over one already open screen renderer, so there is nothing + // to dispatch on. + rive::Renderer* beginScreenFrame(uint64_t target) override + { + assert(target == 0); + return m_screen; + } + void beginOreFrame() override { m_ore->beginFrame(m_rc); } + void endOreFrame() override { m_ore->endFrame(m_rc); } + void afterOreFrame() override { ore_gm::invalidateGLStateAfterOre(m_rc); } + +private: + rive::gpu::RenderContext* m_rc; + rive::Renderer* m_screen; + ore_gm::OreGMContext* m_ore; +}; +#endif + +class RenderDeferredCanvasGM : public GM +{ +public: + RenderDeferredCanvasGM(bool deferred) : GM(256, 256), m_deferred(deferred) + {} + + ColorInt clearColor() const override { return 0xff202028; } + + void onDraw(rive::Renderer* originalRenderer) override + { + auto renderContext = TestingWindow::Get()->renderContext(); + if (!renderContext || !m_ore.ensureContext(renderContext)) + { + return; + } + +#if ORE_GM_HAS_BACKEND + auto& realCtx = *renderContext->getOreContext(); + auto canvas = renderContext->makeRenderCanvas(200, 200); + if (!canvas) + { + return; + } + + ImageSampler sampler{}; + sampler.filter = ImageFilter::nearest; + + auto recordClear = [&](Context& ctx, TextureView* view) { + ColorAttachment ca{}; + ca.view = view; + ca.loadOp = LoadOp::clear; + ca.storeOp = StoreOp::store; + ca.clearColor = {0.10f, 0.70f, 0.55f, 1.0f}; + RenderPassDesc rp{}; + rp.colorAttachments[0] = ca; + rp.colorCount = 1; + auto pass = ctx.beginRenderPass(rp); + pass->setViewport(0, 0, 200, 200); + pass->finish(); + }; + auto drawCanvasToScreen = [&](Renderer* r) { + r->save(); + r->translate(28, 28); + r->drawImage(canvas->renderImage(), + sampler, + BlendMode::srcOver, + 1.0f); + r->restore(); + }; + + if (m_deferred) + { + // Same DeferredReplayer the goldens host and editor use. The marker + // orders the Ore replay before the screen draw. + rive::cmd::DeferredSession session(&realCtx); + + auto view = session.oreContext().wrapCanvasTexture(canvas.get()); + recordClear(session.oreContext(), view.get()); + session.recordOreReplayMarker(); + + auto dr = session.makeScreenRenderer(); + drawCanvasToScreen(dr.get()); + + // Snapshot replay is the same path a threaded consumer takes. + rive::cmd::DeferredFrame frame = rive::cmd::snapshotFrame(session); + GMCanvasSink sink(renderContext, originalRenderer, &m_ore); + rive::cmd::DeferredReplayer replayer; + replayer.replayFrame(frame, sink); + } + else + { + auto view = realCtx.wrapCanvasTexture(canvas.get()); + m_ore.beginFrame(renderContext); + recordClear(realCtx, view.get()); + m_ore.endFrame(renderContext); + ore_gm::invalidateGLStateAfterOre(renderContext); + drawCanvasToScreen(originalRenderer); + } +#endif + } + +private: + bool m_deferred; + ore_gm::OreGMContext m_ore; +}; + +GMREGISTER(render_deferred_canvas_immediate, + return new RenderDeferredCanvasGM(false)) +GMREGISTER(render_deferred_canvas, return new RenderDeferredCanvasGM(true))
diff --git a/tests/gm/ore_render_deferred_canvas.mm b/tests/gm/ore_render_deferred_canvas.mm new file mode 100644 index 0000000..a62f22b --- /dev/null +++ b/tests/gm/ore_render_deferred_canvas.mm
@@ -0,0 +1,2 @@ +// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple. +#include "ore_render_deferred_canvas.cpp"
diff --git a/tests/gm/render_canvas_dag.cpp b/tests/gm/render_canvas_dag.cpp new file mode 100644 index 0000000..d501784 --- /dev/null +++ b/tests/gm/render_canvas_dag.cpp
@@ -0,0 +1,291 @@ +/* + * Copyright 2026 Rive + * + * A canvas sampling another canvas replays after its writer regardless of + * record order, so the reversed recording must match the in-order one. The + * cycle GM pins the demoted back edge to previous-frame sampling. + */ + +#include "gm.hpp" +#include "gmutils.hpp" + +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) + +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/rive_renderer.hpp" +#include "rive/renderer/cmd/deferred_replayer.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" + +using namespace rivegm; +using namespace rive; +using namespace rive::gpu; + +namespace +{ +// DeferredFrameSink over the GM harness. The harness frame is already open, +// so the first sink action flushes it and later frames resume with preserve. +class DagGMSink : public rive::cmd::DeferredFrameSink +{ +public: + DagGMSink(RenderContext* rc, + const RenderContext::FrameDescriptor& mainDesc) : + m_rc(rc), m_mainDesc(mainDesc) + {} + + Factory* factory() override { return TestingWindow::Get()->factory(); } + + // The GM harness owns one main render target. + Renderer* beginScreenFrame(uint64_t target) override + { + assert(target == 0); + flushOpenFrame(); + auto d = m_mainDesc; + d.loadAction = LoadAction::preserveRenderTarget; + m_rc->beginFrame(std::move(d)); + m_frameOpen = true; + m_screen = std::make_unique<RiveRenderer>(m_rc); + return m_screen.get(); + } + + Renderer* beginCanvasContent(RenderCanvas* canvas, + uint32_t clearColor) override + { + flushOpenFrame(); + m_activeCanvas = canvas; + auto d = m_mainDesc; + d.renderTargetWidth = canvas->width(); + d.renderTargetHeight = canvas->height(); + d.loadAction = LoadAction::clear; + d.clearColor = clearColor; + m_rc->beginFrame(std::move(d)); + m_frameOpen = true; + m_canvasRenderer = std::make_unique<RiveRenderer>(m_rc); + return m_canvasRenderer.get(); + } + + void endCanvasContent() override + { + if (m_activeCanvas == nullptr) + { + return; + } + TestingWindow::Get()->flushPLSContext(m_activeCanvas->renderTarget()); + m_frameOpen = false; + m_canvasRenderer = nullptr; + m_activeCanvas = nullptr; + } + +private: + // The harness (or the previous replay) leaves the main frame open. + void flushOpenFrame() + { + if (!m_flushedHarnessFrame || m_frameOpen) + { + TestingWindow::Get()->flushPLSContext(); + m_flushedHarnessFrame = true; + m_frameOpen = false; + } + } + + RenderContext* m_rc; + RenderContext::FrameDescriptor m_mainDesc; + bool m_flushedHarnessFrame = false; + bool m_frameOpen = false; + std::unique_ptr<RiveRenderer> m_screen; + std::unique_ptr<RiveRenderer> m_canvasRenderer; + RenderCanvas* m_activeCanvas = nullptr; +}; + +rcp<RenderPath> ovalPath(rive::cmd::DeferredSession& session, AABB bounds) +{ + RawPath raw; + raw.addOval(bounds); + return session.makeRenderPath(raw, FillRule::nonZero); +} + +rcp<RenderPaint> solidPaint(rive::cmd::DeferredSession& session, ColorInt color) +{ + auto paint = session.makeRenderPaint(); + paint->color(color); + return paint; +} + +void drawCanvasImage(Renderer* r, + RenderCanvas* canvas, + float x, + float y, + bool flip) +{ + r->save(); + r->translate(x, y); + if (flip) + { + r->translate(0, static_cast<float>(canvas->height())); + r->scale(1, -1); + } + r->drawImage(canvas->renderImage(), + {.filter = ImageFilter::nearest}, + BlendMode::srcOver, + 1.0f); + r->restore(); +} + +void replayFrameThroughGM(rive::cmd::DeferredSession& session, + rive::cmd::DeferredReplayer& replayer, + RenderContext* rc, + const RenderContext::FrameDescriptor& mainDesc) +{ + rive::cmd::DeferredFrame frame = rive::cmd::snapshotFrame(session); + session.resetFrame(); + DagGMSink sink(rc, mainDesc); + replayer.replayFrame(frame, sink); +} +} // namespace + +// Canvas B samples canvas A; reversed records B's bracket first. Both GMs +// must produce identical pixels: the schedule, not record order, decides. +class CanvasDagChainGM : public GM +{ +public: + CanvasDagChainGM(bool reversed) : GM(256, 256), m_reversed(reversed) {} + + ColorInt clearColor() const override { return 0xff202028; } + + void onDraw(Renderer*) override + { + auto rc = TestingWindow::Get()->renderContext(); + if (!rc) + { + return; + } + auto canvasA = rc->makeRenderCanvas(128, 128); + auto canvasB = rc->makeRenderCanvas(128, 128); + if (!canvasA || !canvasB) + { + return; + } + auto mainDesc = rc->frameDescriptor(); + bool flip = rc->platformFeatures().framebufferBottomUp; + + rive::cmd::DeferredSession session(nullptr); + rive::cmd::DeferredReplayer replayer; + auto green = solidPaint(session, 0xff30c060); + auto orange = solidPaint(session, 0xffe08830); + auto circle = ovalPath(session, {24, 24, 104, 104}); + auto dot = ovalPath(session, {8, 8, 40, 40}); + + auto recordA = [&]() { + Renderer* a = session.beginCanvasContent(canvasA.get(), 0xff103050); + a->drawPath(circle.get(), green.get()); + session.endCanvasContent(canvasA.get()); + }; + auto recordB = [&]() { + // B composites A, then draws its own dot on top. + Renderer* b = session.beginCanvasContent(canvasB.get(), 0xff501030); + drawCanvasImage(b, canvasA.get(), 0, 0, flip); + b->drawPath(dot.get(), orange.get()); + session.endCanvasContent(canvasB.get()); + }; + if (m_reversed) + { + recordB(); + recordA(); + } + else + { + recordA(); + recordB(); + } + auto screen = session.makeScreenRenderer(); + drawCanvasImage(screen.get(), canvasA.get(), 0, 64, flip); + drawCanvasImage(screen.get(), canvasB.get(), 128, 64, flip); + + replayFrameThroughGM(session, replayer, rc, mainDesc); + } + +private: + bool m_reversed; +}; + +GMREGISTER(canvas_dag_chain, return new CanvasDagChainGM(false)) +GMREGISTER(canvas_dag_chain_reversed, return new CanvasDagChainGM(true)) + +// A samples B while B samples A. The demoted back edge samples the previous +// frame by contract: frame two must show frame one's content crossed over, +// deterministic because frame one seeded both canvases. +class CanvasDagCycleGM : public GM +{ +public: + CanvasDagCycleGM() : GM(256, 256) {} + + ColorInt clearColor() const override { return 0xff202028; } + + void onDraw(Renderer*) override + { + auto rc = TestingWindow::Get()->renderContext(); + if (!rc) + { + return; + } + auto canvasA = rc->makeRenderCanvas(128, 128); + auto canvasB = rc->makeRenderCanvas(128, 128); + if (!canvasA || !canvasB) + { + return; + } + auto mainDesc = rc->frameDescriptor(); + bool flip = rc->platformFeatures().framebufferBottomUp; + + rive::cmd::DeferredSession session(nullptr); + rive::cmd::DeferredReplayer replayer; + + // Frame one: seed A green, B orange, no cross sampling. + { + auto green = solidPaint(session, 0xff30c060); + auto orange = solidPaint(session, 0xffe08830); + auto circle = ovalPath(session, {24, 24, 104, 104}); + Renderer* a = session.beginCanvasContent(canvasA.get(), 0xff103050); + a->drawPath(circle.get(), green.get()); + session.endCanvasContent(canvasA.get()); + Renderer* b = session.beginCanvasContent(canvasB.get(), 0xff501030); + b->drawPath(circle.get(), orange.get()); + session.endCanvasContent(canvasB.get()); + replayFrameThroughGM(session, replayer, rc, mainDesc); + } + + // Frame two: each canvas samples the other shrunken, then the screen + // shows both. The back edge sees frame one's pixels. + { + auto white = solidPaint(session, 0xffffffff); + auto dot = ovalPath(session, {4, 4, 24, 24}); + Renderer* a = session.beginCanvasContent(canvasA.get(), 0xff103050); + a->save(); + a->scale(0.5f, 0.5f); + drawCanvasImage(a, canvasB.get(), 0, 0, flip); + a->restore(); + a->drawPath(dot.get(), white.get()); + session.endCanvasContent(canvasA.get()); + + Renderer* b = session.beginCanvasContent(canvasB.get(), 0xff501030); + b->save(); + b->scale(0.5f, 0.5f); + drawCanvasImage(b, canvasA.get(), 0, 0, flip); + b->restore(); + b->drawPath(dot.get(), white.get()); + session.endCanvasContent(canvasB.get()); + + auto screen = session.makeScreenRenderer(); + drawCanvasImage(screen.get(), canvasA.get(), 0, 64, flip); + drawCanvasImage(screen.get(), canvasB.get(), 128, 64, flip); + replayFrameThroughGM(session, replayer, rc, mainDesc); + } + } +}; + +GMREGISTER(canvas_dag_cycle, return new CanvasDagCycleGM()) + +#else + +// Canvas or scripting disabled: nothing to register. + +#endif
diff --git a/tests/gm/render_deferred_2d.cpp b/tests/gm/render_deferred_2d.cpp new file mode 100644 index 0000000..89cc052 --- /dev/null +++ b/tests/gm/render_deferred_2d.cpp
@@ -0,0 +1,128 @@ +/* + * Copyright 2026 Rive + * + * Draws the same scene immediately and via a DeferredFactory recording + * replayed against the real factory and renderer. The goldens must be byte + * identical. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/render_replay.hpp" +#include "rive/math/raw_path.hpp" +#include "rive/math/mat2d.hpp" +#include "rive/shapes/paint/image_sampler.hpp" +#include "assets/batdude.png.hpp" + +using namespace rivegm; +using namespace rive; + +static RawPath kShape() +{ + RawPath p; + p.move({40, 40}); + p.line({160, 70}); + p.line({120, 110}); + p.line({200, 200}); + p.line({120, 160}); + p.line({60, 210}); + p.close(); + return p; +} + +// Fill and stroke so all paint properties get recorded. +static void drawScene(Factory* factory, Renderer* renderer) +{ + RawPath shape = kShape(); + auto path = factory->makeRenderPath(shape, FillRule::nonZero); + + auto fill = factory->makeRenderPaint(); + fill->style(RenderPaintStyle::fill); + fill->color(0xFFFFA030); + renderer->drawPath(path.get(), fill.get()); + + auto stroke = factory->makeRenderPaint(); + stroke->style(RenderPaintStyle::stroke); + stroke->color(0xFF3050FF); + stroke->thickness(10); + stroke->join(StrokeJoin::round); + stroke->cap(StrokeCap::round); + renderer->drawPath(path.get(), stroke.get()); + + // Exercises gradient shaders. + RawPath box; + box.move({30, 30}); + box.line({226, 30}); + box.line({226, 90}); + box.line({30, 90}); + box.close(); + auto boxPath = factory->makeRenderPath(box, FillRule::nonZero); + const ColorInt colors[] = {0xFF00E0A0, 0xFFE000A0}; + const float stops[] = {0.0f, 1.0f}; + auto grad = factory->makeLinearGradient(30, 30, 226, 90, colors, stops, 2); + auto gradPaint = factory->makeRenderPaint(); + gradPaint->style(RenderPaintStyle::fill); + gradPaint->shader(grad); + renderer->drawPath(boxPath.get(), gradPaint.get()); + + // Exercises paths built verb by verb. + auto tri = factory->makeEmptyRenderPath(); + tri->fillRule(FillRule::nonZero); + tri->moveTo(20, 195); + tri->lineTo(80, 195); + tri->lineTo(50, 150); + tri->close(); + auto triPaint = factory->makeRenderPaint(); + triPaint->style(RenderPaintStyle::fill); + triPaint->color(0xFFFFFFFF); + renderer->drawPath(tri.get(), triPaint.get()); + + // Exercises image decode and draw. + auto img = factory->decodeImage(assets::batdude_png()); + if (img) + { + renderer->save(); + renderer->transform(Mat2D(0.12f, 0, 0, 0.12f, 150, 110)); + renderer->drawImage(img.get(), + ImageSampler::LinearClamp(), + BlendMode::srcOver, + 1.0f); + renderer->restore(); + } +} + +class RenderDeferred2DGM : public GM +{ +public: + RenderDeferred2DGM(bool deferred) : GM(256, 256), m_deferred(deferred) {} + + ColorInt clearColor() const override { return 0xff202028; } + + void onDraw(rive::Renderer* renderer) override + { + Factory* factory = TestingWindow::Get()->factory(); + if (!factory) + { + return; + } + + if (m_deferred) + { + cmd::DeferredFactory df; + auto dr = df.makeRenderer(); + drawScene(&df, dr.get()); + cmd::replayRenderCommands(factory, renderer, df.commandBuffer()); + } + else + { + drawScene(factory, renderer); + } + } + +private: + bool m_deferred; +}; + +GMREGISTER(render_deferred_2d_immediate, return new RenderDeferred2DGM(false)) +GMREGISTER(render_deferred_2d, return new RenderDeferred2DGM(true))
diff --git a/tests/gm/serialized_replay_2d.cpp b/tests/gm/serialized_replay_2d.cpp new file mode 100644 index 0000000..a30d10f --- /dev/null +++ b/tests/gm/serialized_replay_2d.cpp
@@ -0,0 +1,75 @@ +/* + * Copyright 2026 Rive + * + * Draws the same shape immediately and via a SerializingFactory recording + * replayed against the real factory and renderer. The goldens must be byte + * identical since the recorded stream is the deferral contract. + */ + +#include "gm.hpp" +#include "gmutils.hpp" +#include "utils/serializing_factory.hpp" +#include "utils/serialized_replay.hpp" +#include "rive/math/raw_path.hpp" + +using namespace rivegm; +using namespace rive; + +static RawPath kShape() +{ + // Nonconvex polygon so winding is exercised. + RawPath p; + p.move({40, 40}); + p.line({160, 70}); + p.line({120, 110}); + p.line({200, 200}); + p.line({120, 160}); + p.line({60, 210}); + p.close(); + return p; +} + +class SerializedReplay2DGM : public GM +{ +public: + SerializedReplay2DGM(bool replay) : GM(256, 256), m_replay(replay) {} + + ColorInt clearColor() const override { return 0xff202028; } + + void onDraw(rive::Renderer* renderer) override + { + Factory* factory = TestingWindow::Get()->factory(); + if (!factory) + return; + + RawPath shape = kShape(); + + if (m_replay) + { + SerializingFactory sf; + auto recorder = sf.makeRenderer(); + auto path = sf.makeRenderPath(shape, FillRule::nonZero); + auto paint = sf.makeRenderPaint(); + paint->color(0xFFFFA030); + paint->style(RenderPaintStyle::fill); + recorder->drawPath(path.get(), paint.get()); + + replaySerializedCommands(sf.bytes(), factory, renderer); + } + else + { + auto path = factory->makeRenderPath(shape, FillRule::nonZero); + auto paint = factory->makeRenderPaint(); + paint->color(0xFFFFA030); + paint->style(RenderPaintStyle::fill); + renderer->drawPath(path.get(), paint.get()); + } + } + +private: + bool m_replay; +}; + +GMREGISTER(serialized_replay_2d_immediate, + return new SerializedReplay2DGM(false)) +GMREGISTER(serialized_replay_2d, return new SerializedReplay2DGM(true))
diff --git a/tests/goldens/goldens.cpp b/tests/goldens/goldens.cpp index ac2cb54..1af0eca 100644 --- a/tests/goldens/goldens.cpp +++ b/tests/goldens/goldens.cpp
@@ -5,18 +5,10 @@ // Don't compile this file as part of the "tests" project. #ifndef TESTING -#include "goldens_arguments.hpp" -#include "common/test_harness.hpp" +#include "goldens_shared.hpp" #include "common/tcp_client.hpp" #include "common/rive_mgr.hpp" -#include "common/testing_window.hpp" #include "common/write_png_file.hpp" -#include "rive/artboard.hpp" -#include "rive/renderer.hpp" -#include "rive/file.hpp" -#include "rive/refcnt.hpp" -#include "rive/animation/state_machine_instance.hpp" -#include "rive/static_scene.hpp" #include <filesystem> #include <fstream> #include <iostream> @@ -34,74 +26,60 @@ GoldensArguments s_args; -class RIVLoader +// RIVE_GOLDENS_ADVANCE=N advances N frames at sixty fps before rendering. +static void advanceScene(rive::Scene* scene) { -public: - RIVLoader(const std::vector<uint8_t>& rivBytes, - const char* artboardName, - const char* stateMachineName) + const char* a = goldens_getenv("RIVE_GOLDENS_ADVANCE"); + int frames = a ? atoi(a) : 0; + if (frames <= 0) { - m_file = rive::File::import(rivBytes, TestingWindow::Get()->factory()); - if (m_file == nullptr) - { - throw "Bad riv file"; - } - if (artboardName != nullptr && artboardName[0] != '\0') - { - m_artboard = m_file->artboardNamed(artboardName); - } - else - { - m_artboard = m_file->artboardDefault(); - } - if (m_artboard == nullptr) - { - throw "Can't load artboard"; - } - - // Bind the default view model instance - m_viewModelInstance = m_file->createViewModelInstance(m_artboard.get()); - m_artboard->bindViewModelInstance(m_viewModelInstance); - - if (stateMachineName != nullptr && stateMachineName[0] != '\0') - { - m_scene = m_artboard->stateMachineNamed(stateMachineName); - } - else - { - m_scene = m_artboard->defaultStateMachine(); - } - - if (m_scene == nullptr) - { - // This is a riv without any state machines. Just draw the artboard. - m_scene = std::make_unique<rive::StaticScene>(m_artboard.get()); - } - - if (m_scene != nullptr && m_viewModelInstance != nullptr) - { - m_scene->bindViewModelInstance(m_viewModelInstance); - } + scene->advanceAndApply(0); + return; } + for (int i = 0; i < frames; ++i) + scene->advanceAndApply(1.0f / 60.0f); +} - rive::Scene* stateMachine() const { return m_scene.get(); } - -private: - rive::rcp<rive::File> m_file; - std::unique_ptr<rive::ArtboardInstance> m_artboard; - std::unique_ptr<rive::Scene> m_scene; - rive::rcp<rive::ViewModelInstance> m_viewModelInstance; -}; - -static bool render_and_dump_png(int cellSize, - const char* rivName, - const std::vector<uint8_t>& rivBytes, - const char* artboardName, - const char* stateMachineName) +void dumpPixelsAsPng(const char* rivName, + int windowWidth, + int windowHeight, + std::vector<uint8_t> pixels) { - int windowWidth = cellSize * s_args.cols(); - int windowHeight = cellSize * s_args.rows(); - TestingWindow::Get()->resize(windowWidth, windowHeight); + assert(pixels.size() == + static_cast<size_t>(windowHeight) * windowWidth * 4); + std::ostringstream imageName; + imageName + << std::filesystem::path(rivName).filename().stem().generic_string(); + if (s_args.rows() != 1 || s_args.cols() != 1) + { + imageName << '.' << s_args.cols() << 'x' << s_args.rows() << '.'; + } + TestHarness::Instance().savePNG({ + .name = imageName.str(), + .width = static_cast<uint32_t>(windowWidth), + .height = static_cast<uint32_t>(windowHeight), + .pixels = std::move(pixels), + }); + if (s_args.verbose()) + { + printf("[goldens] Sent %s\n", + std::filesystem::path(imageName.str()) + .replace_extension("png") + .generic_string() + .c_str()); + } +} + +static bool render_and_dump_png( + int cellSize, + const char* rivName, + rive::Scene* scene, + rive::Artboard* artboard = nullptr, + rive::cmd::DeferredSession* deferredSession = nullptr) +{ + // onceAfterGM can tear down the window between rivs, size every run. + TestingWindow::Get()->resize(cellSize * s_args.cols(), + cellSize * s_args.rows()); if (s_args.verbose()) { @@ -109,19 +87,74 @@ } try { - RIVLoader riv(rivBytes, artboardName, stateMachineName); - rive::Scene* stateMachine = riv.stateMachine(); - const int frames = s_args.cols() * s_args.rows(); - const double duration = stateMachine->durationSeconds(); + const double duration = scene->durationSeconds(); const double frameDuration = duration / frames; const rive::AABB cellBounds = rive::AABB(0, 0, cellSize, cellSize); - // Render the stateMachine in a grid. +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) + // Deferred mode records the screen and Ore through the session, then + // replays synchronously per grid cell. The cadence mirrors the + // immediate path below so the output must be byte identical. + if (deferredSession != nullptr && artboard != nullptr) + { + advanceScene(scene); + rive::cmd::DeferredReplayer replayer; + for (int y = 0; y < s_args.rows(); ++y) + { + for (int x = 0; x < s_args.cols(); ++x) + { + bool first = (x | y) == 0; + if (!first) + { + scene->advanceAndApply(frameDuration); + } + deferredSession->recordOreReplayMarker(); + + auto screenRec = deferredSession->makeScreenRenderer(); + screenRec->save(); + screenRec->translate(x * cellSize, y * cellSize); + screenRec->align(rive::Fit::cover, + rive::Alignment::center, + cellBounds, + scene->bounds()); + artboard->drawInternal(screenRec.get()); + screenRec->restore(); + + // Snapshot replay is the same path a threaded consumer + // takes. + rive::cmd::DeferredFrame frame = + rive::cmd::snapshotFrame(*deferredSession); + deferredSession->resetFrame(); + GoldensFrameSink sink(first); + replayer.replayFrame(frame, sink); + + bool last = + y == s_args.rows() - 1 && x == s_args.cols() - 1; + if (!last) + { + TestingWindow::Get()->endFrame(); + } + } + } + + int windowWidth = s_args.cols() * cellSize; + int windowHeight = s_args.rows() * cellSize; + std::vector<uint8_t> pixels; + TestingWindow::Get()->endFrame(&pixels); + dumpPixelsAsPng(rivName, + windowWidth, + windowHeight, + std::move(pixels)); + return true; + } +#endif + + // Render the scene in a grid. + advanceScene(scene); auto renderer = TestingWindow::Get()->beginFrame({.clearColor = 0xffffffff}); renderer->save(); - stateMachine->advanceAndApply(0); for (int y = 0; y < s_args.rows(); ++y) { for (int x = 0; x < s_args.cols(); ++x) @@ -129,8 +162,8 @@ if ((x | y) != 0) { TestingWindow::Get()->endFrame(); + scene->advanceAndApply(frameDuration); TestingWindow::Get()->beginFrame({.doClear = false}); - stateMachine->advanceAndApply(frameDuration); } renderer->save(); @@ -139,8 +172,16 @@ renderer->align(rive::Fit::cover, rive::Alignment::center, cellBounds, - stateMachine->bounds()); - stateMachine->draw(renderer.get()); + scene->bounds()); + + if (artboard != nullptr) + { + artboard->drawInternal(renderer.get()); + } + else + { + scene->draw(renderer.get()); + } renderer->restore(); } @@ -148,35 +189,11 @@ renderer->restore(); // Save the png. + int windowWidth = s_args.cols() * cellSize; + int windowHeight = s_args.rows() * cellSize; std::vector<uint8_t> pixels; TestingWindow::Get()->endFrame(&pixels); - assert(pixels.size() == windowHeight * windowWidth * 4); - - std::ostringstream imageName; - imageName << std::filesystem::path(rivName) - .filename() - .stem() - .generic_string(); - if (s_args.rows() != 1 || s_args.cols() != 1) - { - imageName << '.' << s_args.cols() << 'x' << s_args.rows() << '.'; - } - - TestHarness::Instance().savePNG({ - .name = imageName.str(), - .width = static_cast<uint32_t>(windowWidth), - .height = static_cast<uint32_t>(windowHeight), - .pixels = std::move(pixels), - }); - - if (s_args.verbose()) - { - printf("[goldens] Sent %s\n", - std::filesystem::path(imageName.str()) - .replace_extension("png") - .generic_string() - .c_str()); - } + dumpPixelsAsPng(rivName, windowWidth, windowHeight, std::move(pixels)); if (s_args.interactive()) { @@ -216,11 +233,6 @@ fprintf(stderr, "error rendering %s\n", rivName); abort(); } - - // Allow the testing window to do any cleanup it might want to do between - // GMs - TestingWindow::Get()->onceAfterGM(); - return true; } @@ -232,12 +244,36 @@ throw "Bad file"; } - return render_and_dump_png( - cellSize, - file.c_str(), - std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}), - s_args.artboard().c_str(), - s_args.stateMachine().c_str()); + std::vector<uint8_t> bytes(std::istreambuf_iterator<char>(stream), {}); +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) + if (const char* n = goldens_getenv("RIVE_GOLDENS_BENCH")) + { + int iters = atoi(n); + if (iters <= 0) + iters = 200; + run_benchmark(bytes, + s_args.artboard().c_str(), + s_args.stateMachine().c_str(), + iters); + return true; + } +#endif + + bool ok; + { + RIVLoader riv(bytes, + s_args.artboard().c_str(), + s_args.stateMachine().c_str()); + ok = render_and_dump_png(cellSize, + file.c_str(), + riv.stateMachine(), + riv.artboard(), + riv.deferredSession()); + } + // Between-GM cleanup can tear the device down, so the loader and its + // recorded resources must already be gone. + TestingWindow::Get()->onceAfterGM(); + return ok; } static bool is_riv_file(const std::filesystem::path& file) @@ -311,6 +347,9 @@ int cellSize = kWindowTargetSize / std::max(s_args.cols(), s_args.rows()); + int windowWidth = cellSize * s_args.cols(); + int windowHeight = cellSize * s_args.rows(); + TestingWindow::Get()->resize(windowWidth, windowHeight); // First check if the --src argument is a TCP server instead of a file. if (TestHarness::Instance().hasTCPConnection()) @@ -320,14 +359,22 @@ std::vector<uint8_t> rivBytes; while (TestHarness::Instance().fetchRivFile(rivName, rivBytes)) { - if (!render_and_dump_png(cellSize, - rivName.c_str(), - rivBytes, - nullptr /*default artboard*/, - nullptr /*default state machine*/)) { - return 0; + RIVLoader riv(rivBytes, + nullptr /*default artboard*/, + nullptr /*default state machine*/); + if (!render_and_dump_png(cellSize, + rivName.c_str(), + riv.stateMachine(), + riv.artboard(), + riv.deferredSession())) + { + return 0; + } } + // Between-GM cleanup can tear the device down, so the loader + // and its recorded resources must already be gone. + TestingWindow::Get()->onceAfterGM(); } } else
diff --git a/tests/goldens/goldens_arguments.hpp b/tests/goldens/goldens_arguments.hpp index 0d09a63..6bf1f7d 100644 --- a/tests/goldens/goldens_arguments.hpp +++ b/tests/goldens/goldens_arguments.hpp
@@ -95,6 +95,12 @@ "only_ubershaders", "Use only ubershaders (where supported)", {'u', "only_ubershaders"}); + args::Flag deferred( + optional, + "deferred", + "record through a deferred session and replay synchronously; " + "output must be identical to immediate mode", + {"deferred"}); args::CompletionFlag completion(*m_parser, {"complete"}); try @@ -143,6 +149,7 @@ m_cols = std::max(args::get(cols), 1); m_pngThreads = std::max(args::get(pngThreads), 1); m_onlyUbershaders = args::get(onlyUbershaders); + m_deferred = args::get(deferred); } const std::string& testHarness() const { return m_testHarness; } @@ -159,6 +166,7 @@ int cols() const { return m_cols; } int pngThreads() const { return m_pngThreads; } bool onlyUbershaders() const { return m_onlyUbershaders; } + bool deferred() const { return m_deferred; } private: std::unique_ptr<args::ArgumentParser> m_parser; @@ -178,5 +186,6 @@ int m_cols; int m_pngThreads; bool m_onlyUbershaders; + bool m_deferred; }; #endif
diff --git a/tests/goldens/goldens_bench.cpp b/tests/goldens/goldens_bench.cpp new file mode 100644 index 0000000..d08c87e --- /dev/null +++ b/tests/goldens/goldens_bench.cpp
@@ -0,0 +1,718 @@ +/* + * Copyright 2022 Rive + */ + +// Env gated diagnostics and the RIVE_GOLDENS_BENCH benchmark, split out of +// goldens.cpp. + +// Don't compile this file as part of the "tests" project. +#ifndef TESTING + +#include "goldens_shared.hpp" + +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) + +#include <algorithm> +#include <chrono> +#include <cstdio> +#include <unordered_map> + +// Tallies per resource mutations and draws in one recorded 2D frame to spot +// redundant rebuilds. +static void analyze_frame_redundancy(const rive::cmd::RenderCommandBuffer& cmd) +{ + using namespace rive::cmd; + RenderCommandReader r(cmd.commandBytes(), cmd.blobBytes()); + std::unordered_map<RenderHandle, int> rewinds, addRaw, draws, paintSets; + std::unordered_map<RenderHandle, uint32_t> lastColor; + int colorSets = 0, colorSameValueRepeat = 0; + size_t geomBytes = 0; + uint8_t type; + while (r.next(type)) + { + switch (static_cast<RenderCmd>(type)) + { + case RenderCmd::pathRewind: + rewinds[r.read<ResIdPOD>().id]++; + break; + case RenderCmd::pathFillRule: + r.read<PathFillRulePOD>(); + break; + case RenderCmd::pathAddRawPath: + { + auto c = r.read<PathRawPOD>(); + addRaw[c.path]++; + geomBytes += c.verbCount * sizeof(rive::PathVerb) + + c.pointCount * sizeof(rive::Vec2D); + break; + } + case RenderCmd::pathAddRenderPath: + r.read<PathAddPathPOD>(); + break; + case RenderCmd::paintStyle: + case RenderCmd::paintJoin: + case RenderCmd::paintCap: + case RenderCmd::paintBlendMode: + paintSets[r.read<PaintU8POD>().paint]++; + break; + case RenderCmd::paintColor: + { + auto c = r.read<PaintColorPOD>(); + paintSets[c.paint]++; + colorSets++; + auto it = lastColor.find(c.paint); + if (it != lastColor.end() && it->second == c.color) + colorSameValueRepeat++; + lastColor[c.paint] = c.color; + break; + } + case RenderCmd::paintThickness: + case RenderCmd::paintFeather: + paintSets[r.read<PaintFloatPOD>().paint]++; + break; + case RenderCmd::paintShader: + paintSets[r.read<PaintShaderPOD>().paint]++; + break; + case RenderCmd::paintInvalidateStroke: + r.read<ResIdPOD>(); + break; + case RenderCmd::save: + case RenderCmd::restore: + case RenderCmd::makeEmptyPath: + case RenderCmd::makePaint: + break; + case RenderCmd::transform: + r.read<TransformPOD>(); + break; + case RenderCmd::drawPath: + draws[r.read<DrawPathPOD>().path]++; + break; + case RenderCmd::clipPath: + r.read<ClipPathPOD>(); + break; + case RenderCmd::resourceNewVersion: + r.read<ResourceVersionPOD>(); + break; + case RenderCmd::drawImage: + r.read<DrawImagePOD>(); + break; + case RenderCmd::drawImageMesh: + r.read<DrawImageMeshPOD>(); + break; + case RenderCmd::modulateOpacity: + r.read<OpacityPOD>(); + break; + case RenderCmd::canvasContentBegin: + r.read<CanvasContentPOD>(); + break; + case RenderCmd::canvasContentEnd: + r.read<ResIdPOD>(); + break; + case RenderCmd::makePath: + r.read<MakePathPOD>(); + break; + case RenderCmd::makeLinearGradient: + r.read<LinearGradientPOD>(); + break; + case RenderCmd::makeRadialGradient: + r.read<RadialGradientPOD>(); + break; + case RenderCmd::decodeImage: + r.read<DecodeImagePOD>(); + break; + case RenderCmd::makeBuffer: + r.read<MakeBufferPOD>(); + break; + case RenderCmd::bufferData: + r.read<BufferDataPOD>(); + break; + case RenderCmd::destroyResource: + r.read<DestroyResourcePOD>(); + break; + } + } + auto sum = [](const std::unordered_map<RenderHandle, int>& m) { + int t = 0; + for (auto& kv : m) + t += kv.second; + return t; + }; + auto multi = [](const std::unordered_map<RenderHandle, int>& m) { + int t = 0, mx = 0; + for (auto& kv : m) + { + if (kv.second > 1) + t++; + mx = std::max(mx, kv.second); + } + return std::pair<int, int>(t, mx); + }; + int totalRewind = sum(rewinds), totalAdd = sum(addRaw), + totalDraw = sum(draws), totalPaint = sum(paintSets); + auto rw = multi(rewinds); + auto ad = multi(addRaw); + auto pt = multi(paintSets); + + printf("\n-- frame redundancy analysis (one clean frame) --\n"); + printf(" paths: %zu distinct rewound, %d total rewinds " + "(%d rewound >1x, max %dx)\n", + rewinds.size(), + totalRewind, + rw.first, + rw.second); + printf(" paths: %zu distinct addRawPath, %d total adds " + "(%d added >1x, max %dx), geom %.1f KB\n", + addRaw.size(), + totalAdd, + ad.first, + ad.second, + geomBytes / 1024.0); + printf(" paints: %zu distinct, %d total property sets " + "(%d set >1x, max %dx)\n", + paintSets.size(), + totalPaint, + pt.first, + pt.second); + printf(" paint color sets: %d total, %d set to the SAME value again " + "(redundant)\n", + colorSets, + colorSameValueRepeat); + printf(" draws: %zu distinct paths drawn, %d total drawPath\n", + draws.size(), + totalDraw); + if (rewinds.size() > 0) + printf(" => rebuild ratio: %.2f rewinds/path, %.2f adds/path " + "(1.0 = each built once; >1 = redundant rebuilds)\n", + double(totalRewind) / rewinds.size(), + addRaw.empty() ? 0.0 : double(totalAdd) / addRaw.size()); +} + +// Counts drawPath commands that resolve against the resident table versus ones +// skipped, to tell missing resources apart from other replay bugs. +static void diagnose_replay_coverage(const rive::cmd::RenderCommandBuffer& cmd, + const rive::cmd::ResourceTable& t) +{ + using namespace rive::cmd; + RenderCommandReader r(cmd.commandBytes(), cmd.blobBytes()); + int total = 0, resolved = 0, pNull = 0, pOOR = 0, ptNull = 0, ptOOR = 0; + RenderHandle maxPath = 0, maxPaint = 0; + uint8_t type; + while (r.next(type)) + { + switch (static_cast<RenderCmd>(type)) + { + case RenderCmd::drawPath: + { + auto c = r.read<DrawPathPOD>(); + total++; + maxPath = std::max(maxPath, c.path); + maxPaint = std::max(maxPaint, c.paint); + bool pOk = t.paths.get(c.path) != nullptr; + bool ptOk = t.paints.get(c.paint) != nullptr; + if (c.path >= t.paths.objects.size()) + pOOR++; + else if (!pOk) + pNull++; + if (c.paint >= t.paints.objects.size()) + ptOOR++; + else if (!ptOk) + ptNull++; + if (pOk && ptOk) + resolved++; + break; + } + case RenderCmd::pathRewind: + case RenderCmd::clipPath: + case RenderCmd::paintInvalidateStroke: + case RenderCmd::canvasContentEnd: + r.read<ResIdPOD>(); + break; + case RenderCmd::pathFillRule: + r.read<PathFillRulePOD>(); + break; + case RenderCmd::pathAddRawPath: + r.read<PathRawPOD>(); + break; + case RenderCmd::pathAddRenderPath: + r.read<PathAddPathPOD>(); + break; + case RenderCmd::paintStyle: + case RenderCmd::paintJoin: + case RenderCmd::paintCap: + case RenderCmd::paintBlendMode: + r.read<PaintU8POD>(); + break; + case RenderCmd::paintColor: + r.read<PaintColorPOD>(); + break; + case RenderCmd::paintThickness: + case RenderCmd::paintFeather: + r.read<PaintFloatPOD>(); + break; + case RenderCmd::paintShader: + r.read<PaintShaderPOD>(); + break; + case RenderCmd::transform: + r.read<TransformPOD>(); + break; + case RenderCmd::drawImage: + r.read<DrawImagePOD>(); + break; + case RenderCmd::drawImageMesh: + r.read<DrawImageMeshPOD>(); + break; + case RenderCmd::modulateOpacity: + r.read<OpacityPOD>(); + break; + case RenderCmd::canvasContentBegin: + r.read<CanvasContentPOD>(); + break; + case RenderCmd::makePath: + r.read<MakePathPOD>(); + break; + case RenderCmd::makeLinearGradient: + r.read<LinearGradientPOD>(); + break; + case RenderCmd::makeRadialGradient: + r.read<RadialGradientPOD>(); + break; + case RenderCmd::decodeImage: + r.read<DecodeImagePOD>(); + break; + case RenderCmd::makeBuffer: + r.read<MakeBufferPOD>(); + break; + case RenderCmd::bufferData: + r.read<BufferDataPOD>(); + break; + default: + break; // no payload + } + } + printf( + "\n-- replay coverage diagnosis (clean frame vs resident table) --\n"); + printf(" table: %zu paths, %zu paints\n", + t.paths.objects.size(), + t.paints.objects.size()); + printf(" drawPath: %d total, %d resolved, skipped path[null %d, OOR %d] " + "paint[null %d, OOR %d]\n", + total, + resolved, + pNull, + pOOR, + ptNull, + ptOOR); + printf(" max referenced: path id %u, paint id %u\n", maxPath, maxPaint); +} + +// Drains one recorded frame through the caller owned replayer. Leaves the +// screen frame open for the caller to present via endFrame. +static void replay_deferred_frame(rive::cmd::DeferredReplayer& replayer, + rive::cmd::DeferredSession* session) +{ + GoldensFrameSink sink; + replayer.replayFrame(*session, sink); +} + +void run_benchmark(const std::vector<uint8_t>& bytes, + const char* artboardName, + const char* stateMachineName, + int iters) +{ + using clock = std::chrono::steady_clock; + auto us = [](clock::duration d) { + return std::chrono::duration<double, std::micro>(d).count(); + }; + auto* win = TestingWindow::Get(); + const int cellSize = 256; + const rive::AABB cell(0, 0, cellSize, cellSize); + const float dt = 1.0f / 60.0f; + const int kWarmup = 8; + + auto drawInto = [&](rive::Renderer* r, rive::Scene* s, rive::Artboard* a) { + r->save(); + r->align(rive::Fit::cover, rive::Alignment::center, cell, s->bounds()); + a->drawInternal(r); + r->restore(); + }; + + // Immediate: full main thread frame. + RIVLoader imm(bytes, + artboardName, + stateMachineName, + RIVLoader::DeferMode::Immediate); + auto* immScene = imm.stateMachine(); + auto* immArt = imm.artboard(); + immScene->advanceAndApply(0.0f); + auto immFrame = [&]() { + immScene->advanceAndApply(dt); + auto r = win->beginFrame({.clearColor = 0xffffffff}); + drawInto(r.get(), immScene, immArt); + win->endFrame(); + }; + for (int i = 0; i < kWarmup; ++i) + immFrame(); + auto t0 = clock::now(); + for (int i = 0; i < iters; ++i) + immFrame(); + double immUs = us(clock::now() - t0) / iters; + + // Deferred record: no GPU submission. + RIVLoader def(bytes, + artboardName, + stateMachineName, + RIVLoader::DeferMode::Deferred); + auto* session = def.deferredSession(); + auto* defScene = def.stateMachine(); + auto* defArt = def.artboard(); + defScene->advanceAndApply(0.0f); + auto recFrame = [&]() { + defScene->advanceAndApply(dt); + session->recordOreReplayMarker(); + auto r = session->makeScreenRenderer(); + drawInto(r.get(), defScene, defArt); + }; + // The 2D stream accumulates because recFrame never resets. Resources are + // created on the first frame only, so later deltas are draws only. + auto bytes2D = [&]() -> size_t { + return session->commandBuffer().commandBytes().size() + + session->commandBuffer().blobBytes().size(); + }; + auto streamBytes = [&]() -> size_t { + return bytes2D() + session->oreContext().streamBytes().total(); + }; + recFrame(); // first frame includes one time resource creation + auto coldOre = session->oreContext().streamBytes(); + size_t cold2D = bytes2D(); + size_t coldBytes = cold2D + coldOre.total(); + for (int i = 1; i < kWarmup; ++i) + recFrame(); + size_t before = streamBytes(); + size_t before2D = bytes2D(); + auto t1 = clock::now(); + for (int i = 0; i < iters; ++i) + recFrame(); + double recUs = us(clock::now() - t1) / iters; + double perFrameBytes = double(streamBytes() - before) / iters; + double perFrame2D = double(bytes2D() - before2D) / iters; + + // RIVE_GOLDENS_ORE_HISTO prints an Ore opcode histogram for one clean + // frame to diagnose per frame resource churn. + if (goldens_getenv("RIVE_GOLDENS_ORE_HISTO")) + { + session->resetFrame(); + recFrame(); + using rive::ore::cmd::CommandType; + static const char* kNames[] = {"beginRenderPass", "setPipeline", + "setVertexBuffer", "setIndexBuffer", + "setBindGroup", "setViewport", + "setScissorRect", "setStencilRef", + "setBlendColor", "draw", + "drawIndexed", "finish", + "makeBuffer", "makeTexture", + "makeSampler", "makeShaderModule", + "makeBGLayout", "makeTextureView", + "makePipeline", "makeBindGroup", + "bufferUpdate", "textureUpload", + "destroyResource"}; + int counts[64] = {}; + auto& cb = session->oreContext().stream(); + rive::ore::cmd::OreCommandReader rd(cb.commandBytes(), cb.blobBytes()); + CommandType t; + while (rd.next(t)) + { + uint8_t v = static_cast<uint8_t>(t); + if (v < 64) + { + counts[v]++; + } + rive::ore::cmd::skipOreCommand(t, rd); + } + printf("\n-- one steady frame, Ore opcode histogram --\n"); + for (size_t i = 0; i < sizeof(kNames) / sizeof(kNames[0]); ++i) + { + if (counts[i] != 0) + { + printf(" %-16s : %d\n", kNames[i], counts[i]); + } + } + } + + // Deferred replay: one recorded frame replayed repeatedly, cold and + // steady, to isolate the amortizable resource creation cost. + RIVLoader rep(bytes, + artboardName, + stateMachineName, + RIVLoader::DeferMode::Deferred); + auto* repSession = rep.deferredSession(); + auto* repScene = rep.stateMachine(); + auto* repArt = rep.artboard(); + repScene->advanceAndApply(0.0f); + for (int i = 0; i < kWarmup; ++i) + repScene->advanceAndApply(dt); + repSession->recordOreReplayMarker(); + { + auto r = repSession->makeScreenRenderer(); + drawInto(r.get(), repScene, repArt); + } + + const int kReplays = 30; + // Cold: a fresh replayer each frame recreates every resource. + for (int i = 0; i < 3; ++i) + { + rive::cmd::DeferredReplayer cold; + replay_deferred_frame(cold, repSession); + win->endFrame(); + } + auto t2 = clock::now(); + for (int i = 0; i < kReplays; ++i) + { + rive::cmd::DeferredReplayer cold; + replay_deferred_frame(cold, repSession); + win->endFrame(); + } + double coldUs = us(clock::now() - t2) / kReplays; + + // Steady: Ore makes are idempotent so Ore resources stay resident. 2D + // makes overwrite rather than skip, so 2D resources are recreated. + rive::cmd::DeferredReplayer steady; + for (int i = 0; i < 3; ++i) + { + replay_deferred_frame(steady, repSession); + win->endFrame(); + } + auto t3 = clock::now(); + for (int i = 0; i < kReplays; ++i) + { + replay_deferred_frame(steady, repSession); + win->endFrame(); + } + double steadyUs = us(clock::now() - t3) / kReplays; + + // Phase breakdown for pure 2D scenes. Replay runs on a clean single frame + // against a primed resident table so it reflects one real frame. + bool pure2D = repSession->oreContext().streamBytes().commands == 0; + double mImmAdv = 0, mDefAdv = 0, mImmRen = 0, mDefRec = 0; + double immAdv = 0, immCpu = 0, immGpu = 0; + double repAdv = 0, repRecDraw = 0, repCpu = 0, repGpu = 0; + if (pure2D) + { + for (int i = 0; i < kReplays + 3; ++i) + { + auto a = clock::now(); + immScene->advanceAndApply(dt); + rive::Artboard::incFrameId(); + auto b = clock::now(); + auto r = win->beginFrame({.clearColor = 0xffffffff}); + drawInto(r.get(), immScene, immArt); + auto c = clock::now(); + win->endFrame(); + auto d = clock::now(); + if (i >= 3) + { + immAdv += us(b - a); + immCpu += us(c - b); + immGpu += us(d - c); + } + } + immAdv /= kReplays; + immCpu /= kReplays; + immGpu /= kReplays; + + // Prime the resident table with one full replay, then measure clean + // single frames against it. + rive::cmd::ResourceTable t2; + rive::cmd::replayRenderCommands(win->factory(), + nullptr, + repSession->commandBuffer(), + t2); + for (int i = 0; i < kReplays + 3; ++i) + { + repSession->resetFrame(); + auto a = clock::now(); + repScene->advanceAndApply(dt); + rive::Artboard::incFrameId(); + auto a2 = clock::now(); + { + auto rr = repSession->makeScreenRenderer(); + drawInto(rr.get(), repScene, repArt); + } + auto b = clock::now(); + // Consumer replay against the resident table. + auto screen = win->beginFrame({.clearColor = 0xffffffff}); + rive::cmd::replayRenderCommands(win->factory(), + screen.get(), + repSession->commandBuffer(), + t2); + auto c = clock::now(); + bool last = (i == kReplays + 2); + std::vector<uint8_t> px; + win->endFrame(last && goldens_getenv("RIVE_GOLDENS_BENCH_DUMP") + ? &px + : nullptr); + auto d = clock::now(); + if (last && goldens_getenv("RIVE_GOLDENS_BENCH_DUMP")) + dumpPixelsAsPng("bench_consumer", + win->width(), + win->height(), + std::move(px)); + if (i >= 3) + { + repAdv += us(a2 - a); + repRecDraw += us(b - a2); + repCpu += us(c - b); + repGpu += us(d - c); + } + } + analyze_frame_redundancy(repSession->commandBuffer()); + diagnose_replay_coverage(repSession->commandBuffer(), t2); + repAdv /= kReplays; + repRecDraw /= kReplays; + repCpu /= kReplays; + repGpu /= kReplays; + + // Two fresh artboards advanced in lockstep so advance is compared at + // the same animation state, isolating the serializer overhead. + RIVLoader immM(bytes, + artboardName, + stateMachineName, + RIVLoader::DeferMode::Immediate); + RIVLoader defM(bytes, + artboardName, + stateMachineName, + RIVLoader::DeferMode::Deferred); + auto* immMs = immM.stateMachine(); + auto* immMa = immM.artboard(); + auto* defMs = defM.stateMachine(); + auto* defMa = defM.artboard(); + auto* defMsess = defM.deferredSession(); + immMs->advanceAndApply(0.0f); + defMs->advanceAndApply(0.0f); + for (int i = 0; i < kReplays + 5; ++i) + { + defMsess->resetFrame(); + auto t0 = clock::now(); + immMs->advanceAndApply(dt); + auto t1 = clock::now(); + defMs->advanceAndApply(dt); // same frame, plus serialize + auto t2 = clock::now(); + rive::Artboard::incFrameId(); + auto rim = win->beginFrame({.clearColor = 0xffffffff}); + auto t3 = clock::now(); + drawInto(rim.get(), immMs, immMa); + auto t4 = clock::now(); + win->endFrame(); + auto t5 = clock::now(); + { + auto rr = defMsess->makeScreenRenderer(); + drawInto(rr.get(), defMs, defMa); // records instead of drawing + } + auto t6 = clock::now(); + if (i >= 5) + { + mImmAdv += us(t1 - t0); + mDefAdv += us(t2 - t1); + mImmRen += us(t4 - t3); + mDefRec += us(t6 - t5); + } + } + mImmAdv /= kReplays; + mDefAdv /= kReplays; + mImmRen /= kReplays; + mDefRec /= kReplays; + } + + printf("\n=== deferred-rendering benchmark (%d iters @ 60fps) ===\n", + iters); + printf("scene resolution: %dx%d, 1 cell\n", cellSize, cellSize); + printf("\n-- per-frame timing (microseconds) --\n"); + printf(" immediate (main thread, record + GPU submit) : %9.1f us\n", + immUs); + printf(" deferred RECORD only (main thread) : %9.1f us " + "(%.2fx immediate)\n", + recUs, + recUs / immUs); + printf(" deferred REPLAY cold (recreate every frame): %9.1f us " + "(%.2fx immediate)\n", + coldUs, + coldUs / immUs); + printf(" deferred REPLAY steady (Ore resident) : %9.1f us " + "(%.2fx immediate) [MEASURED]\n", + steadyUs, + steadyUs / immUs); + printf(" Ore shader/pipeline recompile saved/frame : %9.1f us\n", + coldUs - steadyUs); + printf("\n-- serialized stream size --\n"); + printf(" 2D ordered stream, first frame (creates+draws): %9zu B " + "(%.1f KB)\n", + cold2D, + cold2D / 1024.0); + printf(" Ore ordered stream, first frame (creates+passes): %9zu B " + "(%.1f KB)\n", + coldOre.total(), + coldOre.total() / 1024.0); + printf(" steady per-frame (crosses every frame) : %9.0f B " + "(%.2f KB)\n", + perFrameBytes, + perFrameBytes / 1024.0); + printf(" 2D draws (steady, creates amortized) : %9.0f B\n", + perFrame2D); + if (pure2D) + { + printf("\n-- phase breakdown (pure-2D, single clean frame, us) --\n"); + printf(" IMMEDIATE (all on the main thread):\n"); + printf(" advance (anim / IK / skin / databind) : %8.1f us\n", + immAdv); + printf(" render CPU (issue draw calls) : %8.1f us\n", + immCpu); + printf(" flush + present (feed the GPU) : %8.1f us\n", + immGpu); + printf(" total : %8.1f us\n", + immAdv + immCpu + immGpu); + printf(" DEFERRED:\n"); + printf(" PRODUCER (main): advance (+serialize) : %8.1f us " + "(immediate advance was %.1f)\n", + repAdv, + immAdv); + printf(" PRODUCER (main): record draw commands : %8.1f us " + "(immediate draw-CPU was %.1f)\n", + repRecDraw, + immCpu); + printf(" PRODUCER total (main thread) : %8.1f us\n", + repAdv + repRecDraw); + printf(" CONSUMER (render): replay CPU : %8.1f us " + "(parse + apply + draw calls)\n", + repCpu); + printf(" CONSUMER (render): flush + present : %8.1f us\n", + repGpu); + printf(" consumer total (render thread) : %8.1f us\n", + repCpu + repGpu); + printf(" deltas:\n"); + printf(" GPU feed: replay vs immediate : %+8.1f us " + "(should be ~0 — identical work)\n", + repGpu - immGpu); + printf(" parse/apply tax: replayCPU - immCPU : %+8.1f us\n", + repCpu - immCpu); + printf(" advance moved off render thread : %8.1f us\n", + immAdv); + printf( + "\n-- matched-frame serializer cost (same animation state) --\n"); + printf(" advance: immediate %.1f vs deferred %.1f " + "=> serializer-in-advance %+.1f us\n", + mImmAdv, + mDefAdv, + mDefAdv - mImmAdv); + printf(" draws : immediate issue %.1f vs deferred record %.1f " + "=> %+.1f us\n", + mImmRen, + mDefRec, + mDefRec - mImmRen); + printf(" total serializer overhead vs immediate: %+.1f us/frame\n", + (mDefAdv - mImmAdv) + (mDefRec - mImmRen)); + } + printf("=======================================================\n\n"); +} + +#endif // WITH_RIVE_SCRIPTING && RIVE_CANVAS + +#endif // TESTING
diff --git a/tests/goldens/goldens_shared.hpp b/tests/goldens/goldens_shared.hpp new file mode 100644 index 0000000..7f8e4b8 --- /dev/null +++ b/tests/goldens/goldens_shared.hpp
@@ -0,0 +1,258 @@ +/* + * Copyright 2022 Rive + */ + +// Shared between goldens.cpp and the env gated diagnostics in +// goldens_bench.cpp. + +#pragma once + +// Don't compile the goldens tool as part of the "tests" project. +#ifndef TESTING + +#include "goldens_arguments.hpp" +#include "common/test_harness.hpp" +#include "common/testing_window.hpp" +#include "rive/artboard.hpp" +#include "rive/renderer.hpp" +#include "rive/file.hpp" +#include "rive/refcnt.hpp" +#include "rive/animation/state_machine_instance.hpp" +#include "rive/static_scene.hpp" +#ifdef WITH_RIVE_SCRIPTING +#include "rive/lua/scripting_vm.hpp" +#include "rive/lua/rive_lua_libs.hpp" +#endif +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) +// RIVE_GOLDENS_DEFER_ORE records through a DeferredOreContext and replays on +// the real context in the same frame, single threaded. +#include "rive/renderer/render_context.hpp" +#include "rive/renderer/render_context_impl.hpp" +#include "rive/renderer/render_canvas.hpp" +#include "rive/renderer/rive_renderer.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "rive/renderer/cmd/deferred_replayer.hpp" +#endif +#include <cstdlib> +#include <memory> +#include <vector> + +// Builds without scripting or canvas never include the deferred headers but +// the accessors below still name the type. +namespace rive::cmd +{ +class DeferredSession; +} + +extern GoldensArguments s_args; + +// Consoles build without an env API, probes just come back unset there. +inline const char* goldens_getenv(const char* name) +{ +#ifdef NO_GETENV + return nullptr; +#else + return getenv(name); +#endif +} + +void dumpPixelsAsPng(const char* rivName, + int windowWidth, + int windowHeight, + std::vector<uint8_t> pixels); + +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) +// DeferredFrameSink over TestingWindow. Leaves the screen frame open for the +// caller to present via endFrame. +class GoldensFrameSink : public rive::cmd::DeferredFrameSink +{ +public: + GoldensFrameSink(bool doClear = true) : + m_rc(TestingWindow::Get()->renderContext()), m_doClear(doClear) + {} + + rive::Factory* factory() override + { + return TestingWindow::Get()->factory(); + } + + // Goldens render into the one TestingWindow, so there is a single target. + rive::Renderer* beginScreenFrame(uint64_t target) override + { + assert(target == 0); + m_screen = TestingWindow::Get()->beginFrame( + {.clearColor = 0xffffffff, .doClear = m_doClear}); + return m_screen.get(); + } + void beginOreFrame() override { TestingWindow::Get()->beginOreFrame(); } + void endOreFrame() override { TestingWindow::Get()->endOreFrame(); } + + rive::Renderer* beginCanvasContent(rive::gpu::RenderCanvas* canvas, + uint32_t clearColor) override + { + m_activeCanvas = canvas; + rive::gpu::RenderContext::FrameDescriptor d{}; + d.renderTargetWidth = canvas->width(); + d.renderTargetHeight = canvas->height(); + d.loadAction = rive::gpu::LoadAction::clear; + d.clearColor = clearColor; + m_rc->beginFrame(d); + m_canvasRenderer = new rive::RiveRenderer(m_rc); + return m_canvasRenderer; + } + void endCanvasContent() override + { + if (m_activeCanvas == nullptr) + return; + void* cb = m_rc->impl()->makeCommandBuffer(); + rive::gpu::RenderContext::FlushResources fr{}; + fr.renderTarget = m_activeCanvas->renderTarget(); + fr.externalCommandBuffer = cb; + m_rc->flush(fr); + m_rc->impl()->commitCommandBuffer(cb); + delete m_canvasRenderer; + m_canvasRenderer = nullptr; + m_activeCanvas = nullptr; + } + +private: + rive::gpu::RenderContext* m_rc; + bool m_doClear; + std::unique_ptr<rive::Renderer> m_screen; + rive::RiveRenderer* m_canvasRenderer = nullptr; + rive::gpu::RenderCanvas* m_activeCanvas = nullptr; +}; + +// RIVE_GOLDENS_BENCH=<iters> loads the same scene immediate and deferred and +// reports per frame record cost, replay cost, and stream size. +void run_benchmark(const std::vector<uint8_t>& bytes, + const char* artboardName, + const char* stateMachineName, + int iters); +#endif + +class RIVLoader +{ +public: + // Auto defers for --deferred, or RIVE_GOLDENS_DEFER_ORE with a single + // cell. The benchmark forces one or the other to load both side by side. + enum class DeferMode + { + Auto, + Immediate, + Deferred + }; + + RIVLoader(const std::vector<uint8_t>& rivBytes, + const char* artboardName, + const char* stateMachineName, + DeferMode mode = DeferMode::Auto) + { + rive::Factory* importFactory = TestingWindow::Get()->factory(); +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) + // Importing through the DeferredSession makes the artboard's own 2D + // resources deferred objects with ids so drawInternal can record. + bool wantDeferred = + mode == DeferMode::Deferred || + (mode == DeferMode::Auto && + (s_args.deferred() || (goldens_getenv("RIVE_GOLDENS_DEFER_ORE") && + s_args.cols() * s_args.rows() == 1))); + if (wantDeferred) + { + if (auto* rc = TestingWindow::Get()->renderContext()) + { + if (auto* ore = rc->getOreContext()) + { + m_session = + std::make_unique<rive::cmd::DeferredSession>(ore); + importFactory = m_session.get(); + } + } + } +#endif + m_file = rive::File::import(rivBytes, importFactory); + if (m_file == nullptr) + { + throw "Bad riv file"; + } +#ifdef WITH_RIVE_SCRIPTING + // Without the RenderContext handed over like a real host does, + // gpuCanvas bails and no GPU work runs. + if (auto* vm = m_file->scriptingVM()) + { + vm->context()->setRenderContext( + TestingWindow::Get()->renderContext()); +#if defined(RIVE_CANVAS) + if (m_session) + { + vm->context()->setOreContext(&m_session->oreContext()); + // Regular canvas 2D content records into the deferred stream. + vm->context()->setDeferredCanvasHost(m_session.get()); + } +#endif + } +#endif + if (artboardName != nullptr && artboardName[0] != '\0') + { + m_artboard = m_file->artboardNamed(artboardName); + } + else + { + m_artboard = m_file->artboardDefault(); + } + if (m_artboard == nullptr) + { + throw "Can't load artboard"; + } + + // Bind the default view model instance + m_viewModelInstance = m_file->createViewModelInstance(m_artboard.get()); + m_artboard->bindViewModelInstance(m_viewModelInstance); + + if (stateMachineName != nullptr && stateMachineName[0] != '\0') + { + m_scene = m_artboard->stateMachineNamed(stateMachineName); + } + else + { + m_scene = m_artboard->defaultStateMachine(); + } + + if (m_scene == nullptr) + { + // This is a riv without any state machines. Just draw the artboard. + m_scene = std::make_unique<rive::StaticScene>(m_artboard.get()); + } + + if (m_scene != nullptr && m_viewModelInstance != nullptr) + { + m_scene->bindViewModelInstance(m_viewModelInstance); + } + } + + rive::Scene* stateMachine() const { return m_scene.get(); } + rive::Artboard* artboard() const { return m_artboard.get(); } + + // Null when deferred mode is off. + rive::cmd::DeferredSession* deferredSession() const + { +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) + return m_session.get(); +#else + return nullptr; +#endif + } + +private: + // Destroyed last since deferred resources held by the file record their + // destruction into the session, so it must outlive them. +#if defined(WITH_RIVE_SCRIPTING) && defined(RIVE_CANVAS) + std::unique_ptr<rive::cmd::DeferredSession> m_session; +#endif + rive::rcp<rive::File> m_file; + std::unique_ptr<rive::ArtboardInstance> m_artboard; + std::unique_ptr<rive::Scene> m_scene; + rive::rcp<rive::ViewModelInstance> m_viewModelInstance; +}; + +#endif // TESTING
diff --git a/tests/player/player.cpp b/tests/player/player.cpp index 9c5b8e6..66b0364 100644 --- a/tests/player/player.cpp +++ b/tests/player/player.cpp
@@ -340,11 +340,6 @@ renderer->save(); for (int x = -copiesLeft; x <= copiesRight; ++x) { - // drawInternal skips drawCanvases. In the future we can - // pre-pass them, but ORE commandBuffers are currently wired up - // in a way that causes severe perf regressions and flickering - // on Vulkan. Once deferred rendering is finished we can turn - // pre-passes back on. m_artboard->drawInternal(renderer.get()); renderer->translate(spacingPx, 0); }
diff --git a/tests/premake5.lua b/tests/premake5.lua index 23905ea..465a0a4 100644 --- a/tests/premake5.lua +++ b/tests/premake5.lua
@@ -6,7 +6,12 @@ }) if not _OPTIONS['for_unreal'] then - rive_tools_project('bench', _OPTIONS['os'] == 'ios' and 'StaticLib' or _OPTIONS['all_tools_as_static'] and 'StaticLib' or 'ConsoleApp' ) + rive_tools_project( + 'bench', + _OPTIONS['os'] == 'ios' and 'StaticLib' + or _OPTIONS['all_tools_as_static'] and 'StaticLib' + or 'ConsoleApp' + ) do files({ 'bench/*.cpp' }) end @@ -14,7 +19,15 @@ rive_tools_project('gms', 'RiveTool') do - files({ 'gm/*.cpp'}) + files({ 'gm/*.cpp' }) + -- Deferred-rendering 2D record/replay (SerializingFactory + the replay that + -- drives a real Factory/Renderer) so GMs can verify 2D replay against PLS. + files({ + '../utils/serializing_factory.cpp', + '../utils/serialized_replay.cpp', + }) + -- serializing_factory.cpp decodes images (decoders header). + includedirs({ '../decoders/include' }) -- Ore GM tests need Obj-C++ on Apple (ore headers include <Metal/Metal.h>). -- .mm wrappers #include the .cpp files so every Apple generator compiles -- them as Obj-C++ without needing compileas or buildoptions hacks. @@ -50,7 +63,7 @@ filter({}) filter({ 'options:not no_tools_shader_hotloading' }) do - files({RIVE_PLS_DIR .. '/shader_hotload/**.cpp' }) + files({ RIVE_PLS_DIR .. '/shader_hotload/**.cpp' }) end filter({ 'options:for_unreal' }) do @@ -65,10 +78,15 @@ rive_tools_project('goldens', 'RiveTool') do exceptionhandling('On') - files({ 'goldens/goldens.cpp'}) + files({ 'goldens/goldens.cpp', 'goldens/goldens_bench.cpp' }) + -- The deferred recording factory (deferred_render_factory.hpp) decodes image + -- dimensions at record time so the artboard's layout sees real sizes; needs + -- the decoder header + RIVE_DECODERS (the lib is already linked). + includedirs({ '../decoders/include' }) + defines({ 'RIVE_DECODERS' }) filter({ 'options:not no_tools_shader_hotloading' }) do - files({RIVE_PLS_DIR .. '/shader_hotload/**.cpp' }) + files({ RIVE_PLS_DIR .. '/shader_hotload/**.cpp' }) end filter({ 'options:for_unreal' }) do @@ -82,7 +100,7 @@ rive_tools_project('player', 'RiveTool') do - files({ 'player/player.cpp'}) + files({ 'player/player.cpp' }) filter('system:emscripten') do files({ 'player/player.html' }) @@ -90,6 +108,6 @@ filter({ 'options:not no_tools_shader_hotloading' }) do - files({RIVE_PLS_DIR .. '/shader_hotload/**.cpp' }) + files({ RIVE_PLS_DIR .. '/shader_hotload/**.cpp' }) end end
diff --git a/tests/unit_tests/assets/parity/Halloween_v3.riv b/tests/unit_tests/assets/parity/Halloween_v3.riv new file mode 100644 index 0000000..5d1677b --- /dev/null +++ b/tests/unit_tests/assets/parity/Halloween_v3.riv Binary files differ
diff --git a/tests/unit_tests/assets/parity/Knight_square_2.riv b/tests/unit_tests/assets/parity/Knight_square_2.riv new file mode 100644 index 0000000..d5bf938 --- /dev/null +++ b/tests/unit_tests/assets/parity/Knight_square_2.riv Binary files differ
diff --git a/tests/unit_tests/assets/parity/Tom_Morello.riv b/tests/unit_tests/assets/parity/Tom_Morello.riv new file mode 100644 index 0000000..f1c0b75 --- /dev/null +++ b/tests/unit_tests/assets/parity/Tom_Morello.riv Binary files differ
diff --git a/tests/unit_tests/assets/parity/UI_Swipe_left_to_delete.riv b/tests/unit_tests/assets/parity/UI_Swipe_left_to_delete.riv new file mode 100644 index 0000000..5e1dbf2 --- /dev/null +++ b/tests/unit_tests/assets/parity/UI_Swipe_left_to_delete.riv Binary files differ
diff --git a/tests/unit_tests/assets/parity/falling.riv b/tests/unit_tests/assets/parity/falling.riv new file mode 100644 index 0000000..dac76ad --- /dev/null +++ b/tests/unit_tests/assets/parity/falling.riv Binary files differ
diff --git a/tests/unit_tests/assets/parity/popsicle_loader.riv b/tests/unit_tests/assets/parity/popsicle_loader.riv new file mode 100644 index 0000000..2d7ba67 --- /dev/null +++ b/tests/unit_tests/assets/parity/popsicle_loader.riv Binary files differ
diff --git a/tests/unit_tests/renderer/canvas_schedule_test.cpp b/tests/unit_tests/renderer/canvas_schedule_test.cpp new file mode 100644 index 0000000..eb688fa --- /dev/null +++ b/tests/unit_tests/renderer/canvas_schedule_test.cpp
@@ -0,0 +1,276 @@ +/* + * 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); +}
diff --git a/tests/unit_tests/renderer/deferred_flush_parity_test.cpp b/tests/unit_tests/renderer/deferred_flush_parity_test.cpp new file mode 100644 index 0000000..62c0c9b --- /dev/null +++ b/tests/unit_tests/renderer/deferred_flush_parity_test.cpp
@@ -0,0 +1,361 @@ +/* + * Copyright 2026 Rive + */ + +// Gate for the deferred recording changes: the same riv drawn immediately and +// drawn through a record + replay round trip must ask the render context for +// the same GPU work, flush for flush. Ported from the draw-time serialization +// tree so both sides are held to one bar. + +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/deferred_replayer.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "rive/renderer/rive_renderer.hpp" +#include "common/render_context_null.hpp" +#include "rive_file_reader.hpp" +#include "rive/scene.hpp" + +#include <catch.hpp> +#include <algorithm> +#include <cmath> +#include <filesystem> +#include <string> +#include <vector> + +using namespace rive; +using namespace rive::cmd; + +namespace +{ +constexpr int kFrames = 30; +constexpr int kFirstSteadyFrame = 2; +constexpr float kFrameSeconds = 1.0f / 60; + +void advanceFrame(Scene* scene, ArtboardInstance* artboard, int frame) +{ + float seconds = frame == 0 ? 0 : kFrameSeconds; + if (scene != nullptr) + { + scene->advanceAndApply(seconds); + } + else + { + artboard->advance(seconds); + } +} + +void drawFrame(Scene* scene, ArtboardInstance* artboard, Renderer* renderer) +{ + renderer->save(); + if (scene != nullptr) + { + scene->draw(renderer); + } + else + { + artboard->draw(renderer); + } + renderer->restore(); +} + +// What one frame asked of the render context, summed over its flushes. +struct FlushStats +{ + uint64_t flushes = 0; + uint64_t pathCount = 0; + uint64_t contourCount = 0; + uint64_t tessVertexSpans = 0; + uint64_t gradSpans = 0; + uint64_t gradDataHeight = 0; + uint64_t tessDataHeight = 0; + uint64_t atlasFillBatches = 0; + uint64_t atlasStrokeBatches = 0; + uint64_t atlasContentArea = 0; +}; + +FlushStats operator-(const FlushStats& a, const FlushStats& b) +{ + return {a.flushes - b.flushes, + a.pathCount - b.pathCount, + a.contourCount - b.contourCount, + a.tessVertexSpans - b.tessVertexSpans, + a.gradSpans - b.gradSpans, + a.gradDataHeight - b.gradDataHeight, + a.tessDataHeight - b.tessDataHeight, + a.atlasFillBatches - b.atlasFillBatches, + a.atlasStrokeBatches - b.atlasStrokeBatches, + a.atlasContentArea - b.atlasContentArea}; +} + +class FlushObservingNULL : public RenderContextNULL +{ +public: + FlushStats stats; + uint32_t featuresEver = 0; // union of combinedShaderFeatures + + void flush(const gpu::FlushDescriptor& d) override + { + featuresEver |= static_cast<uint32_t>(d.combinedShaderFeatures); + stats.flushes++; + stats.pathCount += d.pathCount; + stats.contourCount += d.contourCount; + stats.tessVertexSpans += d.tessVertexSpanCount; + stats.gradSpans += d.gradSpanCount; + stats.gradDataHeight += d.gradDataHeight; + stats.tessDataHeight += d.tessDataHeight; + stats.atlasFillBatches += d.featherAtlasFillBatchCount; + stats.atlasStrokeBatches += d.featherAtlasStrokeBatchCount; + stats.atlasContentArea += uint64_t(d.featherAtlasContentWidth) * + uint64_t(d.featherAtlasContentHeight); + } +}; + +class ObservingContext : public gpu::RenderContext +{ +public: + ObservingContext() : RenderContext(std::make_unique<FlushObservingNULL>()) + {} + FlushObservingNULL* observer() + { + return static_impl_cast<FlushObservingNULL>(); + } +}; + +std::vector<FlushStats> runImmediate(const char* rivPath, uint32_t* features) +{ + ObservingContext ctx; + auto file = ReadRiveFile(rivPath, &ctx); + auto artboard = file->artboardDefault(); + auto scene = artboard->defaultScene(); + uint32_t w = static_cast<uint32_t>(std::ceil(artboard->width())); + uint32_t h = static_cast<uint32_t>(std::ceil(artboard->height())); + auto rt = ctx.observer()->makeRenderTarget(w, h); + + std::vector<FlushStats> frames; + for (int frame = 0; frame < kFrames; frame++) + { + advanceFrame(scene.get(), artboard.get(), frame); + FlushStats before = ctx.observer()->stats; + ctx.beginFrame({.renderTargetWidth = w, .renderTargetHeight = h}); + RiveRenderer renderer(&ctx); + drawFrame(scene.get(), artboard.get(), &renderer); + ctx.flush({.renderTarget = rt.get()}); + frames.push_back(ctx.observer()->stats - before); + } + *features = ctx.observer()->featuresEver; + return frames; +} + +// Opens the real screen frame on the observed context, like the host sinks. +class NullContextSink : public DeferredFrameSink +{ +public: + NullContextSink(ObservingContext* ctx, uint32_t w, uint32_t h) : + m_ctx(ctx), m_width(w), m_height(h) + {} + + Factory* factory() override { return m_ctx; } + ore::Context* oreContext() override { return nullptr; } + // Parity is defined against the single render target the immediate side + // draws, so a second target would have nothing to compare with. + Renderer* beginScreenFrame(uint64_t target) override + { + REQUIRE(target == 0); + m_ctx->beginFrame( + {.renderTargetWidth = m_width, .renderTargetHeight = m_height}); + m_renderer = std::make_unique<RiveRenderer>(m_ctx); + return m_renderer.get(); + } + bool frameOpen() const { return m_renderer != nullptr; } + void closeFrame() { m_renderer = nullptr; } + +private: + ObservingContext* m_ctx; + uint32_t m_width, m_height; + std::unique_ptr<RiveRenderer> m_renderer; +}; + +std::vector<FlushStats> runDeferred(const char* rivPath) +{ + DeferredSession session(nullptr); + auto file = ReadRiveFile(rivPath, &session); + auto artboard = file->artboardDefault(); + auto scene = artboard->defaultScene(); + uint32_t w = static_cast<uint32_t>(std::ceil(artboard->width())); + uint32_t h = static_cast<uint32_t>(std::ceil(artboard->height())); + + ObservingContext ctx; + auto rt = ctx.observer()->makeRenderTarget(w, h); + NullContextSink sink(&ctx, w, h); + DeferredReplayer replayer; + + std::vector<FlushStats> frames; + for (int frame = 0; frame < kFrames; frame++) + { + advanceFrame(scene.get(), artboard.get(), frame); + drawFrame(scene.get(), artboard.get(), session.screenRenderer()); + DeferredFrame snapshot = snapshotFrame(session); + session.resetFrame(); + + FlushStats before = ctx.observer()->stats; + replayer.replayFrame(snapshot, sink); + CHECK(replayer.droppedDraws() == 0); + if (sink.frameOpen()) + { + ctx.flush({.renderTarget = rt.get()}); + sink.closeFrame(); + } + frames.push_back(ctx.observer()->stats - before); + } + return frames; +} + +void printFlushParity(const char* name, + const std::vector<FlushStats>& imm, + const std::vector<FlushStats>& def) +{ + auto steadyAvg = [](const std::vector<FlushStats>& v, auto pick) { + double sum = 0; + for (size_t i = kFirstSteadyFrame; i < v.size(); i++) + { + sum += static_cast<double>(pick(v[i])); + } + return sum / static_cast<double>(v.size() - kFirstSteadyFrame); + }; + printf("\n== %s flush parity (steady state, per frame) ==\n", name); + printf(" %-18s %12s %12s\n", "", "immediate", "deferred"); + auto row = [&](const char* label, auto pick) { + printf(" %-18s %12.1f %12.1f\n", + label, + steadyAvg(imm, pick), + steadyAvg(def, pick)); + }; + row("flushes", [](const FlushStats& s) { return s.flushes; }); + row("paths", [](const FlushStats& s) { return s.pathCount; }); + row("contours", [](const FlushStats& s) { return s.contourCount; }); + row("tessSpans", [](const FlushStats& s) { return s.tessVertexSpans; }); + row("tessDataHeight", [](const FlushStats& s) { return s.tessDataHeight; }); + row("gradSpans", [](const FlushStats& s) { return s.gradSpans; }); + row("gradDataHeight", [](const FlushStats& s) { return s.gradDataHeight; }); + row("atlasFillBatches", + [](const FlushStats& s) { return s.atlasFillBatches; }); + row("atlasStrokeBatches", + [](const FlushStats& s) { return s.atlasStrokeBatches; }); + row("atlasContentArea", + [](const FlushStats& s) { return s.atlasContentArea; }); +} + +void printShaderFeatures(uint32_t features) +{ + static const char* kNames[] = {"CLIPPING", + "CLIP_RECT", + "ADVANCED_BLEND", + "FEATHER", + "EVEN_ODD", + "NESTED_CLIPPING", + "HSL_BLEND_MODES", + "DITHER"}; + printf(" shader features:"); + for (size_t i = 0; i < 8; i++) + { + if (features & (1u << i)) + { + printf(" %s", kNames[i]); + } + } + printf("\n"); +} + +// A missing riv fails the gate rather than passing vacuously. +void requireRiv(const std::string& path) +{ + FILE* fp = fopen(path.c_str(), "rb"); + if (fp == nullptr) + { + FAIL("flush parity riv missing: " << path); + } + fclose(fp); +} + +void checkParity(const char* name, + const std::vector<FlushStats>& imm, + const std::vector<FlushStats>& def) +{ + // Equal flush structure means recording changed nothing the renderer can + // see. + for (size_t i = kFirstSteadyFrame; i < imm.size(); i++) + { + INFO(name << " frame " << i); + CHECK(imm[i].flushes == def[i].flushes); + CHECK(imm[i].tessVertexSpans == def[i].tessVertexSpans); + CHECK(imm[i].atlasContentArea == def[i].atlasContentArea); + CHECK(imm[i].gradDataHeight == def[i].gradDataHeight); + } +} + +void flushParity(const char* name) +{ + // Plain git assets so device deploys carry real bytes, not lfs pointers. + std::string path = std::string("assets/parity/") + name; + requireRiv(path); + uint32_t features = 0; + auto imm = runImmediate(path.c_str(), &features); + auto def = runDeferred(path.c_str()); + printFlushParity(name, imm, def); + printShaderFeatures(features); + checkParity(name, imm, def); +} + +// Whole corpus sweep, so properties the six named rivs never exercise +// (feathers above all) are still held to parity. Hidden because it is slow +// and needs the full lfs corpus; run with test.sh -m "[.corpus_parity]". +void flushParityQuiet(const std::string& name) +{ + std::string path = std::string("../../../../zzzgold/rivs/") + name; + FILE* fp = fopen(path.c_str(), "rb"); + if (fp == nullptr) + { + return; + } + fclose(fp); + uint32_t features = 0; + auto imm = runImmediate(path.c_str(), &features); + auto def = runDeferred(path.c_str()); + checkParity(name.c_str(), imm, def); +} +} // namespace + +TEST_CASE("deferred flush parity, regressing rivs", "[deferred_flush_parity]") +{ + flushParity("Halloween_v3.riv"); + flushParity("UI_Swipe_left_to_delete.riv"); + flushParity("Tom_Morello.riv"); +} + +TEST_CASE("deferred flush parity, parity rivs", "[deferred_flush_parity]") +{ + flushParity("Knight_square_2.riv"); + flushParity("falling.riv"); + flushParity("popsicle_loader.riv"); +} + +TEST_CASE("deferred flush parity, whole corpus", "[.][corpus_parity]") +{ + std::vector<std::string> names; + std::error_code ec; + for (const auto& e : + std::filesystem::directory_iterator("../../../../zzzgold/rivs/", ec)) + { + std::string n = e.path().filename().string(); + if (n.size() > 4 && n.compare(n.size() - 4, 4, ".riv") == 0) + { + names.push_back(n); + } + } + std::sort(names.begin(), names.end()); + printf("corpus flush parity over %zu rivs\n", names.size()); + for (const std::string& n : names) + { + flushParityQuiet(n); + } +}
diff --git a/tests/unit_tests/renderer/deferred_measure_test.cpp b/tests/unit_tests/renderer/deferred_measure_test.cpp new file mode 100644 index 0000000..7d6d070 --- /dev/null +++ b/tests/unit_tests/renderer/deferred_measure_test.cpp
@@ -0,0 +1,644 @@ +/* + * Copyright 2026 Rive + */ + +// Measurement harness for the draw-time serialization work. Deliberately +// source identical between the pre-refactor and post-refactor trees so the +// two sides can be compared opcode for opcode and microsecond for +// microsecond. Emits machine readable rows; nothing here asserts on a +// threshold. +// +// Hidden tag; run explicitly with test.sh -m "[deferred_measure]". +// +// Environment: +// RIVE_MEASURE_FRAMES total frames per riv (default 3000) +// RIVE_MEASURE_WARMUP frames treated as transient (default 300) +// RIVE_MEASURE_RIVS comma separated riv names, or "all" for the +// whole zzzgold corpus (default: a +// 6 riv short list) +// RIVE_MEASURE_SESSIONS concurrent sessions for the resident table +// sizing case (default 8) + +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/deferred_render_resource.hpp" +#include "rive/renderer/cmd/deferred_replayer.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "rive/renderer/cmd/render_commands.hpp" +#include "rive/renderer/cmd/render_replay.hpp" +#include "rive_file_reader.hpp" +#include "rive/scene.hpp" +#include "utils/factory_utils.hpp" +#include "utils/no_op_renderer.hpp" + +#include <catch.hpp> +#include <algorithm> +#include <chrono> +#include <cstdlib> +#include <cstring> +#include <filesystem> +#include <string> +#include <type_traits> +#include <vector> + +using namespace rive; +using namespace rive::cmd; + +namespace +{ +// ---- configuration ---- + +int envInt(const char* name, int fallback) +{ + const char* v = getenv(name); + return v != nullptr && *v != '\0' ? atoi(v) : fallback; +} + +const char* kRivDir = "../../../../zzzgold/rivs/"; + +std::vector<std::string> corpus() +{ + const char* v = getenv("RIVE_MEASURE_RIVS"); + std::string spec = v != nullptr && *v != '\0' ? v : ""; + std::vector<std::string> names; + if (spec == "all") + { + std::error_code ec; + for (const auto& e : std::filesystem::directory_iterator(kRivDir, ec)) + { + std::string n = e.path().filename().string(); + if (n.size() > 4 && n.compare(n.size() - 4, 4, ".riv") == 0) + { + names.push_back(n); + } + } + std::sort(names.begin(), names.end()); + return names; + } + if (spec.empty()) + { + return {"Halloween_v3.riv", + "UI_Swipe_left_to_delete.riv", + "Tom_Morello.riv", + "Knight_square_2.riv", + "falling.riv", + "popsicle_loader.riv"}; + } + size_t start = 0; + while (start <= spec.size()) + { + size_t comma = spec.find(',', start); + if (comma == std::string::npos) + { + comma = spec.size(); + } + if (comma > start) + { + names.push_back(spec.substr(start, comma - start)); + } + start = comma + 1; + } + return names; +} + +// ---- sink ---- + +// No-op resources: replay mutates them freely and the factory counts +// creations, so consumer side object churn per frame is observable. +class MPath : public RenderPath +{ +public: + void rewind() override {} + void fillRule(FillRule) override {} + void addPath(CommandPath*, const Mat2D&) override {} + void addRenderPath(const RenderPath*, const Mat2D&) override {} + void addRawPath(const RawPath&) override {} + void moveTo(float, float) override {} + void lineTo(float, float) override {} + void cubicTo(float, float, float, float, float, float) override {} + void close() override {} +}; + +class MPaint : public RenderPaint +{ +public: + void color(unsigned int) override {} + void style(RenderPaintStyle) override {} + void thickness(float) override {} + void join(StrokeJoin) override {} + void cap(StrokeCap) override {} + void blendMode(BlendMode) override {} + void shader(rcp<RenderShader>) override {} + void invalidateStroke() override {} + void feather(float) override {} +}; + +class MShader : public RenderShader +{}; +class MImage : public RenderImage +{}; + +class MFactory : public Factory +{ +public: + int paths = 0, paints = 0, shaders = 0, buffers = 0, images = 0; + + rcp<RenderPath> makeRenderPath(RawPath&, FillRule) override + { + paths++; + return make_rcp<MPath>(); + } + rcp<RenderPath> makeEmptyRenderPath() override + { + paths++; + return make_rcp<MPath>(); + } + rcp<RenderPaint> makeRenderPaint() override + { + paints++; + return make_rcp<MPaint>(); + } + rcp<RenderShader> makeLinearGradient(float, + float, + float, + float, + const ColorInt[], + const float[], + size_t) override + { + shaders++; + return make_rcp<MShader>(); + } + rcp<RenderShader> makeRadialGradient(float, + float, + float, + const ColorInt[], + const float[], + size_t) override + { + shaders++; + return make_rcp<MShader>(); + } + rcp<RenderBuffer> makeRenderBuffer(RenderBufferType t, + RenderBufferFlags f, + size_t s) override + { + buffers++; + return make_rcp<DataRenderBuffer>(t, f, s); + } + rcp<RenderImage> decodeImage(Span<const uint8_t>) override + { + images++; + return make_rcp<MImage>(); + } +}; + +class MSink : public DeferredFrameSink +{ +public: + MFactory f; + Factory* factory() override { return &f; } + ore::Context* oreContext() override { return nullptr; } + // The harness measures one session against one screen, and a no-op + // renderer has nothing to dispatch per target anyway. + Renderer* beginScreenFrame(uint64_t target) override + { + REQUIRE(target == 0); + return &m_renderer; + } + +private: + NoOpRenderer m_renderer; +}; + +// ---- retained geometry, present only on the post-refactor tree ---- + +template <typename T, typename = void> struct HasRetained : std::false_type +{}; +template <typename T> +struct HasRetained<T, decltype(void(T::retainedGeometryBytes()))> + : std::true_type +{}; + +// Templated so the branch the tree does not have is never looked up: an +// if constexpr in a plain function still requires both arms to name real +// members. +template <typename Path = DeferredRenderPath> int64_t retainedGeometry() +{ + if constexpr (HasRetained<Path>::value) + { + return Path::retainedGeometryBytes(); + } + else + { + // The pre-refactor path serializes each mutation straight into the + // stream and holds no authoritative geometry, so there is nothing to + // report and no counter to read. + return -2; + } +} + +// ---- stream census ---- + +constexpr size_t kNumCmds = static_cast<size_t>(RenderCmd::lastRenderCmd) + 1; + +const char* cmdName(size_t i) +{ + switch (static_cast<RenderCmd>(i)) + { +#define RIVE_MEASURE_CMD_NAME(cmd, POD) \ + case RenderCmd::cmd: \ + return #cmd; + RIVE_RENDER_CMD_TABLE(RIVE_MEASURE_CMD_NAME) +#undef RIVE_MEASURE_CMD_NAME + } + return "?"; +} + +struct Census +{ + uint64_t count[kNumCmds] = {}; + uint64_t geomBytes = 0; + uint64_t commandBytes = 0, blobBytes = 0; + uint64_t frames = 0; + bool overrun = false; + + void add(const RenderCommandBuffer& buf) + { + frames++; + commandBytes += buf.commandBytes().size(); + blobBytes += buf.blobBytes().size(); + CommandReader<uint8_t> r(buf.commandBytes(), buf.blobBytes()); + uint8_t type; + while (r.next(type)) + { + if (type >= kNumCmds) + { + overrun = true; + break; + } + RenderCmd cmd = static_cast<RenderCmd>(type); + count[type]++; + switch (cmd) + { + case RenderCmd::makePath: + { + auto c = r.read<MakePathPOD>(); + geomBytes += c.verbCount * sizeof(PathVerb) + + c.pointCount * sizeof(Vec2D); + break; + } + case RenderCmd::pathAddRawPath: + { + auto c = r.read<PathRawPOD>(); + geomBytes += c.verbCount * sizeof(PathVerb) + + c.pointCount * sizeof(Vec2D); + break; + } + default: + r.skip(payloadSizeOf(cmd)); + break; + } + } + overrun |= r.overrun(); + } +}; + +// ---- one riv ---- + +struct Phase +{ + double advanceUs = 0, recordUs = 0, snapshotUs = 0, replayUs = 0; + uint64_t frames = 0; + Census census; + int64_t sinkPaths = 0, sinkPaints = 0, sinkShaders = 0, sinkBuffers = 0, + sinkImages = 0; +}; + +void row(const char* riv, const char* phase, const char* metric, double value) +{ + printf("MEASURE,%s,%s,%s,%.6f\n", riv, phase, metric, value); +} + +void emit(const char* riv, const char* name, const Phase& p) +{ + if (p.frames == 0) + { + return; + } + double n = static_cast<double>(p.frames); + row(riv, name, "frames", n); + row(riv, name, "advance_us", p.advanceUs / n); + row(riv, name, "record_us", p.recordUs / n); + row(riv, name, "snapshot_us", p.snapshotUs / n); + row(riv, name, "replay_us", p.replayUs / n); + row(riv, name, "cmd_bytes", p.census.commandBytes / n); + row(riv, name, "blob_bytes", p.census.blobBytes / n); + row(riv, + name, + "stream_bytes", + (p.census.commandBytes + p.census.blobBytes) / n); + row(riv, name, "geom_bytes", p.census.geomBytes / n); + row(riv, name, "sink_paths", p.sinkPaths / n); + row(riv, name, "sink_paints", p.sinkPaints / n); + row(riv, name, "sink_shaders", p.sinkShaders / n); + row(riv, name, "sink_buffers", p.sinkBuffers / n); + row(riv, name, "sink_images", p.sinkImages / n); + for (size_t c = 0; c < kNumCmds; c++) + { + if (p.census.count[c] != 0) + { + std::string m = std::string("op_") + cmdName(c); + row(riv, name, m.c_str(), p.census.count[c] / n); + } + } +} + +void measureRiv(const std::string& name, int frames, int warmup) +{ + std::string path = std::string(kRivDir) + name; + FILE* fp = fopen(path.c_str(), "rb"); + if (fp == nullptr) + { + printf("MEASURE_SKIP,%s,missing\n", name.c_str()); + return; + } + fclose(fp); + + DeferredSession session(nullptr); + auto file = ReadRiveFile(path.c_str(), &session); + if (file == nullptr) + { + printf("MEASURE_SKIP,%s,undecodable\n", name.c_str()); + return; + } + auto artboard = file->artboardDefault(); + if (artboard == nullptr) + { + printf("MEASURE_SKIP,%s,no_artboard\n", name.c_str()); + return; + } + auto scene = artboard->defaultScene(); + + MSink sink; + DeferredReplayer replayer; + Phase first, transient, steady; + int64_t retainedAtSteady = -3; + uint32_t dropped = 0; + + for (int frame = 0; frame < frames; frame++) + { + Phase* p = frame == 0 ? &first : frame < warmup ? &transient : &steady; + float seconds = frame == 0 ? 0.f : 1.f / 60.f; + + auto t0 = std::chrono::steady_clock::now(); + if (scene != nullptr) + { + scene->advanceAndApply(seconds); + } + else + { + artboard->advance(seconds); + } + auto t1 = std::chrono::steady_clock::now(); + Renderer* renderer = session.screenRenderer(); + renderer->save(); + if (scene != nullptr) + { + scene->draw(renderer); + } + else + { + artboard->draw(renderer); + } + renderer->restore(); + auto t2 = std::chrono::steady_clock::now(); + + p->census.add(session.commandBuffer()); + + DeferredFrame snapshot = snapshotFrame(session); + session.resetFrame(); + auto t3 = std::chrono::steady_clock::now(); + + MFactory& f = sink.f; + int paths = f.paths, paints = f.paints, shaders = f.shaders, + buffers = f.buffers, images = f.images; + replayer.replayFrame(snapshot, sink); + auto t4 = std::chrono::steady_clock::now(); + dropped += replayer.droppedDraws(); + + auto us = [](auto a, auto b) { + return std::chrono::duration<double, std::micro>(b - a).count(); + }; + p->advanceUs += us(t0, t1); + p->recordUs += us(t1, t2); + p->snapshotUs += us(t2, t3); + p->replayUs += us(t3, t4); + p->frames++; + p->sinkPaths += f.paths - paths; + p->sinkPaints += f.paints - paints; + p->sinkShaders += f.shaders - shaders; + p->sinkBuffers += f.buffers - buffers; + p->sinkImages += f.images - images; + + if (frame == frames - 1) + { + retainedAtSteady = retainedGeometry(); + } + } + + emit(name.c_str(), "first", first); + emit(name.c_str(), "transient", transient); + emit(name.c_str(), "steady", steady); + row(name.c_str(), + "run", + "retained_geometry_bytes", + static_cast<double>(retainedAtSteady)); + row(name.c_str(), "run", "dropped_draws", static_cast<double>(dropped)); + row(name.c_str(), + "run", + "stream_overrun", + first.census.overrun || transient.census.overrun || + steady.census.overrun + ? 1 + : 0); + + // Consumer resident tables after the run: how far the dense vectors had + // to grow, which is what process wide ids trade against. + const ResourceTable& t = replayer.table(); + auto live = [](const auto& r) { + size_t n = 0; + for (const auto& o : r.objects) + { + n += o != nullptr ? 1 : 0; + } + return static_cast<double>(n); + }; + row(name.c_str(), + "resident", + "path_slots", + static_cast<double>(t.paths.objects.size())); + row(name.c_str(), "resident", "path_live", live(t.paths)); + row(name.c_str(), + "resident", + "paint_slots", + static_cast<double>(t.paints.objects.size())); + row(name.c_str(), "resident", "paint_live", live(t.paints)); + row(name.c_str(), + "resident", + "shader_slots", + static_cast<double>(t.shaders.objects.size())); + row(name.c_str(), "resident", "shader_live", live(t.shaders)); + row(name.c_str(), + "resident", + "image_slots", + static_cast<double>(t.images.objects.size())); + row(name.c_str(), + "resident", + "buffer_slots", + static_cast<double>(t.buffers.objects.size())); + row(name.c_str(), "resident", "buffer_live", live(t.buffers)); + // Bytes the dense vectors themselves occupy, ignoring the objects. + double slotBytes = static_cast<double>(t.paths.objects.size()) * + (sizeof(rcp<RenderPath>) + 2 * sizeof(uint32_t)) + + static_cast<double>(t.paints.objects.size()) * + (sizeof(rcp<RenderPaint>) + 2 * sizeof(uint32_t)) + + static_cast<double>(t.shaders.objects.size()) * + (sizeof(rcp<RenderShader>) + 2 * sizeof(uint32_t)) + + static_cast<double>(t.images.objects.size()) * + (sizeof(rcp<RenderImage>) + 2 * sizeof(uint32_t)) + + static_cast<double>(t.buffers.objects.size()) * + (sizeof(rcp<RenderBuffer>) + 2 * sizeof(uint32_t)); + row(name.c_str(), "resident", "slot_vector_bytes", slotBytes); +} +} // namespace + +TEST_CASE("deferred measure", "[.][deferred_measure]") +{ + int frames = envInt("RIVE_MEASURE_FRAMES", 3000); + int warmup = envInt("RIVE_MEASURE_WARMUP", 300); + printf("MEASURE_CONFIG,frames,%d,warmup,%d\n", frames, warmup); + printf("MEASURE_CONFIG,retained_instrumented,%d\n", + HasRetained<DeferredRenderPath>::value ? 1 : 0); + for (const std::string& name : corpus()) + { + measureRiv(name, frames, warmup); + } +} + +// Several sessions live at once, each drawing its own riv, so the consumer +// resident vectors size to the process wide id high water rather than to any +// one session's own resources. +TEST_CASE("deferred measure concurrent sessions", "[.][deferred_measure]") +{ + int sessions = envInt("RIVE_MEASURE_SESSIONS", 8); + int frames = std::max(4, envInt("RIVE_MEASURE_FRAMES", 3000) / 100); + std::vector<std::string> names = corpus(); + if (names.empty()) + { + return; + } + + struct Live + { + std::unique_ptr<DeferredSession> session; + rcp<File> file; + std::unique_ptr<ArtboardInstance> artboard; + std::unique_ptr<Scene> scene; + MSink sink; + DeferredReplayer replayer; + }; + std::vector<std::unique_ptr<Live>> live; + for (int i = 0; i < sessions; i++) + { + const std::string& name = names[i % names.size()]; + std::string path = std::string(kRivDir) + name; + FILE* fp = fopen(path.c_str(), "rb"); + if (fp == nullptr) + { + continue; + } + fclose(fp); + auto l = std::make_unique<Live>(); + l->session = std::make_unique<DeferredSession>(nullptr); + l->file = ReadRiveFile(path.c_str(), l->session.get()); + if (l->file == nullptr) + { + continue; + } + l->artboard = l->file->artboardDefault(); + if (l->artboard == nullptr) + { + continue; + } + l->scene = l->artboard->defaultScene(); + live.push_back(std::move(l)); + } + printf("MEASURE_CONFIG,concurrent_sessions,%d,frames,%d\n", + static_cast<int>(live.size()), + frames); + + for (int frame = 0; frame < frames; frame++) + { + for (auto& l : live) + { + float seconds = frame == 0 ? 0.f : 1.f / 60.f; + if (l->scene != nullptr) + { + l->scene->advanceAndApply(seconds); + } + else + { + l->artboard->advance(seconds); + } + Renderer* r = l->session->screenRenderer(); + r->save(); + if (l->scene != nullptr) + { + l->scene->draw(r); + } + else + { + l->artboard->draw(r); + } + r->restore(); + DeferredFrame snapshot = snapshotFrame(*l->session); + l->session->resetFrame(); + l->replayer.replayFrame(snapshot, l->sink); + } + } + + double totalSlots = 0, totalLive = 0, totalBytes = 0; + for (size_t i = 0; i < live.size(); i++) + { + const ResourceTable& t = live[i]->replayer.table(); + auto liveCount = [](const auto& r) { + size_t n = 0; + for (const auto& o : r.objects) + { + n += o != nullptr ? 1 : 0; + } + return static_cast<double>(n); + }; + double slots = static_cast<double>( + t.paths.objects.size() + t.paints.objects.size() + + t.shaders.objects.size() + t.images.objects.size() + + t.buffers.objects.size()); + double used = liveCount(t.paths) + liveCount(t.paints) + + liveCount(t.shaders) + liveCount(t.images) + + liveCount(t.buffers); + double bytes = slots * (sizeof(rcp<RenderPath>) + 2 * sizeof(uint32_t)); + printf("MEASURE,session_%d,resident,slots,%.0f\n", + static_cast<int>(i), + slots); + printf("MEASURE,session_%d,resident,live,%.0f\n", + static_cast<int>(i), + used); + totalSlots += slots; + totalLive += used; + totalBytes += bytes; + } + printf("MEASURE,all_sessions,resident,slots,%.0f\n", totalSlots); + printf("MEASURE,all_sessions,resident,live,%.0f\n", totalLive); + printf("MEASURE,all_sessions,resident,slot_vector_bytes,%.0f\n", + totalBytes); +}
diff --git a/tests/unit_tests/renderer/deferred_replay_order_test.cpp b/tests/unit_tests/renderer/deferred_replay_order_test.cpp new file mode 100644 index 0000000..0b92c39 --- /dev/null +++ b/tests/unit_tests/renderer/deferred_replay_order_test.cpp
@@ -0,0 +1,306 @@ +/* + * Copyright 2026 Rive + */ + +// Segment replay reorders canvas brackets before screen gaps. The replayer +// hoists creates into a record order pass and defers destroys to a trailing +// pass so reordering cannot break mint order or free a slot early. + +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/deferred_replayer.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "deferred_test_sink.hpp" + +#include <catch.hpp> + +using namespace rive; +using rive::cmd::DeferredSegment; +using Target = rive::cmd::DeferredSegment::Target; +using deferred_test::TestSink; + +namespace +{ +// Counts paint creations so tests can assert version materialization. +class CountingFactory : public SerializingFactory +{ +public: + int paintCount = 0; + rcp<RenderPaint> makeRenderPaint() override + { + paintCount++; + return SerializingFactory::makeRenderPaint(); + } +}; +} // namespace + +using CountingSink = deferred_test::TestSinkT<CountingFactory>; + +TEST_CASE("a create inside a canvas bracket replays in mint order", + "[deferred][replay][segment]") +{ + cmd::DeferredFactory factory; + auto& buffer = factory.commandBuffer(); + auto renderer = factory.makeRenderer(); + + auto paint = factory.makeRenderPaint(); + auto p1 = factory.makeEmptyRenderPath(); // path id 0, screen phase + + // Path id 1 is minted inside the canvas bracket. + uint32_t bracketBegin = static_cast<uint32_t>(buffer.commandBytes().size()); + constexpr cmd::RenderHandle kCanvas = 7 | cmd::kCanvasHandleFlag; + buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentBegin), + cmd::CanvasContentPOD{kCanvas, 0xFF000000}); + auto p2 = factory.makeEmptyRenderPath(); // path id 1, canvas phase + renderer->drawPath(p2.get(), paint.get()); + buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentEnd), + cmd::ResIdPOD{kCanvas}); + uint32_t bracketEnd = static_cast<uint32_t>(buffer.commandBytes().size()); + + // The scheduler runs the bracket first, so without the hoisted create + // pass path id 1 would replay before id 0 and the screen draws drop. + renderer->drawPath(p1.get(), paint.get()); + renderer->drawPath(p2.get(), paint.get()); + + cmd::DeferredFrame frame; + auto copy = [](Span<const uint8_t> s) { + return std::vector<uint8_t>(s.data(), s.data() + s.size()); + }; + frame.commands = copy(buffer.commandBytes()); + frame.blobs = copy(buffer.blobBytes()); + frame.segments = { + {Target::screen, 0, 0, bracketBegin}, + {Target::canvas, 7, bracketBegin, bracketEnd}, + {Target::screen, + 0, + bracketEnd, + static_cast<uint32_t>(frame.commands.size())}, + }; + + TestSink sink; + cmd::DeferredReplayer replayer; + replayer.replayFrame(frame, sink); + CHECK(replayer.droppedDraws() == 0); +} + +TEST_CASE("interleaved multi-target drawing splits per-renderer ranges", + "[deferred][replay][segment]") +{ + cmd::DeferredSession session(nullptr); + auto* screen = session.screenRenderer(); + // Routed canvas recorders like beginCanvasContent hands a script. + cmd::DeferredRenderer c1(&session.commandBuffer(), + &session.canvases(), + &session, + 1); + cmd::DeferredRenderer c2(&session.commandBuffer(), + &session.canvases(), + &session, + 2); + + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + + // Attribution is per renderer, so interleaving must record non nested + // canvas ranges around the screen gaps. + screen->drawPath(path.get(), paint.get()); + c1.drawPath(path.get(), paint.get()); + c2.drawPath(path.get(), paint.get()); + c1.drawPath(path.get(), paint.get()); + screen->drawPath(path.get(), paint.get()); + session.closeOpenRange(); + + std::vector<DeferredSegment> segs; + for (const auto& s : session.recordedSegments()) + { + if (s.target == Target::canvas) + { + segs.push_back(s); + } + } + REQUIRE(segs.size() == 3); + CHECK(segs[0].targetId == 1u); + CHECK(segs[1].targetId == 2u); + CHECK(segs[2].targetId == 1u); + for (size_t i = 1; i < segs.size(); i++) + { + CHECK(segs[i].begin >= segs[i - 1].end); + } + + // Canvas 1's two ranges group into one canvas frame, nothing drops. + cmd::DeferredFrame frame; + auto copy = [](Span<const uint8_t> s) { + return std::vector<uint8_t>(s.data(), s.data() + s.size()); + }; + frame.commands = copy(session.commandBuffer().commandBytes()); + frame.blobs = copy(session.commandBuffer().blobBytes()); + frame.segments = session.schedulerSegments(); + + TestSink sink; + cmd::DeferredReplayer replayer; + replayer.replayFrame(frame, sink); + CHECK(replayer.droppedDraws() == 0); +} + +TEST_CASE("Image:view on a decoded image records an imageView wrap", + "[deferred][replay][image]") +{ + cmd::DeferredSession session(nullptr); + auto view = session.oreContext().recordWrapImageView(42, 64, 64); + REQUIRE(view != nullptr); + + // In imageView mode the canvasId field carries the 2D image resource id. + const auto& stream = session.oreContext().stream(); + ore::cmd::OreCommandReader reader(stream.commandBytes(), + stream.blobBytes()); + ore::cmd::CommandType type; + REQUIRE(reader.next(type)); + REQUIRE(type == ore::cmd::CommandType::wrapCanvasView); + auto pod = reader.read<ore::cmd::WrapCanvasViewPOD>(); + CHECK(pod.canvasId == 42u); + CHECK(pod.mode == + static_cast<uint32_t>(ore::cmd::WrapCanvasViewMode::imageView)); +} + +TEST_CASE("a screen-gap destroy does not starve a reordered canvas segment", + "[deferred][replay][segment]") +{ + cmd::DeferredFactory factory; + auto& buffer = factory.commandBuffer(); + auto renderer = factory.makeRenderer(); + + auto paint = factory.makeRenderPaint(); + auto p1 = factory.makeEmptyRenderPath(); + + // The rcp release records a destroy. + renderer->drawPath(p1.get(), paint.get()); + cmd::RenderHandle id = cmd::DeferredRenderPath::idOfPath(p1.get()); + REQUIRE(id != cmd::kInvalidRenderHandle); + p1 = nullptr; + buffer.drainDestroys(); + + // This bracket is recorded after the destroy but replays before the + // screen gap, so the destroy must stay behind its draws. + uint32_t bracketBegin = static_cast<uint32_t>(buffer.commandBytes().size()); + constexpr cmd::RenderHandle kCanvas = 3 | cmd::kCanvasHandleFlag; + buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentBegin), + cmd::CanvasContentPOD{kCanvas, 0xFF000000}); + auto p2 = factory.makeEmptyRenderPath(); + renderer->drawPath(p2.get(), paint.get()); + buffer.append(static_cast<uint8_t>(cmd::RenderCmd::canvasContentEnd), + cmd::ResIdPOD{kCanvas}); + uint32_t bracketEnd = static_cast<uint32_t>(buffer.commandBytes().size()); + + renderer->drawPath(p2.get(), paint.get()); + + cmd::DeferredFrame frame; + auto copy = [](Span<const uint8_t> s) { + return std::vector<uint8_t>(s.data(), s.data() + s.size()); + }; + frame.commands = copy(buffer.commandBytes()); + frame.blobs = copy(buffer.blobBytes()); + frame.segments = { + {Target::screen, 0, 0, bracketBegin}, + {Target::canvas, 3, bracketBegin, bracketEnd}, + {Target::screen, + 0, + bracketEnd, + static_cast<uint32_t>(frame.commands.size())}, + }; + + TestSink sink; + cmd::DeferredReplayer replayer; + replayer.replayFrame(frame, sink); + CHECK(replayer.droppedDraws() == 0); +} + +TEST_CASE("a paint mutated after a draw keeps the draw's version", + "[deferred][replay][version]") +{ + cmd::DeferredSession session(nullptr); + auto* screen = session.screenRenderer(); + cmd::DeferredRenderer canvas(&session.commandBuffer(), + &session.canvases(), + &session, + 1); + + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + + // Mutations replay in record order ahead of every draw, so the draws pin + // the version they saw: red for the canvas draw, blue for the screen one. + paint->color(0xFFFF0000); + canvas.drawPath(path.get(), paint.get()); + paint->color(0xFF0000FF); + screen->drawPath(path.get(), paint.get()); + session.closeOpenRange(); + + cmd::DeferredFrame frame; + auto copy = [](Span<const uint8_t> s) { + return std::vector<uint8_t>(s.data(), s.data() + s.size()); + }; + frame.commands = copy(session.commandBuffer().commandBytes()); + frame.blobs = copy(session.commandBuffer().blobBytes()); + frame.segments = session.schedulerSegments(); + + CountingSink sink; + cmd::DeferredReplayer replayer; + replayer.replayFrame(frame, sink); + CHECK(replayer.droppedDraws() == 0); + // The bump materialized the red version alongside the live blue paint. + CHECK(sink.serializingFactory.paintCount == 2); +} + +TEST_CASE("a paint mutated only before its draws stays one object", + "[deferred][replay][version]") +{ + cmd::DeferredSession session(nullptr); + auto* screen = session.screenRenderer(); + + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + paint->color(0xFF00FF00); + paint->thickness(2.0f); + screen->drawPath(path.get(), paint.get()); + screen->drawPath(path.get(), paint.get()); + session.closeOpenRange(); + + cmd::DeferredFrame frame; + auto copy = [](Span<const uint8_t> s) { + return std::vector<uint8_t>(s.data(), s.data() + s.size()); + }; + frame.commands = copy(session.commandBuffer().commandBytes()); + frame.blobs = copy(session.commandBuffer().blobBytes()); + frame.segments = session.schedulerSegments(); + + CountingSink sink; + cmd::DeferredReplayer replayer; + replayer.replayFrame(frame, sink); + CHECK(replayer.droppedDraws() == 0); + CHECK(sink.serializingFactory.paintCount == 1); +} + +TEST_CASE("the first mutation of a new frame reuses the live object", + "[deferred][replay][version]") +{ + cmd::DeferredSession session(nullptr); + auto* screen = session.screenRenderer(); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + + CountingSink sink; + cmd::DeferredReplayer replayer; + auto runFrame = [&](ColorInt color) { + paint->color(color); + screen->drawPath(path.get(), paint.get()); + auto frame = cmd::snapshotFrame(session); + session.resetFrame(); + replayer.replayFrame(frame, sink); + CHECK(replayer.droppedDraws() == 0); + }; + // Animated content mutates every frame; the resident paint must be + // reused in place, not reallocated per frame. + runFrame(0xFFFF0000); + runFrame(0xFF00FF00); + runFrame(0xFF0000FF); + CHECK(sink.serializingFactory.paintCount == 1); +}
diff --git a/tests/unit_tests/renderer/deferred_segment_test.cpp b/tests/unit_tests/renderer/deferred_segment_test.cpp new file mode 100644 index 0000000..5cc9adc --- /dev/null +++ b/tests/unit_tests/renderer/deferred_segment_test.cpp
@@ -0,0 +1,209 @@ +/* + * Copyright 2026 Rive + */ + +// Screen segments are the 2D stream regions outside canvas brackets, each +// naming the render target its draws belong to. Recording only, no GPU. + +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" + +#include <catch.hpp> + +using namespace rive; +using rive::cmd::DeferredSegment; +using Target = rive::cmd::DeferredSegment::Target; + +namespace +{ +// A canvas recorder like the one beginCanvasContent hands a script. +std::unique_ptr<cmd::DeferredRenderer> canvasRecorder( + cmd::DeferredSession& session, + uint64_t canvasId) +{ + return std::make_unique<cmd::DeferredRenderer>(&session.commandBuffer(), + &session.canvases(), + &session, + canvasId); +} +} // namespace + +TEST_CASE("a screen only frame is one screen segment", "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + uint32_t afterCreates = + static_cast<uint32_t>(session.commandBuffer().commandBytes().size()); + session.screenRenderer()->drawPath(path.get(), paint.get()); + session.closeOpenRange(); + + auto all = session.schedulerSegments(); + REQUIRE(all.size() == 1); + CHECK(all[0].target == Target::screen); + CHECK(all[0].targetId == 0u); + CHECK(all[0].begin == afterCreates); + CHECK(all[0].end == session.commandBuffer().commandBytes().size()); +} + +TEST_CASE("bytes recorded before any target draws claim no segment", + "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + // Creates and drained destroys replay from the whole stream, so they need + // no segment; giving them one would open a target's frame in a frame + // where only other targets drew. + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + CHECK(session.commandBuffer().commandBytes().size() > 0); + session.closeOpenRange(); + CHECK(session.schedulerSegments().empty()); +} + +TEST_CASE("a canvas bracket carves leading and trailing screen segments", + "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + auto canvas = canvasRecorder(session, 1); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + uint32_t afterCreates = + static_cast<uint32_t>(session.commandBuffer().commandBytes().size()); + + session.screenRenderer()->drawPath(path.get(), paint.get()); + canvas->drawPath(path.get(), paint.get()); + session.screenRenderer()->drawPath(path.get(), paint.get()); + session.closeOpenRange(); + + auto all = session.schedulerSegments(); + REQUIRE(all.size() == 3); + CHECK(all[0].target == Target::screen); + CHECK(all[0].begin == afterCreates); + CHECK(all[1].target == Target::canvas); + CHECK(all[1].targetId == 1u); + CHECK(all[1].begin == all[0].end); + CHECK(all[2].target == Target::screen); + CHECK(all[2].begin == all[1].end); + CHECK(all[2].end == session.commandBuffer().commandBytes().size()); +} + +TEST_CASE("a canvas bracket at offset 0 has no leading screen segment", + "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + auto canvas = canvasRecorder(session, 1); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + + // Creates land in the stream before the first draw, so open the canvas + // range from the very start by drawing into it first. + session.closeOpenRange(); + uint32_t start = + static_cast<uint32_t>(session.commandBuffer().commandBytes().size()); + canvas->drawPath(path.get(), paint.get()); + session.screenRenderer()->drawPath(path.get(), paint.get()); + session.closeOpenRange(); + + std::vector<DeferredSegment> after; + for (const auto& s : session.schedulerSegments()) + { + if (s.begin >= start) + { + after.push_back(s); + } + } + REQUIRE(after.size() == 2); + CHECK(after[0].target == Target::canvas); + CHECK(after[0].begin == start); + CHECK(after[1].target == Target::screen); + CHECK(after[1].begin == after[0].end); +} + +TEST_CASE("each screen target gets its own segments", "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + + // Two widgets painting in one frame, interleaved as Flutter would. + session.screenRenderer(0)->drawPath(path.get(), paint.get()); + session.screenRenderer(7)->drawPath(path.get(), paint.get()); + session.screenRenderer(0)->drawPath(path.get(), paint.get()); + session.closeOpenRange(); + + auto all = session.schedulerSegments(); + REQUIRE(all.size() == 3); + CHECK(all[0].targetId == 0u); + CHECK(all[1].targetId == 7u); + CHECK(all[2].targetId == 0u); + for (const auto& s : all) + { + CHECK(s.target == Target::screen); + } + for (size_t i = 1; i < all.size(); i++) + { + CHECK(all[i].begin == all[i - 1].end); + } +} + +TEST_CASE("a canvas hands the stream back to the screen it interrupted", + "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + auto canvas = canvasRecorder(session, 1); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + + session.screenRenderer(7)->drawPath(path.get(), paint.get()); + canvas->drawPath(path.get(), paint.get()); + session.closeOpenRange(); // closes the canvas range, as a snapshot would + // Creates recorded after that belong to target 7, which was drawing, not + // to the default screen, which drew nothing this frame. + auto later = session.makeEmptyRenderPath(); + session.closeOpenRange(); + + auto all = session.schedulerSegments(); + REQUIRE(all.size() == 3); + CHECK(all[0].target == Target::screen); + CHECK(all[0].targetId == 7u); + CHECK(all[1].target == Target::canvas); + CHECK(all[2].target == Target::screen); + CHECK(all[2].targetId == 7u); +} + +TEST_CASE("a session frame closes when the last target finishes", + "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + uint64_t a = session.acquireScreenTarget(); + uint64_t b = session.acquireScreenTarget(); + CHECK(a == 0u); + CHECK(b == 1u); + + // Sequential painting: each target opens and closes its own window. + session.beginTargetFrame(a); + CHECK(session.endTargetFrame(a)); + session.beginTargetFrame(b); + CHECK(session.endTargetFrame(b)); + + // Nested painting: the inner finish must not end the session's frame, + // resetting the stream under a target still recording. + session.beginTargetFrame(a); + session.beginTargetFrame(b); + CHECK(!session.endTargetFrame(b)); + CHECK(session.endTargetFrame(a)); + + // A released target's id and recorder are reclaimed. + session.releaseScreenTarget(a); + CHECK(session.acquireScreenTarget() == a); +} + +TEST_CASE("the screen recorder for a target survives resetFrame", + "[ore][cmd][segment]") +{ + cmd::DeferredSession session(nullptr); + // FFI hosts take this raw and keep drawing through it across frames. + Renderer* first = session.screenRenderer(3); + session.resetFrame(); + CHECK(session.screenRenderer(3) == first); +}
diff --git a/tests/unit_tests/renderer/deferred_test_sink.hpp b/tests/unit_tests/renderer/deferred_test_sink.hpp new file mode 100644 index 0000000..0ba1601 --- /dev/null +++ b/tests/unit_tests/renderer/deferred_test_sink.hpp
@@ -0,0 +1,44 @@ +/* + * Copyright 2026 Rive + */ + +#pragma once + +#include "rive/renderer/cmd/deferred_replayer.hpp" +#include "utils/serializing_factory.hpp" + +#include <memory> +#include <unordered_map> + +namespace deferred_test +{ +// GPU free sink over a serializing factory. Canvas content is unsupported so +// those draws drop without counting. FactoryT may subclass SerializingFactory +// to observe replay side effects. +template <typename FactoryT = rive::SerializingFactory> +class TestSinkT : public rive::cmd::DeferredFrameSink +{ +public: + FactoryT serializingFactory; + + rive::Factory* factory() override { return &serializingFactory; } + rive::ore::Context* oreContext() override { return nullptr; } + // A serialized frame per target, so a multi target replay is legible as + // separate frames instead of one merged stream. + rive::Renderer* beginScreenFrame(uint64_t target) override + { + serializingFactory.frameSize(256, 256); + serializingFactory.addFrame(); + auto& screen = m_screens[target]; + screen = serializingFactory.makeRenderer(); + return screen.get(); + } + + size_t openedTargets() const { return m_screens.size(); } + +private: + std::unordered_map<uint64_t, std::unique_ptr<rive::Renderer>> m_screens; +}; + +using TestSink = TestSinkT<>; +} // namespace deferred_test
diff --git a/tests/unit_tests/renderer/foreign_image_registry_test.cpp b/tests/unit_tests/renderer/foreign_image_registry_test.cpp new file mode 100644 index 0000000..fd32bb8 --- /dev/null +++ b/tests/unit_tests/renderer/foreign_image_registry_test.cpp
@@ -0,0 +1,219 @@ +/* + * Copyright 2026 Rive + */ + +// ForeignImageRegistry is the cross session image route. A RenderImage the +// session did not decode is not in its id space, so the registry retains the +// object and the frame snapshot carries the rcp: whoever replays resolves the +// image itself rather than an id some other table has to agree about. +// +// These cases drive it the way a host does, through DeferredRenderer::drawImage +// on a session's own recorder, and read the resolved object back off replay. + +#include "rive/renderer/cmd/deferred_render_factory.hpp" +#include "rive/renderer/cmd/deferred_replayer.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "deferred_test_sink.hpp" + +#include <catch.hpp> + +using namespace rive; + +namespace +{ +// A RenderImage nothing decoded through a session, which is what makes it +// foreign: lite_rtti_cast to DeferredRenderImage fails and the recorder falls +// through to the registry. +class ForeignImage : public RenderImage +{ +public: + ForeignImage(int tag, bool* destroyed) : m_tag(tag), m_destroyed(destroyed) + { + m_Width = 4; + m_Height = 4; + } + + ~ForeignImage() override + { + if (m_destroyed != nullptr) + { + *m_destroyed = true; + } + } + + int tag() const { return m_tag; } + +private: + int m_tag; + bool* m_destroyed; +}; + +// Records the image object each replayed draw resolved to, which is the only +// way to tell a right resolution from a wrong one that also draws. +class ImageRecorder : public Renderer +{ +public: + std::vector<const RenderImage*> drawn; + + void drawImage(const RenderImage* image, + ImageSampler, + BlendMode, + float) override + { + drawn.push_back(image); + } + + void save() override {} + void restore() override {} + void transform(const Mat2D&) override {} + void drawPath(RenderPath*, RenderPaint*) override {} + void clipPath(RenderPath*) override {} + void drawImageMesh(const RenderImage* image, + ImageSampler, + rcp<RenderBuffer>, + rcp<RenderBuffer>, + rcp<RenderBuffer>, + uint32_t, + uint32_t, + BlendMode, + float) override + { + drawn.push_back(image); + } + void modulateOpacity(float) override {} +}; + +class ImageSink : public deferred_test::TestSink +{ +public: + ImageRecorder recorder; + + Renderer* beginScreenFrame(uint64_t) override { return &recorder; } +}; + +void drawForeign(cmd::DeferredSession& session, RenderImage* image) +{ + session.screenRenderer()->drawImage(image, + ImageSampler::LinearClamp(), + BlendMode::srcOver, + 1.0f); +} + +// Replays one recorded frame and reports what its draws resolved to. +std::vector<const RenderImage*> replayed(const cmd::DeferredFrame& frame) +{ + ImageSink sink; + cmd::DeferredReplayer replayer; + replayer.replayFrame(frame, sink); + CHECK(replayer.droppedDraws() == 0); + return sink.recorder.drawn; +} + +// Same, for the inline form, which resolves against the live registry instead +// of the snapshot's copy. Both routes are shipped, so both are covered. +std::vector<const RenderImage*> replayedInline(cmd::DeferredSession& session) +{ + ImageSink sink; + cmd::DeferredReplayer replayer; + replayer.replayFrame(session, sink); + CHECK(replayer.droppedDraws() == 0); + return sink.recorder.drawn; +} +} // namespace + +TEST_CASE("a foreign image resolves in a session that never decoded it", + "[deferred][foreign_image]") +{ + bool destroyed = false; + rcp<ForeignImage> image(new ForeignImage(1, &destroyed)); + + // Two sessions with nothing shared between them: separate id spaces, + // separate registries, separate streams. + cmd::DeferredSession first(nullptr); + cmd::DeferredSession second(nullptr); + + drawForeign(first, image.get()); + auto firstDrawn = replayed(cmd::takeFrame(first)); + + drawForeign(second, image.get()); + auto secondLive = replayedInline(second); + auto secondDrawn = replayed(cmd::takeFrame(second)); + + REQUIRE(firstDrawn.size() == 1); + REQUIRE(secondLive.size() == 1); + REQUIRE(secondDrawn.size() == 1); + CHECK(firstDrawn[0] == image.get()); + CHECK(secondLive[0] == image.get()); + CHECK(secondDrawn[0] == image.get()); + CHECK_FALSE(destroyed); +} + +TEST_CASE("two sessions numbering the same images oppositely each resolve " + "their own", + "[deferred][foreign_image]") +{ + rcp<ForeignImage> a(new ForeignImage(1, nullptr)); + rcp<ForeignImage> b(new ForeignImage(2, nullptr)); + + // Registration order sets the unflagged id, so the two sessions give the + // same pair of images opposite ids. Resolving through anything id keyed + // and shared crosses them, and both draws still land. + cmd::DeferredSession forward(nullptr); + drawForeign(forward, a.get()); + drawForeign(forward, b.get()); + auto forwardLive = replayedInline(forward); + auto forwardDrawn = replayed(cmd::takeFrame(forward)); + + cmd::DeferredSession reverse(nullptr); + drawForeign(reverse, b.get()); + drawForeign(reverse, a.get()); + auto reverseLive = replayedInline(reverse); + auto reverseDrawn = replayed(cmd::takeFrame(reverse)); + + auto tags = [](const std::vector<const RenderImage*>& drawn) { + std::vector<int> out; + for (auto* image : drawn) + { + out.push_back(static_cast<const ForeignImage*>(image)->tag()); + } + return out; + }; + + CHECK(tags(forwardLive) == std::vector<int>{1, 2}); + CHECK(tags(forwardDrawn) == std::vector<int>{1, 2}); + CHECK(tags(reverseLive) == std::vector<int>{2, 1}); + CHECK(tags(reverseDrawn) == std::vector<int>{2, 1}); +} + +TEST_CASE("a snapshot holds a foreign image past the frame and past its " + "caller", + "[deferred][foreign_image]") +{ + bool destroyed = false; + auto* raw = new ForeignImage(3, &destroyed); + rcp<ForeignImage> image(raw); + + cmd::DeferredSession session(nullptr); + drawForeign(session, raw); + + // takeFrame copies the retained images out and clears the registry, so + // after the caller lets go the snapshot is the only owner left. A registry + // that recorded the pointer without retaining it leaves replay a dangling + // one, and replay would still draw. + cmd::DeferredFrame frame = cmd::takeFrame(session); + // Checked before the caller's reference goes away: a registry that only + // recorded the pointer would leave the object already dead here, and the + // release below would be a use after free rather than an assertion. + REQUIRE(raw->debugging_refcnt() > 1); + image = nullptr; + REQUIRE_FALSE(destroyed); + + auto drawn = replayed(frame); + REQUIRE(drawn.size() == 1); + CHECK(drawn[0] == raw); + CHECK(static_cast<const ForeignImage*>(drawn[0])->tag() == 3); + CHECK_FALSE(destroyed); + + frame = cmd::DeferredFrame{}; + CHECK(destroyed); +}
diff --git a/tests/unit_tests/renderer/gpu_census_test.cpp b/tests/unit_tests/renderer/gpu_census_test.cpp new file mode 100644 index 0000000..709e479 --- /dev/null +++ b/tests/unit_tests/renderer/gpu_census_test.cpp
@@ -0,0 +1,152 @@ +/* + * Copyright 2026 Rive + */ + +// The GPU census walks the replayer's resident tables. What it has to get +// right to be usable as evidence: it counts what is live and not what was +// freed, it scales with the resources actually resident, and it is a level so +// reading it twice gives the same answer. + +#include "rive/renderer/cmd/deferred_replayer.hpp" +#include "rive/renderer/cmd/deferred_session.hpp" +#include "rive/renderer/cmd/gpu_census.hpp" +#include "deferred_test_sink.hpp" + +#include <catch.hpp> + +using namespace rive; +using deferred_test::TestSink; + +namespace +{ +// Replay one session frame and hand back what stayed resident. +cmd::GpuCensus replayAndCensus(cmd::DeferredSession& session, + cmd::DeferredReplayer& replayer, + TestSink& sink) +{ + cmd::DeferredFrame frame = cmd::takeFrame(session); + replayer.replayFrame(frame, sink); + return replayer.gpuCensus(); +} +} // namespace + +TEST_CASE("the census counts what replay left resident", "[deferred][census]") +{ + cmd::DeferredSession session(nullptr); + cmd::DeferredReplayer replayer; + TestSink sink; + + auto* screen = session.screenRenderer(); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + screen->drawPath(path.get(), paint.get()); + + cmd::GpuCensus c = replayAndCensus(session, replayer, sink); + CHECK(c.paths == 1); + CHECK(c.paints == 1); + // Nothing sized was recorded, so the byte total has to be zero rather than + // some incidental nonzero from the count tables. + CHECK(c.totalBytes() == 0); + + // A level, not a running total: the same walk twice is the same answer. + CHECK(replayer.gpuCensus().totalBytes() == c.totalBytes()); + CHECK(replayer.gpuCensus().liveObjects() == c.liveObjects()); +} + +TEST_CASE("census bytes scale with the resources resident", + "[deferred][census]") +{ + cmd::DeferredSession session(nullptr); + cmd::DeferredReplayer replayer; + TestSink sink; + + auto* screen = session.screenRenderer(); + auto paint = session.makeRenderPaint(); + auto buffer = session.makeRenderBuffer(RenderBufferType::vertex, + RenderBufferFlags::none, + 1024); + // Touch it so the draw keeps the recording honest about a live buffer. + auto path = session.makeEmptyRenderPath(); + screen->drawPath(path.get(), paint.get()); + + cmd::GpuCensus one = replayAndCensus(session, replayer, sink); + CHECK(one.buffers == 1); + CHECK(one.bufferBytes == 1024); + CHECK(one.totalBytes() == 1024); + + // A second buffer of the same size doubles the sized total, and the + // unsized counts stay put. + auto buffer2 = session.makeRenderBuffer(RenderBufferType::vertex, + RenderBufferFlags::none, + 1024); + screen->drawPath(path.get(), paint.get()); + cmd::GpuCensus two = replayAndCensus(session, replayer, sink); + CHECK(two.buffers == 2); + CHECK(two.bufferBytes == 2048); + CHECK(two.paths == one.paths); + CHECK(two.paints == one.paints); +} + +TEST_CASE("a destroyed resource leaves the census but keeps its slot", + "[deferred][census]") +{ + cmd::DeferredSession session(nullptr); + cmd::DeferredReplayer replayer; + TestSink sink; + + auto* screen = session.screenRenderer(); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + { + auto doomed = session.makeRenderBuffer(RenderBufferType::vertex, + RenderBufferFlags::none, + 4096); + screen->drawPath(path.get(), paint.get()); + cmd::GpuCensus live = replayAndCensus(session, replayer, sink); + CHECK(live.bufferBytes == 4096); + CHECK(live.slots2d >= live.liveObjects()); + } + // The rcp died, so the next frame carries the destroy record. + session.commandBuffer().drainDestroys(); + screen->drawPath(path.get(), paint.get()); + cmd::GpuCensus after = replayAndCensus(session, replayer, sink); + CHECK(after.buffers == 0); + CHECK(after.bufferBytes == 0); + // The tables never compact, so the freed slot is still counted as minted. + CHECK(after.slots2d >= 1); +} + +TEST_CASE("reset empties the census", "[deferred][census]") +{ + cmd::DeferredSession session(nullptr); + cmd::DeferredReplayer replayer; + TestSink sink; + + auto* screen = session.screenRenderer(); + auto paint = session.makeRenderPaint(); + auto path = session.makeEmptyRenderPath(); + auto buffer = session.makeRenderBuffer(RenderBufferType::vertex, + RenderBufferFlags::none, + 2048); + screen->drawPath(path.get(), paint.get()); + CHECK(replayAndCensus(session, replayer, sink).totalBytes() == 2048); + + replayer.reset(); + cmd::GpuCensus empty = replayer.gpuCensus(); + CHECK(empty.totalBytes() == 0); + CHECK(empty.liveObjects() == 0); + CHECK(empty.slots2d == 0); + CHECK(empty.slotsOre == 0); +} + +TEST_CASE("ore texture sizing covers mips, layers and samples", + "[deferred][census]") +{ + // No GPU here, so size the arithmetic directly against the format table + // rather than through a real texture. + using rive::ore::TextureFormat; + CHECK(ore::textureFormatBytesPerTexel(TextureFormat::rgba8unorm) == 4); + CHECK(ore::textureFormatBytesPerTexel(TextureFormat::r8unorm) == 1); + // rgba32float is 16 bytes, so a 4x4 single level is 256. + CHECK(ore::textureFormatBytesPerTexel(TextureFormat::rgba32float) == 16); +}
diff --git a/tests/unit_tests/renderer/ore_command_buffer_test.cpp b/tests/unit_tests/renderer/ore_command_buffer_test.cpp new file mode 100644 index 0000000..5f94d3a --- /dev/null +++ b/tests/unit_tests/renderer/ore_command_buffer_test.cpp
@@ -0,0 +1,116 @@ +/* + * Copyright 2026 Rive + */ + +// Confirms every command and upload payload round trips byte for byte through +// OreCommandBuffer. No GPU needed. + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" + +#include <catch.hpp> +#include <cstring> +#include <vector> + +using namespace rive::ore; +using namespace rive::ore::cmd; +using rive::Span; + +TEST_CASE("ore command stream round-trips through the reader", "[ore][cmd]") +{ + OreCommandBuffer buf; + + BeginRenderPassCmd begin{}; + begin.colorCount = 1; + begin.colors[0].view = 0; + begin.colors[0].resolveTarget = kInvalidHandle; + begin.colors[0].loadOp = LoadOp::clear; + begin.colors[0].storeOp = StoreOp::store; + begin.colors[0].clearR = 0.25f; + begin.colors[0].clearG = 0.5f; + begin.colors[0].clearB = 0.75f; + begin.colors[0].clearA = 1.0f; + begin.depthStencil.view = kInvalidHandle; + buf.append(CommandType::beginRenderPass, begin); + + buf.append(CommandType::setPipeline, SetPipelineCmd{7}); + buf.append(CommandType::setVertexBuffer, SetVertexBufferCmd{0, 3, 16}); + buf.append(CommandType::draw, DrawCmd{6, 2, 1, 0}); + buf.appendOpcode(CommandType::finish); + + OreCommandReader r(buf.commandBytes(), buf.blobBytes()); + CommandType t; + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::beginRenderPass); + auto b = r.read<BeginRenderPassCmd>(); + CHECK(b.colorCount == 1); + CHECK(b.colors[0].view == 0u); + CHECK(b.colors[0].resolveTarget == kInvalidHandle); + CHECK(b.colors[0].loadOp == LoadOp::clear); + CHECK(b.colors[0].clearR == 0.25f); + CHECK(b.colors[0].clearB == 0.75f); + CHECK(b.depthStencil.view == kInvalidHandle); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::setPipeline); + CHECK(r.read<SetPipelineCmd>().pipeline == 7u); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::setVertexBuffer); + auto vb = r.read<SetVertexBufferCmd>(); + CHECK(vb.slot == 0u); + CHECK(vb.buffer == 3u); + CHECK(vb.offset == 16u); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::draw); + auto d = r.read<DrawCmd>(); + CHECK(d.vertexCount == 6u); + CHECK(d.instanceCount == 2u); + CHECK(d.firstVertex == 1u); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::finish); + + REQUIRE_FALSE(r.next(t)); +} + +TEST_CASE("ore command buffer reset keeps the buffer reusable", "[ore][cmd]") +{ + OreCommandBuffer buf; + buf.append(CommandType::draw, DrawCmd{1, 1, 0, 0}); + CHECK_FALSE(buf.empty()); + + buf.reset(); + CHECK(buf.empty()); + CHECK(buf.keepAlive().empty()); + + buf.appendOpcode(CommandType::finish); + CHECK_FALSE(buf.empty()); +} + +TEST_CASE("ore command buffer capture maps nullptr to kInvalidHandle", + "[ore][cmd]") +{ + OreCommandBuffer buf; + CHECK(buf.capture(nullptr) == kInvalidHandle); + CHECK(buf.keepAlive().empty()); +} + +TEST_CASE("a truncated trailing opcode latches overrun", "[ore][cmd]") +{ + // Ore opcodes are four bytes; three leftover bytes are a truncated + // stream, not a clean end. + std::vector<uint8_t> bytes = {1, 0, 0}; + rive::cmd::CommandReader<uint32_t> truncated( + rive::Span<const uint8_t>(bytes.data(), bytes.size()), + rive::Span<const uint8_t>()); + uint32_t op; + CHECK_FALSE(truncated.next(op)); + CHECK(truncated.overrun()); + + rive::cmd::CommandReader<uint32_t> clean{rive::Span<const uint8_t>(), + rive::Span<const uint8_t>()}; + CHECK_FALSE(clean.next(op)); + CHECK_FALSE(clean.overrun()); +}
diff --git a/tests/unit_tests/renderer/ore_command_silver_test.cpp b/tests/unit_tests/renderer/ore_command_silver_test.cpp new file mode 100644 index 0000000..c8f1e56 --- /dev/null +++ b/tests/unit_tests/renderer/ore_command_silver_test.cpp
@@ -0,0 +1,116 @@ +/* + * Copyright 2026 Rive + */ + +// Silver is the portable field wise form with a GPU free comparator, the +// cross arch regression form that the host endian serialize is not. + +#include "rive/renderer/ore/cmd/ore_command_silver.hpp" + +#include <catch.hpp> +#include <vector> + +using namespace rive::ore; +using namespace rive::ore::cmd; + +// Covers every command type, including blob arena dynamic offsets and a +// negative baseVertex. +static void recordRepresentative(OreCommandBuffer& buf) +{ + BeginRenderPassCmd begin{}; + begin.colorCount = 2; + begin.colors[0] = {0, + kInvalidHandle, + LoadOp::clear, + StoreOp::store, + 0.1f, + 0.2f, + 0.3f, + 1.0f}; + begin.colors[1] = + {1, 2, LoadOp::load, StoreOp::discard, 0.0f, 0.0f, 0.0f, 0.0f}; + begin.depthStencil = {3, + LoadOp::clear, + StoreOp::store, + 1.0f, + LoadOp::clear, + StoreOp::store, + 0}; + buf.append(CommandType::beginRenderPass, begin); + + buf.append(CommandType::setPipeline, SetPipelineCmd{7}); + buf.append(CommandType::setVertexBuffer, SetVertexBufferCmd{0, 4, 16}); + buf.append(CommandType::setIndexBuffer, + SetIndexBufferCmd{5, IndexFormat::uint16, 0}); + + const uint32_t dynOffsets[2] = {64, 128}; + uint64_t dynStart = buf.appendBlob(dynOffsets, sizeof(dynOffsets)); + buf.append(CommandType::setBindGroup, SetBindGroupCmd{1, 6, dynStart, 2}); + + buf.append(CommandType::setViewport, + SetViewportCmd{0.f, 0.f, 256.f, 128.f, 0.f, 1.f}); + buf.append(CommandType::setScissorRect, SetScissorRectCmd{0, 0, 256, 128}); + buf.append(CommandType::setStencilReference, SetStencilReferenceCmd{0x80}); + buf.append(CommandType::setBlendColor, + SetBlendColorCmd{1.f, 0.5f, 0.f, 1.f}); + buf.append(CommandType::draw, DrawCmd{6, 2, 1, 0}); + buf.append(CommandType::drawIndexed, DrawIndexedCmd{12, 1, 0, -3, 0}); + buf.appendOpcode(CommandType::finish); +} + +TEST_CASE("ore silver round-trips and self-compares equal", "[ore][cmd]") +{ + OreCommandBuffer buf; + recordRepresentative(buf); + + std::vector<uint8_t> silver; + serializeSilver(buf, silver); + REQUIRE(silver.size() > sizeof(kSilverMagic)); + + // Identical recordings must serialize byte identical, so no host padding + // can leak in. + OreCommandBuffer buf2; + recordRepresentative(buf2); + std::vector<uint8_t> silver2; + serializeSilver(buf2, silver2); + CHECK(silver == silver2); + + CHECK(silverMatch(silver, silver2)); +} + +TEST_CASE("ore silver detects a diverging field", "[ore][cmd]") +{ + OreCommandBuffer expected; + recordRepresentative(expected); + std::vector<uint8_t> expectedSilver; + serializeSilver(expected, expectedSilver); + + OreCommandBuffer actual; + BeginRenderPassCmd begin{}; + begin.colorCount = 1; + begin.colors[0] = + {0, kInvalidHandle, LoadOp::clear, StoreOp::store, 0.f, 0.f, 0.f, 1.f}; + begin.depthStencil.view = kInvalidHandle; + actual.append(CommandType::beginRenderPass, begin); + actual.append(CommandType::draw, DrawCmd{99, 1, 0, 0}); + std::vector<uint8_t> actualSilver; + serializeSilver(actual, actualSilver); + + CHECK_FALSE(silverMatch(expectedSilver, actualSilver)); +} + +TEST_CASE("ore silver tolerates sub-epsilon float drift", "[ore][cmd]") +{ + OreCommandBuffer a; + a.append(CommandType::setBlendColor, SetBlendColorCmd{0.5f, 0.f, 0.f, 1.f}); + std::vector<uint8_t> silverA; + serializeSilver(a, silverA); + + OreCommandBuffer b; + b.append(CommandType::setBlendColor, + SetBlendColorCmd{0.5f + kSilverEpsilon * 0.5f, 0.f, 0.f, 1.f}); + std::vector<uint8_t> silverB; + serializeSilver(b, silverB); + + CHECK(silverMatch(silverA, silverB)); +}
diff --git a/tests/unit_tests/renderer/ore_deferred_alias_test.cpp b/tests/unit_tests/renderer/ore_deferred_alias_test.cpp new file mode 100644 index 0000000..4cc9710 --- /dev/null +++ b/tests/unit_tests/renderer/ore_deferred_alias_test.cpp
@@ -0,0 +1,120 @@ +/* + * Copyright 2026 Rive + */ + +// Two lifetime guarantees on the session's resource maps: a recycled address +// resolves through whatever object holds it now, and a session's teardown may +// run off the recording thread. + +#include "rive/renderer/ore/cmd/ore_deferred_context.hpp" +#include "rive/renderer/ore/cmd/ore_deferred_resource.hpp" + +#include <catch.hpp> + +#include <memory> +#include <thread> +#include <unordered_set> +#include <vector> + +using namespace rive; +using namespace rive::ore; +using namespace rive::ore::cmd; + +// Skip under AddressSanitizer: its quarantine holds freed blocks back, so the +// allocator never hands a dead resource's address to the next one and the +// aliasing this test exists to pin cannot be set up. The test REQUIREs the +// recycle it depends on, so it fails loudly rather than passing vacuously +// wherever the premise does not hold. +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if !defined(__SANITIZE_ADDRESS__) && !__has_feature(address_sanitizer) +TEST_CASE("a recycled address resolves through the object holding it now", + "[ore_deferred_alias]") +{ + // A DeferredResource's destructor only queues its destroy, so the + // allocator can hand a dead resource's address to a new one long before + // anything that recorded that address has been cleaned up. One session's + // dead resource leaves an address behind and the object that lands there + // next belongs to a different session, so nothing the creator does can + // reach the first session's memory of it. Only the object itself can + // answer for the address, which is why the lookup asks the object. + DeferredOreContext a(nullptr); + ShaderModuleDesc smDesc{}; + + // Dropping these queues destroys that nothing drains, so their ids and + // their addresses are both loose while a still recorded them. + constexpr int kCount = 32; + std::unordered_set<const rive::gpu::GPUResource*> dead; + { + std::vector<rcp<ShaderModule>> sessionMods; + for (int i = 0; i < kCount; ++i) + { + sessionMods.push_back(a.makeShaderModule(smDesc)); + dead.insert(sessionMods.back().get()); + } + } + + // b creates the modules that reclaim those addresses. + DeferredOreContext b(nullptr); + ShaderModule* recycled = nullptr; + std::vector<rcp<ShaderModule>> bMods; + for (int i = 0; i < kCount && recycled == nullptr; ++i) + { + bMods.push_back(b.makeShaderModule(smDesc)); + ShaderModule* mod = bMods.back().get(); + recycled = dead.count(mod) != 0 ? mod : nullptr; + } + REQUIRE(recycled != nullptr); + + // b records it, so b names it by the id it created it under. + ResourceHandle own = + static_cast<DeferredShaderModule*>(recycled)->clientHandle(); + CHECK((own & kRealResourceFlag) == 0); + CHECK(b.handleFor(recycled) == own); + + // a must not reuse that id: it indexes the table a's own stream feeds, + // where the same number names something else. A deferred object that + // records into a foreign stream takes the real resource path instead. + ResourceHandle foreign = a.handleFor(recycled); + CHECK(foreign != own); + CHECK((foreign & kRealResourceFlag) != 0); + + // And a pipeline a builds over it carries that same real reference, so + // replay resolves it from the retained side table rather than binding + // whatever a holds at b's id. + PipelineDesc pDesc{}; + pDesc.vertexModule = recycled; + REQUIRE(a.makePipeline(pDesc) != nullptr); + CHECK(a.handleFor(recycled) == foreign); +} +#endif + +TEST_CASE("session teardown off the recording thread stays quiet", + "[ore_deferred_alias]") +{ + // The recording thread assertion must not fire on the paths the deferred + // design puts off thread on purpose. Dart finalizers release resources on + // GC threads, and on threaded wasm riveDeleteDeferredSession posts the + // delete to the replay worker, so a session's last destroy drain runs + // there rather than on the thread that recorded it. + auto d = std::make_unique<DeferredOreContext>(nullptr); + BufferDesc bufDesc{}; + bufDesc.size = 16; + auto live = d->makeBuffer(bufDesc); + + // A finalizer thread dropping the last reference while the session is + // still recording: the destroy queues under the destroy mutex. + { + auto doomed = d->makeBuffer(bufDesc); + std::thread finalizer([&] { doomed = nullptr; }); + finalizer.join(); + } + + // Teardown on a third thread, which drains that queue and records the + // destroys into a stream it never appended to before. + live = nullptr; + std::thread worker([&] { d = nullptr; }); + worker.join(); + CHECK(d == nullptr); +}
diff --git a/tests/unit_tests/renderer/ore_deferred_device_state_test.cpp b/tests/unit_tests/renderer/ore_deferred_device_state_test.cpp new file mode 100644 index 0000000..4c4d614 --- /dev/null +++ b/tests/unit_tests/renderer/ore_deferred_device_state_test.cpp
@@ -0,0 +1,120 @@ +/* + * Copyright 2026 Rive + */ + +// What a recording context answers about the device its stream will run on. +// A recorded capability branch is not a readout, it is a prediction that gets +// written into a stream and replayed on real hardware, so an answer that is +// merely plausible is worse than no answer at all: replay executes the wrong +// branch flawlessly and nothing downstream can tell. + +#include "rive/renderer/ore/cmd/ore_deferred_context.hpp" +#include "rive/renderer/ore/ore_context.hpp" + +#include <catch.hpp> + +using namespace rive; +using namespace rive::ore; +using namespace rive::ore::cmd; + +namespace +{ +// GPU free stand-in for a real backend context: the only thing under test is +// what it advertises, so the factories are unreachable. +class FakeDeviceContext : public Context +{ +public: + FakeDeviceContext() : Context(nullptr) {} + + Features& editableFeatures() { return m_features; } + + rcp<Buffer> makeBuffer(const BufferDesc&) override { return nullptr; } + rcp<Texture> makeTexture(const TextureDesc&) override { return nullptr; } + rcp<TextureView> makeTextureView(const TextureViewDesc&) override + { + return nullptr; + } + rcp<Sampler> makeSampler(const SamplerDesc&) override { return nullptr; } + rcp<ShaderModule> makeShaderModule(const ShaderModuleDesc&) override + { + return nullptr; + } + rcp<BindGroupLayout> makeBindGroupLayout( + const BindGroupLayoutDesc&) override + { + return nullptr; + } + rcp<Pipeline> makePipeline(const PipelineDesc&, std::string*) override + { + return nullptr; + } + rcp<BindGroup> makeBindGroup(const BindGroupDesc&) override + { + return nullptr; + } + std::unique_ptr<RenderPass> beginRenderPass(const RenderPassDesc&, + std::string*) override + { + return nullptr; + } + void beginFrame(const FrameDescriptor&) override {} + void endFrame() override {} + void waitForGPU() override {} + rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas*) override + { + return nullptr; + } + rcp<TextureView> wrapRiveTexture(gpu::Texture*, uint32_t, uint32_t) override + { + return nullptr; + } + ShaderTarget shaderTarget() const override { return ShaderTarget::glsl; } +}; +} // namespace + +TEST_CASE("a recording context reports the replay device's capabilities", + "[ore][cmd][deferred]") +{ + FakeDeviceContext device; + Features& real = device.editableFeatures(); + // A device more capable than Features' initializers in both directions: + // a flag they deny and a limit they understate. + real.colorBufferHalfFloat = true; + real.maxSamples = 8; + real.maxTextureSize2D = 16384; + + SECTION("bound at construction, as every native host binds") + { + DeferredOreContext recorder(&device); + CHECK(recorder.featuresKnown()); + CHECK(recorder.features().colorBufferHalfFloat); + CHECK(recorder.features().maxSamples == 8u); + CHECK(recorder.features().maxTextureSize2D == 16384u); + } + + SECTION("bound late, as web binds on attach") + { + DeferredOreContext recorder(nullptr); + recorder.bindReal(&device); + CHECK(recorder.featuresKnown()); + CHECK(recorder.features().colorBufferHalfFloat); + CHECK(recorder.features().maxSamples == 8u); + } + + SECTION("unbound, nothing has been measured and it says so") + { + // The values are still Features' initializers, which is exactly why + // featuresKnown has to exist: they read as a real, poor device, so no + // caller can distinguish a guess from a measurement by inspecting them. + DeferredOreContext recorder(nullptr); + CHECK_FALSE(recorder.featuresKnown()); + CHECK_FALSE(recorder.features().colorBufferHalfFloat); + } +} + +TEST_CASE("a real context always knows its own capabilities", + "[ore][cmd][deferred]") +{ + FakeDeviceContext device; + CHECK(device.featuresKnown()); +}
diff --git a/tests/unit_tests/renderer/ore_deferred_reuse_test.cpp b/tests/unit_tests/renderer/ore_deferred_reuse_test.cpp new file mode 100644 index 0000000..ddd4f36 --- /dev/null +++ b/tests/unit_tests/renderer/ore_deferred_reuse_test.cpp
@@ -0,0 +1,127 @@ +/* + * Copyright 2026 Rive + */ + +// Producer side id reuse: the generational free list and a destroy then +// recreate lifecycle in one ordered stream, which the GMs never exercise. +// GPU free, asserts the recorded bytes. + +#include "rive/renderer/cmd/id_allocator.hpp" +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_commands.hpp" +#include "rive/renderer/ore/cmd/ore_make_recording.hpp" + +#include <catch.hpp> +#include <cstring> + +using namespace rive::ore; +using namespace rive::ore::cmd; +using rive::IdAllocator; +using rive::Span; + +TEST_CASE("IdAllocator recycles ids with a bumped generation", + "[ore][cmd][reuse]") +{ + IdAllocator<uint32_t> ids; + + SECTION("a freed id returns at generation+1") + { + auto a = ids.alloc(); + auto b = ids.alloc(); + REQUIRE(a.id == 0u); + REQUIRE(b.id == 1u); + + ids.release(a.id, a.generation); + auto c = ids.alloc(); + CHECK(c.id == 0u); + CHECK(c.generation == 1u); + + // Free list is empty again so the alloc is fresh. + auto d = ids.alloc(); + CHECK(d.id == 2u); + CHECK(d.generation == 0u); + + // Generation keeps climbing on repeated recycling. + ids.release(c.id, c.generation); + auto e = ids.alloc(); + CHECK(e.id == 0u); + CHECK(e.generation == 2u); + } + + SECTION("an id whose generation would overflow is retired, never recycled") + { + auto a = ids.alloc(); + // Bumping the max generation would wrap, so the id is dropped. + ids.release(a.id, 0xffffffffu); + auto b = ids.alloc(); + CHECK(b.id == 1u); + CHECK(b.generation == 0u); + } +} + +TEST_CASE("ordered stream records a create/write/destroy/recreate lifecycle", + "[ore][cmd][reuse]") +{ + // Mirrors DeferredOreContext, one allocator and one ordered stream. + IdAllocator<rive::ore::cmd::ResourceHandle> ids; + OreCommandBuffer cb; + + auto a = ids.alloc(); + BufferDesc bd{}; + bd.size = 16; + bd.usage = BufferUsage::vertex; + recordMakeBuffer(cb, a.id, a.generation, bd); + + const uint32_t data[4] = {10, 20, 30, 40}; + recordBufferUpdate(cb, a.id, data, sizeof(data), 0); + + // The destroy records into the same stream after the write. + recordDestroyResource(cb, a.id, a.generation); + ids.release(a.id, a.generation); + + auto b = ids.alloc(); + REQUIRE(b.id == a.id); + REQUIRE(b.generation == 1u); + BufferDesc bd2{}; + bd2.size = 32; + bd2.usage = BufferUsage::index; + recordMakeBuffer(cb, b.id, b.generation, bd2); + + OreCommandReader r(cb.commandBytes(), cb.blobBytes()); + CommandType type; + + REQUIRE(r.next(type)); + REQUIRE(type == CommandType::makeBuffer); + auto m0 = r.read<MakeResourcePOD>(); + auto d0 = r.read<BufferDescPOD>(); + CHECK(m0.id == a.id); + CHECK(m0.generation == 0u); + CHECK(d0.size == 16u); + CHECK(d0.usage == BufferUsage::vertex); + + REQUIRE(r.next(type)); + REQUIRE(type == CommandType::bufferUpdate); + auto up = r.read<BufferUpdatePOD>(); + CHECK(up.handle == a.id); + CHECK(up.offset == 0u); + Span<const uint8_t> bytes = r.blobAt(up.bytes.offset, up.bytes.size); + REQUIRE(bytes.size() == sizeof(data)); + CHECK(std::memcmp(bytes.data(), data, sizeof(data)) == 0); + + REQUIRE(r.next(type)); + REQUIRE(type == CommandType::destroyResource); + auto ds = r.read<DestroyResourcePOD>(); + CHECK(ds.handle == a.id); + CHECK(ds.generation == 0u); + + REQUIRE(r.next(type)); + REQUIRE(type == CommandType::makeBuffer); + auto m1 = r.read<MakeResourcePOD>(); + auto d1 = r.read<BufferDescPOD>(); + CHECK(m1.id == a.id); + CHECK(m1.generation == 1u); + CHECK(d1.size == 32u); + CHECK(d1.usage == BufferUsage::index); + + CHECK_FALSE(r.next(type)); +}
diff --git a/tests/unit_tests/renderer/ore_make_recording_test.cpp b/tests/unit_tests/renderer/ore_make_recording_test.cpp new file mode 100644 index 0000000..6163c23 --- /dev/null +++ b/tests/unit_tests/renderer/ore_make_recording_test.cpp
@@ -0,0 +1,379 @@ +/* + * Copyright 2026 Rive + */ + +// Confirms every make descriptor field, label string, and data blob round +// trips through the ordered ore stream with the caller's id and generation. +// GPU free, no real resources. + +#include "rive/renderer/ore/cmd/ore_command_buffer.hpp" +#include "rive/renderer/ore/cmd/ore_make_recording.hpp" + +#include <catch.hpp> +#include <cstring> + +using namespace rive::ore; +using namespace rive::ore::cmd; +using rive::Span; + +namespace +{ +Span<const uint8_t> blobOf(const OreCommandReader& r, BlobRef ref) +{ + return ref.absent() ? Span<const uint8_t>(nullptr, 0) + : r.blobAt(ref.offset, ref.size); +} +const char* cstrOf(const OreCommandReader& r, BlobRef ref) +{ + return reinterpret_cast<const char*>(blobOf(r, ref).data()); +} +} // namespace + +TEST_CASE("make stream records make* with the caller's ids", "[ore][cmd]") +{ + OreCommandBuffer cb; + + const uint32_t verts[4] = {1, 2, 3, 4}; + BufferDesc bd{}; + bd.usage = BufferUsage::vertex; + bd.size = sizeof(verts); + bd.data = verts; + bd.immutable = true; + bd.label = "vb"; + recordMakeBuffer(cb, 0, 1, bd); + + TextureDesc td{}; + td.width = 256; + td.height = 128; + td.depthOrArrayLayers = 1; + td.format = TextureFormat::rgba8unorm; + td.type = TextureType::texture2D; + td.renderTarget = true; + td.numMipmaps = 1; + td.sampleCount = 4; + td.label = "rt"; + recordMakeTexture(cb, 1, 1, td); + + SamplerDesc sd{}; + sd.minFilter = Filter::linear; + sd.magFilter = Filter::nearest; + sd.mipmapFilter = Filter::linear; + sd.wrapU = WrapMode::repeat; + sd.wrapV = WrapMode::clampToEdge; + sd.wrapW = WrapMode::mirrorRepeat; + sd.compare = CompareFunction::less; + sd.minLod = 0.5f; + sd.maxLod = 7.0f; + sd.maxAnisotropy = 8; + sd.label = nullptr; // null label must round trip as absent + recordMakeSampler(cb, 2, 3, sd); + + OreCommandReader r(cb.commandBytes(), cb.blobBytes()); + CommandType t; + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeBuffer); + auto bh = r.read<MakeResourcePOD>(); + CHECK(bh.id == 0u); + CHECK(bh.generation == 1u); + auto b = r.read<BufferDescPOD>(); + CHECK(b.usage == BufferUsage::vertex); + CHECK(b.size == sizeof(verts)); + CHECK(b.immutable); + auto bData = blobOf(r, b.data); + REQUIRE(bData.size() == sizeof(verts)); + CHECK(std::memcmp(bData.data(), verts, sizeof(verts)) == 0); + auto bLabel = blobOf(r, b.label); + REQUIRE(bLabel.size() == 3u); // "vb\0" + CHECK(std::strcmp(cstrOf(r, b.label), "vb") == 0); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeTexture); + auto th = r.read<MakeResourcePOD>(); + CHECK(th.id == 1u); + auto tx = r.read<TextureDescPOD>(); + CHECK(tx.width == 256u); + CHECK(tx.height == 128u); + CHECK(tx.format == TextureFormat::rgba8unorm); + CHECK(tx.type == TextureType::texture2D); + CHECK(tx.renderTarget); + CHECK(tx.sampleCount == 4u); + CHECK(std::strcmp(cstrOf(r, tx.label), "rt") == 0); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeSampler); + auto sh = r.read<MakeResourcePOD>(); + CHECK(sh.id == 2u); + CHECK(sh.generation == 3u); + auto s = r.read<SamplerDescPOD>(); + CHECK(s.minFilter == Filter::linear); + CHECK(s.magFilter == Filter::nearest); + CHECK(s.wrapU == WrapMode::repeat); + CHECK(s.wrapW == WrapMode::mirrorRepeat); + CHECK(s.compare == CompareFunction::less); + CHECK(s.minLod == 0.5f); + CHECK(s.maxLod == 7.0f); + CHECK(s.maxAnisotropy == 8u); + CHECK(s.label.absent()); + + REQUIRE_FALSE(r.next(t)); +} + +TEST_CASE("make stream: a buffer with no initial data is absent, not empty", + "[ore][cmd]") +{ + OreCommandBuffer cb; + BufferDesc bd{}; + bd.usage = BufferUsage::uniform; + bd.size = 64; + bd.data = nullptr; + recordMakeBuffer(cb, 0, 0, bd); + + OreCommandReader r(cb.commandBytes(), cb.blobBytes()); + CommandType t; + REQUIRE(r.next(t)); + r.read<MakeResourcePOD>(); + auto b = r.read<BufferDescPOD>(); + CHECK(b.size == 64u); + CHECK(b.data.absent()); + CHECK(blobOf(r, b.data).size() == 0u); +} + +TEST_CASE("make stream records shader module, layout, view", "[ore][cmd]") +{ + OreCommandBuffer cb; + + const uint8_t code[8] = {0xDE, 0xAD, 0xBE, 0xEF, 1, 2, 3, 4}; + const uint8_t bmap[3] = {9, 8, 7}; + ShaderModuleDesc sm{}; + sm.code = code; + sm.codeSize = sizeof(code); + sm.language = ShaderLanguage::wgsl; + sm.stage = ShaderStage::vertex; + sm.bindingMapBytes = bmap; + sm.bindingMapSize = sizeof(bmap); + sm.shaderAssetId = 42; + recordMakeShaderModule(cb, 0, 0, sm); + + BindGroupLayoutEntry entries[2]{}; + entries[0].binding = 0; + entries[0].kind = BindingKind::uniformBuffer; + entries[0].hasDynamicOffset = true; + entries[1].binding = 1; + entries[1].kind = BindingKind::sampledTexture; + entries[1].nativeSlotFS = 5; + BindGroupLayoutDesc bgl{}; + bgl.groupIndex = 2; + bgl.entries = entries; + bgl.entryCount = 2; + recordMakeBindGroupLayout(cb, 1, 0, bgl); + + // The view references the texture by handle. + TextureDesc td{}; + td.width = td.height = 64; + recordMakeTexture(cb, 2, 0, td); + TextureViewDesc tv{}; + tv.dimension = TextureViewDimension::texture2D; + tv.baseMipLevel = 1; + tv.mipCount = 2; + recordMakeTextureView(cb, 3, 0, tv, 2); + + OreCommandReader r(cb.commandBytes(), cb.blobBytes()); + CommandType t; + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeShaderModule); + r.read<MakeResourcePOD>(); + auto s = r.read<ShaderModuleDescPOD>(); + CHECK(s.language == ShaderLanguage::wgsl); + CHECK(s.stage == ShaderStage::vertex); + CHECK(s.shaderAssetId == 42u); + auto codeBlob = blobOf(r, s.code); + REQUIRE(codeBlob.size() == sizeof(code)); + CHECK(std::memcmp(codeBlob.data(), code, sizeof(code)) == 0); + CHECK(blobOf(r, s.bindingMapBytes).size() == sizeof(bmap)); + CHECK(s.hlslSource.absent()); + CHECK(s.label.absent()); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeBindGroupLayout); + r.read<MakeResourcePOD>(); + auto l = r.read<BindGroupLayoutDescPOD>(); + CHECK(l.groupIndex == 2u); + CHECK(l.entryCount == 2u); + auto entriesBlob = blobOf(r, l.entries); + REQUIRE(entriesBlob.size() == 2 * sizeof(BindGroupLayoutEntry)); + const auto* outEntries = + reinterpret_cast<const BindGroupLayoutEntry*>(entriesBlob.data()); + CHECK(outEntries[0].binding == 0u); + CHECK(outEntries[0].hasDynamicOffset); + CHECK(outEntries[1].binding == 1u); + CHECK(outEntries[1].kind == BindingKind::sampledTexture); + CHECK(outEntries[1].nativeSlotFS == 5u); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeTexture); + r.read<MakeResourcePOD>(); + r.read<TextureDescPOD>(); + + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeTextureView); + auto vh = r.read<MakeResourcePOD>(); + CHECK(vh.id == 3u); + auto v = r.read<TextureViewDescPOD>(); + CHECK(v.texture == 2u); + CHECK(v.baseMipLevel == 1u); + CHECK(v.mipCount == 2u); +} + +TEST_CASE("make stream records a pipeline with vertex layouts + refs", + "[ore][cmd]") +{ + OreCommandBuffer cb; + + // Stand ins for handles recorded earlier. + const ResourceHandle vsModule = 10, fsModule = 11, layout0 = 12, + layout1 = 13; + + VertexAttribute attrs[2]{}; + attrs[0] = {VertexFormat::float2, 0, 0}; + attrs[1] = {VertexFormat::float4, 8, 1}; + VertexBufferLayout vbl{}; + vbl.stride = 24; + vbl.stepMode = VertexStepMode::vertex; + vbl.attributes = attrs; + vbl.attributeCount = 2; + + PipelineDesc pd{}; + pd.vertexEntryPoint = "vs_main"; + pd.fragmentEntryPoint = "fs_main"; + pd.vertexBuffers = &vbl; + pd.vertexBufferCount = 1; + pd.topology = PrimitiveTopology::triangleList; + pd.colorTargets[0].format = TextureFormat::rgba8unorm; + pd.colorTargets[0].blendEnabled = true; + pd.colorCount = 1; + pd.sampleCount = 4; + pd.label = "pipe"; + ResourceHandle bglHandles[2] = {layout0, layout1}; + recordMakePipeline(cb, + 0, + 0, + pd, + vsModule, + fsModule, + Span<const ResourceHandle>(bglHandles, 2)); + + OreCommandReader r(cb.commandBytes(), cb.blobBytes()); + CommandType t; + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makePipeline); + r.read<MakeResourcePOD>(); + auto p = r.read<PipelineDescPOD>(); + CHECK(p.vertexModule == vsModule); + CHECK(p.fragmentModule == fsModule); + CHECK(p.colorCount == 1u); + CHECK(p.colorTargets[0].format == TextureFormat::rgba8unorm); + CHECK(p.colorTargets[0].blendEnabled); + CHECK(p.sampleCount == 4u); + CHECK(std::strcmp(cstrOf(r, p.vertexEntryPoint), "vs_main") == 0); + + auto bglBlob = blobOf(r, p.bindGroupLayouts); + REQUIRE(p.bindGroupLayoutCount == 2u); + REQUIRE(bglBlob.size() == 2 * sizeof(ResourceHandle)); + const auto* bgl = reinterpret_cast<const ResourceHandle*>(bglBlob.data()); + CHECK(bgl[0] == layout0); + CHECK(bgl[1] == layout1); + + REQUIRE(p.vertexBufferCount == 1u); + auto vbBlob = blobOf(r, p.vertexBuffers); + REQUIRE(vbBlob.size() == sizeof(VertexBufferLayoutPOD)); + const auto* vb = + reinterpret_cast<const VertexBufferLayoutPOD*>(vbBlob.data()); + CHECK(vb[0].stride == 24u); + CHECK(vb[0].attributeCount == 2u); + auto attrBlob = blobOf(r, vb[0].attributes); + REQUIRE(attrBlob.size() == 2 * sizeof(VertexAttribute)); + const auto* outAttrs = + reinterpret_cast<const VertexAttribute*>(attrBlob.data()); + CHECK(outAttrs[0].format == VertexFormat::float2); + CHECK(outAttrs[1].format == VertexFormat::float4); + CHECK(outAttrs[1].offset == 8u); + CHECK(outAttrs[1].shaderSlot == 1u); +} + +TEST_CASE("make stream records a bind group with entry refs", "[ore][cmd]") +{ + OreCommandBuffer cb; + const ResourceHandle layout = 5, buf0 = 6, view0 = 7, samp0 = 8; + + BindGroupDesc::UBOEntry ubo{}; + ubo.slot = 0; + ubo.offset = 16; + ubo.size = 256; + BindGroupDesc::TexEntry tex{}; + tex.slot = 1; + BindGroupDesc::SampEntry samp{}; + samp.slot = 2; + + BindGroupDesc bg{}; + bg.layout = nullptr; // unused, the ref is passed explicitly + bg.ubos = &ubo; + bg.uboCount = 1; + bg.textures = &tex; + bg.textureCount = 1; + bg.samplers = &samp; + bg.samplerCount = 1; + bg.label = "bg"; + + ResourceHandle uboH[1] = {buf0}, texH[1] = {view0}, sampH[1] = {samp0}; + recordMakeBindGroup(cb, + 0, + 0, + bg, + layout, + Span<const ResourceHandle>(uboH, 1), + Span<const ResourceHandle>(texH, 1), + Span<const ResourceHandle>(sampH, 1)); + + OreCommandReader r(cb.commandBytes(), cb.blobBytes()); + CommandType t; + REQUIRE(r.next(t)); + REQUIRE(t == CommandType::makeBindGroup); + r.read<MakeResourcePOD>(); + auto b = r.read<BindGroupDescPOD>(); + CHECK(b.layout == layout); + REQUIRE(b.uboCount == 1u); + REQUIRE(b.textureCount == 1u); + REQUIRE(b.samplerCount == 1u); + + const auto* ubos = + reinterpret_cast<const UBOEntryPOD*>(blobOf(r, b.ubos).data()); + CHECK(ubos[0].slot == 0u); + CHECK(ubos[0].buffer == buf0); + CHECK(ubos[0].offset == 16u); + CHECK(ubos[0].size == 256u); + const auto* texs = + reinterpret_cast<const TexEntryPOD*>(blobOf(r, b.textures).data()); + CHECK(texs[0].slot == 1u); + CHECK(texs[0].view == view0); + const auto* samps = + reinterpret_cast<const SampEntryPOD*>(blobOf(r, b.samplers).data()); + CHECK(samps[0].slot == 2u); + CHECK(samps[0].sampler == samp0); +} + +TEST_CASE("make stream reset reuses the buffer", "[ore][cmd]") +{ + OreCommandBuffer cb; + TextureDesc td{}; + td.width = td.height = 16; + recordMakeTexture(cb, 0, 0, td); + recordMakeTexture(cb, 1, 0, td); + CHECK_FALSE(cb.empty()); + + cb.reset(); + CHECK(cb.empty()); + recordMakeTexture(cb, 0, 1, td); + CHECK_FALSE(cb.empty()); +}
diff --git a/tests/unit_tests/renderer/ore_render_pass_recording_test.cpp b/tests/unit_tests/renderer/ore_render_pass_recording_test.cpp new file mode 100644 index 0000000..687ccbe --- /dev/null +++ b/tests/unit_tests/renderer/ore_render_pass_recording_test.cpp
@@ -0,0 +1,116 @@ +/* + * Copyright 2026 Rive + */ + +// A RenderPassRecording must emit exactly the stream a hand built +// OreCommandBuffer would, compared by silver. Null resources capture as +// kInvalidHandle so no GPU is required. + +#include "rive/renderer/ore/cmd/ore_render_pass_recording.hpp" +#include "rive/renderer/ore/cmd/ore_command_silver.hpp" + +#include <catch.hpp> +#include <vector> + +using namespace rive::ore; +using namespace rive::ore::cmd; + +TEST_CASE("RenderPassRecording emits the expected command stream", "[ore][cmd]") +{ + RenderPassDesc desc; + desc.colorCount = 1; + desc.colorAttachments[0].view = nullptr; + desc.colorAttachments[0].resolveTarget = nullptr; + desc.colorAttachments[0].loadOp = LoadOp::clear; + desc.colorAttachments[0].storeOp = StoreOp::store; + desc.colorAttachments[0].clearColor = {0.25f, 0.5f, 0.75f, 1.0f}; + desc.depthStencil.view = nullptr; + + OreCommandBuffer recorded; + { + // A null context is safe, validation only dereferences it to report + // errors. + RenderPassRecording pass(nullptr, &recorded, desc); + pass.setPipeline(nullptr); + pass.setViewport(0.f, 0.f, 128.f, 64.f, 0.f, 1.f); + pass.setScissorRect(0, 0, 128, 64); + pass.draw(6, 1, 0, 0); + pass.finish(); + } + + // Hand built reference stream. + OreCommandBuffer expected; + BeginRenderPassCmd begin{}; + begin.colorCount = 1; + begin.colors[0] = {kInvalidHandle, + kInvalidHandle, + LoadOp::clear, + StoreOp::store, + 0.25f, + 0.5f, + 0.75f, + 1.0f}; + begin.depthStencil.view = kInvalidHandle; + begin.depthStencil.depthLoadOp = LoadOp::clear; + begin.depthStencil.depthStoreOp = StoreOp::store; + begin.depthStencil.depthClearValue = 1.0f; + begin.depthStencil.stencilLoadOp = LoadOp::clear; + begin.depthStencil.stencilStoreOp = StoreOp::discard; + begin.depthStencil.stencilClearValue = 0; + expected.append(CommandType::beginRenderPass, begin); + // A null pipeline records an invalid handle. + expected.append(CommandType::setPipeline, SetPipelineCmd{kInvalidHandle}); + expected.append(CommandType::setViewport, + SetViewportCmd{0.f, 0.f, 128.f, 64.f, 0.f, 1.f}); + expected.append(CommandType::setScissorRect, + SetScissorRectCmd{0, 0, 128, 64}); + expected.append(CommandType::draw, DrawCmd{6, 1, 0, 0}); + expected.appendOpcode(CommandType::finish); + + std::vector<uint8_t> recordedSilver, expectedSilver; + serializeSilver(recorded, recordedSilver); + serializeSilver(expected, expectedSilver); + CHECK(silverMatch(expectedSilver, recordedSilver)); +} + +TEST_CASE("RenderPassRecording finish is idempotent", "[ore][cmd]") +{ + RenderPassDesc desc; + desc.colorCount = 0; + desc.depthStencil.view = nullptr; + + OreCommandBuffer recorded; + RenderPassRecording pass(nullptr, &recorded, desc); + pass.draw(3, 1, 0, 0); + pass.finish(); + CHECK(pass.isFinished()); + pass.finish(); + + OreCommandReader r(recorded.commandBytes(), recorded.blobBytes()); + CommandType t; + int finishes = 0; + int total = 0; + while (r.next(t)) + { + ++total; + if (t == CommandType::finish) + { + ++finishes; + continue; + } + // The reader requires consuming each payload. + switch (t) + { + case CommandType::beginRenderPass: + r.read<BeginRenderPassCmd>(); + break; + case CommandType::draw: + r.read<DrawCmd>(); + break; + default: + FAIL("unexpected command in stream"); + } + } + CHECK(finishes == 1); + CHECK(total == 3); +}
diff --git a/tests/unit_tests/renderer/pls_path_test.cpp b/tests/unit_tests/renderer/pls_path_test.cpp index dc68279..657d9a8 100644 --- a/tests/unit_tests/renderer/pls_path_test.cpp +++ b/tests/unit_tests/renderer/pls_path_test.cpp
@@ -75,4 +75,26 @@ (math::PI * 1000 * 1000 - math::PI * 900 * 900) == Approx(1).margin(1e-2f)); } + +TEST_CASE("addRawPath invalidates derived state", "[RiveRenderPath]") +{ + RiveRenderPath path; + + RawPath first; + first.addRect({0, 0, 10, 10}, PathDirection::clockwise); + path.addRawPath(first); + + // Warm the caches so a missing invalidation shows up below. + CHECK(path.getBounds().right() == 10); + CHECK(path.getCoarseArea() == 100); + uint64_t firstMutationID = path.getRawPathMutationID(); + + RawPath second; + second.addRect({20, 20, 40, 40}, PathDirection::clockwise); + path.addRawPath(second); + + CHECK(path.getBounds().right() == 40); + CHECK(path.getCoarseArea() == 100 + 400); + CHECK(path.getRawPathMutationID() != firstMutationID); +} } // namespace rive::gpu
diff --git a/tests/unit_tests/runtime/command_queue_test.cpp b/tests/unit_tests/runtime/command_queue_test.cpp index 16945dd..0f4eb74 100644 --- a/tests/unit_tests/runtime/command_queue_test.cpp +++ b/tests/unit_tests/runtime/command_queue_test.cpp
@@ -5607,7 +5607,11 @@ static void local_server_thread(CommandServer* server) { +#ifndef NDEBUG + // Only exists to satisfy the server's debug-only thread asserts, and the + // override itself is compiled out with them. server->testing_overrideThreadID(std::this_thread::get_id()); +#endif server->serveUntilDisconnect(); }
diff --git a/tests/unit_tests/runtime/instance_factory_test.cpp b/tests/unit_tests/runtime/instance_factory_test.cpp new file mode 100644 index 0000000..1455eb4 --- /dev/null +++ b/tests/unit_tests/runtime/instance_factory_test.cpp
@@ -0,0 +1,132 @@ +/* + * Copyright 2026 Rive + */ + +// An artboard instanced with an override factory routes all instance level +// render resource creation through it, nested instances included; the file +// level factory keeps only the shared decode products. + +#include <rive/artboard.hpp> +#include <rive/factory.hpp> +#include <rive/nested_artboard.hpp> +#include <utils/no_op_renderer.hpp> +#include "rive_file_reader.hpp" + +#include <catch.hpp> + +using namespace rive; + +namespace +{ +// Counts creations, delegates through the base so no op objects come back. +class CountingFactory : public Factory +{ +public: + int paints = 0; + int paths = 0; + int buffers = 0; + int shaders = 0; + + rcp<RenderBuffer> makeRenderBuffer(RenderBufferType type, + RenderBufferFlags flags, + size_t size) override + { + ++buffers; + return inner().makeRenderBuffer(type, flags, size); + } + rcp<RenderShader> makeLinearGradient(float sx, + float sy, + float ex, + float ey, + const ColorInt colors[], + const float stops[], + size_t count) override + { + ++shaders; + return inner().makeLinearGradient(sx, sy, ex, ey, colors, stops, count); + } + rcp<RenderShader> makeRadialGradient(float cx, + float cy, + float radius, + const ColorInt colors[], + const float stops[], + size_t count) override + { + ++shaders; + return inner().makeRadialGradient(cx, cy, radius, colors, stops, count); + } + rcp<RenderPath> makeRenderPath(RawPath& path, FillRule rule) override + { + ++paths; + return inner().makeRenderPath(path, rule); + } + rcp<RenderPath> makeEmptyRenderPath() override + { + ++paths; + return inner().makeEmptyRenderPath(); + } + rcp<RenderPaint> makeRenderPaint() override + { + ++paints; + return inner().makeRenderPaint(); + } + rcp<RenderImage> decodeImage(Span<const uint8_t> bytes) override + { + return inner().decodeImage(bytes); + } + +private: + // NoOpFactory's overrides are private; the base class view is public. + Factory& inner() { return m_inner; } + NoOpFactory m_inner; +}; +} // namespace + +TEST_CASE("instance without an override keeps the file factory", + "[instance_factory]") +{ + auto file = ReadRiveFile("assets/nested_artboard_opacity.riv"); + auto instance = file->artboard()->instance<ArtboardInstance>(); + REQUIRE(instance != nullptr); + REQUIRE(instance->factory() == file->artboard()->factory()); +} + +TEST_CASE("instance override reroutes resource creation", "[instance_factory]") +{ + auto file = ReadRiveFile("assets/nested_artboard_opacity.riv"); + CountingFactory facade; + auto instance = file->artboard()->instance<ArtboardInstance>(&facade); + REQUIRE(instance != nullptr); + REQUIRE(instance->factory() == &facade); + // Fill and stroke paints are created during instancing. + REQUIRE(facade.paints > 0); +} + +TEST_CASE("nested instances inherit the override factory", "[instance_factory]") +{ + auto file = ReadRiveFile("assets/nested_artboard_opacity.riv"); + CountingFactory facade; + auto instance = file->artboard()->instance<ArtboardInstance>(&facade); + REQUIRE(instance != nullptr); + + auto nested = instance->find<NestedArtboard>("Nested artboard container"); + REQUIRE(nested != nullptr); + REQUIRE(nested->sourceArtboard() != nullptr); + REQUIRE(nested->sourceArtboard()->factory() == &facade); +} + +TEST_CASE("advance and draw allocate nothing on the file factory after an " + "override instance", + "[instance_factory]") +{ + auto file = ReadRiveFile("assets/nested_artboard_opacity.riv"); + CountingFactory facade; + auto instance = file->artboard()->instance<ArtboardInstance>(&facade); + REQUIRE(instance != nullptr); + + instance->advance(0.016f); + NoOpRenderer renderer; + instance->draw(&renderer); + // Lazily created shape paths land on the facade, not the file factory. + REQUIRE(facade.paths > 0); +}
diff --git a/tests/unit_tests/runtime/scripting/scripting_canvas_drawing_phase_test.cpp b/tests/unit_tests/runtime/scripting/scripting_canvas_drawing_phase_test.cpp deleted file mode 100644 index daa5081..0000000 --- a/tests/unit_tests/runtime/scripting/scripting_canvas_drawing_phase_test.cpp +++ /dev/null
@@ -1,106 +0,0 @@ -/* - * Copyright 2026 Rive - */ - -// Tests for the canvasDrawingPhase gate. The flag is set by -// `Artboard::drawCanvases()` via `ScopedCanvasDrawingPhase` and is checked by -// the Lua bindings that start canvas-level GPU work — `Canvas:beginFrame()` -// and `GPUCanvas:beginRenderPass()` — so a script can't begin a canvas draw -// from a non-draw callback (state-machine input handler, Coop event, async -// completion, …). -// -// `Canvas` and `GPUCanvas` are gated behind `RIVE_CANVAS` / `RIVE_ORE` build -// flags that aren't enabled in the unit-test build, so we can't exercise -// those bindings directly here. Instead we cover the gate at the C++ level -// via the `ScopedCanvasDrawingPhase` RAII helper, and verify that the -// non-gated `Artboard:drawCanvas()` binding (which has no GPU dependency) -// continues to be callable from any phase. - -#include "catch.hpp" -#include "scripting_test_utilities.hpp" -#include "rive/lua/rive_lua_libs.hpp" -#include "rive_file_reader.hpp" - -using namespace rive; - -TEST_CASE("ScopedCanvasDrawingPhase toggles the flag and restores it", - "[scripting]") -{ - ScriptingTest vm("function noop():() end"); - lua_State* L = vm.state(); - auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L)); - REQUIRE(context != nullptr); - - // Default state: not in a drawing phase. - CHECK(context->canvasDrawingPhase() == false); - - { - ScopedCanvasDrawingPhase phase(context); - CHECK(context->canvasDrawingPhase() == true); - - // Nested scopes preserve the previous (true) value when they unwind, - // so reentrant draws don't accidentally clear the outer phase. - { - ScopedCanvasDrawingPhase nested(context); - CHECK(context->canvasDrawingPhase() == true); - } - CHECK(context->canvasDrawingPhase() == true); - } - - // Restored to the original false after the outer scope unwinds. - CHECK(context->canvasDrawingPhase() == false); -} - -TEST_CASE("ScopedCanvasDrawingPhase tolerates a null context", "[scripting]") -{ - // Some host code paths (e.g. early init / teardown) may not have a - // ScriptingContext yet. The RAII helper has to be a no-op in that case - // rather than crash, since `Artboard::drawCanvases()` constructs it - // unconditionally. - ScopedCanvasDrawingPhase phase(nullptr); - SUCCEED("ScopedCanvasDrawingPhase(nullptr) did not crash"); -} - -TEST_CASE("Artboard:drawCanvas() is callable regardless of drawing phase", - "[scripting]") -{ - // `Artboard:drawCanvas()` itself is not gated — only the canvas-level GPU - // entry points (`Canvas:beginFrame()`, `GPUCanvas:beginRenderPass()`) are. - // Verify the binding succeeds both inside and outside the drawing phase. - // `coin.riv` has no scripted objects so internalDrawCanvases() walks an - // empty list and returns cleanly in either case. - ScriptingTest vm("function callDrawCanvas(artboard:Artboard):()\n" - " artboard:drawCanvas()\n" - "end\n"); - lua_State* L = vm.state(); - auto* context = static_cast<ScriptingContext*>(lua_getthreaddata(L)); - REQUIRE(context != nullptr); - REQUIRE(context->canvasDrawingPhase() == false); - - auto file = ReadRiveFile("assets/coin.riv", vm.serializer()); - auto artboard = file->artboard(); - REQUIRE(artboard != nullptr); - lua_newrive<ScriptedArtboard>(L, - L, - file.get(), - artboard->instance(), - nullptr, - nullptr); - - // Outside the drawing phase: still succeeds. - lua_getglobal(L, "callDrawCanvas"); - lua_pushvalue(L, -2); - CHECK(lua_pcall(L, 1, 0, 0) == LUA_OK); - - // Inside the drawing phase: also succeeds. - { - ScopedCanvasDrawingPhase phase(context); - CHECK(context->canvasDrawingPhase() == true); - lua_getglobal(L, "callDrawCanvas"); - lua_pushvalue(L, -2); - CHECK(lua_pcall(L, 1, 0, 0) == LUA_OK); - } - - // Phase restored to false after the scope. - CHECK(context->canvasDrawingPhase() == false); -}
diff --git a/tests/unit_tests/runtime/scripting/scripting_gpu_features_test.cpp b/tests/unit_tests/runtime/scripting/scripting_gpu_features_test.cpp new file mode 100644 index 0000000..63fad59 --- /dev/null +++ b/tests/unit_tests/runtime/scripting/scripting_gpu_features_test.cpp
@@ -0,0 +1,217 @@ +/* + * Copyright 2026 Rive + */ + +// What a script reads out of context.features while its GPU work is being +// recorded. The answer has to describe the device that will replay the stream, +// because a capability branch taken at record time is written into the stream +// and then replayed verbatim. An answer that is merely plausible is the worst +// case: replay runs the wrong branch flawlessly on hardware that contradicts +// it, and nothing downstream can tell. +// +// Driven through the ScriptingContext ore override, which is the same hook +// every host that records uses (riveScriptingUseDeferredSession, the goldens +// RIVLoader, the editor). The Flutter runtime on this branch never binds a +// render context, so it is not one of the reachable paths. + +#include "catch.hpp" +#include "scripting_test_utilities.hpp" + +#if defined(RIVE_CANVAS) && defined(RIVE_ORE) +#include "rive/renderer/ore/cmd/ore_deferred_context.hpp" +#include "rive/renderer/ore/ore_context.hpp" + +#include <string> + +using namespace rive; + +namespace +{ +// GPU free stand-in for a real backend context. Only what it advertises +// matters here, so the factories are unreachable. +class FakeDeviceContext : public ore::Context +{ +public: + FakeDeviceContext() : ore::Context(nullptr) {} + + ore::Features& editableFeatures() { return m_features; } + + rcp<ore::Buffer> makeBuffer(const ore::BufferDesc&) override + { + return nullptr; + } + rcp<ore::Texture> makeTexture(const ore::TextureDesc&) override + { + return nullptr; + } + rcp<ore::TextureView> makeTextureView(const ore::TextureViewDesc&) override + { + return nullptr; + } + rcp<ore::Sampler> makeSampler(const ore::SamplerDesc&) override + { + return nullptr; + } + rcp<ore::ShaderModule> makeShaderModule( + const ore::ShaderModuleDesc&) override + { + return nullptr; + } + rcp<ore::BindGroupLayout> makeBindGroupLayout( + const ore::BindGroupLayoutDesc&) override + { + return nullptr; + } + rcp<ore::Pipeline> makePipeline(const ore::PipelineDesc&, + std::string*) override + { + return nullptr; + } + rcp<ore::BindGroup> makeBindGroup(const ore::BindGroupDesc&) override + { + return nullptr; + } + std::unique_ptr<ore::RenderPass> beginRenderPass(const ore::RenderPassDesc&, + std::string*) override + { + return nullptr; + } + void beginFrame(const FrameDescriptor&) override {} + void endFrame() override {} + void waitForGPU() override {} + rcp<ore::TextureView> wrapCanvasTexture(gpu::RenderCanvas*) override + { + return nullptr; + } + rcp<ore::TextureView> wrapRiveTexture(gpu::Texture*, + uint32_t, + uint32_t) override + { + return nullptr; + } + ore::ShaderTarget shaderTarget() const override + { + return ore::ShaderTarget::glsl; + } +}; + +// context.features reaches this through a namecall on a ScriptedContext bound +// to a ScriptedObject. Calling it as a bare global is the same entry point +// without that scaffolding. +int pushGPUFeatures(lua_State* L) { return lua_push_gpu_features(L); } + +// Runs `source` against a VM whose ore context is `ore`, with the features +// readout exposed as a global. Returns the error message, empty on success. +std::string runWithOreContext(ore::Context* ore, const char* source) +{ + // Deferred execution so the ore override is in place before the chunk + // runs, then called here rather than through execute(), which only prints + // the message this asserts on. + ScriptingTest test(source, 0, true, {}, false); + test.vm()->context()->setOreContext(ore); + lua_State* L = test.state(); + lua_pushcfunction(L, pushGPUFeatures, "features"); + lua_setglobal(L, "gpuFeatures"); + if (lua_pcall(L, 0, 0, 0) == LUA_OK) + { + return {}; + } + const char* message = lua_tostring(L, -1); + std::string error = message != nullptr ? message : "unknown error"; + lua_pop(L, 1); + return error; +} +} // namespace + +TEST_CASE("a recording script reads the replay device's capabilities", + "[scripting][gpu][features]") +{ + FakeDeviceContext device; + ore::Features& real = device.editableFeatures(); + // A device more capable than Features' initializers in both directions: a + // flag they deny and a limit they understate. + real.colorBufferHalfFloat = true; + real.maxSamples = 8; + + SECTION("bound at construction, as every native host binds") + { + ore::cmd::DeferredOreContext recorder(&device); + std::string error = runWithOreContext( + &recorder, + "local f = gpuFeatures()\n" + "assert(f.colorBufferHalfFloat == true, 'half float denied')\n" + "assert(f.maxSamples == 8, 'maxSamples ' .. f.maxSamples)\n"); + CHECK(error.empty()); + } + + SECTION("bound late, as web binds on attach") + { + // bindReal used to store the pointer and copy nothing, so a script + // running after attach still read the initializers. + ore::cmd::DeferredOreContext recorder(nullptr); + recorder.bindReal(&device); + std::string error = runWithOreContext( + &recorder, + "local f = gpuFeatures()\n" + "assert(f.colorBufferHalfFloat == true, 'half float denied')\n" + "assert(f.maxSamples == 8, 'maxSamples ' .. f.maxSamples)\n"); + CHECK(error.empty()); + } + + SECTION("a real context answers as it always did") + { + std::string error = runWithOreContext( + &device, + "local f = gpuFeatures()\n" + "assert(f.maxSamples == 8, 'maxSamples wrong')\n"); + CHECK(error.empty()); + } +} + +TEST_CASE("an unbound recording context refuses to report capabilities", + "[scripting][gpu][features]") +{ + // Refusing rather than answering conservatively, because the conservative + // answer is what produces the silent wrong branch: it is indistinguishable + // from a real low end device, so no script can defend itself against it, + // and the branch it picks is baked into a stream that replays elsewhere. + ore::cmd::DeferredOreContext recorder(nullptr); + std::string error = runWithOreContext(&recorder, "local f = gpuFeatures()"); + CHECK(error.find("context.features") != std::string::npos); +} + +TEST_CASE("an undecidable capability gate does not invent a refusal", + "[scripting][gpu][features]") +{ + // The gates in lua_gpu.cpp are diagnostics; the real backend is the + // authority. Gating on the initializers while unbound would reject a + // format most devices render, which is the same fiction pointed the other + // way -- and unlike a wrong readout it breaks content outright. + const char* kScript = "local t = GPUTexture.new({ width = 4, height = 4, " + "format = 'rgba16float', renderTarget = true })"; + + SECTION("unbound, the float renderTarget gate stays out of it") + { + ore::cmd::DeferredOreContext recorder(nullptr); + std::string error = runWithOreContext(&recorder, kScript); + CHECK(error.find("colorBufferHalfFloat") == std::string::npos); + } + + SECTION("bound to a half float device, the gate does not fire") + { + FakeDeviceContext device; + device.editableFeatures().colorBufferHalfFloat = true; + ore::cmd::DeferredOreContext recorder(&device); + std::string error = runWithOreContext(&recorder, kScript); + CHECK(error.find("colorBufferHalfFloat") == std::string::npos); + } + + SECTION("bound to a device without it, the gate still fires") + { + FakeDeviceContext device; + ore::cmd::DeferredOreContext recorder(&device); + std::string error = runWithOreContext(&recorder, kScript); + CHECK(error.find("colorBufferHalfFloat") != std::string::npos); + } +} +#endif
diff --git a/tests/unit_tests/runtime/scripting/scripting_routing_test.cpp b/tests/unit_tests/runtime/scripting/scripting_routing_test.cpp new file mode 100644 index 0000000..598d887 --- /dev/null +++ b/tests/unit_tests/runtime/scripting/scripting_routing_test.cpp
@@ -0,0 +1,109 @@ +#include "catch.hpp" +#include "scripting_test_utilities.hpp" +#include "rive/lua/rive_lua_libs.hpp" +#include "rive/renderer/cmd/deferred_canvas_host.hpp" +#include "rive_file_reader.hpp" +#include "utils/no_op_factory.hpp" + +using namespace rive; + +namespace +{ +class StubCanvasHost : public cmd::DeferredCanvasHost +{ +public: + Renderer* beginCanvasContent(gpu::RenderCanvas*, uint32_t) override + { + return nullptr; + } + void endCanvasContent(gpu::RenderCanvas*) override {} +}; + +// Import factory shaped like an FFI deferred session: a device is already +// bound and canvas work must record through the host. +class BoundSessionFactory : public NoOpFactory +{ +public: + StubCanvasHost host; + Factory* renderContext() override { return this; } + cmd::DeferredCanvasHost* deferredCanvasHost() override { return &host; } +}; + +// Import factory shaped like a web deferred session: no device yet, but the +// recording host exists from the start. +class UnboundSessionFactory : public NoOpFactory +{ +public: + StubCanvasHost host; + cmd::DeferredCanvasHost* deferredCanvasHost() override { return &host; } +}; +} // namespace + +TEST_CASE("import routing wires the canvas host when the factory has a device", + "[scripting]") +{ + BoundSessionFactory factory; + auto file = ReadRiveFile("assets/script_advance_test.riv", &factory); + auto* context = file->scriptingVM()->context(); + REQUIRE(context != nullptr); + // renderContext() reads through a factory fallback, so the router must + // not mistake the factory's own device for a caller-chosen one and skip + // the host, which has no fallback of its own. + CHECK(context->deferredCanvasHost() == &factory.host); + CHECK(context->renderContext() == &factory); + CHECK_FALSE(context->renderContextIsLateBound()); +} + +TEST_CASE("import routing wires the canvas host before any device exists", + "[scripting]") +{ + UnboundSessionFactory factory; + auto file = ReadRiveFile("assets/script_advance_test.riv", &factory); + auto* context = file->scriptingVM()->context(); + REQUIRE(context != nullptr); + CHECK(context->deferredCanvasHost() == &factory.host); + // No device: stays late bound so canvas backings defer to whoever binds. + CHECK(context->renderContextIsLateBound()); +} + +#if defined(RIVE_CANVAS) && defined(RIVE_ORE) +TEST_CASE("sized canvas construction goes pending until a device binds", + "[scripting]") +{ + ScriptingTest vm(R"( +function init(self, context) + local gpu = context:gpuCanvas({ width = 4, height = 4 }) + local c2d = context:canvas({ width = 4, height = 4 }) + return gpu ~= nil and c2d ~= nil +end +)"); + StubCanvasHost host; + vm.vm()->context()->setDeferredCanvasHost(&host); + + ScriptedObjectTest scriptedObjectTest; + lua_State* L = vm.state(); + lua_getglobal(L, "init"); + lua_pushvalue(L, -2); + lua_newrive<ScriptedContext>(L, &scriptedObjectTest); + CHECK(lua_pcall(L, 2, 1, 0) == LUA_OK); + CHECK(lua_toboolean(L, -1)); +} + +TEST_CASE("sized canvas construction still refuses a deviceless factory", + "[scripting]") +{ + ScriptingTest vm(R"( +function init(self, context) + return context:gpuCanvas({ width = 4, height = 4 }) +end +)"); + // No canvas host: this factory will never have a device, so pending + // would be a silent forever-hang and the refusal must stay. + ScriptedObjectTest scriptedObjectTest; + lua_State* L = vm.state(); + lua_getglobal(L, "init"); + lua_pushvalue(L, -2); + lua_newrive<ScriptedContext>(L, &scriptedObjectTest); + CHECK(lua_pcall(L, 2, 1, 0) != LUA_OK); +} +#endif
diff --git a/tests/unit_tests/runtime/serialized_replay_test.cpp b/tests/unit_tests/runtime/serialized_replay_test.cpp new file mode 100644 index 0000000..d704664 --- /dev/null +++ b/tests/unit_tests/runtime/serialized_replay_test.cpp
@@ -0,0 +1,88 @@ +/* + * Copyright 2026 Rive + */ + +// Replays a SerializingFactory stream into a second SerializingFactory and +// asserts the re-recorded stream is byte identical, proving every call is +// reproduced in order. GPU free, pixels are covered by the GMs. + +#include "utils/serializing_factory.hpp" +#include "utils/serialized_replay.hpp" +#include "rive/math/raw_path.hpp" +#include "rive/math/mat2d.hpp" + +#include <catch.hpp> +#include <cstring> + +using namespace rive; + +TEST_CASE("serialized 2D commands replay byte-identically", + "[serialize][replay]") +{ + SerializingFactory a; + a.frameSize(256, 256); + a.addFrame(); + auto rendererA = a.makeRenderer(); + + // Exercises every paint mutation op. + auto paint = a.makeRenderPaint(); + paint->color(0xFF112233); + paint->style(RenderPaintStyle::stroke); + paint->thickness(3.5f); + paint->join(StrokeJoin::round); + paint->cap(StrokeCap::square); + paint->blendMode(BlendMode::multiply); + paint->feather(2.0f); + + RawPath rp; + rp.move({0, 0}); + rp.line({10, 0}); + rp.cubic({10, 5}, {5, 10}, {0, 10}); + rp.close(); + auto path = a.makeRenderPath(rp, FillRule::evenOdd); + + auto clip = a.makeEmptyRenderPath(); + RawPath cp; + cp.move({0, 0}); + cp.line({20, 0}); + cp.line({20, 20}); + cp.close(); + clip->addRawPath(cp); + + ColorInt cols[2] = {0xFFFF0000, 0xFF0000FF}; + float stops[2] = {0.0f, 1.0f}; + auto grad = a.makeLinearGradient(0, 0, 100, 100, cols, stops, 2); + auto paint2 = a.makeRenderPaint(); + paint2->shader(grad); + + rendererA->save(); + rendererA->transform(Mat2D(1, 0, 0, 1, 5, 7)); + rendererA->clipPath(clip.get()); + rendererA->modulateOpacity(0.5f); + rendererA->drawPath(path.get(), paint.get()); + rendererA->drawPath(path.get(), paint2.get()); + rendererA->restore(); + + SerializingFactory b; + auto rendererB = b.makeRenderer(); + SerializedReplayHooks hooks; + hooks.onFrame = [&]() { b.addFrame(); }; + hooks.onFrameSize = [&](uint32_t w, uint32_t h) { b.frameSize(w, h); }; + REQUIRE(replaySerializedCommands(a.bytes(), &b, rendererB.get(), hooks)); + + auto sa = a.bytes(); + auto sb = b.bytes(); + REQUIRE(sa.size() == sb.size()); + CHECK(std::memcmp(sa.data(), sb.data(), sa.size()) == 0); +} + +TEST_CASE("serialized replay rejects a bad header", "[serialize][replay]") +{ + const uint8_t garbage[8] = {'X', 'X', 'X', 'X', 1, 0, 0, 0}; + SerializingFactory b; + auto r = b.makeRenderer(); + CHECK_FALSE( + replaySerializedCommands(Span<const uint8_t>(garbage, sizeof(garbage)), + &b, + r.get())); +}
diff --git a/utils/serialized_replay.cpp b/utils/serialized_replay.cpp new file mode 100644 index 0000000..dac6df9 --- /dev/null +++ b/utils/serialized_replay.cpp
@@ -0,0 +1,350 @@ +/* + * Copyright 2026 Rive + */ + +#include "utils/serialized_replay.hpp" +#include "utils/serialize_ops.hpp" +#include "rive/core/binary_reader.hpp" +#include "rive/math/mat2d.hpp" +#include <unordered_map> +#include <vector> + +using namespace rive; + +namespace +{ +// Absent ids mean a truncated or corrupt stream; callers fail instead of +// dereferencing a null resource. +template <typename T> +T* find(std::unordered_map<uint64_t, rcp<T>>& map, uint64_t id) +{ + auto it = map.find(id); + return it != map.end() ? it->second.get() : nullptr; +} +} // namespace + +bool rive::replaySerializedCommands(Span<const uint8_t> stream, + Factory* factory, + Renderer* renderer, + const SerializedReplayHooks& hooks) +{ + BinaryReader reader(stream); + if (reader.readByte() != 'S' || reader.readByte() != 'R' || + reader.readByte() != 'I' || reader.readByte() != 'V') + { + return false; + } + if (reader.readVarUint64() != 1) + { + return false; + } + + std::unordered_map<uint64_t, rcp<RenderPath>> paths; + std::unordered_map<uint64_t, rcp<RenderPaint>> paints; + std::unordered_map<uint64_t, rcp<RenderShader>> shaders; + std::unordered_map<uint64_t, rcp<RenderImage>> images; + std::unordered_map<uint64_t, rcp<RenderBuffer>> buffers; + + while (!reader.reachedEnd()) + { + SerializeOp op = static_cast<SerializeOp>(reader.readVarUint64()); + if (reader.hasError()) + return false; + switch (op) + { + case SerializeOp::makeRenderPath: + { + uint64_t id = reader.readVarUint64(); + // Geometry and fill rule arrive as later ops. + paths[id] = factory->makeEmptyRenderPath(); + break; + } + case SerializeOp::makeRenderPaint: + { + uint64_t id = reader.readVarUint64(); + paints[id] = factory->makeRenderPaint(); + break; + } + case SerializeOp::rewind: + { + uint64_t id = reader.readVarUint64(); + RenderPath* path = find(paths, id); + if (path == nullptr) + return false; + path->rewind(); + break; + } + case SerializeOp::fillRule: + { + uint64_t id = reader.readVarUint64(); + RenderPath* path = find(paths, id); + if (path == nullptr) + return false; + path->fillRule(static_cast<FillRule>(reader.readVarUint64())); + break; + } + case SerializeOp::addRawPath: + { + uint64_t id = reader.readVarUint64(); + RenderPath* path = find(paths, id); + if (path == nullptr) + return false; + RawPath rp = deserializeRawPath(reader); + path->addRawPath(rp); + break; + } + case SerializeOp::color: + { + uint64_t id = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + paint->color(static_cast<unsigned int>(reader.readVarUint64())); + break; + } + case SerializeOp::style: + { + uint64_t id = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + // The stream writes 0 for stroke and 1 for fill. + bool stroked = reader.readVarUint64() == 0; + paint->style(stroked ? RenderPaintStyle::stroke + : RenderPaintStyle::fill); + break; + } + case SerializeOp::thickness: + { + uint64_t id = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + paint->thickness(reader.readFloat32()); + break; + } + case SerializeOp::join: + { + uint64_t id = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + paint->join(static_cast<StrokeJoin>(reader.readVarUint64())); + break; + } + case SerializeOp::cap: + { + uint64_t id = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + paint->cap(static_cast<StrokeCap>(reader.readVarUint64())); + break; + } + case SerializeOp::feather: + { + uint64_t id = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + paint->feather(reader.readFloat32()); + break; + } + case SerializeOp::blendMode: + { + uint64_t id = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + paint->blendMode( + static_cast<BlendMode>(reader.readVarUint64())); + break; + } + case SerializeOp::shader: + { + uint64_t id = reader.readVarUint64(); + uint64_t shaderId = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + if (paint == nullptr) + return false; + // The stream writes 0 for both nullptr and shader id 0. The map + // resolves it since a missing entry yields a null rcp. + paint->shader(shaders[shaderId]); + break; + } + case SerializeOp::makeLinearGradient: + case SerializeOp::makeRadialGradient: + { + uint64_t id = reader.readVarUint64(); + size_t count = static_cast<size_t>(reader.readVarUint64()); + std::vector<ColorInt> colors(count); + std::vector<float> stops(count); + for (size_t i = 0; i < count; ++i) + { + colors[i] = static_cast<ColorInt>(reader.readVarUint64()); + stops[i] = reader.readFloat32(); + } + float a = reader.readFloat32(); + float b = reader.readFloat32(); + float c = reader.readFloat32(); + if (op == SerializeOp::makeLinearGradient) + { + float d = reader.readFloat32(); + shaders[id] = factory->makeLinearGradient(a, + b, + c, + d, + colors.data(), + stops.data(), + count); + } + else + { + shaders[id] = factory->makeRadialGradient(a, + b, + c, + colors.data(), + stops.data(), + count); + } + break; + } + case SerializeOp::decodeImage: + { + uint64_t id = reader.readVarUint64(); + size_t size = static_cast<size_t>(reader.readVarUint64()); + Span<const uint8_t> data = reader.readBytes(size); + images[id] = factory->decodeImage(data); + break; + } + case SerializeOp::makeRenderBuffer: + { + uint64_t id = reader.readVarUint64(); + size_t size = static_cast<size_t>(reader.readVarUint64()); + auto type = + static_cast<RenderBufferType>(reader.readVarUint64()); + auto flags = + static_cast<RenderBufferFlags>(reader.readVarUint64()); + buffers[id] = factory->makeRenderBuffer(type, flags, size); + break; + } + case SerializeOp::setVertexBufferData: + case SerializeOp::setIndexBufferData: + { + uint64_t id = reader.readVarUint64(); + RenderBuffer* buf = find(buffers, id); + if (buf == nullptr) + return false; + void* mapped = buf->map(); + if (op == SerializeOp::setVertexBufferData) + { + size_t n = buf->sizeInBytes() / sizeof(float); + float* out = static_cast<float*>(mapped); + for (size_t i = 0; i < n; ++i) + out[i] = reader.readFloat32(); + } + else + { + size_t n = buf->sizeInBytes() / sizeof(uint16_t); + uint16_t* out = static_cast<uint16_t*>(mapped); + for (size_t i = 0; i < n; ++i) + out[i] = static_cast<uint16_t>(reader.readVarUint64()); + } + buf->unmap(); + break; + } + case SerializeOp::save: + renderer->save(); + break; + case SerializeOp::restore: + renderer->restore(); + break; + case SerializeOp::transform: + { + float m[6]; + for (int i = 0; i < 6; ++i) + m[i] = reader.readFloat32(); + renderer->transform(Mat2D(m[0], m[1], m[2], m[3], m[4], m[5])); + break; + } + case SerializeOp::modulateOpacity: + renderer->modulateOpacity(reader.readFloat32()); + break; + case SerializeOp::drawPath: + { + uint64_t pathId = reader.readVarUint64(); + uint64_t paintId = reader.readVarUint64(); + RenderPath* path = find(paths, pathId); + RenderPaint* paint = find(paints, paintId); + if (path == nullptr || paint == nullptr) + return false; + renderer->drawPath(path, paint); + break; + } + case SerializeOp::clipPath: + { + uint64_t pathId = reader.readVarUint64(); + RenderPath* path = find(paths, pathId); + if (path == nullptr) + return false; + renderer->clipPath(path); + break; + } + case SerializeOp::drawImage: + { + uint64_t imageId = reader.readVarUint64(); + auto blend = static_cast<BlendMode>(reader.readVarUint64()); + float opacity = reader.readFloat32(); + renderer->drawImage(images[imageId].get(), + ImageSampler::LinearClamp(), + blend, + opacity); + break; + } + case SerializeOp::drawImageMesh: + { + uint64_t imageId = reader.readVarUint64(); + auto blend = static_cast<BlendMode>(reader.readVarUint64()); + float opacity = reader.readFloat32(); + rcp<RenderBuffer> pos = buffers[reader.readVarUint64()]; + rcp<RenderBuffer> uvs = buffers[reader.readVarUint64()]; + rcp<RenderBuffer> idx = buffers[reader.readVarUint64()]; + uint32_t vertexCount = + pos ? static_cast<uint32_t>(pos->sizeInBytes() / + (2 * sizeof(float))) + : 0; + uint32_t indexCount = + idx ? static_cast<uint32_t>(idx->sizeInBytes() / + sizeof(uint16_t)) + : 0; + renderer->drawImageMesh(images[imageId].get(), + ImageSampler::LinearClamp(), + pos, + uvs, + idx, + vertexCount, + indexCount, + blend, + opacity); + break; + } + case SerializeOp::frame: + if (hooks.onFrame) + hooks.onFrame(); + break; + case SerializeOp::frameSize: + { + uint32_t w = static_cast<uint32_t>(reader.readVarUint64()); + uint32_t h = static_cast<uint32_t>(reader.readVarUint64()); + if (hooks.onFrameSize) + hooks.onFrameSize(w, h); + break; + } + default: + return false; // unknown opcode + } + if (reader.hasError()) + return false; + } + return true; +}
diff --git a/utils/serializing_factory.cpp b/utils/serializing_factory.cpp index 4db173c..f52ccb3 100644 --- a/utils/serializing_factory.cpp +++ b/utils/serializing_factory.cpp
@@ -1,4 +1,5 @@ #include "utils/serializing_factory.hpp" +#include "utils/serialize_ops.hpp" #include "rive/decoders/bitmap_decoder.hpp" #include "rive/core/binary_reader.hpp" #include "rive/artboard.hpp" @@ -13,47 +14,6 @@ // Threshold for floating point tests. static const float epsilon = 0.001f; -enum class SerializeOp : unsigned char -{ - makeRenderBuffer = 0, - makeLinearGradient = 1, - makeRadialGradient = 2, - makeRenderPath = 3, - makeRenderPaint = 5, - decodeImage = 6, - save = 7, - restore = 8, - transform = 9, - drawPath = 10, - clipPath = 11, - drawImage = 12, - drawImageMesh = 13, - - // RenderBuffer - setVertexBufferData = 14, - setIndexBufferData = 15, - - // RenderPath - addRawPath = 16, - rewind = 17, - fillRule = 18, - - // RenderPaint - style = 20, - color = 21, - thickness = 22, - join = 23, - cap = 24, - feather = 25, - blendMode = 26, - shader = 27, - - frame = 28, - frameSize = 29, - modulateOpacity = 30, - -}; - static const char* opToName(SerializeOp op) { switch (op) @@ -143,23 +103,6 @@ uint64_t m_id; }; -static void serializeRawPath(BinaryWriter* writer, const RawPath& path) -{ - auto verbs = path.verbs(); - auto points = path.points(); - writer->writeVarUint((uint64_t)verbs.size()); - for (auto verb : verbs) - { - writer->writeVarUint((uint64_t)verb); - } - writer->writeVarUint((uint64_t)points.size()); - for (auto point : points) - { - writer->writeFloat(point.x); - writer->writeFloat(point.y); - } -} - class SerializingRenderShader : public RenderShader { public: @@ -1357,12 +1300,14 @@ { auto fullFileName = std::string("silvers/") + std::string(filename) + std::string(".sriv"); +#ifndef NO_GETENV const char* rebaseline = getenv("REBASELINE_SILVERS"); if (rebaseline != nullptr) { save(fullFileName.c_str()); return true; } +#endif FILE* fp = fopen(fullFileName.c_str(), "rb"); if (fp == nullptr)