OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / gate_heightmap_lod.py

107 lines · python ref:
#!/usr/bin/env python3
"""
GATE: is the baked heightfield BAND-LIMITED per LOD?

The property the octree clipmap needs is not "the coarse surface is close to the
fine one" -- it is "the coarse surface is a SMOOTHED version of the fine one".
Every other layer in this generator gets that from nyquist_octaves(..., voxel_scale,
..), which drops octaves as the voxel grows. A sampled heightfield has no such
property unless somebody gives it one.

Without it, LOD k samples a 30 m field at 2^k * 30 m spacing. At LOD 7 that is
points 3840 m apart in a field whose sd is 1167 m, so neighbouring voxels differ by
about 1.6 km and the coarse mesh is noise rather than terrain. geometric_error then
never falls (dc_octree_clipmap.cpp:328-351 uses it for both the content-aware split
boost and the screen-space test), so the clipmap splits forever and never converges.

THE CHECK: render the surface at voxel V on a V-spaced grid, and compare against
the FINE surface box-averaged over V. A band-limited field matches; a point-sampled
one does not.

ALIGNMENT, which this gate got wrong first time round and reported as a code bug.
Level-k texel j is the average of level-0 texels [j*2^k, (j+1)*2^k), so its CENTRE
sits at ox + (j*2^k + (2^k-1)/2)*mpp -- half a coarse texel to the right of
ox + j*V. Sampling the renderer at ox + j*V and comparing against box j compares
two different places, and reads as 57 m of error at LOD 4 where there is 13. The
coarse render is therefore offset onto the box centres.

TOLERANCE IS RELATIVE TO THE VOXEL. A difference far below the voxel size cannot
be represented by that LOD's mesh and is not a defect; what matters is whether the
coarse surface is the fine one smoothed, to within what the LOD can express.

THE CONTROL is V = 30, the field's own pitch, where the two are the same thing by
construction. If the control ever fails the comparison itself is wrong.

    python gate_heightmap_lod.py assets/terrain/diffusion_gully_30km.hblk
"""
import argparse, os, subprocess, sys
import numpy as np

DC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "dc-terrain"))
MPP = 30.0


def render(binpath, hblk, ox, oz, n, mpp, voxel, out):
    cmd = [binpath, "--heightmap", hblk, "--ox", str(ox), "--oz", str(oz),
           "--n", str(n), "--mpp", str(mpp), "--out", out,
           "--set", "voxel_size", str(voxel)]
    r = subprocess.run(cmd, cwd=DC, capture_output=True, text=True)
    if r.returncode != 0:
        print(r.stdout, r.stderr); raise SystemExit("renderer failed")
    return np.fromfile(out, dtype="<f4", count=n * n).astype(np.float64).reshape(n, n)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("hblk"); ap.add_argument("--bin", default="/tmp/erosion_render2")
    ap.add_argument("--ox", type=float, default=45000.0)
    ap.add_argument("--oz", type=float, default=30000.0)
    ap.add_argument("--fine-n", type=int, default=256)
    ap.add_argument("--tol-frac", type=float, default=0.10,
                    help="rms tolerance as a fraction of the voxel size")
    a = ap.parse_args()

    hb = os.path.abspath(a.hblk)
    span = a.fine_n * MPP
    print(f"window {span/1000:.2f} km at ({a.ox:.0f}, {a.oz:.0f}), "
          f"fine {a.fine_n}x{a.fine_n} @ {MPP:g} m\n")

    fine = render(a.bin, hb, a.ox, a.oz, a.fine_n, MPP, 1.0, "/tmp/lod_fine.f32")

    print(f"{'voxel m':>8} {'LOD':>4} {'rms vs box-avg':>15} {'tol':>8}  verdict")
    fails = 0
    for V in [30.0, 120.0, 480.0, 1920.0]:
        s = int(round(V / MPP))
        n = a.fine_n // s
        if n < 4:
            continue
        # onto the box centres — see ALIGNMENT above
        shift = (s - 1) * MPP * 0.5
        coarse = render(a.bin, hb, a.ox + shift, a.oz + shift, n, V, V,
                        f"/tmp/lod_{int(V)}.f32")
        m = n * s
        box = fine[:m, :m].reshape(n, s, n, s).mean(axis=(1, 3))
        rms = float(np.sqrt(((coarse - box) ** 2).mean()))
        tol = max(a.tol_frac * V, 5.0)
        ok = rms <= tol
        tag = "PASS" if ok else "FAIL"
        if V == MPP:
            tag = ("CONTROL ok" if ok else "CONTROL BROKEN — the comparison is wrong")
        elif not ok:
            fails += 1
        print(f"{V:>8.0f} {int(np.log2(V/MPP)):>4} {rms:>15.1f} {tol:>8.1f}  {tag}")

    print()
    if fails == 0:
        print("VERDICT: the field is band-limited — every LOD is a smoothed version")
        print("         of the one below it, which is what the clipmap needs to settle.")
        return 0
    print("VERDICT: the field is NOT band-limited. Coarse LODs point-sample a 30 m")
    print("         field, so they are noise rather than smoothed terrain, and the")
    print("         clipmap has nothing to converge to.")
    return 1


if __name__ == "__main__":
    sys.exit(main())