| … | … | @@ -0,0 +1,139 @@ |
| 1 | # The graph declares its density on its own interface |
| 2 | |
| 3 | Status: DESIGN. Written 2026-09-08, rewritten the same day after auditing the |
| 4 | Blender side. |
| 5 | |
| 6 | **This belongs as step 7 of `spec-graph-composition.md`** ("terminal return / |
| 7 | volume composition", open). It is standalone only until someone folds it in. |
| 8 | |
| 9 | --- |
| 10 | |
| 11 | ## The problem |
| 12 | |
| 13 | A 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 | |
| 21 | Both defects found on 2026-09-08 are that last fallback. The bridged voronoi |
| 22 | mountain 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 |
| 24 | no isosurface, hence no terrain. The graph meant "displace a mesh". Nothing |
| 25 | could 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 |
| 30 | outputs to the group interface and the modifier surfaces the non-Geometry ones |
| 31 | as output attributes. That is ordinary authoring in stock Blender — no addon |
| 32 | feature, 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 | |
| 41 | The serializer already crosses this. A Group Output node's `inputs` list IS the |
| 42 | group'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 | |
| 47 | Adding a Float there and wiring the field to it was measured end to end on |
| 48 | 2026-09-08: `find_density_output` picked it up and the emitted GLSL produced a |
| 49 | correct 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 |
| 56 | mesher'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 |
| 62 | and there is no name-based escape hatch, so a marker group dissolves into its |
| 63 | contents 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 |
| 65 | are 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 |
| 67 | are instantiable there, because geometry node types are Blender's C code. |
| 68 | |
| 69 | **Godot-only sockets on `GroupOutput`.** The pattern exists — `bl_type = ""` |
| 70 | marks a Godot-only node (`graph_exporter.cpp:431`), and five sockets sit |
| 71 | deliberately past Blender's last one — but it does not survive here: |
| 72 | `GroupOutputNode::set_interface` does `input_defs.clear()` and rebuilds purely |
| 73 | from the JSON, so an appended socket is wiped on every import. More |
| 74 | fundamentally a WIRE into such a socket could never survive, because connections |
| 75 | arrive wholesale from Blender. Declaring in Blender carries the socket AND the |
| 76 | wire, 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 |
| 81 | convention is a coincidence any future node trips; and §2.5's own two halves |
| 82 | contradict — it names `plan.output_node` as the terminal, but `find_output_node` |
| 83 | only returns nodes that have a **Geometry output**, so a pure density terminal |
| 84 | is invisible to it. Reading the graph's declared INTERFACE has neither problem: |
| 85 | there is exactly one Group Output, and an interface carries as many named |
| 86 | sockets as you like. |
| 87 | |
| 88 | **Appending the terminal in the Godot editor after import.** Destroyed on every |
| 89 | re-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 |
| 94 | float; it gains preferring one NAMED `Density` over positional first-float. A |
| 95 | new reader returns `Bounds Min` / `Bounds Max` / `Voxel Size` by name from the |
| 96 | same 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 |
| 100 | consumer wants world. M is `(x, y, z) -> (-x, z, y)`, self-inverse — the same |
| 101 | conversion `_to_godot_frame` applies to geometry and the emitter now applies to |
| 102 | the query point. **A bounds pair converted by M is not sorted**, so min/max must |
| 103 | be recomputed componentwise afterwards. Getting this wrong yields an inverted |
| 104 | box, 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 |
| 107 | already uses (`graph_->call(...)`, no compile-time dependency on proc-nodes) and |
| 108 | returns the bounds from `DCGenerator::get_bounds` — which already exists and |
| 109 | already defaults to `false` = unbounded. Bounded is not a new concept here; it |
| 110 | is a virtual nobody has had reason to override. |
| 111 | |
| 112 | No rescaling anywhere: `evaluate` takes world coordinates and the graph declares |
| 113 | world 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 — |
| 116 | that 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 |
| 122 | its density through a Group Output float. `evaluate_socket_value` hands back a |
| 123 | lazy field promise for a value cone rooted at `Position`, and the scalar branch |
| 124 | casts it to zero. Measured 2026-09-08: GPU correct, CPU zero, on the same graph. |
| 125 | |
| 126 | This design makes that spelling the NORMAL one rather than an unused one, so it |
| 127 | must 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)` |
| 132 | equals `evaluate_density_batch([p])` at every p. A terminal changes which node |
| 133 | the density is read from on both sides, so that gate covers the change by |
| 134 | construction. |
| 135 | |
| 136 | Two 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 |