#84: move 215 root files into a folder hierarchy

Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-21 19:44:14 +02:00
commit 2f161621c3
495 changed files with 571 additions and 567 deletions

View file

@ -0,0 +1,241 @@
shader_type canvas_item;
// Gas giant cloud-band backdrop for a landed gas surface. Same uniform
// interface as lunar_surface.gdshader (camera_cell/camera_frac/camera_zoom/
// viewport_size/cell_size/base_color/crater_color/highlight_color), so
// SurfaceBackground.gd feeds this shader with no changes at all - only the
// surface profile decides which shader the material uses.
//
// Renders horizontal cloud bands instead of craters: each grid row (a
// horizontal strip one cell tall) hashes its own band centre, thickness and
// drift speed, nearest-row-wins picks which band a pixel belongs to, and the
// resulting distance field drives colour exactly the way the crater distance
// field does in lunar_surface.gdshader - base_color is the deep atmosphere,
// crater_color the shadowed band body, highlight_color the lit band crest.
// Turbulence perturbs each row's edge so the bands ripple instead of reading
// as ruled lines, and each row slides sideways at its OWN rate, which is what
// gives a gas giant its sheared, differentially-rotating look.
//
// PRECISION: never reconstruct an absolute world position. Every value fed
// to a hash is either a small camera-relative fraction (local, in [0,1]) or
// an exact integer cell/row index (cell, camera_cell + cell_rel) - the same
// split lunar_surface.gdshader and Starfield.gd use, for the same reason:
// world coordinates reach the tens of thousands, where a mobile float loses
// the fractional precision this shader depends on and no `highp` fixes it.
// The one exception is TIME, which the drift is built from directly per the
// ticket - see combine_lattice() for why that's still safe to mix in.
//
// CONTINUITY is the other invariant, and it is the one that broke. Every
// lattice coordinate handed to value_noise() is an (integer, fraction) pair,
// and the noise is only continuous if crossing a cell boundary - fraction
// 1 -> 0 while the integer steps by one - lands on the same value from both
// sides. That holds for the pair as it is built, and for halve_lattice()
// below, which is written to preserve it. It did NOT hold for the old
// second octave, which doubled the integer part while
// leaving the fraction alone: at every cell boundary the noise jumped from
// hash(2i+1) to hash(2i+2) with nothing in between, so the band edges broke
// into visibly disjoint segments once per cell across the whole screen. If a
// new octave is added, rescale the coordinate with halve_lattice() (or an
// equivalent that folds the carry) - anything that touches the integer and
// the fraction independently puts the seams straight back.
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;
// The period the camera cell arrives wrapped to - see SurfaceBackground.gd,
// which wraps it in double precision before it ever reaches a shader. Must
// be a multiple of 4, so halving it twice for the coarse octaves below stays
// an exact integer count of cells.
uniform float tile_cells = 32.0;
uniform vec4 base_color : source_color = vec4(0.13, 0.025, 0.22, 1.0);
uniform vec4 crater_color : source_color = vec4(0.055, 0.008, 0.11, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.52, 0.17, 0.7, 1.0);
// Row scan range. 4 rows x a handful of hashes each is far cheaper than
// lunar's 5x5 (25-sample) crater scan - this shader has to stay renderable
// through the softpipe software rasterizer the screenshot harness uses.
const int ROW_SCAN = 2; // rows -1..+2
const float MIN_THICKNESS = 0.25;
const float MAX_THICKNESS = 0.55;
// How far a band edge may wander, in cells. The scan above has to reach
// every row that could still change a pixel, or a band gets shaded by a row
// the loop never visited - a hard horizontal seam. The furthest normalized
// distance anything below reacts to is MAX_SIGNIFICANT_DISTANCE (the
// crest's outer edge), so a row matters out to that times MAX_THICKNESS,
// plus TURBULENCE_STRENGTH * 0.5 =
// 0.74 cells; with band centres anywhere in [0,1) that is only ever dy in
// -1..+1, inside the loop's -1..+2. Raising either constant eats that
// headroom - tests/test_gas_bands.gd fails if it runs out.
const float TURBULENCE_STRENGTH = 0.32;
const float DRIFT_SPEED = 0.045; // cells per second, lateral
// The largest normalized distance any shading term below reacts to: past
// this the crest has faded out and the pixel is plain atmosphere. Named
// because the row-scan headroom argument above is stated in terms of it,
// and tests/test_gas_bands.gd reads both out of this file to check it.
const float MAX_SIGNIFICANT_DISTANCE = 1.05;
// Same hash21 formulation as lunar_surface.gdshader (Dave Hoskins, all
// multipliers < 1 so it stays accurate at any cell-index magnitude).
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);
}
// Three independent values from one hash, so a row's centre, thickness and
// drift rate cost roughly what one of them used to. Dave Hoskins' hash23,
// same family and the same all-multipliers-below-one property.
vec3 hash23(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yxz + 33.33);
return fract((p3.xxy + p3.yzz) * p3.zyx);
}
// Splits a float into an exact integer part and a [0,1) remainder - the
// same floor/subtract trick `rel` -> `cell_rel` + `local` already uses
// below, generalized so it can also fold TIME-based drift into a lattice
// coordinate without ever forming one large continuous float.
vec2 int_frac(float v) {
float i = floor(v);
return vec2(i, v - i);
}
// Combines two already-safe (integer, fraction) pairs - e.g. a cell index
// plus a drift offset - by adding the integers (exact, since both are
// integers) and the fractions (both in [0,1), so the sum is in [0,2)),
// then re-normalizing any carry out of the fraction back into the integer
// part. Never adds an integer directly to a fraction.
vec2 combine_lattice(vec2 base, vec2 add) {
float f = base.y + add.y;
float carry = floor(f);
return vec2(base.x + add.x + carry, f - carry);
}
// Halves a lattice coordinate: the same point, expressed on a lattice of
// twice the spacing, which is how a lower-frequency octave is taken without
// ever forming the underlying continuous coordinate.
//
// Continuous by construction. `rem` is which half of the coarse cell the
// fine index sits in (0 or 1), so the fraction is (rem + f) * 0.5, sweeping
// 0 -> 1 across the two fine cells that make up one coarse cell. Crossing
// out of the first (f: 1 -> 0, i -> i+1, rem: 0 -> 1) gives 0.5 from both
// sides; crossing out of the second gives 1.0 then 0.0 with the coarse
// index stepping, which is the same lattice point.
vec2 halve_lattice(vec2 coord) {
float half_i = floor(coord.x * 0.5);
float rem = coord.x - half_i * 2.0;
return vec2(half_i, (rem + coord.y) * 0.5);
}
// 1D value noise over an integer lattice, given as a pre-split (i, f) pair:
// hashes the two neighboring integers and blends by the fraction. `axis`
// varies the hash sequence so different callers (different rows, or the
// turbulence octaves) don't sample the same noise.
//
// Every index is taken modulo `period` first, which is what makes the field
// exactly periodic - the same trick lunar_surface.gdshader uses. It is not
// decoration: the camera cell index arrives ALREADY WRAPPED into
// [0, tile_cells), so a pixel's cell index shifts by exactly tile_cells the
// moment the camera crosses a wrap boundary. Hash on the raw index and the
// entire sky rearranges itself at that instant. Hash on the wrapped one and
// the shift lands on the same value, so the boundary cannot be seen.
//
// The +1 neighbour is wrapped too, or the seam would simply move to the
// last cell of each period instead of disappearing.
float value_noise(vec2 coord, float period, float axis) {
float a = hash21(vec2(mod(coord.x, period), axis));
float b = hash21(vec2(mod(coord.x + 1.0, period), axis));
return mix(a, b, smoothstep(0.0, 1.0, coord.y));
}
// Fractal turbulence for one row: three octaves, each an exact rescaling of
// the same lattice coordinate. The weights are deliberately bottom-heavy -
// the quarter-frequency octave (one undulation per four cells, ~880 world
// units) carries most of the amplitude, so bands sweep in long slow curves
// and the finer octaves only roughen the edge. The old version was a single
// cell-frequency wobble, which read as buzz on the band edge rather than as
// weather.
//
// Each octave carries its OWN period: halving the lattice halves how many
// cells one period spans, so the coarse octaves wrap at tile_cells/2 and
// tile_cells/4. Crossing a camera wrap shifts the fine index by exactly
// tile_cells, hence the halved indices by exactly tile_cells/2 and /4, and
// the parity halve_lattice keys its fraction off is preserved because
// tile_cells divides by 4 - so all three octaves land on the same values
// they had a moment before.
float row_turbulence(vec2 lattice, float period, float axis) {
vec2 half_freq = halve_lattice(lattice);
vec2 quarter = halve_lattice(half_freq);
return value_noise(quarter, period * 0.25, axis + 11.0) * 0.55
+ value_noise(half_freq, period * 0.5, axis + 47.0) * 0.30
+ value_noise(lattice, period, axis + 83.0) * 0.15;
}
void fragment() {
vec2 screen_px = UV * viewport_size;
vec2 centered = screen_px - viewport_size * 0.5;
// Identical split to lunar_surface.gdshader: `rel` is always small and
// camera-relative, `cell` is an exact integer built from two integers
// (camera_cell + cell_rel), and `local` is the bounded [0,1) remainder.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
vec2 cell = camera_cell + cell_rel;
vec2 base_lattice = vec2(cell.x, local.x);
float time_units = TIME * DRIFT_SPEED;
// Nearest-row-wins band field, the same single-scalar discipline as
// lunar_surface.gdshader's nearest-crater distance: every visual below
// is a pure function of `nearest`, so the band body and its crest can
// never describe two different bands.
float nearest = 1000.0;
for (int dy = -1; dy <= ROW_SCAN; dy++) {
float row = cell.y + float(dy);
// centre, thickness and drift rate, from one hash - on the WRAPPED
// row index, so a band keeps its identity across a camera wrap.
vec3 row_hash = hash23(vec2(mod(row, tile_cells), 5.0));
float band_centre = row_hash.x;
float thickness = mix(MIN_THICKNESS, MAX_THICKNESS, row_hash.y);
// Differential rotation: every row slides at its own rate, and some
// slide backwards. Neighbouring bands therefore shear past each
// other instead of the whole sky sliding as one sheet.
float drift_rate = mix(-1.0, 1.0, row_hash.z);
// TIME is unbounded but session-scale (the ticket calls for using it
// directly, no extra uniform), so it gets its own int/frac split and
// is combined with the cell's via combine_lattice() rather than
// added to cell.x as one continuous float.
vec2 drift = int_frac(time_units * drift_rate);
vec2 row_lattice = combine_lattice(base_lattice, drift);
// Phased per-row so neighbouring bands don't ripple in lockstep.
float turb = row_turbulence(row_lattice, tile_cells, mod(row, tile_cells) * 3.7);
// Safe: local.y is bounded [0,1), dy and row are small integers, so
// this stays a small camera-relative value with no huge magnitude.
float to_band = (local.y - float(dy)) - band_centre;
to_band += (turb - 0.5) * TURBULENCE_STRENGTH;
float normalized = abs(to_band) / thickness;
nearest = min(nearest, normalized);
}
float core = 1.0 - smoothstep(0.0, 0.6, nearest);
float crest = smoothstep(0.5, 0.78, nearest)
* (1.0 - smoothstep(0.78, MAX_SIGNIFICANT_DISTANCE, nearest));
vec3 color = base_color.rgb;
color = mix(color, crater_color.rgb, core * 0.85);
color = mix(color, highlight_color.rgb, crest * 0.6);
// Same dither as lunar_surface.gdshader, to mask 8-bit banding on the
// smooth band gradients.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
COLOR = vec4(color, 1.0);
}

