OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 240ed09

volume: the SDF-grid nodes are Blender's, and the fill is threaded
Measured against the real thing this time: `mountain_voronoi1.blend`'s VoronoiMountain tree, serialized by the addon's own geometry_serializer and evaluated headlessly through demo/import_json_probe.gd. It imports 38 of 39 nodes -- only GeometryNodeSetMaterial is dropped -- and bakes 48149 verts through the whole mesh -> SDF grid -> fillet -> mesh chain. It works. What it was not was FAST: MeshToSDFGrid took 75.7 s, and a bake runs the graph twice. THREADED. Every sample is a BVH closest-point query and the grid caps at 256 per axis, so this loop is the entire cost of the node. libdc parallelises the equivalent loops with `#pragma omp parallel for`, but proc-nodes builds without -fopenmp, so every libdc loop this extension compiles is silently serial. std::thread over z-slices rather than adding the flag: one loop instead of all of them, and no runtime pulled in. 75.7 s -> 16.9 s on 16 threads. Sharing the generator is safe -- evaluate() is non-const by declaration only. THE SOCKETS ARE BLENDER'S NOW, measured off the 5.2.0 binary: Mesh to SDF Grid Mesh / Voxel Size 0.30 / Band Width INT 3 was ... 0.1 float ... float SDF Grid Fillet Grid / Iterations INT 1 was Volume / Radius float 1.0 Grid to Mesh Grid / Threshold 0.10 / Adaptivity was Volume / Threshold 0.0 The bridge sends only MODIFIED defaults, so a socket left alone in Blender arrives carrying OURS -- Voxel Size at 0.1 against 0.30 is three times the resolution per axis and twenty-seven times the samples. The fillet was worse than a wrong default: `Radius` could only resolve by INDEX, and a Blender `Iterations = 5` landed as radius 5.0 and became 5/voxel passes. Adaptivity stays unmapped on purpose -- its simplifier is dc_simplify, which proc-nodes does not compile. sdf_grid_gate 3/3, worst |r-1| = 0.1164 (the 0.10 threshold now, not 0.0) mountain 48149 verts, y -20.46 .. -0.05 STILL OPEN, and it is why the bridged mountain runs at 256^3: that graph drives Voxel Size from Group Input port 14, a MODIFIER input, and modifier input VALUES do not cross the bridge. proc-nodes falls back to its own default. v0.82.0 carried the output interface; the input values are the other half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240ed0979c789e1ed49d4c7ed97ce63b6d155914
by plankatron <fleeky@gmail.com> · 2026-09-06T18:01:19.000Z

Changed files (7) +186 -21

