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. // // 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 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; // 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; const vec2 LIGHT_DIR = vec2(-0.6, -0.8); const float MIN_RADIUS = 0.16; // 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. const float MAX_RADIUS = 0.38; // 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); } 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. // // 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); vec2 cell_rel = floor(rel); vec2 local = rel - cell_rel; // Absolute cell index, used only for hashing. Built from exact integers. vec2 cell = baking ? cell_rel : 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. // // 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++) { vec2 offset = vec2(float(dx), float(dy)); // 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); // 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. 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); vec2 to_sample = local - crater_center; 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; } } } 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); 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. // 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); } // 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); }