shader_type canvas_item; // Gas giant cloud-band backdrop for a landed gas surface. Same uniform // interface as lunar_surface.gdshader (camera_cell/camera_frac/camera_zoom/ // viewport_size/cell_size/base_color/crater_color/highlight_color), so // SurfaceBackground.gd feeds this shader with no changes at all - only the // surface profile decides which shader the material uses. // // Renders horizontal cloud bands instead of craters: each grid row (a // horizontal strip one cell tall) hashes its own band centre, thickness and // drift speed, nearest-row-wins picks which band a pixel belongs to, and the // resulting distance field drives colour exactly the way the crater distance // field does in lunar_surface.gdshader - base_color is the deep atmosphere, // crater_color the shadowed band body, highlight_color the lit band crest. // Turbulence perturbs each row's edge so the bands ripple instead of reading // as ruled lines, and each row slides sideways at its OWN rate, which is what // gives a gas giant its sheared, differentially-rotating look. // // PRECISION: never reconstruct an absolute world position. Every value fed // to a hash is either a small camera-relative fraction (local, in [0,1]) or // an exact integer cell/row index (cell, camera_cell + cell_rel) - the same // split lunar_surface.gdshader and Starfield.gd use, for the same reason: // world coordinates reach the tens of thousands, where a mobile float loses // the fractional precision this shader depends on and no `highp` fixes it. // The one exception is TIME, which the drift is built from directly per the // ticket - see combine_lattice() for why that's still safe to mix in. // // CONTINUITY is the other invariant, and it is the one that broke. Every // lattice coordinate handed to value_noise() is an (integer, fraction) pair, // and the noise is only continuous if crossing a cell boundary - fraction // 1 -> 0 while the integer steps by one - lands on the same value from both // sides. That holds for the pair as it is built, and for halve_lattice() // below, which is written to preserve it. It did NOT hold for the old // second octave, which doubled the integer part while // leaving the fraction alone: at every cell boundary the noise jumped from // hash(2i+1) to hash(2i+2) with nothing in between, so the band edges broke // into visibly disjoint segments once per cell across the whole screen. If a // new octave is added, rescale the coordinate with halve_lattice() (or an // equivalent that folds the carry) - anything that touches the integer and // the fraction independently puts the seams straight back. 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; // The period the camera cell arrives wrapped to - see SurfaceBackground.gd, // which wraps it in double precision before it ever reaches a shader. Must // be a multiple of 4, so halving it twice for the coarse octaves below stays // an exact integer count of cells. uniform float tile_cells = 32.0; uniform vec4 base_color : source_color = vec4(0.13, 0.025, 0.22, 1.0); uniform vec4 crater_color : source_color = vec4(0.055, 0.008, 0.11, 1.0); uniform vec4 highlight_color : source_color = vec4(0.52, 0.17, 0.7, 1.0); // Row scan range. 4 rows x a handful of hashes each is far cheaper than // lunar's 5x5 (25-sample) crater scan - this shader has to stay renderable // through the softpipe software rasterizer the screenshot harness uses. const int ROW_SCAN = 2; // rows -1..+2 const float MIN_THICKNESS = 0.25; const float MAX_THICKNESS = 0.55; // How far a band edge may wander, in cells. The scan above has to reach // every row that could still change a pixel, or a band gets shaded by a row // the loop never visited - a hard horizontal seam. The furthest normalized // distance anything below reacts to is MAX_SIGNIFICANT_DISTANCE (the // crest's outer edge), so a row matters out to that times MAX_THICKNESS, // plus TURBULENCE_STRENGTH * 0.5 = // 0.74 cells; with band centres anywhere in [0,1) that is only ever dy in // -1..+1, inside the loop's -1..+2. Raising either constant eats that // headroom - tests/test_gas_bands.gd fails if it runs out. const float TURBULENCE_STRENGTH = 0.32; const float DRIFT_SPEED = 0.045; // cells per second, lateral // The largest normalized distance any shading term below reacts to: past // this the crest has faded out and the pixel is plain atmosphere. Named // because the row-scan headroom argument above is stated in terms of it, // and tests/test_gas_bands.gd reads both out of this file to check it. const float MAX_SIGNIFICANT_DISTANCE = 1.05; // Same hash21 formulation as lunar_surface.gdshader (Dave Hoskins, all // multipliers < 1 so it stays accurate at any cell-index magnitude). 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); } // Three independent values from one hash, so a row's centre, thickness and // drift rate cost roughly what one of them used to. Dave Hoskins' hash23, // same family and the same all-multipliers-below-one property. vec3 hash23(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); p3 += dot(p3, p3.yxz + 33.33); return fract((p3.xxy + p3.yzz) * p3.zyx); } // Splits a float into an exact integer part and a [0,1) remainder - the // same floor/subtract trick `rel` -> `cell_rel` + `local` already uses // below, generalized so it can also fold TIME-based drift into a lattice // coordinate without ever forming one large continuous float. vec2 int_frac(float v) { float i = floor(v); return vec2(i, v - i); } // Combines two already-safe (integer, fraction) pairs - e.g. a cell index // plus a drift offset - by adding the integers (exact, since both are // integers) and the fractions (both in [0,1), so the sum is in [0,2)), // then re-normalizing any carry out of the fraction back into the integer // part. Never adds an integer directly to a fraction. vec2 combine_lattice(vec2 base, vec2 add) { float f = base.y + add.y; float carry = floor(f); return vec2(base.x + add.x + carry, f - carry); } // Halves a lattice coordinate: the same point, expressed on a lattice of // twice the spacing, which is how a lower-frequency octave is taken without // ever forming the underlying continuous coordinate. // // Continuous by construction. `rem` is which half of the coarse cell the // fine index sits in (0 or 1), so the fraction is (rem + f) * 0.5, sweeping // 0 -> 1 across the two fine cells that make up one coarse cell. Crossing // out of the first (f: 1 -> 0, i -> i+1, rem: 0 -> 1) gives 0.5 from both // sides; crossing out of the second gives 1.0 then 0.0 with the coarse // index stepping, which is the same lattice point. vec2 halve_lattice(vec2 coord) { float half_i = floor(coord.x * 0.5); float rem = coord.x - half_i * 2.0; return vec2(half_i, (rem + coord.y) * 0.5); } // 1D value noise over an integer lattice, given as a pre-split (i, f) pair: // hashes the two neighboring integers and blends by the fraction. `axis` // varies the hash sequence so different callers (different rows, or the // turbulence octaves) don't sample the same noise. // // Every index is taken modulo `period` first, which is what makes the field // exactly periodic - the same trick lunar_surface.gdshader uses. It is not // decoration: the camera cell index arrives ALREADY WRAPPED into // [0, tile_cells), so a pixel's cell index shifts by exactly tile_cells the // moment the camera crosses a wrap boundary. Hash on the raw index and the // entire sky rearranges itself at that instant. Hash on the wrapped one and // the shift lands on the same value, so the boundary cannot be seen. // // The +1 neighbour is wrapped too, or the seam would simply move to the // last cell of each period instead of disappearing. float value_noise(vec2 coord, float period, float axis) { float a = hash21(vec2(mod(coord.x, period), axis)); float b = hash21(vec2(mod(coord.x + 1.0, period), axis)); return mix(a, b, smoothstep(0.0, 1.0, coord.y)); } // Fractal turbulence for one row: three octaves, each an exact rescaling of // the same lattice coordinate. The weights are deliberately bottom-heavy - // the quarter-frequency octave (one undulation per four cells, ~880 world // units) carries most of the amplitude, so bands sweep in long slow curves // and the finer octaves only roughen the edge. The old version was a single // cell-frequency wobble, which read as buzz on the band edge rather than as // weather. // // Each octave carries its OWN period: halving the lattice halves how many // cells one period spans, so the coarse octaves wrap at tile_cells/2 and // tile_cells/4. Crossing a camera wrap shifts the fine index by exactly // tile_cells, hence the halved indices by exactly tile_cells/2 and /4, and // the parity halve_lattice keys its fraction off is preserved because // tile_cells divides by 4 - so all three octaves land on the same values // they had a moment before. float row_turbulence(vec2 lattice, float period, float axis) { vec2 half_freq = halve_lattice(lattice); vec2 quarter = halve_lattice(half_freq); return value_noise(quarter, period * 0.25, axis + 11.0) * 0.55 + value_noise(half_freq, period * 0.5, axis + 47.0) * 0.30 + value_noise(lattice, period, axis + 83.0) * 0.15; } void fragment() { vec2 screen_px = UV * viewport_size; vec2 centered = screen_px - viewport_size * 0.5; // Identical split to lunar_surface.gdshader: `rel` is always small and // camera-relative, `cell` is an exact integer built from two integers // (camera_cell + cell_rel), and `local` is the bounded [0,1) remainder. vec2 rel = camera_frac + centered * (camera_zoom / cell_size); vec2 cell_rel = floor(rel); vec2 local = rel - cell_rel; vec2 cell = camera_cell + cell_rel; vec2 base_lattice = vec2(cell.x, local.x); float time_units = TIME * DRIFT_SPEED; // Nearest-row-wins band field, the same single-scalar discipline as // lunar_surface.gdshader's nearest-crater distance: every visual below // is a pure function of `nearest`, so the band body and its crest can // never describe two different bands. float nearest = 1000.0; for (int dy = -1; dy <= ROW_SCAN; dy++) { float row = cell.y + float(dy); // centre, thickness and drift rate, from one hash - on the WRAPPED // row index, so a band keeps its identity across a camera wrap. vec3 row_hash = hash23(vec2(mod(row, tile_cells), 5.0)); float band_centre = row_hash.x; float thickness = mix(MIN_THICKNESS, MAX_THICKNESS, row_hash.y); // Differential rotation: every row slides at its own rate, and some // slide backwards. Neighbouring bands therefore shear past each // other instead of the whole sky sliding as one sheet. float drift_rate = mix(-1.0, 1.0, row_hash.z); // TIME is unbounded but session-scale (the ticket calls for using it // directly, no extra uniform), so it gets its own int/frac split and // is combined with the cell's via combine_lattice() rather than // added to cell.x as one continuous float. vec2 drift = int_frac(time_units * drift_rate); vec2 row_lattice = combine_lattice(base_lattice, drift); // Phased per-row so neighbouring bands don't ripple in lockstep. float turb = row_turbulence(row_lattice, tile_cells, mod(row, tile_cells) * 3.7); // Safe: local.y is bounded [0,1), dy and row are small integers, so // this stays a small camera-relative value with no huge magnitude. float to_band = (local.y - float(dy)) - band_centre; to_band += (turb - 0.5) * TURBULENCE_STRENGTH; float normalized = abs(to_band) / thickness; nearest = min(nearest, normalized); } float core = 1.0 - smoothstep(0.0, 0.6, nearest); float crest = smoothstep(0.5, 0.78, nearest) * (1.0 - smoothstep(0.78, MAX_SIGNIFICANT_DISTANCE, nearest)); vec3 color = base_color.rgb; color = mix(color, crater_color.rgb, core * 0.85); color = mix(color, highlight_color.rgb, crest * 0.6); // Same dither as lunar_surface.gdshader, to mask 8-bit banding on the // smooth band gradients. float dither = (hash21(screen_px + vec2(7.0, 13.0)) - 0.5) / 128.0; color += vec3(dither); COLOR = vec4(color, 1.0); }