Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)

The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.

Three reductions to the field itself:

- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
  it was insurance against craters seen clipping at cell boundaries
  on-device, i.e. a crater reaching past its nominal radius because a hash
  returned a value outside [0,1] and extrapolated mix(). Clamping the
  crater CENTRE into its own cell, alongside the radius clamp already
  there, bounds the reach whatever the hash returns - so a crater two cells
  out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
  threshold the shading reacts to (the last saturates at 1.15). The outer
  ring provably cannot change a pixel. This removes the clipping bug's
  cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
  on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
  than once per sample.

Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.

Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.

The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.

tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.

test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.

PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-17 23:20:14 +02:00
commit 657ca9d035
11 changed files with 845 additions and 74 deletions

View file

@ -0,0 +1,106 @@
extends SceneTree
# Not a test - a stopwatch for a fullscreen background shader, in the same
# spirit as _profile_startup.gd but aimed at the FRAGMENT side instead of
# scene setup. Forgejo #68 ("the moon game is a bit slow").
#
# Renders nothing but one fullscreen ColorRect carrying the given shader, so
# the frame time is essentially pure per-pixel shader cost with no gameplay,
# UI or draw calls mixed in. Run it via tests/profile_shader.sh, which
# supplies Xvfb + Mesa softpipe exactly like screenshot.sh does.
#
# Softpipe is a pure-C rasterizer, not the phone's GPU, so the ABSOLUTE
# numbers mean nothing. What it measures well is RELATIVE per-fragment ALU
# cost between two versions of the same shader: softpipe executes the
# fragment program per pixel on the CPU, so halving the arithmetic shows up
# as roughly half the frame time.
#
# TWO THINGS WILL MISLEAD YOU IF YOU READ THE RAW NUMBER.
#
# 1. There is a FLOOR. A shader that does nothing but assign a constant
# colour still costs ~657 ms/frame at 720x1280 here, which is softpipe's
# own per-pixel overhead. Subtract it before comparing, or a shader that
# got 20x cheaper will look like it only got 2x cheaper.
#
# 2. It cannot price a TEXTURE FETCH against arithmetic. Softpipe does
# filtering and mip selection in CPU code, so a fetch measures as
# expensive here; real GPUs - mobile ones especially - run it on
# dedicated hardware where it is close to free. Trading arithmetic for a
# lookup is exactly the kind of change this harness will report backwards.
# Use it to compare arithmetic against arithmetic, and nothing else.
#
# Args (after `--`): <shader_res_path> [timed_frames] [warmup_frames]
var shader_path: String
var timed_frames: int = 30
var warmup_frames: int = 10
var frames: int = 0
var rect: ColorRect
var start_usec: int = 0
func _initialize() -> void:
var args: PackedStringArray = OS.get_cmdline_user_args()
if args.is_empty():
printerr("usage: -- <shader_res_path> [timed_frames] [warmup_frames]")
quit(1)
return
shader_path = args[0]
if args.size() >= 2:
timed_frames = int(args[1])
if args.size() >= 3:
warmup_frames = int(args[2])
var shader: Shader = load(shader_path)
if shader == null:
printerr("could not load shader: %s" % shader_path)
quit(1)
return
var material := ShaderMaterial.new()
material.shader = shader
var layer := CanvasLayer.new()
layer.layer = -10
root.add_child(layer)
rect = ColorRect.new()
rect.material = material
rect.anchor_right = 1.0
rect.anchor_bottom = 1.0
rect.size = root.get_visible_rect().size
layer.add_child(rect)
func _process(_delta: float) -> bool:
# Move the camera every frame. A static camera would let a driver skip
# redundant uniform uploads and would only ever exercise one crater
# layout; drifting keeps the measurement honest and representative of
# actually flying around.
var t: float = float(frames) * 0.05
var p: Vector2 = Vector2(cos(t), sin(t)) * 40.0
var cell := Vector2(floor(p.x), floor(p.y))
rect.material.set_shader_parameter("camera_cell", cell)
rect.material.set_shader_parameter("camera_frac", p - cell)
rect.material.set_shader_parameter("camera_zoom", 1.0)
rect.material.set_shader_parameter("viewport_size", root.get_visible_rect().size)
frames += 1
# Shaders compile on first use and the first frames carry that cost, so
# start the clock only once the pipeline is warm.
if frames == warmup_frames:
start_usec = Time.get_ticks_usec()
return false
if frames < warmup_frames + timed_frames:
return false
var span_usec: int = Time.get_ticks_usec() - start_usec
var per_frame_ms: float = float(span_usec) / 1000.0 / float(timed_frames)
var px: Vector2 = root.get_visible_rect().size
print("SHADER_PROFILE: %s" % shader_path)
print(" viewport : %dx%d (%d px)" % [px.x, px.y, int(px.x * px.y)])
print(" frames timed : %d (after %d warmup)" % [timed_frames, warmup_frames])
print(" per frame : %.1f ms" % per_frame_ms)
print(" per megapixel : %.1f ms" % (per_frame_ms / (px.x * px.y / 1000000.0)))
quit(0)
return true

