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::units::FloraUnits;
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(Clone, Copy, Debug, PartialEq)]
pub struct FireSpot {
pub at: Vec2,
pub heat: f32,
pub share: f32,
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FireSnapshot {
pub side: u32,
pub cells: Vec<(u32, f32, 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, 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 hottest(&self, square: f32, n: usize) -> Vec<FireSpot> {
let square = if square.is_finite() && square > self.cell { square } else { self.cell };
let held = (square / self.cell).powi(2);
let mut squares: std::collections::BTreeMap<(i64, i64), (Vec2, f32)> = std::collections::BTreeMap::new();
for (centre, cell) in self.burning() {
let key = ((centre.x / square).floor() as i64, (centre.y / square).floor() as i64);
let entry = squares.entry(key).or_insert((Vec2::ZERO, 0.0));
entry.0 += centre * cell.heat;
entry.1 += cell.heat;
}
let mut spots: Vec<FireSpot> =
squares.into_values().map(|(weighted, heat)| FireSpot { at: weighted / heat, heat, share: (heat / held).min(1.0) }).collect();
spots.sort_by(|a, b| b.heat.total_cmp(&a.heat));
spots.truncate(n);
spots
}
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 snapshot(&self) -> FireSnapshot {
let side = self.side as usize;
let mut cells = Vec::new();
if let Some([x0, z0, x1, z1]) = self.touched {
for z in z0..=z1 {
for x in x0..=x1 {
let i = z * side + x;
let cell = self.cells[i];
if cell.char > 0.0 || cell.heat > 0.0 {
cells.push((i as u32, cell.char, cell.heat));
}
}
}
}
FireSnapshot { side: self.side, cells }
}
pub fn restore_snapshot(&mut self, snapshot: &FireSnapshot) -> bool {
if snapshot.side != self.side {
return false;
}
let now = self.steps as f32 * STEP;
self.cells.fill(FireCell::default());
self.lit.fill(f32::INFINITY);
self.touched = None;
for &(i, char, heat) in &snapshot.cells {
let i = i as usize;
if i >= self.cells.len() {
continue;
}
let (char, heat) = (char.clamp(0.0, 1.0), heat.clamp(0.0, 1.0));
self.cells[i] = FireCell { char, heat };
if heat > 0.0 {
self.lit[i] = now;
}
self.touch(i);
}
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>, units: f32) {
let ground = self.spread.ground.clamp(0.0, 1.0);
self.fuel.fill(ground);
let reach = (self.spread.reach * units).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, units: f32) {
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) * units * 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>, With<crate::deadfall::PooledDeadfall>)>;
pub(crate) fn burn(
mut fire: ResMut<FloraFire>,
time: Res<Time>,
wind: Option<Res<FloraWind>>,
units: Res<FloraUnits>,
trees: Query<&GlobalTransform, AnyTree>,
) {
let state = fire.bypass_change_detection();
let units = units.units_per_metre;
let laid = (trees.iter().len(), state.spread.reach, state.spread.ground, units);
if state.fueled_for != Some(laid) {
state.refuel(trees.iter().map(GlobalTransform::translation), units);
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, units);
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_in(units: f32) -> FloraFire {
let mut fire = FloraFire::with_cells(Vec2::ZERO, 64.0 * units, 32);
fire.refuel((-6..=6).map(|i| Vec3::new(i as f32 * 4.0 * units, 0.0, 0.0)), units);
fire
}
fn row_of_trees() -> FloraFire {
row_of_trees_in(1.0)
}
fn run_in(fire: &mut FloraFire, seconds: f32, wind: Vec2, units: f32) {
for _ in 0..(seconds / STEP).round() as usize {
fire.step(wind, units);
}
}
fn run(fire: &mut FloraFire, seconds: f32, wind: Vec2) {
run_in(fire, seconds, wind, 1.0);
}
#[test]
fn the_hottest_squares_hold_their_burning_cells_hottest_first() {
let mut fire = FloraFire::with_cells(Vec2::ZERO, 64.0, 64);
let side = 64;
let mut cells = vec![FireCell::default(); side * side];
for (x, z) in [(40, 40), (41, 40), (40, 41), (41, 41)] {
cells[z * side + x] = FireCell { char: 0.5, heat: 1.0 };
}
for (x, z) in [(10, 50), (11, 50)] {
cells[z * side + x] = FireCell { char: 0.2, heat: 0.5 };
}
cells[5 * side + 5] = FireCell { char: 1.0, heat: 0.0 };
assert!(fire.restore(&cells));
let spots = fire.hottest(8.0, 5);
assert_eq!(spots.len(), 2);
assert_eq!((spots[0].at, spots[0].heat), (Vec2::new(9.0, 9.0), 4.0));
assert!((spots[0].share - 4.0 / 64.0).abs() < 1.0e-6, "four of an 8 m square's 64 cells");
assert_eq!((spots[1].at, spots[1].heat), (Vec2::new(-21.0, 18.5), 1.0));
assert_eq!(fire.hottest(8.0, 1), spots[..1]);
let cells: Vec<Vec2> = fire.hottest(0.0, 10).iter().map(|spot| spot.at).collect();
assert_eq!(cells.len(), 6);
assert_eq!(cells[..4], [Vec2::new(8.5, 8.5), Vec2::new(8.5, 9.5), Vec2::new(9.5, 8.5), Vec2::new(9.5, 9.5)]);
}
#[test]
fn a_snapshot_holds_the_touched_cells_only_and_restores_the_fire_as_it_was() {
let mut fire = row_of_trees();
fire.ignite(Vec3::new(-24.0, 0.0, 0.0), 2.0);
run(&mut fire, 6.0, Vec2::ZERO);
let snapshot = fire.snapshot();
let touched = fire.cells().iter().filter(|c| c.char > 0.0 || c.heat > 0.0).count();
assert!(touched > 0 && snapshot.cells.len() == touched, "{} entries for {touched} touched cells", snapshot.cells.len());
assert!(touched < fire.cells().len() / 4);
let mut loaded = row_of_trees();
assert!(loaded.restore_snapshot(&snapshot));
assert_eq!(loaded.cells(), fire.cells());
assert_eq!((loaded.is_burning(), loaded.touched_bounds()), (fire.is_burning(), fire.touched_bounds()));
let mut dense = row_of_trees();
assert!(dense.restore(fire.cells()));
run(&mut loaded, 4.0, Vec2::ZERO);
run(&mut dense, 4.0, Vec2::ZERO);
assert_eq!(loaded.cells(), dense.cells());
let mut other = FloraFire::with_cells(Vec2::ZERO, 64.0, 16);
assert!(!other.restore_snapshot(&snapshot));
assert!(!other.is_burning() && other.touched_bounds().is_none());
#[cfg(feature = "ron")]
{
let text = ron::to_string(&snapshot).unwrap();
assert_eq!(ron::from_str::<FireSnapshot>(&text).unwrap(), snapshot, "it saves");
}
}
#[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)), 1.0);
fire.ignite(Vec3::ZERO, 0.5);
let target = Vec3::new(24.0, 0.0, 0.0);
(1..=600).find(|_| {
fire.step(Vec2::ZERO, 1.0);
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_fire_in_a_world_in_feet_spreads_as_many_cells_a_second_as_one_in_metres() {
const FEET: f32 = 3.2808;
let lit = |world: f32, units: f32, seconds: f32| {
let mut fire = row_of_trees_in(world);
fire.ignite(Vec3::new(-24.5 * world, 0.0, 0.3 * world), 0.5 * world);
run_in(&mut fire, seconds, Vec2::ZERO, units);
fire.cells().iter().filter(|cell| cell.heat > 0.0 || cell.char > 0.0).count()
};
let mut last = 0;
for seconds in [4.0, 8.0, 12.0, 16.0] {
let metres = lit(1.0, 1.0, seconds);
let feet = lit(FEET, FEET, seconds);
assert!(metres > last, "{metres} cells alight after {seconds} s, {last} before");
assert!(metres.abs_diff(feet) <= 1, "{metres} cells alight after {seconds} s in metres, {feet} in feet");
last = metres;
}
let unaware = lit(FEET, 1.0, 16.0);
assert!(unaware < last / 2, "an unaware fire lit {unaware} cells against {last}");
}
#[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(), 1.0);
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, 1.0);
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);
}
}