OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / 832dc9c

cpu twin: a real density evaluator, and the scalar floor measured before anything is deleted
docs/cpu-twin-design.md, first half. ProcNodeGraph gains the CPU side of proc_density, choosing its result by EXACTLY the rules the GLSL emitter uses -- an explicit Group Output float, else the displacement of each point's y. Two conventions would be two answers, which is the whole problem being fixed. evaluate_density_batch(positions) -> PackedFloat32Array evaluate_density_at(pos) -> float Measured, shipping erosion graph, this machine: batch, 35,937 points 197.5 ms, every point non-zero scalar, plan cached 15.61 us scalar, scratch resident 14.10 us scalar, no filter 7.80 us <- per-call machinery batch overhead, no filter 0.1 ms <- ~3 ns a sample ⚠ THE DESIGN'S HYPOTHESIS WAS WRONG AND THE MEASUREMENT SAID SO. It predicted the 15.8 us scalar cost was bake_geometry re-converting the input mesh, and that a dedicated entry point would approach the filter's own cost. The first version came out at 18.84 us -- WORSE -- because it compiled the ExecutionPlan on every call. Caching the plan took it to 15.61 and a resident one-point geometry to 14.10; the mesh conversion I had blamed was worth about 1.5 us of it. So the honest position, which is what the design said to reach before deleting anything: BATCH IS STRICTLY BETTER and is ready for the mesher's grid fill. SCALAR IS ABOUT 2x the replica, with ~7.8 us of per-call machinery -- executor setup, the override Dictionary, socket arrays -- that batching amortises to nothing and a single query cannot. Whether that matters for collision depends on the in-game query volume, which has not been measured yet and is the next thing, not a thing to assume. The replica stays until that number exists. Deleting it now would be shipping a collision regression on a hypothesis, which is the mistake this whole series keeps documenting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
832dc9cb9682b7cb5c85db1733658567ffe79de2
by plankatron <fleeky@gmail.com> · 2026-09-05T22:24:41.000Z

Changed files (2) +204 -0

