OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / ee4c16a

gpu: a batch that reads a producer outside itself must not run
This is my regression, and it is the worst shape there is: a silently wrong answer on the GPU. When I fixed the `n30_s0` undeclared-identifier error I made GlslEmitContext::in() fall back to the socket's LITERAL if the producer was not emitted in that shader. That keeps the shader COMPILING and makes it compute something else. The bridged mountain's batch was [CombineXYZ, SetPosition] with the Switch carrying the height OUTSIDE it, so Combine XYZ's Z read its default 0.0 and the displacement vanished. I turned a loud compile failure into a quiet wrong mesh. CPU path y -2.00..28.85 Blender's own z -2.00..28.85 GPU path y -2.00.. 2.00 the cage's own thickness, no error Fused VALUE nodes were never representability-checked at all: the dispatch loop `continue`s past them before the test. Every node in a batch is checked now, and one failure refuses the WHOLE batch -- a partial batch is how two halves of a graph end up computing different functions. ⛔ AND WHERE THE REFUSAL GOES MATTERS. My first attempt ran the batch's nodes by hand inside _execute_gpu_batch. The report then said "cpu" for both of them and the mountain was STILL flat, because a bare node->execute() skips _bind_context_or_defer -- the lazy-field binding that lives in the main loop -- so the deferred field was never materialised. The batch is now refused at the CLAIM SITE and the ordinary per-node path runs it. Same answer as the CPU, to the last decimal. gpu_batch_gate 6/6 glsl_golden_gate 32/32 sdf_grid_gate 3/3 volume_mesh_gate 4/4 bridged mountain, GPU == CPU == Blender ⚠ THE GATE DID NOT CATCH THIS and still would not: gpu_batch_gate's graph is fully fused, so it has no producer outside the batch to read. It compares CPU against GPU, which is the right assertion on the wrong shape. A case with a value node the batch cannot absorb is what it needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ee4c16a1af7ba8d17491efeb06bb3b1477a11fc4
by plankatron <fleeky@gmail.com> · 2026-09-06T21:15:50.000Z

Changed files (3) +120 -0

