use bevy::asset::embedded_asset;
use bevy::camera::primitives::Aabb;
use bevy::camera::visibility::NoAutoAabb;
use bevy::light::NotShadowCaster;
use bevy::math::Vec3A;
use bevy::mesh::{MeshTag, MeshVertexBufferLayoutRef};
use bevy::pbr::{Material, MaterialPipeline, MaterialPipelineKey, MeshMaterial3d};
use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError};
use bevy::shader::ShaderRef;
use bevy::time::Real;
use crate::Snuff;
use crate::flame::{Flame, FlameAssets, FlameLook, FlameSite, TintKey, TintedMaterials, scene_colour};
use crate::model::fire_colour::{SPARK_DULL_RED, SPARK_ORANGE};
use crate::model::{
SMOKE_TAIL_SECONDS, SNUFF_SECONDS, ember_cull_extents, pack_flame_tag, retint, set_flame_brightness,
unpack_flame_tag,
};
const SMOKE_SHADER_PATH: &str = "embedded://bevy_lights/smoke.wesl";
pub const MAX_SMOKE_DENSITY: f32 = 1.0;
const EMISSION_SECONDS: f32 = 2.4;
const AFTERGLOW: f32 = 0.18;
const TAIL_BIT: u32 = 1 << 16;
fn steady_tag(site: &FlameSite, share: f32, max_height: f32) -> u32 {
pack_flame_tag(site.phase, site.height, share, max_height) & !TAIL_BIT
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Component)]
pub struct FlameSmoke {
pub density: f32,
}
impl Default for FlameSmoke {
fn default() -> Self {
Self { density: 0.8 }
}
}
impl FlameSmoke {
pub fn problems(&self) -> Vec<crate::Problem> {
let mut out = Vec::new();
crate::model::problem::within(&mut out, "density", self.density, 0.0, MAX_SMOKE_DENSITY);
out
}
fn share(&self) -> f32 {
if self.density.is_finite() { (self.density / MAX_SMOKE_DENSITY).clamp(0.0, 1.0) } else { 0.0 }
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct SmokeMaterial {
#[uniform(0)]
base: LinearRgba,
#[uniform(0)]
params: Vec4,
#[uniform(0)]
warm: Vec4,
#[uniform(0)]
cool: Vec4,
#[uniform(0)]
tail: Vec4,
}
impl SmokeMaterial {
fn new(mid: [f32; 3], strength: f32, max_height: f32, depth_fade: bool) -> Self {
let mut material = Self {
base: LinearRgba::NONE,
params: Vec4::new(0.0, max_height, if depth_fade { 1.0 } else { 0.0 }, strength),
warm: Vec4::ZERO,
cool: Vec4::ZERO,
tail: Vec4::new(SMOKE_TAIL_SECONDS, SNUFF_SECONDS, EMISSION_SECONDS, AFTERGLOW),
};
material.recolour(mid, strength);
material
}
fn recolour(&mut self, mid: [f32; 3], strength: f32) {
let stop = |stop| {
let [red, green, blue] = retint(stop, mid);
Vec4::new(red, green, blue, 0.0)
};
self.base = LinearRgba { red: mid[0], green: mid[1], blue: mid[2], alpha: strength };
self.warm = stop(SPARK_ORANGE);
self.cool = stop(SPARK_DULL_RED);
}
}
impl Material for SmokeMaterial {
fn vertex_shader() -> ShaderRef {
SMOKE_SHADER_PATH.into()
}
fn fragment_shader() -> ShaderRef {
SMOKE_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(())
}
}
pub(crate) fn build(app: &mut App) {
embedded_asset!(app, "smoke.wesl");
app.add_plugins(bevy::pbr::MaterialPlugin::<SmokeMaterial>::default())
.init_resource::<SmokeAssets>()
.register_type::<FlameSmoke>()
.add_systems(Startup, build_smoke_assets);
}
#[derive(Resource, Default)]
pub(crate) struct SmokeAssets {
mesh: Handle<Mesh>,
material: Handle<SmokeMaterial>,
faded_material: Handle<SmokeMaterial>,
tinted: TintedMaterials<SmokeMaterial>,
built: bool,
}
impl SmokeAssets {
fn material_for(&self, depth_fade: bool) -> &Handle<SmokeMaterial> {
if depth_fade { &self.faded_material } else { &self.material }
}
fn material_id(&self, color: Option<Color>, depth_fade: bool) -> Option<AssetId<SmokeMaterial>> {
match color {
None => Some(self.material_for(depth_fade).id()),
Some(color) => self.tinted.id(&TintKey::new(color, depth_fade)),
}
}
fn material_of(
&mut self,
color: Option<Color>,
depth_fade: bool,
look: &FlameLook,
materials: &mut Assets<SmokeMaterial>,
) -> Handle<SmokeMaterial> {
match color {
None => self.material_for(depth_fade).clone(),
Some(color) => {
let key = TintKey::new(color, depth_fade);
self.tinted.handle(key, materials, || {
SmokeMaterial::new(key.rgb(), look.strength, look.max_height, depth_fade)
})
}
}
}
}
#[derive(Component)]
pub(crate) struct Smoke;
#[derive(Component)]
pub(crate) struct Smoked;
#[derive(Component)]
pub(crate) struct SmokeTail {
elapsed: f32,
}
fn build_smoke_assets(
mut assets: ResMut<SmokeAssets>,
look: Res<FlameLook>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<SmokeMaterial>>,
) {
assets.mesh = meshes.add(Rectangle::new(1.0, 1.0));
assets.material =
materials.add(SmokeMaterial::new(scene_colour(&look), look.strength, look.max_height, false));
assets.faded_material =
materials.add(SmokeMaterial::new(scene_colour(&look), look.strength, look.max_height, true));
assets.built = true;
}
fn cull_box(height: f32, scale: Vec3) -> Vec3A {
Vec3A::from(ember_cull_extents(height, scale.x, [scale.y - 1.0, scale.z - 1.0]))
}
fn tail_scale(elapsed: f32) -> f32 {
1.0 / (1.0 + elapsed.max(0.0))
}
fn tail_cull_box(height: f32, scale: Vec3) -> Vec3A {
let [x, y, z] = ember_cull_extents(height, 1.0, [scale.y - 1.0, scale.z - 1.0]);
Vec3A::new(x / scale.x.max(1.0e-4), y, z)
}
type UnsmokedFlame = (With<Flame>, Without<Smoked>, Without<crate::FlameTongue>);
pub(crate) fn dress_smoke(
mut commands: Commands,
flames: Query<(Entity, &ChildOf, &Transform), UnsmokedFlame>,
sites: Query<(&FlameSite, &FlameSmoke)>,
mut smoke_assets: ResMut<SmokeAssets>,
mut materials: ResMut<Assets<SmokeMaterial>>,
look: Res<FlameLook>,
flame_assets: Res<FlameAssets>,
) {
if !smoke_assets.built || !flame_assets.built() {
return;
}
for (flame, parent, at) in &flames {
let Ok((site, smoke)) = sites.get(parent.parent()) else { continue };
let packed = steady_tag(site, smoke.share(), flame_assets.max_height());
let tag = MeshTag::new(packed);
let height = unpack_flame_tag(packed, flame_assets.max_height()).1;
let bound = Aabb { center: Vec3A::ZERO, half_extents: cull_box(height, at.scale) };
let mesh = smoke_assets.mesh.clone();
let material = smoke_assets.material_of(site.color, site.depth_fade, &look, &mut materials);
commands.entity(flame).queue_silenced(move |mut flame: EntityWorldMut| {
flame.insert(Smoked).with_children(|parent| {
parent.spawn((
Name::new("Smoke"),
Smoke,
tag,
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::IDENTITY,
NotShadowCaster,
bound,
NoAutoAabb,
));
});
});
}
}
type SteadyQuads = (With<Smoke>, Without<SmokeTail>);
type TrailQuads = (With<Smoke>, Without<Flame>);
type Trail = (
Entity,
&'static ChildOf,
&'static mut SmokeTail,
&'static mut Transform,
&'static mut MeshTag,
&'static mut Aabb,
);
type SmokeQuad = (
Entity,
&'static ChildOf,
&'static mut MeshTag,
&'static mut Aabb,
&'static mut MeshMaterial3d<SmokeMaterial>,
);
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct SmokeOwners<'w, 's> {
flames: Query<'w, 's, (&'static ChildOf, &'static Transform), With<Flame>>,
sites: Query<'w, 's, (&'static FlameSite, Option<&'static FlameSmoke>)>,
assets: Res<'w, FlameAssets>,
quads: Query<'w, 's, (), With<Smoke>>,
}
impl SmokeOwners<'_, '_> {
fn site_of(&self, flame: Entity) -> Option<(&FlameSite, Option<&FlameSmoke>, Vec3)> {
let (parent, at) = self.flames.get(flame).ok()?;
let (site, smoke) = self.sites.get(parent.parent()).ok()?;
Some((site, smoke, at.scale))
}
fn max_height(&self) -> f32 {
self.assets.max_height()
}
fn has_quads(&self) -> bool {
!self.quads.is_empty()
}
}
pub(crate) fn update_smoke(
mut commands: Commands,
real: Res<Time<Real>>,
look: Res<FlameLook>,
mut smoke_assets: ResMut<SmokeAssets>,
mut materials: ResMut<Assets<SmokeMaterial>>,
mut quads: Query<SmokeQuad, SteadyQuads>,
owners: SmokeOwners,
) {
if !owners.has_quads() {
return;
}
let max_height = owners.max_height();
for (entity, parent, mut tag, mut bound, mut material) in &mut quads {
let Some((site, Some(wanted), scale)) = owners.site_of(parent.parent()) else {
commands.entity(entity).despawn();
commands.entity(parent.parent()).remove::<Smoked>();
continue;
};
if smoke_assets.material_id(site.color, site.depth_fade) != Some(material.0.id()) {
material.0 = smoke_assets.material_of(site.color, site.depth_fade, &look, &mut materials);
}
let packed = set_flame_brightness(tag.value, wanted.share());
if packed != tag.value {
tag.value = packed;
}
let half_extents = cull_box(unpack_flame_tag(tag.value, max_height).1, scale);
if bound.half_extents != half_extents {
bound.half_extents = half_extents;
}
}
if !smoke_assets.tinted.is_empty() {
smoke_assets.tinted.sweep(&materials);
}
let elapsed = real.elapsed_secs_wrapped();
let scene = scene_colour(&look);
let scene_materials = [smoke_assets.material.id(), smoke_assets.faded_material.id()];
for (id, is_scene) in scene_materials
.into_iter()
.map(|id| (id, true))
.chain(smoke_assets.tinted.ids().map(|id| (id, false)))
{
let Some(mut made) = materials.get_mut(id) else { continue };
made.params.x = elapsed;
if look.is_changed() {
if is_scene {
made.recolour(scene, look.strength);
} else {
made.base.alpha = look.strength;
}
made.params.y = look.max_height;
made.params.w = look.strength;
}
}
}
pub(crate) fn begin_smoke_tails(
mut commands: Commands,
snuffed: Query<&Snuff, Added<Snuff>>,
quads: Query<(Entity, &ChildOf), SteadyQuads>,
flames: Query<&ChildOf, With<Flame>>,
sites: Query<&FlameSite>,
) {
if snuffed.is_empty() {
return;
}
for (quad, parent) in &quads {
let Ok(on_site) = flames.get(parent.parent()) else { continue };
let Ok(site) = sites.get(on_site.parent()) else { continue };
let Some(fire) = site.fire else { continue };
let Ok(snuff) = snuffed.get(fire) else { continue };
if snuff.elapsed < SNUFF_SECONDS {
commands.entity(quad).insert(SmokeTail { elapsed: snuff.elapsed.max(0.0) });
}
}
}
pub(crate) fn update_smoke_tails(
mut commands: Commands,
time: Res<Time>,
mut quads: Query<Trail, TrailQuads>,
owners: SmokeOwners,
snuffs: Query<&Snuff>,
) {
if quads.is_empty() {
return;
}
let max_height = owners.max_height();
let dt = time.delta_secs();
for (entity, parent, mut tail, mut local, mut tag, mut bound) in &mut quads {
let Some((site, Some(wanted), scale)) = owners.site_of(parent.parent()) else {
commands.entity(entity).despawn();
commands.entity(parent.parent()).remove::<Smoked>();
continue;
};
let out = site.fire.is_some_and(|fire| snuffs.contains(fire));
if !out || tail.elapsed >= SMOKE_TAIL_SECONDS {
commands.entity(entity).remove::<SmokeTail>();
local.scale = Vec3::ONE;
tag.value = steady_tag(site, wanted.share(), max_height);
bound.half_extents = cull_box(unpack_flame_tag(tag.value, max_height).1, scale);
continue;
}
tail.elapsed += dt;
let carried = tail_scale(tail.elapsed);
let wanted_scale = Vec3::new(carried / scale.x.max(1.0e-4), 1.0, 1.0);
if local.scale != wanted_scale {
local.scale = wanted_scale;
}
let packed = set_flame_brightness(tag.value, wanted.share()) | TAIL_BIT;
if packed != tag.value {
tag.value = packed;
}
let half_extents =
tail_cull_box(unpack_flame_tag(tag.value, max_height).1, Vec3::new(carried, scale.y, scale.z));
if bound.half_extents != half_extents {
bound.half_extents = half_extents;
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::flame::{build_flame_assets, dress_flames, update_flames};
use crate::model::{FLICKER_DEPTH, ember_reach};
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<crate::FlameMaterial>::default())
.insert_resource(Assets::<SmokeMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.init_resource::<SmokeAssets>()
.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
std::time::Duration::from_secs_f64(1.0 / 60.0),
));
app.add_systems(Startup, (build_flame_assets, build_smoke_assets)).add_systems(
Update,
(dress_flames, update_flames, dress_smoke, update_smoke, begin_smoke_tails, update_smoke_tails)
.chain(),
);
app.update();
app
}
fn site(height: f32, phase: f32) -> FlameSite {
FlameSite {
color: None,
height,
phase,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
}
}
fn smoke_quads(app: &mut App) -> Vec<(Entity, u32)> {
app.world_mut()
.query_filtered::<(&ChildOf, &MeshTag), With<Smoke>>()
.iter(app.world())
.map(|(parent, tag)| (parent.parent(), tag.value))
.collect()
}
fn smoke_of(app: &App, site: Entity) -> SmokeMaterial {
let flame = app.world().get::<Children>(site).and_then(|c| c.first().copied()).expect("a flame");
let quad = app.world().get::<Children>(flame).and_then(|c| c.first().copied()).expect("a smoke quad");
let handle = app.world().get::<MeshMaterial3d<SmokeMaterial>>(quad).expect("a material").0.clone();
app.world().resource::<Assets<SmokeMaterial>>().get(&handle).expect("it exists").clone()
}
#[test]
fn only_a_site_that_asks_gets_a_smoke_quad_and_it_hangs_on_the_flame() {
let mut app = app();
let plain = app.world_mut().spawn((site(0.5, 0.0), Transform::default())).id();
let smoking =
app.world_mut().spawn((site(0.5, 1.0), FlameSmoke::default(), Transform::default())).id();
app.update();
app.update();
let quads = smoke_quads(&mut app);
assert_eq!(quads.len(), 1, "one site asked, so one quad, and a second frame does not twin it");
let owner = app.world().get::<ChildOf>(quads[0].0).expect("the flame hangs on its site").parent();
assert_eq!(owner, smoking, "the quad is the smoking site's flame's child");
assert_ne!(owner, plain);
}
#[test]
fn the_quads_tag_carries_the_sites_height_phase_and_the_density() {
let mut app = app();
app.world_mut().spawn((site(0.8, 2.0), FlameSmoke { density: 0.4 }, Transform::default()));
app.update();
app.update();
let (_, tag) = smoke_quads(&mut app)[0];
let (phase, height, share) = unpack_flame_tag(tag, 4.0);
assert!((phase - 2.0).abs() < 0.03, "phase {phase}");
assert!((height - 0.8).abs() < 1e-3, "height {height}");
assert!((share - 0.4 / MAX_SMOKE_DENSITY).abs() < 0.005, "the density's share {share}");
}
#[test]
fn a_changed_density_rewrites_the_tag_and_a_removed_component_takes_the_quad_away_and_can_return() {
let mut app = app();
let id =
app.world_mut().spawn((site(0.5, 0.0), FlameSmoke { density: 0.2 }, Transform::default())).id();
app.update();
app.update();
let before = smoke_quads(&mut app)[0].1;
app.world_mut().get_mut::<FlameSmoke>(id).unwrap().density = 0.9;
app.update();
let after = smoke_quads(&mut app)[0].1;
assert_ne!(before, after, "the density is in the tag");
assert!((unpack_flame_tag(after, 4.0).2 - 0.9 / MAX_SMOKE_DENSITY).abs() < 0.005);
app.world_mut().entity_mut(id).remove::<FlameSmoke>();
app.update();
app.update();
assert!(smoke_quads(&mut app).is_empty(), "no component, no quad");
app.world_mut().entity_mut(id).insert(FlameSmoke::default());
app.update();
app.update();
assert_eq!(smoke_quads(&mut app).len(), 1, "added again, dressed again");
}
#[test]
fn a_density_outside_its_range_is_reported_and_clamped_into_the_tag() {
assert!(FlameSmoke { density: 0.6 }.problems().is_empty());
assert_eq!(FlameSmoke { density: -0.1 }.problems().len(), 1);
assert_eq!(FlameSmoke { density: MAX_SMOKE_DENSITY + 0.5 }.problems().len(), 1);
assert_eq!(FlameSmoke { density: f32::NAN }.problems().len(), 1);
assert_eq!(FlameSmoke { density: 9.0 }.share(), 1.0);
assert_eq!(FlameSmoke { density: f32::NAN }.share(), 0.0);
}
#[test]
fn a_fire_smokes_in_its_own_colour_and_follows_it_when_it_changes() {
let mut app = app();
let green = Color::from(LinearRgba::rgb(0.2, 1.0, 0.3));
let tinted = app
.world_mut()
.spawn((FlameSite { color: Some(green), ..site(0.5, 0.0) }, FlameSmoke::default()))
.id();
let orange = app.world_mut().spawn((site(0.5, 1.0), FlameSmoke::default())).id();
app.update();
app.update();
let stops = |m: &SmokeMaterial| [[m.warm.x, m.warm.y, m.warm.z], [m.cool.x, m.cool.y, m.cool.z]];
assert_eq!(
stops(&smoke_of(&app, orange)),
[SPARK_ORANGE, SPARK_DULL_RED],
"the scene's smoke is lit by the shipped fire"
);
for stop in stops(&smoke_of(&app, tinted)) {
assert!(
stop[1] > stop[0] && stop[1] > stop[2],
"a green fire's smoke is green at both ends: {stop:?}"
);
}
app.world_mut().get_mut::<FlameSite>(tinted).expect("the site").color =
Some(Color::from(LinearRgba::rgb(0.1, 0.3, 1.0)));
app.update();
let [warm, _] = stops(&smoke_of(&app, tinted));
assert!(warm[2] > warm[1] && warm[1] > warm[0], "now blue: {warm:?}");
}
#[test]
fn the_smoke_is_additive_so_it_joins_no_sorting() {
let material = SmokeMaterial::new([1.0, 0.55, 0.18], 1.0, 4.0, false);
assert!(matches!(material.alpha_mode(), AlphaMode::Add));
}
fn drawn_points(origin: Vec3, size: f32, wind: Vec2, eye: Vec3, camera_right: Vec3) -> Vec<Vec3> {
const SOURCE_HEIGHT: f32 = 0.35;
const HALF_WIDTH: f32 = 0.7;
const RISE_MAX: f32 = 2.6;
const MARGIN: f32 = 0.35;
const LEAN_TOP: f32 = 3.9;
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 cos_e = (1.0 - sin_e * sin_e).max(0.0).sqrt();
let reach = LEAN_TOP * wind.dot(Vec2::new(right.x, right.z));
let (x_low, x_high) = (-HALF_WIDTH + reach.min(0.0), HALF_WIDTH + reach.max(0.0));
let (v_low, v_high) = (-MARGIN, RISE_MAX * cos_e + MARGIN);
let screen_up = to_eye.normalize().cross(right).normalize();
let mut points = Vec::new();
for i in 0..5 {
for j in 0..5 {
let (u, v) = (i as f32 / 4.0, j as f32 / 4.0);
let across = x_low + (x_high - x_low) * u;
let climb = v_low + (v_high - v_low) * v;
points.push(origin + right * (across * size) + screen_up * (climb * size));
}
}
points
}
#[test]
fn a_smoke_quad_any_part_of_which_is_on_screen_is_never_culled() {
crate::flame::culling_tests::assert_a_quad_on_screen_is_never_culled(
ember_cull_extents,
ember_reach,
drawn_points,
);
}
fn world_box(app: &mut App) -> Option<(Vec3, Vec3)> {
let (flame, bound) =
app.world_mut().query_filtered::<(&ChildOf, &Aabb), With<Smoke>>().iter(app.world()).next()?;
let scale = app.world().get::<Transform>(flame.parent())?.scale;
Some((scale, Vec3::from(bound.half_extents) * scale))
}
#[test]
fn a_smoke_quads_box_keeps_its_size_through_the_catch_and_the_lean_on_every_frame() {
let mut app = app();
app.insert_resource(crate::FlameWind { velocity: Vec2::new(3.0, -1.5) });
let height = 0.9;
app.world_mut().spawn((
FlameSite { kindle: crate::KINDLE_SECONDS, ..site(height, 0.0) },
FlameSmoke::default(),
Transform::default(),
));
let (mut checked, mut seen_small) = (0, false);
for frame in 0..(crate::KINDLE_SECONDS * 60.0) as usize + 6 {
app.update();
let Some((scale, world)) = world_box(&mut app) else { continue };
checked += 1;
let size = height * scale.x.min(1.0).powf(2.0 / 3.0);
let want = Vec3::splat(ember_reach([scale.y - 1.0, scale.z - 1.0]) * 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 quad wants {want}"
);
seen_small |= scale.x < 0.3 && (scale.y - 1.0).abs() > 0.1;
}
assert!(checked > 30, "only {checked} frames had a smoke quad");
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_smoke_quad_carries_a_box_of_its_own_and_is_not_left_to_the_units_quad() {
let mut app = app();
app.world_mut().spawn((site(0.5, 0.0), FlameSmoke::default(), Transform::default()));
app.update();
app.update();
let quad = app.world_mut().query_filtered::<Entity, With<Smoke>>().single(app.world()).unwrap();
let entity = app.world().entity(quad);
assert!(entity.contains::<Aabb>(), "a smoke quad has a culling box");
assert!(entity.contains::<NoAutoAabb>(), "which Bevy is told not to recompute");
assert!(
!entity.contains::<bevy::camera::visibility::NoFrustumCulling>(),
"and which the frustum test uses: an off-screen fire's smoke is not drawn"
);
}
fn smoking_site_with_fire(app: &mut App, height: f32) -> (Entity, Entity) {
let fire = app.world_mut().spawn_empty().id();
let site_entity = app
.world_mut()
.spawn((
FlameSite { fire: Some(fire), ..site(height, 0.0) },
FlameSmoke::default(),
Transform::default(),
))
.id();
for _ in 0..4 {
app.update();
}
(fire, site_entity)
}
fn burn_down(app: &mut App, fire: Entity) {
let step = app.world().resource::<Time>().delta_secs().max(1.0 / 60.0);
if let Some(mut snuff) = app.world_mut().get_mut::<Snuff>(fire) {
snuff.elapsed = (snuff.elapsed + step).min(SNUFF_SECONDS);
}
app.update();
}
fn the_quad(app: &mut App) -> Entity {
app.world_mut().query_filtered::<Entity, With<Smoke>>().single(app.world()).unwrap()
}
fn drawn_scale(app: &mut App) -> Vec3 {
let quad = the_quad(app);
let flame = app.world().get::<ChildOf>(quad).unwrap().parent();
app.world().get::<Transform>(flame).unwrap().scale * app.world().get::<Transform>(quad).unwrap().scale
}
fn flames_scale(app: &mut App) -> Vec3 {
let quad = the_quad(app);
let flame = app.world().get::<ChildOf>(quad).unwrap().parent();
app.world().get::<Transform>(flame).unwrap().scale
}
fn is_trail(app: &mut App) -> bool {
let quad = the_quad(app);
let entity = app.world().entity(quad);
let (flagged, begun) =
(entity.get::<MeshTag>().unwrap().value & TAIL_BIT != 0, entity.contains::<SmokeTail>());
assert_eq!(flagged, begun, "the tag says trail exactly while the quad has a trail clock");
begun
}
#[test]
fn a_steady_quads_tag_is_never_read_as_a_trail_whatever_its_height() {
for step in 0..2000 {
let site = FlameSite { phase: step as f32 * 0.37, ..site(0.001 + step as f32 * 0.001, 0.0) };
let tag = steady_tag(&site, 0.8, 4.0);
assert_eq!(tag & TAIL_BIT, 0, "height {} packs {tag:#x}", site.height);
let (_, height, _) = unpack_flame_tag(tag, 4.0);
assert!(
(height - site.height).abs() <= 2.0 * 4.0 / 65535.0 + 1e-6,
"height {} reads back as {height}",
site.height
);
}
}
#[test]
fn a_fire_put_out_leaves_a_trail_that_outlives_its_flame_and_ends_with_its_clock() {
let mut app = app();
let (fire, _) = smoking_site_with_fire(&mut app, 0.8);
assert!(!is_trail(&mut app), "a burning fire's smoke follows its catch");
assert_eq!(drawn_scale(&mut app).x, 1.0);
app.world_mut().entity_mut(fire).insert(Snuff::default());
burn_down(&mut app, fire);
assert!(is_trail(&mut app), "a fire put out begins a trail on the frame its Snuff is seen");
for _ in 0..179 {
burn_down(&mut app, fire);
}
assert!(flames_scale(&mut app).x < 0.0011, "the flame is out: {}", flames_scale(&mut app));
assert!(is_trail(&mut app), "and the smoke goes on");
let carried = drawn_scale(&mut app).x;
assert!(
((1.0 / carried - 1.0) - 3.0).abs() < 0.1,
"the scale carries the seconds since the douse (1 / (1 + t)): {carried} for 3 s"
);
for _ in 0..140 {
burn_down(&mut app, fire);
}
assert!(!is_trail(&mut app), "the trail is over after {SMOKE_TAIL_SECONDS} s");
let quad = the_quad(&mut app);
assert_eq!(app.world().get::<Transform>(quad).unwrap().scale, Vec3::ONE);
let (_, tag) = smoke_quads(&mut app)[0];
let (phase, height, share) = unpack_flame_tag(tag, 4.0);
assert!(
phase.abs() < 0.03 && (height - 0.8).abs() < 1e-3 && share > 0.79,
"the tag is the steady one again"
);
}
#[test]
fn only_a_fire_seen_going_out_leaves_a_trail() {
let mut app = app();
let out = app.world_mut().spawn(Snuff { elapsed: SNUFF_SECONDS }).id();
app.world_mut().spawn((
FlameSite { fire: Some(out), ..site(0.5, 0.0) },
FlameSmoke::default(),
Transform::default(),
));
for _ in 0..4 {
app.update();
}
assert!(!is_trail(&mut app), "a fire that was already out when the scene loaded begins no trail");
let mut app = self::app();
app.world_mut().spawn((site(0.5, 0.0), FlameSmoke::default(), Transform::default()));
let other = app.world_mut().spawn_empty().id();
for _ in 0..4 {
app.update();
}
app.world_mut().entity_mut(other).insert(Snuff::default());
for _ in 0..4 {
burn_down(&mut app, other);
}
assert!(!is_trail(&mut app), "a site that names no fire has no douse to follow");
}
#[test]
fn re_lighting_a_fire_ends_its_trail_at_once() {
let mut app = app();
let (fire, _) = smoking_site_with_fire(&mut app, 0.5);
app.world_mut().entity_mut(fire).insert(Snuff::default());
for _ in 0..90 {
burn_down(&mut app, fire);
}
assert!(is_trail(&mut app));
app.world_mut().entity_mut(fire).remove::<Snuff>();
app.update();
assert!(!is_trail(&mut app), "the fire is lit again: its plume follows the catch");
let quad = the_quad(&mut app);
assert_eq!(
app.world().get::<Transform>(quad).unwrap().scale,
Vec3::ONE,
"and the quad is no longer counter-scaled"
);
assert!(
drawn_scale(&mut app).x > 0.99,
"the settled flame is whole again, so is its smoke: {}",
drawn_scale(&mut app)
);
}
#[test]
fn a_paused_worlds_trail_holds_still() {
let mut app = app();
let (fire, _) = smoking_site_with_fire(&mut app, 0.5);
app.world_mut().entity_mut(fire).insert(Snuff::default());
for _ in 0..30 {
burn_down(&mut app, fire);
}
app.world_mut().resource_mut::<Time<Virtual>>().pause();
app.update();
let held = drawn_scale(&mut app);
for _ in 0..30 {
app.update();
}
assert_eq!(drawn_scale(&mut app), held, "virtual time stopped, so did the trail's clock");
app.world_mut().resource_mut::<Time<Virtual>>().unpause();
for _ in 0..3 {
app.update();
}
assert!(drawn_scale(&mut app).x < held.x, "and it runs on when time does");
}
#[test]
fn a_trails_box_holds_the_whole_column_though_its_flame_has_shrunk_to_nothing() {
let mut app = app();
app.insert_resource(crate::FlameWind { velocity: Vec2::new(3.0, -1.5) });
let height = 0.9;
let (fire, _) = smoking_site_with_fire(&mut app, height);
app.world_mut().entity_mut(fire).insert(Snuff::default());
let mut checked = 0;
for frame in 0..(SMOKE_TAIL_SECONDS * 60.0) as usize - 6 {
burn_down(&mut app, fire);
let scale = drawn_scale(&mut app);
let quad = the_quad(&mut app);
let half = Vec3::from(app.world().get::<Aabb>(quad).unwrap().half_extents);
let world = half * scale;
let want = Vec3::splat(ember_reach([scale.y - 1.0, scale.z - 1.0]) * height);
let slack = 1e-3 * (1.0 + want.max_element());
assert!(
(world - want).abs().max_element() < slack,
"frame {frame}: scale {scale}, the box measures {world} and the trail wants {want}"
);
checked += 1;
}
assert!(checked > 250);
assert!(flames_scale(&mut app).x < 0.0011, "the flame the box must not be taken from is a point");
}
}