bevy_lights 0.4.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
425
426
427
428
429
430
431
432
433
434
435
//! Fire light: how a fire's intensity moves from frame to frame, and how it takes hold when it is
//! first lit.
//!
//! The **rate** is measured: a buoyant diffusion flame puffs at `f = 1.5 / √D` Hz for a fuel bed `D`
//! metres across (Cetegen & Ahmed 1993; Xia & Zhang 2018, whose fit to six experimental studies is
//! `f = 0.48 √(g/D)`). [`flicker_rate`] is that law and [`flicker_factor_of`] is the flicker sized by
//! it. The **shape** — two sines, `0.85 ± 0.15`, the second at 1.7 times the first — and the kindle
//! flare are design defaults inherited from Sanctuary (decision 0127), kept so a migrated world's
//! fires look the same on the day the crate is adopted. `flicker_factor` is the shape at Sanctuary's
//! fixed rates, for a host with no size to give.
//!
//! `docs/research/fire-light.md` records the law, its constants and the two papers it comes from.

use core::f32::consts::{PI, TAU};

/// The constant in the puffing-frequency law: `f = 1.5 / √D` Hz.
///
/// From Cetegen & Ahmed 1993 (`f ∝ D^-1/2` with a factor of about 1.5), restated by Xia & Zhang 2018
/// as `f = 0.48 √(g/D)` with `g = 9.8 m/s²`; the two are the same law, since `0.48·√9.8 = 1.503`.
pub const PUFFING_CONSTANT: f32 = 1.5;

/// The narrowest a fuel bed may be before the law is asked for a rate it does not describe, in the
/// host's world units (metres for a metre world). At 1 cm the rate is 15 Hz, which is a candle flame
/// rather larger than a wick; below this the rate is clamped and stays finite.
const MIN_BED: f32 = 0.01;

/// How fast a fire of fuel-bed diameter `diameter` flickers, in hertz: `1.5 / √diameter`.
///
/// The law is the literature's: a buoyant diffusion flame sheds a toroidal vortex at a frequency set
/// by the fuel bed's size, faster for a small fire and slower for a large one (Cetegen & Ahmed 1993;
/// Xia & Zhang 2018). A 0.34 m coal bed is 2.6 Hz; a 1 m brazier 1.5 Hz; a 4 m pyre 0.75 Hz.
///
/// `diameter` is in the host's world units (metres for a metre world) and is clamped at 1 cm from
/// below, so a degenerate bed gives a fast finite rate rather than an infinity. A `NaN` is 1 Hz.
///
/// ```
/// # use bevy_lights::model::fire::*;
/// assert!((flicker_rate(1.0) - 1.5).abs() < 1e-5);
/// assert!((flicker_rate(0.34) - 2.573).abs() < 1e-2, "a brazier's coals");
/// assert!(flicker_rate(0.1) > flicker_rate(1.0), "a small fire is faster");
/// ```
pub fn flicker_rate(diameter: f32) -> f32 {
    if diameter.is_nan() {
        return 1.0;
    }
    PUFFING_CONSTANT / diameter.clamp(MIN_BED, 1.0e6).sqrt()
}

/// The flicker multiplier of a fire whose fuel bed is `diameter` across, at time `t` seconds and clock
/// `phase` radians: the same two-sine shape as [`flicker_factor`], run at the rate the law gives.
///
/// The first sine runs at `f = `[`flicker_rate`]`(diameter)` hertz (`ω = 2πf` rad/s) and the second at
/// 1.7 times it, so two fires of different sizes do not flicker in step *and* a larger fire's light
/// moves more slowly than a smaller one's — which is the whole point of sizing it. The window and the
/// mean are the same as [`flicker_factor`]'s (`0.85 ± 0.15`), because a lumen budget is written
/// against that mean.
///
/// ```
/// # use bevy_lights::model::fire::*;
/// for diameter in [0.1, 0.34, 1.0, 4.0] {
///     let mut t = 0.0;
///     while t <= 5.0 {
///         let f = flicker_factor_of(diameter, t, 0.0);
///         assert!((0.70..=1.00).contains(&f));
///         t += 0.01;
///     }
/// }
/// ```
pub fn flicker_factor_of(diameter: f32, t: f32, phase: f32) -> f32 {
    flicker_swing(Some(diameter), FLICKER_DEPTH, t, phase)
}

