OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 467e7ec

sdf+fields: a worklist dilation, a threaded Voronoi, and where the time really is
Three changes and one disproven hypothesis, all measured on the bridged voronoi mountain (2,582,018 verts). Mesh bit-identical throughout: sum(z)=1792386.7043, sumsq(z)=28382638.9998. WORKLIST DILATION. The separable band dilation copied the whole 11M mask twice and scanned it three times to find the ~62k voxels actually set. It now walks a list of marked voxels, appending as it goes and iterating to the size each pass started at -- which is exactly "dilate the current set along this axis", with the mask deduping. 55 ms. THREADED VORONOI, and the write pointers hoisted. proc_parallel_for is a new shared helper (core/proc_parallel.h): proc-nodes evaluated every field on ONE core, where Blender parallelises every field and multi-function evaluation (field.cc:477, multi_function.cc:136, grain 2048-10000). The loop also called ptrw() FIVE TIMES PER ELEMENT -- 12.9 million copy-on-write checks for five stores. Set Position 14.9 s -> 12.1 s. ⚠ ONLY 2.8 s, so the value cone is not Voronoi-dominated and the rest of it is still to be found. The helper is in place for the other nodes. STAGE TIMING, which changed the picture completely: [SDF] prep 365ms bvh 10543ms band 1459581 of 11010048 (13.3%) stamp 217ms dilate 55ms query 1218ms flood 257ms MeshToSDFGrid total 12736ms The narrow band took the QUERIES from ~53 s to 1.2 s -- about 44x, not the 4.3x the node total suggested. What remains is the BVH BUILD: 10.5 s, single threaded, over 5.16M triangles, rebuilt from scratch on every evaluation. The earlier "84 us per query" figure was wrong; it averaged the build into the queries. ⛔ DISPROVEN: that ProcEvalContext::materialize re-executing its producer on every read was multiplying the cone back to the root. A counter on that path never reached 20,000 calls in a whole bake, so it is called per edge, not per element. Removed rather than "fixed". Next, in order: parallelise the BVH build (10.5 s), find the rest of Set Position's 12.1 s, thread the remaining value nodes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
467e7ec2111d7b63709140e7c89f88439c42ed70
by plankatron <fleeky@gmail.com> · 2026-09-08T16:44:56.000Z

Changed files (3) +154 -32

