OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes commits / a8cad1c

libdc: the extractor is shared, and reaching for it found the CPU volume nodes are stubs
proc-nodes now compiles dc-terrain/libdc -- Dual Contouring, octree, QEF -- so Volume to Mesh can use the extractor that already exists rather than a second copy of it. That is the MIRROR of dc-terrain's own CPPPATH entry for ../proc-nodes/src/shared: the two repos already require each other side by side. * CPPPATH ../dc-terrain/libdc/include, sources through a VariantDir so OUR objects land in OUR tree -- dc-terrain compiles the same files with its own flags and leaves objects BESIDE THE SOURCE, and two builds writing the same .os paths would have the last one win silently. * A missing sibling is a LOUD Exit(1), not an empty Glob that links a library with no extractor in it. * ⚠ libdc is NOT quite self-contained, and my first check said it was because it filtered out every dc_ include and so could not see a dc_ include that ESCAPES libdc. Measured properly: dc_seam.h includes dc_octree_clipmap.h from dc-terrain/src, which uses godot_cpp. Seams stitch clipmap chunk boundaries; one authored volume has none. dc_seam.cpp is skipped, and so is dc_simplify.cpp, which wants <meshoptimizer.h> and which nothing on the meshing path calls. 8 objects build. * Recorded, not fixed: dc_seam.h reaching into src/ is a layering violation in dc-terrain. Moving dc_octree_clipmap.h into libdc would make libdc godot-dependent, which is worse. VolumeToMeshNode gains Resolution / Bounds Min / Bounds Size / Threshold, a DCGenerator adapter over the graph, and a re-entrancy guard -- meshing evaluates the density at every grid corner, which runs the graph, which runs this node. Resolution 0 (the default) means "carry the volume, do not mesh", so every graph written before today is unchanged. find_density_output names Volume to Mesh's socket 0 EXPLICITLY now. "The first connected Float" would happily have picked the new Threshold socket if Density were left unwired -- a density that is silently the iso level. --- ⛔ IT DOES NOT MESH YET, AND THE REASON IS NOT THE EXTRACTOR. SphereSdfNode::execute: "Since we don't have a single eval position in the mesh context, output the radius as a density proxy. Volume evaluation uses GLSL." p_ctx.write_float(p_node_idx, 0, r); The volume nodes have a real compile_glsl and a STUB execute(). The field handed to Dual Contouring is a constant, and a constant has no zero crossing. Measured: a unit-sphere graph returns 1.0 at every point, inside and outside. The node now says this out loud instead of returning an empty mesh, and the gate asserts the gap rather than asserting a success that is not there. Finishing it means the volume nodes evaluating per POSITION on the CPU -- a Vector input defaulting to the implicit Position field, the way the texture nodes already work. Nothing about the extractor, which is wired and reachable. --- TWO EXECUTOR BUGS FOUND ON THE WAY, both pre-existing, both in the CPU density evaluator that NOTHING CALLS -- grep across proc-nodes, dc-terrain and hyper-tps finds no caller of evaluate_density_batch or evaluate_density_at, which is why they stood. dc-terrain answers density from its own transcription instead. 1. evaluate_socket_value took NO OVERRIDES, so it evaluated against no geometry. evaluate_density_batch built a point cloud, executed with it, then threw that away and called this -- getting a constant from an empty context. 2. It was passed the terminal's INPUT socket index where it reads an OUTPUT socket. What the density is lives on the node FEEDING that input. Both fixed, in both entry points. The unit sphere went 0.0-everywhere to 1.0-everywhere, which is the volume-node stub above rather than these. glsl_golden_gate 30/30, emitted-op parity 770/770, all three proc-nodes targets build, dc-terrain still builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a8cad1c917a4f59ac4d8f711b1cf649609102311
by plankatron <fleeky@gmail.com> · 2026-09-06T15:55:43.000Z

Changed files (7) +320 -7

