makepad/libs/scene/src/light.rs
Admin d2130e6550 libs: model, mesh_edit, csg, scene, sim, render, xr
Squash of 25 work commits (Sep 1–12):
  237da90  render: the sprite lane hands the screen draw back the way it found it
  895b9a7  particles: an emitter can ride a body's own frame
  914471f  ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
  3fcccf3  sim: the whole world is implicitly editable, and one seam says where the ground is
  5692fab  sim: the landform world proves itself — walker through the tunnel included
  f4af6df  sim: terrain knows who changed it — a plan layer over player history
  adbb078  render: a water volume can be physics without a picture
  c17480f  render: a non-rigid body may carry its own orientation
  acad401  sim: an agent with no route holds and retries instead of walking into the wall
  22b2bf9  sim + chat: the composed world surface takes a map floor; the chat gets plan tools
  faf8112  web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
  d3472eb  tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
  4946c9e  sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
  4317f58  sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
  9791279  render: support rigged models and custom materials across viewers
  3ce2792  sim: use deck geometry for collision and sensing
  c8b79ff  Add portable PBR, rig and soft-body authoring support
  108d423  Add transactional polygon modeling and editable asset documents
  38f4d3b  Refine editable modeling and firm yarn character behavior
  08a2637  sim: add entity-owned lights and vehicle headlights
  00d96a7  render: add clustered lights, local shadows and incremental GI
  cf916af  render: import glTF asset extensions and wire clustered GI
  c6d2cca  model: cut transaction memory and raise capacity limits
  41af47a  raytrace: add a CPU probe-ray BVH budget example
  c963d0a  libs: the game sim splits into makepad-scene and makepad-soft-body; render, model, fab and the asset importer retarget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00

208 lines
6.7 KiB
Rust

