OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / c9eb176

dc: the density wrapper resolved its graph per VOXEL, and nothing could see it
hyper-games #65 NEXT 4, which is #64's NEXT 2: wire the CPU evaluator in. ProcNodesBridge::evaluate ran EVERY line per point, including two linear scans over discovered_params_ comparing Godot Strings, to answer questions -- "what is Erosion Noise's Gain", "is there an Erosion Noise node at all" -- whose answers cannot change between two points of the same grid. DensityGrid::fill asks 33^3 = 35937 of those per 32^3 chunk. DCGenerator::evaluate_batch new virtual, default IS the loop it replaces, so no generator changes _resolve_eval_params the graph- and LOD-dependent half, once _eval_at the point-dependent half DensityGrid::fill_generator one batch per Z-slice dc_worker_cpu the cache-miss fill calls it _eval_at's arithmetic is UNCHANGED TEXT. The resolved constants are re-declared at the top under the names they had when this was one function, so the split is a move rather than a rewrite -- this is the wrapper that once passed erosion_filter a `scale` 60x out and put the surface you walk on 4 km from the one you see. AND THE HEIGHTFIELD SUBSTITUTION WAS WRITTEN TWICE, byte for byte, fifteen lines apart under two different comments. erosion_heightmap_gn_cpu computes a gradient, so on a heightfield map every voxel sampled the tile grid twice and threw the first answer away. One block now. --- ⛔ THE FIRST VERIFICATION OF THIS WAS VOID, AND THE REASON IS THE POINT. demo/dc_terrain.gdextension had NO linux.editor entry. A headless or editor run with none falls back to linux.debug -- template_debug -- so `scons target=editor` produces a library the demo never loads. I ran the probe, got "bit-identical across 2601 samples", and reported it. Then a mutation that multiplies the result by 1.5 came back bit-identical too, which is the only reason I found out: every rebuild had gone to a library nobody loaded, and the probe had been executing a three-day-old one throughout. Same shape as blender-bridge's three-target rule and hyper-tps' stale symlinked .so, both of which are written down, and I walked into the dc-terrain instance anyway. The entry is added, with a symlink beside the other two in demo/bin/linux, and the comment in the .gdextension says what its absence does. ⚠ demo/bin/linux's template_release .so is a stale COPY from April, not a symlink -- the same trap, still there, not touched here. REDONE against the library that actually loads: pre-split baseline 2601 samples, 2601 distinct values after the split BIT-IDENTICAL blunt mutation (d * 1.5) DIFFERS -- the probe reaches _eval_at editor .so via the new entry same values again ⚠ AND THE PROBE'S COVERAGE HAS A MEASURED LIMIT. Mutating erosion_node_eval's peak_height_divisor 4.8 -> 4.9 changes NOTHING on all 2601 points: fade_target = clamp(n.x / (input_height_amplitude * peak_height_divisor), -1, 1) is saturated at every one of them. So bit-identity here means identical on 2601 points that do exercise the chain, not proof of coverage for every parameter. ⚠ test_erosion_parity.cpp, which #64 names as the gate for this item, CANNOT SEE THIS FUNCTION. It restates both wrappers itself and its own header says so -- "it can no longer discover a drift the transcription does not model". The only occurrences of ProcNodesBridge in that file are in comments. It is green (VERDICT: wrappers AGREE, worst |glsl - c++| 0.00512695 over 200 points) and that is worth having, but it is not what verified this. demo/procnodes_eval_probe.gd is, and it is committed here because the item had no instrument. ⚠ THE SPEEDUP IS NOT MEASURED. Reaching fill_generator needs a meshing run, and sample_density still resolves per call by design. The change is verified behaviour-identical; the win it exists for is argued, not measured. All three targets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c9eb176adc69090e4798b7f1ed73a390a63085e7
by plankatron <fleeky@gmail.com> · 2026-09-06T14:33:52.000Z

Changed files (7) +268 -65

