bevy_elements 0.5.1

Living environments for Bevy: sun, moon and stars, climate-driven weather, GPU wind, volumetric clouds, rain, snow and fog, lightning, terrain with hydraulic and thermal erosion, flooding, snow cover, wetness and dust — simulated in compute shaders.
Documentation
// Precipitation draw pass: rain streaks, snowflakes, dust sprites and splash rings as procedural
// quads (no vertex buffers), blended premultiplied into the HDR main texture after the main pass
// and soft faded against the depth prepass. See docs/research/precipitation.md ("Rendering
// geometry", "Coverage-preserving alpha", "Lighting", "Fades") and docs/research/dust.md
// ("Particles").
//
// Streaks and flakes are screen-space capsules from the particle's head to its tail
// p − (v − v_cam)·T. A dust sprite is drawn as a flake of the dust's albedo. Splashes are
// horizontal quads on the ground.

import bevy_render::view::View;
import bevy_elements::precipitation::common::{particle_rand, drop_diameter_mm};

struct Particle {
    pos: vec3<f32>,
    kind: u32,
    vel: vec3<f32>,
    life: f32,
}

struct SplashRing {
    counter: u32,
    pad0: u32,
    pad1: u32,
    pad2: u32,
    ring: array<vec4<f32>, 4096>,
}

struct DrawParams {
    // xyz: unit vector toward the sun. w: its direct illuminance, lx.
    sun: vec4<f32>,
    // xyz: unit vector toward the moon. w: its direct illuminance, lx.
    moon: vec4<f32>,
    // x: sky illuminance, lx. y: extinction σ_V, 1/m. z: soft fade, m. w: splash soft fade, m.
    ambient: vec4<f32>,
    // xyz: lightning flash position, m. w: intensity, cd (0 = none).
    flash: vec4<f32>,
    // x: Marshall–Palmer Λ, 1/mm. y: streak alpha. z: exposure time, s. w: edge fade, m.
    rain: vec4<f32>,
    // x: snow albedo. y: ring radius, m. z: ring life, s. w: shader clock, s.
    snow: vec4<f32>,
    // rgb: dust albedo. w: dust sprite diameter, m.
    dust: vec4<f32>,
    // xyz: camera velocity, m/s. w: box half edge, m.
    camera: vec4<f32>,
}

@group(0) @binding(0) var<uniform> view: View;
@group(0) @binding(1) var<uniform> draw: DrawParams;
@group(0) @binding(2) var<storage, read> particles: array<Particle>;
@group(0) @binding(3) var<storage, read> splashes: SplashRing;
@group(0) @binding(4) var scene_depth: texture_depth_2d;

const KIND_RAIN: f32 = 0.0;
const KIND_SNOW: f32 = 1.0;
const KIND_SPLASH: f32 = 2.0;
const TIME_WRAP: f32 = 1024.0;
const RAIN_PHASE_G: f32 = 0.8;
const PI: f32 = 3.14159265;
const NEAR_FADE_START: f32 = 0.15;
const NEAR_FADE_LENGTH: f32 = 0.35;
const SNOW_ALPHA: f32 = 0.8;
/// Opacity of one dust sprite: a cluster-scale veil of grains, so below a flake's.
const DUST_ALPHA: f32 = 0.5;
// A flake's image is its disc stretched by at most this many diameters along the relative
// velocity (Deviation 17 of the research note): at most 2:1 head to tail.
const SNOW_MAX_STRETCH: f32 = 1.0;
// Half width, px, below which a flake's image does not shrink: a soft blob (a rain streak's is
// half a pixel).
const SNOW_MIN_HALF_WIDTH: f32 = 1.0;
const SPLASH_ALPHA: f32 = 0.6;
const SPLASH_QUAD: f32 = 1.3;

struct VertexOut {
    @builtin(position) position: vec4<f32>,
    // Streaks and flakes: pixels across and along the capsule. Splashes: ring-radius units.
    @location(0) local: vec2<f32>,
    // x: half width, px (splash: age / life). y: length, px. z: view depth, m. w: kind.
    @location(1) @interpolate(flat) shape: vec4<f32>,
    // rgb: radiance times exposure and transmittance. a: opacity before coverage and fades.
    @location(2) @interpolate(flat) colour: vec4<f32>,
}

fn degenerate() -> VertexOut {
    return VertexOut(vec4<f32>(2.0, 2.0, 2.0, 1.0), vec2<f32>(0.0), vec4<f32>(0.0), vec4<f32>(0.0));
}

/// Henyey–Greenstein phase function, integrates to 1 over the sphere.
fn henyey_greenstein(cos_theta: f32, g: f32) -> f32 {
    let g2 = g * g;
    return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * cos_theta, 1.5));
}

