321 lines
15 KiB
GDScript3
321 lines
15 KiB
GDScript3
|
|
extends SceneTree
|
||
|
|
|
||
|
|
# Headless test: run with
|
||
|
|
# godot --headless -s res://tests/test_lunar_field.gd
|
||
|
|
# Forgejo #68: the lunar backdrop shader was the most expensive thing in the
|
||
|
|
# surface scene, running a 5x5 (25-sample) neighbour scan on every pixel of a
|
||
|
|
# fullscreen rect. It is now 3x3 (9 samples).
|
||
|
|
#
|
||
|
|
# A fragment shader cannot be asserted on directly from GDScript, so this
|
||
|
|
# test guards the REASONING that made the reduction safe. The 5x5 width was
|
||
|
|
# insurance against craters observed clipping at cell boundaries on-device -
|
||
|
|
# i.e. against a crater reaching further than its nominal radius allowed.
|
||
|
|
# The fix was to bound that reach at the source (clamp the centre into its
|
||
|
|
# own cell, clamp the radius), which makes a 3x3 scan provably sufficient.
|
||
|
|
# If someone later loosens either clamp or raises MAX_RADIUS past a cell, the
|
||
|
|
# clipping bug comes straight back - and looks like a GPU mystery rather than
|
||
|
|
# an edit. This test makes that edit fail loudly instead.
|
||
|
|
#
|
||
|
|
# Covers:
|
||
|
|
# A. the shader source still carries every premise the bound rests on
|
||
|
|
# (3x3 loop, clamped centre, clamped radius, MAX_RADIUS below a cell)
|
||
|
|
# B. worst case geometry: even a crater pinned at the far corner of a
|
||
|
|
# ring-2 cell at maximum radius cannot reach a threshold that changes a
|
||
|
|
# pixel. Independent of what the hash returns, so it holds on any driver
|
||
|
|
# C. differential check against the real hash: over thousands of sampled
|
||
|
|
# points, a 3x3 scan and a 5x5 scan produce the SAME rendered output
|
||
|
|
# D. the miss sentinel stays inside mediump range when squared, so it
|
||
|
|
# cannot become +Inf (and then NaN) on a mobile GPU
|
||
|
|
|
||
|
|
const SHADER_PATH := "res://lunar_surface.gdshader"
|
||
|
|
const TILED_SHADER_PATH := "res://lunar_tiled.gdshader"
|
||
|
|
const BACKGROUND_SCRIPT_PATH := "res://LunarBackground.gd"
|
||
|
|
# Largest normalized distance any shading term below reacts to. Past this,
|
||
|
|
# every smoothstep has saturated and the pixel is plain regolith.
|
||
|
|
const MAX_SIGNIFICANT_DISTANCE := 1.15
|
||
|
|
const MEDIUMP_MAX := 65504.0
|
||
|
|
const SAMPLE_COUNT := 4000
|
||
|
|
|
||
|
|
var source: String = ""
|
||
|
|
var min_radius: float = 0.0
|
||
|
|
var max_radius: float = 0.0
|
||
|
|
var miss_distance_sq: float = 0.0
|
||
|
|
var start_distance_sq: float = 0.0
|
||
|
|
var tile_cells: float = 0.0
|
||
|
|
|
||
|
|
|
||
|
|
func _initialize() -> void:
|
||
|
|
var file: FileAccess = FileAccess.open(SHADER_PATH, FileAccess.READ)
|
||
|
|
if file == null:
|
||
|
|
_fail("could not read %s" % SHADER_PATH)
|
||
|
|
return
|
||
|
|
source = file.get_as_text()
|
||
|
|
file.close()
|
||
|
|
|
||
|
|
min_radius = _read_const("MIN_RADIUS")
|
||
|
|
max_radius = _read_const("MAX_RADIUS")
|
||
|
|
miss_distance_sq = _read_const("MISS_DISTANCE_SQ")
|
||
|
|
start_distance_sq = _read_const("START_DISTANCE_SQ")
|
||
|
|
if is_nan(min_radius) or is_nan(max_radius) or is_nan(miss_distance_sq) or is_nan(start_distance_sq):
|
||
|
|
_fail("could not parse the shader's radius/sentinel constants")
|
||
|
|
return
|
||
|
|
|
||
|
|
if not _check_premises():
|
||
|
|
return
|
||
|
|
if not _check_tiling_setup():
|
||
|
|
return
|
||
|
|
if not _check_worst_case_geometry():
|
||
|
|
return
|
||
|
|
if not _check_sentinels():
|
||
|
|
return
|
||
|
|
if not _check_scans_agree():
|
||
|
|
return
|
||
|
|
if not _check_field_is_periodic():
|
||
|
|
return
|
||
|
|
|
||
|
|
print("TEST_LUNAR_FIELD: PASS")
|
||
|
|
quit(0)
|
||
|
|
|
||
|
|
|
||
|
|
# --- A. the premises are still in the source ---------------------------------
|
||
|
|
func _check_premises() -> bool:
|
||
|
|
# The scan itself. Both loops must be the 3x3 form; a stray -2 anywhere in
|
||
|
|
# the bounds means someone widened it back without updating this test.
|
||
|
|
if not ("for (int dx = -1; dx <= 1; dx++)" in source and "for (int dy = -1; dy <= 1; dy++)" in source):
|
||
|
|
return _fail("the neighbour scan is no longer the 3x3 loop this test reasons about")
|
||
|
|
# The centre clamp. Without it a hash outside [0,1] places a crater centre
|
||
|
|
# outside its own cell and the ring-2 bound below stops holding.
|
||
|
|
if not ("clamp(h.xy, 0.0, 1.0)" in source):
|
||
|
|
return _fail("the crater centre is no longer clamped into its own cell")
|
||
|
|
# The radius clamp. Without it mix() extrapolates into an arbitrarily
|
||
|
|
# large radius, which is the specific failure that caused the original
|
||
|
|
# on-device crater clipping.
|
||
|
|
if not ("clamp(mix(MIN_RADIUS, MAX_RADIUS," in source and "), MIN_RADIUS, MAX_RADIUS)" in source):
|
||
|
|
return _fail("the crater radius is no longer clamped to [MIN_RADIUS, MAX_RADIUS]")
|
||
|
|
# MAX_SIGNIFICANT_DISTANCE below is the furthest normalized distance any
|
||
|
|
# shading term still reacts to, and the overlap suppression is the term
|
||
|
|
# that reaches furthest. Pin it here: raising that upper bound past
|
||
|
|
# MAX_SIGNIFICANT_DISTANCE would make ring-2 craters matter again, and
|
||
|
|
# the geometry check would go on passing against a stale number.
|
||
|
|
if not ("smoothstep(0.85, %.2f, second_nearest)" % MAX_SIGNIFICANT_DISTANCE in source):
|
||
|
|
return _fail("the overlap suppression no longer saturates at %.2f, so the significant range this test assumes is stale" \
|
||
|
|
% MAX_SIGNIFICANT_DISTANCE)
|
||
|
|
print("shader still clamps centre and radius, still scans 3x3, still saturates by %.2f" % MAX_SIGNIFICANT_DISTANCE)
|
||
|
|
return true
|
||
|
|
|
||
|
|
|
||
|
|
# --- E. the tiling rig is wired to one shared period -------------------------
|
||
|
|
# The tile period appears in three places - the field shader that bakes it,
|
||
|
|
# the tiled shader that scrolls it, and the GDScript that sizes the bake and
|
||
|
|
# wraps the camera. If they ever disagree the seam stops being seamless, and
|
||
|
|
# it fails as a faint repeating discontinuity rather than as an error.
|
||
|
|
func _check_tiling_setup() -> bool:
|
||
|
|
if not ("mod(cell + offset, tile_cells)" in source):
|
||
|
|
return _fail("the field shader no longer wraps the cell index, so the baked tile would not be seamless")
|
||
|
|
|
||
|
|
var script_file: FileAccess = FileAccess.open(BACKGROUND_SCRIPT_PATH, FileAccess.READ)
|
||
|
|
if script_file == null:
|
||
|
|
return _fail("could not read %s" % BACKGROUND_SCRIPT_PATH)
|
||
|
|
var script_source: String = script_file.get_as_text()
|
||
|
|
script_file.close()
|
||
|
|
|
||
|
|
var regex := RegEx.new()
|
||
|
|
regex.compile("const\\s+TILE_CELLS\\s*:\\s*float\\s*=\\s*([0-9.]+)")
|
||
|
|
var found: RegExMatch = regex.search(script_source)
|
||
|
|
if found == null:
|
||
|
|
return _fail("LunarBackground.gd no longer declares TILE_CELLS, the shared tile period")
|
||
|
|
tile_cells = float(found.get_string(1))
|
||
|
|
if tile_cells < 4.0:
|
||
|
|
return _fail("a tile period of %.0f cells is too short to read as anything but a repeat" % tile_cells)
|
||
|
|
|
||
|
|
# The camera index must be wrapped on the GDScript side, in double
|
||
|
|
# precision, before it reaches the shader - the whole point of the
|
||
|
|
# existing cell/fraction split is that the raw index is too large for a
|
||
|
|
# mobile float, and the tiled shader adds it straight into a UV.
|
||
|
|
if not ("fposmod(cell.x, TILE_CELLS)" in script_source):
|
||
|
|
return _fail("LunarBackground.gd no longer wraps the camera cell into the tile before handing it to the shader")
|
||
|
|
|
||
|
|
var tiled_file: FileAccess = FileAccess.open(TILED_SHADER_PATH, FileAccess.READ)
|
||
|
|
if tiled_file == null:
|
||
|
|
return _fail("could not read %s" % TILED_SHADER_PATH)
|
||
|
|
var tiled_source: String = tiled_file.get_as_text()
|
||
|
|
tiled_file.close()
|
||
|
|
|
||
|
|
# repeat_enable is the mechanism that makes the canvas infinite;
|
||
|
|
# mipmaps are what stop the crater field shimmering when zoomed out.
|
||
|
|
if not ("repeat_enable" in tiled_source):
|
||
|
|
return _fail("the tiled shader no longer samples with repeat_enable, so the canvas stops repeating")
|
||
|
|
if not ("filter_linear_mipmap" in tiled_source):
|
||
|
|
return _fail("the tiled shader no longer uses mipmaps, so zooming out will alias the crater field")
|
||
|
|
print("tile period %.0f cells, wrapped in GDScript, sampled with repeat + mipmaps" % tile_cells)
|
||
|
|
return true
|
||
|
|
|
||
|
|
|
||
|
|
# --- F. the field really is seamless -----------------------------------------
|
||
|
|
# Periodicity is asserted directly rather than inferred from the wrap being
|
||
|
|
# present: the same point one whole tile away must render identically. A seam
|
||
|
|
# is exactly the case where it does not.
|
||
|
|
func _check_field_is_periodic() -> bool:
|
||
|
|
var rng := RandomNumberGenerator.new()
|
||
|
|
rng.seed = 6800
|
||
|
|
var shifts: Array = [
|
||
|
|
Vector2(tile_cells, 0.0),
|
||
|
|
Vector2(0.0, tile_cells),
|
||
|
|
Vector2(tile_cells, tile_cells),
|
||
|
|
Vector2(-tile_cells * 3.0, tile_cells * 2.0),
|
||
|
|
]
|
||
|
|
for i in range(SAMPLE_COUNT):
|
||
|
|
var cell := Vector2(rng.randi_range(-4000, 4000), rng.randi_range(-4000, 4000))
|
||
|
|
var local := Vector2(rng.randf(), rng.randf())
|
||
|
|
var here := _shade(cell, local, 1)
|
||
|
|
for shift in shifts:
|
||
|
|
var there := _shade(cell + shift, local, 1)
|
||
|
|
for k in range(here.size()):
|
||
|
|
if absf(here[k] - there[k]) > 0.0001:
|
||
|
|
return _fail("the field is not periodic: cell %s and cell %s render differently (term %d)" \
|
||
|
|
% [cell, cell + shift, k])
|
||
|
|
print("%d sampled points: the field repeats exactly every %.0f cells in both axes" % [SAMPLE_COUNT, tile_cells])
|
||
|
|
return true
|
||
|
|
|
||
|
|
|
||
|
|
# --- B. worst case geometry, independent of the hash -------------------------
|
||
|
|
func _check_worst_case_geometry() -> bool:
|
||
|
|
if max_radius >= 1.0:
|
||
|
|
return _fail("MAX_RADIUS is %.3f - a crater at least a full cell wide can reach past a 3x3 scan" % max_radius)
|
||
|
|
if min_radius <= 0.0 or min_radius > max_radius:
|
||
|
|
return _fail("MIN_RADIUS %.3f is not a sane lower bound below MAX_RADIUS %.3f" % [min_radius, max_radius])
|
||
|
|
|
||
|
|
# Closest approach between the sampled pixel and ANY crater centre two
|
||
|
|
# cells out. The pixel's local coordinate spans [0,1]; a ring-2 cell spans
|
||
|
|
# [2,3] (or [-2,-1]), and the centre clamp keeps the centre inside it. So
|
||
|
|
# the gap is at least one whole cell however the hash lands.
|
||
|
|
var closest_ring_two_distance: float = 1.0
|
||
|
|
var closest_normalized: float = closest_ring_two_distance / max_radius
|
||
|
|
if closest_normalized <= MAX_SIGNIFICANT_DISTANCE:
|
||
|
|
return _fail("a crater two cells out reaches a normalized distance of %.2f, inside the significant range (%.2f) - a 3x3 scan would clip it" \
|
||
|
|
% [closest_normalized, MAX_SIGNIFICANT_DISTANCE])
|
||
|
|
print("worst case ring-2 crater lands at %.2f, clear of the %.2f threshold (%.1fx margin)" \
|
||
|
|
% [closest_normalized, MAX_SIGNIFICANT_DISTANCE, closest_normalized / MAX_SIGNIFICANT_DISTANCE])
|
||
|
|
return true
|
||
|
|
|
||
|
|
|
||
|
|
# --- D. the sentinels cannot overflow a mobile float -------------------------
|
||
|
|
func _check_sentinels() -> bool:
|
||
|
|
# The largest value the loop can actually carry: a real squared distance
|
||
|
|
# plus the miss sentinel added on top of it.
|
||
|
|
var largest_real_sq: float = pow(sqrt(8.0) / min_radius, 2.0)
|
||
|
|
var largest_carried: float = miss_distance_sq + largest_real_sq
|
||
|
|
if largest_carried >= MEDIUMP_MAX:
|
||
|
|
return _fail("the loop can carry %.0f, past the mediump ceiling %.0f - it becomes +Inf and then NaN on a mobile GPU" \
|
||
|
|
% [largest_carried, MEDIUMP_MAX])
|
||
|
|
if miss_distance_sq <= largest_real_sq:
|
||
|
|
return _fail("MISS_DISTANCE_SQ %.0f does not beat the largest real squared distance %.0f, so an empty cell could win the scan" \
|
||
|
|
% [miss_distance_sq, largest_real_sq])
|
||
|
|
if start_distance_sq <= largest_carried:
|
||
|
|
return _fail("START_DISTANCE_SQ %.0f is not above every value the loop can produce (%.0f), so the winning offset can stay unset" \
|
||
|
|
% [start_distance_sq, largest_carried])
|
||
|
|
print("sentinels ordered correctly and %.0f stays inside the mediump ceiling %.0f" % [largest_carried, MEDIUMP_MAX])
|
||
|
|
return true
|
||
|
|
|
||
|
|
|
||
|
|
# --- C. 3x3 and 5x5 render the same thing ------------------------------------
|
||
|
|
func _check_scans_agree() -> bool:
|
||
|
|
# The hash is ported from the shader below. It will not be bit-identical
|
||
|
|
# to any given GPU's evaluation, and does not need to be: what is being
|
||
|
|
# checked is that dropping the outer ring changes nothing, which follows
|
||
|
|
# from the clamped bounds rather than from the hash's exact values. The
|
||
|
|
# port's job is to catch a mistake in that reasoning on realistic inputs,
|
||
|
|
# not to emulate a driver.
|
||
|
|
var rng := RandomNumberGenerator.new()
|
||
|
|
rng.seed = 68
|
||
|
|
var worst_delta: float = 0.0
|
||
|
|
for i in range(SAMPLE_COUNT):
|
||
|
|
# Cell indices deliberately reach into the thousands - the same
|
||
|
|
# magnitudes the camera actually produces after flying a while.
|
||
|
|
var cell := Vector2(rng.randi_range(-4000, 4000), rng.randi_range(-4000, 4000))
|
||
|
|
var local := Vector2(rng.randf(), rng.randf())
|
||
|
|
var near := _shade(cell, local, 1)
|
||
|
|
var far := _shade(cell, local, 2)
|
||
|
|
for k in range(near.size()):
|
||
|
|
var delta: float = absf(near[k] - far[k])
|
||
|
|
worst_delta = maxf(worst_delta, delta)
|
||
|
|
if delta > 0.0001:
|
||
|
|
return _fail("3x3 and 5x5 scans disagree at cell %s local %s: term %d differs by %f" \
|
||
|
|
% [cell, local, k, delta])
|
||
|
|
print("%d sampled points: 3x3 and 5x5 scans agree exactly (worst term delta %.8f)" % [SAMPLE_COUNT, worst_delta])
|
||
|
|
return true
|
||
|
|
|
||
|
|
|
||
|
|
# The three values the shader's colour is actually a function of. Comparing
|
||
|
|
# these rather than raw `nearest` is deliberate: a 3x3 scan can legitimately
|
||
|
|
# report a different nearest distance than a 5x5 one (it may find no crater
|
||
|
|
# where 5x5 found a far one), and what matters is only that the PIXEL comes
|
||
|
|
# out the same colour.
|
||
|
|
func _shade(cell: Vector2, local: Vector2, ring: int) -> Array:
|
||
|
|
var nearest_sq: float = start_distance_sq
|
||
|
|
var second_sq: float = start_distance_sq
|
||
|
|
var nearest_offset := Vector2(0.0, 1.0)
|
||
|
|
|
||
|
|
for dx in range(-ring, ring + 1):
|
||
|
|
for dy in range(-ring, ring + 1):
|
||
|
|
var offset := Vector2(dx, dy)
|
||
|
|
var neighbor: Vector2 = cell + offset
|
||
|
|
# Same wrap the shader does, and the reason the field tiles.
|
||
|
|
neighbor = Vector2(fposmod(neighbor.x, tile_cells), fposmod(neighbor.y, tile_cells))
|
||
|
|
var h: Vector4 = _hash42(neighbor)
|
||
|
|
var has_crater: float = 1.0 if h.z >= 0.35 else 0.0
|
||
|
|
var centre: Vector2 = offset + Vector2(clampf(h.x, 0.0, 1.0), clampf(h.y, 0.0, 1.0))
|
||
|
|
var radius: float = clampf(lerpf(min_radius, max_radius, h.w), min_radius, max_radius)
|
||
|
|
var to_sample: Vector2 = local - centre
|
||
|
|
var normalized_sq: float = to_sample.length_squared() / (radius * radius) \
|
||
|
|
+ (1.0 - has_crater) * miss_distance_sq
|
||
|
|
if normalized_sq < nearest_sq:
|
||
|
|
second_sq = nearest_sq
|
||
|
|
nearest_sq = normalized_sq
|
||
|
|
nearest_offset = to_sample
|
||
|
|
elif normalized_sq < second_sq:
|
||
|
|
second_sq = normalized_sq
|
||
|
|
|
||
|
|
var nearest: float = sqrt(nearest_sq)
|
||
|
|
var second: float = sqrt(second_sq)
|
||
|
|
var offset_length: float = nearest_offset.length()
|
||
|
|
var nearest_dir: Vector2 = nearest_offset / offset_length if offset_length > 0.0001 else Vector2(0.0, 1.0)
|
||
|
|
var facing: float = nearest_dir.dot(Vector2(-0.6, -0.8).normalized())
|
||
|
|
|
||
|
|
var floor_amount: float = 1.0 - smoothstep(0.45, 0.9, nearest)
|
||
|
|
var rim_band: float = smoothstep(0.55, 0.85, nearest) * (1.0 - smoothstep(0.85, 1.0, nearest))
|
||
|
|
rim_band *= smoothstep(0.85, 1.15, second)
|
||
|
|
# facing only reaches the output multiplied by rim_band, so folding it in
|
||
|
|
# here keeps an irrelevant direction difference deep inside a crater floor
|
||
|
|
# from reading as a visual difference.
|
||
|
|
return [floor_amount, rim_band * clampf(facing, 0.0, 1.0), rim_band * clampf(-facing, 0.0, 1.0)]
|
||
|
|
|
||
|
|
|
||
|
|
func _hash42(p: Vector2) -> Vector4:
|
||
|
|
var p4 := _fract4(Vector4(p.x, p.y, p.x, p.y) * Vector4(0.1031, 0.1030, 0.0973, 0.1099))
|
||
|
|
var d: float = p4.dot(Vector4(p4.w, p4.z, p4.x, p4.y) + Vector4(33.33, 33.33, 33.33, 33.33))
|
||
|
|
p4 += Vector4(d, d, d, d)
|
||
|
|
# fract((p4.xxyz + p4.yzzw) * p4.zywx)
|
||
|
|
var a := Vector4(p4.x, p4.x, p4.y, p4.z)
|
||
|
|
var b := Vector4(p4.y, p4.z, p4.z, p4.w)
|
||
|
|
var c := Vector4(p4.z, p4.y, p4.w, p4.x)
|
||
|
|
return _fract4((a + b) * c)
|
||
|
|
|
||
|
|
|
||
|
|
func _fract4(v: Vector4) -> Vector4:
|
||
|
|
return Vector4(v.x - floor(v.x), v.y - floor(v.y), v.z - floor(v.z), v.w - floor(v.w))
|
||
|
|
|
||
|
|
|
||
|
|
func _read_const(name: String) -> float:
|
||
|
|
var regex := RegEx.new()
|
||
|
|
regex.compile("const\\s+float\\s+" + name + "\\s*=\\s*([0-9.eE+-]+)")
|
||
|
|
var found: RegExMatch = regex.search(source)
|
||
|
|
if found == null:
|
||
|
|
return NAN
|
||
|
|
return float(found.get_string(1))
|
||
|
|
|
||
|
|
|
||
|
|
func _fail(message: String) -> bool:
|
||
|
|
print("TEST_LUNAR_FIELD: FAIL (%s)" % message)
|
||
|
|
quit(1)
|
||
|
|
return false
|