OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / erosion_range_gate.gd

352 lines · gd ref:
extends SceneTree
## Does map_dc_erosion's density field actually put a SURFACE inside its vertical band?
##
## The map reported "no terrain" with a generator that loaded, compiled and logged
## its parameters. That rules out the extension and the graph, and leaves the
## question nobody had asked: does d = 0 ever occur between floor_y and ceiling_y?
##
## It does not have to. procnodes maps world Y into the density's native range by
##     y_mapped = (y - floor_y) * (height_scale / y_range) - mtn_offset * height_scale
## so y_range appears in a DIVISOR. Widen the band and the mapping FLATTENS: the
## same noise amplitude now spans a far larger world distance, and the surface can
## sit entirely above ceiling_y or below floor_y. Then every column is uniformly
## air or uniformly solid, there is no sign change, dual contouring emits nothing,
## and the symptom is an empty world with a healthy-looking log.
##
## Headless, so sample_density() takes the CPU path — ProcNodesBridge::evaluate.
##
## THIS GATE DOES NOT TEST CPU/GPU AGREEMENT, and an earlier version of this
## comment claimed it did. It only ever calls sample_density, which is the CPU
## path and nothing else; the GPU is not reachable from here. That claim is part
## of why a 60x divergence between the two lived for as long as it did — the
## meshed surface and the collidable surface differed by a mean of 4 km on this
## very map while this gate reported healthy numbers, because it was measuring
## one side of a comparison it believed it was making.
##
## The comparison lives in dc-terrain/tests/test_erosion_parity.cpp, which
## compiles the shared erosion_filter.inc as GLSL on the real device and as C++
## on the host and diffs the surfaces. What THIS gate answers is narrower and
## still worth answering: is there a surface inside the vertical band at all.
##
## Run:
##   export HYPER_TPS_GODOT=/storage/projects/code/godot/nvidia-rtx-godot/bin/godot.linuxbsd.editor.x86_64
##   "$HYPER_TPS_GODOT" --headless --script res://scenes/test/erosion_range_gate.gd
##
## ⚠ NOT `godot4`. That is 4.6.stable.official on PATH; the game runs the
## nvidia-rtx-godot FORK (4.7.beta.custom_build), which is what
## scripts/dev-run.sh launches and what CI uses -- dev-run.sh:24 says so
## outright. A gate run on a different engine from the game is the
## "check examined the wrong artifact" failure (hyper-games #44), even
## when the numbers happen to match: this gate was re-run on both on
## 2026-09-07 and agreed exactly, because the terrain maths lives in the
## GDExtension rather than the engine. That is luck, not a guarantee.

# Mirrors map_dc_erosion.gd. The band is centred on y = 0 because Godot character
# physics degrades with ALTITUDE; the measurements are in the map.
const FLOOR_Y := -9100.0
const CEILING_Y := 11900.0

# Overridable so a parameter set can be swept in seconds instead of deployed:
#   --th 600 --ms 0.03 --moct 6 --gain 0.5
#
# THE DEFAULTS ARE map_dc_erosion.gd's, deliberately. They used to be a mix of
# map values and sweep leftovers — mountain_octaves 4 against the map's 8,
# mountain_gain 0.50 against 0.55, lacunarity 2.0 against 1.87, and detail_scale
# 0.0 against 200.0, which switched the detail layer OFF entirely. A bare run
# therefore measured a configuration that does not ship, which is the same
# defect class as the gate below being pointed at the wrong build. Flags still
# override, so sweeping is unaffected; what changed is what you get when you
# pass nothing.
var TERRAIN_HEIGHT := 60000.0   # map_dc_erosion.gd:48
var MOUNTAIN_SCALE := 0.1       # map_dc_erosion.gd (1.0 -> 0.1)
var MOUNTAIN_HEIGHT := 0.55     # map_dc_erosion.gd
var MOUNTAIN_OCTAVES := 8       # map_dc_erosion.gd:53
var RELIEF_SCALE := 0.5         # map_dc_erosion.gd:60
var RELIEF_FLOOR := 0.08        # map_dc_erosion.gd:62
var GN_GAIN := 0.1              # map_dc_erosion.gd:125
var MOUNTAIN_GAIN := 0.68       # map_dc_erosion.gd:69
var DETAIL_SCALE := 600.0       # map_dc_erosion.gd:79
var DETAIL_HEIGHT := 30.0       # map_dc_erosion.gd
var DETAIL_OCTAVES := 4         # map_dc_erosion.gd:85
var DETAIL_GAIN := 0.5          # map_dc_erosion.gd:86
var LACUNARITY := 1.87          # map_dc_erosion.gd:92
var SQRT_SPACE := false

