bevy_lights 0.3.0

Practical lights for Bevy: fires that flicker and flare as they kindle, a budgeted pool of real lights placed over thousands of virtual ones, and billboard shader flames.
Documentation
// The embers of a fire (`src/embers.rs`, `FlameEmbers`): sparks that leave a flame, climb the plume and cool as they go. Drawn on ONE
// camera-facing quad per flame, as a function of the clock alone (no particle state, no buffers, no simulation): every spark's start,
// lane, speed and life are an integer hash of its slot and its cycle, so a spark is a pure function of time and a street of braziers
// costs one draw and sixteen capsule distances per pixel of the quad.
//
// Production practice (`docs/research/flame-embers.md`): spawn many fast particles, "use vertex shader to extrude particle along velocity
// to do motion blur/hide strobing" (SIGGRAPH 2011 Advances, Fire/Flamethrowers/Thrusters), so each spark here is a capsule along its own
// velocity, and "crossfade ... over the lifetime", so each spark's colour and brightness fall with its temperature. The cooling is
// Newton's law, the term Scintilla's ember model (SIGGRAPH Asia 2023, Sec. 5.7) integrates with Stefan-Boltzmann loss.
//
// The quad faces the camera at any elevation and every length is in flame heights, so a spark's world rise `h` lands `h * cos(elevation)`
// up the screen and its lane toward the camera lands `z * sin(elevation)` down it: the same sparks read as a plume from the side and as
// a scatter over the bowl from above. The flame's own instance scale carries over to the quad (its length along x is the fire's catch,
// the other two are the wind's lean), as `flame.wesl` reads them, because the quad is the flame's child.
//
// Additive blending, no depth write, no prepass: the same material contract as the flame.

import bevy_pbr::render::{
    forward_io::{Vertex, decompress_vertex},
    mesh_functions,
    mesh_view_bindings::view,
    view_transformations::{position_world_to_clip, depth_ndc_to_view_z},
};
@if(DEPTH_PREPASS)
import bevy_pbr::prepass::utils::prepass_depth;

struct Embers {
    // The host's fire colour; `.a` is the strength.
    base: vec4<f32>,
    // x: seconds (wrapped hourly), y: the tag's height scale in world units (`FlameLook::max_height`), z: 1 for the material of a site
    // that asked for the depth fade, w: the strength.
    params: vec4<f32>,
}

@group(constants::MATERIAL_BIND_GROUP) @binding(0) var<uniform> material: Embers;

const TAU: f32 = 6.2831853;
// Sparks drawn per flame: a spark's slot. Every slot emits one spark per cycle, and the cycle is `SLOTS / rate` seconds, so the flame
// emits `rate` sparks a second and never more than `SLOTS` are alive.
const SLOTS: u32 = 16u;
// The tag's rate byte is `rate / MAX_RATE`; `MAX_EMBER_RATE` in `src/embers.rs` is this number.
const MAX_RATE: f32 = 8.0;
// The same law as the flame's size (`flame.wesl`), so the sparks do not outgrow the flame they leave.
const CATCH_SIZE_EXPONENT: f32 = 0.6666667;
// How far up the quad reaches, in flame heights above the foot, and how wide it is either side of the axis.
const RISE_MAX: f32 = 2.6;
const HALF_WIDTH: f32 = 0.7;
// The quad's margin around the sparks, in flame heights.
const MARGIN: f32 = 0.35;
// How far a spark's drift outruns the flame's lean: a spark has no body to hold it in the plume.
const DRIFT: f32 = 1.5;
// A spark's life, in seconds, and the drag its climb decays with (per second).
const LIFE_MIN: f32 = 0.9;
const LIFE_MAX: f32 = 1.8;
const DRAG: f32 = 1.3;
// The streak: how many seconds of its own motion the capsule trails behind the spark.
const STREAK: f32 = 0.035;
// A spark's radius in flame heights (the glow's core), and the floor under it in pixels, below which the spark's energy is kept and its
// size is not (so a far fire's sparks shrink in brightness, not into aliasing).
const RADIUS_MIN: f32 = 0.010;
const RADIUS_MAX: f32 = 0.018;
const RADIUS_PIXELS: f32 = 1.2;
// Emission at a spark's hottest, against a flame's core of 1: sparks are brighter than the flame they leave, and bloom.
const SPARK_GAIN: f32 = 4.0;
const CORE_YELLOW: vec3<f32> = vec3<f32>(1.0, 0.78, 0.30);
const ORANGE: vec3<f32> = vec3<f32>(1.0, 0.42, 0.07);
const DULL_RED: vec3<f32> = vec3<f32>(0.80, 0.11, 0.01);
// The depth fade, the flame's own (`flame.wesl`): the source is a third of the way up the flame, the ring of taps is this fraction of its
// height, and a tap hides the source when the scene is nearer by FADE_MARGIN of the flame's height.
const SOURCE_HEIGHT: f32 = 0.35;
const FADE_RADIUS: f32 = 0.10;
const FADE_MARGIN: f32 = 0.15;

