OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 57c76da

volume: Grid to Mesh never hung, and the field going in was inside out
The gate was the hang. `demo/sdf_grid_gate.gd` called `vertex_count()` and `triangle_count()` -- bound as `get_vertex_count` / `get_triangle_count` -- and `m.positions`, which is a C++ member and not a property. A script error aborts `_init` before `quit()`, and a headless SceneTree with nothing to quit it runs for ever. Disabling the node "fixed" it only because the empty geometry made `m` null and short-circuited the bad call, which is also why 32^3 "hung identically" and why bypassing the fillet changed nothing. Extraction of the gate's 15^3 grid takes 0.375 s. Behind that were two real defects, both in Mesh to SDF Grid, both invisible until something consumed the grid: THE SIGN. libdc reads a face normal as (v1-v0)x(v2-v0), so its stated convention -- positive = solid -- holds only for a CLOCKWISE mesh, which is Godot's. Measured as the enclosed volume under that cross product: cube -1.000 and cylinder -6.243, but uv sphere +4.122 and ico sphere +4.047, because those two carry Blender's faces. A unit sphere therefore came back inside out, 3112 of 3375 samples "solid" and most solid at the box corners, and the extractor dutifully meshed the box. The node now derives the sign from the mesh's own enclosed volume, so it is winding-independent -- which it has to be, since the graphs that reach it come from Blender. THE SLIVERS. Blender's uv-sphere cap is a quad with the pole vertex written twice, so fanning it leaves one zero-area triangle per segment per pole. At the north pole the two copies are bit-identical, |cross| is 0, and libdc's normalize() zeroes it. At the SOUTH pole they are (-8.7e-08,-1,-0) and (-8.6e-08,-1,-1.7e-08); |cross| is 3.3e-09, which is above the 1e-12 floor, so it becomes a unit vector of pure rounding noise -- weighted, at a sliver's corner, by an angle near pi against 0.038 rad for the real triangle beside it. The noise won: the south pole's pseudonormal came out (-0.108,+0.994,+0.001), pointing into the sphere, and the field below it read +0.75/+0.50/+0.25 where it should read -0.75/-0.50/-0.25. That the north pole was correct is the whole tell. Triangles are now dropped by HEIGHT -- twice the area over the longest edge, against the mesh's size -- so the test is scale-free. Also: `indices` is the corner array whenever a mesh carries ngons, so reading it three at a time was a triangle list only by accident. Uses triangulated_indices() now, which is what Blender's grid, circle and cylinder fills need. sdf_grid_gate 1/3 -> 3/3, worst |r-1| = 0.0163 on a 0.25 voxel grid glsl_golden_gate 32/32 unchanged volume_mesh_gate 4/4 unchanged Probes kept: sdf_column_probe (the field down the pole axis against the analytic sphere), sdf_grid_probe (the chain end to end), sphere_winding_probe (enclosed volume and zero-area census per primitive). Not fixed here, and worth its own decision: the primitives disagree on winding. uv sphere and ico sphere are counter-clockwise, cube and cylinder clockwise. Godot culls clockwise-front, and blender-bridge's topology parity gate asserts Blender's face lists, so the two pull opposite ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
57c76dae2c7424ef9cba9542cdede1cac7e3a15a
by plankatron <fleeky@gmail.com> · 2026-09-06T17:44:20.000Z

Changed files (5) +241 -34

