bevy_lights 0.5.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 smoke of a fire (`src/smoke.rs`, `FlameSmoke`): the plume that goes on up above the flame, lit by it. Drawn on ONE camera-facing quad
// per flame as a function of the clock alone (no particle state, no buffers): a column whose centreline leaves the flame's tip and bends
// downwind, whose width is a turbulent plume's, and whose inside is wisps of noise carried up it.
//
// Additive blending, no depth write, no prepass: the same material contract as the flame and the sparks. Smoke at night is visible only by the
// light the fire puts into it, which is exactly what an additive layer draws, and an additive layer joins no sorting (SIGGRAPH 2025 Advances,
// the OIT talk: blended VFX smoke is the case that sorts badly). `docs/research/flame-smoke.md` has the sources and the model.
//
// The quad is the ember quad's (`src/embers.wesl`): the same extents in flame heights, the same vertex stage, so it takes the fire's catch
// and the wind's lean from the flame's own instance scale and `ember_cull_extents` is its culling box. Change `HALF_WIDTH`, `RISE_MAX`,
// `MARGIN` or `LEAN_TOP` here and the port in `src/smoke.rs`'s culling test and `model/tag.rs`'s mirrors must change with them.

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 Smoke {
    // The fire's colour (`FlameLook::color`, or a site's `FlameSite::color`); `.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>,
    // The smoke's two colours, the sparks' orange and dull-red stops moved to the fire's hue by the CPU (`retint`): the lit smoke just above
    // the flame, and what it has cooled to near the top of the column.
    warm: vec4<f32>,
    cool: vec4<f32>,
    // The trail of a fire put out (the model's constants, `SmokeMaterial::tail`): x the trail's length in seconds, y the flame's snuff in seconds,
    // z how long the wick goes on making smoke, w the coals' glow against the flame's unit light.
    tail: vec4<f32>,
}

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

const TAU: f32 = 6.2831853;
// The same law as the flame's size (`flame.wesl`), so the smoke does not outgrow the flame it leaves.
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: the ember quad's.
const RISE_MAX: f32 = 2.6;
const HALF_WIDTH: f32 = 0.7;
const MARGIN: f32 = 0.35;
// How far downwind the column's top is, in flame heights per unit of the flame's lean: the ember quad's `DRIFT * RISE_MAX`. A plume in a
// crosswind is a bent-over plume, which rises as `x ∝ z^(3/2)` (Briggs), so the column's centreline is `LEAN_BEND * lean * rise^1.5`:
// the flame's tip (`rise = 1`) to within 7 %, and `LEAN_TOP` at `RISE_MAX` (`LEAN_TOP / RISE_MAX^1.5`).
const LEAN_TOP: f32 = 3.9;
const LEAN_BEND: f32 = 0.9303;
// Emission of a column at density 1, against a flame's core of 1.
const SMOKE_GAIN: f32 = 0.85;
// The flame's middle, where its light is and where the plume's virtual origin is, in flame heights.
const SOURCE: f32 = 0.35;
// 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;
// The lowest bit of the tag's 16-bit height: this quad is the trail of a fire put out, its scale's x is `1 / (1 + seconds since it went out)`
// (`src/smoke.rs`, `tail_scale`), and it is drawn at the height the fire burned at whatever the catch says.
const TAIL_BIT: u32 = 0x10000u;
// How fast the column's wisps travel along it, in path lengths a second: the noise below scrolls `along * 2.2 - t * 0.7`. The air carries the
// smoke a fire has made on at this speed, so a source that stops leaves a column whose base lifts off at it.
const ADVECT: f32 = 0.3181818;

