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