View file

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

View file

@ -0,0 +1,302 @@
shader_type canvas_item;
// Lunar/crater backdrop for the "Big Stable Asteroid" atmosphere. Reacts
// to camera position/zoom exactly like starfield.gdshader (world-space
// sampling from camera uniforms), so it parallaxes correctly as the fleet
// flies around instead of being a static picture.
//
// Structured as a Worley / cellular distance field, following the design
// of the "Floating Bubbles" shader on godotshaders.com: a neighbor scan
// reduces everything to ONE scalar - the normalized distance to the
// nearest crater - and every visual (floor darkness, rim light, rim
// shadow) is then derived from that single value.
//
// This single-field structure matters, and is the whole reason for the
// rewrite. The previous version accumulated three fields independently
// (floor via max() over all neighbors, rim light/shadow via a separate
// "strongest crater wins" test). Those could disagree: one crater's dark
// rim band could be composited over a different, nearer crater's floor,
// cutting a hard wrongly-shaped bite into it. Deriving everything from
// the nearest crater makes that class of bug structurally impossible -
// the floor and the rim always describe the same crater.
//
// Where two craters overlap, the nearest-crater field has a crease (normal
// for Worley noise) and the lighting direction flips across it, which drew
// 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://shaders/lunar_surface.gdshader
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;
// Sampled from a NASA Artemis lunar photograph: the regolith is a
// bright neutral grey with a very faint cool cast (blue a touch above
// red), craters read as mid-grey floors rather than black, and the lit
// rims are only slightly brighter than the surface.
uniform vec4 base_color : source_color = vec4(0.75, 0.75, 0.76, 1.0);
uniform vec4 crater_color : source_color = vec4(0.44, 0.44, 0.45, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.89, 0.89, 0.90, 1.0);
// DIAGNOSTIC ONLY (set in PlanetSurface.tscn for a debug build; 0 = off).
// 1 = tint alternate cells, to show where cell boundaries fall.
// 2 = output the raw normalized distance field as grayscale, with NO
// rim, lighting, dither or tint. This bisects the shader: the field
// and the shading built on top of it are independent halves, so if
// 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);
}
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;
// Position is expressed RELATIVE TO THE CAMERA'S CELL, never as an
// absolute world coordinate. camera_frac is the camera's offset inside
// its own cell (always in [0,1]) and `centered` spans at most a few
// cells, so every value here stays small and exactly representable even
// on a mobile GPU - whereas an absolute world position reaches the tens
// of thousands, where float precision degrades and no `highp` qualifier
// can help (it widens the type, but the magnitude is the problem).
// GDScript does the large-number split in double precision; see
// LunarBackground.gd / Starfield.gd.
//
// cell and local also MUST come from one shared value, with local
// derived as (rel - cell_rel) rather than an independent fract():
// 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 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;
// Single scalar field: normalized distance to the nearest crater.
// < 1.0 means this pixel is inside that crater; 1.0 is exactly on its
// edge. Normalizing by each crater's own radius is what lets craters
// vary in size while still collapsing to one comparable number.
//
// The SECOND-nearest crater is tracked alongside the nearest, purely so
// the rim can be suppressed inside overlaps. Where two craters overlap,
// the nearest-crater test flips along the line between them; the
// lighting direction flips with it and paints a thin bright rim wedge
// 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);
// 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));
// 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
// sentinel is deliberately a small number, not something like
// 1e9: mediump floats top out around 65504, so a huge literal
// 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);
vec2 to_sample = local - crater_center;
float normalized_sq = dot(to_sample, to_sample) / (radius * radius)
+ (1.0 - has_crater) * MISS_DISTANCE_SQ;
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);
// Everything below is a pure function of `nearest` (plus the winning
// crater's direction), so all three visuals stay mutually consistent.
float floor_amount = 1.0 - smoothstep(0.45, 0.9, nearest);
// Thin raised-rim band right at the crater's edge, split into a lit
// half (facing LIGHT_DIR) and a shadowed half (facing away), giving a
// sense of a raised lip catching light.
float rim_band = smoothstep(0.55, 0.85, nearest) * (1.0 - smoothstep(0.85, 1.0, nearest));
// Fade the rim out where this pixel also lies inside a second crater:
// that is the overlap region, where a rim would be an internal crease
// rather than a real outer lip. Craters there merge into one basin.
rim_band *= smoothstep(0.85, 1.15, second_nearest);
float rim_light = rim_band * clamp(facing, 0.0, 1.0);
float rim_shadow = rim_band * clamp(-facing, 0.0, 1.0);
vec3 color = base_color.rgb;
color = mix(color, crater_color.rgb, floor_amount * 0.85);
color = mix(color, highlight_color.rgb, rim_light * 0.6);
color = mix(color, crater_color.rgb * 0.7, rim_shadow * 0.5);
// 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.
// 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.
if (debug_mode > 0.5 && debug_mode < 1.5) {
float checker = mod(cell.x + cell.y, 2.0);
color = mix(color, vec3(0.10, 0.30, 0.55), checker * 0.35);
vec2 edge = min(local, 1.0 - local);
float on_edge = 1.0 - smoothstep(0.0, 0.012, min(edge.x, edge.y));
color = mix(color, vec3(1.0, 0.25, 0.25), on_edge * 0.9);
}
// Bisection output: the field alone, with none of the shading above
// applied. Godot forbids `return` inside fragment(), so this overrides
// the computed colour rather than exiting early.
if (debug_mode > 1.5) {
color = vec3(clamp(nearest / 2.0, 0.0, 1.0));
}
COLOR = vec4(color, 1.0);
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,117 @@
shader_type canvas_item;
// Backdrop for a landed enemy research lab.
//
// The other surfaces are NATURAL: lunar_surface.gdshader erodes craters,
// gas_surface.gdshader bands clouds. A research lab is the one place in the
// game that is BUILT, and it was previously borrowing the crater field with
// a dark red palette - which read as "rocky planet, tinted", not as walking
// onto someone's facility. So this draws a plated floor instead: panel
// seams, service conduits running through them, and lit junctions where two
// conduits cross.
//
// Deliberately loop-free. lunar_surface.gdshader needs a neighbour scan
// because a crater overlaps its cell's borders; nothing here does, so every
// feature is a function of this pixel's own cell and the shader stays a
// handful of hashes per pixel (#68's lesson, applied up front rather than
// after the fact).
//
// The conduits still have to run CONTINUOUSLY across cell borders, which is
// what a neighbour scan would normally buy. The trick is to make a
// horizontal run's existence and height depend only on cell.y, and a
// vertical run's on cell.x: every cell in a row then independently agrees on
// the same answer, so the line joins up without any cell ever looking at
// its neighbours.
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;
// Shared with the lunar background so the same camera feed drives both, and
// so the field repeats on the same period. See LunarBackground.gd.
uniform float tile_cells = 32.0;
uniform vec4 base_color : source_color = vec4(0.16, 0.12, 0.12, 1.0);
uniform vec4 crater_color : source_color = vec4(0.05, 0.03, 0.03, 1.0);
uniform vec4 highlight_color : source_color = vec4(0.75, 0.20, 0.15, 1.0);
// Plates per cell, across and down.
const float SUBPANELS = 3.0;
const float SEAM_WIDTH = 0.05;
const float CONDUIT_WIDTH = 0.055;
// How many rows/columns carry a conduit. Low enough that the floor stays
// mostly plating, high enough that a screen usually shows a junction.
const float CONDUIT_CHANCE = 0.38;
float hash11(float p) {
vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
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 every other background here: an
// absolute world position reaches the tens of thousands, where a mobile
// float has already lost the fractional bits this needs. camera_cell
// arrives pre-wrapped from GDScript's double precision.
vec2 rel = camera_frac + centered * (camera_zoom / cell_size);
vec2 cell_rel = floor(rel);
vec2 local = rel - cell_rel;
vec2 cell = mod(camera_cell + cell_rel, tile_cells);
// --- plating ---------------------------------------------------------
vec2 sub = local * SUBPANELS;
vec2 sub_cell = floor(sub);
vec2 sub_local = sub - sub_cell;
vec2 edge = min(sub_local, 1.0 - sub_local);
float seam = 1.0 - smoothstep(0.0, SEAM_WIDTH, min(edge.x, edge.y));
// A little per-plate variation, so a large floor does not read as one
// flat fill: plates are stamped, not poured.
float plate = hash21(cell * SUBPANELS + sub_cell);
vec3 color = mix(base_color.rgb, crater_color.rgb, plate * 0.40);
color = mix(color, crater_color.rgb, seam * 0.85);
// --- conduits --------------------------------------------------------
// Existence and offset keyed to a SINGLE axis of the cell index, which
// is what makes a run continuous across every cell it passes through.
float has_h = step(1.0 - CONDUIT_CHANCE, hash11(cell.y + 0.5));
float has_v = step(1.0 - CONDUIT_CHANCE, hash11(cell.x + 13.7));
float run_y = 0.25 + 0.5 * hash11(cell.y + 7.3);
float run_x = 0.25 + 0.5 * hash11(cell.x + 3.1);
float h_band = has_h * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.y - run_y)));
float v_band = has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH, abs(local.x - run_x)));
// Charge travelling along the run. Phase is built from cell + local, a
// continuous coordinate, so the pulse crosses cell borders unbroken
// rather than restarting inside every cell.
float pulse_h = 0.55 + 0.45 * sin((cell.x + local.x) * 2.4 - TIME * 1.6);
float pulse_v = 0.55 + 0.45 * sin((cell.y + local.y) * 2.4 - TIME * 1.6);
float conduit = max(h_band * pulse_h, v_band * pulse_v);
// A dark channel under the glow, so a conduit reads as recessed into the
// floor rather than painted on top of it.
color = mix(color, crater_color.rgb, max(h_band, v_band) * 0.55);
color = mix(color, highlight_color.rgb, conduit * 0.75);
// Junction box wherever a horizontal and a vertical run cross.
float junction = has_h * has_v * (1.0 - smoothstep(0.0, CONDUIT_WIDTH * 2.4,
length(vec2(local.x - run_x, local.y - run_y))));
color = mix(color, highlight_color.rgb, junction * 0.9);
// Same dither as the other backgrounds: the gradients here are shallow
// and dark, which is exactly where 8-bit quantization bands visibly.
float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0;
color += vec3(dither);
COLOR = vec4(color, 1.0);
}

