OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / b66f3e9

density: a graph may declare its density and have no geometry at all
The CPU twin returned 0.0 at every point for a graph whose density is declared on its own Group Output interface, while the emitted GLSL produced a correct signed 3D field for the same graph. Now 100/100 on tests/test_emitted_density_parity.cpp, and the displacement-shaped graph stays 100/100. TWO CAUSES, and the first is why the second was invisible. 1. evaluate_density_batch ran the graph for GEOMETRY and returned early on a null result, BEFORE the density lookup. A graph that declares its density on its interface has no geometry chain -- that is the point of it -- so execute() legitimately returned nothing and every point came back zero. find_density_output was finding the socket perfectly well, a few lines further down than the function ever reached. Only the DISPLACEMENT path needs a mesh, so the check moves there. 2. evaluate_socket_value returned SocketValue::to_variant(), which has no field spelling: for SocketType::Float it hands back the scalar `f` whatever is_field says, so a per-point field collapses to one number. Its caller checks for a PACKED_FLOAT32_ARRAY that to_variant() cannot produce. It now materializes the value -- redeeming a lazy promise against the input geometry -- and returns the array for a Float field. materialize() returns a non-lazy value unchanged, so the scalar callers are untouched: ProcNodeGraph::evaluate_socket and the 770 op-parity cases behind it have no geometry and never set is_field. Measured 770/770 unchanged. I diagnosed 2 first and shipped it alone, and the gate stayed red because 1 was in front of it. The fix that finally moved the number came from instrumenting find_density_output rather than reasoning about the value it returned. Baselines all held: op parity 770/770, golden 31/32, batch 7/8 -- the last two failures predate this work. docs/output-terminals-design.md is the design this unblocks, rewritten today after auditing the Blender side: the terminal is FOUR NAMED OUTPUTS ON THE GROUP INTERFACE, not a node. A node cannot be the vehicle -- geometry_serializer.py inlines every GeometryNodeGroup unconditionally, and a custom node cannot be registered into a GeometryNodeTree at all. It belongs as step 7 of spec-graph-composition.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b66f3e9a6bda09117295d9ef6a754375139eb8b0
by plankatron <fleeky@gmail.com> · 2026-09-08T14:22:57.000Z

Changed files (3) +180 -5

