use bevy::asset::{RenderAssetUsages, embedded_asset};
use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
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, Extent3d, RenderPipelineDescriptor, SpecializedMeshPipelineError, TextureDimension,
TextureFormat,
};
use bevy::shader::ShaderRef;
use bevy::time::Real;
use crate::flame::{FlameLook, FlameSite, Sites};
use crate::model::{
BED_RAMP_FIELD, BED_RAMP_HEAT, BED_TILE, DEFAULT_BED_COOLING, KINDLE_SECONDS, bed_drawn_heat,
bed_heat_step, bed_offset, bed_ramp, bed_visible_heat, char_tile, flicker_swing, pack_bed_tag,
};
const BED_SHADER_PATH: &str = "embedded://bevy_lights/bed.wesl";
const BED_LIFT: f32 = 0.012;
const FIELD_SIDE: u32 = 128;
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Component, Default)]
pub struct FuelBed {
pub diameter: f32,
pub seed: u32,
pub cooling: f32,
}
impl Default for FuelBed {
fn default() -> Self {
Self { diameter: 0.7, seed: 0, cooling: DEFAULT_BED_COOLING }
}
}
impl FuelBed {
pub fn problems(&self) -> Vec<crate::Problem> {
let mut out = Vec::new();
crate::model::problem::positive(&mut out, "diameter", self.diameter);
crate::model::problem::positive(&mut out, "cooling", self.cooling);
out
}
fn width(&self) -> Option<f32> {
(self.diameter.is_finite() && self.diameter > 0.0).then_some(self.diameter)
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct FuelBedMaterial {
#[uniform(0)]
params: Vec4,
#[uniform(0)]
ramp: [Vec4; BED_RAMP_HEAT * BED_RAMP_FIELD],
#[texture(1)]
#[sampler(2)]
field: Handle<Image>,
}
impl Material for FuelBedMaterial {
fn vertex_shader() -> ShaderRef {
BED_SHADER_PATH.into()
}
fn fragment_shader() -> ShaderRef {
BED_SHADER_PATH.into()
}
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Mask(0.5)
}
fn enable_prepass() -> bool {
false
}
fn enable_shadows() -> bool {
false
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
_key: MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
descriptor.primitive.cull_mode = None;
Ok(())
}
}
pub(crate) fn build(app: &mut App) {
embedded_asset!(app, "bed.wesl");
app.add_plugins(MaterialPlugin::<FuelBedMaterial>::default())
.init_resource::<BedAssets>()
.register_type::<FuelBed>();
}
#[derive(Resource, Default)]
pub(crate) struct BedAssets {
mesh: Handle<Mesh>,
material: Handle<FuelBedMaterial>,
dark: f32,
built: bool,
}
#[derive(Component)]
pub(crate) struct Bed;
#[derive(Component)]
pub(crate) struct BedHeat(f32);
#[derive(Component)]
pub(crate) struct Bedded;
fn field_image() -> Image {
let side = FIELD_SIDE as usize;
let mut level: Vec<u8> = Vec::with_capacity(side * side);
for y in 0..side {
for x in 0..side {
let field = char_tile(x as f32 / side as f32, y as f32 / side as f32, 7);
level.push((field * 255.0).round() as u8);
}
}
let mut data = level.clone();
let mut width = side;
let mut mips = 1;
while width > 1 {
let next = width / 2;
let mut smaller = Vec::with_capacity(next * next);
for y in 0..next {
for x in 0..next {
let sum = [(0, 0), (1, 0), (0, 1), (1, 1)]
.into_iter()
.map(|(dx, dy)| u32::from(level[(2 * y + dy) * width + 2 * x + dx]))
.sum::<u32>();
smaller.push(((sum + 2) / 4) as u8);
}
}
data.extend_from_slice(&smaller);
level = smaller;
width = next;
mips += 1;
}
let mut image = Image::new_uninit(
Extent3d { width: FIELD_SIDE, height: FIELD_SIDE, depth_or_array_layers: 1 },
TextureDimension::D2,
TextureFormat::R8Unorm,
RenderAssetUsages::RENDER_WORLD,
);
image.data = Some(data);
image.texture_descriptor.mip_level_count = mips;
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
address_mode_u: ImageAddressMode::Repeat,
address_mode_v: ImageAddressMode::Repeat,
mag_filter: ImageFilterMode::Linear,
min_filter: ImageFilterMode::Linear,
mipmap_filter: ImageFilterMode::Linear,
..default()
});
image
}
pub(crate) fn wanted(assets: Res<BedAssets>, asking: Query<(), With<FuelBed>>) -> bool {
!assets.built && !asking.is_empty()
}
pub(crate) fn build_bed_assets(
mut assets: ResMut<BedAssets>,
look: Res<FlameLook>,
mut meshes: ResMut<Assets<Mesh>>,
mut images: ResMut<Assets<Image>>,
mut materials: ResMut<Assets<FuelBedMaterial>>,
) {
let mut ramp = [Vec4::ZERO; BED_RAMP_HEAT * BED_RAMP_FIELD];
for (slot, entry) in ramp.iter_mut().zip(bed_ramp()) {
*slot = Vec4::from(entry);
}
assets.mesh = meshes.add(Plane3d::default().mesh().size(1.0, 1.0));
assets.material = materials.add(FuelBedMaterial {
params: Vec4::new(0.0, BED_TILE, look.strength, 0.0),
ramp,
field: images.add(field_image()),
});
assets.dark = bed_visible_heat();
assets.built = true;
}
type BedParts = (
&'static ChildOf,
&'static mut BedHeat,
&'static mut MeshTag,
&'static mut Transform,
&'static mut Visibility,
);
#[allow(clippy::too_many_arguments)]
pub(crate) fn update_beds(
mut commands: Commands,
real: Res<Time<Real>>,
time: Res<Time>,
look: Res<FlameLook>,
assets: Res<BedAssets>,
mut materials: ResMut<Assets<FuelBedMaterial>>,
undressed: Query<(Entity, &FlameSite, &FuelBed), Without<Bedded>>,
fuel: Query<(&FlameSite, &FuelBed)>,
mut beds: Query<BedParts, With<Bed>>,
mut removed: RemovedComponents<FuelBed>,
children: Query<&Children>,
marks: Query<(), With<Bed>>,
owners: Sites,
) {
if !assets.built {
return;
}
for site in removed.read() {
if let Ok(kids) = children.get(site) {
for kid in kids.iter().filter(|kid| marks.contains(*kid)) {
commands.entity(kid).try_despawn();
}
}
commands.entity(site).try_remove::<Bedded>();
}
for (entity, site, wanted) in &undressed {
let cold =
owners.snuff_of(site).is_some() || owners.kindle_of(site).is_some_and(|k| k < KINDLE_SECONDS);
let heat = if cold { 0.0 } else { 1.0 };
let swing = flicker_swing(site.diameter, site.depth, time.elapsed_secs(), site.phase);
let tag = pack_bed_tag(bed_drawn_heat(heat, swing), bed_offset(wanted.seed));
let width = wanted.width().unwrap_or(0.0);
let mesh = assets.mesh.clone();
let material = assets.material.clone();
commands.entity(entity).queue_silenced(move |mut site: EntityWorldMut| {
site.insert(Bedded).with_children(|parent| {
parent.spawn((
Name::new("Fuel bed"),
Bed,
BedHeat(heat),
MeshTag::new(tag),
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::from_xyz(0.0, BED_LIFT, 0.0).with_scale(Vec3::new(width, 1.0, width)),
NotShadowCaster,
Visibility::Inherited,
));
});
});
}
let t = time.elapsed_secs();
let dt = time.delta_secs();
for (parent, mut heat, mut tag, mut at, mut visibility) in &mut beds {
let Ok((site, wanted)) = fuel.get(parent.parent()) else { continue };
let burning = owners.snuff_of(site).is_none();
let now = bed_heat_step(heat.0, burning, dt, wanted.cooling);
if now != heat.0 {
heat.0 = now;
}
let (diameter, depth, phase) = owners.fire_of(site);
let drawn = bed_drawn_heat(now, flicker_swing(diameter, depth, t, phase));
let packed = pack_bed_tag(drawn, bed_offset(wanted.seed));
if packed != tag.value {
tag.value = packed;
}
let width = wanted.width();
let scale = Vec3::new(width.unwrap_or(0.0), 1.0, width.unwrap_or(0.0));
if at.scale != scale {
at.scale = scale;
}
let wanted_visibility =
if width.is_some() && drawn >= assets.dark { Visibility::Inherited } else { Visibility::Hidden };
if *visibility != wanted_visibility {
*visibility = wanted_visibility;
}
}
if !beds.is_empty()
&& let Some(mut made) = materials.get_mut(&assets.material)
{
made.params.x = real.elapsed_secs_wrapped();
if look.is_changed() {
made.params.z = look.strength;
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::flicker::Snuff;
use crate::model::{FLICKER_DEPTH, unpack_bed_tag};
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<Image>::default())
.insert_resource(Assets::<FuelBedMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<BedAssets>()
.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
std::time::Duration::from_secs_f64(1.0 / 60.0),
));
app.add_systems(Update, (build_bed_assets.run_if(wanted), update_beds).chain());
app.update();
app
}
fn site(fire: Option<Entity>) -> FlameSite {
FlameSite {
color: None,
height: 0.7,
phase: 0.4,
kindle: 0.0,
diameter: Some(0.8),
depth: FLICKER_DEPTH,
depth_fade: false,
fire,
}
}
fn discs(app: &mut App, site: Entity) -> Vec<(Entity, f32, Transform, Visibility)> {
app.world_mut()
.query_filtered::<(Entity, &ChildOf, &MeshTag, &Transform, &Visibility), With<Bed>>()
.iter(app.world())
.filter(|(_, parent, ..)| parent.parent() == site)
.map(|(entity, _, tag, at, visibility)| (entity, unpack_bed_tag(tag.value).0, *at, *visibility))
.collect()
}
fn run(app: &mut App, seconds: f32) {
for _ in 0..(seconds * 60.0).round() as usize {
app.update();
}
}
#[test]
fn only_a_site_that_asks_gets_a_disc_and_it_lies_flat_at_the_foot_the_width_of_the_bed() {
let mut app = app();
let plain = app.world_mut().spawn((site(None), Transform::default())).id();
let bedded = app
.world_mut()
.spawn((site(None), FuelBed { diameter: 1.4, ..default() }, Transform::default()))
.id();
app.update();
app.update();
assert!(discs(&mut app, plain).is_empty(), "a site without a bed has no disc");
let found = discs(&mut app, bedded);
assert_eq!(found.len(), 1);
let at = found[0].2;
assert_eq!(at.scale, Vec3::new(1.4, 1.0, 1.4));
assert!(
at.translation.x == 0.0
&& at.translation.z == 0.0
&& at.translation.y > 0.0
&& at.translation.y < 0.05
);
assert_eq!(found[0].3, Visibility::Inherited);
}
#[test]
fn a_removed_bed_takes_its_disc_away_and_can_come_back_and_a_changed_diameter_resizes_it() {
let mut app = app();
let at = app.world_mut().spawn((site(None), FuelBed::default(), Transform::default())).id();
app.update();
app.update();
assert_eq!(discs(&mut app, at).len(), 1);
app.world_mut().entity_mut(at).insert(FuelBed { diameter: 2.0, ..default() });
app.update();
assert_eq!(discs(&mut app, at).len(), 1, "a changed bed is the same disc");
assert_eq!(discs(&mut app, at)[0].2.scale.x, 2.0);
app.world_mut().entity_mut(at).remove::<FuelBed>();
app.update();
app.update();
assert!(discs(&mut app, at).is_empty());
app.world_mut().entity_mut(at).insert(FuelBed::default());
app.update();
app.update();
assert_eq!(discs(&mut app, at).len(), 1, "a bed given back is dressed again");
}
#[test]
fn a_bed_that_is_not_a_positive_width_draws_nothing_and_never_panics() {
let mut app = app();
for diameter in [f32::NAN, 0.0, -1.0, f32::INFINITY] {
let at = app
.world_mut()
.spawn((site(None), FuelBed { diameter, ..default() }, Transform::default()))
.id();
app.update();
app.update();
let found = discs(&mut app, at);
assert_eq!(found.len(), 1);
assert_eq!(found[0].3, Visibility::Hidden, "diameter {diameter}");
}
}
#[test]
fn a_fire_that_burns_keeps_its_bed_hot_and_a_fire_put_out_cools_it_and_a_relight_warms_it_back() {
let mut app = app();
let light = app.world_mut().spawn_empty().id();
let at = app
.world_mut()
.spawn((site(Some(light)), FuelBed { cooling: 10.0, ..default() }, Transform::default()))
.id();
run(&mut app, 2.0);
let burning = discs(&mut app, at)[0].1;
assert!(burning > 0.9, "a burning fire's bed is hot: {burning}");
app.world_mut().entity_mut(light).insert(Snuff { elapsed: 1.2 });
run(&mut app, 1.0);
let a_second_later = discs(&mut app, at)[0].1;
assert!(
a_second_later > 0.8 * burning,
"a second after the douse the coals are still glowing: {a_second_later}"
);
run(&mut app, 10.0);
let cooled = discs(&mut app, at)[0].1;
assert!(cooled < 0.5 * burning && cooled > 0.2 * burning, "one time constant on: {cooled}");
app.world_mut().entity_mut(light).remove::<Snuff>();
run(&mut app, 10.0);
let relit = discs(&mut app, at)[0].1;
assert!(relit > 0.9, "a fire lit again warms its bed back: {relit}");
}
#[test]
fn coals_that_have_gone_dark_are_not_drawn_and_are_again_when_the_fire_is_relit() {
let mut app = app();
let light = app.world_mut().spawn(Snuff { elapsed: 1.2 }).id();
let at = app
.world_mut()
.spawn((site(Some(light)), FuelBed { cooling: 2.0, ..default() }, Transform::default()))
.id();
run(&mut app, 1.0);
assert_eq!(
discs(&mut app, at)[0].3,
Visibility::Hidden,
"a bed dressed under a fire already out is cold"
);
app.world_mut().entity_mut(light).remove::<Snuff>();
run(&mut app, 6.0);
assert_eq!(discs(&mut app, at)[0].3, Visibility::Inherited);
app.world_mut().entity_mut(light).insert(Snuff { elapsed: 1.2 });
run(&mut app, 30.0);
assert_eq!(
discs(&mut app, at)[0].3,
Visibility::Hidden,
"the glow is gone after the cooling has run"
);
}
#[test]
fn a_fire_still_catching_starts_its_bed_cold_and_a_fire_that_has_burned_starts_it_hot() {
let mut app = app();
let catching = app.world_mut().spawn(crate::Kindle::default()).id();
let settled = app.world_mut().spawn(crate::Kindle { elapsed: 5.0 }).id();
let new_fire =
app.world_mut().spawn((site(Some(catching)), FuelBed::default(), Transform::default())).id();
let old_fire =
app.world_mut().spawn((site(Some(settled)), FuelBed::default(), Transform::default())).id();
app.update();
app.update();
let cold = discs(&mut app, new_fire)[0].1;
let hot = discs(&mut app, old_fire)[0].1;
assert!(cold < 0.2, "{cold}");
assert!(hot > 0.9, "{hot}");
}
#[test]
fn beds_of_different_seeds_show_different_windows_and_the_flicker_moves_the_heat_a_little() {
let mut app = app();
let make = |app: &mut App, seed| {
app.world_mut().spawn((site(None), FuelBed { seed, ..default() }, Transform::default())).id()
};
let (a, b, c) = (make(&mut app, 1), make(&mut app, 2), make(&mut app, 1));
app.update();
app.update();
let offset = |app: &mut App, site: Entity| {
let (disc, ..) = discs(app, site)[0];
unpack_bed_tag(app.world().get::<MeshTag>(disc).unwrap().value).1
};
assert_ne!(offset(&mut app, a), offset(&mut app, b));
assert_eq!(offset(&mut app, a), offset(&mut app, c));
let mut seen = Vec::new();
for _ in 0..240 {
app.update();
seen.push(discs(&mut app, a)[0].1);
}
let (low, high) = seen.iter().fold((f32::MAX, f32::MIN), |(l, h), v| (l.min(*v), h.max(*v)));
assert!(high > low && high - low < 0.1, "the heat breathes by a few per cent: {low}..{high}");
}
#[test]
fn a_host_that_never_asks_for_a_bed_builds_no_assets_and_the_first_bed_builds_them_once() {
let mut app = app();
app.world_mut().spawn((site(None), Transform::default()));
run(&mut app, 0.5);
assert!(!app.world().resource::<BedAssets>().built, "no bed was asked for");
assert_eq!(app.world().resource::<Assets<Image>>().len(), 0, "and the coal field was not baked");
for seed in [1, 2] {
app.world_mut().spawn((site(None), FuelBed { seed, ..default() }, Transform::default()));
run(&mut app, 0.1);
}
assert!(app.world().resource::<BedAssets>().built);
assert_eq!(app.world().resource::<Assets<Image>>().len(), 1, "one field for every bed");
assert_eq!(app.world().resource::<Assets<FuelBedMaterial>>().len(), 1, "and one material");
}
#[test]
fn a_fuel_beds_problems_name_what_is_unusable() {
assert!(FuelBed::default().problems().is_empty());
assert_eq!(FuelBed { diameter: 0.0, ..default() }.problems().len(), 1);
assert_eq!(FuelBed { cooling: f32::NAN, ..default() }.problems().len(), 1);
}
}