/// The depth of the flicker a fire has when the host does not choose one: the light dips up to
/// `0.15` below its peak, so the multiplier runs `0.70..=1.00` with mean `0.85`.
///
/// A design default inherited from Sanctuary (decision 0127), recorded in `docs/research/fire-light.md`.
pub const FLICKER_DEPTH: f32 = 0.15;

/// The deepest flicker a host may ask for. The multiplier then runs `0.00..=1.00`: a fire that gutters
/// all the way out and back. Any deeper would go below zero, so [`flicker_depth`] clamps here.
pub const MAX_FLICKER_DEPTH: f32 = 0.5;

/// A host's flicker depth made safe to use: clamped to `0..=`[`MAX_FLICKER_DEPTH`], with a `NaN`
/// read as the default [`FLICKER_DEPTH`].
///
/// ```
/// # use bevy_lights::model::fire::*;
/// assert_eq!(flicker_depth(0.3), 0.3);
/// assert_eq!(flicker_depth(-1.0), 0.0);
/// assert_eq!(flicker_depth(9.0), MAX_FLICKER_DEPTH);
/// assert_eq!(flicker_depth(f32::NAN), FLICKER_DEPTH);
/// ```
pub fn flicker_depth(depth: f32) -> f32 {
    if depth.is_nan() {
        return FLICKER_DEPTH;
    }
    depth.clamp(0.0, MAX_FLICKER_DEPTH)
}

/// The depth of a **calm** fire: a sheltered lantern, a wick under a parapet, a gate lamp's pan. The
/// light dips at most `0.10` below its peak (`0.90..=1.00`, mean `0.95`).
///
/// A design default, the calm end of the two illustrative depths in `docs/research/flicker-depth.md`
/// (`0.03`, within `0.05` of this). It is named so that hosts mapping a sheltered fire onto it choose
/// one number instead of each inventing a literal.
pub const CALM_FLICKER_DEPTH: f32 = 0.05;

/// The depth of a **whipping** fire: a hand torch in a draught, a wick in the wind. The light dips up
/// to `0.60` below its peak (`0.40..=1.00`, mean `0.70`).
///
/// A design default, the gusting end of the two illustrative depths in `docs/research/flicker-depth.md`
/// (`0.3`, exactly this). It sits at the note's advised ceiling; [`MAX_FLICKER_DEPTH`] is the geometric
/// limit above it.
pub const WHIPPING_FLICKER_DEPTH: f32 = 0.3;

/// The mean of a fire's flicker multiplier at `depth`: `1 - depth`, with `depth` read through
/// [`flicker_depth`] so it is the mean of what the light actually does. At [`FLICKER_DEPTH`] it is
/// `0.85`.
///
/// A lumen budget is written against a fire's **mean** (a pool that must light the ground to 7900 lux
/// on average), while [`Flicker::base`](crate::Flicker::base) is its **peak**. Divide the budget by
/// this instead of by a literal `0.85` that goes stale the moment a fire chooses another depth.
///
/// ```
/// # use bevy_lights::model::fire::*;
/// // The pool must average 7900 lumens whatever the fire's depth: solve for the peak.
/// for depth in [CALM_FLICKER_DEPTH, FLICKER_DEPTH, WHIPPING_FLICKER_DEPTH] {
///     let base = 7900.0 / flicker_mean(depth);
///     let mut sum = 0.0;
///     let n = 100_000;
///     for i in 0..n {
///         sum += base * flicker_swing(None, depth, i as f32 * 0.01, 0.4);
///     }
///     assert!((sum / n as f32 - 7900.0).abs() < 80.0);
/// }
/// assert_eq!(flicker_mean(FLICKER_DEPTH), 0.85);
/// ```
pub fn flicker_mean(depth: f32) -> f32 {
    1.0 - flicker_depth(depth)
}

