diff --git a/BuildController.gd b/BuildController.gd index 6a45e1e..c9bc7d0 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -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() diff --git a/Fleet.gd b/Fleet.gd index ef17702..dae7f48 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -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: diff --git a/LunarBackground.gd b/LunarBackground.gd index af4f9d8..8b85533 100644 --- a/LunarBackground.gd +++ b/LunarBackground.gd @@ -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) diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 56e8577..199cdf8 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -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 diff --git a/ResourceReadout.gd b/ResourceReadout.gd index c88433f..a2f6d97 100644 --- a/ResourceReadout.gd +++ b/ResourceReadout.gd @@ -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() diff --git a/Ship.gd b/Ship.gd index 074c6b4..08baa0c 100644 --- a/Ship.gd +++ b/Ship.gd @@ -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) diff --git a/lunar_surface.gdshader b/lunar_surface.gdshader index a1158ee..9f42999 100644 --- a/lunar_surface.gdshader +++ b/lunar_surface.gdshader @@ -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. diff --git a/lunar_tiled.gdshader b/lunar_tiled.gdshader new file mode 100644 index 0000000..1574c85 --- /dev/null +++ b/lunar_tiled.gdshader @@ -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); +} diff --git a/lunar_tiled.gdshader.uid b/lunar_tiled.gdshader.uid new file mode 100644 index 0000000..03c7ea0 --- /dev/null +++ b/lunar_tiled.gdshader.uid @@ -0,0 +1 @@ +uid://dmmdpa3pmr8g diff --git a/tests/_profile_surface_shader.gd b/tests/_profile_surface_shader.gd new file mode 100644 index 0000000..15a7868 --- /dev/null +++ b/tests/_profile_surface_shader.gd @@ -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 `--`): [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: -- [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 diff --git a/tests/_profile_surface_shader.gd.uid b/tests/_profile_surface_shader.gd.uid new file mode 100644 index 0000000..73e9418 --- /dev/null +++ b/tests/_profile_surface_shader.gd.uid @@ -0,0 +1 @@ +uid://dgg7cnpiq2y58 diff --git a/tests/_setup_editor_load_panel_preview.gd.uid b/tests/_setup_editor_load_panel_preview.gd.uid new file mode 100644 index 0000000..f81e4d7 --- /dev/null +++ b/tests/_setup_editor_load_panel_preview.gd.uid @@ -0,0 +1 @@ +uid://ckltesr471lqy diff --git a/tests/profile_shader.sh b/tests/profile_shader.sh new file mode 100644 index 0000000..3a57e52 --- /dev/null +++ b/tests/profile_shader.sh @@ -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 [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 [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' +" diff --git a/tests/test_enemy_detection_range.gd.uid b/tests/test_enemy_detection_range.gd.uid new file mode 100644 index 0000000..2500c16 --- /dev/null +++ b/tests/test_enemy_detection_range.gd.uid @@ -0,0 +1 @@ +uid://dekjmrvv0baqy diff --git a/tests/test_fleet_speed_bounded.gd b/tests/test_fleet_speed_bounded.gd new file mode 100644 index 0000000..3b69888 --- /dev/null +++ b/tests/test_fleet_speed_bounded.gd @@ -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 diff --git a/tests/test_fleet_speed_bounded.gd.uid b/tests/test_fleet_speed_bounded.gd.uid new file mode 100644 index 0000000..43f0546 --- /dev/null +++ b/tests/test_fleet_speed_bounded.gd.uid @@ -0,0 +1 @@ +uid://bkcs1gl5h616f diff --git a/tests/test_lunar_field.gd b/tests/test_lunar_field.gd new file mode 100644 index 0000000..a6c2f03 --- /dev/null +++ b/tests/test_lunar_field.gd @@ -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 diff --git a/tests/test_lunar_field.gd.uid b/tests/test_lunar_field.gd.uid new file mode 100644 index 0000000..2570d17 --- /dev/null +++ b/tests/test_lunar_field.gd.uid @@ -0,0 +1 @@ +uid://b0bjyh6fn2v34 diff --git a/tests/test_research_lab_extractor_gate.gd.uid b/tests/test_research_lab_extractor_gate.gd.uid new file mode 100644 index 0000000..6b5f117 --- /dev/null +++ b/tests/test_research_lab_extractor_gate.gd.uid @@ -0,0 +1 @@ +uid://1ynm1v0c1qfk diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd index 059321c..ca8d4cd 100644 --- a/tests/test_space_time_distorder.gd +++ b/tests/test_space_time_distorder.gd @@ -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" diff --git a/tests/test_surface_profiles.gd b/tests/test_surface_profiles.gd index a9acc62..e310f54 100644 --- a/tests/test_surface_profiles.gd +++ b/tests/test_surface_profiles.gd @@ -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