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};
use crate::model::{FLICKER_DEPTH, SNUFF_SECONDS, kindle_gain, snuff_gain};
use crate::{FADE_SECONDS, HYSTERESIS, LightsSystems, chain_sets};
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),
);
}
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,
}
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,
}
}
}
#[cfg(feature = "serde")]
mod template_serde {
use bevy::light::PointLight;
use bevy::prelude::Color;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(default)]
struct Template {
color: Color,
range: f32,
radius: f32,
shadow_maps_enabled: bool,
}
impl Default for Template {
fn default() -> Self {
let light = super::LightPool::default().light;
Self {
color: light.color,
range: light.range,
radius: light.radius,
shadow_maps_enabled: light.shadow_maps_enabled,
}
}
}
pub fn serialize<S: Serializer>(light: &PointLight, serializer: S) -> Result<S::Ok, S::Error> {
Template {
color: light.color,
range: light.range,
radius: light.radius,
shadow_maps_enabled: light.shadow_maps_enabled,
}
.serialize(serializer)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<PointLight, D::Error> {
let t = Template::deserialize(deserializer)?;
Ok(PointLight {
color: t.color,
intensity: 0.0,
range: t.range,
radius: t.radius,
shadow_maps_enabled: t.shadow_maps_enabled,
..Default::default()
})
}
}
#[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);
out
}
}
#[derive(Resource, Clone, Debug, Default, PartialEq, Eq, Reflect)]
#[reflect(Resource, Default)]
pub struct PoolBudget {
pub holders: Vec<Option<Entity>>,
pub wanting: usize,
}
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))
}
}
#[derive(Clone, Copy, PartialEq)]
struct SlotLook {
color: Color,
range: f32,
radius: f32,
shadow_maps_enabled: bool,
}
impl SlotLook {
fn of(light: &PointLight) -> Self {
Self {
color: light.color,
range: light.range,
radius: light.radius,
shadow_maps_enabled: light.shadow_maps_enabled,
}
}
fn apply(self, light: &mut PointLight) {
if light.color != self.color
|| light.range != self.range
|| light.radius != self.radius
|| light.shadow_maps_enabled != self.shadow_maps_enabled
{
light.color = self.color;
light.range = self.range;
light.radius = self.radius;
light.shadow_maps_enabled = self.shadow_maps_enabled;
}
}
}
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>,
&'static PoolCandidate,
),
Without<PoolLight>,
>;
#[derive(Default)]
struct HandOffState {
held: std::collections::HashMap<usize, Entity>,
fade_at: std::collections::HashMap<usize, f32>,
applied: Option<SlotLook>,
old_look: std::collections::HashMap<Entity, (Vec3, f32, f32)>,
departure_release: std::collections::HashMap<usize, (Vec3, f32, f32)>,
}
#[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>>,
snuffs: Query<(Entity, &Snuff), With<PoolCandidate>>,
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, 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 },
PointLight { intensity: 0.0, ..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 = SlotLook::of(&pool.light);
if *applied != Some(look) {
for (_, _, _, _, _, mut light) in &mut slots {
look.apply(&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 out: Vec<Entity> =
snuffs.iter().filter(|(_, snuff)| snuff.elapsed >= SNUFF_SECONDS).map(|(entity, _)| entity).collect();
let mut lit: Vec<Assignment> = match focus_at {
Some(focus_at) if pool.gain > 0.0 => candidates
.iter()
.filter(|(entity, .., visibility, _)| {
!visibility.is_some_and(|v| *v == Visibility::Hidden) && !out.contains(entity)
})
.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 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 (index, (_, holding)) in departures.iter().enumerate() {
let Some((at, intensity, phase)) = old_look.get(holding).copied() else { continue };
let Some(reserve) = free_reserves.get(index).copied() else { break };
departure_release.insert(reserve, (at, intensity, phase));
fade_at.insert(reserve, 0.0);
}
}
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();
for (_, slot, mut transform, mut visibility, mut flicker, _) in &mut slots {
if slot.slot >= pool.size {
let age = fade_at.entry(slot.slot).or_insert(f32::MAX);
*age += delta;
let out = fade_out_factor(*age, pool.fade_seconds);
let live = departure_release.get(&slot.slot).copied();
if out < 1.0
&& let Some((at, intensity, phase)) = live
{
transform.translation = at;
flicker.phase = phase;
flicker.base = intensity * pool.gain * out;
visibility.set_if_neq(Visibility::Inherited);
} else {
flicker.base = 0.0;
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 {
*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)
})
});
match assigned {
Some((at, intensity, entity)) => {
transform.translation = at;
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((at, intensity, ..)) = assigned {
old_look.insert(chosen.unwrap_or(Entity::PLACEHOLDER), (at, intensity, flicker.phase));
}
}
schedule.holders.clear();
schedule.holders.extend((0..pool.size).map(|slot| held.get(&slot).copied()));
schedule.wanting = lit.len();
old_look.retain(|entity, _| {
lit.iter().any(|(_, _, _, candidate, ..)| candidate == entity)
|| held.values().any(|held| held == entity)
});
}