use bevy_color::Color;
use bevy_ecs::prelude::{Query, ResMut, Resource};
use glam::Vec3;
use crate::sceneobjects::{Category, SceneObject};
use crate::ui::icons::path;
#[derive(crate::ecs::Component, Clone, Copy, Debug, PartialEq)]
pub struct Light {
pub direction: Vec3,
pub color: Color,
pub kelvin: Option<f32>,
pub strength: f32,
pub angle: f32,
pub shadow: bool,
}
impl Default for Light {
fn default() -> Self {
Self {
direction: Vec3::Y,
color: Color::WHITE,
kelvin: None,
strength: 2.2,
angle: 0.25,
shadow: true,
}
}
}
impl Light {
pub fn shadow(mut self, shadow: bool) -> Self {
self.shadow = shadow;
self
}
pub fn from(mut self, direction: Vec3) -> Self {
self.direction = direction;
self
}
pub fn tint(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn temperature(mut self, kelvin: f32) -> Self {
self.kelvin = Some(kelvin);
self
}
pub fn no_temperature(mut self) -> Self {
self.kelvin = None;
self
}
pub fn strength(mut self, strength: f32) -> Self {
self.strength = strength;
self
}
pub fn angle(mut self, degrees: f32) -> Self {
self.angle = degrees;
self
}
pub fn color(&self) -> Vec3 {
let tint = self.color.to_linear();
let tint = Vec3::new(tint.red, tint.green, tint.blue);
match self.kelvin {
Some(temperature) => kelvin(temperature) * tint,
None => tint,
}
}
}
#[derive(Resource, Clone, Copy, Debug, PartialEq)]
pub struct Ambient {
pub kelvin: f32,
pub strength: f32,
pub occlusion: f32,
}
impl Default for Ambient {
fn default() -> Self {
Self {
kelvin: 7000.0,
strength: 0.22,
occlusion: 1.0,
}
}
}
impl Ambient {
pub fn color(&self) -> Vec3 {
kelvin(self.kelvin) * self.strength
}
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)]
pub struct Suns {
pub shadowed: Option<Light>,
pub unshadowed: Option<Light>,
}
pub fn default_lights() -> [Light; 2] {
[
Light::default()
.from(Vec3::new(-0.55, 1.0, -0.45))
.temperature(4200.0)
.strength(2.2)
.angle(1.5),
Light::default()
.from(Vec3::new(1.0, 0.35, 0.1))
.temperature(9500.0)
.strength(1.5)
.angle(0.0)
.shadow(false),
]
}
pub fn kelvin(kelvin: f32) -> Vec3 {
let t = kelvin.clamp(1000.0, 40000.0) / 100.0;
let red = match t <= 66.0 {
true => 255.0,
false => 329.698_73 * (t - 60.0).powf(-0.133_204_76),
};
let green = match t <= 66.0 {
true => 99.470_8 * t.ln() - 161.119_57,
false => 288.122_16 * (t - 60.0).powf(-0.075_514_85),
};
let blue = if t >= 66.0 {
255.0
} else if t <= 19.0 {
0.0
} else {
138.517_73 * (t - 10.0).ln() - 305.044_8
};
let srgb = Vec3::new(red, green, blue).clamp(Vec3::ZERO, Vec3::splat(255.0)) / 255.0;
let srgb = srgb / srgb.max_element().max(1e-4);
Vec3::new(to_linear(srgb.x), to_linear(srgb.y), to_linear(srgb.z))
}
fn to_linear(value: f32) -> f32 {
match value <= 0.04045 {
true => value / 12.92,
false => ((value + 0.055) / 1.055).powf(2.4),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_warm_light_is_red_and_a_cool_one_is_blue() {
let candle = kelvin(1900.0);
assert!(candle.x > candle.y && candle.y > candle.z, "{candle:?}");
let sky = kelvin(12000.0);
assert!(sky.z > sky.y && sky.y > sky.x, "{sky:?}");
}
#[test]
fn daylight_is_very_nearly_white() {
let daylight = kelvin(6500.0);
let spread = daylight.max_element() - daylight.min_element();
assert!(spread < 0.12, "daylight should be neutral: {daylight:?}");
}
#[test]
fn temperature_changes_the_colour_and_not_the_brightness() {
for temperature in [1500.0, 3000.0, 5500.0, 9000.0, 20000.0] {
let color = kelvin(temperature);
assert!(
(color.max_element() - 1.0).abs() < 1e-4,
"{temperature}K came back at {color:?}",
);
}
}
#[test]
fn the_range_is_bounded_rather_than_going_strange_at_the_ends() {
for temperature in [-100.0, 0.0, 1.0, 1e9] {
let color = kelvin(temperature);
assert!(color.is_finite(), "{temperature}K gave {color:?}");
assert!(color.min_element() >= 0.0 && color.max_element() <= 1.0);
}
}
#[test]
fn the_default_lighting_is_warm_from_the_left_and_cool_from_the_right() {
let [key, rim] = default_lights();
assert!(key.shadow, "the warm one is the one that casts");
assert!(key.direction.x < 0.0, "the key comes from the left");
assert!(key.direction.y > 0.0, "and from above");
assert!(rim.direction.x > 0.0, "the rim comes from the right");
assert!(key.kelvin < rim.kelvin, "warm key, cool rim");
assert!(
rim.strength < key.strength,
"the rim is a hint, not a second sun",
);
assert!(!rim.shadow, "the rim casts nothing");
}
#[test]
fn a_light_can_be_built_up_from_the_default_one() {
let light = Light::default()
.temperature(3000.0)
.strength(0.7)
.angle(4.0);
assert_eq!(light.kelvin, Some(3000.0));
assert_eq!(light.strength, 0.7);
assert_eq!(light.angle, 4.0);
assert_eq!(light.direction, Light::default().direction, "unchanged");
}
}
#[derive(crate::ecs::Component, Clone, Copy, Debug, PartialEq)]
pub struct LocalLight {
pub position: Vec3,
pub kelvin: f32,
pub intensity: f32,
pub range: f32,
pub shape: LightShape,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LightShape {
Point,
Spot {
direction: Vec3,
inner: f32,
outer: f32,
},
}
impl LocalLight {
pub fn point(position: Vec3, range: f32) -> Self {
Self {
position,
kelvin: 4000.0,
intensity: 1.0,
range,
shape: LightShape::Point,
}
}
pub fn spot(position: Vec3, towards: Vec3, range: f32, cone: f32) -> Self {
Self {
position,
kelvin: 4000.0,
intensity: 1.0,
range,
shape: LightShape::Spot {
direction: (towards - position).normalize_or(Vec3::NEG_Y),
inner: cone * 0.8,
outer: cone,
},
}
}
pub fn temperature(mut self, kelvin: f32) -> Self {
self.kelvin = kelvin;
self
}
pub fn intensity(mut self, intensity: f32) -> Self {
self.intensity = intensity;
self
}
pub fn color(&self) -> Vec3 {
kelvin(self.kelvin) * self.intensity
}
}
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuLight {
pub position_range: [f32; 4],
pub color: [f32; 4],
pub direction_outer: [f32; 4],
pub cone: [f32; 4],
}
pub const NOT_A_CONE: f32 = -2.0;
impl From<&LocalLight> for GpuLight {
fn from(light: &LocalLight) -> Self {
let color = light.color();
let (direction, outer, inner) = match light.shape {
LightShape::Point => (Vec3::ZERO, NOT_A_CONE, 0.0),
LightShape::Spot {
direction,
inner,
outer,
} => (
direction.normalize_or(Vec3::NEG_Y),
outer.cos(),
inner.cos(),
),
};
Self {
position_range: [
light.position.x,
light.position.y,
light.position.z,
light.range.max(0.0),
],
color: [color.x, color.y, color.z, 0.0],
direction_outer: [direction.x, direction.y, direction.z, outer],
cone: [inner, 0.0, 0.0, 0.0],
}
}
}
pub const MAX_LIGHTS: usize = 256;
#[derive(crate::ecs::Resource, Default)]
pub struct LightDrawList(pub Vec<GpuLight>);
pub fn collect_lights_system(
mut list: bevy_ecs::system::ResMut<LightDrawList>,
lights: bevy_ecs::system::Query<&LocalLight>,
) {
list.0.clear();
for light in &lights {
if list.0.len() == MAX_LIGHTS {
log::warn!("more than {MAX_LIGHTS} local lights; the rest are dark this frame");
break;
}
if light.range > 0.0 && light.intensity > 0.0 {
list.0.push(GpuLight::from(light));
}
}
}
#[cfg(test)]
mod local_light_tests {
use super::*;
#[test]
fn a_point_light_is_not_a_cone() {
let gpu = GpuLight::from(&LocalLight::point(Vec3::Y, 5.0));
assert_eq!(gpu.direction_outer[3], NOT_A_CONE);
assert_eq!(gpu.position_range, [0.0, 1.0, 0.0, 5.0]);
}
#[test]
fn a_spot_points_where_it_was_aimed() {
let light = LocalLight::spot(Vec3::new(0.0, 3.0, 0.0), Vec3::ZERO, 6.0, 0.5);
let gpu = GpuLight::from(&light);
assert_eq!(
[gpu.direction_outer[0], gpu.direction_outer[1]],
[0.0, -1.0]
);
assert!(gpu.direction_outer[3] < gpu.cone[0]);
assert!(gpu.direction_outer[3] > NOT_A_CONE);
}
#[test]
fn brightness_is_carried_in_the_colour() {
let dim = GpuLight::from(&LocalLight::point(Vec3::ZERO, 1.0).intensity(0.5));
let bright = GpuLight::from(&LocalLight::point(Vec3::ZERO, 1.0).intensity(2.0));
assert!(bright.color[0] > dim.color[0]);
}
}
impl LocalLight {
pub fn object(&self, name: impl Into<String>) -> SceneObject {
let icon = match self.shape {
LightShape::Point => path::LIGHTBULB,
LightShape::Spot { .. } => path::LAMP,
};
SceneObject::new(name, icon, Category::Light)
}
}
impl Light {
pub fn object(name: impl Into<String>) -> SceneObject {
SceneObject::new(name, path::SUN, Category::Light)
}
}
pub fn collect_suns_system(mut suns: ResMut<Suns>, lights: Query<&Light>) {
let mut next = Suns::default();
for light in &lights {
let slot = match light.shadow {
true => &mut next.shadowed,
false => &mut next.unshadowed,
};
match slot {
Some(_) => log::warn!(
"more than one directional light that {} a shadow; the extra one is dark",
match light.shadow {
true => "casts",
false => "does not cast",
},
),
None => *slot = Some(*light),
}
}
*suns = next;
}
#[cfg(test)]
mod lighting_tests {
use super::*;
use crate::ecs::Application;
fn unlit() -> Application {
let mut app = Application::new();
app.insert_resource(Suns::default());
app.add_update_systems(collect_suns_system);
app
}
fn app() -> Application {
let mut app = unlit();
let [key, rim] = default_lights();
app.world.spawn((key, Light::object("Key Light")));
app.world.spawn((rim, Light::object("Rim Light")));
app
}
fn casting(app: &mut Application) -> Vec<bool> {
let mut casting: Vec<bool> = app
.world
.query::<&Light>()
.iter(&app.world)
.map(|light| light.shadow)
.collect();
casting.sort_by(|a, b| b.cmp(a));
casting
}
#[test]
fn a_scene_with_no_lights_is_lit_by_nothing() {
let mut app = unlit();
app.update();
assert!(casting(&mut app).is_empty(), "nothing was spawned for it");
assert_eq!(*app.world.resource::<Suns>(), Suns::default());
}
#[test]
fn the_suns_go_when_the_lights_do() {
let mut app = app();
app.update();
assert!(app.world.resource::<Suns>().shadowed.is_some());
let lights: Vec<bevy_ecs::prelude::Entity> = app
.world
.query_filtered::<bevy_ecs::prelude::Entity, bevy_ecs::prelude::With<Light>>()
.iter(&app.world)
.collect();
for light in lights {
app.world.despawn(light);
}
app.update();
assert_eq!(*app.world.resource::<Suns>(), Suns::default());
}
#[test]
fn each_one_is_something_the_scene_can_list() {
let mut app = app();
app.update();
let mut names: Vec<String> = app
.world
.query::<&SceneObject>()
.iter(&app.world)
.map(|item| item.name.clone())
.collect();
names.sort();
assert_eq!(names, ["Key Light", "Rim Light"]);
let icons: Vec<&str> = app
.world
.query::<&SceneObject>()
.iter(&app.world)
.map(|object| object.icon)
.collect();
assert!(
icons.iter().all(|icon| *icon == path::SUN),
"a light with an angular size is a sun, infinitely far away: {icons:?}",
);
}
#[test]
fn the_resource_the_renderer_reads_comes_from_the_entities() {
let mut app = app();
app.update();
let key = app
.world
.query::<(bevy_ecs::prelude::Entity, &Light)>()
.iter(&app.world)
.find(|(_, light)| light.shadow)
.map(|(entity, _)| entity)
.expect("a light that casts");
app.world.get_mut::<Light>(key).unwrap().kelvin = Some(2000.0);
app.update();
assert_eq!(
app.world.resource::<Suns>().shadowed.unwrap().kelvin,
Some(2000.0),
"the entity is the truth and the resource is the copy",
);
}
#[test]
fn a_light_that_stops_casting_changes_which_slot_it_fills() {
let mut app = app();
app.update();
let key = app
.world
.query::<(bevy_ecs::prelude::Entity, &Light)>()
.iter(&app.world)
.find(|(_, light)| light.shadow)
.map(|(entity, _)| entity)
.expect("a light that casts");
let rim = app
.world
.query::<(bevy_ecs::prelude::Entity, &Light)>()
.iter(&app.world)
.find(|(_, light)| !light.shadow)
.map(|(entity, _)| entity)
.expect("a light that does not");
app.world.despawn(rim);
app.world.get_mut::<Light>(key).unwrap().shadow = false;
app.update();
let suns = *app.world.resource::<Suns>();
assert!(suns.shadowed.is_none(), "nothing casts any more");
assert!(suns.unshadowed.is_some(), "and it fills the other slot");
}
}