| … | … | @@ -36,6 +36,54 @@ if os.environ.get("CXX"): |
| 36 | 36 | |
| 37 | 37 | env.Append(CPPPATH=["src/", "src/shared/"]) |
| 38 | 38 | |
| 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. | |
| 50 | LIBDC_INC = "../dc-terrain/libdc/include" | |
| 51 | LIBDC_SRC = "../dc-terrain/libdc/src" | |
| 52 | if 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) | |
| 60 | env.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. | |
| 66 | VariantDir("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. | |
| 83 | LIBDC_SKIP = ("dc_seam.cpp", "dc_simplify.cpp") | |
| 84 | LIBDC_SOURCES = [f for f in Glob("build/libdc/*.cpp") | |
| 85 | if not str(f).endswith(LIBDC_SKIP)] | |
| 86 | ||
| 39 | 87 | # THE erosion filter lives once, in src/shared/erosion_filter.inc, and is |
| 40 | 88 | # compiled two ways: as C++ (via erosion_cpp_shim.h, here and in dc-terrain) |
| 41 | 89 | # and as GLSL (embedded in the generated header below, because a GDExtension |
| … | … | @@ -88,7 +136,8 @@ glsl_inc_gen = [ |
| 88 | 136 | ] |
| 89 | 137 | |
| 90 | 138 | sources = ( |
| 91 | Glob("src/*.cpp") | |
| 139 | LIBDC_SOURCES | |
| 140 | + Glob("src/*.cpp") | |
| 92 | 141 | + Glob("src/core/*.cpp") |
| 93 | 142 | + Glob("src/graph/*.cpp") |
| 94 | 143 | + Glob("src/nodes/*.cpp") |
| … | … | @@ -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. | |
| 7 | extends SceneTree | |
| 8 | var _fail := 0 | |
| 9 | func _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 | ||
| 13 | func _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 | ||
| 23 | func _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) |
| … | … | @@ -1130,7 +1130,7 @@ void GraphExecutor::_execute_gpu_batch(ProcEvalContext &p_ctx, const GpuBatch &p |
| 1130 | 1130 | } |
| 1131 | 1131 | |
| 1132 | 1132 | Variant 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) { | |
| 1134 | 1134 | if (p_node_idx < 0 || p_node_idx >= p_graph.get_node_count()) { |
| 1135 | 1135 | return Variant(); |
| 1136 | 1136 | } |
| … | … | @@ -1149,6 +1149,11 @@ Variant GraphExecutor::evaluate_socket_value(const ProcNodeGraph &p_graph, |
| 1149 | 1149 | } |
| 1150 | 1150 | |
| 1151 | 1151 | 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 | } | |
| 1152 | 1157 | ctx.graph = &p_graph; |
| 1153 | 1158 | ctx.max_output_sockets = max_outputs; |
| 1154 | 1159 | ctx.output_values.resize(p_graph.get_node_count() * max_outputs); |
| … | … | @@ -148,8 +148,18 @@ public: |
| 148 | 148 | // from reaching into the production path. |
| 149 | 149 | // |
| 150 | 150 | // 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. | |
| 151 | 160 | 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()); | |
| 153 | 163 | |
| 154 | 164 | static Ref<ProcGeometry> execute( |
| 155 | 165 | const ProcNodeGraph &p_graph, |
| … | … | @@ -474,7 +474,20 @@ bool ProcNodeGraph::find_density_output(int &r_node, int &r_socket) const { |
| 474 | 474 | continue; |
| 475 | 475 | } |
| 476 | 476 | 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")) { | |
| 478 | 491 | continue; |
| 479 | 492 | } |
| 480 | 493 | for (int s = 0; s < n->get_input_count(); s++) { |
| … | … | @@ -545,7 +558,26 @@ PackedFloat32Array ProcNodeGraph::evaluate_density_batch(const PackedVector3Arra |
| 545 | 558 | |
| 546 | 559 | int out_node = -1, out_socket = -1; |
| 547 | 560 | 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); | |
| 549 | 581 | // A scalar output is the same value at every point; a field is per point. |
| 550 | 582 | if (v.get_type() == Variant::PACKED_FLOAT32_ARRAY) { |
| 551 | 583 | PackedFloat32Array f = v; |
| … | … | @@ -614,7 +646,26 @@ float ProcNodeGraph::evaluate_density_at(const Vector3 &p_pos) { |
| 614 | 646 | |
| 615 | 647 | int out_node = -1, out_socket = -1; |
| 616 | 648 | 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); | |
| 618 | 669 | if (v.get_type() == Variant::PACKED_FLOAT32_ARRAY) { |
| 619 | 670 | PackedFloat32Array f = v; |
| 620 | 671 | return f.size() > 0 ? f[0] : 0.0f; |
| … | … | @@ -2,6 +2,9 @@ |
| 2 | 2 | |
| 3 | 3 | #include "../graph/graph_executor.h" |
| 4 | 4 | #include "../graph/proc_node_graph.h" |
| 5 | #include "../core/proc_mesh.h" | |
| 6 | #include "dc_density_grid.h" | |
| 7 | #include "dc_mesher.h" | |
| 5 | 8 | |
| 6 | 9 | #include <godot_cpp/variant/utility_functions.hpp> |
| 7 | 10 | #include "../graph/graph_executor.h" |
| … | … | @@ -92,10 +95,57 @@ void VolumeToMeshNode::_bind_methods() { |
| 92 | 95 | } |
| 93 | 96 | |
| 94 | 97 | VolumeToMeshNode::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 | |
| 96 | 106 | output_defs.push_back({ "Geometry", SocketType::Geometry, Variant() }); |
| 97 | 107 | } |
| 98 | 108 | |
| 109 | namespace { | |
| 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. | |
| 118 | class GraphDensityGenerator : public DCGenerator { | |
| 119 | ProcNodeGraph *graph_; | |
| 120 | float threshold_; | |
| 121 | ||
| 122 | public: | |
| 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 | ||
| 99 | 149 | void VolumeToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) { |
| 100 | 150 | // FILL THE VOLUME. This used to write an EMPTY ProcVolume on an empty |
| 101 | 151 | // 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) { |
| 106 | 156 | // in it. The graph can emit exactly that, and now does. |
| 107 | 157 | Ref<ProcGeometry> geo; |
| 108 | 158 | 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 | } | |
| 109 | 167 | Ref<ProcVolume> vol; |
| 110 | 168 | vol.instantiate(); |
| 111 | 169 | if (p_ctx.graph != nullptr) { |
| … | … | @@ -124,6 +182,89 @@ void VolumeToMeshNode::execute(ProcEvalContext &p_ctx, int p_node_idx) { |
| 124 | 182 | } |
| 125 | 183 | } |
| 126 | 184 | 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 | } | |
| 127 | 268 | p_ctx.write_geometry(p_node_idx, 0, geo); |
| 128 | 269 | } |
| 129 | 270 |
| … | … | @@ -77,6 +77,12 @@ protected: |
| 77 | 77 | public: |
| 78 | 78 | VolumeToMeshNode(); |
| 79 | 79 | |
| 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 | ||
| 80 | 86 | StringName get_node_type() const override { return "VolumeToMesh"; } |
| 81 | 87 | String get_node_title() const override { return "Volume to Mesh"; } |
| 82 | 88 | String get_node_category() const override { return "Volume"; } |