/// Brightness of the lightning flash at `p` as an illuminance-like term (0 when there is no
/// flash); added to the sky light and, as its share of the total, makes rain more transparent
/// (Tatarchuk §3.6.2).
fn flash_at(p: vec3<f32>) -> f32 {
    let d = p - draw.flash.xyz;
    return draw.flash.w / max(dot(d, d), 1.0);
}

/// The fade at the sides of the box: 1 inside, falling to 0 over the outer `draw.rain.w` metres.
fn edge_fade(p: vec3<f32>) -> f32 {
    let half_edge = draw.camera.w;
    let d = abs(p - view.world_position);
    let m = max(max(d.x, d.y), d.z);
    return 1.0 - smoothstep(half_edge - draw.rain.w, half_edge, m);
}

fn corner_of(vi: u32) -> u32 {
    var corners = array<u32, 6>(0u, 1u, 2u, 2u, 1u, 3u);
    return corners[vi];
}

fn to_px(clip: vec4<f32>, size: vec2<f32>) -> vec2<f32> {
    let ndc = clip.xy / clip.w;
    return vec2<f32>((ndc.x * 0.5 + 0.5) * size.x, (0.5 - ndc.y * 0.5) * size.y);
}

@vertex
fn particle_vertex(
    @builtin(vertex_index) vi: u32,
    @builtin(instance_index) ii: u32,
) -> VertexOut {
    let p = particles[ii];
    let kind = p.kind & 3u;
    if kind > 2u {
        return degenerate();
    }
    let epoch = p.kind >> 2u;
    let size = view.viewport.zw;
    let focal_px = 0.5 * size.y * view.clip_from_view[1][1];
    let near = max(view.clip_from_view[3][2], 1e-3);

    let v_rel = p.vel - draw.camera.xyz;
    var diameter_m: f32;
    if kind == 0u {
        diameter_m = drop_diameter_mm(particle_rand(ii, epoch, 0u), draw.rain.x) * 1e-3;
    } else if kind == 1u {
        diameter_m = (4.0 + 4.0 * particle_rand(ii, epoch, 1u).w) * 1e-3;
    } else {
        diameter_m = draw.dust.w;
    }
    // The world streak: the drop's path in the exposure time. A flake is a soft disc: its
    // streak is capped at `SNOW_MAX_STRETCH` diameters, so wind does not draw it as a dash.
    var streak = v_rel * draw.rain.z;
    if kind != 0u {
        streak *= min(1.0, SNOW_MAX_STRETCH * diameter_m / max(length(streak), 1e-6));
    }
    var head = view.clip_from_world * vec4<f32>(p.pos, 1.0);
    var tail = view.clip_from_world * vec4<f32>(p.pos - streak, 1.0);
    if head.w < near && tail.w < near {
        return degenerate();
    }
    if head.w < near {
        head = mix(head, tail, (near - head.w) / (tail.w - head.w));
    }
    if tail.w < near {
        tail = mix(tail, head, (near - tail.w) / (head.w - tail.w));
    }
    let z = 0.5 * (head.w + tail.w);

    // Half width in px: the drop's own, but at least half a pixel (a flake's `SNOW_MIN_HALF_WIDTH`);
    // opacity follows the true coverage, relative to that minimum, so a sub-pixel drop fades as
    // 1/z (Garg & Nayar 2007, Eqs. 21–22).
    let min_half_width = select(SNOW_MIN_HALF_WIDTH, 0.5, kind == 0u);
    let true_half_width = 0.5 * diameter_m * focal_px / z;
    let half_width = max(true_half_width, min_half_width);
    let coverage = min(1.0, true_half_width / min_half_width);

    let head_px = to_px(head, size);
    let tail_px = to_px(tail, size);
    let along_vec = tail_px - head_px;
    let length_px = length(along_vec);
    var along_dir = vec2<f32>(0.0, 1.0);
    if length_px > 1e-4 {
        along_dir = along_vec / length_px;
    }
    let across_dir = vec2<f32>(-along_dir.y, along_dir.x);
    let margin = half_width + 0.5;

    let k = corner_of(vi);
    let at_tail = (k & 1u) == 1u;
    let side = f32(k >> 1u) * 2.0 - 1.0;
    let along_offset = select(-margin, length_px + margin, at_tail);
    let px = head_px + along_dir * along_offset + across_dir * side * margin;

    // Light: the drop refracts the environment; the flake scatters diffusely.
    let to_sun = draw.sun.xyz;
    let to_moon = draw.moon.xyz;
    let ray = normalize(p.pos - view.world_position);
    let flash = flash_at(p.pos);
    let sky = draw.ambient.x + flash;
    var radiance: vec3<f32>;
    var opacity: f32;
    if kind == 0u {
        radiance = vec3<f32>(sky / PI
            + draw.sun.w * henyey_greenstein(dot(to_sun, ray), RAIN_PHASE_G)
            + draw.moon.w * henyey_greenstein(dot(to_moon, ray), RAIN_PHASE_G));
        // Tatarchuk §3.6.2: rain turns more transparent under a flash.
        let flash_share = flash / max(sky, 1e-6);
        opacity = min(draw.rain.y * coverage, 0.9) * (1.0 - 0.5 * flash_share);
    } else {
        // A flake or a dust sprite scatters diffusely, with its own albedo.
        let albedo = select(draw.dust.xyz, vec3<f32>(draw.snow.x), kind == 1u);
        radiance = albedo * (sky + draw.sun.w * abs(dot(to_sun, ray))
            + draw.moon.w * abs(dot(to_moon, ray))) / PI;
        // Dust is drawn at cluster scale (`DUST_SPRITE_DRAW_M`): each sprite is a veil of many
        // grains, so it carries its own opacity — below a flake's — rather than a puff's.
        opacity = select(DUST_ALPHA, SNOW_ALPHA, kind == 1u) * coverage;
    }
    let transmittance = exp(-draw.ambient.y * distance(p.pos, view.world_position));
    opacity *= edge_fade(p.pos);

    var out: VertexOut;
    out.position = vec4<f32>(
        px.x / size.x * 2.0 - 1.0,
        1.0 - px.y / size.y * 2.0,
        0.5,
        1.0,
    );
    out.local = vec2<f32>(side * margin, along_offset);
    out.shape = vec4<f32>(half_width, length_px, z, select(KIND_RAIN, KIND_SNOW, kind != 0u));
    out.colour = vec4<f32>(radiance * view.exposure * transmittance, opacity);
    return out;
}

