OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / f1cb2d7

dc: bake a ProcVolume to a mesh on the GPU, through the pipeline that already exists
The cloud path was already GPU -- ProcVolume's GLSL in a shader_type fog material, raymarched by Godot's froxel pass. This is the OTHER half: turning the same volume into a constructed mesh, also on the GPU, rather than through libdc's CPU dual contouring. DCTerrain::bake_volume_mesh(glsl, origin, voxel_size, cells) -> ArrayMesh. It fills a density grid from the volume's GLSL and meshes it in ONE DCGpuThread submit -- both stages on the device. 536 verts, 1068 tris for a unit sphere; AABB -0.9956 .. 0.9956 on every axis, worst |r - 1| = 0.0075, well inside one voxel (0.1875). WHY HERE AND NOT IN proc-nodes. DCGpuThread and DCGpuMeshPipeline need a RenderingDevice, so they cannot move to libdc without making libdc godot-dependent. And a SECOND RenderingDevice is not an option: a second VkDevice bring-up starves and left two meshers running in one world (hyper-games #58). This borrows the terrain's one device -- and creates it into the same member if the terrain has not, so there is still only ever one. It takes a String rather than a ProcVolume because dc-terrain has no compile-time dependency on proc-nodes' classes. --- ⛔ THE ENTRY POINT IS evaluate_density(float,float,float), NOT proc_density(vec3), AND GETTING IT WRONG IS SILENT. DCCompute::set_custom_shader's comment says it: "Custom GLSL provides evaluate_density() + helpers". Handing it the graph's text unadapted does not fail -- the shader COMPILES, proc_density is simply an unused function, and the template's own default density runs instead. Measured: a unit-sphere volume baked to 225 verts and 392 triangles with EVERY VERTEX AT y = 0.000000. A flat plane, produced without one error line, from a submit that returned true. The adapter is three lines and DCTerrainGeneratorProcNodes already writes the same one at dc_terrain_generator_procnodes.cpp:860. ⚠ custom_glsl is compared BY POINTER by DCGpuThread, which caches it. A temporary's c_str() would leave it holding a dangling address, and a later allocation landing there would read as "same shader" and reuse the wrong compiled one, silently. baked_volume_glsl_ is a member for that reason. --- TWO THINGS THE RUN FOUND THAT WERE NOT THE FEATURE. ⚠ demo/bin/linux had no `shaders` symlink, so DCGpuMeshPipeline could not open edge_classify.glsl, fell back to the CPU mesher, and is_available() never went true. dc-terrain/bin/linux has that symlink; the demo project's did not. demo/bin is gitignored so it cannot be committed -- the gate's header records the one command instead. ⛔ compile_failure_gate.gd HAD BEEN RED SINCE aac88dc AND NOTHING NOTICED. It used VolumeToMesh as its "a node with no GLSL must refuse" subject. Making that node gpu_transparent() in proc-nodes was correct and retired the subject -- proc-nodes' own golden gate was updated in that commit, this one was not, and I did not re-run dc-terrain's gates after changing proc-nodes. It sat at 7/9. Join Geometry now, same reasoning as there. 9/9. ⚠ And the bake gate's first version waited 601 FRAMES, which an uncapped SceneTree burns in a fraction of a second: the GPU became ready on the LAST LINE of the log, after every attempt had already failed. It waits on the clock now. Bring-up measured 4.7-4.9 s, matching the 4.17 s in #58. Gates: volume bake PASS, compile_failure 9/9, erosion parity "wrappers AGREE", all three targets carry the adapter (checked by string). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f1cb2d7a4c05e8c01d5bcbe7b279a0d075743c24
by plankatron <fleeky@gmail.com> · 2026-09-06T16:11:10.000Z

Changed files (4) +246 -5

demo/compile_failure_gate.gd +13 -5
……@@ -33,16 +33,24 @@ func _init() -> void:
3333 _ck("good graph: no error", g1.get_last_compile_error() == "",
3434 '"%s"' % g1.get_last_compile_error())
3535
36 # C. a graph proc-nodes REFUSES to emit. VolumeToMesh outputs Geometry,
37 # which has no GLSL spelling, so it can never acquire one by someone
38 # doing the next item on a list -- the same reason glsl_golden_gate.gd
39 # uses it for its refusal case.
36 # C. a graph proc-nodes REFUSES to emit.
37 #
38 # ⛔ THIS USED VolumeToMesh AND WENT RED WHEN THAT NODE BECAME LEGITIMATE.
39 # proc-nodes made it gpu_transparent() -- it NAMES a density rather than
40 # failing to compute one -- so a graph containing it now compiles, and this
41 # case silently stopped testing refusal. proc-nodes' own glsl_golden_gate.gd
42 # had the identical dependency and was updated in the same commit; THIS gate
43 # was not, and nothing re-ran it, so it sat at 7/9.
44 #
45 # Join Geometry instead: a density function is a float of a position, there
46 # is no geometry in it to join, so this subject cannot be finished into
47 # legitimacy by anybody.
4048 var bad := ProcNodeGraph.new()
4149 var n2 := bad.add_node(GradientNoiseNode.new())
4250 var e2 := bad.add_node(ErosionNoiseNode.new())
4351 bad.connect_sockets(n2, 0, e2, 7)
4452 bad.connect_sockets(n2, 1, e2, 8)
45 bad.add_node(VolumeToMeshNode.new())
53 bad.add_node(JoinGeometryNode.new())
4654 var g2 := DCTerrainGeneratorProcNodes.new()
4755 g2.set_graph(bad)
4856 _ck("refused graph: not compiled", not g2.has_compiled_shader(),
demo/volume_bake_gate.gd +90 -0
……@@ -0,0 +1,90 @@
1## A proc-nodes ProcVolume, meshed ON THE GPU by dc-terrain's own pipeline.
2##
3## ⚠ NOT HEADLESS. The dummy rendering driver has no RenderingDevice, so there
4## is no GPU thread and this would report failure for the wrong reason. Run with
5## a real driver.
6##
7## ⚠ IT NEEDS demo/bin/linux/shaders. DCGpuMeshPipeline loads its .glsl files
8## relative to the library, and dc-terrain/bin/linux has a `shaders` symlink
9## while the demo project's did not -- so the mesh pipeline failed to open
10## edge_classify.glsl, fell back to the CPU, and is_available() stayed false.
11## demo/bin is gitignored, so that symlink is NOT in the repo:
12##
13## ln -s ../../../shaders demo/bin/linux/shaders
14##
15## ⚠ AND IT WAITS. GPU bring-up is not instant -- hyper-games #58 measured 30.2s
16## for a starved second device, which is exactly the mistake this entry point
17## avoids by borrowing the terrain's one device. So poll rather than assume.
18extends SceneTree
19
20var _t: DCTerrain = null
21var _glsl := ""
22var _frames := 0
23var _done := false
24# ⚠ A TIME BUDGET, NOT A FRAME COUNT. The first version waited 601 FRAMES, and
25# an uncapped SceneTree burns those in a fraction of a second -- the GPU became
26# ready on the last line of the log, after every one of them had already failed.
27# Bring-up is seconds of wall clock; frames are not a unit of time here.
28var _t0 := 0.0
29const TIMEOUT_S := 60.0
30
31func _build_volume_glsl() -> String:
32 var g := ProcNodeGraph.new()
33 var s1 := g.add_node(SphereSdfNode.new())
34 var vtm := g.add_node(VolumeToMeshNode.new())
35 g.connect_sockets(s1, 0, vtm, 0)
36 var geo = g.evaluate_socket(vtm, 0)
37 if geo == null:
38 return ""
39 var vol = geo.get_volume()
40 return "" if vol == null else vol.get_glsl_source()
41
42func _process(_d: float) -> bool:
43 if _t == null:
44 _glsl = _build_volume_glsl()
45 print(" volume GLSL: %d chars" % _glsl.length())
46 _t = DCTerrain.new()
47 _t.use_gpu = true
48 root.add_child(_t)
49 _t0 = Time.get_ticks_msec()
50 return false
51 _frames += 1
52 if _done:
53 return true
54 # 3 units across, centred on the origin: the unit sphere sits inside.
55 var m: ArrayMesh = _t.bake_volume_mesh(_glsl, Vector3(-1.5, -1.5, -1.5), 3.0 / 16.0, 16)
56 if m != null and m.get_surface_count() > 0:
57 var arrays := m.surface_get_arrays(0)
58 var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
59 var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
60 var worst := 0.0
61 var lo := Vector3(1e9, 1e9, 1e9)
62 var hi := Vector3(-1e9, -1e9, -1e9)
63 for v in verts:
64 worst = max(worst, abs(v.length() - 1.0))
65 lo = Vector3(min(lo.x, v.x), min(lo.y, v.y), min(lo.z, v.z))
66 hi = Vector3(max(hi.x, v.x), max(hi.y, v.y), max(hi.z, v.z))
67 print(" AABB: %v .. %v" % [lo, hi])
68 print(" first 3: %v %v %v" % [verts[0], verts[1], verts[2]])
69 # If the mesh comes back GRID-LOCAL, re-centring on the grid middle is
70 # what makes it a unit sphere again. Measure both readings rather than
71 # assume which one the pipeline returns.
72 var worst_local := 0.0
73 for v in verts:
74 worst_local = max(worst_local, abs((v - Vector3(1.5, 1.5, 1.5)).length() - 1.0))
75 print(" worst |r-1| world-reading=%.4f grid-local-reading=%.4f" % [worst, worst_local])
76 worst = min(worst, worst_local)
77 print(" BAKED: %d verts, %d tris, worst |r-1| = %.4f (frame %d)"
78 % [verts.size(), idx.size() / 3, worst, _frames])
79 print(" after %.1fs of GPU bring-up" % ((Time.get_ticks_msec() - _t0) / 1000.0))
80 print(" RESULT: %s" % ("PASS" if verts.size() > 0 and idx.size() > 0 and worst < 0.3 else "FAIL"))
81 _done = true
82 quit(0)
83 return true
84 var elapsed := (Time.get_ticks_msec() - _t0) / 1000.0
85 if elapsed > TIMEOUT_S:
86 print(" RESULT: FAIL -- no mesh after %.1fs / %d frames (GPU never became available?)"
87 % [elapsed, _frames])
88 quit(1)
89 return true
90 return false
src/dc_terrain.cpp +117 -0
……@@ -3191,6 +3191,122 @@ PackedFloat32Array DCTerrain::debug_gpu_density(const Vector3 &origin, float vox
31913191 return out;
31923192}
31933193
3194Ref<ArrayMesh> DCTerrain::bake_volume_mesh(const String &p_glsl, const Vector3 &p_origin,
3195 float p_voxel_size, int p_cells) {
3196 Ref<ArrayMesh> out;
3197 if (p_glsl.is_empty()) {
3198 UtilityFunctions::push_error(
3199 "DCTerrain.bake_volume_mesh: no GLSL. A ProcVolume carries one only "
3200 "after its graph compiles -- check ProcVolume.get_glsl_source().");
3201 return out;
3202 }
3203 // BRING THE THREAD UP IF IT IS NOT, into the SAME SLOT the terrain uses.
3204 //
3205 // _notification(ENTER_TREE) only creates it on the game-mode path:
3206 // force_shared = is_editor_hint() && use_gpu, and an editor or
3207 // script-launched instance takes the shared-worker branch and creates no
3208 // GPU thread at all. A bake should not require the terrain to be streaming.
3209 //
3210 // Creating it HERE is safe precisely because it is the same member: if the
3211 // terrain later enters game mode it finds gpu_thread_ non-null and reuses
3212 // it. One device, ever. A second VkDevice is the 30.2 s starvation that
3213 // left two meshers in one world (hyper-games #58).
3214 if (!gpu_thread_) {
3215 gpu_thread_ = std::make_unique<DCGpuThread>();
3216 gpu_thread_->start();
3217 }
3218 if (!gpu_thread_->is_available()) {
3219 // NON-BLOCKING ON PURPOSE. Bring-up takes seconds, and blocking a bound
3220 // call for that freezes the frame -- the same "right total, wrong shape"
3221 // mistake the worker deferral records. Return null and let the caller
3222 // ask again.
3223 UtilityFunctions::push_warning(
3224 "DCTerrain.bake_volume_mesh: the GPU is still coming up. This "
3225 "returns null rather than blocking the frame -- call again next "
3226 "frame.");
3227 return out;
3228 }
3229 if (p_cells < 1) p_cells = 1;
3230 if (p_voxel_size <= 0.0f) p_voxel_size = 1.0f;
3231
3232 // ⛔ THE ENTRY POINT IS evaluate_density(float,float,float), NOT
3233 // proc_density(vec3). DCCompute::set_custom_shader's own comment says
3234 // "Custom GLSL provides evaluate_density() + helpers", and the template
3235 // calls that name with three floats.
3236 //
3237 // Handing it the graph's text unadapted does NOT fail loudly: the shader
3238 // compiles (proc_density is simply an unused function) and the template's
3239 // own default density runs instead. Measured 2026-09-06 -- a unit-sphere
3240 // volume baked to 225 verts and 392 triangles with EVERY VERTEX AT y = 0.
3241 // A flat plane, produced without one error line.
3242 //
3243 // DCTerrainGeneratorProcNodes writes this same adapter at
3244 // dc_terrain_generator_procnodes.cpp:860; a volume needs only the trivial
3245 // form, with no terrain height mapping, because a ProcVolume is already in
3246 // world space.
3247 baked_volume_glsl_ = std::string(p_glsl.utf8().get_data());
3248 baked_volume_glsl_ +=
3249 "\n\nfloat evaluate_density(float x, float y, float z) {\n"
3250 "\treturn proc_density(vec3(x, y, z));\n"
3251 "}\n";
3252
3253 DensityGrid grid;
3254 float org[3] = { (float)p_origin.x, (float)p_origin.y, (float)p_origin.z };
3255 grid.allocate(p_cells, p_voxel_size, org);
3256
3257 DCMeshData md;
3258 const uint32_t quality = (get_use_adaptive_qef() ? 2u : 0u)
3259 | (get_use_shortest_diagonal() ? 1u : 0u);
3260 if (!gpu_thread_->submit(grid, density_, baked_volume_glsl_.c_str(), nullptr, &md,
3261 org, p_voxel_size, quality, nullptr, 0, 0u)) {
3262 UtilityFunctions::push_error(
3263 "DCTerrain.bake_volume_mesh: the GPU declined the submit. If the "
3264 "custom shader failed to compile, DCGpuThread prints that above.");
3265 return out;
3266 }
3267
3268 const int vcount = (int)md.positions.size() / 3;
3269 if (vcount == 0 || md.indices.empty()) {
3270 // An empty result is a real answer -- a density with no zero crossing
3271 // in these bounds -- and it is worth distinguishing from a failure.
3272 UtilityFunctions::push_warning(
3273 "DCTerrain.bake_volume_mesh: the GPU meshed no surface. The density "
3274 "has no zero crossing inside the bounds given, so either the volume "
3275 "is empty there or the bounds miss it.");
3276 return out;
3277 }
3278
3279 PackedVector3Array pos;
3280 pos.resize(vcount);
3281 Vector3 *pw = pos.ptrw();
3282 for (int i = 0; i < vcount; i++) {
3283 pw[i] = Vector3(md.positions[i * 3], md.positions[i * 3 + 1], md.positions[i * 3 + 2]);
3284 }
3285 PackedInt32Array idx;
3286 idx.resize((int)md.indices.size());
3287 int32_t *iw = idx.ptrw();
3288 for (size_t i = 0; i < md.indices.size(); i++) {
3289 iw[i] = md.indices[i];
3290 }
3291
3292 Array arrays;
3293 arrays.resize(Mesh::ARRAY_MAX);
3294 arrays[Mesh::ARRAY_VERTEX] = pos;
3295 arrays[Mesh::ARRAY_INDEX] = idx;
3296 if ((int)md.normals.size() == vcount * 3) {
3297 PackedVector3Array nrm;
3298 nrm.resize(vcount);
3299 Vector3 *nw = nrm.ptrw();
3300 for (int i = 0; i < vcount; i++) {
3301 nw[i] = Vector3(md.normals[i * 3], md.normals[i * 3 + 1], md.normals[i * 3 + 2]);
3302 }
3303 arrays[Mesh::ARRAY_NORMAL] = nrm;
3304 }
3305 out.instantiate();
3306 out->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
3307 return out;
3308}
3309
31943310float DCTerrain::sample_density(const Vector3 &position) const {
31953311 n_sample_density_.fetch_add(1, std::memory_order_relaxed);
31963312 float d = generator_ptr_->evaluate(position.x, position.y, position.z, 0.0f);
……@@ -4409,6 +4525,7 @@ void DCTerrain::_bind_methods() {
44094525 // there is no way for a test, or a map script, to ask whether the terrain
44104526 // on screen is the one the assigned generator describes.
44114527 ClassDB::bind_method(D_METHOD("is_using_builtin_generator"), &DCTerrain::is_using_builtin_generator);
4528 ClassDB::bind_method(D_METHOD("bake_volume_mesh", "glsl", "origin", "voxel_size", "cells"), &DCTerrain::bake_volume_mesh);
44124529 ClassDB::bind_method(D_METHOD("get_scalar_query_counts"), &DCTerrain::get_scalar_query_counts);
44134530 ClassDB::bind_method(D_METHOD("reset_scalar_query_counts"), &DCTerrain::reset_scalar_query_counts);
44144531 ClassDB::bind_method(D_METHOD("sample_density", "position"), &DCTerrain::sample_density);
src/dc_terrain.h +26 -0
……@@ -208,6 +208,26 @@ public:
208208 // in a real render session.
209209 PackedFloat32Array debug_gpu_density(const Vector3 &origin, float voxel_size, int cells);
210210
211 // ── BAKE A VOLUME TO A MESH, ON THE GPU ─────────────────────────────────
212 //
213 // Takes the GLSL a proc-nodes ProcVolume carries -- `float proc_density(vec3
214 // pos)` -- fills a density grid with it and meshes it, both through the
215 // SAME DCGpuThread the terrain already runs. Returns an ArrayMesh, or null
216 // if the GPU declined.
217 //
218 // WHY HERE AND NOT IN proc-nodes. This needs DCGpuThread and a
219 // RenderingDevice, which live in dc-terrain/src and cannot move to libdc
220 // without making libdc godot-dependent. And a SECOND RenderingDevice is not
221 // an option: a second VkDevice bring-up starves and left two meshers
222 // running in one world (hyper-games #58). One device, one GPU thread, and
223 // this borrows it.
224 //
225 // The GLSL takes a String rather than a ProcVolume because dc-terrain has
226 // no compile-time dependency on proc-nodes' classes -- the same reason
227 // DCTerrainGeneratorProcNodes reaches the graph through reflection.
228 Ref<ArrayMesh> bake_volume_mesh(const String &p_glsl, const Vector3 &p_origin,
229 float p_voxel_size, int p_cells);
230
211231 // THE COLLISION MESH, as triangles, so it can be drawn.
212232 //
213233 // Collision is built through PhysicsServer3D as bare RIDs hung off the
……@@ -393,6 +413,12 @@ private:
393413 // Warn-once latch for the built-in-generator substitution below.
394414 bool warned_generator_fallback_ = false;
395415 bool using_builtin_generator_ = true;
416 // ⚠ HOLDS THE BAKED GLSL ALIVE. DCGpuThread caches the custom_glsl POINTER
417 // and compares by pointer, not by string -- so handing it a temporary's
418 // c_str() leaves it holding a dangling address, and a later allocation
419 // landing there would read as "same shader" and reuse the wrong compiled
420 // one. Silently. This member outlives every submit.
421 std::string baked_volume_glsl_;
396422 // mutable + atomic: the query entry points are const and DCCollisionSDF is
397423 // reachable from more than one thread.
398424 mutable std::atomic<uint64_t> n_sample_density_{0};