struct EmberVertexOutput {
    @builtin(position) position: vec4<f32>,
    // The pixel's place on the plane the sparks live in, in flame heights: `.x` across, `.y` up the screen from the flame's foot.
    @location(0) uv: vec2<f32>,
    @location(1) @interpolate(flat) phase: f32,
    // The fire's catch (its instance scale).
    @location(2) @interpolate(flat) took: f32,
    // `.x` the sparks a second, `.y` the signed sideways drift per flame height of rise (along the camera's right), `.z` the cosine and `.w`
    // the sine of the camera's elevation over the source.
    @location(3) @interpolate(flat) shape: vec4<f32>,
    @location(4) @interpolate(flat) fade: vec4<f32>,
    @location(5) @interpolate(flat) fade_margin: f32,
}

// ---- The depth fade, as `flame.wesl` has it: a fire whose source cannot be seen sheds no sparks -----------------------------

fn pixel_of(ndc: vec2<f32>) -> vec2<f32> {
    return view.viewport.xy + vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5) * view.viewport.zw;
}

fn fade_probe(origin: vec3<f32>, right: vec3<f32>, size: f32) -> vec4<f32> {
    if material.params.z < 0.5 {
        return vec4<f32>(0.0);
    }
    let source = origin + vec3<f32>(0.0, 1.0, 0.0) * (size * SOURCE_HEIGHT);
    let c0 = position_world_to_clip(source);
    let c1 = position_world_to_clip(source + right * (size * FADE_RADIUS));
    if c0.w <= 0.0 || c1.w <= 0.0 {
        return vec4<f32>(0.0);
    }
    let n0 = c0.xyz / c0.w;
    let p0 = pixel_of(n0.xy);
    let p1 = pixel_of(c1.xy / c1.w);
    return vec4<f32>(p0, depth_ndc_to_view_z(n0.z), clamp(distance(p0, p1), 2.0, 24.0));
}

@if(DEPTH_PREPASS)
fn tap_clear(pixel: vec2<f32>, source_z: f32, margin: f32) -> f32 {
    let low = view.viewport.xy;
    let high = view.viewport.xy + view.viewport.zw;
    if pixel.x < low.x || pixel.y < low.y || pixel.x >= high.x || pixel.y >= high.y {
        return 1.0;
    }
    let scene_z = depth_ndc_to_view_z(prepass_depth(vec4<f32>(pixel, 0.0, 1.0), 0u));
    return select(1.0, 0.0, scene_z > source_z + margin);
}

@if(DEPTH_PREPASS)
fn source_visibility(fade: vec4<f32>, margin: f32) -> f32 {
    if fade.w <= 0.0 {
        return 1.0;
    }
    let c = fade.xy;
    let r = fade.w;
    let d = fade.z;
    var seen = tap_clear(c + vec2<f32>(0.17678, 0.00000) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.22577, 0.20683) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(0.03456, -0.39377) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(0.28457, 0.37117) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.52222, -0.09237) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(0.49470, -0.31468) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.16547, 0.61553) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.31556, -0.60759) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(0.68464, 0.25003) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.71226, 0.29401) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(0.34335, -0.73373) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(0.25373, 0.80893) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.76475, -0.44319) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(0.89713, -0.19723) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.54751, 0.77877) * r, d, margin);
    seen += tap_clear(c + vec2<f32>(-0.12649, -0.97609) * r, d, margin);
    return smoothstep(0.0, 1.0, seen / 16.0);
}

