pub const FIRE_KELVIN: f32 = 1900.0;
pub const DEFAULT_MID: [f32; 3] = [1.0, 0.55, 0.18];
pub const EDGE_RED: [f32; 3] = [0.70, 0.06, 0.005];
pub const CORE_YELLOW: [f32; 3] = [1.0, 0.78, 0.30];
pub const EMBER_RED: [f32; 3] = [0.95, 0.20, 0.02];
pub const SPARK_DULL_RED: [f32; 3] = [0.80, 0.11, 0.01];
pub const SPARK_ORANGE: [f32; 3] = [1.0, 0.42, 0.07];
fn to_hsv(c: [f32; 3]) -> (f32, f32, f32) {
let [r, g, b] = c;
let max = r.max(g).max(b);
let range = max - r.min(g).min(b);
if range <= 0.0 || max <= 0.0 {
return (0.0, 0.0, max);
}
let sector = if max == r {
((g - b) / range).rem_euclid(6.0)
} else if max == g {
(b - r) / range + 2.0
} else {
(r - g) / range + 4.0
};
(sector * 60.0, range / max, max)
}
fn from_hsv(hue: f32, sat: f32, val: f32) -> [f32; 3] {
let sector = hue.rem_euclid(360.0) / 60.0;
let c = val * sat;
let x = c * (1.0 - (sector.rem_euclid(2.0) - 1.0).abs());
let (r, g, b) = match sector as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = val - c;
[r + m, g + m, b + m]
}
fn sanitised(c: [f32; 3]) -> [f32; 3] {
c.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 })
}
fn fire_family(hue: f32, sat: f32) -> f32 {
let smoothstep = |low: f32, high: f32, x: f32| {
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
};
let beyond = if hue <= 60.0 { 0.0 } else { (hue - 60.0).min(360.0 - hue) };
(1.0 - smoothstep(0.0, 60.0, beyond)) * smoothstep(0.1, 0.4, sat)
}
fn luminance(c: [f32; 3]) -> f32 {
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
fn with_luminance(turned: [f32; 3], target: f32) -> [f32; 3] {
let have = luminance(turned);
if target > have {
let value = turned[0].max(turned[1]).max(turned[2]);
let toward = ((target - have) / (value - have).max(f32::MIN_POSITIVE)).clamp(0.0, 1.0);
turned.map(|c| c + toward * (value - c))
} else if have > 0.0 {
turned.map(|c| c * target / have)
} else {
turned
}
}
pub fn retint(stop: [f32; 3], mid: [f32; 3]) -> [f32; 3] {
let mid = sanitised(mid);
let (mid_hue, mid_sat, mid_val) = to_hsv(mid);
let family = fire_family(mid_hue, mid_sat);
if family >= 1.0 {
return stop;
}
let (default_hue, default_sat, default_val) = to_hsv(DEFAULT_MID);
let (hue, sat, val) = to_hsv(sanitised(stop));
let turn = if mid_sat > 0.0 { mid_hue - default_hue } else { 0.0 };
let turned =
from_hsv(hue + turn, (sat * mid_sat / default_sat).clamp(0.0, 1.0), val * mid_val / default_val);
let from = sanitised(stop);
let turned = with_luminance(turned, luminance(from) / luminance(DEFAULT_MID) * luminance(mid));
[0, 1, 2].map(|i| from[i] + (turned[i] - from[i]) * (1.0 - family))
}
pub fn flame_mid(light: [f32; 3]) -> [f32; 3] {
let light = sanitised(light);
let have = luminance(light);
if have <= 0.0 {
return DEFAULT_MID;
}
let (hue, sat, _) = to_hsv(light);
let family = fire_family(hue, sat);
let scale = luminance(DEFAULT_MID) / have;
light.map(|c| c + (c * scale - c) * (1.0 - family))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
const STOPS: [[f32; 3]; 6] =
[EDGE_RED, CORE_YELLOW, EMBER_RED, SPARK_DULL_RED, SPARK_ORANGE, DEFAULT_MID];
const OTHER_HUES: [[f32; 3]; 5] =
[[0.1, 0.3, 1.0], [0.2, 1.0, 0.3], [0.6, 0.2, 1.0], [0.1, 0.9, 0.9], [3.0, 1.0, 0.2]];
fn near(a: [f32; 3], b: [f32; 3], tolerance: f32) -> bool {
a.iter().zip(&b).all(|(a, b)| (a - b).abs() <= tolerance)
}
#[test]
fn a_fire_of_any_red_orange_or_yellow_keeps_the_shipped_ramp() {
for mid in [
DEFAULT_MID,
[1.0, 0.18, 0.0],
[1.0, 0.8, 0.35],
[1.0, 0.05, 0.0],
[3.0, 1.2, 0.3],
[1.0, 1.0, 0.1],
] {
for stop in STOPS {
assert_eq!(retint(stop, mid), stop, "{stop:?} through {mid:?}");
}
}
}
#[test]
fn any_other_hue_takes_the_middle_of_the_orange_ramp_to_itself() {
for mid in [[0.1, 0.3, 1.0], [0.2, 1.0, 0.3], [0.6, 0.2, 1.0], [0.1, 0.9, 0.9]] {
let moved = retint(DEFAULT_MID, mid);
assert!(near(moved, mid, 1e-5), "the orange middle should become {mid:?}, not {moved:?}");
}
}
#[test]
fn a_hue_keeps_the_ramps_own_offsets() {
let hue_of = |c: [f32; 3]| to_hsv(c).0;
let offset = |stop: [f32; 3], mid: [f32; 3]| {
(hue_of(retint(stop, mid)) - hue_of(mid) + 540.0).rem_euclid(360.0) - 180.0
};
for mid in [[0.1, 0.3, 1.0], [0.2, 1.0, 0.3], [0.6, 0.2, 1.0]] {
assert!((offset(EDGE_RED, mid) + 22.3).abs() < 0.5, "{mid:?}: {}", offset(EDGE_RED, mid));
assert!((offset(CORE_YELLOW, mid) - 14.1).abs() < 0.5, "{mid:?}: {}", offset(CORE_YELLOW, mid));
}
}
#[test]
fn a_rim_is_darker_and_more_saturated_than_the_middle_whatever_the_hue() {
for step in 0..24 {
let mid = from_hsv(step as f32 * 15.0, 0.9, 1.0);
let (_, mid_sat, mid_val) = to_hsv(mid);
let (_, rim_sat, rim_val) = to_hsv(retint(EDGE_RED, mid));
assert!(rim_val < mid_val, "hue {}: rim value {rim_val} vs {mid_val}", step * 15);
assert!(rim_sat >= mid_sat - 1e-4, "hue {}: rim saturation {rim_sat} vs {mid_sat}", step * 15);
}
}
#[test]
fn the_ramp_changes_smoothly_across_the_hue_wheel() {
let mut last = [EDGE_RED, CORE_YELLOW, EMBER_RED];
for step in 1..=1440 {
let mid = from_hsv(step as f32 * 0.25, 0.9, 1.0);
let now = [retint(EDGE_RED, mid), retint(CORE_YELLOW, mid), retint(EMBER_RED, mid)];
for (was, is) in last.iter().zip(&now) {
assert!(near(*was, *is, 0.03), "hue {}: {was:?} became {is:?}", step as f32 * 0.25);
}
last = now;
}
}
#[test]
fn a_rim_is_dimmer_and_a_core_brighter_than_the_middle_in_every_hue_outside_the_family() {
let ratio = |stop: [f32; 3]| luminance(stop) / luminance(DEFAULT_MID);
for step in 0..=36 {
let mid = from_hsv(120.0 + step as f32 * 5.0, 0.9, 1.0);
let y = luminance(mid);
let (rim, core) = (luminance(retint(EDGE_RED, mid)), luminance(retint(CORE_YELLOW, mid)));
assert!(rim < y && y < core + 1e-4, "hue {}: rim {rim}, middle {y}, core {core}", 120 + step * 5);
assert!(
(rim / y - ratio(EDGE_RED)).abs() < 0.02,
"the rim is {} of the middle, the orange ramp's {}",
rim / y,
ratio(EDGE_RED)
);
}
}
#[test]
fn a_grey_or_white_fire_has_a_grey_ramp() {
for mid in [[1.0, 1.0, 1.0], [0.5, 0.5, 0.5]] {
for stop in STOPS {
let out = retint(stop, mid);
assert!(near([out[0]; 3], out, 1e-6), "{stop:?} through {mid:?} is {out:?}");
}
}
assert!(retint(CORE_YELLOW, [1.0; 3])[0] > retint(EDGE_RED, [1.0; 3])[0]);
}
#[test]
fn a_brighter_fire_of_another_hue_is_a_brighter_ramp() {
let dim = retint(CORE_YELLOW, [0.1, 0.3, 1.0]);
let hot = retint(CORE_YELLOW, [0.2, 0.6, 2.0]);
assert!(near(hot, dim.map(|v| v * 2.0), 1e-5), "{dim:?} doubled is not {hot:?}");
}
#[test]
fn nothing_a_host_can_pass_makes_a_ramp_that_is_not_a_colour() {
let hostile = [
[0.0; 3],
[-1.0, 0.5, 2.0],
[f32::NAN, 1.0, 0.0],
[f32::INFINITY, f32::NEG_INFINITY, 0.3],
[1.0e30, 1.0e-30, 0.0],
[0.0, 0.0, 1.0e-45],
];
for mid in hostile.into_iter().chain(OTHER_HUES) {
for stop in STOPS {
let out = retint(stop, mid);
assert!(
out.iter().all(|v| v.is_finite() && *v >= 0.0),
"{stop:?} through {mid:?} is {out:?}"
);
}
}
}
#[test]
fn a_flame_of_any_hue_is_as_bright_as_the_orange_one_and_the_same_hue() {
for light in [[0.1, 0.3, 1.0], [0.2, 1.0, 0.3], [0.6, 0.2, 1.0], [0.1, 0.9, 0.9]] {
let flame = flame_mid(light);
let want = luminance(DEFAULT_MID);
assert!((luminance(flame) - want).abs() < want * 0.02, "{light:?} -> {flame:?}");
let ratio = flame[2] / light[2];
assert!(near(flame, light.map(|c| c * ratio), 1e-5), "{light:?} -> {flame:?}");
}
}
#[test]
fn a_fire_in_the_family_is_its_own_flame_and_a_hostile_colour_is_a_flame() {
for light in [DEFAULT_MID, [1.0, 0.18, 0.0], [1.0, 0.8, 0.35], [3.0, 1.2, 0.3]] {
assert_eq!(flame_mid(light), light);
}
for light in [[f32::NAN, 0.0, 0.0], [0.0; 3], [-1.0, -1.0, -1.0], [f32::INFINITY, 0.0, 0.0]] {
let flame = flame_mid(light);
assert!(flame.iter().all(|c| c.is_finite() && *c >= 0.0), "{light:?} -> {flame:?}");
}
assert_eq!(flame_mid([0.0; 3]), DEFAULT_MID);
}
}