//! Authored, entity-local lights. Pure data: render budgets and shadow maps
//! belong to the device, not the authoritative simulation.
use makepad_math::*;
pub const MAX_ENTITY_LIGHTS: usize = 16;
pub const MAX_LIGHT_NAME_BYTES: usize = 64;
#[derive(Clone, Debug, PartialEq)]
pub struct EntityLight {
pub name: String,
pub pos: Vec3f,
/// Entity-local emission direction; engine forward is -Z.
pub dir: Vec3f,
pub color: Vec3f,
pub intensity: f32,
pub range: f32,
pub spot: bool,
/// Half angles in degrees, 0 <= inner <= outer < 90.
pub inner_angle: f32,
pub outer_angle: f32,
pub enabled: bool,
pub shadows: bool,
}
impl Default for EntityLight {
fn default() -> Self {
Self {
name: "light".into(),
pos: Vec3f::default(),
dir: vec3f(0.0, 0.0, -1.0),
color: vec3f(1.0, 1.0, 1.0),
intensity: 2.0,
range: 15.0,
spot: false,
inner_angle: 15.0,
outer_angle: 25.0,
enabled: true,
shadows: false,
}
}
}
impl EntityLight {
pub fn validate(&self) -> Result<(), &'static str> {
let finite = |v: Vec3f| v.x.is_finite() && v.y.is_finite() && v.z.is_finite();
if self.name.is_empty() || self.name.len() > MAX_LIGHT_NAME_BYTES {
return Err("name must contain 1..64 bytes");
}
if !finite(self.pos) || !finite(self.dir) || !finite(self.color) {
return Err("position, direction and color must be finite");
}
let d = self.dir.dot(self.dir);
if !d.is_finite() || d < 1.0e-12 {
return Err("direction must be nonzero");
}
if !self.intensity.is_finite() || !(0.0..=10000.0).contains(&self.intensity) {
return Err("intensity must be in 0..10000");
}
if self.color.x < 0.0
|| self.color.y < 0.0
|| self.color.z < 0.0
|| self.color.x > 1.0
|| self.color.y > 1.0
|| self.color.z > 1.0
{
return Err("color must be in 0..1; use intensity for brightness");
}
if !self.range.is_finite() || !(0.05..=10000.0).contains(&self.range) {
return Err("range must be in 0.05..10000");
}
if !self.inner_angle.is_finite()
|| !self.outer_angle.is_finite()
|| self.inner_angle < 0.0
|| self.inner_angle > self.outer_angle
|| !(0.5..=85.0).contains(&self.outer_angle)
{
return Err("cone half-angles require 0 <= inner <= outer, outer in 0.5..85 degrees");
}
Ok(())
}
}
/// Replaces only this named light; editing lights never dirties geometry.
pub fn set_light(lights: &mut Vec<EntityLight>, light: EntityLight) -> Result<(), &'static str> {
light.validate()?;
if let Some(old) = lights.iter_mut().find(|l| l.name == light.name) {
*old = light;
} else {
if lights.len() >= MAX_ENTITY_LIGHTS {
return Err("at most 16 lights per entity");
}
lights.push(light);
}
Ok(())
}
/// Standard vehicle fixture in body space. The offset scales with the
/// chassis; reach/intensity do not. Does not require a particular mesh.
pub fn set_headlights(lights: &mut Vec<EntityLight>, half: Vec3f, enabled: bool) -> Result<(), &'static str> {
let missing = ["headlight_left", "headlight_right"]
.iter()
.filter(|name| !lights.iter().any(|l| l.name == **name))
.count();
if lights.len() + missing > MAX_ENTITY_LIGHTS {
return Err("not enough light slots for both headlights");
}
for (name, side) in [("headlight_left", -1.0), ("headlight_right", 1.0)] {
if let Some(light) = lights.iter_mut().find(|l| l.name == name) {
light.enabled = enabled;
continue;
}
let light = EntityLight {
name: name.into(),
pos: vec3f(
side * half.x * 0.7,
half.y * 0.15,
-half.z - 0.08,
),
dir: vec3f(0.0, -0.08, -1.0),
color: vec3f(1.0, 0.94, 0.82),
intensity: 4.0,
range: 30.0,
spot: true,
inner_angle: 12.0,
outer_angle: 25.0,
enabled,
shadows: true,
};
set_light(lights, light)?;
}
Ok(())
}
impl crate::Entity {
pub fn set_light(&mut self, light: EntityLight) -> Result<(), &'static str> {
set_light(&mut self.lights, light)
}
pub fn set_headlights(&mut self, enabled: bool) -> Result<(), &'static str> {
set_headlights(&mut self.lights, self.half, enabled)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lights_replace_validate_clone_and_follow_owner_lifetime() {
let mut w = crate::World::new();
let mut e = crate::Entity::default();
e.set_light(EntityLight::default()).unwrap();
e.set_light(EntityLight {
intensity: 3.0,
..Default::default()
})
.unwrap();
assert_eq!(e.lights.len(), 1);
assert!(e
.set_light(EntityLight {
range: f32::NAN,
..Default::default()
})
.is_err());
assert_eq!(e.lights[0].intensity, 3.0);
w.entities.push(e);
let snap = w.clone();
w.entities.clear();
assert!(w.entities.is_empty());
assert_eq!(snap.entities[0].lights.len(), 1);
}
#[test]
fn headlights_are_two_named_forward_spots_and_budget_is_bounded() {
let mut e = crate::Entity {
half: vec3f(1.0, 0.5, 2.0),
..Default::default()
};
e.set_headlights(true).unwrap();
assert_eq!(e.lights.len(), 2);
assert!(e
.lights
.iter()
.all(|l| l.spot && l.shadows && l.dir.z < 0.0 && l.pos.z < -2.0));
e.lights[0].intensity = 7.0;
e.set_headlights(false).unwrap();
assert!(e.lights.iter().all(|l| !l.enabled));
assert_eq!(e.lights[0].intensity, 7.0);
for i in 2..MAX_ENTITY_LIGHTS {
e.set_light(EntityLight {
name: format!("l{i}"),
..Default::default()
})
.unwrap();
}
assert!(e.set_light(EntityLight::default()).is_err());
}
#[test]
fn headlights_capacity_failure_is_atomic() {
let mut e = crate::Entity::default();
for i in 0..MAX_ENTITY_LIGHTS - 1 {
e.set_light(EntityLight {
name: format!("l{i}"),
..Default::default()
})
.unwrap();
}
let before = e.lights.clone();
assert!(e.set_headlights(true).is_err());
assert_eq!(e.lights, before);
}
}