@if(!DEPTH_PREPASS)
fn source_visibility(fade: vec4<f32>, margin: f32) -> f32 {
    return 1.0;
}

// -----------------------------------------------------------------------------------------------------------------------

@vertex
fn vertex(vertex_in: Vertex) -> EmberVertexOutput {
    var out: EmberVertexOutput;
    let vertex = decompress_vertex(vertex_in, vertex_in.instance_index);
    let world_from_local = mesh_functions::get_world_from_local(vertex_in.instance_index);
    let origin = world_from_local[3].xyz;
    let took = clamp(length(world_from_local[0].xyz), 0.0, 1.0);
    let right = normalize(vec3<f32>(view.world_from_view[0].x, 0.0, view.world_from_view[0].z));
    let up = vec3<f32>(0.0, 1.0, 0.0);
    // The tag is the flame's (`pack_flame_tag`): the height in the high 16 bits, the clock phase in the next byte, and in the low byte
    // the rate, as a share of MAX_RATE.
    let raw = mesh_functions::get_tag(vertex_in.instance_index);
    let tag = unpack2x16unorm(raw);
    let size = tag.y * material.params.y * pow(max(took, 0.0001), CATCH_SIZE_EXPONENT);
    let centre = origin + up * (size * SOURCE_HEIGHT);
    let to_eye = view.world_from_view[3].xyz - centre;
    let sin_e = clamp(to_eye.y / max(length(to_eye), 1e-4), 0.0, 1.0);
    let cos_e = sqrt(max(1.0 - sin_e * sin_e, 0.0));
    // The wind's lean, as the flame reads it: `1 + sin θ · direction` on the instance scale's other two lengths. A spark drifts DRIFT
    // times as far sideways as the flame's axis leans.
    let wind_world = vec2<f32>(length(world_from_local[1].xyz), length(world_from_local[2].xyz)) - vec2<f32>(1.0);
    let drift = DRIFT * dot(wind_world, vec2<f32>(right.x, right.z));
    let reach = drift * RISE_MAX;
    let x_low = -HALF_WIDTH + min(reach, 0.0);
    let x_high = HALF_WIDTH + max(reach, 0.0);
    let v_low = -MARGIN;
    let v_high = RISE_MAX * cos_e + MARGIN;
    let screen_up = normalize(cross(normalize(to_eye), right));
    let across = mix(x_low, x_high, vertex.position.x + 0.5);
    let climb = mix(v_low, v_high, vertex.position.y + 0.5);
    let billboard = origin + right * (across * size) + screen_up * (climb * size);
    out.position = position_world_to_clip(billboard);
    out.uv = vec2<f32>(across, climb);
    out.phase = f32((raw >> 8u) & 0xffu) / 255.0 * TAU;
    out.took = took;
    out.shape = vec4<f32>(f32(raw & 0xffu) / 255.0 * MAX_RATE, drift, cos_e, sin_e);
    out.fade = fade_probe(origin, right, size);
    out.fade_margin = size * FADE_MARGIN;
    return out;
}

fn pcg(v: u32) -> u32 {
    let s = v * 747796405u + 2891336453u;
    let w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u;
    return (w >> 22u) ^ w;
}

// A reproducible number in 0..1 from a seed and which of a spark's traits it is.
fn rnd(seed: u32, trait_id: u32) -> f32 {
    return f32(pcg(seed ^ (trait_id * 2654435761u))) / 4294967295.0;
}

// The distance from `p` to the segment `a`..`b`.
fn segment_distance(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
    let ab = b - a;
    let along = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-9), 0.0, 1.0);
    return distance(p, a + ab * along);
}