/// The shared shape of every flicker: two sines summed to `-1..=1`, the second at `omega_2` and 1.7
/// times the clock phase.
fn wobble(omega: f32, omega_2: f32, t: f32, phase: f32) -> f32 {
    (omega * t + phase).sin() * 0.5 + (omega_2 * t + 1.7 * phase).sin() * 0.5
}

/// The multiplier a wobble of `-1..=1` gives at `depth`: the fire's peak is 1, and it dips by up to
/// `2 · depth`. At [`FLICKER_DEPTH`] that is `0.85 + 0.15 · wobble`.
fn dip(depth: f32, wobble: f32) -> f32 {
    1.0 - flicker_depth(depth) * (1.0 - wobble)
}

/// The flicker multiplier of a fire, sized by its fuel bed when the host knows it, and as deep as the
/// host asks: the one place the "sized or un-sized" choice is made.
///
/// `Some(diameter)` runs the flicker at the puffing rate ([`flicker_rate`]) and `None` at the crate's
/// un-sized rate ([`flicker_factor`]'s 7.3 rad/s). `depth` is how far the light dips: the multiplier
/// runs `1 - 2·depth ..= 1` with mean `1 - depth`, so the fire's **peak is always its base** and a
/// deeper flicker is a darker trough rather than a brighter crest. [`FLICKER_DEPTH`] is the default;
/// the value is read through [`flicker_depth`], so a stray number cannot drive the light negative.
///
/// A fire's **light** and the **flame drawn on it** are one fire, so both are written from this
/// function: the light's intensity by `flicker_lights` and the flame's emission by `update_flames`.
/// Going through one function is what makes it impossible for a flame to hold still while the ground
/// under it flickers, which is the defect this exists to prevent.
///
/// ```
/// # use bevy_lights::model::fire::*;
/// assert_eq!(flicker_swing(None, FLICKER_DEPTH, 2.0, 0.4), flicker_factor(2.0, 0.4));
/// assert_eq!(flicker_swing(Some(0.34), FLICKER_DEPTH, 2.0, 0.4), flicker_factor_of(0.34, 2.0, 0.4));
/// // The peak is the base at any depth; a deeper fire only dips further.
/// for depth in [0.0, 0.03, 0.15, 0.4] {
///     for i in 0..500 {
///         let f = flicker_swing(None, depth, i as f32 * 0.01, 0.4);
///         assert!(f <= 1.0 + 1e-6 && f >= 1.0 - 2.0 * depth - 1e-6);
///     }
/// }
/// ```
pub fn flicker_swing(diameter: Option<f32>, depth: f32, t: f32, phase: f32) -> f32 {
    let (omega, omega_2) = match diameter {
        Some(diameter) => {
            let omega = TAU * flicker_rate(diameter);
            (omega, 1.7 * omega)
        }
        None => (7.3, 13.1),
    };
    dip(depth, wobble(omega, omega_2, t, phase))
}

/// How long a fire takes to take hold, in seconds: a light that has a [`Kindle`] flares up over this
/// long instead of switching on (Sanctuary decision 0127).
///
/// [`Kindle`]: crate::Kindle
pub const KINDLE_SECONDS: f32 = 0.6;

/// How long a fire takes to go out, in seconds: a light that has a [`Snuff`] falls to nothing over
/// this long. Design default (`docs/research/flame-snuff.md`): quicker than a catch, about the length of
/// a hand closing over a flame.
///
/// [`Snuff`]: crate::Snuff
pub const SNUFF_SECONDS: f32 = 1.2;

