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/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/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_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_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