use bevy::asset::embedded_asset;
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::NoAutoAabb;
use bevy::light::NotShadowCaster;
use bevy::mesh::{MeshTag, MeshVertexBufferLayoutRef};
use bevy::pbr::{Material, MaterialPipeline, MaterialPipelineKey, MaterialPlugin, MeshMaterial3d};
use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError};
use bevy::shader::ShaderRef;
use bevy::time::Real;
use crate::LightsSystems;
use crate::chain_sets;
use crate::flicker::{Flicker, Kindle, Snuff};
use crate::model::{
DEFAULT_FIRE_DIAMETER, FLICKER_DEPTH, KINDLE_SECONDS, flame_cull_extents, flame_tilt, flicker_swing,
kindle_gain, pack_flame_tag, set_flame_brightness, snuff_gain, unpack_flame_tag,
};
use crate::pool::PoolCandidate;
const FLAME_SHADER_PATH: &str = "embedded://bevy_lights/flame.wesl";
pub struct FlamePlugin;
impl Plugin for FlamePlugin {
fn build(&self, app: &mut App) {
chain_sets(app);
embedded_asset!(app, "flame.wesl");
crate::embers::build(app);
app.add_plugins(MaterialPlugin::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.register_type::<FlameLook>()
.register_type::<FlameWind>()
.register_type::<FlameGust>()
.register_type::<FlameSite>()
.register_type::<Flame>()
.add_systems(
Update,
(
gust_wind,
dress_flames,
update_flames,
crate::embers::dress_embers,
crate::embers::update_embers,
)
.chain()
.in_set(LightsSystems::Flame),
)
.add_systems(Startup, build_flame_assets);
crate::report::watch::<FlameSite>(app);
crate::report::watch::<crate::FlameEmbers>(app);
crate::report::watch_resource::<FlameLook>(app);
crate::report::watch_resource::<FlameWind>(app);
crate::report::watch_resource::<FlameGust>(app);
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct FlameMaterial {
#[uniform(0)]
base: LinearRgba,
#[uniform(0)]
params: Vec4,
}
impl Material for FlameMaterial {
fn vertex_shader() -> ShaderRef {
FLAME_SHADER_PATH.into()
}
fn fragment_shader() -> ShaderRef {
FLAME_SHADER_PATH.into()
}
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Add
}
fn enable_prepass() -> bool {
false
}
fn enable_shadows() -> bool {
false
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
_key: MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
if let Some(depth) = descriptor.depth_stencil.as_mut() {
depth.depth_write_enabled = Some(false);
}
descriptor.primitive.cull_mode = None;
Ok(())
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[require(Visibility)]
#[reflect(Component)]
pub struct FlameSite {
pub height: f32,
pub phase: f32,
pub kindle: f32,
pub diameter: Option<f32>,
pub depth: f32,
pub depth_fade: bool,
pub fire: Option<Entity>,
}
impl FlameSite {
pub fn problems(&self) -> Vec<crate::Problem> {
use crate::model::problem::{finite, non_negative, positive, within};
let mut out = Vec::new();
positive(&mut out, "height", self.height);
finite(&mut out, "phase", self.phase);
non_negative(&mut out, "kindle", self.kindle);
if let Some(d) = self.diameter {
positive(&mut out, "diameter", d);
}
within(&mut out, "depth", self.depth, 0.0, crate::MAX_FLICKER_DEPTH);
out
}
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Reflect)]
#[reflect(Component)]
pub struct Flame {
pub kindled: f32,
pub source: Option<Entity>,
}
#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Resource)]
pub struct FlameLook {
pub color: LinearRgba,
pub strength: f32,
pub max_height: f32,
}
impl Default for FlameLook {
fn default() -> Self {
Self { color: LinearRgba::rgb(1.0, 0.55, 0.18), strength: 1.0, max_height: 4.0 }
}
}
impl FlameLook {
pub fn problems(&self) -> Vec<crate::Problem> {
use crate::model::problem::{non_negative, positive};
let mut out = Vec::new();
non_negative(&mut out, "color.red", self.color.red);
non_negative(&mut out, "color.green", self.color.green);
non_negative(&mut out, "color.blue", self.color.blue);
non_negative(&mut out, "strength", self.strength);
positive(&mut out, "max_height", self.max_height);
out
}
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Resource)]
pub struct FlameWind {
pub velocity: Vec2,
}
impl FlameWind {
pub fn problems(&self) -> Vec<crate::Problem> {
use crate::model::problem::finite;
let mut out = Vec::new();
finite(&mut out, "velocity.x", self.velocity.x);
finite(&mut out, "velocity.y", self.velocity.y);
out
}
}
#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Resource)]
pub struct FlameGust {
pub mean: Vec2,
pub intensity: f32,
pub timescale: f32,
pub seed: u32,
}
impl Default for FlameGust {
fn default() -> Self {
Self {
mean: Vec2::ZERO,
intensity: crate::model::GUST_INTENSITY,
timescale: crate::model::GUST_TIMESCALE,
seed: 0,
}
}
}
impl FlameGust {
pub fn problems(&self) -> Vec<crate::Problem> {
use crate::model::problem::{finite, non_negative};
let mut out = Vec::new();
finite(&mut out, "mean.x", self.mean.x);
finite(&mut out, "mean.y", self.mean.y);
non_negative(&mut out, "intensity", self.intensity);
non_negative(&mut out, "timescale", self.timescale);
out
}
}
fn gust_wind(
real: Res<Time<Real>>,
gust: Option<Res<FlameGust>>,
wind: Option<ResMut<FlameWind>>,
mut commands: Commands,
) {
let Some(gust) = gust else { return };
let (x, z) = crate::model::gust_velocity(
(gust.mean.x, gust.mean.y),
gust.intensity,
gust.timescale,
gust.seed,
real.elapsed_secs_f64(),
);
let velocity = Vec2::new(x, z);
match wind {
Some(mut wind) => {
if wind.velocity != velocity {
wind.velocity = velocity;
}
}
None => commands.insert_resource(FlameWind { velocity }),
}
}
fn set_scale(transform: &mut Transform, bound: &mut Aabb, height: f32, took: f32, lean: Vec2) {
let scale = Vec3::new(took, 1.0 + lean.x, 1.0 + lean.y);
if transform.scale != scale {
transform.scale = scale;
bound.half_extents = Vec3A::from(flame_cull_extents(height, took, lean.to_array()));
}
}
#[derive(Resource, Default)]
pub(crate) struct FlameAssets {
mesh: Handle<Mesh>,
material: Handle<FlameMaterial>,
faded_material: Handle<FlameMaterial>,
max_height: f32,
built: bool,
}
impl FlameAssets {
pub(crate) fn built(&self) -> bool {
self.built
}
pub(crate) fn max_height(&self) -> f32 {
self.max_height
}
fn material_for(&self, depth_fade: bool) -> &Handle<FlameMaterial> {
if depth_fade { &self.faded_material } else { &self.material }
}
}
#[derive(Component)]
pub(crate) struct Flamed;
pub(crate) fn build_flame_assets(
mut assets: ResMut<FlameAssets>,
look: Res<FlameLook>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<FlameMaterial>>,
) {
assets.mesh = meshes.add(Rectangle::new(1.0, 1.0));
assets.material = materials.add(material_for(*look, false));
assets.faded_material = materials.add(material_for(*look, true));
assets.max_height = look.max_height;
assets.built = true;
}
fn material_for(look: FlameLook, depth_fade: bool) -> FlameMaterial {
FlameMaterial {
base: LinearRgba {
red: look.color.red,
green: look.color.green.min(1.0),
blue: look.color.blue,
alpha: look.strength,
},
params: Vec4::new(0.0, look.max_height, if depth_fade { 1.0 } else { 0.0 }, look.strength),
}
}
pub(crate) fn dress_flames(
mut commands: Commands,
sites: Query<(Entity, &FlameSite), Without<Flamed>>,
assets: Res<FlameAssets>,
time: Res<Time>,
) {
if !assets.built {
return;
}
let max_height = assets.max_height;
let t = time.elapsed_secs();
for (site, flame) in &sites {
let catch = flame.kindle;
let brightness = flicker_swing(flame.diameter, flame.depth, t, flame.phase);
let packed = pack_flame_tag(flame.phase, flame.height, brightness, max_height);
let tag = MeshTag::new(packed);
let height = unpack_flame_tag(packed, max_height).1;
let bound = Aabb {
center: Vec3A::ZERO,
half_extents: Vec3A::from(flame_cull_extents(height, 1.0, [0.0; 2])),
};
let mesh = assets.mesh.clone();
let material = assets.material_for(flame.depth_fade).clone();
commands.entity(site).queue_silenced(move |mut site: EntityWorldMut| {
site.insert(Flamed).with_children(|parent| {
parent.spawn((
Name::new("Flame"),
Flame { kindled: if catch > 0.0 { 0.0 } else { f32::MAX }, source: None },
tag,
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::IDENTITY,
NotShadowCaster,
bound,
NoAutoAabb,
));
});
});
}
}
type FlameParts = (
&'static mut Flame,
&'static mut Transform,
&'static mut Aabb,
&'static mut MeshTag,
&'static mut MeshMaterial3d<FlameMaterial>,
&'static ChildOf,
);
pub(crate) fn update_flames(
real: Res<Time<Real>>,
time: Res<Time>,
look: Res<FlameLook>,
assets: Res<FlameAssets>,
mut materials: ResMut<Assets<FlameMaterial>>,
mut flames: Query<FlameParts>,
sites: Sites,
) {
if !assets.built {
return;
}
let dt = real.delta_secs();
let t = time.elapsed_secs();
let wind = sites.wind.as_deref().map_or((0.0, Vec2::ZERO), |w| {
let speed = w.velocity.length();
(speed, if speed > 0.0 && speed.is_finite() { w.velocity / speed } else { Vec2::ZERO })
});
let max_height = assets.max_height;
for (mut flame, mut transform, mut bound, mut tag, mut material, parent) in &mut flames {
let height = unpack_flame_tag(tag.value, max_height).1;
let (site, owner) = sites.sites.get(parent.parent()).map_or((None, None), |(s, o)| (Some(s), o));
let source = owner.map(ChildOf::parent).filter(|owner| !sites.sites.contains(*owner));
if flame.source != source {
flame.source = source;
}
let mut lean = Vec2::ZERO;
if let Some(site) = site {
let wanted = assets.material_for(site.depth_fade);
if material.0 != *wanted {
material.0 = wanted.clone();
}
let (diameter, depth, phase) = sites.fire_of(site);
if wind.0 > 0.0 && wind.0.is_finite() {
lean = wind.1 * flame_tilt(wind.0, diameter.unwrap_or(DEFAULT_FIRE_DIAMETER)).sin();
}
let brightness = flicker_swing(diameter, depth, t, phase);
let packed = set_flame_brightness(tag.value, brightness);
if packed != tag.value {
tag.value = packed;
}
}
if let Some(elapsed) = site.and_then(|site| sites.snuff_of(site)) {
set_scale(&mut transform, &mut bound, height, snuff_gain(elapsed).max(0.001), lean);
if flame.kindled < f32::MAX {
flame.kindled = f32::MAX;
}
continue;
}
if let Some(elapsed) =
site.and_then(|site| sites.kindle_of(site)).filter(|elapsed| *elapsed < KINDLE_SECONDS)
{
set_scale(&mut transform, &mut bound, height, kindle_gain(elapsed).max(0.001), lean);
if flame.kindled < f32::MAX {
flame.kindled = f32::MAX;
}
continue;
}
if flame.kindled >= f32::MAX {
set_scale(&mut transform, &mut bound, height, 1.0, lean);
continue;
}
flame.kindled = (flame.kindled + dt).min(f32::MAX);
let catch = site.map_or(0.0, |site| site.kindle);
let gain = if catch > 0.0 { kindle_gain(flame.kindled.min(catch)) } else { 1.0 };
set_scale(&mut transform, &mut bound, height, gain.max(0.001), lean);
if flame.kindled >= catch {
flame.kindled = f32::MAX;
set_scale(&mut transform, &mut bound, height, 1.0, lean);
}
}
let elapsed = real.elapsed_secs_wrapped();
for handle in [&assets.material, &assets.faded_material] {
let Some(mut made) = materials.get_mut(handle) else {
continue;
};
made.params.x = elapsed;
if look.is_changed() {
made.base = LinearRgba {
red: look.color.red,
green: look.color.green.min(1.0),
blue: look.color.blue,
alpha: look.strength,
};
made.params.y = look.max_height;
made.params.w = look.strength;
}
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct Sites<'w, 's> {
sites: Query<'w, 's, (&'static FlameSite, Option<&'static ChildOf>)>,
lights: Query<'w, 's, (Option<&'static Flicker>, Option<&'static PoolCandidate>)>,
kindles: Query<'w, 's, &'static Kindle>,
snuffs: Query<'w, 's, &'static Snuff>,
wind: Option<Res<'w, FlameWind>>,
}
impl Sites<'_, '_> {
fn kindle_of(&self, site: &FlameSite) -> Option<f32> {
self.kindles.get(site.fire?).ok().map(|kindle| kindle.elapsed)
}
fn snuff_of(&self, site: &FlameSite) -> Option<f32> {
self.snuffs.get(site.fire?).ok().map(|snuff| snuff.elapsed)
}
fn fire_of(&self, site: &FlameSite) -> (Option<f32>, f32, f32) {
let own = (site.diameter, site.depth, site.phase);
let Some((flicker, candidate)) = site.fire.and_then(|light| self.lights.get(light).ok()) else {
return own;
};
match (flicker, candidate) {
(Some(flicker), _) => (flicker.diameter, flicker.depth, flicker.phase),
(None, Some(candidate)) => (
candidate.diameter,
candidate.depth.unwrap_or(FLICKER_DEPTH),
candidate.phase.unwrap_or(site.phase),
),
(None, None) => own,
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::model::{FLICKER_DEPTH, SNUFF_SECONDS, unpack_flame_tag};
use core::f32::consts::TAU;
fn dressing_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
std::time::Duration::from_secs_f64(1.0 / 60.0),
));
app.add_systems(Startup, build_flame_assets)
.add_systems(Update, (dress_flames, update_flames).chain());
app.update();
app
}
fn flame_children(app: &mut App) -> Vec<(Entity, u32)> {
app.world_mut()
.query_filtered::<(&MeshTag, &ChildOf), With<Flame>>()
.iter(app.world())
.map(|(tag, child_of)| (child_of.parent(), tag.value))
.collect()
}
#[test]
fn every_site_gets_one_flame_and_a_second_frame_does_not_twin_it() {
let mut app = dressing_app();
let site = app
.world_mut()
.spawn((
FlameSite {
height: 0.9,
phase: 0.4,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
))
.id();
app.update();
let children = flame_children(&mut app);
assert_eq!(children.len(), 1, "one flame");
assert_eq!(children[0].0, site, "a child of the site");
app.update();
assert_eq!(flame_children(&mut app).len(), 1, "still one flame");
}
#[test]
fn an_unchanged_brightness_leaves_the_tag_unwritten() {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: Some(0.34),
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
let flame =
app.world_mut().query_filtered::<Entity, With<Flame>>().single(app.world()).expect("one flame");
let mut changed = 0;
let mut last = app.world().entity(flame).get_ref::<MeshTag>().expect("a tag").last_changed();
for _ in 0..30 {
app.update();
let tag = app.world().entity(flame).get_ref::<MeshTag>().expect("a tag");
let tick = tag.last_changed();
if tick != last {
changed += 1;
last = tick;
}
}
assert!(changed > 0, "the tag never changed, so the flame is not breathing at all");
assert!(changed < 30, "the tag changed on all 30 frames: an unchanged byte still re-extracts");
}
#[test]
fn a_flames_tag_carries_its_sites_phase_and_height() {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
let max_height = app.world().resource::<FlameAssets>().max_height;
let children = flame_children(&mut app);
assert_eq!(children.len(), 1);
let (phase, height, _brightness) = unpack_flame_tag(children[0].1, max_height);
assert!((phase - 2.5).abs() < TAU / 256.0, "phase {phase}");
assert!((height - 0.35).abs() < 1e-3, "height {height}");
}
#[test]
fn a_flames_brightness_is_the_fires_own_swing() {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: Some(0.34),
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
let max_height = app.world().resource::<FlameAssets>().max_height;
let t = app.world().resource::<Time>().elapsed_secs();
let children = flame_children(&mut app);
assert_eq!(children.len(), 1);
let (_, _, brightness) = unpack_flame_tag(children[0].1, max_height);
let expected = flicker_swing(Some(0.34), FLICKER_DEPTH, t, 2.5);
assert!(
(brightness - expected).abs() < 1.0 / 255.0,
"brightness {brightness} against the light's own {expected}"
);
assert!((0.70..=1.0).contains(&brightness), "{brightness} outside the swing window");
}
#[test]
fn a_flame_breathes_by_the_light_its_site_names() {
let mut app = dressing_app();
let light =
app.world_mut().spawn(Flicker { phase: 1.3, diameter: Some(0.3), depth: 0.30, ..default() }).id();
let site = FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: Some(light),
};
app.world_mut().spawn((site, Transform::default()));
for _ in 0..40 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
let brightness = |app: &mut App| {
let t = app.world().resource::<Time>().elapsed_secs();
(unpack_flame_tag(flame_children(app)[0].1, max_height).2, t)
};
let (got, t) = brightness(&mut app);
let theirs = flicker_swing(Some(0.3), 0.30, t, 1.3);
let own = flicker_swing(None, FLICKER_DEPTH, t, 2.5);
assert!((own - theirs).abs() > 2.0 / 255.0, "the fixture must tell the two apart: {own} vs {theirs}");
assert!((got - theirs).abs() < 1.0 / 255.0, "brightness {got} against the light's {theirs}");
app.world_mut().get_mut::<Flicker>(light).expect("the light").set_depth(0.05);
app.update();
let (got, t) = brightness(&mut app);
let calmer = flicker_swing(Some(0.3), 0.05, t, 1.3);
assert!((got - calmer).abs() < 1.0 / 255.0, "brightness {got} after set_depth, wanted {calmer}");
}
#[test]
fn a_flame_reads_a_pool_candidate_the_way_the_pool_does() {
let mut app = dressing_app();
let candidate = app.world_mut().spawn(PoolCandidate { depth: Some(0.30), ..default() }).id();
let bare = app.world_mut().spawn(PoolCandidate::default()).id();
let site = |fire| FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: None,
depth: 0.0,
depth_fade: false,
fire: Some(fire),
};
let deep = app.world_mut().spawn((site(candidate), Transform::default())).id();
let plain = app.world_mut().spawn((site(bare), Transform::default())).id();
for _ in 0..40 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
let t = app.world().resource::<Time>().elapsed_secs();
for (site, depth) in [(deep, 0.30), (plain, FLICKER_DEPTH)] {
let tag =
flame_children(&mut app).into_iter().find(|(parent, _)| *parent == site).expect("a flame").1;
let wanted = flicker_swing(None, depth, t, 2.5);
let got = unpack_flame_tag(tag, max_height).2;
assert!((got - wanted).abs() < 1.0 / 255.0, "depth {depth}: brightness {got}, wanted {wanted}");
}
}
#[test]
fn a_dangling_fire_link_falls_back_to_the_sites_own_values() {
let mut app = dressing_app();
let gone = app.world_mut().spawn_empty().id();
app.world_mut().despawn(gone);
let bare = app.world_mut().spawn_empty().id();
for fire in [gone, bare] {
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: Some(0.34),
depth: 0.25,
depth_fade: false,
fire: Some(fire),
},
Transform::default(),
));
}
for _ in 0..40 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
let t = app.world().resource::<Time>().elapsed_secs();
let wanted = flicker_swing(Some(0.34), 0.25, t, 2.5);
let flames = flame_children(&mut app);
assert_eq!(flames.len(), 2);
for (_, tag) in flames {
let got = unpack_flame_tag(tag, max_height).2;
assert!((got - wanted).abs() < 1.0 / 255.0, "brightness {got}, wanted {wanted}");
}
}
#[test]
fn a_relit_fire_catches_its_flame_with_its_light() {
let mut app = dressing_app();
let light = app.world_mut().spawn(Kindle { elapsed: KINDLE_SECONDS }).id();
let site = |fire| {
(
FlameSite {
height: 0.35,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire,
},
Transform::default(),
)
};
let linked = app.world_mut().spawn(site(Some(light))).id();
let free = app.world_mut().spawn(site(None)).id();
for _ in 0..5 {
app.update();
}
let scales = |app: &mut App| {
app.world_mut()
.query_filtered::<(&Transform, &ChildOf), With<Flame>>()
.iter(app.world())
.map(|(t, c)| (c.parent(), t.scale.x))
.collect::<Vec<_>>()
};
assert!(scales(&mut app).iter().all(|(_, s)| *s == 1.0), "a settled fire draws whole");
for step in [0.0, 0.15, 0.3, 0.45] {
app.world_mut().entity_mut(light).insert(Kindle { elapsed: step });
app.update();
assert_eq!(flame_children(&mut app).len(), 2);
let scales = scales(&mut app);
let scale_of = |site| scales.iter().find(|(parent, _)| *parent == site).map(|(_, s)| *s);
let wanted = kindle_gain(step).max(0.001);
let got = scale_of(linked).unwrap_or_default();
assert!((got - wanted).abs() < 1e-6, "linked flame {got} at elapsed {step}, wanted {wanted}");
assert_eq!(scale_of(free), Some(1.0), "the unlinked flame is untouched");
}
app.world_mut().entity_mut(light).insert(Kindle { elapsed: 0.2 });
app.update();
app.world_mut().entity_mut(light).remove::<Kindle>();
app.update();
app.update();
assert!(
scales(&mut app).iter().all(|(_, s)| *s == 1.0),
"a flame is whole once its light's kindle is gone"
);
}
#[test]
fn a_snuffed_fire_takes_its_flame_out_with_its_light() {
let mut app = dressing_app();
let light = app.world_mut().spawn(Kindle { elapsed: KINDLE_SECONDS }).id();
let site = |fire| {
(
FlameSite {
height: 0.35,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire,
},
Transform::default(),
)
};
let linked = app.world_mut().spawn(site(Some(light))).id();
let free = app.world_mut().spawn(site(None)).id();
for _ in 0..5 {
app.update();
}
let scale_of = |app: &mut App, site: Entity| {
app.world_mut()
.query_filtered::<(&Transform, &ChildOf), With<Flame>>()
.iter(app.world())
.find(|(_, c)| c.parent() == site)
.map(|(t, _)| t.scale.x)
.unwrap_or_default()
};
for step in [0.0, 0.3, 0.6, 0.9, SNUFF_SECONDS] {
app.world_mut().entity_mut(light).insert(Snuff { elapsed: step });
app.update();
let wanted = snuff_gain(step).max(0.001);
let got = scale_of(&mut app, linked);
assert!((got - wanted).abs() < 1e-6, "linked flame {got} at elapsed {step}, wanted {wanted}");
assert_eq!(scale_of(&mut app, free), 1.0, "the unlinked flame is untouched");
}
app.world_mut().entity_mut(light).remove::<Snuff>();
app.update();
app.update();
assert_eq!(scale_of(&mut app, linked), 1.0, "a flame is whole once its light's snuff is gone");
}
#[test]
fn a_sized_flame_and_an_unsized_one_do_not_read_alike() {
fn brightness_of(diameter: Option<f32>) -> f32 {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
for _ in 0..18 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
unpack_flame_tag(flame_children(&mut app)[0].1, max_height).2
}
assert_ne!(brightness_of(Some(0.34)), brightness_of(None));
}
#[test]
fn a_changed_look_rewrites_the_shared_material() {
let mut app = dressing_app();
let handle = app.world().resource::<FlameAssets>().material.clone();
{
let mut look = app.world_mut().resource_mut::<FlameLook>();
look.color = LinearRgba::rgb(0.2, 0.9, 0.4);
look.strength = 0.5;
}
app.update();
let material = app
.world()
.resource::<Assets<FlameMaterial>>()
.get(&handle)
.cloned()
.unwrap_or_else(|| panic!("the shared flame material must exist"));
assert!((material.base.red - 0.2).abs() < 1e-6, "{:?}", material.base);
assert!((material.base.green - 0.9).abs() < 1e-6, "{:?}", material.base);
assert!((material.base.alpha - 0.5).abs() < 1e-6, "{:?}", material.base);
assert!((material.params.w - 0.5).abs() < 1e-6, "{:?}", material.params);
}
#[test]
fn a_site_that_asks_for_the_depth_fade_is_drawn_with_the_material_that_has_it() {
fn fade_of(app: &mut App, site: Entity) -> f32 {
let flame = app
.world()
.get::<Children>(site)
.and_then(|c| c.first().copied())
.expect("a dressed site has a flame");
let handle = app
.world()
.get::<MeshMaterial3d<FlameMaterial>>(flame)
.expect("a flame has a material")
.0
.clone();
app.world()
.resource::<Assets<FlameMaterial>>()
.get(&handle)
.expect("the material exists")
.params
.z
}
let mut app = dressing_app();
let site = |depth_fade| FlameSite {
height: 0.9,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade,
fire: None,
};
let plain = app.world_mut().spawn(site(false)).id();
let faded = app.world_mut().spawn(site(true)).id();
app.update();
assert_eq!(fade_of(&mut app, plain), 0.0);
assert_eq!(fade_of(&mut app, faded), 1.0);
app.world_mut().get_mut::<FlameSite>(plain).expect("the site").depth_fade = true;
app.world_mut().get_mut::<FlameSite>(faded).expect("the site").depth_fade = false;
app.update();
assert_eq!(fade_of(&mut app, plain), 1.0);
assert_eq!(fade_of(&mut app, faded), 0.0);
app.world_mut().resource_mut::<FlameLook>().strength = 0.5;
app.update();
for site in [plain, faded] {
let flame = app.world().get::<Children>(site).and_then(|c| c.first().copied()).expect("a flame");
let handle =
app.world().get::<MeshMaterial3d<FlameMaterial>>(flame).expect("a material").0.clone();
let material =
app.world().resource::<Assets<FlameMaterial>>().get(&handle).expect("exists").clone();
assert!((material.params.w - 0.5).abs() < 1e-6, "{:?}", material.params);
}
}
#[test]
fn a_site_on_a_prop_keeps_its_flame_at_the_site_and_leaves_with_the_prop() {
let mut app = dressing_app();
let prop = app.world_mut().spawn(Transform::from_xyz(4.0, 0.0, 0.0)).id();
let site = app
.world_mut()
.spawn((
FlameSite {
height: 0.35,
phase: 0.7,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::from_xyz(0.0, 1.5, 0.0),
ChildOf(prop),
))
.id();
app.update();
let flames = flame_children(&mut app);
assert_eq!(flames.len(), 1, "one flame: {flames:?}");
assert_eq!(flames[0].0, site, "the flame is the site's child, wherever the site stands");
let local = app
.world_mut()
.query_filtered::<&Transform, With<Flame>>()
.single(app.world())
.copied()
.unwrap_or_default();
assert_eq!(local.translation, Vec3::ZERO, "the flame's foot is the site's origin");
app.world_mut().despawn(prop);
app.update();
assert!(flame_children(&mut app).is_empty(), "a prop that goes takes its flame with it");
}
#[test]
fn a_flame_names_the_prop_its_site_hangs_on_and_follows_a_reparent() {
let site_of = || FlameSite {
height: 0.35,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
};
let mut app = dressing_app();
let torch = app.world_mut().spawn(Transform::default()).id();
let other = app.world_mut().spawn(Transform::default()).id();
let on_torch = app.world_mut().spawn((site_of(), ChildOf(torch))).id();
let on_root = app.world_mut().spawn(site_of()).id();
let nested = app.world_mut().spawn((site_of(), ChildOf(on_root))).id();
app.update();
let source_of = |app: &mut App, site: Entity| -> Option<Option<Entity>> {
app.world_mut()
.query::<(&Flame, &ChildOf)>()
.iter(app.world())
.find(|(_, parent)| parent.parent() == site)
.map(|(flame, _)| flame.source)
};
assert_eq!(source_of(&mut app, on_torch), Some(Some(torch)));
assert_eq!(source_of(&mut app, on_root), Some(None), "a root site belongs to no prop");
assert_eq!(source_of(&mut app, nested), Some(None), "a site under a site belongs to the outer site");
app.world_mut().entity_mut(on_torch).insert(ChildOf(other));
app.update();
assert_eq!(source_of(&mut app, on_torch), Some(Some(other)), "a reparented site is followed");
app.world_mut().entity_mut(on_torch).remove::<ChildOf>();
app.update();
assert_eq!(source_of(&mut app, on_torch), Some(None), "an unparented site has no source");
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod kindle_tests {
use super::*;
use crate::KINDLE_SECONDS;
use crate::model::FLICKER_DEPTH;
use bevy::time::TimeUpdateStrategy;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.insert_resource(TimeUpdateStrategy::ManualDuration(std::time::Duration::from_secs_f64(
1.0 / 60.0,
)));
app.add_systems(Startup, build_flame_assets)
.add_systems(Update, (dress_flames, update_flames).chain());
app.update();
app
}
fn scale_of(app: &mut App) -> f32 {
app.world_mut()
.query_filtered::<&Transform, With<Flame>>()
.iter(app.world())
.next()
.map(|transform| transform.scale.x)
.unwrap_or(f32::NAN)
}
#[test]
fn a_flame_lit_with_its_fire_grows_from_nothing_and_stops_at_full() {
let mut app = app();
let site = FlameSite {
height: 0.9,
phase: 0.0,
kindle: KINDLE_SECONDS,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
};
app.world_mut().spawn((site, Transform::default()));
app.update();
app.update();
let early = scale_of(&mut app);
assert!(early < 0.2, "a flame that has just caught is tiny: {early}");
for _ in 0..((KINDLE_SECONDS / 2.0 * 60.0) as usize) {
app.update();
}
let middle = scale_of(&mut app);
assert!(middle > early && middle < 1.05, "the flame is still growing at half time: {middle}");
for _ in 0..((KINDLE_SECONDS * 60.0) as usize) {
app.update();
}
let settled = scale_of(&mut app);
assert!((settled - 1.0).abs() < 1e-5, "a caught flame is full size and stays there: {settled}");
}
#[test]
fn a_flame_that_has_been_burning_all_along_is_full_size_at_once() {
let mut app = app();
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
app.update();
assert_eq!(scale_of(&mut app), 1.0);
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod catch_colour_tests {
use super::*;
use crate::KINDLE_SECONDS;
use crate::model::FLICKER_DEPTH;
use bevy::time::TimeUpdateStrategy;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.insert_resource(TimeUpdateStrategy::ManualDuration(std::time::Duration::from_secs_f64(
1.0 / 60.0,
)));
app.add_systems(Startup, build_flame_assets)
.add_systems(Update, (gust_wind, dress_flames, update_flames).chain());
app.update();
app
}
fn scales(app: &mut App) -> Vec<f32> {
let mut found: Vec<f32> = app
.world_mut()
.query_filtered::<&Transform, With<Flame>>()
.iter(app.world())
.map(|transform| transform.scale.x)
.collect();
found.sort_by(|a, b| a.total_cmp(b));
found
}
#[test]
fn a_catching_flame_carries_its_own_progress_for_the_colour() {
let mut app = app();
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 0.0,
kindle: KINDLE_SECONDS,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
app.update();
let early = scales(&mut app)[0];
assert!(early < 0.2, "a catching flame reports a small catch: {early}");
for _ in 0..((KINDLE_SECONDS * 60.0) as usize + 2) {
app.update();
}
let settled = scales(&mut app)[0];
assert!((settled - 1.0).abs() < 1e-5, "and exactly 1 once it has taken: {settled}");
}
#[test]
fn one_fire_catching_does_not_tint_another() {
let mut app = app();
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 0.0,
kindle: KINDLE_SECONDS,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 1.3,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::from_xyz(5.0, 0.0, 0.0),
));
app.update();
app.update();
let catching = scales(&mut app);
assert_eq!(catching.len(), 2, "two flames: {catching:?}");
assert!(catching[0] < 0.2, "the catching one is small: {catching:?}");
assert!((catching[1] - 1.0).abs() < 1e-5, "the steady one is untouched: {catching:?}");
}
fn flame_scale(app: &mut App) -> Vec3 {
app.update();
app.world_mut().query_filtered::<&Transform, With<Flame>>().single(app.world()).unwrap().scale
}
fn site_with(diameter: Option<f32>) -> FlameSite {
FlameSite {
height: 0.9,
phase: 0.0,
kindle: 0.0,
diameter,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
}
}
#[test]
fn a_flame_leans_downwind_by_its_own_fires_size_and_stands_upright_in_still_air() {
let mut app = app();
app.world_mut().spawn((site_with(Some(0.34)), Transform::default()));
assert_eq!(flame_scale(&mut app), Vec3::ONE, "no wind resource: upright");
app.insert_resource(FlameWind { velocity: Vec2::new(3.0, 0.0) });
let scale = flame_scale(&mut app);
let want = crate::model::flame_tilt(3.0, 0.34).sin();
assert!((scale.y - (1.0 + want)).abs() < 1e-6, "downwind along +X: {scale:?}");
assert_eq!(scale.z, 1.0, "nothing across it: {scale:?}");
assert_eq!(scale.x, 1.0, "the catch is untouched: {scale:?}");
app.insert_resource(FlameWind { velocity: Vec2::new(0.0, -3.0) });
let scale = flame_scale(&mut app);
assert!((scale.z - (1.0 - want)).abs() < 1e-6, "downwind along -Z: {scale:?}");
assert_eq!(scale.y, 1.0);
app.insert_resource(FlameWind::default());
assert_eq!(flame_scale(&mut app), Vec3::ONE, "the wind dropped: upright again");
}
#[test]
fn a_larger_fire_leans_less_in_the_same_wind() {
let mut app = app();
app.insert_resource(FlameWind { velocity: Vec2::new(2.0, 0.0) });
app.world_mut().spawn((site_with(Some(0.2)), Transform::default()));
app.world_mut().spawn((site_with(Some(2.0)), Transform::default()));
app.update();
app.update();
let mut lean: Vec<(f32, f32)> = app
.world_mut()
.query_filtered::<(&Transform, &ChildOf), With<Flame>>()
.iter(app.world())
.map(|(t, c)| (site_diameter(app.world(), c.parent()), t.scale.y - 1.0))
.collect();
lean.sort_by(|a, b| a.0.total_cmp(&b.0));
assert!(lean[0].1 > lean[1].1 && lean[1].1 > 0.0, "{lean:?}");
}
fn site_diameter(world: &World, site: Entity) -> f32 {
world.get::<FlameSite>(site).and_then(|s| s.diameter).unwrap_or(f32::NAN)
}
#[test]
fn a_wind_that_is_not_a_number_or_infinite_leaves_the_flame_upright() {
let mut app = app();
app.world_mut().spawn((site_with(None), Transform::default()));
for velocity in [Vec2::new(f32::NAN, 0.0), Vec2::new(f32::INFINITY, 0.0)] {
app.insert_resource(FlameWind { velocity });
assert_eq!(flame_scale(&mut app), Vec3::ONE, "{velocity:?}");
}
}
#[test]
fn a_gust_drives_the_wind_and_the_lean_changes_smoothly_frame_to_frame() {
let mut app = app();
app.world_mut().spawn((site_with(Some(0.34)), Transform::default()));
app.insert_resource(FlameGust { mean: Vec2::new(2.0, 0.0), seed: 5, ..default() });
let mut leans = Vec::new();
for _ in 0..600 {
let scale = flame_scale(&mut app);
leans.push(scale.y - 1.0);
}
let (lo, hi) = leans.iter().fold((f32::MAX, f32::MIN), |(lo, hi), &l| (lo.min(l), hi.max(l)));
assert!(hi - lo > 0.1, "the lean never moved: {lo}..{hi}");
let worst = leans.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0, f32::max);
assert!(worst < 0.05, "one frame jumped the lean by {worst}");
assert!(app.world().get_resource::<FlameWind>().is_some(), "the plugin inserted the wind it drives");
}
#[test]
fn without_a_gust_a_hosts_own_wind_is_left_alone() {
let mut app = app();
app.world_mut().spawn((site_with(Some(0.34)), Transform::default()));
app.insert_resource(FlameWind { velocity: Vec2::new(3.0, 0.0) });
for _ in 0..30 {
app.update();
}
assert_eq!(app.world().resource::<FlameWind>().velocity, Vec2::new(3.0, 0.0));
}
#[test]
fn a_gust_with_no_turbulence_is_the_steady_mean_and_a_bad_setting_is_reported() {
let mut app = app();
app.insert_resource(FlameGust { mean: Vec2::new(0.0, 2.0), intensity: 0.0, ..default() });
app.update();
assert_eq!(app.world().resource::<FlameWind>().velocity, Vec2::new(0.0, 2.0));
let bad = FlameGust { mean: Vec2::new(f32::NAN, 0.0), intensity: -1.0, ..default() };
let fields: Vec<_> = bad.problems().iter().map(|p| p.field).collect();
assert_eq!(fields, ["mean.x", "intensity"]);
assert!(FlameGust::default().problems().is_empty());
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod culling_tests {
use super::*;
use crate::model::{FLAME_REACH, FLICKER_DEPTH};
use crate::{FireKind, KINDLE_SECONDS};
use bevy::camera::primitives::{Frustum, Sphere};
use bevy::camera::{PerspectiveProjection, Projection};
use bevy::math::Affine3A;
use bevy::time::TimeUpdateStrategy;
use core::f32::consts::{FRAC_PI_2, TAU};
struct Draw(u64);
impl Draw {
fn unit(&mut self) -> f32 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 >> 40) as f32 / (1u64 << 24) as f32
}
fn range(&mut self, low: f32, high: f32) -> f32 {
low + (high - low) * self.unit()
}
}
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
fn drawn_points(origin: Vec3, size: f32, wind: Vec2, eye: Vec3, camera_right: Vec3) -> Vec<Vec3> {
const ASPECT: f32 = 0.6;
const SOURCE_HEIGHT: f32 = 0.35;
const OVERHEAD_START: f32 = 0.873;
const OVERHEAD_FULL: f32 = 1.326;
const OVERHEAD_HEIGHT: f32 = 1.0;
let right = Vec3::new(camera_right.x, 0.0, camera_right.z).normalize();
let centre = origin + Vec3::Y * (size * SOURCE_HEIGHT);
let to_eye = eye - centre;
let sin_e = (to_eye.y / to_eye.length().max(1e-4)).clamp(0.0, 1.0);
let topdown = smoothstep(OVERHEAD_START, OVERHEAD_FULL, sin_e.asin());
let wind = wind * (1.0 - topdown);
let lean = wind.dot(Vec2::new(right.x, right.z));
let width = ASPECT + lean.abs();
let mut points = Vec::new();
for i in 0..5 {
for j in 0..5 {
let (x, y) = (i as f32 / 4.0 - 0.5, j as f32 / 4.0 - 0.5);
let across = x * width + 0.5 * lean;
let mut at = origin + right * (across * size) + Vec3::Y * (y * size + size * 0.5);
if sin_e > OVERHEAD_START.sin() {
let screen_up = to_eye.normalize().cross(right).normalize();
at = centre + right * (across * size) + screen_up * (y * size * OVERHEAD_HEIGHT);
}
points.push(at);
}
}
points
}
#[test]
fn a_flame_any_part_of_which_is_on_screen_is_never_culled() {
let mut draw = Draw(0x9e37_79b9_7f4a_7c15);
let projection = Projection::Perspective(PerspectiveProjection {
fov: 1.0,
aspect_ratio: 16.0 / 9.0,
near: 0.1,
far: 1000.0,
..default()
});
let clip_from_view = projection.get_clip_from_view();
let (mut on_screen, mut edge) = (0, 0);
for round in 0..40_000 {
let origin = Vec3::new(draw.range(-3.0, 3.0), draw.range(0.0, 2.0), draw.range(-3.0, 3.0));
let height = draw.range(0.04, 3.0);
let took = if draw.unit() < 0.4 { 1.0 } else { draw.range(0.001, 1.0) };
let tilt = if draw.unit() < 0.25 { 0.0 } else { draw.range(0.0, 0.995) };
let heading = draw.range(0.0, TAU);
let lean = Vec2::new(heading.cos(), heading.sin()) * tilt;
let around = draw.range(0.0, TAU);
let elevation = draw.range(-0.3, FRAC_PI_2);
let distance = draw.range(0.3, 14.0);
let eye = origin
+ Vec3::new(around.cos() * elevation.cos(), elevation.sin(), around.sin() * elevation.cos())
* distance;
let aim = origin
+ Vec3::Y * (height * 0.4)
+ Vec3::new(draw.range(-1.0, 1.0), draw.range(-1.0, 1.0), draw.range(-1.0, 1.0))
* distance
* 0.7;
let camera = GlobalTransform::from(Transform::from_translation(eye).looking_at(aim, Vec3::Y));
let frustum: Frustum = projection.compute_frustum(&camera);
let parent = if round % 2 == 0 {
Quat::IDENTITY
} else {
Quat::from_euler(
EulerRot::YXZ,
draw.range(0.0, TAU),
draw.range(-1.5, 1.5),
draw.range(0.0, TAU),
)
};
let world_from_local = Affine3A::from_scale_rotation_translation(
Vec3::new(took, 1.0 + lean.x, 1.0 + lean.y),
parent,
origin,
);
let global = GlobalTransform::from(world_from_local);
let [hx, hy, hz] = flame_cull_extents(height, took, lean.to_array());
let bound = Aabb { center: Vec3A::ZERO, half_extents: Vec3A::new(hx, hy, hz) };
let size = height * took.powf(2.0 / 3.0);
let world_from_view = camera.affine();
let view_from_world = world_from_view.inverse();
let points = drawn_points(origin, size, lean, eye, camera.right().as_vec3());
let reach = (FLAME_REACH * size) * (1.0 + 1e-4);
let worst = points.iter().map(|p| p.distance(origin)).fold(0.0, f32::max);
assert!(
worst <= reach,
"round {round}: a point {worst} from the foot of a flame of drawn height {size}"
);
let seen = points
.into_iter()
.filter(|p| {
let clip = clip_from_view * view_from_world.transform_point3(*p).extend(1.0);
clip.w > 0.0
&& clip.x.abs() <= clip.w
&& clip.y.abs() <= clip.w
&& clip.z > 0.0
&& clip.z <= clip.w
})
.count();
if seen == 0 {
continue;
}
on_screen += 1;
if seen < 25 {
edge += 1;
}
let sphere = Sphere {
center: world_from_local.transform_point3a(bound.center),
radius: global.radius_vec3a(bound.half_extents),
};
assert!(
frustum.intersects_sphere(&sphere, false)
&& frustum.intersects_obb(&bound, &world_from_local, true, false),
"round {round}: {seen} of 25 points of a {height} m flame (catch {took}, lean {lean}) are on screen, yet it is culled"
);
}
assert!(on_screen > 8_000, "only {on_screen} samples had the flame on screen");
assert!(edge > 1_500, "only {edge} samples had it half out of the frame");
}
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.insert_resource(TimeUpdateStrategy::ManualDuration(std::time::Duration::from_secs_f64(
1.0 / 60.0,
)));
app.add_systems(Startup, build_flame_assets)
.add_systems(Update, (dress_flames, update_flames).chain());
app.update();
app
}
fn world_half_extents(app: &mut App) -> (Vec3, Vec3) {
let (transform, bound) = app
.world_mut()
.query_filtered::<(&Transform, &Aabb), With<Flame>>()
.single(app.world())
.map(|(t, b)| (*t, *b))
.unwrap();
(transform.scale, Vec3::from(bound.half_extents) * transform.scale)
}
#[test]
fn a_flames_box_keeps_its_size_through_the_catch_and_the_lean() {
let mut app = app();
app.insert_resource(FlameWind { velocity: Vec2::new(3.0, -1.5) });
let height = 0.9;
app.world_mut().spawn((
FlameSite {
height,
phase: 0.0,
kindle: KINDLE_SECONDS,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
let mut seen_small = false;
for frame in 0..(KINDLE_SECONDS * 60.0) as usize + 6 {
app.update();
let (scale, world) = world_half_extents(&mut app);
let size = height * scale.x.min(1.0).powf(2.0 / 3.0);
let want = Vec3::splat(FLAME_REACH * size);
let slack = 1e-4 * (1.0 + want.max_element());
assert!(
(world.y - want.y).abs() < slack
&& (world.z - want.z).abs() < slack
&& world.x >= want.x - slack
&& world.x <= want.x * scale.x.max(1.0) + slack,
"frame {frame}: scale {scale}, the box measures {world} and the drawn flame wants {want}"
);
seen_small |= scale.x < 0.3 && (scale.y - 1.0).abs() > 0.1;
}
assert!(
seen_small,
"the run never had a small, leaning flame, so it did not test the catch and the lean together"
);
}
#[test]
fn a_flame_carries_a_box_of_its_own_and_is_not_left_to_the_units_quad() {
let mut app = app();
app.world_mut().spawn((FireKind::Torch.site(), Transform::default()));
app.update();
let flame = app.world_mut().query_filtered::<Entity, With<Flame>>().single(app.world()).unwrap();
let entity = app.world().entity(flame);
assert!(entity.contains::<Aabb>(), "a flame has a culling box");
assert!(
entity.contains::<bevy::camera::visibility::NoAutoAabb>(),
"which Bevy is told not to recompute"
);
assert!(
!entity.contains::<bevy::camera::visibility::NoFrustumCulling>(),
"and which the frustum test uses: an off-screen flame is not drawn"
);
}
}