OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 92f5cc8

gpu: pc.paramN meant two different things, and three nodes used both
hyper-games #65 NEXT item 2. It asked for an audit of the two batch-path nodes' push constants, on the grounds that nothing gates that the index a node reads is the slot the buffer manager fills. The audit found four defects, and the framing was off in both directions: Simplex Noise is not a batch-path node at all, and Set Position -- which is -- was broken worse than either. THE ROOT CAUSE IS ONE SENTENCE. `pc.paramN` means a push-constant SLOT to compile_batch, whose shader template declares the block and whose executor refills it every dispatch. It means SOCKET N to compile_to_function, which has no block and textually replaced `pc.paramN` with socket N's default. Two conventions, one spelling, and every node that used both was wrong in the density path. Measured on the emitted text, 2026-09-06: Displace vec3 npos = vec3(-pos.x, pos.z, pos.y) * 1.0; float n = bp_perlin_fbm_3(npos, float(int(5.0)), 4.0, 0.5, ... pos += normal * n * 0.0; Every argument one socket early -- Strength read as Scale, Scale as Detail, Detail as Roughness -- and `pc.param0` is socket 0, which is GEOMETRY and has no float literal, so the displacement was multiplied by zero. A no-op. SimplexNoise vec3 nsp = pos * 0.0; ... ps_fbm_noise_3d(nsp.x, nsp.y, nsp.z, int(5.0), 4.0, pc.param3) * 0.5 + 0.5; The same shift, and `pc.param3` addressed a fourth socket the node has not got, so the substitution never reached it and it survived VERBATIM into a function where `pc` is not declared. No density graph containing this node has ever compiled, which is also why fixing it can regress nothing. It also read the shader's `pos` where execute() reads socket 0. SetPosition pos += vec3(0.0, 0.0, 0.0); Sockets 0, 1 and 2 are Geometry, Bool and Vector; none has a float literal, so all three slots came out 0.0. A no-op. AND ONE THAT WAS LIVE ON THE GPU. GraphExecutor packed Set Position's block with `read_vector(node_idx, 1)`, commented "offset vector (socket 1)". Its sockets have been Blender's -- Geometry, Selection, Position, Offset -- since they were matched to Blender's, so socket 1 is SELECTION and the offset is socket 3. Every GPU-batched Set Position displaced by a boolean and ignored Offset entirely. The CPU path is a different function, so nothing could see it. THE FIX IS TO STATE THE MAPPING ONCE. ProcNode::get_push_constant_sockets() returns the input sockets the batch block carries, in slot order. GlslEmitContext::param() walks it to spell `pc.paramN`, GraphExecutor walks it to pack the bytes, and the per-type `if` in the executor is gone. Float, Int and Bool take one slot, Vector takes three. A node with no map -- Simplex Noise -- gets in() everywhere and behaves as if none of this existed. The two paths must still emit DIFFERENT text, and that is not cosmetic: the batch pipeline is cached in the ExecutionPlan and set_input_default() emits no `changed`, so a literal baked there would freeze at the value the plan was built with. GlslEmitContext::push_constants says which path is being emitted, and it is the only thing a node has to know. Displace sockets 1,2,3,4,5 -> param0..param4, the text it always emitted SetPosition socket 3 (Offset) -> param0..param2, likewise SimplexNoise no map; in() on sockets 0, 1, 2 and its two properties baked A value the shader cannot see now sends the node to the CPU. The batch shader's only channel is the push-constant block, so a non-geometry input that is connected and not in the map is a value the dispatch would silently ignore -- Set Position's Selection and Position are exactly that, and the CPU applies `new = Selection ? (Position + Offset) : old` per element. Falling back is the only answer that is not a different function. GATED, in demo/glsl_golden_gate.gd, 14/14 -> 24/24: H no emitted `pc.` anywhere 30 GPU-capable nodes, swept by ClassDB I the map names sockets that exist, are not Geometry, and fit 7 floats J the density path reads the right socket, proved with values nothing else has: scale 22, detail 33, roughness 44, lacunarity 55, strength 11 K the batch path still spells SLOTS, in the declared order, with no default baked in Five mutations, every one caught by the case that should catch it: Displace density back to pc.paramN slots Displace socket order Displace map, sockets 1 and 2 swapped Displace batch slots SetPosition reads socket 1 (the old packer) 3 cases, incl. batch slots SimplexNoise reads `pos`, not socket 0 SimplexNoise socket order SetPosition map with Geometry socket 0 map sanity + batch slots ⚠ CASE H CATCHES THE LEAK, NOT THE SHIFT, and the sweep is what said so. Reverting Displace to `pc.param1`/`pc.param2` leaves H green -- both indices are inside the socket count, so the substitution consumes them and the text is merely reading the wrong sockets. Only Simplex Noise's `pc.param3` was ever visible to it. J is what catches a shift. The docstring claiming otherwise was written before the sweep and is corrected in the file. ⚠ THE CPU FALLBACK IS NOT GATED. It lives in the dispatch path and reaching it needs a RenderingDevice, which the golden gate has not got. It is verified by reading only, and case I asserting that Selection and Position are absent from the map is the nearest thing to a proof there is. ProcNodeGraph::compile_batch_glsl() exists so the gate can read the batch text at all -- it was previously reachable only through a GPU dispatch, which is how the executor's socket index came to be stale with nothing to notice. Unmoved: golden map_dc_erosion_density 44140 chars byte-identical, emitted-op parity 770/770 on the RTX 2070, all three targets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92f5cc883fb777f53dd04d0a209fd3e52ebf2b4f
by plankatron <fleeky@gmail.com> · 2026-09-06T13:50:01.000Z

