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>
2026-08-10 18:41:58 +02:00
|
|
|
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.
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
//
|
|
|
|
|
// 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
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
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;
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
// 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;
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
const vec2 LIGHT_DIR = vec2(-0.6, -0.8);
|
|
|
|
|
const float MIN_RADIUS = 0.16;
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
// 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.
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
const float MAX_RADIUS = 0.38;
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
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.
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
//
|
|
|
|
|
// 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);
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
vec2 cell_rel = floor(rel);
|
|
|
|
|
vec2 local = rel - cell_rel;
|
|
|
|
|
// Absolute cell index, used only for hashing. Built from exact integers.
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
vec2 cell = baking ? cell_rel : camera_cell + cell_rel;
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
// 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.
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
//
|
|
|
|
|
// 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++) {
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
vec2 offset = vec2(float(dx), float(dy));
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
// 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);
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
// 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
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
// 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);
|
|
|
|
|
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
vec2 to_sample = local - crater_center;
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
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;
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
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);
|
|
|
|
|
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
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.
|
Make the lunar backdrop cheap: 3x3 field, then bake and tile it (#68)
The crater shader ran on every pixel of a fullscreen rect every frame and
was by far the most expensive thing in the surface scene. Nothing else came
close: RockField keeps 14 rocks alive, Rock/Ship/Enemy already avoid
per-frame queue_redraw() for transform-only changes, and LunarBackground.gd
is a handful of scalar ops.
Three reductions to the field itself:
- 5x5 -> 3x3 neighbour scan. That width was never the geometric answer;
it was insurance against craters seen clipping at cell boundaries
on-device, i.e. a crater reaching past its nominal radius because a hash
returned a value outside [0,1] and extrapolated mix(). Clamping the
crater CENTRE into its own cell, alongside the radius clamp already
there, bounds the reach whatever the hash returns - so a crater two cells
out is >= 1.0 cells away, a normalized distance of >= 2.63, past every
threshold the shading reacts to (the last saturates at 1.15). The outer
ring provably cannot change a pixel. This removes the clipping bug's
cause instead of hiding it behind 16 extra samples.
- One hash42 per cell instead of hash21 twice plus hash22, decorrelating
on output lanes rather than input offsets.
- Squared distances in the loop, square-rooted twice at the end rather
than once per sample.
Measured with the new tests/profile_shader.sh, above softpipe's own ~657ms
fullscreen floor: 9482ms -> 405ms of shader work, a 23x reduction.
Then the larger change: the field is a fixed function of world position, so
every frame recomputed an identical answer. It is now made periodic (the
cell index is wrapped before hashing, which makes a Worley field tile
exactly rather than merely blend), baked once at load into one tile, and
scrolled with repeat wrapping by lunar_tiled.gdshader - one texture fetch
per pixel. Two honest costs: the field repeats every 7040 world units, and
a fixed-resolution tile softens when zoomed past 1:1. Lunar regolith is
forgiving of both, having no landmarks and being smooth gradients already.
The bake waits a frame and asks the material whether it actually samples a
field_texture, so a gas giant - which swaps in its own background shader
(#51) - does not pay for a 2048-square tile it will never read. Headless
runs skip it entirely, having nothing to rasterize.
tests/test_lunar_field.gd guards the reasoning, since a fragment shader
cannot be asserted on directly: the premises are still in the source, the
worst-case ring-2 geometry stays clear of every threshold independent of
the hash, a 3x3 and a 5x5 scan render identically over 4000 sampled points,
the field repeats exactly in both axes, and the miss sentinel still cannot
overflow mediump into +Inf and then NaN.
test_surface_profiles.gd asserted "a rocky landing leaves the background
alone" by naming lunar_surface.gdshader; it now reads the shader from the
scene, which is the property it actually cares about.
PlanetSurface.tscn's edit also carries the planet-name relocation described
in the HUD commit - same scene file, so the two cannot be split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:14 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
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>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|