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