use super::char::char_radiance;
use super::cinematography::blackbody_radiance;
pub const BED_TILE: f32 = 0.36;
pub const BED_COOL_KELVIN: f32 = 450.0;
pub const BED_WARM_SECONDS: f32 = 4.0;
pub const DEFAULT_BED_COOLING: f32 = 60.0;
pub const BED_BREATH: f32 = 0.06;
pub const BED_RAMP_FIELD: usize = 32;
pub const BED_RAMP_HEAT: usize = 24;
pub const BED_PEAK: f32 = 3.5;
pub const BED_VISIBLE_SHARE: f32 = 1e-3;
const STOPS_KELVIN: [f32; 3] = [950.0, 1020.0, 1300.0];
pub fn bed_radiance(field: f32, heat: f32) -> [f32; 3] {
let stops = stops_at(heat);
char_radiance(field, stops[0], stops[1], stops[2])
}
fn stops_at(heat: f32) -> [[f32; 3]; 3] {
let heat = if heat.is_nan() { 1.0 } else { heat.clamp(0.0, 1.0) };
let drop = BED_COOL_KELVIN * (1.0 - heat);
STOPS_KELVIN.map(|kelvin| blackbody_radiance(kelvin - drop))
}
fn peak() -> f32 {
let [red, green, blue] = blackbody_radiance(STOPS_KELVIN[2]);
red.max(green).max(blue).max(f32::MIN_POSITIVE)
}
pub fn bed_ramp() -> Vec<[f32; 4]> {
let scale = BED_PEAK / peak();
let mut table = Vec::with_capacity(BED_RAMP_FIELD * BED_RAMP_HEAT);
for row in 0..BED_RAMP_HEAT {
let stops = stops_at(row as f32 / (BED_RAMP_HEAT - 1) as f32);
for column in 0..BED_RAMP_FIELD {
let field = column as f32 / (BED_RAMP_FIELD - 1) as f32;
let [red, green, blue] = char_radiance(field, stops[0], stops[1], stops[2]);
table.push([red * scale, green * scale, blue * scale, 0.0]);
}
}
table
}
pub fn bed_heat_step(heat: f32, burning: bool, dt: f32, cooling: f32) -> f32 {
let heat = if heat.is_nan() { 1.0 } else { heat.clamp(0.0, 1.0) };
let dt = if dt.is_finite() && dt > 0.0 { dt } else { 0.0 };
let (target, time) = if burning {
(1.0, BED_WARM_SECONDS)
} else {
(0.0, if cooling.is_finite() && cooling > 0.0 { cooling } else { DEFAULT_BED_COOLING })
};
(heat + (target - heat) * (1.0 - (-dt / time).exp())).clamp(0.0, 1.0)
}
pub fn bed_drawn_heat(heat: f32, swing: f32) -> f32 {
let heat = if heat.is_nan() { 1.0 } else { heat.clamp(0.0, 1.0) };
let dip = if swing.is_nan() { 0.0 } else { (1.0 - swing).clamp(0.0, 1.0) };
heat * (1.0 - BED_BREATH * dip)
}
pub fn bed_offset(seed: u32) -> [f32; 2] {
let mix = |mut v: u32| {
v = (v ^ (v >> 16)).wrapping_mul(0x7feb_352d);
v = (v ^ (v >> 15)).wrapping_mul(0x846c_a68b);
v ^ (v >> 16)
};
let a = mix(seed.wrapping_add(0x9e37_79b9));
let b = mix(a ^ 0x85eb_ca6b);
[(a & 0xff) as f32 / 256.0, (b & 0xff) as f32 / 256.0]
}
pub fn pack_bed_tag(heat: f32, offset: [f32; 2]) -> u32 {
let heat = if heat.is_nan() { 1.0 } else { heat.clamp(0.0, 1.0) };
let byte = |v: f32| if v.is_nan() { 0 } else { (v.clamp(0.0, 1.0) * 255.0).round() as u32 };
(heat * 65_535.0).round() as u32 | (byte(offset[0]) << 16) | (byte(offset[1]) << 24)
}
pub fn unpack_bed_tag(tag: u32) -> (f32, [f32; 2]) {
(
(tag & 0xffff) as f32 / 65_535.0,
[((tag >> 16) & 0xff) as f32 / 255.0, ((tag >> 24) & 0xff) as f32 / 255.0],
)
}
pub fn bed_visible_heat() -> f32 {
let scale = BED_PEAK / peak();
let hottest = |heat: f32| {
let [red, green, blue] = bed_radiance(1.0, heat);
red.max(green).max(blue) * scale
};
let (mut low, mut high) = (0.0f32, 1.0f32);
for _ in 0..24 {
let middle = 0.5 * (low + high);
if hottest(middle) < BED_PEAK * BED_VISIBLE_SHARE {
low = middle;
} else {
high = middle;
}
}
high
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::model::char::{EMBER_AT, char_tile, coal_radiance};
fn max_channel(rgb: [f32; 3]) -> f32 {
rgb[0].max(rgb[1]).max(rgb[2])
}
#[test]
fn a_burning_bed_is_the_coal_ramp_at_its_ember_and_fuel_stops() {
for field in [EMBER_AT, 1.0] {
let bed = bed_radiance(field, 1.0);
let coal = coal_radiance(field);
for channel in 0..3 {
let (a, b) = (bed[channel], coal[channel]);
let tolerance = 0.05 * b.max(a) + 0.5;
assert!((a - b).abs() <= tolerance, "field {field} channel {channel}: bed {a} vs coal {b}");
}
}
}
#[test]
fn a_cooler_bed_is_never_brighter_in_any_channel_at_any_field() {
for field in (0..=20).map(|i| i as f32 / 20.0) {
let mut before = bed_radiance(field, 1.0);
for step in 1..=20 {
let now = bed_radiance(field, 1.0 - step as f32 / 20.0);
for channel in 0..3 {
assert!(
now[channel] <= before[channel] * 1.0001 + 1e-9,
"field {field} step {step} channel {channel}: {} then {}",
before[channel],
now[channel]
);
}
before = now;
}
}
}
#[test]
fn a_cooling_bed_gets_redder_as_it_dims_which_a_grey_dimming_would_not() {
let red_share = |heat: f32| {
let [r, g, b] = bed_radiance(1.0, heat);
r / (r + g + b)
};
let mut before = red_share(1.0);
for step in 1..=14 {
let now = red_share(1.0 - step as f32 * 0.03);
assert!(now >= before - 1e-4, "step {step}: the red share fell from {before} to {now}");
before = now;
}
assert!(red_share(0.6) > red_share(1.0) + 0.02, "{} vs {}", red_share(0.6), red_share(1.0));
}
#[test]
fn a_cold_bed_is_dark_on_a_display_and_a_burning_one_has_its_dynamic_range() {
let table = bed_ramp();
let cold_peak =
table[..BED_RAMP_FIELD].iter().map(|c| max_channel([c[0], c[1], c[2]])).fold(0.0f32, f32::max);
assert!(
cold_peak < 0.005 * BED_PEAK,
"a cold bed's brightest entry is {cold_peak} of a display's white"
);
let top = (BED_RAMP_HEAT - 1) * BED_RAMP_FIELD;
let burning = &table[top..top + BED_RAMP_FIELD];
let hottest = burning.iter().map(|c| max_channel([c[0], c[1], c[2]])).fold(0.0f32, f32::max);
assert!((hottest - BED_PEAK).abs() < 1e-3);
let ember = burning[(EMBER_AT * (BED_RAMP_FIELD - 1) as f32).round() as usize];
let ember = max_channel([ember[0], ember[1], ember[2]]);
assert!(ember > 0.002 * hottest && ember < 0.25 * hottest, "ember {ember} against {hottest}");
}
#[test]
fn the_table_is_finite_and_never_negative() {
for entry in bed_ramp() {
assert!(entry.iter().all(|c| c.is_finite() && *c >= 0.0), "{entry:?}");
}
}
#[test]
fn a_fire_put_out_leaves_its_bed_most_of_its_heat_a_second_later_and_almost_none_after_four_time_constants()
{
let mut heat = 1.0;
for _ in 0..60 {
heat = bed_heat_step(heat, false, 1.0 / 60.0, DEFAULT_BED_COOLING);
}
assert!(heat > 0.97, "a second after the douse the bed holds {heat}");
for _ in 0..(4.0 * DEFAULT_BED_COOLING * 60.0) as usize {
heat = bed_heat_step(heat, false, 1.0 / 60.0, DEFAULT_BED_COOLING);
}
assert!(heat < 0.03, "after four time constants {heat}");
}
#[test]
fn the_coals_stop_glowing_visibly_well_before_the_heat_is_zero_and_a_minute_or_so_after_the_douse() {
let visible = bed_visible_heat();
assert!((0.1..0.8).contains(&visible), "{visible}");
let seconds = -DEFAULT_BED_COOLING * visible.ln();
assert!((15.0..120.0).contains(&seconds), "{seconds} s");
}
#[test]
fn a_relight_warms_the_bed_from_where_it_had_cooled_to() {
let mut heat = 0.2;
for _ in 0..(3.0 * BED_WARM_SECONDS * 60.0) as usize {
heat = bed_heat_step(heat, true, 1.0 / 60.0, DEFAULT_BED_COOLING);
}
assert!(heat > 0.95, "{heat}");
assert!(bed_heat_step(0.2, true, 1.0 / 60.0, DEFAULT_BED_COOLING) > 0.2);
}
#[test]
fn the_heat_step_survives_what_a_setting_can_be() {
for (heat, dt, cooling) in [
(f32::NAN, 0.016, 45.0),
(0.5, f32::NAN, 45.0),
(0.5, -1.0, 45.0),
(0.5, f32::INFINITY, 45.0),
(0.5, 0.016, f32::NAN),
(0.5, 0.016, 0.0),
(0.5, 0.016, -3.0),
(7.0, 0.016, 45.0),
] {
for burning in [true, false] {
let now = bed_heat_step(heat, burning, dt, cooling);
assert!((0.0..=1.0).contains(&now), "{heat} {dt} {cooling} {burning}: {now}");
}
}
}
#[test]
fn the_breathing_follows_the_flame_a_little_and_never_warms_a_cold_bed() {
assert_eq!(bed_drawn_heat(0.8, 1.0), 0.8);
let dipped = bed_drawn_heat(0.8, 0.5);
assert!(dipped < 0.8 && dipped > 0.8 * (1.0 - BED_BREATH) - 1e-6, "{dipped}");
assert_eq!(bed_drawn_heat(0.0, 0.0), 0.0);
assert!(bed_drawn_heat(f32::NAN, f32::NAN).is_finite());
assert!(bed_drawn_heat(1.0, 0.0) >= 1.0 - BED_BREATH - 1e-6);
}
#[test]
fn the_tag_round_trips_its_heat_and_its_offset_and_the_fields_do_not_bleed() {
for heat in [0.0, 0.25, 0.7, 1.0] {
for offset in [[0.0, 0.0], [0.5, 0.25], [0.996, 0.004]] {
let (h, o) = unpack_bed_tag(pack_bed_tag(heat, offset));
assert!((h - heat).abs() < 1e-4);
assert!((o[0] - offset[0]).abs() < 1.0 / 255.0 && (o[1] - offset[1]).abs() < 1.0 / 255.0);
}
}
let (h, o) = unpack_bed_tag(pack_bed_tag(1.0, [0.0, 0.0]));
assert_eq!((h, o), (1.0, [0.0, 0.0]));
let (h, _) = unpack_bed_tag(pack_bed_tag(f32::NAN, [f32::NAN, 2.0]));
assert!(h.is_finite());
}
#[test]
fn two_seeds_show_two_windows_of_the_same_tile_and_one_seed_is_always_the_same() {
assert_eq!(bed_offset(3), bed_offset(3));
assert_ne!(bed_offset(3), bed_offset(4));
for seed in 0..64 {
let [u, v] = bed_offset(seed);
assert!((0.0..1.0).contains(&u) && (0.0..1.0).contains(&v), "{seed}: {u} {v}");
}
}
#[test]
fn the_tile_has_the_fields_shape_and_repeats() {
let mut values = Vec::new();
for i in 0..64 {
for j in 0..64 {
values.push(char_tile(i as f32 / 64.0, j as f32 / 64.0, 7));
}
}
let mean = values.iter().sum::<f32>() / values.len() as f32;
assert!((0.35..0.65).contains(&mean), "mean {mean}");
assert!(values.iter().any(|v| *v < 0.05) && values.iter().any(|v| *v > 0.9));
for k in 0..32 {
let (u, v) = (k as f32 * 0.0371, k as f32 * 0.0613);
let a = char_tile(u, v, 7);
assert!((a - char_tile(u + 1.0, v, 7)).abs() < 1e-3, "u repeat at {k}");
assert!((a - char_tile(u, v + 2.0, 7)).abs() < 1e-3, "v repeat at {k}");
assert!((a - char_tile(u - 3.0, v - 1.0, 7)).abs() < 1e-3, "negative repeat at {k}");
}
assert_ne!(char_tile(0.3, 0.7, 1), char_tile(0.3, 0.7, 2));
}
}