extends SceneTree # Headless test: run with # godot --headless -s res://tests/test_gas_bands.gd # The gas giant's cloud bands were breaking into disjoint segments - the band # edges stepped sideways once per cell, right across the screen. The cause was # in gas_surface.gdshader's second turbulence octave, which took a # double-frequency sample by doubling only the INTEGER half of a lattice # coordinate and leaving the fraction alone. That is not the same point at # twice the frequency; it is a different point on every cell, so the noise - # and with it the band edge - jumped discontinuously at every cell boundary. # # A fragment shader cannot be asserted on from GDScript, so this ports the # band field to GDScript against the SAME hashes and the SAME constants # (parsed out of the shader, so the two cannot drift) and measures the thing # that actually went wrong: continuity. # # Covers: # A. the field is continuous in X across many cell boundaries - no step # larger than a small multiple of what a smooth field produces between # adjacent samples # B. the same in Y, where the row loop's window slides by one # C. the test has teeth: the OLD, broken octave is re-implemented here and # must FAIL the same scan. Without this, a field that had been flattened # to a constant would sail through A and B # D. the row scan still reaches every row that can change a pixel, from the # constants as they currently stand - the seam this prevents is a # horizontal one, which A and B would not catch # E. the field is EXACTLY periodic over the tile period, in both axes. The # camera cell index arrives already wrapped (SurfaceBackground.gd wraps # it in double precision), so a pixel's cell index shifts by exactly one # period whenever the camera crosses a wrap boundary. Unless the hashes # are wrapped to match, the whole sky rearranges itself at that instant const SHADER_PATH := "res://shaders/gas_surface.gdshader" # Sample spacing along the scan, in cells. Small enough that a genuinely # smooth field moves very little between samples. const STEP := 0.001 const SPAN_CELLS := 12.0 # What counts as a break. The field's slope is bounded by its octave weights # (0.55/0.30/0.15 over 4/2/1 cells, smoothstep slope at most 1.5) times # TURBULENCE_STRENGTH, divided by the thinnest band - about 0.9 per cell, so # a smooth field moves ~0.0009 between samples. Twenty times that is still # far below the ~0.3 jumps the broken octave produced, so this neither trips # on noise nor lets the real defect through. const MAX_STEP := 0.02 var source: String = "" var min_thickness: float = 0.0 var max_thickness: float = 0.0 var turbulence_strength: float = 0.0 var max_significant: float = 0.0 var row_scan: int = 0 var tile_cells: float = 32.0 # Set while scanning, to run the same scan against the broken formulation. var use_broken_octave: bool = false 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_thickness = _read_float_const("MIN_THICKNESS") max_thickness = _read_float_const("MAX_THICKNESS") turbulence_strength = _read_float_const("TURBULENCE_STRENGTH") max_significant = _read_float_const("MAX_SIGNIFICANT_DISTANCE") row_scan = int(_read_int_const("ROW_SCAN")) tile_cells = _read_uniform_default("tile_cells") if is_nan(tile_cells): _fail("the shader no longer declares a tile_cells uniform, so nothing bounds what it hashes") return if is_nan(min_thickness) or is_nan(max_thickness) or is_nan(turbulence_strength) \ or is_nan(max_significant) or row_scan == 0: _fail("could not parse the shader's band constants") return if not _check_premises(): return if not _check_continuity(): return if not _check_row_scan_headroom(): return if not _check_periodicity(): return print("TEST_GAS_BANDS: PASS") quit(0) # --- A/B/C: continuity ------------------------------------------------------ func _check_continuity() -> bool: var x_break: float = _worst_step_along_x() if x_break > MAX_STEP: return _fail("the band field breaks along X: a step of %.4f between samples %0.4f cells apart" \ % [x_break, STEP]) var y_break: float = _worst_step_along_y() if y_break > MAX_STEP: return _fail("the band field breaks along Y: a step of %.4f between samples %0.4f cells apart" \ % [y_break, STEP]) print("continuous across %d cell boundaries: worst step %.5f in X, %.5f in Y (allowed %.3f)" \ % [int(SPAN_CELLS), x_break, y_break, MAX_STEP]) # C. The scan has to be able to see a break, or A and B prove nothing. use_broken_octave = true var broken: float = _worst_step_along_x() use_broken_octave = false if broken <= MAX_STEP: return _fail("the continuity scan did not detect the ORIGINAL defect (worst step %.5f), so it cannot be trusted to detect a new one" % broken) print("scan verified against the original defect: the broken octave steps %.3f, %.0fx the allowance" \ % [broken, broken / MAX_STEP]) return true func _worst_step_along_x() -> float: # Deliberately started off-lattice, so the sample points straddle cell # boundaries rather than landing exactly on them - a break exactly at an # integer could otherwise be stepped over. var y: float = 0.317 var worst: float = 0.0 var x: float = -SPAN_CELLS * 0.5 + 0.0004 var previous: float = _nearest(x, y) while x < SPAN_CELLS * 0.5: x += STEP var value: float = _nearest(x, y) worst = maxf(worst, absf(value - previous)) previous = value return worst func _worst_step_along_y() -> float: var x: float = 0.211 var worst: float = 0.0 var y: float = -SPAN_CELLS * 0.5 + 0.0004 var previous: float = _nearest(x, y) while y < SPAN_CELLS * 0.5: y += STEP var value: float = _nearest(x, y) worst = maxf(worst, absf(value - previous)) previous = value return worst # --- D: the row scan still covers every row that matters -------------------- func _check_row_scan_headroom() -> bool: # A row can still change a pixel while the pixel is within # MAX_SIGNIFICANT_DISTANCE * thickness of its band, and the band itself # wanders by up to half the turbulence strength. var reach: float = max_significant * max_thickness + turbulence_strength * 0.5 # to_band = (local.y - dy) - band_centre, with local.y in [0,1) and # band_centre in [0,1), so (local.y - band_centre) spans (-1, 1) and a # row is relevant for dy anywhere in (-1 - reach, 1 + reach). var lowest_needed: int = ceili(-1.0 - reach) var highest_needed: int = floori(1.0 + reach) if lowest_needed < -1 or highest_needed > row_scan: return _fail("a band reaches %.2f cells, so rows dy %d..%d can still change a pixel - but the loop only scans -1..%d" \ % [reach, lowest_needed, highest_needed, row_scan]) print("band reach %.2f cells needs dy within %d..%d; the loop scans -1..%d" \ % [reach, lowest_needed, highest_needed, row_scan]) return true # --- the field, ported ------------------------------------------------------ func _nearest(x: float, y: float) -> float: # Same split the shader does: an exact integer cell index plus a bounded # fraction. TIME is pinned to zero here - drift slides the field, it does # not change its shape, and a fixed value keeps this reproducible. var cell := Vector2(floor(x), floor(y)) var local := Vector2(x - cell.x, y - cell.y) var base_lattice := Vector2(cell.x, local.x) var nearest: float = 1000.0 for dy in range(-1, row_scan + 1): var row: float = cell.y + float(dy) var row_hash: Vector3 = _hash23(Vector2(fposmod(row, tile_cells), 5.0)) var band_centre: float = row_hash.x var thickness: float = lerpf(min_thickness, max_thickness, row_hash.y) var turb: float = _row_turbulence(base_lattice, tile_cells, fposmod(row, tile_cells) * 3.7) var to_band: float = (local.y - float(dy)) - band_centre to_band += (turb - 0.5) * turbulence_strength nearest = minf(nearest, absf(to_band) / thickness) return nearest func _row_turbulence(lattice: Vector2, period: float, axis: float) -> float: if use_broken_octave: # The original, as it stood: one cell-frequency octave plus a second # that doubled the integer part of the lattice coordinate and left # the fraction untouched. return _value_noise(lattice, period, axis + 11.0) * 0.65 \ + _value_noise(Vector2(lattice.x * 2.0, lattice.y), period, axis + 47.0) * 0.35 var half_freq: Vector2 = _halve_lattice(lattice) var quarter: Vector2 = _halve_lattice(half_freq) return _value_noise(quarter, period * 0.25, axis + 11.0) * 0.55 \ + _value_noise(half_freq, period * 0.5, axis + 47.0) * 0.30 \ + _value_noise(lattice, period, axis + 83.0) * 0.15 func _halve_lattice(coord: Vector2) -> Vector2: var half_i: float = floor(coord.x * 0.5) var rem: float = coord.x - half_i * 2.0 return Vector2(half_i, (rem + coord.y) * 0.5) func _value_noise(coord: Vector2, period: float, axis: float) -> float: var a: float = _hash21(Vector2(fposmod(coord.x, period), axis)) var b: float = _hash21(Vector2(fposmod(coord.x + 1.0, period), axis)) return lerpf(a, b, smoothstep(0.0, 1.0, coord.y)) func _fract(v: float) -> float: return v - floor(v) func _fract3(v: Vector3) -> Vector3: return Vector3(_fract(v.x), _fract(v.y), _fract(v.z)) func _hash21(p: Vector2) -> float: var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * 0.1031) var shifted := Vector3(p3.y, p3.z, p3.x) + Vector3(33.33, 33.33, 33.33) p3 += Vector3.ONE * p3.dot(shifted) return _fract((p3.x + p3.y) * p3.z) func _hash23(p: Vector2) -> Vector3: var p3: Vector3 = _fract3(Vector3(p.x, p.y, p.x) * Vector3(0.1031, 0.1030, 0.0973)) var shifted := Vector3(p3.y, p3.x, p3.z) + Vector3(33.33, 33.33, 33.33) p3 += Vector3.ONE * p3.dot(shifted) return _fract3(Vector3( (p3.x + p3.y) * p3.z, (p3.x + p3.z) * p3.y, (p3.y + p3.z) * p3.x)) # --- premises --------------------------------------------------------------- func _check_premises() -> bool: # The exact defect, named so it cannot quietly return. if "vec2(turb_x.x * 2.0" in source: return _fail("the shader is doubling a lattice coordinate's integer part while leaving its fraction alone - that is the band-break defect itself") if not ("halve_lattice" in source): return _fail("the shader no longer rescales its octaves through halve_lattice, so continuity is no longer guaranteed by construction") if not ("combine_lattice" in source): return _fail("the shader no longer folds drift in through combine_lattice, so TIME is being added to a cell index directly") return true func _read_float_const(name: String) -> float: var regex := RegEx.new() regex.compile("const\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name) var found: RegExMatch = regex.search(source) if found == null: return NAN return float(found.get_string(1)) func _read_int_const(name: String) -> float: var regex := RegEx.new() regex.compile("const\\s+int\\s+%s\\s*=\\s*(-?[0-9]+)" % name) var found: RegExMatch = regex.search(source) if found == null: return 0.0 return float(found.get_string(1)) func _read_uniform_default(name: String) -> float: var regex := RegEx.new() regex.compile("uniform\\s+float\\s+%s\\s*=\\s*(-?[0-9.]+)" % name) var found: RegExMatch = regex.search(source) if found == null: return NAN return float(found.get_string(1)) # --- E: exact periodicity --------------------------------------------------- func _check_periodicity() -> bool: if fmod(tile_cells, 4.0) != 0.0: return _fail("a tile period of %.0f cells is not a multiple of 4, so the quarter-frequency octave cannot wrap on a whole cell and the seam comes back" % tile_cells) var worst: float = 0.0 for i in range(400): var x: float = -7.3 + float(i) * 0.11 var y: float = 2.9 + float(i) * 0.07 worst = maxf(worst, absf(_nearest(x, y) - _nearest(x + tile_cells, y))) worst = maxf(worst, absf(_nearest(x, y) - _nearest(x, y + tile_cells))) # Exact, not approximate: the same hashes are evaluated on indices that # differ by whole periods, so identical inputs reach them. Anything but a # rounding-scale difference means the wrap is visible. if worst > 1e-9: return _fail("the field is not periodic over %.0f cells (differs by %.6f), so crossing a camera wrap rearranges the sky" \ % [tile_cells, worst]) print("field is exactly periodic over %.0f cells in both axes (worst difference %s)" % [tile_cells, str(worst)]) return true func _fail(message: String) -> bool: print("TEST_GAS_BANDS: FAIL (%s)" % message) quit(1) return false