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>
2026-08-17 23:20:14 +02:00
|
|
|
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
|
|
|
|
|
|
#84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.
buildings core controllers enemies logistics planets
resources shaders surface ui vessels vfx world
332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.
Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.
The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
|
|
|
const SHADER_PATH := "res://shaders/lunar_surface.gdshader"
|
|
|
|
|
const TILED_SHADER_PATH := "res://shaders/lunar_tiled.gdshader"
|
|
|
|
|
const BACKGROUND_SCRIPT_PATH := "res://surface/SurfaceBackground.gd"
|
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>
2026-08-17 23:20:14 +02:00
|
|
|
# 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:
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
return _fail("SurfaceBackground.gd no longer declares TILE_CELLS, the shared tile period")
|
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>
2026-08-17 23:20:14 +02:00
|
|
|
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):
|
Share one backdrop between game and editor, and fix three input/economy bugs
The setup editor and the landed surface drew different backgrounds for the
same planet. Each scene carried its own hand-written ShaderMaterial, and only
the game side ever swapped in a profile's shader, so a gas giant authored in
the editor showed recoloured craters while the game showed cloud bands. Both
now instance one SurfaceBackground.tscn and configure it through one method,
and every profile names its own shader - the old "null means keep whatever
the scene already had" convention was the drift, since each scene "already
had" something different. The shared material is resource_local_to_scene, or
two live surfaces would repaint each other.
The gas giant's bands broke into disjoint segments once per cell: the second
turbulence octave doubled only the INTEGER half of a lattice coordinate,
which is a different point in every cell rather than the same point at twice
the frequency. Measured, the old form steps 0.153 at a cell boundary against
0.0002 now. A second discontinuity went with it - the camera cell index
arrives pre-wrapped, so hashing it raw rearranged the whole sky every 32
cells; hashes are now wrapped per octave and the field is exactly periodic.
Bands also gained low-frequency octaves and per-row differential drift.
WorldMap swallowed every touch in _input(), which runs before the GUI system,
so CloseButton never saw one: the map could be opened and not closed. Android
and desktop hid it behind Godot's emulated mouse event, which this never
swallowed; a phone browser gives no such second chance. Its test had been
closing the map by calling the method the button was failing to reach.
Construction now draws on the fleet hold as well as the bank. With an empty
starting bank and ore bankable only at a mothership, charging construction to
the bank alone made the first mothership cost ore that could not yet exist.
Also: a conquered planet no longer re-spawns its garrison (guarded on having
capture points, since a setup with none registers as vacuously conquered);
planets keep the map they drew, with the candidate list sorted rather than
left in filesystem order and assignments pruned rather than cleared on
rescan; and the map editor lists setups at a tappable size and can delete
one, as an explicit armed mode rather than a tap away from destroying work.
Full suite 106/106.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:16:38 +02:00
|
|
|
return _fail("SurfaceBackground.gd no longer wraps the camera cell into the tile before handing it to the shader")
|
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>
2026-08-17 23:20:14 +02:00
|
|
|
|
|
|
|
|
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
|