Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded infinite world: tap/joystick steering, pinch zoom, formation pivoting, planet landings, an ore economy with extractors and haulers, and player built structures (mothership, ship crafter, command centre). All art is hand-drawn with draw_* calls rather than textures, and the backgrounds are camera-relative shaders so they stay precise at large world coordinates. Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot tooling that renders real frames through Xvfb and Mesa softpipe, since --headless has no GPU. Excluded from version control: .godot/ (import cache, and it holds export credentials), builds/, android/build/ and packaged APKs - all regenerable, and ~325 MB combined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
85814ce88c
192 changed files with 9602 additions and 0 deletions
212
lunar_surface.gdshader
Normal file
212
lunar_surface.gdshader
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
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.
|
||||
|
||||
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;
|
||||
|
||||
const vec2 LIGHT_DIR = vec2(-0.6, -0.8);
|
||||
const float MIN_RADIUS = 0.16;
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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.
|
||||
float nearest = MISS_DISTANCE;
|
||||
float second_nearest = MISS_DISTANCE;
|
||||
vec2 nearest_dir = 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++) {
|
||||
vec2 offset = vec2(float(dx), float(dy));
|
||||
highp vec2 neighbor_cell = cell + offset;
|
||||
|
||||
// Cells without a crater are pushed far away rather than
|
||||
// branched around, keeping the loop body uniform. NOTE the
|
||||
// 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. MISS_DISTANCE only has to beat the largest real
|
||||
// normalized distance (~2.9 cells / 0.16 min radius ~= 18).
|
||||
float has_crater = step(0.35, hash21(neighbor_cell + vec2(41.0, 5.0)));
|
||||
vec2 crater_center = offset + hash22(neighbor_cell);
|
||||
vec2 to_sample = local - crater_center;
|
||||
float 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 = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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);
|
||||
}
|
||||
Loading…
Reference in a new issue