diff --git a/docs/rendergraph.md b/docs/rendergraph.md
new file mode 100644
index 00000000..8923f7db
--- /dev/null
+++ b/docs/rendergraph.md
@@ -0,0 +1,1382 @@
+# Render Graph
+
+An immediate-mode frame scheduler for WebGPU (`webgpu/base/RenderGraph.h`, namespace
+`webgpu::rg`). Each frame you declare the resources and passes the frame needs. The graph
+compiles them into an execution order, manages resource lifetimes, and records the passes into
+your command encoder.
+
+The graph owns two things: pass ordering and resource lifetime. It is not a GPU-API wrapper.
+Pass bodies call `wgpu*` functions directly.
+
+> The header [`webgpu/base/RenderGraph.h`](../webgpu/base/RenderGraph.h) is the per-call
+> reference: every public declaration carries its parameters, defaults, and constraints. This
+> document covers how the pieces fit together, with worked examples. It does not restate the
+> signatures.
+
+## Contents
+
+- [What it is](#what-it-is)
+- [The frame loop](#the-frame-loop)
+- [Resources](#resources)
+- [Passes](#passes)
+- [The two rules](#the-two-rules)
+- [Ordering and culling](#ordering-and-culling)
+- [Pass groups](#pass-groups)
+- [Views and ViewRange](#views-and-viewrange)
+- [Buffers](#buffers)
+- [Attachments](#attachments)
+- [Compute passes](#compute-passes)
+- [Transfer passes and copies](#transfer-passes-and-copies)
+- [initialize() and hash-gated rebakes](#initialize-and-hash-gated-rebakes)
+- [Examples](#examples)
+- [Debugging](#debugging)
+
+---
+
+## What it is
+
+Three concepts carry the whole API.
+
+A resource is a GPU texture or buffer the graph knows about. Most resources are transient:
+declared fresh every frame, allocated from a pool, and possibly aliased onto the same GPU
+memory as another transient whose lifetime does not overlap. External objects such as the
+swapchain are imported instead.
+
+A handle is what declaring a resource returns. `ResourceHandle` is a small value type that
+names the resource for this frame. Handles are cheap to copy. They are what you pass between
+systems and capture into pass lambdas, and they never carry the GPU object itself: the graph
+resolves a handle to a real `WGPUTexture` or `WGPUBuffer` only during execution.
+
+Creators return the kind-tagged `TextureHandle` or `BufferHandle`, and the declarations take
+those, so handing a buffer to `sampled()` fails to compile rather than tripping an assert. Both
+convert to `ResourceHandle` for the calls that accept either, `bind()` among them, but never the
+other way round. Store handles in their typed form, and prefer `auto` for locals.
+
+A pass is a unit of GPU work declared with `add_pass`. It names every resource it reads and
+writes, and the graph derives ordering from those declarations. A pass whose output nothing
+consumes is culled, unless it is marked `force_keep()`.
+
+`add_pass` takes two lambdas, and the split between them is the core of the API. Setup runs
+immediately, at record time. It receives a `PassBuilder&` and declares accesses: attachments,
+sampled textures, storage buffers, copies. No GPU work happens in setup. Execute runs later,
+inside `execute()`, once passes are scheduled and resources are realized. It receives a
+`PassContext&` that resolves handles to live GPU objects and holds the encoders. Execute is
+where you record draw, dispatch, and copy commands.
+
+### A frame at a glance
+
+A typical deferred frame. Passes (rectangles) declare reads and writes of resources (rounded).
+The graph derives every arrow from those declarations. The swapchain is imported, so the pass
+that writes it is a sink and cannot be culled.
+
+```mermaid
+flowchart LR
+ subgraph passes [ ]
+ direction LR
+ Tiles["Tiles
(Graphics)"]
+ Atmos["Atmosphere
(Graphics)"]
+ Clouds["CloudsRender
(Compute)"]
+ Light["Lighting
(Graphics)"]
+ Comp["Composite
(Graphics)"]
+ end
+
+ gbuffer(["gbuffer
albedo/normal/depth"])
+ atmoRT(["atmosphere.rt"])
+ cloudTex(["clouds.color"])
+ lit(["lit.color"])
+ swap[["swapchain
(imported sink)"]]
+
+ Tiles --> gbuffer
+ gbuffer --> Light
+ Atmos --> atmoRT
+ gbuffer --> Clouds
+ Clouds --> cloudTex
+ Light --> lit
+ lit --> Comp
+ atmoRT --> Comp
+ cloudTex --> Comp
+ Comp --> swap
+```
+
+`Composite` transitively feeds the imported `swapchain`, so every pass above survives culling.
+Drop the `Composite -> swapchain` edge, say by making nothing read `lit`, and the whole chain
+becomes dead. See [Ordering and culling](#ordering-and-culling).
+
+---
+
+## The frame loop
+
+The call order is a contract. Follow it exactly.
+
+```cpp
+// Startup, once:
+webgpu::rg::GraphAllocator* allocator = webgpu::rg::create_allocator();
+
+// Every frame:
+webgpu::rg::begin_frame(allocator);
+webgpu::rg::RenderGraph* rg = webgpu::rg::start_recording(allocator);
+
+// Import the swapchain first if a pass renders to it (imports happen during recording):
+auto swapchain = rg->import_texture("swapchain",
+ { .view = surface_view, .size = { size.x, size.y, 1 }, .format = surface_format });
+
+// ...declare resources and passes...
+
+// once, after all passes are declared. returns the error chain, and is [[nodiscard]]:
+// an unchecked compile() is how a broken frame becomes a silent black screen.
+for (auto* e = rg->compile(); e; e = e->next) // always check. see Debugging.
+ qCritical("%.*s", (int)e->message.length, e->message.data);
+
+rg->execute(device, queue, encoder, /*enableProfiling*/ false);
+// ...submit the encoder to the queue yourself...
+rg->collect_gpu_timings(); // safe always. no-op unless profiling was on.
+webgpu::rg::end_frame(allocator); // after the queue submit
+
+// Shutdown, once:
+webgpu::rg::destroy_allocator(allocator);
+```
+
+Three ordering constraints cause most of the bugs here:
+
+1. Every `import_*`, `create_*`, and `add_pass` runs before `compile()`. After compile the
+ graph is frozen.
+2. `end_frame()` runs after you submit the encoder, not before. The graph's pooled objects are
+ still referenced by the commands you are submitting.
+3. You own the encoder and the submit. The graph records into your encoder and never submits
+ for you.
+
+Several graphs per frame are allowed. A later graph may reuse an earlier one's pooled
+transients, which assumes the graphs are submitted in execute order on one queue.
+
+---
+
+## Resources
+
+Everything a pass touches must be a graph resource, declared before `compile()`. There are five
+kinds. They differ in who owns the GPU object and how long it lives.
+
+| Kind | Lifetime | Create with | Use for |
+| --- | --- | --- | --- |
+| Transient (default) | this frame | `create_transient_texture` / `_buffer` | intermediate targets, scratch |
+| Import | caller-owned | `import_texture` / `import_buffer` | swapchain, objects other systems own |
+| History | cross-frame ping-pong | `create_history_texture` / `_buffer` | temporal effects (TAA, reprojection) |
+| Persistent | cross-frame, one object | `create_persistent_texture` / `_buffer` | graph-owned caches |
+| Initialized | cross-frame, baked once | `create_initialized_texture` / `_buffer` | fallbacks for optional bind slots |
+
+Default to transient. Reach for the others only when you need cross-frame memory.
+
+```cpp
+auto rt = rg->create_transient_texture("atmosphere.rt", {
+ .dimension = WGPUTextureDimension_2D,
+ .format = WGPUTextureFormat_RGBA8Unorm,
+ .absolute = { w, h, 1 },
+});
+```
+
+### Relative sizing
+
+`TextureDesc::sizeKind = Relative` sizes a texture at `scaleX` and `scaleY` times the size of
+`relativeTo`, so a half-resolution pass tracks its source through window resizes without you
+recomputing anything:
+
+```cpp
+auto half = rg->create_transient_texture("bloom.half", {
+ .dimension = WGPUTextureDimension_2D,
+ .format = WGPUTextureFormat_RGBA16Float,
+ .sizeKind = webgpu::rg::SizeKind::Relative,
+ .scaleX = 0.5f,
+ .scaleY = 0.5f,
+ .relativeTo = fullResColor,
+});
+```
+
+`relativeTo` may itself be relative. The chain resolves at `compile()`. A cycle is a compile
+error, not a hang.
+
+Known limitation: view-format reinterpretation is unsupported. WebGPU needs reinterpret formats
+listed at texture creation, and `TextureDesc` does not plumb `viewFormats` yet.
+
+### Transient aliasing
+
+With aliasing on, which is `compile(true)` and the default, two transients pack onto the same
+GPU object when their lifetimes do not overlap and their descriptors match (size, format,
+dimension, mip and sample count, usage). A transient's lifetime runs from its first write to its
+last read. This keeps peak VRAM down.
+
+Overlapping lifetimes never share, even with identical descriptors. `compile(false)` turns
+aliasing off, giving every transient its own object. If a bug disappears with aliasing off, a
+lifetime was mis-declared, usually an undeclared read or write.
+
+Aliasing stays invisible to correctness as long as your declarations are honest: the graph only
+reuses memory it has proven dead. The one thing you must not do is reach around the graph and
+cache a transient's `WGPUTexture` or view across passes or frames. The object behind a handle
+can be a different, shared one next time. Always resolve inside the pass body via `PassContext`.
+
+### Undeclared reads and aliasing
+
+The `ctx.*` resolvers assert the handle was declared in this pass, so you cannot create an
+undeclared read through the intended path: a missing `sampled()` makes `ctx.view()` assert-fail.
+The one way past that is to stash a transient's raw view outside the graph and bind it with the
+free `webgpu::bind()` (from `gpu_utils.h`) in a later pass that never declares the read:
+
+```cpp
+WGPUTextureView m_cached_normal_view = nullptr; // a transient's view, escaped
+
+auto normal = rg->create_transient_texture("gbuffer.normal", desc);
+
+// Pass A legitimately writes `normal`, then caches its view "to avoid rebuilding":
+rg->add_pass("GBuffer", webgpu::rg::PassKind::Graphics,
+ [normal](webgpu::rg::PassBuilder& b) { b.color(normal, 0); },
+ [this, normal](webgpu::rg::PassContext& c) {
+ draw_gbuffer(c);
+ m_cached_normal_view = c.view(normal); // legal here, but now it has escaped the graph
+ });
+
+// ...intervening passes declare their own transients. To the graph, `normal` is dead after
+// GBuffer (no later pass declares a read), so the aliaser packs one of them onto its slot...
+
+// Pass B reads `normal` but forgets to declare it, and binds the cached view raw:
+rg->add_pass("Lighting", webgpu::rg::PassKind::Graphics,
+ [lit](webgpu::rg::PassBuilder& b) {
+ b.color(lit, 0);
+ // BUG: no b.sampled(normal); the graph never learns Lighting reads `normal`
+ },
+ [this](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_light_layout,
+ { webgpu::bind(0, m_cached_normal_view) }, // raw bind, invisible to hazards and aliasing
+ "lighting");
+ wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+ });
+```
+
+Under `compile(false)` this looks correct: `normal` keeps its own texture, so the cached view
+still points at real data. Under `compile(true)` another transient was packed onto that physical
+slot after GBuffer, so the same cached view now reads its pixels. The result is subtly wrong
+lighting from identical code, flipped only by aliasing. The fix is to declare the read with
+`b.sampled(normal)` and bind it with `c.bind(0, normal)`. Never cache the view.
+
+### Cross-frame pooling
+
+Aliasing packs transients within a frame. The transient pool is separate: it keeps GPU objects
+across frames. At `end_frame` a transient's object is returned to the pool rather than
+destroyed, and next frame's matching `create_transient_*` gets it back. An object left
+unclaimed for `kRetain` (4) frames is destroyed. That idle window is deliberate: a pass that
+records on only some frames must not pay a destroy and create every time it skips one.
+
+A resize would defeat that window. The old-size objects are never claimed again, so each one
+would linger 4 more frames, and a drag-resize spikes VRAM with a generation per frame. So the
+pool also supersede-evicts: at `end_frame`, an idle entry is destroyed immediately if a sibling
+created this frame is the same resource at a different size.
+
+"The same resource" is keyed on the resource's interned name, not its descriptor. Two different
+transients that merely differ in size look identical to a descriptor-only check, and evicting on
+that guess churns without bound: the idle one is destroyed, recreated on its next claim, and
+then supersedes the other. That is one destroy plus create every frame, forever, for any
+transient not claimed every frame. The name key is what tells a genuine resize apart from a
+coincidence.
+
+The asymmetry to remember when touching this: a missed supersede only delays an evict to
+`kRetain`, while a false supersede churns. When in doubt the predicate refuses. An entry with no
+identity is never superseded.
+
+### History
+
+`create_history_texture` and `create_history_buffer` return a `HistoryTexture` or
+`HistoryBuffer`, a pair of handles. Write `.curr`, read `.prev`. This frame's `.curr` becomes next frame's `.prev`. Gate
+every read of `.prev` with `ctx.history_valid(history)`, passing the pair itself rather than
+either handle. It returns false on the frame the
+`.prev` object was recreated and cleared (first use, resize, reset); skip the history sample
+then. A non-zero `hash` invalidates the history whenever the hash changes.
+
+The two backing textures ping-pong. Frame N writes buffer A and reads B; frame N+1 writes B and
+reads A. The dashed edge is the cross-frame carry the graph tracks for you:
+
+```mermaid
+flowchart LR
+ subgraph fN [Frame N]
+ pN["TAA"] --> currN(["curr = A"])
+ prevN(["prev = B"]) --> pN
+ end
+ subgraph fN1 [Frame N+1]
+ pN1["TAA"] --> currN1(["curr = B"])
+ prevN1(["prev = A"]) --> pN1
+ end
+ currN -. "becomes prev" .-> prevN1
+ currN1 -. "becomes prev" .-> prevN
+```
+
+`.prev` is meaningful only when last frame actually wrote `.curr` into the same pool entry. It
+is not meaningful on the first frame, the frame after a resize, or any frame the history was
+invalidated. In all of these the backing object was just recreated and holds no prior result.
+`ctx.history_valid(history)` folds all three cases into one bool. False means skip the temporal
+sample this frame and fall back to the non-history path.
+
+```cpp
+// TAA: blend with history only when it is valid, otherwise output the fresh sample as-is.
+float alpha = c.history_valid(history) ? 0.9f : 0.0f; // 0 == ignore history
+```
+
+Read `.prev` without this gate and the first frame, and every frame after a reset, samples
+cleared or uninitialized memory: smeared or black temporal output that clears up after a frame.
+
+### Camera cuts via the history hash
+
+The optional `hash` on `create_history_texture` and `_buffer` is a content-identity stamp. The
+graph keeps it with the pool entry. When the hash you pass differs from the stored one, the
+entry is destroyed and recreated blank, which drops `history_valid()` to false for that frame,
+exactly as a resize would. Pass the same hash and the ping-pong keeps rotating untouched.
+
+This is the mechanism for a camera cut. On a teleport, a projection swap, or any jump where last
+frame's pixels no longer correspond to this frame's, reprojection is worse than useless. Feed a
+hash that changes on the cut and history self-resets for one frame:
+
+```cpp
+// Any value that changes exactly when temporal continuity breaks. The graph only compares it to
+// last frame's, so how you derive it is yours: a counter you bump on the cut is enough.
+uint64_t cut = cameraCutCounter;
+auto taa = rg->create_history_texture("taa.color", desc, cut);
+// The frame `cut` changes, history_valid(taa) is false, so skip the reprojected sample.
+```
+
+`hash == 0` (the default) disables hash invalidation, so history then resets only on first use
+and resize. You do not detect the cut frame yourself and branch: change the hash and let
+`history_valid()` report the reset at the read site. This is the same mechanism `initialize()`
+uses for gated rebakes. See [initialize() and hash-gated rebakes](#initialize-and-hash-gated-rebakes).
+
+### Initialized fallbacks
+
+`create_initialized_texture` takes a `WGPUColor` fill; `create_initialized_buffer` takes an
+optional `data` pointer (null zero-fills). Contents are baked once and reused every frame. This
+is the standard fallback for an optional binding slot: when a feature is off, bind a 1x1
+initialized texture instead of restructuring the pipeline layout. To bake more complex content
+once, such as a LUT computed by a pass, use a persistent resource plus `PassBuilder::initialize()`.
+See [initialize() and hash-gated rebakes](#initialize-and-hash-gated-rebakes).
+
+### Import
+
+`import_texture` wraps a GPU object the graph does not own. The caller guarantees it stays alive
+for the frame. Imports are never culled: writing an imported resource is an output that leaves
+the frame, which is what keeps the chain feeding the swapchain alive.
+
+```cpp
+auto swapchain = rg->import_texture("swapchain",
+ { .view = surface_view, .size = { size.x, size.y, 1 }, .format = surface_format });
+```
+
+Passing the backing `texture` enables the copy family and `ctx.texture()` on this handle, and lets
+the graph build views from a declared `ViewRange` as it would for a graph-owned texture. Leaving it
+null registers only the view, which restricts the handle to sample and attach, and makes every
+subresource selection moot: `ctx.view()` returns the registered view whatever you declared.
+`mipCount`, `sampleCount`, and `dimension` default to a single-sample, single-mip 2D texture.
+Pass the real values if the source differs. `dimension` is the source texture's own dimension,
+so an array or cube source says `2D`.
+
+### Names
+
+Every declaration takes the resource or pass name as a `std::string_view`. The graph hashes it
+and copies the name into its arena during the call. The name backs labels, the debug UI, and
+error messages; the hash speeds up cross-frame lookups. So the view only has to be valid for
+that one call, which is the ordinary `string_view` rule.
+
+```cpp
+// Both fine. The argument is alive for the duration of the call:
+rg->create_transient_texture("overlay." + std::to_string(i), desc); // temporary lives to the ';'
+
+const std::string name = "overlay." + std::to_string(i);
+rg->create_transient_texture(name, desc); // named string, obviously alive
+```
+
+The only trap is the generic one: do not hand the graph a `string_view` into a string that has
+already been destroyed. String literals such as `"clouds.lo_color"` live forever and are always
+safe.
+
+---
+
+## Passes
+
+`add_pass(id, kind, setup, execute)` declares one unit of GPU work. The `kind` decides which
+encoder the body gets. For `Graphics` you do not call `wgpuCommandEncoderBeginRenderPass`.
+`ctx.render_pass` arrives ready, cleared or loaded per your `color()` and `depth_stencil()`
+declarations.
+
+```cpp
+rg->add_pass("Atmosphere", webgpu::rg::PassKind::Graphics,
+ // SETUP: runs now. Declare every resource this pass reads and writes. No GPU work.
+ [&](webgpu::rg::PassBuilder& b) {
+ b.color(rt, 0);
+ },
+ // EXECUTE: runs later inside execute(). Record the GPU commands.
+ [camera_bg, pipeline = m_pipeline->pipeline().handle()](webgpu::rg::PassContext& ctx) {
+ wgpuRenderPassEncoderSetBindGroup(ctx.render_pass, 0, camera_bg, 0, nullptr);
+ wgpuRenderPassEncoderSetPipeline(ctx.render_pass, pipeline);
+ wgpuRenderPassEncoderDraw(ctx.render_pass, 3, 1, 0, 0);
+ });
+return rt; // hand the handle to whoever consumes this pass's output
+```
+
+### Setup declares, execute records
+
+The split between the two lambdas matters more than any single method.
+
+Setup gets a `PassBuilder&` and declares every resource the pass touches: attachments (`color`,
+`depth_stencil`, `resolve`), shader resources (`sampled`, `storage_read`, `storage_write`,
+`storage_read_write`, `uniform`, `host_write`), buffer inputs (`vertex_buffer`, `index_buffer`,
+`indirect_buffer`), and copies. Those declarations are the only thing the graph knows about the
+pass. It derives ordering from them, infers usage flags from them, and decides aliasing from
+them. Setup does no GPU work.
+
+Two builder calls declare no access but change scheduling. `initialize(target, hash)` gates the
+pass on a bake being stale (see [initialize()](#initialize-and-hash-gated-rebakes)), and
+`force_keep()` exempts it from culling (see [Ordering and culling](#ordering-and-culling)).
+
+Execute gets a `PassContext&` and records commands. Its resolvers (`ctx.view`, `ctx.bind`,
+`ctx.texture`, `ctx.buffer`, the sizes and formats, the copy infos) each assert the handle was
+declared in this pass. That assert stops most undeclared reads at the door. The one path around
+it is [Undeclared reads and aliasing](#undeclared-reads-and-aliasing).
+
+For the per-call reference, every parameter, default, and constraint, read
+[`webgpu/base/RenderGraph.h`](../webgpu/base/RenderGraph.h). It is the source of truth and is
+commented for that purpose. This document does not restate it.
+
+---
+
+## The two rules
+
+Break either one and you get a compile error, a crash, or silently wrong pixels.
+
+### Rule 1: the execute lambda must be trivially destructible
+
+Its closure is stored in an arena that frees memory without running destructors. A
+`static_assert` in `add_pass` enforces this. Capture handles, raw GPU handles (`WGPUBindGroup`,
+`WGPURenderPipeline`), plain values, and `this` by value. Never capture a `std::string`, smart
+pointer, container, or RAII wrapper.
+
+```cpp
+// OK: raw handle pulled out at declaration time.
+[pipeline = m_pipeline->pipeline().handle()](webgpu::rg::PassContext& ctx) { ... }
+// Compile error: std::string is not trivially destructible.
+[label = std::string("debug")](webgpu::rg::PassContext& ctx) { ... }
+```
+
+### Rule 2: never pre-build a bind group over a transient's view
+
+A transient's real GPU texture is not chosen until `execute()`, and it can change frame to frame
+through pooling and aliasing. A bind group built outside the pass body points at stale or wrong
+memory. Build bind groups inside the execute lambda, as a body-local `webgpu::raii::BindGroup`,
+from `ctx.bind(...)` entries:
+
+```cpp
+[this, result](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_layout, {
+ c.bind(0, result), // graph resource -> ctx.bind
+ m_ubo->create_bind_group_entry(1), // app-owned static -> its own helper
+ }, "blit");
+ wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+}
+```
+
+The body-local group is safe to destroy at end of scope. The encoder keeps its own reference
+once `SetBindGroup` records it.
+
+The same rule forbids caching a transient's view to bind raw in a later pass. That read is
+undeclared, so the aliaser can hand its memory to another transient and the cached view reads
+the wrong pixels, a bug that shows only with aliasing on. See
+[Undeclared reads and aliasing](#undeclared-reads-and-aliasing) and the `compile(false)` row in
+[Troubleshooting](#troubleshooting).
+
+Mixing rule: any resource a graph pass touches binds with `ctx.bind(handle)`, so the graph can
+order and alias it. A static or externally-synced object, such as a static UBO or a shadow map
+the app owns, binds with its own `create_bind_group_entry()` or the free `webgpu::bind()`
+overloads (in `webgpu/base/gpu_utils.h`):
+
+```cpp
+WGPUBindGroupEntry bind(uint32_t binding, WGPUTextureView view);
+WGPUBindGroupEntry bind(uint32_t binding, WGPUSampler sampler);
+WGPUBindGroupEntry bind(uint32_t binding, WGPUBuffer buffer, uint64_t offset, uint64_t size);
+```
+
+---
+
+## Ordering and culling
+
+You never write dependencies by hand. The graph derives order from your access declarations: a
+`sampled(x)` runs after whatever `color(x)` or `storage_write(x)` produced `x`. To chain two
+passes, write a fresh transient in the producer, capture the handle, and declare it as a read in
+the consumer:
+
+```cpp
+auto out = producer(rg); // returns a handle it wrote
+consumer(rg, /*reads*/ out); // declares b.sampled(out) -> ordered after producer
+```
+
+Read-after-write and write-after-write both become ordering edges. Write-after-write keeps
+declaration order. Declare the producing pass before its readers, def before use.
+
+Below, `Bake` writes `lut` and `Shade` reads it: one edge, one order. `DebugDump` writes only a
+plain transient nobody reads, so it never reaches a sink and `compile()` drops it (dashed):
+
+```mermaid
+flowchart LR
+ Bake["Bake"] --> lut(["lut"])
+ lut --> Shade["Shade"]
+ Shade --> out(["shaded"])
+ out --> swap[["swapchain
(sink)"]]
+
+ Dump["DebugDump
(culled)"] -.-> scratch(["scratch
(no reader)"])
+
+ classDef dead fill:#8883,stroke-dasharray:4 3,color:#888;
+ class Dump,scratch dead;
+```
+
+To keep `DebugDump` (readback, indirect-arg generation, a bake nobody reads this frame), mark it
+`force_keep()` or gate it with `initialize()`.
+
+`compile()` drops every pass whose output nothing needs. A pass is kept only if it transitively
+feeds a sink:
+
+| Sink | Why it counts |
+| --- | --- |
+| A read by another surviving pass | ordinary producer/consumer dependency |
+| A write to an imported resource | the value leaves the frame (e.g. the swapchain) |
+| A write to a history `.curr` | it becomes next frame's `.prev` |
+| A `force_keep()` pass | explicit side-effect root |
+
+A pass that only writes a plain transient nobody reads is dead and silently culled. It leaves
+the debug panel and GPU timings. If your pass has a side effect the graph cannot see (readback,
+indirect-arg generation, a bake nobody reads this frame), mark it `force_keep()` or gate it with
+`initialize()`.
+
+---
+
+## Pass groups
+
+A `.` in a pass name creates a group. The span before the first dot is the group name, and
+passes that share it are bracketed together in GPU captures and drawn as one region in the
+RenderGraph panel. Naming costs nothing and makes a 40-pass frame readable.
+
+```cpp
+rg->add_pass("bloom.threshold", ...);
+rg->add_pass("bloom.blurH", ...);
+rg->add_pass("bloom.blurV", ...);
+rg->add_pass("bloom.composite", ...);
+```
+
+Grouping is a labelling feature only. It has no effect on ordering, culling, aliasing, usage
+inference, or correctness. A pass with no dot in its name belongs to no group.
+
+### What the group drives
+
+During `execute()`, the graph brackets each run of same-group passes in a command-encoder debug
+group with `wgpuCommandEncoderPushDebugGroup` and `PopDebugGroup`. A RenderDoc, PIX, or Xcode
+capture then shows `bloom` as one collapsible region containing its four passes instead of four
+unrelated entries.
+
+The panel uses the same prefix to draw the run inside one bordered region and to keep it as a
+single block in the layout, so a group stays visually together instead of being scattered by the
+dependency columns. A region needs at least two passes; a lone `bloom.threshold` draws as an
+ordinary box.
+
+### Nesting with more dots
+
+The encoder debug group uses the first segment only. The panel goes further and builds a tree
+from the remaining segments, so `bloom.down.0` and `bloom.down.1` nest under `bloom.down`, which
+nests under `bloom`. Each level collapses independently and the panel remembers the collapse
+state per prefix across frames.
+
+```cpp
+rg->add_pass("bloom.down.0", ...); // panel: bloom > down > 0
+rg->add_pass("bloom.down.1", ...);
+rg->add_pass("bloom.up.0", ...); // panel: bloom > up > 0
+```
+
+As with the top level, a nested level needs at least two members to become its own subgroup.
+
+### Groups follow execution order, not declaration order
+
+The run is computed over passes in execution order, which `compile()` derives from your
+declarations. Passes sharing a prefix that do not end up scheduled next to each other produce
+two separate debug groups with the same name, not one merged region.
+
+This is usually invisible, because a group is normally a chain and a chain schedules
+consecutively. It shows up when an unrelated pass gets scheduled into the middle of a group, for
+example when a group member reads a resource produced late. If a group looks split in a capture,
+check the Graph tab for what landed between its members.
+
+Culling applies first. A culled pass is not in the execution order at all, so it contributes
+nothing to its group, and a group whose members are all culled disappears.
+
+By convention resource names use dots too, as in `gbuffer.albedo` and `clouds.lo_color`. That is
+for readability in labels and error messages. Only pass names form groups.
+
+---
+
+## Views and ViewRange
+
+`ctx.view(h)` returns a view whose shape matches what you declared: same base mip and layer,
+same `ViewRange`. You never build a `WGPUTextureViewDescriptor`.
+
+Which subresource you mean is part of the same option struct as everything else. `sampled()` and
+the `storage_*()` family take a `ViewRange`, whose `baseMip`/`baseLayer` default to 0. Attachments
+and copies take a `Subresource` instead, since they address exactly one mip and one layer.
+
+```cpp
+b.sampled(tex, { .baseMip = 2 }); // read mip 2
+b.color(tex, 0, { .sub = { .mip = 3 } }); // render into mip 3
+// In the body, fetch a specific subresource explicitly:
+WGPUTextureView mip2 = c.view(tex, 2); // mip 2, layer 0
+auto entry = c.bind(0, tex, 2); // same view, as a bind-group entry
+```
+
+`ViewRange` is how you name more than one subresource: a mip chain, an array slice, a cubemap,
+or a single aspect of a depth-stencil texture. It is the optional last argument of `sampled()`
+and the `storage_*()` family. Attachments take a `Subresource` instead, since a render target is
+always one mip and one layer.
+
+Write it with designated initializers. The fields are same-typed, so a positional
+`{ 1, 2, 0, 6 }` compiles happily while meaning something else entirely. The `cube()`,
+`cube_array()` and `whole()` helpers return a ready-made range, and `.at(mip, layer)` rebases
+one: `cube().at(0, 4)` is cube face 4. The shape you declare here is the shape `ctx.view(h)` hands back in the body.
+
+### The default is one subresource
+
+Every field has a default that suits a plain 2D texture, so `{}` (what you get by omitting it)
+means one mip, one layer, all aspects, dimension inferred. Set a field only when that is not
+what you want.
+
+### mipCount and layerCount count forward, and 0 means the rest
+
+Both are counts from the base, not indices. `b.sampled(tex, { .baseMip = 2, .mipCount = 3 })` is mips 2,
+3, and 4.
+
+`0` is the useful special case: all remaining from the base. Prefer it to counting by hand. An
+over-wide count is the usual cause of a `"declares a view of …"` compile error, and `0` cannot
+overrun.
+
+```cpp
+b.sampled(prefiltered, { .mipCount = 0 }); // the whole mip chain
+b.sampled(cascades, { .layerCount = 4 }); // layers 0..3, as a 2DArray
+b.sampled(cascades, { .baseLayer = 2, .layerCount = 0 }); // layer 2 to the end
+```
+
+### dim is inferred unless you set it
+
+`dim` defaults to `Undefined`, which resolves to `3D` for a 3D texture, else `2DArray` when the
+view covers more than one layer, else `2D`. That is right nearly always. Set it explicitly for a
+cube, or to force `2DArray` over a single layer because the shader binding says
+`texture_2d_array`.
+
+### aspect picks a plane
+
+For a combined depth-stencil format, say which plane a sampled read means:
+
+```cpp
+b.sampled(depth, { .aspect = WGPUTextureAspect_DepthOnly });
+```
+
+### Layers come from the texture, not the range
+
+`layerCount` can only slice layers the texture actually has, and array layers live in
+`TextureDesc::absolute.depthOrArrayLayers` of a 2D texture. An array is 2D with depth > 1, not
+`WGPUTextureDimension_3D`. Confuse the two and you get a `3D` view where you wanted an array, or
+a range error:
+
+```cpp
+auto cascades = rg->create_transient_texture("shadow.cascades", {
+ .dimension = WGPUTextureDimension_2D, // 2D, not 3D
+ .format = WGPUTextureFormat_Depth32Float,
+ .absolute = { 2048, 2048, /*layers*/ 4 }, // the layer count lives here
+});
+b.sampled(cascades, { .layerCount = 4 }); // -> 2DArray of 4
+```
+
+### Cubes: use the helpers
+
+Cube views have strict rules: exactly 6 layers, a positive multiple of 6 for an array, and a 2D
+source. Three constexpr helpers spell them out correctly. `whole()` is the everything-from-the-
+base shorthand:
+
+```cpp
+b.sampled(envMap, webgpu::rg::cube()); // 6 layers, dim Cube
+b.sampled(probes, webgpu::rg::cube_array(4)); // 4 cubes = 24 layers
+b.sampled(prefiltered, webgpu::rg::whole()); // every mip and layer
+// each helper also takes an aspect and a mipCount:
+b.sampled(envMap, webgpu::rg::cube(WGPUTextureAspect_All, /*mipCount*/ 0)); // cube + full chain
+```
+
+`compile()` checks the rest and names the offending pass and resource: a cube covering other
+than 6 layers, a cube array not a multiple of 6, a base plus count past the last layer, or a
+cube view of a non-2D texture. One rule has no workaround: a cube view on a `storage_read` or
+`storage_write` is an error, because WebGPU storage textures cannot be cube. Sample it as a
+cube, or bind it as a `2DArray` for storage.
+
+### Recipe: generate a mip chain
+
+A copy cannot resize, so mip generation is a blit pass per level: sample mip `i`, render into
+mip `i+1`, same handle, two subresources. Hazard tracking is whole-resource, so the chain
+serializes level by level, which is the order you want anyway.
+
+The texture must be created with the levels it is about to fill. `TextureDesc::mipLevelCount`
+defaults to 1 and there is no `0` meaning all, so spell the chain out; a loop past the declared
+count is the `"is accessed at mip …"` compile error:
+
+```cpp
+constexpr uint32_t kMips = 8;
+auto tex = rg->create_transient_texture("prefiltered", {
+ .dimension = WGPUTextureDimension_2D,
+ .format = WGPUTextureFormat_RGBA16Float,
+ .absolute = { 256, 256, 1 },
+ .mipLevelCount = kMips, // without this the texture has one mip
+});
+
+for (uint32_t mip = 1; mip < kMips; ++mip) {
+ rg->add_pass(names[mip], webgpu::rg::PassKind::Graphics, // names[] must outlive each add_pass call
+ [&](webgpu::rg::PassBuilder& b) {
+ b.sampled(tex, { .baseMip = mip - 1 }); // read mip i-1
+ b.color(tex, 0, { .sub = { .mip = mip } }); // write mip i
+ },
+ [tex, mip, layout = m_blitLayout.handle()](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup group(c.device, layout, { c.bind(0, tex, mip - 1) }, "mip blit");
+ wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, group.handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+ });
+}
+```
+
+---
+
+## Buffers
+
+Declare buffers with `create_transient_buffer`, `create_persistent_buffer`, or
+`create_history_buffer`, each taking a `BufferDesc { size }`. `import_buffer` takes the caller's
+`WGPUBuffer` instead of a desc, since the object already has its size. Usage flags are inferred
+from how passes declare the buffer. You never set `WGPUBufferUsage` yourself:
+
+| Declaration | Inferred usage | WGSL |
+| --- | --- | --- |
+| `b.uniform(h)` | Uniform | `var` |
+| `b.storage_read(h)` | Storage (read) | `var` |
+| `b.storage_write(h)` / `b.storage_read_write(h)` | Storage (write / read_write) | `var` |
+| `b.vertex_buffer(h)` | Vertex | vertex fetch |
+| `b.index_buffer(h)` | Index | index fetch |
+| `b.indirect_buffer(h)` | Indirect | draw/dispatch indirect args |
+| `b.host_write(h)` | CopyDst | filled via `wgpuQueueWriteBuffer` in the body |
+| `b.copy_buffer(src,dst,…)` | CopySrc / CopyDst | see copies |
+
+`ctx.buffer(h)` gives the `WGPUBuffer`, and `ctx.buffer_size(h)` its realized size.
+`ctx.bind(binding, h)` makes a whole-buffer binding at offset 0 with the realized size, unless the
+declaration carried a `BufferRange` (below).
+
+### Binding a sub-range
+
+`uniform()` and the `storage_*()` buffer declarations take an optional `BufferRange { offset, size }`,
+the buffer analog of `ViewRange`: the shape you declare is the shape `ctx.bind(binding, h)` hands
+back. `size = 0` (the default) means all remaining bytes from `offset`, resolved at declare time.
+
+```cpp
+// bind the second 256-byte slice of a packed parameter buffer
+b.uniform(params, { .offset = 256, .size = 256 });
+b.storage_read(packed, { .offset = 512 }); // byte 512 to the end
+```
+
+Constraints, all checked at `compile()`: the range must fit the buffer, must not be empty, and
+`offset` must be a multiple of 256, the spec-default uniform/storage offset alignment. One range per
+buffer per pass; two bind declarations giving the same buffer different ranges is ambiguous and
+asserts in `ctx.bind`. Hazard tracking stays whole-resource, so two passes touching disjoint ranges
+of one buffer still order serially; use separate transients if that matters.
+
+```cpp
+auto args = rg->create_transient_buffer("cull.args", { .size = 16 });
+
+rg->add_pass("Cull", webgpu::rg::PassKind::Compute,
+ [args, ids](webgpu::rg::PassBuilder& b) {
+ b.storage_read(ids);
+ b.storage_write(args); // -> Storage usage on args
+ },
+ [this, args, ids](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_cull_layout,
+ { c.bind(0, ids), c.bind(1, args) }, "cull");
+ wgpuComputePassEncoderSetPipeline(c.compute_pass, m_cull_pipeline);
+ wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bg.handle(), 0, nullptr);
+ wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, groups, 1, 1);
+ });
+// A later Graphics pass declares b.indirect_buffer(args) -> args gains Indirect usage
+// and the draw is ordered after Cull.
+```
+
+---
+
+## Attachments
+
+The graph schedules passes and wires attachments. The pass body owns the pipeline (blend,
+depth-stencil state, `multisample.count`). The graph does not validate formats; Dawn does.
+
+### Color and depth
+
+`color()` and `depth_stencil()` declare a write into one subresource (a render target is always
+one mip, one layer). Their load and store ops are plain WebGPU, passed through untouched: `Clear`
+starts from the clear value, `Load` keeps what is there, `Store` keeps the result, `Discard`
+throws it away.
+
+`color()` takes the attachment slot explicitly, right after the handle. It is the `@location`
+the fragment shader writes, not the order you declare in. Slots may be sparse, but one slot
+twice in a pass is an error.
+
+```cpp
+// Slot 0, clear to opaque black, keep the result:
+b.color(target, 0); // defaults: Clear, Store, {0,0,0,1}
+// Draw over what a previous pass produced (composite, overlays, tracks):
+b.color(target, 0, { .load = WGPULoadOp_Load });
+// Depth attachment, reverse-Z: clear to 0.0 and pair with a Greater/GreaterEqual pipeline.
+b.depth_stencil(depth, { .clearDepth = 0.0f });
+```
+
+A multi-target pass names one slot per `color()`, matching its fragment shader's `@location`s,
+plus an optional `depth_stencil()`:
+
+```cpp
+rg->add_pass("Tiles", webgpu::rg::PassKind::Graphics,
+ [albedo, position, normal, gdepth](webgpu::rg::PassBuilder& b) {
+ b.color(albedo, 0, { .clear = { 0, 0, 0, 0 } });
+ b.color(position, 1, { .clear = { 0, 0, 0, 0 } });
+ b.color(normal, 2, { .clear = { 0, 0, 0, 0 } });
+ b.depth_stencil(gdepth, { .clearDepth = 0.0f }); // reverse-Z
+ },
+ [this](webgpu::rg::PassContext& c) { /* set bind groups + pipeline; draw */ });
+```
+
+Up to 8 color attachments (`kMaxColorAttachments`). A test-only pass that reads depth without
+writing it declares `depth_stencil_read_only(depth)`, the cheapest way to avoid a false write
+hazard.
+
+### MSAA and resolve
+
+`TextureDesc::sampleCount = 4` makes a texture multisampled (WebGPU 1.0 allows 1 or 4). MSAA and
+non-MSAA textures never alias each other.
+
+`resolve(src, target)` declares `target` as the single-sample resolve of `src`. The `src` handle
+must already have a `color()` declaration in this pass, or you get an error naming the pass. Each
+color attachment takes at most one resolve target.
+
+```cpp
+auto msaaColor = rg->create_transient_texture("msaa.color",
+ { .dimension = WGPUTextureDimension_2D, .format = kColorFormat, .absolute = size, .sampleCount = 4 });
+auto msaaDepth = rg->create_transient_texture("msaa.depth",
+ { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_Depth32Float, .absolute = size, .sampleCount = 4 });
+auto resolved = rg->create_transient_texture("resolved",
+ { .dimension = WGPUTextureDimension_2D, .format = kColorFormat, .absolute = size }); // single-sample
+
+rg->add_pass("forward.msaa", webgpu::rg::PassKind::Graphics,
+ [&](webgpu::rg::PassBuilder& b) {
+ b.color(msaaColor, 0); // multisample color
+ b.resolve(msaaColor, resolved); // src must already be declared as a color()
+ b.depth_stencil(msaaDepth); // depth is NOT resolved
+ },
+ [pipeline = msaaPipe](webgpu::rg::PassContext& ctx) {
+ // pipeline MUST be built with .multisample = { .count = 4, .mask = ~0u }
+ wgpuRenderPassEncoderSetPipeline(ctx.render_pass, pipeline);
+ wgpuRenderPassEncoderDraw(ctx.render_pass, 3, 1, 0, 0);
+ });
+```
+
+The resolve target may be an imported texture, resolving straight into the swapchain:
+`b.resolve(msaaColor, swapchain)`. Dawn enforces the rest: every attachment in one pass shares
+one `sampleCount` equal to the pipeline's; a resolve target is single-sample with the same
+format and size as its color; depth and stencil cannot be resolved through a render pass.
+
+### Stencil
+
+Use a depth+stencil format (`Depth24PlusStencil8`, `Depth32FloatStencil8`) and pass the stencil
+load, store, and clear to `depth_stencil()`. The graph only carries the attachment's load, store,
+and clear. The actual stencil compare and write live in the pipeline's `WGPUDepthStencilState`.
+The stencil params default to `Undefined`, so depth-only formats are unaffected; leave them out.
+
+```cpp
+// Pass 1: write the mask (pipeline: stencil passOp = Replace, ref = 1).
+b.depth_stencil(ds, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store });
+// Pass 2: effect only where stencil == 1 (pipeline: compare = Equal, ref = 1).
+b.depth_stencil_read_only(ds); // test only -> reads ds, orders after the mask pass
+b.color(outColor, 0, { .load = WGPULoadOp_Load });
+```
+
+`depth_stencil_read_only()` marks both depth and stencil read-only. Marking stencil read-only
+while depth stays writable is not expressible; they share one flag.
+
+---
+
+## Compute passes
+
+`PassKind::Compute` gives you `ctx.compute_pass`. Declare storage, sampled, and uniform accesses
+in setup; build the bind group in the body from `ctx.bind(...)` and dispatch. A pass that writes
+a storage target and a later pass that samples it are ordered automatically.
+
+```cpp
+rg->add_pass("CloudsRender", webgpu::rg::PassKind::Compute,
+ [lo_color, lo_depth, gbuffer_depth](webgpu::rg::PassBuilder& b) {
+ b.storage_write(lo_color); // binding 4 (rgba16float)
+ b.storage_write(lo_depth); // binding 5 (r32float)
+ b.sampled(gbuffer_depth); // ordering only: bound via a pre-built depth bind group
+ },
+ [this, lo_color, lo_depth, depth_bg, shared_config_bg](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bind_group(c.device,
+ m_ctx->resource_registry().bind_group_layout("render_clouds"),
+ {
+ m_params_ubo->raw_buffer().create_bind_group_entry(0), // static
+ m_atlas_view->create_bind_group_entry(1), // static
+ c.bind(4, lo_color), // graph storage-write
+ c.bind(5, lo_depth),
+ }, "CloudsRender");
+ wgpuComputePassEncoderSetPipeline(c.compute_pass, m_render_pipeline->handle());
+ wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bind_group.handle(), 0, nullptr);
+ wgpuComputePassEncoderSetBindGroup(c.compute_pass, 1, depth_bg, 0, nullptr);
+ wgpuComputePassEncoderSetBindGroup(c.compute_pass, 2, shared_config_bg, 0, nullptr);
+ wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass,
+ ceil_div(res.x, 8u), ceil_div(res.y, 8u), 1);
+ });
+```
+
+Storage and sampled transitions between compute passes, and between compute and graphics, are
+auto-synchronized by Dawn. There are no manual barriers. A read and write in the same dispatch
+uses `storage_read_write()`; in-dispatch races are the shader's responsibility.
+
+---
+
+## Transfer passes and copies
+
+`PassKind::Transfer` gets no pass object. The body encodes copies directly on `ctx.encoder`.
+Each copy method declares `CopySrc` on src and `CopyDst` on dst, and the ctx resolvers build the
+WebGPU copy descriptors with the declared subresource applied.
+
+Four copies are declarable: `copy_texture` (subresource to subresource, it cannot resize, so it
+is no use for mip generation), `copy_texture_to_buffer` (readback and export),
+`copy_buffer_to_texture` (host-staged upload), and `copy_buffer` (a byte range; offsets and size
+must be multiples of 4, and src and dst must differ).
+
+Resolvers give you one unambiguous handle and direction per pass. For several copies over one
+resource, build the infos by hand via `ctx.texture()` and `ctx.buffer()`.
+
+```cpp
+// Buffer -> buffer (e.g. counter -> indirect args):
+rg->add_pass("CopyArgs", webgpu::rg::PassKind::Transfer,
+ [counter, indirectArgs](webgpu::rg::PassBuilder& b) {
+ b.copy_buffer(counter, indirectArgs, 0, 0, 16);
+ },
+ [counter, indirectArgs](webgpu::rg::PassContext& ctx) {
+ auto c = ctx.buffer_copy_info(counter, indirectArgs); // size already expanded
+ wgpuCommandEncoderCopyBufferToBuffer(ctx.encoder, c.src, c.srcOffset, c.dst, c.dstOffset, c.size);
+ });
+```
+
+The buffer side of a texture copy is yours to describe, and WebGPU requires each row to start on
+a 256-byte boundary. `webgpu::rg::aligned_bytes_per_row(widthTexels, texelBlockBytes)` does that
+rounding so the readback buffer and the layout agree:
+
+```cpp
+// Texture -> buffer readback. The buffer-side layout is the body's; rows are 256-aligned.
+rg->add_pass("Readback", webgpu::rg::PassKind::Transfer,
+ [src, dst](webgpu::rg::PassBuilder& b) { b.copy_texture_to_buffer(src, dst); },
+ [src, dst, w, h](webgpu::rg::PassContext& ctx) {
+ WGPUTexelCopyBufferLayout layout {
+ .offset = 0,
+ .bytesPerRow = webgpu::rg::aligned_bytes_per_row(w, /*texelBytes*/ 4),
+ .rowsPerImage = h,
+ };
+ auto s = ctx.copy_src_info(src); // texture side, declared mip/layer applied
+ auto d = ctx.copy_dst_buffer(dst, layout); // buffer side, {layout, buffer}
+ auto sz = ctx.copy_extent_src(src);
+ wgpuCommandEncoderCopyTextureToBuffer(ctx.encoder, &s, &d, &sz);
+ });
+```
+
+Hazard tracking is whole-resource: two copies over disjoint ranges of the same pair still order
+serially. Copying an imported texture requires that the import passed its backing `WGPUTexture`.
+Copies are not auto-encoded in `execute()`; the body calls `wgpu*` directly, the same contract
+as every other pass kind.
+
+---
+
+## initialize() and hash-gated rebakes
+
+`initialize()` marks a pass as the baker for a pool-backed resource: a persistent texture or
+buffer, or a history `.curr`. You still declare the write normally (`color()`, `storage_write()`,
+a copy). `initialize()` only decides whether the pass runs this frame. The graph runs it only
+when the target needs rebaking, and skips it, culling the pass so it leaves the panel and
+timings, on every frame the baked content is still valid. Readers bind the pooled result either
+way: persistent resources are external to the frame, so no in-graph writer is required for a
+reader to resolve them.
+
+Declare it in setup, alongside the write it gates:
+
+```cpp
+// Bake a BRDF or atmosphere LUT once, then reuse the pooled texture every frame.
+auto lut = rg->create_persistent_texture("brdf.lut", lutDesc);
+
+rg->add_pass("BakeBRDF", webgpu::rg::PassKind::Compute,
+ [lut](webgpu::rg::PassBuilder& b) {
+ b.storage_write(lut); // the actual write
+ b.initialize(lut); // hash 0 -> bake once, then skip forever
+ },
+ [this, lut](webgpu::rg::PassContext& c) { /* dispatch the bake */ });
+// Every later frame: BakeBRDF is skipped and culled; samplers still bind the pooled LUT.
+```
+
+### The rebake triggers
+
+A pass gated by `initialize(target, hash)` runs when any of these holds; otherwise it is skipped
+and culled:
+
+| Trigger | Meaning |
+| --- | --- |
+| First frame | the pool entry has never been realized or baked |
+| Pool recreation | the entry was evicted, or resized or reformatted (a bigger usage set, a size/format/dim/mip/sample change). Recreation clears the baked flag |
+| Hash mismatch | the `hash` you pass differs from the one stamped at the last bake |
+
+A body bakes all its targets or none: one stale target re-arms the whole pass. The hash is
+re-stamped when the pass actually runs, during `execute()`, so a frame that fails `compile()`
+never claims a bake it did not perform.
+
+### Ad-hoc rebuilding via the hash
+
+`hash == 0` means bake once, never again. A non-zero hash turns `initialize()` into an on-demand
+rebuild: pass a value derived from every input the baked content depends on, and the bake re-runs
+exactly on the frames that value changes. There is no dirty flag, no manual invalidation call,
+and no rebuild every frame.
+
+```cpp
+// Rebake only when a setting that feeds the LUT changes. Any hash over those inputs works, the
+// graph just compares it to the value stamped at the last bake:
+uint64_t sig = std::hash {}(
+ { reinterpret_cast(&lutParams), sizeof(lutParams) }); // explicit size, not strlen
+
+rg->add_pass("BakeBRDF", webgpu::rg::PassKind::Compute,
+ [lut, sig](webgpu::rg::PassBuilder& b) {
+ b.storage_write(lut);
+ b.initialize(lut, sig); // re-runs only on the frame `sig` changes
+ },
+ [this, lut](webgpu::rg::PassContext& c) { /* dispatch the bake */ });
+```
+
+This is the same hash mechanism history uses for
+[camera cuts](#camera-cuts-via-the-history-hash): one number, changed when the content's
+identity changes, and the graph rebuilds on that frame. Use it for LUTs that depend on tunables,
+precomputed shadow or irradiance data keyed to sun direction, and any expensive result whose
+inputs change occasionally but not per frame.
+
+---
+
+## Examples
+
+Each example is a small slice of a real frame graph. They compose the pieces from the sections
+above: MRT attachments, cross-stage ordering, relative sizing, history, persistent bakes, and
+imports. The pass bodies are abbreviated to the graph-relevant calls; pipeline creation and
+draw setup live in the app.
+
+### Deferred shading: G-buffer plus compute lighting
+
+A Graphics pass fills a multi-target G-buffer. A Compute pass reads the three targets and writes
+a lit storage texture. The read-after-write edge is derived from the declarations, so the compute
+pass runs after the G-buffer with no manual barrier.
+
+```cpp
+auto albedo = rg->create_transient_texture("gbuffer.albedo",
+ { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA8Unorm, .absolute = size });
+auto normal = rg->create_transient_texture("gbuffer.normal",
+ { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA16Float, .absolute = size });
+auto depth = rg->create_transient_texture("gbuffer.depth",
+ { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_Depth32Float, .absolute = size });
+auto lit = rg->create_transient_texture("lit.color",
+ { .dimension = WGPUTextureDimension_2D, .format = WGPUTextureFormat_RGBA16Float, .absolute = size });
+
+rg->add_pass("GBuffer", webgpu::rg::PassKind::Graphics,
+ [albedo, normal, depth](webgpu::rg::PassBuilder& b) {
+ b.color(albedo, 0, { .clear = { 0, 0, 0, 0 } });
+ b.color(normal, 1, { .clear = { 0, 0, 0, 0 } });
+ b.depth_stencil(depth, { .clearDepth = 0.0f }); // reverse-Z
+ },
+ [this](webgpu::rg::PassContext& c) { draw_scene(c); });
+
+rg->add_pass("Lighting", webgpu::rg::PassKind::Compute,
+ [albedo, normal, depth, lit](webgpu::rg::PassBuilder& b) {
+ b.sampled(albedo);
+ b.sampled(normal);
+ b.sampled(depth); // depth-only format, so no aspect needed
+ b.storage_write(lit); // ordered after GBuffer by the reads above
+ },
+ [this, albedo, normal, depth, lit, lights_bg](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_light_layout, {
+ c.bind(0, albedo), c.bind(1, normal), c.bind(2, depth), c.bind(3, lit),
+ }, "tiled-lighting");
+ wgpuComputePassEncoderSetPipeline(c.compute_pass, m_light_pipeline);
+ wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bg.handle(), 0, nullptr);
+ wgpuComputePassEncoderSetBindGroup(c.compute_pass, 1, lights_bg, 0, nullptr); // app-owned light list
+ wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass,
+ ceil_div(size.width, 16u), ceil_div(size.height, 16u), 1); // one workgroup per 16x16 tile
+ });
+// return `lit` to the tonemap/composite pass.
+```
+
+For a tiled light culling prepass, add a `create_transient_buffer` for the per-tile light index
+list, `storage_write` it in a cull compute pass, and `storage_read` it here. The cull pass then
+orders before lighting.
+
+### Bloom: relative-sized downsample chain
+
+The bright-pass and blur targets are half resolution, sized with `Relative` so they follow the
+HDR target through resizes. The chain is a sequence of transients, each written by one pass and
+read by the next. The final composite loads the HDR target and adds the blurred bloom on top.
+
+```cpp
+webgpu::rg::TextureDesc halfDesc {
+ .dimension = WGPUTextureDimension_2D,
+ .format = WGPUTextureFormat_RGBA16Float,
+ .sizeKind = webgpu::rg::SizeKind::Relative,
+ .scaleX = 0.5f, .scaleY = 0.5f,
+ .relativeTo = hdrColor,
+};
+auto bright = rg->create_transient_texture("bloom.bright", halfDesc);
+auto blurH = rg->create_transient_texture("bloom.blur_h", halfDesc);
+auto blurV = rg->create_transient_texture("bloom.blur_v", halfDesc);
+
+// A tiny helper: full-screen pass that samples one texture and writes another.
+auto blit_pass = [&](const char* name, webgpu::rg::TextureHandle src,
+ webgpu::rg::TextureHandle dst, WGPURenderPipeline pipe) {
+ rg->add_pass(name, webgpu::rg::PassKind::Graphics,
+ [src, dst](webgpu::rg::PassBuilder& b) { b.sampled(src); b.color(dst, 0); },
+ [this, src, pipe](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_blit_layout, { c.bind(0, src) }, "bloom");
+ wgpuRenderPassEncoderSetPipeline(c.render_pass, pipe);
+ wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+ });
+};
+
+blit_pass("bloom.threshold", hdrColor, bright, m_threshold_pipe);
+blit_pass("bloom.blurH", bright, blurH, m_blur_h_pipe);
+blit_pass("bloom.blurV", blurH, blurV, m_blur_v_pipe);
+
+// Composite: keep the HDR target, add bloom. Additive blend lives in m_add_pipe.
+rg->add_pass("bloom.composite", webgpu::rg::PassKind::Graphics,
+ [hdrColor, blurV](webgpu::rg::PassBuilder& b) {
+ b.color(hdrColor, 0, { .load = WGPULoadOp_Load }); // load, do not clear
+ b.sampled(blurV);
+ },
+ [this, blurV](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_blit_layout, { c.bind(0, blurV) }, "bloom.add");
+ wgpuRenderPassEncoderSetPipeline(c.render_pass, m_add_pipe);
+ wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+ });
+```
+
+`bright` is dead after `bloom.blurH` reads it, so the aliaser can pack `blurV` onto the same
+memory. The three half-res targets cost one or two physical textures, not three.
+
+### TAA: history plus a camera-cut hash
+
+TAA reads last frame's resolved color and blends it with the current frame. The history resource
+gives the ping-pong pair. `history_valid` handles the first frame and any reset. The hash resets
+history on a camera cut, so reprojection does not smear across the discontinuity.
+
+```cpp
+uint64_t cut = cameraCutCounter; // changes on teleport, projection swap, etc.
+auto taa = rg->create_history_texture("taa.color", colorDesc, cut);
+
+rg->add_pass("TAA", webgpu::rg::PassKind::Graphics,
+ [currentColor, velocity, taa](webgpu::rg::PassBuilder& b) {
+ b.sampled(currentColor);
+ b.sampled(velocity);
+ b.sampled(taa.prev); // last frame's result
+ b.color(taa.curr, 0); // this frame's result, becomes next frame's prev
+ },
+ [this, currentColor, velocity, taa](webgpu::rg::PassContext& c) {
+ float alpha = c.history_valid(taa) ? 0.9f : 0.0f; // 0 == ignore history this frame
+ wgpuQueueWriteBuffer(c.queue, m_taa_ubo, 0, &alpha, sizeof(alpha)); // shader reads the blend weight
+ webgpu::raii::BindGroup bg(c.device, *m_taa_layout, {
+ c.bind(0, currentColor), c.bind(1, velocity), c.bind(2, taa.prev),
+ webgpu::bind(3, m_taa_ubo, 0, sizeof(float)), // app-owned UBO
+ }, "taa");
+ wgpuRenderPassEncoderSetPipeline(c.render_pass, m_taa_pipe);
+ wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+ });
+// A later tonemap pass samples taa.curr for display.
+```
+
+Writing `taa.curr` makes the pass a sink, so it is never culled even on a frame where nothing
+downstream has been declared yet.
+
+### IBL: import an environment cube and bake a specular BRDF LUT
+
+Two resources with different lifetimes back image-based lighting. The environment cubemap is
+loaded once by the app and imported; the split-sum BRDF LUT is baked once into a persistent
+texture and reused every frame.
+
+An imported cubemap registered view-only, meaning `.texture` left null, hands back the caller's
+cube view as-is. The graph never builds a view for it, so `ctx.view()` ignores any `ViewRange` you
+declared and `ctx.view(h, mip, layer)` asserts unless both are 0. Sample it with a plain
+`sampled()` and no range.
+
+`cube()` here is not merely redundant, it fails to compile the graph: the cube check validates
+against the layer count in the import's `size`, which is 1 below, so `b.sampled(env, cube())`
+errors with "(baseLayer 0 + 6 layers) exceeds the texture's 1 layer(s)". Declare the real layer
+count in `size` and it compiles, but on a view-only import the range still selects nothing. The
+`cube()` and `cube_array()` helpers are for textures the graph builds views for.
+
+That is the view-only case specifically, not imports in general. An import that does pass its
+backing `texture` takes the ordinary path: the graph builds views from the `ViewRange` you
+declare, cube helpers included, exactly as for a graph-owned texture.
+
+```cpp
+// App owns and keeps alive a cube WGPUTextureView. Register it view-only (no backing texture).
+auto env = rg->import_texture("ibl.env",
+ { .view = m_env_cube_view, .size = { envSize, envSize, 1 }, .format = WGPUTextureFormat_RGBA16Float });
+
+// The split-sum BRDF LUT: a 2D rg16f table, baked once, then read every frame.
+auto brdfLut = rg->create_persistent_texture("ibl.brdf_lut", {
+ .dimension = WGPUTextureDimension_2D,
+ .format = WGPUTextureFormat_RG16Float,
+ .absolute = { 512, 512, 1 },
+});
+
+rg->add_pass("BakeBRDF", webgpu::rg::PassKind::Compute,
+ [brdfLut](webgpu::rg::PassBuilder& b) {
+ b.storage_write(brdfLut);
+ b.initialize(brdfLut); // hash 0: bake once, then the pass is skipped and culled
+ },
+ [this, brdfLut](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_brdf_layout, { c.bind(0, brdfLut) }, "bake-brdf");
+ wgpuComputePassEncoderSetPipeline(c.compute_pass, m_brdf_pipe);
+ wgpuComputePassEncoderSetBindGroup(c.compute_pass, 0, bg.handle(), 0, nullptr);
+ wgpuComputePassEncoderDispatchWorkgroups(c.compute_pass, 512 / 8, 512 / 8, 1);
+ });
+
+// The lighting pass consumes both. `env` is imported and `brdfLut` is persistent, so neither
+// needs an in-graph writer this frame for the reads to resolve.
+rg->add_pass("IBL", webgpu::rg::PassKind::Graphics,
+ [env, brdfLut, gbuffer_normal, lit](webgpu::rg::PassBuilder& b) {
+ b.sampled(env); // imported cube view, returned as registered
+ b.sampled(brdfLut);
+ b.sampled(gbuffer_normal);
+ b.color(lit, 0, { .load = WGPULoadOp_Load });
+ },
+ [this, env, brdfLut, gbuffer_normal](webgpu::rg::PassContext& c) {
+ webgpu::raii::BindGroup bg(c.device, *m_ibl_layout, {
+ c.bind(0, env), c.bind(1, brdfLut), c.bind(2, gbuffer_normal),
+ m_ibl_sampler->create_bind_group_entry(3), // app-owned sampler
+ }, "ibl");
+ wgpuRenderPassEncoderSetPipeline(c.render_pass, m_ibl_pipe);
+ wgpuRenderPassEncoderSetBindGroup(c.render_pass, 0, bg.handle(), 0, nullptr);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+ });
+```
+
+### Prefiltered specular: bake a mip-per-roughness cube
+
+The import above is view-only, so `sampled(env)` returns whatever view the app registered and the
+graph has no say in its mip coverage. To let the graph own the roughness chain, make the
+prefiltered specular map a graph-owned persistent cube instead. Then the range you declare is
+honored, and `cube(All, 0)` binds every roughness level.
+
+Storage textures cannot be cube, so the bake renders each face and mip as a color attachment: one
+pass per (mip, layer), each declaring its own subresource write and all gating on the same
+`initialize()` target so the whole set bakes together. Key the hash on the environment's identity
+to rebake when the sky changes.
+
+```cpp
+constexpr uint32_t kFaces = 6;
+constexpr uint32_t kRoughnessMips = 5; // roughness 0..1 across the chain
+
+auto prefiltered = rg->create_persistent_texture("ibl.prefiltered", {
+ .dimension = WGPUTextureDimension_2D, // a cube is 2D with 6 layers
+ .format = WGPUTextureFormat_RGBA16Float,
+ .absolute = { 128, 128, kFaces },
+ .mipLevelCount = kRoughnessMips, // spell the chain out, there is no 0 = all
+});
+
+uint64_t envId = m_env_cube_hash; // changes when the loaded environment changes
+for (uint32_t mip = 0; mip < kRoughnessMips; ++mip)
+ for (uint32_t face = 0; face < kFaces; ++face)
+ rg->add_pass("ibl.prefilter", webgpu::rg::PassKind::Graphics,
+ [prefiltered, mip, face](webgpu::rg::PassBuilder& b) {
+ b.color(prefiltered, 0, { .sub = { .mip = mip, .layer = face } }); // one face+mip
+ b.initialize(prefiltered, envId); // whole set bakes once, rebakes when envId changes
+ },
+ [this, mip, face](webgpu::rg::PassContext& c) { /* draw the prefiltered face at this mip */ });
+```
+
+The lighting pass then samples it as a whole-chain cube. Because `prefiltered` is graph-owned, the
+`cube()` range is built by the graph, and `mipCount = 0` means every roughness level:
+
+```cpp
+// ...in the IBL pass setup, alongside b.sampled(env) etc.:
+b.sampled(prefiltered, webgpu::rg::cube(WGPUTextureAspect_All, /*mipCount*/ 0));
+// ...in the body:
+c.bind(4, prefiltered); // the shader picks the mip: textureSampleLevel(cube, s, dir, roughness * maxMip)
+```
+
+Since the bakes gate on `initialize(prefiltered, envId)`, all `kFaces * kRoughnessMips` passes run
+the first frame and whenever `envId` changes, and are skipped and culled otherwise. The pooled cube
+survives, so the lighting pass keeps binding it.
+
+---
+
+## Debugging
+
+### The error model
+
+Authoring errors, such as reading a transient before any pass writes it or a cyclic dependency,
+do not throw. Instead:
+
+1. `compile()` poisons the graph, entering a Failed state.
+2. `compile()` returns a chain of `ErrorMessage { WGPUStringView message; ErrorMessage* next; }`,
+ null when healthy. It is `[[nodiscard]]`, so ignoring it is a compiler warning. `get_errors()`
+ returns the same chain later in the same frame, for code that does not compile the graph
+ itself. The chain lives in the frame arena, so read it before the next `begin_frame()`;
+ holding it past that asserts. Copy the text out if you need it to outlive the frame.
+3. `execute()` becomes a no-op. Nothing is recorded and nothing crashes. Anything rendered
+ outside the graph still works.
+
+A poisoned graph therefore shows as a black scene while the UI keeps running. Loop the chain
+`compile()` hands back (see [The frame loop](#the-frame-loop)); it is your only signal. Messages also appear as a red banner in the RenderGraph panel.
+
+### Troubleshooting
+
+| Symptom | Likely cause | Fix |
+| --- | --- | --- |
+| Black scene, UI still fine | Graph poisoned; `execute()` is a no-op | Read `get_errors()` or the red banner |
+| `"read before write"` | A pass reads a transient no earlier pass writes | Add or reorder the producer, or make it an import/history/initialized resource |
+| `"accessed at mip/layer …"`, `"declares a view of …"`, `"range … covers bytes …"`, `"starts at byte …"`, `"not 256-byte aligned"` | A declared range does not fit the resource: a `baseMip`/`baseLayer` past the end, a `ViewRange` count that overruns, a `copy_buffer` or `BufferRange` past a buffer's end, or a bind offset off the 256-byte alignment | Fix the range on the `b.*()` call. `mipCount`/`layerCount`/`size` of 0 means all remaining and always fits |
+| `"cycle"` but no real cycle | Whole-resource tracking: two passes touch disjoint ranges of the same two resources in opposite directions | Split into two handles. Transients are pooled, extra ones are nearly free |
+| Pass missing from panel/timings | Culled, nothing reads its output | `force_keep()` or `initialize()`, or wire a real consumer |
+| Crash or garbage in a bind group | Broke Rule 2 (pre-built bind group over a transient) | Build it inside the execute body from `ctx.bind` |
+| Won't compile at `add_pass` | Broke Rule 1 (non-trivial capture) | Capture raw handles and values, not `std::string`/RAII |
+| Bug vanishes with `compile(false)` | A transient lifetime is mis-declared, usually a read that was never declared because the object was bound raw from a cached view | Search pass bodies for `webgpu::bind(` on a view that traces back to a graph transient; declare the read and use `ctx.bind` instead (see [Undeclared reads and aliasing](#undeclared-reads-and-aliasing)) |
+
+### The RenderGraph panel
+
+Open it from the floating toolbar (project-diagram icon). Every frame it shows FPS and frame
+time, the graph's own CPU cost (`compile`/`realize`/`execute` µs), and the red compile-failed
+banner. Four tabs:
+
+- Graph: the frame's DAG, one box per pass, colored by kind, with dependency columns and sinks
+ on the right. Reads on the left edge, writes on the right; hollow pins are external inputs;
+ history edges are dashed. First place to look for "why does B run before A" or "why was my
+ pass culled".
+- Lifetimes: per-resource first-use to last-use spans. Shows how aliasing packed disjoint
+ lifetimes onto shared memory and how much VRAM it saved.
+- Memory: every pool and the graph's total VRAM.
+- Timings: per-pass GPU time over 256 frames (needs profiling, below).
+
+### GPU profiling
+
+Opt-in. Needs the `TimestampQuery` device feature.
+
+```cpp
+rg->execute(device, queue, encoder, /*enableProfiling*/ true);
+// ...submit...
+rg->collect_gpu_timings(); // kicks async readback; safe to call always
+```
+
+Results arrive via the instance event pump a few frames later, not the same frame. The panel
+header and Timings tab display them as they come in.
diff --git a/nucleus/utils/defer.h b/nucleus/utils/defer.h
new file mode 100644
index 00000000..a61e351c
--- /dev/null
+++ b/nucleus/utils/defer.h
@@ -0,0 +1,41 @@
+/*****************************************************************************
+ * Copyright (C) 2026 Matthias Huerbe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+ #pragma once
+
+#ifndef DEFER_CONCAT_INTERNAL
+#define DEFER_CONCAT_INTERNAL(x, y) x##y
+#endif
+#ifndef DEFER_CONCAT
+#define DEFER_CONCAT(x, y) DEFER_CONCAT_INTERNAL(x, y)
+#endif
+
+#if defined(_MSC_VER)
+#define DEFER_FORCEINLINE __forceinline
+#else
+#define DEFER_FORCEINLINE inline __attribute__((always_inline))
+#endif
+
+namespace InternalDefer {
+template
+struct Deferrer : F {
+ DEFER_FORCEINLINE constexpr Deferrer(F&& f) : F(static_cast(f)) {}
+ DEFER_FORCEINLINE constexpr ~Deferrer() { F::operator()(); }
+};
+}
+
+#define defer [[maybe_unused]] const ::InternalDefer::Deferrer DEFER_CONCAT(_defer_, __COUNTER__) = [&](void) -> void
diff --git a/unittests/webgpu_engine/CMakeLists.txt b/unittests/webgpu_engine/CMakeLists.txt
index 9fe24a89..1381c8fd 100644
--- a/unittests/webgpu_engine/CMakeLists.txt
+++ b/unittests/webgpu_engine/CMakeLists.txt
@@ -25,6 +25,7 @@ alp_add_unittest(unittests_webgpu_engine
test_GpuShaderFunctions.cpp
test_ShaderPreprocessor.cpp
test_wgpu_string.cpp
+ test_RenderGraph.cpp
)
target_link_libraries(unittests_webgpu_engine PUBLIC webgpu_engine)
diff --git a/unittests/webgpu_engine/test_RenderGraph.cpp b/unittests/webgpu_engine/test_RenderGraph.cpp
new file mode 100644
index 00000000..415530bc
--- /dev/null
+++ b/unittests/webgpu_engine/test_RenderGraph.cpp
@@ -0,0 +1,5021 @@
+/*****************************************************************************
+ * Copyright (C) 2026 Matthias Huerbe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+
+#include "UnittestWebgpuContext.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using namespace webgpu;
+using namespace webgpu::rg;
+using webgpu::rg::Internal::AccessType;
+using webgpu::rg::Internal::PassNode;
+using webgpu::rg::Internal::ResourceAccess;
+using webgpu::rg::Internal::storage;
+
+
+static_assert(!std::is_default_constructible_v);
+static_assert(!std::is_default_constructible_v);
+static_assert(!std::is_default_constructible_v);
+static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v);
+static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v);
+static_assert(!std::is_copy_constructible_v && !std::is_move_constructible_v);
+
+// handles stay cheap values, and a typed handle converts to the base but never back
+static_assert(std::is_trivially_copyable_v);
+static_assert(std::is_convertible_v);
+static_assert(!std::is_convertible_v);
+static_assert(!std::is_convertible_v);
+static_assert(!std::is_convertible_v);
+static_assert(!static_cast(ResourceHandle {}));
+static_assert(!static_cast(TextureHandle {}));
+static_assert(static_cast(ResourceHandle { 1, ResourceKind::Texture, 7 }));
+static_assert(ResourceHandle { 1, ResourceKind::Texture, 7 } == ResourceHandle { 1, ResourceKind::Texture, 7 });
+static_assert(!(ResourceHandle { 1, ResourceKind::Texture, 7 } == ResourceHandle { 1, ResourceKind::Texture, 8 }));
+static_assert(!(ResourceHandle { 1, ResourceKind::Texture, 7 } == ResourceHandle { 1, ResourceKind::Buffer, 7 }));
+
+struct TestGraph {
+ GraphAllocator* allocator = create_allocator();
+ RenderGraph* rg;
+
+ TestGraph()
+ {
+ begin_frame(allocator);
+ rg = start_recording(allocator);
+ }
+ ~TestGraph() { destroy_allocator(allocator); }
+
+ TestGraph(const TestGraph&) = delete;
+ TestGraph& operator=(const TestGraph&) = delete;
+
+ TextureHandle transient(std::string_view id, uint32_t mipLevelCount = 1)
+ {
+ TextureDesc desc {};
+ desc.dimension = WGPUTextureDimension_2D;
+ desc.format = WGPUTextureFormat_RGBA8Unorm;
+ desc.absolute = { 16, 16, 1 };
+ desc.mipLevelCount = mipLevelCount;
+ return rg->create_transient_texture(id, desc);
+ }
+
+ BufferHandle transient_buffer(std::string_view id, uint64_t size = 64)
+ {
+ BufferDesc desc {};
+ desc.size = size;
+ return rg->create_transient_buffer(id, desc);
+ }
+};
+
+// declare a compute producer for buf so a later copy_buffer src is not a read-before-write error
+static void add_buffer_producer(RenderGraph* rg, BufferHandle buf)
+{
+ rg->add_pass(
+ "produce", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(buf); }, [](PassContext&) {});
+}
+
+// 16x16 RGBA8Unorm 2D, matching TestGraph::transient
+static TextureDesc tex2d()
+{
+ TextureDesc d {};
+ d.dimension = WGPUTextureDimension_2D;
+ d.format = WGPUTextureFormat_RGBA8Unorm;
+ d.absolute = { 16, 16, 1 };
+ return d;
+}
+
+// fake non-null view. compile() only reads the `imported` flag, and these tests stop at compile().
+// import_buffer would not do: it calls wgpuBufferGetSize.
+static TextureHandle import_tex(RenderGraph* rg, std::string_view id)
+{
+ return rg->import_texture(id, { .view = (WGPUTextureView)0x1, .size = { 16, 16, 1 }, .format = WGPUTextureFormat_RGBA8Unorm });
+}
+
+// surviving passes in execution order after compile()
+static std::vector pass_order(RenderGraph* rg)
+{
+ std::vector v;
+ for (PassNode* p = storage(rg)->m_passes; p; p = p->next)
+ v.emplace_back(p->id.name.data, p->id.name.length);
+ return v;
+}
+static int idx_of(const std::vector& v, const char* n)
+{
+ for (int i = 0; i < static_cast(v.size()); ++i)
+ if (v[i] == n)
+ return i;
+ return -1;
+}
+
+// ---------------------------------------------------------------------------------------------------
+// ViewRange presets and cube-view validation.
+
+// pins the specific diagnostic, not just "some error fired"
+static bool error_mentions(RenderGraph* rg, std::string_view needle)
+{
+ for (ErrorMessage* e = rg->get_errors(); e; e = e->next)
+ if (std::string_view(e->message.data, e->message.length).find(needle) != std::string_view::npos)
+ return true;
+ return false;
+}
+
+
+TEST_CASE("rg::cube preset builds a 6-layer cube view", "[RenderGraph]")
+{
+ constexpr ViewRange c = cube();
+ STATIC_REQUIRE(c.dim == WGPUTextureViewDimension_Cube);
+ STATIC_REQUIRE(c.layerCount == 6); // the whole point: the bare ViewRange default (1) is an invalid cube
+ STATIC_REQUIRE(c.mipCount == 1); // default stays single-mip
+ STATIC_REQUIRE(c.aspect == WGPUTextureAspect_All);
+
+ // aspect passes through, for a depth shadow cube
+ STATIC_REQUIRE(cube(WGPUTextureAspect_DepthOnly).aspect == WGPUTextureAspect_DepthOnly);
+ // mipCount 0 == all remaining, for a prefiltered IBL env cube spanning its whole mip chain
+ STATIC_REQUIRE(cube(WGPUTextureAspect_All, 0).mipCount == 0);
+}
+
+TEST_CASE("rg::cube_array preset builds a 6*N-layer cube-array view", "[RenderGraph]")
+{
+ STATIC_REQUIRE(cube_array(1).layerCount == 6);
+ STATIC_REQUIRE(cube_array(3).layerCount == 18);
+ STATIC_REQUIRE(cube_array(3).dim == WGPUTextureViewDimension_CubeArray);
+ STATIC_REQUIRE(cube_array(3).mipCount == 1);
+ STATIC_REQUIRE(cube_array(2, WGPUTextureAspect_All, 0).mipCount == 0);
+}
+
+TEST_CASE("rg::whole preset is all mips and all layers with an inferred dimension", "[RenderGraph]")
+{
+ constexpr ViewRange w = whole();
+ STATIC_REQUIRE(w.dim == WGPUTextureViewDimension_Undefined); // inferred from the texture
+ STATIC_REQUIRE(w.mipCount == 0); // 0 == all remaining
+ STATIC_REQUIRE(w.layerCount == 0);
+}
+
+// a `layers`-layer 2D array, written by a producer so the sampled consumer is not a read-before-write,
+// then sampled through `range`.
+static TextureHandle cube_source(TestGraph& g, uint32_t layers, WGPUTextureDimension dim = WGPUTextureDimension_2D)
+{
+ TextureDesc desc {};
+ desc.dimension = dim;
+ desc.format = WGPUTextureFormat_RGBA8Unorm;
+ desc.absolute = { 16, 16, layers };
+ auto h = g.rg->create_transient_texture("cubesrc", desc);
+ g.rg->add_pass(
+ "produce", PassKind::Graphics,
+ [h, layers, dim](PassBuilder& b) {
+ // a 3D texture has no array layers to attach per-layer, so one attachment defines it
+ uint32_t n = (dim == WGPUTextureDimension_2D) ? layers : 1;
+ for (uint32_t l = 0; l < n; ++l)
+ b.color(h, l, { .clear = { 0, 0, 0, 1 }, .sub = { .layer = l } });
+ },
+ [](PassContext&) {});
+ return h;
+}
+
+TEST_CASE("compile - a valid 6-layer cube view passes validation", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 6);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, cube());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("compile - a cube view without exactly 6 layers is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 4);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 4, .dim = WGPUTextureViewDimension_Cube });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "needs exactly 6")); // named error, not an opaque device error
+}
+
+TEST_CASE("compile - a cube-array view whose layer count is not a multiple of 6 is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 8);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 8, .dim = WGPUTextureViewDimension_CubeArray });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "positive multiple of 6"));
+}
+
+// the overrun check sits after the exactly-6 / multiple-of-6 arms, so reaching it needs a view those
+// accept: a well-formed 6-layer cube starting too late in an 8-layer texture. 8 is the widest source
+// here, since cube_source declares one color() per layer and kMaxColorAttachments caps that.
+TEST_CASE("compile - a well-formed cube view starting past the layer count is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 8);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, cube().at(0, 4)); // 4 + 6 > 8
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "exceeds the texture's 8 layer(s)"));
+ REQUIRE_FALSE(error_mentions(g.rg, "needs exactly 6")); // the layer count itself is fine
+}
+
+// cube validation reads the layer count off the import's declared size, so a caller-owned cube view
+// registered as the 1-layer default is rejected however cube-shaped the real view is. documented in
+// docs/rendergraph.md, the IBL example: sample a view-only import with a plain sampled(), no range.
+TEST_CASE("compile - a cube view on a view-only import is rejected on the declared layer count", "[RenderGraph]")
+{
+ TestGraph g;
+ auto env = import_tex(g.rg, "ibl.env"); // size { 16, 16, 1 }, so one layer
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [env](PassBuilder& b) {
+ b.sampled(env, cube());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ // the overrun arm, not the covers-N one: cube() sets exactly 6 layers, so the count is well formed
+ // and it is the 1-layer import it does not fit
+ REQUIRE(error_mentions(g.rg, "exceeds the texture's 1 layer(s)"));
+ REQUIRE_FALSE(error_mentions(g.rg, "needs exactly 6"));
+}
+
+// the same import declaring its real layer count compiles: the check is on the declared size, not on
+// imports as such.
+TEST_CASE("compile - a cube view on an import declaring 6 layers passes", "[RenderGraph]")
+{
+ TestGraph g;
+ auto env = g.rg->import_texture(
+ "ibl.env", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm });
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [env](PassBuilder& b) {
+ b.sampled(env, cube());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+// the prefiltered specular cube from docs/rendergraph.md: a graph-owned persistent cube baked one
+// (mip, layer) at a time via color(), every bake pass gating on initialize() of the same target, then
+// sampled as a whole-chain cube. proves the doc's three load-bearing claims compile together: many
+// initialize() passes share one target, a per-layer color() bake coexists with a cube() read of the
+// same resource, and cube(All, 0) accepts the full mip chain.
+TEST_CASE("compile - a prefiltered specular cube bakes per (mip, layer) and samples as a full-chain cube", "[RenderGraph]")
+{
+ constexpr uint32_t kFaces = 6;
+ constexpr uint32_t kRoughnessMips = 3;
+
+ TestGraph g;
+ TextureDesc desc {};
+ desc.dimension = WGPUTextureDimension_2D;
+ desc.format = WGPUTextureFormat_RGBA16Float;
+ desc.absolute = { 128, 128, kFaces };
+ desc.mipLevelCount = kRoughnessMips;
+ auto prefiltered = g.rg->create_persistent_texture("ibl.prefiltered", desc);
+
+ for (uint32_t mip = 0; mip < kRoughnessMips; ++mip)
+ for (uint32_t face = 0; face < kFaces; ++face)
+ g.rg->add_pass(
+ "prefilter.bake", PassKind::Graphics,
+ [prefiltered, mip, face](PassBuilder& b) {
+ b.color(prefiltered, 0, { .sub = { .mip = mip, .layer = face } });
+ b.initialize(prefiltered, /*hash*/ 42); // one env identity, whole set bakes together
+ },
+ [](PassContext&) {});
+
+ auto lit = g.transient("lit");
+ g.rg->add_pass(
+ "IBL", PassKind::Graphics,
+ [prefiltered, lit](PassBuilder& b) {
+ b.sampled(prefiltered, cube(WGPUTextureAspect_All, /*mipCount*/ 0)); // every roughness level
+ b.color(lit, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("compile - a cube view on a storage access is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 6);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.storage_read(h, cube());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "cannot be cube"));
+}
+
+TEST_CASE("compile - a cube view on a non-2D texture is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 6, WGPUTextureDimension_3D);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, cube());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "not a 2D texture"));
+}
+
+// the IBL environment path. import_texture defaults dimension to 2D precisely so node_layers sees the
+// imported extent's 6 layers, not the Undefined default's 1.
+TEST_CASE("compile - an imported 6-layer cube view passes validation", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = g.rg->import_texture("envcube", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm });
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, cube());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+// an imported texture's layer count is its extent's, not 1, which only holds if the node knows it is 2D
+TEST_CASE("compile - an imported texture reports its real layer count to cube validation", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = g.rg->import_texture("arr", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 4 }, .format = WGPUTextureFormat_RGBA8Unorm });
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 0, .dim = WGPUTextureViewDimension_Cube }); // 0 == all remaining
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "covers 4 layer(s)")); // not "1 layer(s)"
+}
+
+// a cube view of an imported 3D texture is rejected as non-2D
+TEST_CASE("compile - an imported 3D texture is rejected for a cube view", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = g.rg->import_texture("vol", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm, .dimension = WGPUTextureDimension_3D });
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, cube());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "not a 2D texture"));
+}
+
+// ---------------------------------------------------------------------------------------------------
+// access range validation. ranges are fully declared in setup, so compile() rejects one that does not
+// fit its resource.
+
+TEST_CASE("compile - an access past the texture's mip count is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = g.transient("tex"); // 1 mip
+ g.rg->add_pass(
+ "write", PassKind::Graphics,
+ [h](PassBuilder& b) {
+ b.color(h, 0, { .sub = { .mip = 3 } });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "accessed at mip 3 but has 1 mip(s)"));
+}
+
+TEST_CASE("compile - an access past the texture's layer count is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 6);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, { .baseLayer = 9 });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "accessed at layer 9 but has 6 layer(s)"));
+}
+
+TEST_CASE("compile - a view whose mip range ends past the chain is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = g.transient("chain", 2);
+ g.rg->add_pass(
+ "write", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.storage_write(h, ViewRange { .mipCount = 3, .layerCount = 1 });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "view of 3 mip(s) from mip 0 but has 2 mip(s)"));
+}
+
+// the layer half. a non-cube viewDim keeps this out of cube validation, so the range check must catch it.
+TEST_CASE("compile - a view whose layer range ends past the array is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = cube_source(g, 2);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.sampled(h, ViewRange { .mipCount = 1, .layerCount = 3 }); // 0 + 3 > 2
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "view of 3 layer(s) from layer 0 but has 2 layer(s)"));
+}
+
+// 0 means all remaining, so rg::whole() stays clean on any mip chain
+TEST_CASE("compile - a whole() view over a mip chain passes range validation", "[RenderGraph]")
+{
+ TestGraph g;
+ auto h = g.transient("chain", 4);
+ g.rg->add_pass(
+ "write", PassKind::Compute,
+ [h](PassBuilder& b) {
+ b.storage_write(h, whole());
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("compile - a copy_texture past the dst's layer count is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto src = g.transient("src");
+ auto dst = g.transient("dst"); // 1 layer
+ g.rg->add_pass(
+ "produce", PassKind::Graphics, [src](PassBuilder& b) { b.color(src, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [src, dst](PassBuilder& b) {
+ b.copy_texture(src, dst, {}, { .layer = 2 });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "accessed at layer 2 but has 1 layer(s)"));
+}
+
+// copy_buffer resolves the size at declare and records it on both sides, so the dst range is checked
+// against the dst buffer
+TEST_CASE("compile - a copy_buffer range that overruns the dst is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto bufA = g.transient_buffer("bufA", 64);
+ auto bufB = g.transient_buffer("bufB", 64);
+
+ add_buffer_producer(g.rg, bufA);
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [bufA, bufB](PassBuilder& b) {
+ b.copy_buffer(bufA, bufB, 0, /*dstOffset*/ 32, /*size*/ 64); // 32 + 64 > 64
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "covers bytes [32, 96) but the buffer is 64 byte(s)"));
+}
+
+// size 0 expands against the src at declare time, so an offset src copy lands a smaller resolved size
+// on both accesses and stays inside a same-sized dst
+TEST_CASE("compile - a whole copy_buffer from an offset src fits a same-sized dst", "[RenderGraph]")
+{
+ TestGraph g;
+ auto bufA = g.transient_buffer("bufA", 64);
+ auto bufB = g.transient_buffer("bufB", 64);
+
+ add_buffer_producer(g.rg, bufA);
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [bufA, bufB](PassBuilder& b) {
+ b.copy_buffer(bufA, bufB, /*srcOffset*/ 32, 0, /*size*/ 0); // resolves to 32 bytes
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+// a declared bind range shares the copy range's bounds check, so an overrun is caught at compile
+TEST_CASE("compile - a bind range that overruns the buffer is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto buf = g.transient_buffer("buf", 64);
+
+ add_buffer_producer(g.rg, buf);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [buf](PassBuilder& b) {
+ b.storage_read(buf, { .offset = 32, .size = 64 }); // 32 + 64 > 64
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "covers bytes [32, 96) but the buffer is 64 byte(s)"));
+}
+
+// bind offsets must hit the spec-default 256-byte uniform/storage offset alignment, or Dawn rejects
+// the bind group at execute with a far less local message
+TEST_CASE("compile - a bind range with a misaligned offset is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto buf = g.transient_buffer("buf", 64);
+
+ add_buffer_producer(g.rg, buf);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [buf](PassBuilder& b) {
+ b.uniform(buf, { .offset = 16, .size = 32 });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "not 256-byte aligned"));
+}
+
+// an offset at the end resolves to a zero-byte range, which the > bounds check alone would miss
+TEST_CASE("compile - a bind range starting at the buffer's end is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto buf = g.transient_buffer("buf", 512);
+
+ add_buffer_producer(g.rg, buf);
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [buf](PassBuilder& b) {
+ b.storage_read(buf, { .offset = 512 });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "starts at byte 512 but the buffer is 512 byte(s)"));
+}
+
+// ---------------------------------------------------------------------------------------------------
+// compile() reports through the error chain rather than asserting, so these are observable in any build.
+
+TEST_CASE("compile - a cyclic relativeTo chain is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ // the public API cannot author a cycle, relativeTo only ever points backwards. build the loop on the
+ // nodes to reach resolve_size's recursion guard.
+ auto a = g.transient("a");
+ TextureDesc rel {};
+ rel.dimension = WGPUTextureDimension_2D;
+ rel.format = WGPUTextureFormat_RGBA8Unorm;
+ rel.sizeKind = SizeKind::Relative;
+ rel.relativeTo = a;
+ auto b = g.rg->create_transient_texture("b", rel);
+
+ Internal::ResourceNode* an = Internal::find_node(g.rg, a);
+ an->sizeKind = SizeKind::Relative; // close the loop: a sizes against b, b sizes against a
+ an->relativeToHandle = b;
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "cyclic relativeTo chain"));
+}
+
+// ---------------------------------------------------------------------------------------------------
+// foreign/stale handle backstops, not authoring diagnostics: an out-of-range id would index compile()'s
+// phase-1/2 scratch tables out of bounds. the builder's Q_ASSERTs stop such a handle being declared at
+// all, hence the white-box stamping. a null byId witnesses that compile() bailed before building the
+// tables the bad id would have indexed.
+
+TEST_CASE("compile - an out-of-range access handle is rejected before the scratch tables are built", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+ g.rg->add_pass(
+ "write", PassKind::Graphics,
+ [tex](PassBuilder& b) {
+ b.color(tex, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ // an id past next_resource_id is the case that would read byId[] out of bounds
+ storage(g.rg)->m_passes->accesses[0].handle.id = 9999;
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "uses an invalid resource handle (id 9999)"));
+ REQUIRE(storage(g.rg)->byId == nullptr); // bailed before byId[9999] could be written
+}
+
+TEST_CASE("compile - an access handle from another graph is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+ g.rg->add_pass(
+ "write", PassKind::Graphics,
+ [tex](PassBuilder& b) {
+ b.color(tex, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ // in range, but stamped with another graph's generation: the id would resolve to the wrong node
+ storage(g.rg)->m_passes->accesses[0].handle.generation = storage(g.rg)->generation + 1;
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "uses an invalid resource handle"));
+ REQUIRE(storage(g.rg)->byId == nullptr);
+}
+
+TEST_CASE("compile - a foreign initialize() target handle is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto pers = g.rg->create_persistent_texture("pers", tex2d());
+ g.rg->add_pass(
+ "bake", PassKind::Graphics,
+ [pers](PassBuilder& b) {
+ b.color(pers, 0);
+ b.initialize(pers);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ // only the init target goes stale, so the access loop passes and the init loop must catch this
+ storage(g.rg)->m_passes->initTargets[0].target.generation = storage(g.rg)->generation + 1;
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "initialize() target handle"));
+ REQUIRE(storage(g.rg)->byId == nullptr);
+}
+
+TEST_CASE("compile - a foreign relativeTo handle is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto base = g.transient("base");
+ TextureDesc rel {};
+ rel.dimension = WGPUTextureDimension_2D;
+ rel.format = WGPUTextureFormat_RGBA8Unorm;
+ rel.sizeKind = SizeKind::Relative;
+ rel.relativeTo = base;
+ auto scaled = g.rg->create_transient_texture("scaled", rel);
+
+ // resolve_size() indexes relativeTo unguarded, so the prologue is the only thing stopping an
+ // out-of-bounds read
+ Internal::find_node(g.rg, scaled)->relativeToHandle.generation = storage(g.rg)->generation + 1;
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "relativeTo"));
+ REQUIRE(error_mentions(g.rg, "from another graph or a previous frame"));
+ REQUIRE(storage(g.rg)->byId == nullptr);
+}
+
+TEST_CASE("compile - a second compile() on the same graph is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ REQUIRE(g.rg->compile() == nullptr);
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "already compiled"));
+}
+
+// ---------------------------------------------------------------------------------------------------
+// explicit color-attachment slots
+
+// the ColorAttachment access for `h` in `p`, or null when the pass declares none
+static const ResourceAccess* color_access(PassNode* p, ResourceHandle h)
+{
+ for (uint32_t i = 0; i < p->accessCount; ++i)
+ if (p->accesses[i].type == AccessType::ColorAttachment && p->accesses[i].handle.id == h.id)
+ return &p->accesses[i];
+ return nullptr;
+}
+
+static uint32_t count_access(PassNode* p, AccessType t)
+{
+ uint32_t n = 0;
+ for (uint32_t i = 0; i < p->accessCount; ++i)
+ if (p->accesses[i].type == t)
+ ++n;
+ return n;
+}
+
+TEST_CASE("PassBuilder::color - the declared slot is carried on the access", "[RenderGraph]")
+{
+ TestGraph g;
+ auto a = g.transient("a");
+ auto b = g.transient("b");
+
+ g.rg->add_pass(
+ "mrt", PassKind::Graphics,
+ [a, b](PassBuilder& pb) {
+ pb.color(a, 3);
+ pb.color(b, 1);
+ pb.force_keep();
+ },
+ [](PassContext&) {});
+
+ PassNode* p = storage(g.rg)->m_passes;
+ REQUIRE(color_access(p, a)->colorIndex == 3);
+ REQUIRE(color_access(p, b)->colorIndex == 1); // declared second, but slot 1
+}
+
+// release-only: these feed color()/resolve() an author error the builder guards with Q_ASSERT. in debug
+// the abort IS the contract, so only the release backstop is observable here.
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder::color - a slot declared twice in one pass is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto a = g.transient("a");
+ auto b = g.transient("b");
+
+ g.rg->add_pass(
+ "dup", PassKind::Graphics,
+ [a, b](PassBuilder& pb) {
+ pb.color(a, 0);
+ pb.color(b, 0); // same slot -> rejected, access not recorded
+ pb.force_keep();
+ },
+ [](PassContext&) {});
+
+ PassNode* p = storage(g.rg)->m_passes;
+ REQUIRE(count_access(p, AccessType::ColorAttachment) == 1);
+ REQUIRE(color_access(p, a) != nullptr);
+ REQUIRE(color_access(p, b) == nullptr); // the duplicate never bound
+}
+#endif
+
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder::color - a slot at or past kMaxColorAttachments is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto a = g.transient("a");
+
+ g.rg->add_pass(
+ "oob", PassKind::Graphics,
+ [a](PassBuilder& pb) {
+ pb.color(a, webgpu::rg::Internal::kMaxColorAttachments);
+ pb.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::ColorAttachment) == 0);
+}
+#endif
+
+TEST_CASE("PassBuilder::resolve - the target carries its src color slot", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc msaaDesc = tex2d();
+ msaaDesc.sampleCount = 4;
+ auto msaa = g.rg->create_transient_texture("msaa", msaaDesc);
+ auto target = g.transient("target");
+
+ g.rg->add_pass(
+ "fwd", PassKind::Graphics,
+ [msaa, target](PassBuilder& b) {
+ b.color(msaa, 2);
+ b.resolve(msaa, target);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ PassNode* p = storage(g.rg)->m_passes;
+ bool found = false;
+ for (uint32_t i = 0; i < p->accessCount; ++i)
+ if (p->accesses[i].type == AccessType::ResolveAttachment && p->accesses[i].handle.id == target.id) {
+ REQUIRE(p->accesses[i].colorIndex == 2); // the slot of the color() it resolves, not 0
+ found = true;
+ }
+ REQUIRE(found);
+}
+
+// pairing does not depend on declaration adjacency: an unrelated color() may sit between the two
+TEST_CASE("PassBuilder::resolve - src pairing does not depend on declaration adjacency", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc msaaDesc = tex2d();
+ msaaDesc.sampleCount = 4;
+ auto msaa = g.rg->create_transient_texture("msaa", msaaDesc);
+ auto other = g.transient("other");
+ auto target = g.transient("target");
+
+ g.rg->add_pass(
+ "fwd", PassKind::Graphics,
+ [msaa, other, target](PassBuilder& b) {
+ b.color(msaa, 0);
+ b.color(other, 1); // declared between the color() and its resolve()
+ b.resolve(msaa, target);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ PassNode* p = storage(g.rg)->m_passes;
+ bool found = false;
+ for (uint32_t i = 0; i < p->accessCount; ++i)
+ if (p->accesses[i].type == AccessType::ResolveAttachment && p->accesses[i].handle.id == target.id) {
+ REQUIRE(p->accesses[i].colorIndex == 0); // msaa's slot, not the adjacent other's
+ found = true;
+ }
+ REQUIRE(found);
+}
+
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder::resolve - a src that is not a color() in this pass is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto notAnAttachment = g.transient("nope");
+ auto target = g.transient("target");
+
+ g.rg->add_pass(
+ "fwd", PassKind::Graphics,
+ [notAnAttachment, target](PassBuilder& b) {
+ b.resolve(notAnAttachment, target); // src was never declared via color()
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::ResolveAttachment) == 0);
+}
+#endif
+
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder::resolve - two resolve targets for one color slot are rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc msaaDesc = tex2d();
+ msaaDesc.sampleCount = 4;
+ auto msaa = g.rg->create_transient_texture("msaa", msaaDesc);
+ auto t1 = g.transient("t1");
+ auto t2 = g.transient("t2");
+
+ g.rg->add_pass(
+ "fwd", PassKind::Graphics,
+ [msaa, t1, t2](PassBuilder& b) {
+ b.color(msaa, 0);
+ b.resolve(msaa, t1);
+ b.resolve(msaa, t2); // same slot resolved twice -> rejected
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::ResolveAttachment) == 1);
+}
+#endif
+
+// the remaining builder guards, same release-only reasoning. unlike color()/resolve() these do NOT drop
+// the declaration, so the release behavior worth pinning is that the access lands anyway.
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder - reading and writing one resource in a pass is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+
+ g.rg->add_pass(
+ "rw", PassKind::Graphics,
+ [tex](PassBuilder& b) {
+ b.sampled(tex); // same subresource, read and written in one pass: the graph cannot order these
+ b.color(tex, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ // both accesses are recorded: the guard traps, it does not filter
+ PassNode* p = storage(g.rg)->m_passes;
+ REQUIRE(count_access(p, AccessType::Sampled) == 1);
+ REQUIRE(count_access(p, AccessType::ColorAttachment) == 1);
+}
+#endif
+
+// the Transfer exemption is not release-only: each copy is its own usage scope, so this stays silent in
+// debug too. pins the access count that exemption produces.
+TEST_CASE("PassBuilder - a Transfer pass may read and write one resource", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex", /*mipLevelCount*/ 2);
+
+ g.rg->add_pass(
+ "produce", PassKind::Graphics, [tex](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [tex](PassBuilder& b) {
+ b.copy_texture(tex, tex, {}, { .mip = 1 }); // src and dst are the same handle, no conflict trap
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ PassNode* copyPass = storage(g.rg)->m_passes->next;
+ REQUIRE(count_access(copyPass, AccessType::CopySrc) == 1);
+ REQUIRE(count_access(copyPass, AccessType::CopyDst) == 1);
+}
+
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder::depth_stencil - a second depth-stencil attachment is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc dsDesc = tex2d();
+ dsDesc.format = WGPUTextureFormat_Depth32Float;
+ auto d1 = g.rg->create_transient_texture("d1", dsDesc);
+ auto d2 = g.rg->create_transient_texture("d2", dsDesc);
+
+ g.rg->add_pass(
+ "shadow", PassKind::Graphics,
+ [d1, d2](PassBuilder& b) {
+ b.depth_stencil(d1);
+ b.depth_stencil(d2); // a render pass has one depth-stencil slot
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ // no early return, so both land and execute() would silently let the last one win
+ REQUIRE(count_access(storage(g.rg)->m_passes, AccessType::DepthStencilAttachment) == 2);
+}
+#endif
+
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder::initialize - the same target twice in one pass is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ BufferDesc bd {};
+ bd.size = 64;
+ auto p = g.rg->create_persistent_buffer("pers", bd);
+
+ g.rg->add_pass(
+ "bake", PassKind::Compute,
+ [p](PassBuilder& b) {
+ b.storage_write(p);
+ b.initialize(p);
+ b.initialize(p); // duplicate: asserted, but not dropped
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(storage(g.rg)->m_passes->initCount == 2);
+}
+#endif
+
+#ifdef QT_NO_DEBUG
+TEST_CASE("PassBuilder::initialize - a target past kMaxInitTargets is dropped", "[RenderGraph]")
+{
+ TestGraph g;
+ BufferDesc bd {};
+ bd.size = 64;
+ // buffers, not textures: kMaxColorAttachments would cap a multi-target Graphics pass at 8 first
+ constexpr uint32_t kOver = Internal::PassNode::kMaxInitTargets + 1;
+ std::vector targets;
+ std::vector names(kOver);
+ for (uint32_t i = 0; i < kOver; ++i) {
+ names[i] = "pers" + std::to_string(i);
+ targets.push_back(g.rg->create_persistent_buffer(names[i], bd));
+ }
+
+ g.rg->add_pass(
+ "bake", PassKind::Compute,
+ [&targets](PassBuilder& b) {
+ for (BufferHandle t : targets) {
+ b.storage_write(t);
+ b.initialize(t);
+ }
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ // the 9th target is dropped rather than overrunning the fixed array
+ REQUIRE(storage(g.rg)->m_passes->initCount == Internal::PassNode::kMaxInitTargets);
+}
+#endif
+
+TEST_CASE("RenderGraph - valid graph compiles clean", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+
+ g.rg->add_pass(
+ "write", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(tex, 0);
+ b.force_keep(); // nothing reads tex; keep the pass past culling
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ // keeps the foreign-handle tests honest: they assert byId stays null when compile() bails, so a clean
+ // compile must be what populates it
+ REQUIRE(storage(g.rg)->byId != nullptr);
+}
+
+TEST_CASE("RenderGraph - read before write is reported as an error", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+
+ // reads a transient no pass ever writes -> compile() must poison the graph
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.sampled(tex);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ // nobody declared a write anywhere, which is the first of the three read-before-write diagnoses
+ REQUIRE(error_mentions(g.rg, "that no pass ever writes"));
+}
+
+// the third read-before-write diagnosis: a writer exists, but the reader is declared first, so no RAW
+// edge forms and the writer is culled instead of being pulled ahead of it.
+TEST_CASE("RenderGraph - reading before a later-declared writer names the ordering diagnosis", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+
+ g.rg->add_pass( // declared first, so at sweep time tex has no producer to depend on
+ "read", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.sampled(tex);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "write", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "before any pass writes it"));
+ REQUIRE_FALSE(error_mentions(g.rg, "that no pass ever writes")); // a writer does exist
+}
+
+
+TEST_CASE("RenderGraph - single clear+discard attachment is inferred transient", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+
+ g.rg->add_pass(
+ "write", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(tex, 0, { .store = WGPUStoreOp_Discard });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(storage(g.rg)->transientCount == 1);
+}
+
+
+TEST_CASE("RenderGraph - sampled attachment is not transient", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "write", PassKind::Graphics,
+ [&](PassBuilder& b) { b.color(tex, 0); },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "read", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(tex);
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(storage(g.rg)->transientCount == 0);
+}
+
+TEST_CASE("RenderGraph - consumer runs after producer across a deduped edge", "[RenderGraph]")
+{
+ TestGraph g;
+ auto a = g.transient("a");
+ auto b = g.transient("b");
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "producer", PassKind::Graphics,
+ [&](PassBuilder& pb) {
+ pb.color(a, 0);
+ pb.color(b, 1);
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "consumer", PassKind::Graphics,
+ [&](PassBuilder& pb) {
+ pb.sampled(a);
+ pb.sampled(b);
+ pb.color(out, 0);
+ pb.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ // m_passes is the post-cull execution order, so the producer must precede the consumer
+ int producerPos = -1, consumerPos = -1, pos = 0;
+ for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) {
+ if (p->id == make_resource_id("producer"))
+ producerPos = pos;
+ else if (p->id == make_resource_id("consumer"))
+ consumerPos = pos;
+ }
+ REQUIRE(producerPos >= 0);
+ REQUIRE(consumerPos >= 0);
+ REQUIRE(producerPos < consumerPos);
+}
+
+// record + compile an N-pass linear chain. pass i samples tex[i-1] and writes tex[i], so every edge is a
+// real RAW hazard: stresses hazard detection, topo sort and lifetime packing. execute() needs a device
+// and is out of scope.
+TEST_CASE("RenderGraph - compile perf (linear chain)", "[RenderGraph][!benchmark]")
+{
+ constexpr int N = 256;
+
+ std::vector passNames(N), texNames(N);
+ for (int i = 0; i < N; ++i) {
+ passNames[i] = "pass" + std::to_string(i);
+ texNames[i] = "tex" + std::to_string(i);
+ }
+
+ GraphAllocator* allocator = create_allocator();
+
+ auto build_chain = [&](RenderGraph* rg) {
+ TextureDesc desc {};
+ desc.dimension = WGPUTextureDimension_2D;
+ desc.format = WGPUTextureFormat_RGBA8Unorm;
+ desc.absolute = { 16, 16, 1 };
+
+ TextureHandle prev {};
+ for (int i = 0; i < N; ++i) {
+ auto tex = rg->create_transient_texture(texNames[i], desc);
+ const bool last = (i == N - 1);
+ rg->add_pass(
+ passNames[i], PassKind::Graphics,
+ [&, i, tex, prev, last](PassBuilder& b) {
+ if (i > 0)
+ b.sampled(prev);
+ b.color(tex, 0);
+ if (last)
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ prev = tex;
+ }
+ };
+
+
+ {
+ begin_frame(allocator);
+ RenderGraph* rg = start_recording(allocator);
+ build_chain(rg);
+ REQUIRE(rg->compile() == nullptr);
+ }
+
+ BENCHMARK("record + compile " + std::to_string(N) + "-pass chain")
+ {
+ begin_frame(allocator); // per-frame arena reset
+ RenderGraph* rg = start_recording(allocator);
+ build_chain(rg);
+ return rg->compile();
+ };
+
+ destroy_allocator(allocator);
+}
+
+TEST_CASE("RenderGraph - copy_buffer compiles clean and records the byte range", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::AccessType;
+ using webgpu::rg::Internal::ResourceAccess;
+
+ TestGraph g;
+ auto bufA = g.transient_buffer("bufA");
+ auto bufB = g.transient_buffer("bufB");
+
+ add_buffer_producer(g.rg, bufA);
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_buffer(bufA, bufB, 0, 4, 4);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ // second declared pass is the transfer pass, check both recorded accesses
+ PassNode* copyPass = storage(g.rg)->m_passes->next;
+ REQUIRE(copyPass != nullptr);
+ const ResourceAccess* src = nullptr;
+ const ResourceAccess* dst = nullptr;
+ for (uint32_t i = 0; i < copyPass->accessCount; ++i) {
+ const ResourceAccess& a = copyPass->accesses[i];
+ if (a.type == AccessType::CopySrc)
+ src = &a;
+ if (a.type == AccessType::CopyDst)
+ dst = &a;
+ }
+ REQUIRE(src != nullptr);
+ REQUIRE(dst != nullptr);
+ REQUIRE(src->handle.id == bufA.id);
+ REQUIRE(src->bufOffset == 0);
+ REQUIRE(src->bufSize == 4);
+ REQUIRE(dst->handle.id == bufB.id);
+ REQUIRE(dst->bufOffset == 4);
+ REQUIRE(dst->bufSize == 4);
+}
+
+TEST_CASE("RenderGraph - copy_buffer from an unwritten transient is an error", "[RenderGraph]")
+{
+ TestGraph g;
+ auto bufA = g.transient_buffer("bufA");
+ auto bufB = g.transient_buffer("bufB");
+
+ // no producer for bufA -> read before write must poison the graph
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_buffer(bufA, bufB);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "that no pass ever writes"));
+}
+
+TEST_CASE("RenderGraph - partial copy_buffer dst is not a first-define", "[RenderGraph]")
+{
+ TestGraph g;
+ auto bufA = g.transient_buffer("bufA");
+ auto bufB = g.transient_buffer("bufB");
+
+ add_buffer_producer(g.rg, bufA);
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_buffer(bufA, bufB, 0, /*dstOffset*/ 4, /*size*/ 4);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ // bytes outside [4,8) are untouched, so bufB must not be eligible as a full first-define for aliasing
+ REQUIRE(webgpu::rg::Internal::find_node(g.rg, bufB)->firstDefines == false);
+}
+
+TEST_CASE("RenderGraph - copy_texture compiles clean and records the subresources", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::AccessType;
+ using webgpu::rg::Internal::ResourceAccess;
+
+ TestGraph g;
+ auto texA = g.transient("texA");
+ auto texB = g.transient("texB", /*mipLevelCount*/ 2);
+
+ g.rg->add_pass(
+ "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(texA, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_texture(texA, texB, {}, { .mip = 1 });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ PassNode* copyPass = storage(g.rg)->m_passes->next;
+ REQUIRE(copyPass != nullptr);
+ const ResourceAccess* src = nullptr;
+ const ResourceAccess* dst = nullptr;
+ for (uint32_t i = 0; i < copyPass->accessCount; ++i) {
+ const ResourceAccess& a = copyPass->accesses[i];
+ if (a.type == AccessType::CopySrc)
+ src = &a;
+ if (a.type == AccessType::CopyDst)
+ dst = &a;
+ }
+ REQUIRE(src != nullptr);
+ REQUIRE(dst != nullptr);
+ REQUIRE(src->handle.id == texA.id);
+ REQUIRE(src->baseMip == 0);
+ REQUIRE(dst->handle.id == texB.id);
+ REQUIRE(dst->baseMip == 1);
+}
+
+TEST_CASE("RenderGraph - copy_texture between subresources of one texture is legal", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex", /*mipLevelCount*/ 2);
+
+ g.rg->add_pass(
+ "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_texture(tex, tex, {}, { .mip = 1 });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("RenderGraph - copy_texture_to_buffer dst is never a first-define", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+ auto buf = g.transient_buffer("buf");
+
+ g.rg->add_pass(
+ "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "readback", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_texture_to_buffer(tex, buf);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ // coverage depends on the body's layout, so the aliaser must not treat the dst as fully defined
+ REQUIRE(webgpu::rg::Internal::find_node(g.rg, buf)->firstDefines == false);
+}
+
+TEST_CASE("RenderGraph - copy_buffer_to_texture write satisfies a later read", "[RenderGraph]")
+{
+ TestGraph g;
+ auto buf = g.transient_buffer("buf");
+ auto tex = g.transient("tex");
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "upload", PassKind::Transfer, [&](PassBuilder& b) { b.host_write(buf); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "stage", PassKind::Transfer, [&](PassBuilder& b) { b.copy_buffer_to_texture(buf, tex); }, [](PassContext&) {});
+ // the copy's CopyDst is tex's writer, so sampling it afterwards is not a read-before-write
+ g.rg->add_pass(
+ "consume", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(tex);
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("RenderGraph - explicit full-size copy_buffer dst is a first-define", "[RenderGraph]")
+{
+ TestGraph g;
+ auto bufA = g.transient_buffer("bufA", 64);
+ auto bufB = g.transient_buffer("bufB", 64);
+
+ add_buffer_producer(g.rg, bufA);
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_buffer(bufA, bufB, 0, 0, /*size*/ 64); // explicit size spanning the whole dst
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(webgpu::rg::Internal::find_node(g.rg, bufB)->firstDefines == true);
+}
+
+TEST_CASE("RenderGraph - whole copy_buffer dst is a first-define", "[RenderGraph]")
+{
+ TestGraph g;
+ auto bufA = g.transient_buffer("bufA");
+ auto bufB = g.transient_buffer("bufB");
+
+ add_buffer_producer(g.rg, bufA);
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_buffer(bufA, bufB); // offsets 0, size 0 = whole buffer
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(webgpu::rg::Internal::find_node(g.rg, bufB)->firstDefines == true);
+}
+
+// no reader, not imported/persistent, no force_keep -> dead
+TEST_CASE("RenderGraph - dead pass with no reader is culled", "[RenderGraph]")
+{
+ TestGraph g;
+ auto sink = import_tex(g.rg, "sink");
+ auto mid = g.transient("mid");
+ auto orphan = g.transient("orphan");
+
+ g.rg->add_pass(
+ "writer", PassKind::Graphics, [&](PassBuilder& b) { b.color(mid, 0); }, [](PassContext&) {});
+ g.rg->add_pass( // writes orphan, read by nobody -> dead
+ "dead", PassKind::Graphics, [&](PassBuilder& b) { b.color(orphan, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "reader", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(mid);
+ b.color(sink, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ auto order = pass_order(g.rg);
+ REQUIRE(order.size() == 2);
+ REQUIRE(idx_of(order, "dead") < 0); // dropped
+ REQUIRE(idx_of(order, "writer") >= 0);
+ REQUIRE(idx_of(order, "reader") >= 0);
+}
+
+// force_keep() rescues a reader-less, sink-less side-effect pass
+TEST_CASE("RenderGraph - force_keep rescues an otherwise-dead pass", "[RenderGraph]")
+{
+ { // baseline: no force_keep -> the pass is dead and culled to nothing
+ TestGraph g;
+ auto buf = g.transient_buffer("scratch", 256);
+ g.rg->add_pass(
+ "effect", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(buf); }, [](PassContext&) {});
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).empty());
+ }
+ { // same pass + force_keep() -> survives
+ TestGraph g;
+ auto buf = g.transient_buffer("scratch", 256);
+ g.rg->add_pass(
+ "effect", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.storage_write(buf);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).size() == 1);
+ }
+}
+
+// WAW is an ordering edge, so the earlier writer survives cull and is scheduled first
+TEST_CASE("RenderGraph - write-after-write orders both writers before the reader", "[RenderGraph]")
+{
+ TestGraph g;
+ auto sink = import_tex(g.rg, "sink");
+ auto x = g.transient("x");
+
+ g.rg->add_pass(
+ "w1", PassKind::Graphics, [&](PassBuilder& b) { b.color(x, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "w2", PassKind::Graphics, [&](PassBuilder& b) { b.color(x, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "reader", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(x);
+ b.color(sink, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ auto order = pass_order(g.rg);
+ REQUIRE(order.size() == 3);
+ REQUIRE(idx_of(order, "w1") < idx_of(order, "w2"));
+ REQUIRE(idx_of(order, "w2") < idx_of(order, "reader"));
+}
+
+// legal, an imported value comes from outside the frame. unlike a transient read-before-write.
+TEST_CASE("RenderGraph - reading an imported resource with no writer is legal", "[RenderGraph]")
+{
+ TestGraph g;
+ auto src = import_tex(g.rg, "src"); // imported, never written by any pass
+ auto sink = import_tex(g.rg, "sink");
+
+ g.rg->add_pass(
+ "blit", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(src);
+ b.color(sink, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).size() == 1);
+}
+
+// a history .curr producer is a cull root, so it survives with no same-frame reader
+TEST_CASE("RenderGraph - history .curr write is a cull sink", "[RenderGraph]")
+{
+ TestGraph g;
+ auto hist = g.rg->create_history_texture("hist", tex2d());
+
+ g.rg->add_pass(
+ "temporal", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(hist.prev);
+ b.color(hist.curr, 0);
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ auto order = pass_order(g.rg);
+ REQUIRE(order.size() == 1);
+ REQUIRE(idx_of(order, "temporal") >= 0);
+}
+
+// a plain persistent write is NOT a sink, it is pool-cached and dependency-driven. contrast
+// imported/history.
+TEST_CASE("RenderGraph - persistent write with no reader is culled", "[RenderGraph]")
+{
+ TestGraph g;
+ auto pers = g.rg->create_persistent_texture("pers", tex2d());
+
+ g.rg->add_pass(
+ "bake", PassKind::Graphics, [&](PassBuilder& b) { b.color(pers, 0); }, [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).empty());
+}
+
+// disjoint lifetimes + identical signatures -> one physical slot
+TEST_CASE("RenderGraph - disjoint transients share one physical slot", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto sink1 = import_tex(g.rg, "sink1");
+ auto sink2 = import_tex(g.rg, "sink2");
+ auto t1 = g.transient("t1");
+ auto t2 = g.transient("t2");
+
+ g.rg->add_pass(
+ "a", PassKind::Graphics, [&](PassBuilder& b) { b.color(t1, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "b", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(t1);
+ b.color(sink1, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+ g.rg->add_pass( // t1 is dead by here, so t2 can reuse its storage
+ "c", PassKind::Graphics, [&](PassBuilder& b) { b.color(t2, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "d", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(t2);
+ b.color(sink2, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(true) == nullptr); // enableAlias
+ REQUIRE(pass_order(g.rg).size() == 4);
+ REQUIRE(storage(g.rg)->m_slotCount == 1);
+ ResourceNode* r1 = find_node(g.rg, t1);
+ ResourceNode* r2 = find_node(g.rg, t2);
+ REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot);
+ REQUIRE(r1->aliasSlot == r2->aliasSlot);
+}
+
+// aliasing off: no slots built, every transient keeps its own object
+TEST_CASE("RenderGraph - aliasing disabled builds no slots", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto sink1 = import_tex(g.rg, "sink1");
+ auto sink2 = import_tex(g.rg, "sink2");
+ auto t1 = g.transient("t1");
+ auto t2 = g.transient("t2");
+
+ g.rg->add_pass(
+ "a", PassKind::Graphics, [&](PassBuilder& b) { b.color(t1, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "b", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(t1);
+ b.color(sink1, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "c", PassKind::Graphics, [&](PassBuilder& b) { b.color(t2, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "d", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(t2);
+ b.color(sink2, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(false) == nullptr); // aliasing OFF
+ REQUIRE(storage(g.rg)->m_slotCount == 0);
+ REQUIRE(find_node(g.rg, t1)->aliasSlot == ResourceNode::kNoSlot);
+ REQUIRE(find_node(g.rg, t2)->aliasSlot == ResourceNode::kNoSlot);
+}
+
+// both alive at the consumer -> distinct slots. the packer respects lifetime, not just signature.
+TEST_CASE("RenderGraph - overlapping-lifetime transients do not share a slot", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto sink = import_tex(g.rg, "sink");
+ auto t1 = g.transient("t1");
+ auto t2 = g.transient("t2");
+
+ g.rg->add_pass(
+ "a", PassKind::Graphics, [&](PassBuilder& b) { b.color(t1, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "b", PassKind::Graphics, [&](PassBuilder& b) { b.color(t2, 0); }, [](PassContext&) {});
+ g.rg->add_pass( // reads both -> t1 and t2 alive together here
+ "c", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(t1);
+ b.sampled(t2);
+ b.color(sink, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(true) == nullptr);
+ REQUIRE(storage(g.rg)->m_slotCount == 2);
+ ResourceNode* r1 = find_node(g.rg, t1);
+ ResourceNode* r2 = find_node(g.rg, t2);
+ REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot);
+ REQUIRE(r2->aliasSlot != ResourceNode::kNoSlot);
+ REQUIRE(r1->aliasSlot != r2->aliasSlot);
+}
+
+// greedy reuse past two: three back-to-back disjoint transients collapse onto one slot. force_keep
+// gives each a one-pass life.
+TEST_CASE("RenderGraph - three disjoint transients collapse onto one slot", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto t1 = g.transient("t1");
+ auto t2 = g.transient("t2");
+ auto t3 = g.transient("t3");
+
+ g.rg->add_pass(
+ "k1", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t1, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "k2", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t2, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "k3", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t3, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(true) == nullptr);
+ REQUIRE(pass_order(g.rg).size() == 3);
+ REQUIRE(storage(g.rg)->m_slotCount == 1); // one physical slot for three transients
+ ResourceNode* r1 = find_node(g.rg, t1);
+ ResourceNode* r2 = find_node(g.rg, t2);
+ ResourceNode* r3 = find_node(g.rg, t3);
+ REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot);
+ REQUIRE(r1->aliasSlot == r2->aliasSlot);
+ REQUIRE(r2->aliasSlot == r3->aliasSlot);
+}
+
+// ---------------------------------------------------------------------------------------------------
+
+// sampleCount 4 color + single-sample resolve target. resolve() is a write, so a later
+// sampled(resolved) orders after it.
+TEST_CASE("RenderGraph - msaa resolve is a write that satisfies a later read", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::AccessType;
+ using webgpu::rg::Internal::find_node;
+
+ TestGraph g;
+ TextureDesc msaaDesc {};
+ msaaDesc.dimension = WGPUTextureDimension_2D;
+ msaaDesc.format = WGPUTextureFormat_RGBA8Unorm;
+ msaaDesc.absolute = { 16, 16, 1 };
+ msaaDesc.sampleCount = 4;
+ auto msaaColor = g.rg->create_transient_texture("msaa.color", msaaDesc);
+ auto resolved = g.transient("resolved"); // single-sample, same format+size
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "forward.msaa", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(msaaColor, 0);
+ b.resolve(msaaColor, resolved);
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "present", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(resolved);
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(find_node(g.rg, msaaColor)->sampleCount == 4);
+
+ // recorded as a ResolveAttachment write on `resolved`
+ PassNode* msaaPass = storage(g.rg)->m_passes;
+ bool foundResolve = false;
+ for (uint32_t i = 0; i < msaaPass->accessCount; ++i)
+ if (msaaPass->accesses[i].type == AccessType::ResolveAttachment && msaaPass->accesses[i].handle.id == resolved.id)
+ foundResolve = true;
+ REQUIRE(foundResolve);
+
+ // present ordered after the resolve
+ int msaaPos = -1, presentPos = -1, pos = 0;
+ for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) {
+ if (p->id == make_resource_id("forward.msaa"))
+ msaaPos = pos;
+ else if (p->id == make_resource_id("present"))
+ presentPos = pos;
+ }
+ REQUIRE(msaaPos >= 0);
+ REQUIRE(presentPos >= 0);
+ REQUIRE(msaaPos < presentPos);
+}
+
+// the stencil load/store/clear reaches the access, and a read-only reader orders after the mask writer
+TEST_CASE("RenderGraph - stencil mask write then read-only test pass", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::AccessType;
+
+ TestGraph g;
+ TextureDesc dsDesc {};
+ dsDesc.dimension = WGPUTextureDimension_2D;
+ dsDesc.format = WGPUTextureFormat_Depth24PlusStencil8;
+ dsDesc.absolute = { 16, 16, 1 };
+ auto ds = g.rg->create_transient_texture("mask.ds", dsDesc);
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "stencil.mask", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil(ds, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store, .stencilClear = 7 });
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "stencil.effect", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil_read_only(ds);
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ PassNode* maskPass = storage(g.rg)->m_passes;
+ const webgpu::rg::Internal::ResourceAccess* dsWrite = nullptr;
+ for (uint32_t i = 0; i < maskPass->accessCount; ++i)
+ if (maskPass->accesses[i].type == AccessType::DepthStencilAttachment)
+ dsWrite = &maskPass->accesses[i];
+ REQUIRE(dsWrite != nullptr);
+ REQUIRE(dsWrite->stencilLoadOp == WGPULoadOp_Clear);
+ REQUIRE(dsWrite->stencilStoreOp == WGPUStoreOp_Store);
+ REQUIRE(dsWrite->stencilClear == 7);
+
+ // effect pass reads via DepthStencilReadOnly and runs after the mask write
+ int maskPos = -1, effectPos = -1, pos = 0;
+ bool foundReadOnly = false;
+ for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) {
+ if (p->id == make_resource_id("stencil.mask"))
+ maskPos = pos;
+ if (p->id == make_resource_id("stencil.effect")) {
+ effectPos = pos;
+ for (uint32_t i = 0; i < p->accessCount; ++i)
+ if (p->accesses[i].type == AccessType::DepthStencilReadOnly)
+ foundReadOnly = true;
+ }
+ }
+ REQUIRE(foundReadOnly);
+ REQUIRE(maskPos >= 0);
+ REQUIRE(effectPos >= 0);
+ REQUIRE(maskPos < effectPos);
+}
+
+// the declared view shape reaches the access, so ctx.view()/ctx.bind() hand back exactly it
+TEST_CASE("RenderGraph - sampled ViewRange is recorded on the access", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::AccessType;
+
+ TestGraph g;
+ TextureDesc desc {};
+ desc.dimension = WGPUTextureDimension_2D;
+ desc.format = WGPUTextureFormat_RGBA8Unorm;
+ desc.absolute = { 16, 16, 6 };
+ desc.mipLevelCount = 4;
+ auto env = g.rg->create_transient_texture("env", desc);
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "produce", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(env); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "consume", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ // cube view over all 6 layers, mips 1..2, from rendergraph.md Views
+ b.sampled(env, { .baseMip = 1, .mipCount = 2, .layerCount = 6, .dim = WGPUTextureViewDimension_Cube });
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ PassNode* consume = storage(g.rg)->m_passes->next;
+ REQUIRE(consume != nullptr);
+ const webgpu::rg::Internal::ResourceAccess* read = nullptr;
+ for (uint32_t i = 0; i < consume->accessCount; ++i)
+ if (consume->accesses[i].type == AccessType::Sampled)
+ read = &consume->accesses[i];
+ REQUIRE(read != nullptr);
+ REQUIRE(read->viewDim == WGPUTextureViewDimension_Cube);
+ REQUIRE(read->baseMip == 1);
+ REQUIRE(read->mipCount == 2);
+ REQUIRE(read->baseLayer == 0);
+ REQUIRE(read->layerCount == 6);
+}
+
+// per level, sample mip i-1 and render into mip i of the SAME handle. whole-resource hazards serialize
+// the chain in level order.
+TEST_CASE("RenderGraph - mip chain blit on one handle compiles and serializes", "[RenderGraph]")
+{
+ constexpr uint32_t kMips = 4;
+ TestGraph g;
+ auto tex = g.transient("chain", kMips);
+
+ g.rg->add_pass(
+ "mip0", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); },
+ [](PassContext&) {});
+
+ std::string names[kMips];
+ for (uint32_t mip = 1; mip < kMips; ++mip) {
+ names[mip] = "mip" + std::to_string(mip);
+ const bool last = (mip == kMips - 1);
+ g.rg->add_pass(
+ names[mip], PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(tex, { .baseMip = mip - 1 });
+ b.color(tex, 0, { .sub = { .mip = mip } });
+ if (last)
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ }
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ // execution order preserves the level order: mip0, mip1, mip2, mip3
+ uint32_t pos = 0;
+ for (PassNode* p = storage(g.rg)->m_passes; p; p = p->next, ++pos) {
+ REQUIRE(pos < kMips);
+ std::string expected = "mip" + std::to_string(pos);
+ REQUIRE(std::string_view(p->id.name.data, p->id.name.length) == expected);
+ }
+ REQUIRE(pos == kMips); // all passes survived culling
+}
+
+// ---------------------------------------------------------------------------------------------------
+// TransientResourcePool supersede-eviction, device-free. destroy() null-guards every GPU release, so
+// hand-populated null entries drive end_frame() directly.
+
+using webgpu::rg::Internal::Arena;
+using webgpu::rg::Internal::StringInterner;
+using webgpu::rg::Internal::SubviewPool;
+using webgpu::rg::Internal::TexSignature;
+using webgpu::rg::Internal::TransientResourcePool;
+
+// ---------------------------------------------------------------------------------------------------
+// StringInterner. a stack arena stands in for the allocator's persist, nothing here needs a device.
+
+struct InternerFixture {
+ Arena arena {};
+ StringInterner interner {};
+
+ InternerFixture() { interner.arena = &arena; }
+ ~InternerFixture() { arena.free_all(); }
+};
+
+TEST_CASE("StringInterner - equal strings share one canonical copy", "[RenderGraph]")
+{
+ InternerFixture f;
+
+ // distinct std::string objects, so a pointer match cannot come from the caller's own storage
+ std::string a = "gbuffer.albedo";
+ std::string b = "gbuffer.albedo";
+ REQUIRE(a.data() != b.data());
+
+ ResourceId ia = f.interner.intern(a);
+ ResourceId ib = f.interner.intern(b);
+
+ REQUIRE(ia.name.data == ib.name.data); // one canonical copy
+ REQUIRE(ia.value == ib.value);
+ REQUIRE(ia.value == webgpu::rg::fnv1a("gbuffer.albedo")); // the id hash is still fnv1a of the name
+ REQUIRE(std::string_view(ia.name.data, ia.name.length) == "gbuffer.albedo");
+}
+
+TEST_CASE("StringInterner - distinct strings get distinct canonical copies", "[RenderGraph]")
+{
+ InternerFixture f;
+ ResourceId a = f.interner.intern("gbuffer.albedo");
+ ResourceId b = f.interner.intern("gbuffer.normal");
+
+ REQUIRE(a.name.data != b.name.data);
+ REQUIRE(a.value != b.value);
+}
+
+// the pre-fix pool compared names under a 63-char truncation. the interner stores each in full.
+TEST_CASE("StringInterner - names differing only past 63 chars stay distinct", "[RenderGraph]")
+{
+ InternerFixture f;
+ const std::string prefix(63, 'x');
+ ResourceId a = f.interner.intern(prefix + "alpha");
+ ResourceId b = f.interner.intern(prefix + "beta");
+
+ REQUIRE(a.name.data != b.name.data);
+ REQUIRE(a.value != b.value);
+ REQUIRE(a.name.length == 68);
+ REQUIRE(std::string_view(a.name.data, a.name.length) == prefix + "alpha"); // stored in full, not truncated
+ REQUIRE(std::string_view(b.name.data, b.name.length) == prefix + "beta");
+}
+
+// a default-constructed string_view's null data must not reach memcmp
+TEST_CASE("StringInterner - an empty name interns without dereferencing null", "[RenderGraph]")
+{
+ InternerFixture f;
+ ResourceId a = f.interner.intern(std::string_view {});
+ ResourceId b = f.interner.intern(""); // same content, different data pointer on the way in
+
+ REQUIRE(a.name.data == b.name.data); // still dedups to one canonical copy
+ REQUIRE(a.name.length == 0);
+ REQUIRE(a.name.data[0] == '\0');
+ REQUIRE(a.name.data != f.interner.intern("nonempty").name.data);
+}
+
+TEST_CASE("StringInterner - a name longer than the old inline caps is stored in full", "[RenderGraph]")
+{
+ InternerFixture f;
+ const std::string longName(200, 'q'); // past both the old 64-char pool cap and 48-char profiler cap
+ ResourceId id = f.interner.intern(longName);
+
+ REQUIRE(id.name.length == 200);
+ REQUIRE(std::string_view(id.name.data, id.name.length) == longName);
+ REQUIRE(id.name.data[200] == '\0'); // NUL-terminated, so it is usable as a WebGPU label
+}
+
+// the interner never rehashes, which is what makes a canonical pointer safe to keep. fill past kBuckets
+// so every bucket chains.
+TEST_CASE("StringInterner - canonical pointers stay stable across many interns", "[RenderGraph]")
+{
+ InternerFixture f;
+ ResourceId first = f.interner.intern("first.name");
+ const char* firstData = first.name.data;
+
+ std::vector ids;
+ for (int i = 0; i < StringInterner::kBuckets * 8; ++i)
+ ids.push_back(f.interner.intern("filler." + std::to_string(i)));
+
+ REQUIRE(f.interner.intern("first.name").name.data == firstData); // same node, no relocation
+ REQUIRE(std::string_view(firstData, first.name.length) == "first.name"); // bytes still intact
+
+ // every filler is still its own canonical copy and re-interns to the same pointer
+ for (size_t i = 0; i < ids.size(); ++i) {
+ ResourceId again = f.interner.intern("filler." + std::to_string(i));
+ REQUIRE(again.name.data == ids[i].name.data);
+ }
+}
+
+// live-cell count for either pool's intrusive entry list
+template static uint32_t list_count(const E* head)
+{
+ uint32_t n = 0;
+ for (const E* e = head; e; e = e->next)
+ ++n;
+ return n;
+}
+
+// the minimum end_frame()/superseded_by need without a device: cells from `arena`, and a `subviewPool`
+// for destroy() to recycle the (always null here) subview list into.
+struct PoolFixture {
+ Arena arena {};
+ SubviewPool subviewPool {};
+ TransientResourcePool pool {};
+
+ PoolFixture()
+ {
+ subviewPool.arena = &arena;
+ pool.arena = &arena;
+ pool.subviewPool = &subviewPool;
+ }
+ // drain the pool BEFORE freeing the arena its cells live in. a destructor body runs before its members
+ // are destroyed, so a bare free_all() would leave ~TransientResourcePool() walking freed memory.
+ ~PoolFixture()
+ {
+ pool.destroy_all();
+ arena.free_all();
+ }
+
+ // appends rather than prepends, so list order matches declaration order for the positional reads
+ TransientResourcePool::Entry* push(const TransientResourcePool::Entry& proto)
+ {
+ TransientResourcePool::Entry* e = pool.alloc_entry();
+ TransientResourcePool::Entry* next = e->next;
+ *e = proto;
+ e->next = next;
+ TransientResourcePool::Entry** pp = &pool.entries;
+ while (*pp)
+ pp = &(*pp)->next;
+ *pp = e;
+ e->next = nullptr;
+ return e;
+ }
+
+ uint32_t count() const { return list_count(pool.entries); }
+
+ // nth live entry, for the positional checks
+ const TransientResourcePool::Entry* at(uint32_t i) const
+ {
+ const TransientResourcePool::Entry* e = pool.entries;
+ for (uint32_t k = 0; e && k < i; ++k)
+ e = e->next;
+ return e;
+ }
+};
+
+// stand-ins for the interned name pointers acquire passes as an entry's supersede identity. only the
+// address matters, superseded_by compares pointers.
+static const char kIdA[] = "resourceA";
+static const char kIdB[] = "resourceB";
+
+// GPU handles left null so destroy() is a no-op. identity defaults to kIdA, so a test that does not care
+// gets two entries of the SAME logical resource, i.e. the resize case.
+static TransientResourcePool::Entry tex_entry(
+ WGPUExtent3D size, uint64_t createdFrame, uint64_t lastUsedFrame, const void* identity = kIdA)
+{
+ TransientResourcePool::Entry e {};
+ e.kind = ResourceKind::Texture;
+ e.sig.size = size;
+ e.sig.format = WGPUTextureFormat_RGBA8Unorm;
+ e.sig.dim = WGPUTextureDimension_2D;
+ e.sig.mipLevelCount = 1;
+ e.sig.sampleCount = 1;
+ e.usage = (WGPUTextureUsage)(WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_TextureBinding);
+ e.createdFrame = createdFrame;
+ e.lastUsedFrame = lastUsedFrame;
+ e.identity = identity;
+ return e;
+}
+
+TEST_CASE("TransientResourcePool - old-size idle texture is superseded by a new-size sibling", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old size, idle this frame
+ f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10)); // new size, created this frame
+
+ f.pool.end_frame();
+
+ // the old-size entry is evicted at end_frame, before kRetain (frame - 9 == 1 < 4) would fire.
+ REQUIRE(f.count() == 1);
+ REQUIRE(f.at(0)->sig.size.width == 1024);
+}
+
+TEST_CASE("TransientResourcePool - texture claimed this frame is not superseded", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 10)); // used THIS frame -> live
+ f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2); // both kept
+}
+
+TEST_CASE("TransientResourcePool - descriptor mismatch does not supersede", "[RenderGraph]")
+{
+ auto survives = [](auto mutate) {
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old, idle
+ TransientResourcePool::Entry sibling = tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10);
+ mutate(sibling);
+ f.push(sibling);
+ f.pool.end_frame();
+ // old entry not superseded -> both survive (frame - 9 == 1 < kRetain, so kRetain keeps it too)
+ return f.count() == 2;
+ };
+
+ REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.format = WGPUTextureFormat_BGRA8Unorm; }));
+ REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.dim = WGPUTextureDimension_3D; }));
+ REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.mipLevelCount = 4; }));
+ REQUIRE(survives([](TransientResourcePool::Entry& s) { s.sig.sampleCount = 4; }));
+ // a differently-purposed sibling must not sweep the old entry: STORAGE does not cover SAMPLED+ATTACHMENT
+ REQUIRE(survives([](TransientResourcePool::Entry& s) { s.usage = WGPUTextureUsage_StorageBinding; }));
+}
+
+TEST_CASE("TransientResourcePool - sibling created in an earlier frame does not supersede", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old, idle
+ f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 8, /*lastUsed*/ 9)); // different size but NOT this frame
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2); // no supersede; kRetain keeps both (idle only 1 frame)
+}
+
+// a resize that also flips a usage bit is still a resize. usage matches by superset, so the added
+// STORAGE bit does not hide the supersede.
+TEST_CASE("TransientResourcePool - a wider-usage new-size sibling supersedes the old extent", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9)); // old size, idle
+ TransientResourcePool::Entry sibling = tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10);
+ sibling.usage = (WGPUTextureUsage)(sibling.usage | WGPUTextureUsage_StorageBinding); // superset of the old entry's
+ f.push(sibling);
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 1);
+ REQUIRE(f.at(0)->sig.size.width == 1024);
+}
+
+// GPU handles null so destroy() is a no-op. identity defaults to kIdA, see tex_entry.
+static TransientResourcePool::Entry buf_entry(
+ uint64_t size, WGPUBufferUsage usage, uint64_t createdFrame, uint64_t lastUsedFrame, const void* identity = kIdA)
+{
+ TransientResourcePool::Entry e {};
+ e.kind = ResourceKind::Buffer;
+ e.bufferSize = size;
+ e.bufUsage = usage;
+ e.createdFrame = createdFrame;
+ e.lastUsedFrame = lastUsedFrame;
+ e.identity = identity;
+ return e;
+}
+
+// the buffer arm. pre-patch this short-circuited on non-textures, so a resize drag stacked up to
+// kRetain generations of screen-derived buffers.
+TEST_CASE("TransientResourcePool - a resized idle buffer is superseded by its new-size sibling", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9)); // old size, idle
+ f.push(buf_entry(512, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst), /*created*/ 10, /*lastUsed*/ 10));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 1); // collapsed same-frame, not after kRetain
+ REQUIRE(f.at(0)->bufferSize == 512);
+}
+
+TEST_CASE("TransientResourcePool - a buffer whose usage the sibling does not cover is not superseded", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(buf_entry(256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_Indirect), /*created*/ 3, /*lastUsed*/ 9));
+ f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10)); // misses Indirect
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2); // differently-purposed, so not swept
+}
+
+TEST_CASE("TransientResourcePool - a buffer claimed this frame is not superseded", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 10)); // used THIS frame -> live
+ f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2); // the lastUsedFrame >= frame guard holds for buffers too
+}
+
+TEST_CASE("TransientResourcePool - a buffer sibling created in an earlier frame does not supersede", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9));
+ f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 8, /*lastUsed*/ 9)); // not this frame's replacement
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2);
+}
+
+// a texture and a buffer never supersede each other, whatever their sizes
+TEST_CASE("TransientResourcePool - kinds do not supersede across each other", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9));
+ f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2);
+}
+
+TEST_CASE("TransientResourcePool - same-size buffers are not superseded", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+
+ f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9));
+ // same size -> not a resize, so not a supersede
+ f.push(buf_entry(256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst), /*created*/ 10, /*lastUsed*/ 10));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2);
+}
+
+TEST_CASE("TransientResourcePool - drag resize keeps only the newest generation each frame", "[RenderGraph]")
+{
+ PoolFixture f;
+
+ // simulate three consecutive frames, each acquiring a fresh, larger size while the previous
+ // generation goes idle. after each end_frame() only the size just created must remain.
+ const WGPUExtent3D sizes[3] = { { 800, 600, 1 }, { 900, 675, 1 }, { 1000, 750, 1 } };
+ for (int i = 0; i < 3; ++i) {
+ // this frame's new-size entry, claimed this frame
+ f.push(tex_entry(sizes[i], /*created*/ f.pool.frame, /*lastUsed*/ f.pool.frame));
+ f.pool.createdThisFrame = 1;
+
+ f.pool.end_frame(); // supersede-evicts any older-size idle generation, then advances frame
+
+ REQUIRE(f.count() == 1);
+ REQUIRE(f.at(0)->sig.size.width == sizes[i].width);
+
+ // going into the next frame the surviving entry is now idle (not touched next frame until acquired)
+ }
+}
+
+TEST_CASE("TransientResourcePool - two idle generations are both swept by one new-size sibling", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ // two older generations idle at once, which the drag-resize test never produces. both well inside
+ // kRetain, so only supersede can take them: end_frame's scan clears the whole backlog in one pass.
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 8));
+ f.push(tex_entry({ 900, 675, 1 }, /*created*/ 8, /*lastUsed*/ 9));
+ f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 1);
+ REQUIRE(f.at(0)->sig.size.width == 1024);
+}
+
+TEST_CASE("TransientResourcePool - the event log records the supersede evict", "[RenderGraph]")
+{
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9));
+ f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10));
+ f.pool.log_reset();
+
+ f.pool.end_frame();
+
+ // the surviving counts cannot say WHEN an eviction fired, the log can. only Evict is visible here,
+ // Create is logged by acquire, which needs a device.
+ REQUIRE(f.pool.eventCount == 1);
+ REQUIRE(f.pool.eventLog[0].event == TransientResourcePool::Event::Evict);
+ REQUIRE(f.pool.eventLog[0].kind == ResourceKind::Texture);
+ REQUIRE(f.pool.eventLog[0].frame == 10); // logged before end_frame advances the clock
+ REQUIRE(f.pool.eventLog[0].size.width == 800); // the old generation, not the survivor
+ REQUIRE(f.pool.eventLog[0].size.height == 600);
+}
+
+// ---------------------------------------------------------------------------------------------------
+// supersede must not fire across two DIFFERENT resources that merely differ in size. the pool matches by
+// descriptor, so without an identity key an idle resource looks like a stale generation of any
+// same-shaped sibling created this frame.
+
+// claim the pooled buffer matching size+usage, else create what acquire's miss path would. identity
+// tracks the LAST claimant, since an entry may legitimately change hands. true if this created one.
+static bool claim_or_create_buf(PoolFixture& f, uint64_t size, WGPUBufferUsage usage, const void* identity)
+{
+ for (TransientResourcePool::Entry* e = f.pool.entries; e; e = e->next)
+ if (e->kind == ResourceKind::Buffer && !e->inUse && e->bufferSize == size && e->bufUsage == usage) {
+ e->lastUsedFrame = f.pool.frame;
+ e->identity = identity;
+ return false;
+ }
+ f.push(buf_entry(size, usage, /*created*/ f.pool.frame, /*lastUsed*/ f.pool.frame, identity));
+ ++f.pool.createdThisFrame;
+ return true;
+}
+
+// tex_entry's counterpart to claim_or_create_buf
+static bool claim_or_create_tex(PoolFixture& f, WGPUExtent3D size, const void* identity)
+{
+ TransientResourcePool::Entry want = tex_entry(size, 0, 0, identity);
+ for (TransientResourcePool::Entry* e = f.pool.entries; e; e = e->next)
+ if (e->kind == ResourceKind::Texture && !e->inUse && e->sig == want.sig && e->usage == want.usage) {
+ e->lastUsedFrame = f.pool.frame;
+ e->identity = identity;
+ return false;
+ }
+ f.push(tex_entry(size, /*created*/ f.pool.frame, /*lastUsed*/ f.pool.frame, identity));
+ ++f.pool.createdThisFrame;
+ return true;
+}
+
+TEST_CASE("TransientResourcePool - alternately claimed buffers of different sizes do not churn", "[RenderGraph]")
+{
+ PoolFixture f;
+
+ // two unrelated buffers, same usage, different sizes, each claimed every OTHER frame. neither is a
+ // resize of the other, and each is idle only 1 frame at a time, so both survive untouched.
+ uint32_t creates = 0;
+ for (uint64_t i = 0; i < 6; ++i) {
+ if (i % 2 == 0)
+ creates += claim_or_create_buf(f, 256, WGPUBufferUsage_Storage, kIdA);
+ else
+ creates += claim_or_create_buf(f, 512, WGPUBufferUsage_Storage, kIdB);
+ f.pool.release_claims();
+ f.pool.end_frame();
+ }
+
+ REQUIRE(creates == 2); // one per resource, on its first claim, never recreated
+ REQUIRE(f.count() == 2);
+ REQUIRE(f.pool.eventCount == 0); // no evict ever logged
+}
+
+TEST_CASE("TransientResourcePool - alternately claimed textures of different extents do not churn", "[RenderGraph]")
+{
+ PoolFixture f;
+
+ // the texture arm of the same hole, differing only in extent
+ uint32_t creates = 0;
+ for (uint64_t i = 0; i < 6; ++i) {
+ if (i % 2 == 0)
+ creates += claim_or_create_tex(f, { 800, 600, 1 }, kIdA);
+ else
+ creates += claim_or_create_tex(f, { 400, 300, 1 }, kIdB);
+ f.pool.release_claims();
+ f.pool.end_frame();
+ }
+
+ REQUIRE(creates == 2);
+ REQUIRE(f.count() == 2);
+ REQUIRE(f.pool.eventCount == 0);
+}
+
+TEST_CASE("TransientResourcePool - a size-only difference across identities does not supersede", "[RenderGraph]")
+{
+ // the identity bail alone, without the alternating-frame timing above. same shape as "a resized idle
+ // buffer is superseded by its new-size sibling", only the identity differs.
+ {
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(buf_entry(256, WGPUBufferUsage_Storage, /*created*/ 3, /*lastUsed*/ 9, kIdA));
+ f.push(buf_entry(512, WGPUBufferUsage_Storage, /*created*/ 10, /*lastUsed*/ 10, kIdB));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2);
+ }
+ {
+ PoolFixture f;
+ f.pool.frame = 10;
+ f.pool.createdThisFrame = 1;
+ f.push(tex_entry({ 800, 600, 1 }, /*created*/ 3, /*lastUsed*/ 9, kIdA));
+ f.push(tex_entry({ 1024, 768, 1 }, /*created*/ 10, /*lastUsed*/ 10, kIdB));
+
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2);
+ }
+}
+
+TEST_CASE("TransientResourcePool - different-size buffers claimed every frame are never superseded", "[RenderGraph]")
+{
+ PoolFixture f;
+
+ // the common case, and the guard that the identity key does not regress it. the lastUsedFrame >=
+ // frame bail already covers this, with or without identity.
+ uint32_t creates = 0;
+ for (uint64_t i = 0; i < 6; ++i) {
+ creates += claim_or_create_buf(f, 256, WGPUBufferUsage_Storage, kIdA);
+ creates += claim_or_create_buf(f, 512, WGPUBufferUsage_Storage, kIdB);
+ f.pool.release_claims();
+ f.pool.end_frame();
+ }
+
+ REQUIRE(creates == 2);
+ REQUIRE(f.count() == 2);
+}
+
+// ---------------------------------------------------------------------------------------------------
+// transient buffer acquire matching. a real device, so the miss branch actually creates and
+// reused-vs-created is observable as an entry count.
+
+TEST_CASE("TransientResourcePool - a usage-superset idle buffer is reused", "[RenderGraph][gpu]")
+{
+ UnittestWebgpuContext gpu;
+ PoolFixture f;
+
+ // seed the pool the way a previous frame would have: one idle Storage|CopySrc buffer
+ WGPUBuffer first = nullptr;
+ f.pool.acquire(gpu.device, 256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopySrc), kIdA, first);
+ REQUIRE(first != nullptr);
+ REQUIRE(f.count() == 1);
+ f.pool.release_claims();
+
+ // a narrower request is covered by that buffer's usage -> reuse the same object, no second entry
+ WGPUBuffer second = nullptr;
+ f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, second);
+ REQUIRE(second == first);
+ REQUIRE(f.count() == 1);
+
+ f.pool.destroy_all();
+}
+
+TEST_CASE("TransientResourcePool - a buffer whose usage misses a requested bit is not reused", "[RenderGraph][gpu]")
+{
+ UnittestWebgpuContext gpu;
+ PoolFixture f;
+
+ WGPUBuffer first = nullptr;
+ f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, first);
+ f.pool.release_claims();
+
+ // storage does not cover Storage|Indirect -> a fresh object, since usage matches by superset
+ WGPUBuffer second = nullptr;
+ f.pool.acquire(gpu.device, 256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_Indirect), kIdA, second);
+ REQUIRE(second != first);
+ REQUIRE(f.count() == 2);
+
+ f.pool.destroy_all();
+}
+
+// identity must follow the claimant, or the original creator's resize sweeps an object that now belongs
+// to someone else. needs a real acquire, the device-free fixtures stamp identity themselves.
+
+TEST_CASE("TransientResourcePool - a rehomed buffer is not swept by its creator's resize", "[RenderGraph][gpu]")
+{
+ UnittestWebgpuContext gpu;
+ PoolFixture f;
+
+ // frame 0: A creates a 256B object
+ WGPUBuffer a0 = nullptr;
+ f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, a0);
+ f.pool.release_claims();
+ f.pool.end_frame();
+
+ // frame 1: A does not record, B asks for the same descriptor and is handed A's object
+ WGPUBuffer b1 = nullptr;
+ f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdB, b1);
+ REQUIRE(b1 == a0); // descriptor match -> reused, the object now serves B
+ f.pool.release_claims();
+ f.pool.end_frame();
+
+ // frame 2: B is idle, A returns resized. a's new object must not sweep the one B is still using.
+ WGPUBuffer a2 = nullptr;
+ f.pool.acquire(gpu.device, 512, WGPUBufferUsage_Storage, kIdA, a2);
+ f.pool.release_claims();
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2); // b's object survived A's resize
+}
+
+TEST_CASE("TransientResourcePool - a rehomed texture is not swept by its creator's resize", "[RenderGraph][gpu]")
+{
+ UnittestWebgpuContext gpu;
+ PoolFixture f;
+
+ const TransientResourcePool::Entry small = tex_entry({ 16, 16, 1 }, 0, 0);
+ const TransientResourcePool::Entry large = tex_entry({ 32, 32, 1 }, 0, 0);
+
+ WGPUTexture a0 = nullptr, a2 = nullptr, b1 = nullptr;
+ WGPUTextureView v = nullptr;
+
+ f.pool.acquire(gpu.device, small.sig, small.usage, kIdA, a0, v);
+ f.pool.release_claims();
+ f.pool.end_frame();
+
+ f.pool.acquire(gpu.device, small.sig, small.usage, kIdB, b1, v);
+ REQUIRE(b1 == a0);
+ f.pool.release_claims();
+ f.pool.end_frame();
+
+ f.pool.acquire(gpu.device, large.sig, large.usage, kIdA, a2, v);
+ f.pool.release_claims();
+ f.pool.end_frame();
+
+ REQUIRE(f.count() == 2);
+}
+
+// the removed MapRead|MapWrite exact-match reservation. the graph never gives a transient buffer those
+// bits, so it only ever blocked reuse of a state the graph cannot produce.
+TEST_CASE("TransientResourcePool - a map-bit-carrying idle buffer no longer blocks superset reuse", "[RenderGraph][gpu]")
+{
+ UnittestWebgpuContext gpu;
+ PoolFixture f;
+
+ WGPUBuffer seeded = nullptr;
+ f.pool.acquire(gpu.device, 256, (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst), kIdA, seeded);
+ f.pool.release_claims();
+ // stamp the map bit on rather than asking WebGPU for the invalid Storage|MapRead combination. only
+ // the flags matter, acquire compares bufUsage and never the object.
+ f.pool.entries->bufUsage
+ = (WGPUBufferUsage)(WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead);
+
+ // pre-patch the MapRead bit made this an exact-match miss and forced a second object
+ WGPUBuffer plain = nullptr;
+ f.pool.acquire(gpu.device, 256, WGPUBufferUsage_Storage, kIdA, plain);
+ REQUIRE(plain == seeded);
+ REQUIRE(f.count() == 1);
+
+ f.pool.destroy_all();
+}
+
+// ===================================================================================================
+// execute-level tests. everything above stops at compile(), these run realize_graph() + execute() against
+// a real Dawn device and drive the cross-frame pools over whole frames on one long-lived allocator.
+//
+// NOTE: release_resources() nulls a transient's realized handles at the end of execute(), so white-box
+// handle checks must capture inside a pass body via ctx.*. the pools persist and are read after frame().
+
+using webgpu::rg::Internal::PersistentResourcePool;
+using webgpu::rg::Internal::ResourceNode;
+using webgpu::rg::Internal::find_node;
+
+struct ExecGraph {
+ UnittestWebgpuContext gpu;
+ GraphAllocator* allocator = create_allocator();
+
+ ~ExecGraph() { destroy_allocator(allocator); }
+ ExecGraph(const ExecGraph&) = delete;
+ ExecGraph& operator=(const ExecGraph&) = delete;
+ ExecGraph() = default;
+
+ // block until the queue drains, so a readback or pool inspection sees finished work
+ void wait_idle()
+ {
+ bool done = false;
+ WGPUQueueWorkDoneCallbackInfo cb {
+ .nextInChain = nullptr,
+ .mode = WGPUCallbackMode_AllowProcessEvents,
+ .callback = [](WGPUQueueWorkDoneStatus, WGPUStringView, void* d, void*) { *reinterpret_cast(d) = true; },
+ .userdata1 = &done,
+ .userdata2 = nullptr,
+ };
+ WGPUFuture f = wgpuQueueOnSubmittedWorkDone(gpu.queue, cb);
+ WGPUFutureWaitInfo wi { .future = f, .completed = false };
+ REQUIRE(wgpuInstanceWaitAny(gpu.instance, 1, &wi, 1000ull * 1000 * 1000) == WGPUWaitStatus_Success);
+ }
+
+ // compile + execute + submit one already-recorded graph on its own command buffer
+ void submit_graph(RenderGraph* rg)
+ {
+ REQUIRE(rg->compile() == nullptr);
+
+ WGPUCommandEncoderDescriptor ed {};
+ WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(gpu.device, &ed);
+ rg->execute(gpu.device, gpu.queue, enc);
+ WGPUCommandBufferDescriptor cd {};
+ WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, &cd);
+ wgpuQueueSubmit(gpu.queue, 1, &cmd);
+ wgpuCommandBufferRelease(cmd);
+ wgpuCommandEncoderRelease(enc);
+ }
+
+ // one full frame: record -> compile -> execute -> submit -> wait -> inspect -> end_frame
+ template
+ void frame(Record&& record, Inspect&& inspect)
+ {
+ begin_frame(allocator);
+ RenderGraph* rg = start_recording(allocator);
+ record(rg);
+ submit_graph(rg);
+ wait_idle();
+ inspect(rg);
+ end_frame(allocator);
+ }
+
+ template
+ void frame(Record&& record)
+ {
+ frame(std::forward(record), [](RenderGraph*) {});
+ }
+};
+
+// texture->buffer readback into an imported `dst`. rows are 256-aligned, so buf size is alignedRow*H.
+static void encode_readback(PassContext& ctx, TextureHandle src, BufferHandle dst, uint32_t w, uint32_t h)
+{
+ WGPUTexelCopyBufferLayout layout { .offset = 0, .bytesPerRow = webgpu::rg::aligned_bytes_per_row(w, 4), .rowsPerImage = h };
+ auto s = ctx.copy_src_info(src);
+ auto d = ctx.copy_dst_buffer(dst, layout);
+ auto sz = ctx.copy_extent_src(src);
+ wgpuCommandEncoderCopyTextureToBuffer(ctx.encoder, &s, &d, &sz);
+}
+
+// smoke: clear a transient, copy it into an imported mappable buffer, read the pixel back. exercises
+// transient acquire, attachment build, pass ordering and the copy family end to end.
+TEST_CASE("RenderGraph exec - clear then readback returns the clear color", "[RenderGraph][gpu]")
+{
+ constexpr uint32_t W = 16, H = 16;
+ const uint32_t alignedRow = webgpu::rg::aligned_bytes_per_row(W, 4);
+
+ ExecGraph g;
+ // imported readback target, MapRead so the host can read it directly after submit
+ webgpu::raii::RawBuffer readback(g.gpu.device, WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead, alignedRow * H, "rg readback");
+
+ g.frame([&](RenderGraph* rg) {
+ auto tex = rg->create_transient_texture("smoke.tex", [] {
+ TextureDesc d {};
+ d.dimension = WGPUTextureDimension_2D;
+ d.format = WGPUTextureFormat_RGBA8Unorm;
+ d.absolute = { W, H, 1 };
+ return d;
+ }());
+ auto buf = rg->import_buffer("smoke.readback", readback.handle());
+
+ rg->add_pass(
+ "clear", PassKind::Graphics,
+ [&](PassBuilder& b) { b.color(tex, 0, { .clear = { 1.0, 0.0, 0.0, 1.0 } }); }, [](PassContext&) {});
+ rg->add_pass(
+ "readback", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_texture_to_buffer(tex, buf);
+ b.force_keep();
+ },
+ [=](PassContext& ctx) { encode_readback(ctx, tex, buf, W, H); });
+ });
+
+ std::vector pixels;
+ REQUIRE(readback.read_back_sync(g.gpu.instance, g.gpu.device, pixels) == WGPUMapAsyncStatus_Success);
+ REQUIRE(pixels.size() == alignedRow * H);
+ // pixel (0,0) = first RGBA8 texel = the clear color (1,0,0,1) -> 255,0,0,255
+ REQUIRE(pixels[0] == 255);
+ REQUIRE(pixels[1] == 0);
+ REQUIRE(pixels[2] == 0);
+ REQUIRE(pixels[3] == 255);
+}
+
+// the ping-pong pass shared by the history tests. .curr is a cull sink, so it survives without
+// force_keep. `body` rides the arena exec slot, so it must be trivially destructible.
+template
+static void add_temporal_pass(RenderGraph* rg, HistoryTexture h, Body body)
+{
+ rg->add_pass(
+ "temporal", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(h.prev);
+ b.color(h.curr, 0);
+ },
+ body);
+}
+
+// over two frames the pair ping-pongs its two physical textures, and history_valid(.prev) is false on
+// the first frame but true afterwards
+TEST_CASE("RenderGraph exec - history ping-pongs and history_valid trips only after the first frame", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTexture curr[2] = {}, prev[2] = {};
+ bool validPrev[2] = {};
+
+ for (int f = 0; f < 2; ++f) {
+ WGPUTexture* co = &curr[f];
+ WGPUTexture* po = &prev[f];
+ bool* vo = &validPrev[f];
+ g.frame([&](RenderGraph* rg) {
+ auto h = rg->create_history_texture("hist", tex2d());
+ add_temporal_pass(rg, h, [h, co, po, vo](PassContext& ctx) {
+ *co = ctx.texture(h.curr);
+ *po = ctx.texture(h.prev);
+ *vo = ctx.history_valid(h);
+ });
+ });
+ }
+
+ REQUIRE(curr[0] != nullptr);
+ REQUIRE(prev[0] != nullptr);
+ REQUIRE(curr[0] != prev[0]); // two distinct physical textures
+ REQUIRE(curr[1] == prev[0]); // ping-pong: last frame's curr is this frame's prev
+ REQUIRE(prev[1] == curr[0]);
+ REQUIRE(validPrev[0] == false); // frame 0: .prev never written
+ REQUIRE(validPrev[1] == true); // frame 1: .prev holds frame 0's .curr
+}
+
+// a changed hash recreates the entry, zeroing .prev, so history_valid drops to false that frame
+TEST_CASE("RenderGraph exec - a changed history hash invalidates .prev", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ bool validPrev[3] = {};
+ const uint64_t hashes[3] = { 111, 111, 222 }; // frame 2 changes the settings hash
+
+ for (int f = 0; f < 3; ++f) {
+ bool* vo = &validPrev[f];
+ const uint64_t hash = hashes[f];
+ g.frame([&](RenderGraph* rg) {
+ auto h = rg->create_history_texture("hist", tex2d(), hash);
+ add_temporal_pass(rg, h, [h, vo](PassContext& ctx) { *vo = ctx.history_valid(h); });
+ });
+ }
+
+ REQUIRE(validPrev[0] == false); // first use
+ REQUIRE(validPrev[1] == true); // steady state
+ REQUIRE(validPrev[2] == false); // hash change -> recreate -> prev invalid
+}
+
+// an untouched entry is freed after kRetain frames, so its textures do not leak for the process
+// lifetime once a feature goes inactive
+TEST_CASE("RenderGraph exec - an untouched history entry is evicted after kRetain frames", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ g.frame([&](RenderGraph* rg) {
+ auto h = rg->create_history_texture("hist", tex2d());
+ add_temporal_pass(rg, h, [](PassContext&) {});
+ });
+ REQUIRE(list_count(g.allocator->pool.entries) == 1);
+
+ for (uint32_t i = 0; i < PersistentResourcePool::kRetain; ++i)
+ g.frame([](RenderGraph*) {}); // empty graph: entry goes untouched
+ REQUIRE((g.allocator->pool.entries == nullptr));
+}
+
+// the entry holds the interned view, not an inline char[64], so a name past the old 63-char cap
+// survives whole
+TEST_CASE("RenderGraph exec - a pool entry keeps a long name in full", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ const std::string longName(120, 'z');
+
+ g.frame([&](RenderGraph* rg) {
+ auto h = rg->create_history_texture(longName, tex2d());
+ add_temporal_pass(rg, h, [](PassContext&) {});
+ });
+
+ PersistentResourcePool::Entry* e = g.allocator->pool.entries;
+ REQUIRE(e != nullptr);
+ REQUIRE(e->name_view().length == 120);
+ REQUIRE(std::string_view(e->name_view().data, e->name_view().length) == longName);
+}
+
+// the entry stores the canonical pointer, which find() keys on, so a re-declared name resolves to the
+// same entry rather than a second one
+TEST_CASE("RenderGraph exec - a re-declared name reuses its pool entry across frames", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ auto record = [](RenderGraph* rg) {
+ auto h = rg->create_history_texture("hist.reused", tex2d());
+ add_temporal_pass(rg, h, [](PassContext&) {});
+ };
+
+ g.frame(record);
+ REQUIRE(list_count(g.allocator->pool.entries) == 1);
+ const char* firstName = g.allocator->pool.entries->name_view().data;
+
+ g.frame(record); // same name next frame -> the same canonical pointer -> the same entry
+ REQUIRE(list_count(g.allocator->pool.entries) == 1);
+ REQUIRE(g.allocator->pool.entries->name_view().data == firstName);
+}
+
+// ---------------------------------------------------------------------------------------------------
+// TransientResourcePool, device-backed. drives acquire()/release_claims across real frames, where the
+// tests above are device-free.
+
+using webgpu::rg::Internal::TransientResourcePool;
+
+// StoreOp_Store keeps it out of the memoryless path, so its usage bits are stable frame to frame
+TEST_CASE("RenderGraph exec - a same-descriptor transient is reused across frames", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTexture seen[2] = {};
+
+ for (int f = 0; f < 2; ++f) {
+ WGPUTexture* out = &seen[f];
+ g.frame([&](RenderGraph* rg) {
+ auto tex = rg->create_transient_texture("t", tex2d());
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(tex, 0);
+ b.force_keep();
+ },
+ [tex, out](PassContext& ctx) { *out = ctx.texture(tex); });
+ });
+ }
+
+ REQUIRE(seen[0] != nullptr);
+ REQUIRE(seen[0] == seen[1]); // same pooled object handed back
+ REQUIRE(list_count(g.allocator->transient.entries) == 1); // not reallocated
+}
+
+// superset-reuse: frame 0 gives RenderAttachment|CopySrc, frame 1 asks for RenderAttachment alone
+TEST_CASE("RenderGraph exec - a wider-usage pooled transient satisfies a narrower request", "[RenderGraph][gpu]")
+{
+ constexpr uint32_t W = 16, H = 16;
+ const uint32_t alignedRow = webgpu::rg::aligned_bytes_per_row(W, 4);
+
+ ExecGraph g;
+ webgpu::raii::RawBuffer readback(g.gpu.device, WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead, alignedRow * H, "superset readback");
+ WGPUTexture seen[2] = {};
+
+ // frame 0: color() + copy_texture_to_buffer -> usage RenderAttachment|CopySrc
+ g.frame([&](RenderGraph* rg) {
+ auto tex = rg->create_transient_texture("t", tex2d());
+ auto buf = rg->import_buffer("rb", readback.handle());
+ rg->add_pass(
+ "w", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); },
+ [tex, out = &seen[0]](PassContext& ctx) { *out = ctx.texture(tex); });
+ rg->add_pass(
+ "rb", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_texture_to_buffer(tex, buf);
+ b.force_keep();
+ },
+ [tex, buf](PassContext& ctx) { encode_readback(ctx, tex, buf, W, H); });
+ });
+
+ // frame 1: color() only -> usage RenderAttachment (a subset) -> must reuse the wider pooled object
+ g.frame([&](RenderGraph* rg) {
+ auto tex = rg->create_transient_texture("t", tex2d());
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(tex, 0);
+ b.force_keep();
+ },
+ [tex, out = &seen[1]](PassContext& ctx) { *out = ctx.texture(tex); });
+ });
+
+ REQUIRE(seen[0] != nullptr);
+ REQUIRE(seen[0] == seen[1]); // narrower request reused the wider object
+ REQUIRE(list_count(g.allocator->transient.entries) == 1);
+}
+
+// an object unclaimed for kRetain frames is destroyed by end_frame
+TEST_CASE("RenderGraph exec - an idle transient is evicted after kRetain frames", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ g.frame([&](RenderGraph* rg) {
+ auto tex = rg->create_transient_texture("t", tex2d());
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(tex, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ });
+ REQUIRE(list_count(g.allocator->transient.entries) == 1);
+
+ for (uint32_t i = 0; i < TransientResourcePool::kRetain; ++i)
+ g.frame([](RenderGraph*) {}); // empty graph: object goes unclaimed
+ REQUIRE((g.allocator->transient.entries == nullptr));
+}
+
+// ---------------------------------------------------------------------------------------------------
+// initialize() gating. an init pass is a cull root only while armed: once its target is baked with the
+// matching hash compile() sets skipInit and drops it. the counter proves the body runs exactly on the
+// frames the pass is armed.
+TEST_CASE("RenderGraph exec - initialize() bakes once, skips while the hash holds, re-arms on change", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ int bakes = 0;
+ int snap[3] = {};
+ const uint64_t hashes[3] = { 7, 7, 9 }; // stable for two frames, then changes
+
+ for (int f = 0; f < 3; ++f) {
+ const uint64_t hash = hashes[f];
+ int* counter = &bakes;
+ g.frame(
+ [&](RenderGraph* rg) {
+ auto p = rg->create_persistent_texture("baked", tex2d());
+ rg->add_pass(
+ "bake", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(p, 0); // declare the write
+ b.initialize(p, hash); // gate: run only when stale
+ b.force_keep(); // keep the armed bake (no same-frame reader)
+ },
+ [counter](PassContext&) { (*counter)++; });
+ },
+ [&, f](RenderGraph*) { snap[f] = bakes; });
+ }
+
+ REQUIRE(snap[0] == 1); // frame 0: first bake
+ REQUIRE(snap[1] == 1); // frame 1: same hash -> skipInit, body did not run
+ REQUIRE(snap[2] == 2); // frame 2: hash changed -> re-armed
+}
+
+// two graphs in one begin_frame/end_frame. release_resources() at the end of A's execute() frees its
+// claim, so B's realize hands back the very same texture. the "execute order on one queue" contract.
+TEST_CASE("RenderGraph exec - a second graph in one frame reuses the first graph's released transient", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTexture a = nullptr, b = nullptr;
+
+ auto record_one = [](RenderGraph* rg, WGPUTexture* out) {
+ auto tex = rg->create_transient_texture("t", tex2d());
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& bb) {
+ bb.color(tex, 0);
+ bb.force_keep();
+ },
+ [tex, out](PassContext& ctx) { *out = ctx.texture(tex); });
+ };
+
+ begin_frame(g.allocator);
+ {
+ RenderGraph* rg = start_recording(g.allocator);
+ record_one(rg, &a);
+ g.submit_graph(rg); // execute() releases the transient claim on finish
+ }
+ {
+ RenderGraph* rg = start_recording(g.allocator); // same frame, same allocator
+ record_one(rg, &b);
+ g.submit_graph(rg);
+ }
+ g.wait_idle();
+ end_frame(g.allocator);
+
+ REQUIRE(a != nullptr);
+ REQUIRE(a == b); // graph B reused graph A's now-idle transient
+ REQUIRE(list_count(g.allocator->transient.entries) == 1); // one physical object served both graphs
+}
+
+// the aging clock ticks once per begin_frame/end_frame, not per execute(): two graphs per frame here,
+// yet the transient still survives exactly kRetain idle frames. release_claims() only frees claims for
+// same-frame reuse, only end_frame() advances `frame`.
+TEST_CASE("RenderGraph exec - transient eviction clock is per-frame, not per-graph", "[RenderGraph][gpu]")
+{
+ using webgpu::rg::Internal::TransientResourcePool;
+ ExecGraph g;
+
+ // the filler below uses a different format, so it neither reuses this object nor trips the
+ // same-format supersede path, leaving only the kRetain idle clock to evict it
+ auto count_tracked = [&] {
+ size_t n = 0;
+ for (const TransientResourcePool::Entry* e = g.allocator->transient.entries; e; e = e->next)
+ if (e->kind == ResourceKind::Texture && e->sig.size.width == 16 && e->sig.format == WGPUTextureFormat_RGBA8Unorm)
+ ++n;
+ return n;
+ };
+
+ auto clear_transient = [](RenderGraph* rg, const char* id, uint32_t wh, WGPUTextureFormat fmt) {
+ TextureDesc d = tex2d();
+ d.absolute = { wh, wh, 1 };
+ d.format = fmt;
+ auto t = rg->create_transient_texture(id, d);
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t, 0);
+ b.force_keep(); // nothing reads it; keep the pass so the transient is realized
+ },
+ [](PassContext&) {});
+ };
+
+ // frame 0: one graph creates the transient we track
+ g.frame([&](RenderGraph* rg) { clear_transient(rg, "tracked", 16, WGPUTextureFormat_RGBA8Unorm); });
+ REQUIRE(count_tracked() == 1);
+
+ // aging frames: TWO graphs each, on a distinct-format filler so nothing refreshes the tracked
+ // object's recency. per-execute aging would kill it twice as fast.
+ auto aging_frame = [&] {
+ begin_frame(g.allocator);
+ { RenderGraph* rg = start_recording(g.allocator); clear_transient(rg, "filler", 8, WGPUTextureFormat_R8Unorm); g.submit_graph(rg); }
+ { RenderGraph* rg = start_recording(g.allocator); clear_transient(rg, "filler", 8, WGPUTextureFormat_R8Unorm); g.submit_graph(rg); }
+ g.wait_idle();
+ end_frame(g.allocator);
+ };
+
+ for (uint64_t i = 1; i < TransientResourcePool::kRetain; ++i) {
+ aging_frame();
+ REQUIRE(count_tracked() == 1); // idle < kRetain frames -> retained despite 2 graphs/frame
+ }
+ aging_frame();
+ REQUIRE(count_tracked() == 0); // idle == kRetain frames -> evicted
+}
+
+// two graphs, one frame, same transient at different sizes. acquire() is exact-size create-on-miss and
+// destruction defers to end_frame(), so B never gets A's wrong-sized object, both live through the
+// frame, and both being used this frame keeps supersede from firing on either.
+TEST_CASE("RenderGraph exec - two sizes used in one frame coexist and neither is evicted", "[RenderGraph][gpu]")
+{
+ using webgpu::rg::Internal::TransientResourcePool;
+ ExecGraph g;
+ WGPUTexture big = nullptr, small = nullptr;
+
+ auto clear_sized = [](RenderGraph* rg, uint32_t wh, WGPUTexture* out) {
+ TextureDesc d = tex2d(); // same format; only the size differs between the two graphs
+ d.absolute = { wh, wh, 1 };
+ auto t = rg->create_transient_texture("t", d);
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t, 0);
+ b.force_keep();
+ },
+ [t, out](PassContext& ctx) { *out = ctx.texture(t); });
+ };
+
+ auto count = [&](uint32_t w) {
+ size_t n = 0;
+ for (const TransientResourcePool::Entry* e = g.allocator->transient.entries; e; e = e->next)
+ if (e->kind == ResourceKind::Texture && e->sig.size.width == w)
+ ++n;
+ return n;
+ };
+
+ begin_frame(g.allocator);
+ { RenderGraph* rg = start_recording(g.allocator); clear_sized(rg, 16, &big); g.submit_graph(rg); }
+ { RenderGraph* rg = start_recording(g.allocator); clear_sized(rg, 8, &small); g.submit_graph(rg); }
+ g.wait_idle();
+
+ // mid-frame: both live at once, and B got a fresh 8x8 rather than A's 16x16
+ REQUIRE(big != nullptr);
+ REQUIRE(small != nullptr);
+ REQUIRE(big != small); // exact-size match -> distinct objects, no wrong-size reuse
+ REQUIRE(list_count(g.allocator->transient.entries) == 2); // no mid-frame destruction
+
+ end_frame(g.allocator);
+
+ // both touched this frame -> superseded_by bails on lastUsedFrame >= frame
+ REQUIRE(count(16) == 1);
+ REQUIRE(count(8) == 1);
+}
+
+// ---------------------------------------------------------------------------------------------------
+// resize end to end. the device-free pool tests drive end_frame() on hand-built entries, these are the
+// only proof the real realize -> acquire -> end_frame path resizes as designed, identity and all.
+
+// RGBA8 2D at a caller-chosen size, for the resize and relative-sizing tests
+static TextureDesc tex2d_sized(uint32_t w, uint32_t h)
+{
+ TextureDesc d = tex2d();
+ d.absolute = { w, h, 1 };
+ return d;
+}
+
+TEST_CASE("RenderGraph exec - a resize destroys the old-size transient", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTexture before = nullptr, after = nullptr;
+
+ // one transient, same name every frame: a screen-sized target across a window resize
+ auto frame_at = [](RenderGraph* rg, uint32_t w, WGPUTexture* out) {
+ auto t = rg->create_transient_texture("screen", tex2d_sized(w, w));
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t, 0);
+ b.force_keep();
+ },
+ [t, out](PassContext& ctx) { *out = ctx.texture(t); });
+ };
+
+ uint32_t slotCount = 99;
+ g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &before); },
+ [&](RenderGraph* rg) { slotCount = storage(rg)->m_slotCount; });
+ // a lone clear+store attachment is aliasing-eligible, so this rides an alias slot and the identity
+ // under test is PhysicalResource::identity. the per-resource arm is the alias-excluded test below.
+ REQUIRE(slotCount == 1);
+
+ g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &before); }); // steady state: pooled, not recreated
+ REQUIRE(list_count(g.allocator->transient.entries) == 1);
+
+ g.frame([&](RenderGraph* rg) { frame_at(rg, 32, &after); });
+
+ // the 16x16 is gone the frame the 32x32 appeared, well inside kRetain, so only supersede took it
+ REQUIRE(after != nullptr);
+ REQUIRE(after != before);
+ REQUIRE(list_count(g.allocator->transient.entries) == 1);
+ REQUIRE(g.allocator->transient.entries->sig.size.width == 32);
+}
+
+TEST_CASE("RenderGraph exec - a non-aliased transient resizes without leaking a generation", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTexture before = nullptr, after = nullptr;
+
+ // a mip>1 transient is excluded from aliasing, so this drives the per-resource arm, whose identity
+ // comes off the resource name. without it the supersede silently stops firing.
+ auto frame_at = [](RenderGraph* rg, uint32_t w, WGPUTexture* out) {
+ TextureDesc d = tex2d_sized(w, w);
+ d.mipLevelCount = 2;
+ auto t = rg->create_transient_texture("chain", d);
+ rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t, 0);
+ b.force_keep();
+ },
+ [t, out](PassContext& ctx) { *out = ctx.texture(t); });
+ };
+
+ uint32_t slotCount = 99;
+ g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &before); },
+ [&](RenderGraph* rg) { slotCount = storage(rg)->m_slotCount; });
+ REQUIRE(slotCount == 0); // not aliased -> the per-resource arm
+
+ g.frame([&](RenderGraph* rg) { frame_at(rg, 32, &after); });
+
+ REQUIRE(after != before);
+ REQUIRE(list_count(g.allocator->transient.entries) == 1);
+ REQUIRE(g.allocator->transient.entries->sig.size.width == 32);
+}
+
+TEST_CASE("RenderGraph exec - an aliased slot resizes without leaking a generation", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTexture a = nullptr, b = nullptr;
+
+ // two disjoint transients on one alias slot, resized. aliasing must not multiply generations.
+ auto frame_at = [](RenderGraph* rg, uint32_t w, WGPUTexture* o1, WGPUTexture* o2) {
+ auto t1 = rg->create_transient_texture("t1", tex2d_sized(w, w));
+ auto t2 = rg->create_transient_texture("t2", tex2d_sized(w, w));
+ rg->add_pass(
+ "k1", PassKind::Graphics,
+ [&](PassBuilder& bb) {
+ bb.color(t1, 0);
+ bb.force_keep();
+ },
+ [t1, o1](PassContext& ctx) { *o1 = ctx.texture(t1); });
+ rg->add_pass(
+ "k2", PassKind::Graphics,
+ [&](PassBuilder& bb) {
+ bb.color(t2, 0);
+ bb.force_keep();
+ },
+ [t2, o2](PassContext& ctx) { *o2 = ctx.texture(t2); });
+ };
+
+ uint32_t slotsBefore = 0;
+ g.frame([&](RenderGraph* rg) { frame_at(rg, 16, &a, &b); },
+ [&](RenderGraph* rg) { slotsBefore = storage(rg)->m_slotCount; });
+ REQUIRE(slotsBefore == 1); // both transients share one slot
+ REQUIRE(a == b);
+ REQUIRE(list_count(g.allocator->transient.entries) == 1);
+
+ uint32_t slotsAfter = 0;
+ g.frame([&](RenderGraph* rg) { frame_at(rg, 32, &a, &b); },
+ [&](RenderGraph* rg) { slotsAfter = storage(rg)->m_slotCount; });
+
+ REQUIRE(slotsAfter == 1);
+ REQUIRE(a == b);
+ REQUIRE(list_count(g.allocator->transient.entries) == 1); // the 16x16 slot object did not survive
+ REQUIRE(g.allocator->transient.entries->sig.size.width == 32);
+}
+
+// ===================================================================================================
+// gap-analysis additions. compile-only unless tagged [gpu]. relative sizing, history-layer validation,
+// storage RMW, buffer/excluded aliasing, the buffer read access types, the initialized/history
+// constructors.
+
+// declaring only slots 0 and 2 must make execute() emit a 3-entry array whose slot 1 is a null
+// attachment. a bare render pass would not prove it: with no pipeline bound Dawn barely validates the
+// color array. a pipeline writing @location(0) and @location(2) forces it to match 3 targets.
+TEST_CASE("RenderGraph exec - a sparse color slot is emitted as a valid null attachment", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+
+ static const char* kWgsl = R"(
+@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
+ var p = array(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
+ return vec4f(p[i], 0.0, 1.0);
+}
+struct Out {
+ @location(0) a: vec4f,
+ @location(2) c: vec4f,
+}
+@fragment fn fs() -> Out {
+ return Out(vec4f(1.0, 0.0, 0.0, 1.0), vec4f(0.0, 0.0, 1.0, 1.0));
+}
+)";
+ WGPUShaderSourceWGSL wgsl {};
+ wgsl.chain.sType = WGPUSType_ShaderSourceWGSL;
+ wgsl.code = WGPUStringView { .data = kWgsl, .length = WGPU_STRLEN };
+ WGPUShaderModuleDescriptor smd {};
+ smd.nextInChain = &wgsl.chain;
+ WGPUShaderModule sm = wgpuDeviceCreateShaderModule(g.gpu.device, &smd);
+ REQUIRE(sm != nullptr);
+
+ // slot 1 is deliberately an unused target, so the pipeline expects a null attachment exactly where
+ // the graph must emit one
+ WGPUColorTargetState targets[3] {};
+ targets[0].format = WGPUTextureFormat_RGBA8Unorm;
+ targets[0].writeMask = WGPUColorWriteMask_All;
+ targets[1].format = WGPUTextureFormat_Undefined;
+ targets[2].format = WGPUTextureFormat_RGBA8Unorm;
+ targets[2].writeMask = WGPUColorWriteMask_All;
+
+ WGPUFragmentState fs {};
+ fs.module = sm;
+ fs.entryPoint = WGPUStringView { .data = "fs", .length = WGPU_STRLEN };
+ fs.targetCount = 3;
+ fs.targets = targets;
+
+ WGPURenderPipelineDescriptor rpd {};
+ rpd.vertex.module = sm;
+ rpd.vertex.entryPoint = WGPUStringView { .data = "vs", .length = WGPU_STRLEN };
+ rpd.primitive.topology = WGPUPrimitiveTopology_TriangleList;
+ rpd.multisample.count = 1;
+ rpd.multisample.mask = 0xFFFFFFFF;
+ rpd.fragment = &fs;
+ WGPURenderPipeline pipe = wgpuDeviceCreateRenderPipeline(g.gpu.device, &rpd);
+ REQUIRE(pipe != nullptr);
+
+ wgpuDevicePushErrorScope(g.gpu.device, WGPUErrorFilter_Validation);
+
+ g.frame([pipe](RenderGraph* rg) {
+ auto s0 = rg->create_transient_texture("slot0", tex2d_sized(16, 16));
+ auto s2 = rg->create_transient_texture("slot2", tex2d_sized(16, 16));
+ rg->add_pass(
+ "sparse", PassKind::Graphics,
+ [s0, s2](PassBuilder& b) {
+ b.color(s0, 0);
+ b.color(s2, 2); // slot 1 left unused -> null attachment, colorAttachmentCount == 3
+ b.force_keep();
+ },
+ [pipe](PassContext& c) {
+ wgpuRenderPassEncoderSetPipeline(c.render_pass, pipe);
+ wgpuRenderPassEncoderDraw(c.render_pass, 3, 1, 0, 0);
+ });
+ });
+
+ struct ScopeResult {
+ bool done = false;
+ bool failed = false;
+ std::string message;
+ } res;
+ WGPUPopErrorScopeCallbackInfo ci {
+ .nextInChain = nullptr,
+ .mode = WGPUCallbackMode_AllowProcessEvents,
+ .callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type, WGPUStringView msg, void* d, void*) {
+ ScopeResult* r = reinterpret_cast(d);
+ r->done = true;
+ r->failed = (type != WGPUErrorType_NoError);
+ if (msg.data)
+ r->message.assign(msg.data, msg.length);
+ },
+ .userdata1 = &res,
+ .userdata2 = nullptr,
+ };
+ WGPUFuture f = wgpuDevicePopErrorScope(g.gpu.device, ci);
+ WGPUFutureWaitInfo wi { .future = f, .completed = false };
+ REQUIRE(wgpuInstanceWaitAny(g.gpu.instance, 1, &wi, 1000ull * 1000 * 1000) == WGPUWaitStatus_Success);
+ REQUIRE(res.done);
+ INFO(res.message);
+ REQUIRE_FALSE(res.failed);
+
+ wgpuRenderPipelineRelease(pipe);
+ wgpuShaderModuleRelease(sm);
+}
+
+
+// a relativeTo child is width*scale, rounded not truncated. 10 x 0.25 = 2.5 -> 3.
+TEST_CASE("RenderGraph - relative-sized transient rounds against its base", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+
+ TestGraph g;
+ auto base = g.rg->create_transient_texture("base", tex2d_sized(10, 10));
+
+ TextureDesc rd {};
+ rd.dimension = WGPUTextureDimension_2D;
+ rd.format = WGPUTextureFormat_RGBA8Unorm;
+ rd.sizeKind = SizeKind::Relative;
+ rd.scaleX = 0.25f;
+ rd.scaleY = 0.25f;
+ rd.relativeTo = base;
+ rd.absolute = { 0, 0, 1 }; // width/height come from the base; depthOrArrayLayers stays 1
+ auto child = g.rg->create_transient_texture("child", rd);
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(child, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ // 10 * 0.25 = 2.5 -> round to 3 (truncation would give 2)
+ REQUIRE(find_node(g.rg, child)->resolved.width == 3);
+ REQUIRE(find_node(g.rg, child)->resolved.height == 3);
+}
+
+// a two-deep chain resolves recursively: 40 -> mid 0.5 -> leaf 0.5
+TEST_CASE("RenderGraph - relative-size chain resolves each level", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+
+ TestGraph g;
+ auto base = g.rg->create_transient_texture("base", tex2d_sized(40, 40));
+
+ auto half_of = [&](std::string_view id, TextureHandle parent) {
+ TextureDesc d {};
+ d.dimension = WGPUTextureDimension_2D;
+ d.format = WGPUTextureFormat_RGBA8Unorm;
+ d.sizeKind = SizeKind::Relative;
+ d.scaleX = 0.5f;
+ d.scaleY = 0.5f;
+ d.relativeTo = parent;
+ d.absolute = { 0, 0, 1 };
+ return g.rg->create_transient_texture(id, d);
+ };
+ auto mid = half_of("mid", base);
+ auto leaf = half_of("leaf", mid);
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(leaf, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(find_node(g.rg, mid)->resolved.width == 20);
+ REQUIRE(find_node(g.rg, leaf)->resolved.width == 10);
+}
+
+// ---------------------------------------------------------------------------------------------------
+// render-pass descriptor validation. each is a rule WebGPU enforces on the descriptor execute() builds,
+// caught here so compile() can name the pass and resources instead of leaving a device error that only
+// describes the descriptor. each pass needs force_keep(), or culling drops it before the validator runs.
+
+static TextureDesc tex2d_fmt(WGPUTextureFormat fmt, uint32_t w = 16, uint32_t h = 16, uint32_t samples = 1)
+{
+ TextureDesc d {};
+ d.dimension = WGPUTextureDimension_2D;
+ d.format = fmt;
+ d.absolute = { w, h, 1 };
+ d.sampleCount = samples;
+ return d;
+}
+
+TEST_CASE("compile - a Graphics pass with no attachments is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+ // the writer must come first, or the read-before-write check returns before render-pass validation
+ g.rg->add_pass(
+ "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(tex); // reads only, so BeginRenderPass would have nothing to attach
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "Graphics pass with no attachments"));
+}
+
+// depth-only is legal, it is what a shadow map looks like
+TEST_CASE("compile - a depth-only Graphics pass is valid", "[RenderGraph]")
+{
+ TestGraph g;
+ auto depth = g.rg->create_transient_texture("shadow", tex2d_fmt(WGPUTextureFormat_Depth32Float));
+ g.rg->add_pass(
+ "shadow", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil(depth);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("compile - attachments of different sizes in one pass are rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto full = g.rg->create_transient_texture("full", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16));
+ auto half = g.rg->create_transient_texture("half", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 8, 8));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(full, 0);
+ b.color(half, 1);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "must be the same size"));
+}
+
+TEST_CASE("compile - attachments of different sample counts in one pass are rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4));
+ auto single = g.rg->create_transient_texture("single", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(msaa, 0);
+ b.color(single, 1);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "must have the same sample count"));
+}
+
+TEST_CASE("compile - a well-formed MSAA resolve is valid", "[RenderGraph]")
+{
+ TestGraph g;
+ auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4));
+ auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(msaa, 0);
+ b.resolve(msaa, target);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("compile - resolving a single-sampled source is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto src = g.rg->create_transient_texture("src", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1));
+ auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 1));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(src, 0);
+ b.resolve(src, target);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "must be multisampled"));
+}
+
+TEST_CASE("compile - a multisampled resolve target is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4));
+ auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(msaa, 0);
+ b.resolve(msaa, target);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "must be single-sampled"));
+}
+
+TEST_CASE("compile - a resolve target with a different format is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4));
+ auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_R32Float, 16, 16, 1));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(msaa, 0);
+ b.resolve(msaa, target);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "must have the same format"));
+}
+
+// a resolve target is excluded from the shared-extent check, so the resolve arm must catch this
+TEST_CASE("compile - a resolve target of a different size is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto msaa = g.rg->create_transient_texture("msaa", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 16, 16, 4));
+ auto target = g.rg->create_transient_texture("target", tex2d_fmt(WGPUTextureFormat_RGBA8Unorm, 8, 8, 1));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(msaa, 0);
+ b.resolve(msaa, target);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "they must be the same size"));
+}
+
+TEST_CASE("compile - a colour format bound as depth-stencil is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto notDepth = g.transient("notDepth"); // RGBA8Unorm
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil(notDepth);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "format is not a depth format"));
+}
+
+TEST_CASE("compile - stencil ops on a format without a stencil aspect are rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto depth = g.rg->create_transient_texture("depth", tex2d_fmt(WGPUTextureFormat_Depth32Float));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil(depth, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "has no stencil aspect"));
+}
+
+// the inverse: WebGPU requires the ops when the format has a stencil aspect, and depth_stencil() leaves
+// them Undefined, so the default is wrong for a combined format
+TEST_CASE("compile - a stencil format without stencil ops is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto ds = g.rg->create_transient_texture("ds", tex2d_fmt(WGPUTextureFormat_Depth24PlusStencil8));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil(ds); // stencil ops default to Undefined
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "requires stencil load/store ops"));
+}
+
+TEST_CASE("compile - a stencil format with stencil ops is valid", "[RenderGraph]")
+{
+ TestGraph g;
+ auto ds = g.rg->create_transient_texture("ds", tex2d_fmt(WGPUTextureFormat_Depth24PlusStencil8));
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil(ds, { .stencilLoad = WGPULoadOp_Clear, .stencilStore = WGPUStoreOp_Store });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+// execute() never sets depthSlice, which WebGPU requires for a 3D color target
+TEST_CASE("compile - a 3D texture used as a render target is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc d = tex2d_fmt(WGPUTextureFormat_RGBA8Unorm);
+ d.dimension = WGPUTextureDimension_3D;
+ d.absolute = { 16, 16, 4 };
+ auto vol = g.rg->create_transient_texture("vol", d);
+ g.rg->add_pass(
+ "draw", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(vol, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "3D texture used as a render target"));
+}
+
+// ---------------------------------------------------------------------------------------------------
+// copy_extent_* describes the declared subresource, not everything the texture holds. copy_*_info()
+// offsets origin.z by baseLayer, so a whole-array extent would run the copy off the end. resolves from
+// the node and access alone, so the pass is built by hand and no device is needed.
+
+static PassNode* pass_named(RenderGraph* rg, std::string_view name)
+{
+ for (PassNode* p = storage(rg)->m_passes; p; p = p->next)
+ if (std::string_view(p->id.name.data, p->id.name.length) == name)
+ return p;
+ return nullptr;
+}
+
+// no comma in the name, Catch2 treats one as a filter separator
+TEST_CASE("PassContext::copy_extent - an array copy covers one layer rather than the whole array", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc d = tex2d();
+ d.absolute = { 16, 16, 4 }; // 4 array layers
+ auto src = g.rg->create_transient_texture("src", d);
+ auto dst = g.rg->create_transient_texture("dst", d);
+
+ g.rg->add_pass(
+ "produce", PassKind::Graphics,
+ [&](PassBuilder& b) { b.color(src, 0, { .sub = { .layer = 2 } }); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "copy", PassKind::Transfer,
+ [&](PassBuilder& b) {
+ b.copy_texture(src, dst, { .layer = 2 }, { .layer = 1 }); // layer 2 -> layer 1, one layer
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ PassNode* copyPass = pass_named(g.rg, "copy");
+ REQUIRE(copyPass != nullptr);
+ Internal::PassContextAccess access(g.rg, copyPass);
+ PassContext& ctx = access.ctx;
+ REQUIRE(ctx.copy_extent_src(src).depthOrArrayLayers == 1); // not 4
+ REQUIRE(ctx.copy_extent_dst(dst).depthOrArrayLayers == 1);
+ REQUIRE(ctx.copy_extent_src(src).width == 16);
+}
+
+// 3D depth slices halve with the mip like width and height. only an import can carry both, since
+// validate_texture_desc holds graph-created 3D textures to one mip.
+TEST_CASE("PassContext::copy_extent - a 3D copy shrinks its depth with the mip", "[RenderGraph]")
+{
+ TestGraph g;
+ auto import3d = [&](std::string_view id) {
+ return g.rg->import_texture(id, { .view = (WGPUTextureView)0x1, .size = { 16, 16, 8 }, .format = WGPUTextureFormat_RGBA8Unorm, .mipCount = 2, .dimension = WGPUTextureDimension_3D });
+ };
+ auto src = import3d("src3d");
+ auto dst = import3d("dst3d");
+
+ g.rg->add_pass(
+ "copy", PassKind::Transfer, [&](PassBuilder& b) { b.copy_texture(src, dst, { .mip = 1 }, { .mip = 1 }); }, [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ PassNode* copyPass = pass_named(g.rg, "copy");
+ REQUIRE(copyPass != nullptr);
+ Internal::PassContextAccess access(g.rg, copyPass);
+ PassContext& ctx = access.ctx;
+ WGPUExtent3D e = ctx.copy_extent_src(src);
+ REQUIRE(e.width == 8); // 16 >> 1
+ REQUIRE(e.height == 8);
+ REQUIRE(e.depthOrArrayLayers == 4); // 8 >> 1, not the base depth of 8
+}
+
+// ---------------------------------------------------------------------------------------------------
+// zero extents. a 0-sized texture fails device validation with no link back to the descriptor, so
+// compile() names it first. rounding down to 0 clamps instead, it happens legitimately mid-resize.
+
+// a child small enough to round to 0 clamps to 1 rather than failing the frame
+TEST_CASE("RenderGraph - a relative size that rounds to zero clamps to 1", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+
+ TestGraph g;
+ auto base = g.rg->create_transient_texture("base", tex2d_sized(1, 1));
+
+ TextureDesc rd {};
+ rd.dimension = WGPUTextureDimension_2D;
+ rd.format = WGPUTextureFormat_RGBA8Unorm;
+ rd.sizeKind = SizeKind::Relative;
+ rd.scaleX = 0.25f; // 1 * 0.25 = 0.25 -> rounds to 0
+ rd.scaleY = 0.25f;
+ rd.relativeTo = base;
+ rd.absolute = { 0, 0, 1 };
+ auto child = g.rg->create_transient_texture("child", rd);
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(base, 0);
+ b.color(child, 1);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(find_node(g.rg, child)->resolved.width == 1);
+ REQUIRE(find_node(g.rg, child)->resolved.height == 1);
+}
+
+// an absolute extent left at its default is an authoring error, not a rounding artifact
+TEST_CASE("RenderGraph - a live texture with a zero absolute extent is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ // the declare-time assert covers this in a debug build, so reach the compile backstop the same
+ // white-box way the cyclic relativeTo test does
+ auto zero = g.rg->create_transient_texture("zero", tex2d());
+ Internal::find_node(g.rg, zero)->absolute = { 0, 0, 1 };
+ Internal::find_node(g.rg, zero)->resolved = {};
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(zero, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "\"zero\" resolves to a zero extent (0 x 0)"));
+}
+
+// the minimized-window case. the error must name the import, not the relative children, which clamp to
+// 1 and are not themselves wrong. the height is deliberately non-zero: were the memo guard to test width
+// alone this would fall through to the Absolute arm and report 0 x 0, since an import never fills
+// `absolute`.
+TEST_CASE("RenderGraph - a zero-sized import is rejected and its children are not", "[RenderGraph]")
+{
+ TestGraph g;
+ auto surface = g.rg->import_texture("surface", { .view = (WGPUTextureView)0x1, .size = { 0, 16, 1 }, .format = WGPUTextureFormat_RGBA8Unorm });
+
+ TextureDesc rd {};
+ rd.dimension = WGPUTextureDimension_2D;
+ rd.format = WGPUTextureFormat_RGBA8Unorm;
+ rd.sizeKind = SizeKind::Relative;
+ rd.scaleX = 0.5f;
+ rd.scaleY = 0.5f;
+ rd.relativeTo = surface;
+ rd.absolute = { 0, 0, 1 };
+ auto child = g.rg->create_transient_texture("half", rd);
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(child, 0);
+ b.color(surface, 1);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "\"surface\" resolves to a zero extent (0 x 16)")); // not 0 x 0
+ REQUIRE_FALSE(error_mentions(g.rg, "\"half\" resolves to a zero extent"));
+}
+
+// relative with no relativeTo would clamp to a silent 1x1, so it is reported instead
+TEST_CASE("RenderGraph - a relative size with no relativeTo is rejected", "[RenderGraph]")
+{
+ TestGraph g;
+ auto orphan = g.rg->create_transient_texture("orphan", tex2d());
+ Internal::find_node(g.rg, orphan)->sizeKind = SizeKind::Relative; // never names a base
+ Internal::find_node(g.rg, orphan)->resolved = {};
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(orphan, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "names no resource to size against"));
+}
+
+// depthOrArrayLayers == 0 means one layer everywhere else, so the resolver normalizes rather than
+// treating it as a zero extent
+TEST_CASE("RenderGraph - a zero depthOrArrayLayers normalizes to one layer", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+
+ TestGraph g;
+ TextureDesc d = tex2d();
+ d.absolute = { 16, 16, 0 };
+ auto tex = g.rg->create_transient_texture("tex", d);
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(tex, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(find_node(g.rg, tex)->resolved.depthOrArrayLayers == 1);
+}
+
+// release-only: validate_texture_desc asserts both at the create call, and in debug the abort IS the
+// contract. these reach the release backstop through the public API rather than by patching the node.
+#ifdef QT_NO_DEBUG
+TEST_CASE("RenderGraph - a zero absolute extent is caught through the public API", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc d = tex2d();
+ d.absolute = { 0, 0, 1 };
+ auto zero = g.rg->create_transient_texture("zero", d);
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(zero, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "resolves to a zero extent"));
+}
+#endif
+
+#ifdef QT_NO_DEBUG
+TEST_CASE("RenderGraph - a missing relativeTo is caught through the public API", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc d = tex2d();
+ d.sizeKind = SizeKind::Relative; // relativeTo left at {}
+ auto orphan = g.rg->create_transient_texture("orphan", d);
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(orphan, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "names no resource to size against"));
+}
+#endif
+
+// only live textures are created, so a zero-sized one nothing touches must not fail the frame
+TEST_CASE("RenderGraph - an unused zero-sized texture compiles clean", "[RenderGraph]")
+{
+ TestGraph g;
+ auto used = g.transient("used");
+ auto dead = g.rg->create_transient_texture("dead", tex2d());
+ Internal::find_node(g.rg, dead)->absolute = { 0, 0, 1 }; // declared, never accessed
+ Internal::find_node(g.rg, dead)->resolved = {};
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(used, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+// writing .curr while nothing reads .prev culls the layers asymmetrically, which is unrealizable
+TEST_CASE("RenderGraph - history with a curr writer but no prev reader is an error", "[RenderGraph]")
+{
+ TestGraph g;
+ auto hist = g.rg->create_history_texture("hist", tex2d());
+
+ g.rg->add_pass(
+ "writeCurr", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(hist.curr, 0); // .curr used, .prev never read
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "read .prev and write .curr in one pass"));
+}
+
+// only .curr is writable
+TEST_CASE("RenderGraph - writing a history .prev layer is an error", "[RenderGraph]")
+{
+ TestGraph g;
+ auto hist = g.rg->create_history_texture("hist", tex2d());
+
+ g.rg->add_pass(
+ "writePrev", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(hist.prev, 0); // layer 1 is read-only this frame
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "only layer 0, the .curr handle, is writable"));
+}
+
+// the same-pass write cannot seed the read, dispatch invocations are unordered
+TEST_CASE("RenderGraph - storage_read_write on an unproduced transient is an error", "[RenderGraph]")
+{
+ TestGraph g;
+ auto buf = g.transient_buffer("rmw");
+
+ g.rg->add_pass(
+ "rmw", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.storage_read_write(buf);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ // the read-modify-write diagnosis, not "no pass ever writes": a writer exists, it just cannot seed
+ // this read
+ REQUIRE(error_mentions(g.rg, "can't seed the read"));
+}
+
+// legal once an earlier pass produced the buffer
+TEST_CASE("RenderGraph - storage_read_write after a producer compiles clean", "[RenderGraph]")
+{
+ TestGraph g;
+ auto buf = g.transient_buffer("rmw");
+
+ add_buffer_producer(g.rg, buf); // storage_write producer, satisfies the RMW read
+ g.rg->add_pass(
+ "rmw", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.storage_read_write(buf);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(idx_of(pass_order(g.rg), "produce") < idx_of(pass_order(g.rg), "rmw"));
+}
+
+// the isBuf path the texture alias tests never reach. storage_write fully defines.
+TEST_CASE("RenderGraph - disjoint transient buffers share one physical slot", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto b1 = g.transient_buffer("b1", 64);
+ auto b2 = g.transient_buffer("b2", 64);
+
+ g.rg->add_pass(
+ "k1", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.storage_write(b1);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "k2", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.storage_write(b2);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(true) == nullptr);
+ REQUIRE(storage(g.rg)->m_slotCount == 1);
+ ResourceNode* r1 = find_node(g.rg, b1);
+ ResourceNode* r2 = find_node(g.rg, b2);
+ REQUIRE(r1->aliasSlot != ResourceNode::kNoSlot);
+ REQUIRE(r1->aliasSlot == r2->aliasSlot);
+}
+
+// mip-chain transients are excluded, a slot's default view would not fit
+TEST_CASE("RenderGraph - mip-chain transients are excluded from aliasing", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto t1 = g.transient("t1", /*mipLevelCount*/ 2);
+ auto t2 = g.transient("t2", /*mipLevelCount*/ 2);
+
+ g.rg->add_pass(
+ "k1", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t1, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "k2", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t2, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(true) == nullptr);
+ REQUIRE(storage(g.rg)->m_slotCount == 0);
+ REQUIRE(find_node(g.rg, t1)->aliasSlot == ResourceNode::kNoSlot);
+ REQUIRE(find_node(g.rg, t2)->aliasSlot == ResourceNode::kNoSlot);
+}
+
+// a non-Clear first touch does not fully define the storage, so it is ineligible even though a pass
+// writes it
+TEST_CASE("RenderGraph - a load-first transient is excluded from aliasing", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto t1 = g.transient("t1");
+ auto t2 = g.transient("t2");
+
+ g.rg->add_pass(
+ "k1", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t1, 0, { .load = WGPULoadOp_Load }); // load-first: not a full define
+ b.force_keep();
+ },
+ [](PassContext&) {});
+ g.rg->add_pass(
+ "k2", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t2, 0, { .load = WGPULoadOp_Load });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(true) == nullptr);
+ REQUIRE(storage(g.rg)->m_slotCount == 0);
+ REQUIRE(find_node(g.rg, t1)->aliasSlot == ResourceNode::kNoSlot);
+ REQUIRE(find_node(g.rg, t2)->aliasSlot == ResourceNode::kNoSlot);
+}
+
+// the depth analogue of the color clear+discard test
+TEST_CASE("RenderGraph - single clear+discard depth attachment is inferred transient", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc dd {};
+ dd.dimension = WGPUTextureDimension_2D;
+ dd.format = WGPUTextureFormat_Depth32Float;
+ dd.absolute = { 16, 16, 1 };
+ auto ds = g.rg->create_transient_texture("ds", dd);
+
+ g.rg->add_pass(
+ "depth", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.depth_stencil(ds, { .store = WGPUStoreOp_Discard });
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(storage(g.rg)->transientCount == 1);
+}
+
+// uniform/vertex/index/indirect accumulate their usage onto the node. read against persistent buffers,
+// which are external, so no read-before-write.
+TEST_CASE("RenderGraph - buffer read accesses accumulate their usage bits", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+
+ TestGraph g;
+ BufferDesc bd {};
+ bd.size = 64;
+ auto ubo = g.rg->create_persistent_buffer("ubo", bd);
+ auto vbo = g.rg->create_persistent_buffer("vbo", bd);
+ auto ibo = g.rg->create_persistent_buffer("ibo", bd);
+ auto indirect = g.rg->create_persistent_buffer("indirect", bd);
+ auto target = g.transient("target"); // vertex/index/indirect are graphics inputs, so this
+ // stays a Graphics pass and needs a real attachment
+
+ g.rg->add_pass(
+ "read", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(target, 0);
+ b.uniform(ubo);
+ b.vertex_buffer(vbo);
+ b.index_buffer(ibo);
+ b.indirect_buffer(indirect);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE((find_node(g.rg, ubo)->bufUsage & WGPUBufferUsage_Uniform) != 0);
+ REQUIRE((find_node(g.rg, vbo)->bufUsage & WGPUBufferUsage_Vertex) != 0);
+ REQUIRE((find_node(g.rg, ibo)->bufUsage & WGPUBufferUsage_Index) != 0);
+ REQUIRE((find_node(g.rg, indirect)->bufUsage & WGPUBufferUsage_Indirect) != 0);
+}
+
+// the rewrite shares no edge with the reader except the WAR one, so reader-before-rewrite proves it
+// fired
+TEST_CASE("RenderGraph - write-after-read orders the rewriter after the reader", "[RenderGraph]")
+{
+ TestGraph g;
+ auto sink = import_tex(g.rg, "sink");
+ auto x = g.transient("x");
+
+ g.rg->add_pass(
+ "produce", PassKind::Graphics, [&](PassBuilder& b) { b.color(x, 0); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "read", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(x);
+ b.color(sink, 0, { .load = WGPULoadOp_Load });
+ },
+ [](PassContext&) {});
+ g.rg->add_pass( // clobbers x's version the reader still uses -> WAR edge onto "read"
+ "rewrite", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(x, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ auto order = pass_order(g.rg);
+ REQUIRE(idx_of(order, "read") < idx_of(order, "rewrite"));
+}
+
+// initialize() targets must be pool-backed
+TEST_CASE("RenderGraph - initialize() on a transient target is an error", "[RenderGraph]")
+{
+ TestGraph g;
+ auto tex = g.transient("tex");
+
+ g.rg->add_pass(
+ "bake", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(tex, 0); // the required write
+ b.initialize(tex); // ... but tex is transient, not persistent
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() != nullptr);
+ REQUIRE(error_mentions(g.rg, "must be persistent or history"));
+}
+
+// the ping-pong pattern for a GPU-authored buffer. .curr is a cull sink and .prev is external, so it
+// compiles clean.
+TEST_CASE("RenderGraph - history buffer read-prev/write-curr compiles clean", "[RenderGraph]")
+{
+ TestGraph g;
+ BufferDesc bd {};
+ bd.size = 64;
+ auto h = g.rg->create_history_buffer("hbuf", bd);
+
+ g.rg->add_pass(
+ "temporal", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.storage_read(h.prev);
+ b.storage_write(h.curr);
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).size() == 1);
+}
+
+// synthesizes an initialize()'d bake pass, which a reader pulls in as a dependency
+TEST_CASE("RenderGraph - create_initialized_texture bakes a pass a reader can consume", "[RenderGraph]")
+{
+ TestGraph g;
+ auto fallback = g.rg->create_initialized_texture("fallback", tex2d(), { 0, 0, 0, 1 });
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "use", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(fallback);
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).size() == 2); // the synthesized bake + the reader
+ REQUIRE(idx_of(pass_order(g.rg), "fallback") != -1); // single layer keeps the bare id, no suffix
+}
+
+// one pass per layer, named ".layer" so debug groups and profiler series stay distinct. a missing
+// suffix would not merely blur labels, assert_unique_id rejects duplicate pass ids.
+TEST_CASE("RenderGraph - create_initialized_texture names one bake pass per layer", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc d = tex2d();
+ d.absolute = { 16, 16, 3 };
+ auto fallback = g.rg->create_initialized_texture("fallback", d, { 0, 0, 0, 1 });
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "use", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(fallback, ViewRange { .mipCount = 1, .layerCount = 3, .dim = WGPUTextureViewDimension_2DArray });
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ std::vector order = pass_order(g.rg);
+ REQUIRE(order.size() == 4); // three bakes + the reader
+ REQUIRE(idx_of(order, "fallback.layer0") != -1);
+ REQUIRE(idx_of(order, "fallback.layer1") != -1);
+ REQUIRE(idx_of(order, "fallback.layer2") != -1);
+ REQUIRE(idx_of(order, "fallback") == -1); // suffixed, not bare
+}
+
+// the suffixed name is sized from the id, not a fixed buffer, so a long id comes through whole
+TEST_CASE("RenderGraph - a long multi-layer init id is not truncated", "[RenderGraph]")
+{
+ TestGraph g;
+ const std::string longId(200, 'y');
+ TextureDesc d = tex2d();
+ d.absolute = { 16, 16, 2 };
+ auto fallback = g.rg->create_initialized_texture(longId, d, { 0, 0, 0, 1 });
+ auto out = g.transient("out");
+
+ g.rg->add_pass(
+ "use", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.sampled(fallback, ViewRange { .mipCount = 1, .layerCount = 2, .dim = WGPUTextureViewDimension_2DArray });
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+
+ std::vector order = pass_order(g.rg);
+ REQUIRE(idx_of(order, (longId + ".layer0").c_str()) != -1);
+ REQUIRE(idx_of(order, (longId + ".layer1").c_str()) != -1);
+}
+
+// synthesizes a host_write + initialize() bake pass, which a uniform reader pulls in
+TEST_CASE("RenderGraph - create_initialized_buffer bakes a pass a reader can consume", "[RenderGraph]")
+{
+ TestGraph g;
+ BufferDesc bd {};
+ bd.size = 64;
+ auto fallback = g.rg->create_initialized_buffer("fallback", bd, nullptr);
+
+ g.rg->add_pass(
+ "use", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.uniform(fallback);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).size() == 2); // the synthesized upload + the reader
+}
+
+// a memoryless transient has no storage to pack, so it is excluded from aliasing. the third exclusion
+// branch, alongside the mip-chain and load-first tests.
+TEST_CASE("RenderGraph - a memoryless attachment is excluded from aliasing", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+ using webgpu::rg::Internal::ResourceNode;
+
+ TestGraph g;
+ auto t = g.transient("t");
+
+ g.rg->add_pass(
+ "w", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(t, 0, { .store = WGPUStoreOp_Discard }); // clear+discard -> memoryless
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile(true) == nullptr); // aliasing on: a plain clear+store transient here would be eligible
+ REQUIRE(storage(g.rg)->transientCount == 1); // inferred memoryless
+ REQUIRE(storage(g.rg)->m_slotCount == 0); // ... so no alias slot was opened for it
+ REQUIRE(find_node(g.rg, t)->aliasSlot == ResourceNode::kNoSlot);
+}
+
+// the read side of a storage-texture binding, where the suite only exercised storage_write
+TEST_CASE("RenderGraph - storage_read on a texture accumulates StorageBinding", "[RenderGraph]")
+{
+ using webgpu::rg::Internal::find_node;
+
+ TestGraph g;
+ auto tex = g.transient("tex");
+
+ g.rg->add_pass(
+ "produce", PassKind::Compute, [&](PassBuilder& b) { b.storage_write(tex); }, [](PassContext&) {});
+ g.rg->add_pass(
+ "read", PassKind::Compute,
+ [&](PassBuilder& b) {
+ b.storage_read(tex);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE((find_node(g.rg, tex)->texUsage & WGPUTextureUsage_StorageBinding) != 0);
+}
+
+// end_pass records one InitTarget per call, and phase 0 arms the whole pass while any target is stale
+TEST_CASE("RenderGraph - initialize() records multiple targets in one pass", "[RenderGraph]")
+{
+ TestGraph g;
+ auto p1 = g.rg->create_persistent_texture("p1", tex2d());
+ auto p2 = g.rg->create_persistent_texture("p2", tex2d());
+
+ g.rg->add_pass(
+ "bake", PassKind::Graphics,
+ [&](PassBuilder& b) {
+ b.color(p1, 0); // MRT: both targets written
+ b.color(p2, 1);
+ b.initialize(p1);
+ b.initialize(p2);
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+ REQUIRE(pass_order(g.rg).size() == 1); // armed on the first frame, not skipped
+ REQUIRE(storage(g.rg)->m_passes->initCount == 2); // both targets recorded
+}
+
+// the compile tests above only check slot bookkeeping, this proves the shared object reaches execute()
+TEST_CASE("RenderGraph exec - disjoint transients realize onto the same physical texture", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTexture a = nullptr, b = nullptr;
+
+ g.frame([&](RenderGraph* rg) {
+ auto t1 = rg->create_transient_texture("t1", tex2d());
+ auto t2 = rg->create_transient_texture("t2", tex2d());
+ // t1 lives only in k1, t2 only in k2 -> disjoint intervals -> alias onto one slot
+ rg->add_pass(
+ "k1", PassKind::Graphics,
+ [&](PassBuilder& bb) {
+ bb.color(t1, 0);
+ bb.force_keep();
+ },
+ [t1, out = &a](PassContext& ctx) { *out = ctx.texture(t1); });
+ rg->add_pass(
+ "k2", PassKind::Graphics,
+ [&](PassBuilder& bb) {
+ bb.color(t2, 0);
+ bb.force_keep();
+ },
+ [t2, out = &b](PassContext& ctx) { *out = ctx.texture(t2); });
+ });
+
+ REQUIRE(a != nullptr);
+ REQUIRE(a == b); // both aliased transients got the one pooled physical texture
+ REQUIRE(list_count(g.allocator->transient.entries) == 1); // a single object served both
+}
+
+// ---------------------------------------------------------------------------------------------------
+// PassContext resolvers. the suite above asserts on the compiled graph with empty bodies, these run
+// bodies and assert on what ctx hands back, the half a real renderer touches.
+
+// two passes read one texture through different shapes, so they must not get the same view. also pins
+// the resolve_view cache: asking twice in one body returns one object.
+TEST_CASE("RenderGraph exec - ctx.view hands back the shape the access declared", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTextureView cubeView = nullptr, cubeAgain = nullptr, plainView = nullptr;
+
+ g.frame([&](RenderGraph* rg) {
+ TextureDesc d = tex2d();
+ d.absolute = { 16, 16, 6 }; // 6 layers: samplable as a cube, or as a plain 2D layer
+ auto env = rg->create_transient_texture("env", d);
+ // each reader needs its own attachment, and unlike the compile-only cube tests this executes
+ auto outCube = rg->create_transient_texture("outCube", tex2d());
+ auto outPlain = rg->create_transient_texture("outPlain", tex2d());
+
+ // one layer per pass, six RGBA8 attachments would exceed maxColorAttachmentBytesPerSample
+ static const char* kFaceNames[6] = { "face0", "face1", "face2", "face3", "face4", "face5" };
+ for (uint32_t l = 0; l < 6; ++l)
+ rg->add_pass(
+ kFaceNames[l], PassKind::Graphics,
+ [env, l](PassBuilder& b) { b.color(env, 0, { .clear = { 0, 0, 0, 1 }, .sub = { .layer = l } }); },
+ [](PassContext&) {});
+ rg->add_pass(
+ "readCube", PassKind::Graphics,
+ [env, outCube](PassBuilder& b) {
+ b.sampled(env, cube());
+ b.color(outCube, 0);
+ b.force_keep();
+ },
+ [env, cv = &cubeView, ca = &cubeAgain](PassContext& ctx) {
+ *cv = ctx.view(env);
+ *ca = ctx.view(env);
+ });
+ rg->add_pass(
+ "readPlain", PassKind::Graphics,
+ [env, outPlain](PassBuilder& b) {
+ b.sampled(env); // default ViewRange: one layer, 2D
+ b.color(outPlain, 0);
+ b.force_keep();
+ },
+ [env, pv = &plainView](PassContext& ctx) { *pv = ctx.view(env); });
+ });
+
+ REQUIRE(cubeView != nullptr);
+ REQUIRE(plainView != nullptr);
+ REQUIRE(cubeView == cubeAgain); // one cached view per shape, not one per call
+ REQUIRE(cubeView != plainView); // the declared range, not the texture, decides the view
+}
+
+// a blit sampling mip N-1 while rendering mip N gets two different views of the one texture
+TEST_CASE("RenderGraph exec - ctx.view(mip, layer) resolves per subresource", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUTextureView srcMip = nullptr, dstMip = nullptr;
+
+ g.frame([&](RenderGraph* rg) {
+ TextureDesc d = tex2d();
+ d.mipLevelCount = 4;
+ auto chain = rg->create_transient_texture("chain", d);
+
+ rg->add_pass(
+ "mip0", PassKind::Graphics,
+ [chain](PassBuilder& b) { b.color(chain, 0); },
+ [](PassContext&) {});
+ rg->add_pass(
+ "mip1", PassKind::Graphics,
+ [chain](PassBuilder& b) {
+ b.sampled(chain); // read mip 0
+ b.color(chain, 0, { .sub = { .mip = 1 } }); // write mip 1
+ b.force_keep();
+ },
+ [chain, s = &srcMip, dm = &dstMip](PassContext& ctx) {
+ *s = ctx.view(chain, 0, 0);
+ *dm = ctx.view(chain, 1, 0);
+ });
+ });
+
+ REQUIRE(srcMip != nullptr);
+ REQUIRE(dstMip != nullptr);
+ REQUIRE(srcMip != dstMip); // one view per subresource, not one per texture
+}
+
+// size 0 means the rest of the src, expanded at declare time, so the body sees a resolved non-zero size
+TEST_CASE("RenderGraph exec - ctx.buffer_copy_info reports the resolved range", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ PassContext::BufferCopyInfo info {};
+
+ g.frame([&](RenderGraph* rg) {
+ BufferDesc bd {};
+ bd.size = 64;
+ auto src = rg->create_transient_buffer("src", bd);
+ auto dst = rg->create_transient_buffer("dst", bd);
+
+ add_buffer_producer(rg, src);
+ rg->add_pass(
+ "copy", PassKind::Transfer,
+ [src, dst](PassBuilder& b) {
+ b.copy_buffer(src, dst, /*srcOffset*/ 16, /*dstOffset*/ 8, /*size*/ 0); // -> 64 - 16 = 48
+ b.force_keep();
+ },
+ [src, dst, out = &info](PassContext& ctx) { *out = ctx.buffer_copy_info(src, dst); });
+ });
+
+ REQUIRE(info.size == 48); // expanded against the src, not left at 0
+ REQUIRE(info.srcOffset == 16);
+ REQUIRE(info.dstOffset == 8);
+ REQUIRE(info.src != nullptr);
+ REQUIRE(info.dst != nullptr);
+ REQUIRE(info.src != info.dst);
+}
+
+// a mip halves a 3D texture's depth but never an array's layer count. texture_size answers from the
+// declaration alone, so neither import needs realizing.
+TEST_CASE("RenderGraph exec - ctx.texture_size shifts 3D depth but not array layers", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUExtent3D vol0 {}, vol1 {}, arr0 {}, arr1 {};
+
+ g.frame([&](RenderGraph* rg) {
+ auto vol = rg->import_texture("vol", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 8 }, .format = WGPUTextureFormat_RGBA8Unorm, .mipCount = 4, .dimension = WGPUTextureDimension_3D });
+ auto arr = rg->import_texture("arr", { .view = (WGPUTextureView)0x1, .size = { 16, 16, 8 }, .format = WGPUTextureFormat_RGBA8Unorm, .mipCount = 4 });
+
+ auto t = rg->create_transient_texture("probe", tex2d());
+ rg->add_pass(
+ "probe", PassKind::Graphics,
+ [t](PassBuilder& b) {
+ b.color(t, 0);
+ b.force_keep();
+ },
+ [vol, arr, v0 = &vol0, v1 = &vol1, a0 = &arr0, a1 = &arr1](PassContext& ctx) {
+ *v0 = ctx.texture_size(vol, 0);
+ *v1 = ctx.texture_size(vol, 1);
+ *a0 = ctx.texture_size(arr, 0);
+ *a1 = ctx.texture_size(arr, 1);
+ });
+ });
+
+ REQUIRE(vol0.depthOrArrayLayers == 8);
+ REQUIRE(vol1.width == 8);
+ REQUIRE(vol1.height == 8);
+ REQUIRE(vol1.depthOrArrayLayers == 4); // 3D: depth is a spatial axis, it halves
+
+ REQUIRE(arr0.depthOrArrayLayers == 8);
+ REQUIRE(arr1.width == 8);
+ REQUIRE(arr1.height == 8);
+ REQUIRE(arr1.depthOrArrayLayers == 8); // 2D array: every layer keeps its own mip chain
+}
+
+// a body uploads through the forwarded queue, a later copy moves it to an imported mappable buffer, the
+// host reads it back. nothing else in the suite runs a host_write body or touches ctx.queue.
+TEST_CASE("RenderGraph exec - a host_write body uploads through ctx.queue", "[RenderGraph][gpu]")
+{
+ constexpr uint32_t kMagic = 0xABCD1234u;
+
+ ExecGraph g;
+ webgpu::raii::RawBuffer readback(g.gpu.device, WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead, 4, "hostwrite readback");
+
+ g.frame([&](RenderGraph* rg) {
+ BufferDesc bd {};
+ bd.size = 4;
+ auto staged = rg->create_transient_buffer("staged", bd);
+ auto out = rg->import_buffer("hostwrite.readback", readback.handle());
+
+ rg->add_pass(
+ "upload", PassKind::Transfer, [staged](PassBuilder& b) { b.host_write(staged); },
+ [staged, magic = kMagic](PassContext& ctx) {
+ wgpuQueueWriteBuffer(ctx.queue, ctx.buffer(staged), 0, &magic, sizeof(magic));
+ });
+ rg->add_pass(
+ "copy", PassKind::Transfer,
+ [staged, out](PassBuilder& b) { b.copy_buffer(staged, out, 0, 0, 4); },
+ [staged, out](PassContext& ctx) {
+ auto info = ctx.buffer_copy_info(staged, out);
+ wgpuCommandEncoderCopyBufferToBuffer(ctx.encoder, info.src, info.srcOffset, info.dst, info.dstOffset, info.size);
+ });
+ });
+
+ std::vector bytes;
+ REQUIRE(readback.read_back_sync(g.gpu.instance, g.gpu.device, bytes) == WGPUMapAsyncStatus_Success);
+ REQUIRE(bytes.size() == 4);
+ uint32_t got = 0;
+ std::memcpy(&got, bytes.data(), sizeof(got));
+ REQUIRE(got == kMagic); // the queue write landed and the copy carried it
+}
+
+// ---------------------------------------------------------------------------------------------------
+// import_buffer and PassContext::bind. import_buffer takes no BufferDesc, so unlike every other buffer
+// factory its size is queried from the imported object rather than declared.
+
+TEST_CASE("RenderGraph exec - an imported buffer reports the size queried from the buffer", "[RenderGraph][gpu]")
+{
+ constexpr uint64_t kSize = 256;
+
+ ExecGraph g;
+ webgpu::raii::RawBuffer owned(g.gpu.device, WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst, kSize, "imported");
+
+ uint64_t reported = 0;
+ WGPUBuffer resolved = nullptr;
+ WGPUBindGroupEntry entry {};
+
+ g.frame([&](RenderGraph* rg) {
+ auto imported = rg->import_buffer("imported", owned.handle());
+ rg->add_pass(
+ "read", PassKind::Compute,
+ [imported](PassBuilder& b) {
+ b.storage_read(imported); // an import needs no writer, so this alone is a legal graph
+ b.force_keep();
+ },
+ [imported, sz = &reported, buf = &resolved, e = &entry](PassContext& ctx) {
+ *sz = ctx.buffer_size(imported);
+ *buf = ctx.buffer(imported);
+ *e = ctx.bind(7, imported);
+ });
+ });
+
+ REQUIRE(reported == kSize); // wgpuBufferGetSize, not a declared BufferDesc
+ REQUIRE(resolved == owned.handle()); // the caller's buffer, not a pooled copy
+
+ // the buffer arm of bind(): whole, at offset 0, per the PassContext contract
+ REQUIRE(entry.binding == 7);
+ REQUIRE(entry.buffer == resolved);
+ REQUIRE(entry.offset == 0);
+ REQUIRE(entry.size == kSize);
+ REQUIRE(entry.textureView == nullptr);
+}
+
+// a declared BufferRange is the shape ctx.bind() hands back, same contract as ViewRange for textures
+TEST_CASE("RenderGraph exec - bind applies the declared BufferRange", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ webgpu::raii::RawBuffer owned(g.gpu.device, WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst, 512, "imported");
+
+ WGPUBindGroupEntry explicitRange {};
+ WGPUBindGroupEntry restRange {};
+
+ g.frame([&](RenderGraph* rg) {
+ auto imported = rg->import_buffer("imported", owned.handle());
+ rg->add_pass(
+ "explicit", PassKind::Compute,
+ [imported](PassBuilder& b) {
+ b.storage_read(imported, { .offset = 256, .size = 128 });
+ b.force_keep();
+ },
+ [imported, e = &explicitRange](PassContext& ctx) { *e = ctx.bind(0, imported); });
+ });
+ g.frame([&](RenderGraph* rg) {
+ auto imported = rg->import_buffer("imported", owned.handle());
+ rg->add_pass(
+ "rest", PassKind::Compute,
+ [imported](PassBuilder& b) {
+ b.storage_read(imported, { .offset = 256 }); // size 0 = all remaining
+ b.force_keep();
+ },
+ [imported, e = &restRange](PassContext& ctx) { *e = ctx.bind(0, imported); });
+ });
+
+ REQUIRE(explicitRange.offset == 256);
+ REQUIRE(explicitRange.size == 128);
+ REQUIRE(restRange.offset == 256);
+ REQUIRE(restRange.size == 256); // resolved at declare time against the queried size
+}
+
+// the queried size is not merely reported back: it is the bound compile() range-checks copies against,
+// which is the only thing standing between an oversized copy and a device error.
+TEST_CASE("compile - a copy past an imported buffer's queried size is rejected", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ webgpu::raii::RawBuffer owned(g.gpu.device, WGPUBufferUsage_CopyDst, 64, "small");
+
+ begin_frame(g.allocator);
+ RenderGraph* rg = start_recording(g.allocator);
+
+ BufferDesc bd {};
+ bd.size = 128;
+ auto src = rg->create_transient_buffer("src", bd);
+ auto dst = rg->import_buffer("dst", owned.handle());
+
+ add_buffer_producer(rg, src);
+ rg->add_pass(
+ "copy", PassKind::Transfer,
+ [src, dst](PassBuilder& b) {
+ b.copy_buffer(src, dst, 0, 0, 128); // the src fits, the imported dst does not
+ b.force_keep();
+ },
+ [](PassContext&) {});
+
+ REQUIRE(rg->compile() != nullptr);
+ REQUIRE(error_mentions(rg, "covers bytes [0, 128) but the buffer is 64 byte(s)"));
+}
+
+// bind(binding, ResourceHandle) switches on the handle's kind, so a texture must fill the view arm and
+// leave the buffer fields at zero. a wrong arm here is a silently invalid bind group.
+TEST_CASE("RenderGraph exec - bind on a texture handle fills the view arm", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUBindGroupEntry entry {};
+ WGPUTextureView direct = nullptr;
+
+ g.frame([&](RenderGraph* rg) {
+ auto tex = rg->create_transient_texture("tex", tex2d());
+ auto out = rg->create_transient_texture("out", tex2d());
+
+ rg->add_pass(
+ "produce", PassKind::Graphics, [tex](PassBuilder& b) { b.color(tex, 0); }, [](PassContext&) {});
+ rg->add_pass(
+ "read", PassKind::Graphics,
+ [tex, out](PassBuilder& b) {
+ b.sampled(tex);
+ b.color(out, 0);
+ b.force_keep();
+ },
+ [tex, e = &entry, d = &direct](PassContext& ctx) {
+ *e = ctx.bind(3, tex); // two args, so this is the ResourceHandle overload
+ *d = ctx.view(tex);
+ });
+ });
+
+ REQUIRE(entry.binding == 3);
+ REQUIRE(entry.textureView != nullptr);
+ REQUIRE(entry.textureView == direct); // the same view the declared access resolves to, not a fresh one
+ REQUIRE(entry.buffer == nullptr);
+ REQUIRE(entry.size == 0);
+}
+
+// the subresource overload must forward to view(mip, layer). binding the whole-texture view instead
+// would compile and run, just sample the wrong level.
+TEST_CASE("RenderGraph exec - bind(mip, layer) carries the per-subresource view", "[RenderGraph][gpu]")
+{
+ ExecGraph g;
+ WGPUBindGroupEntry mip0 {}, mip1 {};
+
+ g.frame([&](RenderGraph* rg) {
+ TextureDesc d = tex2d();
+ d.mipLevelCount = 4;
+ auto chain = rg->create_transient_texture("chain", d);
+
+ rg->add_pass(
+ "mip0", PassKind::Graphics, [chain](PassBuilder& b) { b.color(chain, 0); }, [](PassContext&) {});
+ rg->add_pass(
+ "mip1", PassKind::Graphics,
+ [chain](PassBuilder& b) {
+ b.sampled(chain); // mip 0
+ b.color(chain, 0, { .sub = { .mip = 1 } }); // mip 1
+ b.force_keep();
+ },
+ [chain, a = &mip0, b = &mip1](PassContext& ctx) {
+ *a = ctx.bind(0, chain, 0, 0);
+ *b = ctx.bind(1, chain, 1, 0);
+ });
+ });
+
+ REQUIRE(mip0.binding == 0);
+ REQUIRE(mip1.binding == 1);
+ REQUIRE(mip0.textureView != nullptr);
+ REQUIRE(mip1.textureView != nullptr);
+ REQUIRE(mip0.textureView != mip1.textureView); // one view per subresource
+ REQUIRE(mip0.buffer == nullptr);
+ REQUIRE(mip1.buffer == nullptr);
+}
+
+// ctx.view() branches on whether the import supplied a backing texture, not on `imported` itself.
+// view-only: the caller owns the view and the graph hands it back untouched, so a declared ViewRange
+// selects nothing. texture-backed: the graph builds the view from the range like any owned texture.
+// docs/rendergraph.md states both halves, in the Import section and the IBL example.
+TEST_CASE("execute - a view-only import returns the registered view, a texture-backed one builds its own", "[RenderGraph]")
+{
+ ExecGraph g;
+
+ WGPUTextureDescriptor td {};
+ td.dimension = WGPUTextureDimension_2D;
+ td.format = WGPUTextureFormat_RGBA8Unorm;
+ td.size = { 16, 16, 6 }; // 6 layers, so a cube view over it is well formed
+ td.mipLevelCount = 1;
+ td.sampleCount = 1;
+ td.usage = WGPUTextureUsage_TextureBinding;
+ WGPUTexture tex = wgpuDeviceCreateTexture(g.gpu.device, &td);
+ REQUIRE(tex != nullptr);
+
+ WGPUTextureViewDescriptor vd {};
+ vd.format = WGPUTextureFormat_RGBA8Unorm;
+ vd.dimension = WGPUTextureViewDimension_Cube;
+ vd.mipLevelCount = 1;
+ vd.arrayLayerCount = 6;
+ vd.aspect = WGPUTextureAspect_All;
+ WGPUTextureView callerView = wgpuTextureCreateView(tex, &vd);
+ REQUIRE(callerView != nullptr);
+
+ WGPUTextureView seenViewOnly = nullptr;
+ WGPUTextureView seenBacked = nullptr;
+
+ g.frame([&](RenderGraph* rg) {
+ // no .texture: the graph has nothing to build a view from
+ auto viewOnly = rg->import_texture(
+ "ibl.env.view_only", { .view = callerView, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm });
+ // same view registered, but the backing texture comes along too
+ auto backed = rg->import_texture("ibl.env.backed",
+ { .view = callerView, .size = { 16, 16, 6 }, .format = WGPUTextureFormat_RGBA8Unorm, .texture = tex });
+
+ rg->add_pass(
+ "read", PassKind::Compute,
+ [viewOnly, backed](PassBuilder& b) {
+ // a narrowing range on both, to prove only one of them acts on it. base stays 0 on the
+ // view-only side: a non-zero base there is an assert, not a silently ignored range.
+ b.sampled(viewOnly, ViewRange { .layerCount = 1 });
+ // a layer the caller's whole-cube view cannot be, so an equal result would be a real match
+ b.sampled(backed, ViewRange { .baseLayer = 2, .layerCount = 1 });
+ b.force_keep();
+ },
+ [viewOnly, backed, a = &seenViewOnly, b = &seenBacked](PassContext& c) {
+ *a = c.view(viewOnly);
+ *b = c.view(backed);
+ });
+ });
+
+ REQUIRE(seenViewOnly == callerView); // handed back as registered, the 1-layer range ignored
+ REQUIRE(seenBacked != nullptr);
+ REQUIRE(seenBacked != callerView); // built by the graph, layer 2 alone, not the caller's whole cube
+
+ wgpuTextureViewRelease(callerView);
+ wgpuTextureRelease(tex);
+}
+
+// The remaining worked examples from docs/rendergraph.md, transcribed to their graph-level calls with
+// app-side pipelines and draws stubbed. Each proves the example is a valid graph program and catches
+// API drift like the color() signature change. Bodies stop at declaration; compile() is the check.
+
+TEST_CASE("compile - doc example: deferred shading, G-buffer plus compute lighting", "[RenderGraph]")
+{
+ TestGraph g;
+ const WGPUExtent3D size { 64, 64, 1 };
+ auto desc = [&](WGPUTextureFormat fmt) {
+ TextureDesc d {};
+ d.dimension = WGPUTextureDimension_2D;
+ d.format = fmt;
+ d.absolute = size;
+ return d;
+ };
+ auto albedo = g.rg->create_transient_texture("gbuffer.albedo", desc(WGPUTextureFormat_RGBA8Unorm));
+ auto normal = g.rg->create_transient_texture("gbuffer.normal", desc(WGPUTextureFormat_RGBA16Float));
+ auto depth = g.rg->create_transient_texture("gbuffer.depth", desc(WGPUTextureFormat_Depth32Float));
+ auto lit = g.rg->create_transient_texture("lit.color", desc(WGPUTextureFormat_RGBA16Float));
+
+ g.rg->add_pass(
+ "GBuffer", PassKind::Graphics,
+ [albedo, normal, depth](PassBuilder& b) {
+ b.color(albedo, 0, { .clear = { 0, 0, 0, 0 } });
+ b.color(normal, 1, { .clear = { 0, 0, 0, 0 } });
+ b.depth_stencil(depth, { .clearDepth = 0.0f }); // reverse-Z
+ },
+ [](PassContext&) {});
+
+ g.rg->add_pass(
+ "Lighting", PassKind::Compute,
+ [albedo, normal, depth, lit](PassBuilder& b) {
+ b.sampled(albedo);
+ b.sampled(normal);
+ b.sampled(depth); // depth-only format, no aspect needed
+ b.storage_write(lit); // ordered after GBuffer by the reads above
+ b.force_keep(); // stands in for the tonemap/composite consumer the example returns lit to
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr);
+}
+
+TEST_CASE("compile - doc example: bloom, relative-sized downsample chain", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc fullDesc {};
+ fullDesc.dimension = WGPUTextureDimension_2D;
+ fullDesc.format = WGPUTextureFormat_RGBA16Float;
+ fullDesc.absolute = { 128, 128, 1 };
+ auto hdrColor = g.rg->create_transient_texture("hdr.color", fullDesc);
+
+ // the example assumes hdrColor already exists; give it a producer
+ g.rg->add_pass(
+ "scene", PassKind::Graphics, [hdrColor](PassBuilder& b) { b.color(hdrColor, 0); }, [](PassContext&) {});
+
+ TextureDesc halfDesc {};
+ halfDesc.dimension = WGPUTextureDimension_2D;
+ halfDesc.format = WGPUTextureFormat_RGBA16Float;
+ halfDesc.sizeKind = SizeKind::Relative;
+ halfDesc.scaleX = 0.5f;
+ halfDesc.scaleY = 0.5f;
+ halfDesc.relativeTo = hdrColor;
+ auto bright = g.rg->create_transient_texture("bloom.bright", halfDesc);
+ auto blurH = g.rg->create_transient_texture("bloom.blur_h", halfDesc);
+ auto blurV = g.rg->create_transient_texture("bloom.blur_v", halfDesc);
+
+ auto blit = [&](const char* name, TextureHandle src, TextureHandle dst) {
+ g.rg->add_pass(
+ name, PassKind::Graphics,
+ [src, dst](PassBuilder& b) {
+ b.sampled(src);
+ b.color(dst, 0);
+ },
+ [](PassContext&) {});
+ };
+ blit("bloom.threshold", hdrColor, bright);
+ blit("bloom.blurH", bright, blurH);
+ blit("bloom.blurV", blurH, blurV);
+
+ g.rg->add_pass(
+ "bloom.composite", PassKind::Graphics,
+ [hdrColor, blurV](PassBuilder& b) {
+ b.color(hdrColor, 0, { .load = WGPULoadOp_Load }); // load, do not clear
+ b.sampled(blurV);
+ b.force_keep(); // stands in for the swapchain sink downstream
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr); // Relative sizing resolves against hdrColor
+}
+
+TEST_CASE("compile - doc example: TAA, history plus a camera-cut hash", "[RenderGraph]")
+{
+ TestGraph g;
+ TextureDesc colorDesc {};
+ colorDesc.dimension = WGPUTextureDimension_2D;
+ colorDesc.format = WGPUTextureFormat_RGBA16Float;
+ colorDesc.absolute = { 64, 64, 1 };
+
+ auto currentColor = g.rg->create_transient_texture("current.color", colorDesc);
+ auto velocity = g.rg->create_transient_texture("velocity", colorDesc);
+
+ g.rg->add_pass(
+ "scene", PassKind::Graphics,
+ [currentColor, velocity](PassBuilder& b) {
+ b.color(currentColor, 0);
+ b.color(velocity, 1);
+ },
+ [](PassContext&) {});
+
+ uint64_t cut = 5; // changes on teleport, projection swap, etc.
+ auto taa = g.rg->create_history_texture("taa.color", colorDesc, cut);
+
+ g.rg->add_pass(
+ "TAA", PassKind::Graphics,
+ [currentColor, velocity, taa](PassBuilder& b) {
+ b.sampled(currentColor);
+ b.sampled(velocity);
+ b.sampled(taa.prev); // last frame's result
+ b.color(taa.curr, 0); // this frame's result, becomes next frame's prev
+ },
+ [](PassContext&) {});
+
+ REQUIRE(g.rg->compile() == nullptr); // writing taa.curr is a sink, so no force_keep needed
+}
diff --git a/webgpu/base/CMakeLists.txt b/webgpu/base/CMakeLists.txt
index ac966abd..30e9d684 100644
--- a/webgpu/base/CMakeLists.txt
+++ b/webgpu/base/CMakeLists.txt
@@ -61,6 +61,8 @@ set(SOURCES
RenderResourceRegistry.h RenderResourceRegistry.cpp
gpu_utils.h gpu_utils.cpp
wgpu_string.h
+ RenderGraph.h RenderGraph.cpp RenderGraph_internal.h
+
webgpu_interface.hpp webgpu_interface.cpp)
add_library(webgpu STATIC ${SOURCES})
diff --git a/webgpu/base/RenderGraph.cpp b/webgpu/base/RenderGraph.cpp
new file mode 100644
index 00000000..9a864d3f
--- /dev/null
+++ b/webgpu/base/RenderGraph.cpp
@@ -0,0 +1,3562 @@
+/*****************************************************************************
+ * Copyright (C) 2026 Matthias Huerbe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+
+#include "RenderGraph.h"
+#include "RenderGraph_internal.h"
+#include "gpu_utils.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#ifdef __EMSCRIPTEN__
+#include
+// on WebGPU the usage flag is the only sign of support, so probe for the constant
+EM_JS(int, rg_web_has_transient_attachment, (), { return (typeof GPUTextureUsage !== 'undefined' && ('TRANSIENT_ATTACHMENT' in GPUTextureUsage)) ? 1 : 0; });
+#endif
+
+
+template
+static void list_append(T** head, T** tail, T* newNode)
+{
+ if (*tail)
+ (*tail)->next = newNode;
+ else
+ *head = newNode;
+ *tail = newNode;
+}
+
+namespace webgpu::rg {
+
+using namespace Internal;
+
+namespace Internal {
+
+size_t sv_length(WGPUStringView s) { return (s.length == WGPU_STRLEN) ? (s.data ? std::strlen(s.data) : 0) : s.length; }
+
+bool sv_eq(WGPUStringView a, WGPUStringView b)
+{
+ size_t na = sv_length(a), nb = sv_length(b);
+ return na == nb && (na == 0 || std::memcmp(a.data, b.data, na) == 0);
+}
+
+WGPUStringView group_prefix(WGPUStringView name)
+{
+ size_t n = sv_length(name);
+ for (size_t i = 0; i < n; ++i)
+ if (name.data[i] == '.')
+ return WGPUStringView { .data = name.data, .length = i };
+ return WGPUStringView {};
+}
+
+Subview* SubviewPool::alloc()
+{
+ if (freeList) {
+ Subview* s = freeList;
+ freeList = s->next;
+ return s;
+ }
+ return arena->make();
+}
+
+void SubviewPool::recycle(Subview*& head)
+{
+ while (Subview* s = head) {
+ head = s->next;
+ if (s->view)
+ wgpuTextureViewRelease(s->view);
+ s->view = nullptr;
+ s->next = freeList;
+ freeList = s;
+ }
+}
+
+WGPUTextureView subview_for(SubviewPool& pool, Subview*& head, WGPUTexture tex, const ViewKey& k)
+{
+ for (Subview* s = head; s; s = s->next)
+ if (s->key == k)
+ return s->view;
+ Subview* s = pool.alloc();
+ s->key = k;
+ WGPUTextureViewDescriptor d = k.desc();
+ s->view = wgpuTextureCreateView(tex, &d);
+ s->next = head; // prepend
+ head = s;
+ return s->view;
+}
+
+PersistentResourcePool::Entry* PersistentResourcePool::alloc_entry()
+{
+ if (freeList) {
+ Entry* e = freeList;
+ freeList = e->next;
+ *e = Entry {};
+ return e;
+ }
+ return arena->make();
+}
+
+PersistentResourcePool::Entry* PersistentResourcePool::find(ResourceId id)
+{
+ for (Entry* e = entries; e; e = e->next)
+ if (e->idValue == id.value && e->name.data == id.name.data) // same data storage so the same data ptr
+ return e;
+ return nullptr;
+}
+
+PersistentResourcePool::Entry* PersistentResourcePool::touch(ResourceId id, uint32_t layers, uint64_t hash, ResourceKind kind)
+{
+ if (Entry* e = find(id)) {
+ if (e->lastTouched == evictClock) // same resource already touched
+ return e;
+ if (e->kind != kind || e->layers != layers)
+ destroy(e);
+ e->prevTouched = e->lastTouched;
+ ++e->frame;
+ e->lastTouched = evictClock;
+ e->layers = layers;
+ e->kind = kind;
+ if (history_hash_forces_destroy(*e, hash)) {
+ destroy(e);
+ e->historyHash = hash;
+ }
+ return e;
+ }
+ Entry* e = alloc_entry();
+ e->name = id.name;
+ e->idValue = id.value;
+ e->lastTouched = evictClock;
+ e->prevTouched = evictClock;
+ e->layers = layers;
+ e->kind = kind;
+ e->historyHash = hash;
+ e->next = entries;
+ entries = e;
+ return e;
+}
+
+uint32_t PersistentResourcePool::slot(const Entry& e, uint32_t layerIndex) const
+{
+ return (uint32_t)((e.frame + layerIndex) % e.layers);
+}
+
+bool PersistentResourcePool::tex_entry_would_recreate(const Entry& e, const TexSignature& sig, WGPUTextureUsage usage)
+{
+ return !e.created || !(e.sig == sig) || (usage & ~e.usageAtCreate) != 0;
+}
+
+bool PersistentResourcePool::buf_entry_would_recreate(const Entry& e, uint64_t size, WGPUBufferUsage usage)
+{
+ return !e.created || e.bufferSize != size || (usage & ~e.bufUsageAtCreate) != 0;
+}
+
+bool PersistentResourcePool::history_hash_forces_destroy(const Entry& e, uint64_t hash)
+{
+ return hash != 0 && e.historyHash != hash;
+}
+
+void PersistentResourcePool::realize_texture_entry(Entry* e, WGPUDevice device, const TexSignature& sig)
+{
+ if (!tex_entry_would_recreate(*e, sig, e->usage))
+ return;
+
+ destroy(e);
+ e->sig = sig;
+ e->usageAtCreate = e->usage;
+ for (uint32_t i = 0; i < e->layers; ++i)
+ {
+ WGPUTextureDescriptor d {
+ .label = e->name_view(),
+ .usage = e->usage,
+ .dimension = sig.dim,
+ .size = sig.size,
+ .format = sig.format,
+ .mipLevelCount = sig.mipLevelCount,
+ .sampleCount = sig.sampleCount,
+ };
+ e->tex[i] = wgpuDeviceCreateTexture(device, &d);
+ e->view[i] = wgpuTextureCreateView(e->tex[i], nullptr);
+ }
+ e->created = true;
+ e->createdClock = evictClock;
+}
+
+void PersistentResourcePool::realize_buffer_entry(Entry* e, WGPUDevice device, uint64_t size)
+{
+ if (!buf_entry_would_recreate(*e, size, e->bufUsage))
+ return;
+ destroy(e);
+ e->bufferSize = size;
+ e->bufUsageAtCreate = e->bufUsage;
+ for (uint32_t i = 0; i < e->layers; ++i)
+ {
+ WGPUBufferDescriptor d {
+ .label = e->name_view(),
+ .usage = e->bufUsage,
+ .size = size,
+ };
+ e->buf[i] = wgpuDeviceCreateBuffer(device, &d);
+ }
+ e->created = true;
+ e->createdClock = evictClock;
+}
+
+void PersistentResourcePool::destroy(Entry* e)
+{
+ for (uint32_t i = 0; i < kLayers; ++i)
+ {
+ subviewPool->recycle(e->subviews[i]);
+ if (e->view[i]) {
+ wgpuTextureViewRelease(e->view[i]);
+ e->view[i] = nullptr;
+ }
+ if (e->tex[i]) {
+ wgpuTextureRelease(e->tex[i]);
+ e->tex[i] = nullptr;
+ }
+ if (e->buf[i]) {
+ wgpuBufferRelease(e->buf[i]);
+ e->buf[i] = nullptr;
+ }
+ }
+ e->created = false;
+ e->baked = false;
+}
+
+void PersistentResourcePool::end_frame()
+{
+ for (Entry** pp = &entries; *pp;)
+ {
+ Entry* e = *pp;
+ if (evictClock - e->lastTouched >= kRetain)
+ {
+ destroy(e);
+ *pp = e->next;
+ e->next = freeList; // recycle
+ freeList = e;
+ } else {
+ pp = &e->next;
+ }
+ }
+ ++evictClock;
+}
+
+void PersistentResourcePool::destroy_all()
+{
+ for (Entry* e = entries; e; e = e->next)
+ destroy(e);
+ entries = nullptr;
+ freeList = nullptr;
+}
+
+PersistentResourcePool::~PersistentResourcePool()
+{
+ destroy_all();
+}
+
+void TransientResourcePool::log_event(Event event, const Entry& e)
+{
+ eventLog[eventCount++ % kLog] = {
+ frame, event, e.kind, e.sig.size, e.sig.format, e.bufferSize
+ };
+}
+
+void TransientResourcePool::log_reset() { eventCount = 0; }
+
+static constexpr bool transient_usage_satisfies(WGPUFlags have, WGPUFlags want, WGPUFlags exact)
+{
+ return (have & want) == want && (have & exact) == (want & exact);
+}
+
+TransientResourcePool::Entry* TransientResourcePool::alloc_entry()
+{
+ // recycle
+ if (freeList) {
+ Entry* e = freeList;
+ freeList = e->next;
+ *e = Entry {};
+ return e;
+ }
+ return arena->make();
+}
+
+void TransientResourcePool::acquire(WGPUDevice device, const TexSignature& sig, WGPUTextureUsage usage, const void* identity, WGPUTexture& outTex, WGPUTextureView& outView)
+{
+ for (Entry* e = entries; e; e = e->next)
+ {
+ if (e->kind == ResourceKind::Texture && !e->inUse && e->sig == sig
+ && transient_usage_satisfies(e->usage, usage, WGPUTextureUsage_TransientAttachment))
+ {
+ e->inUse = true;
+ e->lastUsedFrame = frame;
+ e->identity = identity;
+ outTex = e->tex;
+ outView = e->view;
+ return;
+ }
+ }
+ Entry* e = alloc_entry();
+ e->kind = ResourceKind::Texture;
+ e->sig = sig;
+ e->usage = usage;
+ e->identity = identity;
+ WGPUTextureDescriptor d {
+ .usage = usage,
+ .dimension = sig.dim,
+ .size = sig.size,
+ .format = sig.format,
+ .mipLevelCount = sig.mipLevelCount,
+ .sampleCount = sig.sampleCount,
+ };
+ e->tex = wgpuDeviceCreateTexture(device, &d);
+ e->view = wgpuTextureCreateView(e->tex, nullptr);
+ e->inUse = true;
+ e->lastUsedFrame = frame;
+ e->createdFrame = frame;
+ e->next = entries;
+ entries = e;
+ ++createdThisFrame;
+ log_event(Event::Create, *e);
+ outTex = e->tex;
+ outView = e->view;
+}
+
+void TransientResourcePool::acquire(WGPUDevice device, uint64_t size, WGPUBufferUsage usage, const void* identity, WGPUBuffer& outBuf)
+{
+ for (Entry* e = entries; e; e = e->next)
+ {
+ if (e->kind == ResourceKind::Buffer && !e->inUse && e->bufferSize == size
+ && transient_usage_satisfies(e->bufUsage, usage, /*exact*/ 0))
+ {
+ e->inUse = true;
+ e->lastUsedFrame = frame;
+ e->identity = identity; // last claimant, see the texture overload
+ outBuf = e->buf;
+ return;
+ }
+ }
+ Entry* e = alloc_entry();
+ e->kind = ResourceKind::Buffer;
+ e->bufferSize = size;
+ e->bufUsage = usage;
+ e->identity = identity;
+ WGPUBufferDescriptor d { .usage = usage, .size = size };
+ e->buf = wgpuDeviceCreateBuffer(device, &d);
+ e->inUse = true;
+ e->lastUsedFrame = frame;
+ e->createdFrame = frame;
+ e->next = entries;
+ entries = e;
+ ++createdThisFrame;
+ log_event(Event::Create, *e);
+ outBuf = e->buf;
+}
+
+void TransientResourcePool::release_claims()
+{
+ for (Entry* e = entries; e; e = e->next)
+ e->inUse = false;
+}
+
+bool TransientResourcePool::superseded_by(const Entry& e, const Entry& s, uint64_t frame)
+{
+ if (e.kind != s.kind) // supersede compares like-for-like
+ return false;
+ if (e.lastUsedFrame >= frame) // claimed this frame -> still live, not superseded
+ return false;
+ if (s.createdFrame != frame) // s must be this frame's replacement
+ return false;
+ // same logical resource, or it is not a resize. the descriptor alone cannot tell a resize from an
+ // unrelated resource of another size, and evicting on the latter churns without bound.
+ if (!e.identity || e.identity != s.identity)
+ return false;
+
+ if (e.kind == ResourceKind::Texture) {
+ // s must cover e under the same rule acquire reuses by. superset, so a resize may add usage bits,
+ // but a differently-purposed idle sibling still cannot supersede.
+ if (!transient_usage_satisfies(s.usage, e.usage, WGPUTextureUsage_TransientAttachment))
+ return false;
+ // same signature EXCEPT the extent
+ if (s.sig.format != e.sig.format || s.sig.dim != e.sig.dim
+ || s.sig.mipLevelCount != e.sig.mipLevelCount || s.sig.sampleCount != e.sig.sampleCount)
+ return false;
+ return !extent_eq(s.sig.size, e.sig.size); // identical but for the extent
+ }
+
+ if (!transient_usage_satisfies(s.bufUsage, e.bufUsage, /*exact*/ 0))
+ return false;
+ return s.bufferSize != e.bufferSize;
+}
+
+void TransientResourcePool::end_frame()
+{
+ for (Entry** pp = &entries; *pp;) {
+ Entry* e = *pp;
+ bool superseded = false;
+
+ if (createdThisFrame && !e->inUse) {
+ for (Entry* s = entries; s; s = s->next)
+ if (superseded_by(*e, *s, frame)) {
+ superseded = true;
+ break;
+ }
+ }
+ if (superseded || frame - e->lastUsedFrame >= kRetain) {
+ log_event(Event::Evict, *e);
+ destroy(e);
+ *pp = e->next;
+ e->next = freeList; // recycle
+ freeList = e;
+ } else {
+ pp = &e->next;
+ }
+ }
+ createdThisFrame = 0;
+ ++frame;
+}
+
+void TransientResourcePool::destroy(Entry* e)
+{
+ subviewPool->recycle(e->subviews);
+ if (e->view) {
+ wgpuTextureViewRelease(e->view);
+ e->view = nullptr;
+ }
+ if (e->tex) {
+ wgpuTextureRelease(e->tex);
+ e->tex = nullptr;
+ }
+ if (e->buf) {
+ wgpuBufferRelease(e->buf);
+ e->buf = nullptr;
+ }
+}
+
+void TransientResourcePool::destroy_all()
+{
+ for (Entry* e = entries; e; e = e->next)
+ destroy(e);
+ entries = nullptr;
+ freeList = nullptr;
+}
+
+TransientResourcePool::~TransientResourcePool()
+{
+ destroy_all();
+}
+
+void GpuProfiler::clear_history()
+{
+ seriesCount = 0;
+ historyHead = 0;
+ historyLen = 0;
+ lastSampledId = 0;
+}
+
+void GpuProfiler::sample_history()
+{
+ if (!recording || resultId == lastSampledId)
+ return;
+ lastSampledId = resultId;
+ for (uint32_t s = 0; s < seriesCount; ++s)
+ series[s].v[historyHead] = 0.0f;
+ for (uint32_t i = 0; i < resultCount; ++i)
+ {
+ uint32_t occ = 0;
+ for (uint32_t j = 0; j < i; ++j)
+ if (resultNames[j].data == resultNames[i].data)
+ ++occ;
+ uint32_t s = 0;
+ for (uint32_t seen = 0; s < seriesCount; ++s)
+ if (series[s].name.data == resultNames[i].data && seen++ == occ)
+ break;
+ if (s == seriesCount) {
+ if (seriesCount >= kMaxPasses)
+ continue;
+ series[s] = {};
+ series[s].name = resultNames[i];
+ ++seriesCount;
+ }
+ series[s].v[historyHead] = resultUs[i];
+ }
+ historyHead = (historyHead + 1) % kHistory;
+ if (historyLen < kHistory)
+ ++historyLen;
+}
+
+void GpuProfiler::init(WGPUDevice device)
+{
+ if (initialized)
+ return;
+ const uint64_t bytes = uint64_t(2) * kMaxPasses * sizeof(uint64_t);
+ WGPUQuerySetDescriptor qd { .type = WGPUQueryType_Timestamp, .count = 2 * kMaxPasses };
+ querySet = wgpuDeviceCreateQuerySet(device, &qd);
+ WGPUBufferDescriptor rd { .usage = WGPUBufferUsage_QueryResolve | WGPUBufferUsage_CopySrc, .size = bytes };
+ resolveBuf = wgpuDeviceCreateBuffer(device, &rd);
+ for (Slot& s : ring) {
+ WGPUBufferDescriptor bd { .usage = WGPUBufferUsage_MapRead | WGPUBufferUsage_CopyDst, .size = bytes };
+ s.buf = wgpuDeviceCreateBuffer(device, &bd);
+ }
+ initialized = true;
+}
+
+int GpuProfiler::free_slot() const
+{
+ for (uint32_t i = 0; i < kRing; ++i)
+ if (!ring[i].pending)
+ return (int)i;
+ return -1;
+}
+
+Arena::ArenaBlock* Arena::new_block(size_t minPayload)
+{
+ const size_t maxAlign = alignof(std::max_align_t);
+ const size_t payloadBytes = minPayload < blockSize ? blockSize : minPayload;
+ const size_t raw = sizeof(ArenaBlock) + maxAlign + payloadBytes; // +maxAlign covers the round-up
+ ArenaBlock* b = static_cast(std::malloc(raw));
+ if (!b)
+ return nullptr;
+ uint8_t* start = reinterpret_cast(b) + sizeof(ArenaBlock);
+ b->payload = reinterpret_cast(align_up(reinterpret_cast(start), maxAlign));
+ b->capacity = payloadBytes;
+ b->used = 0;
+ b->next = nullptr;
+ totalCapacity += payloadBytes;
+ ++blockCount;
+ return b;
+}
+
+void Arena::reserve()
+{
+ if (!current)
+ head = current = new_block(blockSize);
+}
+
+size_t Arena::live_used() const { return liveBytes; }
+
+void* Arena::alloc_raw(size_t size, size_t align)
+{
+ if (!current)
+ {
+ head = current = new_block(size + align);
+ if (!current)
+ oom(size);
+ }
+
+ size_t off = align_up(current->used, align);
+ if (off + size > current->capacity)
+ {
+ if (current->next && size + align <= current->next->capacity) {
+ current = current->next;
+ liveBytes -= current->used;
+ current->used = 0;
+ } else {
+ ArenaBlock* b = new_block(size + align);
+ if (!b)
+ oom(size);
+ b->next = current->next;
+ current->next = b;
+ current = b;
+ }
+ off = align_up(current->used, align);
+ }
+
+ void* p = current->payload + off;
+ liveBytes += (off + size) - current->used;
+ current->used = off + size;
+ if (liveBytes > peakUsage)
+ peakUsage = liveBytes;
+ return p;
+}
+
+void* Arena::extend_tail(void* p, size_t oldSize, size_t addSize)
+{
+ if (!current || !p)
+ return nullptr;
+ if (reinterpret_cast(p) + oldSize != current->payload + current->used)
+ return nullptr;
+ if (current->used + addSize > current->capacity)
+ return nullptr;
+ current->used += addSize;
+ liveBytes += addSize;
+ if (liveBytes > peakUsage)
+ peakUsage = liveBytes;
+ return p;
+}
+
+[[noreturn]] void Arena::oom(size_t size)
+{
+ qFatal("[RenderGraph] error: arena alloc failed; malloc of %zu bytes failed, heap exhausted.\n", size);
+}
+
+void Arena::reset()
+{
+ for (ArenaBlock* b = head; b; b = b->next) {
+#ifndef NDEBUG
+ // poison rewound bytes so a stale pointer reads garbage
+ std::memset(b->payload, 0xDD, b->used);
+#endif
+ b->used = 0;
+ }
+ current = head;
+ liveBytes = 0;
+ peakUsage = 0;
+}
+
+void Arena::free_all()
+{
+ for (ArenaBlock* b = head; b;) {
+ ArenaBlock* n = b->next;
+ std::free(b);
+ b = n;
+ }
+ head = current = nullptr;
+ totalCapacity = peakUsage = blockCount = liveBytes = 0;
+}
+
+Arena::Mark Arena::mark() const
+{
+ return Mark { current, current ? current->used : 0 };
+}
+
+void Arena::rewind(Mark m)
+{
+ for (ArenaBlock* b = (m.block ? m.block->next : head); b; b = b->next)
+ {
+ liveBytes -= b->used;
+ b->used = 0;
+ }
+
+ if (m.block)
+ {
+ liveBytes -= m.block->used - m.used;
+ m.block->used = m.used;
+ current = m.block;
+ }
+ else
+ {
+ current = head;
+ }
+}
+
+WGPUStringView Arena::copy_string(WGPUStringView s)
+{
+ const size_t len = (s.length == WGPU_STRLEN) ? (s.data ? std::strlen(s.data) : 0) : s.length;
+ char* buf = alloc(len + 1);
+ if (len)
+ std::memcpy(buf, s.data, len);
+ buf[len] = '\0';
+ return WGPUStringView { buf, len };
+}
+
+ResourceId StringInterner::intern(std::string_view s)
+{
+ const uint64_t h = fnv1a(s);
+ Node*& head = buckets[h & (kBuckets - 1)];
+ for (Node* n = head; n; n = n->next)
+ if (n->hash == h && n->name.length == s.length()
+ && (s.empty() || std::memcmp(n->name.data, s.data(), s.length()) == 0)) // empty view may carry a null data
+ return { n->hash, n->name };
+
+ Node* n = arena->make();
+ n->hash = h;
+ n->name = arena->copy_string(WGPUStringView { s.data(), s.length() });
+ n->next = head;
+ head = n;
+ return { n->hash, n->name };
+}
+
+ScopedScratch::ScopedScratch(Arena& a)
+ : arena(&a)
+ , mark(a.mark())
+{}
+
+ScopedScratch::~ScopedScratch()
+{
+ arena->rewind(mark);
+}
+
+void ScopedScratch::reset()
+{
+ arena->rewind(mark);
+}
+
+bool ResourceNode::is_external() const
+{
+ return imported || persistent;
+}
+
+RenderGraphStorage* storage(RenderGraph* rg)
+{
+ return reinterpret_cast(reinterpret_cast(rg) + GraphAllocator::align_up(sizeof(RenderGraph), alignof(RenderGraphStorage)));
+}
+
+ResourceNode* find_node(RenderGraph* rg, ResourceHandle h)
+{
+ RenderGraphStorage& s = *storage(rg);
+
+ Q_ASSERT(!(h.id && h.generation && h.generation != s.generation) && "stale/foreign ResourceHandle passed to a ctx resolver");
+
+ if (s.byId && h.id && h.id < s.next_resource_id)
+ return s.byId[h.id];
+ for (ResourceNode* r = s.m_resouces; r; r = r->next)
+ if (r->handle.id == h.id)
+ return r;
+ return nullptr;
+}
+
+
+enum struct DefineMode : uint8_t { None, IfClearLoadOp, Always, CopyDst };
+
+struct AccessSemantics {
+ bool isWrite; // read-vs-write for hazard edges
+ DefineMode define; // type-level half of access_defines
+ WGPUTextureUsage texUsage; // WGPUTextureUsage_None for buffer-only accesses
+ WGPUBufferUsage bufUsage; // WGPUBufferUsage_None for texture-only accesses
+};
+
+constexpr AccessSemantics access_semantics(AccessType t)
+{
+ switch (t) {
+ case AccessType::ColorAttachment:
+ return { true, DefineMode::IfClearLoadOp, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None };
+ case AccessType::DepthStencilAttachment:
+ return { true, DefineMode::IfClearLoadOp, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None };
+ case AccessType::DepthStencilReadOnly:
+ return { false, DefineMode::None, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None };
+ case AccessType::ResolveAttachment:
+ return { true, DefineMode::None, WGPUTextureUsage_RenderAttachment, WGPUBufferUsage_None };
+ case AccessType::Sampled:
+ return { false, DefineMode::None, WGPUTextureUsage_TextureBinding, WGPUBufferUsage_None };
+ case AccessType::StorageRead:
+ return { false, DefineMode::None, WGPUTextureUsage_StorageBinding, WGPUBufferUsage_Storage };
+ case AccessType::StorageWrite:
+ return { true, DefineMode::Always, WGPUTextureUsage_StorageBinding, WGPUBufferUsage_Storage };
+ case AccessType::Uniform:
+ return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst };
+ case AccessType::CopySrc:
+ return { false, DefineMode::None, WGPUTextureUsage_CopySrc, WGPUBufferUsage_CopySrc };
+ case AccessType::CopyDst:
+ return { true, DefineMode::CopyDst, WGPUTextureUsage_CopyDst, WGPUBufferUsage_CopyDst };
+ case AccessType::Vertex:
+ return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Vertex };
+ case AccessType::Index:
+ return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Index };
+ case AccessType::Indirect:
+ return { false, DefineMode::None, WGPUTextureUsage_None, WGPUBufferUsage_Indirect };
+ }
+
+ Q_UNREACHABLE();
+ return {};
+}
+
+bool access_is_write(AccessType t)
+{
+ return access_semantics(t).isWrite;
+}
+
+WGPUStringView pass_name_at(PassNode* head, uint32_t idx)
+{
+ for (PassNode* p = head; p; p = p->next)
+ {
+ if (idx == 0)
+ return p->id.name;
+ --idx;
+ }
+ return WGPUStringView {};
+}
+
+// block size in texels (1x1 for uncompressed) plus bytes per block
+TexelBlock format_block(WGPUTextureFormat f)
+{
+ switch (f)
+ {
+ // 8-bit
+ case WGPUTextureFormat_R8Unorm:
+ case WGPUTextureFormat_R8Snorm:
+ case WGPUTextureFormat_R8Uint:
+ case WGPUTextureFormat_R8Sint:
+ case WGPUTextureFormat_Stencil8:
+ return { 1, 1, 1 };
+
+ // 16-bit
+ case WGPUTextureFormat_R16Uint:
+ case WGPUTextureFormat_R16Sint:
+ case WGPUTextureFormat_R16Float:
+
+ case WGPUTextureFormat_RG8Unorm:
+ case WGPUTextureFormat_RG8Snorm:
+ case WGPUTextureFormat_RG8Uint:
+ case WGPUTextureFormat_RG8Sint:
+
+ case WGPUTextureFormat_Depth16Unorm:
+ return { 1, 1, 2 };
+
+ // 32-bit
+ case WGPUTextureFormat_R32Float:
+ case WGPUTextureFormat_R32Uint:
+ case WGPUTextureFormat_R32Sint:
+
+ case WGPUTextureFormat_RG16Uint:
+ case WGPUTextureFormat_RG16Sint:
+ case WGPUTextureFormat_RG16Float:
+
+ case WGPUTextureFormat_RGBA8Unorm:
+ case WGPUTextureFormat_RGBA8UnormSrgb:
+ case WGPUTextureFormat_RGBA8Snorm:
+ case WGPUTextureFormat_RGBA8Uint:
+ case WGPUTextureFormat_RGBA8Sint:
+
+ case WGPUTextureFormat_BGRA8Unorm:
+ case WGPUTextureFormat_BGRA8UnormSrgb:
+
+ case WGPUTextureFormat_RGB10A2Uint:
+ case WGPUTextureFormat_RGB10A2Unorm:
+ case WGPUTextureFormat_RG11B10Ufloat:
+
+ case WGPUTextureFormat_Depth24Plus:
+ case WGPUTextureFormat_Depth32Float:
+ return { 1, 1, 4 };
+
+ // 64-bit
+ case WGPUTextureFormat_RG32Float:
+ case WGPUTextureFormat_RG32Uint:
+ case WGPUTextureFormat_RG32Sint:
+
+ case WGPUTextureFormat_RGBA16Uint:
+ case WGPUTextureFormat_RGBA16Sint:
+ case WGPUTextureFormat_RGBA16Float:
+
+ case WGPUTextureFormat_Depth24PlusStencil8:
+ case WGPUTextureFormat_Depth32FloatStencil8:
+ return { 1, 1, 8 };
+
+ // 128-bit
+ case WGPUTextureFormat_RGBA32Float:
+ case WGPUTextureFormat_RGBA32Uint:
+ case WGPUTextureFormat_RGBA32Sint:
+ return { 1, 1, 16 };
+
+
+ // BC1 / BC4: 8 bytes per 4x4 block
+ case WGPUTextureFormat_BC1RGBAUnorm:
+ case WGPUTextureFormat_BC1RGBAUnormSrgb:
+ case WGPUTextureFormat_BC4RUnorm:
+ case WGPUTextureFormat_BC4RSnorm:
+ return { 4, 4, 8 };
+
+ // BC2/3/5/6H/7: 16 bytes per 4x4 block
+ case WGPUTextureFormat_BC2RGBAUnorm:
+ case WGPUTextureFormat_BC2RGBAUnormSrgb:
+ case WGPUTextureFormat_BC3RGBAUnorm:
+ case WGPUTextureFormat_BC3RGBAUnormSrgb:
+ case WGPUTextureFormat_BC5RGUnorm:
+ case WGPUTextureFormat_BC5RGSnorm:
+ case WGPUTextureFormat_BC6HRGBUfloat:
+ case WGPUTextureFormat_BC6HRGBFloat:
+ case WGPUTextureFormat_BC7RGBAUnorm:
+ case WGPUTextureFormat_BC7RGBAUnormSrgb:
+ return { 4, 4, 16 };
+
+
+ // ETC2 RGB8 / RGB8A1 and EAC R11: 8 bytes per 4x4 block
+ case WGPUTextureFormat_ETC2RGB8Unorm:
+ case WGPUTextureFormat_ETC2RGB8UnormSrgb:
+ case WGPUTextureFormat_ETC2RGB8A1Unorm:
+ case WGPUTextureFormat_ETC2RGB8A1UnormSrgb:
+ case WGPUTextureFormat_EACR11Unorm:
+ case WGPUTextureFormat_EACR11Snorm:
+ return { 4, 4, 8 };
+
+ // ETC2 RGBA8 and EAC RG11: 16 bytes per 4x4 block
+ case WGPUTextureFormat_ETC2RGBA8Unorm:
+ case WGPUTextureFormat_ETC2RGBA8UnormSrgb:
+ case WGPUTextureFormat_EACRG11Unorm:
+ case WGPUTextureFormat_EACRG11Snorm:
+ return { 4, 4, 16 };
+
+
+ // ASTC: always 16 bytes per block, block footprint read straight off the enum name
+ case WGPUTextureFormat_ASTC4x4Unorm:
+ case WGPUTextureFormat_ASTC4x4UnormSrgb: return { 4, 4, 16 };
+ case WGPUTextureFormat_ASTC5x4Unorm:
+ case WGPUTextureFormat_ASTC5x4UnormSrgb: return { 5, 4, 16 };
+ case WGPUTextureFormat_ASTC5x5Unorm:
+ case WGPUTextureFormat_ASTC5x5UnormSrgb: return { 5, 5, 16 };
+ case WGPUTextureFormat_ASTC6x5Unorm:
+ case WGPUTextureFormat_ASTC6x5UnormSrgb: return { 6, 5, 16 };
+ case WGPUTextureFormat_ASTC6x6Unorm:
+ case WGPUTextureFormat_ASTC6x6UnormSrgb: return { 6, 6, 16 };
+ case WGPUTextureFormat_ASTC8x5Unorm:
+ case WGPUTextureFormat_ASTC8x5UnormSrgb: return { 8, 5, 16 };
+ case WGPUTextureFormat_ASTC8x6Unorm:
+ case WGPUTextureFormat_ASTC8x6UnormSrgb: return { 8, 6, 16 };
+ case WGPUTextureFormat_ASTC8x8Unorm:
+ case WGPUTextureFormat_ASTC8x8UnormSrgb: return { 8, 8, 16 };
+ case WGPUTextureFormat_ASTC10x5Unorm:
+ case WGPUTextureFormat_ASTC10x5UnormSrgb: return { 10, 5, 16 };
+ case WGPUTextureFormat_ASTC10x6Unorm:
+ case WGPUTextureFormat_ASTC10x6UnormSrgb: return { 10, 6, 16 };
+ case WGPUTextureFormat_ASTC10x8Unorm:
+ case WGPUTextureFormat_ASTC10x8UnormSrgb: return { 10, 8, 16 };
+ case WGPUTextureFormat_ASTC10x10Unorm:
+ case WGPUTextureFormat_ASTC10x10UnormSrgb: return { 10, 10, 16 };
+ case WGPUTextureFormat_ASTC12x10Unorm:
+ case WGPUTextureFormat_ASTC12x10UnormSrgb: return { 12, 10, 16 };
+ case WGPUTextureFormat_ASTC12x12Unorm:
+ case WGPUTextureFormat_ASTC12x12UnormSrgb: return { 12, 12, 16 };
+
+ default:
+ break;
+ }
+
+ return { 0, 0, 0 };
+}
+
+// exact size of one texture
+uint64_t texture_bytes(WGPUExtent3D size, WGPUTextureFormat format, uint32_t mipLevelCount,
+ uint32_t sampleCount, WGPUTextureDimension dim)
+{
+ const TexelBlock b = format_block(format);
+ if (!b.bytes) return 0;
+ const bool is3D = dim == WGPUTextureDimension_3D;
+ const uint32_t layers = is3D ? 1u : (size.depthOrArrayLayers ? size.depthOrArrayLayers : 1);
+ const uint32_t samples = sampleCount ? sampleCount : 1;
+ uint64_t total = 0;
+ for (uint32_t m = 0; m < mipLevelCount; ++m) {
+ const uint32_t w = (size.width >> m) ? (size.width >> m) : 1u;
+ const uint32_t h = (size.height >> m) ? (size.height >> m) : 1u;
+ const uint32_t d = is3D ? ((size.depthOrArrayLayers >> m) ? (size.depthOrArrayLayers >> m) : 1u) : 1u;
+ const uint32_t bw = (w + b.w - 1) / b.w;
+ const uint32_t bh = (h + b.h - 1) / b.h;
+ total += (uint64_t)bw * bh * d * layers * samples * b.bytes;
+ }
+ return total;
+}
+
+} // namespace Internal
+
+void* GraphAllocator::alloc_raw(size_t size, size_t align)
+{
+ return front.alloc_raw(size, align);
+}
+
+WGPUStringView GraphAllocator::copy_string(WGPUStringView s)
+{
+ return front.copy_string(s);
+}
+
+void GraphAllocator::reset()
+{
+ front.reset();
+ scratch.reset();
+ ++frameId;
+}
+
+// printf arg pair for a "%.*s" name, null data prints empty
+#define RG_NAME(x) (int)(x).name.length, (x).name.data ? (x).name.data : ""
+
+// records the error and poisons the graph to the failed state
+static void push_error(RenderGraphStorage& s, const char* fmt, ...)
+{
+ ScopedScratch scratch(s.m_allocator->scratch);
+
+ va_list ap;
+ va_start(ap, fmt);
+ va_list probe;
+ va_copy(probe, ap);
+ const int len = std::vsnprintf(nullptr, 0, fmt, probe);
+ va_end(probe);
+
+ char* buf = scratch.alloc(size_t(len) + 1);
+ std::vsnprintf(buf, size_t(len) + 1, fmt, ap);
+ va_end(ap);
+
+ ErrorMessage* e = s.m_allocator->make();
+ e->message = s.m_allocator->copy_string(WGPUStringView { buf, size_t(len) });
+ list_append(&s.m_errors, &s.m_errorsTail, e);
+ s.m_state = RenderGraphState::Failed;
+}
+
+
+static ResourceHandle create_handle(RenderGraphStorage& s, ResourceKind kind)
+{
+ return {
+ .id = s.next_resource_id++,
+ .kind = kind,
+ .generation = s.generation,
+ };
+}
+
+static ResourceId intern_id(GraphAllocator& a, std::string_view s)
+{
+ return a.names.intern(s);
+}
+
+static void assert_unique_id(RenderGraphStorage& s, ResourceId id)
+{
+#ifndef NDEBUG
+ for (ResourceNode* r = s.m_resouces; r; r = r->next)
+ Q_ASSERT(!(r->id == id) && "ResourceIds must to be unique!");
+#else
+ (void)s;
+ (void)id;
+#endif
+}
+
+GraphAllocator* create_allocator()
+{
+ GraphAllocator* allocator = new GraphAllocator;
+ allocator->front.blockSize = ARENA_DEFAULT_BLOCK_SIZE;
+ allocator->scratch.blockSize = ARENA_SCRATCH_DEFAULT_BLOCK_SIZE;
+ allocator->persist.blockSize = ARENA_SCRATCH_DEFAULT_BLOCK_SIZE;
+ allocator->front.reserve();
+ allocator->scratch.reserve();
+ allocator->persist.reserve();
+ allocator->names.arena = &allocator->persist;
+ allocator->pool.arena = &allocator->persist;
+ allocator->transient.arena = &allocator->persist;
+ allocator->subviews.arena = &allocator->persist;
+ allocator->pool.subviewPool = &allocator->subviews;
+ allocator->transient.subviewPool = &allocator->subviews;
+ return allocator;
+}
+
+void destroy_allocator(GraphAllocator* allocator)
+{
+ allocator->pool.destroy_all();
+ allocator->transient.destroy_all();
+ allocator->front.free_all();
+ allocator->scratch.free_all();
+ allocator->persist.free_all();
+ delete allocator;
+}
+
+void begin_frame(GraphAllocator* allocator)
+{
+ allocator->reset();
+}
+
+void end_frame(GraphAllocator* allocator)
+{
+ allocator->pool.end_frame();
+ allocator->transient.end_frame();
+}
+
+RenderGraph* start_recording(GraphAllocator* allocator)
+{
+ Q_ASSERT(allocator);
+ Internal::GraphPair* pair = allocator->make();
+ RenderGraph* rg = &pair->rg;
+ RenderGraphStorage* st = &pair->st;
+ Q_ASSERT(st == storage(rg) && "storage must sit immediately after the RenderGraph");
+ st->m_allocator = allocator;
+ st->frameId = allocator->frameId;
+
+ static uint64_t g_next_graph_generation = 1;
+ st->generation = g_next_graph_generation++;
+ return rg;
+}
+
+
+static bool access_defines(const ResourceAccess& a)
+{
+ switch (access_semantics(a.type).define) {
+ case DefineMode::None: return false;
+ case DefineMode::IfClearLoadOp: return a.loadOp == WGPULoadOp_Clear;
+ case DefineMode::Always: return true;
+ case DefineMode::CopyDst: return a.handle.kind == ResourceKind::Buffer && a.bufFullDefine;
+ }
+ return false;
+}
+
+static WGPUTextureUsage tex_usage_of(AccessType t) { return access_semantics(t).texUsage; }
+static WGPUBufferUsage buf_usage_of(AccessType t) { return access_semantics(t).bufUsage; }
+
+// half-open ranges [base, base+count). count 0 means all remaining.
+static constexpr bool ranges_overlap(uint32_t aBase, uint32_t aCount, uint32_t bBase, uint32_t bCount)
+{
+ uint32_t aEnd = aCount ? aBase + aCount : UINT32_MAX;
+ uint32_t bEnd = bCount ? bBase + bCount : UINT32_MAX;
+ return aBase < bEnd && bBase < aEnd;
+}
+
+// All covers depth+stencil, so it overlaps either aspect. otherwise two accesses clash only on the same one.
+static constexpr bool aspects_overlap(WGPUTextureAspect a, WGPUTextureAspect b)
+{
+ return a == WGPUTextureAspect_All || b == WGPUTextureAspect_All || a == b;
+}
+
+static constexpr bool in_pass_accesses_conflict(AccessType a,
+ uint32_t aBaseMip,
+ uint32_t aMipCount,
+ uint32_t aBaseLayer,
+ uint32_t aLayerCount,
+ WGPUTextureAspect aAspect,
+ AccessType b,
+ uint32_t bBaseMip,
+ uint32_t bMipCount,
+ uint32_t bBaseLayer,
+ uint32_t bLayerCount,
+ WGPUTextureAspect bAspect)
+{
+ if (!ranges_overlap(aBaseMip, aMipCount, bBaseMip, bMipCount))
+ return false; // disjoint mips
+ if (!ranges_overlap(aBaseLayer, aLayerCount, bBaseLayer, bLayerCount))
+ return false; // disjoint layers
+ if (!aspects_overlap(aAspect, bAspect))
+ return false; // disjoint aspects
+ if (!access_is_write(a) && !access_is_write(b))
+ return false;
+ if ((a == AccessType::StorageRead && b == AccessType::StorageWrite) || (a == AccessType::StorageWrite && b == AccessType::StorageRead))
+ return false;
+ return true;
+}
+
+static void validate_texture_desc(const TextureDesc& desc)
+{
+ Q_ASSERT_X(desc.dimension != WGPUTextureDimension_1D, "validate_texture_desc",
+ "The render graph does not support 1d textures, use 2d textures instead");
+ Q_ASSERT_X(desc.relativeTo.id == 0 || (desc.scaleX > 0.0f && desc.scaleY > 0.0f), "validate_texture_desc",
+ "When relative to another texture it cannot be a scale of 0");
+ Q_ASSERT_X(desc.sizeKind != SizeKind::Relative || desc.relativeTo.id != 0, "validate_texture_desc",
+ "SizeKind::Relative needs a relativeTo handle to size against, otherwise it resolves to nothing");
+ Q_ASSERT_X(desc.sizeKind != SizeKind::Absolute || (desc.absolute.width && desc.absolute.height), "validate_texture_desc",
+ "SizeKind::Absolute needs a non-zero width and height in TextureDesc.absolute");
+ Q_ASSERT_X(desc.sampleCount == 1 || desc.sampleCount == 4, "validate_texture_desc",
+ "WebGPU(1.0) only supports MSAA samples of 1 or 4.");
+ Q_ASSERT_X(desc.dimension != WGPUTextureDimension_3D || desc.sampleCount == 1, "validate_texture_desc",
+ "Texture3D does not support MSAA");
+ Q_ASSERT_X(desc.dimension != WGPUTextureDimension_3D || desc.mipLevelCount == 1, "validate_texture_desc",
+ "Texture3D only supports mipLevelCount of 1");
+}
+
+static void apply_texture_desc(ResourceNode* r, const TextureDesc& desc)
+{
+ validate_texture_desc(desc);
+ r->dimension = desc.dimension;
+ r->format = desc.format;
+ r->sizeKind = desc.sizeKind;
+ r->scaleX = desc.scaleX;
+ r->scaleY = desc.scaleY;
+ r->relativeToHandle = desc.relativeTo;
+ r->absolute = desc.absolute;
+ r->mipLevelCount = desc.mipLevelCount ? desc.mipLevelCount : 1;
+ r->sampleCount = desc.sampleCount ? desc.sampleCount : 1;
+}
+
+static TexSignature tex_signature(const ResourceNode* r)
+{
+ return TexSignature { r->resolved, r->format, r->dimension, r->mipLevelCount, r->sampleCount };
+}
+
+TextureHandle RenderGraph::create_transient_texture(std::string_view id, const TextureDesc& desc)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+ Q_ASSERT((desc.relativeTo.id == 0 || desc.relativeTo.generation == s.generation) && "TextureDesc.relativeTo is a stale/foreign handle");
+
+ ResourceNode* resouce = s.m_allocator->make();
+
+ resouce->handle = create_handle(s, ResourceKind::Texture);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Texture;
+ apply_texture_desc(resouce, desc);
+
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+
+ return TextureHandle { resouce->handle };
+}
+
+BufferHandle RenderGraph::create_transient_buffer(std::string_view id, const BufferDesc& desc)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+
+ ResourceNode* resouce = s.m_allocator->make();
+
+ resouce->handle = create_handle(s, ResourceKind::Buffer);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Buffer;
+ resouce->bufferSize = desc.size;
+
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+
+ return BufferHandle { resouce->handle };
+}
+
+TextureHandle RenderGraph::import_texture(std::string_view id, const ImportTextureDesc& desc)
+{
+ const WGPUTextureView view = desc.view;
+ const WGPUExtent3D size = desc.size;
+ const WGPUTextureFormat format = desc.format;
+ WGPUTexture texture = desc.texture;
+ const uint32_t mipCount = desc.mipCount;
+ const uint32_t sampleCount = desc.sampleCount;
+ const WGPUTextureDimension dimension = desc.dimension;
+
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+
+ ResourceNode* resouce = s.m_allocator->make();
+
+ resouce->handle = create_handle(s, ResourceKind::Texture);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Texture;
+ Q_ASSERT(view && "import_texture: ImportTextureDesc.view is required");
+ Q_ASSERT(format != WGPUTextureFormat_Undefined && "import_texture: pass the imported view's format");
+ resouce->imported = true;
+ resouce->view = view;
+ resouce->texture = texture;
+ if (texture) {
+ Q_ASSERT(size.width == wgpuTextureGetWidth(texture) && size.height == wgpuTextureGetHeight(texture)
+ && size.depthOrArrayLayers == wgpuTextureGetDepthOrArrayLayers(texture)
+ && "import_texture: size disagrees with the backing texture (stale extent after resize?)");
+ }
+ resouce->resolved = size;
+ resouce->format = format;
+ resouce->mipLevelCount = mipCount ? mipCount : 1;
+ resouce->sampleCount = sampleCount ? sampleCount : 1;
+ Q_ASSERT_X(dimension != WGPUTextureDimension_1D, "import_texture",
+ "The render graph does not support 1d textures, use 2d textures instead");
+ resouce->dimension = dimension;
+
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+
+ return TextureHandle { resouce->handle };
+}
+
+BufferHandle RenderGraph::import_buffer(std::string_view id, WGPUBuffer buffer)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+ Q_ASSERT(buffer && "import_buffer: null buffer; wgpuBufferGetSize below would dereference it");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+
+ ResourceNode* resouce = s.m_allocator->make();
+
+ resouce->handle = create_handle(s, ResourceKind::Buffer);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Buffer;
+ resouce->imported = true;
+ resouce->buffer = buffer;
+ resouce->bufferSize = wgpuBufferGetSize(buffer);
+
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+
+ return BufferHandle { resouce->handle };
+}
+
+HistoryTexture RenderGraph::create_history_texture(std::string_view id, const TextureDesc& desc, uint64_t hash)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+ Q_ASSERT((desc.relativeTo.id == 0 || desc.relativeTo.generation == s.generation) && "TextureDesc.relativeTo is a stale/foreign handle");
+
+ HistoryTexture out {};
+ for (uint32_t i = 0; i < PersistentResourcePool::kLayers; ++i)
+ {
+ ResourceNode* resouce = s.m_allocator->make();
+ resouce->handle = create_handle(s, ResourceKind::Texture);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Texture;
+ resouce->persistent = true;
+ resouce->history = true;
+ resouce->historyIndex = i;
+ resouce->historyHash = hash;
+ apply_texture_desc(resouce, desc);
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+ if (i == 0)
+ out.curr = TextureHandle { resouce->handle };
+ else
+ out.prev = TextureHandle { resouce->handle };
+ }
+ return out;
+}
+
+HistoryBuffer RenderGraph::create_history_buffer(std::string_view id, const BufferDesc& desc, uint64_t hash)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+
+ HistoryBuffer out {};
+ for (uint32_t i = 0; i < PersistentResourcePool::kLayers; ++i)
+ {
+ ResourceNode* resouce = s.m_allocator->make();
+ resouce->handle = create_handle(s, ResourceKind::Buffer);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Buffer;
+ resouce->persistent = true;
+ resouce->history = true;
+ resouce->historyIndex = i;
+ resouce->historyHash = hash;
+ resouce->bufferSize = desc.size;
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+ if (i == 0)
+ out.curr = BufferHandle { resouce->handle };
+ else
+ out.prev = BufferHandle { resouce->handle };
+ }
+ return out;
+}
+
+
+BufferHandle RenderGraph::create_persistent_buffer(std::string_view id, const BufferDesc& desc)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+
+ ResourceNode* resouce = s.m_allocator->make();
+ resouce->handle = create_handle(s, ResourceKind::Buffer);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Buffer;
+ resouce->persistent = true;
+ resouce->historyIndex = 0; // the only layer
+ resouce->bufferSize = desc.size;
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+ return BufferHandle { resouce->handle };
+}
+
+
+TextureHandle RenderGraph::create_persistent_texture(std::string_view id, const TextureDesc& desc)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+
+ const ResourceId rid = intern_id(*s.m_allocator, id);
+ assert_unique_id(s, rid);
+ Q_ASSERT((desc.relativeTo.id == 0 || desc.relativeTo.generation == s.generation) && "TextureDesc.relativeTo is a stale/foreign handle");
+
+ ResourceNode* resouce = s.m_allocator->make();
+ resouce->handle = create_handle(s, ResourceKind::Texture);
+ resouce->id = rid;
+ resouce->kind = ResourceKind::Texture;
+ resouce->persistent = true;
+ resouce->historyIndex = 0; // the only layer
+ apply_texture_desc(resouce, desc);
+ list_append(&s.m_resouces, &s.m_resoucesTail, resouce);
+ return TextureHandle { resouce->handle };
+}
+
+static bool is_depth_format(WGPUTextureFormat f)
+{
+ switch (f)
+ {
+ case WGPUTextureFormat_Depth16Unorm:
+ case WGPUTextureFormat_Depth24Plus:
+ case WGPUTextureFormat_Depth24PlusStencil8:
+ case WGPUTextureFormat_Depth32Float:
+ case WGPUTextureFormat_Depth32FloatStencil8:
+ return true;
+ default:
+ return false;
+ }
+}
+
+static bool format_has_stencil(WGPUTextureFormat f)
+{
+ switch (f)
+ {
+ case WGPUTextureFormat_Stencil8:
+ case WGPUTextureFormat_Depth24PlusStencil8:
+ case WGPUTextureFormat_Depth32FloatStencil8:
+ return true;
+ default:
+ return false;
+ }
+}
+
+TextureHandle RenderGraph::create_initialized_texture(std::string_view id, const TextureDesc& desc, WGPUColor fill)
+{
+ // the fill is a clear on a render target, and the graph cannot render into a 3D texture
+ Q_ASSERT_X(desc.dimension != WGPUTextureDimension_3D, "create_initialized_texture",
+ "3D textures cannot be cleared this way, the graph cannot use them as render targets");
+ TextureHandle h = create_persistent_texture(id, desc);
+ uint32_t layers = desc.absolute.depthOrArrayLayers ? desc.absolute.depthOrArrayLayers : 1;
+ bool depth = is_depth_format(desc.format);
+ Arena& scratch = storage(this)->m_allocator->scratch;
+
+ for (uint32_t layer = 0; layer < layers; ++layer)
+ {
+ ScopedScratch ss(scratch);
+ std::string_view passId = id;
+ if (layers > 1) {
+ const int n = std::snprintf(nullptr, 0, "%.*s.layer%u", (int)id.size(), id.data(), layer);
+ char* buf = ss.alloc(size_t(n) + 1);
+ std::snprintf(buf, size_t(n) + 1, "%.*s.layer%u", (int)id.size(), id.data(), layer);
+ passId = std::string_view(buf, size_t(n));
+ }
+ add_pass(
+ passId,
+ PassKind::Graphics,
+ [h, depth, fill, layer](PassBuilder& b) {
+ if (depth)
+ b.depth_stencil(h, { .clearDepth = (float)fill.r, .sub = { .layer = layer } });
+ else
+ b.color(h, 0, { .clear = fill, .sub = { .layer = layer } });
+ b.initialize(h);
+ },
+ [](PassContext&) {});
+ }
+ return h;
+}
+
+BufferHandle RenderGraph::create_initialized_buffer(std::string_view id, const BufferDesc& desc, const void* data)
+{
+ BufferHandle h = create_persistent_buffer(id, desc);
+ RenderGraphStorage& s = *storage(this);
+ // copy into the arena, the caller's data does not outlive this call
+ uint8_t* bytes = s.m_allocator->alloc(desc.size);
+ if (data)
+ std::memcpy(bytes, data, desc.size);
+ uint64_t size = desc.size;
+ add_pass(
+ id,
+ PassKind::Transfer,
+ [h](PassBuilder& b) {
+ b.host_write(h);
+ b.initialize(h);
+ },
+ [h, bytes, size](PassContext& ctx) { wgpuQueueWriteBuffer(ctx.queue, ctx.buffer(h), 0, bytes, size); });
+ return h;
+}
+
+void RenderGraph::begin_pass(PassBuilder& builder, std::string_view id, PassKind kind)
+{
+ RenderGraphStorage& s = *storage(this);
+ Q_ASSERT(s.m_state == RenderGraphState::Recording && "Render graph is in read only mode after compile()");
+ PassNode* pass = s.m_allocator->make();
+ pass->id = intern_id(*s.m_allocator, id);
+ pass->kind = kind;
+
+ builder.m_pass = pass;
+ builder.m_graph = this;
+}
+
+void RenderGraph::end_pass(PassBuilder& builder)
+{
+ PassNode* pass = builder.m_pass;
+ for (uint32_t t = 0; t < pass->initCount; ++t)
+ {
+ bool wrote = false;
+ for (uint32_t i = 0; i < pass->accessCount; ++i)
+ if (pass->accesses[i].handle.id == pass->initTargets[t].target.id && access_is_write(pass->accesses[i].type)) {
+ wrote = true;
+ break;
+ }
+ Q_ASSERT(wrote
+ && "initialize() target must also be written by this pass (e.g. .color()/.storage_write()). "
+ "initialize() only gates skip/run, it is not itself a write");
+ }
+ RenderGraphStorage& s = *storage(this);
+ list_append(&s.m_passes, &s.m_passesTail, pass);
+}
+
+void* RenderGraph::alloc_exec(size_t size, size_t align)
+{
+ return storage(this)->m_allocator->alloc_raw(size, align);
+}
+
+void RenderGraph::set_exec(PassBuilder& builder, void* obj, void (*fn)(void*, PassContext&))
+{
+ builder.m_pass->exec_obj = obj;
+ builder.m_pass->exec_fn = fn;
+}
+
+static void add_dependency(GraphAllocator* alloc, PassNode* p, PassNode* dep)
+{
+ NodeAdjacency* link = alloc->make();
+ link->pass = dep;
+ link->next = p->adjacency;
+ p->adjacency = link;
+}
+
+// one declaration-order sweep with implicit SSA resource versioning
+static void sweep_resource_versions(Arena& scratch, PassNode* head, uint32_t next_resource_id, uint32_t passCount, GraphAllocator* alloc)
+{
+ ScopedScratch ss(scratch);
+ PassNode** currentProducer = ss.alloc(next_resource_id);
+ NodeAdjacency** pendingReaders = ss.alloc(next_resource_id);
+ uint32_t* linkedBy = ss.alloc(passCount);
+
+ auto emit_edge = [&](PassNode* dependent, PassNode* dep) {
+ if (linkedBy[dep->index] == dependent->index)
+ return;
+ linkedBy[dep->index] = dependent->index;
+ add_dependency(alloc, dependent, dep);
+ };
+
+ for (PassNode* p = head; p; p = p->next) {
+ if (p->skipInit)
+ continue; // initialize() pass already satisfied
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ uint32_t id = p->accesses[i].handle.id;
+ if (!id)
+ continue;
+ if (access_is_write(p->accesses[i].type)) {
+ // WAW: order this write after the previous writer. without it two writers of one
+ // resource have no edge -> undefined order -> corruption.
+ if (currentProducer[id] && currentProducer[id] != p)
+ emit_edge(p, currentProducer[id]); // WAW
+ // WAR: order this write after every reader still using the version it clobbers.
+ for (NodeAdjacency* r = pendingReaders[id]; r; r = r->next)
+ if (r->pass != p)
+ emit_edge(p, r->pass); // WAR
+ currentProducer[id] = p; // new version
+ pendingReaders[id] = nullptr; // readers retired
+ } else {
+ // RAW: this read depends on the producer of the version it sees. a read before any
+ // writer binds to "no producer" (no edge).
+ if (currentProducer[id] && currentProducer[id] != p)
+ emit_edge(p, currentProducer[id]); // RAW
+ // register as a pending reader of the current version (for a future write's WAR).
+ NodeAdjacency* link = ss.make();
+ link->pass = p;
+ link->next = pendingReaders[id];
+ pendingReaders[id] = link;
+ }
+ }
+ }
+}
+
+// one level of the explicit DFS stack
+struct TopoFrame {
+ PassNode* pass {};
+ NodeAdjacency* next {}; // unvisited remainder of pass->adjacency
+};
+
+// post-order DFS
+static void topo_visit(PassNode* root, PassNode** order, uint32_t& count, bool& hadCycle, TopoFrame* stack)
+{
+ if (root->onStack) { // trap for cycle detection
+ hadCycle = true;
+ return;
+ }
+ if (root->placed)
+ return;
+
+ uint32_t depth = 0;
+ root->placed = root->onStack = true;
+ stack[depth++] = { root, root->adjacency };
+
+ while (depth) {
+ TopoFrame& f = stack[depth - 1];
+ if (f.next) {
+ PassNode* child = f.next->pass;
+ f.next = f.next->next;
+ if (child->onStack) { // trap for cycle detection
+ hadCycle = true;
+ continue;
+ }
+ if (child->placed)
+ continue;
+ child->placed = child->onStack = true;
+ stack[depth++] = { child, child->adjacency };
+ continue;
+ }
+
+ f.pass->onStack = false;
+ order[count++] = f.pass;
+ --depth;
+ }
+}
+
+static bool is_sink(PassNode* p, const bool* sinkRoot)
+{
+ for (uint32_t i = 0; i < p->accessCount; ++i)
+ if (access_is_write(p->accesses[i].type) && sinkRoot[p->accesses[i].handle.id])
+ return true;
+ return false;
+}
+
+static WGPUExtent3D resolve_size(ResourceNode* r, RenderGraphStorage& s)
+{
+ if (r->imported || r->resolved.width)
+ return r->resolved;
+ if (r->sizeKind == SizeKind::Absolute) {
+ r->resolved = r->absolute;
+ if (!r->resolved.depthOrArrayLayers)
+ r->resolved.depthOrArrayLayers = 1; // 0 layers means 1 everywhere else, see node_layers()
+ return r->resolved;
+ }
+ if (r->resolving) {
+ push_error(s,
+ "resource \"%.*s\" sits on a cyclic relativeTo chain. A relative size must "
+ "eventually resolve against an absolutely sized resource.",
+ RG_NAME(r->id));
+ return WGPUExtent3D {};
+ }
+
+ ResourceNode* base = r->relativeToHandle.id ? s.byId[r->relativeToHandle.id] : nullptr;
+ if (!base) {
+ push_error(s,
+ "resource \"%.*s\" is SizeKind::Relative but names no resource to size against. "
+ "set TextureDesc.relativeTo, or use SizeKind::Absolute.",
+ RG_NAME(r->id));
+ return r->resolved = WGPUExtent3D {};
+ }
+ r->resolving = true;
+ WGPUExtent3D b = resolve_size(base, s);
+ r->resolving = false;
+ uint32_t layers = r->absolute.depthOrArrayLayers ? r->absolute.depthOrArrayLayers : 1;
+ // round, don't truncate. clamp to 1
+ uint32_t w = (uint32_t)(b.width * r->scaleX + 0.5f);
+ uint32_t h = (uint32_t)(b.height * r->scaleY + 0.5f);
+ return r->resolved = { w ? w : 1u, h ? h : 1u, layers };
+}
+
+static void detect_transient_attachments(RenderGraphStorage& s)
+{
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ if (r->is_external() || r->kind != ResourceKind::Texture)
+ continue;
+ if (r->texUsage != WGPUTextureUsage_RenderAttachment)
+ continue;
+ if (r->dimension != WGPUTextureDimension_2D || r->mipLevelCount != 1 || r->resolved.depthOrArrayLayers > 1)
+ continue;
+
+ // exactly one recorded access, otherwise its contents span passes and cannot be memoryless
+ if (r->liveAccessCount != 1)
+ continue;
+ const ResourceAccess* a = r->soleAccess;
+
+ // a writable color/depth attachment only
+ if (a->type != AccessType::ColorAttachment && a->type != AccessType::DepthStencilAttachment)
+ continue;
+ if (a->loadOp != WGPULoadOp_Clear || a->storeOp != WGPUStoreOp_Discard)
+ continue;
+
+ if (a->type == AccessType::DepthStencilAttachment && a->stencilLoadOp != WGPULoadOp_Undefined
+ && (a->stencilLoadOp != WGPULoadOp_Clear || a->stencilStoreOp != WGPUStoreOp_Discard))
+ continue;
+
+ r->transientAttachment = true;
+ ++s.transientCount;
+ }
+}
+
+// a history resource is two layer nodes sharing one pool entry
+static void validate_history_layers(RenderGraphStorage& s)
+{
+ auto pool_used = [](const ResourceNode* r) { return r->kind == ResourceKind::Texture ? r->texUsage != 0 : r->bufUsage != 0; };
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ if (!r->history || r->historyIndex != 0)
+ continue; // anchor on .curr, skip .prev and single-layer persistent
+ ResourceNode* prev = r->next; // .prev (layer 1) was appended right after .curr
+ // both ids are interned, so canonical pointers match exactly where hashes could collide
+ if (!prev || !prev->history || prev->id.name.data != r->id.name.data || prev->historyIndex != 1) {
+ push_error(s,
+ "history resource \"%.*s\": its .curr and .prev layer nodes are not adjacent in "
+ "declaration order. This is an internal error, not an authoring one.",
+ RG_NAME(r->id));
+ continue;
+ }
+ if (pool_used(r) != pool_used(prev))
+ push_error(s,
+ "history resource \"%.*s\": .curr (layer 0) is %s but .prev (layer 1) is %s "
+ "after culling; read .prev and write .curr in one pass, or use neither.",
+ RG_NAME(r->id),
+ pool_used(r) ? "used" : "unused",
+ pool_used(prev) ? "used" : "unused");
+ }
+}
+
+// validate cube/cubearray view layer counts
+static void validate_cube_views(RenderGraphStorage& s);
+
+// validate every access's subresource / byte range against the resource it names
+static void validate_access_ranges(RenderGraphStorage& s);
+
+// validate the render-pass descriptor each Graphics pass will build: attachment sizes and sample counts,
+// resolve pairs, depth-stencil formats
+static void validate_render_passes(RenderGraphStorage& s);
+
+// compile()
+static void compile_impl(RenderGraph* graph, bool enableAlias)
+{
+ auto t0 = std::chrono::steady_clock::now();
+ RenderGraphStorage& s = *storage(graph);
+ if (s.m_state != RenderGraphState::Recording) {
+ static constexpr const char* kStateName[] = { "recording", "compiled", "failed", "finished" };
+ push_error(s, "compile() called on a graph that is already %s. compile() runs once per recording.",
+ kStateName[(int)s.m_state]);
+ return;
+ }
+
+ // a handle this frame's graph did not create
+ auto foreign = [&s](ResourceHandle h) { return h.id >= s.next_resource_id || h.generation != s.generation; };
+
+ uint32_t passIndex = 0;
+ for (PassNode* p = s.m_passes; p; p = p->next) {
+ p->index = passIndex++;
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ ResourceHandle h = p->accesses[i].handle;
+ if (h.id == 0 || foreign(h)) {
+ push_error(s,
+ "pass \"%.*s\" uses an invalid resource handle (id %u): zero, out of range, "
+ "or from another graph / a previous frame.",
+ RG_NAME(p->id),
+ h.id);
+ return;
+ }
+ }
+ for (uint32_t i = 0; i < p->initCount; ++i) {
+ ResourceHandle t = p->initTargets[i].target;
+ if (foreign(t)) {
+ push_error(s,
+ "pass \"%.*s\" initialize() target handle (id %u) is from another graph or a previous frame.",
+ RG_NAME(p->id),
+ t.id);
+ return;
+ }
+ }
+ }
+ s.passCount = passIndex;
+
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ ResourceHandle rel = r->relativeToHandle;
+ if (rel.id != 0 && foreign(rel)) {
+ push_error(s,
+ "resource \"%.*s\" relativeTo (id %u) is from another graph or a previous frame.",
+ RG_NAME(r->id),
+ rel.id);
+ return;
+ }
+ }
+
+ // build fast index based lookup
+ s.byId = s.m_allocator->alloc(s.next_resource_id);
+ for (ResourceNode* r = s.m_resouces; r; r = r->next){
+ if(r->handle.id < s.next_resource_id)
+ s.byId[r->handle.id] = r;
+ }
+
+ WGPUTextureUsage* predTexUsage = s.m_allocator->alloc(s.next_resource_id);
+ WGPUBufferUsage* predBufUsage = s.m_allocator->alloc(s.next_resource_id);
+ bool* hasWriter = s.m_allocator->alloc(s.next_resource_id);
+ for (PassNode* p = s.m_passes; p; p = p->next)
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ const ResourceAccess& a = p->accesses[i];
+ predTexUsage[a.handle.id] |= tex_usage_of(a.type);
+ predBufUsage[a.handle.id] |= buf_usage_of(a.type);
+ hasWriter[a.handle.id] |= access_is_write(a.type); // OR: any declared write, not just the last access to id
+ }
+
+ // phase 0: skip up-to-date bake passes. a skipped pass produces no version and is not a cull root, so
+ // phases 1-2 drop it. readers still bind to the pooled result.
+ for (PassNode* p = s.m_passes; p; p = p->next) {
+ p->skipInit = false;
+ if (!p->initCount)
+ continue;
+ // skip only when every target is realized and baked with its exact hash. one stale target re-arms
+ // the pass, since a body bakes all its targets or none.
+ bool allBaked = true;
+ for (uint32_t i = 0; i < p->initCount; ++i) {
+ ResourceNode* r = s.byId[p->initTargets[i].target.id]; // non-null: backstop range-checked the id
+ if (!r->persistent) {
+ push_error(s, "initialize() target must be persistent or history (create_persistent_texture/_buffer or create_history_texture/_buffer)");
+ allBaked = false;
+ continue;
+ }
+ PersistentResourcePool::Entry* e = s.m_allocator->pool.find(r->id);
+ bool upToDate = e && e->created && e->baked && e->initHash == p->initTargets[i].hash;
+ // a matching bake is still stale if realize() would recreate the entry after skipInit was
+ // decided, leaving it blank for a frame.
+ bool wouldRecreate = false;
+ if (e && e->created) {
+ // same predicate realize_*_entry uses, so the prediction cannot drift from the decision.
+ if (r->kind == ResourceKind::Texture) {
+ resolve_size(r, s); // tex_signature reads r->resolved
+ wouldRecreate = PersistentResourcePool::tex_entry_would_recreate(*e, tex_signature(r), predTexUsage[r->handle.id]);
+ } else {
+ wouldRecreate = PersistentResourcePool::buf_entry_would_recreate(*e, r->bufferSize, predBufUsage[r->handle.id]);
+ }
+ // touch() also recreates on a history-hash mismatch, and has not run yet at phase-0 time.
+ wouldRecreate = wouldRecreate || PersistentResourcePool::history_hash_forces_destroy(*e, r->historyHash);
+ }
+ if (!upToDate || wouldRecreate)
+ allBaked = false;
+ }
+ p->skipInit = allBaked;
+ }
+ if (s.m_state == RenderGraphState::Failed) // a non-persistent initialize() target or a cyclic relativeTo chain
+ return;
+
+ // phase 1: build the dependency DAG in the "depends-on" direction. reads before any writer get no edge
+ // here, the post-cull pass turns them into errors.
+ sweep_resource_versions(s.m_allocator->scratch, s.m_passes, s.next_resource_id, s.passCount, s.m_allocator);
+
+ // phase 2: cull dead passes and topo sort, fused into one DFS seeded from sinks.
+ {
+ // sinkRoot = imported or history .curr, whose value must survive without a same-frame reader.
+ // persistent bakes are excluded on purpose, they are pool-cached and dependency-driven.
+ ScopedScratch ss(s.m_allocator->scratch);
+ bool* sinkRoot = ss.alloc(s.next_resource_id);
+ for (ResourceNode* r = s.m_resouces; r; r = r->next)
+ sinkRoot[r->handle.id] = r->imported || r->history;
+
+ // topo into a scratch array, then relink the intrusive list into execution order
+ PassNode** order = ss.alloc(s.passCount);
+ TopoFrame* topoStack = ss.alloc(s.passCount);
+ uint32_t count = 0;
+ bool hadCycle = false;
+ for (PassNode* p = s.m_passes; p; p = p->next)
+ if (!p->skipInit
+ && (is_sink(p, sinkRoot) || p->forceKeep)) // satisfied initialize() pass is not a root; force_keep() keeps a reader-less side-effect pass alive
+ {
+ p->sink = true;
+ topo_visit(p, order, count, hadCycle, topoStack); // only reaches passes that feed a sink
+ }
+
+ // acyclic by construction, phase 1 emits only backward edges, so a cycle means corruption.
+ if (hadCycle) {
+ push_error(s,
+ "compile() found a cycle in the pass dependency graph. The graph is "
+ "acyclic by construction, so this is an internal error, not an authoring one.");
+ return;
+ }
+
+ // relink next-pointers to follow topo order
+ for (uint32_t i = 0; i + 1 < count; ++i)
+ order[i]->next = order[i + 1];
+ if (count)
+ order[count - 1]->next = nullptr;
+ s.m_passes = count ? order[0] : nullptr;
+ s.m_passesTail = count ? order[count - 1] : nullptr;
+ }
+
+ // post-cull validation over the final schedule: reading a transient no earlier surviving pass produced
+ // samples uninitialized pool contents. external resources are exempt, their value comes from outside
+ // the frame. bails before phase 3, so a misordered graph never reaches realize().
+ {
+ ScopedScratch ss(s.m_allocator->scratch);
+ bool* produced = ss.alloc(s.next_resource_id); // a surviving pass has written it so far
+ bool* external = ss.alloc(s.next_resource_id); // imported or history
+ bool* prevLayer = ss.alloc(s.next_resource_id); // history layer k>0, read-only this frame
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ external[r->handle.id] = r->is_external();
+ prevLayer[r->handle.id] = r->persistent && r->historyIndex != 0;
+ }
+
+ bool hadError = false;
+ for (PassNode* p = s.m_passes; p; p = p->next)
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ uint32_t id = p->accesses[i].handle.id;
+ if (access_is_write(p->accesses[i].type)) {
+ produced[id] = true;
+ // older layers of a history resource are read-only this frame, .curr is the only legal
+ // write target. writing layer k>0 clobbers a slot that becomes a future current.
+ if (prevLayer[id]) {
+ ResourceNode* w = s.byId[id]; // non-null: prevLayer is only set from a live node
+ push_error(s,
+ "pass \"%.*s\" writes history resource \"%.*s\" "
+ "layer %u; only layer 0, the .curr handle, is writable.",
+ RG_NAME(p->id),
+ RG_NAME(w->id),
+ w->historyIndex);
+ hadError = true;
+ }
+ continue;
+ }
+ if (id == 0 || external[id] || produced[id])
+ continue;
+ ResourceNode* r = s.byId[id]; // non-null: the handle backstop range-checked every access id
+
+ // a write to id in THIS pass makes the read an in-place read_write, which needs its own
+ // diagnosis below.
+ bool samePassWrite = false;
+ for (uint32_t j = 0; j < p->accessCount; ++j)
+ if (p->accesses[j].handle.id == id && access_is_write(p->accesses[j].type)) {
+ samePassWrite = true;
+ break;
+ }
+
+ // three diagnoses of one shape, all naming the pass then the resource twice
+ const char* fmt = !hasWriter[id]
+ ? "pass \"%.*s\" reads transient resource \"%.*s\" that no pass ever writes; "
+ "a transient starts uninitialized, so write \"%.*s\" before reading it."
+ : samePassWrite
+ ? "pass \"%.*s\" read-modify-writes transient resource \"%.*s\" that no earlier pass "
+ "produced; the same-pass write can't seed the read (it sees uninitialized contents). "
+ "Write \"%.*s\" in an earlier pass, or use storage_write if the shader is write-only."
+ : "pass \"%.*s\" reads resource \"%.*s\" before any pass "
+ "writes it; declare a writer of \"%.*s\" first.";
+ push_error(s, fmt, RG_NAME(p->id), RG_NAME(r->id), RG_NAME(r->id));
+ hadError = true;
+ }
+ if (hadError)
+ return;
+ }
+
+ // phase 3: accumulate WGPU usage and resolve concrete sizes. WebGPU requires the usage bits at create
+ // time, so realize() is left with only the device create calls.
+ {
+ uint32_t passIdx = 0;
+ for (PassNode* p = s.m_passes; p; p = p->next, ++passIdx) // m_passes == surviving (post-cull) passes
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ ResourceNode* r = s.byId[p->accesses[i].handle.id];
+ if (!r)
+ continue;
+ // transient-attachment bookkeeping: count live accesses and keep the last one
+ ++r->liveAccessCount;
+ r->soleAccess = &p->accesses[i];
+ // lifetime: the walk is in execution order, so the first touch is firstUse and each later
+ // one overwrites lastUse.
+ if (!r->is_external()) { // external is pool- or caller-owned -> excluded from aliasing
+ if (r->firstUse == ResourceNode::kNoPass) {
+ r->firstUse = passIdx;
+ r->firstDefines = access_defines(p->accesses[i]); // aliasing: does the first touch overwrite?
+ }
+ r->lastUse = passIdx;
+ if (access_is_write(p->accesses[i].type))
+ r->hasWriter = true;
+ }
+ // tex_usage_of/buf_usage_of are kind-agnostic, so dispatch on the node kind
+ if (r->kind == ResourceKind::Texture)
+ r->texUsage |= tex_usage_of(p->accesses[i].type);
+ else
+ r->bufUsage |= buf_usage_of(p->accesses[i].type);
+ }
+
+ // walk each texture's relativeTo chain. memoized and recursive, so any declaration order works.
+ for (ResourceNode* r = s.m_resouces; r; r = r->next)
+ if (r->kind == ResourceKind::Texture)
+ resolve_size(r, s);
+ if (s.m_state == RenderGraphState::Failed) // a cyclic relativeTo chain leaves sizes unresolved
+ return;
+
+ // a zero extent would reach wgpuDeviceCreateTexture and surface as a device validation error with
+ // no link back to the descriptor that caused it.
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ if (r->kind != ResourceKind::Texture || !r->texUsage)
+ continue;
+ if (r->resolved.width && r->resolved.height)
+ continue;
+ push_error(s,
+ "resource \"%.*s\" resolves to a zero extent (%u x %u); a texture needs a non-zero width "
+ "and height. Check its TextureDesc.absolute, or the relativeTo base it scales against.",
+ RG_NAME(r->id),
+ r->resolved.width,
+ r->resolved.height);
+ }
+ if (s.m_state == RenderGraphState::Failed)
+ return;
+
+ // NOTE(Huerbe): usage==0 here == untouched by a live pass -> realize() skips it = free
+ // dead-resource culling. no separate resource liveness list needed.
+ }
+
+ // reject a history whose .curr/.prev layers ended up asymmetrically culled. needs phase-3 usage.
+ validate_history_layers(s);
+ if (s.m_state == RenderGraphState::Failed)
+ return;
+
+ // reject misauthored cube/cubearray views (needs the resolved layer counts from phase-3).
+ validate_cube_views(s);
+ if (s.m_state == RenderGraphState::Failed)
+ return;
+
+ // reject accesses whose subresource/byte range does not fit the resource (same phase-3 dependency).
+ validate_access_ranges(s);
+ if (s.m_state == RenderGraphState::Failed)
+ return;
+
+ // reject render passes WebGPU would refuse. runs after the range checks, so the attachment subresources
+ // it measures are already known to be in range.
+ validate_render_passes(s);
+ if (s.m_state == RenderGraphState::Failed)
+ return;
+
+ // infer transient (memoryless) attachments from the usage gather.
+ detect_transient_attachments(s);
+
+ // phase 4: transient memory aliasing (opt-in). pack disjoint-lifetime, same-signature transients onto
+ // a shared physical object
+ if (enableAlias)
+ {
+ ScopedScratch ss(s.m_allocator->scratch);
+ ResourceNode** elig = ss.alloc(s.next_resource_id);
+ uint32_t nElig = 0;
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ // a transientAttachment is memoryless, so there is no storage to alias
+ if (r->is_external() || r->firstUse == ResourceNode::kNoPass || !r->hasWriter || !r->firstDefines || r->transientAttachment)
+ continue;
+ if (r->kind == ResourceKind::Texture && (r->mipLevelCount != 1 || r->sampleCount != 1 || r->resolved.depthOrArrayLayers > 1))
+ continue;
+ elig[nElig++] = r;
+ }
+
+ // by firstUse asc. stable so equal firstUse keeps declaration order, which first-fit below depends on
+ std::stable_sort(elig, elig + nElig, [](const ResourceNode* a, const ResourceNode* b) { return a->firstUse < b->firstUse; });
+
+ // first-fit: reuse the first signature-matching slot whose occupant is already dead, else open a
+ // new one. strict freeFrom < firstUse, equality means they share a pass, which WebGPU forbids.
+ if (nElig)
+ s.m_slots = s.m_allocator->alloc(nElig);
+ for (uint32_t i = 0; i < nElig; ++i) {
+ ResourceNode* r = elig[i];
+ const bool isBuf = r->kind == ResourceKind::Buffer;
+ // the signature match below assumes the eligibility filter kept mip>1 / MSAA nodes out. were
+ // that filter to change, the match would silently pass them, so trip here instead.
+ if (!isBuf && (r->mipLevelCount != 1 || r->sampleCount != 1)) {
+ push_error(s,
+ "resource \"%.*s\" reached alias slot assignment with %u mip(s) and %u sample(s); "
+ "aliasing eligibility must exclude mip>1 / MSAA nodes. This is an internal error, "
+ "not an authoring one.",
+ RG_NAME(r->id),
+ r->mipLevelCount,
+ r->sampleCount);
+ return;
+ }
+ uint32_t slot = ResourceNode::kNoSlot;
+ for (uint32_t k = 0; k < s.m_slotCount; ++k) {
+ PhysicalResource& ph = s.m_slots[k];
+ if (ph.kind != r->kind)
+ continue; // never mix textures + buffers in one slot
+ const bool sig = isBuf ? (ph.bufferSize == r->bufferSize) : (ph.sig == tex_signature(r));
+ if (sig && ph.freeFrom < r->firstUse) {
+ slot = k;
+ break;
+ } // no shared pass
+ }
+ if (slot == ResourceNode::kNoSlot) {
+ slot = s.m_slotCount++;
+ PhysicalResource& ph = s.m_slots[slot];
+ ph.kind = r->kind;
+ ph.identity = r->id.name.data; // the member that opens the slot names it, see PhysicalResource
+ if (isBuf)
+ ph.bufferSize = r->bufferSize;
+ else
+ ph.sig = tex_signature(r);
+ }
+ PhysicalResource& ph = s.m_slots[slot];
+ if (isBuf)
+ ph.bufUsage |= r->bufUsage; // widen to the union: every member shares this one object
+ else
+ ph.texUsage |= r->texUsage;
+ ph.freeFrom = r->lastUse;
+ r->aliasSlot = slot;
+ }
+ }
+
+ auto t1 = std::chrono::steady_clock::now();
+ s.timing_compile_us = std::chrono::duration(t1 - t0).count();
+
+ s.m_state = (s.m_errors == nullptr) ? RenderGraphState::Compiled : RenderGraphState::Failed;
+}
+
+ErrorMessage* RenderGraph::compile(bool enableAlias)
+{
+ compile_impl(this, enableAlias);
+ return get_errors();
+}
+
+static uint32_t node_layers(const ResourceNode* r)
+{
+ return (r->dimension == WGPUTextureDimension_2D) ? (r->resolved.depthOrArrayLayers ? r->resolved.depthOrArrayLayers : 1) : 1;
+}
+
+static uint32_t mip_dim(uint32_t v, uint32_t mip)
+{
+ v >>= mip;
+ return v ? v : 1;
+}
+
+static void validate_cube_views(RenderGraphStorage& s)
+{
+ for (PassNode* p = s.m_passes; p; p = p->next)
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ const ResourceAccess& a = p->accesses[i];
+ if (a.viewDim != WGPUTextureViewDimension_Cube && a.viewDim != WGPUTextureViewDimension_CubeArray)
+ continue;
+ ResourceNode* r = s.byId[a.handle.id];
+ if (!r || r->kind != ResourceKind::Texture)
+ continue;
+
+ if (a.type == AccessType::StorageRead || a.type == AccessType::StorageWrite) {
+ push_error(s, "pass \"%.*s\": resource \"%.*s\" requests a cube view on a storage access; "
+ "WebGPU storage textures cannot be cube.", RG_NAME(p->id), RG_NAME(r->id));
+ continue;
+ }
+ if (r->dimension != WGPUTextureDimension_2D) {
+ push_error(s, "pass \"%.*s\": resource \"%.*s\" requests a cube view but is not a 2D texture; "
+ "cube views require a 2D texture with 6 (cube) or 6*N (cube array) layers.", RG_NAME(p->id), RG_NAME(r->id));
+ continue;
+ }
+ uint32_t layers = node_layers(r);
+ uint32_t layerCount = a.layerCount ? a.layerCount : (layers - a.baseLayer); // 0 == all remaining
+ if (a.viewDim == WGPUTextureViewDimension_Cube && layerCount != 6)
+ push_error(s, "pass \"%.*s\": cube view of \"%.*s\" covers %u layer(s); a cube view needs exactly 6. "
+ "Use ViewRange.layerCount = 6 (e.g. rg::cube()).", RG_NAME(p->id), RG_NAME(r->id), layerCount);
+ else if (a.viewDim == WGPUTextureViewDimension_CubeArray && (layerCount == 0 || layerCount % 6 != 0))
+ push_error(s, "pass \"%.*s\": cube-array view of \"%.*s\" covers %u layer(s); a cube array needs a "
+ "positive multiple of 6 (e.g. rg::cube_array(n)).", RG_NAME(p->id), RG_NAME(r->id), layerCount);
+ else if (a.baseLayer + layerCount > layers)
+ push_error(s, "pass \"%.*s\": cube view of \"%.*s\" (baseLayer %u + %u layers) exceeds the "
+ "texture's %u layer(s).", RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.baseLayer, layerCount, layers);
+ }
+}
+
+// render-pass rules WebGPU enforces on the descriptor execute() builds. checking them here names the pass
+// and the resources that disagree, where the device can only name the descriptor it was handed.
+static void validate_render_passes(RenderGraphStorage& s)
+{
+ struct Att {
+ const ResourceAccess* a;
+ ResourceNode* r;
+ };
+
+ // an attachment view is a single mip, so its size is that mip's size
+ auto att_w = [](const Att& t) { return mip_dim(t.r->resolved.width, t.a->baseMip); };
+ auto att_h = [](const Att& t) { return mip_dim(t.r->resolved.height, t.a->baseMip); };
+
+ for (PassNode* p = s.m_passes; p; p = p->next) {
+ if (p->kind != PassKind::Graphics)
+ continue;
+
+ Att color[kMaxColorAttachments] {};
+ uint32_t colorCount = 0;
+ Att resolve[kMaxColorAttachments] {};
+ uint32_t resolveCount = 0;
+ Att depth {};
+ bool hasDepth = false;
+
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ const ResourceAccess& a = p->accesses[i];
+ ResourceNode* r = s.byId[a.handle.id];
+ if (!r)
+ continue;
+ const Att t { &a, r };
+ if (a.type == AccessType::ColorAttachment && colorCount < kMaxColorAttachments)
+ color[colorCount++] = t;
+ else if (a.type == AccessType::ResolveAttachment && resolveCount < kMaxColorAttachments)
+ resolve[resolveCount++] = t;
+ else if (a.type == AccessType::DepthStencilAttachment || a.type == AccessType::DepthStencilReadOnly) {
+ depth = t;
+ hasDepth = true;
+ } else
+ continue;
+
+ // execute() always leaves depthSlice at UNDEFINED, which WebGPU requires a 3D color target to
+ // set, so a 3D attachment can never be valid here
+ if (r->dimension == WGPUTextureDimension_3D)
+ push_error(s,
+ "pass \"%.*s\": resource \"%.*s\" is a 3D texture used as a render target; the graph "
+ "does not set depthSlice, so it cannot render into one. Use a 2D array texture.",
+ RG_NAME(p->id), RG_NAME(r->id));
+ }
+
+ if (!colorCount && !hasDepth) {
+ push_error(s,
+ "pass \"%.*s\" is a Graphics pass with no attachments; a render pass needs at least one "
+ "color() or a depth_stencil(). Use PassKind::Compute if the pass records no draws.",
+ RG_NAME(p->id));
+ continue;
+ }
+
+ // every color and depth attachment shares one size and sample count. resolve targets are excluded,
+ // they are single-sample by definition and checked against their own color below.
+ const Att& ref = colorCount ? color[0] : depth;
+ auto check_against_ref = [&](const Att& t) {
+ if (att_w(t) != att_w(ref) || att_h(t) != att_h(ref))
+ push_error(s,
+ "pass \"%.*s\": attachment \"%.*s\" is %u x %u but \"%.*s\" is %u x %u; every "
+ "attachment in a render pass must be the same size.",
+ RG_NAME(p->id), RG_NAME(t.r->id), att_w(t), att_h(t), RG_NAME(ref.r->id), att_w(ref), att_h(ref));
+ if (t.r->sampleCount != ref.r->sampleCount)
+ push_error(s,
+ "pass \"%.*s\": attachment \"%.*s\" has %u sample(s) but \"%.*s\" has %u; every "
+ "attachment in a render pass must have the same sample count.",
+ RG_NAME(p->id), RG_NAME(t.r->id), t.r->sampleCount, RG_NAME(ref.r->id), ref.r->sampleCount);
+ };
+ for (uint32_t i = 0; i < colorCount; ++i)
+ check_against_ref(color[i]);
+ if (hasDepth)
+ check_against_ref(depth);
+
+ if (hasDepth) {
+ const ResourceAccess& a = *depth.a;
+ if (!is_depth_format(depth.r->format))
+ push_error(s,
+ "pass \"%.*s\": resource \"%.*s\" is bound as the depth-stencil attachment but its "
+ "format is not a depth format.",
+ RG_NAME(p->id), RG_NAME(depth.r->id));
+ else {
+ const bool stencilOps = a.stencilLoadOp != WGPULoadOp_Undefined || a.stencilStoreOp != WGPUStoreOp_Undefined;
+ const bool hasStencil = format_has_stencil(depth.r->format);
+ if (stencilOps && !hasStencil)
+ push_error(s,
+ "pass \"%.*s\": depth-stencil attachment \"%.*s\" declares stencil load/store ops "
+ "but its format has no stencil aspect.",
+ RG_NAME(p->id), RG_NAME(depth.r->id));
+ // read-only binds both aspects read-only, so WebGPU asks for no ops at all
+ if (!stencilOps && hasStencil && a.type == AccessType::DepthStencilAttachment)
+ push_error(s,
+ "pass \"%.*s\": depth-stencil attachment \"%.*s\" has a stencil aspect, so WebGPU "
+ "requires stencil load/store ops; pass them to depth_stencil().",
+ RG_NAME(p->id), RG_NAME(depth.r->id));
+ }
+ }
+
+ // a resolve target takes the multisampled colour of the slot it was declared against
+ for (uint32_t i = 0; i < resolveCount; ++i) {
+ const Att& t = resolve[i];
+ const Att* src = nullptr;
+ for (uint32_t k = 0; k < colorCount; ++k)
+ if (color[k].a->colorIndex == t.a->colorIndex) {
+ src = &color[k];
+ break;
+ }
+ if (!src)
+ continue; // resolve() rejects a src that is not a color() in this pass
+ if (src->r->sampleCount <= 1)
+ push_error(s,
+ "pass \"%.*s\": \"%.*s\" resolves \"%.*s\", which has %u sample(s); a resolve source "
+ "must be multisampled.",
+ RG_NAME(p->id), RG_NAME(t.r->id), RG_NAME(src->r->id), src->r->sampleCount);
+ if (t.r->sampleCount != 1)
+ push_error(s,
+ "pass \"%.*s\": resolve target \"%.*s\" has %u sample(s); a resolve target must be "
+ "single-sampled.",
+ RG_NAME(p->id), RG_NAME(t.r->id), t.r->sampleCount);
+ if (t.r->format != src->r->format)
+ push_error(s,
+ "pass \"%.*s\": resolve target \"%.*s\" and its source \"%.*s\" must have the same "
+ "format.",
+ RG_NAME(p->id), RG_NAME(t.r->id), RG_NAME(src->r->id));
+ if (att_w(t) != att_w(*src) || att_h(t) != att_h(*src))
+ push_error(s,
+ "pass \"%.*s\": resolve target \"%.*s\" is %u x %u but its source \"%.*s\" is %u x %u; "
+ "they must be the same size.",
+ RG_NAME(p->id), RG_NAME(t.r->id), att_w(t), att_h(t), RG_NAME(src->r->id), att_w(*src), att_h(*src));
+ }
+ }
+}
+
+// true for the access types whose declared BufferRange ctx.bind() applies
+static bool is_bind_access(AccessType t) { return t == AccessType::Uniform || t == AccessType::StorageRead || t == AccessType::StorageWrite; }
+
+static void validate_access_ranges(RenderGraphStorage& s)
+{
+ for (PassNode* p = s.m_passes; p; p = p->next)
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ const ResourceAccess& a = p->accesses[i];
+ ResourceNode* r = s.byId[a.handle.id];
+ if (!r)
+ continue;
+
+ if (r->kind == ResourceKind::Texture) {
+ const uint32_t mips = r->mipLevelCount;
+ const uint32_t layers = node_layers(r);
+ if (a.baseMip >= mips) {
+ push_error(s, "pass \"%.*s\": resource \"%.*s\" is accessed at mip %u but has %u mip(s).",
+ RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.baseMip, mips);
+ continue;
+ }
+ if (a.baseLayer >= layers) {
+ push_error(s, "pass \"%.*s\": resource \"%.*s\" is accessed at layer %u but has %u layer(s).",
+ RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.baseLayer, layers);
+ continue;
+ }
+ // a count of 0 means all remaining, so only an explicit count can overrun.
+ if (a.mipCount && uint32_t(a.baseMip) + a.mipCount > mips)
+ push_error(s, "pass \"%.*s\": resource \"%.*s\" declares a view of %u mip(s) from mip %u but has %u mip(s).",
+ RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.mipCount, (uint32_t)a.baseMip, mips);
+ if (a.layerCount && uint32_t(a.baseLayer) + a.layerCount > layers)
+ push_error(s, "pass \"%.*s\": resource \"%.*s\" declares a view of %u layer(s) from layer %u but has %u layer(s).",
+ RG_NAME(p->id), RG_NAME(r->id), (uint32_t)a.layerCount, (uint32_t)a.baseLayer, layers);
+ continue;
+ }
+
+ if (!a.bufSize && !a.bufOffset)
+ continue;
+ if (a.bufOffset + a.bufSize > r->bufferSize)
+ push_error(s, "pass \"%.*s\": range on \"%.*s\" covers bytes [%llu, %llu) but the buffer is %llu byte(s).",
+ RG_NAME(p->id), RG_NAME(r->id), (unsigned long long)a.bufOffset, (unsigned long long)(a.bufOffset + a.bufSize),
+ (unsigned long long)r->bufferSize);
+ if (is_bind_access(a.type)) {
+ // a declared offset at or past the end resolves to a zero-byte range
+ if (!a.bufSize)
+ push_error(s, "pass \"%.*s\": range on \"%.*s\" starts at byte %llu but the buffer is %llu byte(s).",
+ RG_NAME(p->id), RG_NAME(r->id), (unsigned long long)a.bufOffset, (unsigned long long)r->bufferSize);
+ // 256 is the spec-default minUniform/StorageBufferOffsetAlignment. the graph checks
+ // the portable default rather than querying device limits.
+ if (a.bufOffset % 256 != 0)
+ push_error(s, "pass \"%.*s\": binding range on \"%.*s\" starts at byte %llu, which is not 256-byte aligned.",
+ RG_NAME(p->id), RG_NAME(r->id), (unsigned long long)a.bufOffset);
+ }
+ }
+}
+
+static WGPUTextureViewDimension default_view_dim(const ResourceNode* r, uint32_t layerCount)
+{
+ return (r->dimension == WGPUTextureDimension_3D) ? WGPUTextureViewDimension_3D
+ : (layerCount > 1) ? WGPUTextureViewDimension_2DArray
+ : WGPUTextureViewDimension_2D;
+}
+
+static Subview** find_subview_list(GraphAllocator* a, WGPUTexture tex)
+{
+ if (!tex)
+ return nullptr;
+ for (TransientResourcePool::Entry* e = a->transient.entries; e; e = e->next)
+ if (e->kind == ResourceKind::Texture && e->tex == tex)
+ return &e->subviews;
+ for (PersistentResourcePool::Entry* e = a->pool.entries; e; e = e->next)
+ for (uint32_t i = 0; i < PersistentResourcePool::kLayers; ++i)
+ if (e->tex[i] == tex)
+ return &e->subviews[i];
+ return nullptr;
+}
+
+
+static ViewKey view_signature_for(const ResourceNode* r, const ResourceAccess& a)
+{
+ uint32_t layers = node_layers(r);
+ uint32_t mipCount = a.mipCount ? a.mipCount : (r->mipLevelCount - a.baseMip);
+ uint32_t layerCount = a.layerCount ? a.layerCount : (layers - a.baseLayer);
+ WGPUTextureViewDimension dim = a.viewDim;
+ if (dim == WGPUTextureViewDimension_Undefined)
+ dim = default_view_dim(r, layerCount);
+ return ViewKey {
+ .format = r->format,
+ .dimension = dim,
+ .aspect = a.viewAspect,
+ .baseMipLevel = a.baseMip,
+ .mipLevelCount = mipCount,
+ .baseArrayLayer = a.baseLayer,
+ .arrayLayerCount = layerCount,
+ };
+}
+
+static bool is_full_view(const ResourceNode* r, const ViewKey& k)
+{
+ uint32_t layers = node_layers(r);
+ WGPUTextureViewDimension full = default_view_dim(r, layers);
+ return k.baseMipLevel == 0 && k.mipLevelCount == r->mipLevelCount && k.baseArrayLayer == 0 && k.arrayLayerCount == layers
+ && k.aspect == WGPUTextureAspect_All && k.format == r->format && k.dimension == full;
+}
+
+static WGPUTextureView resolve_view(RenderGraphStorage& s, ResourceNode* r, const ViewKey& key)
+{
+ if (!r->texture) { // imported
+ Q_ASSERT(key.baseMipLevel == 0 && key.baseArrayLayer == 0 && "subresource selection on an imported texture is ignored (caller owns the view)");
+ Q_ASSERT(key.aspect == WGPUTextureAspect_All && key.format == r->format
+ && "aspect/format reinterpretation on an imported texture is ignored (caller owns the view)");
+ return r->view;
+ }
+ if (is_full_view(r, key))
+ return r->view;
+ if (r->subviews)
+ return subview_for(s.m_allocator->subviews, *r->subviews, r->texture, key);
+
+ if (s.viewScratchN == s.viewScratchCap)
+ {
+ uint32_t newCap = s.viewScratchCap ? s.viewScratchCap * 2 : 8;
+ WGPUTextureView* grown = s.m_allocator->scratch.alloc(newCap);
+ if (s.viewScratchN)
+ std::memcpy(grown, s.viewScratch, (size_t)s.viewScratchN * sizeof(WGPUTextureView));
+ s.viewScratch = grown;
+ s.viewScratchCap = newCap;
+ }
+ WGPUTextureViewDescriptor desc = key.desc();
+ return s.viewScratch[s.viewScratchN++] = wgpuTextureCreateView(r->texture, &desc);
+}
+
+static const ResourceAccess* find_pass_access(const PassNode* pass, ResourceHandle h)
+{
+ for (uint32_t i = 0; i < pass->accessCount; ++i)
+ if (pass->accesses[i].handle.id == h.id)
+ return &pass->accesses[i];
+ return nullptr;
+}
+
+static const ResourceAccess* find_copy_access(PassNode* pass, ResourceHandle h, bool wantDst, const char* who)
+{
+ const ResourceAccess* found = nullptr;
+ AccessType want = wantDst ? AccessType::CopyDst : AccessType::CopySrc;
+ for (uint32_t i = 0; i < pass->accessCount; ++i)
+ {
+ const ResourceAccess& a = pass->accesses[i];
+ if (a.handle.id != h.id || a.type != want)
+ continue;
+ Q_ASSERT(!found && "ctx: two copy declarations pair the same handle+direction in this pass; ambiguous subresource");
+ found = &a;
+ }
+ Q_ASSERT_X(found, "find_copy_access", who);
+ return found;
+}
+
+// finds the bind-type access for h
+static const ResourceAccess* find_bind_access(PassNode* pass, ResourceHandle h)
+{
+ const ResourceAccess* found = nullptr;
+ for (uint32_t i = 0; i < pass->accessCount; ++i)
+ {
+ const ResourceAccess& a = pass->accesses[i];
+ if (a.handle.id != h.id || !is_bind_access(a.type))
+ continue;
+ Q_ASSERT((!found || (found->bufOffset == a.bufOffset && found->bufSize == a.bufSize))
+ && "ctx.bind: two bind declarations give the same buffer different ranges in this pass; ambiguous bind");
+ found = &a;
+ }
+ return found;
+}
+
+static WGPUExtent3D copy_extent_for(RenderGraph* graph, PassNode* pass, ResourceHandle h, bool wantDst)
+{
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ const ResourceAccess* a = find_copy_access(pass, h, wantDst, "copy_extent: no matching copy declared with this handle in this pass");
+ uint32_t depth;
+ if (r->dimension == WGPUTextureDimension_3D)
+ depth = mip_dim(r->resolved.depthOrArrayLayers, a->baseMip); // baseLayer is 0 here, node_layers() reports 1 for 3D so validate_access_ranges rejects any other
+ else
+ depth = a->layerCount ? a->layerCount : (node_layers(r) - a->baseLayer); // 0 == all remaining
+ return WGPUExtent3D { mip_dim(r->resolved.width, a->baseMip), mip_dim(r->resolved.height, a->baseMip), depth };
+}
+
+static bool history_valid_impl(RenderGraph* graph, ResourceHandle h)
+{
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "ctx: failed to find node! Handle is 0 or from another graph");
+ if (!r)
+ return false;
+ Q_ASSERT(r->persistent && "ctx: can only be called on persistent resources");
+ return r->historyValid;
+}
+
+bool PassContext::history_valid_impl(ResourceHandle prev) const
+{
+ return webgpu::rg::history_valid_impl(graph, prev);
+}
+
+WGPUTextureView PassContext::view(TextureHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture && "ctx.view: only textures can create a view");
+ Q_ASSERT(pass);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r)
+ {
+ return {};
+ }
+ Q_ASSERT(find_pass_access(pass, h) && "ctx.view: resource not declared as an access in this pass's setup");
+ if (!r->texture)
+ return r->view;
+
+ const ResourceAccess* rd = nullptr;
+ const ResourceAccess* wr = nullptr;
+ for (uint32_t i = 0; i < pass->accessCount; ++i) {
+ const ResourceAccess& a = pass->accesses[i];
+ if (a.handle.id != h.id)
+ continue;
+ if (!access_is_write(a.type)) {
+ Q_ASSERT(!rd && "ctx.view: two reads of one handle in a pass; ambiguous subresource; use ctx.texture");
+ rd = &a;
+ } else {
+ wr = &a;
+ }
+ }
+ const ResourceAccess* acc = rd ? rd : wr;
+ if (!acc)
+ return r->view;
+ RenderGraphStorage& s = *storage(graph);
+ return resolve_view(s, r, view_signature_for(r, *acc));
+}
+
+
+static bool subres_declared(const PassNode* pass, const ResourceNode* r, ResourceHandle h, uint32_t mip, uint32_t layer)
+{
+ for (uint32_t i = 0; i < pass->accessCount; ++i) {
+ const ResourceAccess& a = pass->accesses[i];
+ if (a.handle.id != h.id)
+ continue;
+ uint32_t mipEnd = a.mipCount ? a.baseMip + a.mipCount : r->mipLevelCount;
+ uint32_t layerEnd = a.layerCount ? a.baseLayer + a.layerCount : node_layers(r);
+ if (mip >= a.baseMip && mip < mipEnd && layer >= a.baseLayer && layer < layerEnd)
+ return true;
+ }
+ return false;
+}
+
+WGPUTextureView PassContext::view(TextureHandle h, uint32_t mip, uint32_t layer) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture && "ctx.view: only textures can create a view");
+ Q_ASSERT(pass);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return {};
+ }
+ Q_ASSERT(find_pass_access(pass, h) && "ctx.view: resource not declared as an access in this pass's setup");
+ if (!r->texture) { // imported: the caller-owned view spans the whole resource
+ Q_ASSERT(mip == 0 && layer == 0 && "subresource selection on an imported texture is ignored (caller owns the view)");
+ return r->view;
+ }
+ Q_ASSERT(mip < r->mipLevelCount && "ctx.view: mip past the texture's mip count");
+ Q_ASSERT(layer < node_layers(r) && "ctx.view: layer past the texture's layer count");
+ Q_ASSERT(subres_declared(pass, r, h, mip, layer) && "ctx.view: mip/layer outside every range this pass declared on the resource");
+ ViewKey key {
+ .format = r->format,
+ .dimension = default_view_dim(r, 1), // single-layer subview -> never the 2DArray arm
+ .aspect = WGPUTextureAspect_All,
+ .baseMipLevel = mip,
+ .mipLevelCount = 1,
+ .baseArrayLayer = layer,
+ .arrayLayerCount = 1,
+ };
+ return resolve_view(*storage(graph), r, key);
+}
+
+WGPUTexture PassContext::texture(TextureHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return {};
+ }
+ Q_ASSERT(find_pass_access(pass, h) && "ctx.texture: resource not declared as an access in this pass's setup");
+ return r->texture;
+}
+
+WGPUBuffer PassContext::buffer(BufferHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Buffer);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return {};
+ }
+ Q_ASSERT(find_pass_access(pass, h) && "ctx.buffer: resource not declared as an access in this pass's setup");
+ Q_ASSERT(r->buffer && "ctx.buffer(): resource declared but never realized (no live writer)");
+ return r->buffer;
+}
+
+WGPUBindGroupEntry PassContext::bind(uint32_t binding, ResourceHandle h) const
+{
+ if (h.kind == ResourceKind::Texture)
+ return webgpu::bind(binding, view(TextureHandle { h }));
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ const ResourceAccess* a = find_bind_access(pass, h);
+ if (a && a->bufSize)
+ return webgpu::bind(binding, buffer(BufferHandle { h }), a->bufOffset, a->bufSize);
+ return webgpu::bind(binding, buffer(BufferHandle { h }), 0, r ? r->bufferSize : 0);
+}
+
+WGPUBindGroupEntry PassContext::bind(uint32_t binding, TextureHandle h, uint32_t mip, uint32_t layer) const
+{
+ return webgpu::bind(binding, view(h, mip, layer));
+}
+
+WGPUExtent3D PassContext::texture_size(TextureHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return {};
+ }
+ return r->resolved;
+}
+
+WGPUExtent3D PassContext::texture_size(TextureHandle h, uint32_t mip) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return {};
+ }
+ Q_ASSERT(mip < (r->mipLevelCount ? r->mipLevelCount : 1) && "ctx.texture_size: mip past the texture's mip count");
+ uint32_t d = r->resolved.depthOrArrayLayers ? r->resolved.depthOrArrayLayers : 1;
+ if (r->dimension == WGPUTextureDimension_3D)
+ d = mip_dim(d, mip); // depth shifts, array layers don't
+ return WGPUExtent3D { mip_dim(r->resolved.width, mip), mip_dim(r->resolved.height, mip), d };
+}
+
+WGPUTextureFormat PassContext::format(TextureHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return WGPUTextureFormat_Undefined;
+ }
+ Q_ASSERT(r->format != WGPUTextureFormat_Undefined && "ctx.format: imported texture without a format; pass it to import_texture");
+ return r->format;
+}
+
+uint32_t PassContext::mip_count(TextureHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return 0;
+ }
+ Q_ASSERT(r->mipLevelCount && "ctx.mip_count: mip count is 0");
+ return r->mipLevelCount;
+}
+
+uint32_t PassContext::sample_count(TextureHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return 0;
+ }
+ Q_ASSERT(r->sampleCount && "ctx.sample_count: sample count is 0");
+ return r->sampleCount;
+}
+
+uint64_t PassContext::buffer_size(BufferHandle h) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Buffer);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ if (!r) {
+ return {};
+ }
+ return r->bufferSize;
+}
+
+PassContext::BufferCopyInfo PassContext::buffer_copy_info(BufferHandle src, BufferHandle dst) const
+{
+ Q_ASSERT(src.id != 0 && dst.id != 0);
+ Q_ASSERT(src.kind == ResourceKind::Buffer && dst.kind == ResourceKind::Buffer && "buffer_copy_info: both handles must be buffers");
+ const ResourceAccess* sa = find_copy_access(pass, src, false, "buffer_copy_info: no copy_buffer() with this handle as src declared in this pass");
+ const ResourceAccess* da = find_copy_access(pass, dst, true, "buffer_copy_info: no copy_buffer() with this handle as dst declared in this pass");
+ ResourceNode* sn = find_node(graph, src);
+ ResourceNode* dn = find_node(graph, dst);
+ Q_ASSERT(sn && dn && "buffer_copy_info: handle is 0 or from another graph");
+ Q_ASSERT(sn->buffer && dn->buffer && "buffer_copy_info: buffer declared but never realized");
+
+ return { sn->buffer, sa->bufOffset, dn->buffer, da->bufOffset, sa->bufSize };
+}
+
+static WGPUTexelCopyTextureInfo copy_texture_info(RenderGraph* graph, PassNode* pass, ResourceHandle h, bool wantDst, WGPUOrigin3D origin, WGPUTextureAspect aspect)
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Texture);
+ ResourceNode* r = find_node(graph, h);
+ Q_ASSERT(r != nullptr && "failed to find node! Handle is 0 or from another graph");
+ Q_ASSERT(r->texture
+ && "copy_*_info: imported texture has no backing WGPUTexture; pass the WGPUTexture to "
+ "import_texture to enable copies, or copy via a render-pass blit instead");
+ const ResourceAccess* a = find_copy_access(pass,
+ h,
+ wantDst,
+ wantDst ? "copy_dst_info: no copy with this handle as dst declared in this pass"
+ : "copy_src_info: no copy with this handle as src declared in this pass");
+ origin.z += a->baseLayer;
+ return WGPUTexelCopyTextureInfo { .texture = r->texture, .mipLevel = a->baseMip, .origin = origin, .aspect = aspect };
+}
+
+WGPUTexelCopyTextureInfo PassContext::copy_src_info(TextureHandle h, WGPUOrigin3D origin, WGPUTextureAspect aspect) const
+{
+ return copy_texture_info(graph, pass, h, false, origin, aspect);
+}
+
+WGPUTexelCopyTextureInfo PassContext::copy_dst_info(TextureHandle h, WGPUOrigin3D origin, WGPUTextureAspect aspect) const
+{
+ return copy_texture_info(graph, pass, h, true, origin, aspect);
+}
+
+WGPUExtent3D PassContext::copy_extent_src(TextureHandle h) const
+{
+ return copy_extent_for(graph, pass, h, false);
+}
+
+WGPUExtent3D PassContext::copy_extent_dst(TextureHandle h) const
+{
+ return copy_extent_for(graph, pass, h, true);
+}
+
+WGPUTexelCopyBufferInfo PassContext::copy_src_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Buffer);
+ find_copy_access(pass, h, /*wantDst=*/false, "copy_src_buffer: no copy with this handle as src declared in this pass");
+ return WGPUTexelCopyBufferInfo { .layout = layout, .buffer = buffer(h) };
+}
+
+WGPUTexelCopyBufferInfo PassContext::copy_dst_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const
+{
+ Q_ASSERT(h.id != 0);
+ Q_ASSERT(h.kind == ResourceKind::Buffer);
+ find_copy_access(pass, h, /*wantDst=*/true, "copy_dst_buffer: no copy with this handle as dst declared in this pass");
+ return WGPUTexelCopyBufferInfo { .layout = layout, .buffer = buffer(h) };
+}
+
+static void release_resources(RenderGraph* rg);
+
+static constexpr bool is_mappable(WGPUBufferUsage usage)
+{
+ return (usage & (WGPUBufferUsage_MapRead | WGPUBufferUsage_MapWrite)) != 0;
+}
+
+// create the GPU resources compile() worked out
+static void realize_graph(RenderGraph* rg, WGPUDevice device)
+{
+ auto t0 = std::chrono::steady_clock::now();
+ RenderGraphStorage& s = *storage(rg);
+ if (s.m_state == RenderGraphState::Failed)
+ return;
+ if (s.m_state != RenderGraphState::Compiled) {
+ push_error(s, "realize() reached a graph that was never compiled. This is an internal error, not an authoring one.");
+ return;
+ }
+
+ // inferred transient attachments get the memoryless usage bit only where the platform supports it
+#ifdef __EMSCRIPTEN__
+ const bool transientOK = rg_web_has_transient_attachment() != 0;
+#else
+ const bool transientOK = wgpuDeviceHasFeature(device, kTransientAttachmentsFeature);
+#endif
+ s.m_allocator->transientFeatureOn = transientOK;
+
+ PersistentResourcePool& pool = s.m_allocator->pool;
+ TransientResourcePool& transient = s.m_allocator->transient;
+
+ auto pool_used = [](const ResourceNode* r) { return r->kind == ResourceKind::Texture ? r->texUsage != 0 : r->bufUsage != 0; };
+
+ // touch + union usage into the entry. curr and prev must be co-used, or the rotation would surface a
+ // never-written slot. compile() already rejects that asymmetry.
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ if (!r->persistent || !pool_used(r))
+ continue;
+ PersistentResourcePool::Entry* e = pool.touch(r->id, r->history ? PersistentResourcePool::kLayers : 1, r->historyHash, r->kind);
+ if (r->kind == ResourceKind::Texture)
+ e->usage |= r->texUsage;
+ else
+ e->bufUsage |= r->bufUsage;
+ }
+
+ // realize + point each layer node at its rotated slot
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ if (!r->persistent || !pool_used(r))
+ continue;
+ PersistentResourcePool::Entry* e = pool.find(r->id); // touched above -> non-null
+ if (!e) {
+ push_error(s,
+ "persistent resource \"%.*s\" is used but has no pool entry from the touch pass. "
+ "This is an internal error, not an authoring one.",
+ RG_NAME(r->id));
+ return;
+ }
+ // preconditions realize_*_entry relies on and cannot report itself: usage only ever grows, and a
+ // recreate never lands twice in one frame.
+ const bool usageGrewOnly = r->kind == ResourceKind::Texture ? (e->usageAtCreate & ~e->usage) == 0 : (e->bufUsageAtCreate & ~e->bufUsage) == 0;
+ const bool wouldRecreate = r->kind == ResourceKind::Texture
+ ? PersistentResourcePool::tex_entry_would_recreate(*e, tex_signature(r), e->usage)
+ : PersistentResourcePool::buf_entry_would_recreate(*e, r->bufferSize, e->bufUsage);
+ if (!usageGrewOnly || (wouldRecreate && e->createdClock == pool.evictClock)) {
+ push_error(s,
+ "persistent resource \"%.*s\": %s. This is an internal error, not an authoring one.",
+ RG_NAME(r->id),
+ !usageGrewOnly ? "its pooled usage shrank since the entry was created"
+ : "its layer nodes disagree on the descriptor, so the pool entry would be recreated twice in one frame");
+ return;
+ }
+ uint32_t sl = pool.slot(*e, r->historyIndex);
+ if (r->kind == ResourceKind::Texture) {
+ pool.realize_texture_entry(e, device, tex_signature(r));
+ r->texture = e->tex[sl];
+ r->view = e->view[sl];
+ } else {
+ pool.realize_buffer_entry(e, device, r->bufferSize);
+ r->buffer = e->buf[sl];
+ }
+ // .prev holds valid history only if the entry survived from a prior frame, was not recreated this
+ // frame, and was used last frame.
+ r->historyValid = r->history && (e->createdClock != pool.evictClock) && (e->prevTouched == pool.evictClock - 1);
+ }
+
+ // aliasing (compile() phase 4): one acquire per slot with the union usage, then point every member at
+ // it. per slot, not per member, keeps the claim count right. m_slotCount is 0 when aliasing is off,
+ // leaving the loops below to realize each transient alone.
+ for (uint32_t i = 0; i < s.m_slotCount; ++i) {
+ PhysicalResource& ph = s.m_slots[i];
+ if (ph.kind == ResourceKind::Texture) {
+ transient.acquire(device, ph.sig, ph.texUsage, ph.identity, ph.texture, ph.view);
+ continue;
+ }
+ if (is_mappable(ph.bufUsage)) {
+ push_error(s,
+ "alias slot %u asks for a mappable transient buffer; transient buffers are never mappable, "
+ "import a caller-owned buffer for readback or staging. This is an internal error, not an "
+ "authoring one.",
+ i);
+ return;
+ }
+ transient.acquire(device, ph.bufferSize, ph.bufUsage, ph.identity, ph.buffer);
+ }
+ for (ResourceNode* r = s.m_resouces; r; r = r->next)
+ if (r->aliasSlot != ResourceNode::kNoSlot) {
+ PhysicalResource& ph = s.m_slots[r->aliasSlot];
+ r->texture = ph.texture;
+ r->view = ph.view;
+ r->buffer = ph.buffer;
+ }
+
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ // graph-owned per-frame TEXTURES not on an alias slot: reuse a pooled texture matching this
+ // descriptor. release_resources() leaves it alone, the pool evicts at end_frame().
+ if (r->is_external() || r->kind != ResourceKind::Texture || !r->texUsage || r->aliasSlot != ResourceNode::kNoSlot)
+ continue;
+ // memoryless hint for an inferred transient attachment. the pool keys on usage, so these never
+ // collide with ordinary render targets.
+ WGPUTextureUsage usage = r->texUsage;
+ if (transientOK && r->transientAttachment)
+ usage |= WGPUTextureUsage_TransientAttachment;
+ transient.acquire(device, tex_signature(r), usage, r->id.name.data, r->texture, r->view);
+ }
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ // graph-owned per-frame BUFFERS not on an alias slot.
+ if (r->is_external() || r->kind != ResourceKind::Buffer || !r->bufUsage || r->aliasSlot != ResourceNode::kNoSlot)
+ continue;
+ if (is_mappable(r->bufUsage)) {
+ push_error(s,
+ "transient buffer \"%.*s\" asks for a mappable usage; transient buffers are never mappable, "
+ "import a caller-owned buffer for readback or staging. This is an internal error, not an "
+ "authoring one.",
+ RG_NAME(r->id));
+ return;
+ }
+ transient.acquire(device, r->bufferSize, r->bufUsage, r->id.name.data, r->buffer);
+ }
+
+ // point each texture node at the subview cache of its realized physical texture, so ctx.view() reuses
+ // subresource views across frames. by handle, and after every acquire, so it cannot dangle.
+ for (ResourceNode* r = s.m_resouces; r; r = r->next)
+ if (r->kind == ResourceKind::Texture && r->texture)
+ r->subviews = find_subview_list(s.m_allocator, r->texture);
+
+ auto t1 = std::chrono::steady_clock::now();
+ s.timing_realize_us = std::chrono::duration(t1 - t0).count();
+}
+
+void RenderGraph::execute(WGPUDevice device, WGPUQueue queue, WGPUCommandEncoder encoder, bool enableProfiling)
+{
+ RenderGraphStorage& s = *storage(this);
+ if (s.m_state != RenderGraphState::Compiled) {
+ Q_ASSERT(s.m_state == RenderGraphState::Failed && "execute(): graph not compiled; compile() missing, or execute() called twice");
+ return;
+ }
+
+ ScopedScratch scratch(s.m_allocator->scratch);
+ s.viewScratchCap = 8;
+ s.viewScratch = scratch.alloc(s.viewScratchCap);
+
+ realize_graph(this, device);
+ if (s.m_state != RenderGraphState::Compiled)
+ return; // realize failed: the pass bodies would encode against unrealized resources
+
+ auto t0 = std::chrono::steady_clock::now();
+
+ // opt-in GPU timing: lazily build the query set and buffers, then grab a free read-back ring slot. with
+ // every slot awaiting its map callback, skip profiling this frame rather than stall.
+ GpuProfiler& prof = s.m_allocator->profiler;
+ bool profiling = enableProfiling;
+ int slotIdx = -1;
+ // one pendingSlot per allocator, so only one profiled graph per frame. run this one unprofiled rather
+ // than clobber an earlier graph's results.
+ if (profiling && prof.pendingSlot != -1) {
+ qDebug("[RenderGraph] profiling skipped for this graph: an earlier graph this frame is already profiled\n");
+ profiling = false;
+ }
+ if (profiling) {
+ prof.init(device);
+ // disable profiling when no free slot is found
+ slotIdx = prof.free_slot();
+ if (slotIdx < 0)
+ profiling = false;
+ }
+ GpuProfiler::Slot* slot = profiling ? &prof.ring[slotIdx] : nullptr;
+ uint32_t pi = 0; // begin/end pair index, advanced only for passes that run, keeps the resolve range contiguous
+
+ // bracket each contiguous run of same-prefix passes in an encoder debug group, so a RenderDoc/PIX
+ // capture shows collapsible regions. push/pop happen between passes, so they are balanced.
+ WGPUStringView openGroup {};
+ for (PassNode* p = s.m_passes; p; p = p->next) {
+ Q_ASSERT(p->kind != PassKind::None && "a pass of kind None is not allowed");
+ WGPUStringView grp = group_prefix(p->id.name);
+ if (!sv_eq(grp, openGroup)) {
+ if (sv_length(openGroup))
+ wgpuCommandEncoderPopDebugGroup(encoder);
+ if (sv_length(grp))
+ wgpuCommandEncoderPushDebugGroup(encoder, grp);
+ openGroup = grp;
+ }
+
+ // an initialize() pass that survived the cull bakes this frame: stamp each target baked, so next
+ // frame's compile() skips it. recorded here, not in compile(), so no frame claims a bake it never ran.
+ if (p->initCount && p->exec_fn)
+ for (uint32_t i = 0; i < p->initCount; ++i)
+ if (ResourceNode* t = find_node(this, p->initTargets[i].target))
+ if (PersistentResourcePool::Entry* e = s.m_allocator->pool.find(t->id)) {
+ e->initHash = p->initTargets[i].hash;
+ e->baked = true;
+ }
+
+ PassContext ctx {};
+ ctx.encoder = encoder;
+ ctx.graph = this;
+ ctx.queue = queue;
+ ctx.device = device;
+ ctx.pass = p;
+ s.viewScratchN = 0; // per-pass: attachment + body ctx.view() views accumulate here, freed after the body
+
+ // time this pass iff profiling is live, the pass records a body, and the set has a free pair.
+ // Transfer passes need RG_TIME_TRANSFER_PASSES, they use off-spec encoder timestamps.
+#ifdef RG_TIME_TRANSFER_PASSES
+ constexpr bool kTimeTransfer = true;
+#else
+ constexpr bool kTimeTransfer = false;
+#endif
+ const bool timeThis = profiling && p->exec_fn && pi < GpuProfiler::kMaxPasses && (kTimeTransfer || p->kind != PassKind::Transfer);
+ WGPUPassTimestampWrites tw { .querySet = prof.querySet, .beginningOfPassWriteIndex = 2 * pi, .endOfPassWriteIndex = 2 * pi + 1 };
+ if (timeThis)
+ slot->names[pi] = p->id.name; // interned view, stable until the read-back lands frames later
+
+ if (p->kind == PassKind::Compute && p->exec_fn) {
+ WGPUComputePassDescriptor cd { .label = p->id.name, .timestampWrites = timeThis ? &tw : nullptr };
+ ctx.compute_pass = wgpuCommandEncoderBeginComputePass(encoder, &cd);
+ p->exec_fn(p->exec_obj, ctx);
+ wgpuComputePassEncoderEnd(ctx.compute_pass);
+ wgpuComputePassEncoderRelease(ctx.compute_pass);
+ } else if (p->kind == PassKind::Graphics) {
+ // no exec_fn guard: a body-less graphics pass still begins and ends, so its clear loadOps run
+ // gather declared attachments from the access list -> WebGPU render pass descriptor
+ WGPURenderPassColorAttachment color[kMaxColorAttachments] {};
+ // color() slots may be sparse, so pre-seed every entry as a valid null attachment. depthSlice
+ // is the one field whose null value is not 0.
+ for (uint32_t i = 0; i < kMaxColorAttachments; ++i)
+ color[i].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
+ uint32_t maxColorSlot = 0; // highest explicit slot seen -> colorAttachmentCount = maxColorSlot + 1
+ bool anyColor = false;
+ WGPURenderPassDepthStencilAttachment depth {};
+ bool hasDepth = false;
+
+ // an attachment view is exactly one subresource, a render target cannot span a mip chain or an
+ // array. attachment accesses carry no ViewRange, so this is the (baseMip, baseLayer) slice.
+ auto attach_view = [&](ResourceNode* r, const ResourceAccess& a) -> WGPUTextureView {
+ Q_ASSERT(a.baseMip < r->mipLevelCount && "attachment baseMip past the texture's mip count");
+ Q_ASSERT((!r->texture || a.baseLayer < node_layers(r)) && "attachment baseLayer past the texture's layer count");
+ return resolve_view(s, r, view_signature_for(r, a));
+ };
+
+ for (uint32_t i = 0; i < p->accessCount; ++i) {
+ const ResourceAccess& a = p->accesses[i];
+ ResourceNode* r = find_node(this, a.handle);
+ if (!r)
+ continue;
+ if (a.type == AccessType::ColorAttachment && a.colorIndex < kMaxColorAttachments) { // color() asserted range; release backstop
+ color[a.colorIndex] = WGPURenderPassColorAttachment {
+ .view = attach_view(r, a),
+ .depthSlice = WGPU_DEPTH_SLICE_UNDEFINED,
+ .resolveTarget = nullptr, // patched below if a resolve() in this pass targets this slot
+ .loadOp = a.loadOp,
+ .storeOp = a.storeOp,
+ .clearValue = WGPUColor { a.clearColor[0], a.clearColor[1], a.clearColor[2], a.clearColor[3] }, // f32 -> WGPUColor(double)
+ };
+ if (!anyColor || a.colorIndex > maxColorSlot)
+ maxColorSlot = a.colorIndex;
+ anyColor = true;
+ } else if (a.type == AccessType::ResolveAttachment && a.colorIndex < kMaxColorAttachments) {
+ // resolve() stamped the color slot it targets onto colorIndex, so patch that slot.
+ color[a.colorIndex].resolveTarget = attach_view(r, a);
+ } else if (a.type == AccessType::DepthStencilAttachment || a.type == AccessType::DepthStencilReadOnly) {
+ depth = WGPURenderPassDepthStencilAttachment {
+ .view = attach_view(r, a),
+ .depthLoadOp = a.loadOp,
+ .depthStoreOp = a.storeOp,
+ .depthClearValue = a.clearDepth,
+ .depthReadOnly = a.type == AccessType::DepthStencilReadOnly,
+ .stencilLoadOp = a.stencilLoadOp,
+ .stencilStoreOp = a.stencilStoreOp,
+ .stencilClearValue = a.stencilClear,
+ .stencilReadOnly = a.type == AccessType::DepthStencilReadOnly,
+ };
+ hasDepth = true;
+ }
+ }
+
+ WGPURenderPassDescriptor rd {
+ .label = p->id.name,
+ .colorAttachmentCount = anyColor ? maxColorSlot + 1 : 0,
+ .colorAttachments = color,
+ .depthStencilAttachment = hasDepth ? &depth : nullptr,
+ .timestampWrites = timeThis ? &tw : nullptr,
+ };
+ ctx.render_pass = wgpuCommandEncoderBeginRenderPass(encoder, &rd);
+ if (p->exec_fn)
+ p->exec_fn(p->exec_obj, ctx);
+ wgpuRenderPassEncoderEnd(ctx.render_pass);
+ wgpuRenderPassEncoderRelease(ctx.render_pass);
+ } else { // Transfer: body records straight onto the encoder. no pass object -> bracket with encoder timestamps.
+ if (p->exec_fn) {
+#ifdef RG_TIME_TRANSFER_PASSES // off-spec: the device must be created with the allow_unsafe_apis toggle
+ if (timeThis)
+ wgpuCommandEncoderWriteTimestamp(encoder, prof.querySet, 2 * pi);
+#endif
+ p->exec_fn(p->exec_obj, ctx);
+#ifdef RG_TIME_TRANSFER_PASSES
+ if (timeThis)
+ wgpuCommandEncoderWriteTimestamp(encoder, prof.querySet, 2 * pi + 1);
+#endif
+ }
+ }
+
+ if (timeThis)
+ ++pi;
+
+ // free the pass-scoped subresource views, attachments and ctx.view() alike
+ for (uint32_t i = 0; i < s.viewScratchN; ++i)
+ wgpuTextureViewRelease(s.viewScratch[i]);
+ }
+ if (sv_length(openGroup))
+ wgpuCommandEncoderPopDebugGroup(encoder); // close the last open group
+
+ // resolve the queries we wrote into the staging buffer, then stage them into the chosen ring slot. the
+ // copy rides this frame's submit, which collect_gpu_timings() maps once queued.
+ if (profiling && pi > 0) {
+ wgpuCommandEncoderResolveQuerySet(encoder, prof.querySet, 0, 2 * pi, prof.resolveBuf, 0);
+ wgpuCommandEncoderCopyBufferToBuffer(encoder, prof.resolveBuf, 0, slot->buf, 0, uint64_t(pi) * 2 * sizeof(uint64_t));
+ slot->count = pi;
+ prof.pendingSlot = slotIdx;
+ }
+
+ s.m_state = RenderGraphState::Finished;
+ release_resources(this);
+ auto t1 = std::chrono::steady_clock::now();
+ s.timing_execute_us = std::chrono::duration(t1 - t0).count();
+}
+
+// kick the async read-back of the slot execute() just filled. after the queue submit, the copy must be
+// queued first. the callback lands a couple of frames on, during an instance event pump.
+void RenderGraph::collect_gpu_timings()
+{
+ GpuProfiler& prof = storage(this)->m_allocator->profiler;
+ if (prof.pendingSlot < 0)
+ return;
+ GpuProfiler::Slot* slot = &prof.ring[prof.pendingSlot];
+ prof.pendingSlot = -1;
+ slot->pending = true;
+
+ WGPUBufferMapCallbackInfo cb {};
+ cb.mode = WGPUCallbackMode_AllowSpontaneous;
+ cb.callback = [](WGPUMapAsyncStatus status, WGPUStringView, void* u1, void* u2) {
+ auto* slot = static_cast(u1);
+ auto* prof = static_cast(u2);
+ if (status == WGPUMapAsyncStatus_Success) {
+ const auto* t = static_cast(wgpuBufferGetConstMappedRange(slot->buf, 0, slot->count * 2 * sizeof(uint64_t)));
+ if (t) {
+ prof->resultCount = slot->count;
+ for (uint32_t i = 0; i < slot->count; ++i) {
+ const uint64_t b = t[2 * i], e = t[2 * i + 1];
+ prof->resultUs[i] = (e > b) ? float((e - b) / 1000.0) : 0.0f; // ns -> us; clamp reorders
+ prof->resultNames[i] = slot->names[i]; // interned, so it outlives the slot's reuse
+ }
+ ++prof->resultId; // signal the history sampler that a fresh read-back landed
+ }
+ wgpuBufferUnmap(slot->buf);
+ }
+ slot->pending = false;
+ };
+ cb.userdata1 = slot;
+ cb.userdata2 = &prof;
+ wgpuBufferMapAsync(slot->buf, WGPUMapMode_Read, 0, slot->count * 2 * sizeof(uint64_t), cb);
+}
+
+ErrorMessage* RenderGraph::get_errors() const
+{
+ RenderGraphStorage& s = *storage(const_cast(this));
+ Q_ASSERT(s.frameId == s.m_allocator->frameId && "get_errors(): graph outlived its frame, read errors before the next begin_frame()");
+ return s.m_errors;
+}
+
+static void release_resources(RenderGraph* rg)
+{
+ RenderGraphStorage& s = *storage(rg);
+ Q_ASSERT(s.m_state == RenderGraphState::Finished);
+ for (ResourceNode* r = s.m_resouces; r; r = r->next) {
+ if (r->is_external())
+ continue;
+ r->texture = nullptr;
+ r->view = nullptr;
+ r->buffer = nullptr;
+ }
+
+ for (uint32_t i = 0; i < s.m_slotCount; ++i) {
+ s.m_slots[i].texture = nullptr;
+ s.m_slots[i].view = nullptr;
+ s.m_slots[i].buffer = nullptr;
+ }
+
+ s.m_allocator->transient.release_claims();
+}
+
+// which resource kind an access is legal
+static constexpr bool access_allows_kind(AccessType t, ResourceKind k)
+{
+ switch (t) {
+ case AccessType::ColorAttachment:
+ case AccessType::DepthStencilAttachment:
+ case AccessType::DepthStencilReadOnly:
+ case AccessType::ResolveAttachment:
+ case AccessType::Sampled:
+ return k == ResourceKind::Texture;
+ case AccessType::Uniform:
+ case AccessType::Vertex:
+ case AccessType::Index:
+ case AccessType::Indirect:
+ return k == ResourceKind::Buffer;
+ default: // StorageRead/Write, CopySrc/Dst: both kinds are legal
+ return true;
+ }
+}
+
+static void use(PassNode* pass, RenderGraph* graph,
+ ResourceHandle handle,
+ AccessType type,
+ WGPULoadOp load = WGPULoadOp_Undefined,
+ WGPUStoreOp store = WGPUStoreOp_Undefined,
+ WGPUColor clear = {},
+ float clearDepth = {},
+ uint32_t baseMip = 0,
+ uint32_t baseLayer = 0,
+ ViewRange range = {},
+ WGPULoadOp stencilLoad = WGPULoadOp_Undefined,
+ WGPUStoreOp stencilStore = WGPUStoreOp_Undefined,
+ uint32_t stencilClear = 0)
+{
+ Q_ASSERT(handle.id != 0);
+ Q_ASSERT(handle.id < storage(graph)->next_resource_id && "stale or foreign ResourceHandle; not created by this frame's graph");
+ Q_ASSERT(handle.generation == storage(graph)->generation && "stale/foreign ResourceHandle: not created by this graph");
+ Q_ASSERT(access_allows_kind(type, handle.kind) && "handle kind disagrees with this access; a handle was converted across kinds");
+
+ // WebGPU permits several writable-storage uses in one scope. the graph is stricter, it cannot
+ // synchronize two writes inside a pass. Transfer passes are exempt, each copy is its own usage scope.
+ const bool w = access_is_write(type);
+ if (pass->kind != PassKind::Transfer)
+ for (uint32_t i = 0; i < pass->accessCount; ++i) {
+ if (pass->accesses[i].handle.id != handle.id)
+ continue;
+ if (in_pass_accesses_conflict(type,
+ baseMip,
+ range.mipCount,
+ baseLayer,
+ range.layerCount,
+ range.aspect,
+ pass->accesses[i].type,
+ pass->accesses[i].baseMip,
+ pass->accesses[i].mipCount,
+ pass->accesses[i].baseLayer,
+ pass->accesses[i].layerCount,
+ pass->accesses[i].viewAspect)) {
+ qDebug("[RenderGraph] error: pass \"%.*s\" uses resource id %u %s in one pass; a "
+ "written resource must be its only use in the pass.\n",
+ RG_NAME(pass->id),
+ handle.id,
+ (w && access_is_write(pass->accesses[i].type)) ? "as more than one write (unsynchronized)" : "as both written and read");
+ Q_ASSERT(false && "RenderGraph: illegal in-pass resource usage (read+write or double write in one pass)");
+ }
+ }
+
+ if (pass->accessCount == pass->accessCap) {
+ GraphAllocator* A = storage(graph)->m_allocator;
+ uint32_t oldCap = pass->accessCap;
+ uint32_t newCap = oldCap ? oldCap * 2 : 4;
+ ResourceAccess* buf = nullptr;
+ if (oldCap)
+ buf = static_cast(
+ A->front.extend_tail(pass->accesses, (size_t)oldCap * sizeof(ResourceAccess), (size_t)(newCap - oldCap) * sizeof(ResourceAccess)));
+ if (!buf) {
+ buf = A->alloc(newCap);
+ if (pass->accessCount)
+ std::memcpy(buf, pass->accesses, (size_t)pass->accessCount * sizeof(ResourceAccess));
+ }
+ pass->accesses = buf;
+ pass->accessCap = newCap;
+ }
+
+ pass->accesses[pass->accessCount++] = ResourceAccess {
+ .handle = handle,
+ .clearColor = { float(clear.r), float(clear.g), float(clear.b), float(clear.a) },
+ .clearDepth = clearDepth,
+ .loadOp = load,
+ .storeOp = store,
+ .stencilLoadOp = stencilLoad,
+ .stencilStoreOp = stencilStore,
+ .viewDim = range.dim,
+ .viewAspect = range.aspect,
+ .baseLayer = uint16_t(baseLayer),
+ .layerCount = uint16_t(range.layerCount),
+ .type = type,
+ .stencilClear = uint8_t(stencilClear),
+ .baseMip = uint8_t(baseMip),
+ .mipCount = uint8_t(range.mipCount),
+ };
+}
+
+void PassBuilder::color(TextureHandle handle, uint32_t attachmentIndex, ColorAttachment a)
+{
+ const WGPULoadOp load = a.load;
+ const WGPUStoreOp store = a.store;
+ const WGPUColor clear = a.clear;
+ const uint32_t baseMip = a.sub.mip;
+ const uint32_t baseLayer = a.sub.layer;
+
+ Q_ASSERT(handle.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Graphics && "color attachment is only legal for graphics passes.");
+
+ if (attachmentIndex >= kMaxColorAttachments) {
+ qDebug("[RenderGraph] error: pass \"%.*s\" declares color attachment slot %u >= %u "
+ "(WebGPU maxColorAttachments); attachment on resource id %u dropped at execute.\n",
+ RG_NAME(m_pass->id),
+ attachmentIndex,
+ kMaxColorAttachments,
+ handle.id);
+ Q_ASSERT(false && "RenderGraph: color() attachmentIndex out of range");
+ return;
+ }
+ // a slot binds exactly one resource per pass.
+ for (uint32_t i = 0; i < m_pass->accessCount; ++i)
+ if (m_pass->accesses[i].type == AccessType::ColorAttachment && m_pass->accesses[i].colorIndex == attachmentIndex) {
+ qDebug("[RenderGraph] error: pass \"%.*s\" declares color attachment slot %u twice; "
+ "each slot binds one resource.\n",
+ RG_NAME(m_pass->id),
+ attachmentIndex);
+ Q_ASSERT(false && "RenderGraph: duplicate color() attachmentIndex in one pass");
+ return;
+ }
+
+ use(m_pass, m_graph, handle, AccessType::ColorAttachment, load, store, clear, {}, baseMip, baseLayer);
+ m_pass->accesses[m_pass->accessCount - 1].colorIndex = uint8_t(attachmentIndex);
+}
+
+void PassBuilder::resolve(TextureHandle src, TextureHandle target, Subresource sub)
+{
+ const uint32_t baseMip = sub.mip;
+ const uint32_t baseLayer = sub.layer;
+
+ Q_ASSERT(src.id != 0);
+ Q_ASSERT(target.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Graphics && "resolve target is only legal for graphics passes.");
+
+ uint8_t srcSlot = 0;
+ bool foundSrc = false;
+ for (uint32_t i = 0; i < m_pass->accessCount; ++i)
+ if (m_pass->accesses[i].type == AccessType::ColorAttachment && m_pass->accesses[i].handle.id == src.id) {
+ srcSlot = m_pass->accesses[i].colorIndex;
+ foundSrc = true;
+ break;
+ }
+ if (!foundSrc) {
+ qDebug("[RenderGraph] error: pass \"%.*s\" calls resolve() whose src (resource id %u) is not "
+ "a color attachment declared in this pass; declare its color() first.\n",
+ RG_NAME(m_pass->id),
+ src.id);
+ Q_ASSERT(false && "RenderGraph: resolve() src is not a color attachment in this pass");
+ return;
+ }
+ // one resolve target per color slot.
+ for (uint32_t i = 0; i < m_pass->accessCount; ++i)
+ if (m_pass->accesses[i].type == AccessType::ResolveAttachment && m_pass->accesses[i].colorIndex == srcSlot) {
+ qDebug("[RenderGraph] error: pass \"%.*s\" declares two resolve targets for color slot %u; "
+ "one resolve target per color attachment.\n",
+ RG_NAME(m_pass->id),
+ srcSlot);
+ Q_ASSERT(false && "RenderGraph: two resolve() targets for one color slot in a pass");
+ return;
+ }
+
+ use(m_pass, m_graph, target, AccessType::ResolveAttachment, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer);
+ m_pass->accesses[m_pass->accessCount - 1].colorIndex = srcSlot;
+}
+
+static void assert_single_depth(PassNode* pass)
+{
+ for (uint32_t i = 0; i < pass->accessCount; ++i) {
+ AccessType t = pass->accesses[i].type;
+ if (t == AccessType::DepthStencilAttachment || t == AccessType::DepthStencilReadOnly) {
+ qDebug("[RenderGraph] error: pass \"%.*s\" declares a second depth-stencil attachment; "
+ "a render pass has one depth-stencil slot, so only the last would take effect.\n",
+ RG_NAME(pass->id));
+ Q_ASSERT(false && "RenderGraph: two depth-stencil attachments in one pass");
+ }
+ }
+}
+
+void PassBuilder::depth_stencil(TextureHandle handle, DepthStencilAttachment a)
+{
+ const WGPULoadOp load = a.load;
+ const WGPUStoreOp store = a.store;
+ const float clearDepth = a.clearDepth;
+ const uint32_t baseMip = a.sub.mip;
+ const uint32_t baseLayer = a.sub.layer;
+ const WGPULoadOp stencilLoad = a.stencilLoad;
+ const WGPUStoreOp stencilStore = a.stencilStore;
+ const uint32_t stencilClear = a.stencilClear;
+
+ Q_ASSERT(handle.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Graphics && "depth-stencil attachment is only legal for graphics passes.");
+ assert_single_depth(m_pass);
+ use(m_pass, m_graph, handle, AccessType::DepthStencilAttachment, load, store, {}, clearDepth, baseMip, baseLayer, {}, stencilLoad, stencilStore, stencilClear);
+}
+
+void PassBuilder::depth_stencil_read_only(TextureHandle handle, Subresource sub)
+{
+ const uint32_t baseMip = sub.mip;
+ const uint32_t baseLayer = sub.layer;
+
+ Q_ASSERT(handle.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Graphics && "depth-stencil attachment is only legal for graphics passes.");
+ assert_single_depth(m_pass);
+ use(m_pass, m_graph, handle, AccessType::DepthStencilReadOnly, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer);
+}
+
+void PassBuilder::sampled(TextureHandle handle, ViewRange range)
+{
+ const uint32_t baseMip = range.baseMip;
+ const uint32_t baseLayer = range.baseLayer;
+
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::Sampled, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer, range);
+}
+
+void PassBuilder::storage_read(TextureHandle handle, ViewRange range)
+{
+ const uint32_t baseMip = range.baseMip;
+ const uint32_t baseLayer = range.baseLayer;
+
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::StorageRead, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer, range);
+}
+
+void PassBuilder::storage_write(TextureHandle handle, ViewRange range)
+{
+ const uint32_t baseMip = range.baseMip;
+ const uint32_t baseLayer = range.baseLayer;
+
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::StorageWrite, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, baseMip, baseLayer, range);
+}
+
+void PassBuilder::storage_read_write(TextureHandle handle, ViewRange range)
+{
+ Q_ASSERT(handle.id != 0);
+ storage_read(handle, range);
+ storage_write(handle, range);
+}
+
+// records a declared bind range on the access use() just appended. ONLY USE after calling use()
+static void set_bind_range(RenderGraph* graph, PassNode* pass, BufferHandle handle, BufferRange range)
+{
+ if (!range.offset && !range.size)
+ return;
+ ResourceNode* n = find_node(graph, handle);
+ Q_ASSERT(n && "bind range: handle is 0 or from another graph");
+ const uint64_t sz = range.size ? range.size : (n->bufferSize > range.offset ? n->bufferSize - range.offset : 0);
+ pass->accesses[pass->accessCount - 1].bufOffset = range.offset;
+ pass->accesses[pass->accessCount - 1].bufSize = sz;
+}
+
+void PassBuilder::storage_read(BufferHandle handle, BufferRange range)
+{
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::StorageRead);
+ set_bind_range(m_graph, m_pass, handle, range);
+}
+
+void PassBuilder::storage_write(BufferHandle handle, BufferRange range)
+{
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::StorageWrite);
+ set_bind_range(m_graph, m_pass, handle, range);
+}
+
+void PassBuilder::storage_read_write(BufferHandle handle, BufferRange range)
+{
+ Q_ASSERT(handle.id != 0);
+ storage_read(handle, range);
+ storage_write(handle, range);
+}
+
+void PassBuilder::uniform(BufferHandle handle, BufferRange range)
+{
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::Uniform);
+ set_bind_range(m_graph, m_pass, handle, range);
+}
+
+void PassBuilder::host_write(BufferHandle handle)
+{
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::CopyDst);
+}
+
+void PassBuilder::copy_texture(TextureHandle src, TextureHandle dst, Subresource srcSub, Subresource dstSub)
+{
+ const uint32_t srcMip = srcSub.mip, srcLayer = srcSub.layer;
+ const uint32_t dstMip = dstSub.mip, dstLayer = dstSub.layer;
+
+ Q_ASSERT(src.id != 0 && dst.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_texture() is only legal in Transfer passes");
+ Q_ASSERT(!(src.id == dst.id && srcMip == dstMip && srcLayer == dstLayer) && "copy_texture: src and dst are the same subresource");
+ use(m_pass, m_graph, src, AccessType::CopySrc, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, srcMip, srcLayer);
+ use(m_pass, m_graph, dst, AccessType::CopyDst, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, dstMip, dstLayer);
+}
+
+void PassBuilder::copy_texture_to_buffer(TextureHandle src, BufferHandle dst, Subresource srcSub)
+{
+ const uint32_t srcMip = srcSub.mip, srcLayer = srcSub.layer;
+
+ Q_ASSERT(src.id != 0 && dst.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_texture_to_buffer() is only legal in Transfer passes");
+ use(m_pass, m_graph, src, AccessType::CopySrc, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, srcMip, srcLayer);
+ use(m_pass, m_graph, dst, AccessType::CopyDst);
+}
+
+void PassBuilder::copy_buffer_to_texture(BufferHandle src, TextureHandle dst, Subresource dstSub)
+{
+ const uint32_t dstMip = dstSub.mip, dstLayer = dstSub.layer;
+
+ Q_ASSERT(src.id != 0 && dst.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_buffer_to_texture() is only legal in Transfer passes");
+ use(m_pass, m_graph, src, AccessType::CopySrc);
+ use(m_pass, m_graph, dst, AccessType::CopyDst, WGPULoadOp_Undefined, WGPUStoreOp_Undefined, {}, {}, dstMip, dstLayer);
+}
+
+void PassBuilder::copy_buffer(BufferHandle src, BufferHandle dst, uint64_t srcOffset, uint64_t dstOffset, uint64_t size)
+{
+ Q_ASSERT(src.id != 0 && dst.id != 0);
+ Q_ASSERT(m_pass->kind == PassKind::Transfer && "copy_buffer() is only legal in Transfer passes");
+ Q_ASSERT(src.id != dst.id && "copy_buffer: WebGPU forbids a same-buffer copyBufferToBuffer");
+ Q_ASSERT((srcOffset % 4 == 0 && dstOffset % 4 == 0 && size % 4 == 0) && "copy_buffer: offsets and size must be multiples of 4 (WebGPU)");
+ ResourceNode* sn = find_node(m_graph, src);
+ ResourceNode* dn = find_node(m_graph, dst);
+ Q_ASSERT(sn && dn && "copy_buffer: handle is 0 or from another graph");
+
+ const uint64_t n = size ? size : (sn->bufferSize > srcOffset ? sn->bufferSize - srcOffset : 0);
+ use(m_pass, m_graph, src, AccessType::CopySrc);
+ m_pass->accesses[m_pass->accessCount - 1].bufOffset = srcOffset;
+ m_pass->accesses[m_pass->accessCount - 1].bufSize = n;
+ use(m_pass, m_graph, dst, AccessType::CopyDst);
+ m_pass->accesses[m_pass->accessCount - 1].bufOffset = dstOffset;
+ m_pass->accesses[m_pass->accessCount - 1].bufSize = n;
+ // full define for aliasing only when this copy provably overwrites every dst byte
+ m_pass->accesses[m_pass->accessCount - 1].bufFullDefine = (dstOffset == 0 && n == dn->bufferSize);
+}
+
+void PassBuilder::vertex_buffer(BufferHandle handle)
+{
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::Vertex);
+}
+
+void PassBuilder::index_buffer(BufferHandle handle)
+{
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::Index);
+}
+
+void PassBuilder::indirect_buffer(BufferHandle handle)
+{
+ Q_ASSERT(handle.id != 0);
+ use(m_pass, m_graph, handle, AccessType::Indirect);
+}
+
+void PassBuilder::initialize(ResourceHandle target, uint64_t hash)
+{
+ Q_ASSERT(target.id != 0);
+ Q_ASSERT(target.id < storage(m_graph)->next_resource_id && "stale or foreign ResourceHandle; not created by this frame's graph");
+ if (target.id == 0)
+ return;
+ for (uint32_t i = 0; i < m_pass->initCount; ++i)
+ Q_ASSERT(m_pass->initTargets[i].target.id != target.id && "initialize() called twice for the same target in one pass");
+ if (m_pass->initCount >= PassNode::kMaxInitTargets) {
+ qDebug("[RenderGraph] error: pass \"%.*s\" declares more than %u initialize() targets. target on resource id %u dropped.",
+ RG_NAME(m_pass->id),
+ PassNode::kMaxInitTargets,
+ target.id);
+ Q_ASSERT(false && "RenderGraph: more than kMaxInitTargets initialize() targets in one pass");
+ return;
+ }
+
+ if (!m_pass->initTargets) {
+ GraphAllocator* A = storage(m_graph)->m_allocator;
+ m_pass->initTargets = A->alloc(PassNode::kMaxInitTargets);
+ }
+ m_pass->initTargets[m_pass->initCount++] = { target, hash };
+}
+
+void PassBuilder::force_keep()
+{
+ m_pass->forceKeep = true;
+}
+
+} // namespace webgpu::rg
diff --git a/webgpu/base/RenderGraph.h b/webgpu/base/RenderGraph.h
new file mode 100644
index 00000000..4ba76031
--- /dev/null
+++ b/webgpu/base/RenderGraph.h
@@ -0,0 +1,401 @@
+/*****************************************************************************
+ * Copyright (C) 2026 Matthias Huerbe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+
+// immediate-mode render graph for WebGPU. does pass ordering and resource lifetime
+
+#pragma once
+
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace webgpu::rg {
+
+// picks the encoder the pass records into, and which accesses are legal
+enum struct PassKind : uint8_t {
+ None = 0,
+ Graphics, // PassContext::render_pass, attachments already bound
+ Compute, // PassContext::compute_pass
+ Transfer // no pass object, copies go on PassContext::encoder
+};
+
+enum struct SizeKind : uint8_t {
+ Absolute,
+ Relative // scaled relative to relativeTo
+};
+
+enum struct ResourceKind : uint8_t {
+ Texture,
+ Buffer,
+};
+
+// handle for one a render graph resource for this frame
+struct ResourceHandle {
+ uint32_t id {};
+ ResourceKind kind {};
+ uint64_t generation {};
+
+ constexpr explicit operator bool() const { return id != 0; }
+ constexpr bool operator==(const ResourceHandle&) const = default;
+};
+
+struct TextureHandle : ResourceHandle {};
+struct BufferHandle : ResourceHandle {};
+
+// ping-pong pair, rotated each frame. this frame's curr is next frame's prev.
+template
+struct History {
+ H curr; // written this frame
+ H prev; // last frame's curr
+};
+using HistoryTexture = History;
+using HistoryBuffer = History;
+
+namespace Internal {
+struct ResourceNode;
+struct PassNode;
+struct GraphPair;
+struct PassContextAccess;
+}
+struct GraphAllocator;
+struct RenderGraph;
+
+struct ErrorMessage {
+ WGPUStringView message;
+ ErrorMessage* next {};
+};
+
+// resolves handles to live GPU objects during execute()
+struct PassContext {
+ WGPUCommandEncoder encoder {};
+ WGPURenderPassEncoder render_pass {}; // Graphics passes, else null
+ WGPUComputePassEncoder compute_pass {}; // Compute passes, else null
+ WGPUQueue queue {};
+ WGPUDevice device {};
+
+ // shape comes from this pass's access declaration, ViewRange included
+ WGPUTextureView view(TextureHandle h) const;
+ WGPUTextureView view(TextureHandle h, uint32_t mip, uint32_t layer = 0) const;
+
+ // buffers bind at the BufferRange declared in this pass, or whole at offset 0 when none was
+ WGPUBindGroupEntry bind(uint32_t binding, ResourceHandle h) const;
+ WGPUBindGroupEntry bind(uint32_t binding, TextureHandle h, uint32_t mip, uint32_t layer = 0) const;
+
+ // all assert the handle has a declared access in this pass
+ WGPUTexture texture(TextureHandle h) const;
+ WGPUBuffer buffer(BufferHandle h) const;
+ WGPUExtent3D texture_size(TextureHandle h) const;
+ WGPUExtent3D texture_size(TextureHandle h, uint32_t mip) const;
+ uint64_t buffer_size(BufferHandle h) const;
+ WGPUTextureFormat format(TextureHandle h) const;
+ uint32_t mip_count(TextureHandle h) const;
+ uint32_t sample_count(TextureHandle h) const;
+
+ // false on the frame .prev was (re)created and cleared to 0
+ template
+ bool history_valid(const History& history) const
+ {
+ return history_valid_impl(history.prev);
+ }
+
+ // copy descriptors for a copy declared in this pass, declared mip/layer applied
+ WGPUTexelCopyTextureInfo copy_src_info(TextureHandle h, WGPUOrigin3D origin = {}, WGPUTextureAspect aspect = WGPUTextureAspect_All) const;
+ WGPUTexelCopyTextureInfo copy_dst_info(TextureHandle h, WGPUOrigin3D origin = {}, WGPUTextureAspect aspect = WGPUTextureAspect_All) const;
+ // the declared subresource at its mip level, never the whole array
+ WGPUExtent3D copy_extent_src(TextureHandle h) const;
+ WGPUExtent3D copy_extent_dst(TextureHandle h) const;
+ // buffer side of a texture<->buffer copy. layout is the caller's data layout.
+ WGPUTexelCopyBufferInfo copy_src_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const;
+ WGPUTexelCopyBufferInfo copy_dst_buffer(BufferHandle h, WGPUTexelCopyBufferLayout layout) const;
+
+ // size is already expanded, never 0
+ struct BufferCopyInfo {
+ WGPUBuffer src {};
+ uint64_t srcOffset {};
+ WGPUBuffer dst {};
+ uint64_t dstOffset {};
+ uint64_t size {};
+ };
+ BufferCopyInfo buffer_copy_info(BufferHandle src, BufferHandle dst) const;
+
+private:
+ RenderGraph* graph {};
+ Internal::PassNode* pass {};
+
+ bool history_valid_impl(ResourceHandle prev) const;
+
+ PassContext() = default;
+ PassContext(const PassContext&) = delete;
+ PassContext& operator=(const PassContext&) = delete;
+ friend struct RenderGraph;
+ friend struct Internal::PassContextAccess;
+};
+
+// exactly one mip level and one array layer
+struct Subresource {
+ uint32_t mip = 0;
+ uint32_t layer = 0;
+};
+
+// view shape for a sampled/storage access. the default is one mip, one layer, all aspects, dimension
+// inferred.
+struct ViewRange {
+ uint32_t baseMip = 0; // first mip level the view sees
+ uint32_t mipCount = 1; // from baseMip. 0 = all remaining.
+ uint32_t baseLayer = 0; // first array layer the view sees
+ uint32_t layerCount = 1; // from baseLayer. 0 = all remaining.
+ // Undefined infers 3D for a 3D texture, else 2DArray when the view covers more than one layer, else 2D
+ WGPUTextureViewDimension dim = WGPUTextureViewDimension_Undefined;
+ WGPUTextureAspect aspect = WGPUTextureAspect_All; // picks one plane of a depth-stencil texture
+
+ // same shape, rebased
+ constexpr ViewRange at(uint32_t mip, uint32_t layer = 0) const
+ {
+ ViewRange r = *this;
+ r.baseMip = mip;
+ r.baseLayer = layer;
+ return r;
+ }
+};
+
+// cube view, exactly 6 layers. sampling only, WebGPU storage textures cannot be cube.
+constexpr ViewRange cube(WGPUTextureAspect aspect = WGPUTextureAspect_All, uint32_t mipCount = 1)
+{
+ return ViewRange { .mipCount = mipCount, .layerCount = 6, .dim = WGPUTextureViewDimension_Cube, .aspect = aspect };
+}
+
+// cube array view, 6*cubes layers
+constexpr ViewRange cube_array(uint32_t cubes, WGPUTextureAspect aspect = WGPUTextureAspect_All, uint32_t mipCount = 1)
+{
+ return ViewRange { .mipCount = mipCount, .layerCount = 6 * cubes, .dim = WGPUTextureViewDimension_CubeArray, .aspect = aspect };
+}
+
+// all mips and layers from baseMip/baseLayer, dimension inferred
+constexpr ViewRange whole(WGPUTextureAspect aspect = WGPUTextureAspect_All)
+{
+ return ViewRange { .mipCount = 0, .layerCount = 0, .aspect = aspect };
+}
+
+// byte range of a buffer binding. the default binds the whole buffer
+struct BufferRange {
+ uint64_t offset = 0; // must be a multiple of 256 according to the spec
+ uint64_t size = 0; // from offset. 0 = all remaining.
+};
+
+// the 256-byte row alignment WebGPU requires for buffer<->texture copies
+inline uint32_t aligned_bytes_per_row(uint32_t widthTexels, uint32_t texelBlockBytes)
+{
+ constexpr uint32_t align = 256u;
+ uint32_t raw = widthTexels * texelBlockBytes;
+ return (raw + (align - 1u)) & ~(align - 1u);
+}
+
+// how a color attachment is bound. the default is clear to black, store, mip 0, layer 0.
+struct ColorAttachment {
+ WGPULoadOp load = WGPULoadOp_Clear; // Load keeps the previous contents, Clear overwrites with clear
+ WGPUStoreOp store = WGPUStoreOp_Store; // Discard when nothing reads the result afterwards
+ WGPUColor clear = { 0, 0, 0, 1 }; // used only with WGPULoadOp_Clear
+ Subresource sub {}; // which mip and layer to render into
+};
+
+// left Undefined, the graph fills them in from the format.
+struct DepthStencilAttachment {
+ WGPULoadOp load = WGPULoadOp_Clear; // depth plane. Load keeps last frame's depth
+ WGPUStoreOp store = WGPUStoreOp_Store; // Discard when no later pass samples the depth
+ float clearDepth = 1.0f; // used only with WGPULoadOp_Clear. 1.0 is the far plane
+ Subresource sub {}; // which mip and layer to render into
+ WGPULoadOp stencilLoad = WGPULoadOp_Undefined; // Undefined derives from the format, set only to override
+ WGPUStoreOp stencilStore = WGPUStoreOp_Undefined; // Undefined derives from the format, set only to override
+ uint32_t stencilClear = 0; // used only with a stencil clear
+};
+
+// declares one pass's resource accesses. one call per use.
+struct PassBuilder {
+ // attachmentIndex is the fragment shader @location. slots may be sparse.
+ void color(TextureHandle handle, uint32_t attachmentIndex, ColorAttachment attachment = {});
+ void depth_stencil(TextureHandle handle, DepthStencilAttachment attachment = {});
+ // test only, no write
+ void depth_stencil_read_only(TextureHandle handle, Subresource sub = {});
+ // MSAA resolve of src into target
+ void resolve(TextureHandle src, TextureHandle target, Subresource sub = {});
+ void sampled(TextureHandle handle, ViewRange range = {});
+ void storage_read(TextureHandle handle, ViewRange range = {});
+ void storage_write(TextureHandle handle, ViewRange range = {});
+ void storage_read_write(TextureHandle handle, ViewRange range = {});
+ // storage buffer. range picks the bytes ctx.bind() binds; one range per buffer per pass.
+ void storage_read(BufferHandle handle, BufferRange range = {});
+ void storage_write(BufferHandle handle, BufferRange range = {});
+ void storage_read_write(BufferHandle handle, BufferRange range = {});
+ void uniform(BufferHandle handle, BufferRange range = {});
+ void host_write(BufferHandle handle);
+ void copy_texture(TextureHandle src, TextureHandle dst, Subresource srcSub = {}, Subresource dstSub = {});
+
+ void copy_texture_to_buffer(TextureHandle src, BufferHandle dst, Subresource srcSub = {});
+ void copy_buffer_to_texture(BufferHandle src, TextureHandle dst, Subresource dstSub = {});
+
+ // size 0 means the whole src buffer from srcOffset
+ void copy_buffer(BufferHandle src, BufferHandle dst, uint64_t srcOffset = 0, uint64_t dstOffset = 0, uint64_t size = 0);
+ void vertex_buffer(BufferHandle handle);
+ void index_buffer(BufferHandle handle);
+ void indirect_buffer(BufferHandle handle);
+
+ // bake pass for a pool-backed resource. runs only when the target is stale.
+ void initialize(ResourceHandle target, uint64_t hash = 0);
+
+ // exempts this pass from dead-pass culling
+ void force_keep();
+
+private:
+ Internal::PassNode* m_pass {};
+ RenderGraph* m_graph {};
+
+ PassBuilder() = default;
+ PassBuilder(const PassBuilder&) = delete;
+ PassBuilder& operator=(const PassBuilder&) = delete;
+ friend struct RenderGraph;
+};
+
+// usage flags are inferred from how passes declare the resource
+struct TextureDesc {
+ WGPUTextureDimension dimension = WGPUTextureDimension_Undefined; // set 2D or 3D explicitly, Undefined is not normalized. 1D is unsupported.
+ WGPUTextureFormat format = WGPUTextureFormat_Undefined; // required, there is no default
+ SizeKind sizeKind = SizeKind::Absolute; // picks between absolute and the scale/relativeTo pair
+ float scaleX = 1.0f; // Relative only: factor of relativeTo's size
+ float scaleY = 1.0f; // Relative only
+ TextureHandle relativeTo {}; // Relative only: the texture whose size is scaled
+ WGPUExtent3D absolute = WGPU_EXTENT_3D_INIT; // Absolute only: size in texels, depthOrArrayLayers is the layer count for 2D
+ uint32_t mipLevelCount = 1; // full chain must be spelled out, there is no 0 = all
+ uint32_t sampleCount = 1; // 1 or 4 (WebGPU 1.0 limit)
+};
+
+struct BufferDesc {
+ uint64_t size {}; // bytes
+};
+
+// an object the graph does not own. a null texture registers the view only, which rules out
+// ctx.texture() and the copy family. dimension is the source texture's own, so an array or cube source
+// says 2D.
+struct ImportTextureDesc {
+ WGPUTextureView view = nullptr; // required, what ctx.view() returns for this handle
+ WGPUExtent3D size = WGPU_EXTENT_3D_INIT; // required, the graph cannot query an imported view
+ WGPUTextureFormat format = WGPUTextureFormat_Undefined; // required, must match the view's format
+ WGPUTexture texture = nullptr; // optional, enables ctx.texture() and the copy family
+ uint32_t mipCount = 1; // of the underlying texture, only relevant when texture is set
+ uint32_t sampleCount = 1; // must match the source, wrong value breaks attachment use
+ WGPUTextureDimension dimension = WGPUTextureDimension_2D; // the source texture's own dimension, see the struct comment
+};
+
+// render graph api for creating resources and adding passes
+struct RenderGraph {
+ // lives for this frame, pooled and possibly aliased with another transient
+ TextureHandle create_transient_texture(std::string_view id, const TextureDesc& desc);
+ BufferHandle create_transient_buffer(std::string_view id, const BufferDesc& desc);
+
+ // wraps an object the graph does not own, usually the swapchain. writing an import is a frame
+ // output, so those passes are never culled.
+ TextureHandle import_texture(std::string_view id, const ImportTextureDesc& desc);
+
+ BufferHandle import_buffer(std::string_view id, WGPUBuffer buffer);
+
+ // cross-frame ping-pong for temporal effects. hash != 0 resets history when the hash changes.
+ HistoryTexture create_history_texture(std::string_view id, const TextureDesc& desc, uint64_t hash = 0);
+ HistoryBuffer create_history_buffer(std::string_view id, const BufferDesc& desc, uint64_t hash = 0);
+
+ // graph-owned, kept across frames
+ BufferHandle create_persistent_buffer(std::string_view id, const BufferDesc& desc);
+
+ // initialized once from data, or zero-filled
+ BufferHandle create_initialized_buffer(std::string_view id, const BufferDesc& desc, const void* data = nullptr);
+
+ TextureHandle create_persistent_texture(std::string_view id, const TextureDesc& desc);
+
+ // cleared once to fill
+ TextureHandle create_initialized_texture(std::string_view id, const TextureDesc& desc, WGPUColor fill);
+
+ // setup(PassBuilder&) declares the pass's accesses now. executeFn(PassContext&) records the GPU
+ // commands during execute(), and must be trivially destructible.
+ template
+ void add_pass(std::string_view id, PassKind kind, BuilderFn&& setup, ExecuteFn&& executeFn)
+ {
+ Q_ASSERT(!id.empty() && "must have name");
+ Q_ASSERT(kind != PassKind::None);
+ PassBuilder builder;
+ begin_pass(builder, id, kind);
+ setup(builder);
+ store_exec(builder, std::forward(executeFn));
+ end_pass(builder);
+ }
+
+ // schedules the graph and freezes it. returns the authoring-error chain, null when ok.
+ [[nodiscard]] ErrorMessage* compile(bool enableAlias = true);
+
+ // records the surviving passes into encoder. does not submit.
+ void execute(WGPUDevice device, WGPUQueue queue, WGPUCommandEncoder encoder, bool enableProfiling = false);
+
+ // kicks the async read-back of the GPU timings
+ void collect_gpu_timings();
+
+ // returns the errors captured by compile()
+ ErrorMessage* get_errors() const;
+
+private:
+ RenderGraph() = default;
+ RenderGraph(const RenderGraph&) = delete;
+ RenderGraph& operator=(const RenderGraph&) = delete;
+ friend struct Internal::GraphPair;
+
+ template
+ void store_exec(PassBuilder& b, F&& f)
+ {
+ using D = std::decay_t;
+ static_assert(std::is_trivially_destructible_v,
+ "execute callback must be trivially destructible; the arena frees without running destructors, so capture handles or ids by value");
+ void* m = alloc_exec(sizeof(D), alignof(D)); // never null, arena OOM aborts
+ ::new (m) D(std::forward(f));
+ set_exec(b, m, [](void* o, PassContext& c) { (*static_cast(o))(c); });
+ }
+ void* alloc_exec(size_t size, size_t align);
+ void set_exec(PassBuilder& builder, void* obj, void (*fn)(void*, PassContext&));
+
+
+ void begin_pass(PassBuilder& builder, std::string_view id, PassKind kind);
+ void end_pass(PassBuilder& builder);
+};
+
+// backs all graphs, arenas and the GPU object pool. one per device.
+GraphAllocator* create_allocator();
+
+// destroys everything it owns, pooled GPU objects included
+void destroy_allocator(GraphAllocator* allocator);
+
+// all graphs and handles from the previous frame die here
+void begin_frame(GraphAllocator* allocator);
+
+// starts a frame-scoped graph. several per frame are allowed.
+RenderGraph* start_recording(GraphAllocator* allocator);
+
+// ages the pools so unused GPU objects get evicted
+void end_frame(GraphAllocator* allocator);
+
+} // namespace webgpu::rg
diff --git a/webgpu/base/RenderGraph_internal.h b/webgpu/base/RenderGraph_internal.h
new file mode 100644
index 00000000..4392e2ac
--- /dev/null
+++ b/webgpu/base/RenderGraph_internal.h
@@ -0,0 +1,786 @@
+/*****************************************************************************
+ * Copyright (C) 2026 Matthias Huerbe
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *****************************************************************************/
+
+// internal node and allocator layout. not public API, may change without notice.
+// used for unit tests and for the RenderGraphPanel
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "RenderGraph.h"
+
+namespace webgpu::rg {
+
+// FNV-1a, the basis of ResourceId::value
+inline constexpr uint64_t fnv1a(std::string_view s)
+{
+ uint64_t hash = 14695981039346656037ULL;
+ for (char c : s) {
+ hash ^= static_cast(c);
+ hash *= 1099511628211ULL;
+ }
+ return hash;
+}
+
+// carries the name too, so a hash collision never conflates two names
+struct ResourceId {
+ uint64_t value {};
+ WGPUStringView name {};
+};
+
+// borrows the name, so an id outliving the caller's string must intern it
+constexpr ResourceId make_resource_id(std::string_view s) {
+ return ResourceId(fnv1a(s), WGPUStringView { .data = s.data(), .length = s.length() });
+}
+
+constexpr bool operator==(ResourceId a, ResourceId b)
+{
+ return a.value == b.value && a.name.length == b.name.length &&
+ std::string_view(a.name.data, a.name.length) == std::string_view(b.name.data, b.name.length);
+}
+
+} // namespace webgpu::rg
+
+namespace webgpu::rg::Internal {
+
+// TransientAttachments, a Dawn-native feature. by value, since the emdawnwebgpu web headers ship the
+// usage bit without the feature enum.
+static constexpr WGPUFeatureName kTransientAttachmentsFeature = (WGPUFeatureName)0x00050006u;
+
+// WebGPU spec limit per render pass
+static constexpr uint32_t kMaxColorAttachments = 8;
+
+// drives hazard edges and usage bits (columns: category, direction, usage)
+enum struct AccessType : uint8_t {
+ ColorAttachment, // attachment write tex RenderAttachment
+ DepthStencilAttachment, // attachment write tex RenderAttachment
+ DepthStencilReadOnly, // attachment-read read tex RenderAttachment (test only, no write hazard)
+ ResolveAttachment, // attachment write tex RenderAttachment
+ Sampled, // constant read tex TextureBinding
+ StorageRead, // storage-read read tex StorageBinding / buf Storage
+ StorageWrite, // storage write tex StorageBinding / buf Storage
+ Uniform, // constant read buf Uniform (+CopyDst)
+ CopySrc, // copy read tex/buf CopySrc
+ CopyDst, // copy write tex/buf CopyDst
+ Vertex, // input read buf Vertex
+ Index, // input read buf Index
+ Indirect, // input read buf Indirect
+};
+
+// one recorded access on a pass. fields ordered wide-to-narrow to kill padding (~96B).
+struct ResourceAccess {
+ ResourceHandle handle {};
+
+ // byte range of a copy_buffer() or a declared BufferRange on a uniform/storage bind. resolved at
+ // declare time so a recorded size is never 0 for a valid range. texture accesses and whole-buffer
+ // binds leave both 0.
+ uint64_t bufOffset {};
+ uint64_t bufSize {};
+
+ // f32 not WGPUColor, lossless for every WebGPU color format at half the size
+ float clearColor[4] {};
+ float clearDepth {};
+
+ // attachment only
+ WGPULoadOp loadOp {};
+ WGPUStoreOp storeOp {};
+ // set for a depth+stencil format
+ WGPULoadOp stencilLoadOp {};
+ WGPUStoreOp stencilStoreOp {};
+
+ WGPUTextureViewDimension viewDim { WGPUTextureViewDimension_Undefined }; // Undefined -> infer
+ WGPUTextureAspect viewAspect { WGPUTextureAspect_All };
+
+ // subresource + view range.
+ uint16_t baseLayer {};
+ uint16_t layerCount { 1 };
+ AccessType type {};
+ uint8_t stencilClear {};
+ // color slot of a ColorAttachment, or of the color() a ResolveAttachment resolves. 0 otherwise.
+ uint8_t colorIndex {};
+ // buffer CopyDst only: provably overwrites the whole buffer. a copy_texture_to_buffer dst stays
+ // false, its coverage is only known at encode time.
+ bool bufFullDefine {};
+ uint8_t baseMip {};
+ uint8_t mipCount { 1 };
+};
+
+// resolves the WGPU_STRLEN sentinel
+size_t sv_length(WGPUStringView s);
+
+bool sv_eq(WGPUStringView a, WGPUStringView b);
+
+// the span before the first '.', empty if none
+WGPUStringView group_prefix(WGPUStringView name);
+
+struct Arena;
+
+struct ViewKey {
+ WGPUTextureFormat format = WGPUTextureFormat_Undefined;
+ WGPUTextureViewDimension dimension = WGPUTextureViewDimension_Undefined;
+ WGPUTextureAspect aspect = WGPUTextureAspect_All;
+ uint32_t baseMipLevel = 0, mipLevelCount = 0, baseArrayLayer = 0, arrayLayerCount = 0;
+
+ bool operator==(const ViewKey& o) const {
+ return format == o.format && dimension == o.dimension && aspect == o.aspect
+ && baseMipLevel == o.baseMipLevel && mipLevelCount == o.mipLevelCount
+ && baseArrayLayer == o.baseArrayLayer && arrayLayerCount == o.arrayLayerCount;
+ }
+ WGPUTextureViewDescriptor desc() const {
+ return WGPUTextureViewDescriptor {
+ .format = format, .dimension = dimension,
+ .baseMipLevel = baseMipLevel, .mipLevelCount = mipLevelCount,
+ .baseArrayLayer = baseArrayLayer, .arrayLayerCount = arrayLayerCount,
+ .aspect = aspect,
+ };
+ }
+};
+
+// one cached non-full view of a physical texture
+struct Subview {
+ ViewKey key;
+ WGPUTextureView view;
+ Subview* next;
+};
+
+
+struct SubviewPool {
+ Arena* arena = nullptr;
+ Subview* freeList = nullptr;
+
+ // pop a recycled node or bump a fresh one
+ Subview* alloc();
+ // release each view in the list, push its nodes onto freeList
+ void recycle(Subview*& head);
+};
+
+// cached non-full view for this key on one texture's list
+WGPUTextureView subview_for(SubviewPool& pool, Subview*& head, WGPUTexture tex, const ViewKey& k);
+
+inline bool extent_eq(WGPUExtent3D a, WGPUExtent3D b) {
+ return a.width == b.width && a.height == b.height && a.depthOrArrayLayers == b.depthOrArrayLayers;
+}
+
+// a texture's create-time signature
+struct TexSignature {
+ WGPUExtent3D size {};
+ WGPUTextureFormat format = WGPUTextureFormat_Undefined;
+ WGPUTextureDimension dim = WGPUTextureDimension_Undefined;
+ uint32_t mipLevelCount = 1;
+ uint32_t sampleCount = 1;
+
+ bool operator==(const TexSignature& o) const {
+ return extent_eq(size, o.size) && format == o.format && dim == o.dim
+ && mipLevelCount == o.mipLevelCount && sampleCount == o.sampleCount;
+ }
+};
+
+// owns GPU resources that outlive the per-frame graph teardown. one Entry per logical resource, keyed
+// by name content, holding the physical objects the graph ping-pongs.
+struct PersistentResourcePool {
+ static constexpr uint32_t kLayers = 2; // current + previous. N>2 deliberately unsupported.
+ static constexpr uint64_t kRetain = 4; // free an entry untouched for this many frames
+
+ struct Entry {
+ uint64_t idValue = 0;
+ WGPUStringView name {}; // identity across frames
+ uint64_t frame = 0; // rotation counter
+ uint64_t lastTouched = 0; // evictClock at the last touch
+ uint64_t prevTouched = 0; // evictClock at the touch before this frame's
+ uint32_t layers = kLayers; // kLayers = history, 1 = single in-place
+ ResourceKind kind = ResourceKind::Texture;
+ uint64_t initHash = 0; // initialize(): hash of the content baked in
+ bool baked = false; // initialize(): cleared on (re)create
+ uint64_t historyHash = 0; // mismatch -> destroy+recreate
+
+ WGPUTexture tex[kLayers] = {};
+ WGPUTextureView view[kLayers] = {}; // whole-texture view per layer
+ Subview* subviews[kLayers] = {};
+
+ // texture
+ bool created = false;
+ uint64_t createdClock = UINT64_MAX; // catches a same-frame recreate
+ TexSignature sig {};
+ WGPUTextureUsage usage = {}; // union over layers
+ WGPUTextureUsage usageAtCreate = {};
+
+ // buffer
+ WGPUBuffer buf[kLayers] = {};
+ uint64_t bufferSize = 0;
+ WGPUBufferUsage bufUsage = {}; // union over layers/frames
+ WGPUBufferUsage bufUsageAtCreate = {};
+
+ Entry* next = nullptr; // live-list link when in entries, free-list link when recycled
+
+ // interned, NUL-terminated, cross-frame stable
+ WGPUStringView name_view() const { return name; }
+ };
+
+ Entry* entries = nullptr;
+ Entry* freeList = nullptr;
+ Arena* arena = nullptr;
+ SubviewPool* subviewPool = nullptr;
+ uint64_t evictClock = 0;
+
+ // pop a recycled cell or create a new one
+ Entry* alloc_entry();
+
+ // null if absent
+ Entry* find(ResourceId id);
+
+ // ensure the entry exists and advance its rotation, so rotation tracks use, not declaration.
+ // idempotent within a frame: a history's two layer nodes share one entry, only the first rotates.
+ Entry* touch(ResourceId id, uint32_t layers, uint64_t hash, ResourceKind kind);
+
+ // physical slot backing a layer under the entry's current rotation
+ uint32_t slot(const Entry& e, uint32_t layerIndex) const;
+
+ // would realize_*_entry destroy+recreate this entry
+ static bool tex_entry_would_recreate(const Entry& e, const TexSignature& sig, WGPUTextureUsage usage);
+ static bool buf_entry_would_recreate(const Entry& e, uint64_t size, WGPUBufferUsage usage);
+
+ // does this hash force touch() to destroy the entry
+ static bool history_hash_forces_destroy(const Entry& e, uint64_t hash);
+
+ void realize_texture_entry(Entry* e, WGPUDevice device, const TexSignature& sig);
+
+ // usage is the union across layers, since each physical buffer cycles through every layer role
+ void realize_buffer_entry(Entry* e, WGPUDevice device, uint64_t size);
+
+ void destroy(Entry* e);
+
+ // free entries untouched for kRetain frames, then advance the clock. once per realized frame.
+ void end_frame();
+
+ // destroys all managed entities
+ void destroy_all();
+
+ ~PersistentResourcePool();
+};
+
+// caches per-frame transient GPU objects across teardown so realize() stops churning the driver. one
+// physical object per Entry, keyed by descriptor and tagged by kind. usage matches by superset, except
+// the restriction bits in acquire(). objects unclaimed for kRetain frames are evicted.
+struct TransientResourcePool {
+ static constexpr uint64_t kRetain = 4;
+
+ struct Entry {
+ ResourceKind kind = ResourceKind::Texture; // picks the arm
+ // texture arm
+ TexSignature sig {};
+ WGPUTextureUsage usage = {};
+ WGPUTexture tex = {};
+ WGPUTextureView view = {}; // whole-texture view, the resolve_view fast path
+ Subview* subviews = nullptr;
+ // buffer arm
+ uint64_t bufferSize = 0;
+ WGPUBufferUsage bufUsage = {};
+ WGPUBuffer buf = {};
+ // shared
+ bool inUse = false; // released in release_claims
+ uint64_t lastUsedFrame = 0; // eviction only
+ uint64_t createdFrame = 0; // supersede-eviction key, with identity
+ // interned name of the entry's LAST claimant, null if never claimed by name
+ const void* identity = nullptr;
+ Entry* next = nullptr; // live-list link when in entries, free-list link when recycled
+ };
+ // cells bump-allocated from arena, evicted cells recycle through freeList
+ Entry* entries = nullptr;
+ Entry* freeList = nullptr;
+ Arena* arena = nullptr;
+ SubviewPool* subviewPool = nullptr; // destroy() recycles into it
+ uint64_t frame = 0;
+ uint32_t createdThisFrame = 0;
+
+ // pop a recycled cell or bump a fresh one, default-constructed
+ Entry* alloc_entry();
+
+ // debug event log
+ enum class Event : uint8_t { Create, Evict };
+ struct LogRec {
+ uint64_t frame;
+ Event event;
+ ResourceKind kind;
+ WGPUExtent3D size; // texture
+ WGPUTextureFormat format; // texture
+ uint64_t bufferSize; // buffer
+ };
+ static constexpr uint32_t kLog = 128;
+ LogRec eventLog[kLog] = {};
+ uint64_t eventCount = 0;
+
+ void log_event(Event event, const Entry& e);
+
+ void log_reset();
+
+ // hand out a free texture matching the signature (usage by superset), or create one
+ void acquire(WGPUDevice device, const TexSignature& sig, WGPUTextureUsage usage, const void* identity, WGPUTexture& outTex, WGPUTextureView& outView);
+
+ void acquire(WGPUDevice device, uint64_t size, WGPUBufferUsage usage, const void* identity, WGPUBuffer& outBuf);
+
+ // drop every inUse claim so the frame's objects become reusable
+ void release_claims();
+
+ // true iff idle entry e is made redundant this frame by same-kind sibling s, identical but for its
+ // size. kills the resize VRAM spike. keyed on identity too, so only a resize of the SAME resource
+ // supersedes.
+ static bool superseded_by(const Entry& e, const Entry& s, uint64_t frame);
+
+ // destroy objects idle >= kRetain frames, advance the clock. once per frame.
+ void end_frame();
+
+ void destroy(Entry* e);
+
+ // idempotent. must run while arena is still alive.
+ void destroy_all();
+
+ ~TransientResourcePool();
+};
+
+// opt-in per-pass GPU timing. two timestamp queries per executed pass, resolved into a staging buffer
+// and copied into a ring of mappable read-back buffers so a frame never stalls. created on the first
+// profiled frame, and only when the device has TimestampQuery.
+struct GpuProfiler {
+ static constexpr uint32_t kMaxPasses = 64; // -> 2*64 timestamps in the set
+ static constexpr uint32_t kRing = 3; // covers GPU read-back latency without stalling
+
+ WGPUQuerySet querySet {}; // type Timestamp, count 2*kMaxPasses
+ WGPUBuffer resolveBuf {}; // 2*kMaxPasses*8, QueryResolve | CopySrc
+
+ struct Slot {
+ WGPUBuffer buf {}; // 2*kMaxPasses*8, MapRead | CopyDst
+ bool pending {}; // mapAsync in flight -> not reusable until the cb fires
+ uint32_t count {}; // passes captured into this fill
+ WGPUStringView names[kMaxPasses] {}; // interned pass names, captured at execute
+ } ring[kRing];
+ int pendingSlot { -1 }; // slot execute() filled this frame, awaiting the map kick
+
+ // last completed read-back, read by the debug UI
+ uint32_t resultCount {};
+ WGPUStringView resultNames[kMaxPasses] {}; // interned, so they outlive the ring slot's reuse
+ float resultUs[kMaxPasses] {};
+
+ bool initialized {};
+
+ // timing history, driven by the "Timings" tab
+ static constexpr uint32_t kHistory = 256; // frames retained
+ bool recording {};
+ uint32_t resultId {}; // bumped by the read-back cb per sample, dedupes capture
+ struct Series {
+ WGPUStringView name {}; // interned, series matched by canonical .data pointer
+ bool enabled { true };
+ float v[kHistory] {};
+ };
+ Series series[kMaxPasses];
+ uint32_t seriesCount {};
+ uint32_t historyHead {}; // next write column
+ uint32_t historyLen {}; // valid columns (<= kHistory)
+ uint32_t lastSampledId {}; // resultId already appended
+
+ void clear_history();
+
+ // append one column iff recording and a fresh read-back arrived since last call. results match a
+ // series by interned name and occurrence index, so repeated pass names keep distinct rows.
+ // NOTE(Huerbe): series keyed by name. switching graphs adds new series and zero-fills old ones. clear resets.
+ void sample_history();
+
+ // one-time creation of the query set, resolve buffer, and read-back ring
+ void init(WGPUDevice device);
+
+ // first ring slot not awaiting a map, -1 if all are in flight
+ int free_slot() const;
+};
+
+static constexpr size_t ARENA_DEFAULT_BLOCK_SIZE = 16 * 1024;
+static constexpr size_t ARENA_SCRATCH_DEFAULT_BLOCK_SIZE = 2 * 1024;
+
+// chained-block bump allocator behind all graph storage. frees nothing until reset() or free_all().
+struct Arena {
+
+ struct ArenaBlock {
+ ArenaBlock* next;
+ uint8_t* payload; // aligned payload start, inside this same malloc
+ size_t capacity;
+ size_t used; // bump cursor
+ };
+
+ ArenaBlock* head {};
+ ArenaBlock* current {};
+ size_t blockSize = ARENA_DEFAULT_BLOCK_SIZE;
+
+ // debug stats
+ size_t totalCapacity {};
+ size_t peakUsage {};
+ size_t blockCount {};
+ size_t liveBytes {};
+
+ // alignment must be a power of two
+ static constexpr size_t align_up(size_t value, size_t alignment)
+ {
+ return (value + alignment - 1) & ~(alignment - 1);
+ }
+
+ ArenaBlock* new_block(size_t minPayload);
+
+ // ensure the first block exists ahead of the first alloc
+ void reserve();
+
+ size_t live_used() const;
+
+ void* alloc_raw(size_t size, size_t align);
+
+ // grow p in place when it is the tail of the current block, null when it cannot
+ void* extend_tail(void* p, size_t oldSize, size_t addSize);
+
+ // fatal, aborts via qFatal. alloc_raw and its callers rely on this and never null-check.
+ [[noreturn]] void oom(size_t size);
+
+ // rewind every block, keeping the memory
+ void reset();
+
+ void free_all();
+
+ struct Mark {
+ ArenaBlock* block;
+ size_t used;
+ };
+ Mark mark() const;
+ void rewind(Mark m);
+
+ template
+ static T* construct(void* m, Args&&... args)
+ {
+ return ::new (m) T(std::forward(args)...);
+ }
+
+ template
+ static T* zero(void* m, size_t count)
+ {
+ std::memset(m, 0, sizeof(T) * count);
+ return static_cast(m);
+ }
+
+ template
+ T* make(Args&&... args)
+ {
+ return construct(alloc_raw(sizeof(T), alignof(T)), std::forward(args)...);
+ }
+
+ // zeroed POD storage, contiguous within one block
+ template
+ T* alloc(size_t count = 1)
+ {
+ return zero(alloc_raw(sizeof(T) * count, alignof(T)), count);
+ }
+
+ // arena-owned copy, always null-terminated
+ WGPUStringView copy_string(WGPUStringView s);
+};
+
+struct StringInterner {
+ static constexpr uint32_t kBuckets = 64; // power of two, index = hash & (kBuckets-1)
+
+ struct Node {
+ uint64_t hash; // matched before the content compare
+ WGPUStringView name; // NUL-terminated
+ Node* next;
+ };
+
+ Arena* arena = nullptr;
+ Node* buckets[kBuckets] = {};
+
+ ResourceId intern(std::string_view s);
+};
+
+
+} // namespace webgpu::rg::Internal
+
+namespace webgpu::rg {
+
+// persistent data storage for the render graph
+struct GraphAllocator {
+ Internal::Arena front; // permanent per-frame nodes
+ Internal::Arena scratch; // compile()-local temporaries, driven by ScopedScratch
+ Internal::Arena persist; // Entry cells for both pools. never per-frame reset, freed at destroy_allocator.
+
+ Internal::StringInterner names; // dedup table for every resource/pass/pool/profiler name
+ Internal::PersistentResourcePool pool; // name-keyed history textures + buffers
+ Internal::TransientResourcePool transient; // descriptor-keyed per-frame texture + buffer cache
+ Internal::SubviewPool subviews; // shared cache of non-full texture views for both pools
+ Internal::GpuProfiler profiler; // opt-in per-pass GPU timestamps
+
+ // whether the device advertises WGPUFeatureName_TransientAttachments
+ bool transientFeatureOn {};
+
+ // bumped by reset()
+ uint64_t frameId { 1 };
+
+ static constexpr size_t align_up(size_t value, size_t alignment) { return Internal::Arena::align_up(value, alignment); }
+
+ // front-arena forwarders for per-frame allocations
+ void* alloc_raw(size_t size, size_t align);
+ template
+ T* make(Args&&... args)
+ {
+ return front.make(std::forward(args)...);
+ }
+ template
+ T* alloc(size_t count = 1)
+ {
+ return front.alloc(count);
+ }
+ // null-terminated front-arena copy
+ WGPUStringView copy_string(WGPUStringView s);
+
+ // per-frame teardown: rewind both arenas
+ void reset();
+};
+
+} // namespace webgpu::rg
+
+namespace webgpu::rg::Internal {
+
+struct ScopedScratch {
+ Arena* arena;
+ Arena::Mark mark;
+ explicit ScopedScratch(Arena& a);
+ ~ScopedScratch();
+ ScopedScratch(const ScopedScratch&) = delete;
+ ScopedScratch& operator=(const ScopedScratch&) = delete;
+
+ void reset();
+
+ template
+ T* alloc(size_t count = 1)
+ {
+ return arena->alloc(count);
+ }
+ template
+ T* make(Args&&... args)
+ {
+ return arena->make(std::forward(args)...);
+ }
+};
+
+struct ResourceNode {
+ ResourceHandle handle {};
+ ResourceId id {};
+ ResourceKind kind {};
+ bool imported : 1 {}; // managed outside the graph, e.g. a swapchain texture
+ bool persistent : 1 {}; // one layer of a PersistentResourcePool-backed resource
+ bool history : 1 {}; // .curr written this frame, read next
+ bool historyValid : 1 {}; // false if the entry was recreated this frame
+ bool resolving : 1 {}; // on the relativeTo recursion stack -> re-entry is a cycle
+ bool hasWriter : 1 {}; // phase-4 aliasing eligibility: some pass writes this
+ bool firstDefines : 1 {}; // phase-4 aliasing eligibility: first touch fully overwrites the occupant
+ bool transientAttachment : 1 {}; // graph-owned attachment that never leaves the GPU
+
+ uint32_t historyIndex {}; // 0 == current (write target), 1 == previous (read)
+ uint64_t historyHash {};
+
+ // storage not owned by the per-frame graph, imported or pool-backed
+ bool is_external() const;
+
+ // texture fields
+ WGPUTextureDimension dimension = WGPUTextureDimension_Undefined;
+ WGPUTextureFormat format = WGPUTextureFormat_Undefined;
+ SizeKind sizeKind = SizeKind::Absolute;
+ float scaleX = 1.0f;
+ float scaleY = 1.0f;
+ ResourceHandle relativeToHandle {};
+ WGPUExtent3D absolute = WGPU_EXTENT_3D_INIT;
+ uint32_t mipLevelCount = 1; // > 1 = mip chain, per-mip views built at bind/attach time
+ uint32_t sampleCount = 1; // > 1 = MSAA
+
+ // buffer fields
+ uint64_t bufferSize {};
+
+ // realized GPU handles
+ WGPUTexture texture {}; // created: the texture object backing view
+ WGPUTextureView view {}; // imported: the registered swapchain view
+ // head slot of the subview list of the pooled physical texture backing this node
+ Subview** subviews {};
+ WGPUBuffer buffer {}; // imported: the registered buffer
+ WGPUExtent3D resolved = WGPU_EXTENT_3D_INIT; // the base for Relative resolution
+ WGPUTextureUsage texUsage {}; // accumulated in compile() from the access list
+ WGPUBufferUsage bufUsage {};
+
+ // first/last surviving pass to touch this, in execution order. filled in compile() phase 3
+ static constexpr uint32_t kNoPass = ~0u;
+ uint32_t firstUse = kNoPass; // kNoPass = dead transient or imported.
+ uint32_t lastUse = kNoPass; // kNoPass = dead transient or imported.
+
+ // the physical slot this transient shares with other disjoint-lifetime resources. kNoSlot means its own object, ineligible or aliasing off.
+ static constexpr uint32_t kNoSlot = ~0u;
+ uint32_t aliasSlot = kNoSlot;
+
+ // filled during the phase-3 access walk. soleAccess is the last one recorded, the only one when the count is 1.
+ uint32_t liveAccessCount {};
+ const ResourceAccess* soleAccess {};
+
+ ResourceNode* next {};
+};
+
+struct NodeAdjacency;
+
+// intrusive list node with a growable access array
+struct PassNode {
+ ResourceId id {};
+ PassKind kind {};
+
+ // dense declaration-order index, assigned early in compile()
+ uint32_t index {};
+
+ // type-erased pass body, invoked by execute()
+ void* exec_obj {};
+ void (*exec_fn)(void*, PassContext&) {};
+
+ ResourceAccess* accesses {};
+ uint32_t accessCount {};
+ uint32_t accessCap {};
+
+ NodeAdjacency* adjacency {};
+
+ bool placed {}; // topo sort: already emitted into execution order
+ bool onStack {}; // topo sort: on the DFS stack -> a re-entry is a back-edge
+ bool sink {}; // cull root: writes an imported or history resource, so it survives with no reader
+ bool forceKeep {}; // force_keep(): extra cull root
+
+ // initialize() targets this pass bakes. empty is an ordinary pass. compile() sets skipInit only when
+ // every target is populated and baked with a matching hash, since a body bakes all or none.
+ static constexpr uint32_t kMaxInitTargets = 8;
+ struct InitTarget {
+ ResourceHandle target;
+ uint64_t hash;
+ };
+
+ InitTarget* initTargets {};
+ uint32_t initCount {};
+ bool skipInit {};
+
+ PassNode* next {};
+};
+
+// pass must execute before the owner of this list
+struct NodeAdjacency {
+ PassNode* pass {};
+ NodeAdjacency* next {};
+};
+
+// one physical GPU object shared by transients with disjoint lifetimes and an identical signature
+// (aliasing, compile() phase 4). a slot never mixes the texture and buffer arms.
+struct PhysicalResource {
+ ResourceKind kind {};
+ uint32_t freeFrom {}; // occupant lastUse, reusable iff the next member's firstUse > this
+ // interned name of the first member to open this slot. stable across frames, which is what lets
+ // superseded_by recognise a resize of it.
+ const void* identity {};
+ // texture
+ TexSignature sig {}; // members must match exactly to share this slot
+ WGPUTextureUsage texUsage {}; // union over members
+ WGPUTexture texture {}; // filled by realize() via transient.acquire()
+ WGPUTextureView view {};
+ // buffer
+ uint64_t bufferSize {};
+ WGPUBufferUsage bufUsage {}; // union over members
+ WGPUBuffer buffer {};
+};
+
+enum struct RenderGraphState {
+ Recording = 0, // mutable
+ Compiled, // read-only, ready to realize/execute
+ Failed, // authoring error, realize/execute no-op
+ Finished // spent for this frame
+};
+
+// concrete state behind the opaque RenderGraph handle, co-allocated in the same block
+struct RenderGraphStorage {
+ GraphAllocator* m_allocator {};
+ ResourceNode* m_resouces {};
+ ResourceNode* m_resoucesTail {};
+ PassNode* m_passes {};
+ PassNode* m_passesTail {};
+ RenderGraphState m_state {};
+ uint32_t next_resource_id = 1; // 0 = invalid handle, so this - 1 is the resource count
+ uint32_t passCount {}; // incl. skipInit. sizes the dedup stamp.
+ uint64_t generation {}; // graph-instance id
+ uint64_t frameId {}; // checked against the allocator's
+ ResourceNode** byId {};
+ PhysicalResource* m_slots {};
+ uint32_t m_slotCount {};
+ // graph-owned attachments flagged memoryless
+ uint32_t transientCount {};
+ // execute() scratch: subresource views for the current pass, released after the body. one view per
+ // access, so the per-pass access ceiling bounds it.
+ WGPUTextureView* viewScratch {};
+ uint32_t viewScratchN {};
+ uint32_t viewScratchCap {}; // grows on demand, monotonic across passes
+ ErrorMessage* m_errors {}; // non-null => m_state is Failed
+ ErrorMessage* m_errorsTail {};
+
+ // CPU timings
+ float timing_compile_us {};
+ float timing_realize_us {};
+ float timing_execute_us {};
+};
+
+// storage() relies on st sitting immediately after rg
+struct GraphPair {
+ RenderGraph rg;
+ RenderGraphStorage st;
+};
+
+// unit-test helper
+struct PassContextAccess {
+ PassContext ctx;
+ PassContextAccess(RenderGraph* graph, PassNode* pass)
+ {
+ ctx.graph = graph;
+ ctx.pass = pass;
+ }
+};
+
+RenderGraphStorage* storage(RenderGraph* rg);
+
+ResourceNode* find_node(RenderGraph* rg, ResourceHandle h);
+
+// the write half of AccessType
+bool access_is_write(AccessType t);
+
+// empty past the end. for the debug lifetime widget.
+WGPUStringView pass_name_at(PassNode* head, uint32_t idx);
+
+// texel-block descriptor
+struct TexelBlock { uint32_t w, h, bytes; };
+TexelBlock format_block(WGPUTextureFormat f);
+
+uint64_t texture_bytes(WGPUExtent3D size, WGPUTextureFormat format, uint32_t mipLevelCount = 1, uint32_t sampleCount = 1, WGPUTextureDimension dim = WGPUTextureDimension_2D);
+
+} // namespace webgpu::rg::Internal