## THE LAND/SEA CURVE (proc-nodes/src/shared/hypso_curve.inc).
##
## A plain fBm is UNIMODAL, so intersecting it with a water plane at any level
## gives a coastline of uniform fractal character everywhere -- no shelves, no
## basins, no reason for land to clump. This curve remaps the mountain layer to
## a BIMODAL distribution (abyssal plain + continental platform, with a steep
## continental slope between) so the waterline falls in the trough where few
## samples sit, which is what makes a coastline sharp.
##
## Gated by dc-terrain/tests/test_hypso_curve.cpp: monotonic (min dout/dh = 0),
## C1 by a scaling test (ratio 2.00), 2 output modes with dip 0.95, and the
## summit lift measured to leave everything below its onset untouched.
##
## SEA sets the land fraction, measured over the REAL multifractal field with
## this map's constants (176400 columns, +/-458.8 km):
##     sea  0.00 -> 53.7%   0.10 -> 45.7%   0.20 -> 33.7%   0.30 -> 22.9%
## 0.20 is close to Earth's ~29% and leaves ocean for the volumetric seafloor.
##
## ⚠ relief_scale/relief_floor DO NOT modulate this. The generator uses
## mountain_height alone on the hypso path, because relief_amp varies 12.5x
## across the world and scaling the curve by it would put sea level at a
## different world Y in every region -- one water plane could then match the
## shore nowhere. See the note at the emission site.
const HYPSO_ENABLED := 1.0
const HYPSO_SEA := 0.20
const HYPSO_SHELF_WIDTH := 0.12   # gentle shelf below sea level
const HYPSO_SLOPE_WIDTH := 0.05   # steep drop half-width, SUBMERGED
const HYPSO_ABYSS := -0.62
const HYPSO_PLAT_SLOPE := 0.13
const HYPSO_MTN_ONSET := 0.35
const HYPSO_MTN_WIDTH := 0.10
const HYPSO_MTN_SLOPE := 1.30
const HYPSO_PEAK_ONSET := 0.60
const HYPSO_PEAK_GAIN := 0.30

## Columns spread across the 57 km world, and the vertical resolution to walk them
## at. 100 m steps over 10 km is 100 samples per column — enough to find a surface,
## coarse enough to stay instant.
const COLUMNS := 24
## +/- 450 km, i.e. most of the depth-16 world (917.5 km across). This was 28000
## back when the world was 57 km and that covered it; after the depth 14 -> 16
## change the same number sampled the central 6% and called it a survey. A gate
## whose sample region silently stops tracking the thing it samples is the same
## defect as one pointed at the wrong build.
const SPAN := 450000.0
const STEP := 100.0


const null_h := -999999.0

## Surface height in a column, or null_h when the column never crosses zero.
##
## COARSE SCAN TO BRACKET, THEN BISECT. Returning the coarse step directly
## quantises every height to that step, and the first version of this used 10 m —
## so a 4 m feature read as either 0 or 10, "mean step" was mostly zeros with
## occasional 10 m jumps, and adding mountain octaves appeared to change NOTHING
## at 6, 8, 10 and 12 octaves. That was the ruler, not the terrain. 24 bisections
## over a 10 m bracket resolve to well under a millimetre.
func _surface_at(terrain, x: float, z: float) -> float:
	var prev: float = terrain.sample_density(Vector3(x, FLOOR_Y, z))
	var lo := FLOOR_Y
	var y := FLOOR_Y + 10.0
	while y <= CEILING_Y:
		var d: float = terrain.sample_density(Vector3(x, y, z))
		if (prev >= 0.0) != (d >= 0.0):
			var a := lo
			var b := y
			var da := prev
			for _i in range(24):
				var m: float = (a + b) * 0.5
				var dm: float = terrain.sample_density(Vector3(x, m, z))
				if (da >= 0.0) != (dm >= 0.0):
					b = m
				else:
					a = m
					da = dm
			return (a + b) * 0.5
		prev = d
		lo = y
		y += 10.0
	return null_h