src/core/proc_parallel.h +73 -0
……@@ -0,0 +1,73 @@
1#ifndef PROC_PARALLEL_H
2#define PROC_PARALLEL_H
3
4#include <algorithm>
5#include <thread>
6#include <vector>
7
8namespace godot {
9
10// ── PARALLEL ELEMENT LOOPS ───────────────────────────────────────────────────
11//
12// proc-nodes evaluated every field on ONE core. Measured 2026-09-08 on the
13// bridged voronoi mountain, 2,582,018 vertices: the value cone behind Set
14// Position took 14.9 s, and `grep -rln "std::thread\|parallel_for" src/` found
15// exactly one file in the whole extension -- the SDF grid fill.
16//
17// Blender parallelises every field and multi-function evaluation:
18// FN_multi_function.hh:111 int64_t min_grain_size = 10000;
19// intern/multi_function.cc:136 threading::parallel_for_aligned(...)
20// intern/field.cc:477 threading::parallel_for(mask.index_range(), 2048, ...)
21//
22// so on a 16-core machine that difference alone is most of a 16x.
23//
24// std::thread rather than a job system: proc-nodes has no scheduler, node
25// execution is a straight topological walk, and these loops are pure per-element
26// writes into pre-sized arrays with no shared state. Adding a scheduler to reach
27// the same place would be a much larger change for the same number.
28//
29// The grain exists because a thread costs more to start than a short loop costs
30// to run; below it the serial path is taken and nothing is spawned.
31template <typename F>
32inline void proc_parallel_for(int p_count, F &&p_fn, int p_min_grain = 4096) {
33 if (p_count <= 0) {
34 return;
35 }
36 if (p_count < p_min_grain) {
37 for (int i = 0; i < p_count; i++) {
38 p_fn(i);
39 }
40 return;
41 }
42 unsigned hw = std::thread::hardware_concurrency();
43 int nthreads = (int)(hw > 0 ? hw : 1u);
44 nthreads = std::min(nthreads, (p_count + p_min_grain - 1) / p_min_grain);
45 if (nthreads < 2) {
46 for (int i = 0; i < p_count; i++) {
47 p_fn(i);
48 }
49 return;
50 }
51 const int per = (p_count + nthreads - 1) / nthreads;
52 std::vector<std::thread> pool;
53 pool.reserve((size_t)nthreads);
54 for (int t = 0; t < nthreads; t++) {
55 const int b = t * per;
56 const int e = std::min(b + per, p_count);
57 if (b >= e) {
58 break;
59 }
60 pool.emplace_back([b, e, &p_fn]() {
61 for (int i = b; i < e; i++) {
62 p_fn(i);
63 }
64 });
65 }
66 for (std::thread &th : pool) {
67 th.join();
68 }
69}
70
71} // namespace godot
72
73#endif // PROC_PARALLEL_H
src/nodes/noise_nodes.cpp +19 -7
……@@ -1,4 +1,5 @@
11#include "noise_nodes.h"
2#include "../core/proc_parallel.h"
23
34#include "../shared/blender_noise.h"
45#include "../shared/blender_perlin.h"
……@@ -648,7 +649,18 @@ void VoronoiTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
648649 Color s_col;
649650 Vector3 s_pos;
650651
651 for (int e = 0; e < n; e++) {
652 // ⛔ HOIST THE WRITE POINTERS. ptrw() is a copy-on-write check, and this
653 // wrote through five of them PER ELEMENT -- 12.9 million CoW checks on the
654 // bridged mountain's 2.58M vertices, for five stores.
655 float *dist_p = is_field ? dist_a.ptrw() : nullptr;
656 Color *col_p = is_field ? col_a.ptrw() : nullptr;
657 Vector3 *pos_p = is_field ? pos_a.ptrw() : nullptr;
658 float *w_p = is_field ? w_a.ptrw() : nullptr;
659 float *rad_p = is_field ? rad_a.ptrw() : nullptr;
660
661 // Per element and independent: every iteration reads its own inputs and
662 // writes its own slot. The scalar path is n == 1 and stays serial.
663 proc_parallel_for(n, [&](int e) {
652664 bvoronoi::VoronoiParams params;
653665 params.feature = feature;
654666 params.metric = metric;
……@@ -733,11 +745,11 @@ void VoronoiTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
733745 Color c(color.x, color.y, color.z, 1.0f);
734746 Vector3 p3(position.x, position.y, position.z);
735747 if (is_field) {
736 dist_a.ptrw()[e] = dist;
737 col_a.ptrw()[e] = c;
738 pos_a.ptrw()[e] = p3;
739 w_a.ptrw()[e] = position.w;
740 rad_a.ptrw()[e] = radius;
748 dist_p[e] = dist;
749 col_p[e] = c;
750 pos_p[e] = p3;
751 w_p[e] = position.w;
752 rad_p[e] = radius;
741753 } else {
742754 s_dist = dist;
743755 s_col = c;
……@@ -745,7 +757,7 @@ void VoronoiTextureNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
745757 s_w = position.w;
746758 s_rad = radius;
747759 }
748 }
760 });
749761
750762 if (is_field) {
751763 p_ctx.write_float_field(p_node_idx, 0, dist_a, domain);
src/nodes/volume_grid_nodes.cpp +62 -25
……@@ -10,6 +10,7 @@
1010#include "dc_mesher.h"
1111
1212#include <godot_cpp/variant/utility_functions.hpp>
13#include <godot_cpp/classes/time.hpp>
1314
1415#include <thread>
1516#include <vector>
……@@ -35,6 +36,7 @@ MeshToSDFGridNode::MeshToSDFGridNode() {
3536}
3637
3738void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
39 const uint64_t t_node0 = Time::get_singleton()->get_ticks_usec();
3840 Ref<ProcGeometry> in_geo = p_ctx.read_geometry(p_node_idx, 0);
3941 Ref<ProcGeometry> out_geo;
4042 out_geo.instantiate();
……@@ -157,6 +159,7 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
157159 "faces point, and the extracted surface will follow it.");
158160 }
159161
162 const uint64_t t_setup = Time::get_singleton()->get_ticks_usec();
160163 DCGeneratorMeshSDF sdf;
161164 if (!sdf.load_arrays(pos.data(), vcount, idx.data(), (int)(idx.size() / 3))) {
162165 UtilityFunctions::push_error(
……@@ -219,12 +222,21 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
219222 // grid boundary. `signed_volume` already says whether it is closed, and an
220223 // open one falls back to computing every voxel, which is what this has
221224 // always done.
225 const uint64_t t_bvh = Time::get_singleton()->get_ticks_usec();
226 const uint64_t t_start = t_bvh;
227 uint64_t t_stamp = t_start, t_dilate = t_start, t_query = t_start;
222228 const bool closed = Math::abs(signed_volume) >= 1e-9;
223229 const int bandv = MAX(1, (int)band);
224230
225231 std::vector<uint8_t> in_band;
226232 if (closed) {
227233 in_band.assign((size_t)total, 0);
234 // The marked voxels, kept as a WORKLIST. Dilating from a list touches
235 // only the band; the separable three-pass version this replaced copied
236 // the whole 10.2M mask twice and scanned it three times to find the
237 // ~62k voxels that were actually set.
238 std::vector<int64_t> marked;
239 marked.reserve(1 << 16);
228240 // 1. Stamp each triangle's voxel AABB. No expansion here: dilating
229241 // afterwards is O(grid) once instead of O(triangles x band^3), and
230242 // these triangles are far smaller than a voxel -- expanding each one
……@@ -243,41 +255,50 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
243255 const int z1 = CLAMP((int)Math::floor((hi.z - mn[2]) / voxel), 0, (int)nz - 1);
244256 for (int z = z0; z <= z1; z++) {
245257 for (int y = y0; y <= y1; y++) {
246 uint8_t *row = &in_band[(size_t)at(0, y, z)];
258 const int64_t base = at(0, y, z);
247259 for (int x = x0; x <= x1; x++) {
248 row[x] = 1;
260 if (!in_band[(size_t)(base + x)]) {
261 in_band[(size_t)(base + x)] = 1;
262 marked.push_back(base + x);
263 }
249264 }
250265 }
251266 }
252267 }
253 // 2. Dilate by `band` voxels, separably: three 1D max-filters rather
254 // than one 3D neighbourhood, so the cost is 3 x O(grid) x band.
255 std::vector<uint8_t> tmp(in_band.size());
256 auto dilate_axis = [&](int axis) {
257 tmp = in_band;
258 for (int64_t z = 0; z < nz; z++) {
259 for (int64_t y = 0; y < ny; y++) {
260 for (int64_t x = 0; x < nx; x++) {
261 if (tmp[(size_t)at(x, y, z)] == 0) {
262 continue;
263 }
264 for (int d = -bandv; d <= bandv; d++) {
265 int64_t xx = x, yy = y, zz = z;
266 if (axis == 0) xx += d; else if (axis == 1) yy += d; else zz += d;
267 if (xx < 0 || xx >= nx || yy < 0 || yy >= ny || zz < 0 || zz >= nz) {
268 continue;
269 }
270 in_band[(size_t)at(xx, yy, zz)] = 1;
271 }
268 t_stamp = Time::get_singleton()->get_ticks_usec();
269 // 2. Dilate by `band` voxels, separably, over the WORKLIST. Three 1D
270 // passes rather than one 3D neighbourhood, and each pass walks only
271 // the voxels currently in the band -- appending as it goes and
272 // iterating to the size the pass started at, which is exactly
273 // "dilate the current set along this axis". The mask dedupes, so a
274 // voxel is visited once however many neighbours reach it.
275 for (int axis = 0; axis < 3; axis++) {
276 const size_t cur = marked.size();
277 for (size_t k = 0; k < cur; k++) {
278 const int64_t o = marked[k];
279 const int64_t x = o % nx;
280 const int64_t y = (o / nx) % ny;
281 const int64_t z = o / (nx * ny);
282 for (int d = -bandv; d <= bandv; d++) {
283 if (d == 0) {
284 continue;
285 }
286 int64_t xx = x, yy = y, zz = z;
287 if (axis == 0) xx += d; else if (axis == 1) yy += d; else zz += d;
288 if (xx < 0 || xx >= nx || yy < 0 || yy >= ny || zz < 0 || zz >= nz) {
289 continue;
290 }
291 const int64_t nb = at(xx, yy, zz);
292 if (!in_band[(size_t)nb]) {
293 in_band[(size_t)nb] = 1;
294 marked.push_back(nb);
272295 }
273296 }
274297 }
275 };
276 dilate_axis(0);
277 dilate_axis(1);
278 dilate_axis(2);
298 }
279299 }
280300
301 t_dilate = Time::get_singleton()->get_ticks_usec();
281302 // ⛔ THREADED, because this is the whole cost of the node. libdc parallelises
282303 // its equivalent loops with `#pragma omp parallel for`, but proc-nodes builds
283304 // without -fopenmp so those pragmas are silently ignored and every libdc loop
……@@ -326,6 +347,7 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
326347 }
327348 }
328349
350 t_query = Time::get_singleton()->get_ticks_usec();
329351 // ── OUTSIDE BY FLOOD, INSIDE BY WHAT IS LEFT ────────────────────────────
330352 //
331353 // A voxel outside the band cannot contain a sign change, so it needs only
……@@ -377,6 +399,21 @@ void MeshToSDFGridNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
377399 }
378400 }
379401
402 {
403 // WHERE THE TIME GOES, per stage. "MeshToSDFGrid: 12.4 s" says nothing
404 // about whether that is the band being too wide, the queries being slow,
405 // or the bookkeeping around them.
406 const uint64_t t_end = Time::get_singleton()->get_ticks_usec();
407 int64_t band_n = 0;
408 for (size_t i = 0; i < in_band.size(); i++) {
409 band_n += in_band[i] ? 1 : 0;
410 }
411 UtilityFunctions::print(" [SDF] prep ", (t_setup - t_node0) / 1000, "ms bvh ", (t_bvh - t_setup) / 1000, "ms grid ", (int64_t)nx, "x", (int64_t)ny, "x", (int64_t)nz,
412 " = ", total, " voxels, band ", band_n,
413 band_n > 0 ? String(" (") + String::num((double)band_n * 100.0 / (double)total, 1) + "%)" : String(""),
414 " stamp ", (t_stamp - t_start) / 1000, "ms dilate ", (t_dilate - t_stamp) / 1000,
415 "ms query ", (t_query - t_dilate) / 1000, "ms flood ", (t_end - t_query) / 1000, "ms");
416 }
380417 out_geo->set_volume(vol);
381418 p_ctx.write_geometry(p_node_idx, 0, out_geo);
382419}