feat(ore): scripts render GPU passes into our render target (#14436) cf39effc4d
* feat(ore): scripts render GPU passes into Rive's render target under its content

* fix(ore): utf8 dash in the target GM wrapper

* feat(ore): scripts render into our Metal target and top down GL texture targets

* feat(ore): our iOS, Flutter Metal, Android Vulkan, recorder and cli hosts expose their target

* fix(ore): leave the Vulkan host target an attachment after a pass, and add its goldens

* feat(ore): our Windows Flutter host exposes its target to scripts

* feat(ore): our Unreal host exposes its render target to scripts

* fix(scripting): move GPUTarget off the userdata tag Text now uses

* test(ore): target gm goldens for web, android, iossim and streamer lanes

* refactor(ore): share target descriptions across hosts and drop the lazy target claims

* fix(ore): release the target after each frame, skip a resized one, refuse sampling it and keep its view while hidden

* fix(scripting): report stale target views and share one target object per script

* perf(ore): leave the D3D12 target a render target across script passes

* test(ore): run the target parity family, cover a failed wrap and expose the Android Vulkan target

* fix(renderer): skip the D3D12 target transition when Ore already left it a render target

* fix(ore): free the D3D12 host target wrap on release so the host can resize its swapchain

* fix(scripting): stop leaking the target view when a target pass raises a Lua error

* fix(rive_native): acquire the Windows swapchain texture before Ore replays so both draw into it

* test(unreal): expose the gm window's target so the target gm draws through Ore

* test(unreal): keep the gm window's target hidden until preserve works on our Unreal gm lane

* test(ore): overshoot the target GM fallback so a short edge cannot blend the last frame

* test(ore): android vulkan target goldens now that the lane draws through Ore

* fix(ore): clear a target resized since a script drew into it

* fix(evi): read window size and format safely off the render thread and keep srgb targets hidden

* fix(ore): expose the current back buffer from the d3d12 fiddle

* fix(scripting): keep the AS pass desc intact, hold the target from init, and match Luau and AS on hidden and stale targets

* fix(scripting): drop the target view rcp before a pass can raise, and regenerate the wasm host stubs

* docs(scripting): read the target view each frame since a kept one errors after a resize

* chore(rasc_site): rebuild the repl

Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com>
diff --git a/.rive_head b/.rive_head
index e2f3294..1b5a40f 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-f42f02a06d63bc4ca64b2ceb223ec4475784b289
+cf39effc4d5fb53f3a1c0230d3f9aa3b5b6a26be
diff --git a/include/rive/lua/rive_lua_libs.hpp b/include/rive/lua/rive_lua_libs.hpp
index 9f19b37..2cd51aa 100644
--- a/include/rive/lua/rive_lua_libs.hpp
+++ b/include/rive/lua/rive_lua_libs.hpp
@@ -341,6 +341,8 @@
     features,
     shader,
     format,
+    gpuTarget,
+    sampleCount,
 
     // Promise
     andThen,
@@ -937,6 +939,15 @@
     uint32_t pendingHeight = 0;
 };
 
+// Rive's render target, read live from the Ore context on every access.
+class ScriptedGPUTarget
+{
+public:
+    static constexpr uint8_t luaTag = LUA_T_COUNT + 73;
+    static constexpr const char* luaName = "GPUTarget";
+    static constexpr bool hasMetatable = true;
+};
+
 #endif // RIVE_ORE
 
 enum class CanvasState
diff --git a/include/rive/wasm/rive_bindings_v1.h b/include/rive/wasm/rive_bindings_v1.h
index f0bbb8d..e065559 100644
--- a/include/rive/wasm/rive_bindings_v1.h
+++ b/include/rive/wasm/rive_bindings_v1.h
@@ -588,6 +588,8 @@
 uint32_t rive_gpu_canvas_image(uint32_t canvas);
 RIVE_BINDING_IMPORT("rive_gpu_v1", "canvas_resize")
 uint32_t rive_gpu_canvas_resize(uint32_t canvas, uint32_t width, uint32_t height, uint32_t* props, uint32_t propCount);
+RIVE_BINDING_IMPORT("rive_gpu_v1", "target_view")
+uint32_t rive_gpu_target_view(uint32_t current, uint32_t* props, uint32_t propCount);
 RIVE_BINDING_IMPORT("rive_gpu_v1", "pass_begin")
 uint32_t rive_gpu_pass_begin(const rive_gpu_pass_desc_v1* desc, uint32_t descByteCount, const rive_gpu_pass_color_attachment_v1* colors, uint32_t colorByteCount);
 RIVE_BINDING_IMPORT("rive_gpu_v1", "pass_set_pipeline")
diff --git a/renderer/include/rive/renderer/cmd/deferred_host.hpp b/renderer/include/rive/renderer/cmd/deferred_host.hpp
index 3a27ac0..100ac08 100644
--- a/renderer/include/rive/renderer/cmd/deferred_host.hpp
+++ b/renderer/include/rive/renderer/cmd/deferred_host.hpp
@@ -58,6 +58,10 @@
     // GL, Metal), where zero serials also disable backing reuse.
     virtual rive::ore::Context::FrameDescriptor oreFrame() { return {}; }
     void afterOreFrame() override;
+    void setTargetPreserved(bool preserved) override
+    {
+        m_targetPreserved = preserved;
+    }
     // The interlock choice a frame is opened with. A canvas frame takes the
     // screen's unless canvasMode says otherwise, so a canvas never lands in a
     // mode the host did not choose.
@@ -94,6 +98,7 @@
 
 private:
     bool m_clear;
+    bool m_targetPreserved = false;
     uint32_t m_color;
     uint64_t m_target;
     // One sink serves every target of one replayed frame, so whether Ore runs
@@ -128,8 +133,12 @@
                                     : m_session->screenRenderer(m_target);
     }
 
-    // Frame open: stash the clear policy and mark the Ore replay point.
-    void beginRecord(bool clear, uint32_t color);
+    // Frame open: stash the clear policy, declare the render target scripts
+    // may draw into, and mark the Ore replay point. A zero width target keeps
+    // it hidden.
+    void beginRecord(bool clear,
+                     uint32_t color,
+                     const ore::Context::TargetDesc& target = {});
 
     bool doClear() const { return m_doClear; }
     uint32_t clearColor() const { return m_color; }
diff --git a/renderer/include/rive/renderer/cmd/deferred_replayer.hpp b/renderer/include/rive/renderer/cmd/deferred_replayer.hpp
index bbab986..620436a 100644
--- a/renderer/include/rive/renderer/cmd/deferred_replayer.hpp
+++ b/renderer/include/rive/renderer/cmd/deferred_replayer.hpp
@@ -10,6 +10,7 @@
 #include "rive/renderer/cmd/render_replay.hpp"
 #include "rive/renderer/ore/cmd/ore_replay.hpp"
 #include "rive/renderer/render_context_impl.hpp"
+#include "rive/renderer/render_target.hpp"
 #include <algorithm>
 #include <deque>
 #include <mutex>
@@ -58,6 +59,13 @@
     // Backend state fix up after an Ore frame, such as a GL state invalidate.
     virtual void afterOreFrame() {}
 
+    // The render target Ore's target view wraps this frame, null where the
+    // host exposes none to scripts.
+    virtual gpu::RenderTarget* targetRenderTarget() { return nullptr; }
+    // Script GPU work drew into the target under Rive's content, so the
+    // screen frame loads it rather than clearing. Told before it opens.
+    virtual void setTargetPreserved(bool) {}
+
     // 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*/,
@@ -92,6 +100,8 @@
     // The caps the recording answered with, so a replay far from the session
     // can still verify it runs on the device the stream was declared for.
     ore::ReplayCaps oreCaps;
+    // The packed size of the target scripts drew into, or 0.
+    uint32_t oreTargetSize = 0;
 };
 
 inline DeferredFrame snapshotFrame(DeferredSession& session)
@@ -113,6 +123,7 @@
     f.oreReals = session.oreContext().realResources();
     f.segments = session.schedulerSegments();
     f.oreCaps = session.oreContext().caps();
+    f.oreTargetSize = session.oreContext().drawnTargetSize();
     return f;
 }
 
@@ -145,7 +156,8 @@
             session.oreContext().realResources(),
             sink,
             session.schedulerSegments(),
-            session.oreContext().caps());
+            session.oreContext().caps(),
+            session.oreContext().drawnTargetSize());
     }
 
     // Snapshot form: replay an owned frame, independent of the session.
@@ -169,7 +181,8 @@
             frame.oreReals,
             sink,
             frame.segments,
-            frame.oreCaps);
+            frame.oreCaps,
+            frame.oreTargetSize);
     }
 
     // Drop the resident tables so the next replay recreates everything.
@@ -202,9 +215,20 @@
                 const std::vector<rcp<rive::gpu::GPUResource>>& oreReals,
                 DeferredFrameSink& sink,
                 const std::vector<DeferredSegment>& segments,
-                const ore::ReplayCaps& oreCaps)
+                const ore::ReplayCaps& oreCaps,
+                uint32_t oreTargetSize)
     {
         m_stats = ReplayStats{};
+        // With no Ore context or a target resized since recording the passes
+        // drop, and a load would show whatever the target held.
+        gpu::RenderTarget* target =
+            oreTargetSize != 0 && sink.oreContext() != nullptr
+                ? sink.targetRenderTarget()
+                : nullptr;
+        sink.setTargetPreserved(
+            target != nullptr &&
+            ore::cmd::packTargetSize(target->width(), target->height()) ==
+                oreTargetSize);
         m_2d.clearVersionAliases();
         ReplayHooks hooks;
         hooks.stats = &m_stats;
@@ -279,6 +303,7 @@
                 assert(false && "replay device does not match declared caps");
             }
             sink.beginOreFrame();
+            realOre->setRenderTarget(sink.targetRenderTarget());
             ore::cmd::replayOreStream(
                 *realOre,
                 oreCommands,
@@ -310,7 +335,9 @@
                     // or an earlier frame.
                     return m_2d.images.get(imageId);
                 });
+            realOre->setRenderTarget(nullptr);
             sink.endOreFrame();
+            m_ore.releaseFrameTargets();
             sink.afterOreFrame();
         };
 
@@ -353,12 +380,10 @@
         }
         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
+        // screen frame opened. A script writes canvases, which reach a target
+        // through 2D draws that sample them, and the host's one target view,
+        // which lands under every draw. 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.
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp b/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp
index cb8c37e..fb364bd 100644
--- a/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp
+++ b/renderer/include/rive/renderer/ore/cmd/ore_commands.hpp
@@ -7,6 +7,7 @@
 #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 <cassert>
 #include <cstddef>
 #include <cstdint>
 
@@ -88,14 +89,24 @@
     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
+    targetView = 3, // the replaying context's renderTarget, wrapped per frame
 };
 
+// A script recorded against this size, so replay skips a target that differs.
+inline uint32_t packTargetSize(uint32_t width, uint32_t height)
+{
+    assert(width <= 0xffff && height <= 0xffff);
+    return width << 16 | height;
+}
+
 struct WrapCanvasViewPOD
 {
     ResourceHandle id;
     uint32_t generation;
-    uint32_t canvasId; // canvas id, or the 2D image id for imageView
-    uint32_t mode;     // WrapCanvasViewMode
+    // Canvas id, the 2D image id for imageView, or the recorded size packed
+    // by packTargetSize for targetView.
+    uint32_t canvasId;
+    uint32_t mode; // WrapCanvasViewMode
 };
 
 // The consumer clears the slot only when the generation matches, so a stale
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp b/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp
index 6e9b773..8b968c6 100644
--- a/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp
+++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_context.hpp
@@ -180,6 +180,15 @@
         }
         for (uint32_t i = 0; i < desc.textureCount; ++i)
         {
+            // The target is rewrapped every frame and may not be sampleable.
+            auto* d = rive::lite_rtti_cast<DeferredTextureView*>(
+                desc.textures[i].view);
+            if (d != nullptr && d->hostTarget)
+            {
+                setLastError("makeBindGroup: the target view cannot be "
+                             "sampled");
+                return nullptr;
+            }
             texs[i] = handleFor(desc.textures[i].view);
         }
         for (uint32_t i = 0; i < desc.samplerCount; ++i)
@@ -207,9 +216,85 @@
         const RenderPassDesc& desc,
         std::string* /*outError*/ = nullptr) override
     {
+        bool drawsTarget = false;
+        for (uint32_t i = 0; i < desc.colorCount; ++i)
+        {
+            const ColorAttachment& c = desc.colorAttachments[i];
+            for (TextureView* view : {c.view, c.resolveTarget})
+            {
+                auto* d = rive::lite_rtti_cast<DeferredTextureView*>(view);
+                if (d == nullptr || !d->hostTarget)
+                {
+                    continue;
+                }
+                // A retired target view names an image that may be gone.
+                if (d != m_targetView.get())
+                {
+                    setLastError("beginRenderPass: stale target view");
+                    return nullptr;
+                }
+                // Like a frame with no target, the pass drops without error.
+                if (m_targetHidden)
+                {
+                    return nullptr;
+                }
+                drawsTarget = true;
+            }
+        }
+        if (drawsTarget && !m_targetWrapped)
+        {
+            // The host may hand over a different image every frame.
+            recordWrapTargetView();
+        }
+        m_targetDrawn = m_targetDrawn || drawsTarget;
         return std::make_unique<RenderPassRecording>(this, &m_render, desc);
     }
 
+    // The host declares its target before scripts record. A changed target
+    // gets a new view, since the view carries its size and format. A host
+    // that hides its target for a frame keeps the view for when it returns.
+    void setTarget(const TargetDesc& desc)
+    {
+        m_targetHidden = desc.width == 0;
+        if (!m_targetHidden && desc != m_target)
+        {
+            m_target = desc;
+            m_targetView = nullptr;
+        }
+    }
+
+    rcp<TextureView> targetView() override
+    {
+        if (m_targetHidden)
+        {
+            return nullptr;
+        }
+        if (m_targetView == nullptr)
+        {
+            auto a = m_ids.alloc();
+            m_targetView = makeProxyView(a.id,
+                                         a.generation,
+                                         m_target.width,
+                                         m_target.height,
+                                         m_target.format,
+                                         m_target.sampleCount);
+            m_targetView->hostTarget = true;
+            // Replay fills ids in mint order, so the slot is claimed now.
+            recordWrapTargetView();
+        }
+        return m_targetView;
+    }
+
+    // Script GPU work drew into the target this frame, so Rive's frame must
+    // load it rather than clear it.
+    bool targetDrawn() const { return m_targetDrawn; }
+    // The packed size scripts drew the target at this frame, or 0.
+    uint32_t drawnTargetSize() const
+    {
+        return m_targetDrawn ? packTargetSize(m_target.width, m_target.height)
+                             : 0;
+    }
+
     // 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;
@@ -262,14 +347,28 @@
                                             uint32_t width,
                                             uint32_t height)
     {
+        return makeProxyView(id,
+                             generation,
+                             width,
+                             height,
+                             m_caps.canvasTargetFormat,
+                             1);
+    }
+    rcp<DeferredTextureView> makeProxyView(ResourceHandle id,
+                                           uint32_t generation,
+                                           uint32_t width,
+                                           uint32_t height,
+                                           TextureFormat format,
+                                           uint32_t sampleCount)
+    {
         TextureDesc texDesc{};
         texDesc.width = width;
         texDesc.height = height;
-        texDesc.format = m_caps.canvasTargetFormat;
+        texDesc.format = format;
         texDesc.type = TextureType::texture2D;
         texDesc.renderTarget = true;
         texDesc.numMipmaps = 1;
-        texDesc.sampleCount = 1;
+        texDesc.sampleCount = sampleCount;
         auto proxyTex =
             make_rcp<DeferredTexture>(0u, 0u, nullptr, nullptr, texDesc);
         TextureViewDesc viewDesc{};
@@ -482,6 +581,8 @@
         // A pass still open would append its finish into the next frame.
         finishOpenRenderPassesFrom(0);
         m_render.reset();
+        m_targetWrapped = false;
+        m_targetDrawn = false;
         // Cross thread destroys drain on the recording thread, landing at the
         // new frame's stream head.
         m_render.drainDestroys();
@@ -541,6 +642,16 @@
         }
     }
 
+    void recordWrapTargetView()
+    {
+        recordWrapCanvasView(m_render,
+                             m_targetView->clientHandle(),
+                             m_targetView->generation(),
+                             packTargetSize(m_target.width, m_target.height),
+                             WrapCanvasViewMode::targetView);
+        m_targetWrapped = true;
+    }
+
     // Resolves a flagged real id to its retained real object.
     rive::gpu::GPUResource* resolveReal(ResourceHandle h)
     {
@@ -602,6 +713,12 @@
     // addressed by flagged id. Cleared every resetFrame.
     PtrHandleMap m_realPtrToHandle;
     std::vector<rcp<rive::gpu::GPUResource>> m_realResources;
+    TargetDesc m_target;
+    rcp<DeferredTextureView> m_targetView;
+    // This frame's stream already wraps the target.
+    bool m_targetWrapped = false;
+    bool m_targetDrawn = false;
+    bool m_targetHidden = true;
 };
 
 } // 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
