use core::f32::consts::TAU;
const BYTE: f32 = 255.0;
pub fn pack_flame_tag(phase: f32, height: f32, brightness: f32, max_height: f32) -> u32 {
let unorm16 = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u32;
let unorm8 = |v: f32| (v.clamp(0.0, 1.0) * BYTE).round() as u32;
let height_share = if max_height > 0.0 { height / max_height } else { 0.0 };
unorm16(height_share) << 16 | unorm8(phase.rem_euclid(TAU) / TAU) << 8 | unorm8(brightness)
}
pub fn unpack_flame_tag(tag: u32, max_height: f32) -> (f32, f32, f32) {
let height = (tag >> 16) as f32 / 65535.0;
let phase = ((tag >> 8) & 0xff) as f32 / BYTE;
let brightness = (tag & 0xff) as f32 / BYTE;
(phase * TAU, height * max_height.max(0.0), brightness)
}
pub const FLAME_REACH: f32 = 1.7;
const CATCH_SIZE_EXPONENT: f32 = 2.0 / 3.0;
const MIN_SCALE: f32 = 1.0e-4;
pub fn flame_cull_extents(height: f32, took: f32, lean: [f32; 2]) -> [f32; 3] {
cull_extents(FLAME_REACH, height, took, lean)
}
const EMBER_HALF_WIDTH: f32 = 0.7;
const EMBER_RISE_MAX: f32 = 2.6;
const EMBER_MARGIN: f32 = 0.35;
const EMBER_DRIFT: f32 = 1.5;
pub fn ember_reach(lean: [f32; 2]) -> f32 {
let wind = lean[0].hypot(lean[1]);
let wind = if wind.is_finite() { wind } else { 0.0 };
let across = EMBER_HALF_WIDTH + EMBER_DRIFT * EMBER_RISE_MAX * wind;
across.hypot(EMBER_RISE_MAX + EMBER_MARGIN)
}
pub fn ember_cull_extents(height: f32, took: f32, lean: [f32; 2]) -> [f32; 3] {
cull_extents(ember_reach(lean), height, took, lean)
}
fn cull_extents(reach: f32, height: f32, took: f32, lean: [f32; 2]) -> [f32; 3] {
let took = if took.is_nan() { 0.0 } else { took.clamp(0.0, 1.0) };
let size = height.max(0.0) * took.max(1.0e-4).powf(CATCH_SIZE_EXPONENT);
let reach = reach * size;
[took, 1.0 + lean[0], 1.0 + lean[1]].map(|scale| reach / scale.max(MIN_SCALE))
}
pub fn set_flame_brightness(tag: u32, brightness: f32) -> u32 {
let unorm8 = (brightness.clamp(0.0, 1.0) * BYTE).round() as u32;
(tag & !0xff) | unorm8
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
const PHASE_STEP: f32 = TAU / 256.0;
#[test]
fn a_flame_tag_round_trips_its_phase_height_and_brightness() {
for (phase, height, brightness) in
[(0.0, 0.0, 0.0), (1.0, 2.5, 0.85), (3.0, 8.0, 1.0), (TAU - 0.001, 0.35, 0.70), (-2.0, 1.5, 0.93)]
{
let tag = pack_flame_tag(phase, height, brightness, 8.0);
let (read_phase, read_height, read_brightness) = unpack_flame_tag(tag, 8.0);
let expected = phase.rem_euclid(TAU);
assert!(
(read_phase - expected).abs() <= PHASE_STEP / 2.0 + f32::EPSILON,
"phase {phase} read back as {read_phase}, wanted {expected}"
);
assert!((read_height - height).abs() < 1e-3, "height {height} read back as {read_height}");
assert!(
(read_brightness - brightness).abs() <= 0.5 / BYTE + f32::EPSILON,
"brightness {brightness} read back as {read_brightness}"
);
}
}
#[test]
fn the_three_fields_do_not_bleed_into_each_other() {
let tag = pack_flame_tag(0.0, 8.0, 0.0, 8.0);
assert_eq!(tag >> 16, 65535);
assert_eq!(tag & 0xffff, 0);
let bright = pack_flame_tag(0.0, 8.0, 1.0, 8.0);
assert_eq!(bright >> 16, tag >> 16);
assert_eq!(bright & 0xff, 255);
assert_eq!((bright >> 8) & 0xff, 0);
}
#[test]
fn a_height_over_the_maximum_packs_full_and_a_zero_maximum_packs_nothing() {
assert_eq!(pack_flame_tag(0.0, 20.0, 1.0, 8.0) >> 16, 65535);
assert_eq!(pack_flame_tag(0.0, 4.0, 1.0, 0.0) >> 16, 0);
assert_eq!(pack_flame_tag(0.0, 4.0, 1.0, -3.0) >> 16, 0);
assert_eq!(unpack_flame_tag(pack_flame_tag(0.0, 4.0, 1.0, 0.0), 0.0).1, 0.0);
}
#[test]
fn a_brightness_outside_its_range_clamps() {
assert_eq!(pack_flame_tag(0.0, 1.0, 3.0, 8.0) & 0xff, 255);
assert_eq!(pack_flame_tag(0.0, 1.0, -3.0, 8.0) & 0xff, 0);
}
#[test]
fn a_flames_box_is_the_same_cube_about_its_foot_whatever_the_scale_does_to_it() {
for (height, took, lean) in [
(0.35, 1.0, [0.0, 0.0]),
(0.35, 0.2, [0.5, -0.3]),
(2.0, 0.001, [-0.94, 0.0]),
(1.1, 0.7, [0.3, 0.94]),
] {
let half = flame_cull_extents(height, took, lean);
let scale = [took, 1.0 + lean[0], 1.0 + lean[1]];
let size = height * took.powf(2.0 / 3.0);
for axis in 0..3 {
let world = half[axis] * scale[axis];
assert!(
(world - FLAME_REACH * size).abs() < 1e-4 * (1.0 + world),
"axis {axis}: height {height}, catch {took}, lean {lean:?}: {world} against {}",
FLAME_REACH * size
);
}
}
}
#[test]
fn a_flames_box_stays_finite_for_inputs_the_shader_clamps() {
for (height, took, lean) in [
(1.0, 0.0, [0.0, 0.0]),
(1.0, f32::NAN, [0.0, 0.0]),
(-1.0, 1.0, [0.0, 0.0]),
(1.0, 1.0, [-1.0, -1.0]),
(1.0, 7.0, [3.0, 3.0]),
(1.0, 1.0, [f32::NAN, 0.2]),
(1.0, 1.0, [f32::INFINITY, 0.0]),
] {
for half in [flame_cull_extents(height, took, lean), ember_cull_extents(height, took, lean)] {
assert!(
half.iter().all(|h| h.is_finite() && *h >= 0.0),
"{height} {took} {lean:?} gave {half:?}"
);
}
}
assert_eq!(flame_cull_extents(1.0, 7.0, [0.0, 0.0]), flame_cull_extents(1.0, 1.0, [0.0, 0.0]));
assert_eq!(ember_cull_extents(1.0, 7.0, [0.0, 0.0]), ember_cull_extents(1.0, 1.0, [0.0, 0.0]));
}
#[test]
fn an_embers_box_is_the_same_cube_about_its_foot_whatever_the_scale_does_to_it() {
for (height, took, lean) in [
(0.35, 1.0, [0.0, 0.0]),
(0.35, 0.2, [0.5, -0.3]),
(2.0, 0.001, [-0.94, 0.0]),
(1.1, 0.7, [0.3, 0.94]),
] {
let half = ember_cull_extents(height, took, lean);
let scale = [took, 1.0 + lean[0], 1.0 + lean[1]];
let size = height * took.powf(2.0 / 3.0);
let want = ember_reach(lean) * size;
for axis in 0..3 {
let world = half[axis] * scale[axis];
assert!(
(world - want).abs() < 1e-4 * (1.0 + world),
"axis {axis}: height {height}, catch {took}, lean {lean:?}: {world} against {want}"
);
}
}
}
#[test]
fn an_embers_reach_grows_with_the_lean_and_never_falls_short_of_the_flames() {
let still = ember_reach([0.0, 0.0]);
let gale = ember_reach([0.94, 0.0]);
assert!(
still > FLAME_REACH,
"the sparks' quad is wider than the flame: {still} against {FLAME_REACH}"
);
assert!((still - 3.03).abs() < 0.01, "still air: {still}");
assert!((gale - 5.27).abs() < 0.01, "at the lean limit: {gale}");
assert!((ember_reach([0.6, 0.8]) - ember_reach([1.0, 0.0])).abs() < 1e-6);
assert!((ember_reach([-0.6, 0.8]) - ember_reach([1.0, 0.0])).abs() < 1e-6);
}
}