307 lines
12 KiB
GDScript3
307 lines
12 KiB
GDScript3
|
|
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://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
|