index f179bc2..488d69a 100644
--- a/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp
+++ b/renderer/include/rive/renderer/ore/cmd/ore_deferred_resource.hpp
@@ -52,6 +52,7 @@
     // recycling: an address only names this handle for as long as this object
     // occupies it.
     ResourceHandle clientHandle() const { return m_clientHandle; }
+    uint32_t generation() const { return m_generation; }
 
     // A handle resolves against the table this resource's stream feeds, so a
     // reader that writes a different stream must not use it.
@@ -159,6 +160,9 @@
                                                              desc),
         DeferredResource(handle, generation, stream, allocator)
     {}
+
+    // Stands in for the host render target, wrapped anew each frame.
+    bool hostTarget = false;
 };
 
 class DeferredSampler : public LITE_RTTI_OVERRIDE(Sampler, DeferredSampler),
diff --git a/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp b/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp
index d960f58..ab365fa 100644
--- a/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp
+++ b/renderer/include/rive/renderer/ore/cmd/ore_make_replay.hpp
@@ -108,6 +108,18 @@
         return id < objects.size() && objects[id] != nullptr &&
                generations[id] == generation;
     }
+
+    // A wrapped host target must not outlive its frame: the host may resize
+    // or present the image once the frame ends.
+    std::vector<ResourceHandle> frameTargets;
+    void releaseFrameTargets()
+    {
+        for (ResourceHandle id : frameTargets)
+        {
+            objects[id] = nullptr;
+        }
+        frameTargets.clear();
+    }
 };
 
 // A flagged id is an already real resource; any other id indexes the resident
@@ -550,6 +562,30 @@
         {
             // The consumer performs the real wrap reserved at record time.
             auto pod = reader.read<WrapCanvasViewPOD>();
+            if (pod.mode ==
+                static_cast<uint32_t>(WrapCanvasViewMode::targetView))
+            {
+                rive::gpu::RenderTarget* target = ctx.renderTarget();
+                rcp<TextureView> wrapped =
+                    target != nullptr &&
+                            packTargetSize(target->width(), target->height()) ==
+                                pod.canvasId
+                        ? ctx.wrapRenderTarget(target)
+                        : nullptr;
+                if (wrapped == nullptr)
+                {
+                    return skipUnresolvedMake(pod.id,
+                                              pod.generation,
+                                              "targetView",
+                                              nullptr);
+                }
+                table.set(pod.id,
+                          std::move(wrapped),
+                          pod.generation,
+                          OreKind::textureView);
+                table.frameTargets.push_back(pod.id);
+                return true;
+            }
             if (table.alive(pod.id, pod.generation))
             {
                 return true;
diff --git a/renderer/include/rive/renderer/ore/ore_context.hpp b/renderer/include/rive/renderer/ore/ore_context.hpp
index a2186e2..1be061f 100644
--- a/renderer/include/rive/renderer/ore/ore_context.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context.hpp
@@ -32,6 +32,7 @@
 namespace rive::gpu
 {
 class RenderCanvas;
+class RenderTarget;
 class Texture;
 } // namespace rive::gpu
 
@@ -184,6 +185,48 @@
 
     virtual rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) = 0;
 
+    // What a script sees of the host's render target; it builds its
+    // pipelines against these. Zero width means no target is exposed.
+    struct TargetDesc
+    {
+        uint32_t width = 0;
+        uint32_t height = 0;
+        TextureFormat format = TextureFormat::rgba8unorm;
+        uint32_t sampleCount = 1;
+
+        bool operator==(const TargetDesc& o) const
+        {
+            return width == o.width && height == o.height &&
+                   format == o.format && sampleCount == o.sampleCount;
+        }
+        bool operator!=(const TargetDesc& o) const { return !(*this == o); }
+
+        // The single sampled 8 bit target a window host exposes.
+        static TargetDesc color8(uint32_t width, uint32_t height, bool bgra)
+        {
+            return {width,
+                    height,
+                    bgra ? TextureFormat::bgra8unorm
+                         : TextureFormat::rgba8unorm,
+                    1};
+        }
+    };
+
+    // Backends that cannot render into a host's own render target return
+    // null, which drops the passes into it.
+    virtual rcp<TextureView> wrapRenderTarget(gpu::RenderTarget*)
+    {
+        return nullptr;
+    }
+
+    // The host render target scripts draw into under Rive's content. Only the
+    // recording context hands one out, wrapped again each frame at replay.
+    virtual rcp<TextureView> targetView() { return nullptr; }
+
+    // Set by the host around each Ore frame it replays.
+    void setRenderTarget(gpu::RenderTarget* target) { m_renderTarget = target; }
+    gpu::RenderTarget* renderTarget() const { return m_renderTarget; }
+
     // Color format makeRenderCanvas allocates. A backend that allocates
     // anything other than rgba8 must override or canvas draws fail the
     // pipeline compat check at replay.
@@ -427,6 +470,8 @@
 
     bool m_deferredRecording = false;
 
+    gpu::RenderTarget* m_renderTarget = nullptr;
+
     std::unordered_map<uint64_t, rcp<BindGroupLayout>> m_internedLayouts;
 
     cmd::OreCommandBuffer m_pendingFrame;
diff --git a/renderer/include/rive/renderer/ore/ore_context_d3d11.hpp b/renderer/include/rive/renderer/ore/ore_context_d3d11.hpp
index 48a8142..4913bea 100644
--- a/renderer/include/rive/renderer/ore/ore_context_d3d11.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_d3d11.hpp
@@ -41,6 +41,7 @@
     void waitForGPU() override;
 
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
+    rcp<TextureView> wrapRenderTarget(gpu::RenderTarget* target) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
                                      uint32_t height) override;
@@ -70,7 +71,7 @@
         const BindGroupLayoutDesc& desc);
     std::unique_ptr<RenderPass> d3d11BeginRenderPass(const RenderPassDesc& desc,
                                                      std::string* outError);
-    rcp<TextureView> d3d11WrapCanvasTexture(gpu::RenderCanvas* canvas);
+    rcp<TextureView> d3d11WrapTarget(gpu::RenderTarget* target, bool canvas);
     rcp<TextureView> d3d11WrapRiveTexture(gpu::Texture* gpuTex,
                                           uint32_t w,
                                           uint32_t h);
diff --git a/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp b/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp
index 82d3291..6d9aec5 100644
--- a/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_d3d12.hpp
@@ -51,6 +51,7 @@
     uint64_t safeFrameNumber() const { return m_safeFrameNumber; }
 
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
+    rcp<TextureView> wrapRenderTarget(gpu::RenderTarget* target) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
                                      uint32_t height) override;
@@ -89,7 +90,7 @@
         const BindGroupLayoutDesc& desc);
     std::unique_ptr<RenderPass> d3d12BeginRenderPass(const RenderPassDesc& desc,
                                                      std::string* outError);
-    rcp<TextureView> d3d12WrapCanvasTexture(gpu::RenderCanvas* canvas);
+    rcp<TextureView> d3d12WrapTarget(gpu::RenderTarget* target, bool canvas);
     rcp<TextureView> d3d12WrapRiveTexture(gpu::Texture* gpuTex,
                                           uint32_t w,
                                           uint32_t h);
@@ -125,6 +126,8 @@
     Microsoft::WRL::ComPtr<ID3D12DescriptorHeap> m_d3dCpuSamplerHeap;
     UINT m_d3dCpuSrvAllocated = 0;
     UINT m_d3dCpuRtvAllocated = 0;
+    // Host targets are wrapped anew every frame, so they share one RTV slot.
+    UINT m_d3dTargetRtvIndex = UINT_MAX;
     UINT m_d3dCpuDsvAllocated = 0;
     UINT m_d3dCpuSamplerAllocated = 0;
     UINT m_d3dSrvDescSize = 0;
diff --git a/renderer/include/rive/renderer/ore/ore_context_gl.hpp b/renderer/include/rive/renderer/ore/ore_context_gl.hpp
index 1a472f8..7402976 100644
--- a/renderer/include/rive/renderer/ore/ore_context_gl.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_gl.hpp
@@ -8,6 +8,11 @@
 
 #include <unordered_map>
 
+namespace rive::gpu
+{
+class TextureRenderTargetGL;
+} // namespace rive::gpu
+
 // 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.
@@ -60,6 +65,7 @@
     bool usesDeferredFrameReplay() const override { return false; }
 
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
+    rcp<TextureView> wrapRenderTarget(gpu::RenderTarget* target) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
                                      uint32_t height) override;
@@ -76,6 +82,8 @@
 
     ContextGL() : Context(nullptr) {}
 
+    rcp<TextureView> glWrapTarget(gpu::TextureRenderTargetGL* target);
+
     // An incomplete framebuffer repeats every frame; print each message once.
     std::string m_lastReportedError;
 
diff --git a/renderer/include/rive/renderer/ore/ore_context_metal.hpp b/renderer/include/rive/renderer/ore/ore_context_metal.hpp
index 6ea6c2f..b257f16 100644
--- a/renderer/include/rive/renderer/ore/ore_context_metal.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_metal.hpp
@@ -49,6 +49,7 @@
     bool usesDeferredFrameReplay() const override { return true; }
 
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
+    rcp<TextureView> wrapRenderTarget(gpu::RenderTarget* target) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
                                      uint32_t height) override;
@@ -88,7 +89,7 @@
     rcp<BindGroup> mtlMakeBindGroup(const BindGroupDesc& desc);
     std::unique_ptr<RenderPass> mtlBeginRenderPass(const RenderPassDesc& desc,
                                                    std::string* outError);
-    rcp<TextureView> mtlWrapCanvasTexture(gpu::RenderCanvas* canvas);
+    rcp<TextureView> mtlWrapTarget(gpu::RenderTarget* target);
 
     id<MTLDevice> m_mtlDevice = nil;
     id<MTLCommandQueue> m_mtlQueue = nil;
diff --git a/renderer/include/rive/renderer/ore/ore_context_vulkan.hpp b/renderer/include/rive/renderer/ore/ore_context_vulkan.hpp
index bb207f4..48518db 100644
--- a/renderer/include/rive/renderer/ore/ore_context_vulkan.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_vulkan.hpp
@@ -5,6 +5,7 @@
 #pragma once
 
 #include "rive/renderer/ore/ore_context.hpp"
+#include "rive/renderer/vulkan/render_target_vulkan.hpp"
 #include "rive/renderer/vulkan/vulkan_context.hpp"
 #include <functional>
 #include <utility>
@@ -14,6 +15,23 @@
 {
 class TextureVulkan;
 
+// What a host exposes of a Vulkan target to scripts, hidden unless its format
+// is one Ore reports.
+inline Context::TargetDesc targetDescFor(const gpu::RenderTargetVulkan& target)
+{
+    switch (target.framebufferFormat())
+    {
+        case VK_FORMAT_R8G8B8A8_UNORM:
+        case VK_FORMAT_B8G8R8A8_UNORM:
+            return Context::TargetDesc::color8(target.width(),
+                                               target.height(),
+                                               target.framebufferFormat() ==
+                                                   VK_FORMAT_B8G8R8A8_UNORM);
+        default:
+            return {};
+    }
+}
+
 // Refcounted VkDescriptorPool. Dies in one shot when its last ref
 // (ContextVulkan or any BindGroupVulkan) is released.
 class DescriptorPoolGeneration : public RefCnt<DescriptorPoolGeneration>
@@ -109,6 +127,7 @@
     void waitForGPU() override;
 
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
+    rcp<TextureView> wrapRenderTarget(gpu::RenderTarget*) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
                                      uint32_t height) override;
@@ -133,6 +152,11 @@
         Context(vk), m_vk(vk)
     {}
 
+    rcp<TextureView> wrapTargetImage(gpu::RenderTarget*,
+                                     uint32_t width,
+                                     uint32_t height,
+                                     VkImageLayout layout);
+
     const rcp<rive::gpu::VulkanContext> m_vk;
 
     VkQueue m_vkQueue = VK_NULL_HANDLE;
diff --git a/renderer/include/rive/renderer/ore/ore_context_wgpu.hpp b/renderer/include/rive/renderer/ore/ore_context_wgpu.hpp
index c63a539..a37b19d 100644
--- a/renderer/include/rive/renderer/ore/ore_context_wgpu.hpp
+++ b/renderer/include/rive/renderer/ore/ore_context_wgpu.hpp
@@ -45,6 +45,7 @@
     void waitForGPU() override;
 
     rcp<TextureView> wrapCanvasTexture(gpu::RenderCanvas* canvas) override;
+    rcp<TextureView> wrapRenderTarget(gpu::RenderTarget* target) override;
     rcp<TextureView> wrapRiveTexture(gpu::Texture* gpuTex,
                                      uint32_t width,
                                      uint32_t height) override;
diff --git a/renderer/path_fiddle/fiddle_context_d3d.cpp b/renderer/path_fiddle/fiddle_context_d3d.cpp
index e83803d..f915aab 100644
--- a/renderer/path_fiddle/fiddle_context_d3d.cpp
+++ b/renderer/path_fiddle/fiddle_context_d3d.cpp
@@ -111,6 +111,25 @@
 
     void toggleZoomWindow() override {}
 
+    void bindTargetTexture()
+    {
+        if (m_renderTarget->targetTexture() != nullptr)
+        {
+            return;
+        }
+        if (m_isHeadless)
+        {
+            m_renderTarget->setTargetTexture(m_headlessDrawTexture);
+            return;
+        }
+        ComPtr<ID3D11Texture2D> backbuffer;
+        VERIFY_OK(m_swapchain->GetBuffer(
+            0,
+            __uuidof(ID3D11Texture2D),
+            reinterpret_cast<void**>(backbuffer.ReleaseAndGetAddressOf())));
+        m_renderTarget->setTargetTexture(backbuffer);
+    }
+
     std::unique_ptr<Renderer> makeRenderer(int width, int height) override
     {
         return std::make_unique<RiveRenderer>(m_renderContext.get());
@@ -122,27 +141,18 @@
         m_renderContext->beginFrame(frameDescriptor);
     }
 