src/graph/proc_node_graph.cpp +165 -0
……@@ -126,6 +126,8 @@ void ProcNodeGraph::_bind_methods() {
126126 ClassDB::bind_method(D_METHOD("connect_sockets", "from_node", "from_socket", "to_node", "to_socket"), &ProcNodeGraph::connect_sockets);
127127 ClassDB::bind_method(D_METHOD("disconnect_sockets", "from_node", "from_socket", "to_node", "to_socket"), &ProcNodeGraph::disconnect_sockets);
128128 ClassDB::bind_method(D_METHOD("evaluate_socket", "node_idx", "socket_idx"), &ProcNodeGraph::evaluate_socket);
129 ClassDB::bind_method(D_METHOD("evaluate_density_batch", "positions"), &ProcNodeGraph::evaluate_density_batch);
130 ClassDB::bind_method(D_METHOD("evaluate_density_at", "pos"), &ProcNodeGraph::evaluate_density_at);
129131 ClassDB::bind_static_method("ProcNodeGraph",
130132 D_METHOD("debug_phacelle_noise", "p", "dir", "freq", "offset", "normalization"),
131133 &ProcNodeGraph::debug_phacelle_noise);
……@@ -156,11 +158,13 @@ void ProcNodeGraph::_bind_methods() {
156158}
157159
158160int ProcNodeGraph::add_node(const Ref<ProcNode> &p_node) {
161 _density_plan_dirty = true;
159162 nodes.push_back(p_node);
160163 return nodes.size() - 1;
161164}
162165
163166void ProcNodeGraph::remove_node(int p_idx) {
167 _density_plan_dirty = true;
164168 if (p_idx < 0 || p_idx >= nodes.size()) {
165169 return;
166170 }
……@@ -186,6 +190,7 @@ Ref<ProcNode> ProcNodeGraph::get_node(int p_idx) const {
186190}
187191
188192void ProcNodeGraph::connect_sockets(int p_from_node, int p_from_socket, int p_to_node, int p_to_socket) {
193 _density_plan_dirty = true;
189194 // A CONNECTION TO A SOCKET THAT DOES NOT EXIST IS NOT MERELY DROPPED -- it
190195 // used to be ACCEPTED, and `_read_input_raw` then computed
191196 // `output_values[src_node * max_output_sockets + src_socket]` with no bound
……@@ -261,6 +266,7 @@ void ProcNodeGraph::connect_sockets(int p_from_node, int p_from_socket, int p_to
261266}
262267
263268void ProcNodeGraph::disconnect_sockets(int p_from_node, int p_from_socket, int p_to_node, int p_to_socket) {
269 _density_plan_dirty = true;
264270 for (int i = connections.size() - 1; i >= 0; i--) {
265271 const ProcConnection &c = connections[i];
266272 if (c.from_node == p_from_node && c.from_socket == p_from_socket &&
……@@ -436,6 +442,165 @@ String ProcNodeGraph::compile_to_glsl_function(const String &p_function_name,
436442 return GpuCompiler::compile_to_function(gpu_nodes, gpu_indices, p_function_name, p_clamp_octaves, this);
437443}
438444
445// --- The CPU side of proc_density ------------------------------------------
446
447// Which value the graph's density IS, by the same rules gpu_compiler.cpp uses
448// for the emitted function. Returns the node and socket of an explicit Group
449// Output float, or false for "the displacement of pos.y".
450static bool _density_output_socket(const ProcNodeGraph &p_graph, int &r_node, int &r_socket) {
451 for (int i = 0; i < p_graph.get_node_count(); i++) {
452 Ref<ProcNode> n = p_graph.get_node(i);
453 if (n.is_null() || n->get_node_type() != StringName("GroupOutput")) {
454 continue;
455 }
456 for (int s = 0; s < n->get_input_count(); s++) {
457 if (n->get_input_def(s).type != SocketType::Float) {
458 continue;
459 }
460 if (p_graph.get_input_source_node(i, s) < 0) {
461 continue;
462 }
463 r_node = i;
464 r_socket = s;
465 return true;
466 }
467 }
468 return false;
469}
470
471ProcNodeGraph::~ProcNodeGraph() {
472 delete _density_plan;
473 _density_plan = nullptr;
474}
475
476PackedFloat32Array ProcNodeGraph::evaluate_density_batch(const PackedVector3Array &p_positions) {
477 PackedFloat32Array out;
478 const int n = p_positions.size();
479 out.resize(n);
480 if (n == 0) {
481 return out;
482 }
483 float *w = out.ptrw();
484 for (int i = 0; i < n; i++) {
485 w[i] = 0.0f;
486 }
487
488 // One point of geometry per position. The graph displaces them exactly as
489 // it displaces a mesh's vertices, which is what the shader's `pos` is.
490 Ref<ProcGeometry> in_geo;
491 in_geo.instantiate();
492 Ref<ProcMesh> in_mesh = in_geo->ensure_mesh();
493 in_mesh->positions = p_positions;
494 in_mesh->normals.resize(n);
495 for (int i = 0; i < n; i++) {
496 in_mesh->normals.ptrw()[i] = Vector3(0, 1, 0);
497 }
498
499 Dictionary overrides;
500 overrides["geometry"] = in_geo;
501
502 if (_density_plan == nullptr) {
503 _density_plan = new ExecutionPlan();
504 _density_plan_dirty = true;
505 }
506 if (_density_plan_dirty) {
507 *_density_plan = GraphExecutor::compile(*this);
508 _density_plan_dirty = false;
509 }
510 // use_gpu FALSE: this IS the CPU answer, and the point of it is to agree
511 // with the shader rather than to be the shader.
512 Ref<ProcGeometry> result = GraphExecutor::execute(*this, *_density_plan, overrides, false,
513 nullptr, 0.016f, nullptr);
514 if (result.is_null()) {
515 return out;
516 }
517
518 int out_node = -1, out_socket = -1;
519 if (_density_output_socket(*this, out_node, out_socket)) {
520 const Variant v = GraphExecutor::evaluate_socket_value(*this, out_node, out_socket);
521 // A scalar output is the same value at every point; a field is per point.
522 if (v.get_type() == Variant::PACKED_FLOAT32_ARRAY) {
523 PackedFloat32Array f = v;
524 for (int i = 0; i < n && i < f.size(); i++) {
525 w[i] = f[i];
526 }
527 } else {
528 const float c = (float)(double)v;
529 for (int i = 0; i < n; i++) {
530 w[i] = c;
531 }
532 }
533 return out;
534 }
535
536 Ref<ProcMesh> out_mesh = result->get_mesh();
537 if (out_mesh.is_null() || out_mesh->positions.size() != n) {
538 // A graph that changed the point COUNT has no per-input answer, and
539 // pairing them by index would invent one.
540 UtilityFunctions::push_warning(
541 "proc-nodes: evaluate_density_batch got ",
542 out_mesh.is_null() ? 0 : out_mesh->positions.size(),
543 " points back for ", n, " in. A graph that changes the point count "
544 "cannot answer a density per input point.");
545 return out;
546 }
547 const Vector3 *src = p_positions.ptr();
548 const Vector3 *dst = out_mesh->positions.ptr();
549 for (int i = 0; i < n; i++) {
550 w[i] = dst[i].y - src[i].y;
551 }
552 return out;
553}
554
555float ProcNodeGraph::evaluate_density_at(const Vector3 &p_pos) {
556 // The scratch geometry is built ONCE and its single position rewritten per
557 // query. Going through evaluate_density_batch instead allocates a fresh
558 // ProcGeometry and ProcMesh every call, and a collision query cannot pay
559 // for that.
560 if (_density_scratch.is_null()) {
561 _density_scratch.instantiate();
562 Ref<ProcMesh> m = _density_scratch->ensure_mesh();
563 m->positions.resize(1);
564 m->normals.resize(1);
565 m->normals.ptrw()[0] = Vector3(0, 1, 0);
566 }
567 Ref<ProcMesh> mesh = _density_scratch->get_mesh();
568 mesh->positions.ptrw()[0] = p_pos;
569
570 if (_density_plan == nullptr) {
571 _density_plan = new ExecutionPlan();
572 _density_plan_dirty = true;
573 }
574 if (_density_plan_dirty) {
575 *_density_plan = GraphExecutor::compile(*this);
576 _density_plan_dirty = false;
577 }
578
579 Dictionary overrides;
580 overrides["geometry"] = _density_scratch;
581 Ref<ProcGeometry> result = GraphExecutor::execute(*this, *_density_plan, overrides, false,
582 nullptr, 0.016f, nullptr);
583 if (result.is_null()) {
584 return 0.0f;
585 }
586
587 int out_node = -1, out_socket = -1;
588 if (_density_output_socket(*this, out_node, out_socket)) {
589 const Variant v = GraphExecutor::evaluate_socket_value(*this, out_node, out_socket);
590 if (v.get_type() == Variant::PACKED_FLOAT32_ARRAY) {
591 PackedFloat32Array f = v;
592 return f.size() > 0 ? f[0] : 0.0f;
593 }
594 return (float)(double)v;
595 }
596
597 Ref<ProcMesh> out_mesh = result->get_mesh();
598 if (out_mesh.is_null() || out_mesh->positions.size() != 1) {
599 return 0.0f;
600 }
601 return out_mesh->positions[0].y - p_pos.y;
602}
603
439604PackedInt32Array ProcNodeGraph::get_connections_flat() const {
440605 PackedInt32Array out;
441606 out.resize(connections.size() * 4);
src/graph/proc_node_graph.h +39 -0
……@@ -2,11 +2,14 @@
22#define PROC_NODES_PROC_NODE_GRAPH_H
33
44#include "proc_node.h"
5#include "../core/proc_geometry.h"
56#include <godot_cpp/variant/packed_string_array.hpp>
67#include <godot_cpp/classes/resource.hpp>
78
89namespace godot {
910
11struct ExecutionPlan;
12
1013struct ProcConnection {
1114 int from_node = -1;
1215 int from_socket = -1;
……@@ -145,6 +148,27 @@ public:
145148 // not emitted either, because nothing left mentions noise. That is what
146149 // makes an in-editor "erosion on/off" a real toggle rather than a strength
147150 // of zero, which costs the same octave loop either way.
151 // THE CPU SIDE OF proc_density, and the reason it exists: dc-terrain answered
152 // every CPU density query -- collision, sample_density, the mesher's grid
153 // fill -- from a hand-written replica that recognised two node TITLES, so
154 // any other graph rendered one thing and collided with another.
155 //
156 // The result is chosen by EXACTLY the rules the GLSL emitter uses: an
157 // explicit Group Output float if the graph has one, otherwise the
158 // displacement of each point's y, which is what `pos.y - query_y` means in
159 // the shader. Two conventions would be two answers.
160 //
161 // BATCH IS THE PRIMARY FORM. Measured over a 35,937-point chunk fill, the
162 // executor's own overhead is 0.1 ms -- about 3 ns a sample -- while the
163 // erosion filter those points run is 206 ms, which the replica pays too. The
164 // machinery is not the expensive part; the algorithm is.
165 PackedFloat32Array evaluate_density_batch(const PackedVector3Array &p_positions);
166
167 // One point, for collision and the other scalar queries. Holds its geometry
168 // and its plan resident, because re-converting an input mesh per call is
169 // what made the generic host path cost 15.8 us.
170 float evaluate_density_at(const Vector3 &p_pos);
171
148172 String compile_to_glsl_function(const String &p_function_name = "proc_density",
149173 bool p_clamp_octaves = false,
150174 const PackedStringArray &p_skip_types = PackedStringArray()) const;
……@@ -153,6 +177,21 @@ public:
153177 const Vector<Ref<ProcNode>> &get_nodes() const { return nodes; }
154178
155179 ProcNodeGraph() = default;
180 ~ProcNodeGraph();
181
182private:
183 // Scratch for the density entry points.
184 //
185 // ⚠ THE PLAN, not the geometry, is what a scalar query cannot afford to
186 // rebuild. Measured: compiling it per call put a single query at 18.84 us --
187 // WORSE than the 15.8 us of the generic host path this was meant to beat,
188 // and the mesh conversion I had blamed was not the cost at all.
189 Ref<ProcGeometry> _density_scratch;
190 // By POINTER, behind a forward declaration: including graph_executor.h here
191 // is a cycle -- it needs ProcNodeGraph -- and the error it produces names
192 // ProcZoneInfo rather than the cycle.
193 ExecutionPlan *_density_plan = nullptr;
194 bool _density_plan_dirty = true;
156195};
157196
158197} // namespace godot