struct SmokeVertexOutput {
    @builtin(position) position: vec4<f32>,
    // The pixel's place on the plane the smoke lives 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 density, `.y` the signed sideways lean per flame height (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,
    // Seconds since the fire went out for a trail, -1 for a plume that follows its flame.
    @location(6) @interpolate(flat) tau: f32,
}

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

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) -> SmokeVertexOutput {
    var out: SmokeVertexOutput;
    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 raw = mesh_functions::get_tag(vertex_in.instance_index);
    let trail = (raw & TAIL_BIT) != 0u;
    let scale_x = clamp(length(world_from_local[0].xyz), 0.0, 1.0);
    // A trail is drawn at the size the fire burned at: the catch reads as 1, and the scale's x is the clock.
    let took = select(scale_x, 1.0, trail);
    let tau = select(-1.0, 1.0 / max(scale_x, 0.01) - 1.0, trail);
    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
    // density, as a share of the most density a site can ask for (1).
    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.
    let wind_world = vec2<f32>(length(world_from_local[1].xyz), length(world_from_local[2].xyz)) - vec2<f32>(1.0);
    let lean = dot(wind_world, vec2<f32>(right.x, right.z));
    let reach = lean * LEAN_TOP;
    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.tau = tau;
    out.shape = vec4<f32>(f32(raw & 0xffu) / 255.0, lean, 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 at an integer lattice point.
fn lattice(p: vec2<i32>) -> f32 {
    return f32(pcg((u32(p.x) * 1597334677u) ^ (u32(p.y) * 3812015801u))) / 4294967295.0;
}

fn value_noise(p: vec2<f32>) -> f32 {
    let i = vec2<i32>(floor(p));
    let f = fract(p);
    let u = f * f * (3.0 - 2.0 * f);
    let a = lattice(i);
    let b = lattice(i + vec2<i32>(1, 0));
    let c = lattice(i + vec2<i32>(0, 1));
    let d = lattice(i + vec2<i32>(1, 1));
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// Three octaves, each turned and doubled, in 0..1. `detail` in 0..1 fades the octaves out toward their mean, so a column too far away for its
// wisps to be resolved is a soft haze and not a crawl of noise.
fn wisps(p: vec2<f32>, detail: f32) -> f32 {
    var sum = 0.0;
    var amp = 0.5;
    var q = p;
    for (var octave = 0; octave < 3; octave += 1) {
        sum += amp * mix(0.5, value_noise(q), detail);
        q = vec2<f32>(q.x * 0.8 - q.y * 0.6, q.x * 0.6 + q.y * 0.8) * 2.03 + vec2<f32>(17.0, 5.0);
        amp *= 0.5;
    }
    return sum / 0.875;
}

// The light a smoke column gives at `uv` (flame heights across and up the screen), `clock` seconds, `phase` radians, a `lean` of the flame
// along the camera's right, and the camera's elevation cosine. `pixel` is flame heights per pixel. `tau` is the seconds since the fire went
// out for the trail of one that has (`< 0` for a plume that follows its flame, which is then exactly the column below).
fn smoke_light(uv: vec2<f32>, clock: f32, phase: f32, lean: f32, cos_e: f32, pixel: f32, tau: f32) -> vec3<f32> {
    let trail = tau >= 0.0;
    // The flame's share that has gone: `1 - snuff_gain(tau)`, a smoothstep over the snuff's length.
    let dead = select(0.0, smoothstep(0.0, material.tail.y, tau), trail);
    // The column's own height above the foot: the screen's `up` is `rise * cos_e`, so a camera above the fire sees the column foreshortened.
    let rise = uv.y / max(cos_e, 0.35);
    if rise < mix(0.5, 0.15, dead) || rise > RISE_MAX {
        return vec3<f32>(0.0);
    }
    let t = clock + phase * 3.1;
    let above = max(rise - 0.7, 0.0);
    // The centreline leaves the flame's tip and bends downwind as a bent-over plume (`x ∝ z^1.5`), and a slow meander grows with height so a
    // calm column wanders. `slope` is how far sideways it goes per unit of rise, and `stretch` the length of the path per unit of rise.
    let reach = sqrt(rise);
    let meander = 0.16 * above * sin(0.8 * t + 1.9 * rise + phase);
    let centre = LEAN_BEND * lean * rise * reach + meander;
    let slope = 1.5 * LEAN_BEND * lean * reach;
    let stretch = sqrt(1.0 + slope * slope);
    // A trail (`docs/research/flame-smoke-tail.md`). The air carries the smoke a source made on, so what is at this height left the source
    // `age` seconds ago, which was `tau - age` seconds after the fire began to go out: before it (negative) the source was whole, after it the
    // wick was smouldering out (`made` is the share of the whole source it was). The column therefore keeps everything above
    // `SOURCE + ADVECT * tau`, and its base lifts off at that speed.
    var made = 1.0;
    if trail {
        made = 1.0 - smoothstep(0.0, material.tail.z, tau - max(rise * stretch - SOURCE, 0.0) / ADVECT);
    }
    // A turbulent plume spreads linearly from its virtual origin, the flame's middle (Morton, Taylor and Turner: `b = 1.2 α z`, α ≈ 0.13), in
    // the plume's own frame: a bent column is cut obliquely by the screen's horizontal, so its half-width across is `stretch` times that. It is
    // never drawn thinner than a pixel and a quarter: a thinner column keeps its energy in a wider, dimmer one instead of crawling. What a
    // weak source made is a thinner wisp than what the flame made.
    let true_width = (0.05 + 0.156 * max(rise - SOURCE, 0.0)) * stretch * mix(0.5, 1.0, made);
    let half_width = max(true_width, 1.25 * pixel);
    let across = uv.x - centre;
    if abs(across) > 2.4 * half_width {
        return vec3<f32>(0.0);
    }
    // Wisps, carried along the column and warped sideways by a curl that grows with height; the column's edge is ragged by the same curl. The
    // noise lives in the plume's own frame (across it and along its path), so a bent column is bent all the way through.
    let along = rise * stretch;
    let detail = 1.0 - smoothstep(0.02, 0.06, pixel);
    var p = vec2<f32>(across / stretch * 3.0, along * 2.2 - t * 0.7);
    let curl = value_noise(p * 0.55 + vec2<f32>(3.1, t * 0.15)) - 0.5;
    p.x += (0.5 + 0.9 * above) * curl;
    let lateral = (across + 0.45 * half_width * curl) / half_width;
    let envelope = exp(-lateral * lateral * 1.6);
    let body = 0.3 + 0.7 * smoothstep(0.2, 0.8, wisps(p + vec2<f32>(phase * 5.0, 0.0), detail));
    // The flame hides the column's first part, up to its tip; as the flame sinks that window slides down to the wick.
    let born = smoothstep(mix(0.55, 0.10, dead), mix(1.15, 0.45, dead), rise);
    let gone = 1.0 - smoothstep(1.4, RISE_MAX, rise);
    // Lit by the flame: an inverse square from its middle, softened so it does not blow up at the source. The distance is the straight one in
    // the plane the column lives in, not the path's: a bent column is as close to its fire as the wind lets it be.
    let away = length(vec2<f32>(uv.x, rise - SOURCE)) / 0.7;
    var lit = 1.0 / (1.0 + away * away);
    // A trail is lit by the dying flame while there is one and then by the glow of the coals, a broad weak light that does not fall off like a
    // point's, and it ends in a fade so that it is dark when its clock stops.
    var leaving = 1.0;
    if trail {
        let glow = material.tail.w / (1.0 + 0.25 * away * away);
        lit = (1.0 - dead) * lit + dead * glow;
        leaving = 1.0 - smoothstep(0.3, 1.0, tau / material.tail.x);
    }
    // The sparks' orange going to their dull red as the column cools, pulled toward its own luminance so it reads as warm grey smoke and not as
    // a second flame.
    var colour = mix(material.warm.rgb, material.cool.rgb, smoothstep(0.9, 2.4, rise));
    colour = mix(colour, vec3<f32>(dot(colour, vec3<f32>(0.2126, 0.7152, 0.0722))), 0.35);
    return colour * (SMOKE_GAIN * envelope * body * born * gone * lit * made * leaving * (true_width / half_width));
}

@fragment
fn fragment(in: SmokeVertexOutput) -> @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 density = in.shape.x;
    if density < 0.004 || in.took < 0.05 {
        return vec4<f32>(0.0);
    }
    let light = smoke_light(in.uv, material.params.x, in.phase, in.shape.y, in.shape.z, pixel, in.tau);
    if max(light.r, max(light.g, light.b)) <= 0.0 {
        return vec4<f32>(0.0);
    }
    let seen = source_visibility(in.fade, in.fade_margin) * in.took * in.took;
    return vec4<f32>(light * (density * material.params.w * seen), 0.0);
}