+    // Ore may draw into the target before Rive's first flush.
+    void beginOreFrame(rive::ore::Context* oreContext) override
+    {
+        bindTargetTexture();
+        FiddleContext::beginOreFrame(oreContext);
+    }
+
     void flushPLSContext(RenderTarget* offscreenRenderTarget) final
     {
         RIVE_PROF_SCOPE_L(0)
 
-        if (m_renderTarget->targetTexture() == nullptr)
-        {
-            if (m_isHeadless)
-            {
-                m_renderTarget->setTargetTexture(m_headlessDrawTexture);
-            }
-            else
-            {
-                ComPtr<ID3D11Texture2D> backbuffer;
-                VERIFY_OK(m_swapchain->GetBuffer(
-                    0,
-                    __uuidof(ID3D11Texture2D),
-                    reinterpret_cast<void**>(
-                        backbuffer.ReleaseAndGetAddressOf())));
-                m_renderTarget->setTargetTexture(backbuffer);
-            }
-        }
+        bindTargetTexture();
         m_renderContext->flush({
             .renderTarget = offscreenRenderTarget != nullptr
                                 ? offscreenRenderTarget
diff --git a/renderer/path_fiddle/fiddle_context_d3d12.cpp b/renderer/path_fiddle/fiddle_context_d3d12.cpp
index fbd6a42..c6a1f1b 100644
--- a/renderer/path_fiddle/fiddle_context_d3d12.cpp
+++ b/renderer/path_fiddle/fiddle_context_d3d12.cpp
@@ -342,15 +342,18 @@
         return m_renderContext->static_impl_cast<RenderContextD3D12Impl>();
     }
 
+    // Asked before this frame's flush refreshes m_frameIndex, which still
+    // names the buffer just presented.
     rive::gpu::RenderTarget* renderTargetOrNull() override
     {
-        return m_renderTargets[m_frameIndex].get();
+        return m_renderTargets[getFrameIndex()].get();
     }
 
     void* getCommandBuffer() override { return m_commandList.Get(); }
 
     void beginOreFrame(rive::ore::Context* oreContext) override
     {
+        m_frameIndex = getFrameIndex();
         beginFrame();
         oreContext->beginFrame({.externalCommandBuffer = m_commandList.Get(),
                                 .safeFrameNumber = m_safeFrame,
diff --git a/renderer/path_fiddle/fiddle_context_metal.mm b/renderer/path_fiddle/fiddle_context_metal.mm
index 5f98dce..4e533c3 100644
--- a/renderer/path_fiddle/fiddle_context_metal.mm
+++ b/renderer/path_fiddle/fiddle_context_metal.mm
@@ -35,6 +35,9 @@
         }
         m_renderContext =
             RenderContextMetalImpl::MakeContext(m_gpu, metalOptions);
+        // Ore commits on this queue so its passes order ahead of our flush.
+        m_renderContext->static_impl_cast<RenderContextMetalImpl>()
+            ->setCommandQueue(m_queue);
         printf("==== MTLDevice: %s (%s) ====\n",
                m_gpu.name.UTF8String,
                // MTLDebugDevice when API validation is on, the real driver
@@ -99,22 +102,15 @@
     void begin(const RenderContext::FrameDescriptor& frameDescriptor) override
     {
         m_renderContext->beginFrame(frameDescriptor);
+        // Ore replays into the target before the first flush.
+        acquireDrawable();
     }
 
     void flushPLSContext(RenderTarget* offscreenRenderTarget) final
     {
         @autoreleasepool
         {
-            if (m_currentFrameSurface == nil)
-            {
-                m_currentFrameSurface = [m_swapchain nextDrawable];
-                assert(m_currentFrameSurface.texture.width ==
-                       m_renderTarget->width());
-                assert(m_currentFrameSurface.texture.height ==
-                       m_renderTarget->height());
-                m_renderTarget->setTargetTexture(m_currentFrameSurface.texture);
-            }
-
+            acquireDrawable();
             id<MTLCommandBuffer> flushCommandBuffer = [m_queue commandBuffer];
             m_renderContext->flush({
                 .renderTarget = offscreenRenderTarget != nullptr
@@ -198,6 +194,19 @@
     }
 
 private:
+    void acquireDrawable()
+    {
+        if (m_currentFrameSurface == nil)
+        {
+            m_currentFrameSurface = [m_swapchain nextDrawable];
+            assert(m_currentFrameSurface.texture.width ==
+                   m_renderTarget->width());
+            assert(m_currentFrameSurface.texture.height ==
+                   m_renderTarget->height());
+            m_renderTarget->setTargetTexture(m_currentFrameSurface.texture);
+        }
+    }
+
     const FiddleContextOptions m_fiddleOptions;
     id<MTLDevice> m_gpu = MTLCreateSystemDefaultDevice();
     id<MTLCommandQueue> m_queue = [m_gpu newCommandQueue];
diff --git a/renderer/src/d3d12/render_context_d3d12_impl.cpp b/renderer/src/d3d12/render_context_d3d12_impl.cpp
index fab8212..113f0bb 100644
--- a/renderer/src/d3d12/render_context_d3d12_impl.cpp
+++ b/renderer/src/d3d12/render_context_d3d12_impl.cpp
@@ -1596,9 +1596,15 @@
         RIVE_PROF_GPUNAME_L(1, "clearPLSTextures");
         if (desc.fixedFunctionColorOutput)
         {
-            m_resourceManager->transition(cmdList,
-                                          targetTexture,
-                                          D3D12_RESOURCE_STATE_RENDER_TARGET);
+            // Ore leaves a host target it drew into as a render target.
+            if (targetTexture->lastState() !=
+                D3D12_RESOURCE_STATE_RENDER_TARGET)
+            {
+                m_resourceManager->transition(
+                    cmdList,
+                    targetTexture,
+                    D3D12_RESOURCE_STATE_RENDER_TARGET);
+            }
 
             auto rtvHandle =
                 m_rtvHeap->cpuHandleForIndex(TARGET_RTV_HEAP_OFFSET);
@@ -1724,9 +1730,12 @@
 
     if (renderPassHasCoalescedResolveAndTransfer)
     {
-        m_resourceManager->transition(cmdList,
-                                      targetTexture,
-                                      D3D12_RESOURCE_STATE_RENDER_TARGET);
+        if (targetTexture->lastState() != D3D12_RESOURCE_STATE_RENDER_TARGET)
+        {
+            m_resourceManager->transition(cmdList,
+                                          targetTexture,
+                                          D3D12_RESOURCE_STATE_RENDER_TARGET);
+        }
 
         auto rtvHandle = m_rtvHeap->cpuHandleForIndex(TARGET_RTV_HEAP_OFFSET);
         cmdList->OMSetRenderTargets(1, &rtvHandle, FALSE, nullptr);
diff --git a/renderer/src/deferred_host.cpp b/renderer/src/deferred_host.cpp
index 80f41e3..ed41ec8 100644
--- a/renderer/src/deferred_host.cpp
+++ b/renderer/src/deferred_host.cpp
@@ -57,7 +57,7 @@
     }
     if (m_screen == nullptr)
     {
-        m_screen = beginScreen(target, m_clear, m_color);
+        m_screen = beginScreen(target, m_clear && !m_targetPreserved, m_color);
     }
     return m_screen;
 }
@@ -130,10 +130,13 @@
     m_activeCanvas = nullptr;
 }
 
-void DeferredInlineHost::beginRecord(bool clear, uint32_t color)
+void DeferredInlineHost::beginRecord(bool clear,
+                                     uint32_t color,
+                                     const ore::Context::TargetDesc& target)
 {
     m_doClear = clear;
     m_color = color;
+    m_session->oreContext().setTarget(target);
     m_session->recordOreReplayMarker();
 }
 
diff --git a/renderer/src/ore/d3d11/ore_context_d3d11.cpp b/renderer/src/ore/d3d11/ore_context_d3d11.cpp
index 8481c42..9d753e5 100644
--- a/renderer/src/ore/d3d11/ore_context_d3d11.cpp
+++ b/renderer/src/ore/d3d11/ore_context_d3d11.cpp
@@ -1347,17 +1347,20 @@
 }
 
 // ============================================================================
-// d3d11WrapCanvasTexture
+// d3d11WrapTarget
 // ============================================================================
 
-rcp<TextureView> ContextD3D11::d3d11WrapCanvasTexture(gpu::RenderCanvas* canvas)
+rcp<TextureView> ContextD3D11::d3d11WrapTarget(gpu::RenderTarget* target,
+                                               bool canvas)
 {
-    assert(canvas != nullptr);
+    assert(target != nullptr);
 
-    auto* d3dTarget =
-        static_cast<gpu::RenderTargetD3D*>(canvas->renderTarget());
+    auto* d3dTarget = static_cast<gpu::RenderTargetD3D*>(target);
     ID3D11Texture2D* d3dTex = d3dTarget->targetTexture();
-    assert(d3dTex != nullptr);
+    if (d3dTex == nullptr)
+    {
+        return nullptr;
+    }
 
     D3D11_TEXTURE2D_DESC d3dDesc{};
     d3dTex->GetDesc(&d3dDesc);
@@ -1379,8 +1382,8 @@
     }
 
     TextureDesc texDesc{};
-    texDesc.width = canvas->width();
-    texDesc.height = canvas->height();
+    texDesc.width = target->width();
+    texDesc.height = target->height();
     texDesc.format = oreFormat;
     texDesc.type = TextureType::texture2D;
     texDesc.renderTarget = true;
@@ -1404,6 +1407,11 @@
     // Borrow the existing RTV from the D3D render target (AddRefs via ComPtr).
     view->m_d3dRTV = d3dTarget->targetRTV();
 
+    if (!canvas)
+    {
+        return view;
+    }
+
     // 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{};
@@ -1603,7 +1611,13 @@
 
 rcp<TextureView> ContextD3D11::wrapCanvasTexture(gpu::RenderCanvas* canvas)
 {
-    return d3d11WrapCanvasTexture(canvas);
+    assert(canvas != nullptr);
+    return d3d11WrapTarget(canvas->renderTarget(), true);
+}
+
+rcp<TextureView> ContextD3D11::wrapRenderTarget(gpu::RenderTarget* target)
+{
+    return target != nullptr ? d3d11WrapTarget(target, false) : nullptr;
 }
 
 rcp<TextureView> ContextD3D11::wrapRiveTexture(gpu::Texture* gpuTex,
diff --git a/renderer/src/ore/d3d12/ore_context_d3d12.cpp b/renderer/src/ore/d3d12/ore_context_d3d12.cpp
index 5588a45..f46006f 100644
--- a/renderer/src/ore/d3d12/ore_context_d3d12.cpp
+++ b/renderer/src/ore/d3d12/ore_context_d3d12.cpp
@@ -1694,10 +1694,10 @@
         // Record resource + final state for finish() to transition back.
         pass->m_d3dColorResources[i] = tex->m_d3dTexture.Get();
         pass->m_d3dColorTextures[i] = tex;
-        // External (canvas) textures go back to COMMON; ORE-owned go to
+        // External textures go back to their owner's state; ORE-owned go to
         // PIXEL_SHADER_RESOURCE.
         const D3D12_RESOURCE_STATES colourFinalState =
-            tex->m_d3dIsExternal ? D3D12_RESOURCE_STATE_COMMON
+            tex->m_d3dIsExternal ? tex->m_d3dExternalState
                                  : D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
         pass->m_d3dColorFinalStates[i] = colourFinalState;
         pass->m_d3dColorCount++;
@@ -1725,7 +1725,7 @@
             r.resolvePriorState = resolveTex->m_d3dCurrentState;
             r.resolveFinalState =
                 resolveTex->m_d3dIsExternal
-                    ? D3D12_RESOURCE_STATE_COMMON
+                    ? resolveTex->m_d3dExternalState
                     : D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE;
         }
     }
@@ -1844,18 +1844,21 @@
 }
 
 // ============================================================================
-// d3d12WrapCanvasTexture
+// d3d12WrapTarget
 // ============================================================================
 
-rcp<TextureView> ContextD3D12::d3d12WrapCanvasTexture(gpu::RenderCanvas* canvas)
+rcp<TextureView> ContextD3D12::d3d12WrapTarget(gpu::RenderTarget* target,
+                                               bool canvas)
 {
 #if defined(ORE_BACKEND_D3D12)
-    assert(canvas != nullptr);
+    assert(target != nullptr);
 
-    auto* d3dTarget =
-        static_cast<gpu::RenderTargetD3D12*>(canvas->renderTarget());
+    auto* d3dTarget = static_cast<gpu::RenderTargetD3D12*>(target);
     gpu::D3D12Texture* d3dTex = d3dTarget->targetTexture();
-    assert(d3dTex != nullptr);
+    if (d3dTex == nullptr)
+    {
+        return nullptr;
+    }
 
     DXGI_FORMAT dxgiFmt = d3dTex->format();
 
@@ -1877,18 +1880,34 @@
     }
 
     TextureDesc texDesc{};
-    texDesc.width = canvas->width();
-    texDesc.height = canvas->height();
+    texDesc.width = target->width();
+    texDesc.height = target->height();
     texDesc.format = oreFormat;
     texDesc.type = TextureType::texture2D;
     texDesc.renderTarget = true;
     texDesc.numMipmaps = 1;
     texDesc.sampleCount = 1;
 
-    auto texture = rcp<TextureD3D12>(new TextureD3D12(m_manager, texDesc));
+    // A host target frees on its last release, not in purgatory, since its
+    // back buffer reference would fail the host's ResizeBuffers.
+    auto manager = canvas ? m_manager : nullptr;
+    auto texture = rcp<TextureD3D12>(new TextureD3D12(manager, texDesc));
     texture->m_d3dTexture =
         d3dTex->resource(); // Borrow — RenderCanvas owns it.
-    texture->m_d3dCurrentState = D3D12_RESOURCE_STATE_COMMON;
+    // Rive tracks a host target's state and draws into it after us, so it
+    // moves to and stays a render target through Rive's own tracking.
+    if (!canvas && m_d3dCmdList != nullptr &&
+        d3dTex->lastState() != D3D12_RESOURCE_STATE_RENDER_TARGET)
+    {
+        static_cast<gpu::D3D12ResourceManager*>(d3dTex->manager())
+            ->transition(m_d3dCmdList,
+                         d3dTex,
+                         D3D12_RESOURCE_STATE_RENDER_TARGET);
+    }
+    const D3D12_RESOURCE_STATES ownerState =
+        canvas ? D3D12_RESOURCE_STATE_COMMON : d3dTex->lastState();
+    texture->m_d3dCurrentState = ownerState;
+    texture->m_d3dExternalState = ownerState;
     texture->m_d3dIsExternal = true;
     texture->m_d3dDevice = m_d3dDevice.Get();
     texture->m_d3dOreContext = this;
@@ -1902,12 +1921,12 @@
     viewDesc.layerCount = 1;
 
     auto view = rcp<TextureViewD3D12>(
-        new TextureViewD3D12(m_manager, std::move(texture), viewDesc));
+        new TextureViewD3D12(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)
+    if (canvas && m_d3dCpuSrvAllocated < 1024)
     {
         D3D12_CPU_DESCRIPTOR_HANDLE srvHandle =
             m_d3dCpuSrvHeap->GetCPUDescriptorHandleForHeapStart();
@@ -1926,10 +1945,24 @@
         view->m_d3dSrvHandle = srvHandle;
     }
 
-    // Create the RTV in our CPU RTV heap.
+    // Create the RTV in our CPU RTV heap. Passes copy RTVs as they record,
+    // so last frame's target slot is free to rewrite.
+    UINT rtvIndex;
+    if (canvas)
+    {
+        rtvIndex = m_d3dCpuRtvAllocated++;
+    }
+    else
+    {
+        if (m_d3dTargetRtvIndex == UINT_MAX)
+        {
+            m_d3dTargetRtvIndex = m_d3dCpuRtvAllocated++;
+        }
+        rtvIndex = m_d3dTargetRtvIndex;
+    }
     D3D12_CPU_DESCRIPTOR_HANDLE handle =
         m_d3dCpuRtvHeap->GetCPUDescriptorHandleForHeapStart();
-    handle.ptr += (SIZE_T)m_d3dCpuRtvAllocated++ * m_d3dRtvDescSize;
+    handle.ptr += (SIZE_T)rtvIndex * m_d3dRtvDescSize;
 
     D3D12_RENDER_TARGET_VIEW_DESC rtvDesc = {};
     rtvDesc.Format = dxgiFmt;
@@ -1941,6 +1974,7 @@
 
     return view;
 #else
+    (void)target;
     (void)canvas;
     return {};
 #endif
@@ -2110,7 +2144,13 @@
 
 rcp<TextureView> ContextD3D12::wrapCanvasTexture(gpu::RenderCanvas* canvas)
 {
-    return d3d12WrapCanvasTexture(canvas);
+    assert(canvas != nullptr);
+    return d3d12WrapTarget(canvas->renderTarget(), true);
+}
+
+rcp<TextureView> ContextD3D12::wrapRenderTarget(gpu::RenderTarget* target)
+{
+    return target != nullptr ? d3d12WrapTarget(target, false) : nullptr;
 }
 
 rcp<TextureView> ContextD3D12::wrapRiveTexture(gpu::Texture* gpuTex,
diff --git a/renderer/src/ore/d3d12/ore_render_pass_d3d12.cpp b/renderer/src/ore/d3d12/ore_render_pass_d3d12.cpp
index 2d9d484..f2b1412 100644
--- a/renderer/src/ore/d3d12/ore_render_pass_d3d12.cpp
+++ b/renderer/src/ore/d3d12/ore_render_pass_d3d12.cpp
@@ -484,14 +484,17 @@
             }
         }
 
-        D3D12_RESOURCE_BARRIER barrier = {};
-        barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
-        barrier.Transition.pResource = m_d3dColorResources[i];
-        barrier.Transition.StateBefore = stateBefore;
-        barrier.Transition.StateAfter = m_d3dColorFinalStates[i];
-        barrier.Transition.Subresource =
-            D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
-        m_d3dCmdList->ResourceBarrier(1, &barrier);
+        if (stateBefore != m_d3dColorFinalStates[i])
+        {
+            D3D12_RESOURCE_BARRIER barrier = {};
+            barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
+            barrier.Transition.pResource = m_d3dColorResources[i];
+            barrier.Transition.StateBefore = stateBefore;
+            barrier.Transition.StateAfter = m_d3dColorFinalStates[i];
+            barrier.Transition.Subresource =
+                D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
+            m_d3dCmdList->ResourceBarrier(1, &barrier);
+        }
 
         if (m_d3dColorTextures[i] != nullptr)
             m_d3dColorTextures[i]->m_d3dCurrentState = m_d3dColorFinalStates[i];
diff --git a/renderer/src/ore/d3d12/ore_texture_d3d12.hpp b/renderer/src/ore/d3d12/ore_texture_d3d12.hpp
index a1f1517..3df5d65 100644
--- a/renderer/src/ore/d3d12/ore_texture_d3d12.hpp
+++ b/renderer/src/ore/d3d12/ore_texture_d3d12.hpp
@@ -23,7 +23,9 @@
     friend class RenderPassD3D12;
     Microsoft::WRL::ComPtr<ID3D12Resource> m_d3dTexture;
     D3D12_RESOURCE_STATES m_d3dCurrentState = D3D12_RESOURCE_STATE_COMMON;
-    bool m_d3dIsExternal = false;        // true for wrapCanvasTexture.
+    bool m_d3dIsExternal = false; // true for wrapCanvasTexture.
+    // The state its owner expects an external texture back in after a pass.
+    D3D12_RESOURCE_STATES m_d3dExternalState = D3D12_RESOURCE_STATE_COMMON;
     ID3D12Device* m_d3dDevice = nullptr; // Weak ref.
     // ContextD3D12. Weak ref. actually used for barriers
     ContextD3D12* m_d3dOreContext = nullptr;
diff --git a/renderer/src/ore/gl/ore_context_gl.cpp b/renderer/src/ore/gl/ore_context_gl.cpp
index dc7cb96..c22155e 100644
--- a/renderer/src/ore/gl/ore_context_gl.cpp
+++ b/renderer/src/ore/gl/ore_context_gl.cpp
@@ -1338,17 +1338,16 @@
 // wrapCanvasTexture
 // ============================================================================
 
-rcp<TextureView> ContextGL::wrapCanvasTexture(gpu::RenderCanvas* canvas)
+rcp<TextureView> ContextGL::glWrapTarget(gpu::TextureRenderTargetGL* target)
 {
-    assert(canvas != nullptr);
-
-    auto* glTarget =
-        static_cast<gpu::TextureRenderTargetGL*>(canvas->renderTarget());
-    GLuint texID = glTarget->externalTextureID();
-    assert(texID != 0);
+    GLuint texID = target->externalTextureID();
+    if (texID == 0)
+    {
+        return nullptr;
+    }
     TextureDesc texDesc{};
-    texDesc.width = canvas->width();
-    texDesc.height = canvas->height();
+    texDesc.width = target->width();
+    texDesc.height = target->height();
     texDesc.format = TextureFormat::rgba8unorm;
     texDesc.type = TextureType::texture2D;
     texDesc.renderTarget = true;
@@ -1356,7 +1355,7 @@
     texDesc.sampleCount = 1;
 
     auto texture = rcp<TextureGL>(new TextureGL(texDesc));
-    texture->m_glTexture = texID; // Borrow — RenderCanvas owns it.
+    texture->m_glTexture = texID; // Borrow — the host owns it.
     texture->m_glTarget = GL_TEXTURE_2D;
     texture->m_glOwnsTexture = false;
 
@@ -1371,6 +1370,23 @@
     return rcp<TextureViewGL>(new TextureViewGL(std::move(texture), viewDesc));
 }
 
+rcp<TextureView> ContextGL::wrapCanvasTexture(gpu::RenderCanvas* canvas)
+{
+    assert(canvas != nullptr);
+    return glWrapTarget(
+        static_cast<gpu::TextureRenderTargetGL*>(canvas->renderTarget()));
+}
+
+rcp<TextureView> ContextGL::wrapRenderTarget(gpu::RenderTarget* target)
+{
+    // Our GLSL flips Y at bake time, so passes land upright only in top down
+    // textures. Framebuffers and bottom up textures wait for an offscreen copy.
+    auto* glTarget = lite_rtti_cast<gpu::TextureRenderTargetGL*>(
+        static_cast<gpu::RenderTargetGL*>(target));
+    return glTarget != nullptr && !glTarget->bottomUp() ? glWrapTarget(glTarget)
+                                                        : nullptr;
+}
+
 rcp<TextureView> ContextGL::wrapRiveTexture(gpu::Texture* gpuTex,
                                             uint32_t w,
                                             uint32_t h)
diff --git a/renderer/src/ore/metal/ore_context_metal.mm b/renderer/src/ore/metal/ore_context_metal.mm
index a9bbc70..e65e1ee 100644
--- a/renderer/src/ore/metal/ore_context_metal.mm
+++ b/renderer/src/ore/metal/ore_context_metal.mm
@@ -1090,18 +1090,19 @@
     return pass;
 }
 
-inline rcp<TextureView> ContextMetal::mtlWrapCanvasTexture(
-    gpu::RenderCanvas* canvas)
+inline rcp<TextureView> ContextMetal::mtlWrapTarget(gpu::RenderTarget* target)
 {
-    assert(canvas != nullptr);
+    assert(target != nullptr);
 
-    auto* metalTarget =
-        static_cast<gpu::RenderTargetMetal*>(canvas->renderTarget());
-    id<MTLTexture> mtlTexture = metalTarget->targetTexture();
-    assert(mtlTexture != nil);
+    id<MTLTexture> mtlTexture =
+        static_cast<gpu::RenderTargetMetal*>(target)->targetTexture();
+    if (mtlTexture == nil)
+    {
+        return nullptr;
+    }
 
-    uint32_t w = canvas->width();
-    uint32_t h = canvas->height();
+    uint32_t w = target->width();
+    uint32_t h = target->height();
 
     TextureDesc texDesc{};
     texDesc.width = w;
@@ -1311,7 +1312,13 @@
 
 rcp<TextureView> ContextMetal::wrapCanvasTexture(gpu::RenderCanvas* canvas)
 {
-    return mtlWrapCanvasTexture(canvas);
+    assert(canvas != nullptr);
+    return mtlWrapTarget(canvas->renderTarget());
+}
+
+rcp<TextureView> ContextMetal::wrapRenderTarget(gpu::RenderTarget* target)
+{
+    return target != nullptr ? mtlWrapTarget(target) : nullptr;
 }
 
 rcp<TextureView> ContextMetal::wrapRiveTexture(gpu::Texture* gpuTex,
diff --git a/renderer/src/ore/vulkan/ore_context_vulkan.cpp b/renderer/src/ore/vulkan/ore_context_vulkan.cpp
index 93c9ed4..7d40f29 100644
--- a/renderer/src/ore/vulkan/ore_context_vulkan.cpp
+++ b/renderer/src/ore/vulkan/ore_context_vulkan.cpp
@@ -1649,12 +1649,57 @@
 // wrapCanvasTexture
 // ============================================================================
 
+static TextureFormat oreFormatFor(VkFormat vkFmt)
+{
+    switch (vkFmt)
+    {
+        case VK_FORMAT_B8G8R8A8_UNORM:
+            return TextureFormat::bgra8unorm;
+        case VK_FORMAT_R16G16B16A16_SFLOAT:
+            return TextureFormat::rgba16float;
+        case VK_FORMAT_A2B10G10R10_UNORM_PACK32:
+            return TextureFormat::rgb10a2unorm;
+        default:
+            return TextureFormat::rgba8unorm;
+    }
+}
+
 rcp<TextureView> ContextVulkan::wrapCanvasTexture(gpu::RenderCanvas* canvas)
 {
     assert(canvas != nullptr);
+    return wrapTargetImage(canvas->renderTarget(),
+                           canvas->width(),
+                           canvas->height(),
+                           VK_IMAGE_LAYOUT_UNDEFINED);
+}
 
-    auto* vkTarget =
-        static_cast<gpu::RenderTargetVulkan*>(canvas->renderTarget());
+// A host target may change image every frame, so its view starts in the
+// layout Rive's tracker left it in and a load keeps what is there.
+rcp<TextureView> ContextVulkan::wrapRenderTarget(gpu::RenderTarget* target)
+{
+    auto* vkTarget = static_cast<gpu::RenderTargetVulkan*>(target);
+    if (vkTarget == nullptr)
+    {
+        return nullptr;
+    }
+    rcp<TextureView> view =
+        wrapTargetImage(target,
+                        target->width(),
+                        target->height(),
+                        vkTarget->targetLastAccess().layout);
+    if (view != nullptr)
+    {
+        static_cast<TextureVulkan*>(view->texture())->m_vkSampleable = false;
+    }
+    return view;
+}
+
+rcp<TextureView> ContextVulkan::wrapTargetImage(gpu::RenderTarget* target,
+                                                uint32_t width,
+                                                uint32_t height,
+                                                VkImageLayout layout)
+{
+    auto* vkTarget = static_cast<gpu::RenderTargetVulkan*>(target);
 
     VkImage image = vkTarget->targetImage();
     VkImageView imageView = vkTarget->targetImageView();
@@ -1664,28 +1709,10 @@
     // Derive the ore format from the actual Vulkan surface format so any MSAA
     // texture created from this descriptor matches the resolve target exactly
     // (Vulkan requires identical formats for MSAA resolve).
-    auto vkFmt = vkTarget->framebufferFormat();
-    TextureFormat oreFormat;
-    switch (vkFmt)
-    {
-        case VK_FORMAT_B8G8R8A8_UNORM:
-            oreFormat = TextureFormat::bgra8unorm;
-            break;
-        case VK_FORMAT_R16G16B16A16_SFLOAT:
-            oreFormat = TextureFormat::rgba16float;
-            break;
-        case VK_FORMAT_A2B10G10R10_UNORM_PACK32:
-            oreFormat = TextureFormat::rgb10a2unorm;
-            break;
-        default:
-            oreFormat = TextureFormat::rgba8unorm;
-            break;
-    }
-
     TextureDesc texDesc{};
-    texDesc.width = canvas->width();
-    texDesc.height = canvas->height();
-    texDesc.format = oreFormat;
+    texDesc.width = width;
+    texDesc.height = height;
+    texDesc.format = oreFormatFor(vkTarget->framebufferFormat());
     texDesc.type = TextureType::texture2D;
     texDesc.renderTarget = true;
     texDesc.numMipmaps = 1;
@@ -1697,7 +1724,7 @@
     texture->m_vk = m_vk;
     // Mark as not VMA-owned so the destructor skips the VMA free.
     texture->m_vmaAllocation = VK_NULL_HANDLE;
-    texture->m_vkLayout = VK_IMAGE_LAYOUT_UNDEFINED;
+    texture->m_vkLayout = layout;
     // Rive's tracker still says undefined until something draws the canvas
     if (vkTarget->targetLastAccess().layout != VK_IMAGE_LAYOUT_UNDEFINED)
     {
diff --git a/renderer/src/ore/vulkan/ore_render_pass_vulkan.cpp b/renderer/src/ore/vulkan/ore_render_pass_vulkan.cpp
index d8c1d0f..6644625 100644
--- a/renderer/src/ore/vulkan/ore_render_pass_vulkan.cpp
+++ b/renderer/src/ore/vulkan/ore_render_pass_vulkan.cpp
@@ -213,6 +213,27 @@
 
     m_vkContext->m_vk->CmdEndRenderPass(m_vkCmdBuf);
 
+    // A target that is never sampled stays in the layout the pass left it in,
+    // and Rive's tracker hears the write so its own barrier starts from there.
+    auto keepAsAttachment = [](Texture* texture,
+                               gpu::RenderTargetVulkan* renderTarget) {
+        auto* tex = lite_rtti_cast<TextureVulkan*>(texture);
+        if (tex == nullptr || tex->m_vkSampleable)
+        {
+            return false;
+        }
+        tex->m_vkLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
+        if (renderTarget != nullptr)
+        {
+            renderTarget->updateLastAccess({
+                VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
+                VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
+                VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
+            });
+        }
+        return true;
+    };
+
     // Transition color attachments COLOR_ATTACHMENT_OPTIMAL →
     // SHADER_READ_ONLY_OPTIMAL so callers (e.g. Rive drawImage) can sample.
     //
@@ -233,6 +254,9 @@
     {
         if (m_vkColorImages[i] == VK_NULL_HANDLE)
             continue;
+        if (keepAsAttachment(m_vkColorTextures[i].get(),
+                             m_vkColorRenderTargets[i]))
+            continue;
         VkImageMemoryBarrier barrier{};
         barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
         barrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
@@ -275,7 +299,8 @@
     // attachment in COLOR_ATTACHMENT_OPTIMAL.
     for (auto& resolve : m_vkResolveTargets)
     {
-        if (resolve.image == VK_NULL_HANDLE)
+        if (resolve.image == VK_NULL_HANDLE ||
+            keepAsAttachment(resolve.texture.get(), resolve.renderTarget))
         {
             continue;
         }
diff --git a/renderer/src/ore/vulkan/ore_texture_vulkan.hpp b/renderer/src/ore/vulkan/ore_texture_vulkan.hpp
index fb7afa8..d53aa1a 100644
--- a/renderer/src/ore/vulkan/ore_texture_vulkan.hpp
+++ b/renderer/src/ore/vulkan/ore_texture_vulkan.hpp
@@ -27,6 +27,9 @@
     VkImage m_vkImage = VK_NULL_HANDLE;
     VmaAllocation m_vmaAllocation = VK_NULL_HANDLE;
     VkImageLayout m_vkLayout = VK_IMAGE_LAYOUT_UNDEFINED;
+    // A host's render target may lack sampled usage, so passes leave it an
+    // attachment.
+    bool m_vkSampleable = true;
     // One flag per mip and layer, set once anything gives that subresource
     // contents, pending uploads included.
     std::vector<bool> m_vkWritten;
diff --git a/renderer/src/ore/wgpu/ore_context_wgpu.cpp b/renderer/src/ore/wgpu/ore_context_wgpu.cpp
index c889314..bab08d5 100644
--- a/renderer/src/ore/wgpu/ore_context_wgpu.cpp
+++ b/renderer/src/ore/wgpu/ore_context_wgpu.cpp
@@ -1155,9 +1155,16 @@
 rcp<TextureView> ContextWGPU::wrapCanvasTexture(gpu::RenderCanvas* canvas)
 {
     assert(canvas != nullptr);
+    return wrapRenderTarget(canvas->renderTarget());
+}
 
-    auto* wgpuTarget =
-        static_cast<gpu::RenderTargetWebGPU*>(canvas->renderTarget());
+rcp<TextureView> ContextWGPU::wrapRenderTarget(gpu::RenderTarget* target)
+{
+    auto* wgpuTarget = static_cast<gpu::RenderTargetWebGPU*>(target);
+    if (wgpuTarget == nullptr || !wgpuTarget->targetTextureView())
+    {
+        return nullptr;
+    }
 
     // Derive the ore format from the actual WebGPU surface format so any MSAA
     // texture created from this descriptor matches the resolve target exactly.
@@ -1180,8 +1187,8 @@
     }
 
     TextureDesc texDesc{};
-    texDesc.width = canvas->width();
-    texDesc.height = canvas->height();
+    texDesc.width = target->width();
+    texDesc.height = target->height();
     texDesc.format = oreFormat;
     texDesc.type = TextureType::texture2D;
     texDesc.renderTarget = true;
diff --git a/src/lua/lua_atoms.cpp b/src/lua/lua_atoms.cpp
index c7d5e81..5658e86 100644
--- a/src/lua/lua_atoms.cpp
+++ b/src/lua/lua_atoms.cpp
@@ -279,6 +279,8 @@
     {"features", (int16_t)LuaAtoms::features},
     {"shader", (int16_t)LuaAtoms::shader},
     {"format", (int16_t)LuaAtoms::format},
+    {"gpuTarget", (int16_t)LuaAtoms::gpuTarget},
+    {"sampleCount", (int16_t)LuaAtoms::sampleCount},
     {"andThen", (int16_t)LuaAtoms::andThen},
     {"catch", (int16_t)LuaAtoms::catch_},
     {"finally", (int16_t)LuaAtoms::finally_},
diff --git a/src/lua/lua_scripted_context.cpp b/src/lua/lua_scripted_context.cpp
index 34473c5..17ec613 100644
--- a/src/lua/lua_scripted_context.cpp
+++ b/src/lua/lua_scripted_context.cpp
@@ -658,6 +658,33 @@
                 return 1;
 #endif
             }
+            case (int)LuaAtoms::gpuTarget:
+            {
+#if defined(RIVE_CANVAS) && defined(RIVE_ORE)
+                auto* oreCtx = static_cast<ore::Context*>(
+                    static_cast<ScriptingContext*>(lua_getthreaddata(L))
+                        ->oreContext());
+                // Available whenever a host could expose its target, so
+                // init can hold it before the first frame declares one.
+                if (oreCtx != nullptr && oreCtx->isRecording())
+                {
+                    // The target holds no state, so one userdata serves
+                    // every call.
+                    static const char* kTargetKey = "rive.gpuTarget";
+                    lua_rawgetfield(L, LUA_REGISTRYINDEX, kTargetKey);
+                    if (lua_isnil(L, -1))
+                    {
+                        lua_pop(L, 1);
+                        lua_newrive<ScriptedGPUTarget>(L);
+                        lua_pushvalue(L, -1);
+                        lua_rawsetfield(L, LUA_REGISTRYINDEX, kTargetKey);
+                    }
+                    return 1;
+                }
+#endif
+                lua_pushnil(L);
+                return 1;
+            }
             case (int)LuaAtoms::features:
                 return lua_push_gpu_features(L);
 
diff --git a/src/lua/renderer/lua_gpu.cpp b/src/lua/renderer/lua_gpu.cpp
index 5b9ff77..e999164 100644
--- a/src/lua/renderer/lua_gpu.cpp
+++ b/src/lua/renderer/lua_gpu.cpp
@@ -2166,14 +2166,17 @@
 // GPURenderPass
 // ============================================================================
 
-static void validate_render_pass(lua_State* L, ScriptedGPURenderPass* self)
+// False for a pass that drops its commands, like one begun on a frame with no
+// target.
+static bool validate_render_pass(lua_State* L, ScriptedGPURenderPass* self)
 {
     // The pass finishes without the wrapper when the pass it was begun
     // inside finishes first, or when the script call that began it returns.
-    if (self->m_finished || !self->pass || self->pass->isFinished())
+    if (self->m_finished || (self->pass && self->pass->isFinished()))
     {
         luaL_error(L, "render pass expired: it was already finished");
     }
+    return self->pass != nullptr;
 }
 
 static void validate_pipeline_set(lua_State* L, ScriptedGPURenderPass* self)
@@ -2187,7 +2190,10 @@
 static int gpurenderpass_setpipeline(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     auto* pipeline = lua_torive<ScriptedGPUPipeline>(L, 2);
     if (pipeline->sampleCount != self->sampleCount)
     {
@@ -2216,7 +2222,10 @@
 static int gpurenderpass_setvertexbuffer(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     // Note: WebGPU / Metal / Vulkan / D3D11 all permit `setVertexBuffer`
     // before `setPipeline` — vertex-buffer state is layered onto whatever
     // pipeline is current at draw time. Don't gate on `m_pipelineSet`.
@@ -2236,7 +2245,10 @@
 static int gpurenderpass_setindexbuffer(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     auto* buffer = lua_torive<ScriptedGPUBuffer>(L, 2);
     IndexFormat fmt = IndexFormat::uint16;
     if (lua_isstring(L, 3))
@@ -2252,7 +2264,10 @@
 static int gpurenderpass_setbindgroup(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     uint32_t groupIndex = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
     if (groupIndex >= ore::kMaxBindGroups)
     {
@@ -2325,7 +2340,10 @@
 static int gpurenderpass_setviewport(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     float x = static_cast<float>(luaL_checknumber(L, 2));
     float y = static_cast<float>(luaL_checknumber(L, 3));
     float w = static_cast<float>(luaL_checknumber(L, 4));
@@ -2337,7 +2355,10 @@
 static int gpurenderpass_setscissorrect(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     uint32_t x = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
     uint32_t y = static_cast<uint32_t>(luaL_checkunsigned(L, 3));
     uint32_t w = static_cast<uint32_t>(luaL_checkunsigned(L, 4));
@@ -2349,7 +2370,10 @@
 static int gpurenderpass_setstencilreference(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     uint32_t ref = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
     self->pass->setStencilReference(ref);
     return 0;
@@ -2358,7 +2382,10 @@
 static int gpurenderpass_setblendcolor(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     float r = static_cast<float>(luaL_checknumber(L, 2));
     float g = static_cast<float>(luaL_checknumber(L, 3));
     float b = static_cast<float>(luaL_checknumber(L, 4));
@@ -2372,7 +2399,10 @@
 static int gpurenderpass_draw(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     validate_pipeline_set(L, self);
     uint32_t vertexCount = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
     uint32_t instanceCount =
@@ -2396,7 +2426,10 @@
 static int gpurenderpass_drawindexed(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
+    if (!validate_render_pass(L, self))
+    {
+        return 0;
+    }
     validate_pipeline_set(L, self);
     uint32_t indexCount = static_cast<uint32_t>(luaL_checkunsigned(L, 2));
     uint32_t instanceCount =
@@ -2432,8 +2465,10 @@
 static int gpurenderpass_finish(lua_State* L)
 {
     auto* self = lua_torive<ScriptedGPURenderPass>(L, 1);
-    validate_render_pass(L, self);
-    self->pass->finish();
+    if (validate_render_pass(L, self))
+    {
+        self->pass->finish();
+    }
     self->m_finished = true;
     return 0;
 }
@@ -2523,25 +2558,23 @@
     return StoreOp::store;
 }
 
-// canvas:beginRenderPass(desc) — open a render pass against this canvas.
+// target:beginRenderPass(desc) — open a render pass against a GPUTarget.
 //
 // `desc` is required: at least one color attachment or a depthStencil
 // attachment must be supplied. Every attachment carries its own view, and
 // sampleCount is derived from those views (all attachments must share one
 // sampleCount, matching WebGPU validation).
 //
-// Color attachment `view` is optional: when omitted it defaults to the
-// receiving canvas's own colorView. Provide an explicit view for cases
+// Color attachment `view` is optional: when omitted it defaults to
+// targetView, the receiver's own view. Provide an explicit view for cases
 // where the target differs (e.g. MSAA: view = msaa:view(),
 // resolveTarget = canvas:colorView()).
-int gpucanvas_beginrenderpass(lua_State* L)
+static Context* check_beginrenderpass(lua_State* L, const char* typeName)
 {
-    auto* self = lua_torive<ScriptedGPUCanvas>(L, 1);
-
     Context* oreCtx = getOreContext(L);
     if (oreCtx == nullptr)
     {
-        luaL_error(L, "GPUCanvas:beginRenderPass() requires a GPU context");
+        luaL_error(L, "%s:beginRenderPass() requires a GPU context", typeName);
     }
 
     auto* scriptingContext =
@@ -2551,14 +2584,26 @@
     if (scriptingContext == nullptr || !oreCtx->isRecording())
     {
         luaL_error(L,
-                   "GPUCanvas:beginRenderPass() requires the deferred "
-                   "recorder");
+                   "%s:beginRenderPass() requires the deferred recorder",
+                   typeName);
     }
 
     luaL_checktype(L, 2, LUA_TTABLE);
+    return oreCtx;
+}
+
+// A host target that is hidden this frame has no view, so a pass that omits
+// one drops after validating, as the module lane does.
+static int gputarget_beginrenderpass(lua_State* L,
+                                     const char* typeName,
+                                     ore::TextureView* targetView,
+                                     bool hostTarget = false)
+{
+    Context* oreCtx = check_beginrenderpass(L, typeName);
 
     RenderPassDesc passDesc{};
     passDesc.colorCount = 0;
+    bool dropped = false;
 
     // Tracks the first attachment we see (any color slot or depth) and
     // becomes the pass's authoritative sampleCount. Subsequent attachments
@@ -2605,10 +2650,14 @@
             ore::TextureView* viewPtr = nullptr;
             if (lua_isnil(L, -1))
             {
-                // Sugar: omitting `view` defaults to the receiving canvas's
-                // own colorView. Errors only if the canvas is deferred
-                // (zero-sized — no backing texture).
-                if (!self->oreColorView)
+                // Sugar: omitting `view` defaults to the receiver's own
+                // view. Errors only if a canvas is deferred (zero-sized — no
+                // backing texture).
+                if (targetView == nullptr && hostTarget)
+                {
+                    dropped = true;
+                }
+                else if (targetView == nullptr)
                 {
                     luaL_error(L,
                                "beginRenderPass: color[%d].view omitted but "
@@ -2617,7 +2666,7 @@
                                "before drawing, or pass an explicit view.",
                                slot + 1);
                 }
-                viewPtr = self->oreColorView.get();
+                viewPtr = targetView;
             }
             else
             {
@@ -2632,9 +2681,13 @@
                 viewPtr = tv->view.get();
             }
             passDesc.colorAttachments[slot].view = viewPtr;
-            char colorLabel[32];
-            snprintf(colorLabel, sizeof(colorLabel), "color[%d]", slot + 1);
-            recordSampleCount(viewPtr->texture()->sampleCount(), colorLabel);
+            if (viewPtr != nullptr)
+            {
+                char colorLabel[32];
+                snprintf(colorLabel, sizeof(colorLabel), "color[%d]", slot + 1);
+                recordSampleCount(viewPtr->texture()->sampleCount(),
+                                  colorLabel);
+            }
             lua_pop(L, 1); // view
 
             // resolveTarget (optional — for MSAA). Source view must be
@@ -2799,8 +2852,20 @@
                    "color attachment or a depthStencil attachment");
     }
 
+    if (dropped)
+    {
+        lua_newrive<ScriptedGPURenderPass>(L);
+        return 1;
+    }
+    oreCtx->clearLastError();
+    auto pass = ore::cmd::beginRecordedRenderPass(*oreCtx, passDesc);
+    // A pass refused without an error drops, like one with no target.
+    if (pass == nullptr && !oreCtx->lastError().empty())
+    {
+        luaL_error(L, "beginRenderPass: %s", oreCtx->lastError().c_str());
+    }
     auto* rp = lua_newrive<ScriptedGPURenderPass>(L);
-    rp->pass = ore::cmd::beginRecordedRenderPass(*oreCtx, passDesc);
+    rp->pass = std::move(pass);
     rp->m_finished = false;
     rp->sampleCount =
         passSampleCount < 1 ? 1u : static_cast<uint32_t>(passSampleCount);
@@ -2809,6 +2874,97 @@
     return 1;
 }
 
+static int gpucanvas_beginrenderpass(lua_State* L)
+{
+    auto* self = lua_torive<ScriptedGPUCanvas>(L, 1);
+    return gputarget_beginrenderpass(L,
+                                     ScriptedGPUCanvas::luaName,
+                                     self->oreColorView.get());
+}
+
+static int hosttarget_beginrenderpass(lua_State* L)
+{
+    lua_torive<ScriptedGPUTarget>(L, 1);
+    Context* oreCtx = check_beginrenderpass(L, ScriptedGPUTarget::luaName);
+    // A Lua error longjmps past any rcp still on the stack, so drop ours before
+    // the pass can raise; the context keeps the view alive.
+    TextureView* view = oreCtx->targetView().get();
+    return gputarget_beginrenderpass(L, ScriptedGPUTarget::luaName, view, true);
+}
+
+// The fields every GPUTarget answers from the view it renders into, or 0 when
+// the atom is not one of them.
+static int gputarget_pushfield(lua_State* L,
+                               int atom,
+                               const rcp<TextureView>& view)
+{
+    Texture* texture = view != nullptr ? view->texture() : nullptr;
+    switch (atom)
+    {
+        case (int)LuaAtoms::width:
+            lua_pushnumber(L, texture != nullptr ? texture->width() : 0);
+            return 1;
+        case (int)LuaAtoms::height:
+            lua_pushnumber(L, texture != nullptr ? texture->height() : 0);
+            return 1;
+        case (int)LuaAtoms::format:
+            // A deferred canvas reports the format makeRenderCanvas allocates.
+            lua_pushstring(L,
+                           lua_totextureformatstring(
+                               texture != nullptr ? texture->format()
+                                                  : TextureFormat::rgba8unorm));
+            return 1;
+        case (int)LuaAtoms::sampleCount:
+            lua_pushnumber(L, texture != nullptr ? texture->sampleCount() : 1);
+            return 1;
+        case (int)LuaAtoms::view:
+            if (view == nullptr)
+            {
+                lua_pushnil(L);
+                return 1;
+            }
+            lua_newrive<ScriptedGPUTextureView>(L)->view = view;
+            return 1;
+    }
+    return 0;
+}
+
+static int hosttarget_index(lua_State* L)
+{
+    int atom;
+    const char* key = lua_tostringatom(L, 2, &atom);
+    if (!key)
+    {
+        luaL_typeerrorL(L, 2, lua_typename(L, LUA_TSTRING));
+    }
+    lua_torive<ScriptedGPUTarget>(L, 1);
+    Context* oreCtx = getOreContext(L);
+    if (int pushed = gputarget_pushfield(
+            L,
+            atom,
+            oreCtx != nullptr ? oreCtx->targetView() : nullptr))
+    {
+        return pushed;
+    }
+    luaL_error(L, "'%s' is not a valid index of GPUTarget", key);
+    return 0;
+}
+
+static int hosttarget_namecall(lua_State* L)
+{
+    int atom;
+    const char* str = lua_namecallatom(L, &atom);
+    if (str != nullptr && atom == (int)LuaAtoms::beginRenderPass)
+    {
+        return hosttarget_beginrenderpass(L);
+    }
+    luaL_error(L,
+               "%s is not a valid method of %s",
+               str,
+               ScriptedGPUTarget::luaName);
+    return 0;
+}
+
 rcp<gpu::RenderCanvas> rive::allocScriptRenderCanvas(gpu::RenderContext* rc,
                                                      ScriptingContext* ctx,
                                                      uint32_t width,
@@ -3033,18 +3189,10 @@
                            self->canvas ? self->canvas->height()
                                         : self->pendingHeight);
             return 1;
-        case (int)LuaAtoms::format:
-            // Realized canvas reports its texture format. Deferred canvas
-            // reports the format makeRenderCanvas always allocates.
-            if (self->oreColorView && self->oreColorView->texture())
-                lua_pushstring(L,
-                               lua_totextureformatstring(
-                                   self->oreColorView->texture()->format()));
-            else
-                lua_pushstring(
-                    L,
-                    lua_totextureformatstring(TextureFormat::rgba8unorm));
-            return 1;
+    }
+    if (int pushed = gputarget_pushfield(L, atom, self->oreColorView))
+    {
+        return pushed;
     }
     luaL_error(L, "'%s' is not a valid index of GPUCanvas", key);
     return 0;
@@ -3587,6 +3735,18 @@
                                            gpucanvashandle_direct_height);
     }
 
+    // GPUTarget (no public constructor — created via context:gpuTarget())
+    {
+        luaL_register(L, ScriptedGPUTarget::luaName, empty);
+        lua_register_rive<ScriptedGPUTarget>(L);
+        lua_pushcfunction(L, hosttarget_namecall, nullptr);
+        lua_setfield(L, -2, "__namecall");
+        lua_pushcfunction(L, hosttarget_index, nullptr);
+        lua_setfield(L, -2, "__index");
+        lua_setreadonly(L, -1, true);
+        lua_pop(L, 1);
+    }
+
     // Canvas (no public constructor — created via context:canvas())
     {
         luaL_register(L, ScriptedCanvas::luaName, empty);
diff --git a/src/wasm/idl/bindings.py b/src/wasm/idl/bindings.py
index 3851ebf..9d7f45c 100644
--- a/src/wasm/idl/bindings.py
+++ b/src/wasm/idl/bindings.py
@@ -663,6 +663,13 @@
             u32('height'),
             mutbuf('uint32_t', 'props', 'propCount'),
         ], ret='u32'),
+        # A view over the host render target, which renders under Rive's
+        # content; props mirror canvas_color_view. Returns current while it
+        # still names the target, 0 when the host exposes none.
+        op('target_view', [
+            u32('current'),
+            mutbuf('uint32_t', 'props', 'propCount'),
+        ], ret='u32'),
         op('pass_begin', [
             podref('gpu_pass_desc', 'desc'),
             buf('rive_gpu_pass_color_attachment_v1', 'colors',
diff --git a/src/wasm/module/gpu_proxy.cpp b/src/wasm/module/gpu_proxy.cpp
index 8d96d41..0e4210f 100644
--- a/src/wasm/module/gpu_proxy.cpp
+++ b/src/wasm/module/gpu_proxy.cpp
@@ -346,6 +346,24 @@
     uint32_t m_handle;
 };
 
+// A view over a host color target, described by the props the host fills:
+// width, height, format and sample count. The texture is metadata only, so
+// attachment validation sees the real format and sample count.
+rcp<ore::TextureView> makeModuleColorView(uint32_t handle,
+                                          const uint32_t props[4])
+{
+    ore::TextureDesc textureDesc;
+    textureDesc.width = props[0];
+    textureDesc.height = props[1];
+    textureDesc.format = (ore::TextureFormat)props[2];
+    textureDesc.sampleCount = props[3];
+    textureDesc.renderTarget = true;
+    ore::TextureViewDesc viewDesc;
+    auto texture = make_rcp<ModuleOreTexture>(0, textureDesc);
+    viewDesc.texture = texture.get();
+    return make_rcp<ModuleOreTextureView>(handle, std::move(texture), viewDesc);
+}
+
 class ModuleOreContext : public ore::Context
 {
 public:
@@ -732,6 +750,24 @@
     {
         return notPortedYet<ore::TextureView>("canvas wraps");
     }
+
+    // The host hands back the same handle until its target changes.
+    rcp<ore::TextureView> targetView() override
+    {
+        uint32_t current =
+            m_targetView != nullptr
+                ? static_cast<ModuleOreTextureView*>(m_targetView.get())
+                      ->handle()
+                : 0;
+        uint32_t props[4] = {};
+        uint32_t handle = rive_gpu_target_view(current, props, 4);
+        if (handle != current)
+        {
+            m_targetView =
+                handle != 0 ? makeModuleColorView(handle, props) : nullptr;
+        }
+        return m_targetView;
+    }
     rcp<ore::TextureView> wrapRiveTexture(gpu::Texture*,
                                           uint32_t,
                                           uint32_t) override
@@ -814,6 +850,7 @@
 
     mutable ore::ShaderTarget m_shaderTarget = ore::ShaderTarget::wgsl;
     mutable bool m_shaderTargetKnown = false;
+    rcp<ore::TextureView> m_targetView;
 };
 
 } // namespace
@@ -858,17 +895,6 @@
     {
         return {};
     }
-    ore::TextureDesc textureDesc;
-    textureDesc.width = props[0];
-    textureDesc.height = props[1];
-    textureDesc.format = (ore::TextureFormat)props[2];
-    textureDesc.sampleCount = props[3];
-    textureDesc.renderTarget = true;
-    ore::TextureViewDesc viewDesc;
-    // Metadata-only texture wrapper so attachment validation sees the
-    // canvas's real format and sample count.
-    auto texture = make_rcp<ModuleOreTexture>(0, textureDesc);
-    viewDesc.texture = texture.get();
     WasmModuleCanvas out;
     // Backing installs unbacked (null image texture, module semantics);
     // construction alone no longer carries the render target.
@@ -876,9 +902,7 @@
     out.canvas->setBacking(
         nullptr,
         make_rcp<ModuleRenderTarget>(canvasHandle, props[0], props[1]));
-    out.colorView = make_rcp<ModuleOreTextureView>(viewHandle,
-                                                   std::move(texture),
-                                                   viewDesc);
+    out.colorView = makeModuleColorView(viewHandle, props);
     return out;
 }
 
@@ -898,24 +922,13 @@
     {
         return {};
     }
-    ore::TextureDesc textureDesc;
-    textureDesc.width = props[0];
-    textureDesc.height = props[1];
-    textureDesc.format = (ore::TextureFormat)props[2];
-    textureDesc.sampleCount = props[3];
-    textureDesc.renderTarget = true;
-    ore::TextureViewDesc viewDesc;
-    auto texture = make_rcp<ModuleOreTexture>(0, textureDesc);
-    viewDesc.texture = texture.get();
     WasmModuleCanvas out;
     out.canvas = make_rcp<gpu::RenderCanvas>(props[0], props[1]);
     out.canvas->setBacking(nullptr,
                            make_rcp<ModuleRenderTarget>(target->disownHandle(),
                                                         props[0],
                                                         props[1]));
-    out.colorView = make_rcp<ModuleOreTextureView>(viewHandle,
-                                                   std::move(texture),
-                                                   viewDesc);
+    out.colorView = makeModuleColorView(viewHandle, props);
     return out;
 }
 
diff --git a/src/wasm/wasm_natives_gen.hpp b/src/wasm/wasm_natives_gen.hpp
index 51c11d4..08d3b4e 100644
--- a/src/wasm/wasm_natives_gen.hpp
+++ b/src/wasm/wasm_natives_gen.hpp
@@ -383,6 +383,7 @@
 uint32_t gpuCanvasColorViewImpl(WasmScriptingVM* vm, uint32_t canvas, uint32_t* props, uint32_t propCount);
 uint32_t gpuCanvasImageImpl(WasmScriptingVM* vm, uint32_t canvas);
 uint32_t gpuCanvasResizeImpl(WasmScriptingVM* vm, uint32_t canvas, uint32_t width, uint32_t height, uint32_t* props, uint32_t propCount);
+uint32_t gpuTargetViewImpl(WasmScriptingVM* vm, uint32_t current, uint32_t* props, uint32_t propCount);
 uint32_t gpuPassBeginImpl(WasmScriptingVM* vm, const rive_gpu_pass_desc_v1* desc, uint32_t descByteCount, const rive_gpu_pass_color_attachment_v1* colors, uint32_t colorByteCount);
 void gpuPassSetPipelineImpl(WasmScriptingVM* vm, uint32_t pass, uint32_t pipeline);
 void gpuPassSetVertexBufferImpl(WasmScriptingVM* vm, uint32_t pass, uint32_t slot, uint32_t buffer, uint32_t offset);
@@ -1512,6 +1513,15 @@
     }
     return gpuCanvasResizeImpl(vm, canvas, width, height, props, propCount);
 }
+uint32_t gpuTargetView(wasm_exec_env_t env, uint32_t current, uint32_t* props, uint32_t propCount)
+{
+    WasmScriptingVM* vm = vmFromEnv(env);
+    if (propCount != 0 && !wasm_runtime_validate_native_addr(wasm_runtime_get_module_inst(env), (void*)props, (uint64_t)propCount * 4))
+    {
+        return {};
+    }
+    return gpuTargetViewImpl(vm, current, props, propCount);
+}
 uint32_t gpuPassBegin(wasm_exec_env_t env, const rive_gpu_pass_desc_v1* desc, uint32_t descByteCount, const rive_gpu_pass_color_attachment_v1* colors, uint32_t colorByteCount)
 {
     WasmScriptingVM* vm = vmFromEnv(env);
@@ -2275,6 +2285,7 @@
     {"canvas_color_view", (void*)gpuCanvasColorView, "(i*~)i", nullptr},
     {"canvas_image", (void*)gpuCanvasImage, "(i)i", nullptr},
     {"canvas_resize", (void*)gpuCanvasResize, "(iii*~)i", nullptr},
+    {"target_view", (void*)gpuTargetView, "(i*~)i", nullptr},
     {"pass_begin", (void*)gpuPassBegin, "(*~*~)i", nullptr},
     {"pass_set_pipeline", (void*)gpuPassSetPipeline, "(ii)", (void*)&wasm_runtime_rive_leaf_native},
     {"pass_set_vertex_buffer", (void*)gpuPassSetVertexBuffer, "(iiii)", (void*)&wasm_runtime_rive_leaf_native},
diff --git a/src/wasm/wasm_natives_web_gen.hpp b/src/wasm/wasm_natives_web_gen.hpp
index b85d3b2..5b27c5e 100644
--- a/src/wasm/wasm_natives_web_gen.hpp
+++ b/src/wasm/wasm_natives_web_gen.hpp
@@ -1110,6 +1110,12 @@
     return gpuCanvasResizeImpl(vm, canvas, width, height, props, propCount);
 }
 EMSCRIPTEN_KEEPALIVE
+uint32_t rive_web_gpu_target_view(uint32_t vmHandle, uint32_t current, uint32_t* props, uint32_t propCount)
+{
+    auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
+    return gpuTargetViewImpl(vm, current, props, propCount);
+}
+EMSCRIPTEN_KEEPALIVE
 uint32_t rive_web_gpu_pass_begin(uint32_t vmHandle, const rive_gpu_pass_desc_v1* desc, uint32_t descByteCount, const rive_gpu_pass_color_attachment_v1* colors, uint32_t colorByteCount)
 {
     auto vm = (WasmScriptingVM*)(uintptr_t)vmHandle;
diff --git a/src/wasm/wasm_scripting_vm.cpp b/src/wasm/wasm_scripting_vm.cpp
index 573c081..d6d6731 100644
--- a/src/wasm/wasm_scripting_vm.cpp
+++ b/src/wasm/wasm_scripting_vm.cpp
@@ -2258,6 +2258,42 @@
                               new HostGpuTextureView{host->colorView});
 }
 
+uint32_t gpuTargetViewImpl(WasmScriptingVM* vm,
+                           uint32_t current,
+                           uint32_t* props,
+                           uint32_t propCount)
+{
+    ore::Context* oreContext = gpuOreContext(vm);
+    // Props stay unwritten where no host could ever expose a target.
+    if (vm == nullptr || oreContext == nullptr || !oreContext->isRecording() ||
+        propCount < 4)
+    {
+        return 0;
+    }
+    rcp<ore::TextureView> view = oreContext->targetView();
+    if (view == nullptr)
+    {
+        props[0] = 0;
+        props[1] = 0;
+        props[2] = (uint32_t)ore::TextureFormat::rgba8unorm;
+        props[3] = 1;
+        return 0;
+    }
+    props[0] = view->width();
+    props[1] = view->height();
+    props[2] = (uint32_t)view->texture()->format();
+    props[3] = view->texture()->sampleCount();
+    auto host = static_cast<HostGpuTextureView*>(vm->handles().resolve(
+        current,
+        WasmScriptingVM::HandleTable::Tag::gpuTextureView));
+    if (host != nullptr && host->view == view)
+    {
+        return current;
+    }
+    return vm->handles().mint(WasmScriptingVM::HandleTable::Tag::gpuTextureView,
+                              new HostGpuTextureView{std::move(view)});
+}
+
 uint32_t gpuFeaturesImpl(WasmScriptingVM* vm, uint32_t* out, uint32_t outCount)
 {
     ore::Context* oreContext = gpuOreContext(vm);
@@ -2349,10 +2385,14 @@
     desc.depthStencil.stencilLoadOp = (ore::LoadOp)podDesc->stencilLoadOp;
     desc.depthStencil.stencilStoreOp = (ore::StoreOp)podDesc->stencilStoreOp;
     desc.depthStencil.stencilClearValue = podDesc->stencilClearValue;
+    oreContext->clearLastError();
     auto pass = ore::cmd::beginRecordedRenderPass(*oreContext, desc);
     if (pass == nullptr)
     {
-        return 0;
+        // A pass refused without an error drops, like one with no target.
+        return oreContext->lastError().empty()
+                   ? 0
+                   : gpuRejected(vm, oreContext, "beginRenderPass");
     }
     return vm->handles().mint(WasmScriptingVM::HandleTable::Tag::gpuPass,
                               new HostGpuPass{std::move(pass)});
@@ -3473,6 +3513,10 @@
 {
     return 0;
 }
+uint32_t gpuTargetViewImpl(WasmScriptingVM*, uint32_t, uint32_t*, uint32_t)
+{
+    return 0;
+}
 uint32_t gpuFeaturesImpl(WasmScriptingVM*, uint32_t*, uint32_t) { return 0; }
 uint32_t gpuPassBeginImpl(WasmScriptingVM*,
                           const rive_gpu_pass_desc_v1*,
diff --git a/src/wasm/web/rive_module_imports.mjs b/src/wasm/web/rive_module_imports.mjs
index 310c34d..3bd33e9 100644
--- a/src/wasm/web/rive_module_imports.mjs
+++ b/src/wasm/web/rive_module_imports.mjs
@@ -528,6 +528,13 @@
                 copyOut(props_p, props, propCount * 4);
                 return ret;
             },
+            target_view: (current, props, propCount) => {
+                stackReset();
+                const props_p = stageIn(props, propCount * 4);
+                const ret = calls.rive_gpu_target_view(current, props_p, propCount);
+                copyOut(props_p, props, propCount * 4);
+                return ret;
+            },
             pass_begin: (desc, descByteCount, colors, colorByteCount) => {
                 stackReset();
                 const desc_p = stageIn(desc, descByteCount);
diff --git a/tests/common/testing_window.hpp b/tests/common/testing_window.hpp
index 3810eff..e18b122 100644
--- a/tests/common/testing_window.hpp
+++ b/tests/common/testing_window.hpp
@@ -382,6 +382,16 @@
     virtual void beginOreFrame() {}
     virtual void endOreFrame() {}
 
+    // Whether scripts may draw into renderTarget() under Rive's content, and
+    // its format, answered without touching it so the recording thread can
+    // declare it.
+    struct OreTarget
+    {
+        bool exposed = false;
+        bool bgra = false;
+    };
+    virtual OreTarget oreTarget() const { return {}; }
+
     // Whether beginFrame through endFrame may run off the thread that made the
     // window. A current GL context or GLFW event polling in endFrame says no.
     virtual bool supportsRenderThread() const { return false; }
diff --git a/tests/common/testing_window_android_vulkan.cpp b/tests/common/testing_window_android_vulkan.cpp
index 620a326..49bdee0 100644
--- a/tests/common/testing_window_android_vulkan.cpp
+++ b/tests/common/testing_window_android_vulkan.cpp
@@ -169,16 +169,7 @@
     {
         auto oreContext =
             static_cast<rive::ore::Context*>(m_renderContext->getOreContext());
-        if (!m_swapchain->isFrameStarted())
-        {
-            VK_ABORT_ON_FAIL(m_swapchain->beginFrame());
-
-            m_renderTarget->setTargetImageView(
-                m_swapchain->currentVkImageView(),
-                m_swapchain->currentVkImage(),
-                m_swapchain->currentLastAccess());
-        }
-
+        beginSwapchainFrame();
         oreContext->beginFrame(
             {.externalCommandBuffer = m_swapchain->currentCommandBuffer(),
              .safeFrameNumber = m_swapchain->safeFrameNumber(),
@@ -280,27 +271,41 @@
                 m_backendParams.clockwise || options.clockwiseFillOverride};
     }
 
+    // Ore and Rive must bind the same image, since scripts draw into it too.
+    void beginSwapchainFrame()
+    {
+        if (m_swapchain->isFrameStarted())
+        {
+            return;
+        }
+        VK_ABORT_ON_FAIL(m_swapchain->beginFrame());
+        if (m_overflowTexture != nullptr)
+        {
+            m_renderTarget->setTargetImageView(m_overflowTexture->vkImageView(),
+                                               m_overflowTexture->vkImage(),
+                                               m_overflowTexture->lastAccess());
+        }
+        else
+        {
+            m_renderTarget->setTargetImageView(
+                m_swapchain->currentVkImageView(),
+                m_swapchain->currentVkImage(),
+                m_swapchain->currentLastAccess());
+        }
+    }
+
+    // An srgb swapchain would not match the unorm format Ore reports.
+    OreTarget oreTarget() const override
+    {
+        VkFormat format = m_swapchain->imageFormat();
+        return {format == VK_FORMAT_R8G8B8A8_UNORM ||
+                    format == VK_FORMAT_B8G8R8A8_UNORM,
+                format == VK_FORMAT_B8G8R8A8_UNORM};
+    }
+
     void flushPLSContext(RenderTarget* offscreenRenderTarget) override
     {
-        if (!m_swapchain->isFrameStarted())
-        {
-            VK_ABORT_ON_FAIL(m_swapchain->beginFrame());
-
-            if (m_overflowTexture != nullptr)
-            {
-                m_renderTarget->setTargetImageView(
-                    m_overflowTexture->vkImageView(),
-                    m_overflowTexture->vkImage(),
-                    m_overflowTexture->lastAccess());
-            }
-            else
-            {
-                m_renderTarget->setTargetImageView(
-                    m_swapchain->currentVkImageView(),
-                    m_swapchain->currentVkImage(),
-                    m_swapchain->currentLastAccess());
-            }
-        }
+        beginSwapchainFrame();
         m_renderContext->flush({
             .renderTarget = offscreenRenderTarget != nullptr
                                 ? offscreenRenderTarget
diff --git a/tests/common/testing_window_fiddle_context.cpp b/tests/common/testing_window_fiddle_context.cpp
index 1852ae0..eaf0294 100644
--- a/tests/common/testing_window_fiddle_context.cpp
+++ b/tests/common/testing_window_fiddle_context.cpp
@@ -556,6 +556,21 @@
         return m_fiddleContext->renderTargetOrNull();
     }
 
+    OreTarget oreTarget() const override
+    {
+        switch (backend())
+        {
+            case Backend::d3d:
+            case Backend::d3d12:
+            case Backend::dawn:
+                return {true, false};
+            case Backend::metal:
+                return {true, true};
+            default:
+                return {};
+        }
+    }
+
     void flushPLSContext(RenderTarget* offscreenRenderTarget) override
     {
         m_fiddleContext->flushPLSContext(offscreenRenderTarget);
diff --git a/tests/common/testing_window_metal_layer.mm b/tests/common/testing_window_metal_layer.mm
index 5772543..3fd410b 100644
--- a/tests/common/testing_window_metal_layer.mm
+++ b/tests/common/testing_window_metal_layer.mm
@@ -108,11 +108,6 @@
             .synthesizedFailureType = options.synthesizedFailureType,
         };
         m_renderContext->beginFrame(frameDescriptor);
-        return std::make_unique<RiveRenderer>(m_renderContext.get());
-    }
-
-    void flushPLSContext(RenderTarget* offscreenRenderTarget) final
-    {
         if (!m_renderTarget || m_renderTarget->width() != m_width ||
             m_renderTarget->height() != m_height)
         {
@@ -123,7 +118,18 @@
         }
         m_renderTarget->setTargetTexture(m_drawable != nil ? m_drawable.texture
                                                            : nil);
+        return std::make_unique<RiveRenderer>(m_renderContext.get());
+    }
 
+    rive::gpu::RenderTarget* renderTarget() const override
+    {
+        return m_renderTarget.get();
+    }
+
+    OreTarget oreTarget() const override { return {true, true}; }
+
+    void flushPLSContext(RenderTarget* offscreenRenderTarget) final
+    {
         // A deferred replay flushes canvases before beginFrame, so the buffer
         // may not exist yet.
         if (m_flushCommandBuffer == nil)
diff --git a/tests/common/testing_window_metal_texture.mm b/tests/common/testing_window_metal_texture.mm
index 1407c68..fca313c 100644
--- a/tests/common/testing_window_metal_texture.mm
+++ b/tests/common/testing_window_metal_texture.mm
@@ -61,32 +61,20 @@
             .synthesizedFailureType = options.synthesizedFailureType,
         };
         m_renderContext->beginFrame(frameDescriptor);
+        // Ore replays into the target before the first flush.
+        ensureRenderTarget();
         return std::make_unique<RiveRenderer>(m_renderContext.get());
     }
 
+    rive::gpu::RenderTarget* renderTarget() const override
+    {
+        return m_renderTarget.get();
+    }
+
+    OreTarget oreTarget() const override { return {true, true}; }
+
     void flushPLSContext(RenderTarget* offscreenRenderTarget) final
     {
-        if (!m_renderTarget || m_renderTarget->height() != m_height ||
-            m_renderTarget->width() != m_width)
-        {
-            m_renderTarget =
-                m_renderContext->static_impl_cast<RenderContextMetalImpl>()
-                    ->makeRenderTarget(
-                        MTLPixelFormatBGRA8Unorm, m_width, m_height);
-            MTLTextureDescriptor* desc = [[MTLTextureDescriptor alloc] init];
-            desc.pixelFormat = MTLPixelFormatBGRA8Unorm;
-            desc.width = m_width;
-            desc.height = m_height;
-            desc.usage = MTLTextureUsageRenderTarget;
-            desc.textureType = MTLTextureType2D;
-            desc.mipmapLevelCount = 1;
-            desc.storageMode = MTLStorageModePrivate;
-            m_renderTarget->setTargetTexture(
-                [m_gpu newTextureWithDescriptor:desc]);
-            m_pixelReadBuff =
-                [m_gpu newBufferWithLength:m_height * m_width * 4
-                                   options:MTLResourceStorageModeShared];
-        }
         // A deferred replay flushes canvases before beginFrame, so the buffer
         // may not exist yet.
         if (m_flushCommandBuffer == nil)
@@ -194,6 +182,31 @@
     }
 
 private:
+    void ensureRenderTarget()
+    {
+        if (!m_renderTarget || m_renderTarget->height() != m_height ||
+            m_renderTarget->width() != m_width)
+        {
+            m_renderTarget =
+                m_renderContext->static_impl_cast<RenderContextMetalImpl>()
+                    ->makeRenderTarget(
+                        MTLPixelFormatBGRA8Unorm, m_width, m_height);
+            MTLTextureDescriptor* desc = [[MTLTextureDescriptor alloc] init];
+            desc.pixelFormat = MTLPixelFormatBGRA8Unorm;
+            desc.width = m_width;
+            desc.height = m_height;
+            desc.usage = MTLTextureUsageRenderTarget;
+            desc.textureType = MTLTextureType2D;
+            desc.mipmapLevelCount = 1;
+            desc.storageMode = MTLStorageModePrivate;
+            m_renderTarget->setTargetTexture(
+                [m_gpu newTextureWithDescriptor:desc]);
+            m_pixelReadBuff =
+                [m_gpu newBufferWithLength:m_height * m_width * 4
+                                   options:MTLResourceStorageModeShared];
+        }
+    }
+
     id<MTLDevice> m_gpu = MTLCreateSystemDefaultDevice();
     id<MTLCommandQueue> m_queue = [m_gpu newCommandQueue];
     std::unique_ptr<RenderContext> m_renderContext;
diff --git a/tests/common/testing_window_sink.hpp b/tests/common/testing_window_sink.hpp
index 6163c2d..69ab0b5 100644
--- a/tests/common/testing_window_sink.hpp
+++ b/tests/common/testing_window_sink.hpp
@@ -38,11 +38,22 @@
     rive::Renderer* beginScreenFrame(uint64_t target) override
     {
         assert(target == 0);
-        m_screen = TestingWindow::Get()->beginFrame(m_options);
+        FrameOptions options = m_options;
+        options.doClear = options.doClear && !m_targetPreserved;
+        m_screen = TestingWindow::Get()->beginFrame(options);
         return m_screen.get();
     }
     void beginOreFrame() override { TestingWindow::Get()->beginOreFrame(); }
     void endOreFrame() override { TestingWindow::Get()->endOreFrame(); }
+    rive::gpu::RenderTarget* targetRenderTarget() override
+    {
+        TestingWindow* window = TestingWindow::Get();
+        return window->oreTarget().exposed ? window->renderTarget() : nullptr;
+    }
+    void setTargetPreserved(bool preserved) override
+    {
+        m_targetPreserved = preserved;
+    }
 
     rive::Renderer* beginCanvasContent(rive::gpu::RenderCanvas* canvas,
                                        uint32_t clearColor) override
@@ -82,6 +93,7 @@
     std::unique_ptr<rive::Renderer> m_screen;
     std::unique_ptr<rive::RiveRenderer> m_canvasRenderer;
     rive::gpu::RenderCanvas* m_activeCanvas = nullptr;
+    bool m_targetPreserved = false;
 };
 
 #endif // RIVE_CANVAS
diff --git a/tests/common/testing_window_vulkan_texture.cpp b/tests/common/testing_window_vulkan_texture.cpp
index b19ad6b..0693993 100644
--- a/tests/common/testing_window_vulkan_texture.cpp
+++ b/tests/common/testing_window_vulkan_texture.cpp
@@ -103,6 +103,17 @@
         return m_renderContext.get();
     }
 
+    rive::gpu::RenderTarget* renderTarget() const override
+    {
+        return m_renderTarget.get();
+    }
+
+    // An srgb view would not match the unorm format Ore reports.
+    OreTarget oreTarget() const override
+    {
+        return {!m_backendParams.srgb, !m_backendParams.core};
+    }
+
     rcp<rive_tests::OffscreenRenderTarget> makeOffscreenRenderTarget(
         uint32_t width,
         uint32_t height,
diff --git a/tests/gm/gmmain.cpp b/tests/gm/gmmain.cpp
index fb7439e..6ebd0df 100644
--- a/tests/gm/gmmain.cpp
+++ b/tests/gm/gmmain.cpp
@@ -262,6 +262,7 @@
     MAKE_GM(ore_pipeline_switch)
     MAKE_GM(ore_buffer_offsets)
     MAKE_GM(ore_mip_render_target)
+    MAKE_GM(ore_deferred_target)
     MAKE_PARITY_GM3(ore_deferred_replay,
                     ore_deferred_replay_immediate,
                     ore_deferred_replay,
@@ -283,6 +284,9 @@
     MAKE_PARITY_GM2(render_deferred_canvas,
                     render_deferred_canvas_immediate,
                     render_deferred_canvas)
+    MAKE_PARITY_GM2(ore_deferred_target_parity,
+                    ore_deferred_target_immediate,
+                    ore_deferred_target)
 #endif
 #endif
     // 2D only so these are not gated on Ore.
diff --git a/tests/gm/ore_deferred_target.cpp b/tests/gm/ore_deferred_target.cpp
new file mode 100644
index 0000000..2fa4096
--- /dev/null
+++ b/tests/gm/ore_deferred_target.cpp
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2026 Rive
+ *
+ * Ore clears Rive's own render target and draws a triangle into it, then Rive
+ * draws a path on top, immediately and through a DeferredSession whose target
+ * view is wrapped at replay. The goldens must be byte identical and show both.
+ */
+
+#include "gm.hpp"
+#include "gmutils.hpp"
+#include "ore_gm_sink.hpp"
+#if ORE_GM_HAS_BACKEND
+#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/cmd/deferred_session.hpp"
+#endif
+
+using namespace rivegm;
+using namespace rive;
+using namespace rive::gpu;
+
+class OreDeferredTargetGM : public GM
+{
+public:
+    OreDeferredTargetGM(bool deferred) : GM(256, 256), m_deferred(deferred) {}
+
+    // The first Ore pass covers the target, so Rive must not clear over it.
+    void updateFrameOptions(TestingWindow::FrameOptions* options) const override
+    {
+        options->doClear = false;
+    }
+
+    void onDraw(rive::Renderer* originalRenderer) override
+    {
+        auto renderContext = TestingWindow::Get()->renderContext();
+        TestingWindow* window = TestingWindow::Get();
+        bool exposed = renderContext != nullptr &&
+                       m_ore.ensureContext(renderContext) &&
+                       window->oreTarget().exposed;
+#if ORE_GM_HAS_BACKEND
+        if (exposed)
+        {
+            if (m_deferred)
+            {
+                drawDeferred(renderContext, originalRenderer);
+            }
+            else
+            {
+                drawImmediate(renderContext, originalRenderer);
+            }
+            return;
+        }
+#endif
+        // Keep the frame defined where no target is exposed.
+        drawBackground(*window->factory(), originalRenderer);
+        drawPath(*window->factory(), originalRenderer);
+    }
+
+private:
+    static void drawBackground(Factory& factory, Renderer* renderer)
+    {
+        auto paint = factory.makeRenderPaint();
+        paint->color(0xff1ab38c);
+        // Overshoots the frame since Rive loads it, and a rasterizer that
+        // leaves the edge short of full coverage would blend the last frame.
+        auto path = factory.makeEmptyRenderPath();
+        path->moveTo(-1, -1);
+        path->lineTo(257, -1);
+        path->lineTo(257, 257);
+        path->lineTo(-1, 257);
+        path->close();
+        renderer->drawPath(path.get(), paint.get());
+    }
+
+    static void drawPath(Factory& factory, Renderer* renderer)
+    {
+        auto paint = factory.makeRenderPaint();
+        paint->color(0xc0e02040);
+        auto path = factory.makeEmptyRenderPath();
+        path->moveTo(128, 96);
+        path->lineTo(224, 160);
+        path->lineTo(128, 224);
+        path->lineTo(32, 160);
+        path->close();
+        renderer->drawPath(path.get(), paint.get());
+    }
+
+#if ORE_GM_HAS_BACKEND
+    // Clears the whole target, as the contract asks of the first pass.
+    void recordTriangle(ore::Context& ctx, ore::TextureView* target)
+    {
+        auto shader = ore_gm::loadShader(ctx, ore_gm::kTriangle);
+        if (!shader.vsModule)
+        {
+            return;
+        }
+        ore::BufferDesc bd{};
+        bd.usage = ore::BufferUsage::vertex;
+        bd.size = sizeof(ore_gm::kTriVertices);
+        bd.data = ore_gm::kTriVertices;
+        bd.label = "ore_deferred_target_vb";
+        auto vb = ctx.makeBuffer(bd);
+        ore_gm::TrianglePipeline tri(shader,
+                                     target->texture()->format(),
+                                     "ore_deferred_target_pipeline");
+        auto pipeline = ctx.makePipeline(tri.desc);
+        if (!vb || !pipeline)
+        {
+            return;
+        }
+
+        ore::ColorAttachment ca{};
+        ca.view = target;
+        ca.loadOp = ore::LoadOp::clear;
+        ca.storeOp = ore::StoreOp::store;
+        ca.clearColor = {0.10f, 0.70f, 0.55f, 1.0f};
+        ore::RenderPassDesc rp{};
+        rp.colorAttachments[0] = ca;
+        rp.colorCount = 1;
+        rp.label = "ore_deferred_target_pass";
+        auto pass = ctx.beginRenderPass(rp);
+        pass->setPipeline(pipeline.get());
+        pass->setVertexBuffer(0, vb.get());
+        pass->setViewport(0, 0, 256, 256);
+        pass->draw(3);
+        pass->finish();
+    }
+
+    void drawImmediate(RenderContext* renderContext, Renderer* renderer)
+    {
+        auto& realCtx = *renderContext->getOreContext();
+        m_ore.beginFrame(renderContext);
+        auto target =
+            realCtx.wrapRenderTarget(TestingWindow::Get()->renderTarget());
+        if (target != nullptr)
+        {
+            recordTriangle(realCtx, target.get());
+        }
+        m_ore.endFrame(renderContext);
+        ore_gm::invalidateGLStateAfterOre(renderContext);
+        Factory& factory = *TestingWindow::Get()->factory();
+        if (target == nullptr)
+        {
+            // Rive loads the target, so an unwrapped one must still be covered.
+            drawBackground(factory, renderer);
+        }
+        drawPath(factory, renderer);
+    }
+
+    void drawDeferred(RenderContext* renderContext, Renderer* renderer)
+    {
+        auto& realCtx = *renderContext->getOreContext();
+        TestingWindow* window = TestingWindow::Get();
+        rive::cmd::DeferredSession session(ore::ReplayCaps::from(realCtx));
+        session.oreContext().setTarget(
+            ore::Context::TargetDesc::color8(window->width(),
+                                             window->height(),
+                                             window->oreTarget().bgra));
+        auto target = session.oreContext().targetView();
+        recordTriangle(session.oreContext(), target.get());
+        session.recordOreReplayMarker();
+
+        auto screen = session.makeScreenRenderer();
+        drawPath(session, screen.get());
+
+        rive::cmd::DeferredFrame frame = rive::cmd::snapshotFrame(session);
+        ore_gm::GMFrameSink sink(renderContext,
+                                 renderer,
+                                 &m_ore,
+                                 window->renderTarget());
+        rive::cmd::DeferredReplayer replayer;
+        replayer.replayFrame(frame, sink);
+    }
+#endif
+
+    bool m_deferred;
+    ore_gm::OreGMContext m_ore;
+};
+
+GMREGISTER(ore_deferred_target_immediate, return new OreDeferredTargetGM(false))
+GMREGISTER(ore_deferred_target, return new OreDeferredTargetGM(true))
diff --git a/tests/gm/ore_deferred_target.mm b/tests/gm/ore_deferred_target.mm
new file mode 100644
index 0000000..ab0ea16
--- /dev/null
+++ b/tests/gm/ore_deferred_target.mm
@@ -0,0 +1,2 @@
+// Obj-C++ wrapper — ore headers pull in <Metal/Metal.h> on Apple.
+#include "ore_deferred_target.cpp"
diff --git a/tests/gm/ore_gm_sink.hpp b/tests/gm/ore_gm_sink.hpp
new file mode 100644
index 0000000..8cf34e1
--- /dev/null
+++ b/tests/gm/ore_gm_sink.hpp
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+#pragma once
+
+#include "ore_gm_helper.hpp"
+#if ORE_GM_HAS_BACKEND
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+
+#include <cassert>
+
+namespace ore_gm
+{
+// The GM is handed an already open screen renderer, so beginScreenFrame just
+// returns it.
+class GMFrameSink : public rive::cmd::DeferredFrameSink
+{
+public:
+    GMFrameSink(rive::gpu::RenderContext* rc,
+                rive::Renderer* screen,
+                OreGMContext* oreCtx,
+                rive::gpu::RenderTarget* target = nullptr) :
+        m_rc(rc), m_screen(screen), m_ore(oreCtx), m_target(target)
+    {}
+    rive::Factory* factory() override
+    {
+        return TestingWindow::Get()->factory();
+    }
+    rive::gpu::RenderContext* renderContext() override { return m_rc; }
+    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 { invalidateGLStateAfterOre(m_rc); }
+    rive::gpu::RenderTarget* targetRenderTarget() override { return m_target; }
+
+private:
+    rive::gpu::RenderContext* m_rc;
+    rive::Renderer* m_screen;
+    OreGMContext* m_ore;
+    rive::gpu::RenderTarget* m_target;
+};
+} // namespace ore_gm
+#endif
diff --git a/tests/gm/ore_render_deferred_canvas.cpp b/tests/gm/ore_render_deferred_canvas.cpp
index e1a2316..3883d7e 100644
--- a/tests/gm/ore_render_deferred_canvas.cpp
+++ b/tests/gm/ore_render_deferred_canvas.cpp
@@ -9,13 +9,12 @@
 
 #include "gm.hpp"
 #include "gmutils.hpp"
-#include "ore_gm_helper.hpp"
+#include "ore_gm_sink.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;
@@ -24,38 +23,6 @@
 #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();
-    }
-    rive::gpu::RenderContext* renderContext() override { return m_rc; }
-    // 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
@@ -129,7 +96,7 @@
 
             // Snapshot replay is the same path a threaded consumer takes.
             rive::cmd::DeferredFrame frame = rive::cmd::snapshotFrame(session);
-            GMCanvasSink sink(renderContext, originalRenderer, &m_ore);
+            ore_gm::GMFrameSink sink(renderContext, originalRenderer, &m_ore);
             rive::cmd::DeferredReplayer replayer;
             replayer.replayFrame(frame, sink);
         }
diff --git a/tests/player/player.cpp b/tests/player/player.cpp
index bab6b9a..80df1aa 100644
--- a/tests/player/player.cpp
+++ b/tests/player/player.cpp
@@ -438,6 +438,7 @@
                 // Bound before import so registration scripts that reach for
                 // the device find it, like a real host.
                 m_session->bindRenderContext(rc);
+                declareTarget();
                 m_replayer = std::make_unique<rive::cmd::DeferredReplayer>();
                 m_factory = m_session.get();
             }
@@ -565,6 +566,20 @@
     m_stateMachine->submitGamepadsFromBuffer(buffer.data(), buffer.size());
 }
 
+#ifdef RIVE_CANVAS
+void Player::declareTarget()
+{
+    TestingWindow* window = TestingWindow::Get();
+    TestingWindow::OreTarget target = window->oreTarget();
+    m_session->oreContext().setTarget(
+        target.exposed
+            ? rive::ore::Context::TargetDesc::color8(window->width(),
+                                                     window->height(),
+                                                     target.bgra)
+            : rive::ore::Context::TargetDesc{});
+}
+#endif
+
 void Player::presentFrame(FrameJob& job)
 {
 #ifdef RIVE_CANVAS
@@ -679,6 +694,7 @@
 #ifdef RIVE_CANVAS
     if (m_session != nullptr)
     {
+        declareTarget();
         m_session->recordOreReplayMarker();
         renderer = m_session->makeScreenRenderer();
     }
diff --git a/tests/player/player.hpp b/tests/player/player.hpp
index 174b5d2..45ff3c8 100644
--- a/tests/player/player.hpp
+++ b/tests/player/player.hpp
@@ -113,6 +113,8 @@
     std::unique_ptr<rive::cmd::DeferredSession> m_session;
     std::unique_ptr<rive::cmd::DeferredReplayer> m_replayer;
     uint32_t m_lastDroppedDraws = 0;
+    // Tells the recording what target scripts may render into.
+    void declareTarget();
 #endif
 
     std::string m_rivName;
diff --git a/tests/unit_tests/renderer/ore_deferred_target_test.cpp b/tests/unit_tests/renderer/ore_deferred_target_test.cpp
new file mode 100644
index 0000000..5860ef1
--- /dev/null
+++ b/tests/unit_tests/renderer/ore_deferred_target_test.cpp
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2026 Rive
+ */
+
+// The host's render target reaches a recording only as plain values; the real
+// target is wrapped at replay. These pin down what the recording writes for it
+// and when the replayer keeps Rive from clearing over it. GPU free.
+
+#include "rive/renderer/cmd/deferred_replayer.hpp"
+#include "rive/renderer/cmd/deferred_session.hpp"
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+#include "deferred_test_sink.hpp"
+
+#include <catch.hpp>
+#include <memory>
+#include <vector>
+
+using namespace rive;
+using namespace rive::ore;
+using namespace rive::ore::cmd;
+
+namespace
+{
+Context::TargetDesc target(uint32_t width,
+                           uint32_t height,
+                           TextureFormat format = TextureFormat::rgba8unorm,
+                           uint32_t sampleCount = 1)
+{
+    return {width, height, format, sampleCount};
+}
+
+// The target wraps this frame's stream records, in order.
+std::vector<WrapCanvasViewPOD> targetWraps(const DeferredOreContext& ctx)
+{
+    std::vector<WrapCanvasViewPOD> wraps;
+    OreCommandReader reader(ctx.stream().commandBytes(),
+                            ctx.stream().blobBytes());
+    CommandType type;
+    while (reader.next(type))
+    {
+        switch (type)
+        {
+            case CommandType::wrapCanvasView:
+            {
+                auto pod = reader.read<WrapCanvasViewPOD>();
+                if (pod.mode ==
+                    static_cast<uint32_t>(WrapCanvasViewMode::targetView))
+                {
+                    wraps.push_back(pod);
+                }
+                break;
+            }
+            case CommandType::beginRenderPass:
+                reader.read<BeginRenderPassCmd>();
+                break;
+            case CommandType::finish:
+                break;
+            case CommandType::destroyResource:
+                reader.read<DestroyResourcePOD>();
+                break;
+            default:
+                FAIL("unexpected command in a target only stream");
+        }
+    }
+    return wraps;
+}
+
+bool drawInto(Context& ctx, TextureView* view)
+{
+    RenderPassDesc rp{};
+    rp.colorAttachments[0].view = view;
+    rp.colorAttachments[0].loadOp = LoadOp::clear;
+    rp.colorAttachments[0].storeOp = StoreOp::store;
+    rp.colorCount = 1;
+    auto pass = ctx.beginRenderPass(rp);
+    if (pass == nullptr)
+    {
+        return false;
+    }
+    pass->finish();
+    return true;
+}
+
+ResourceHandle idOf(const rcp<TextureView>& view)
+{
+    return static_cast<DeferredTextureView*>(view.get())->clientHandle();
+}
+
+class SizedTarget : public gpu::RenderTarget
+{
+public:
+    SizedTarget(uint32_t width, uint32_t height) :
+        gpu::RenderTarget(width, height)
+    {}
+};
+
+// Records what the replayer tells the host about its target each frame.
+class PreserveSink : public deferred_test::TestSink
+{
+public:
+    std::vector<bool> preserved;
+    bool hasOre = true;
+    uint32_t screenWidth = 64;
+    void setTargetPreserved(bool value) override { preserved.push_back(value); }
+    Context* oreContext() override { return hasOre ? &m_ore : nullptr; }
+    gpu::RenderTarget* targetRenderTarget() override
+    {
+        m_screen = std::make_unique<SizedTarget>(screenWidth, 64);
+        return m_screen.get();
+    }
+
+private:
+    DeferredOreContext m_ore{nullptr};
+    std::unique_ptr<SizedTarget> m_screen;
+};
+} // namespace
+
+TEST_CASE("a target view claims its replay slot when it is made",
+          "[ore][cmd][target]")
+{
+    DeferredOreContext ctx(nullptr);
+    ctx.setTarget(target(64, 32));
+
+    auto view = ctx.targetView();
+    REQUIRE(view != nullptr);
+    CHECK(view->width() == 64u);
+    CHECK(view->height() == 32u);
+
+    auto wraps = targetWraps(ctx);
+    REQUIRE(wraps.size() == 1);
+    CHECK(wraps[0].id == idOf(view));
+
+    // Asking again hands back the same view without claiming another slot.
+    CHECK(ctx.targetView() == view);
+    CHECK(targetWraps(ctx).size() == 1);
+}
+
+TEST_CASE("a frame wraps the target once however many passes draw into it",
+          "[ore][cmd][target]")
+{
+    DeferredOreContext ctx(nullptr);
+    ctx.setTarget(target(64, 64));
+    auto view = ctx.targetView();
+
+    // The wrap made with the view serves the frame it was made in.
+    for (int i = 0; i < 3; ++i)
+    {
+        REQUIRE(drawInto(ctx, view.get()));
+    }
+    CHECK(targetWraps(ctx).size() == 1);
+    CHECK(ctx.targetDrawn());
+
+    ctx.resetFrame();
+    for (int i = 0; i < 3; ++i)
+    {
+        REQUIRE(drawInto(ctx, view.get()));
+    }
+    auto wraps = targetWraps(ctx);
+    REQUIRE(wraps.size() == 1);
+    CHECK(wraps[0].id == idOf(view));
+    CHECK(ctx.targetDrawn());
+
+    // A frame that leaves the target alone neither wraps nor draws it.
+    ctx.resetFrame();
+    CHECK(ctx.targetView() == view);
+    CHECK(targetWraps(ctx).empty());
+    CHECK_FALSE(ctx.targetDrawn());
+}
+
+TEST_CASE("a target changing size or format mints a new view",
+          "[ore][cmd][target]")
+{
+    DeferredOreContext ctx(nullptr);
+    ctx.setTarget(target(64, 64));
+    auto first = ctx.targetView();
+
+    ctx.setTarget(target(64, 64));
+    CHECK(ctx.targetView() == first);
+
+    ctx.setTarget(target(128, 64));
+    auto resized = ctx.targetView();
+    REQUIRE(resized != nullptr);
+    CHECK(resized != first);
+    CHECK(resized->width() == 128u);
+
+    ctx.setTarget(target(128, 64, TextureFormat::bgra8unorm, 4));
+    auto reformatted = ctx.targetView();
+    REQUIRE(reformatted != nullptr);
+    CHECK(reformatted != resized);
+    CHECK(reformatted->texture()->format() == TextureFormat::bgra8unorm);
+    CHECK(reformatted->texture()->sampleCount() == 4u);
+}
+
+TEST_CASE("with no target declared there is no view and passes drop",
+          "[ore][cmd][target]")
+{
+    DeferredOreContext ctx(nullptr);
+    CHECK(ctx.targetView() == nullptr);
+
+    ctx.setTarget(target(64, 64));
+    auto stale = ctx.targetView();
+    REQUIRE(stale != nullptr);
+    ctx.resetFrame();
+
+    ctx.setTarget({});
+    CHECK(ctx.targetView() == nullptr);
+    // A view kept from an earlier frame names an image that may be gone.
+    CHECK_FALSE(drawInto(ctx, stale.get()));
+    CHECK(targetWraps(ctx).empty());
+    CHECK_FALSE(ctx.targetDrawn());
+
+    // The same target coming back keeps its view.
+    ctx.setTarget(target(64, 64));
+    CHECK(ctx.targetView() == stale);
+    CHECK(drawInto(ctx, stale.get()));
+
+    // Replaced by a new target, the old view is refused.
+    ctx.resetFrame();
+    ctx.setTarget(target(128, 64));
+    REQUIRE(ctx.targetView() != nullptr);
+    CHECK_FALSE(drawInto(ctx, stale.get()));
+    CHECK_FALSE(ctx.lastError().empty());
+}
+
+TEST_CASE("replay keeps the target only in frames a script drew into it",
+          "[ore][cmd][target]")
+{
+    rive::cmd::DeferredSession session(ReplayCaps{});
+    auto& ctx = session.oreContext();
+    ctx.setTarget(target(64, 64));
+    auto view = ctx.targetView();
+
+    PreserveSink sink;
+    rive::cmd::DeferredReplayer replayer;
+
+    REQUIRE(drawInto(ctx, view.get()));
+    replayer.replayFrame(rive::cmd::snapshotFrame(session), sink);
+
+    session.resetFrame();
+    replayer.replayFrame(rive::cmd::snapshotFrame(session), sink);
+
+    session.resetFrame();
+    REQUIRE(drawInto(ctx, view.get()));
+    replayer.replayFrame(rive::cmd::snapshotFrame(session), sink);
+
+    CHECK(sink.preserved == std::vector<bool>{true, false, true});
+}
+
+TEST_CASE("replay clears the target when no Ore context can draw it",
+          "[ore][cmd][target]")
+{
+    rive::cmd::DeferredSession session(ReplayCaps{});
+    auto& ctx = session.oreContext();
+    ctx.setTarget(target(64, 64));
+    auto view = ctx.targetView();
+
+    PreserveSink sink;
+    sink.hasOre = false;
+    rive::cmd::DeferredReplayer replayer;
+
+    REQUIRE(drawInto(ctx, view.get()));
+    replayer.replayFrame(rive::cmd::snapshotFrame(session), sink);
+
+    CHECK(sink.preserved == std::vector<bool>{false});
+}
+
+TEST_CASE("replay clears a target resized since the script drew into it",
+          "[ore][cmd][target]")
+{
+    rive::cmd::DeferredSession session(ReplayCaps{});
+    auto& ctx = session.oreContext();
+    ctx.setTarget(target(64, 64));
+    auto view = ctx.targetView();
+    REQUIRE(drawInto(ctx, view.get()));
+    rive::cmd::DeferredFrame frame = rive::cmd::snapshotFrame(session);
+
+    PreserveSink sink;
+    sink.screenWidth = 128;
+    rive::cmd::DeferredReplayer replayer;
+    replayer.replayFrame(frame, sink);
+
+    CHECK(sink.preserved == std::vector<bool>{false});
+}
+
+TEST_CASE("the target view cannot be sampled", "[ore][cmd][target]")
+{
+    DeferredOreContext ctx(nullptr);
+    ctx.setTarget(target(64, 64));
+    auto view = ctx.targetView();
+    REQUIRE(view != nullptr);
+
+    BindGroupDesc bg{};
+    BindGroupDesc::TexEntry tex{};
+    tex.view = view.get();
+    bg.textures = &tex;
+    bg.textureCount = 1;
+    CHECK(ctx.makeBindGroup(bg) == nullptr);
+    CHECK_FALSE(ctx.lastError().empty());
+}
diff --git a/tests/unit_tests/runtime/scripting/scripting_gpu_target_test.cpp b/tests/unit_tests/runtime/scripting/scripting_gpu_target_test.cpp
new file mode 100644
index 0000000..3a56ccb
--- /dev/null
+++ b/tests/unit_tests/runtime/scripting/scripting_gpu_target_test.cpp
@@ -0,0 +1,203 @@
+#include "catch.hpp"
+#include "scripting_test_utilities.hpp"
+#include "rive/lua/rive_lua_libs.hpp"
+#include "utils/no_op_factory.hpp"
+
+#if defined(RIVE_CANVAS) && defined(RIVE_ORE)
+#include "rive/renderer/ore/cmd/ore_deferred_context.hpp"
+
+using namespace rive;
+
+namespace
+{
+// Records into a real deferred Ore context, the one a host session hands
+// scripts, with no device behind it.
+class TargetFactory : public NoOpFactory
+{
+public:
+    ore::cmd::DeferredOreContext oreContext{ore::ReplayCaps{}};
+    ore::Context* ore() override { return &oreContext; }
+};
+
+bool runInit(ScriptingTest& vm)
+{
+    ScriptedObjectTest scriptedObjectTest;
+    lua_State* L = vm.state();
+    lua_getglobal(L, "init");
+    lua_pushvalue(L, -2);
+    lua_newrive<ScriptedContext>(L, &scriptedObjectTest);
+    if (lua_pcall(L, 2, 1, 0) != LUA_OK)
+    {
+        FAIL(lua_tostring(L, -1));
+    }
+    return lua_toboolean(L, -1);
+}
+} // namespace
+
+TEST_CASE("context:gpuTarget is nil without a recording context", "[scripting]")
+{
+    NoOpFactory factory;
+    ScriptingTest vm(R"(
+function init(self, context)
+  return context:gpuTarget() == nil
+end
+)",
+                     1,
+                     false,
+                     {},
+                     true,
+                     &factory);
+    CHECK(runInit(vm));
+}
+
+TEST_CASE("context:gpuTarget is there in init before a target is declared",
+          "[scripting]")
+{
+    TargetFactory factory;
+    ScriptingTest vm(R"(
+function init(self, context)
+  local target = context:gpuTarget()
+  return target ~= nil and target.view == nil and target.width == 0
+end
+)",
+                     1,
+                     false,
+                     {},
+                     true,
+                     &factory);
+    CHECK(runInit(vm));
+}
+
+TEST_CASE("a hidden target still validates the pass it drops", "[scripting]")
+{
+    TargetFactory factory;
+    ScriptingTest vm(R"(
+function init(self, context)
+  local target = context:gpuTarget()
+  target:beginRenderPass({ color = {{ storeOp = 'store' }} }):finish()
+  local ok = pcall(function()
+    target:beginRenderPass({ color = {{ loadOp = 'clear' }} })
+  end)
+  return not ok
+end
+)",
+                     1,
+                     false,
+                     {},
+                     true,
+                     &factory);
+    CHECK(runInit(vm));
+    CHECK_FALSE(factory.oreContext.targetDrawn());
+}
+
+TEST_CASE("a script reads the target and draws into it", "[scripting]")
+{
+    TargetFactory factory;
+    factory.oreContext.setTarget({64, 32, ore::TextureFormat::bgra8unorm, 4});
+    ScriptingTest vm(R"(
+function init(self, context)
+  local target = context:gpuTarget()
+  assert(target.width == 64 and target.height == 32)
+  assert(target.format == 'bgra8unorm' and target.sampleCount == 4)
+  assert(target.view ~= nil)
+  local pass = target:beginRenderPass({
+    color = {{ loadOp = 'clear', storeOp = 'store' }},
+  })
+  pass:finish()
+  return true
+end
+)",
+                     1,
+                     false,
+                     {},
+                     true,
+                     &factory);
+    CHECK(runInit(vm));
+    CHECK(factory.oreContext.targetDrawn());
+}
+
+TEST_CASE("a pass on a frame without a target drops its commands",
+          "[scripting]")
+{
+    TargetFactory factory;
+    factory.oreContext.setTarget({64, 64});
+    ScriptingTest vm(R"(
+local target
+function init(self, context)
+  target = context:gpuTarget()
+  return target ~= nil
+end
+function drawLater()
+  local pass = target:beginRenderPass({ color = {{ storeOp = 'store' }} })
+  pass:setViewport(0, 0, 1, 1)
+  pass:finish()
+  return target.width == 0 and target.view == nil
+end
+)",
+                     1,
+                     false,
+                     {},
+                     true,
+                     &factory);
+    REQUIRE(runInit(vm));
+
+    factory.oreContext.resetFrame();
+    factory.oreContext.setTarget({});
+    lua_State* L = vm.state();
+    lua_getglobal(L, "drawLater");
+    REQUIRE(lua_pcall(L, 0, 1, 0) == LUA_OK);
+    CHECK(lua_toboolean(L, -1));
+    CHECK_FALSE(factory.oreContext.targetDrawn());
+}
+
+TEST_CASE("a cached target view drops while hidden and errors once replaced",
+          "[scripting]")
+{
+    TargetFactory factory;
+    factory.oreContext.setTarget({64, 64});
+    ScriptingTest vm(R"(
+local target, view
+function init(self, context)
+  target = context:gpuTarget()
+  view = target.view
+  return view ~= nil
+end
+function drawCached()
+  target:beginRenderPass({ color = {{ view = view, storeOp = 'store' }} })
+    :finish()
+end
+)",
+                     1,
+                     false,
+                     {},
+                     true,
+                     &factory);
+    REQUIRE(runInit(vm));
+    lua_State* L = vm.state();
+    auto drawCached = [&]() {
+        lua_getglobal(L, "drawCached");
+        bool ok = lua_pcall(L, 0, 0, 0) == LUA_OK;
+        if (!ok)
+        {
+            lua_pop(L, 1);
+        }
+        return ok;
+    };
+
+    // Hidden for a frame, the same target comes back with the same view.
+    factory.oreContext.resetFrame();
+    factory.oreContext.setTarget({});
+    CHECK(drawCached());
+    CHECK_FALSE(factory.oreContext.targetDrawn());
+
+    factory.oreContext.resetFrame();
+    factory.oreContext.setTarget({64, 64});
+    CHECK(drawCached());
+    CHECK(factory.oreContext.targetDrawn());
+
+    // A resized target retires the view, which a pass now reports.
+    factory.oreContext.resetFrame();
+    factory.oreContext.setTarget({128, 64});
+    CHECK_FALSE(drawCached());
+}
+#endif