use bevy::asset::embedded_asset;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::light::NotShadowCaster;
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::flame::{Flame, FlameAssets, FlameLook, FlameSite};
use crate::model::{pack_flame_tag, set_flame_brightness};
const EMBER_SHADER_PATH: &str = "embedded://bevy_lights/embers.wesl";
pub const MAX_EMBER_RATE: f32 = 8.0;
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Component)]
pub struct FlameEmbers {
pub rate: f32,
}
impl Default for FlameEmbers {
fn default() -> Self {
Self { rate: 3.0 }
}
}
impl FlameEmbers {
pub fn problems(&self) -> Vec<crate::Problem> {
let mut out = Vec::new();
crate::model::problem::within(&mut out, "rate", self.rate, 0.0, MAX_EMBER_RATE);
out
}
fn share(&self) -> f32 {
if self.rate.is_finite() { (self.rate / MAX_EMBER_RATE).clamp(0.0, 1.0) } else { 0.0 }
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct EmberMaterial {
#[uniform(0)]
base: LinearRgba,
#[uniform(0)]
params: Vec4,
}
impl Material for EmberMaterial {
fn vertex_shader() -> ShaderRef {
EMBER_SHADER_PATH.into()
}
fn fragment_shader() -> ShaderRef {
EMBER_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, "embers.wesl");
app.add_plugins(bevy::pbr::MaterialPlugin::<EmberMaterial>::default())
.init_resource::<EmberAssets>()
.register_type::<FlameEmbers>()
.add_systems(Startup, build_ember_assets);
}
#[derive(Resource, Default)]
pub(crate) struct EmberAssets {
mesh: Handle<Mesh>,
material: Handle<EmberMaterial>,
faded_material: Handle<EmberMaterial>,
built: bool,
}
impl EmberAssets {
fn material_for(&self, depth_fade: bool) -> &Handle<EmberMaterial> {
if depth_fade { &self.faded_material } else { &self.material }
}
}
#[derive(Component)]
pub(crate) struct Ember;
#[derive(Component)]
pub(crate) struct Embered;
fn material_for(look: FlameLook, depth_fade: bool) -> EmberMaterial {
EmberMaterial {
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),
}
}
fn build_ember_assets(
mut assets: ResMut<EmberAssets>,
look: Res<FlameLook>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<EmberMaterial>>,
) {
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.built = true;
}
pub(crate) fn dress_embers(
mut commands: Commands,
flames: Query<(Entity, &ChildOf), UndressedFlame>,
sites: Query<(&FlameSite, &FlameEmbers)>,
ember_assets: Res<EmberAssets>,
flame_assets: Res<FlameAssets>,
) {
if !ember_assets.built || !flame_assets.built() {
return;
}
for (flame, parent) in &flames {
let Ok((site, embers)) = sites.get(parent.parent()) else { continue };
let tag =
MeshTag::new(pack_flame_tag(site.phase, site.height, embers.share(), flame_assets.max_height()));
let mesh = ember_assets.mesh.clone();
let material = ember_assets.material_for(site.depth_fade).clone();
commands.entity(flame).queue_silenced(move |mut flame: EntityWorldMut| {
flame.insert(Embered).with_children(|parent| {
parent.spawn((
Name::new("Embers"),
Ember,
tag,
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::IDENTITY,
NotShadowCaster,
NoFrustumCulling,
));
});
});
}
}
type UndressedFlame = (With<Flame>, Without<Embered>);
type EmberQuad = (Entity, &'static ChildOf, &'static mut MeshTag, &'static mut MeshMaterial3d<EmberMaterial>);
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct EmberOwners<'w, 's> {
flames: Query<'w, 's, &'static ChildOf, With<Flame>>,
sites: Query<'w, 's, (&'static FlameSite, Option<&'static FlameEmbers>)>,
}
impl EmberOwners<'_, '_> {
fn site_of(&self, flame: Entity) -> Option<(&FlameSite, Option<&FlameEmbers>)> {
let site = self.flames.get(flame).ok()?.parent();
self.sites.get(site).ok()
}
}
pub(crate) fn update_embers(
mut commands: Commands,
real: Res<Time<Real>>,
look: Res<FlameLook>,
ember_assets: Res<EmberAssets>,
mut materials: ResMut<Assets<EmberMaterial>>,
mut embers: Query<EmberQuad, With<Ember>>,
owners: EmberOwners,
) {
if embers.is_empty() {
return;
}
for (entity, parent, mut tag, mut material) in &mut embers {
let site = owners.site_of(parent.parent());
let Some((site, Some(wanted))) = site else {
commands.entity(entity).despawn();
commands.entity(parent.parent()).remove::<Embered>();
continue;
};
let handle = ember_assets.material_for(site.depth_fade);
if material.0 != *handle {
material.0 = handle.clone();
}
let packed = set_flame_brightness(tag.value, wanted.share());
if packed != tag.value {
tag.value = packed;
}
}
let elapsed = real.elapsed_secs_wrapped();
for handle in [&ember_assets.material, &ember_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;
}
}
}
#[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, unpack_flame_tag};
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::<EmberMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.init_resource::<EmberAssets>();
app.add_systems(Startup, (build_flame_assets, build_ember_assets))
.add_systems(Update, (dress_flames, update_flames, dress_embers, update_embers).chain());
app.update();
app
}
fn site(height: f32, phase: f32) -> FlameSite {
FlameSite {
height,
phase,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
}
}
fn ember_quads(app: &mut App) -> Vec<(Entity, u32)> {
app.world_mut()
.query_filtered::<(&ChildOf, &MeshTag), With<Ember>>()
.iter(app.world())
.map(|(parent, tag)| (parent.parent(), tag.value))
.collect()
}
#[test]
fn only_a_site_that_asks_gets_an_ember_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 lit = app.world_mut().spawn((site(0.5, 1.0), FlameEmbers::default(), Transform::default())).id();
app.update();
app.update();
let quads = ember_quads(&mut app);
assert_eq!(quads.len(), 1, "one site asked, so one quad, and a second frame does not twin it");
let flame = quads[0].0;
let owner = app.world().get::<ChildOf>(flame).expect("the flame hangs on its site").parent();
assert_eq!(owner, lit, "the quad is the lit site's flame's child");
assert_ne!(owner, plain);
}
#[test]
fn the_quads_tag_carries_the_sites_height_phase_and_the_rate() {
let mut app = app();
app.world_mut().spawn((site(0.8, 2.0), FlameEmbers { rate: 4.0 }, Transform::default()));
app.update();
app.update();
let (_, tag) = ember_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 - 4.0 / MAX_EMBER_RATE).abs() < 0.005, "the rate's share {share}");
}
#[test]
fn a_changed_rate_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), FlameEmbers { rate: 2.0 }, Transform::default())).id();
app.update();
app.update();
let before = ember_quads(&mut app)[0].1;
app.world_mut().get_mut::<FlameEmbers>(id).unwrap().rate = 6.0;
app.update();
let after = ember_quads(&mut app)[0].1;
assert_ne!(before, after, "the rate is in the tag");
assert!((unpack_flame_tag(after, 4.0).2 - 6.0 / MAX_EMBER_RATE).abs() < 0.005);
app.world_mut().entity_mut(id).remove::<FlameEmbers>();
app.update();
app.update();
assert!(ember_quads(&mut app).is_empty(), "no component, no quad");
app.world_mut().entity_mut(id).insert(FlameEmbers::default());
app.update();
app.update();
assert_eq!(ember_quads(&mut app).len(), 1, "added again, dressed again");
}
#[test]
fn a_rate_outside_its_range_is_reported_and_clamped_into_the_tag() {
assert!(FlameEmbers { rate: 3.0 }.problems().is_empty());
assert_eq!(FlameEmbers { rate: -1.0 }.problems().len(), 1);
assert_eq!(FlameEmbers { rate: MAX_EMBER_RATE + 1.0 }.problems().len(), 1);
assert_eq!(FlameEmbers { rate: f32::NAN }.problems().len(), 1);
assert_eq!(FlameEmbers { rate: 99.0 }.share(), 1.0);
assert_eq!(FlameEmbers { rate: f32::NAN }.share(), 0.0);
}
}