spaceship/shaders/starfield.gdshader

125 lines
5.9 KiB
Text
Raw Normal View History

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