View file

@ -0,0 +1 @@
uid://dgg7cnpiq2y58

34
tests/profile_shader.sh Normal file
View file

@ -0,0 +1,34 @@
#!/data/data/com.termux/files/usr/bin/bash
# Times a fullscreen background shader's per-pixel cost. Same Xvfb + Mesa
# softpipe rig as screenshot.sh (see the long note there about llvmpipe
# crashing with SIGILL in this environment), because a fragment shader can
# only be timed if something actually rasterizes it - --headless renders
# nothing at all.
#
# Softpipe is a CPU rasterizer, so treat the numbers as RELATIVE only:
# comparing two versions of the same shader is meaningful, comparing against
# a phone's frame budget is not.
#
# Usage: bash tests/profile_shader.sh <shader_res_path> [timed_frames] [warmup] [WxH]
# Example: bash tests/profile_shader.sh res://lunar_surface.gdshader 30 10 720x1280
set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
SHADER_PATH="${1:?usage: profile_shader.sh <shader_res_path> [timed_frames] [warmup] [WxH]}"
TIMED_FRAMES="${2:-30}"
WARMUP_FRAMES="${3:-10}"
RESOLUTION="${4:-720x1280}"
proot-distro login ubuntu -- bash -c "
pkill -x Xvfb >/dev/null 2>&1 || true
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
Xvfb :99 -screen 0 ${RESOLUTION}x24 &
XVFB_PID=\$!
trap 'kill \$XVFB_PID >/dev/null 2>&1 || true' EXIT
sleep 2
mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt
cd '$PROJECT_DIR'
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --audio-driver Dummy --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_profile_surface_shader.gd -- '$SHADER_PATH' '$TIMED_FRAMES' '$WARMUP_FRAMES'
"

321
tests/test_lunar_field.gd Normal file
View file

@ -0,0 +1,321 @@
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

View file

@ -0,0 +1 @@
uid://b0bjyh6fn2v34

View file

@ -19,7 +19,7 @@ extends SceneTree
# rather than checking on the same one.
var game_state: Node
var lunar_shader: Shader
var scene_shader: Shader
var gas_shader: Shader
var rocky_shader_after_compose: Shader
var scene: Node
@ -29,7 +29,17 @@ var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
lunar_shader = load("res://lunar_surface.gdshader")
# Whatever PlanetSurface.tscn itself ships with, read from the scene
# rather than named by path. The property under test is "a null
# background_shader leaves the scene's own background alone" - naming a
# specific file here instead made this fail the moment the lunar
# background changed implementation (#68 swapped the per-pixel
# procedural shader for a baked, tiled one), which is a change this test
# has no business having an opinion about.
var packed: PackedScene = load("res://PlanetSurface.tscn")
var probe: Node = packed.instantiate()
scene_shader = probe.get_node("BackgroundLayer/LunarBackground").material.shader
probe.free()
# Loaded by path into a local, not referenced by its bare global
# class_name - same load-order hazard PlanetSetups.gd and
@ -47,8 +57,8 @@ func _initialize() -> void:
quit(1)
return
gas_shader = gas_profile.background_shader
if gas_shader == lunar_shader:
print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the lunar shader, not its own)")
if gas_shader == scene_shader:
print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the scene's own shader, not its own)")
quit(1)
return
@ -79,7 +89,7 @@ func _process(delta: float) -> bool:
var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material
scene.free()
if rocky_shader_after_compose != lunar_shader:
if rocky_shader_after_compose != scene_shader:
print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet changed the background shader)")
quit(1)
return true
@ -87,8 +97,8 @@ func _process(delta: float) -> bool:
print("TEST_SURFACE_PROFILES: FAIL (landing on a gas giant did not swap in gas_surface.gdshader)")
quit(1)
return true
if gas_bg.shader == lunar_shader:
print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the lunar shader)")
if gas_bg.shader == scene_shader:
print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the scene's own lunar background)")
quit(1)
return true