/// The flicker multiplier of a fire light at time `t` seconds and clock `phase` radians: a sum of two
/// sines mapped to 0.70..1.00 of the light's base intensity, mean 0.85.
///
/// The rates are 7.3 and 13.1 rad/s (about 1.2 and 2.1 Hz), and the second sine runs at 1.7 times
/// `phase`, so two fires at different phases never drift back into step. Both rates are a design
/// default inherited from Sanctuary and are **uncited**; `docs/research/fire-light.md` records the
/// puffing-frequency literature they should be replaced with.
///
/// The result is exactly `0.85 ± 0.15` in the range `[0.70, 1.00]`, which is what a light budget
/// written against the mean (Sanctuary's `WALL_LIGHT_LUMENS` divides by 0.85) assumes.
pub fn flicker_factor(t: f32, phase: f32) -> f32 {
    flicker_swing(None, FLICKER_DEPTH, t, phase)
}

/// The share of its burn a fire has `t` seconds after it was lit: 0 at the start, then a flare that
/// runs ahead of the smoothstep rise (`s + 0.35 sin(pi s)`: 0.85 at half time, a hair over 1 at 0.85
/// of the way) and settles to exactly 1 from [`KINDLE_SECONDS`] on.
///
/// The flare is what makes a lamp being lit read as fire rather than as a switch (Sanctuary decision
/// 0127). It is a design default, not a measurement.
pub fn kindle_gain(t: f32) -> f32 {
    let s = (t / KINDLE_SECONDS).clamp(0.0, 1.0);
    let s = s * s * (3.0 - 2.0 * s);
    s + 0.35 * (PI * s).sin()
}

/// The share of its burn a fire has `t` seconds after it began to go out: exactly 1 at the start, then a
/// smoothstep down (`1 - s²(3 - 2s)`, `s = t / `[`SNUFF_SECONDS`]) to exactly 0 from [`SNUFF_SECONDS`] on.
///
/// The mirror of [`kindle_gain`] without its flare: a failing flame does not flare
/// (`docs/research/flame-snuff.md`). The slope is zero at both ends, so the first instant still reads as
/// lit and the last glow does not click off. Design default, not a measurement.
///
/// ```
/// # use bevy_lights::model::fire::*;
/// assert_eq!(snuff_gain(0.0), 1.0);
/// assert_eq!(snuff_gain(SNUFF_SECONDS), 0.0);
/// assert!((snuff_gain(SNUFF_SECONDS / 2.0) - 0.5).abs() < 1e-6);
/// ```
pub fn snuff_gain(t: f32) -> f32 {
    let s = (t / SNUFF_SECONDS).clamp(0.0, 1.0);
    1.0 - s * s * (3.0 - 2.0 * s)
}

/// The most a flame is allowed to lean, in radians (70°): `sin θ` is 0.94.
///
/// Design default (`docs/research/flame-wind.md`): the correlation reaches 80° at a gale's 10 m/s, and
/// a flame laid flat stops reading as a flame.
pub const FLAME_TILT_LIMIT: f32 = 70.0 * PI / 180.0;

/// How far a flame of fuel-bed diameter `diameter` leans from vertical in a wind of `speed`, in
/// radians: Welker & Sliepcevich's `tan θ / cos θ = c · Fr^a · Re^b` with Pritchard & Binding's
/// `c = 0.666`, `a = 0.333`, `b = 0.117`, `Fr = U² / (g·D)` and `Re = D·U / ν`
/// (`docs/research/flame-wind.md`).
///
/// **SI units**: `speed` in metres per second and `diameter` in metres. The crate converts nothing, so a
/// host whose world is not in metres scales both before calling. Zero (or negative or `NaN`) wind is no
/// lean; the result rises with the wind, falls with the fire's size and is capped at
/// [`FLAME_TILT_LIMIT`]. A diameter is clamped at 1 cm from below, as [`flicker_rate`]'s is.
///
/// ```
/// # use bevy_lights::model::fire::*;
/// assert_eq!(flame_tilt(0.0, 0.34), 0.0);
/// assert!((flame_tilt(3.0, 0.34).to_degrees() - 59.8).abs() < 0.5, "a brazier in a stiff breeze");
/// assert!(flame_tilt(3.0, 4.0) < flame_tilt(3.0, 0.34), "a bonfire bends less than a brazier");
/// assert!(flame_tilt(100.0, 0.2) <= FLAME_TILT_LIMIT);
/// ```
pub fn flame_tilt(speed: f32, diameter: f32) -> f32 {
    if speed.is_nan() || speed <= 0.0 {
        return 0.0;
    }
    let d = if diameter.is_nan() { DEFAULT_FIRE_DIAMETER } else { diameter.clamp(MIN_BED, 1.0e6) };
    let froude = speed * speed / (GRAVITY * d);
    let reynolds = d * speed / AIR_VISCOSITY;
    let k = 0.666 * froude.powf(0.333) * reynolds.powf(0.117);
    // tan θ / cos θ = sin θ / cos² θ = k  ⇒  k·sin²θ + sin θ − k = 0.
    let sin = if k < 1.0e-6 { k } else { ((1.0 + 4.0 * k * k).sqrt() - 1.0) / (2.0 * k) };
    sin.clamp(0.0, 1.0).asin().min(FLAME_TILT_LIMIT)
}

