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, 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::model::pack_flame_tag;
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");
app.add_plugins(MaterialPlugin::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.register_type::<FlameLook>()
.register_type::<FlameSite>()
.add_systems(Update, (dress_flames, update_flames).chain().in_set(LightsSystems::Flame))
.add_systems(Startup, build_flame_assets);
}
}
#[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,
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Flame;
#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[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 }
}
}
#[derive(Resource, Default)]
struct FlameAssets {
mesh: Handle<Mesh>,
material: Handle<FlameMaterial>,
max_height: f32,
built: bool,
}
#[derive(Component)]
struct Flamed;
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));
assets.max_height = look.max_height;
assets.built = true;
}
fn material_for(look: FlameLook) -> 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, 0.0, look.strength),
}
}
fn dress_flames(
mut commands: Commands,
sites: Query<(Entity, &FlameSite), Without<Flamed>>,
assets: Res<FlameAssets>,
) {
if !assets.built {
return;
}
let max_height = assets.max_height;
for (site, flame) in &sites {
let tag = MeshTag::new(pack_flame_tag(flame.phase, flame.height, max_height));
let mesh = assets.mesh.clone();
let material = assets.material.clone();
commands.entity(site).queue_silenced(move |mut site: EntityWorldMut| {
site.insert(Flamed).with_children(|parent| {
parent.spawn((
Name::new("Flame"),
Flame,
tag,
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::IDENTITY,
NotShadowCaster,
NoFrustumCulling,
));
});
});
}
}
fn update_flames(
time: Res<Time<Real>>,
look: Res<FlameLook>,
assets: Res<FlameAssets>,
mut materials: ResMut<Assets<FlameMaterial>>,
) {
if !assets.built {
return;
}
let elapsed = time.elapsed_secs_wrapped();
if let Some(mut made) = materials.get_mut(&assets.material) {
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::model::unpack_flame_tag;
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>();
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 }, 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 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 }, 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) = unpack_flame_tag(children[0].1, max_height);
assert!((phase - 2.5).abs() < 1e-3, "phase {phase}");
assert!((height - 0.35).abs() < 1e-3, "height {height}");
}
#[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);
}
}