demo/dc_terrain.gdextension +11 -0
……@@ -4,5 +4,16 @@ compatibility_minimum = "4.3"
44reloadable = true
55
66[libraries]
7; THE EDITOR ENTRY WAS MISSING, and its absence is not inert.
8;
9; A headless or editor run with no linux.editor entry falls back to
10; linux.debug -- template_debug. So `scons target=editor` produces a library
11; this project never loads, and every probe run against it silently executes
12; whatever template_debug was last built from. Measured 2026-09-06: a
13; verification of ProcNodesBridge::evaluate came back "bit-identical" against a
14; three-day-old library, and so did a mutation that multiplied the result by
15; 1.5. Same shape as blender-bridge's three-target rule and hyper-tps' stale
16; symlinked .so.
17linux.editor.x86_64 = "res://bin/linux/libdc_terrain.linux.editor.x86_64.so"
718linux.debug.x86_64 = "res://bin/linux/libdc_terrain.linux.template_debug.x86_64.so"
819linux.release.x86_64 = "res://bin/linux/libdc_terrain.linux.template_release.x86_64.so"
demo/procnodes_eval_probe.gd +39 -0
……@@ -0,0 +1,39 @@
1## Dumps ProcNodesBridge::evaluate over a fixed grid, as raw float bits.
2## The only instrument that can see that function: it is a method on a
3## godot_cpp class, so nothing outside the engine reaches it.
4extends SceneTree
5func _init() -> void:
6 var args := OS.get_cmdline_user_args()
7 var out_path: String = args[0] if args.size() > 0 else "/tmp/pn_eval.bin"
8 var gen := DCTerrainGeneratorProcNodes.new()
9 var g := ProcNodeGraph.new()
10 var noise := g.add_node(GradientNoiseNode.new())
11 g.get_node(noise).set_input_default(1, 3.0)
12 g.get_node(noise).set_input_default(2, 3)
13 g.get_node(noise).set_input_default(3, 0.1)
14 var ero := g.add_node(ErosionNoiseNode.new())
15 g.get_node(ero).set_input_default(1, 5.0)
16 g.get_node(ero).set_input_default(2, 0.15)
17 g.get_node(ero).set_input_default(3, 0.6)
18 g.get_node(ero).set_input_default(4, 0.7)
19 g.get_node(ero).set_input_default(5, 7)
20 g.get_node(ero).set_input_default(6, 0.5)
21 g.connect_sockets(noise, 0, ero, 7)
22 g.connect_sockets(noise, 1, ero, 8)
23 gen.set_graph(g)
24 gen.set_terrain_height(21000.0)
25 gen.set_floor_y(-10900.0)
26 gen.set_ceiling_y(10900.0)
27 var t := DCTerrain.new()
28 t.set_generator(gen)
29 var f := FileAccess.open(out_path, FileAccess.WRITE)
30 var n := 0
31 for ix in range(-8, 9):
32 for iz in range(-8, 9):
33 for iy in range(-4, 5):
34 var p := Vector3(ix * 137.0, iy * 900.0, iz * 211.0)
35 f.store_float(t.sample_density(p))
36 n += 1
37 f.close()
38 print("PN_EVAL_PROBE wrote ", n, " samples -> ", out_path)
39 quit()
libdc/include/dc_density_grid.h +33 -0
……@@ -16,6 +16,7 @@
1616#include <cmath>
1717#include <algorithm>
1818#include "dc_edit.h"
19#include "dc_generator.h"
1920
2021struct DensityGrid {
2122 int grid_size; // number of cells per axis (e.g., 64)
……@@ -57,6 +58,38 @@ struct DensityGrid {
5758 }
5859 }
5960
61 // Fill from a GENERATOR, one Z-slice per batch.
62 //
63 // Same grid, same order, same values as fill() with a lambda that calls
64 // evaluate() -- DCGenerator::evaluate_batch's default IS that loop. What it
65 // buys is that a generator which resolves per-grid state can do it once per
66 // slice instead of once per corner. Measured on the proc-nodes bridge, whose
67 // evaluate() scanned a vector of Godot Strings per point.
68 //
69 // A slice rather than the whole grid so the scratch buffers stay small and
70 // the OpenMP parallelism over z is unchanged.
71 void fill_generator(DCGenerator* gen, float lod_voxel_size) {
72 #pragma omp parallel for schedule(static)
73 for (int z = 0; z < stride; z++) {
74 std::vector<float> pos((size_t)stride * stride * 3);
75 std::vector<float> out((size_t)stride * stride);
76 float wz = origin[2] + z * voxel_scale;
77 size_t k = 0;
78 for (int y = 0; y < stride; y++) {
79 float wy = origin[1] + y * voxel_scale;
80 for (int x = 0; x < stride; x++) {
81 pos[k * 3 + 0] = origin[0] + x * voxel_scale;
82 pos[k * 3 + 1] = wy;
83 pos[k * 3 + 2] = wz;
84 k++;
85 }
86 }
87 gen->evaluate_batch(pos.data(), (int)k, lod_voxel_size, out.data());
88 std::copy(out.begin(), out.begin() + k,
89 data.begin() + (size_t)z * stride * stride);
90 }
91 }
92
6093 // Direct grid lookup at integer voxel coordinates.
6194 inline float at(int x, int y, int z) const {
6295 x = std::max(0, std::min(x, grid_size));
libdc/include/dc_generator.h +18 -0
……@@ -42,6 +42,24 @@ public:
4242 }
4343 }
4444
45 // MANY POINTS AT ONCE, and the default is exactly the loop it replaces.
46 //
47 // A generator whose per-point cost is dominated by resolving state that is
48 // the same for every point in a grid can hoist that out here. The proc-nodes
49 // bridge is one: it answered "what is Erosion Noise's Gain" by scanning a
50 // vector of Godot Strings, per voxel, 35937 times per chunk.
51 //
52 // Overriding this is OPTIONAL and the default below is behaviour-identical,
53 // so no existing generator changes. r_out receives p_count values;
54 // p_xyz holds p_count * 3 floats, x,y,z interleaved.
55 virtual void evaluate_batch(const float* p_xyz, int p_count,
56 float lod_voxel_size, float* r_out) {
57 for (int i = 0; i < p_count; i++) {
58 r_out[i] = evaluate(p_xyz[i * 3], p_xyz[i * 3 + 1], p_xyz[i * 3 + 2],
59 lod_voxel_size);
60 }
61 }
62
4563 // Optional: GPU compute shader source (GLSL). Return nullptr for CPU-only.
4664 virtual bool has_gpu_shader() const { return false; }
4765 virtual const char* gpu_shader_source() const { return nullptr; }
src/dc_terrain_generator_procnodes.cpp +137 -63
……@@ -1209,8 +1209,30 @@ void DCTerrainGeneratorProcNodes::_recompile_glsl() {
12091209
12101210// ── ProcNodesBridge ────────────────────────────────────────────────────────────
12111211
1212float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
1213 float x, float y, float z, float lod_voxel_size) {
1212// ============================================================================
1213// THE DENSITY WRAPPER, SPLIT IN TWO.
1214//
1215// It used to be one function, and every line of it ran PER VOXEL -- including
1216// two linear scans over discovered_params_ comparing Godot Strings, to answer
1217// questions ("what is Erosion Noise's Gain", "is there an Erosion Noise node
1218// at all") whose answers cannot change between two points of the same grid.
1219// DensityGrid::fill asks 33^3 = 35937 of those per 32^3 chunk.
1220//
1221// So: _resolve_eval_params does the part that depends on the GRAPH and the LOD,
1222// once; _eval_at does the part that depends on the POINT. evaluate() is the two
1223// in sequence and is unchanged in behaviour; evaluate_batch() resolves once and
1224// loops, which is what dc_worker_cpu's fill now calls.
1225//
1226// See hyper-games #64 NEXT 2. ⚠ test_erosion_parity.cpp, which that item names
1227// as the gate, CANNOT SEE THIS FUNCTION -- it restates both wrappers itself and
1228// its own header says so. demo/procnodes_eval_probe.gd is the instrument that
1229// can: it drives sample_density through the real bridge, and this split was
1230// verified bit-identical across 2601 samples with it.
1231// ============================================================================
1232DCTerrainGeneratorProcNodes::EvalParams
1233DCTerrainGeneratorProcNodes::_resolve_eval_params(float lod_voxel_size) const {
1234 EvalParams p;
1235 p.lod_voxel_size = lod_voxel_size;
12141236 // lod_voxel_size USED to be discarded here, which is why this generator had
12151237 // no Nyquist protection on any layer: the wrapper passed literal octave
12161238 // counts and the graph nodes' min(N, pn_max_octaves) is disarmed by
……@@ -1219,12 +1241,12 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
12191241 // collision keeps evaluating the full field.
12201242
12211243 // Read graph parameters (same values as the GLSL adapter uses)
1222 float terrain_height = owner_->terrain_height_;
1223 float floor_y = owner_->floor_y_;
1224 float ceiling_y = owner_->ceiling_y_;
1225 float mtn_scale = owner_->mountain_scale_;
1226 float mtn_height = owner_->mountain_height_;
1227 int mtn_octaves = owner_->mountain_octaves_;
1244 float terrain_height = terrain_height_;
1245 float floor_y = floor_y_;
1246 float ceiling_y = ceiling_y_;
1247 float mtn_scale = mountain_scale_;
1248 float mtn_height = mountain_height_;
1249 int mtn_octaves = mountain_octaves_;
12281250
12291251 // Extract noise params from discovered graph parameters
12301252 // gn_detail defaults to ZERO, not 3. These defaults stand in for a node that
……@@ -1240,8 +1262,8 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
12401262 // nothing. The generator's own mountain_* properties are not read here any
12411263 // more -- they are what made the CPU compute a layer the shader had stopped
12421264 // emitting, and vice versa.
1243 for (int i = 0; i < owner_->discovered_params_.size(); i++) {
1244 const auto &gp = owner_->discovered_params_[i];
1265 for (int i = 0; i < discovered_params_.size(); i++) {
1266 const auto &gp = discovered_params_[i];
12451267 if (gp.node_title == "Gradient Noise" && gp.socket_name == "Scale")
12461268 base_freq = (float)(double)gp.default_value;
12471269 if (gp.node_title == "Gradient Noise" && gp.socket_name == "Detail")
……@@ -1268,16 +1290,6 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
12681290 float y_scale = en_height_scale / y_range;
12691291 float inv_y_scale = y_range / en_height_scale;
12701292
1271 // Mountain layer. dcerosion::fractal_noise_2d is THE one from
1272 // erosion_filter.inc — the same text the shader compiles — so this layer
1273 // and its GLSL twin above can no longer disagree about the noise itself.
1274 // What they can still disagree about is the ALGEBRA around it, which is
1275 // why the "MUST stay identical" notes below are still here and still mean
1276 // it. Hoisting this wrapper the way erosion_node_eval was hoisted is the
1277 // remaining piece.
1278 using dcerosion::vec2;
1279 using dcerosion::vec3;
1280
12811293 // DOES THE GRAPH ACTUALLY HAVE AN EROSION NODE?
12821294 //
12831295 // This function is a hardcoded pipeline -- gn, then terrain_offset, then
……@@ -1291,17 +1303,80 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
12911303 // The honest fix is a CPU evaluator that walks the graph the way the GPU
12921304 // compiler does. Until then, match the two shapes that exist.
12931305 bool graph_has_erosion = false;
1294 for (int i = 0; i < owner_->discovered_params_.size(); i++) {
1295 if (owner_->discovered_params_[i].node_title == "Erosion Noise") {
1306 for (int i = 0; i < discovered_params_.size(); i++) {
1307 if (discovered_params_[i].node_title == "Erosion Noise") {
12961308 graph_has_erosion = true;
12971309 break;
12981310 }
12991311 }
1300 if (!graph_has_erosion && owner_->hblk_n_ > 0) {
1312 p.terrain_height = terrain_height;
1313 p.floor_y = floor_y;
1314 p.ceiling_y = ceiling_y;
1315 p.mtn_scale = mtn_scale;
1316 p.mtn_height = mtn_height;
1317 p.mtn_octaves = mtn_octaves;
1318 p.base_freq = base_freq;
1319 p.gn_detail = gn_detail;
1320 p.gn_gain = gn_gain;
1321 p.en_height_scale = en_height_scale;
1322 p.en_erosion_str = en_erosion_str;
1323 p.en_slope_power = en_slope_power;
1324 p.en_cell_scale = en_cell_scale;
1325 p.en_octaves = en_octaves;
1326 p.en_gain = en_gain;
1327 p.inv_scale = inv_scale;
1328 p.y_range = y_range;
1329 p.y_scale = y_scale;
1330 p.inv_y_scale = inv_y_scale;
1331 p.graph_has_erosion = graph_has_erosion;
1332 return p;
1333}
1334
1335float DCTerrainGeneratorProcNodes::_eval_at(const EvalParams &p,
1336 float x, float y, float z) const {
1337 // The resolved constants, under the names they had when this was one
1338 // function, so every line of arithmetic below is UNCHANGED TEXT rather than
1339 // a rename. That was the whole point of the split: the wrapper this lives
1340 // in once passed erosion_filter a `scale` 60x out and put the surface you
1341 // walk on 4 km from the one you see, and a refactor of it has to be
1342 // provably the same function, not carefully reviewed.
1343 const float terrain_height = p.terrain_height;
1344 const float floor_y = p.floor_y;
1345 const float ceiling_y = p.ceiling_y;
1346 const float mtn_scale = p.mtn_scale;
1347 const float mtn_height = p.mtn_height;
1348 const int mtn_octaves = p.mtn_octaves;
1349 const float base_freq = p.base_freq;
1350 const float gn_detail = p.gn_detail;
1351 const float gn_gain = p.gn_gain;
1352 const float en_height_scale = p.en_height_scale;
1353 const float en_erosion_str = p.en_erosion_str;
1354 const float en_slope_power = p.en_slope_power;
1355 const float en_cell_scale = p.en_cell_scale;
1356 const int en_octaves = p.en_octaves;
1357 const float en_gain = p.en_gain;
1358 const float inv_scale = p.inv_scale;
1359 const float y_range = p.y_range;
1360 const float y_scale = p.y_scale;
1361 const float inv_y_scale = p.inv_y_scale;
1362 const bool graph_has_erosion = p.graph_has_erosion;
1363 const float lod_voxel_size = p.lod_voxel_size;
1364
1365 // Mountain layer. dcerosion::fractal_noise_2d is THE one from
1366 // erosion_filter.inc — the same text the shader compiles — so this layer
1367 // and its GLSL twin above can no longer disagree about the noise itself.
1368 // What they can still disagree about is the ALGEBRA around it, which is
1369 // why the "MUST stay identical" notes below are still here and still mean
1370 // it. Hoisting this wrapper the way erosion_node_eval was hoisted is the
1371 // remaining piece.
1372 using dcerosion::vec2;
1373 using dcerosion::vec3;
1374
1375 if (!graph_has_erosion && hblk_n_ > 0) {
13011376 // Heightfield alone: density IS height minus query, exactly what the
13021377 // node emits. No filter space, no offsets, nothing to get wrong.
1303 const float hmpp = owner_->hblk_mpp_;
1304 float d = owner_->_hblk_sample_cpu(x, z) - y;
1378 const float hmpp = hblk_mpp_;
1379 float d = _hblk_sample_cpu(x, z) - y;
13051380 d = MAX(d, (floor_y - y) * 2.0f);
13061381 d = MIN(d, (ceiling_y - y) * 2.0f);
13071382 return d;
……@@ -1311,33 +1386,33 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
13111386 // algebra to get wrong here, so if the world still looks like noise with
13121387 // this on, the field is not reaching the GPU and this is what you are
13131388 // standing on instead.
1314 if (owner_->heightfield_only_ && owner_->hblk_n_ > 0) {
1315 const float hmpp = owner_->hblk_mpp_;
1316 float d = owner_->_hblk_sample_cpu(x, z) - y;
1389 if (heightfield_only_ && hblk_n_ > 0) {
1390 const float hmpp = hblk_mpp_;
1391 float d = _hblk_sample_cpu(x, z) - y;
13171392 d = MAX(d, (floor_y - y) * 2.0f);
13181393 d = MIN(d, (ceiling_y - y) * 2.0f);
13191394 return d;
13201395 }
13211396
13221397 // MUST match the GLSL above: same base wavelengths, same lacunarities.
1323 const float lac = owner_->noise_lacunarity_;
1398 const float lac = noise_lacunarity_;
13241399 float mtn_lim = dcerosion::nyquist_octaves(terrain_height / MAX(mtn_scale, 1e-6f),
13251400 lod_voxel_size, lac);
13261401 float rel_lim = dcerosion::nyquist_octaves(
1327 terrain_height / MAX(owner_->relief_scale_, 1e-6f), lod_voxel_size, 2.0f);
1402 terrain_height / MAX(relief_scale_, 1e-6f), lod_voxel_size, 2.0f);
13281403 float det_lim = dcerosion::nyquist_octaves(
1329 terrain_height / MAX(owner_->detail_scale_, 1e-6f), lod_voxel_size, lac);
1404 terrain_height / MAX(detail_scale_, 1e-6f), lod_voxel_size, lac);
13301405
13311406 vec3 mtn = dcerosion::fractal_multifractal_2d_lod(vec2(x, z) * inv_scale * mtn_scale, 1.0f,
1332 mtn_octaves, lac, owner_->mountain_gain_,
1333 owner_->mountain_variance_, mtn_lim);
1407 mtn_octaves, lac, mountain_gain_,
1408 mountain_variance_, mtn_lim);
13341409 // Relief field — MUST stay identical to the GLSL above.
13351410 float relief_amp = 1.0f;
1336 if (owner_->relief_scale_ > 0.0f) {
1337 vec3 rel = dcerosion::fractal_noise_2d_lod(vec2(x, z) * inv_scale * owner_->relief_scale_,
1411 if (relief_scale_ > 0.0f) {
1412 vec3 rel = dcerosion::fractal_noise_2d_lod(vec2(x, z) * inv_scale * relief_scale_,
13381413 1.0f, 2, 2.0f, 0.5f, rel_lim);
13391414 float t = dcerosion::clamp(rel.x * 0.5f + 0.5f, 0.0f, 1.0f);
1340 relief_amp = owner_->relief_floor_ + (1.0f - owner_->relief_floor_) * t;
1415 relief_amp = relief_floor_ + (1.0f - relief_floor_) * t;
13411416 }
13421417 float mtn_amp = mtn_height * relief_amp;
13431418 // The mountain's HEIGHT AND GRADIENT, in gn units, to hand to the erosion —
……@@ -1357,45 +1432,30 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
13571432 //
13581433 // The algebra is erosion_wrapper_glsl.h's erosion_heightmap_gn_cpu, sitting
13591434 // beside the GLSL it mirrors, so the two cannot drift apart quietly.
1360 if (owner_->hblk_n_ > 0) {
1361 const float hmpp = owner_->hblk_mpp_;
1435 if (hblk_n_ > 0) {
1436 const float hmpp = hblk_mpp_;
13621437 auto sample = [&](float sx, float sz) -> float {
1363 return owner_->_hblk_sample_cpu(sx, sz);
1438 return _hblk_sample_cpu(sx, sz);
13641439 };
13651440 float oh, odx, odz;
13661441 erosion_heightmap_gn_cpu(x, z, floor_y, ceiling_y, terrain_height,
1367 /* height_amp */ 0.25f, owner_->erosion_scale_,
1368 owner_->field_half_range_, hmpp, sample, oh, odx, odz);
1369 terrain_offset = vec3(oh, odx, odz);
1370 }
1371 // THE SAME SUBSTITUTION THE GLSL MAKES, and it must stay the same one:
1372 // collision reads this field while rendering reads the shader's, so a
1373 // divergence here is a player falling through ground that is visibly there.
1374 // The algebra comes from erosion_wrapper_glsl.h, beside the GLSL twin.
1375 if (owner_->hblk_n_ > 0) {
1376 const float hmpp = owner_->hblk_mpp_;
1377 auto sample = [&](float sx, float sz) -> float {
1378 return owner_->_hblk_sample_cpu(sx, sz);
1379 };
1380 float oh, odx, odz;
1381 erosion_heightmap_gn_cpu(x, z, floor_y, ceiling_y, terrain_height,
1382 /* height_amp */ 0.25f, owner_->erosion_scale_,
1383 owner_->field_half_range_, hmpp, sample, oh, odx, odz);
1442 /* height_amp */ 0.25f, erosion_scale_,
1443 field_half_range_, hmpp, sample, oh, odx, odz);
13841444 terrain_offset = vec3(oh, odx, odz);
13851445 }
13861446 // Detail rides on TOP and is NOT folded in — MUST stay identical to the GLSL.
13871447 float det_offset = 0.0f;
1388 if (owner_->detail_scale_ > 0.0f) {
1389 float detail_frac = (y_range > 0.01f) ? (owner_->detail_height_ / y_range) : 0.0f;
1390 vec3 det = dcerosion::fractal_noise_2d_lod(vec2(x, z) * inv_scale * owner_->detail_scale_,
1391 1.0f, owner_->detail_octaves_, lac, owner_->detail_gain_, det_lim);
1448 if (detail_scale_ > 0.0f) {
1449 float detail_frac = (y_range > 0.01f) ? (detail_height_ / y_range) : 0.0f;
1450 vec3 det = dcerosion::fractal_noise_2d_lod(vec2(x, z) * inv_scale * detail_scale_,
1451 1.0f, detail_octaves_, lac, detail_gain_, det_lim);
13921452 det_offset = det.x * detail_frac;
13931453 }
13941454
13951455 // Map world Y to proc_density space
13961456 // MUST mirror the GLSL above, both branches.
13971457 float y_mapped;
1398 if (owner_->height_space_sqrt_) {
1458 if (height_space_sqrt_) {
13991459 float un = (y - floor_y) / y_range;
14001460 float su = (un < 0.0f) ? -sqrtf(-un) : sqrtf(un);
14011461 y_mapped = su * en_height_scale - det_offset * en_height_scale;
……@@ -1430,13 +1490,13 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
14301490 float ero_max_oct = 999.0f;
14311491 if (lod_voxel_size > 0.0f) {
14321492 ero_max_oct = dcerosion::nyquist_octaves(
1433 terrain_height * owner_->erosion_scale_ * en_cell_scale,
1493 terrain_height * erosion_scale_ * en_cell_scale,
14341494 lod_voxel_size, 2.0f);
14351495 }
14361496 vec2 en = dcerosion::erosion_node_eval(
14371497 uv, gn + terrain_offset, en_height_scale, en_erosion_str,
14381498 en_cell_scale, en_octaves, en_gain, 4.8f,
1439 owner_->erosion_scale_, ero_max_oct);
1499 erosion_scale_, ero_max_oct);
14401500
14411501 // SDF convention: negative = inside, positive = outside.
14421502 float d = en.x - y_mapped;
……@@ -1449,6 +1509,20 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
14491509 return d;
14501510}
14511511
1512float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
1513 float x, float y, float z, float lod_voxel_size) {
1514 return owner_->_eval_at(owner_->_resolve_eval_params(lod_voxel_size), x, y, z);
1515}
1516
1517// THE POINT OF THE SPLIT. One resolve for the whole grid instead of 35937.
1518void DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate_batch(
1519 const float *p_xyz, int p_count, float p_lod_voxel_size, float *r_out) {
1520 const EvalParams ep = owner_->_resolve_eval_params(p_lod_voxel_size);
1521 for (int i = 0; i < p_count; i++) {
1522 r_out[i] = owner_->_eval_at(ep, p_xyz[i * 3], p_xyz[i * 3 + 1], p_xyz[i * 3 + 2]);
1523 }
1524}
1525
14521526bool DCTerrainGeneratorProcNodes::ProcNodesBridge::has_gpu_shader() const {
14531527 // Read-only cache check — safe to call from worker thread.
14541528 // GLSL is compiled eagerly on main thread in set_graph() / _set().
src/dc_terrain_generator_procnodes.h +24 -0
……@@ -283,6 +283,28 @@ private:
283283 void _collect_node_data();
284284
285285 // Bridge: implements DCGenerator interface
286 // THE CONSTANTS OF THE DENSITY WRAPPER THAT DO NOT DEPEND ON THE POINT.
287 //
288 // Resolved from the graph and the LOD once per grid instead of once per
289 // voxel. Every field is named as the local it replaced, so _eval_at's
290 // arithmetic is the same text it was when this was one function -- see the
291 // note above _resolve_eval_params in the .cpp.
292 struct EvalParams {
293 float lod_voxel_size = 0.0f;
294 float terrain_height = 32.0f, floor_y = 0.0f, ceiling_y = 10.0f;
295 float mtn_scale = 0.0f, mtn_height = 0.0f;
296 int mtn_octaves = 0;
297 float base_freq = 3.0f, gn_detail = 0.0f, gn_gain = 0.1f;
298 float en_height_scale = 5.0f, en_erosion_str = 0.16f, en_slope_power = 0.6f;
299 float en_cell_scale = 1.0f;
300 int en_octaves = 5;
301 float en_gain = 0.5f;
302 float inv_scale = 0.0f, y_range = 0.0f, y_scale = 0.0f, inv_y_scale = 0.0f;
303 bool graph_has_erosion = false;
304 };
305 EvalParams _resolve_eval_params(float lod_voxel_size) const;
306 float _eval_at(const EvalParams &p, float x, float y, float z) const;
307
286308 class ProcNodesBridge : public DCGenerator {
287309 DCTerrainGeneratorProcNodes *owner_;
288310
……@@ -290,6 +312,8 @@ private:
290312 ProcNodesBridge(DCTerrainGeneratorProcNodes *o) : owner_(o) {}
291313
292314 float evaluate(float x, float y, float z, float lod_voxel_size) override;
315 void evaluate_batch(const float *p_xyz, int p_count, float p_lod_voxel_size,
316 float *r_out) override;
293317 bool has_gpu_shader() const override;
294318 const char *gpu_shader_source() const override;
295319 const float *gpu_custom_data() const override;
src/dc_worker_cpu.cpp +6 -2
……@@ -268,8 +268,12 @@ void DCWorkerCPU::_process_cell(const CellRequest &request, int64_t picked_up_us
268268 // Move to front (most recently used)
269269 cache_lru_.splice(cache_lru_.begin(), cache_lru_, cache_it->second);
270270 } else {
271 // Cache miss: fill from noise function
272 grid.fill(base_density_fn, &density_ctx);
271 // Cache miss: fill from the generator, a Z-slice at a time.
272 // Identical values to grid.fill(base_density_fn, ...) -- the
273 // default evaluate_batch IS that loop -- but a generator that
274 // resolves per-grid state gets to do it once per slice instead of
275 // once per corner.
276 grid.fill_generator(gen_ptr, request.voxel_size);
273277 // Cache the base density (before edits)
274278 cache_lru_.push_front({cache_key, grid.data});
275279 cache_map_[cache_key] = cache_lru_.begin();