Changed files (13) +484 -31

demo/glsl_golden_gate.gd +176 -0
……@@ -117,6 +117,174 @@ func dump_value_graph(path: String) -> void:
117117 g.compile_to_glsl_function("proc_density", false, PackedStringArray()))
118118
119119
120## H. NO EMITTED DENSITY SHADER MAY REFERENCE `pc.`.
121##
122## compile_to_function produces a standalone `float proc_density(vec3 pos)`
123## with no push-constant block. compile_batch produces a compute shader that
124## HAS one. `pc.paramN` in a node's GLSL means a push-constant SLOT to the
125## second and, through a textual substitution in the first, SOCKET N -- two
126## conventions, and every node that used both was wrong in the density path.
127## Measured 2026-09-06: Displace shifted every argument one socket and
128## multiplied its displacement by 0.0, Set Position emitted `pos += vec3(0,0,0)`,
129## and Simplex Noise left `pc.param3` in a function where `pc` does not exist,
130## so the shader could not compile at all.
131##
132## The sweep is over EVERY instantiable node that claims GPU support, not the
133## three that were broken, because the next node to be written is the one this
134## has to catch.
135##
136## ⚠ IT CATCHES THE LEAK, NOT THE SHIFT, and the mutation sweep is what said so.
137## Reverting Displace to `pc.param1`/`pc.param2` leaves this case GREEN: both
138## indices are inside the node's socket count, so compile_to_function's textual
139## substitution consumes them and nothing survives to be found -- the emitted
140## text is merely reading the wrong sockets. Only Simplex Noise's `pc.param3`
141## was visible here, because it addressed a fourth socket the node has not got.
142## Case J is what catches the shift. This one is the guard against the next
143## node that writes a slot number it has no socket for.
144func _no_push_constants_anywhere() -> void:
145 var checked := 0
146 var offenders: Array[String] = []
147 for cls in ClassDB.get_inheriters_from_class("ProcNode"):
148 if not ClassDB.can_instantiate(cls):
149 continue
150 var n: ProcNode = ClassDB.instantiate(cls)
151 if n == null or not n.supports_gpu():
152 continue
153 var g := ProcNodeGraph.new()
154 g.add_node(n)
155 var t: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
156 if t.is_empty():
157 continue # refusal is case B's subject, not this one
158 checked += 1
159 if t.find("pc.") != -1:
160 offenders.append("%s (%s)" % [cls, n.get_node_type()])
161 _check("no emitted `pc.` anywhere", offenders.is_empty(),
162 "%d GPU-capable nodes emit, none references pc." % checked if offenders.is_empty()
163 else "%d of %d leave a push-constant reference: %s"
164 % [offenders.size(), checked, ", ".join(offenders)])
165
166
167## I. THE SLOT A NODE READS IS THE SLOT THE EXECUTOR FILLS.
168##
169## GlslEmitContext::param() and GraphExecutor both walk
170## get_push_constant_sockets(), so they cannot disagree -- but the MAP itself
171## can still be wrong, and it was: Set Position's packer read socket 1 and
172## called it the offset vector, while socket 1 has been SELECTION since its
173## sockets were matched to Blender's. So assert the map names sockets that
174## exist, are not Geometry, and fit the seven-float block.
175func _push_constant_map_is_sane(cls: String) -> void:
176 var n: ProcNode = ClassDB.instantiate(cls)
177 var map: PackedInt32Array = n.get_push_constant_sockets()
178 var slots := 0
179 var bad := ""
180 for sock in map:
181 if sock < 0 or sock >= n.get_input_count():
182 bad = "socket %d does not exist" % sock
183 break
184 if n.get_input_type(sock) == 6: # Geometry
185 bad = "socket %d is Geometry and cannot cross" % sock
186 break
187 slots += 3 if n.get_input_type(sock) == 3 else 1
188 if bad.is_empty() and slots > 7:
189 bad = "%d slots, block holds 7" % slots
190 _check("%s push-constant map" % n.get_node_type(), bad.is_empty(),
191 "%d socket(s), %d slot(s)" % [map.size(), slots] if bad.is_empty() else bad)
192
193
194## J. THE DENSITY PATH READS THE RIGHT SOCKET, socket by socket.
195##
196## A wrong index emits text that still compiles and still looks like noise, so
197## the only way to see it is to give each socket a value nothing else has and
198## look for it where it belongs. These are the values that were shifted.
199func _reads_the_right_sockets() -> void:
200 var g := ProcNodeGraph.new()
201 var d := g.add_node(DisplaceNode.new())
202 g.get_node(d).set_input_default(1, 11.0) # Strength
203 g.get_node(d).set_input_default(2, 22.0) # Scale
204 g.get_node(d).set_input_default(3, 33) # Detail (Int)
205 g.get_node(d).set_input_default(4, 44.0) # Roughness
206 g.get_node(d).set_input_default(5, 55.0) # Lacunarity
207 var t: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
208 var body := t.substr(max(0, t.find("float proc_density")))
209 _check("Displace socket order",
210 body.find("pos.y) * 22.0") != -1
211 and body.find("float(int(33))") != -1
212 and body.find("33)), 44.0, 55.0,") != -1
213 and body.find("* n * 11.0;") != -1,
214 "scale 22, detail 33, roughness 44, lacunarity 55, strength 11")
215
216 var g2 := ProcNodeGraph.new()
217 var sp := g2.add_node(SetPositionNode.new())
218 g2.get_node(sp).set_input_default(3, Vector3(1.5, 2.5, 3.5)) # Offset
219 var t2: String = g2.compile_to_glsl_function("proc_density", false, PackedStringArray())
220 _check("SetPosition reads Offset (3)",
221 t2.find("pos += vec3(1.5") != -1,
222 "the Offset default reaches the emitted text")
223
224 # Selection is socket 1. It used to be what the executor packed as the
225 # offset, so a gate that only checked Offset would still pass with the old
226 # index if Offset happened to be zero. Set BOTH, and only Offset may appear.
227 var g3 := ProcNodeGraph.new()
228 var sp3 := g3.add_node(SetPositionNode.new())
229 g3.get_node(sp3).set_input_default(2, Vector3(9.75, 9.75, 9.75)) # Position
230 g3.get_node(sp3).set_input_default(3, Vector3(4.25, 5.25, 6.25)) # Offset
231 var t3: String = g3.compile_to_glsl_function("proc_density", false, PackedStringArray())
232 _check("SetPosition ignores Position (2)",
233 t3.find("4.25") != -1 and t3.find("9.75") == -1,
234 "Offset crosses, the unconnected Position does not")
235
236 var g4 := ProcNodeGraph.new()
237 var sx := g4.add_node(NoiseTextureNode.new())
238 g4.get_node(sx).set_input_default(0, Vector3(0.5, 0.25, 0.125)) # Vector
239 g4.get_node(sx).set_input_default(1, 66.0) # Scale
240 g4.get_node(sx).set_input_default(2, 7.0) # Detail
241 var t4: String = g4.compile_to_glsl_function("proc_density", false, PackedStringArray())
242 _check("SimplexNoise socket order",
243 t4.find("vec3(0.5, 0.25, 0.125) * 66.0") != -1
244 and t4.find("int(7.0)") != -1,
245 "Vector 0, Scale 1, Detail 2 -- and it reads socket 0, not `pos`")
246
247
248## K. THE BATCH PATH STILL SPELLS SLOTS, and the right ones.
249##
250## The density and batch paths must emit DIFFERENT text from the same node:
251## a literal or a producer variable in one, `pc.paramN` in the other, because
252## compile_batch's pipeline is cached in the ExecutionPlan and
253## set_input_default() emits no `changed` -- a baked literal there would freeze
254## at whatever the value was when the plan was built.
255##
256## So this asserts the opposite of case H on the same nodes: the batch text MUST
257## carry push constants, in the slots get_push_constant_sockets() declares.
258## Displace's five sockets are param0..param4 in socket order; Set Position's
259## single Vector socket is param0..param2. Both are the text these nodes
260## emitted before the map existed, which is what makes the change a relocation
261## on this side and a fix on the other.
262func _batch_still_uses_push_constants() -> void:
263 var g := ProcNodeGraph.new()
264 var d := g.add_node(DisplaceNode.new())
265 g.get_node(d).set_input_default(1, 11.0)
266 g.get_node(d).set_input_default(2, 22.0)
267 var bt: String = g.compile_batch_glsl(PackedInt32Array([d]))
268 # Slot order is SOCKET order: strength(1)->param0, scale(2)->param1,
269 # detail(3)->param2, roughness(4)->param3, lacunarity(5)->param4.
270 _check("Displace batch slots",
271 bt.find("pos.y) * pc.param1") != -1
272 and bt.find("float(int(pc.param2))") != -1
273 and bt.find("pc.param3, pc.param4,") != -1
274 and bt.find("* n * pc.param0;") != -1
275 and bt.find("11.0") == -1 and bt.find("22.0") == -1,
276 "five slots in socket order, and no default baked in")
277
278 var g2 := ProcNodeGraph.new()
279 var sp := g2.add_node(SetPositionNode.new())
280 g2.get_node(sp).set_input_default(3, Vector3(4.25, 5.25, 6.25))
281 var bt2: String = g2.compile_batch_glsl(PackedInt32Array([sp]))
282 _check("SetPosition batch slots",
283 bt2.find("pos += vec3(pc.param0, pc.param1, pc.param2);") != -1
284 and bt2.find("4.25") == -1,
285 "Offset occupies param0..2, and its default is not baked in")
286
287
120288func _init() -> void:
121289 for a in OS.get_cmdline_user_args():
122290 if a == "--write":
……@@ -256,5 +424,13 @@ func _init() -> void:
256424 var mt2: String = mg2.compile_to_glsl_function("proc_density", true, empty)
257425 _check("vector Mix emits", not mt2.is_empty(), "emission accepted")
258426
427 # H, I, J -- the push-constant audit. See the functions above.
428 _no_push_constants_anywhere()
429 _push_constant_map_is_sane("DisplaceNode")
430 _push_constant_map_is_sane("SetPositionNode")
431 _push_constant_map_is_sane("NoiseTextureNode")
432 _reads_the_right_sockets()
433 _batch_still_uses_push_constants()
434
259435 print("\n%d/%d passed" % [_ran - _fail, _ran])
260436 quit(1 if _fail > 0 else 0)
src/gpu/glsl_emit_context.cpp +40 -0
……@@ -59,6 +59,46 @@ String GlslEmitContext::literal(int p_node_idx, int p_socket) const {
5959 }
6060}
6161
62String GlslEmitContext::param(int p_node_idx, int p_socket) const {
63 if (!push_constants || graph == nullptr) {
64 return in(p_node_idx, p_socket);
65 }
66 Ref<ProcNode> node = graph->get_node(p_node_idx);
67 if (node.is_null()) {
68 return in(p_node_idx, p_socket);
69 }
70 const PackedInt32Array map = node->get_push_constant_sockets();
71
72 // Walk the map summing widths. A Vector takes three slots; everything else
73 // takes one. This is the same walk GraphExecutor does when it packs, and
74 // deliberately so -- a second spelling of it is the drift this ends.
75 int slot = 0;
76 for (int i = 0; i < map.size(); i++) {
77 const int sock = map[i];
78 const int width = (node->get_input_type(sock) == (int)SocketType::Vector) ? 3 : 1;
79 if (sock == p_socket) {
80 // The block is element_count + seven floats. A map that overruns it
81 // would silently read a neighbour's slot, so say so and fall back to
82 // the literal rather than emit a wrong address.
83 if (slot + width > 7) {
84 UtilityFunctions::push_error(
85 "proc-nodes: node ", p_node_idx, " socket ", p_socket,
86 " is declared at push-constant slot ", slot, " width ", width,
87 ", past the seven-float block. Emitting its default instead.");
88 return in(p_node_idx, p_socket);
89 }
90 if (width == 3) {
91 return String("vec3(pc.param") + itos(slot) + ", pc.param" + itos(slot + 1) +
92 ", pc.param" + itos(slot + 2) + ")";
93 }
94 return String("pc.param") + itos(slot);
95 }
96 slot += width;
97 }
98 // Not declared: an ordinary input.
99 return in(p_node_idx, p_socket);
100}
101
62102String GlslEmitContext::out(int p_node_idx, int p_socket) const {
63103 return String("n") + itos(p_node_idx) + "_s" + itos(p_socket);
64104}
src/gpu/glsl_emit_context.h +45 -0
……@@ -40,11 +40,56 @@ struct GlslEmitContext {
4040 // density path passes true; the batch path does not have that uniform.
4141 bool clamp_octaves = false;
4242
43 // WHICH PATH IS BEING EMITTED, and it is not cosmetic.
44 //
45 // compile_batch produces a compute shader with a push-constant block that
46 // GraphExecutor refills on every dispatch, so a scalar a node reads from a
47 // socket must arrive through `pc.paramN` -- the pipeline is cached in the
48 // ExecutionPlan and set_input_default() emits no `changed`, so a literal
49 // baked into that shader would freeze at the value it had when the plan was
50 // built. compile_to_function produces a standalone `float proc_density`
51 // with NO push-constant block at all, so the same `pc.paramN` there is a
52 // reference to something that does not exist.
53 //
54 // The two used to be reconciled by compile_to_function TEXTUALLY replacing
55 // `pc.paramN` with socket N's default. That is a different convention from
56 // the batch path's, where N is a push-constant SLOT the executor packs by
57 // hand, and the two collided on every node that used both. Measured
58 // 2026-09-06 on the emitted density text:
59 //
60 // Displace every argument shifted one socket, and `* pc.param0`
61 // became `* 0.0` (socket 0 is Geometry, which has no
62 // literal) -- the displacement multiplied by zero
63 // SimplexNoise the same shift, plus `pc.param3` left VERBATIM because
64 // the node has only three sockets, in a function where `pc`
65 // is not declared: the shader cannot compile
66 // SetPosition `pos += vec3(0.0, 0.0, 0.0)`
67 //
68 // So the choice is made once, here, and `param()` below is the only place a
69 // node has to know about it.
70 bool push_constants = false;
71
4372 // The expression for an input socket. Connected -> the producer's variable,
4473 // coerced if the types differ; unconnected -> the socket default as a
4574 // literal.
4675 String in(int p_node_idx, int p_socket) const;
4776
77 // An input socket that the BATCH path carries in the push-constant block.
78 //
79 // push_constants -> "pc.param2", or "vec3(pc.param0, pc.param1, pc.param2)"
80 // otherwise -> in(node, socket), the same as any other input
81 //
82 // The slot is computed by walking the node's own get_push_constant_sockets()
83 // and summing the widths, and GraphExecutor packs by walking the SAME list.
84 // One list, so the slot a node reads and the slot the executor fills cannot
85 // drift -- which they had, on Set Position, for as long as its sockets have
86 // matched Blender's (see graph_executor.cpp).
87 //
88 // A socket the node does not declare falls through to in(): a node with no
89 // map at all -- Simplex Noise is one, it is not a geometry node and never
90 // reaches compile_batch -- behaves exactly as if this did not exist.
91 String param(int p_node_idx, int p_socket) const;
92
4893 // The variable an output socket is written to, and its declaration.
4994 String out(int p_node_idx, int p_socket) const;
5095 String declare(int p_node_idx, int p_socket) const;
src/gpu/gpu_compiler.cpp +16 -3
……@@ -158,6 +158,9 @@ String GpuCompiler::compile_batch(
158158 // emitted as plain literals here.
159159 GlslEmitContext ctx;
160160 ctx.clamp_octaves = false;
161 // This path HAS the push-constant block the template declares, and
162 // GraphExecutor refills it on every dispatch. See GlslEmitContext.
163 ctx.push_constants = true;
161164 ctx.graph = p_graph;
162165
163166 String main_body;
……@@ -254,9 +257,19 @@ String GpuCompiler::compile_to_function(
254257 needs_noise = true;
255258 }
256259
257 // Replace pc.paramN with literal defaults PER-NODE before appending.
258 // Each node uses pc.param0..6 for its own sockets — replacing globally
259 // would clobber other nodes' parameters.
260 // ⛔ THE TEXTUAL pc.paramN SUBSTITUTION BELOW IS A LEGACY FALLBACK AND
261 // MUST NOT BE REACHED BY A NODE THAT IS STILL MAINTAINED.
262 //
263 // It replaces `pc.paramN` with SOCKET N's default, which is a different
264 // convention from the batch path's, where N is a push-constant SLOT.
265 // Every node that used both conventions was silently wrong here:
266 // Displace shifted every argument by one socket and multiplied its
267 // displacement by 0.0, Set Position emitted `pos += vec3(0,0,0)`, and
268 // Simplex Noise left `pc.param3` verbatim in a function with no `pc`.
269 // All three now emit through GlslEmitContext::param()/in() and never
270 // reach this loop. It is kept only so a node nobody has migrated
271 // degrades the way it always did rather than emitting a dangling `pc.`,
272 // and glsl_golden_gate.gd asserts that no GPU-capable node leaves one.
260273 for (int s = 0; s < node->get_input_count() && s < 7; s++) {
261274 const SocketDef &def = node->get_input_def(s);
262275 String param_name = "pc.param" + itos(s);
src/graph/graph_executor.cpp +72 -22
……@@ -904,6 +904,36 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p
904904 continue;
905905 }
906906
907 // A VALUE THE SHADER CANNOT SEE MEANS THE CPU RUNS THIS NODE.
908 //
909 // The batch shader's only channel for a socket is the push-constant
910 // block, so a non-geometry input that is CONNECTED and not in the
911 // node's map is a value the dispatch would silently ignore. Set
912 // Position's Selection and Position are exactly that: the CPU applies
913 // `new = Selection ? (Position + Offset) : old` per element, and the
914 // shader can express none of it. Falling back is the only answer that
915 // is not a different function.
916 {
917 const PackedInt32Array pc_map = node->get_push_constant_sockets();
918 bool unrepresentable = false;
919 for (int si = 0; si < node->get_input_count(); si++) {
920 if (node->get_input_type(si) == (int)SocketType::Geometry) {
921 continue;
922 }
923 if (pc_map.has(si)) {
924 continue;
925 }
926 if (p_ctx.is_input_connected(node_idx, si)) {
927 unrepresentable = true;
928 break;
929 }
930 }
931 if (unrepresentable) {
932 node->execute(p_ctx, node_idx);
933 continue;
934 }
935 }
936
907937 // Read input geometry (socket 0 for Displace and SetPosition)
908938 Ref<ProcGeometry> input_geom = p_ctx.read_geometry(node_idx, 0);
909939 if (input_geom.is_null() || input_geom->get_mesh().is_null()) {
……@@ -1011,28 +1041,48 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p
10111041 uint32_t count = (uint32_t)vert_count;
10121042 memcpy(push_ptr, &count, 4);
10131043
1014 // Pack node-specific parameters
1015 if (type == StringName("Displace")) {
1016 // DisplaceNode: param0=strength, param1=scale, param2=detail,
1017 // param3=roughness, param4=lacunarity
1018 float strength = p_ctx.read_float(node_idx, 1);
1019 float noise_scale = p_ctx.read_float(node_idx, 2);
1020 float octaves = (float)p_ctx.read_int(node_idx, 3);
1021 float roughness = p_ctx.read_float(node_idx, 4);
1022 float lacunarity = p_ctx.read_float(node_idx, 5);
1023
1024 memcpy(push_ptr + 4, &strength, 4);
1025 memcpy(push_ptr + 8, &noise_scale, 4);
1026 memcpy(push_ptr + 12, &octaves, 4);
1027 memcpy(push_ptr + 16, &roughness, 4);
1028 memcpy(push_ptr + 20, &lacunarity, 4);
1029 } else if (type == StringName("SetPosition")) {
1030 // SetPositionNode: offset vector (socket 1)
1031 Vector3 offset = p_ctx.read_vector(node_idx, 1);
1032
1033 memcpy(push_ptr + 4, &offset.x, 4);
1034 memcpy(push_ptr + 8, &offset.y, 4);
1035 memcpy(push_ptr + 12, &offset.z, 4);
1044 // PACK FROM THE NODE'S OWN MAP, not from a per-type `if`.
1045 //
1046 // GlslEmitContext::param() walks this same list to decide which
1047 // `pc.paramN` the node's GLSL reads, so the slot written here and the
1048 // slot read there are the same walk over the same list.
1049 //
1050 // ⛔ THEY WERE NOT. This block used to name sockets by hand, and Set
1051 // Position's line read socket 1 and called it "the offset vector". Its
1052 // sockets have been Blender's -- Geometry, Selection, Position, Offset
1053 // -- since they were matched to Blender's, so socket 1 is SELECTION and
1054 // the offset is socket 3. Every GPU-batched Set Position was
1055 // displacing by a boolean and ignoring Offset entirely, and nothing
1056 // could see it because the CPU path is a different function.
1057 //
1058 // Float, Int and Bool take one slot; Vector takes three. Nothing else
1059 // can cross, which is what the connectivity refusal above enforces.
1060 {
1061 const PackedInt32Array pc_map = node->get_push_constant_sockets();
1062 int slot = 0;
1063 for (int m = 0; m < pc_map.size(); m++) {
1064 const int sock = pc_map[m];
1065 const int stype = node->get_input_type(sock);
1066 if (stype == (int)SocketType::Vector) {
1067 if (slot + 3 > 7) {
1068 break;
1069 }
1070 const Vector3 v = p_ctx.read_vector(node_idx, sock);
1071 const float comp[3] = { (float)v.x, (float)v.y, (float)v.z };
1072 for (int c = 0; c < 3; c++, slot++) {
1073 memcpy(push_ptr + 4 + slot * 4, &comp[c], 4);
1074 }
1075 } else {
1076 if (slot + 1 > 7) {
1077 break;
1078 }
1079 const float f = (stype == (int)SocketType::Int)
1080 ? (float)p_ctx.read_int(node_idx, sock)
1081 : p_ctx.read_float(node_idx, sock);
1082 memcpy(push_ptr + 4 + slot * 4, &f, 4);
1083 slot++;
1084 }
1085 }
10361086 }
10371087
10381088 uint64_t t_dispatch = Time::get_singleton()->get_ticks_usec();
src/graph/proc_node.cpp +4 -0
……@@ -6,6 +6,10 @@ void ProcNode::_bind_methods() {
66 ClassDB::bind_method(D_METHOD("get_node_type"), &ProcNode::get_node_type);
77 ClassDB::bind_method(D_METHOD("get_node_title"), &ProcNode::get_node_title);
88 ClassDB::bind_method(D_METHOD("supports_gpu"), &ProcNode::supports_gpu);
9 // Bound so glsl_golden_gate.gd can assert that the slot a node's GLSL reads
10 // is the slot GraphExecutor packs -- the drift this map exists to end is
11 // only visible if the gate can see the map.
12 ClassDB::bind_method(D_METHOD("get_push_constant_sockets"), &ProcNode::get_push_constant_sockets);
913 ClassDB::bind_method(D_METHOD("get_input_count"), &ProcNode::get_input_count);
1014 ClassDB::bind_method(D_METHOD("get_output_count"), &ProcNode::get_output_count);
1115 ClassDB::bind_method(D_METHOD("get_graph_position"), &ProcNode::get_graph_position);
src/graph/proc_node.h +20 -0
……@@ -4,6 +4,7 @@
44#include "socket_value.h"
55#include "../gpu/glsl_emit_context.h"
66#include <godot_cpp/classes/resource.hpp>
7#include <godot_cpp/variant/packed_int32_array.hpp>
78#include <godot_cpp/variant/vector2.hpp>
89
910namespace godot {
……@@ -101,6 +102,25 @@ public:
101102 // did -- see docs/glsl-emitter-design.md.
102103 virtual String compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const { return ""; }
103104
105 // THE INPUT SOCKETS THIS NODE CARRIES IN THE BATCH PATH'S PUSH-CONSTANT
106 // BLOCK, in slot order. Float, Int and Bool take one slot; Vector takes
107 // three. Empty means the node has none -- which is every node that is not a
108 // GPU GEOMETRY node, because compile_batch is only ever reached through
109 // _is_gpu_geometry_node().
110 //
111 // This list is the ONLY statement of the mapping. GlslEmitContext::param()
112 // walks it to spell `pc.paramN`, and GraphExecutor walks it to pack the
113 // bytes, so the slot a node reads is the slot the executor fills BY
114 // CONSTRUCTION. It used to be stated twice, in the node's GLSL literal and
115 // in a per-type `if` in the executor, and they drifted: Set Position's
116 // packer read socket 1 -- which has been SELECTION since its sockets were
117 // matched to Blender's -- and called it the offset vector.
118 //
119 // A socket left out of this list cannot reach the batch shader at all, so
120 // GraphExecutor falls back to the CPU when one of them is connected rather
121 // than dispatching a shader that cannot see it.
122 virtual PackedInt32Array get_push_constant_sockets() const { return PackedInt32Array(); }
123
104124 // TWO DIFFERENT REASONS TO EMIT NOTHING, and conflating them is what made
105125 // the emitter drop nodes silently. A node with missing arithmetic must make
106126 // emission refuse; a node that is STRUCTURALLY TRANSPARENT -- Group Input
src/graph/proc_node_graph.cpp +15 -0
……@@ -155,6 +155,7 @@ void ProcNodeGraph::_bind_methods() {
155155 ClassDB::bind_method(D_METHOD("get_input_link_count", "node_idx", "socket_idx"),
156156 &ProcNodeGraph::get_input_link_count);
157157 ClassDB::bind_method(D_METHOD("compile_to_glsl_function", "function_name", "clamp_octaves", "skip_types"), &ProcNodeGraph::compile_to_glsl_function, DEFVAL("proc_density"), DEFVAL(false), DEFVAL(PackedStringArray()));
158 ClassDB::bind_method(D_METHOD("compile_batch_glsl", "node_indices"), &ProcNodeGraph::compile_batch_glsl);
158159}
159160
160161int ProcNodeGraph::add_node(const Ref<ProcNode> &p_node) {
……@@ -336,6 +337,20 @@ int ProcNodeGraph::find_output_node() const {
336337 return nodes.size() > 0 ? nodes.size() - 1 : -1;
337338}
338339
340String ProcNodeGraph::compile_batch_glsl(const PackedInt32Array &p_node_indices) const {
341 Vector<Ref<ProcNode>> batch_nodes;
342 Vector<int> batch_indices;
343 for (int i = 0; i < p_node_indices.size(); i++) {
344 const int idx = p_node_indices[i];
345 if (idx < 0 || idx >= nodes.size() || nodes[idx].is_null()) {
346 continue;
347 }
348 batch_nodes.push_back(nodes[idx]);
349 batch_indices.push_back(idx);
350 }
351 return GpuCompiler::compile_batch(batch_nodes, batch_indices, this);
352}
353
339354String ProcNodeGraph::compile_to_glsl_function(const String &p_function_name,
340355 bool p_clamp_octaves, const PackedStringArray &p_skip_types) const {
341356 // TOPOLOGICAL ORDER, not graph index order. A value cannot be read before it
src/graph/proc_node_graph.h +10 -0
……@@ -173,6 +173,16 @@ public:
173173 bool p_clamp_octaves = false,
174174 const PackedStringArray &p_skip_types = PackedStringArray()) const;
175175
176 // THE BATCH PATH'S TEXT, FOR THE GATE.
177 //
178 // compile_batch's output is otherwise reachable only through a GPU
179 // dispatch, so the one path where `pc.paramN` is CORRECT had no instrument
180 // that could read it -- which is how Set Position's packer came to read
181 // socket 1 and call it the offset vector, for as long as socket 1 has been
182 // Selection. This returns the same String GraphExecutor hands to
183 // create_pipeline. It compiles nothing and touches no RenderingDevice.
184 String compile_batch_glsl(const PackedInt32Array &p_node_indices) const;
185
176186 // Access
177187 const Vector<Ref<ProcNode>> &get_nodes() const { return nodes; }
178188
src/nodes/geometry_nodes.cpp +16 -1
……@@ -186,7 +186,22 @@ void SetPositionNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
186186}
187187
188188String SetPositionNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const {
189 return "pos += vec3(pc.param0, pc.param1, pc.param2);";
189 // Offset is socket 3. In the batch path param() spells it
190 // `vec3(pc.param0, pc.param1, pc.param2)` -- the same text as before -- and
191 // in the density path it becomes the socket's literal or the producer's
192 // variable.
193 //
194 // ⛔ THE DENSITY PATH EMITTED `pos += vec3(0.0, 0.0, 0.0);`. The old text
195 // named push-constant SLOTS, and compile_to_function replaced `pc.paramN`
196 // with socket N's default: socket 0 is Geometry and sockets 1 and 2 are
197 // Bool and Vector, none of which has a float literal, so all three came out
198 // 0.0. Set Position was a no-op in every emitted density shader.
199 //
200 // It matches execute() only while Selection and Position are unconnected --
201 // `base = has_position ? pos_val : pos[i]`, and unconnected Position means
202 // "keep the position this element has", which is what `pos +=` does.
203 // GraphExecutor refuses the batch when either is wired.
204 return String("pos += ") + p_ctx.param(p_node_idx, 3) + ";";
190205}
191206
192207// --- JoinGeometryNode ---
src/nodes/geometry_nodes.h +12 -0
……@@ -37,6 +37,18 @@ public:
3737 String get_node_category() const override { return "Geometry"; }
3838 bool supports_gpu() const override { return true; }
3939
40 // Offset ONLY -- socket 3, three floats in param0..param2.
41 //
42 // Selection (1) and Position (2) are deliberately absent: the batch shader
43 // has no way to express a per-element selection or an implicit position
44 // field, so GraphExecutor falls back to the CPU when either is connected
45 // rather than dispatching a shader that silently ignores them.
46 PackedInt32Array get_push_constant_sockets() const override {
47 PackedInt32Array m;
48 m.push_back(3);
49 return m;
50 }
51
4052 void execute(ProcEvalContext &p_ctx, int p_node_idx) override;
4153 String compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const override;
4254
src/nodes/noise_nodes.cpp +50 -5
……@@ -260,8 +260,36 @@ String NoiseTextureNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_i
260260 // noise_fbm() from the hand-written library, whose C++ twin was a
261261 // file-local copy in this file -- 240000/240000 identical, measured, which
262262 // is what made deleting the copy a relocation and not a change.
263 return "vec3 nsp = pos * pc.param0;\n\tfloat noise_val = ps_fbm_noise_3d(nsp.x, nsp.y, "
264 "nsp.z, int(pc.param1), pc.param2, pc.param3) * 0.5 + 0.5;";
263 //
264 // ⛔ THIS USED TO EMIT `pc.paramN` AND IT COULD NOT COMPILE. The node is
265 // not a geometry node, so it never reaches compile_batch and there is no
266 // push-constant block anywhere it can appear. In the density path
267 // compile_to_function replaced `pc.paramN` with socket N's default, which
268 // is a different convention -- so `pc.param0` became socket 0 (Vector,
269 // which has no float literal) = 0.0, `pc.param1` became Scale, `pc.param2`
270 // became Detail, and `pc.param3` addressed a fourth socket that does not
271 // exist and was therefore LEFT VERBATIM. Measured 2026-09-06:
272 //
273 // vec3 nsp = pos * 0.0;
274 // float noise_val = ps_fbm_noise_3d(nsp.x, nsp.y, nsp.z, int(5.0), 4.0,
275 // pc.param3) * 0.5 + 0.5;
276 //
277 // A shader containing that cannot be compiled, so no density graph using
278 // this node has ever worked -- which is also why fixing it can regress
279 // nothing.
280 //
281 // It reads socket 0, NOT the shader's `pos`. execute() reads
282 // read_vector(idx, 0), and the two paths agreeing is the point; an
283 // unconnected Vector socket is (0,0,0) on both, which looks surprising and
284 // is what the CPU does.
285 //
286 // Detail is a FLOAT socket truncated by int(), matching
287 // `static_cast<int>(read_float(...))`, so it is not subject to the octave
288 // clamp -- that applies to Int sockets, and this one is not.
289 return String("vec3 nsp = ") + p_ctx.in(p_node_idx, 0) + " * " +
290 p_ctx.in(p_node_idx, 1) + ";\n\tfloat noise_val = ps_fbm_noise_3d(nsp.x, nsp.y, "
291 "nsp.z, int(" + p_ctx.in(p_node_idx, 2) + "), " + String::num(roughness, 9) + ", " +
292 String::num(lacunarity, 9) + ") * 0.5 + 0.5;";
265293}
266294
267295// --- DisplaceNode ---
……@@ -339,9 +367,26 @@ String DisplaceNode::compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx)
339367 // It used to be perlin_fbm() from the hand-written library, against a
340368 // file-local C++ Perlin with its own Jenkins hash -- 660000/660000
341369 // identical, measured, so this is a relocation and not a change.
342 return "vec3 npos = vec3(-pos.x, pos.z, pos.y) * pc.param1;\n\tfloat n = "
343 "bp_perlin_fbm_3(npos, float(int(pc.param2)), pc.param3, pc.param4, true) * 2.0 - "
344 "1.0;\n\tpos += normal * n * pc.param0;";
370 // Sockets: 1 Strength, 2 Scale, 3 Detail, 4 Roughness, 5 Lacunarity, and
371 // get_push_constant_sockets() declares them in exactly that order, so in
372 // the batch path this emits the same text it always did -- param0 strength,
373 // param1 scale, param2 detail, param3 roughness, param4 lacunarity.
374 //
375 // ⛔ IN THE DENSITY PATH IT DID NOT. compile_to_function replaced
376 // `pc.paramN` with socket N's default, one convention against the other, so
377 // every argument arrived one socket early and `* pc.param0` -- the strength
378 // -- became socket 0, which is GEOMETRY and has no float literal, so it was
379 // emitted as `0.0`. Measured 2026-09-06:
380 //
381 // vec3 npos = vec3(-pos.x, pos.z, pos.y) * 1.0; // Strength as Scale
382 // float n = bp_perlin_fbm_3(npos, float(int(5.0)), 4.0, 0.5, true) ...
383 // pos += normal * n * 0.0; // displacement x 0
384 //
385 // Displace was a silent no-op in every emitted density shader.
386 return String("vec3 npos = vec3(-pos.x, pos.z, pos.y) * ") + p_ctx.param(p_node_idx, 2) +
387 ";\n\tfloat n = bp_perlin_fbm_3(npos, float(int(" + p_ctx.param(p_node_idx, 3) +
388 ")), " + p_ctx.param(p_node_idx, 4) + ", " + p_ctx.param(p_node_idx, 5) +
389 ", true) * 2.0 - 1.0;\n\tpos += normal * n * " + p_ctx.param(p_node_idx, 1) + ";";
345390}
346391
347392// --- CPU helper: hash-based random for Voronoi and white noise ---
src/nodes/noise_nodes.h +8 -0
……@@ -98,6 +98,14 @@ public:
9898 bool supports_gpu() const override { return true; }
9999 String compile_glsl_prelude(int p_node_idx) const override;
100100
101 // Strength, Scale, Detail, Roughness, Lacunarity -- five floats in
102 // param0..param4. See ProcNode::get_push_constant_sockets().
103 PackedInt32Array get_push_constant_sockets() const override {
104 PackedInt32Array m;
105 m.push_back(1); m.push_back(2); m.push_back(3); m.push_back(4); m.push_back(5);
106 return m;
107 }
108
101109 void execute(ProcEvalContext &p_ctx, int p_node_idx) override;
102110 String compile_glsl(const GlslEmitContext &p_ctx, int p_node_idx) const override;
103111