// Sky / atmosphere shader.
//
// Rayleigh + Mie single-scattering with sun disk, rendered as a
// fullscreen triangle at the far plane (depth = 1.0).
//
// Based on the Mapbox GL JS skybox_capture scattering model
// (Bruneton & Neyret 2008) adapted for WGSL.
// ---------------------------------------------------------------------------
// Uniforms
// ---------------------------------------------------------------------------
struct SkyUniforms {
/// Inverse view-projection matrix (clip → world).
inv_view_proj: mat4x4<f32>,
/// Sun direction (unit, toward sun). w = sun_intensity.
sun_dir: vec4<f32>,
/// Rayleigh tint (linear RGB). w = sky_enabled (1 or 0).
rayleigh_tint: vec4<f32>,
/// Mie tint (linear RGB). w = opacity.
mie_tint: vec4<f32>,
};
@group(0) @binding(0) var<uniform> u: SkyUniforms;
// ---------------------------------------------------------------------------
// Physical constants (Earth-like atmosphere)
// ---------------------------------------------------------------------------
const PI: f32 = 3.14159265;
const PLANET_RADIUS: f32 = 6360000.0; // m
const ATMOS_RADIUS: f32 = 6420000.0; // m
const H_R: f32 = 8000.0; // Rayleigh scale height (m)
const H_M: f32 = 1200.0; // Mie scale height (m)
const BETA_R: vec3<f32> = vec3(5.5e-6, 13.0e-6, 22.4e-6); // Rayleigh β at sea level
const BETA_M: vec3<f32> = vec3(21.0e-6, 21.0e-6, 21.0e-6); // Mie β
const MIE_G: f32 = 0.76; // Mie asymmetry parameter
const PRIMARY_STEPS: i32 = 12;
const SHADOW_STEPS: i32 = 4;
// ---------------------------------------------------------------------------
// Vertex (fullscreen triangle)
// ---------------------------------------------------------------------------
struct VsOut {
@builtin(position) clip_position: vec4<f32>,
@location(0) ndc: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
// Three-vertex fullscreen triangle covering [-1,1]².
let x = f32(i32(vi) / 2) * 4.0 - 1.0;
let y = f32(i32(vi) % 2) * 4.0 - 1.0;
var out: VsOut;
out.clip_position = vec4(x, y, 1.0, 1.0); // depth = 1.0 (far plane)
out.ndc = vec2(x, y);
return out;
}
// ---------------------------------------------------------------------------
// Ray-sphere intersection
// ---------------------------------------------------------------------------
/// Returns the distance along `dir` from `origin` to the first
/// intersection with a sphere of the given radius centered at the
/// world origin. Returns -1 if no intersection.
fn ray_sphere(origin: vec3<f32>, dir: vec3<f32>, radius: f32) -> f32 {
let a = dot(dir, dir);
let b = 2.0 * dot(dir, origin);
let c = dot(origin, origin) - radius * radius;
let disc = b * b - 4.0 * a * c;
if disc < 0.0 { return -1.0; }
return (-b + sqrt(disc)) / (2.0 * a);
}
// ---------------------------------------------------------------------------
// Scattering
// ---------------------------------------------------------------------------
fn atmosphere(ray_dir: vec3<f32>, sun_dir: vec3<f32>, sun_intensity: f32,
rayleigh_tint: vec3<f32>, mie_tint: vec3<f32>) -> vec3<f32> {
// Camera on planet surface.
let origin = vec3(0.0, PLANET_RADIUS + 1.0, 0.0);
let t_max = ray_sphere(origin, ray_dir, ATMOS_RADIUS);
if t_max < 0.0 { return vec3(0.0); }
let ds = t_max / f32(PRIMARY_STEPS);
var total_r = vec3(0.0, 0.0, 0.0);
var total_m = vec3(0.0, 0.0, 0.0);
var opt_r: f32 = 0.0;
var opt_m: f32 = 0.0;
for (var i: i32 = 0; i < PRIMARY_STEPS; i++) {
let t = (f32(i) + 0.5) * ds;
let pos = origin + ray_dir * t;
let h = length(pos) - PLANET_RADIUS;
let rho_r = exp(-h / H_R) * ds;
let rho_m = exp(-h / H_M) * ds;
opt_r += rho_r;
opt_m += rho_m;
// Secondary ray toward sun for shadow optical depth.
let t_sun = ray_sphere(pos, sun_dir, ATMOS_RADIUS);
let ds_sun = t_sun / f32(SHADOW_STEPS);
var opt_r_sun: f32 = 0.0;
var opt_m_sun: f32 = 0.0;
for (var j: i32 = 0; j < SHADOW_STEPS; j++) {
let t_s = (f32(j) + 0.5) * ds_sun;
let pos_s = pos + sun_dir * t_s;
let h_s = length(pos_s) - PLANET_RADIUS;
opt_r_sun += exp(-h_s / H_R) * ds_sun;
opt_m_sun += exp(-h_s / H_M) * ds_sun;
}
let tau = BETA_R * (opt_r + opt_r_sun) + BETA_M * 1.1 * (opt_m + opt_m_sun);
let atten = exp(-tau);
total_r += rho_r * atten;
total_m += rho_m * atten;
}
// Phase functions.
let cos_theta = dot(ray_dir, sun_dir);
let cos2 = cos_theta * cos_theta;
// Rayleigh phase: 3/(16π) · (1 + cos²θ)
let phase_r = 3.0 / (16.0 * PI) * (1.0 + cos2);
// Mie phase: Henyey-Greenstein
let g2 = MIE_G * MIE_G;
let phase_m = 3.0 / (8.0 * PI) * ((1.0 - g2) * (1.0 + cos2))
/ ((2.0 + g2) * pow(1.0 + g2 - 2.0 * MIE_G * cos_theta, 1.5));
let scatter = sun_intensity * (
phase_r * BETA_R * total_r * rayleigh_tint +
phase_m * BETA_M * total_m * mie_tint
);
return scatter;
}
// ---------------------------------------------------------------------------
// Tone mapping (Uncharted 2 filmic)
// ---------------------------------------------------------------------------
fn uncharted2_partial(x: vec3<f32>) -> vec3<f32> {
let A = 0.15; let B = 0.50; let C = 0.10;
let D = 0.20; let E = 0.02; let F = 0.30;
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
}
fn tonemap(color: vec3<f32>) -> vec3<f32> {
let exposure_bias = 2.0;
let curr = uncharted2_partial(exposure_bias * color);
let white_scale = vec3(1.0) / uncharted2_partial(vec3(11.2));
return curr * white_scale;
}
// ---------------------------------------------------------------------------
// Fragment
// ---------------------------------------------------------------------------
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let enabled = u.rayleigh_tint.w;
if enabled < 0.5 {
discard;
}
// Reconstruct world-space ray direction from NDC.
let clip = vec4(in.ndc.x, in.ndc.y, 1.0, 1.0);
let world_h = u.inv_view_proj * clip;
let world = world_h.xyz / world_h.w;
// The camera is at the world origin for camera-relative rendering.
let ray = normalize(world);
let sun_dir = normalize(u.sun_dir.xyz);
let sun_intensity = u.sun_dir.w;
let rayleigh_tint = u.rayleigh_tint.xyz;
let mie_tint = u.mie_tint.xyz;
// Force ray y ≥ 0 (above horizon) with a small bias to avoid
// artefacts when looking exactly at the horizon.
var ray_clamped = ray;
ray_clamped.y = max(ray_clamped.y, 0.001);
ray_clamped = normalize(ray_clamped);
var color = atmosphere(ray_clamped, sun_dir, sun_intensity, rayleigh_tint, mie_tint);
// Sun disk.
let sun_cos = dot(ray, sun_dir);
let sun_disk = smoothstep(0.9998 - 1e-5, 0.9998 + 1e-5, sun_cos);
color += vec3(sun_disk) * sun_intensity * 0.04;
// Tone map + gamma.
color = tonemap(color);
color = pow(color, vec3(1.0 / 2.2));
let opacity = u.mie_tint.w;
return vec4(color, opacity);
}