use bevy::asset::{Asset, AssetId, Assets};
use bevy::ecs::prelude::*;
use bevy::math::{Rect, Vec2, Vec3};
use bevy::reflect::Reflect;
use bevy::render::storage::ShaderBuffer;
use bevy::time::Time;
use bevy::transform::components::GlobalTransform;
use crate::gpu::GrowthMaterial;
use crate::impostor::ImpostorMaterial;
use crate::pool::PooledTree;
use crate::shading::{FloraMaterial, FloraWind, FloraWindBuffer, WIND_VECTORS};
use crate::ProceduralTree;
pub(crate) const FIRE_SLOT: usize = WIND_VECTORS;
const STEP: f32 = 0.1;
const KINDLE: f32 = 2.0;
const SPARK: f32 = 0.05;
const NO_FUEL_COOLS: f32 = 1.0;
const WIND_GAIN: f32 = 2.0;
pub(crate) const LEAF_SCORCH: f32 = 0.35;
pub(crate) const LEAF_GLOW: f32 = 0.08;
pub(crate) fn leaf_burn_front(char: f32) -> f32 {
let lead = LEAF_GLOW + LEAF_SCORCH + 0.02;
char * (1.0 + lead) - lead
}
pub(crate) fn burnt_front(fire: Option<&FloraFire>, at: Vec3) -> f32 {
match fire.map(|fire| fire.char_at(at)) {
Some(char) if char > 0.0 => leaf_burn_front(char),
_ => f32::NEG_INFINITY,
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FireSpread {
pub speed: f32,
pub burn_time: f32,
pub cool_time: f32,
pub reach: f32,
pub ground: f32,
pub wind: f32,
}
impl Default for FireSpread {
fn default() -> Self {
Self { speed: 1.0, burn_time: 12.0, cool_time: 20.0, reach: 3.0, ground: 0.0, wind: 1.0 }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FireCell {
pub char: f32,
pub heat: f32,
}
#[derive(Resource, Clone, Debug)]
pub struct FloraFire {
pub spread: FireSpread,
pub seed: u64,
corner: Vec2,
cell: f32,
side: u32,
cells: Vec<FireCell>,
fuel: Vec<f32>,
lit: Vec<f32>,
fueled_for: Option<(usize, f32, f32)>,
clock: f32,
steps: u64,
burning: usize,
touched: Option<[usize; 4]>,
}
impl FloraFire {
pub const MAX_CELLS: u32 = 512;
pub fn new(centre: Vec2, size: f32) -> Self {
Self::with_cells(centre, size, 128)
}
pub fn with_cells(centre: Vec2, size: f32, cells: u32) -> Self {
let side = cells.clamp(2, Self::MAX_CELLS);
let size = size.max(1.0e-3);
let count = (side * side) as usize;
Self {
spread: FireSpread::default(),
seed: 0,
corner: centre - Vec2::splat(size * 0.5),
cell: size / side as f32,
side,
cells: vec![FireCell::default(); count],
fuel: vec![0.0; count],
lit: vec![f32::INFINITY; count],
fueled_for: None,
clock: 0.0,
steps: 0,
burning: 0,
touched: None,
}
}
pub fn with_spread(mut self, spread: FireSpread) -> Self {
self.spread = spread;
self
}
pub fn cell_size(&self) -> f32 {
self.cell
}
pub fn cells_per_side(&self) -> u32 {
self.side
}
pub fn cells(&self) -> &[FireCell] {
&self.cells
}
pub fn is_burning(&self) -> bool {
self.burning > 0
}
pub fn touched_bounds(&self) -> Option<Rect> {
let [x0, z0, x1, z1] = self.touched?;
let min = self.corner + Vec2::new(x0 as f32, z0 as f32) * self.cell;
let max = self.corner + Vec2::new(x1 as f32 + 1.0, z1 as f32 + 1.0) * self.cell;
Some(Rect::from_corners(min, max))
}
pub fn burning(&self) -> impl Iterator<Item = (Vec2, FireCell)> + '_ {
let side = self.side as usize;
let [x0, z0, x1, z1] = self.touched.unwrap_or([1, 1, 0, 0]);
(z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| (x, z))).filter_map(move |(x, z)| {
let cell = self.cells[z * side + x];
let centre = self.corner + (Vec2::new(x as f32, z as f32) + 0.5) * self.cell;
(cell.heat > 0.0).then_some((centre, cell))
})
}
pub fn ignite(&mut self, at: Vec3, radius: f32) {
let now = self.steps as f32 * STEP;
for i in self.cells_within(at, radius) {
let cell = &mut self.cells[i];
if cell.heat <= 0.0 && cell.char < 1.0 {
cell.heat = SPARK;
self.lit[i] = now;
self.touch(i);
}
}
self.settle();
}
pub fn extinguish(&mut self, at: Vec3, radius: f32) {
for i in self.cells_within(at, radius) {
self.cells[i].heat = 0.0;
}
self.settle();
}
pub fn restore(&mut self, cells: &[FireCell]) -> bool {
if cells.len() != self.cells.len() {
return false;
}
let now = self.steps as f32 * STEP;
for (i, cell) in cells.iter().enumerate() {
let (char, heat) = (cell.char.clamp(0.0, 1.0), cell.heat.clamp(0.0, 1.0));
self.cells[i] = FireCell { char, heat };
self.lit[i] = if heat > 0.0 { now } else { f32::INFINITY };
}
self.touched = Some([0, 0, self.side as usize - 1, self.side as usize - 1]);
self.settle();
true
}
pub fn scorch(&mut self, at: Vec3, radius: f32, char: f32) {
let char = char.clamp(0.0, 1.0);
for i in self.cells_within(at, radius) {
self.cells[i].char = self.cells[i].char.max(char);
if char > 0.0 {
self.touch(i);
}
}
}
pub fn char_at(&self, at: Vec3) -> f32 {
self.sample(at, |cell| cell.char)
}
pub fn heat_at(&self, at: Vec3) -> f32 {
self.sample(at, |cell| cell.heat)
}
fn sample(&self, at: Vec3, value: impl Fn(&FireCell) -> f32) -> f32 {
let g = (Vec2::new(at.x, at.z) - self.corner) / self.cell - 0.5;
let base = g.floor();
let f = g - base;
let (x, z) = (base.x as i64, base.y as i64);
let get = |x: i64, z: i64| self.index(x, z).map_or(0.0, |i| value(&self.cells[i]));
let near = get(x, z) + (get(x + 1, z) - get(x, z)) * f.x;
let far = get(x, z + 1) + (get(x + 1, z + 1) - get(x, z + 1)) * f.x;
near + (far - near) * f.y
}
fn index(&self, x: i64, z: i64) -> Option<usize> {
let side = self.side as i64;
(0..side).contains(&x).then_some(())?;
(0..side).contains(&z).then_some(())?;
Some((z * side + x) as usize)
}
fn cells_within(&self, at: Vec3, radius: f32) -> Vec<usize> {
let at = Vec2::new(at.x, at.z);
let radius = radius.max(0.0);
let low = ((at - radius - self.corner) / self.cell).floor();
let high = ((at + radius - self.corner) / self.cell).floor();
let mut found = Vec::new();
for z in low.y as i64..=high.y as i64 {
for x in low.x as i64..=high.x as i64 {
let Some(i) = self.index(x, z) else { continue };
let centre = self.corner + (Vec2::new(x as f32, z as f32) + 0.5) * self.cell;
if centre.distance(at) <= radius {
found.push(i);
}
}
}
let own = ((at - self.corner) / self.cell).floor();
if let Some(i) = self.index(own.x as i64, own.y as i64)
&& !found.contains(&i)
{
found.push(i);
}
found
}
fn refuel(&mut self, roots: impl Iterator<Item = Vec3>) {
let ground = self.spread.ground.clamp(0.0, 1.0);
self.fuel.fill(ground);
let reach = self.spread.reach.max(1.0e-3);
for root in roots {
for i in self.cells_within(root, reach) {
let (x, z) = (i % self.side as usize, i / self.side as usize);
let centre = self.corner + (Vec2::new(x as f32, z as f32) + 0.5) * self.cell;
let d = centre.distance(Vec2::new(root.x, root.z));
let near = 1.0 - ((d / reach - 0.5) * 2.0).clamp(0.0, 1.0);
self.fuel[i] = (self.fuel[i] + near).min(1.0);
}
}
}
fn touch(&mut self, i: usize) {
let side = self.side as usize;
let (x, z) = (i % side, i / side);
self.touched = Some(match self.touched {
None => [x, z, x, z],
Some([x0, z0, x1, z1]) => [x0.min(x), z0.min(z), x1.max(x), z1.max(z)],
});
}
fn settle(&mut self) {
let side = self.side as usize;
let [x0, z0, x1, z1] = self.touched.unwrap_or([1, 1, 0, 0]);
let (mut burning, mut fit) = (0, None::<[usize; 4]>);
for z in z0..=z1 {
for x in x0..=x1 {
let cell = self.cells[z * side + x];
if cell.heat > 0.0 {
burning += 1;
}
if cell.heat > 0.0 || cell.char > 0.0 {
fit = Some(match fit {
None => [x, z, x, z],
Some([a, b, c, d]) => [a.min(x), b.min(z), c.max(x), d.max(z)],
});
}
}
}
self.burning = burning;
self.touched = fit;
}
fn step(&mut self, wind: Vec2) {
let side = self.side as i64;
let spread = self.spread;
let now = self.steps as f32 * STEP;
let mut lit: Vec<(usize, f32)> = Vec::new();
let [x0, z0, x1, z1] = self.touched.unwrap_or([1, 1, 0, 0]);
let width = side as usize;
for i in (z0..=z1).flat_map(|z| (x0..=x1).map(move |x| z * width + x)) {
let fuel = self.fuel[i];
let cell = &mut self.cells[i];
if cell.heat <= 0.0 {
continue;
}
if fuel <= 0.0 {
cell.heat = (cell.heat - STEP / NO_FUEL_COOLS).max(0.0);
continue;
}
if cell.char >= 1.0 {
cell.heat = (cell.heat - STEP / spread.cool_time.max(STEP)).max(0.0);
continue;
}
let kindle = KINDLE.min(0.25 * spread.burn_time).max(STEP);
cell.heat = (cell.heat + STEP / kindle).min(1.0);
cell.char = (cell.char + STEP / spread.burn_time.max(STEP) * cell.heat).min(1.0);
let since = self.lit[i];
let (x, z) = ((i as i64) % side, (i as i64) / side);
for (dx, dz) in NEIGHBOURS {
let Some(j) = self.index(x + dx, z + dz) else { continue };
let (target, fuel) = (self.cells[j], self.fuel[j]);
if fuel <= 0.0 || target.heat > 0.0 || target.char >= 1.0 {
continue;
}
let way = Vec2::new(dx as f32, dz as f32);
let along = spread.wind * wind.dot(way.normalize());
let pace = spread.speed.max(1.0e-6) * fuel * (WIND_GAIN * along).exp();
let arrives = since + way.length() * self.cell / pace * jitter(self.seed, j);
if arrives < now + STEP {
lit.push((j, arrives));
}
}
}
for (j, arrives) in lit {
if self.cells[j].heat <= 0.0 {
self.cells[j].heat = SPARK;
self.lit[j] = arrives;
self.touch(j);
} else {
self.lit[j] = self.lit[j].min(arrives);
}
}
self.settle();
self.steps += 1;
}
fn vectors(&self) -> impl Iterator<Item = [f32; 4]> + '_ {
let any = self.touched.is_some();
let [x0, z0, x1, z1] = self.touched.unwrap_or_default();
let (low, high) = ((x0, z0), (x1, z1));
let header = [self.corner.x, self.corner.y, 1.0 / self.cell, if any { self.side as f32 } else { 0.0 }];
let bounds = if any {
let min = self.corner + (Vec2::new(low.0 as f32, low.1 as f32) - 1.0) * self.cell;
let max = self.corner + (Vec2::new(high.0 as f32, high.1 as f32) + 2.0) * self.cell;
[min.x, min.y, max.x, max.y]
} else {
[0.0; 4]
};
let pairs = self.cells.chunks(2).map(|pair| {
let second = pair.get(1).copied().unwrap_or_default();
[pair[0].char, pair[0].heat, second.char, second.heat]
});
[header, bounds].into_iter().chain(pairs)
}
fn vector_count(&self) -> usize {
2 + self.cells.len().div_ceil(2)
}
}
const NEIGHBOURS: [(i64, i64); 8] = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)];
fn jitter(seed: u64, cell: usize) -> f32 {
let mut x = seed ^ (cell as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
0.75 + 0.5 * ((x >> 40) as f32 / (1u64 << 24) as f32)
}
pub(crate) fn fire_burns(fire: Option<Res<FloraFire>>) -> bool {
fire.is_some_and(|fire| fire.is_burning())
}
pub(crate) fn fire_changed(fire: Option<Res<FloraFire>>, mut had: Local<bool>) -> bool {
let now = fire.is_some();
let changed = fire.is_some_and(|fire| fire.is_changed()) || (*had && !now);
*had = now;
changed
}
pub(crate) fn fire_rebinds(rebind: Res<FireRebind>) -> bool {
rebind.0
}
type AnyTree = Or<(With<ProceduralTree>, With<PooledTree>)>;
pub(crate) fn burn(
mut fire: ResMut<FloraFire>,
time: Res<Time>,
wind: Option<Res<FloraWind>>,
trees: Query<&GlobalTransform, AnyTree>,
) {
let state = fire.bypass_change_detection();
let laid = (trees.iter().len(), state.spread.reach, state.spread.ground);
if state.fueled_for != Some(laid) {
state.refuel(trees.iter().map(GlobalTransform::translation));
state.fueled_for = Some(laid);
}
let wind = wind.map_or(Vec2::ZERO, |wind| wind.direction.normalize_or_zero() * wind.strength.max(0.0));
state.clock += time.delta_secs();
let mut stepped = false;
while state.clock >= STEP && state.is_burning() {
state.clock -= STEP;
state.step(wind);
stepped = true;
}
if !state.is_burning() {
state.clock = 0.0;
}
if stepped {
fire.set_changed();
}
}
pub const BURNT: f32 = 0.95;
#[derive(Message, Clone, Copy, Debug, PartialEq)]
pub struct TreeBurnt {
pub tree: Entity,
pub at: Vec3,
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Component)]
pub struct Burnt;
pub(crate) fn report_burnt(
mut commands: Commands,
fire: Res<FloraFire>,
trees: Query<(Entity, &GlobalTransform), (AnyTree, Without<Burnt>)>,
mut burnt: MessageWriter<TreeBurnt>,
) {
for (tree, at) in &trees {
let at = at.translation();
if fire.char_at(at) >= BURNT {
commands.entity(tree).insert(Burnt);
burnt.write(TreeBurnt { tree, at });
}
}
}
#[derive(Resource, Default)]
pub(crate) struct FireRebind(bool);
pub(crate) fn sync_fire(
fire: Option<Res<FloraFire>>,
buffer: Option<Res<FloraWindBuffer>>,
buffers: Option<ResMut<Assets<ShaderBuffer>>>,
mut rebind: ResMut<FireRebind>,
) {
let (Some(buffer), Some(mut buffers)) = (buffer, buffers) else { return };
let length = buffers.get(&buffer.0).and_then(|b| b.cast_slice::<[f32; 4]>()).map_or(0, <[_]>::len);
let wanted = FIRE_SLOT + fire.as_ref().map_or(1, |fire| fire.vector_count());
let changed = fire.as_ref().is_some_and(|fire| fire.is_changed());
if length == wanted && !changed {
return;
}
let Some(mut shader_buffer) = buffers.get_mut(&buffer.0) else { return };
let Some(slice) = shader_buffer.cast_slice_mut::<[f32; 4]>() else { return };
if slice.len() == wanted {
if let Some(fire) = &fire {
for (slot, vector) in slice[FIRE_SLOT..].iter_mut().zip(fire.vectors()) {
*slot = vector;
}
}
return;
}
let mut all: Vec<[f32; 4]> = slice.iter().take(FIRE_SLOT).copied().collect();
all.resize(FIRE_SLOT, [0.0; 4]);
match &fire {
Some(fire) => all.extend(fire.vectors()),
None => all.push([0.0; 4]),
}
*shader_buffer = ShaderBuffer::from(all);
rebind.0 = true;
}
pub(crate) fn rebind_fire(
mut rebind: ResMut<FireRebind>,
flora: Option<ResMut<Assets<FloraMaterial>>>,
growth: Option<ResMut<Assets<GrowthMaterial>>>,
impostors: Option<ResMut<Assets<ImpostorMaterial>>>,
) {
if !rebind.0 {
return;
}
rebind.0 = false;
if let Some(mut assets) = flora {
touch(&mut assets);
}
if let Some(mut assets) = growth {
touch(&mut assets);
}
if let Some(mut assets) = impostors {
touch(&mut assets);
}
}
fn touch<A: Asset>(assets: &mut Assets<A>) {
let ids: Vec<AssetId<A>> = assets.ids().collect();
for id in ids {
if let Some(mut asset) = assets.get_mut(id) {
let _: &mut A = &mut asset;
}
}
}
pub(crate) fn arm_fire(
fire: Option<Res<FloraFire>>,
flora: Option<ResMut<Assets<FloraMaterial>>>,
growth: Option<ResMut<Assets<GrowthMaterial>>>,
impostors: Option<ResMut<Assets<ImpostorMaterial>>>,
) {
let on = fire.is_some();
if let Some(mut assets) = flora {
crate::see_through::arm(&mut assets, on, |m| m.extension.fire, |m, on| m.extension.fire = on);
}
if let Some(mut assets) = growth {
crate::see_through::arm(&mut assets, on, |m| m.extension.fire, |m, on| m.extension.fire = on);
}
if let Some(mut assets) = impostors {
crate::see_through::arm(&mut assets, on, |m| m.fire, |m, on| m.fire = on);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row_of_trees() -> FloraFire {
let mut fire = FloraFire::with_cells(Vec2::ZERO, 64.0, 32);
fire.refuel((-6..=6).map(|i| Vec3::new(i as f32 * 4.0, 0.0, 0.0)));
fire
}
fn run(fire: &mut FloraFire, seconds: f32, wind: Vec2) {
for _ in 0..(seconds / STEP).round() as usize {
fire.step(wind);
}
}
#[test]
fn each_tree_the_fire_burns_through_is_reported_once_and_marked() {
use bevy::asset::{AssetApp, AssetPlugin};
use bevy::transform::components::Transform;
let mut app = bevy::app::App::new();
app.add_plugins((bevy::MinimalPlugins, AssetPlugin::default(), bevy::transform::TransformPlugin));
app.init_asset::<bevy::mesh::Mesh>();
app.init_asset::<bevy::pbr::StandardMaterial>();
app.add_plugins(crate::FloraPlugin);
let mut oak = |x: f32| {
let params = crate::presets::TreePreset::Oak.params();
app.world_mut().spawn((ProceduralTree(params), Transform::from_xyz(x, 0.0, 0.0))).id()
};
let (burning, restored, far) = (oak(0.0), oak(2.0), oak(40.0));
app.world_mut().entity_mut(restored).insert(Burnt);
app.insert_resource(FloraFire::with_cells(Vec2::ZERO, 32.0, 32));
app.update();
let mut cursor = app.world().resource::<Messages<TreeBurnt>>().get_cursor_current();
app.world_mut().resource_mut::<FloraFire>().scorch(Vec3::ZERO, 4.0, 1.0);
app.update();
let reported: Vec<Entity> = cursor.read(app.world().resource::<Messages<TreeBurnt>>()).map(|b| b.tree).collect();
assert_eq!(reported, vec![burning]);
assert!(app.world().get::<Burnt>(burning).is_some());
assert!(app.world().get::<Burnt>(far).is_none());
app.world_mut().resource_mut::<FloraFire>().scorch(Vec3::ZERO, 4.0, 1.0);
app.update();
assert_eq!(cursor.read(app.world().resource::<Messages<TreeBurnt>>()).count(), 0);
}
#[test]
fn a_fire_restored_from_its_cells_burns_on_from_where_it_was() {
let mut fire = row_of_trees();
fire.ignite(Vec3::new(-24.0, 0.0, 0.0), 1.0);
run(&mut fire, 8.0, Vec2::ZERO);
assert!(fire.is_burning());
let saved = fire.cells().to_vec();
let mut loaded = row_of_trees();
assert!(!loaded.restore(&saved[1..]), "a grid of another size was restored");
assert!(loaded.restore(&saved));
assert_eq!(loaded.cells(), &saved[..]);
assert!(loaded.is_burning());
run(&mut loaded, 120.0, Vec2::ZERO);
for i in -6..=6 {
assert!(loaded.char_at(Vec3::new(i as f32 * 4.0, 0.0, 0.0)) > 0.95, "tree {i}");
}
}
#[test]
fn a_fire_runs_along_the_trees_and_stops_where_they_end() {
let mut fire = row_of_trees();
fire.ignite(Vec3::new(-24.0, 0.0, 0.0), 1.0);
run(&mut fire, 120.0, Vec2::ZERO);
for i in -6..=6 {
assert!(fire.char_at(Vec3::new(i as f32 * 4.0, 0.0, 0.0)) > 0.95, "tree {i}");
}
assert_eq!(fire.char_at(Vec3::new(0.0, 0.0, 20.0)), 0.0);
assert_eq!(fire.char_at(Vec3::new(30.0, 0.0, 0.0)), 0.0);
let lit_again = fire.cells().iter().filter(|cell| cell.heat > 0.0 && cell.char < 1.0).count();
assert_eq!(lit_again, 0);
}
#[test]
fn the_fire_moves_at_its_speed_whatever_the_size_of_the_cells() {
let arrival = |cells: u32| {
let mut fire = FloraFire::with_cells(Vec2::ZERO, 64.0, cells);
fire.refuel((0..=10).map(|i| Vec3::new(i as f32 * 3.0, 0.0, 0.0)));
fire.ignite(Vec3::ZERO, 0.5);
let target = Vec3::new(24.0, 0.0, 0.0);
(1..=600).find(|_| {
fire.step(Vec2::ZERO);
fire.heat_at(target) > 0.0
})
.map(|steps| steps as f32 * STEP)
};
let (fine, coarse) = (arrival(128).unwrap(), arrival(32).unwrap());
for (cells, seconds) in [("0.5 m", fine), ("2 m", coarse)] {
assert!((10.0..36.0).contains(&seconds), "{cells} cells: {seconds} s");
}
assert!(fine.max(coarse) / fine.min(coarse) < 1.6, "0.5 m cells {fine} s, 2 m cells {coarse} s");
}
#[test]
fn a_fire_runs_faster_downwind_and_burns_alike_twice() {
let reach = |wind: Vec2| {
let mut fire = row_of_trees();
fire.ignite(Vec3::ZERO, 1.0);
run(&mut fire, 12.0, wind);
let east = (1..=6).filter(|&i| fire.char_at(Vec3::new(i as f32 * 4.0, 0.0, 0.0)) > 0.0).count();
let west = (1..=6).filter(|&i| fire.char_at(Vec3::new(i as f32 * -4.0, 0.0, 0.0)) > 0.0).count();
(east, west, fire.cells().to_vec())
};
let (east, west, cells) = reach(Vec2::new(1.0, 0.0));
assert!(east > west, "downwind {east} trees, upwind {west}");
assert_eq!(cells, reach(Vec2::new(1.0, 0.0)).2, "the same fire twice");
}
#[test]
fn a_cell_without_fuel_goes_out_and_extinguish_keeps_the_char() {
let mut fire = row_of_trees();
fire.ignite(Vec3::new(0.0, 0.0, 20.0), 0.5);
assert!(fire.is_burning());
run(&mut fire, 2.0, Vec2::ZERO);
assert!(!fire.is_burning());
assert_eq!(fire.char_at(Vec3::new(0.0, 0.0, 20.0)), 0.0);
fire.ignite(Vec3::ZERO, 0.5);
run(&mut fire, 6.0, Vec2::ZERO);
fire.extinguish(Vec3::ZERO, 30.0);
let char = fire.char_at(Vec3::ZERO);
assert!(!fire.is_burning() && char > 0.1 && char < 1.0, "char {char}");
run(&mut fire, 5.0, Vec2::ZERO);
assert_eq!(fire.char_at(Vec3::ZERO), char);
}
#[test]
fn reading_back_is_bilinear_between_cell_centres_and_zero_off_the_grid() {
let mut fire = FloraFire::with_cells(Vec2::ZERO, 4.0, 2);
fire.scorch(Vec3::new(-1.0, 0.0, -1.0), 0.1, 1.0);
assert_eq!(fire.char_at(Vec3::new(-1.0, 0.0, -1.0)), 1.0);
assert!((fire.char_at(Vec3::new(0.0, 0.0, -1.0)) - 0.5).abs() < 1.0e-6);
assert!((fire.char_at(Vec3::new(0.0, 0.0, 0.0)) - 0.25).abs() < 1.0e-6);
assert_eq!(fire.char_at(Vec3::new(1.0, 0.0, 1.0)), 0.0);
assert!((fire.char_at(Vec3::new(-2.0, 0.0, -1.0)) - 0.5).abs() < 1.0e-6);
assert_eq!(fire.char_at(Vec3::new(-50.0, 0.0, -1.0)), 0.0);
}
#[test]
fn the_burning_front_scorches_nothing_without_char_and_burns_every_leaf_at_char_one() {
assert!(0.0 - leaf_burn_front(0.0) > LEAF_GLOW + LEAF_SCORCH);
assert!(leaf_burn_front(1.0) >= 1.0);
}
#[test]
fn the_burning_cells_and_the_touched_bounds_follow_the_fire_from_spark_to_cold_embers() {
let mut fire = FloraFire::with_cells(Vec2::ZERO, 256.0, 512);
let stand: Vec<Vec3> =
(0..36).map(|i| Vec3::new(-120.0 + (i % 6) as f32 * 2.5, 0.0, -120.0 + (i / 6) as f32 * 2.5)).collect();
fire.refuel(stand.iter().copied());
let scan = |fire: &FloraFire| {
let side = fire.cells_per_side() as usize;
let (mut burning, mut touched) = (Vec::new(), None::<((usize, usize), (usize, usize))>);
for (i, cell) in fire.cells().iter().enumerate() {
let (x, z) = (i % side, i / side);
if cell.heat > 0.0 {
let centre = Vec2::splat(-128.0) + (Vec2::new(x as f32, z as f32) + 0.5) * 0.5;
burning.push((centre, *cell));
}
if cell.heat > 0.0 || cell.char > 0.0 {
touched = Some(touched.map_or(((x, z), (x, z)), |(l, h): ((usize, usize), (usize, usize))| {
((l.0.min(x), l.1.min(z)), (h.0.max(x), h.1.max(z)))
}));
}
}
(burning, touched)
};
let check = |fire: &FloraFire| {
let (burning, touched) = scan(fire);
assert_eq!(fire.burning().collect::<Vec<_>>(), burning);
let corner = |x: usize, z: usize| Vec2::splat(-128.0) + Vec2::new(x as f32, z as f32) * 0.5;
match (fire.touched_bounds(), touched) {
(None, None) => {}
(Some(bounds), Some((low, high))) => {
assert!(bounds.min.distance(corner(low.0, low.1)) < 1.0e-3, "{bounds:?} {low:?}");
assert!(bounds.max.distance(corner(high.0 + 1, high.1 + 1)) < 1.0e-3, "{bounds:?} {high:?}");
let shader = fire.vectors().nth(1).unwrap();
let grown = [bounds.min.x - 0.5, bounds.min.y - 0.5, bounds.max.x + 0.5, bounds.max.y + 0.5];
assert!(shader.iter().zip(grown).all(|(a, b)| (a - b).abs() < 1.0e-3), "{shader:?}");
}
other => panic!("the bounds and the cells disagree: {other:?}"),
}
};
assert!(fire.touched_bounds().is_none() && fire.burning().next().is_none(), "nothing before the spark");
check(&fire);
fire.ignite(stand[0], 1.0);
check(&fire);
let (mut steps, mut most) = (0, 0);
while fire.is_burning() {
fire.step(Vec2::ZERO);
steps += 1;
most = most.max(fire.burning().count());
if steps % 25 == 0 {
check(&fire);
}
assert!(steps < 3000, "the fire never went out");
}
check(&fire);
assert!(most > 50, "the fire spread through the stand: {most} cells at most");
assert_eq!(fire.burning().count(), 0);
let burnt = fire.touched_bounds().expect("the char stays");
assert!(burnt.min.cmpge(Vec2::splat(-128.0)).all() && burnt.max.cmple(Vec2::splat(-100.0)).all(), "{burnt:?}");
fire.ignite(Vec3::new(100.0, 0.0, 100.0), 0.2);
assert!(fire.touched_bounds().unwrap().max.x > 100.0);
fire.extinguish(Vec3::new(100.0, 0.0, 100.0), 1.0);
assert_eq!(fire.touched_bounds(), Some(burnt), "a spark put out leaves no char");
fire.restore(&vec![FireCell::default(); 512 * 512]);
assert!(fire.touched_bounds().is_none());
fire.scorch(Vec3::new(10.0, 0.0, -10.0), 1.0, 0.5);
check(&fire);
}
}