demo/cage_graph_probe.gd +1 -0
……@@ -52,6 +52,7 @@ func _init() -> void:
5252 var hi := Vector3(-1e30, -1e30, -1e30)
5353 for p in v:
5454 lo = lo.min(p); hi = hi.max(p)
55 print("exec report: ", ProcGeometryNode.get_last_exec_report())
5556 print("GODOT result bbox: x %.2f..%.2f y %.2f..%.2f z %.2f..%.2f (%d verts)"
5657 % [lo.x, hi.x, lo.y, hi.y, lo.z, hi.z, v.size()])
5758 quit(0)
demo/cage_graph_probe_cpu.gd +58 -0
……@@ -0,0 +1,58 @@
1## Feed a REAL cage (from Blender, M-converted) into a bridged graph and report
2## the bounding box, so the result can be compared against Blender's own.
3extends SceneTree
4func _init() -> void:
5 var a := OS.get_cmdline_user_args()
6 var f := FileAccess.open(a[0], FileAccess.READ)
7 var head := f.get_line().split(" ")
8 var nv := int(head[0])
9 var nt := int(head[1])
10 var pos := PackedVector3Array()
11 pos.resize(nv)
12 for i in range(nv):
13 var p := f.get_line().split(" ")
14 pos[i] = Vector3(float(p[0]), float(p[1]), float(p[2]))
15 var idx := PackedInt32Array()
16 idx.resize(nt * 3)
17 for i in range(nt):
18 var p := f.get_line().split(" ")
19 idx[i*3] = int(p[0]); idx[i*3+1] = int(p[1]); idx[i*3+2] = int(p[2])
20 f.close()
21 var arrays := []
22 arrays.resize(Mesh.ARRAY_MAX)
23 arrays[Mesh.ARRAY_VERTEX] = pos
24 arrays[Mesh.ARRAY_INDEX] = idx
25 var am := ArrayMesh.new()
26 am.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
27 print("cage in: %d verts, %d tris" % [nv, nt])
28 # ⛔ ISOLATION: read the ArrayMesh straight back, with proc-nodes nowhere
29 # near it. If the bbox is already swapped here, the harness is the liar.
30 var rt: PackedVector3Array = am.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
31 var rlo := Vector3(1e30, 1e30, 1e30)
32 var rhi := Vector3(-1e30, -1e30, -1e30)
33 for p2 in rt:
34 rlo = rlo.min(p2); rhi = rhi.max(p2)
35 print("ROUND TRIP (no graph): x %.2f..%.2f y %.2f..%.2f z %.2f..%.2f"
36 % [rlo.x, rhi.x, rlo.y, rhi.y, rlo.z, rhi.z])
37
38 var g: ProcNodeGraph = GraphImporter.import_from_json(
39 FileAccess.get_file_as_string(a[1]))
40 var host := ProcGeometryNode.new()
41 host.use_gpu = false
42 host.set_input_mesh(am)
43 host.set_graph(g)
44 get_root().add_child(host)
45 var m: ArrayMesh = host.bake_mesh()
46 if m == null or m.get_surface_count() == 0:
47 print("BAKE: no mesh")
48 quit(1)
49 return
50 var v: PackedVector3Array = m.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
51 var lo := Vector3(1e30, 1e30, 1e30)
52 var hi := Vector3(-1e30, -1e30, -1e30)
53 for p in v:
54 lo = lo.min(p); hi = hi.max(p)
55 print("exec report: ", ProcGeometryNode.get_last_exec_report())
56 print("GODOT result bbox: x %.2f..%.2f y %.2f..%.2f z %.2f..%.2f (%d verts)"
57 % [lo.x, hi.x, lo.y, hi.y, lo.z, hi.z, v.size()])
58 quit(0)
src/graph/graph_executor.cpp +61 -0
……@@ -983,12 +983,61 @@ static bool _batch_contains(const GpuBatch &p_batch, int p_node_idx) {
983983 return false;
984984}
985985
986// ⛔ A CONNECTED INPUT WHOSE PRODUCER IS NOT IN THE SHADER IS A REFUSAL.
987//
988// GlslEmitContext::in() falls back to the socket's LITERAL when the producer is
989// not emitted here. That keeps the shader COMPILING -- and makes it compute
990// something else. The bridged mountain's batch was [CombineXYZ, SetPosition]
991// with the Switch that carries the height OUTSIDE it, so Combine XYZ's Z read
992// its default 0.0 and the displacement vanished: y -2..28.85 on the CPU against
993// y -2..2 on the GPU, with no error anywhere. I turned a loud compile failure
994// into a quiet wrong answer when I added that fallback.
995//
996// Fused VALUE nodes were never checked at all -- the dispatch loop `continue`s
997// past them before the representability test. This checks every node in the
998// batch, and one failure sends the WHOLE batch to the CPU: a partial batch is
999// how two halves of a graph end up computing different functions.
1000static bool _batch_is_representable(ProcEvalContext &p_ctx, const GpuBatch &p_batch) {
1001 for (int f = 0; f < p_batch.fused_nodes.size(); f++) {
1002 const int node_idx = p_batch.fused_nodes[f];
1003 Ref<ProcNode> node = p_ctx.graph->get_node(node_idx);
1004 if (node.is_null()) {
1005 continue;
1006 }
1007 const PackedInt32Array pc_map = node->get_push_constant_sockets();
1008 for (int si = 0; si < node->get_input_count(); si++) {
1009 if (node->get_input_type(si) == (int)SocketType::Geometry) {
1010 continue;
1011 }
1012 if (!p_ctx.is_input_connected(node_idx, si)) {
1013 continue;
1014 }
1015 // Produced inside this shader: it is a local, and correct.
1016 if (_batch_contains(p_batch, p_ctx.graph->get_input_source_node(node_idx, si))) {
1017 continue;
1018 }
1019 // Otherwise the only channel is a push constant, and only for a
1020 // value that is ONE value -- never a field.
1021 if (!pc_map.has(si)) {
1022 return false;
1023 }
1024 const SocketValue raw = p_ctx._read_input_raw(node_idx, si);
1025 if (raw.is_field || raw.is_lazy) {
1026 return false;
1027 }
1028 }
1029 }
1030 return true;
1031}
1032
9861033void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p_batch, const ExecutionPlan &p_plan) {
9871034 RenderingDevice *rd = p_ctx.gpu_buffers.get_rd();
9881035 if (!rd) {
9891036 return;
9901037 }
9911038
1039
1040
9921041 // For Phase 4, process each node in the batch individually.
9931042 // Future phases will fuse multiple nodes into a single shader dispatch.
9941043 for (int b = 0; b < p_batch.node_count; b++) {
……@@ -1447,6 +1496,18 @@ Ref<ProcGeometry> GraphExecutor::execute(
14471496 // Check for GPU batch at this position
14481497 if (gpu_available) {
14491498 int batch_idx = p_plan.find_gpu_batch(i);
1499 // ⛔ REFUSE HERE, NOT INSIDE THE BATCH. An unrepresentable batch has
1500 // to fall through to the ORDINARY per-node path, because that path
1501 // is where _bind_context_or_defer lives -- the lazy-field binding a
1502 // bare node->execute() knows nothing about. Running the nodes by
1503 // hand inside _execute_gpu_batch reported them as CPU and still got
1504 // the wrong answer: the mountain stayed flat, y -2..2 against the
1505 // CPU path's y -2..28.85, because the deferred field was never
1506 // materialised.
1507 if (batch_idx >= 0 &&
1508 !_batch_is_representable(ctx, p_plan.gpu_batches[batch_idx])) {
1509 batch_idx = -1;
1510 }
14501511 if (batch_idx >= 0) {
14511512 _execute_gpu_batch(ctx, p_plan.gpu_batches[batch_idx], p_plan);
14521513 i += p_plan.gpu_batches[batch_idx].node_count - 1;