OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / 60b8bc3

procnodes: read the tile grid, and collapse FOUR CPU samplers into one
_collect_node_data now picks up the node's grid layout. Without that the CPU twin would keep reading the buffer as a single hblk_n_ square -- and that buffer is now the INDEX TABLE followed by tile rects, so it would return table entries as elevations and put the collision surface somewhere else entirely from the rendered one. That is the failure test_erosion_parity.cpp was written for after it cost 4 km of air between the two. FOUR COPIES OF THE SAME BILINEAR became one _hblk_sample_cpu(): two written out plain, two inside lambdas handed to erosion_heightmap_gn_cpu. The tile grid would have needed a fifth. Collapsing them is what makes "does the CPU read the same field as the GPU?" a question with a single answer. The paged half is erosion_wrapper_glsl.h's erosion_heightmap_grid_sample, the same file that emits the GLSL, and the gate now compares them: 4. THE C++ TWIN OF THE FETCH AGREES WITH THE GLSL, EXACTLY ok GLSL fetch == C++ fetch, bit for bit 0 mismatches over 24 fetches ok and the probe covered BOTH outcomes 8 real, 16 stub That second line is not decoration: a probe that only ever saw stubs, or only ever real texels, would agree for the wrong reason. ⚠ BILINEAR ON THE CPU, CATMULL-ROM ON THE GPU -- the EXISTING divergence, not a new one. Every CPU site sampled bilinearly before this change too. Reproducing it exactly is deliberate: wiring the grid must change ADDRESSING and nothing else, or any resulting difference is unattributable. Closing that gap is its own change with its own measurement. The header now carries HeightGridDesc, so the emitter, the C++ twin and the engine share ONE description of the layout instead of three agreeing by hand. Built template_release + template_debug; standalone gate PASS, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
60b8bc37941853bcc67337e07cc347e095cdf556
by plankatron <fleeky@gmail.com> · 2026-09-03T18:48:26.000Z

Changed files (3) +110 -61

