facett-map3d/src/sky.wgsl
// **Skybox / atmosphere** (T3.2) — the background is no longer the flat fog
// clear-colour. A single fullscreen-triangle fragment shader paints an analytic
// gradient sky (warm sun-tinted horizon → cool zenith) plus a bright HDR **sun
// disc + glow**, rendered FIRST into the shared `Rgba16Float` HDR offscreen so the
// existing bloom + ACES tonemap catch the sun. The mesh + water then draw on top.
//
// Per pixel we reconstruct the true world-space camera ray from `inverse(view·
// projection)`: NDC → near & far world points → ray direction. The sky is then a
// pure function of that direction + the injected `SunSky` model, so it is fully
// deterministic (FC-7: no clock read here; sun motion rides the injected `SunSky`).
struct SkyUniforms {
inv_view_proj: mat4x4<f32>, // inverse(camera view·projection): NDC → world
eye: vec4<f32>, // world-space eye (xyz)
sun_dir: vec4<f32>, // surface→sun dir (xyz) + sun-disc intensity (w)
sun_color: vec4<f32>, // sun colour (rgb) + sun-glow intensity (w)
sky_color: vec4<f32>, // zenith tint (rgb) + horizon blend bias (w)
horizon: vec4<f32>, // horizon/haze colour (rgb) + ground darkening (w)
lut: vec4<f32>, // x: LUT-on flag; y: viewer altitude (0..1); z,w reserved
};
@group(0) @binding(0) var<uniform> S: SkyUniforms;
// **Physical-sky transmittance LUT** (FLIGHT-SIM #7). The flight crate's GPU
// scattering-LUT compute pass precomputes atmospheric transmittance-to-sun as an
// Rgba32Float texture (μ across width, altitude up height); when `S.lut.x > 0.5`
// the sun disc/glow is multiplied by the sampled transmittance so the sun reddens
// physically at low elevation. When off, a 1×1 all-ones default is bound and the
// analytic tint below is used unchanged (the CPU-fallback / embeddable path). The
// texture is Rgba32Float (not filterable) → a NON-filtering (nearest) sampler.
@group(0) @binding(1) var lut_tex: texture_2d<f32>;
@group(0) @binding(2) var lut_samp: sampler;
struct SkyOut {
@builtin(position) clip: vec4<f32>,
@location(0) ndc: vec2<f32>, // [-1,1] device coords (== clip.xy at w=1)
};
@vertex
fn sky_vs(@builtin(vertex_index) vi: u32) -> SkyOut {
// Fullscreen triangle (covers [-1,1]²); its clip xy ARE the NDC the rasterizer
// walks, so feeding inv_view_proj reconstructs rays matching the mesh pass — no
// manual y-flip needed (the matrix carries the convention).
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var out: SkyOut;
let xy = p[vi];
out.clip = vec4<f32>(xy, 0.0, 1.0);
out.ndc = xy;
return out;
}
// Reconstruct the world-space view ray direction for an NDC pixel: unproject the
// near (z=0) and far (z=1, wgpu clip-z) points and take their difference.
fn ray_dir(ndc: vec2<f32>) -> vec3<f32> {
let near = S.inv_view_proj * vec4<f32>(ndc, 0.0, 1.0);
let far = S.inv_view_proj * vec4<f32>(ndc, 1.0, 1.0);
let wn = near.xyz / near.w;
let wf = far.xyz / far.w;
return normalize(wf - wn);
}
// The analytic sky radiance along a world direction `dir` — gradient + sun disc +
// glow. Shared shape with the water reflection fallback (`water.wgsl::sky_radiance`)
// so the lake mirrors the same sky model.
fn sky_radiance(dir: vec3<f32>) -> vec3<f32> {
let l = normalize(S.sun_dir.xyz);
// ── Vertical gradient: horizon (warm haze) → zenith (cool sky). Bias toward the
// horizon band so the warm haze reads as a thick atmosphere, not a hard line.
let up = clamp(dir.y, -1.0, 1.0);
let bias = clamp(S.sky_color.w, 0.05, 4.0);
let t = pow(clamp(up, 0.0, 1.0), bias);
var col = mix(S.horizon.rgb, S.sky_color.rgb, t);
// Below the horizon: darken toward a ground-haze so a low camera still reads as
// "looking down at haze", not a mirrored sky.
let below = clamp(-up * 3.0, 0.0, 1.0);
col = mix(col, S.horizon.rgb * S.horizon.w, below);
// ── Sun disc + glow. cos(angle) between the ray and the sun; a tight smoothstep
// core is the disc, a wide pow() falloff is the atmospheric glow. The disc is
// HDR (≫1.0) so the shared bloom pass blooms it and the ACES tonemap rolls it to
// near-white — a real game-engine sun, not a clipped white dot.
let cosang = dot(dir, l);
// Disc core: ~0.6° radius (cos near 1.0). Soft edge for a clean anti-aliased rim.
let disc = smoothstep(0.99986, 0.99994, cosang);
// Broad glow halo around the sun (sky scattering toward the sun).
let glow = pow(clamp(cosang, 0.0, 1.0), 350.0);
// A faint, very wide forward-scatter brightening near the sun side of the sky.
let haze_glow = pow(clamp(cosang * 0.5 + 0.5, 0.0, 1.0), 4.0) * 0.18;
let sun_disc_i = max(S.sun_dir.w, 0.0);
let sun_glow_i = max(S.sun_color.w, 0.0);
// ── Sun tint: analytic by default; when the physical-sky LUT is bound, multiply
// by the atmospheric transmittance TOWARD THE SUN (a function of the sun's
// elevation μ = l·up and the viewer altitude), so the sun reddens as it sets —
// the on-screen result of the GPU scattering-LUT pass (FLIGHT-SIM #7).
var sun_tint = S.sun_color.rgb;
if (S.lut.x > 0.5) {
let mu_sun = clamp(l.y, -1.0, 1.0); // sun elevation cosine (l·up)
let u = clamp(mu_sun * 0.5 + 0.5, 0.0, 1.0); // μ ∈ [-1,1] → LUT width
let v = clamp(S.lut.y, 0.0, 1.0); // viewer altitude → LUT height
let trans = textureSampleLevel(lut_tex, lut_samp, vec2<f32>(u, v), 0.0).rgb;
sun_tint = sun_tint * trans;
}
col = col + sun_tint * (disc * sun_disc_i + glow * sun_glow_i + haze_glow);
return col;
}
@fragment
fn sky_fs(in: SkyOut) -> @location(0) vec4<f32> {
let dir = ray_dir(in.ndc);
// HDR linear radiance out (matches the mesh/water passes): the post stack
// (bright-pass → bloom → ACES) exposes + tonemaps the Rgba16Float offscreen.
return vec4<f32>(sky_radiance(dir), 1.0);
}