spaceship/shaders/starfield.gdshader
Loïc Wisniewski 2f161621c3 #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>
2026-08-21 19:44:14 +02:00

125 lines
5.9 KiB
Text

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);
}