src/dc_terrain_generator_procnodes.cpp +52 -52
……@@ -541,6 +541,30 @@ void DCTerrainGeneratorProcNodes::_collect_node_data() {
541541 if (d.is_empty()) continue;
542542 hblk_.assign(d.ptr(), d.ptr() + d.size());
543543 hblk_version_++;
544 // THE PACKED TILE GRID, if the node supplied one. Without this the CPU
545 // twin would keep reading the buffer as a single hblk_n_ square -- which
546 // is now the INDEX TABLE followed by tile rects, so it would return
547 // table entries as elevations and the collision surface would be
548 // somewhere else entirely from the rendered one.
549 hgrid_ = HeightGridDesc();
550 if (node_obj->has_method("get_grid_info")) {
551 Dictionary gi = node_obj->call("get_grid_info");
552 if ((bool)gi.get("active", false)) {
553 hgrid_.nx = (int)gi.get("nx", 0);
554 hgrid_.ny = (int)gi.get("ny", 0);
555 hgrid_.tx0 = (int)gi.get("tx0", 0);
556 hgrid_.ty0 = (int)gi.get("ty0", 0);
557 hgrid_.payload = (int)gi.get("payload", 0);
558 hgrid_.apron = (int)gi.get("apron", 0);
559 hgrid_.n = (int)gi.get("n", 0);
560 hgrid_.table = (int)gi.get("table", 0);
561 hgrid_.mpp = (float)(double)gi.get("mpp", 30.0);
562 hgrid_.stub = (float)(double)gi.get("stub", 0.0);
563 UtilityFunctions::print("[DCTerrainGeneratorProcNodes] tile grid ",
564 hgrid_.nx, "x", hgrid_.ny, " cells, ", (int)gi.get("tiles", 0),
565 " tiles resident");
566 }
567 }
544568 // ASK THE NODE FOR THE SIDE LENGTH; DO NOT DERIVE IT FROM THE ARRAY.
545569 //
546570 // get_gpu_data() returns the MIP PYRAMID, not one square: 1,403,226
……@@ -572,6 +596,26 @@ void DCTerrainGeneratorProcNodes::_collect_node_data() {
572596 }
573597}
574598
599// ONE CPU heightfield sampler, for the four sites that used to each carry their
600// own bilinear. Paged when the node supplied a tile grid, a plain square
601// otherwise -- and the paged half is erosion_wrapper_glsl.h's, the same file
602// that emits the GLSL, so the twin cannot drift.
603float DCTerrainGeneratorProcNodes::_hblk_sample_cpu(float x, float z) const {
604 if (hblk_.empty()) return 0.0f;
605 if (hgrid_.active())
606 return erosion_heightmap_grid_sample(hblk_.data(), hgrid_, x, z);
607 const int hn = hblk_n_;
608 const float u0 = dcerosion::clamp((x - hblk_ox_) / hblk_mpp_, 0.0f, (float)(hn - 1));
609 const float v0 = dcerosion::clamp((z - hblk_oz_) / hblk_mpp_, 0.0f, (float)(hn - 1));
610 const int i0 = (int)floorf(u0), j0 = (int)floorf(v0);
611 const int i1 = MIN(i0 + 1, hn - 1), j1 = MIN(j0 + 1, hn - 1);
612 const float fu = u0 - (float)i0, fv = v0 - (float)j0;
613 const float h00 = hblk_[(size_t)j0*hn + i0], h10 = hblk_[(size_t)j0*hn + i1];
614 const float h01 = hblk_[(size_t)j1*hn + i0], h11 = hblk_[(size_t)j1*hn + i1];
615 const float a = h00 + (h10 - h00) * fu, b = h01 + (h11 - h01) * fu;
616 return a + (b - a) * fv;
617}
618
575619void DCTerrainGeneratorProcNodes::_discover_params() {
576620 discovered_params_.clear();
577621 if (graph_.is_null()) return;
……@@ -1235,18 +1279,8 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
12351279 if (!graph_has_erosion && owner_->hblk_n_ > 0) {
12361280 // Heightfield alone: density IS height minus query, exactly what the
12371281 // node emits. No filter space, no offsets, nothing to get wrong.
1238 const std::vector<float> &hb = owner_->hblk_;
1239 const int hn = owner_->hblk_n_;
1240 const float hox = owner_->hblk_ox_, hoz = owner_->hblk_oz_, hmpp = owner_->hblk_mpp_;
1241 float u = dcerosion::clamp((x - hox) / hmpp, 0.0f, (float)(hn - 1));
1242 float v = dcerosion::clamp((z - hoz) / hmpp, 0.0f, (float)(hn - 1));
1243 int i0 = (int)floorf(u), j0 = (int)floorf(v);
1244 int i1 = MIN(i0 + 1, hn - 1), j1 = MIN(j0 + 1, hn - 1);
1245 float fu = u - (float)i0, fv = v - (float)j0;
1246 float h00 = hb[(size_t)j0*hn + i0], h10 = hb[(size_t)j0*hn + i1];
1247 float h01 = hb[(size_t)j1*hn + i0], h11 = hb[(size_t)j1*hn + i1];
1248 float a0 = h00 + (h10 - h00) * fu, b0 = h01 + (h11 - h01) * fu;
1249 float d = (a0 + (b0 - a0) * fv) - y;
1282 const float hmpp = owner_->hblk_mpp_;
1283 float d = owner_->_hblk_sample_cpu(x, z) - y;
12501284 d = MAX(d, (floor_y - y) * 2.0f);
12511285 d = MIN(d, (ceiling_y - y) * 2.0f);
12521286 return d;
……@@ -1257,18 +1291,8 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
12571291 // this on, the field is not reaching the GPU and this is what you are
12581292 // standing on instead.
12591293 if (owner_->heightfield_only_ && owner_->hblk_n_ > 0) {
1260 const std::vector<float> &hbv = owner_->hblk_;
1261 const int hn = owner_->hblk_n_;
1262 const float hox = owner_->hblk_ox_, hoz = owner_->hblk_oz_, hmpp = owner_->hblk_mpp_;
1263 float u = dcerosion::clamp((x - hox) / hmpp, 0.0f, (float)(hn - 1));
1264 float v = dcerosion::clamp((z - hoz) / hmpp, 0.0f, (float)(hn - 1));
1265 int i0 = (int)floorf(u), j0 = (int)floorf(v);
1266 int i1 = MIN(i0 + 1, hn - 1), j1 = MIN(j0 + 1, hn - 1);
1267 float fu = u - (float)i0, fv = v - (float)j0;
1268 float h00 = hbv[(size_t)j0*hn + i0], h10 = hbv[(size_t)j0*hn + i1];
1269 float h01 = hbv[(size_t)j1*hn + i0], h11 = hbv[(size_t)j1*hn + i1];
1270 float a0 = h00 + (h10 - h00) * fu, b0 = h01 + (h11 - h01) * fu;
1271 float d = (a0 + (b0 - a0) * fv) - y;
1294 const float hmpp = owner_->hblk_mpp_;
1295 float d = owner_->_hblk_sample_cpu(x, z) - y;
12721296 d = MAX(d, (floor_y - y) * 2.0f);
12731297 d = MIN(d, (ceiling_y - y) * 2.0f);
12741298 return d;
……@@ -1313,20 +1337,9 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
13131337 // The algebra is erosion_wrapper_glsl.h's erosion_heightmap_gn_cpu, sitting
13141338 // beside the GLSL it mirrors, so the two cannot drift apart quietly.
13151339 if (owner_->hblk_n_ > 0) {
1316 const std::vector<float> &hb = owner_->hblk_;
1317 const int hn = owner_->hblk_n_;
1318 const float hox = owner_->hblk_ox_, hoz = owner_->hblk_oz_, hmpp = owner_->hblk_mpp_;
1340 const float hmpp = owner_->hblk_mpp_;
13191341 auto sample = [&](float sx, float sz) -> float {
1320 float u = dcerosion::clamp((sx - hox) / hmpp, 0.0f, (float)(hn - 1));
1321 float v = dcerosion::clamp((sz - hoz) / hmpp, 0.0f, (float)(hn - 1));
1322 int i0 = (int)floorf(u), j0 = (int)floorf(v);
1323 int i1 = MIN(i0 + 1, hn - 1), j1 = MIN(j0 + 1, hn - 1);
1324 float fu = u - (float)i0, fv = v - (float)j0;
1325 float h00 = hb[(size_t)j0*hn + i0], h10 = hb[(size_t)j0*hn + i1];
1326 float h01 = hb[(size_t)j1*hn + i0], h11 = hb[(size_t)j1*hn + i1];
1327 float a0 = h00 + (h10 - h00) * fu;
1328 float b0 = h01 + (h11 - h01) * fu;
1329 return a0 + (b0 - a0) * fv;
1342 return owner_->_hblk_sample_cpu(sx, sz);
13301343 };
13311344 float oh, odx, odz;
13321345 erosion_heightmap_gn_cpu(x, z, floor_y, ceiling_y, terrain_height,
……@@ -1339,22 +1352,9 @@ float DCTerrainGeneratorProcNodes::ProcNodesBridge::evaluate(
13391352 // divergence here is a player falling through ground that is visibly there.
13401353 // The algebra comes from erosion_wrapper_glsl.h, beside the GLSL twin.
13411354 if (owner_->hblk_n_ > 0) {
1342 const std::vector<float> &hb = owner_->hblk_;
1343 const int hn = owner_->hblk_n_;
1344 const float hox = owner_->hblk_ox_, hoz = owner_->hblk_oz_, hmpp = owner_->hblk_mpp_;
1355 const float hmpp = owner_->hblk_mpp_;
13451356 auto sample = [&](float sx, float sz) -> float {
1346 float u = (sx - hox) / hmpp;
1347 float v = (sz - hoz) / hmpp;
1348 u = dcerosion::clamp(u, 0.0f, (float)(hn - 1));
1349 v = dcerosion::clamp(v, 0.0f, (float)(hn - 1));
1350 int i0 = (int)floorf(u), j0 = (int)floorf(v);
1351 int i1 = MIN(i0 + 1, hn - 1), j1 = MIN(j0 + 1, hn - 1);
1352 float fu = u - (float)i0, fv = v - (float)j0;
1353 float h00 = hb[(size_t)j0 * hn + i0], h10 = hb[(size_t)j0 * hn + i1];
1354 float h01 = hb[(size_t)j1 * hn + i0], h11 = hb[(size_t)j1 * hn + i1];
1355 float a = h00 + (h10 - h00) * fu;
1356 float b = h01 + (h11 - h01) * fu;
1357 return a + (b - a) * fv;
1357 return owner_->_hblk_sample_cpu(sx, sz);
13581358 };
13591359 float oh, odx, odz;
13601360 erosion_heightmap_gn_cpu(x, z, floor_y, ceiling_y, terrain_height,
src/dc_terrain_generator_procnodes.h +11 -0
……@@ -4,6 +4,9 @@
44#include "dc_terrain_generator.h"
55#include <godot_cpp/variant/string.hpp>
66#include <string>
7// HeightGridDesc: the packed tile grid's layout, shared with the GLSL emitter
8// and the C++ twin so the three cannot describe the buffer differently.
9#include "erosion_wrapper_glsl.h"
710
811namespace godot {
912
……@@ -245,6 +248,14 @@ class DCTerrainGeneratorProcNodes : public DCTerrainGenerator {
245248 std::string cached_glsl_;
246249 // Bumped on every hblk_ mutation. See ProcNodesBridge::gpu_custom_data_version.
247250 uint32_t hblk_version_ = 0;
251 // The packed tile grid's layout, when the node supplies one. Empty means
252 // hblk_ is a single square and every CPU site addresses it the old way.
253 HeightGridDesc hgrid_;
254 // ONE CPU heightfield sampler. There were FOUR copies of this bilinear --
255 // two plain and two inside lambdas -- and the tile grid would have needed a
256 // fifth. Collapsing them is what makes "does the CPU read the same field as
257 // the GPU?" a question with one answer.
258 float _hblk_sample_cpu(float x, float z) const;
248259
249260public:
250261 // The ASSEMBLED shader string, exactly as handed to the compute pipeline.
tests/test_tile_grid_gpu.cpp +47 -9
……@@ -391,11 +391,12 @@ static std::string single_tile_consts(const HeightTileSrc& t) {
391391// The constants describing the GRID. HBLK_OX/OZ move to grid texel (0,0), so
392392// hblk_sample_level's world->texel mapping is untouched and i,j arrive at the
393393// fetch as GRID-relative texels.
394static std::string grid_consts(const HeightGridPack& g) {
394static std::string grid_consts(const HeightGridPack& p) {
395 const HeightGridDesc &g = p.desc;
395396 char b[1024];
396 const float ox = (float)((double)g.ty0 * g.payload) * g.mpp;
397 const float oz = (float)((double)g.tx0 * g.payload) * g.mpp;
398 const int side = std::max(g.nx, g.ny) * g.payload;
397 const float ox = g.ox();
398 const float oz = g.oz();
399 const int side = g.side();
399400 snprintf(b, sizeof(b),
400401 "const int HBLK_N = %d;\n"
401402 "const float HBLK_OX = %.4f;\n"
……@@ -521,7 +522,7 @@ int main(int argc, char** argv) {
521522 ok("pack accepted the tile set", g.ok);
522523 if (!g.ok) { printf("\nFAIL cannot continue\n"); return 1; }
523524 printf(" world %dx%d cells from (%d,%d) = %d, table %.0f KiB\n",
524 g.nx, g.ny, g.tx0, g.ty0, g.nx * g.ny, g.nx * g.ny * 4.0 / 1024.0);
525 g.desc.nx, g.desc.ny, g.desc.tx0, g.desc.ty0, g.desc.nx * g.desc.ny, g.desc.nx * g.desc.ny * 4.0 / 1024.0);
525526 printf(" %d tiles packed, %d refused, %.1f MiB total\n\n",
526527 g.tiles, g.rejected, g.data.size() * 4.0 / 1048576.0);
527528
……@@ -560,7 +561,7 @@ int main(int argc, char** argv) {
560561 max_slope = std::max(max_slope, fabs(h - A->h[(size_t)j * A->n + i - 1]));
561562 max_slope = std::max(max_slope, fabs(h - A->h[(size_t)(j - 1) * A->n + i]));
562563 }
563 const float u_max = (float)((double)std::max(g.nx, g.ny) * g.payload);
564 const float u_max = (float)((double)std::max(g.desc.nx, g.desc.ny) * g.desc.payload);
564565 const double ulp_u = (double)(nextafterf(u_max, 2.0f * u_max) - u_max);
565566 const double bound = 4.0 * ulp_u * max_slope;
566567 // Over BOTH probe sets: the fractional interior AND the world-metre texel
……@@ -585,7 +586,7 @@ int main(int argc, char** argv) {
585586 printf("\n CONTROL A': corrupt one index-table entry -> probe 1 must FAIL\n");
586587 {
587588 HeightGridPack bad = g;
588 const size_t cell = (size_t)(A->tx - g.tx0) * (size_t)g.ny + (size_t)(A->ty - g.ty0);
589 const size_t cell = (size_t)(A->tx - g.desc.tx0) * (size_t)g.desc.ny + (size_t)(A->ty - g.desc.ty0);
589590 const int was = (int)bad.data[cell];
590591 bad.data[cell] = (float)((was + 1) % g.tiles);
591592 std::vector<float> bg = run_sampler(c, grid_consts(bad), true, bad.data, pts, "corrupt");
……@@ -608,7 +609,7 @@ int main(int argc, char** argv) {
608609 }
609610 }
610611
611 printf("\n4. A TILE ARRIVES AT RUNTIME -- and the SHADER DOES NOT CHANGE\n");
612 printf("\n3. A TILE ARRIVES AT RUNTIME -- and the SHADER DOES NOT CHANGE\n");
612613 {
613614 // The same set minus B, which is what the world looks like before B is
614615 // fetched from a peer.
……@@ -648,6 +649,43 @@ int main(int argc, char** argv) {
648649 gs.tiles == (int)tiles.size() - 1, det);
649650 }
650651
652 printf("\n4. THE C++ TWIN OF THE FETCH AGREES WITH THE GLSL, EXACTLY\n");
653 {
654 // The engine meshes on the GPU but answers sample_density from C++, and
655 // test_erosion_parity.cpp exists because those two drifted once and put
656 // 4 km of air between what you saw and what you stood on. Compare the
657 // TEXEL FETCH itself at integer indices -- both sides are pure lookups
658 // there, no filtering, so anything but bit-identical is a real defect.
659 std::vector<float> ij;
660 std::vector<std::pair<int,int>> want;
661 const int gi0 = (A->ty - g.desc.ty0) * payload; // inside tile A
662 const int gj0 = (A->tx - g.desc.tx0) * payload;
663 for (int k = 0; k < 24; k++) {
664 int i, j;
665 if (k < 8) { i = gi0 + 3 + k * 31; j = gj0 + 5 + k * 29; }
666 else if (k < 16) { i = gi0 + payload + 7 * (k - 8); j = gj0 + 11; } // the HOLE
667 else { i = -900000 + k; j = gj0 + 13; } // outside
668 want.push_back({i, j});
669 ij.push_back((float)i); ij.push_back((float)j);
670 ij.push_back(0.0f); ij.push_back(0.0f);
671 }
672 std::vector<float> gpu = run_sampler(c, grid_consts(g), true, g.data, ij,
673 "texel", "hblk_texel_l(int(pts[i].x), int(pts[i].y), 0, 0)");
674 int bad = 0, stubs = 0, reals = 0;
675 for (size_t k = 0; k < want.size(); k++) {
676 const float cpu = erosion_heightmap_grid_texel(g.data.data(), g.desc,
677 want[k].first, want[k].second);
678 if (cpu != gpu[k]) bad++;
679 if (cpu == STUB) stubs++; else reals++;
680 }
681 snprintf(det, sizeof(det), "%d mismatches over %zu fetches", bad, want.size());
682 ok("GLSL fetch == C++ fetch, bit for bit", bad == 0, det);
683 // An instrument that only ever saw stubs, or only ever real texels,
684 // would agree for the wrong reason.
685 snprintf(det, sizeof(det), "%d real, %d stub", reals, stubs);
686 ok(" and the probe covered BOTH outcomes", reals > 0 && stubs > 0, det);
687 }
688
651689 // REPORTED, NOT ASSERTED, AND NOW LATENT RATHER THAN LIVE.
652690 //
653691 // hblk_sample_level still clamps world->texel to HBLK_SIDE[0] BEFORE the
……@@ -668,7 +706,7 @@ int main(int argc, char** argv) {
668706 // probe passes for the wrong reason -- which is what a first draft of
669707 // this probe did.
670708 std::vector<float> far_pts;
671 far_pts.push_back((float)(((double)(g.ty0 + g.ny + 500) * payload) * mpp));
709 far_pts.push_back((float)(((double)(g.desc.ty0 + g.desc.ny + 500) * payload) * mpp));
672710 far_pts.push_back(tile_centre_z(*A));
673711 far_pts.push_back(0.0f); far_pts.push_back(0.0f);
674712 std::vector<float> fg = run_sampler(c, grid_consts(g), true, g.data, far_pts, "far");