Compare commits

...
Author SHA1 Message Date
Loïc Wisniewski
75b7603f04 Seat the HUD button columns below the resource readout, and move the planet name
The build button overlapped the resource list. ResourceReadout builds one
row per resource type AT RUNTIME (58px each), so how far down it reaches is
not knowable when the scene is authored: PlanetSurface/Main declared
offset_bottom 154, four resources actually reach 270, and BuildButton sat
at a hardcoded 160. A 110px collision that grew with every resource type
added. Because the readout spans the full width - the fleet hold column is
at x 356 - it hit both button columns, TransferButton and
EnterAtmosphereButton as well as the left one.

Rather than nudge the offsets, the layout now derives from the readout:
ResourceReadout.rows_height()/content_bottom() report the real extent and
_ready() stops the node understating its own size, and
BuildController._seat_hud_columns() seats both columns below that at load.
Adding a fifth resource type pushes the buttons down on its own, so the
overlap cannot come back.

Two details worth keeping: the height is derived from the live resource
list rather than cached in _ready(), because BuildController readies before
UI's children in scene-tree order and a cached value would answer zero. And
it translates the block rather than re-flowing it, since
BuildingActionButton and HangarButton deliberately share a row as mutually
exclusive options.

The planet name moved from the centre - where it sat across resource rows
3 and 4 - to the top-right corner, right-aligned. Right alignment is what
keeps it clear: short names sit against the right edge, well clear of the
fleet hold column, and long ones wrap downward into empty space rather than
growing left into it. That scene edit rode along in the #68 commit, both
being changes to PlanetSurface.tscn.

Also adds three .uid files orphaned by earlier commits this session; the
repo tracks them for every other script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:51 +02:00
Loïc Wisniewski
67cb853397 Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.

Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.

The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:

- fleet_velocity is now fed FORWARD into the steering instead of carried
  alongside it. The ship aims to travel at its slot's velocity plus a
  correction closing the gap, so holding formation costs no error and
  `velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
  case where converging asymptotically would leave a residue. While the
  slot moves the ship steers continuously - a snap there was the teleport
  that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
  request, so no sequence of commands can leave a vessel over its own top
  speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.

tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.

test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
Loïc Wisniewski
657ca9d035 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
21 changed files with 1191 additions and 108 deletions

View file

@ -195,7 +195,56 @@ var pending_time: float = 0.0
var context_actions_expanded: bool = false
# Gap between the last resource row and the first row of HUD buttons.
const HUD_COLUMN_MARGIN: float = 18.0
# Seats the top-anchored HUD button columns below the resource readout instead
# of at the fixed y the scene authored.
#
# ResourceReadout builds one row per resource type AT RUNTIME, so how far down
# it reaches is not knowable when the scene is laid out - the scene's guess
# was 154, and four resources actually reach 270, straight over the build
# button sitting at a hardcoded 160. Every resource type added since made it
# worse. Deriving the column top from the readout's real bottom makes the
# overlap impossible rather than merely fixed for today's resource count.
#
# This TRANSLATES the block rather than re-flowing it, so the rows keep their
# spacing relative to one another - BuildingActionButton and HangarButton
# deliberately share a row, being mutually exclusive, and a flow layout would
# pull them apart.
func _seat_hud_columns() -> void:
var readout: ResourceReadout = get_node_or_null("../UI/ResourceReadout")
if readout == null:
return
# Both columns, not just the left one: the readout spans the full width
# (the fleet hold column sits at x 356), so the second column of buttons
# collides with it exactly as the first does.
var buttons: Array = [
build_button, transfer_button,
action_button, landing_button,
building_action_button, hangar_button,
]
var highest: float = INF
for button in buttons:
if button != null:
highest = minf(highest, button.position.y)
if is_inf(highest):
return
var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN - highest
# Only ever pushed down, never pulled up: with few enough resources the
# authored layout already clears the readout, and there is no reason to
# drag the HUD away from where the scene deliberately put it.
if shift <= 0.0:
return
for button in buttons:
if button != null:
button.position.y += shift
func _ready() -> void:
_seat_hud_columns()
build_button.pressed.connect(_on_build_pressed)
build_close_button.pressed.connect(_close_build_menu)
_configure_build_menu_entries()

View file

@ -238,33 +238,25 @@ func stop() -> void:
# since that lands inside arrive_radius immediately).
fleet_target = get_formation_origin()
for ship in ships:
# A ship's own `velocity` is NOT how fast it is really travelling
# while the fleet cruises. Ship snaps to its slot and reports zero
# the moment it is within arrive_radius of it, even though the
# anchor is dragging that slot forward at cruise_speed underneath
# it. Its true motion is its catch-up velocity PLUS the fleet's,
# which it already carries as `fleet_velocity` (zero for a
# point-to-point move_to, where `velocity` alone is the whole
# story).
# A ship's `velocity` is already its true motion, including the
# fleet's travel, because Ship feeds the slot velocity forward into
# its own steering rather than carrying it in a separate field. So
# stopping is only a matter of telling it the slot has halted and
# aiming it at the point its own braking curve reaches.
#
# Braking from `velocity` alone therefore stopped the fleet dead
# on any frame where the ships happened to be seated - and how
# often that happens depends on the frame rate, because the anchor
# advances cruise_speed * delta per frame. At ~25fps it steps far
# enough that ships always lag and always report motion, which is
# the only reason releasing the stick looked right.
var motion: Vector2 = ship.velocity + ship.fleet_velocity
# HAND THE FLEET'S MOTION TO THE SHIP. Until now the anchor was
# carrying it along and its own `velocity` was ~0, so aiming it at a
# braking point ahead made it start from REST: the fleet stopped
# dead, then accelerated into its own stopping distance and halted a
# second time. That is the "pause, then it moves again" stutter.
# Giving it the velocity it was actually travelling at makes the
# stop one continuous decelerating coast.
ship.velocity = motion
# Context that is no longer true: the fleet is not cruising any
# more, so leaving it set would have Ship face a heading it is not
# travelling on.
# This used to do `ship.velocity += ship.fleet_velocity` here, to
# compensate for `velocity` reading zero while the ship was in fact
# cruising (Ship snapped to its slot every tick and zeroed it). That
# compensation was an unbounded ACCUMULATOR: every release added a
# whole cruise velocity, the next steer refilled fleet_velocity, and
# tapping a direction repeatedly stacked them up faster than
# `acceleration` could bleed them off - so a vessel could be flung
# along at many times its own top speed, and ships fell out of sync
# because each bled off at a rate set by its own distance to its
# slot. Ship now bounds its total speed every tick as well, so
# neither half of that can come back.
var motion: Vector2 = ship.velocity
# The fleet is not cruising any more, so the slot is standing still.
ship.fleet_velocity = Vector2.ZERO
var speed: float = motion.length()
if speed <= 0.001:

View file

@ -3,17 +3,142 @@ extends ColorRect
# Feeds the lunar shader the camera's position split into a CELL INDEX plus
# a fraction within that cell, rather than a raw world position. See the
# long note in Starfield.gd for why - identical reasoning, identical split.
#
# Forgejo #68: also bakes the crater field once, at load, instead of letting
# it be re-derived per pixel per frame. The field is a fixed function of
# world position, so every frame was recomputing an identical answer. It is
# rendered once into a square texture covering one period of the field (the
# field is periodic - see the cell wrap in lunar_surface.gdshader) and
# lunar_tiled.gdshader then scrolls that texture with repeat wrapping, which
# keeps the canvas infinite while the per-frame cost drops to one fetch.
# Single source of truth for the tile period, shared with BOTH shaders. The
# field repeats every TILE_CELLS * cell_size world units - at 32 cells of
# 220 that is 7040 units, several screens wide at default zoom.
const TILE_CELLS: float = 32.0
# Texture resolution for the bake, in pixels per cell. One cell is cell_size
# (220) world units, so this is a ~3.4x magnification at default zoom. That
# is affordable because the field is smooth by construction: the sharpest
# feature is the rim band, roughly 0.12 cells wide, which still lands across
# ~8 texels here and so survives bilinear magnification as a gradient rather
# than as steps.
const BAKE_PIXELS_PER_CELL: int = 64
# The palette, sampled from a NASA Artemis lunar photograph. These live on
# the node rather than as parameters of the drawing material because the
# shader that actually consumes them is the BAKE shader, which the scene
# never references directly - leaving them as orphan parameters on the tiled
# material would make them look live when they are only read once at load.
@export var base_color: Color = Color(0.75, 0.75, 0.76)
@export var crater_color: Color = Color(0.44, 0.44, 0.45)
@export var highlight_color: Color = Color(0.89, 0.89, 0.90)
@onready var camera: Camera2D = get_node("../../Camera2D")
func _ready() -> void:
_bake_field()
# Asked of the shader itself rather than by comparing against a known path,
# so this keeps working whichever shader a surface profile swaps in: the
# question is only ever "does what is drawing this background actually
# sample a baked field".
func _material_wants_baked_field() -> bool:
if material == null or material.shader == null:
return false
for uniform in material.shader.get_shader_uniform_list():
if String(uniform.get("name", "")) == "field_texture":
return true
return false
func _process(_delta: float) -> void:
var viewport_size: Vector2 = get_viewport_rect().size
var cell_size: float = material.get_shader_parameter("cell_size")
var p: Vector2 = camera.global_position / cell_size
var cell: Vector2 = Vector2(floor(p.x), floor(p.y))
material.set_shader_parameter("camera_cell", cell)
# The tiled shader samples a repeating texture, so only the camera's
# position WITHIN the tile matters. Wrapping here rather than in the
# shader keeps the raw cell index - which reaches the thousands after a
# while - out of a mobile float entirely: GDScript does this in double
# precision. fposmod, not fmod, so flying into negative coordinates
# wraps to the far side of the tile instead of mirroring.
material.set_shader_parameter("camera_cell", Vector2(
fposmod(cell.x, TILE_CELLS), fposmod(cell.y, TILE_CELLS)))
material.set_shader_parameter("camera_frac", p - cell)
material.set_shader_parameter("camera_zoom", 1.0 / camera.zoom.x)
material.set_shader_parameter("viewport_size", viewport_size)
material.set_shader_parameter("tile_cells", TILE_CELLS)
# Renders exactly one period of the procedural field into a texture and hands
# it to the tiled shader. Runs once, during the planet-arrival fade, where a
# single expensive frame is invisible.
func _bake_field() -> void:
# Headless runs - every scene-based test - rasterize nothing, so a bake
# would allocate a render target, await a draw that never produces
# anything readable, and leave this coroutine suspended forever holding
# the SubViewport alive. Nothing is lost by skipping: there is no display
# for the background to appear on.
if DisplayServer.get_name() == "headless":
return
# One frame of grace before committing to the bake. A surface profile can
# replace this material's shader outright - a gas giant brings its own
# background (#51) - and PlanetSurfaceController does that in _ready(),
# alongside this one, with no ordering guarantee between them. Rendering
# a 2048-square lunar tile for a shader that will never sample it would
# waste exactly the frame the player is already waiting on to land.
await get_tree().process_frame
if not _material_wants_baked_field():
return
var procedural: Shader = load("res://lunar_surface.gdshader")
if procedural == null:
push_warning("lunar background: could not load the field shader to bake")
return
var side: int = int(TILE_CELLS) * BAKE_PIXELS_PER_CELL
var bake_material := ShaderMaterial.new()
bake_material.shader = procedural
# Carry the look across from whatever the scene configured, so the tile
# is baked with the colours actually set on this node rather than the
# shader's own defaults.
bake_material.set_shader_parameter("base_color", base_color)
bake_material.set_shader_parameter("crater_color", crater_color)
bake_material.set_shader_parameter("highlight_color", highlight_color)
bake_material.set_shader_parameter("cell_size", material.get_shader_parameter("cell_size"))
bake_material.set_shader_parameter("tile_cells", TILE_CELLS)
# Non-zero switches the field shader into bake mode: UV maps straight
# onto one tile and the per-screen-pixel dither is left out.
bake_material.set_shader_parameter("bake_cells", TILE_CELLS)
var sub_viewport := SubViewport.new()
sub_viewport.size = Vector2i(side, side)
sub_viewport.disable_3d = true
sub_viewport.transparent_bg = false
sub_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
var bake_rect := ColorRect.new()
bake_rect.material = bake_material
bake_rect.size = Vector2(side, side)
sub_viewport.add_child(bake_rect)
add_child(sub_viewport)
# One frame for the SubViewport to actually draw, then read it back.
await RenderingServer.frame_post_draw
var image: Image = sub_viewport.get_texture().get_image()
sub_viewport.queue_free()
# Headless runs (every scene-based test) render nothing at all, so there
# is no image to read back. That is not a failure: the background simply
# has nothing to show, and the rest of the scene is unaffected.
if image == null or image.is_empty():
return
# Mipmaps are what keep a crater field from shimmering when the camera
# zooms out and each screen pixel starts covering many texels.
image.generate_mipmaps()
var texture := ImageTexture.create_from_image(image)
material.set_shader_parameter("field_texture", texture)

View file

@ -2,7 +2,7 @@
[ext_resource type="Script" path="res://GameController.gd" id="1"]
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
[ext_resource type="Shader" path="res://lunar_surface.gdshader" id="3"]
[ext_resource type="Shader" path="res://lunar_tiled.gdshader" id="3"]
[ext_resource type="Script" path="res://Fleet.gd" id="4"]
[ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"]
[ext_resource type="Script" path="res://IconButton.gd" id="6"]
@ -15,13 +15,12 @@
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("3")
shader_parameter/camera_pos = Vector2(0, 0)
shader_parameter/camera_cell = Vector2(0, 0)
shader_parameter/camera_frac = Vector2(0, 0)
shader_parameter/camera_zoom = 1.0
shader_parameter/viewport_size = Vector2(720, 1280)
shader_parameter/cell_size = 220.0
shader_parameter/base_color = Color(0.75, 0.75, 0.76, 1)
shader_parameter/crater_color = Color(0.44, 0.44, 0.45, 1)
shader_parameter/highlight_color = Color(0.89, 0.89, 0.9, 1)
shader_parameter/tile_cells = 32.0
[node name="PlanetSurface" type="Node2D"]
script = ExtResource("1")
@ -97,12 +96,16 @@ theme_override_constants/outline_size = 4
text = "FLEET HOLD"
[node name="PlanetNameLabel" type="Label" parent="UI"]
offset_left = 24
offset_top = 156
mouse_filter = 2
offset_left = 400
offset_top = 40
offset_right = 696
offset_bottom = 282
offset_bottom = 148
text = ""
horizontal_alignment = 1
horizontal_alignment = 2
vertical_alignment = 0
autowrap_mode = 2
theme_override_font_sizes/font_size = 30
theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1)
theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85)
theme_override_constants/outline_size = 5

View file

@ -61,6 +61,26 @@ func _ready() -> void:
y += ROW_HEIGHT
# Stop understating our own extent. The rows are drawn by child nodes,
# which happily paint outside this Control's rect, so the scene-authored
# height stayed at its original guess no matter how many rows appeared.
size.y = rows_height()
# How tall the rows are, derived from the live resource list rather than from
# whatever _ready() happened to have built. That matters: _ready() runs in
# scene-tree order, so a sibling laying itself out relative to this readout
# (BuildController._seat_hud_columns) can legitimately ask BEFORE this node
# has built anything, and a cached height would answer zero.
func rows_height() -> float:
return float(GameState.resource_types().size()) * ROW_HEIGHT
# Bottom edge of the rows in this Control's parent space - the line anything
# stacked below the readout has to clear.
func content_bottom() -> float:
return position.y + rows_height()
func _add_glyph(resource_type: String, at: Vector2) -> void:
var glyph: ResourceGlyph = ResourceGlyph.new()

49
Ship.gd
View file

@ -13,7 +13,15 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
var definition: Resource
var glow_color: Color = Color(0.25, 0.85, 1.0)
var core_color: Color = Color.WHITE
# The ship's TRUE world velocity - what `position` is actually integrated
# from, and the single source of truth for how fast this vessel is moving.
# Nothing else may contribute motion; see the note in _physics_process.
var velocity: Vector2 = Vector2.ZERO
# The velocity of this ship's formation SLOT (the fleet anchor's own travel).
# Fed forward into the steering below rather than added on top of it: matching
# the slot's speed is most of the job of holding formation, so supplying it
# directly leaves `velocity` free to be a small correction and lets the ship
# sit in its slot with no steady-state lag and no teleporting.
var fleet_velocity: Vector2 = Vector2.ZERO
var move_target: Vector2
@ -109,22 +117,47 @@ func _physics_process(delta: float) -> void:
# "snaps onto its slot" formation tightness at any boost level that
# 1.0x already has.
var boost: float = GameState.fleet_speed_multiplier
var effective_acceleration: float = acceleration * boost
var to_target: Vector2 = move_target - position
var distance: float = to_target.length()
if distance <= arrive_radius * boost:
# Snap only when the slot is STANDING STILL. A stationary target is the
# one case where converging asymptotically would leave a permanent
# sub-pixel residue, and settling exactly is what "the fleet has arrived"
# means everywhere else in the game. While the slot is moving the ship
# steers continuously instead - a snap there would be a teleport, which
# is what used to make `velocity` read zero for a ship that was in fact
# travelling at cruise speed, and that lie is what the whole
# stop()/fleet_velocity bookkeeping tangle existed to paper over.
if fleet_velocity == Vector2.ZERO and distance <= arrive_radius * boost:
position = move_target
velocity = Vector2.ZERO
else:
var effective_acceleration: float = acceleration * boost
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
var desired_speed: float = min(max_speed * boost, braking_speed)
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
# Feed-forward plus correction. The ship aims to travel at its slot's
# velocity, plus whatever extra closes the gap to the slot - so
# holding formation costs no error, and `velocity` stays the ship's
# real motion rather than a residual that has to be added to
# something else to mean anything.
var correction: Vector2 = Vector2.ZERO
if distance > 0.001:
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
correction = to_target.normalized() * min(max_speed * boost, braking_speed)
var desired_velocity: Vector2 = fleet_velocity + correction
# HARD BOUND on how fast any single vessel may travel, applied to the
# total and re-applied every tick. Feed-forward plus correction can
# ask for more than a vessel can actually fly, and without this the
# excess was free speed: a ship that had been handed a large velocity
# kept it, decaying only at `acceleration`. Clamping the REQUEST is
# not enough - the clamp has to sit on the integrated result, so no
# sequence of commands can leave a vessel travelling faster than its
# own top speed.
desired_velocity = desired_velocity.limit_length(max_speed * boost)
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
velocity = velocity.limit_length(max_speed * boost)
position += velocity * delta
var motion: Vector2 = velocity + fleet_velocity
if motion.length() > 8.0:
var target_angle: float = motion.angle() + PI / 2.0
if velocity.length() > 8.0:
var target_angle: float = velocity.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)

View file

@ -25,6 +25,14 @@ shader_type canvas_item;
// a thin bright rim wedge through the middle of the merged basin. The
// second-nearest distance is tracked purely to detect that case and fade
// the rim out there - see the note further down.
//
// This runs on EVERY pixel of a fullscreen rect on a phone, so it is the
// single most expensive thing in the surface scene and the subject of
// Forgejo #68 ("the moon game is a bit slow"). The three optimizations
// that brought it down - a bounded 3x3 scan, one hash per cell instead of
// three, and one sqrt per pixel instead of one per sample - are each
// justified at the point they appear below. Time it with
// bash tests/profile_shader.sh res://lunar_surface.gdshader
uniform vec2 camera_cell = vec2(0.0, 0.0);
uniform vec2 camera_frac = vec2(0.0, 0.0);
@ -46,33 +54,62 @@ uniform vec4 highlight_color : source_color = vec4(0.89, 0.89, 0.90, 1.0);
// cuts appear in this grayscale output the FIELD is at fault, and if
// it is smooth the field is fine and the SHADING is at fault.
uniform float debug_mode = 0.0;
// Width of the repeating tile, in cells. The crater field is made PERIODIC
// with this period (see the wrap in the loop), which is what lets
// LunarBackground.gd bake one square of it into a texture and then scroll
// that texture forever with repeat wrapping, instead of re-deriving the
// field per pixel per frame. Set from LunarBackground.TILE_CELLS, which is
// the single source of truth shared with lunar_tiled.gdshader.
uniform float tile_cells = 32.0;
// BAKE MODE. 0 = normal camera-relative sampling for direct display.
// Above zero, UV is mapped straight onto [0, tile_cells] instead, so
// rendering this shader to a square SubViewport produces exactly one period
// of the field - the texture the runtime shader then samples.
uniform float bake_cells = 0.0;
const vec2 LIGHT_DIR = vec2(-0.6, -0.8);
const float MIN_RADIUS = 0.16;
// MUST stay below 1.0. The 3x3 scan below is only correct because a crater
// cannot reach out of its own cell's immediate neighbourhood; see the long
// note at the loop. tests/test_lunar_field.gd asserts this bound directly
// against the shader source so it cannot drift unnoticed.
const float MAX_RADIUS = 0.38;
// Sentinel for "this cell holds no crater". Must stay well inside mediump
// range (max ~65504) so it can never become +Inf - see the note in the
// loop. Only needs to exceed the largest real normalized distance (~18).
const float MISS_DISTANCE = 1000.0;
// Sentinel for "this cell holds no crater", in SQUARED normalized units to
// match the loop. Must stay well inside mediump range (max ~65504) so it
// can never become +Inf - see the note in the loop. Only needs to exceed
// the largest real squared normalized distance, which for a 3x3 scan is
// (2.83 cells / 0.16 min radius)^2 ~= 313.
const float MISS_DISTANCE_SQ = 10000.0;
// Loop-carried initial value. Above any real value INCLUDING one carrying
// the miss sentinel (~10313), so a pixel whose whole neighbourhood is
// empty still leaves the "winning" offset populated with something finite.
const float START_DISTANCE_SQ = 20000.0;
// One hash call yields all four values a cell needs (crater centre x/y,
// whether the cell holds a crater at all, and its radius). The previous
// version called two different hashes three times per cell with different
// constant offsets, which was three quarters of the loop body's arithmetic
// for no extra information - a single hash with four independent output
// lanes decorrelates just as well.
//
// Multipliers chosen for precision safety on mobile GPUs: they are all
// < 1, so every intermediate stays small no matter how large the cell
// index gets. An earlier version multiplied by ~123/456 first, which lands
// the intermediate in the thousands where a mobile float loses its
// fractional bits entirely. (Standard "hash without sine" formulation,
// Dave Hoskins.)
vec4 hash42(vec2 p) {
vec4 p4 = fract(vec4(p.xyxy) * vec4(0.1031, 0.1030, 0.0973, 0.1099));
p4 += dot(p4, p4.wzxy + 33.33);
return fract((p4.xxyz + p4.yzzw) * p4.zywx);
}
// Hash pair chosen for precision safety on mobile GPUs. The previous
// version multiplied the cell coordinate by ~123/456 before fract(), which
// lands the intermediate in the thousands where a mobile float loses its
// fractional bits entirely. These multipliers are all < 1, so every
// intermediate stays small no matter how large the cell index gets.
// (Standard "hash without sine" formulation, Dave Hoskins.)
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
vec2 hash22(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy);
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
@ -92,11 +129,18 @@ void fragment() {
// calling floor(x) and fract(x) separately lets the GPU round the two
// evaluations differently, which right at a cell boundary makes them
// disagree by a full cell and snaps the sampled point one cell sideways.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
//
// In bake mode none of that applies: the target is one tile of the field
// in isolation, so UV maps directly onto [0, bake_cells] and there is no
// camera in the picture at all.
bool baking = bake_cells > 0.0;
vec2 rel = baking
? UV * bake_cells
: camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
// Absolute cell index, used only for hashing. Built from exact integers.
vec2 cell = camera_cell + cell_rel;
vec2 cell = baking ? cell_rel : camera_cell + cell_rel;
// Single scalar field: normalized distance to the nearest crater.
// < 1.0 means this pixel is inside that crater; 1.0 is exactly on its
@ -110,22 +154,49 @@ void fragment() {
// through the middle of what should read as one merged depression.
// A pixel in that region is inside BOTH craters, i.e. second_nearest is
// also < 1.0 - which is precisely the condition to fade the rim out.
float nearest = MISS_DISTANCE;
float second_nearest = MISS_DISTANCE;
vec2 nearest_dir = vec2(0.0, 1.0);
//
// Tracked SQUARED, and square-rooted once at the end. Ordering is
// unchanged by squaring (both sides are non-negative), so the same
// crater wins - but this turns one sqrt per sample into two per pixel,
// and sqrt is one of the more expensive things a fragment shader can
// ask for.
float nearest_sq = START_DISTANCE_SQ;
float second_nearest_sq = START_DISTANCE_SQ;
vec2 nearest_offset = vec2(0.0, 1.0);
// 5x5 rather than 3x3. A crater's radius is at most MAX_RADIUS cells,
// so in theory 3x3 covers every crater that could possibly reach this
// pixel - but on-device, craters were still being clipped exactly at
// cell boundaries, which means some crater's influence was reaching
// further than the scan did (e.g. a hash returning a value outside its
// nominal range on that GPU, inflating a radius). Scanning two cells
// out costs more samples but makes the clip impossible to reach even
// if a radius comes out several times larger than intended.
for (int dx = -2; dx <= 2; dx++) {
for (int dy = -2; dy <= 2; dy++) {
// 3x3 is provably sufficient, and provably so only because of the two
// clamps in the loop body. A crater's centre is clamped into its own
// cell and its radius is clamped to at most MAX_RADIUS (0.38) cells, so
// the furthest a crater can reach past its own cell is 0.38 cells. This
// pixel's `local` lies in [0,1], so a crater two cells out is at least
// 1.0 cells away - a normalized distance of at least 1/0.38 = 2.6,
// which is past every threshold used below (the floor fades out by 0.9,
// the rim band by 1.0, and the overlap suppression saturates at 1.15).
// Such a crater therefore cannot change a single pixel of the output.
//
// This replaces an earlier 5x5 scan. That width was not chosen from the
// geometry - 3x3 was already the geometric answer - but as insurance
// against craters that were observed clipping at cell boundaries
// on-device, which meant some crater's influence was reaching further
// than its nominal radius allowed, e.g. a hash returning a value outside
// [0,1] on that GPU and extrapolating mix() into a huge radius. Clamping
// both the radius AND the centre fixes that at the source and makes the
// bound above hold no matter what the hash returns, so paying for 25
// samples per pixel to hide a driver quirk is no longer necessary.
// tests/test_lunar_field.gd checks the two scans agree exactly.
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
vec2 offset = vec2(float(dx), float(dy));
highp vec2 neighbor_cell = cell + offset;
// Wrapping the cell index before hashing is the entire trick that
// makes the field tileable: cell N and cell N + tile_cells hash
// identically, so the pattern repeats exactly with that period.
// It works on a Worley field specifically because the scan only
// ever reads immediate neighbours - wrap those consistently and
// the seam is not merely hidden, it does not exist. GLSL mod()
// returns a non-negative result for a positive divisor, so
// negative cell indices wrap correctly too.
highp vec2 neighbor_cell = mod(cell + offset, tile_cells);
vec4 h = hash42(neighbor_cell);
// Cells without a crater are pushed far away rather than
// branched around, keeping the loop body uniform. NOTE the
@ -134,32 +205,45 @@ void fragment() {
// becomes +Inf there, and (1.0 - has_crater) * Inf evaluates to
// 0 * Inf = NaN for every cell that DOES hold a crater - which
// compares false against everything and would silently discard
// every crater. MISS_DISTANCE only has to beat the largest real
// normalized distance (~2.9 cells / 0.16 min radius ~= 18).
float has_crater = step(0.35, hash21(neighbor_cell + vec2(41.0, 5.0)));
vec2 crater_center = offset + hash22(neighbor_cell);
// every crater.
float has_crater = step(0.35, h.z);
// clamp() on both of these is what bounds a crater's reach to
// its own cell plus MAX_RADIUS, which is what lets the scan
// above be 3x3. Without them a driver returning a hash outside
// [0,1] could place a centre, or inflate a radius, far enough to
// reach past the scan - which is exactly the clipping that was
// seen on-device.
vec2 crater_center = offset + clamp(h.xy, 0.0, 1.0);
float radius = clamp(mix(MIN_RADIUS, MAX_RADIUS, h.w), MIN_RADIUS, MAX_RADIUS);
vec2 to_sample = local - crater_center;
float dist = length(to_sample);
// clamp() guards against a hash landing outside [0,1] on some
// driver and extrapolating mix() into a huge radius.
float radius = clamp(mix(MIN_RADIUS, MAX_RADIUS, hash21(neighbor_cell + vec2(9.0, 2.0))), MIN_RADIUS, MAX_RADIUS);
float normalized_sq = dot(to_sample, to_sample) / (radius * radius)
+ (1.0 - has_crater) * MISS_DISTANCE_SQ;
float normalized = dist / radius + (1.0 - has_crater) * MISS_DISTANCE;
if (normalized < nearest) {
second_nearest = nearest;
nearest = normalized;
// Direction from the winning crater's centre to this pixel,
// used for the rim's light/shadow split. Taken from the SAME
// crater that won the distance test, so the shading can never
// describe one crater while the floor describes another.
nearest_dir = dist > 0.0001 ? to_sample / dist : vec2(0.0, 1.0);
} else if (normalized < second_nearest) {
second_nearest = normalized;
if (normalized_sq < nearest_sq) {
second_nearest_sq = nearest_sq;
nearest_sq = normalized_sq;
// Offset from the winning crater's centre to this pixel,
// kept so the rim's light/shadow split can be derived from
// the SAME crater that won the distance test - the shading
// can then never describe one crater while the floor
// describes another.
nearest_offset = to_sample;
} else if (normalized_sq < second_nearest_sq) {
second_nearest_sq = normalized_sq;
}
}
}
float nearest = sqrt(nearest_sq);
float second_nearest = sqrt(second_nearest_sq);
// normalize() would divide by zero for a pixel sitting exactly on a
// crater centre; the guard keeps that pixel's rim term merely arbitrary
// rather than NaN. It is deep inside the floor, where the rim band is
// zero anyway.
float offset_length = length(nearest_offset);
vec2 nearest_dir = offset_length > 0.0001 ? nearest_offset / offset_length : vec2(0.0, 1.0);
vec2 light_dir = normalize(LIGHT_DIR);
float facing = dot(nearest_dir, light_dir);
@ -188,8 +272,14 @@ void fragment() {
// can visibly "step" into faint bands on real hardware. A small random
// jitter well under one visible step breaks the stepping up without
// being perceptible as noise.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
// Skipped when baking: dither is a per-SCREEN-pixel jitter, and baking it
// would freeze the noise into the texture, where magnification smears it
// into visible blotches and tiling repeats it. lunar_tiled.gdshader
// applies the identical term live instead, which is where it belongs.
if (!baking) {
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
}
// Diagnostic checkerboard: alternate cells tinted, plus a bright line
// right at each cell edge. Makes the grid unmistakable in a screenshot.

79
lunar_tiled.gdshader Normal file
View file

@ -0,0 +1,79 @@
shader_type canvas_item;
// The lunar backdrop as it is actually drawn every frame: one texture fetch
// plus a dither, and nothing else.
//
// Forgejo #68. lunar_surface.gdshader derives the crater field procedurally,
// which costs a 9-sample Worley scan on every pixel of a fullscreen rect,
// every frame, forever - and it recomputes the exact same answer each time,
// because the field is a fixed function of world position. So it is
// evaluated ONCE, into a square texture covering one period of the field
// (LunarBackground.gd does the bake at scene load), and this shader scrolls
// that texture with repeat wrapping. The canvas stays infinite; the per-
// frame cost stops scaling with how complicated the field is.
//
// Two things pay for that, both stated plainly because they are real:
//
// 1. The field REPEATS, every tile_cells * cell_size world units. It is
// seamless (lunar_surface.gdshader wraps the cell index before hashing,
// so the periodicity is exact rather than blended), but a player who
// flies far enough passes the same crater arrangement again. Lunar
// regolith is about the most forgiving possible content for that: no
// landmarks, no horizon, no orientation cues, nothing to anchor a
// memory of "I have seen this crater before" to.
// 2. The texture has a fixed resolution, so zooming in past its native
// density softens the craters rather than resolving finer ones. The
// field is smooth gradients by construction - the sharpest thing in it
// is the rim band, and that still spans several texels - so bilinear
// magnification reproduces it closely instead of showing blocks.
//
// Mipmaps handle the other direction (zooming out), which is where a
// high-frequency crater field would otherwise alias into shimmer.
uniform sampler2D field_texture : filter_linear_mipmap, repeat_enable;
uniform vec2 camera_cell = vec2(0.0, 0.0);
uniform vec2 camera_frac = vec2(0.0, 0.0);
uniform float camera_zoom = 1.0;
uniform vec2 viewport_size = vec2(720.0, 1280.0);
uniform float cell_size = 220.0;
uniform float tile_cells = 32.0;
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
// Same camera-relative construction as the procedural shader, and for
// the same reason: an absolute world position reaches the tens of
// thousands, where a mobile float has lost the fractional bits this
// needs. camera_cell arrives ALREADY WRAPPED into [0, tile_cells) -
// LunarBackground.gd does that wrap in GDScript's double precision, so
// the large number never enters the shader at all.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 uv = (camera_cell + rel) / tile_cells;
// repeat_enable does the wrapping, so uv running outside [0,1] is not
// merely tolerated, it is the mechanism.
vec3 color = texture(field_texture, uv).rgb;
// Tiny per-pixel dither to mask 8-bit colour-quantization banding: the
// crater shading is a smooth gradient over a fairly large area, which
// can visibly "step" into faint bands on real hardware. A small random
// jitter well under one visible step breaks the stepping up without
// being perceptible as noise. Doubly needed here - the baked texture is
// itself 8-bit, so it quantizes the gradient a second time.
//
// Deliberately keyed to the SCREEN pixel, not the world position: it has
// to stay fixed to the display or it turns into crawling noise as the
// camera moves, and it must not be baked into the tile or it would
// repeat along with everything else.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
COLOR = vec4(color, 1.0);
}

1
lunar_tiled.gdshader.uid Normal file
View file

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

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

View file

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

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'
"

View file

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

View file

@ -0,0 +1,196 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_fleet_speed_bounded.gd
# A reported movement bug: rotating the fleet and spamming a direction let
# vessels fall out of sync and fly at wildly high speeds - "speed of an
# individual ship is not bounded".
#
# The cause was in Fleet.stop(), which compensated for Ship.velocity reading
# zero during cruise by doing `ship.velocity += ship.fleet_velocity`. That is
# an accumulator: each release added a whole cruise velocity, the next steer
# refilled fleet_velocity, and tapping faster than `acceleration` could bleed
# it off stacked them without limit. Ships desynced because each bled off at
# a rate set by its own distance to its slot.
#
# Covers:
# A. no vessel ever exceeds its own top speed, however hard the direction
# is spammed - the property that was actually violated
# B. spamming cannot outrun an honestly-held stick: the fleet covers no
# more ground than cruising for the same time would
# C. the same holds while the formation is being pivoted, which is the
# case the report named
# D. ships stay in sync with each other rather than fanning out
var game_state: Node
var main: Node
var fleet: Node
var stage: int = 0
var elapsed: float = 0.0
var fastest_seen: float = 0.0
var worst_spread: float = 0.0
var start_centroid: Vector2 = Vector2.ZERO
var spam_toggle: bool = false
var pivot_step: int = 0
var honest_distance: float = 0.0
const SPAM_TIME: float = 3.0
# Every ship in the test fleet is an interceptor, so one limit covers them
# all. Two percent of slack absorbs the single-tick overshoot inherent in
# integrating at a finite step; the bug being caught overshot by multiples,
# not by percents.
const SPEED_TOLERANCE: float = 1.02
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.stored_vessels.clear()
game_state.reset_progression()
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
fleet.spawn_ships(4, Vector2.ZERO)
# --- reference: what holding the stick honestly achieves --------
start_centroid = fleet.get_centroid()
fleet.move_in_direction(Vector2(1.0, 0.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < SPAM_TIME:
_watch_speeds()
return false
honest_distance = fleet.get_centroid().distance_to(start_centroid)
print("held steady for %.1fs: travelled %.1f, fastest vessel %.1f" \
% [SPAM_TIME, honest_distance, fastest_seen])
if not _check_speed_bound("while simply holding a direction"):
return true
fleet.stop()
fastest_seen = 0.0
elapsed = 0.0
stage = 2
return false
2:
# Let the coast-to-rest finish so the spam leg starts from the
# same standing start the honest leg did.
if elapsed < 1.0:
return false
start_centroid = fleet.get_centroid()
elapsed = 0.0
stage = 3
return false
3:
# --- A + B. tap the same direction as fast as frames allow ------
spam_toggle = not spam_toggle
if spam_toggle:
fleet.move_in_direction(Vector2(1.0, 0.0))
else:
fleet.stop()
_watch_speeds()
if elapsed < SPAM_TIME:
return false
var spam_distance: float = fleet.get_centroid().distance_to(start_centroid)
print("spammed for %.1fs: travelled %.1f, fastest vessel %.1f" \
% [SPAM_TIME, spam_distance, fastest_seen])
if not _check_speed_bound("while spamming a direction"):
return true
# Tapping releases the stick half the time, so it should cover
# LESS ground than holding it - certainly not more.
if spam_distance > honest_distance * 1.1:
return _fail_now("spamming a direction covered %.1f against %.1f for simply holding it - tapping is faster than flying" \
% [spam_distance, honest_distance])
fleet.stop()
fastest_seen = 0.0
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 1.0:
return false
start_centroid = fleet.get_centroid()
elapsed = 0.0
stage = 5
return false
5:
# --- C + D. spam the direction WHILE pivoting the formation -----
# The reported case. A pivot re-seats every slot at once, so this
# is where ships are most able to fall out of step with one
# another.
spam_toggle = not spam_toggle
if spam_toggle:
fleet.move_in_direction(Vector2(1.0, 0.0))
else:
fleet.stop()
pivot_step += 1
_pivot_formation(PI / 2.0 if pivot_step % 2 == 0 else -PI / 2.0)
_watch_speeds()
if elapsed < SPAM_TIME:
return false
var pivot_distance: float = fleet.get_centroid().distance_to(start_centroid)
print("spammed with pivots for %.1fs: travelled %.1f, fastest vessel %.1f, worst pair spread %.1f" \
% [SPAM_TIME, pivot_distance, fastest_seen, worst_spread])
if not _check_speed_bound("while spamming a direction and pivoting"):
return true
if pivot_distance > honest_distance * 1.1:
return _fail_now("spamming with pivots covered %.1f against %.1f for simply holding a direction" \
% [pivot_distance, honest_distance])
# Ships must remain a fleet. The formation is a wedge a couple of
# hundred units across; a desync flings one vessel far beyond it.
if worst_spread > 900.0:
return _fail_now("vessels fell %.1f apart - the fleet came apart rather than moving together" % worst_spread)
print("no vessel outran its own top speed, and none broke formation")
print("TEST_FLEET_SPEED_BOUNDED: PASS")
quit(0)
return true
return false
func _watch_speeds() -> void:
var boost: float = game_state.fleet_speed_multiplier
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
fastest_seen = maxf(fastest_seen, ship.velocity.length() / (ship.max_speed * boost))
for other in fleet.ships:
if is_instance_valid(other):
worst_spread = maxf(worst_spread, ship.position.distance_to(other.position))
# fastest_seen is carried as a RATIO of each vessel's own limit, so a mixed
# fleet with different top speeds would be judged per vessel rather than
# against a single global number.
func _check_speed_bound(context: String) -> bool:
if fastest_seen > SPEED_TOLERANCE:
_fail_now("a vessel reached %.2fx its own top speed %s - individual speed is not bounded" \
% [fastest_seen, context])
return false
return true
func _pivot_formation(angle: float) -> void:
var rotated: Array = []
for offset in game_state.formation:
rotated.append(Vector2(offset).rotated(angle))
game_state.formation = rotated
fleet.apply_formation()
func _fail_now(message: String) -> bool:
print("TEST_FLEET_SPEED_BOUNDED: FAIL (%s)" % message)
quit(1)
return true

View file

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

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

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

View file

@ -58,6 +58,17 @@ func _process(delta: float) -> bool:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
# Silencing the spawners is not enough: whatever Main.tscn already
# placed keeps moving under its own _physics_process, and this test
# flies the fleet several thousand units at up to 3x boost. Losing
# the distorder ship to a rock mid-run resets the multiplier -
# CORRECTLY, that is what _sync_composition_state is for - and the
# test then failed three stages later claiming the multiplier had
# not survived a scene swap. Same clean-slate idiom as
# test_ship_lab.gd.
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
if is_instance_valid(hazard):
hazard.queue_free()
# --- A. absent, then appears -------------------------------------
if fader.visible:
@ -192,6 +203,13 @@ func _process(delta: float) -> bool:
# scenario here since this fleet keeps its distorder ship the
# whole way across the swap, via GameState.surviving_vessel_types
# exactly like every other vessel type).
# Check the premise before relying on it. Everything below is about
# the multiplier surviving a scene swap, which is only meaningful
# while the fleet still crews a distorder vessel - so if the fleet
# lost one on the way here, say THAT rather than reporting a reset
# multiplier as if the swap were at fault.
if not fleet.has_capability("distorder"):
return _fail_now("the fleet lost its distorder vessel before the scene swap, so the multiplier reset is expected rather than a bug")
game_state.surviving_ship_count = fleet.ships.size()
game_state.surviving_vessel_types = fleet.vessel_types()
game_state.planet_id = "test_space_time_distorder"

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