func _init() -> void:
	var argv := OS.get_cmdline_user_args()
	for i in range(argv.size() - 1):
		var a: String = argv[i]
		var v: float = float(argv[i + 1])
		if a == "--th": TERRAIN_HEIGHT = v
		elif a == "--ms": MOUNTAIN_SCALE = v
		elif a == "--moct": MOUNTAIN_OCTAVES = int(v)
		elif a == "--mh": MOUNTAIN_HEIGHT = v
		elif a == "--gain": GN_GAIN = v
		elif a == "--rf": RELIEF_FLOOR = v
		elif a == "--mgain": MOUNTAIN_GAIN = v
		elif a == "--dscale": DETAIL_SCALE = v
		elif a == "--dh": DETAIL_HEIGHT = v
		elif a == "--doct": DETAIL_OCTAVES = int(v)
		elif a == "--dgain": DETAIL_GAIN = v
		elif a == "--lac": LACUNARITY = v
		elif a == "--sqrt": SQRT_SPACE = v > 0.5

	var g := ProcNodeGraph.new()
	var noise := g.add_node(GradientNoiseNode.new())
	g.get_node(noise).set_input_default(1, 3.0)
	g.get_node(noise).set_input_default(2, 3)
	g.get_node(noise).set_input_default(3, GN_GAIN)
	var erosion := g.add_node(ErosionNoiseNode.new())
	g.get_node(erosion).set_input_default(1, 5.0)
	g.get_node(erosion).set_input_default(2, 0.15)
	g.get_node(erosion).set_input_default(3, 0.6)
	g.get_node(erosion).set_input_default(4, 0.7)
	g.get_node(erosion).set_input_default(5, 7)
	g.get_node(erosion).set_input_default(6, 0.5)
	g.connect_sockets(noise, 0, erosion, 7)
	g.connect_sockets(noise, 1, erosion, 8)

	var gen := DCTerrainGeneratorProcNodes.new()
	gen.set_terrain_height(TERRAIN_HEIGHT)
	gen.set_floor_y(FLOOR_Y)
	gen.set_ceiling_y(CEILING_Y)
	gen.set_mountain_scale(MOUNTAIN_SCALE)
	gen.set_mountain_height(MOUNTAIN_HEIGHT)
	gen.set_mountain_octaves(MOUNTAIN_OCTAVES)
	gen.set_relief_scale(RELIEF_SCALE)
	gen.set_hypso_enabled(HYPSO_ENABLED)
	gen.set_hypso_sea(HYPSO_SEA)
	gen.set_hypso_shelf_width(HYPSO_SHELF_WIDTH)
	gen.set_hypso_slope_width(HYPSO_SLOPE_WIDTH)
	gen.set_hypso_abyss(HYPSO_ABYSS)
	gen.set_hypso_plat_slope(HYPSO_PLAT_SLOPE)
	gen.set_hypso_mtn_onset(HYPSO_MTN_ONSET)
	gen.set_hypso_mtn_width(HYPSO_MTN_WIDTH)
	gen.set_hypso_mtn_slope(HYPSO_MTN_SLOPE)
	gen.set_hypso_peak_onset(HYPSO_PEAK_ONSET)
	gen.set_hypso_peak_gain(HYPSO_PEAK_GAIN)
	gen.set_relief_floor(RELIEF_FLOOR)
	gen.set_mountain_gain(MOUNTAIN_GAIN)
	gen.set_detail_scale(DETAIL_SCALE)
	gen.set_detail_height(DETAIL_HEIGHT)
	gen.set_detail_octaves(DETAIL_OCTAVES)
	gen.set_detail_gain(DETAIL_GAIN)
	gen.set_noise_lacunarity(LACUNARITY)
	gen.set_height_space_sqrt(SQRT_SPACE)
	gen.set_graph(g)
	print("  READBACK: detail_scale=%.2f detail_height=%.2f mountain_gain=%.2f" % [gen.get_detail_scale(), gen.get_detail_height(), gen.get_mountain_gain()])

	var terrain := DCTerrain.new()
	terrain.set("use_noise", true)
	terrain.set("voxel_size", 1.0)
	terrain.set("generator", gen)
	get_root().add_child(terrain)

	print("Erosion range gate — is there a surface inside [%.0f, %.0f]?" % [FLOOR_Y, CEILING_Y])
	print("  terrain_height %.0f  mountain_scale %.2f x%d oct gain %.2f  relief %.2f/%.2f" %
			[TERRAIN_HEIGHT, MOUNTAIN_SCALE, MOUNTAIN_OCTAVES, MOUNTAIN_GAIN, RELIEF_SCALE, RELIEF_FLOOR])
	print("  detail: scale %.0f (base %.0f m) height %.1f m x%d oct gain %.2f" %
			[DETAIL_SCALE, (TERRAIN_HEIGHT / DETAIL_SCALE) if DETAIL_SCALE > 0.0 else 0.0,
			 DETAIL_HEIGHT, DETAIL_OCTAVES, DETAIL_GAIN])
	print("  %d columns across +/- %.0f m, %.0f m steps\n" % [COLUMNS, SPAN, STEP])

	var with_surface := 0
	var all_solid := 0
	var all_air := 0
	var lo := 1.0e30
	var hi := -1.0e30
	## Every surface height, so the WATERLINE can be read off as a
	## percentile instead of derived. The curve -> world-Y mapping goes
	## through the erosion filter and the y-map, and deriving it by hand
	## was wrong by 3.4x once already. A percentile cannot be.
	var heights: Array[float] = []
	var shown := 0

	for i in range(COLUMNS):
		for j in range(COLUMNS):
			var x: float = -SPAN + 2.0 * SPAN * float(i) / float(COLUMNS - 1)
			var z: float = -SPAN + 2.0 * SPAN * float(j) / float(COLUMNS - 1)
			var prev: float = terrain.sample_density(Vector3(x, FLOOR_Y, z))
			var found := false
			var surface_y := 0.0
			var y := FLOOR_Y + STEP
			while y <= CEILING_Y:
				var d: float = terrain.sample_density(Vector3(x, y, z))
				if (prev >= 0.0) != (d >= 0.0):
					found = true
					surface_y = y
					break
				prev = d
				y += STEP
			if found:
				with_surface += 1
				heights.append(surface_y)
				if surface_y < lo: lo = surface_y
				if surface_y > hi: hi = surface_y
			elif prev >= 0.0:
				all_solid += 1
			else:
				all_air += 1
			if shown < 6:
				shown += 1
				var label := "surface %.0f m" % surface_y
				if not found:
					label = "SOLID all the way" if prev >= 0.0 else "AIR all the way"
				print("  (%9.0f, %9.0f)  %s" % [x, z, label])

	var total := COLUMNS * COLUMNS
	print("\n  columns with a surface : %d / %d" % [with_surface, total])
	print("  uniformly solid        : %d" % all_solid)
	print("  uniformly air          : %d" % all_air)
	if with_surface > 0:
		print("  surface spans          : %.0f m .. %.0f m (relief %.0f m)" % [lo, hi, hi - lo])
	# THE WATERLINE, measured. Land fraction is set by HYPSO_SEA (0.20 -> 33.7%
	# over the real field, test_hypso_curve.cpp), so the water level that
	# DELIVERS that fraction is the matching percentile of surface heights.
	if heights.size() > 8:
		heights.sort()
		var want := [0.10, 0.25, 0.50, 0.663, 0.75, 0.90]
		var parts := PackedStringArray()
		for q in want:
			var idx := int(clamp(q * float(heights.size() - 1), 0.0, float(heights.size() - 1)))
			parts.append("p%02d %.0f" % [int(q * 100.0), heights[idx]])
		print("  surface percentiles    : %s" % ", ".join(parts))
		var wi := int(clamp(0.663 * float(heights.size() - 1), 0.0, float(heights.size() - 1)))
		var water := heights[wi]
		var land := 0
		for h2 in heights:
			if h2 > water: land += 1
		print("  => WATER_Y = %.0f m  (land %.1f%% of %d columns)"
				% [water, 100.0 * float(land) / float(heights.size()), heights.size()])
		print("     centring the band on the surface: floor %.0f ceiling %.0f"
				% [FLOOR_Y - (lo + hi) * 0.5, CEILING_Y - (lo + hi) * 0.5])

	# LOCAL RELIEF — what you feel underfoot, and what foot-IK has to cope with.
	# The column scan above answers "is there ground"; it says nothing about whether
	# the ground has any shape at human scale. A 200 m transect at 2 m steps does.
	print("\n  local relief (200 m transect at 2 m steps, at three places):")
	var worst_local := 1.0e30
	for t in range(3):
		var ox: float = float(t) * 7000.0
		var prev_h := 0.0
		var have_prev := false
		var mn := 1.0e30
		var mx := -1.0e30
		var steps := 0
		var step_sum := 0.0
		for k in range(101):
			var x: float = ox + float(k) * 2.0
			var h := _surface_at(terrain, x, 0.0)
			if h == null_h: continue
			if h < mn: mn = h
			if h > mx: mx = h
			if have_prev:
				step_sum += abs(h - prev_h)
				steps += 1
			prev_h = h
			have_prev = true
		if steps > 0:
			var band: float = mx - mn
			if band < worst_local: worst_local = band
			print("    x=%6.0f..%6.0f  elev %7.0f m,  relief %7.2f m,  mean step %5.2f m" %
					[ox, ox + 200.0, (mn + mx) * 0.5, band, step_sum / float(steps)])

	print("\n  worst local relief: %.2f m over 200 m" % worst_local)
	if worst_local < 5.0:
		print("    -> TOO SMOOTH. Nothing at player scale; this is one big octave.")
	elif worst_local > 400.0:
		print("    -> VERY steep at player scale.")
	else:
		print("    -> has shape at player scale.")

	var pass_ok := with_surface >= total / 2
	print("\n  %s" % ("PASS — most columns have a surface in range" if pass_ok
			else "FAIL — the field has no surface inside the band for most columns,"))
	if not pass_ok:
		print("         which renders as an empty world with a perfectly healthy log.")
	terrain.queue_free()
	quit(0 if pass_ok else 1)