/// The diameter [`flame_tilt`] assumes for a fire with none given, in metres: a torch head to a lantern's
/// flame bed. Design default (`docs/research/flame-wind.md`).
pub const DEFAULT_FIRE_DIAMETER: f32 = 0.2;

/// Gravity, m/s².
const GRAVITY: f32 = 9.81;
/// The kinematic viscosity of air at 20 °C, m²/s.
const AIR_VISCOSITY: f32 = 1.5e-5;

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
    use super::*;

    #[test]
    fn the_flicker_stays_inside_its_budget_window() {
        for phase in 0..7 {
            let phase = phase as f32;
            let mut t = 0.0;
            while t <= 100.0 {
                let f = flicker_factor(t, phase);
                assert!((0.70..=1.00).contains(&f), "flicker {f} at t {t} phase {phase} left 0.70..1.00");
                t += 0.01;
            }
        }
    }

    #[test]
    fn the_flicker_rate_is_the_puffing_law() {
        // f = 1.5 / sqrt(D): a 1 m bed is 1.5 Hz, a brazier's 0.34 m coals 2.57 Hz, a big fire slower.
        for (diameter, hertz) in [(1.0, 1.5), (0.34, 2.573), (0.1, 4.743), (4.0, 0.75)] {
            let rate = flicker_rate(diameter);
            assert!((rate - hertz).abs() < 1e-2, "{diameter} m gave {rate} Hz, wanted {hertz}");
        }
        // Monotone falling in the diameter, and a degenerate bed stays finite.
        let mut last = f32::INFINITY;
        for diameter in [0.05, 0.1, 0.5, 1.0, 2.0, 8.0] {
            let rate = flicker_rate(diameter);
            assert!(rate < last, "{diameter} m did not slow the rate: {rate} against {last}");
            assert!(rate.is_finite() && rate > 0.0);
            last = rate;
        }
        assert!(flicker_rate(0.0).is_finite(), "a zero bed clamps rather than dividing by zero");
        assert!(flicker_rate(-2.0).is_finite());
        assert_eq!(flicker_rate(f32::NAN), 1.0);
    }

    #[test]
    fn a_sized_flicker_keeps_the_window_and_the_mean_and_changes_the_rate() {
        for diameter in [0.05, 0.34, 1.0, 8.0] {
            let mut t = 0.0;
            let mut sum = 0.0;
            let mut n = 0.0;
            let mut last = flicker_factor_of(diameter, 0.0, 0.0);
            let mut turns = 0;
            while t <= 20.0 {
                let f = flicker_factor_of(diameter, t, 0.0);
                assert!((0.70..=1.00).contains(&f), "{diameter} m left the window at {t}: {f}");
                // Count how often the curve crosses its mean going up: a proxy for the rate.
                if last < 0.85 && f >= 0.85 {
                    turns += 1;
                }
                last = f;
                sum += f;
                n += 1.0;
                t += 0.005;
            }
            let mean = sum / n;
            assert!((mean - 0.85).abs() < 0.01, "{diameter} m's mean is {mean}, not 0.85");
            // Over 20 s the law's rate for a small fire must turn more often than a large one's.
            if diameter < 0.2 {
                assert!(turns > 10, "a {diameter} m fire turned only {turns} times in 20 s");
            }
        }
        // The sized rate is faster than the inherited one at a brazier's size, and slower at a pyre's.
        let crosses = |f: &dyn Fn(f32) -> f32| {
            let mut last = f(0.0);
            let mut turns = 0;
            let mut t = 0.0;
            while t <= 10.0 {
                let v = f(t);
                if last < 0.85 && v >= 0.85 {
                    turns += 1;
                }
                last = v;
                t += 0.005;
            }
            turns
        };
        let sized_small = crosses(&|t| flicker_factor_of(0.34, t, 0.0));
        let sized_large = crosses(&|t| flicker_factor_of(4.0, t, 0.0));
        let inherited = crosses(&|t| flicker_factor(t, 0.0));
        assert!(sized_small > inherited, "a brazier should flicker faster than the inherited rate");
        assert!(sized_large < inherited, "a pyre should flicker slower");
    }

    #[test]
    fn the_kindle_flares_then_settles_to_one() {
        assert_eq!(kindle_gain(0.0), 0.0);
        assert!((kindle_gain(0.3) - 0.85).abs() < 1e-4, "half time {:?}", kindle_gain(0.3));
        assert!((kindle_gain(0.6) - 1.0).abs() < 1e-5, "settled {:?}", kindle_gain(0.6));
        assert_eq!(
            kindle_gain(10.0),
            kindle_gain(KINDLE_SECONDS),
            "the gain is exactly 1 after KINDLE_SECONDS"
        );
    }

    #[test]
    fn a_snuffed_fire_falls_from_one_to_exactly_zero_and_never_rises() {
        assert_eq!(snuff_gain(0.0), 1.0);
        assert_eq!(snuff_gain(SNUFF_SECONDS), 0.0);
        assert_eq!(snuff_gain(100.0), 0.0, "out stays out");
        assert_eq!(snuff_gain(-1.0), 1.0, "before the start it is still lit");
        let mut last = 1.0;
        for i in 0..=240 {
            let g = snuff_gain(i as f32 * SNUFF_SECONDS / 240.0);
            assert!(g <= last + 1e-6, "rose at step {i}: {last} -> {g}");
            last = g;
        }
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod wind_tests {
    use super::*;

    #[test]
    fn a_flame_leans_more_in_more_wind_and_less_when_larger() {
        assert_eq!(flame_tilt(0.0, 0.34), 0.0);
        assert_eq!(flame_tilt(-2.0, 0.34), 0.0);
        assert_eq!(flame_tilt(f32::NAN, 0.34), 0.0);
        let mut last = 0.0;
        for speed in [0.1, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 200.0] {
            let tilt = flame_tilt(speed, 0.34);
            // Strictly rising until the limit caps it, never falling after.
            assert!(tilt > last || last == FLAME_TILT_LIMIT, "{speed} m/s: {tilt} not above {last}");
            assert!(tilt <= FLAME_TILT_LIMIT, "{speed} m/s: {tilt} past the limit");
            last = tilt;
        }
        assert_eq!(last, FLAME_TILT_LIMIT, "a gale is capped");
        // The hand-worked anchors in the research note.
        assert!((flame_tilt(3.0, 0.34).to_degrees() - 59.8).abs() < 0.5);
        assert!((flame_tilt(1.0, 0.2).to_degrees() - 47.5).abs() < 0.5);
        assert!((flame_tilt(0.1, 0.34).to_degrees() - 13.0).abs() < 0.5, "a draught still leans");
        for diameter in [0.1, 0.34, 1.0] {
            assert!(flame_tilt(2.0, diameter * 2.0) < flame_tilt(2.0, diameter));
        }
        assert!(flame_tilt(2.0, f32::NAN).is_finite());
    }
}