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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# bevy_lights

Practical lights for Bevy 0.20: fires that flicker and flare as they kindle, a budgeted pool of real
lights placed over thousands of virtual ones, and billboard shader flames.

[![CI](https://github.com/Ladvien/bevy_lights/actions/workflows/ci.yml/badge.svg)](https://github.com/Ladvien/bevy_lights/actions/workflows/ci.yml)

> ⚠️ **Vibe Coded**
>
> This crate was written with heavy LLM assistance, under human direction and review. Every model is
> researched and every default is cited or marked a design default; the code is tested, gated in CI
> and reviewed against captures. It is still code an agent wrote, and you should treat it as such.

![Four braziers, each with a real light of its own, and 48 torch-bearers sharing an 8-light pool](docs/media/showcase.gif)

*Two seconds of `examples/courtyard`, 400 × 225 at 30 fps: four braziers — real lights, each with a
`Kindle` and a 0.34 m coal bed, which the puffing law sizes to `1.5/√0.34 ≈ 2.57 Hz` — and 48
torch-bearers, each a `PoolCandidate`, sharing the example's eight pooled real lights, of which the three best-ranked cast shadows (`LightPool::max_shadowed`); every figure throws a shadow from the braziers and from those three. Each brazier also throws `FlameEmbers` (`LIGHTS_EMBERS=4` in the capture, the example's default is off). The torch-bearers are walking, and their flames lean back from their motion (`FlameMotion`; the braziers, standing still, are upright). The clip opens
while the courtyard is still lighting up and the fires breathe from there; the coals are the
`char_field` texture the example builds at startup. The opening cannot show a **flame** catching: a
flame's catch runs on `Time<Real>`, and on a headless capture the material's first pipeline compile
outlasts `KINDLE_SECONDS`. A *re*-light does not have that problem, so the catch is the next clip.
[`docs/DEMOS.md`](docs/DEMOS.md) has that trap and the frequencies measured in this file. The same
courtyard at 640 × 360 is [`courtyard.gif`](docs/media/courtyard.gif).

![The courtyard's fires put out and lit again: the light and the flame fall away together, then catch together](docs/media/relight.gif)

*`LIGHTS_SNUFF=1.0 LIGHTS_RELIGHT=2.3` on the same courtyard, 400 × 225 at 30 fps, 64 frames (about 2.1 s): every
brazier and torch is snuffed (`Snuff`, 1.2 s) so the stone falls to the cool moonlight with only the coals glowing,
then re-lit (`Kindle`), and each flame grows out of its coals as its light flares up; sparks are `FlameEmbers`.

## What you get

- **Flickering, kindling fires** (`FlickerPlugin`): a point or spot light modulated below its base by a
  summed sine, at a rate its fuel bed sets and a depth the host chooses, plus a `Kindle` that flares a
  fire up over 0.6 s and a `Kindled` message for the sound of it, and a `Snuff` that puts one out over 1.2 s
  (light and linked flame together) with a `Snuffed` message for the hiss; both work on a pooled torch too, via its `PoolCandidate`,
  and a pooled fire that stays out costs the pool less than one that is lit, however many there are.
- **A budgeted pool of real lights** (`LightPoolPlugin::<YourFocusMarker>`): a fixed number of real
  point lights handed to the best-ranked candidates around the entity carrying your focus marker (or, with
  `FocusRule::Shared`, one per viewer, as in split-screen co-op),
  with hysteresis, a cross-fade and a per-frame hand-off budget, so a light that changes owner does not
  pop — and a shadow budget, so only the nearest few cast shadows and a shadow that moves fades rather
  than pops.
- **Flames drawn where the fire is** (`FlamePlugin`): one additive billboard material and one draw per
  flame, each breathing on the same curve as the light it motivates — and at that light's own rate,
  phase and depth when the site is given them, and catching again or going out when its light is re-lit or snuffed (`FlameSite::fire`).
  A fire carried or swung leans against its own motion, so a torch-bearer's flame streams behind them (`FlameMotion`). A fire can shed sparks (`FlameEmbers`) and go on up as a lit plume of smoke (`FlameSmoke`) that bends over in a wind, and a fire that is put out leaves a trail of that smoke lifting off and thinning for five seconds after its flame is gone. A fire wider than it is tall (a campfire, a bonfire) burns as a mound of tongues (`FlameTongues`), not one slender flame, on a bed of coals (`FuelBed`) that breathes with it and glows on, cooling, after it is put out.
- **A whole fire as one component** (`FirePlugin`, `Fire`): `Fire::lit(FireKind::Campfire, 250.0, 0.4)` on an entity gives it the kind's flame, the light the pool hands it, and its sparks, smoke, tongues and bed of coals, each fire on a clock of its own and in one colour if you name one; it is `serde`, so a placed fire is one line of a scene file.
- **The maths as plain functions** (`bevy_lights::model`): the flicker law and its depth, the kindle,
  the pool's hold-and-fade rules, the cinematographer's key against fill, the blackbody, and the
  temperature field of a fuel bed — no `bevy` in any of them, so a host can reuse them in its own
  systems.

## Install

`0.5` is on crates.io:

```toml
[dependencies]
bevy_lights = "0.5"
```

Everything this README describes is in that release. `main` may be ahead of it (`CHANGELOG.md`'s
`Unreleased`); depend on the branch if you want that:

```toml
[dependencies]
bevy_lights = { git = "https://github.com/Ladvien/bevy_lights", branch = "main" }
```

The crate is pinned to Bevy 0.20; its MSRV is Rust 1.97.1. `serde` is an optional feature: it derives
`serde` on the components and resources a host saves — `Flicker`, `Kindle`, `Snuff`, `PoolCandidate`, `PoolLight`,
`FlameSite`, `FlameLook`, `Rank`, `LightPool` — and turns on Bevy's own `serialize` for the colours they carry.
`PointLight` has no serde derive, so `LightPool` saves its slot template as the fields the pool copies onto
its lights — all of them but `intensity` — and every field is optional on the way in.

## Quick start

```rust
use bevy::prelude::*;
use bevy_lights::*;

/// The one thing the pool measures distance from: your camera focus, your player, a squad.
#[derive(Component)]
struct MyFocus;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((
            FlickerPlugin,
            LightPoolPlugin::<MyFocus>::default(),
            FlamePlugin,
            FirePlugin,
        ))
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    // A whole fire is one component. A campfire: the kind's flame, tongues, bed of coals, sparks and
    // smoke, and a light the pool hands it when it is near, sized so its pool puts 250 lx on the ground
    // at its edge (the light sits in the flame: 0.35 m up). Every fire gets a clock of its own.
    commands.spawn((Fire::lit(FireKind::Campfire, 250.0, 0.35), Transform::from_xyz(4.0, 0.0, 2.0)));
    // A torch in a colour of its own: the light is that colour and the flame is the same hue.
    commands.spawn((
        Fire { color: Some(Color::srgb(0.2, 0.9, 0.3)), ..Fire::new(FireKind::Torch) },
        Transform::from_xyz(-2.0, 1.5, 3.0),
    ));

    // The same parts by hand, when you want every number yourself. A brazier with a real light of its
    // own, sized by the pool it lights (1000 lx at 4 m, from 0.9 m up) and coloured by the temperature
    // it burns at.
    let lumens = fire_lumens(1000.0, 4.0, 0.9);
    let [r, g, b] = blackbody_colour(1900.0, 1.0);
    commands.spawn((
        PointLight { color: LinearRgba::rgb(r, g, b).into(), intensity: lumens, range: 10.0, ..default() },
        // `diameter` sizes the flicker by the fuel bed (`f = 1.5/√D` Hz); `None` uses the un-sized rate.
        Flicker { phase: 0.0, diameter: Some(0.34), ..Flicker::at_mean(lumens, FLICKER_DEPTH) },
        Kindle::default(),
        // `kindle` grows the flame with its light, on the same 0.6 s envelope, and `diameter` matches
        // the `Flicker`'s so the drawn flame breathes at the same rate the light does. `depth` is how
        // far it dips: give the flame the same one as its light, or the two disagree.
        FlameSite { height: 1.1, phase: 0.0, kindle: KINDLE_SECONDS, diameter: Some(0.34), depth: FLICKER_DEPTH, depth_fade: false, fire: None, color: None },
    ));

    commands.spawn((MyFocus, Transform::default()));

    // The fill: what the fires read against. The crate does not own the moon — a host adds the light and
    // asks the crate for the illuminance that makes the ratio it wants.
    commands.spawn(night_fill());
}

/// The night's fill: two stops under the fire. The crate does not own the moon, so the host adds it
/// and asks the crate for the illuminance.
fn night_fill() -> DirectionalLight {
    let [r, g, b] = white_balanced(8000.0, 2856.0, 1.0);
    DirectionalLight {
        color: LinearRgba::rgb(r, g, b).into(),
        illuminance: fill_for(1000.0, MOON_KEY),
        ..default()
    }
}
```

A light you want to be able to *re-light* is re-inserted with `Kindle::default()`, which writes
`Kindled` again. A fire you want dimmer or brighter is changed through the `Flicker` (`set_mean`, never the light's
`intensity`: the crate writes the intensity every frame).

`examples/courtyard.rs` is the full showcase, `examples/bench_lights.rs` the CPU bench for the pool and the flicker, and `examples/bench_flames.rs` the bench for thousands of flames with few of them on screen (it renders off-screen and needs a GPU adapter).

## Features

### Fires: flicker, depth and kindle

Every `PointLight` and `SpotLight` carrying `Flicker` is modulated **below** its base intensity by a
summed sine: the multiplier runs `1 - 2·depth ..= 1`, so `Flicker::base` is the fire's brightest moment
and a deeper flicker is a darker trough rather than a brighter crest. At the default `Flicker::depth`
of `FLICKER_DEPTH` that is 0.70–1.00 of base with a mean of 0.85 — the window a lumen budget written
against the mean assumes. `Flicker::depth` is how far that fire dips, so a lantern can ask for 0.03 and
a gusting torch for 0.3; `flicker_depth` clamps the value to `0..=MAX_FLICKER_DEPTH` (0.5, the deepest
whose trough is still zero) and reads a `NaN` as the default. `CALM_FLICKER_DEPTH` (0.05) and `WHIPPING_FLICKER_DEPTH` (0.3) name the two ends, and `flicker_mean(depth)` is the multiplier's mean, so a budget is written with `Flicker::at_mean(lumens, depth)` (or `PoolCandidate::at_mean`) rather than a hand-divided `0.85`. A system that rewrites a light's lumens every frame calls `Flicker::set_mean`, and a fire that changes how it breathes calls `Flicker::set_depth`, which keeps the mean: a deeper depth never silently dims the fire.

Give a fire its fuel-bed size (`Flicker { diameter: Some(0.34) }`) and its flicker runs at the measured
puffing rate, `1.5/√D` Hz — a brazier at 2.6 Hz, a pyre at 0.75 — so a large fire breathes more slowly
than a small one. `diameter: None` is the crate's inherited un-sized rate, for a fire whose size is not
modelled.

**Fire kinds.** `FireKind::{Candle, Lantern, Torch, Brazier, Campfire, Bonfire}` (`FireKind::ALL`, serde as the name) bundles a fuel-bed diameter, a flicker depth and a flame height, so a picker or a project file stores one word: `FireKind::Torch.flicker(lumens)` is a `Flicker` averaging `lumens` and `FireKind::Torch.site()` a `FlameSite`, built from the same three numbers so the light and its flame breathe together (spell the rest with `..`: `FlameSite { phase: 1.3, ..FireKind::Torch.site() }`). `rate()` is the puffing law at that size, 13 Hz for a candle to 0.87 Hz for a bonfire. Only the brazier row is a number the crate shipped before; the rest are design defaults (`docs/research/fire-kinds.md`). `LIGHTS_KIND=<name>` dresses the courtyard's braziers as a kind. `FireKind::reach()` is the metres of ground a kind lights to its pool's edge (1 m for a candle to 9 m for a bonfire), and `FireKind::light(lux, over)` is the `Flicker` whose lumens put `lux` there, so a picker places a fire without typing a lumen number; the brazier's 4 m and the torch's 2 m are the courtyard's own pools, the rest design defaults (`docs/research/fire-reach.md`).

A light with `Kindle` flares up over `KINDLE_SECONDS` (0.6 s) instead of switching on, on
`kindle_gain`'s curve: a smoothstep with a flare running ahead of it, so it is 0.85 of the way at half
time, tips nine tenths of one percent above its base at about 0.46 s, and settles at exactly 1.
`Kindled { entity }` is the message a host hears on the frame a fire catches, for the sound of it. An
invisible light is not rewritten (nothing is extracted for it) but its kindle still counts.

**A whole fire.** A fire is up to six components that must agree (`FlameSite`, `PoolCandidate`, and for the kinds that have them `FlameEmbers`, `FlameSmoke`, `FlameTongues`, `FuelBed`), and `Fire` is the one value that writes them: `Fire::lit(FireKind::Campfire, 250.0, 0.4)` (a kind, and the lux its pool puts on the ground at its edge from a light 0.4 m up) or `Fire { color: Some(blue), ..Fire::new(FireKind::Torch) }`. `FirePlugin` (add it with `FlamePlugin`, `FlickerPlugin` and `LightPoolPlugin`, which it reports missing rather than adds) gives the entity the kind's flame, a `PoolCandidate` the pool hands a light with the flame's own rate, depth and phase and the light standing in the flame, and the kind's sparks, smoke, tongues and bed. A `phase` of `None` is derived from the entity (`fire_phase`), so fires placed alike do not breathe together. `color` is the light's colour and the flame's middle at the shipped flame's brightness (`flame_mid`); `None` is a wood fire, a 1900 K light (`FIRE_KELVIN`; a `PoolCandidate` with no colour takes the pool template's, which is white) and the scene's own flame. `embers`, `smoke`, `tongues` and `bed` turn a kind's extra off (the bed stays coal-coloured under a coloured flame, so a magic fire turns it off). What a host spawns beside a `Fire` wins (`(Fire::new(kind), FlameSmoke { density: 0.2 })`), and a `Fire` that is changed afterwards (replaced, or edited through reflection or BRP) writes every part again and removes what it no longer names, so a picker that turns a campfire into a lantern leaves no tongues behind. Put it out and light it again with `Snuff` and `Kindle` on the same entity. It is `serde` with every field optional (`(kind: Campfire)` is a campfire). `docs/research/fire-component.md`.

![The six fire kinds, each one Fire component on an otherwise empty entity: a candle, a lantern, a torch, a brazier, a campfire and a bonfire, each with its flame, the warm pool its light throws on the ground and its own sparks, smoke, tongues and bed of coals where the kind has them](docs/media/fires.png)

*`LIGHTS_KIND=<kind> cargo run --release --example fires` (1578 × 654, an 8-bit PNG: frame 240 of six 1280 × 720 runs, each scaled to 520 × 293 with the kind's name burned in). The camera stands the same number of flame heights back, so each kind fills its panel. The brazier has no bowl in this example, so its flame stands on its bed of coals, which is the small red disc; every light is a pooled one the `Fire` was handed.*

### The light pool

A fixed number of real point lights (`LightPool::size`, default 16) is handed every frame to the
best-ranked `PoolCandidate`s around the entity carrying your focus marker (exactly one, unless the pool is shared, below), each light at the
candidate's position plus its own world-space offset. Everything else is a flame and nothing else. The
assignment is deterministic: rank, then `Entity`.

`PoolBudget` is the pool's lamp schedule, rewritten every frame: `holds(entity)` and `holder_of(slot)` say which candidates are real lights right now, `casts_shadow(entity)` (and `shadowed`) which of those lights throw a shadow, and `starved()` counts the candidates that wanted one and are only flames, so an editor can show the budget and a game can see `size` is too small.

**More than one viewer.** The pool measures from the entity carrying your focus marker, and by default needs exactly one: two are reported and leave the pool dark. A game with a split screen, or a map camera beside the player's, sets `LightPool::focus` to `FocusRule::Shared` and puts the marker on each viewer. The pool's `size` is then **shared**, not multiplied: each focus is entitled to `ceil(size / foci)` lights, ranked by `rank` among the candidates in `reach` of it, and what one viewer does not use goes to the others, so a viewer among twenty fires cannot take the lights of one standing by three (on a plain nearest-focus distance it would take all of them). A fire between two viewers is one light. A viewer joining or leaving re-divides the lights through the ordinary hand-off (give the pool `reserve` at least as large as the number of lights that move, and its total brightness holds at every frame). With one focus it is the default rule, to the bit; with two, the pool's own work on 5 000 candidates went from 490 to 619 µs (best of eight runs; four foci, 660 µs). Run `LIGHTS_SPLIT=1` on the courtyard example for two cameras on one window, and `LIGHTS_FOCUS_RULE=one` over them for the pool without the rule.

A candidate is ranked by the **light it puts on the subject** (`intensity / distance²`), not by its
distance alone, so a bright brazier at 3 m outranks a dim torch at 2 m; `LightPool::rank` switches to
`Rank::Distance` for the old rule. A slot keeps the candidate it holds until a challenger is
`hysteresis` times better (default 1.15, the overlap rule, measured in the same quantity the rank
uses), and only a candidate **no other slot holds** is ever a challenger, so a newcomer displaces the
pool's *worst* light rather than reshuffling all of them. A slot that does change owner fades back up
over `fade_seconds` (0.35 s) rather than popping, and `reserve` (default 1) both keeps a spare light so
the owner a hand-off leaves behind fades *out* at the same time — the subject's light is continuous
through the change rather than dipping, which is the craft's "overlapping key lights" — and caps how
many lights change owner in one frame, so a hand-off is never made without a cross-fade to hide it.

`PoolCandidate::phase`, `diameter` and `depth` let a slot adopt its winner's own fire clock, fuel-bed
rate and depth, so a light handed to a torch breathes as that torch's flame does. Each is optional:
`phase: None` leaves the slot's own `1.3 · slot` clock alone, `diameter: None` gives the pool's un-sized
rate, and `depth: None` restores `FLICKER_DEPTH` — so a host that models no per-prop fire keeps the
pool's uniform one.

**Shadows are budgeted apart from lights.** The template's `shadow_maps_enabled` turns the pool's
shadows on, and `LightPool::max_shadowed` caps them: `Some(3)` gives a shadow to the three best-ranked
lights the pool holds (the closest and brightest, held with the same `hysteresis`) and lets the rest
light without one; `None`, the default, shadows every light, as before. Every shadowed point light
renders six shadow views a frame — about 0.6 ms a light on an RTX 3090 in the courtyard — so sixteen
lights can afford three shadows where they could not afford sixteen. A light that earns or loses its
shadow because the ranking moved is cross-faded to itself through a reserve light, so the shadow
deepens over `fade_seconds` while the lit ground around it holds still; give a pool with shadows
`reserve: 2`, since hand-offs and shadow changes share the reserves. A change to the cap or the flag
applies at once, as a graphics setting should: shadows turned off, by the flag or a cap of `Some(0)`, are
off on that frame for the reserve lights fading a light out too.

**One template, every light, live.** Every field of `LightPool::light` but `intensity` and
`shadow_maps_enabled` is every pool light's: colour, range, radius, the shadow's biases and near plane,
`contact_shadows_enabled`, and Bevy's `soft_shadows_enabled` where its `experimental_pbr_pcss` feature
gives the light one. A change to the template reaches every light, reserves included, on the next frame,
so an editor's slider moves the whole pool and a light spawned later matches the ones already burning.
Contact and soft shadows are off by default: Bevy's contact shadows need a `ContactShadows` camera (and
TAA, or they dot a lit wall), and its point-light soft shadows only soften at a `radius` of metres, which
also widens every highlight.

### Shader flames

An additive billboard fire (`src/flame.wesl`) is drawn on every `FlameSite`, out of one material and one
draw per flame, with each fire's phase, height and brightness packed into its `MeshTag` so two fires
never burn in step. One shared shape clock, written once a frame, on `Time<Real>` — a paused world's
shape still moves.

A site with a `kindle` grows its flame from nothing on the same curve its light kindles by, so the two
arrive together. `FlameSite::diameter` given the same value as the light's `Flicker::diameter` makes the
flame **breathe at the light's own rate and phase**, so the fire and the ground it lights are one fire
rather than a still flame over a moving pool; `FlameSite::depth` is the same choice for the flame's
brightness, and giving it the light's `Flicker::depth` is what keeps the two dipping together.
`PoolCandidate::phase` does the same for a light the pool hands over.
`FlameSite::fire: Some(light)` removes the choice: the flame then reads the `diameter`, `depth` and `phase` of that
entity's `Flicker` (or `PoolCandidate`) every frame, so the two cannot be set apart and a `Flicker::set_depth` moves
the flame with it. The light need not be the site's parent; `None` keeps the site's own values.

A flame is taller than its torch, so a torch behind a wall edge can leave a tongue of fire hanging over the
wall with nothing under it. `FlameSite::depth_fade` fades the flame by how much of its source the camera
can see: the shader reads the depth prepass at the flame's source over a disc of taps, so a wall edge
sliding across a torch dims its flame over a few pixels instead of popping it. Give the camera
`DepthPrepass` (without it the flame draws whole); a site that leaves `depth_fade` `false` draws exactly
as before, with no depth read. `cargo run --release --example occlusion` shows both, and
`LIGHTS_DEPTH_FADE=1` runs the same scene faded.

**A fire's own colour.** `FlameSite::color: Option<Color>` gives one fire a colour of its own: a blue gas lantern, a green witch-fire or a violet shrine brazier among orange torches. `None` is the scene's `FlameLook::color`, drawn exactly as before. The colour is the middle of the flame's ramp and the rest is the same flame in that hue, a darker and more saturated rim and a paler core (`retint`, in `bevy_lights::model::fire_colour`); a red, orange or yellow keeps the shipped rim and core, which were tuned for them, so no existing scene changes. The flame's sparks (`FlameEmbers`) follow it. **Give the light the same hue**, so the flame and the ground it lights are one colour (a flame colour above 1 is a brighter fire, and a blue has half the luminance of the scene's orange at the same channel values, so scale a dim hue up on the flame, not on the light): the fire's own `PointLight::color` for a `Flicker`, `PoolCandidate::color` for a prop the pool lights (the pool's light takes it on the frame the light changes owner, while it is fading in from nothing, the light fading out keeps its owner's, and a candidate with no colour gets the template's, or your palette's, back). Every distinct colour is one more material and one more batch, shared by every flame of it and released with the last: a handful is free, and a colour changed every frame is a material a frame. `LIGHTS_COLOURS=1 cargo run --release --example courtyard` is the picture below, `docs/research/fire-colour.md` the sources.

![The courtyard with a blue and a green brazier among the orange ones and some blue and violet torches: each flame, its sparks and its light on the stone are one colour](docs/media/colours.png)

*`LIGHTS_COLOURS=1` on the courtyard (seed 7, pool 8, 1280 × 720, frame 300): braziers 1 and 2 are a gas-flame blue `(0.10, 0.32, 1.0)` and a witch-fire green `(0.20, 1.0, 0.30)`, braziers 0 and 3 stay the scene's orange, and every fifth torch carries a blue or a violet `(0.62, 0.20, 1.0)`. Each coloured fire has `FlameSite::color` on its flame (the blue ones scaled 1.8 and the violet 1.6 to be as prominent as the orange), the same hue on its light (`PointLight::color` for a brazier, `PoolCandidate::color` for a torch), and the stone round it is lit in that hue.*

![A campfire and a bonfire, each drawn first as one flame and then with FlameTongues: a mound of tongues of different heights leaning in towards the tallest, in place of one slender flame standing on a wide bed](docs/media/tongues.png)

*`cargo run --release --example campfire` (1280 × 720, frame 240; each panel a crop, the captions burned in): left, `LIGHTS_TONGUES=0`, the two `FireKind`s drawn as the one flame they were; right, the kinds' own `FlameTongues` (`FireKind::Campfire.tongues()`, `FireKind::Bonfire.tongues()`). Both have their kind's sparks and smoke, which stay one quad each.*

**Tongues.** A flame is one teardrop, `0.4` of its height wide: a candle's and a torch's shape, and the wrong one for a campfire's 0.8 m bed under a 0.7 m flame or a bonfire's 3 m pyre under a 2 m one, which drew one slender tongue in the middle of a heap of fuel. Add `FlameTongues { count, spread }` (`count` up to `MAX_TONGUES`, 9; `spread` the width of the bed, in your units; `FireKind::tongues()` gives a campfire's and a bonfire's, and `None` for the rest) to a `FlameSite` and the site keeps its own flame as the middle tongue and gains `count - 1` more `Flame` children round it, each a `FlameTongue`: shorter the further from the middle it stands, on a clock of its own, and leaning in towards the middle one, so the fire is a mound that parts and rejoins as one fire breathes and converges on a tip. `flame_tongue(index, count, spread, height, seed)` is the layout as a plain function; no tongue stands taller than three times its distance from the middle one, the height at which two flames that far apart are one flame (`docs/research/flame-tongues.md`). A tongue is an ordinary flame, so it catches and goes out with its fire, leans in a wind, fades behind a wall edge by the depth fade and is culled by a box of its own; it is one more draw, so a fire that does not ask pays nothing and a campfire is six draws. Changing the component at run time rebuilds the tongues and removing it takes them away.

**Embers.** Add `FlameEmbers { rate }` (sparks a second, `0..=MAX_EMBER_RATE`, default 3) to a `FlameSite` and its flame sheds sparks: short streaks, orange or the fire's own colour, that leave the flame, climb under drag, weave, and cool from yellow through orange to red before they go out (`src/embers.wesl`). It is one extra camera-facing quad per fire, a child of the flame, with no particle state: every spark is a function of the shared clock and an integer hash, so a fire's sparks are the same on every machine and cost one draw. The quad takes the flame's catch and wind lean, so a fire not yet caught sheds nothing, a snuffed one stops, and a wind carries the sparks 1.5 times as far downwind as it leans the flame; `depth_fade` hides the sparks of a hidden source too. A site without the component draws exactly as before. Measured with `LIGHTS_GPU_TIMING=1` on an RTX 3090 (the courtyard's 52 flames, 8 sparks a second each), the transparent pass takes about 11 µs without embers and 107 to 137 µs with them, so the sparks cost about 0.1 ms a frame there. `LIGHTS_EMBERS=3 cargo run --release --example courtyard` shows it; `docs/research/flame-embers.md` has the sources.

![The courtyard's brazier from 3 m at night: a warm-grey lit column rising from the flame's tip, and the same fire in a 3 m/s wind, the column bent over downwind](docs/media/smoke.png)

*`LIGHTS_SMOKE=0.8` (the default density) on the courtyard with `LIGHTS_EMBERS=4` (seed 7, pool 8, each panel a 680 × 680 crop of a 1280 × 720 frame at 300 frames): left, still air; right, `LIGHTS_WIND=3,0`. The smoke is lit by the flame and by nothing else, so it is brightest just above the tip and gone into the dark before the top of its quad.*

**Smoke.** Add `FlameSmoke { density }` (`0.0..=MAX_SMOKE_DENSITY`, default 0.8; `FireKind::smoke()` gives each kind its own) to a `FlameSite` and its flame goes on up as a faint plume: a column that leaves the flame's tip, widens the way a turbulent plume does and thins into the dark, whose wisps are lit by the fire (`src/smoke.wesl`). In a `FlameWind` it bends over downwind as a bent-over plume does, so the wind shows from across the courtyard. It is one more camera-facing quad per fire, a child of the flame, with no particle state: the column is a function of the shared clock, so it is the same on every machine and costs one draw, and a fire without the component pays nothing. The quad has the sparks' geometry and carries their culling box, so a fire whose smoke is off the screen draws none of it; it takes the flame's catch (a fire not yet caught smokes nothing; a fire put out leaves a trail, below), `FlameSite::color` (a green fire smokes green-grey) and `depth_fade`. The smoke is **additive**: at night smoke is visible only by the light the fire puts into it, and an additive layer joins no sorting against your other transparent surfaces. It has no dark, occluding half, so it is a night effect; a game that wants daylight smoke draws its own (`docs/research/flame-smoke.md` has the sources and why). `LIGHTS_SMOKE=d` turns it on in the courtyard, and `BENCH_SMOKE=1` for `bench_flames`.

![The courtyard's brazier from 3 m in a 3 m/s wind, in four frames: the fire burning with its plume and sparks, the flame out and the smoke bent over and lifting off, the base of the column well clear of the bowl, and the last thin remains](docs/media/smoke-tail.png)

*`LIGHTS_SNUFF=2.0 LIGHTS_SMOKE=0.8 LIGHTS_EMBERS=4 LIGHTS_WIND=3,0` on the courtyard (seed 7, pool 8, 1280 × 720, frames 120, 192, 264 and 336 of a 420-frame run, each scaled to 640 × 360; the captions are burned into the file): every brazier and torch is put out at 2.0 s with `Snuff`. The walkers are the example's own and pass in front of the bowl.*

**A put-out fire's trail.** The flame of a fire that gets a `Snuff` goes out over `SNUFF_SECONDS` (1.2 s), and its smoke does not go with it: the smoke already made is carried on up the way the air carries it, the base of the column lifts off the wick and thins as the wick smoulders out, and after `SMOKE_TAIL_SECONDS` (5 s) the trail ends, exactly dark, so nothing pops. It is lit by the dying flame and then by a weak, broad glow from the coals (`docs/research/flame-smoke-tail.md`: smoke density is advected, so a source switched off leaves what it made travelling on). It follows the light a site's `FlameSite::fire` names, on virtual time (a paused world's trail holds still); a site that names no fire has no douse to follow, a fire loaded as already out begins no trail, and re-lighting a fire ends its trail at once. It is the same quad, so it costs nothing a fire without a douse does not, and a scene that puts nothing out draws what it always drew. `LIGHTS_SNUFF=<seconds>` shows it in the courtyard (`docs/DEMOS.md`).

**Wind.** Insert `FlameWind { velocity }` (metres per second on the ground plane; the host copies it from
its weather, which this crate does not own) and every flame leans downwind by the Welker–Sliepcevich
pool-fire tilt for *its own fire's* `diameter`, so a brazier bends less than a torch in the same gust and
every flame in the scene bends the same way. The flame keeps its foot and its length and turns about its
foot (a 3 m/s wind lays a 0.34 m brazier's flame over by about 60°; the lean is capped at 70°), and it is
drawn upright from straight overhead. No `FlameWind` (or a zero one) draws every flame exactly as before.
`flame_tilt(speed, diameter)` is the same law as a plain function. The pool's light for a fire moves with the
lean, to where the leaned flame's middle is (`lean_light_shift`), so a fire built from a `FireKind` (a `Fire`, or its
`site()` and `candidate()`: its light is handed to the flame's middle, not its foot) keeps its key
light in the flame in a wind. `LIGHTS_WIND=x,z cargo run --release --example courtyard` shows it; `docs/research/flame-wind.md`
has the sources.

**Gusts.** Insert `FlameGust { mean, intensity, timescale, seed }` instead of writing `FlameWind` yourself and `FlamePlugin` writes `FlameWind::velocity` every frame from `gust_velocity`: the mean wind with a stationary Gaussian fluctuation of relative size `intensity` (the turbulence intensity, 0.25 by default) that stays correlated for `timescale` seconds (2 by default), with the heading wandering about `intensity` radians. It is a pure function of `Time<Real>` and the `seed`, so two machines see the same wind and a paused world's fires still sway. The tilt law is steep at a breeze and flat at a gale, so gusts read best at 0.2–0.8 m/s (a flame easing between roughly 20° and 40°); at 3 m/s the flame is already near its cap. `LIGHTS_WIND=0.4,0 LIGHTS_GUST=0.5 cargo run --release --example courtyard` shows it; `docs/research/flame-gust.md` has the sources and what is a design default.

**A fire that moves.** The air a moving fire meets is the air it moves through, so a torch carried at a walking pace is in a wind of its own: `FlamePlugin` tracks every `FlameSite`'s ground velocity from its `GlobalTransform` (a component, `FlameVelocity`, that a host reads and never writes) and each flame leans on the wind **less that velocity**, by the same `flame_tilt` of the relative speed. A bearer's flame streams behind them (a 0.1 m torch at 1.4 m/s is laid over by about 55°; at a stroll of 0.5 m/s, 40°), more at a jog, with the wind behind them less and into it more, and stands up again over about half a second when they stop; the pooled light stands in the leaned flame and the sparks and smoke stream with it. A fire that stands still is drawn exactly as before, and a lantern swung on a pole sways its flame against the swing with no code of yours. A host sets how much of its own velocity counts with `FlameMotion { gain, response }` (`1.0` is physics; `0.0` is every flame upright however it is carried; `response` is the filter's time constant, 0.15 s, which averages a fixed-step character's uneven frames). A step of more than 40 m/s in a frame is a teleport and carries no velocity. `smooth_velocity` and `relative_air` are the maths as plain functions; `LIGHTS_WALK=1.4 LIGHTS_KIND=torch cargo run --release --example fires` carries one, and `docs/research/flame-motion.md` has the sources.

![A torch carried to the right at 1.4 m/s and then stopped, in six frames: its flame, smoke and sparks stream behind it while it moves, and stand up again over about half a second after it stops](docs/media/carried.png)

*`LIGHTS_KIND=torch LIGHTS_WALK=1.4 LIGHTS_WALK_FOR=1.5` on `examples/fires.rs`, a `Fire::lit(FireKind::Torch, …)` and nothing else, carried along +X for 1.5 s and then still (six 420 px crops of 1280 × 720 frames, captured at a fixed 1/60 s step: frames 50 and 90, the last of the walk, then 100, 110, 120 and 140; the captions are burned into the file): the flame's bright axis is 57° and 50° from vertical in the first two frames (the law says 55°), 20° a third of a second after it stops and under 10° from half a second on, and the smoke and the sparks stream the same way while it moves.*

**A fire off the screen costs almost nothing.** A flame outside every camera's frustum is neither extracted nor drawn, so a town of thousands of `FlameSite`s costs the frame what the dozens in view cost it: `cargo run --release --example bench_flames` (no window, off-screen, a GPU adapter) puts 5 000 torches in the world with about twenty in view and reads 2.2 ms a frame, where drawing every one read 7.8 (20 000: 6.4 against 30.0). The box that does it is the crate's own, because the flame's catch and the wind's lean ride in the transform's scale and a box built once would shrink as a fire catches or leans: `flame_cull_extents` is that box, for a host that draws its own flames with `FlameMaterial`. The sparks of a `FlameEmbers` fire are culled the same way by a wider box that grows with the lean (`ember_cull_extents`): with `BENCH_EMBERS=1` every torch sheds sparks, and 5 000 of them read 4.2 ms a frame where drawing every quad read 11.5 (20 000: 9.2 against 37.2), the renderer counting about twenty quads in view instead of all of them. `docs/research/flame-culling.md` has the geometry and the measurements. A fire's *light* is the pool's, ranked by distance and never by what the camera sees, so a fire off the screen still lights the screen.

### The lighting look

The numbers a cinematographer sets, as plain functions in `bevy_lights::model::cinematography`:
`key_to_fill` and `stops` (with `LOW_KEY` 8:1, `MOON_KEY` 4:1 and `HIGH_KEY` 2:1), `fill_for`,
`fire_lumens` (the lumens a pool of a given size and brightness needs), `blackbody_colour` (a light's
colour from its temperature), `blackbody_luminance` (that temperature's **magnitude** in cd/m², which a
glowing *surface* needs and a light's colour does not carry) and `white_balanced` (the same as a camera
balanced elsewhere records it). Size a fire by the pool it lights and colour it by the temperature it
burns at, instead of picking lumens and an RGB by eye.

### The fuel bed

A bed of coals is a *surface with a temperature field on it*, and a constant `emissive` is the one thing
a real bed never is: it clips to a white slab the moment the camera looks down at it, and hides the flame
standing in it. `char_field` is the field — a heap of overlapping coal shards, bright lumps with dark ash
between them, seeded so a capture is reproducible — and `char_radiance` / `coal_radiance` map a sample to
a colour through char, ember and fuel. `coal_radiance` writes those three stops as **temperatures**, so a
bed's brightness and its hue come from one model and cannot drift apart at a different exposure.

**`FuelBed`: a bed that lives with its fire.** Add `FuelBed { diameter, seed, cooling }` to a `FlameSite`
(`FireKind::bed()` gives a campfire's, a bonfire's and a brazier's, and `None` for the rest) and the site gains one
flat disc at its foot with the field laid on it at the coals' own size (a wider bed has more coals, not larger ones):
dark ash with orange and yellow lumps in it, hottest under the flame and ragged at the rim. The coals follow
the site's fire (`FlameSite::fire`): they **breathe with its flicker**, **warm as the fire catches** (`Kindle`) and, when
it is put out (`Snuff`), **go on glowing and cool** with time constant `cooling` seconds (60 by default; the
glow is gone in a minute and a half) instead of vanishing with the flame, the way a campfire that is out is a ring of dull red
going to black (`docs/research/fuel-bed-heat.md`). A heat moves every temperature of the ramp by the same number of
kelvin, so the bed goes from orange through a deepening red to nothing, not through grey: `bed_radiance(field, heat)`
is that ramp and `bed_heat_step` the dynamics, as plain functions. One shared material draws every bed (a bed's own heat and
offset ride its `MeshTag`), so fifty campfires are fifty draws, and a site without the component pays nothing.

![A campfire's bed of coals from 2.4 m straight down, in four panels: burning, 0.75 s after the fire is put out with the flame nearly gone and the coals still orange, 2.0 s after with a dim ground and the coals glowing red, and 4.5 s after with only a dull red left](docs/media/fuel-bed.png)

*`cargo run --release --example campfire` with `LIGHTS_EYE=-2.6,2.4,0.5 LIGHTS_LOOK=-2.6,0,0 LIGHTS_SNUFF=1.0 LIGHTS_COOLING=6` (the campfire's `FireKind::bed()`; an 840 × 728 PNG of four 420 × 330 crops of a 1280 × 720 run at frames 30, 105, 180 and 330, the captions burned in): the fire is put out at 1.0 s, its light and flame are gone in 1.2 s and the coals go on glowing, cooling here with a 6 s time constant instead of the default 60 s so a capture can show it. The logs are lit by the fire's light and stand dark against the coals once it is out.*

`examples/courtyard.rs` still lays its own bed on its brazier bowls (an `Rgba32Float` image of the field as a box's `emissive_texture`, its numbers chosen by measuring the stops in display DN, because a ramp whose middle stop is past the display's white point cannot show the structure underneath it): a bowl is a prop with a soot lining the example paints by hand, and its bed does not follow the fire.

### Engine-free maths

Every curve the crate runs, as plain `f32` functions with no `bevy` in them, so a host can reuse them in
its own systems: `flicker_factor` and `flicker_factor_of` with `flicker_rate` and `PUFFING_CONSTANT`,
`flicker_swing` (the sized-or-un-sized choice, and the depth, the light and the flame both read) with
`flicker_depth` and `FLICKER_DEPTH` / `MAX_FLICKER_DEPTH`, `kindle_gain`, `raised_light` and
`flame_tilt` (how far a flame leans in a wind, with `FLAME_TILT_LIMIT`), `relative_air` and `smooth_velocity` (the air a moving fire meets, and the velocity it feels), `flame_light_offset` (where a fire's light belongs, so a flame and the real light it motivates cannot
drift apart), `pack_flame_tag` and its inverse with `set_flame_brightness`; the pool's `hand_off` and
`preferred` (the overlap rule, on distances and on any "smaller is better" quantity) with its
`fade_factor` / `fade_out_factor`, `HYSTERESIS` and `FADE_SECONDS`; the whole of
`model::cinematography` above, and the whole of `model::char` with `EMBER_AT`. `tests/leaf.rs` keeps them
engine-free.

### What a host touches

The components a game spawns or marks are `Reflect` and registered by the plugins — `Fire`, `Flicker`, `Kindle`,
`PoolCandidate`, `FlameSite` — so a BRP front end can read and drive them, and so are the resources a
host sets, `LightPool` (size, reach, gain, rank, hysteresis, fade, reserve, shadow cap, the slot light's template)
`FlameLook` (colour, strength, the set-once `max_height`), `FlameWind` / `FlameGust` (the wind the flames lean in, and a gusting one the plugin writes into it) and `FlameMotion` (how much of a moving fire's own velocity counts as wind). The crate spawns `PoolLight` (registered
too: recolour it for a palette; name its lights what you like) and `Flame`, a plain component a host
finds or counts and never spawns. `Kindled { entity }` is the message a host hears when a fire catches.

## Plays well with

This crate owns **practical lights** and nothing else.

| what | whose |
|---|---|
| Sun, moon, sky, exposure, weather, the lightning flash | [`bevy_elements`](https://github.com/Ladvien/bevy_elements) (this crate never meters exposure and never adds a `DirectionalLight`) |
| A gameplay illuminance grid (how lit a tile is, for AI or stealth); feed it a fire with `ground_ramp`, a linear ramp fitted to the two lux circles the grid is read at | [`bevy_light_grid`](https://github.com/Ladvien/bevy_light_grid) |
| Trees, forest-fire spread | [`bevy_flora`](https://github.com/Ladvien/bevy_flora) |
| Who lights a lamp, and when | [`bevy_society`](https://github.com/Ladvien/bevy_society) or the game |
| Flicker, kindle, the pool of real lights, the shader flames, the placement helpers | **this crate** |

A pool light is a light in mid-air with no source of its own: a host styles it (recolour it for the
palette) and usually tags it so its flame is not drawn there — the flame belongs on the torch.

## Built on

| crate | why |
|---|---|
| [`bevy`](https://github.com/bevyengine/bevy) 0.20 | the engine: `PointLight`, `SpotLight`, `Material`, `MeshTag` |
| [`serde`](https://github.com/serde-rs/serde) | optional derives on every public component and resource (`serde` feature) |

## The craft and the science

Every default is researched and cited, or marked a design default. The notes:

| note | what it settles | key sources |
|---|---|---|
| [`docs/research/fire-light.md`](docs/research/fire-light.md) | the flicker's rate and amplitude, the kindle's flare | Sanctuary decisions 0127/0148; Team Fortress 2's illustrative ramp (SIGGRAPH 2007 *Advances*); the fire-physics literature this crate is still missing |
| [`docs/research/light-pool.md`](docs/research/light-pool.md) | the pool's budget, reach, sort and phases | Olsson, Billeter & Assarsson 2012 (clustered shading); Ghost of Tsushima (SIGGRAPH 2021 *Advances*); *Stochastic tile-based lighting* (SIGGRAPH 2025 *Advances*); RTR4's many-light chapter |
| [`docs/research/flame.md`](docs/research/flame.md) | the flame's shape, noise, ramp and packing | Nguyen, *GPU Gems* ch. 6 §6.1; RTR4 §6.3; Sanctuary decision 0228 |
| [`docs/research/flame-flicker.md`](docs/research/flame-flicker.md) | why a drawn flame breathes with the light it motivates, and how the two share one function | Xia & Zhang 2018; SIGGRAPH 2011 *Advances*' fire talk; Doğaner on motivated sources |
| [`docs/research/flicker-depth.md`](docs/research/flicker-depth.md) | how far a fire dips, who chooses, and a measured ceiling for the default | Mandin & Most (IAFSS 6) on flame fluctuation; Gergely et al. 2020; Doğaner |
| [`docs/research/fuel-bed.md`](docs/research/fuel-bed.md) | why a bed is a temperature field and not a constant `emissive` | Pirk et al. 2017 (wood combustion); Xia & Zhang 2018; *PBR* 3rd ed. ch. 12 on blackbody emitters; Prince 2004 on clipped highlights |
| [`docs/research/corpus.md`](docs/research/corpus.md) | what the home-still library holds for cinematography, photography, CG lighting, game-lighting craft, many-light rendering, fire physics and perception — and what it does not | the live corpus |

The full index, with the notes behind the flame's silhouette, its catch and its read from overhead, is
[`docs/research/README.md`](docs/research/README.md).

## Bevy compatibility

| `bevy_lights` | Bevy |
|---|---|
| 0.1 | 0.20 |

## Documentation

`cargo doc --open`. The crate's own docs are the reference; `AGENTS.md` is the guide for anyone — human
or agent — changing it.

## Contributing

Read `AGENTS.md`. The short version: every feature lands with a research note, every default is cited or
marked a design default, `src/model/` stays engine-free, `src/` stays free of game vocabulary
(`tests/leaf.rs` enforces both), and a shader change is checked with `tools/wesl-check` before any
capture.

## License

MIT OR Apache-2.0, at your option.