@vertex
fn splash_vertex(
    @builtin(vertex_index) vi: u32,
    @builtin(instance_index) ii: u32,
) -> VertexOut {
    let entry = splashes.ring[ii];
    if entry.w < 0.0 {
        return degenerate();
    }
    var age = draw.snow.w - entry.w;
    if age < 0.0 {
        age += TIME_WRAP;
    }
    let life = draw.snow.z;
    if age > life {
        return degenerate();
    }
    let k = corner_of(vi);
    let corner = vec2<f32>(f32(k & 1u) * 2.0 - 1.0, f32(k >> 1u) * 2.0 - 1.0) * SPLASH_QUAD;
    let world = entry.xyz + vec3<f32>(corner.x, 0.0, corner.y) * draw.snow.y;
    let centre = view.clip_from_world * vec4<f32>(entry.xyz, 1.0);
    let ray = normalize(entry.xyz - view.world_position);
    let radiance = (draw.ambient.x + draw.sun.w * max(draw.sun.y, 0.0)
        + draw.moon.w * max(draw.moon.y, 0.0)) / PI;
    let transmittance = exp(-draw.ambient.y * distance(entry.xyz, view.world_position));
    var out: VertexOut;
    out.position = view.clip_from_world * vec4<f32>(world, 1.0);
    out.local = corner;
    out.shape = vec4<f32>(age / life, 0.0, centre.w, KIND_SPLASH);
    out.colour = vec4<f32>(
        vec3<f32>(radiance) * view.exposure * transmittance,
        SPLASH_ALPHA * edge_fade(entry.xyz),
    );
    return out;
}

@fragment
fn particle_fragment(in: VertexOut) -> @location(0) vec4<f32> {
    var coverage: f32;
    var soft = draw.ambient.z;
    if in.shape.w == KIND_SPLASH {
        // An expanding ring that thins and fades as it ages.
        let age = in.shape.x;
        let radius = mix(0.35, 1.0, age);
        let ring = exp(-pow((length(in.local) - radius) / 0.1, 2.0));
        coverage = ring * (1.0 - age) * (1.0 - age);
        soft = draw.ambient.w;
    } else {
        let along = clamp(in.local.y, 0.0, in.shape.y);
        let d = length(vec2<f32>(in.local.x, in.local.y - along));
        if in.shape.w == KIND_RAIN {
            coverage = saturate(in.shape.x + 0.5 - d);
        } else {
            let s = saturate(1.0 - d / (in.shape.x + 0.5));
            coverage = s * s * (3.0 - 2.0 * s);
        }
    }
    // Soft particles: fade as the scene surface comes within `soft` of the particle, and as the
    // particle nears the camera. Reverse-Z infinite far: linear depth is near / depth.
    let depth = textureLoad(scene_depth, vec2<i32>(in.position.xy), 0);
    let near = max(view.clip_from_view[3][2], 1e-3);
    var scene_z = 1e9;
    if depth > 0.0 {
        scene_z = near / depth;
    }
    let z = in.shape.z;
    let fade = saturate((scene_z - z) / soft) * saturate((z - NEAR_FADE_START) / NEAR_FADE_LENGTH);
    let alpha = in.colour.a * coverage * fade;
    return vec4<f32>(in.colour.rgb * alpha, alpha);
}