SConstruct +50 -1
……@@ -36,6 +36,54 @@ if os.environ.get("CXX"):
3636
3737env.Append(CPPPATH=["src/", "src/shared/"])
3838
39# ── libdc: THE ISOSURFACE EXTRACTOR, shared with dc-terrain ──────────────────
40#
41# Dual Contouring -- octree, QEF, contour, simplify -- lives in
42# dc-terrain/libdc, is pure C++ with no Godot dependency, and was already the
43# one implementation. proc-nodes needs it so Volume to Mesh can do what its name
44# says: turn a density field into a constructed mesh. Writing a second extractor
45# here is the mistake this repo has spent months undoing on other algorithms.
46#
47# This is the MIRROR of dc-terrain's own CPPPATH entry for
48# ../proc-nodes/src/shared -- the two repos already require each other to be
49# checked out side by side, and this makes it symmetric rather than new.
50LIBDC_INC = "../dc-terrain/libdc/include"
51LIBDC_SRC = "../dc-terrain/libdc/src"
52if not os.path.isdir(LIBDC_INC):
53 # LOUD. A missing sibling used to be an empty Glob, which builds a library
54 # with no extractor in it and fails at the call site -- or worse, links.
55 print("ERROR: dc-terrain must be checked out beside proc-nodes.")
56 print(" Expected: %s" % os.path.abspath(LIBDC_INC))
57 print(" libdc holds the Dual Contouring extractor that Volume to Mesh")
58 print(" uses; there is deliberately no second copy in this repo.")
59 Exit(1)
60env.Append(CPPPATH=[LIBDC_INC])
61
62# A VARIANT DIR, and it is not optional. dc-terrain compiles these same sources
63# with its own flags and leaves the objects BESIDE THE SOURCE (libdc/src/*.os).
64# Globbing them directly from here would have two builds writing the same object
65# paths with different flags, and whichever ran last would win silently.
66VariantDir("build/libdc", LIBDC_SRC, duplicate=0)
67
68# ⚠ EVERYTHING EXCEPT dc_seam. libdc is self-contained apart from one file:
69# dc_seam.h includes dc_octree_clipmap.h, which lives in dc-terrain/src and
70# DOES use godot_cpp -- so that one header breaks libdc's "pure C++" property
71# and would drag the engine in. Seams stitch chunk boundaries in the clipmap;
72# meshing a single authored volume has no boundaries to stitch, so nothing here
73# needs it.
74#
75# Recorded rather than fixed: the honest repair is dc_seam.h not reaching into
76# src/, and moving dc_octree_clipmap.h INTO libdc would make libdc
77# godot-dependent, which is worse than the layering violation.
78#
79# dc_simplify is out for a different reason: it includes <meshoptimizer.h> from
80# dc-terrain/thirdparty, and NOTHING on the meshing path calls it -- neither
81# dc_mesher.cpp nor dc_contour.cpp mentions simplify. Taking it would mean
82# vendoring a third-party library to compile a file we never call.
83LIBDC_SKIP = ("dc_seam.cpp", "dc_simplify.cpp")
84LIBDC_SOURCES = [f for f in Glob("build/libdc/*.cpp")
85 if not str(f).endswith(LIBDC_SKIP)]
86
3987# THE erosion filter lives once, in src/shared/erosion_filter.inc, and is
4088# compiled two ways: as C++ (via erosion_cpp_shim.h, here and in dc-terrain)
4189# and as GLSL (embedded in the generated header below, because a GDExtension
……@@ -88,7 +136,8 @@ glsl_inc_gen = [
88136]
89137
90138sources = (
91 Glob("src/*.cpp")
139 LIBDC_SOURCES
140 + Glob("src/*.cpp")
92141 + Glob("src/core/*.cpp")
93142 + Glob("src/graph/*.cpp")
94143 + Glob("src/nodes/*.cpp")
demo/volume_mesh_gate.gd +51 -0
……@@ -0,0 +1,51 @@
1## Volume to Mesh must actually MESH -- density field in, triangles out.
2##
3## The extractor is dc-terrain's libdc (Dual Contouring), compiled into this
4## library rather than reimplemented. This asserts the wiring works end to end
5## and that Resolution 0 still means "carry the volume, do not mesh", which is
6## what every graph written before today gets.
7extends SceneTree
8var _fail := 0
9func _ck(n: String, ok: bool, d: String) -> void:
10 print(" %-38s %s %s" % [n, "PASS" if ok else "FAIL", d])
11 if not ok: _fail += 1
12
13func _sphere_graph(res: int) -> ProcNodeGraph:
14 var g := ProcNodeGraph.new()
15 var s1 := g.add_node(SphereSdfNode.new())
16 var vtm := g.add_node(VolumeToMeshNode.new())
17 g.connect_sockets(s1, 0, vtm, 0)
18 g.get_node(vtm).set_input_default(1, res)
19 g.get_node(vtm).set_input_default(2, Vector3(-1.5, -1.5, -1.5))
20 g.get_node(vtm).set_input_default(3, Vector3(3.0, 3.0, 3.0))
21 return g
22
23func _init() -> void:
24 # Resolution 0: the pre-existing behaviour, unchanged.
25 var g0 := _sphere_graph(0)
26 var geo0 = g0.evaluate_socket(1, 0)
27 _ck("res 0: volume carried", geo0 != null and geo0.get_volume() != null
28 and not geo0.get_volume().get_glsl_source().is_empty(), "ProcVolume has GLSL")
29 _ck("res 0: no mesh", geo0 != null and (geo0.get_mesh() == null
30 or geo0.get_mesh().vertex_count() == 0), "meshing is opt-in")
31
32 # Resolution 16. ⛔ THIS DOES NOT MESH YET, and the gate says so rather than
33 # asserting a success that is not there. The extractor is wired and reachable
34 # -- libdc compiles into this library -- but the volume nodes have no CPU
35 # implementation: SphereSdfNode::execute writes its RADIUS as a placeholder
36 # ("Since we don't have a single eval position in the mesh context"), so the
37 # field is constant and Dual Contouring finds no zero crossing.
38 #
39 # When the volume nodes evaluate per position, these two flip to
40 # vertex_count() > 0 and the sphere-radius check below becomes the real one.
41 var g1 := _sphere_graph(16)
42 var geo1 = g1.evaluate_socket(1, 0)
43 var m = geo1.get_mesh() if geo1 != null else null
44 _ck("res 16: KNOWN GAP, no mesh yet", m == null or m.vertex_count() == 0,
45 "CPU volume nodes are stubs -- see the comment above")
46 _ck("res 16: volume still carried", geo1 != null and geo1.get_volume() != null
47 and not geo1.get_volume().get_glsl_source().is_empty(),
48 "mesh AND volume on one geometry")
49
50 print("\n%d/%d passed" % [4 - _fail, 4])
51 quit(1 if _fail > 0 else 0)
src/graph/graph_executor.cpp +6 -1
……@@ -1130,7 +1130,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p
11301130}
11311131
11321132Variant GraphExecutor::evaluate_socket_value(const ProcNodeGraph &p_graph,
1133 int p_node_idx, int p_socket_idx) {
1133 int p_node_idx, int p_socket_idx, const Dictionary &p_input_overrides) {
11341134 if (p_node_idx < 0 || p_node_idx >= p_graph.get_node_count()) {
11351135 return Variant();
11361136 }
……@@ -1149,6 +1149,11 @@ Variant GraphExecutor::evaluate_socket_value(const ProcNodeGraph &p_graph,
11491149 }
11501150
11511151 ProcEvalContext ctx;
1152 // The same override execute() takes, applied the same way: a graph reading
1153 // Position has to be given the points to read.
1154 if (p_input_overrides.has("geometry")) {
1155 ctx.input_geometry = p_input_overrides["geometry"];
1156 }
11521157 ctx.graph = &p_graph;
11531158 ctx.max_output_sockets = max_outputs;
11541159 ctx.output_values.resize(p_graph.get_node_count() * max_outputs);
src/graph/graph_executor.h +11 -1
……@@ -148,8 +148,18 @@ public:
148148 // from reaching into the production path.
149149 //
150150 // Returns nil if the indices are out of range.
151 //
152 // ⚠ p_input_overrides IS LOAD-BEARING, and its absence was a defect. This
153 // used to take no overrides, so it evaluated against NO GEOMETRY -- and a
154 // graph whose value depends on position (every SDF, every noise field) then
155 // answered from an empty context. evaluate_density_batch called it after
156 // carefully building a point cloud, and got a constant. Measured
157 // 2026-09-06: a unit-sphere SDF returned 0.0 at every one of five points,
158 // inside and outside. Nothing had ever called that path, which is why it
159 // stood.
151160 static Variant evaluate_socket_value(const ProcNodeGraph &p_graph,
152 int p_node_idx, int p_socket_idx);
161 int p_node_idx, int p_socket_idx,
162 const Dictionary &p_input_overrides = Dictionary());
153163
154164 static Ref<ProcGeometry> execute(
155165 const ProcNodeGraph &p_graph,
src/graph/proc_node_graph.cpp +54 -3
……@@ -474,7 +474,20 @@ bool ProcNodeGraph::find_density_output(int &r_node, int &r_socket) const {
474474 continue;
475475 }
476476 const StringName ty = n->get_node_type();
477 if (ty != StringName("GroupOutput") && ty != StringName("VolumeToMesh")) {
477 if (ty == StringName("VolumeToMesh")) {
478 // SOCKET 0 EXPLICITLY. Volume to Mesh has Resolution, Threshold and
479 // bounds sockets too, and "the first connected Float" would happily
480 // pick Threshold if Density were left unwired -- a density that is
481 // silently the iso level. Its Density input is socket 0 by
482 // construction, so name it.
483 if (n->get_input_count() > 0 && get_input_source_node(i, 0) >= 0) {
484 r_node = i;
485 r_socket = 0;
486 return true;
487 }
488 continue;
489 }
490 if (ty != StringName("GroupOutput")) {
478491 continue;
479492 }
480493 for (int s = 0; s < n->get_input_count(); s++) {
……@@ -545,7 +558,26 @@ PackedFloat32Array ProcNodeGraph::evaluate_density_batch(const PackedVector3Arra
545558
546559 int out_node = -1, out_socket = -1;
547560 if (_density_output_socket(*this, out_node, out_socket)) {
548 const Variant v = GraphExecutor::evaluate_socket_value(*this, out_node, out_socket);
561 // ⛔ TWO BUGS IN ONE LINE, both fixed here, in BOTH density entry points.
562 //
563 // out_socket is an INPUT socket of the terminal (Group Output's float,
564 // Volume to Mesh's Density). evaluate_socket_value reads an OUTPUT socket,
565 // so this read a different slot entirely. What the density IS lives on the
566 // node FEEDING that input.
567 //
568 // And it passed no overrides, so the fresh run evaluated against NO
569 // GEOMETRY -- discarding the points built immediately above and answering
570 // from an empty context. Measured 2026-09-06: a unit-sphere SDF returned
571 // 0.0 at every one of five points, inside and outside, through both the
572 // Group Output and the Volume to Mesh spelling. Nothing in proc-nodes,
573 // dc-terrain or hyper-tps calls these, which is why it stood.
574 const int src_node = get_input_source_node(out_node, out_socket);
575 const int src_socket = get_input_source_socket(out_node, out_socket);
576 if (src_node < 0) {
577 return out; // declared an output and wired nothing to it
578 }
579 const Variant v = GraphExecutor::evaluate_socket_value(
580 *this, src_node, src_socket, overrides);
549581 // A scalar output is the same value at every point; a field is per point.
550582 if (v.get_type() == Variant::PACKED_FLOAT32_ARRAY) {
551583 PackedFloat32Array f = v;
……@@ -614,7 +646,26 @@ float ProcNodeGraph::evaluate_density_at(const Vector3 &p_pos) {
614646
615647 int out_node = -1, out_socket = -1;
616648 if (_density_output_socket(*this, out_node, out_socket)) {
617 const Variant v = GraphExecutor::evaluate_socket_value(*this, out_node, out_socket);
649 // ⛔ TWO BUGS IN ONE LINE, both fixed here, in BOTH density entry points.
650 //
651 // out_socket is an INPUT socket of the terminal (Group Output's float,
652 // Volume to Mesh's Density). evaluate_socket_value reads an OUTPUT socket,
653 // so this read a different slot entirely. What the density IS lives on the
654 // node FEEDING that input.
655 //
656 // And it passed no overrides, so the fresh run evaluated against NO
657 // GEOMETRY -- discarding the points built immediately above and answering
658 // from an empty context. Measured 2026-09-06: a unit-sphere SDF returned
659 // 0.0 at every one of five points, inside and outside, through both the
660 // Group Output and the Volume to Mesh spelling. Nothing in proc-nodes,
661 // dc-terrain or hyper-tps calls these, which is why it stood.
662 const int src_node = get_input_source_node(out_node, out_socket);
663 const int src_socket = get_input_source_socket(out_node, out_socket);
664 if (src_node < 0) {
665 return 0.0f; // declared an output and wired nothing to it
666 }
667 const Variant v = GraphExecutor::evaluate_socket_value(
668 *this, src_node, src_socket, overrides);
618669 if (v.get_type() == Variant::PACKED_FLOAT32_ARRAY) {
619670 PackedFloat32Array f = v;
620671 return f.size() > 0 ? f[0] : 0.0f;
src/nodes/volume_nodes.cpp +142 -1
……@@ -2,6 +2,9 @@
22
33#include "../graph/graph_executor.h"
44#include "../graph/proc_node_graph.h"
5#include "../core/proc_mesh.h"
6#include "dc_density_grid.h"
7#include "dc_mesher.h"
58
69#include <godot_cpp/variant/utility_functions.hpp>
710#include "../graph/graph_executor.h"
……@@ -92,10 +95,57 @@ void VolumeToMeshNode::_bind_methods() {
9295}
9396
9497VolumeToMeshNode::VolumeToMeshNode() {
95 input_defs.push_back({ "Density", SocketType::Float, 0.0f });
98 // Socket 0 is the DENSITY and find_density_output() names it by index, so
99 // it must stay first. The rest are the meshing controls; Resolution 0 means
100 // "carry the volume, do not mesh", which is what every existing graph gets.
101 input_defs.push_back({ "Density", SocketType::Float, 0.0f }); // 0
102 input_defs.push_back({ "Resolution", SocketType::Int, 0 }); // 1
103 input_defs.push_back({ "Bounds Min", SocketType::Vector, Vector3(-1, -1, -1) }); // 2
104 input_defs.push_back({ "Bounds Size", SocketType::Vector, Vector3(2, 2, 2) }); // 3
105 input_defs.push_back({ "Threshold", SocketType::Float, 0.0f }); // 4
96106 output_defs.push_back({ "Geometry", SocketType::Geometry, Variant() });
97107}
98108
109namespace {
110
111// THE GRAPH AS A DENSITY FIELD, for libdc's extractor.
112//
113// dc_mesh_from_grid wants a filled DensityGrid, and DensityGrid::fill_generator
114// wants a DCGenerator. The graph's density at a POSITION is only obtainable by
115// running the graph, so this runs it -- once per batch, not once per corner,
116// through evaluate_density_batch and the evaluate_batch override added to
117// DCGenerator for exactly this shape.
118class GraphDensityGenerator : public DCGenerator {
119 ProcNodeGraph *graph_;
120 float threshold_;
121
122public:
123 GraphDensityGenerator(ProcNodeGraph *g, float t) : graph_(g), threshold_(t) {}
124
125 float evaluate(float x, float y, float z, float) override {
126 return (float)graph_->evaluate_density_at(Vector3(x, y, z)) - threshold_;
127 }
128
129 void evaluate_batch(const float *p_xyz, int p_count, float, float *r_out) override {
130 PackedVector3Array pts;
131 pts.resize(p_count);
132 Vector3 *w = pts.ptrw();
133 for (int i = 0; i < p_count; i++) {
134 w[i] = Vector3(p_xyz[i * 3], p_xyz[i * 3 + 1], p_xyz[i * 3 + 2]);
135 }
136 PackedFloat32Array d = graph_->evaluate_density_batch(pts);
137 const int n = MIN(p_count, d.size());
138 for (int i = 0; i < n; i++) {
139 r_out[i] = d[i] - threshold_;
140 }
141 for (int i = n; i < p_count; i++) {
142 r_out[i] = -1.0f; // outside, rather than uninitialised
143 }
144 }
145};
146
147} // namespace
148
99149void VolumeToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
100150 // FILL THE VOLUME. This used to write an EMPTY ProcVolume on an empty
101151 // ProcGeometry -- no mesh, no density, no message -- so a graph ending here
……@@ -106,6 +156,14 @@ void VolumeToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
106156 // in it. The graph can emit exactly that, and now does.
107157 Ref<ProcGeometry> geo;
108158 geo.instantiate();
159 if (meshing_) {
160 // THE NESTED PASS. Meshing runs the graph per grid batch, which runs
161 // this node again; without this it recurses until the stack ends.
162 // Upstream still computes the density -- that is what the extractor is
163 // reading -- so returning a bare geometry here costs nothing.
164 p_ctx.write_geometry(p_node_idx, 0, geo);
165 return;
166 }
109167 Ref<ProcVolume> vol;
110168 vol.instantiate();
111169 if (p_ctx.graph != nullptr) {
……@@ -124,6 +182,89 @@ void VolumeToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) {
124182 }
125183 }
126184 geo->set_volume(vol);
185
186 // ── AND THE MESH, which is what the node is named for ────────────────────
187 //
188 // Isosurface extraction: density field -> constructed mesh. The extractor is
189 // dc-terrain's libdc -- Dual Contouring, octree, QEF -- compiled into this
190 // library rather than reimplemented, because a second copy of an algorithm
191 // is the failure this repo keeps paying to undo.
192 const int resolution = p_ctx.read_int(p_node_idx, 1);
193 if (resolution > 0 && p_ctx.graph != nullptr) {
194 const Vector3 bmin = p_ctx.read_vector(p_node_idx, 2);
195 const Vector3 bsize = p_ctx.read_vector(p_node_idx, 3);
196 const float threshold = p_ctx.read_float(p_node_idx, 4);
197 const float span = MAX(MAX(bsize.x, bsize.y), bsize.z);
198 if (span > 0.0f) {
199 // const_cast: evaluate_density_batch caches a plan and a scratch
200 // geometry, so it is non-const for reasons that are bookkeeping.
201 // The graph is not modified.
202 ProcNodeGraph *g = const_cast<ProcNodeGraph *>(p_ctx.graph);
203 GraphDensityGenerator gen(g, threshold);
204 const float voxel = span / (float)resolution;
205 const float origin[3] = { (float)bmin.x, (float)bmin.y, (float)bmin.z };
206
207 meshing_ = true;
208 DensityGrid grid;
209 grid.allocate(resolution, voxel, origin);
210 grid.fill_generator(&gen, voxel);
211 grid.compute_normals();
212 DCMesherConfig cfg;
213 DCMeshData md = dc_mesh_from_grid(grid, cfg);
214 meshing_ = false;
215
216 const int vcount = (int)md.positions.size() / 3;
217 if (vcount == 0 || md.indices.empty()) {
218 // ⛔ THE EXTRACTOR IS FINE. THE CPU DENSITY IS NOT.
219 //
220 // SphereSdfNode::execute writes its RADIUS as a "density proxy"
221 // -- its own comment says so -- and VolumeBooleanNode combines
222 // those proxies, so the field handed to Dual Contouring is a
223 // constant and a constant has no zero crossing. The volume
224 // nodes have a real compile_glsl and a stub execute(); only the
225 // GPU half was ever written.
226 //
227 // Measured 2026-09-06: a unit-sphere graph returns 1.0 at every
228 // point, inside and outside.
229 //
230 // Fixing it means the volume nodes evaluating per POSITION on
231 // the CPU -- taking a Vector input that defaults to the implicit
232 // Position field, the way the texture nodes do -- not anything
233 // about the extractor, which is wired and reachable.
234 UtilityFunctions::push_warning(
235 "proc-nodes: Volume to Mesh extracted no surface. The volume "
236 "nodes have no CPU implementation -- SphereSDF writes its "
237 "radius as a placeholder -- so the density field is constant "
238 "and has no zero crossing. The GLSL path is unaffected.");
239 }
240 if (vcount > 0 && !md.indices.empty()) {
241 Ref<ProcMesh> mesh = geo->ensure_mesh();
242 mesh->positions.resize(vcount);
243 Vector3 *pw = mesh->positions.ptrw();
244 for (int i = 0; i < vcount; i++) {
245 pw[i] = Vector3(md.positions[i * 3], md.positions[i * 3 + 1],
246 md.positions[i * 3 + 2]);
247 }
248 if ((int)md.normals.size() == vcount * 3) {
249 mesh->normals.resize(vcount);
250 Vector3 *nw = mesh->normals.ptrw();
251 for (int i = 0; i < vcount; i++) {
252 nw[i] = Vector3(md.normals[i * 3], md.normals[i * 3 + 1],
253 md.normals[i * 3 + 2]);
254 }
255 }
256 mesh->indices.resize((int)md.indices.size());
257 int32_t *iw = mesh->indices.ptrw();
258 for (size_t i = 0; i < md.indices.size(); i++) {
259 iw[i] = md.indices[i];
260 }
261 // Dual Contouring emits TRIANGLES, so any ngon grouping the
262 // input had is gone -- clearing face_offsets is what every
263 // topology-changing operation here does.
264 mesh->face_offsets.clear();
265 }
266 }
267 }
127268 p_ctx.write_geometry(p_node_idx, 0, geo);
128269}
129270
src/nodes/volume_nodes.h +6 -0
……@@ -77,6 +77,12 @@ protected:
7777public:
7878 VolumeToMeshNode();
7979
80 // ⚠ RE-ENTRANCY. Meshing evaluates the graph's density at every grid corner,
81 // and the only way to get a density AT A POSITION is to run the graph --
82 // which runs THIS NODE again. The nested pass must not mesh, or it recurses
83 // until the stack ends. It writes a bare geometry and returns.
84 bool meshing_ = false;
85
8086 StringName get_node_type() const override { return "VolumeToMesh"; }
8187 String get_node_title() const override { return "Volume to Mesh"; }
8288 String get_node_category() const override { return "Volume"; }