demo/emit_current.gd +19 -0
……@@ -0,0 +1,19 @@
1extends SceneTree
2func _init() -> void:
3 var g := ProcNodeGraph.new()
4 var noise := g.add_node(GradientNoiseNode.new())
5 g.get_node(noise).set_input_default(1, 3.0)
6 g.get_node(noise).set_input_default(2, 3)
7 g.get_node(noise).set_input_default(3, 0.1)
8 var erosion := g.add_node(ErosionNoiseNode.new())
9 g.get_node(erosion).set_input_default(1, 5.0)
10 g.get_node(erosion).set_input_default(2, 0.15)
11 g.get_node(erosion).set_input_default(3, 0.6)
12 g.get_node(erosion).set_input_default(4, 0.7)
13 g.get_node(erosion).set_input_default(5, 7)
14 g.get_node(erosion).set_input_default(6, 0.5)
15 g.connect_sockets(noise, 0, erosion, 7)
16 g.connect_sockets(noise, 1, erosion, 8)
17 FileAccess.open(OS.get_cmdline_user_args()[0], FileAccess.WRITE).store_string(
18 g.compile_to_glsl_function("proc_density", true, PackedStringArray()))
19 quit()
demo/island_diag.gd +30 -0
……@@ -0,0 +1,30 @@
1extends SceneTree
2func _init() -> void:
3 print("PositionNode exists: ", ClassDB.class_exists("PositionNode"))
4 print("MathNode exists: ", ClassDB.class_exists("MathNode"))
5 var g := ProcNodeGraph.new()
6 var sphere := g.add_node(SphereSdfNode.new())
7 g.get_node(sphere).set_input_default(1, 90.0)
8 var noise := g.add_node(NoiseTextureNode.new())
9 var pos_node := g.add_node(PositionNode.new())
10 g.connect_sockets(pos_node, 0, noise, 0)
11 var bite := g.add_node(MathNode.new())
12 g.get_node(bite).set("operation", 2)
13 g.connect_sockets(noise, 0, bite, 0)
14 g.get_node(bite).set_input_default(1, 34.0)
15 var carve := g.add_node(VolumeBooleanNode.new())
16 g.get_node(carve).set("operation", 2)
17 g.connect_sockets(sphere, 0, carve, 0)
18 g.connect_sockets(bite, 0, carve, 1)
19 var vtm := g.add_node(VolumeToMeshNode.new())
20 g.connect_sockets(carve, 0, vtm, 0)
21 print("node count: ", g.get_node_count(), " vtm idx: ", vtm)
22 var geo = g.evaluate_socket(vtm, 0)
23 print("evaluate_socket -> ", geo)
24 # And the same graph WITHOUT the noise branch, to isolate.
25 var g2 := ProcNodeGraph.new()
26 var s2 := g2.add_node(SphereSdfNode.new())
27 var v2 := g2.add_node(VolumeToMeshNode.new())
28 g2.connect_sockets(s2, 0, v2, 0)
29 print("sphere-only -> ", g2.evaluate_socket(v2, 0))
30 quit()
demo/pc_probe.gd +29 -0
……@@ -0,0 +1,29 @@
1extends SceneTree
2func _dump(name: String, g: ProcNodeGraph) -> void:
3 var t: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
4 print("### ", name, " (", t.length(), " chars)")
5 var b := t.substr(max(0, t.find("float proc_density")))
6 for line in b.split("\n"):
7 print(" ", line)
8 print(" >>> 'pc.' occurrences in WHOLE text: ", t.count("pc."))
9 print("")
10func _init() -> void:
11 var g1 := ProcNodeGraph.new()
12 g1.add_node(DisplaceNode.new())
13 _dump("Displace, defaults", g1)
14
15 var g2 := ProcNodeGraph.new()
16 var d := DisplaceNode.new()
17 g2.add_node(d)
18 g2.get_node(0).set_input_default(1, 7.5) # Strength
19 g2.get_node(0).set_input_default(2, 9.0) # Scale
20 _dump("Displace, Strength=7.5 Scale=9.0", g2)
21
22 var g3 := ProcNodeGraph.new()
23 g3.add_node(NoiseTextureNode.new())
24 _dump("SimplexNoise, defaults", g3)
25
26 var g4 := ProcNodeGraph.new()
27 g4.add_node(SetPositionNode.new())
28 _dump("SetPosition, defaults", g4)
29 quit()
demo/sdf_grid_gate.gd +2 -2
……@@ -13,10 +13,10 @@ func _init() -> void:
1313 var sphere := g.add_node(MeshUVSphereNode.new())
1414 g.get_node(sphere).set_input_default(2, 1.0) # radius
1515 var to_grid := g.add_node(MeshToSDFGridNode.new())
16 g.get_node(to_grid).set_input_default(1, 0.25) # voxel size
16 g.get_node(to_grid).set_input_default(1, 0.25) # voxel size (Blender's default is 0.30)
1717 g.connect_sockets(sphere, 0, to_grid, 0)
1818 var fillet := g.add_node(SDFGridFilletNode.new())
19 g.get_node(fillet).set_input_default(1, 0.16) # radius
19 g.get_node(fillet).set_input_default(1, 1) # iterations
2020 g.connect_sockets(to_grid, 0, fillet, 0)
2121 var to_mesh := g.add_node(GridToMeshNode.new())
2222 g.connect_sockets(fillet, 0, to_mesh, 0)
demo/vm_diag.gd +18 -0
……@@ -0,0 +1,18 @@
1extends SceneTree
2func _init() -> void:
3 var g := ProcNodeGraph.new()
4 var s1 := g.add_node(SphereSdfNode.new())
5 var vtm := g.add_node(VolumeToMeshNode.new())
6 g.connect_sockets(s1, 0, vtm, 0)
7 var pts := PackedVector3Array([Vector3(0,0,0), Vector3(0.5,0,0), Vector3(1,0,0),
8 Vector3(1.5,0,0), Vector3(2,0,0)])
9 var d := g.evaluate_density_batch(pts)
10 print("with VolumeToMesh terminal: ", d)
11 var g2 := ProcNodeGraph.new()
12 var s2 := g2.add_node(SphereSdfNode.new())
13 var go := GroupOutputNode.new()
14 go.set_interface([{"name":"Density","type":0}])
15 var goi := g2.add_node(go)
16 g2.connect_sockets(s2, 0, goi, 0)
17 print("with Group Output float: ", g2.evaluate_density_batch(pts))
18 quit()
demo/vol_probe.gd +14 -0
……@@ -0,0 +1,14 @@
1extends SceneTree
2func _init() -> void:
3 var g := ProcNodeGraph.new()
4 var s1 := g.add_node(SphereSdfNode.new())
5 var s2 := g.add_node(SphereSdfNode.new())
6 var bo := g.add_node(VolumeBooleanNode.new())
7 g.connect_sockets(s1, 0, bo, 0)
8 g.connect_sockets(s2, 0, bo, 1)
9 var vtm := g.add_node(VolumeToMeshNode.new())
10 g.connect_sockets(bo, 0, vtm, 0)
11 var t: String = g.compile_to_glsl_function("proc_density", false, PackedStringArray())
12 print("### FULL EMITTED TEXT (", t.length(), " chars)")
13 print(t)
14 quit()
src/nodes/volume_grid_nodes.cpp +74 -19
……@@ -11,6 +11,7 @@
1111
1212#include <godot_cpp/variant/utility_functions.hpp>
1313
14#include <thread>
1415#include <vector>
1516
1617namespace godot {
……@@ -20,9 +21,16 @@ namespace godot {
2021void MeshToSDFGridNode::_bind_methods() {}
2122
2223MeshToSDFGridNode::MeshToSDFGridNode() {
24 // ⛔ THE DEFAULTS ARE BLENDER'S, and that is not cosmetic. The bridge sends
25 // only MODIFIED defaults, so a socket left alone in Blender arrives carrying
26 // OURS. Voxel Size was 0.1 against Blender's 0.30 -- three times the
27 // resolution on every axis, TWENTY-SEVEN times the samples, and every one of
28 // them a BVH closest-point query. That alone was most of the 75 s the
29 // bridged mountain spent in this node. Band Width is an INT on Blender's
30 // node, not a float.
2331 input_defs.push_back({ "Mesh", SocketType::Geometry, Variant() }); // 0
24 input_defs.push_back({ "Voxel Size", SocketType::Float, 0.1f }); // 1
25 input_defs.push_back({ "Band Width", SocketType::Float, 3.0f }); // 2
32 input_defs.push_back({ "Voxel Size", SocketType::Float, 0.3f }); // 1
33 input_defs.push_back({ "Band Width", SocketType::Int, 3 }); // 2
2634 output_defs.push_back({ "Volume", SocketType::Geometry, Variant() });
2735}
2836
……@@ -47,7 +55,7 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
4755
4856 float voxel = p_ctx.read_float(p_node_idx, 1);
4957 if (voxel <= 0.0f) voxel = 0.1f;
50 float band = p_ctx.read_float(p_node_idx, 2);
58 float band = (float)p_ctx.read_int(p_node_idx, 2);
5159 if (band < 0.0f) band = 0.0f;
5260
5361 // libdc's evaluator, not a second one: the pseudonormal method with a BVH
……@@ -185,12 +193,51 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
185193 vol->grid_size = Vector3(res[0] * voxel, res[1] * voxel, res[2] * voxel);
186194 vol->density_grid.resize(res[0] * res[1] * res[2]);
187195 float *w = vol->density_grid.ptrw();
188 for (int z = 0; z < res[2]; z++) {
189 for (int y = 0; y < res[1]; y++) {
190 for (int x = 0; x < res[0]; x++) {
191 w[(z * res[1] + y) * res[0] + x] = sdf_sign * sdf.evaluate(
192 mn[0] + x * voxel, mn[1] + y * voxel, mn[2] + z * voxel, voxel);
196 // ⛔ THREADED, because this is the whole cost of the node. Every sample is a
197 // closest-point query against the BVH, and the grid is capped at 256 per
198 // axis -- 16.7 million of them for the bridged mountain, which took 75.7 s
199 // serially and ran TWICE per evaluation, which is what "the mountain did not
200 // come across" looked like from the outside.
201 //
202 // libdc parallelises the equivalent loops with `#pragma omp parallel for`,
203 // but proc-nodes builds without -fopenmp, so those pragmas are silently
204 // ignored here and every libdc loop this extension compiles is serial.
205 // std::thread rather than adding the flag: it changes one loop instead of
206 // every libdc loop's behaviour inside this library, and pulls in no runtime.
207 //
208 // Safe to share `sdf`: evaluate() is non-const by declaration only -- it
209 // calls closest_distance_sq/query_bvh, both const, and the BVH is immutable
210 // after load_arrays.
211 {
212 const int nz = res[2];
213 unsigned hw = std::thread::hardware_concurrency();
214 int nthreads = (int)(hw > 0 ? hw : 1u);
215 if (nthreads > nz) nthreads = nz;
216 if (nthreads < 1) nthreads = 1;
217 auto slab = [&](int z0, int z1) {
218 for (int z = z0; z < z1; z++) {
219 for (int y = 0; y < res[1]; y++) {
220 for (int x = 0; x < res[0]; x++) {
221 w[(z * res[1] + y) * res[0] + x] = sdf_sign * sdf.evaluate(
222 mn[0] + x * voxel, mn[1] + y * voxel,
223 mn[2] + z * voxel, voxel);
224 }
225 }
193226 }
227 };
228 if (nthreads == 1) {
229 slab(0, nz);
230 } else {
231 std::vector<std::thread> pool;
232 pool.reserve(nthreads);
233 const int per = (nz + nthreads - 1) / nthreads;
234 for (int t = 0; t < nthreads; t++) {
235 const int z0 = t * per;
236 const int z1 = MIN(z0 + per, nz);
237 if (z0 >= z1) break;
238 pool.emplace_back(slab, z0, z1);
239 }
240 for (std::thread &th : pool) th.join();
194241 }
195242 }
196243 out_geo->set_volume(vol);
……@@ -202,9 +249,14 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
202249void SDFGridFilletNode::_bind_methods() {}
203250
204251SDFGridFilletNode::SDFGridFilletNode() {
205 input_defs.push_back({ "Volume", SocketType::Geometry, Variant() }); // 0
206 input_defs.push_back({ "Radius", SocketType::Float, 1.0f }); // 1
207 output_defs.push_back({ "Volume", SocketType::Geometry, Variant() });
252 // ⛔ ITERATIONS, not a radius. Blender's node is `Grid` + `Iterations` (INT,
253 // default 1); this had `Volume` + `Radius` (float, default 1.0), so the
254 // bridge could only resolve socket 1 by INDEX, and even then it meant a
255 // different thing -- a Blender `Iterations = 5` landed as radius 5.0 and
256 // became 5/voxel passes. The socket now says what it does.
257 input_defs.push_back({ "Grid", SocketType::Geometry, Variant() }); // 0
258 input_defs.push_back({ "Iterations", SocketType::Int, 1 }); // 1
259 output_defs.push_back({ "Grid", SocketType::Geometry, Variant() });
208260}
209261
210262void SDFGridFilletNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
……@@ -229,16 +281,14 @@ void SDFGridFilletNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
229281 // operator and will not match Blender value for value.
230282 Ref<ProcVolume> vol = in_vol->duplicate_volume();
231283 const Vector3i r = vol->grid_resolution;
232 float radius = p_ctx.read_float(p_node_idx, 1);
233 if (radius <= 0.0f) {
284 int iters = p_ctx.read_int(p_node_idx, 1);
285 if (iters <= 0) {
234286 p_ctx.write_geometry(p_node_idx, 0, out_geo);
235287 out_geo->set_volume(vol);
236288 return;
237289 }
238 // Radius in voxels -> iterations. Each pass smooths about one voxel.
239 const float voxel = (r.x > 0) ? (vol->grid_size.x / (float)r.x) : 1.0f;
240 int iters = (int)(radius / MAX(voxel, 1e-6f));
241 if (iters < 1) iters = 1;
290 // Each pass smooths about one voxel, which is what Blender's iteration
291 // count buys too -- the operator differs, the unit does not.
242292 if (iters > 32) iters = 32;
243293
244294 PackedFloat32Array next = vol->density_grid;
……@@ -270,8 +320,13 @@ void SDFGridFilletNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
270320void GridToMeshNode::_bind_methods() {}
271321
272322GridToMeshNode::GridToMeshNode() {
273 input_defs.push_back({ "Volume", SocketType::Geometry, Variant() }); // 0
274 input_defs.push_back({ "Threshold", SocketType::Float, 0.0f }); // 1
323 // Blender's node is `Grid` + `Threshold` (0.10) + `Adaptivity`. Adaptivity
324 // has no socket here on purpose -- Dual Contouring's simplifier is
325 // dc_simplify, which proc-nodes deliberately does not compile (it needs
326 // meshoptimizer). A Blender graph carrying one lands on nothing, which is
327 // the same outcome as ignoring it and is visible in the import log.
328 input_defs.push_back({ "Grid", SocketType::Geometry, Variant() }); // 0
329 input_defs.push_back({ "Threshold", SocketType::Float, 0.1f }); // 1
275330 output_defs.push_back({ "Mesh", SocketType::Geometry, Variant() });
276331}
277332