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