diff --git a/BuildController.gd b/BuildController.gd index c9bc7d0..6a45e1e 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -195,56 +195,7 @@ 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 dae7f48..ef17702 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -238,25 +238,33 @@ func stop() -> void: # since that lands inside arrive_radius immediately). fleet_target = get_formation_origin() for ship in ships: - # 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. + # 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). # - # 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. + # 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. ship.fleet_velocity = Vector2.ZERO var speed: float = motion.length() if speed <= 0.001: diff --git a/LunarBackground.gd b/LunarBackground.gd index 8b85533..af4f9d8 100644 --- a/LunarBackground.gd +++ b/LunarBackground.gd @@ -3,142 +3,17 @@ 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)) - # 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_cell", cell) 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 199cdf8..56e8577 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_tiled.gdshader" id="3"] +[ext_resource type="Shader" path="res://lunar_surface.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,12 +15,13 @@ [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("3") -shader_parameter/camera_cell = Vector2(0, 0) -shader_parameter/camera_frac = Vector2(0, 0) +shader_parameter/camera_pos = Vector2(0, 0) shader_parameter/camera_zoom = 1.0 shader_parameter/viewport_size = Vector2(720, 1280) shader_parameter/cell_size = 220.0 -shader_parameter/tile_cells = 32.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) [node name="PlanetSurface" type="Node2D"] script = ExtResource("1") @@ -96,16 +97,12 @@ theme_override_constants/outline_size = 4 text = "FLEET HOLD" [node name="PlanetNameLabel" type="Label" parent="UI"] -mouse_filter = 2 -offset_left = 400 -offset_top = 40 +offset_left = 24 +offset_top = 156 offset_right = 696 -offset_bottom = 148 +offset_bottom = 282 text = "" -horizontal_alignment = 2 -vertical_alignment = 0 -autowrap_mode = 2 -theme_override_font_sizes/font_size = 30 +horizontal_alignment = 1 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 a2f6d97..c88433f 100644 --- a/ResourceReadout.gd +++ b/ResourceReadout.gd @@ -61,26 +61,6 @@ 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 08baa0c..074c6b4 100644 --- a/Ship.gd +++ b/Ship.gd @@ -13,15 +13,7 @@ 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 @@ -117,47 +109,22 @@ 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() - - # 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: + if distance <= arrive_radius * boost: position = move_target velocity = Vector2.ZERO else: - # 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) + 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 velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta) - velocity = velocity.limit_length(max_speed * boost) position += velocity * delta - if velocity.length() > 8.0: - var target_angle: float = velocity.angle() + PI / 2.0 + var motion: Vector2 = velocity + fleet_velocity + if motion.length() > 8.0: + var target_angle: float = motion.angle() + PI / 2.0 rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) diff --git a/lunar_surface.gdshader b/lunar_surface.gdshader index 9f42999..a1158ee 100644 --- a/lunar_surface.gdshader +++ b/lunar_surface.gdshader @@ -25,14 +25,6 @@ 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); @@ -54,62 +46,33 @@ 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", 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); -} +// 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; +// 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; @@ -129,18 +92,11 @@ 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. - // - // 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 rel = 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 = baking ? cell_rel : camera_cell + cell_rel; + vec2 cell = 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 @@ -154,49 +110,22 @@ 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. - // - // 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); + float nearest = MISS_DISTANCE; + float second_nearest = MISS_DISTANCE; + vec2 nearest_dir = vec2(0.0, 1.0); - // 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++) { + // 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++) { vec2 offset = vec2(float(dx), float(dy)); - // 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); + highp vec2 neighbor_cell = cell + offset; // Cells without a crater are pushed far away rather than // branched around, keeping the loop body uniform. NOTE the @@ -205,45 +134,32 @@ 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. - 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); - + // 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); vec2 to_sample = local - crater_center; - float normalized_sq = dot(to_sample, to_sample) / (radius * radius) - + (1.0 - has_crater) * MISS_DISTANCE_SQ; + 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); - 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 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; } } } - 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); @@ -272,14 +188,8 @@ 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. - // 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); - } + 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 deleted file mode 100644 index 1574c85..0000000 --- a/lunar_tiled.gdshader +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 03c7ea0..0000000 --- a/lunar_tiled.gdshader.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dmmdpa3pmr8g diff --git a/tests/_profile_surface_shader.gd b/tests/_profile_surface_shader.gd deleted file mode 100644 index 15a7868..0000000 --- a/tests/_profile_surface_shader.gd +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 73e9418..0000000 --- a/tests/_profile_surface_shader.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dgg7cnpiq2y58 diff --git a/tests/_setup_editor_load_panel_preview.gd.uid b/tests/_setup_editor_load_panel_preview.gd.uid deleted file mode 100644 index f81e4d7..0000000 --- a/tests/_setup_editor_load_panel_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ckltesr471lqy diff --git a/tests/profile_shader.sh b/tests/profile_shader.sh deleted file mode 100644 index 3a57e52..0000000 --- a/tests/profile_shader.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/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 deleted file mode 100644 index 2500c16..0000000 --- a/tests/test_enemy_detection_range.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dekjmrvv0baqy diff --git a/tests/test_fleet_speed_bounded.gd b/tests/test_fleet_speed_bounded.gd deleted file mode 100644 index 3b69888..0000000 --- a/tests/test_fleet_speed_bounded.gd +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index 43f0546..0000000 --- a/tests/test_fleet_speed_bounded.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bkcs1gl5h616f diff --git a/tests/test_lunar_field.gd b/tests/test_lunar_field.gd deleted file mode 100644 index a6c2f03..0000000 --- a/tests/test_lunar_field.gd +++ /dev/null @@ -1,321 +0,0 @@ -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 deleted file mode 100644 index 2570d17..0000000 --- a/tests/test_lunar_field.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b0bjyh6fn2v34 diff --git a/tests/test_research_lab_extractor_gate.gd.uid b/tests/test_research_lab_extractor_gate.gd.uid deleted file mode 100644 index 6b5f117..0000000 --- a/tests/test_research_lab_extractor_gate.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://1ynm1v0c1qfk diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd index ca8d4cd..059321c 100644 --- a/tests/test_space_time_distorder.gd +++ b/tests/test_space_time_distorder.gd @@ -58,17 +58,6 @@ 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: @@ -203,13 +192,6 @@ 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 e310f54..a9acc62 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 scene_shader: Shader +var lunar_shader: Shader var gas_shader: Shader var rocky_shader_after_compose: Shader var scene: Node @@ -29,17 +29,7 @@ var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") - # 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() + lunar_shader = load("res://lunar_surface.gdshader") # Loaded by path into a local, not referenced by its bare global # class_name - same load-order hazard PlanetSetups.gd and @@ -57,8 +47,8 @@ func _initialize() -> void: quit(1) return gas_shader = gas_profile.background_shader - if gas_shader == scene_shader: - print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the scene's own shader, not its own)") + if gas_shader == lunar_shader: + print("TEST_SURFACE_PROFILES: FAIL (gas profile points at the lunar shader, not its own)") quit(1) return @@ -89,7 +79,7 @@ func _process(delta: float) -> bool: var gas_bg: ShaderMaterial = scene.get_node("BackgroundLayer/LunarBackground").material scene.free() - if rocky_shader_after_compose != scene_shader: + if rocky_shader_after_compose != lunar_shader: print("TEST_SURFACE_PROFILES: FAIL (landing on a rocky planet changed the background shader)") quit(1) return true @@ -97,8 +87,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 == scene_shader: - print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the scene's own lunar background)") + if gas_bg.shader == lunar_shader: + print("TEST_SURFACE_PROFILES: FAIL (gas surface still uses the lunar shader)") quit(1) return true