demo/sdf_column_probe.gd +26 -0
……@@ -0,0 +1,26 @@
1## The field down the y axis through the sphere's poles, straight out of the
2## ProcVolume grid.
3extends SceneTree
4
5func _init() -> void:
6 var g := ProcNodeGraph.new()
7 var s := g.add_node(MeshUVSphereNode.new())
8 g.get_node(s).set_input_default(2, 1.0)
9 var tg := g.add_node(MeshToSDFGridNode.new())
10 g.get_node(tg).set_input_default(1, 0.25)
11 g.connect_sockets(s, 0, tg, 0)
12 var vol = g.evaluate_socket(tg, 0).get_volume()
13 var d: PackedFloat32Array = vol.get_density_grid()
14 var r: Vector3i = vol.get_grid_resolution()
15 var o: Vector3 = vol.get_grid_origin()
16 var xi := 7
17 var zi := 7
18 print("column x=%.3f z=%.3f (positive = solid)" % [o.x + xi*0.25, o.z + zi*0.25])
19 for y in range(r.y):
20 var wy := o.y + y * 0.25
21 var v := d[(y*r.y + 0)*0 + (y*r.x*0)] # placeholder
22 v = d[(y*r.y + 0)*0 + ((y*r.y + 0)*0)]
23 v = d[(y * r.y + y) * 0 + 0]
24 v = d[(zi * r.y + y) * r.x + xi]
25 print(" y=%6.3f %8.4f expected %8.4f" % [wy, v, 1.0 - Vector3(o.x+xi*0.25, wy, o.z+zi*0.25).length()])
26 quit(0)
demo/sdf_grid_gate.gd +9 -6
……@@ -29,13 +29,16 @@ func _init() -> void:
2929
3030 var out = g.evaluate_socket(to_mesh, 0)
3131 var m = out.get_mesh() if out != null else null
32 _ck("grid -> mesh", m != null and m.vertex_count() > 0 and m.triangle_count() > 0,
33 "%d verts, %d tris" % [m.vertex_count() if m != null else -1,
34 m.triangle_count() if m != null else -1])
35 if m != null and m.vertex_count() > 0:
32 _ck("grid -> mesh", m != null and m.get_vertex_count() > 0 and m.get_triangle_count() > 0,
33 "%d verts, %d tris" % [m.get_vertex_count() if m != null else -1,
34 m.get_triangle_count() if m != null else -1])
35 if m != null and m.get_vertex_count() > 0:
36 # ProcMesh.positions is a C++ member, not a bound property; the only
37 # way into the vertices from GDScript is the ArrayMesh it builds.
38 var verts: PackedVector3Array = m.to_array_mesh().surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
3639 var worst := 0.0
37 for i in range(m.vertex_count()):
38 worst = max(worst, abs(m.positions[i].length() - 1.0))
40 for i in range(verts.size()):
41 worst = max(worst, abs(verts[i].length() - 1.0))
3942 # A filleted unit sphere is still about a unit sphere; the fillet rounds
4043 # creases and a sphere has none, so this also says the fillet did not
4144 # eat the field.
demo/sdf_grid_probe.gd +57 -0
……@@ -0,0 +1,57 @@
1## Diagnostic for the SDF-grid chain: what the field looks like and where the
2## extracted vertices actually sit. Not a gate -- it prints, it does not judge.
3extends SceneTree
4
5func _init() -> void:
6 var g := ProcNodeGraph.new()
7 var sphere := g.add_node(MeshUVSphereNode.new())
8 g.get_node(sphere).set_input_default(2, 1.0)
9 var to_grid := g.add_node(MeshToSDFGridNode.new())
10 g.get_node(to_grid).set_input_default(1, 0.25)
11 g.connect_sockets(sphere, 0, to_grid, 0)
12 var to_mesh := g.add_node(GridToMeshNode.new())
13 g.connect_sockets(to_grid, 0, to_mesh, 0)
14
15 var sm = g.evaluate_socket(sphere, 0).get_mesh()
16 var sv: PackedVector3Array = sm.to_array_mesh().surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
17 var smin := 1e30
18 var smax := -1e30
19 for v in sv:
20 smin = min(smin, v.length()); smax = max(smax, v.length())
21 print("sphere: %d verts, |r| in [%.4f, %.4f]" % [sv.size(), smin, smax])
22
23 var vol = g.evaluate_socket(to_grid, 0).get_volume()
24 var d: PackedFloat32Array = vol.get_density_grid()
25 var r: Vector3i = vol.get_grid_resolution()
26 var dmin := 1e30
27 var dmax := -1e30
28 var npos := 0
29 for x in d:
30 dmin = min(dmin, x); dmax = max(dmax, x)
31 if x > 0.0: npos += 1
32 print("grid: res %v origin %v size %v" % [r, vol.get_grid_origin(), vol.get_grid_size()])
33 print("grid: value in [%.4f, %.4f], %d/%d positive (inside)" % [dmin, dmax, npos, d.size()])
34 # The boundary shell: is the field already negative all around the border?
35 var bmax := -1e30
36 for z in range(r.z):
37 for y in range(r.y):
38 for x in range(r.x):
39 if x == 0 or y == 0 or z == 0 or x == r.x-1 or y == r.y-1 or z == r.z-1:
40 bmax = max(bmax, d[(z*r.y + y)*r.x + x])
41 print("grid: max value on the border = %.4f" % bmax)
42
43 var m = g.evaluate_socket(to_mesh, 0).get_mesh()
44 var mv: PackedVector3Array = m.to_array_mesh().surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
45 var near := 0
46 var far := 0
47 var fmin := Vector3(1e30,1e30,1e30)
48 var fmax := Vector3(-1e30,-1e30,-1e30)
49 for v in mv:
50 if abs(v.length() - 1.0) < 0.25: near += 1
51 else:
52 far += 1
53 fmin = fmin.min(v); fmax = fmax.max(v)
54 print("mesh: %d verts, %d near the unit sphere, %d elsewhere" % [mv.size(), near, far])
55 if far > 0:
56 print("mesh: the stray vertices span %v .. %v" % [fmin, fmax])
57 quit(0)
demo/sphere_winding_probe.gd +42 -0
……@@ -0,0 +1,42 @@
1## Which way are the primitives wound, and which of their triangles have no area?
2##
3## Both questions belong to Mesh to SDF Grid: libdc takes a face normal as
4## (v1-v0)x(v2-v0), so the enclosed volume under that same cross product says
5## whether its pseudonormals point out of the mesh or into it -- and a triangle
6## with no thickness has no normal at all, only rounding noise, which is enough
7## to flip the field beside it.
8extends SceneTree
9
10func _report(label: String, m) -> void:
11 var p: PackedVector3Array = m.to_array_mesh().surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
12 var idx: PackedInt32Array = m.get_indices()
13 var bb := AABB(p[0], Vector3.ZERO)
14 for v in p:
15 bb = bb.expand(v)
16 var min_height := bb.size.length() * 1e-6
17 var vol := 0.0
18 var slivers := 0
19 for t in range(int(idx.size() / 3)):
20 var a := p[idx[t*3]]
21 var b := p[idx[t*3+1]]
22 var c := p[idx[t*3+2]]
23 vol += a.dot(b.cross(c)) / 6.0
24 var twice_area := (b - a).cross(c - a).length()
25 var longest: float = max(max((b-a).length(), (c-b).length()), (a-c).length())
26 if longest <= 0.0 or twice_area / longest < min_height:
27 slivers += 1
28 print("%-11s %5d tris volume %+9.5f (%s) %d with no area" %
29 [label, int(idx.size()/3), vol,
30 "libdc normals OUTWARD" if vol > 0.0 else "libdc normals INWARD", slivers])
31
32func _init() -> void:
33 var g := ProcNodeGraph.new()
34 var s := g.add_node(MeshUVSphereNode.new())
35 g.get_node(s).set_input_default(2, 1.0)
36 _report("uv sphere", g.evaluate_socket(s, 0).get_mesh())
37 _report("ico sphere", g.evaluate_socket(g.add_node(MeshIcoSphereNode.new()), 0).get_mesh())
38 _report("cube", g.evaluate_socket(g.add_node(MeshCubeNode.new()), 0).get_mesh())
39 _report("cylinder", g.evaluate_socket(g.add_node(MeshCylinderNode.new()), 0).get_mesh())
40 _report("circle", g.evaluate_socket(g.add_node(MeshCircleNode.new()), 0).get_mesh())
41 _report("grid", g.evaluate_socket(g.add_node(MeshGridNode.new()), 0).get_mesh())
42 quit(0)
src/nodes/volume_grid_nodes.cpp +107 -28
……@@ -9,7 +9,6 @@
99#include "dc_density_grid.h"
1010#include "dc_mesher.h"
1111
12#include <godot_cpp/classes/os.hpp>
1312#include <godot_cpp/variant/utility_functions.hpp>
1413
1514#include <vector>
……@@ -33,7 +32,11 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
3332 out_geo.instantiate();
3433
3534 Ref<ProcMesh> mesh = in_geo.is_valid() ? in_geo->get_mesh() : Ref<ProcMesh>();
36 if (mesh.is_null() || mesh->positions.size() == 0 || mesh->indices.size() < 3) {
35 // `indices` is the CORNER array whenever the mesh carries ngons, so reading
36 // it three at a time is a triangle list only by accident. Blender's grid,
37 // circle and cylinder fills all arrive as ngons.
38 PackedInt32Array tris = mesh.is_valid() ? mesh->triangulated_indices() : PackedInt32Array();
39 if (mesh.is_null() || mesh->positions.size() == 0 || tris.size() < 3) {
3740 UtilityFunctions::push_warning(
3841 "proc-nodes: Mesh to SDF Grid got no triangles, so there is no "
3942 "surface to measure distance to. Passing an empty volume through.");
……@@ -56,10 +59,96 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
5659 pos[i * 3 + 1] = (float)mesh->positions[i].y;
5760 pos[i * 3 + 2] = (float)mesh->positions[i].z;
5861 }
59 std::vector<int> idx(mesh->indices.size());
60 for (int i = 0; i < mesh->indices.size(); i++) {
61 idx[i] = mesh->indices[i];
62 // ⛔ DROP THE SLIVER TRIANGLES, AND NOT ONLY THE EXACTLY DEGENERATE ONES.
63 // Blender's uv-sphere cap is a QUAD with the pole vertex written twice, so
64 // fanning it about corner 0 leaves one zero-area triangle per segment per
65 // pole. libdc takes a face normal as normalize((v1-v0)x(v2-v0)) and its
66 // normalize() only zeroes out below 1e-12 -- so what it does with the sliver
67 // depends on whether the two pole copies are bit-identical:
68 //
69 // north pole (0, 1, 0) exactly, twice |cross| = 0 -> (0,0,0)
70 // south pole (-8.7e-08, -1, -0) and
71 // (-8.6e-08, -1, -1.7e-08) |cross| = 3.3e-09 -> a UNIT
72 // vector of pure rounding noise
73 //
74 // and the angle that noise normal is weighted by, at a corner of a sliver, is
75 // nearly pi -- against ~0.038 rad for the real cap triangle beside it. So the
76 // noise WINS the vertex pseudonormal: measured 2026-09-06, the south pole's
77 // pseudonormal came out (-0.108, +0.994, +0.001), pointing INTO the sphere,
78 // and the field below the south pole read +0.75/+0.50/+0.25 where it should
79 // read -0.75/-0.50/-0.25. The north pole, whose copies are bit-identical, was
80 // correct. That asymmetry is the whole tell.
81 //
82 // The test is the triangle's HEIGHT -- twice the area over its longest edge --
83 // against the mesh's own size, so it says "this has no thickness, therefore no
84 // normal" at any scale. The same argument applies to libdc's own loaders,
85 // which is where this would belong if that library were only ours to change.
86 Vector3 bb_min = mesh->positions[0];
87 Vector3 bb_max = mesh->positions[0];
88 for (int i = 1; i < vcount; i++) {
89 bb_min = bb_min.min(mesh->positions[i]);
90 bb_max = bb_max.max(mesh->positions[i]);
6291 }
92 const double min_height = (double)(bb_max - bb_min).length() * 1e-6;
93
94 std::vector<int> idx;
95 idx.reserve(tris.size());
96 for (int i = 0; i + 2 < tris.size(); i += 3) {
97 const Vector3 &a = mesh->positions[tris[i]];
98 const Vector3 &b = mesh->positions[tris[i + 1]];
99 const Vector3 &c = mesh->positions[tris[i + 2]];
100 const double twice_area = (double)(b - a).cross(c - a).length();
101 const double longest = (double)MAX(MAX((b - a).length(), (c - b).length()),
102 (a - c).length());
103 if (longest <= 0.0 || twice_area / longest < min_height) {
104 continue;
105 }
106 idx.push_back(tris[i]);
107 idx.push_back(tris[i + 1]);
108 idx.push_back(tris[i + 2]);
109 }
110 if (idx.size() < 3) {
111 UtilityFunctions::push_warning(
112 "proc-nodes: Mesh to SDF Grid got no triangle with any area. "
113 "Passing an empty volume through.");
114 out_geo->set_volume(Ref<ProcVolume>(memnew(ProcVolume)));
115 p_ctx.write_geometry(p_node_idx, 0, out_geo);
116 return;
117 }
118
119 // ⛔ THE WINDING DECIDES THE SIGN, and proc-nodes' primitives do not agree on
120 // one. libdc calls the side its pseudonormals point to the OUTSIDE, so its
121 // stated convention -- positive = solid -- holds only for a CLOCKWISE mesh,
122 // which is Godot's. Measured 2026-09-06 as the enclosed volume under that same
123 // cross product: cube -1.000 and cylinder -6.243 (clockwise), uv sphere +4.122
124 // and ico sphere +4.047 (counter-clockwise, because they carry Blender's
125 // faces). The same call therefore returns distance-outside as POSITIVE for one
126 // and distance-inside as positive for the other, and a unit sphere came out
127 // inside out -- 3112 of 3375 samples "solid", most solid at the box corners --
128 // with the extractor dutifully meshing the box.
129 //
130 // A closed mesh's own enclosed volume says which it is: sum v0 . (v1 x v2) is
131 // positive exactly when that cross product points out of it. Deriving the sign
132 // per mesh is what makes this node winding-independent, which it has to be:
133 // the graphs that reach it come from Blender.
134 double signed_volume = 0.0;
135 for (size_t t = 0; t + 2 < idx.size(); t += 3) {
136 const Vector3 &a = mesh->positions[idx[t]];
137 const Vector3 &b = mesh->positions[idx[t + 1]];
138 const Vector3 &c = mesh->positions[idx[t + 2]];
139 signed_volume += (double)a.dot(b.cross(c));
140 }
141 signed_volume /= 6.0;
142 const float sdf_sign = (signed_volume > 0.0) ? -1.0f : 1.0f;
143 if (Math::abs(signed_volume) < 1e-9) {
144 // Flat or open: there is no inside, so a "signed" distance is only the
145 // side the faces happen to point. Say so rather than pretend.
146 UtilityFunctions::push_warning(
147 "proc-nodes: Mesh to SDF Grid got a mesh enclosing no volume (flat "
148 "or not closed). The sign of the field is then whichever side the "
149 "faces point, and the extracted surface will follow it.");
150 }
151
63152 DCGeneratorMeshSDF sdf;
64153 if (!sdf.load_arrays(pos.data(), vcount, idx.data(), (int)(idx.size() / 3))) {
65154 UtilityFunctions::push_error(
……@@ -99,7 +188,7 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
99188 for (int z = 0; z < res[2]; z++) {
100189 for (int y = 0; y < res[1]; y++) {
101190 for (int x = 0; x < res[0]; x++) {
102 w[(z * res[1] + y) * res[0] + x] = sdf.evaluate(
191 w[(z * res[1] + y) * res[0] + x] = sdf_sign * sdf.evaluate(
103192 mn[0] + x * voxel, mn[1] + y * voxel, mn[2] + z * voxel, voxel);
104193 }
105194 }
……@@ -236,29 +325,19 @@ void GridToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
236325 }
237326 grid.compute_normals();
238327
239 // ⛔ DISABLED: dc_mesh_from_grid HANGS ON THIS GRID AND I DO NOT KNOW WHY.
328 // ⛔ IT NEVER HUNG. `demo/sdf_grid_gate.gd` called `vertex_count()` and
329 // `triangle_count()`, whose BOUND names are `get_vertex_count` /
330 // `get_triangle_count`, and `m.positions`, which is a C++ member and not a
331 // property at all. A script error aborts `_init` before it reaches `quit()`,
332 // and a headless SceneTree with nothing to quit it runs for ever. Disabling
333 // this node "fixed" the hang only because the empty geometry it wrote made
334 // `m` null, so the bad call was short-circuited and never evaluated -- which
335 // is also why 32^3 "hung identically" and why bypassing the fillet changed
336 // nothing. Extraction takes 0.375 s end to end for the gate's 15^3 grid.
240337 //
241 // Reproduced 2026-09-06 with demo/sdf_grid_gate.gd: a unit UV sphere ->
242 // Mesh to SDF Grid (voxel 0.25, a 15^3 grid) -> Grid to Mesh never returns.
243 // Not slowness -- 15^3 is 3375 samples -- and not the fillet, which bypassing
244 // does not help. Snapping the cell count to a power of two, on the theory
245 // that construct_octree_grid_pooled needs one (dc-terrain only ever passes
246 // 32), did not fix it either. Cause unknown.
247 //
248 // It REFUSES rather than hangs, because a node that locks the editor the
249 // moment someone drops it into a graph is worse than one that says it is not
250 // finished. Mesh to SDF Grid works and is verified; this is the half that is
251 // not. Delete this block and the gate is the reproduction.
252 if (OS::get_singleton()->get_environment("PROC_NODES_GRID_TO_MESH") != "1") {
253 UtilityFunctions::push_error(
254 "proc-nodes: Grid to Mesh is DISABLED -- extraction hangs on a "
255 "ProcVolume grid and the cause is not yet known. The node exists "
256 "and maps so a Blender graph imports without losing nodes, but it "
257 "produces nothing. Set PROC_NODES_GRID_TO_MESH=1 to run it anyway "
258 "(it will hang). See demo/sdf_grid_gate.gd.");
259 p_ctx.write_geometry(p_node_idx, 0, out_geo);
260 return;
261 }
338 // What WAS broken was the field going in, in two ways that only bit the
339 // extractor: the sign, and the sliver triangles at the sphere's south pole.
340 // Both are fixed in Mesh to SDF Grid above, and both are documented there.
262341
263342 DCMesherConfig cfg;
264343 DCMeshData md = dc_mesh_from_grid(grid, cfg);