use std::marker::PhantomData;
use bevy::light::PointLight;
use bevy::prelude::*;
use crate::flicker::{Flicker, FlickerPlugin, Kindle, Kindled, Snuff, Snuffed, advance_candidate_fires};
use crate::model::pool::{fade_factor, fade_out_factor, preferred, shadow_set};
use crate::model::{
DEFAULT_FIRE_DIAMETER, FLICKER_DEPTH, SNUFF_SECONDS, kindle_gain, lean_light_shift, snuff_gain,
};
use crate::{FADE_SECONDS, HYSTERESIS, LightsSystems, chain_sets};
use crate::{FlameSite, FlameWind};
pub struct LightPoolPlugin<F: Component> {
_focus: PhantomData<fn() -> F>,
}
impl<F: Component> Default for LightPoolPlugin<F> {
fn default() -> Self {
Self { _focus: PhantomData }
}
}
impl<F: Component> LightPoolPlugin<F> {
pub fn new() -> Self {
Self::default()
}
}
impl<F: Component> Plugin for LightPoolPlugin<F> {
fn build(&self, app: &mut App) {
chain_sets(app);
app.init_resource::<LightPool>()
.register_type::<LightPool>()
.register_type::<PoolCandidate>()
.register_type::<PoolLight>()
.init_resource::<PoolBudget>()
.register_type::<PoolBudget>()
.add_message::<Kindled>()
.add_message::<Snuffed>()
.add_systems(
Update,
(advance_candidate_fires, assign_pool::<F>).chain().in_set(LightsSystems::Pool),
);
crate::report::watch::<PoolCandidate>(app);
crate::report::watch_resource::<LightPool>(app);
}
fn finish(&self, app: &mut App) {
if !app.is_plugin_added::<FlickerPlugin>() {
error!("bevy_lights: LightPoolPlugin needs FlickerPlugin; pool lights will stay dark");
}
}
}
#[derive(Resource, Clone, Debug, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Resource)]
pub struct LightPool {
pub size: usize,
pub reach: f32,
pub gain: f32,
#[cfg_attr(feature = "serde", serde(with = "template_serde"))]
pub light: PointLight,
pub hysteresis: f32,
pub fade_seconds: f32,
pub rank: Rank,
pub reserve: usize,
pub max_shadowed: Option<usize>,
}
impl Default for LightPool {
fn default() -> Self {
Self {
size: 16,
reach: 60.0,
gain: 1.0,
light: PointLight { intensity: 0.0, range: 10.0, shadow_maps_enabled: false, ..default() },
rank: Rank::Illuminance,
hysteresis: HYSTERESIS,
fade_seconds: FADE_SECONDS,
reserve: 1,
max_shadowed: None,
}
}
}
#[cfg(feature = "serde")]
mod template_serde {
use bevy::light::PointLight;
use bevy::prelude::*;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
const SOFT_SHADOWS: &str = "soft_shadows_enabled";
#[derive(Serialize, Deserialize)]
#[serde(default)]
struct Template {
color: Color,
range: f32,
radius: f32,
shadow_maps_enabled: bool,
contact_shadows_enabled: bool,
soft_shadows_enabled: bool,
affects_lightmapped_mesh_diffuse: bool,
shadow_depth_bias: f32,
shadow_normal_bias: f32,
shadow_map_near_z: f32,
}
impl From<&PointLight> for Template {
fn from(light: &PointLight) -> Self {
Self {
color: light.color,
range: light.range,
radius: light.radius,
shadow_maps_enabled: light.shadow_maps_enabled,
contact_shadows_enabled: light.contact_shadows_enabled,
soft_shadows_enabled: light
.field(SOFT_SHADOWS)
.and_then(|field| field.try_downcast_ref::<bool>())
.copied()
.unwrap_or(false),
affects_lightmapped_mesh_diffuse: light.affects_lightmapped_mesh_diffuse,
shadow_depth_bias: light.shadow_depth_bias,
shadow_normal_bias: light.shadow_normal_bias,
shadow_map_near_z: light.shadow_map_near_z,
}
}
}
impl Default for Template {
fn default() -> Self {
Self::from(&super::LightPool::default().light)
}
}
impl Template {
fn into_light(self) -> PointLight {
let mut light = super::LightPool::default().light;
light.intensity = 0.0;
light.color = self.color;
light.range = self.range;
light.radius = self.radius;
light.shadow_maps_enabled = self.shadow_maps_enabled;
light.contact_shadows_enabled = self.contact_shadows_enabled;
light.affects_lightmapped_mesh_diffuse = self.affects_lightmapped_mesh_diffuse;
light.shadow_depth_bias = self.shadow_depth_bias;
light.shadow_normal_bias = self.shadow_normal_bias;
light.shadow_map_near_z = self.shadow_map_near_z;
if let Some(soft) =
light.field_mut(SOFT_SHADOWS).and_then(|field| field.try_downcast_mut::<bool>())
{
*soft = self.soft_shadows_enabled;
}
light
}
}
pub fn serialize<S: Serializer>(light: &PointLight, serializer: S) -> Result<S::Ok, S::Error> {
Template::from(light).serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<PointLight, D::Error> {
Ok(Template::deserialize(deserializer)?.into_light())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Rank {
#[default]
Illuminance,
Distance,
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[reflect(Component, Default)]
pub struct PoolCandidate {
pub offset: Vec3,
pub intensity: f32,
pub phase: Option<f32>,
pub diameter: Option<f32>,
pub depth: Option<f32>,
}
impl PoolCandidate {
pub fn at_mean(mean: f32, depth: f32) -> Self {
let depth = crate::model::flicker_depth(depth);
Self { intensity: mean / crate::model::flicker_mean(depth), depth: Some(depth), ..Self::default() }
}
pub fn problems(&self) -> Vec<crate::Problem> {
use crate::model::problem::{finite, non_negative, positive, within};
let mut out = Vec::new();
finite(&mut out, "offset.x", self.offset.x);
finite(&mut out, "offset.y", self.offset.y);
finite(&mut out, "offset.z", self.offset.z);
non_negative(&mut out, "intensity", self.intensity);
if let Some(p) = self.phase {
finite(&mut out, "phase", p);
}
if let Some(d) = self.diameter {
positive(&mut out, "diameter", d);
}
if let Some(d) = self.depth {
within(&mut out, "depth", d, 0.0, crate::MAX_FLICKER_DEPTH);
}
out
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[reflect(Component)]
pub struct PoolLight {
pub slot: usize,
}
impl LightPool {
pub fn problems(&self) -> Vec<crate::Problem> {
use crate::model::problem::{non_negative, positive, within};
let mut out = Vec::new();
positive(&mut out, "reach", self.reach);
non_negative(&mut out, "gain", self.gain);
within(&mut out, "hysteresis", self.hysteresis, 1.0, f32::MAX);
non_negative(&mut out, "fade_seconds", self.fade_seconds);
positive(&mut out, "light.range", self.light.range);
non_negative(&mut out, "light.radius", self.light.radius);
positive(&mut out, "light.shadow_map_near_z", self.light.shadow_map_near_z);
out
}
}
#[derive(Resource, Clone, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Resource, Default)]
pub struct PoolBudget {
pub holders: Vec<Option<Entity>>,
pub wanting: usize,
pub shadowed: Vec<Entity>,
}
impl PoolBudget {
pub fn granted(&self) -> usize {
self.holders.iter().flatten().count()
}
pub fn starved(&self) -> usize {
self.wanting.saturating_sub(self.granted())
}
pub fn holder_of(&self, slot: usize) -> Option<Entity> {
self.holders.get(slot).copied().flatten()
}
pub fn holds(&self, candidate: Entity) -> bool {
self.holders.contains(&Some(candidate))
}
pub fn casts_shadow(&self, candidate: Entity) -> bool {
self.shadowed.contains(&candidate)
}
}
fn shared(template: &PointLight) -> PointLight {
PointLight { intensity: 0.0, shadow_maps_enabled: false, ..*template }
}
fn same_light(a: &PointLight, b: &PointLight) -> bool {
a.reflect_partial_eq(b) == Some(true)
}
fn apply_shared(look: &PointLight, light: &mut Mut<PointLight>) {
let wanted =
PointLight { intensity: light.intensity, shadow_maps_enabled: light.shadow_maps_enabled, ..*look };
if !same_light(light, &wanted) {
**light = wanted;
}
}
type Assignment = (f32, f32, f32, Entity, Vec3, f32);
type CandidateRead<'w, 's> = Query<
'w,
's,
(
Entity,
&'static GlobalTransform,
Option<&'static Transform>,
Option<&'static ChildOf>,
Option<&'static Visibility>,
Option<&'static Snuff>,
&'static PoolCandidate,
),
Without<PoolLight>,
>;
#[derive(Clone, Copy)]
struct LastLight {
owner: Entity,
at: Vec3,
intensity: f32,
phase: f32,
diameter: Option<f32>,
depth: f32,
shadow: bool,
}
#[derive(Default)]
struct HandOffState {
held: std::collections::HashMap<usize, Entity>,
fade_at: std::collections::HashMap<usize, f32>,
applied: Option<PointLight>,
shadow_settings: Option<(bool, Option<usize>)>,
old_look: std::collections::HashMap<Entity, LastLight>,
departure_release: std::collections::HashMap<usize, LastLight>,
}
#[allow(clippy::too_many_arguments)]
fn assign_pool<F: Component>(
mut commands: Commands,
pool: Res<LightPool>,
mut schedule: ResMut<PoolBudget>,
focus: Query<&GlobalTransform, With<F>>,
candidates: CandidateRead,
fires: Query<(Option<&Kindle>, Option<&Snuff>), With<PoolCandidate>>,
sites: Query<&FlameSite>,
wind: Option<Res<FlameWind>>,
mut slots: Query<(Entity, &PoolLight, &mut Transform, &mut Visibility, &mut Flicker, &mut PointLight)>,
mut state: Local<HandOffState>,
time: Res<Time>,
) {
let delta = time.delta_secs();
let HandOffState { held, fade_at, applied, shadow_settings, old_look, departure_release } = &mut *state;
let existing: Vec<(Entity, usize)> =
slots.iter().map(|(entity, light, ..)| (entity, light.slot)).collect();
for slot in 0..pool.size + pool.reserve {
if !existing.iter().any(|(_, at)| *at == slot) {
commands.spawn((
Name::new(format!("Pool light {slot}")),
PoolLight { slot },
shared(&pool.light),
Flicker { base: 0.0, phase: 1.3 * slot as f32, ..default() },
Transform::default(),
Visibility::Hidden,
));
}
}
for (entity, slot) in &existing {
if *slot >= pool.size + pool.reserve {
commands.entity(*entity).despawn();
}
}
let look = shared(&pool.light);
if !applied.as_ref().is_some_and(|last| same_light(last, &look)) {
for (_, _, _, _, _, mut light) in &mut slots {
apply_shared(&look, &mut light);
}
*applied = Some(look);
}
let focus_at = {
let mut found = focus.iter();
match (found.next(), found.next()) {
(Some(at), None) => Some(at.translation()),
(Some(_), Some(_)) => {
let count = 2 + found.count();
warn_once!(
"bevy_lights: LightPoolPlugin found {count} focus entities; the pool needs exactly one"
);
None
}
_ => None,
}
};
let mut lit: Vec<Assignment> = match focus_at {
Some(focus_at) if pool.gain > 0.0 => candidates
.iter()
.filter(|(.., visibility, snuff, _)| {
!visibility.is_some_and(|v| *v == Visibility::Hidden)
&& !snuff.is_some_and(|snuff| snuff.elapsed >= SNUFF_SECONDS)
})
.map(|(entity, at, transform, parent, _, _, candidate)| {
let position = match (parent, transform) {
(None, Some(transform)) => transform.translation,
_ => at.translation(),
};
let distance_squared = position.distance_squared(focus_at);
let value = match pool.rank {
Rank::Illuminance => distance_squared / candidate.intensity.max(1e-6),
Rank::Distance => distance_squared.sqrt(),
};
let key = value;
(key, distance_squared, value, entity, position + candidate.offset, candidate.intensity)
})
.filter(|(_, distance_squared, ..)| *distance_squared <= pool.reach * pool.reach)
.collect(),
_ => Vec::new(),
};
lit.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.3.to_bits().cmp(&b.3.to_bits())));
let mut chosen_by_slot: std::collections::HashMap<usize, Entity> = std::collections::HashMap::new();
let free_reserves: Vec<usize> = (pool.size..pool.size + pool.reserve)
.filter(|reserve| {
let busy = departure_release.contains_key(reserve)
&& fade_at.get(reserve).is_some_and(|age| {
*age < pool.fade_seconds && fade_out_factor(*age, pool.fade_seconds) < 1.0
});
!busy
})
.collect();
let budget = if pool.reserve == 0 { pool.size } else { free_reserves.len() };
{
let mut order: Vec<(usize, Option<Entity>)> =
slots.iter().map(|(_, slot, ..)| (slot.slot, held.get(&slot.slot).copied())).collect();
order.sort_unstable_by_key(|(slot, _)| *slot);
order.retain(|(slot, _)| *slot < pool.size);
let holding_of = |slot: usize| order.iter().find(|(at, _)| *at == slot).and_then(|(_, held)| *held);
let rank_of = |entity: Entity| lit.iter().position(|(_, _, _, candidate, ..)| *candidate == entity);
let mut keepers: Vec<usize> = Vec::new();
let mut open: Vec<usize> = Vec::new();
for (slot, _) in &order {
match holding_of(*slot).map(rank_of) {
Some(Some(_)) => keepers.push(*slot),
_ => open.push(*slot),
}
}
for slot in &keepers {
if let Some(holding) = holding_of(*slot) {
chosen_by_slot.insert(*slot, holding);
}
}
let held_by_any: Vec<Entity> = order.iter().filter_map(|(_, holding)| *holding).collect();
let mut challengers: Vec<(Entity, f32)> = lit
.iter()
.filter(|(_, _, _, entity, ..)| !held_by_any.contains(entity))
.map(|(_, _, value, entity, ..)| (*entity, *value))
.collect();
let mut moved = 0usize;
while moved < budget {
let worst = keepers
.iter()
.filter_map(|slot| {
let holding = holding_of(*slot)?;
Some((*slot, rank_of(holding)?, lit.iter().find(|(_, _, _, e, ..)| *e == holding)?))
})
.max_by_key(|(_, rank, _)| *rank);
let Some((worst_slot, _, held)) = worst else { break };
let Some((challenger, challenger_value)) = challengers.first().copied() else { break };
if !preferred(held.2, challenger_value, pool.hysteresis) {
break;
}
keepers.retain(|slot| *slot != worst_slot);
chosen_by_slot.remove(&worst_slot);
open.push(worst_slot);
moved += 1;
challengers.remove(0);
chosen_by_slot.insert(worst_slot, challenger);
}
open.sort_unstable();
for slot in open {
if chosen_by_slot.contains_key(&slot) {
continue;
}
let taken = chosen_by_slot.values().copied().collect::<Vec<Entity>>();
if let Some((_, _, _, entity, ..)) =
lit.iter().find(|(_, _, _, entity, ..)| !taken.contains(entity))
{
chosen_by_slot.insert(slot, *entity);
}
}
}
let mut spare = free_reserves.iter().copied();
{
let mut departures: Vec<(usize, Entity)> = Vec::new();
for (slot, holding) in slots.iter().map(|(_, slot, ..)| (slot.slot, held.get(&slot.slot).copied())) {
if slot >= pool.size {
continue;
}
match (holding, chosen_by_slot.get(&slot).copied()) {
(Some(holding), Some(chosen)) if holding != chosen => departures.push((slot, holding)),
(Some(holding), None) => departures.push((slot, holding)),
_ => {}
}
}
departures.sort_unstable();
for (_, holding) in &departures {
let Some(last) = old_look.get(holding).copied() else { continue };
let Some(reserve) = spare.next() else { break };
departure_release.insert(reserve, last);
fade_at.insert(reserve, 0.0);
}
}
let shadow_budget =
if pool.light.shadow_maps_enabled { pool.max_shadowed.unwrap_or(usize::MAX) } else { 0 };
let settings = (pool.light.shadow_maps_enabled, pool.max_shadowed);
let snap = *shadow_settings != Some(settings);
*shadow_settings = Some(settings);
let mut shadow_of: std::collections::HashMap<usize, bool> = std::collections::HashMap::new();
let mut restart: Vec<usize> = Vec::new();
{
let casting: std::collections::HashMap<usize, bool> =
slots.iter().map(|(_, slot, .., light)| (slot.slot, light.shadow_maps_enabled)).collect();
let mut holders: Vec<(usize, Entity, usize, f32, bool, bool)> = chosen_by_slot
.iter()
.filter(|(slot, _)| **slot < pool.size)
.filter_map(|(slot, entity)| {
let rank = lit.iter().position(|(_, _, _, candidate, ..)| candidate == entity)?;
let kept = held.get(slot) == Some(entity);
let now = kept && casting.get(slot).copied().unwrap_or(false);
Some((*slot, *entity, rank, lit[rank].2, kept, now))
})
.collect();
holders.sort_unstable_by_key(|(_, _, rank, ..)| *rank);
let want = shadow_set(
&holders.iter().map(|(.., value, _, now)| (*value, *now)).collect::<Vec<_>>(),
shadow_budget,
pool.hysteresis,
);
let mut change = |slot: usize, entity: Entity| -> Option<bool> {
if snap {
return Some(false);
}
if fade_at.get(&slot).is_some_and(|age| *age < pool.fade_seconds) {
return None;
}
if pool.reserve == 0 {
return Some(true);
}
let last = old_look.get(&entity).copied()?;
let reserve = spare.next()?;
departure_release.insert(reserve, last);
fade_at.insert(reserve, 0.0);
Some(true)
};
let mut count = 0usize;
for ((slot, entity, .., now), want) in holders.iter().zip(&want) {
if !*now {
continue;
}
let shadow = *want
|| match change(*slot, *entity) {
Some(fades) => {
if fades {
restart.push(*slot);
}
false
}
None => true,
};
if shadow {
count += 1;
}
shadow_of.insert(*slot, shadow);
}
for ((slot, entity, _, _, kept, now), want) in holders.iter().zip(&want) {
if *now {
continue;
}
let gain = *want
&& count < shadow_budget
&& (!*kept
|| match change(*slot, *entity) {
Some(fades) => {
if fades {
restart.push(*slot);
}
true
}
None => false,
});
if gain {
count += 1;
}
shadow_of.insert(*slot, gain);
}
}
let winners: std::collections::HashMap<_, _> = chosen_by_slot
.values()
.filter_map(|entity| {
candidates
.get(*entity)
.ok()
.map(|(.., candidate)| (*entity, (candidate.phase, candidate.diameter, candidate.depth)))
})
.collect();
let lean_of = |entity: Entity, diameter: Option<f32>| -> Vec3 {
match &wind {
Some(wind) if wind.velocity != Vec2::ZERO => {
match sites.get(entity).ok().or_else(|| sites.iter().find(|s| s.fire == Some(entity))) {
Some(site) => {
let speed = wind.velocity.length();
let diameter = diameter.or(site.diameter).unwrap_or(DEFAULT_FIRE_DIAMETER);
let toward = wind.velocity / speed;
Vec3::from(lean_light_shift(site.height, speed, diameter, [toward.x, toward.y]))
}
None => Vec3::ZERO,
}
}
_ => Vec3::ZERO,
}
};
for (_, slot, mut transform, mut visibility, mut flicker, mut light) in &mut slots {
if slot.slot >= pool.size {
let age = fade_at.entry(slot.slot).or_insert(f32::MAX);
let out = fade_out_factor(*age, pool.fade_seconds);
*age += delta;
let live = departure_release.get(&slot.slot).copied();
if out > 0.0
&& let Some(mut last) = live
{
if let Some(at) = lit
.iter()
.find(|(_, _, _, candidate, ..)| *candidate == last.owner)
.map(|(.., at, _)| *at)
{
last.at = at + lean_of(last.owner, last.diameter);
departure_release.insert(slot.slot, last);
}
transform.translation = last.at;
flicker.phase = last.phase;
flicker.diameter = last.diameter;
flicker.depth = last.depth;
flicker.base = last.intensity * pool.gain * out;
let shadow = last.shadow && shadow_budget > 0;
if light.shadow_maps_enabled != shadow {
light.shadow_maps_enabled = shadow;
}
visibility.set_if_neq(Visibility::Inherited);
} else {
flicker.base = 0.0;
if light.shadow_maps_enabled {
light.shadow_maps_enabled = false;
}
visibility.set_if_neq(Visibility::Hidden);
departure_release.remove(&slot.slot);
}
continue;
}
let holding = held.get(&slot.slot).copied();
let chosen = chosen_by_slot.get(&slot.slot).copied();
let changed = holding.is_some() && chosen != holding;
let age = fade_at.entry(slot.slot).or_insert(f32::MAX);
if changed || restart.contains(&slot.slot) {
*age = 0.0;
} else if holding.is_some() && chosen.is_some() {
*age += delta;
}
let fade =
if holding.is_none() && chosen.is_some() { 1.0 } else { fade_factor(*age, pool.fade_seconds) };
let assigned = chosen.and_then(|entity| {
lit.iter().find(|(_, _, _, candidate, ..)| *candidate == entity).map(|(.., at, i)| {
let state = fires.get(entity).map_or(1.0, |(kindle, snuff)| {
kindle.map_or(1.0, |k| kindle_gain(k.elapsed))
* snuff.map_or(1.0, |s| snuff_gain(s.elapsed))
});
(*at, *i * state, entity)
})
});
let shadow = assigned.is_some() && shadow_of.get(&slot.slot).copied().unwrap_or(false);
if light.shadow_maps_enabled != shadow {
light.shadow_maps_enabled = shadow;
}
match assigned {
Some((at, intensity, entity)) => {
let lean = lean_of(entity, winners.get(&entity).and_then(|(_, diameter, _)| *diameter));
transform.translation = at + lean;
if let Some((phase, diameter, depth)) = winners.get(&entity).copied() {
if let Some(phase) = phase {
flicker.phase = phase;
}
flicker.diameter = diameter;
flicker.depth = depth.unwrap_or(FLICKER_DEPTH);
}
flicker.base = intensity * pool.gain * fade;
visibility.set_if_neq(Visibility::Inherited);
}
None => {
flicker.base = 0.0;
visibility.set_if_neq(Visibility::Hidden);
*age = f32::MAX;
}
}
match chosen {
Some(entity) => {
held.insert(slot.slot, entity);
}
None => {
held.remove(&slot.slot);
}
}
if let Some((_, intensity, entity)) = assigned {
old_look.insert(
entity,
LastLight {
owner: entity,
at: transform.translation,
intensity,
phase: flicker.phase,
diameter: flicker.diameter,
depth: flicker.depth,
shadow,
},
);
}
}
let casts = |slot: usize| {
shadow_of.get(&slot).copied().unwrap_or(false).then(|| held.get(&slot).copied()).flatten()
};
let unchanged = schedule.wanting == lit.len()
&& schedule.holders.len() == pool.size
&& schedule.holders.iter().enumerate().all(|(slot, holder)| *holder == held.get(&slot).copied())
&& schedule.shadowed.iter().copied().eq((0..pool.size).filter_map(casts));
if !unchanged {
schedule.holders.clear();
schedule.holders.extend((0..pool.size).map(|slot| held.get(&slot).copied()));
schedule.wanting = lit.len();
schedule.shadowed.clear();
schedule.shadowed.extend((0..pool.size).filter_map(casts));
}
old_look.retain(|entity, _| {
lit.iter().any(|(_, _, _, candidate, ..)| candidate == entity)
|| held.values().any(|held| held == entity)
});
}