use bevy::asset::{Asset, AssetId, Assets};
use bevy::ecs::prelude::*;
use bevy::math::{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,
}
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,
}
}
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 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.burning += 1;
}
}
}
pub fn extinguish(&mut self, at: Vec3, radius: f32) {
for i in self.cells_within(at, radius) {
if self.cells[i].heat > 0.0 {
self.cells[i].heat = 0.0;
self.burning -= 1;
}
}
}
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.burning = self.cells.iter().filter(|cell| cell.heat > 0.0).count();
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);
}
}
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 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();
for i in 0..self.cells.len() {
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 {
let cell = &mut self.cells[j];
if cell.heat <= 0.0 {
cell.heat = SPARK;
self.lit[j] = arrives;
} else {
self.lit[j] = self.lit[j].min(arrives);
}
}
self.burning = self.cells.iter().filter(|cell| cell.heat > 0.0).count();
self.steps += 1;
}
fn vectors(&self) -> impl Iterator<Item = [f32; 4]> + '_ {
let side = self.side as usize;
let touched = |cell: &FireCell| cell.char > 0.0 || cell.heat > 0.0;
let (mut low, mut high) = ((usize::MAX, usize::MAX), (0, 0));
for (i, _) in self.cells.iter().enumerate().filter(|(_, cell)| touched(cell)) {
let (x, z) = (i % side, i / side);
low = (low.0.min(x), low.1.min(z));
high = (high.0.max(x), high.1.max(z));
}
let any = low.0 != usize::MAX;
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);
}
}