OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev / sweep_mountain.py

114 lines · python ref:
#!/usr/bin/env python3
"""
MOUNTAIN-LAYER SWEEP — what gain and octave count let the erosion filter work?

hyper-games #46. The filter is a faithful port of runevision's, and the
reference feeds it ONE near-smooth fBm: freq 3, 3 octaves, gain 0.1, lac 2.0,
amplitude 0.125 (lpmitchell/AdvancedTerrainErosion README, and the same config
in the Shadertoy lineage). dc-terrain adds a mountain layer on top of that at
8 octaves, gain 0.68, lacunarity 1.87 -- gain*lac = 1.27, so the input's slope
GROWS 27% per octave and the finest octave dominates it. The filter then puts
gullies on every bump at every scale.

CRITERION, so this is not eyeballing:

  1. gain * lacunarity, which must be <= 1 for the input's slope not to grow
     with frequency. Arithmetic, not opinion.
  2. the per-octave local power-law exponent beta of the RESULT, measured by
     the #46 §4 instrument. A generator with a drainage hierarchy STEEPENS
     down the column (terrain-diffusion does: 4.18 -> 4.69 -> 5.10). One that
     is texture at every scale stays flat (ours shipped at 3.58/3.44/3.78).

Both baselines are rendered here too, so the column is read against something
rather than in the abstract.
"""
import sys, urllib.parse, urllib.request
import numpy as np
sys.path.insert(0, "/storage/projects/code/godot/terrain-lookdev")
from spectrum_probe import radial_power_spectrum, selftest

LOOKDEV = "http://127.0.0.1:8818"
N, MPP = 512, 30.0
GEOM = dict(n=N, mpp=MPP, ox=120000, oz=40000, steps=256, voxel_size=2 * MPP)
OCTAVE_BANDS = [(64, 128), (128, 256)]      # 120-240 m, 60-120 m at this geometry
FIT = (16, 64)


def fetch(kind, **kw):
    q = urllib.parse.urlencode(kw)
    with urllib.request.urlopen(f"{LOOKDEV}/api/{kind}.bin?{q}", timeout=900) as r:
        n = int(r.headers["X-N"]); mpp = float(r.headers["X-Mpp"])
        buf = r.read()
    return np.frombuffer(buf, dtype="<f4").reshape(n, n).astype(np.float64), mpp


def betas(h, mpp):
    """Local beta per octave, plus the sd of the field. No extrapolation."""
    k, p = radial_power_spectrum(h)
    out = []
    for lo, hi in [FIT] + OCTAVE_BANDS:
        m = (k > lo) & (k <= hi) & (p > 0)
        if m.sum() < 4:
            out.append(float("nan")); continue
        s, _ = np.polyfit(np.log10(k[m]), np.log10(p[m]), 1)
        out.append(-s)
    return out


def row(label, h, mpp, note=""):
    b = betas(h, mpp)
    trend = b[2] - b[1]      # positive = steepens toward fine = hierarchy
    print(f"  {label:<26} {h.std():7.0f} {b[0]:7.2f} {b[1]:7.2f} {b[2]:7.2f} "
          f"{trend:+7.2f}  {note}")
    return b, trend


def main():
    print("INSTRUMENT SELF-TEST")
    if not selftest(verbose=False):
        print("  FAILED"); return 2
    print("  all pass\n")

    print(f"{N}x{N} @ {MPP:g} m/px = {N*MPP/1000:.2f} km, voxel {GEOM['voxel_size']:g} m "
          f"(band-limited to the sampling)")
    print(f"beta measured locally inside each band. 'trend' = beta(60-120m) - beta(120-240m);")
    print(f"POSITIVE means it steepens toward fine scales, i.e. large forms dominate.\n")
    hdr = f"  {'config':<26} {'sd m':>7} {'0.24-1km':>7} {'120-240':>7} {'60-120':>7} {'trend':>7}"
    print(hdr); print("  " + "-" * (len(hdr) - 2))

    # --- baselines ---------------------------------------------------------
    hd, _ = fetch("diffusion", n=N, i1=0, j1=2048)
    row("terrain-diffusion", hd, 30.0, "<- the target character")
    hr, _ = fetch("erosion", **GEOM, mountain_height=0)
    row("erosion, NO mountain", hr, MPP, "<- ~runevision's own config")
    hs, _ = fetch("erosion", **GEOM)
    row("SHIPPING (gain .68, oct 8)", hs, MPP, "<- gain*lac = 1.27")
    print()

    # --- gain sweep at 8 octaves ------------------------------------------
    print("  gain sweep, mountain_octaves = 8")
    best = None
    for g in (0.60, 0.5348, 0.45, 0.35, 0.25, 0.15, 0.10):
        h, _ = fetch("erosion", **GEOM, mountain_gain=g)
        b, t = row(f"  gain {g:.4f}", h, MPP, f"gain*lac {g*1.87:.2f}")
        if best is None or t > best[1]:
            best = (g, t)
    print(f"\n  best trend at gain {best[0]:.4f} ({best[1]:+.2f})\n")

    # --- octave sweep at that gain ----------------------------------------
    print(f"  octave sweep, mountain_gain = {best[0]:.4f}")
    for o in (8, 6, 5, 4, 3):
        h, _ = fetch("erosion", **GEOM, mountain_gain=best[0], mountain_octaves=o)
        row(f"  octaves {o}", h, MPP)

    # --- the reference shape, transplanted --------------------------------
    print("\n  runevision's own input shape on our mountain layer")
    for g, o in ((0.10, 3), (0.15, 3), (0.25, 3), (0.25, 4), (0.35, 4)):
        h, _ = fetch("erosion", **GEOM, mountain_gain=g, mountain_octaves=o)
        row(f"  gain {g:.2f} oct {o}", h, MPP, f"gain*lac {g*1.87:.2f}")
    return 0


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