@fragment
fn fragment(in: EmberVertexOutput) -> @location(0) vec4<f32> {
    // Flame heights per pixel, taken before any branch (a derivative needs uniform control flow).
    let pixel = max(max(abs(dpdx(in.uv.x)), abs(dpdy(in.uv.x))), max(abs(dpdx(in.uv.y)), abs(dpdy(in.uv.y))));
    let rate = in.shape.x;
    if rate < 0.01 || in.took < 0.05 {
        return vec4<f32>(0.0);
    }
    let cycle = f32(SLOTS) / rate;
    let clock = material.params.x / cycle;
    let phase_bits = u32(in.phase * 40.0);
    var light = vec3<f32>(0.0);
    for (var i = 0u; i < SLOTS; i += 1u) {
        let turns = clock + f32(i) / f32(SLOTS) + in.phase / TAU;
        let n = floor(turns);
        let seed = pcg(i * 7919u + u32(n) * 104729u + phase_bits * 1299709u);
        let life = min(mix(LIFE_MIN, LIFE_MAX, rnd(seed, 0u)), cycle);
        // The spark is born somewhere in the cycle's slack, so the emission is not a metronome.
        let born = rnd(seed, 1u) * (cycle - life);
        let age = (turns - n) * cycle - born;
        if age < 0.0 || age >= life {
            continue;
        }
        let rise_start = mix(0.45, 0.80, rnd(seed, 2u));
        let speed = mix(0.45, 1.0, rnd(seed, 3u));
        let lane = (rnd(seed, 4u) - 0.5) * 0.22;
        let depth = (rnd(seed, 5u) - 0.5) * 0.30;
        let sway = mix(0.04, 0.12, rnd(seed, 6u));
        let sway_rate = mix(2.0, 5.0, rnd(seed, 7u));
        let sway_phase = rnd(seed, 8u) * TAU;
        // A spark climbs under drag: the height is the integral of `speed * exp(-DRAG * t)`.
        let held = exp(-DRAG * age);
        let rise = rise_start + speed * (1.0 - held) / DRAG;
        let climb_rate = speed * held;
        let weave = sway * (0.3 + age) * sin(sway_rate * age + sway_phase);
        let weave_rate = sway * (0.3 + age) * sway_rate * cos(sway_rate * age + sway_phase) + sway * sin(sway_rate * age + sway_phase);
        let across = lane + weave + in.shape.y * rise;
        let across_rate = weave_rate + in.shape.y * climb_rate;
        let head = vec2<f32>(across, rise * in.shape.z - depth * in.shape.w);
        let velocity = vec2<f32>(across_rate, climb_rate * in.shape.z);
        let tail = head - velocity * STREAK;
        let share = age / life;
        // Newton cooling: the spark's temperature share falls exponentially from its birth, and its colour and emission follow it.
        let heat = exp(-1.4 * share);
        let radius_true = mix(RADIUS_MAX, RADIUS_MIN, rnd(seed, 9u)) * (1.0 - 0.45 * share);
        let radius = max(radius_true, RADIUS_PIXELS * pixel);
        let reach = segment_distance(in.uv, tail, head) / radius;
        if reach > 5.0 {
            continue;
        }
        // A hot core with a small halo: sparks are points, and a wide glow reads as a smudge.
        let glow = (1.0 - smoothstep(0.5, 1.5, reach)) + 0.18 / (1.0 + reach * reach) * (1.0 - smoothstep(3.0, 5.0, reach));
        var colour = mix(DULL_RED, ORANGE, smoothstep(0.25, 0.55, heat));
        colour = mix(colour, CORE_YELLOW, smoothstep(0.55, 0.90, heat));
        // A spark too small for the pixel is drawn at the pixel's size and dimmed by the width it lost, so a far fire's sparks fade
        // instead of crawling.
        let energy = radius_true / radius;
        let wink = 0.75 + 0.25 * sin(sway_rate * 9.0 * age + sway_phase);
        let envelope = smoothstep(0.0, 0.08, share) * (1.0 - smoothstep(0.70, 1.0, share));
        light += colour * (glow * heat * sqrt(heat) * envelope * wink * min(energy, 1.0) * SPARK_GAIN);
    }
    let seen = source_visibility(in.fade, in.fade_margin) * in.took * in.took;
    return vec4<f32>(light * material.params.w * seen, 0.0);
}