View file

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

125
shaders/starfield.gdshader Normal file
View file

@ -0,0 +1,125 @@
shader_type canvas_item;
// Procedural infinite starfield. Reconstructs a world-space coordinate for
// every screen pixel from the camera's current position/zoom (the exact
// same mapping used for every other object in the scene - ships, enemies,
// etc.), then hashes a grid cell in world space to decide whether that
// cell holds a star, and how big/bright it is. Cost is one fragment
// shader pass over the screen - constant regardless of zoom level or how
// many stars are "visible", with zero per-star CPU bookkeeping.
//
// Star size is a fixed WORLD-space radius (not compensated for zoom), so
// stars shrink/grow exactly in sync with ships and everything else when
// the camera zooms - zooming out reveals more of the field (unavoidable -
// a wider view always shows more of a spatially spread-out field) but
// each star also gets visibly smaller, so it still reads as "zoomed out"
// consistently with the foreground instead of feeling mismatched.
// Stars are found with a 3x3-neighbor cellular lookup: each pixel checks
// its own grid cell AND all 8 neighbors for a nearby star center. Checking
// only the pixel's own cell (which this shader did originally) hard-clips
// any star whose glow crosses a cell boundary, leaving a star with sharp
// straight edges on one or two sides and normal soft falloff on the others
// - which is exactly the artifact that was reported on-device here, and
// the reason the same technique was already used in lunar_surface.gdshader.
//
// The camera position arrives pre-split into a cell index plus a fraction
// within that cell, so this shader never handles an absolute world
// coordinate (which reaches the tens of thousands once the fleet has
// explored, where mobile float precision degrades). See Starfield.gd.
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 = 60.0;
uniform vec4 bg_color : source_color = vec4(0.02, 0.02, 0.08, 1.0);
// DIAGNOSTIC ONLY - see the same uniform in lunar_surface.gdshader.
uniform float debug_mode = 0.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;
// Position is expressed RELATIVE TO THE CAMERA'S CELL, never as an
// absolute world coordinate. camera_frac is the camera's offset inside
// its own cell (always in [0,1]) and `centered` spans at most a few
// cells, so every value here stays small and exactly representable even
// on a mobile GPU - whereas an absolute world position reaches the tens
// of thousands, where float precision degrades and no `highp` qualifier
// can help (it widens the type, but the magnitude is the problem).
// GDScript does the large-number split in double precision; see
// LunarBackground.gd / Starfield.gd.
//
// cell and local also MUST come from one shared value, with local
// derived as (rel - cell_rel) rather than an independent fract():
// 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);
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;
float star = 0.0;
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;
float has_star = step(0.86, hash21(neighbor_cell + vec2(99.0, 7.0)));
// Star center expressed in THIS pixel's cell-local space, so a
// star living in a neighboring cell is still measured correctly
// from here instead of being clipped away at the cell edge.
vec2 star_center = offset + hash22(neighbor_cell);
float dist = length(local - star_center);
// Fixed world-space size (fraction of cell) - no zoom
// compensation, so it scales exactly like any other world object
// when the camera zooms.
float size = mix(0.12, 0.22, hash21(neighbor_cell + vec2(3.0, 5.0)));
float brightness = mix(0.35, 1.0, hash21(neighbor_cell + vec2(11.0, 13.0)));
// smoothstep() edges must be given with edge0 < edge1 - GLSL
// leaves the reversed-edge case undefined, and different GPUs are
// free to compute something else for it. Use correctly-ordered
// edges and invert the result arithmetically instead of swapping
// the edges.
float s = has_star * (1.0 - smoothstep(0.0, size, dist)) * brightness;
star = max(star, s);
}
}
vec3 out_color = bg_color.rgb + vec3(star);
// Diagnostic checkerboard - makes cell boundaries directly visible so a
// screenshot can settle whether the reported cuts lie on them.
if (debug_mode > 0.5 && debug_mode < 1.5) {
float checker = mod(cell.x + cell.y, 2.0);
out_color = mix(out_color, vec3(0.10, 0.30, 0.55), checker * 0.35);
vec2 edge = min(local, 1.0 - local);
float on_edge = 1.0 - smoothstep(0.0, 0.012, min(edge.x, edge.y));
out_color = mix(out_color, vec3(1.0, 0.25, 0.25), on_edge * 0.9);
}
COLOR = vec4(out_color, 1.0);
}

View file

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