From 491a3c1ccbd0f46be655e97d2e3697135df6e3a2 Mon Sep 17 00:00:00 2001 From: skal Date: Mon, 23 Mar 2026 07:54:18 +0100 Subject: feat(gbuffer): wire_dag() + find_downstream_output() for temporal feedback - Add Effect::wire_dag() virtual (called from init_effect_nodes after full DAG built) - Add Effect::find_downstream_output() protected helper (first downstream consumer output) - GBufferEffect::wire_dag() auto-sets cnn_output_node_ via find_downstream_output, guarding against sink (external view, null texture) - GBufferEffect::post_render() null-checks src texture before CopyTextureToTexture - Tests: find_downstream_output cases + wire_dag integration in test_effect_base - Doc: SEQUENCE.md updated with wire_dag pattern, helper contract, and sink guard Co-Authored-By: Claude Sonnet 4.6 --- cnn_v3/src/gbuffer_effect.cc | 10 ++- cnn_v3/src/gbuffer_effect.h | 4 ++ doc/SEQUENCE.md | 49 ++++++++++++- src/gpu/effect.cc | 14 ++++ src/gpu/effect.h | 14 ++++ src/gpu/sequence.cc | 3 + src/tests/gpu/test_effect_base.cc | 142 +++++++++++++++++++++++++++++++++++++- 7 files changed, 233 insertions(+), 3 deletions(-) diff --git a/cnn_v3/src/gbuffer_effect.cc b/cnn_v3/src/gbuffer_effect.cc index 512843c..1bfd685 100644 --- a/cnn_v3/src/gbuffer_effect.cc +++ b/cnn_v3/src/gbuffer_effect.cc @@ -773,10 +773,18 @@ void GBufferEffect::update_raster_bind_group(NodeRegistry& nodes) { wgpuBindGroupLayoutRelease(bgl); } +void GBufferEffect::wire_dag(const std::vector& dag) { + const std::string out = find_downstream_output(dag); + // "sink" is an external view (no owned texture) — not a valid copy source. + if (out != "sink") cnn_output_node_ = out; +} + void GBufferEffect::post_render(WGPUCommandEncoder encoder, NodeRegistry& nodes) { if (cnn_output_node_.empty() || !nodes.has_node(cnn_output_node_)) return; + WGPUTexture src_tex = nodes.get_texture(cnn_output_node_); + if (!src_tex) return; // external view (e.g. sink) — no owned texture to copy WGPUTexelCopyTextureInfo src = {}; - src.texture = nodes.get_texture(cnn_output_node_); + src.texture = src_tex; src.mipLevel = 0; WGPUTexelCopyTextureInfo dst = {}; dst.texture = nodes.get_texture(node_prev_tex_); diff --git a/cnn_v3/src/gbuffer_effect.h b/cnn_v3/src/gbuffer_effect.h index 8e777f8..76d4347 100644 --- a/cnn_v3/src/gbuffer_effect.h +++ b/cnn_v3/src/gbuffer_effect.h @@ -49,6 +49,10 @@ class GBufferEffect : public Effect { // Copies cnn_output_node_ → node_prev_tex_ after all effects have rendered. void post_render(WGPUCommandEncoder encoder, NodeRegistry& nodes) override; + // Auto-wires cnn_output_node_: finds the first downstream effect whose + // input_nodes intersect our output_nodes, and uses its output_nodes[0]. + void wire_dag(const std::vector& dag) override; + // Populate the internal scene with ~20 rotating cubes and a few pumping // spheres. Must be called once before the first render(). void set_scene(); diff --git a/doc/SEQUENCE.md b/doc/SEQUENCE.md index 411e9d4..ca45756 100644 --- a/doc/SEQUENCE.md +++ b/doc/SEQUENCE.md @@ -158,7 +158,54 @@ sequence and no post-CNN effects overwrite `sink`. `post_render()` is unconditionally correct regardless of sequence structure. **Current user**: `GBufferEffect` uses this pattern for `prev.rgb` (CNN temporal -feedback). Call `gbuf->set_cnn_output_node("cnn_out_node")` once at setup. +feedback). `cnn_output_node_` is wired automatically via `wire_dag()` — no +manual `set_cnn_output_node()` call needed. + +### DAG wiring (`wire_dag`) + +```cpp +// Effect base class +virtual void wire_dag(const std::vector& dag) {} +``` + +Called once from `Sequence::init_effect_nodes()` after all `declare_nodes()` +calls, so the full DAG is visible. Override to resolve inter-effect +dependencies that cannot be expressed through node names alone. + +`GBufferEffect::wire_dag()` delegates to the base-class helper +`find_downstream_output(dag)`, then guards against wiring to `"sink"`: + +```cpp +void GBufferEffect::wire_dag(const std::vector& dag) { + const std::string out = find_downstream_output(dag); + if (out != "sink") cnn_output_node_ = out; +} +``` + +`"sink"` is registered as an external view (`texture == nullptr`); copying +from it in `post_render` would crash. When no CNN follows the G-buffer stage +(e.g. debug/deferred sequences), `cnn_output_node_` stays empty and +`post_render` is a no-op. + +#### `Effect::find_downstream_output` + +```cpp +// protected helper — call from wire_dag() +std::string find_downstream_output(const std::vector& dag) const; +``` + +Returns `output_nodes[0]` of the first direct downstream consumer in the DAG, +or `""` if none exists. The helper is agnostic about node semantics — it is +the **caller's responsibility** to reject unsuitable results (e.g. `"sink"` or +any other external/terminal node whose texture is not owned by the registry). + +`post_render` also null-checks the source texture as a belt-and-suspenders +guard: + +```cpp +WGPUTexture src_tex = nodes.get_texture(cnn_output_node_); +if (!src_tex) return; // external view — no owned texture to copy +``` ### Node System diff --git a/src/gpu/effect.cc b/src/gpu/effect.cc index 4230021..1257090 100644 --- a/src/gpu/effect.cc +++ b/src/gpu/effect.cc @@ -58,6 +58,20 @@ void Effect::blit_input_to_output(WGPUCommandEncoder encoder, &extent); } +std::string Effect::find_downstream_output( + const std::vector& dag) const { + for (const auto& node : dag) { + for (const auto& in : node.input_nodes) { + for (const auto& out : output_nodes_) { + if (in == out && !node.output_nodes.empty()) { + return node.output_nodes[0]; + } + } + } + } + return ""; +} + void Effect::create_linear_sampler() { sampler_.set(gpu_create_linear_sampler(ctx_.device)); } diff --git a/src/gpu/effect.h b/src/gpu/effect.h index 566faba..6c50d84 100644 --- a/src/gpu/effect.h +++ b/src/gpu/effect.h @@ -41,6 +41,13 @@ class Effect { (void)nodes; } + // Called once after the full DAG is built (init_effect_nodes). + // Override to auto-wire inter-effect dependencies (e.g. temporal feedback). + // Default is a no-op. + virtual void wire_dag(const std::vector& dag) { + (void)dag; + } + virtual void resize(int width, int height) { width_ = width; height_ = height; @@ -67,6 +74,13 @@ class Effect { Texture dummy_texture_; TextureView dummy_texture_view_; + // DAG query helpers (callable from wire_dag overrides) + // + // Returns output_nodes[0] of the first effect in |dag| whose input_nodes + // intersect this effect's output_nodes_ (i.e. the first direct downstream + // consumer). Returns "" if no such effect exists or it has no outputs. + std::string find_downstream_output(const std::vector& dag) const; + // Helper: Create linear sampler (call in subclass constructor if needed) void create_linear_sampler(); diff --git a/src/gpu/sequence.cc b/src/gpu/sequence.cc index 78647b2..6bff34e 100644 --- a/src/gpu/sequence.cc +++ b/src/gpu/sequence.cc @@ -291,4 +291,7 @@ void Sequence::init_effect_nodes() { for (auto& dag_node : effect_dag_) { dag_node.effect->declare_nodes(nodes_); } + for (auto& dag_node : effect_dag_) { + dag_node.effect->wire_dag(effect_dag_); + } } diff --git a/src/tests/gpu/test_effect_base.cc b/src/tests/gpu/test_effect_base.cc index e73f4d7..ad7bca3 100644 --- a/src/tests/gpu/test_effect_base.cc +++ b/src/tests/gpu/test_effect_base.cc @@ -208,7 +208,145 @@ static void test_sequence_time_params() { fprintf(stdout, " ✓ Sequence time parameters updated correctly\n"); } -// Test 7: Pixel validation helpers +// Minimal Effect subclass for wire_dag / find_downstream_output tests. +// Exposes the protected helper and records what wire_dag received. +class WireDagTestEffect : public Effect { + public: + WireDagTestEffect(const GpuContext& ctx, std::vector ins, + std::vector outs) + : Effect(ctx, std::move(ins), std::move(outs), 0.0f, 1000.0f) {} + + void render(WGPUCommandEncoder, const UniformsSequenceParams&, + NodeRegistry&) override {} + + std::string call_find_downstream(const std::vector& dag) const { + return find_downstream_output(dag); + } + + std::string wired_to; + void wire_dag(const std::vector& dag) override { + wired_to = find_downstream_output(dag); + } +}; + +// Test 7: find_downstream_output DAG query +static void test_find_downstream_output() { + fprintf(stdout, "Testing find_downstream_output...\n"); + + WebGPUTestFixture fixture; + if (!fixture.init()) { + fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n"); + return; + } + + auto a = std::make_shared( + fixture.ctx(), std::vector{"src"}, + std::vector{"mid"}); + auto b = std::make_shared( + fixture.ctx(), std::vector{"mid"}, + std::vector{"out"}); + auto c = std::make_shared( + fixture.ctx(), std::vector{"out"}, + std::vector{"final"}); + + // Two-node chain: A→B. A's downstream is B, returns B's output "out". + std::vector dag_ab = { + {a, {"src"}, {"mid"}, 0}, + {b, {"mid"}, {"out"}, 1}, + }; + assert(a->call_find_downstream(dag_ab) == "out" && + "A's downstream output should be 'out'"); + fprintf(stdout, " ✓ two-node chain: correct downstream output\n"); + + // Three-node chain: A→B→C. A finds B first (not C). + std::vector dag_abc = { + {a, {"src"}, {"mid"}, 0}, + {b, {"mid"}, {"out"}, 1}, + {c, {"out"}, {"final"}, 2}, + }; + assert(a->call_find_downstream(dag_abc) == "out" && + "A should find first downstream, not transitive"); + fprintf(stdout, " ✓ three-node chain: first downstream only\n"); + + // No downstream: A is the last node. + std::vector dag_a_only = { + {a, {"src"}, {"mid"}, 0}, + }; + assert(a->call_find_downstream(dag_a_only) == "" && + "No downstream should return empty string"); + fprintf(stdout, " ✓ no downstream: returns empty string\n"); + + // Unrelated node: B does not consume A's output. + auto unrelated = std::make_shared( + fixture.ctx(), std::vector{"other"}, + std::vector{"sink"}); + std::vector dag_unrelated = { + {a, {"src"}, {"mid"}, 0}, + {unrelated, {"other"}, {"sink"}, 1}, + }; + assert(a->call_find_downstream(dag_unrelated) == "" && + "Unrelated node should not match"); + fprintf(stdout, " ✓ unrelated node: returns empty string\n"); + + // Downstream outputs to "sink" (external view, no owned texture). + // wire_dag must not wire to it — GBufferEffect skips "sink" outputs. + auto to_sink = std::make_shared( + fixture.ctx(), std::vector{"mid"}, + std::vector{"sink"}); + std::vector dag_to_sink = { + {a, {"src"}, {"mid"}, 0}, + {to_sink, {"mid"}, {"sink"}, 1}, + }; + // find_downstream_output returns "sink" (it's agnostic) + assert(a->call_find_downstream(dag_to_sink) == "sink"); + // but wire_dag on a WireDagTestEffect just stores whatever find returns; + // verify GBufferEffect-style guard: "sink" should NOT be wired as prev + a->wire_dag(dag_to_sink); + assert(a->wired_to == "sink" && + "base helper returns sink — caller must guard"); + fprintf(stdout, " ✓ sink downstream: find returns 'sink', caller must guard\n"); +} + +// Test 8: wire_dag called automatically by init_effect_nodes +static void test_wire_dag_called_by_sequence() { + fprintf(stdout, "Testing wire_dag called by init_effect_nodes...\n"); + + WebGPUTestFixture fixture; + if (!fixture.init()) { + fprintf(stdout, " ⚠ WebGPU unavailable - skipping test\n"); + return; + } + + auto upstream = std::make_shared( + fixture.ctx(), std::vector{"source"}, + std::vector{"mid"}); + auto downstream = std::make_shared( + fixture.ctx(), std::vector{"mid"}, + std::vector{"sink"}); + + class TestSequence : public Sequence { + public: + TestSequence(const GpuContext& ctx, + std::shared_ptr up, + std::shared_ptr down) + : Sequence(ctx, 256, 256) { + effect_dag_.push_back({up, {"source"}, {"mid"}, 0}); + effect_dag_.push_back({down, {"mid"}, {"sink"}, 1}); + init_effect_nodes(); // triggers wire_dag on both effects + } + }; + + TestSequence seq(fixture.ctx(), upstream, downstream); + + assert(upstream->wired_to == "sink" && + "upstream should be wired to downstream's output 'sink'"); + assert(downstream->wired_to == "" && + "downstream has no consumer, should be empty"); + + fprintf(stdout, " ✓ upstream wired_to='sink', downstream wired_to=''\n"); +} + +// Test 9: Pixel validation helpers static void test_pixel_helpers() { fprintf(stdout, "Testing pixel validation helpers...\n"); @@ -254,6 +392,8 @@ int main() { test_effect_in_sequence(); test_sequence_render(); test_sequence_time_params(); + test_find_downstream_output(); + test_wire_dag_called_by_sequence(); test_pixel_helpers(); fprintf(stdout, "=== All Effect Base Tests Passed ===\n"); -- cgit v1.2.3