docs/output-terminals-design.md +139 -0
……@@ -0,0 +1,139 @@
1# The graph declares its density on its own interface
2
3Status: DESIGN. Written 2026-09-08, rewritten the same day after auditing the
4Blender side.
5
6**This belongs as step 7 of `spec-graph-composition.md`** ("terminal return /
7volume composition", open). It is standalone only until someone folds it in.
8
9---
10
11## The problem
12
13A proc-nodes graph does not say what it is for, so every consumer guesses:
14
15| consumer | how it finds its terminal | where |
16|---|---|---|
17| `ProcGeometryNode` | last node with a Geometry output and no downstream connection — **else the last node in the array** | `find_output_node`, `proc_node_graph.cpp:310` |
18| the density path | a Group Output float, **or** a `VolumeToMesh` Density input | `find_density_output` |
19| the emitter | if neither, **"the displacement of `pos`"** | `compile_to_function` |
20
21Both defects found on 2026-09-08 are that last fallback. The bridged voronoi
22mountain ends in `Set Position` with an **offset**, so the emitted density was
23`pos.z - query_z` = `h` — a field with no dependence on the query height, hence
24no isosurface, hence no terrain. The graph meant "displace a mesh". Nothing
25could say otherwise.
26
27## The mechanism: the group interface, and nothing else
28
29**Blender's Group Output already takes any number of typed sockets.** Add
30outputs to the group interface and the modifier surfaces the non-Geometry ones
31as output attributes. That is ordinary authoring in stock Blender — no addon
32feature, no custom node.
33
34| interface output | type | meaning |
35|---|---|---|
36| `Density` | Float | the field. Positive inside, negative outside. |
37| `Bounds Min` | Vector | the volume this graph defines, in the graph's own frame |
38| `Bounds Max` | Vector | " |
39| `Voxel Size` | Float | authoring resolution — the finest detail the field carries |
40
41The serializer already crosses this. A Group Output node's `inputs` list IS the
42group's outputs, carrying name and type:
43
44 {"id": 1, "bl_type": "NodeGroupOutput", "proc_type": "GroupOutput",
45 "inputs": [{"name": "Geometry", "identifier": "Socket_20", "type": 6}]}
46
47Adding a Float there and wiring the field to it was measured end to end on
482026-09-08: `find_density_output` picked it up and the emitted GLSL produced a
49correct signed 3D field, 100/100 against the CPU on
50`tests/test_emitted_density_parity.cpp`.
51
52**Bridge cost: zero.** **Blender addon cost: zero.**
53
54`Voxel Size` is the AUTHORING resolution, not the runtime one.
55`DCGenerator::evaluate` takes a `lod_voxel_size` per call and that stays the
56mesher's business; conflating them asks a field for detail it never carried.
57
58## What was ruled out, and why
59
60**A terminal NODE, authored in Blender.** Cannot be built.
61`geometry_serializer.py:342` inlines every `GeometryNodeGroup` unconditionally
62and there is no name-based escape hatch, so a marker group dissolves into its
63contents on the way across. The 97 `ProcNodes_*` groups run the OTHER direction
64— `geometry_deserializer.py` builds a Blender graph FROM proc-nodes — so they
65are not a precedent for this. And a custom node cannot be registered into a
66`GeometryNodeTree` at all: 571 `bpy.types.Node` classes are registered, only 360
67are instantiable there, because geometry node types are Blender's C code.
68
69**Godot-only sockets on `GroupOutput`.** The pattern exists — `bl_type = ""`
70marks a Godot-only node (`graph_exporter.cpp:431`), and five sockets sit
71deliberately past Blender's last one — but it does not survive here:
72`GroupOutputNode::set_interface` does `input_defs.clear()` and rebuilds purely
73from the JSON, so an appended socket is wiped on every import. More
74fundamentally a WIRE into such a socket could never survive, because connections
75arrive wholesale from Blender. Declaring in Blender carries the socket AND the
76wire, on every re-bridge, for free.
77
78**A socket-NAME convention on an arbitrary node** (`spec-graph-composition.md`
79§2.5). Two problems the audit found. `"Density"` is already output socket 0 of
80`SphereSdfNode` and `VolumeBooleanNode` (`volume_nodes.cpp:11,:47`), so the
81convention is a coincidence any future node trips; and §2.5's own two halves
82contradict — it names `plan.output_node` as the terminal, but `find_output_node`
83only returns nodes that have a **Geometry output**, so a pure density terminal
84is invisible to it. Reading the graph's declared INTERFACE has neither problem:
85there is exactly one Group Output, and an interface carries as many named
86sockets as you like.
87
88**Appending the terminal in the Godot editor after import.** Destroyed on every
89re-bridge, which is the loop the bridge exists to make cheap.
90
91## What changes in code
92
93**proc-nodes.** `find_density_output` already finds a connected Group Output
94float; it gains preferring one NAMED `Density` over positional first-float. A
95new reader returns `Bounds Min` / `Bounds Max` / `Voxel Size` by name from the
96same node.
97
98⚠ **Bounds are in the graph's frame.** A bridged graph is Blender-frame
99(`is_blender_frame()`), so authored bounds are Blender coordinates and a
100consumer wants world. M is `(x, y, z) -> (-x, z, y)`, self-inverse — the same
101conversion `_to_godot_frame` applies to geometry and the emitter now applies to
102the query point. **A bounds pair converted by M is not sorted**, so min/max must
103be recomputed componentwise afterwards. Getting this wrong yields an inverted
104box, which reads as "the terrain is missing" rather than as a frame error.
105
106**dc-terrain.** A bounded generator that reads those through the reflection it
107already uses (`graph_->call(...)`, no compile-time dependency on proc-nodes) and
108returns the bounds from `DCGenerator::get_bounds` — which already exists and
109already defaults to `false` = unbounded. Bounded is not a new concept here; it
110is a virtual nobody has had reason to override.
111
112No rescaling anywhere: `evaluate` takes world coordinates and the graph declares
113world bounds, so it meshes at the size Blender says. The
114`terrain_height`/`floor_y`/`ceiling_y` mapping belongs to
115`DCTerrainGeneratorProcNodes`' planet-scale wrapper and is not on this path —
116that wrapper also pins `height_scale` to a lookup for a node titled
117`"Erosion Noise"`, which a bridged graph never has.
118
119## Prerequisite
120
121`evaluate_density_batch` returns **0 at every point** for a graph that declares
122its density through a Group Output float. `evaluate_socket_value` hands back a
123lazy field promise for a value cone rooted at `Position`, and the scalar branch
124casts it to zero. Measured 2026-09-08: GPU correct, CPU zero, on the same graph.
125
126This design makes that spelling the NORMAL one rather than an unused one, so it
127must be fixed first or every CPU-side answer is a silent zero.
128
129## Gating
130
131`tests/test_emitted_density_parity.cpp` asserts what matters: `proc_density(p)`
132equals `evaluate_density_batch([p])` at every p. A terminal changes which node
133the density is read from on both sides, so that gate covers the change by
134construction.
135
136Two assertions to add:
137
138- `get_bounds` after M matches the authored box, componentwise
139- a graph declaring `Density` twice on its interface is refused, not first-match
src/graph/graph_executor.cpp +22 -1
……@@ -1398,7 +1398,28 @@ Variant GraphExecutor::evaluate_socket_value(const ProcNodeGraph &p_graph,
13981398 if (target.is_null() || p_socket_idx < 0 || p_socket_idx >= target->get_output_count()) {
13991399 return Variant();
14001400 }
1401 return ctx.output_values[p_node_idx * max_outputs + p_socket_idx].to_variant();
1401 // ⛔ to_variant() HAS NO FIELD SPELLING. For SocketType::Float it returns the
1402 // scalar `f` whatever is_field says, so a per-point field collapses to one
1403 // number -- and for a value cone rooted at Position that number is 0.
1404 //
1405 // Measured 2026-09-08 on a graph declaring its density through a Group
1406 // Output float: the GPU emitted a correct signed 3D field and
1407 // evaluate_density_batch returned 0.0 at every one of 100 points. Its
1408 // caller checks for a PACKED_FLOAT32_ARRAY, which to_variant() cannot
1409 // produce, so the scalar branch cast the promise to zero. Both density
1410 // entry points read through here, and a graph that declares its density on
1411 // its own interface makes this the NORMAL path rather than an unused
1412 // spelling.
1413 //
1414 // materialize() returns a non-lazy value unchanged, so the scalar callers --
1415 // ProcNodeGraph::evaluate_socket and the 770 op-parity cases behind it,
1416 // which have no geometry and never set is_field -- are untouched.
1417 SocketValue sv = ctx.output_values[p_node_idx * max_outputs + p_socket_idx];
1418 sv = ctx.materialize(sv, ctx.input_geometry);
1419 if (sv.is_field && sv.type == SocketType::Float) {
1420 return sv.float_array;
1421 }
1422 return sv.to_variant();
14021423}
14031424
14041425// THE ONE CONVERSION, at the end, the way an exporter does it.
src/graph/proc_node_graph.cpp +19 -4
……@@ -585,10 +585,21 @@ PackedFloat32Array ProcNodeGraph::evaluate_density_batch(const PackedVector3Arra
585585 // with the shader rather than to be the shader.
586586 Ref<ProcGeometry> result = GraphExecutor::execute(*this, *_density_plan, overrides, false,
587587 nullptr, 0.016f, nullptr);
588 if (result.is_null()) {
589 return out;
590 }
591
588 // ⛔ A NULL GEOMETRY IS NOT A FAILURE FOR A GRAPH THAT DECLARES A DENSITY.
589 //
590 // This used to `return out` right here, BEFORE the density lookup below ever
591 // ran. A graph whose density is declared on its own Group Output interface
592 // has no geometry chain -- that is the point of it -- so execute()
593 // legitimately returns nothing and every point came back 0.0.
594 //
595 // Measured 2026-09-08 on the bridged voronoi mountain terminated that way:
596 // the emitted GLSL produced a correct signed 3D field while this returned
597 // zero at all 100 points, and find_density_output was finding the socket
598 // perfectly well a few lines further down than the function ever reached.
599 // The GPU and CPU halves of one graph disagreed completely, and the reason
600 // was an early return, not any arithmetic.
601 //
602 // Only the DISPLACEMENT path needs a mesh, so the check moves down to it.
592603 int out_node = -1, out_socket = -1;
593604 if (_density_output_socket(*this, out_node, out_socket)) {
594605 // ⛔ TWO BUGS IN ONE LINE, both fixed here, in BOTH density entry points.
……@@ -626,6 +637,10 @@ PackedFloat32Array ProcNodeGraph::evaluate_density_batch(const PackedVector3Arra
626637 return out;
627638 }
628639
640 if (result.is_null()) {
641 // No density terminal AND no geometry -- nothing to answer with.
642 return out;
643 }
629644 Ref<ProcMesh> out_mesh = result->get_mesh();
630645 if (out_mesh.is_null() || out_mesh->positions.size() != n) {
631646 // A graph that changed the point COUNT has no per-input answer, and