summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cnn_v3/src/gbuffer_effect.cc10
-rw-r--r--cnn_v3/src/gbuffer_effect.h4
-rw-r--r--doc/SEQUENCE.md49
-rw-r--r--src/gpu/effect.cc14
-rw-r--r--src/gpu/effect.h14
-rw-r--r--src/gpu/sequence.cc3
-rw-r--r--src/tests/gpu/test_effect_base.cc142
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<EffectDAGNode>& 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<EffectDAGNode>& 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<EffectDAGNode>& 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<EffectDAGNode>& 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<EffectDAGNode>& 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<EffectDAGNode>& 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<struct EffectDAGNode>& 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<EffectDAGNode>& 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<std::string> ins,
+ std::vector<std::string> 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<EffectDAGNode>& dag) const {
+ return find_downstream_output(dag);
+ }
+
+ std::string wired_to;
+ void wire_dag(const std::vector<EffectDAGNode>& 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<WireDagTestEffect>(
+ fixture.ctx(), std::vector<std::string>{"src"},
+ std::vector<std::string>{"mid"});
+ auto b = std::make_shared<WireDagTestEffect>(
+ fixture.ctx(), std::vector<std::string>{"mid"},
+ std::vector<std::string>{"out"});
+ auto c = std::make_shared<WireDagTestEffect>(
+ fixture.ctx(), std::vector<std::string>{"out"},
+ std::vector<std::string>{"final"});
+
+ // Two-node chain: A→B. A's downstream is B, returns B's output "out".
+ std::vector<EffectDAGNode> 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<EffectDAGNode> 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<EffectDAGNode> 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<WireDagTestEffect>(
+ fixture.ctx(), std::vector<std::string>{"other"},
+ std::vector<std::string>{"sink"});
+ std::vector<EffectDAGNode> 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<WireDagTestEffect>(
+ fixture.ctx(), std::vector<std::string>{"mid"},
+ std::vector<std::string>{"sink"});
+ std::vector<EffectDAGNode> 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<WireDagTestEffect>(
+ fixture.ctx(), std::vector<std::string>{"source"},
+ std::vector<std::string>{"mid"});
+ auto downstream = std::make_shared<WireDagTestEffect>(
+ fixture.ctx(), std::vector<std::string>{"mid"},
+ std::vector<std::string>{"sink"});
+
+ class TestSequence : public Sequence {
+ public:
+ TestSequence(const GpuContext& ctx,
+ std::shared_ptr<Effect> up,
+ std::shared_ptr<Effect> 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");