use glam::Vec2;
pub(crate) const MAX_LIGHTS: usize = 4;
pub(crate) const MIN_SHADOW_BINS: u32 = 128;
pub(crate) const MAX_TOTAL_SHADOW_BINS: usize = 4096;
const TAU: f32 = std::f32::consts::TAU;
#[derive(Clone, Copy, Debug, Default)]
pub struct LightStop {
pub at: f32,
pub color: [f32; 4],
}
#[derive(Clone, Copy, Debug)]
pub struct Light2d {
pub position: Vec2,
pub radius: f32,
pub color: [f32; 3],
pub energy: f32,
pub falloff: [LightStop; 3],
pub shadows: bool,
pub shadow_softness: f32,
pub shadow_bins: u32,
}
impl Default for Light2d {
fn default() -> Self {
Self {
position: Vec2::ZERO,
radius: 1.0,
color: [1.0, 1.0, 1.0],
energy: 1.0,
falloff: [
LightStop {
at: 0.0,
color: [1.0, 1.0, 1.0, 1.0],
},
LightStop {
at: 0.5,
color: [0.5, 0.5, 0.5, 1.0],
},
LightStop {
at: 1.0,
color: [0.0, 0.0, 0.0, 1.0],
},
],
shadows: false,
shadow_softness: 2.0,
shadow_bins: 1024,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Occluder2d {
pub min: Vec2,
pub max: Vec2,
}
impl Occluder2d {
pub fn rect(min: Vec2, max: Vec2) -> Self {
Self {
min: min.min(max),
max: min.max(max),
}
}
pub fn cell(cell: [i32; 2], cell_size: f32) -> Self {
let min = Vec2::new(cell[0] as f32 * cell_size, cell[1] as f32 * cell_size);
Self {
min,
max: min + Vec2::splat(cell_size),
}
}
pub fn contains(&self, p: Vec2) -> bool {
p.x >= self.min.x && p.x <= self.max.x && p.y >= self.min.y && p.y <= self.max.y
}
}
pub fn angular_shadow_map(light: &Light2d, occluders: &[Occluder2d], out: &mut Vec<[f32; 2]>) {
out.clear();
let bins = light.shadow_bins as usize;
out.resize(bins, [1.0, 0.0]);
let radius = light.radius;
if bins == 0 || occluders.is_empty() || !(radius.is_finite() && radius > 0.0) {
return;
}
let origin = light.position;
for occ in occluders {
if !circle_touches_rect(origin, radius, *occ) {
continue;
}
for (i, cell) in out.iter_mut().enumerate() {
let ang = ((i as f32 + 0.5) / bins as f32) * TAU;
let dir = Vec2::new(ang.cos(), ang.sin());
let Some(t) = ray_rect_min(origin, dir, radius, *occ) else {
continue;
};
let hit = (t / radius).clamp(0.0, 1.0);
if cell[1] < 0.5 || hit < cell[0] {
*cell = [hit, 1.0];
}
}
}
}
pub(crate) fn resolve_shadow_bins(lights: &[Light2d]) -> Vec<u32> {
let n = lights.len().min(MAX_LIGHTS);
let shadowed = lights[..n].iter().filter(|l| l.shadows).count();
if shadowed == 0 {
return vec![0; n];
}
let cap = ((MAX_TOTAL_SHADOW_BINS / shadowed) as u32) & !1;
let mut out = Vec::with_capacity(n);
for l in &lights[..n] {
if !l.shadows {
out.push(0);
continue;
}
let want = l.shadow_bins.max(MIN_SHADOW_BINS).min(cap) & !1;
if want < l.shadow_bins {
log::warn!(
"light2d: shadow_bins {} clamped to {} ({} shadowed lights, budget {} bins)",
l.shadow_bins,
want,
shadowed,
MAX_TOTAL_SHADOW_BINS
);
}
out.push(want);
}
out
}
fn circle_touches_rect(c: Vec2, radius: f32, occ: Occluder2d) -> bool {
let nx = c.x.min(occ.max.x).max(occ.min.x);
let ny = c.y.min(occ.max.y).max(occ.min.y);
let dx = c.x - nx;
let dy = c.y - ny;
dx * dx + dy * dy <= radius * radius
}
fn ray_rect_min(origin: Vec2, dir: Vec2, max_t: f32, occ: Occluder2d) -> Option<f32> {
let o = [origin.x, origin.y];
let d = [dir.x, dir.y];
let lo = [occ.min.x.min(occ.max.x), occ.min.y.min(occ.max.y)];
let hi = [occ.min.x.max(occ.max.x), occ.min.y.max(occ.max.y)];
let mut tmin = 0.0f32;
let mut tmax = max_t;
for a in 0..2 {
if d[a].abs() <= 1e-9 {
if o[a] < lo[a] || o[a] > hi[a] {
return None;
}
continue;
}
let inv = 1.0 / d[a];
let (mut t0, mut t1) = ((lo[a] - o[a]) * inv, (hi[a] - o[a]) * inv);
if t0 > t1 {
std::mem::swap(&mut t0, &mut t1);
}
tmin = tmin.max(t0);
tmax = tmax.min(t1);
if tmin > tmax {
return None;
}
}
Some(tmin)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn angular_shadow_map_marks_the_covered_sector() {
let light = Light2d {
position: Vec2::ZERO,
radius: 10.0,
shadows: true,
shadow_bins: 64,
..Default::default()
};
let wall = Occluder2d::rect(Vec2::new(2.0, -2.0), Vec2::new(5.0, 2.0));
let mut out = vec![[9.0, 9.0]; 3];
angular_shadow_map(&light, &[wall], &mut out);
assert_eq!(out.len(), 64, "out is resized to shadow_bins");
for (i, cell) in out.iter().enumerate() {
let covered = !(8..56).contains(&i);
assert_eq!(cell[1] > 0.5, covered, "bin {i} hit flag, {cell:?}");
}
assert!(out[8..56].iter().all(|c| *c == [1.0, 0.0]));
let expect = |i: usize| {
let ang = (i as f32 + 0.5) * (TAU / 64.0);
2.0 / ang.cos() / light.radius
};
for i in [0usize, 7, 56, 63] {
assert!(
(out[i][0] - expect(i)).abs() < 1e-4,
"bin {i} distance {} vs {}",
out[i][0],
expect(i)
);
}
angular_shadow_map(&light, &[], &mut out);
assert!(out.iter().all(|c| *c == [1.0, 0.0]));
}
}