use serde::Serialize;
use std::collections::BTreeMap;
use crate::components::{Light, LightKind, Transform};
use crate::light_unit::LightUnit;
use crate::world::{Entity, World};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LightTableKind {
Point,
Spot,
}
impl LightTableKind {
pub fn holds(self, light: &Light) -> bool {
matches!(
(self, light.kind),
(LightTableKind::Point, LightKind::Point) | (LightTableKind::Spot, LightKind::Spot { .. })
)
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct LightTable {
pub kind: LightTableKind,
pub stride: u32,
pub capacity: u32,
pub fields: Vec<LightField>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct LightField {
pub offset: u32,
pub value: LightValue,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LightValue {
ViewPosition,
Range,
InverseRangeSquared,
InverseFalloffDistance,
TranslucencyStrength,
TranslucencyExponentScale,
Colour,
SpecularColour,
ViewToCone,
ViewBoundingSphere,
VerticalConeSphere { margin: f32 },
ViewDepthMin,
ViewDepthMax,
Shadowed,
ShadowProjection { floats: u8 },
ShadowCentre,
ShadowFaces { faces: u8 },
Constant { value: f32, why: String },
Unknown { floats: u8, name: String, why: String },
}
impl LightValue {
pub fn floats(&self) -> u32 {
match self {
LightValue::ViewPosition | LightValue::Colour | LightValue::SpecularColour => 3,
LightValue::Range
| LightValue::InverseRangeSquared
| LightValue::InverseFalloffDistance
| LightValue::TranslucencyStrength
| LightValue::TranslucencyExponentScale
| LightValue::ViewDepthMin
| LightValue::ViewDepthMax
| LightValue::Shadowed
| LightValue::Constant { .. } => 1,
LightValue::ViewToCone => 16,
LightValue::ViewBoundingSphere
| LightValue::VerticalConeSphere { .. }
| LightValue::ShadowCentre => 4,
LightValue::ShadowProjection { floats } | LightValue::Unknown { floats, .. } => {
u32::from(*floats)
}
LightValue::ShadowFaces { faces } => 4 * u32::from(*faces),
}
}
fn is_shadow_placement(&self) -> bool {
matches!(
self,
LightValue::ShadowProjection { .. } | LightValue::ShadowCentre | LightValue::ShadowFaces { .. }
)
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct LocalShadow {
pub projection: Vec<f32>,
pub centre: [f32; 4],
pub faces: Vec<[f32; 4]>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LocalShadows(pub BTreeMap<Entity, LocalShadow>);
impl LocalShadows {
pub fn none() -> Self {
Self::default()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct PackedLights {
pub bytes: Vec<u8>,
pub lights: Vec<Entity>,
pub left_out: usize,
pub unknown: Vec<String>,
pub specular_from_colour: usize,
pub shadowed: Vec<u32>,
pub unshadowed_casters: Vec<Entity>,
}
type Vec3 = [f64; 3];
struct Cone {
right: Vec3,
up: Vec3,
beam: Vec3,
tan_horizontal: f64,
tan_vertical: f64,
vertical: f64,
widest: f64,
near: f64,
}
fn dot(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn unit(v: Vec3) -> Result<Vec3, String> {
let n = dot(v, v).sqrt();
if n > 0.0 && n.is_finite() {
Ok(v.map(|x| x / n))
} else {
Err("a degenerate light axis".into())
}
}
impl LightTable {
pub fn holds(&self, light: &Light) -> bool {
self.kind.holds(light)
}
pub fn verify(&self, element_stride: Option<u32>) -> Result<(), String> {
if self.stride == 0 || !self.stride.is_multiple_of(4) {
return Err(format!(
"a light record of {} bytes, not whole floats",
self.stride
));
}
if let Some(element) = element_stride
&& element != self.stride
{
return Err(format!(
"light records of {} bytes in a buffer of {element}-byte elements",
self.stride
));
}
if self.capacity == 0 {
return Err("a light table that holds no records".into());
}
let mut spans = Vec::with_capacity(self.fields.len());
for f in &self.fields {
let floats = f.value.floats();
let end = f.offset.checked_add(floats * 4);
if !f.offset.is_multiple_of(4) || floats == 0 || end.is_none_or(|end| end > self.stride) {
return Err(format!(
"light field {:?} at {} ({floats} floats) is not inside the {}-byte record",
f.value, f.offset, self.stride
));
}
match &f.value {
LightValue::ViewToCone | LightValue::VerticalConeSphere { .. }
if self.kind != LightTableKind::Spot =>
{
return Err("a cone in a table of point lights".into());
}
LightValue::VerticalConeSphere { margin } if !(margin.is_finite() && *margin >= 0.0) => {
return Err(format!(
"a cone sphere's margin {margin} is not finite and non-negative"
));
}
LightValue::Constant { value, why } if !value.is_finite() || why.trim().is_empty() => {
return Err(format!(
"light constant {value} at {} is not finite or says not why",
f.offset
));
}
LightValue::Unknown { name, why, .. } if name.trim().is_empty() || why.trim().is_empty() => {
return Err(format!(
"unknown light field at {} has no name or says not why",
f.offset
));
}
_ => {}
}
if f.value.is_shadow_placement() && !self.reads_shadowed() {
return Err(format!(
"light field {:?} at {} places a shadow in a table that does not say which lights are shadowed",
f.value, f.offset
));
}
spans.push((f.offset, f.offset + floats * 4));
}
spans.sort_unstable();
if let Some(pair) = spans.windows(2).find(|p| p[1].0 < p[0].1) {
return Err(format!(
"light fields overlap: bytes {}..{} and {}..{}",
pair[0].0, pair[0].1, pair[1].0, pair[1].1
));
}
Ok(())
}
pub fn reads_shadowed(&self) -> bool {
self.fields.iter().any(|f| f.value == LightValue::Shadowed)
}
pub fn pack(
&self,
w: &World,
view: &[[f32; 4]; 4],
shadows: &LocalShadows,
) -> Result<PackedLights, String> {
self.verify(None)?;
let unit_value = w.resource::<LightUnit>().map(|u| f64::from(u.candela_per_unit));
let row = |r: usize| view[r].map(f64::from);
let to_view = |p: Vec3| -> Vec3 {
[0, 1, 2].map(|r| {
let v = row(r);
v[0] * p[0] + v[1] * p[1] + v[2] * p[2] + v[3]
})
};
let turn = |d: Vec3| -> Vec3 {
[0, 1, 2].map(|r| {
let v = row(r);
v[0] * d[0] + v[1] * d[1] + v[2] * d[2]
})
};
let mut lights = Vec::new();
for (e, light) in w.iter::<Light>() {
if !self.holds(light) {
continue;
}
let t = w
.get::<Transform>(e)
.ok_or_else(|| format!("{e:?}: a light without a core.transform"))?;
let at = to_view(t.cols[3].map(f64::from));
lights.push((dot(at, at), e, light, t, at));
}
lights.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let left_out = lights.len().saturating_sub(self.capacity as usize);
lights.truncate(self.capacity as usize);
let mut out = PackedLights {
bytes: vec![0; lights.len() * self.stride as usize],
lights: Vec::with_capacity(lights.len()),
left_out,
unknown: Vec::new(),
specular_from_colour: 0,
shadowed: Vec::new(),
unshadowed_casters: Vec::new(),
};
let reads_shadowed = self.reads_shadowed();
let reads_specular = self.fields.iter().any(|f| f.value == LightValue::SpecularColour);
for f in &self.fields {
if let LightValue::Unknown { name, .. } = &f.value {
out.unknown.push(name.clone());
}
}
let colour = |color: [f32; 3], intensity: f32| -> Result<Vec<f64>, String> {
let unit = unit_value
.ok_or("the world's lights have no core.light_unit: a light record's colour has no value")?;
Ok(color
.iter()
.map(|c| f64::from(*c) * f64::from(intensity) / unit)
.collect())
};
for (n, (_, e, light, t, at)) in lights.into_iter().enumerate() {
if reads_specular && light.specular.is_none() {
out.specular_from_colour += 1;
}
let shadow = shadows.0.get(&e).filter(|_| reads_shadowed);
if shadow.is_some() && light.cast_shadows == Some(false) {
return Err(format!("{e:?}: a shadow for a light that casts none"));
}
if shadow.is_some() {
out.shadowed.push(n as u32);
} else if light.cast_shadows == Some(true) {
out.unshadowed_casters.push(e);
}
let range = light
.range
.map(f64::from)
.filter(|r| r.is_finite() && *r > 0.0)
.ok_or_else(|| format!("{e:?}: a light with no range, which a light record holds"))?;
let cone = match light.kind {
LightKind::Spot { outer_cone, .. } => {
let frustum = w.get::<crate::SpotFrustum>(e);
if let Some(f) = frustum {
<crate::SpotFrustum as crate::Component>::verify(f, e, w)?;
}
let outer = f64::from(outer_cone);
let (horizontal, vertical, near) = frustum.map_or((outer, outer, 0.0), |f| {
(
f64::from(f.horizontal_half_angle),
f64::from(f.vertical_half_angle),
f64::from(f.near),
)
});
let (tan_horizontal, tan_vertical) = (horizontal.tan(), vertical.tan());
for tan in [tan_horizontal, tan_vertical] {
if !(tan.is_finite() && tan > 0.0) {
return Err(format!(
"{e:?}: a spot's cone {horizontal} by {vertical} has no tangent above 0"
));
}
}
if near >= range {
return Err(format!(
"{e:?}: a spot's near plane {near} not before its range {range}"
));
}
let axis = |c: usize| unit(turn(t.cols[c].map(f64::from)));
let beam = axis(2)?.map(|x| -x);
Some(Cone {
right: axis(0)?,
up: axis(1)?,
beam,
tan_horizontal,
tan_vertical,
vertical,
widest: match frustum {
Some(_) => tan_horizontal.hypot(tan_vertical).atan(),
None => outer,
},
near,
})
}
_ => None,
};
let mut values: Vec<(u32, Vec<f64>)> = Vec::with_capacity(self.fields.len());
for f in &self.fields {
let v: Vec<f64> = match &f.value {
LightValue::ViewPosition => at.to_vec(),
LightValue::Range => vec![range],
LightValue::InverseRangeSquared => vec![1.0 / (range * range)],
LightValue::InverseFalloffDistance => match w.get::<crate::LightFalloff>(e) {
Some(falloff) => vec![f64::from(
falloff
.inverse_distance()
.map_err(|why| format!("{e:?}: {why}"))?,
)],
None => {
let name = w
.get::<crate::Name>(e)
.map(|n| n.0.as_str())
.or_else(|| w.get::<crate::Source>(e).map(|s| s.0.as_str()));
out.unknown.push(format!("light {e:?}{}: source falloff distance not stated; inverse falloff distance supplied as zero",
name.map(|n| format!(" ({n})")).unwrap_or_default()));
vec![0.]
}
},
LightValue::TranslucencyStrength | LightValue::TranslucencyExponentScale => {
let source = w
.get::<crate::LightTranslucency>(e)
.ok_or_else(|| format!("{e:?}: light translucency parameters are not stated"))?;
<crate::LightTranslucency as crate::Component>::verify(source, e, w)?;
vec![f64::from(if f.value == LightValue::TranslucencyStrength {
source.strength
} else {
source.exponent_scale
})]
}
LightValue::Colour => colour(light.color, light.intensity)?,
LightValue::SpecularColour => match &light.specular {
Some(s) => colour(s.color, s.intensity)?,
None => colour(light.color, light.intensity)?,
},
LightValue::ViewToCone => {
let c = cone.as_ref().ok_or("a cone for a light that is no spot")?;
let depth = range / (range - c.near);
let mut m = Vec::with_capacity(16);
for (axis, scale, shift) in [
(c.right, 1.0 / c.tan_horizontal, 0.0),
(c.up, 1.0 / c.tan_vertical, 0.0),
(c.beam, depth, c.near),
(c.beam, 1.0, 0.0),
] {
m.extend(axis.map(|x| x * scale));
m.push((-dot(axis, at) - shift) * scale);
}
m
}
LightValue::ViewBoundingSphere => match &cone {
None => vec![at[0], at[1], at[2], range],
Some(c) => {
let outer = c.widest;
let radius = if outer <= std::f64::consts::FRAC_PI_4 {
range / (2.0 * outer.cos().powi(2))
} else {
range * outer.tan()
};
let along = if outer <= std::f64::consts::FRAC_PI_4 {
radius
} else {
range
};
vec![
at[0] + c.beam[0] * along,
at[1] + c.beam[1] * along,
at[2] + c.beam[2] * along,
radius,
]
}
},
LightValue::VerticalConeSphere { margin } => {
let c = cone.as_ref().ok_or("a cone sphere for a light that is no spot")?;
let along = range / (2.0 * c.vertical.cos().powi(2));
vec![
at[0] + c.beam[0] * along,
at[1] + c.beam[1] * along,
at[2] + c.beam[2] * along,
along + f64::from(*margin),
]
}
LightValue::ViewDepthMin | LightValue::ViewDepthMax => {
let (least, most) = match &cone {
None => (at[2] - range, at[2] + range),
Some(c) => {
let base = at[2] + c.beam[2] * range;
let reach = range
* (c.tan_horizontal * c.right[2].abs() + c.tan_vertical * c.up[2].abs());
(at[2].min(base - reach), at[2].max(base + reach))
}
};
vec![if f.value == LightValue::ViewDepthMin {
least
} else {
most
}]
}
LightValue::Shadowed => vec![if shadow.is_some() { 1.0 } else { 0.0 }],
LightValue::ShadowProjection { floats } => match shadow {
Some(s) => s
.projection
.get(..usize::from(*floats))
.ok_or_else(|| {
format!("{e:?}: a shadow with fewer than {floats} projection terms")
})?
.iter()
.map(|x| f64::from(*x))
.collect(),
None => vec![0.0; usize::from(*floats)],
},
LightValue::ShadowCentre => match shadow {
Some(s) => s.centre.map(f64::from).to_vec(),
None => vec![0.0; 4],
},
LightValue::ShadowFaces { faces } => match shadow {
Some(s) => s
.faces
.get(..usize::from(*faces))
.ok_or_else(|| format!("{e:?}: a shadow with fewer than {faces} faces"))?
.iter()
.flatten()
.map(|x| f64::from(*x))
.collect(),
None => vec![0.0; 4 * usize::from(*faces)],
},
LightValue::Constant { value, .. } => vec![f64::from(*value)],
LightValue::Unknown { floats, .. } => vec![0.0; usize::from(*floats)],
};
values.push((f.offset, v));
}
let record = &mut out.bytes[n * self.stride as usize..(n + 1) * self.stride as usize];
for (offset, v) in values {
for (k, x) in v.into_iter().enumerate() {
let x = x as f32;
if !x.is_finite() {
return Err(format!("{e:?}: a light record value at {offset} is not finite"));
}
let byte = offset as usize + 4 * k;
record[byte..byte + 4].copy_from_slice(&x.to_le_bytes());
}
}
out.lights.push(e);
}
Ok(out)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct ListBits {
pub offset: u32,
pub shift: u8,
pub bits: u8,
}
impl ListBits {
pub fn max(&self) -> u32 {
if self.bits >= 32 {
u32::MAX
} else {
(1u32 << self.bits) - 1
}
}
fn mask(&self) -> u32 {
self.max() << self.shift
}
fn verify(&self, stride: u32, what: &str) -> Result<(), String> {
let end = u32::from(self.shift) + u32::from(self.bits);
if self.bits == 0 || end > 32 || !self.offset.is_multiple_of(4) || self.offset + 4 > stride {
return Err(format!(
"{what}: bits {}..{end} of the word at {} are not inside a {stride}-byte record",
self.shift, self.offset
));
}
Ok(())
}
fn write(&self, record: &mut [u8], value: u32) {
let at = self.offset as usize;
let mut word = u32::from_le_bytes([record[at], record[at + 1], record[at + 2], record[at + 3]]);
word = (word & !self.mask()) | ((value & self.max()) << self.shift);
record[at..at + 4].copy_from_slice(&word.to_le_bytes());
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ListConstant {
pub bits: ListBits,
pub value: u32,
pub why: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ListUnknown {
pub bits: ListBits,
pub name: String,
pub why: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LightListPart {
Lookup(LightLookup),
Indices(LightIndices),
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct LightLookup {
pub stride: u32,
pub capacity: u32,
pub first: ListBits,
pub counts: Vec<(LightTableKind, ListBits)>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub count_copies: Vec<(LightTableKind, ListBits)>,
pub constants: Vec<ListConstant>,
pub unknown: Vec<ListUnknown>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct LightIndices {
pub stride: u32,
pub capacity: u32,
pub index: ListBits,
pub slices: ListBits,
#[serde(skip_serializing_if = "Option::is_none")]
pub tile_pass: Option<ListBits>,
pub constants: Vec<ListConstant>,
pub unknown: Vec<ListUnknown>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PackedLightLists {
pub lookup: Vec<u8>,
pub indices: Vec<u8>,
pub unknown: Vec<String>,
}
fn verify_record(
stride: u32,
capacity: u32,
fields: &[(ListBits, &str)],
constants: &[ListConstant],
unknown: &[ListUnknown],
what: &str,
) -> Result<(), String> {
if stride == 0 || !stride.is_multiple_of(4) {
return Err(format!(
"a light {what} record of {stride} bytes, not whole words"
));
}
if capacity == 0 {
return Err(format!("a light {what} that holds no records"));
}
let mut all: Vec<(ListBits, String)> = fields.iter().map(|(b, n)| (*b, (*n).to_owned())).collect();
for c in constants {
if c.why.trim().is_empty() || c.value > c.bits.max() {
return Err(format!(
"light {what} constant {} does not fit its bits or says not why",
c.value
));
}
all.push((c.bits, format!("constant {}", c.value)));
}
for u in unknown {
if u.name.trim().is_empty() || u.why.trim().is_empty() {
return Err(format!(
"an unknown light {what} field has no name or says not why"
));
}
all.push((u.bits, u.name.clone()));
}
for (i, (b, name)) in all.iter().enumerate() {
b.verify(stride, &format!("light {what} {name}"))?;
if let Some((_, other)) = all[..i]
.iter()
.find(|(o, _)| o.offset == b.offset && o.mask() & b.mask() != 0)
{
return Err(format!("light {what} fields {other} and {name} overlap"));
}
}
Ok(())
}
impl LightListPart {
pub fn verify(&self, element_stride: Option<u32>) -> Result<(), String> {
let stride = match self {
LightListPart::Lookup(l) => {
let mut fields = vec![(l.first, "first entry")];
let mut kinds = Vec::new();
for (kind, bits) in &l.counts {
if kinds.contains(kind) {
return Err(format!("a light lookup that counts {kind:?} lights twice"));
}
kinds.push(*kind);
fields.push((*bits, "count"));
}
if l.counts.is_empty() {
return Err("a light lookup that counts no lights".into());
}
for (kind, bits) in &l.count_copies {
if !kinds.contains(kind) {
return Err(format!("a light lookup copies an unlisted {kind:?} count"));
}
fields.push((*bits, "count copy"));
}
verify_record(l.stride, l.capacity, &fields, &l.constants, &l.unknown, "lookup")?;
l.stride
}
LightListPart::Indices(x) => {
let mut fields = vec![(x.index, "index"), (x.slices, "slices")];
if let Some(bits) = x.tile_pass {
fields.push((bits, "tile pass"));
}
verify_record(
x.stride,
x.capacity,
&fields,
&x.constants,
&x.unknown,
"index list",
)?;
x.stride
}
};
match element_stride {
Some(element) if element != stride => Err(format!(
"light list records of {stride} bytes in a buffer of {element}-byte elements"
)),
_ => Ok(()),
}
}
}
pub fn pack_light_lists(
lookup: &LightLookup,
indices: &LightIndices,
counts: &[(LightTableKind, u32)],
shadowed: &[(LightTableKind, u32)],
tiles: u32,
) -> Result<PackedLightLists, String> {
LightListPart::Lookup(lookup.clone()).verify(None)?;
LightListPart::Indices(indices.clone()).verify(None)?;
if tiles == 0 || tiles > lookup.capacity {
return Err(format!(
"{tiles} tiles for a light lookup of {} records",
lookup.capacity
));
}
let count_of =
|kind: LightTableKind| -> u32 { counts.iter().filter(|(k, _)| *k == kind).map(|(_, n)| *n).sum() };
let mut marked: Vec<(LightTableKind, u32)> = shadowed.to_vec();
marked.sort_unstable();
marked.dedup();
for (kind, index) in &marked {
if *index >= count_of(*kind) {
return Err(format!(
"shadowed {kind:?} record {index} is not among the {} the lists count",
count_of(*kind)
));
}
}
if !marked.is_empty() && indices.tile_pass.is_none() {
return Err("the light lists have no tile-pass bit to leave a shadowed light out with".into());
}
let shadowed_of =
|kind: LightTableKind| -> u32 { marked.iter().filter(|(k, _)| *k == kind).count() as u32 };
let mut entries = Vec::new();
for (kind, bits) in &lookup.counts {
let n = count_of(*kind);
if n > bits.max() || n.saturating_sub(1) > indices.index.max() {
return Err(format!("{n} {kind:?} lights do not fit the light lists' bits"));
}
entries.extend((0..n).map(|i| (*kind, i)));
}
for (kind, bits) in &lookup.count_copies {
let n = count_of(*kind);
if n > bits.max() {
return Err(format!(
"{n} {kind:?} lights do not fit the lookup's copied count bits"
));
}
}
if entries.len() as u64 > u64::from(indices.capacity) {
return Err(format!(
"{} light list entries for an index list of {}",
entries.len(),
indices.capacity
));
}
let mut out = PackedLightLists {
lookup: vec![0; tiles as usize * lookup.stride as usize],
indices: vec![0; entries.len() * indices.stride as usize],
unknown: lookup
.unknown
.iter()
.chain(&indices.unknown)
.map(|u| u.name.clone())
.collect(),
};
for record in out.lookup.chunks_exact_mut(lookup.stride as usize) {
lookup.first.write(record, 0);
for (kind, bits) in &lookup.counts {
bits.write(record, count_of(*kind));
}
for (kind, bits) in &lookup.count_copies {
bits.write(record, count_of(*kind) - shadowed_of(*kind));
}
for c in &lookup.constants {
c.bits.write(record, c.value);
}
}
for (record, (kind, index)) in out.indices.chunks_exact_mut(indices.stride as usize).zip(entries) {
indices.index.write(record, index);
indices.slices.write(record, indices.slices.max());
if let Some(bits) = indices.tile_pass {
bits.write(record, u32::from(!marked.contains(&(kind, index))));
}
for c in &indices.constants {
c.bits.write(record, c.value);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::World;
use crate::components::LightSpecular;
fn floats(bytes: &[u8]) -> Vec<f32> {
bytes
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect()
}
fn close(got: f32, want: f64, what: &str) {
assert!(
(f64::from(got) - want).abs() <= 1e-5 * want.abs().max(1.0),
"{what}: {got}, worked by hand {want}"
);
}
const VIEW: [[f32; 4]; 4] = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, -1.0, 10.0],
[0.0, 0.0, 0.0, 1.0],
];
fn table(kind: LightTableKind, stride: u32, fields: Vec<(u32, LightValue)>) -> LightTable {
LightTable {
kind,
stride,
capacity: 4,
fields: fields
.into_iter()
.map(|(offset, value)| LightField { offset, value })
.collect(),
}
}
#[test]
fn source_falloff_and_translucency_pack_without_changing_range_or_radius() {
let mut w = World::new();
let e = w.spawn();
w.insert(e, crate::Name("measured light".into()));
w.insert(
e,
Transform {
cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0.; 3]],
},
);
w.insert(
e,
Light {
kind: LightKind::Point,
color: [1.; 3],
intensity: 1.,
range: Some(20.),
radius: Some(3.),
cast_shadows: None,
specular: None,
},
);
w.insert(
e,
crate::LightTranslucency {
strength: 0.25,
exponent_scale: 0.01,
},
);
let t = table(
LightTableKind::Point,
16,
vec![
(0, LightValue::InverseFalloffDistance),
(4, LightValue::TranslucencyStrength),
(8, LightValue::TranslucencyExponentScale),
(12, LightValue::Range),
],
);
let missing = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
assert_eq!(floats(&missing.bytes), vec![0., 0.25, 0.01, 20.]);
assert_eq!(missing.unknown.len(), 1);
assert!(missing.unknown[0].contains("measured light"));
for distance in [8., 1.5, 0.5] {
w.insert(e, crate::LightFalloff { distance });
let packed = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
assert!(packed.unknown.is_empty());
assert_eq!(floats(&packed.bytes), vec![distance.recip(), 0.25, 0.01, 20.]);
assert_eq!(w.get::<Light>(e).unwrap().radius, Some(3.));
}
for distance in [0., -1., f32::NAN, f32::INFINITY, f32::from_bits(1)] {
w.insert(e, crate::LightFalloff { distance });
assert!(
t.pack(&w, &VIEW, &LocalShadows::none())
.unwrap_err()
.contains("falloff distance")
);
}
}
#[test]
fn a_shadowed_light_packs_its_flag_and_its_place() {
let mut w = World::new();
w.insert_resource(LightUnit {
candela_per_unit: 1.0,
});
let place = |w: &mut World, z: f32, cast_shadows: Option<bool>| {
let e = w.spawn();
w.insert(
e,
Transform {
cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., z]],
},
);
w.insert(
e,
Light {
kind: LightKind::Point,
color: [1.0; 3],
intensity: 1.0,
range: Some(4.0),
cast_shadows,
radius: None,
specular: None,
},
);
e
};
let first = place(&mut w, 3., Some(true));
let second = place(&mut w, 2., Some(true));
let quiet = place(&mut w, 1., None);
let t = table(
LightTableKind::Point,
72,
vec![
(0, LightValue::ViewPosition),
(12, LightValue::Shadowed),
(16, LightValue::ShadowProjection { floats: 2 }),
(24, LightValue::ShadowCentre),
(40, LightValue::ShadowFaces { faces: 2 }),
],
);
assert_eq!(t.verify(None), Ok(()));
let record = |packed: &PackedLights, e: Entity| -> Vec<f32> {
let i = packed.lights.iter().position(|l| *l == e).unwrap();
floats(&packed.bytes[72 * i..72 * (i + 1)])
};
let none = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
assert_eq!(none.lights, vec![first, second, quiet]);
assert!(none.shadowed.is_empty());
assert_eq!(none.unshadowed_casters, vec![first, second]);
for e in [first, second, quiet] {
assert!(record(&none, e)[3..].iter().all(|x| *x == 0.0));
}
let shadow = LocalShadow {
projection: vec![1.001, -0.008, 7.0],
centre: [-34.25, 8.0, -0.5, 1.0],
faces: vec![[0.25, -0.25, 0.5, 0.5], [0.25, -0.25, 1.5, 0.5]],
};
let shadows = LocalShadows([(second, shadow.clone())].into());
let one = t.pack(&w, &VIEW, &shadows).unwrap();
assert_eq!(one.shadowed, vec![1]);
assert_eq!(one.unshadowed_casters, vec![first]);
assert_eq!(
record(&one, second)[3..],
[
1.0, 1.001, -0.008, -34.25, 8.0, -0.5, 1.0, 0.25, -0.25, 0.5, 0.5, 0.25, -0.25, 1.5, 0.5
]
);
assert!(record(&one, first)[3..].iter().all(|x| *x == 0.0));
w.get_mut::<Light>(second).unwrap().cast_shadows = Some(false);
assert!(t.pack(&w, &VIEW, &shadows).unwrap_err().contains("casts none"));
w.get_mut::<Light>(second).unwrap().cast_shadows = Some(true);
let mut short = shadow;
short.faces.pop();
let refused = t.pack(&w, &VIEW, &LocalShadows([(second, short)].into()));
assert!(refused.unwrap_err().contains("fewer than 2 faces"));
let unsaid = table(LightTableKind::Point, 16, vec![(0, LightValue::ShadowCentre)]);
assert!(unsaid.verify(None).unwrap_err().contains("does not say"));
}
#[test]
fn a_point_light_packs_into_its_record() {
let mut w = World::new();
let place = |w: &mut World, at: [f32; 3]| {
let e = w.spawn();
w.insert(
e,
Transform {
cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], at],
},
);
w.insert(
e,
Light {
kind: LightKind::Point,
color: [0.5, 0.25, 1.0],
intensity: 8.0,
range: Some(4.0),
cast_shadows: None,
radius: None,
specular: None,
},
);
e
};
let near = place(&mut w, [1., 2., 3.]);
place(&mut w, [1., 2., -30.]);
let mut t = table(
LightTableKind::Point,
48,
vec![
(0, LightValue::ViewPosition),
(12, LightValue::InverseRangeSquared),
(16, LightValue::Colour),
(
28,
LightValue::Unknown {
floats: 2,
name: "Wrap".into(),
why: "test".into(),
},
),
(36, LightValue::Range),
(
40,
LightValue::Constant {
value: 0.0,
why: "test".into(),
},
),
],
);
assert!(t.pack(&w, &VIEW, &LocalShadows::none()).is_err());
w.insert_resource(LightUnit {
candela_per_unit: 2.0,
});
t.capacity = 1;
let packed = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
assert_eq!((packed.lights.clone(), packed.left_out), (vec![near], 1));
assert_eq!(packed.unknown, vec!["Wrap".to_owned()]);
let f = floats(&packed.bytes);
let want = [1.0, 2.0, 7.0, 1.0 / 16.0, 2.0, 1.0, 4.0, 0.0, 0.0, 4.0, 0.0];
for (i, v) in want.into_iter().enumerate() {
close(f[i], v, &format!("float {i}"));
}
assert_eq!(f.len(), 12);
assert_eq!(f[11], 0.0, "a byte no field covers is zero");
}
#[test]
fn a_light_packs_its_own_specular_and_one_without_packs_its_colour() {
let mut w = World::new();
w.insert_resource(LightUnit {
candela_per_unit: 2.0,
});
let place = |w: &mut World, z: f32, specular: Option<LightSpecular>| {
let e = w.spawn();
w.insert(
e,
Transform {
cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., z]],
},
);
w.insert(
e,
Light {
kind: LightKind::Point,
color: [0.5, 0.25, 1.0],
intensity: 8.0,
range: Some(4.0),
cast_shadows: None,
radius: None,
specular,
},
);
e
};
let own = place(
&mut w,
0.0,
Some(LightSpecular {
color: [1.0, 0.5, 0.0],
intensity: 4.0,
}),
);
let plain = place(&mut w, -5.0, None);
let t = table(
LightTableKind::Point,
24,
vec![(0, LightValue::Colour), (12, LightValue::SpecularColour)],
);
let packed = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
assert_eq!(packed.lights, vec![own, plain]);
assert_eq!(packed.specular_from_colour, 1);
let f = floats(&packed.bytes);
let want = [2.0, 1.0, 4.0, 2.0, 1.0, 0.0, 2.0, 1.0, 4.0, 2.0, 1.0, 4.0];
for (i, v) in want.into_iter().enumerate() {
close(f[i], v, &format!("float {i}"));
}
let colour_only = table(LightTableKind::Point, 12, vec![(0, LightValue::Colour)]);
assert_eq!(
colour_only
.pack(&w, &VIEW, &LocalShadows::none())
.unwrap()
.specular_from_colour,
0
);
}
#[test]
fn a_spot_packs_its_cone_sphere_and_depths() {
let mut w = World::new();
w.insert_resource(LightUnit {
candela_per_unit: 1.0,
});
let e = w.spawn();
w.insert(
e,
Transform {
cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., 0.]],
},
);
w.insert(
e,
Light {
kind: LightKind::Spot {
inner_cone: 0.4,
outer_cone: std::f32::consts::FRAC_PI_6,
},
color: [1.0, 1.0, 1.0],
intensity: 1.0,
range: Some(6.0),
cast_shadows: None,
radius: None,
specular: None,
},
);
let t = table(
LightTableKind::Spot,
112,
vec![
(0, LightValue::ViewToCone),
(64, LightValue::ViewBoundingSphere),
(80, LightValue::ViewDepthMin),
(84, LightValue::ViewDepthMax),
],
);
let f = floats(&t.pack(&w, &VIEW, &LocalShadows::none()).unwrap().bytes);
let cot = 1.0 / (std::f64::consts::FRAC_PI_6).tan();
let want = [
cot, 0.0, 0.0, 0.0, 0.0, cot, 0.0, 0.0, 0.0, 0.0, 1.0, -10.0, 0.0, 0.0, 1.0, -10.0, 0.0, 0.0, 14.0, 4.0, 10.0, 16.0, ];
for (i, v) in want.into_iter().enumerate() {
close(f[i], v, &format!("float {i}"));
}
let p = [3.0 * (std::f64::consts::FRAC_PI_6).tan(), 0.0, 13.0, 1.0];
let row = |r: usize| (0..4).map(|k| f64::from(f[4 * r + k]) * p[k]).sum::<f64>();
close((row(0) / row(3)) as f32, 1.0, "the cone's edge");
}
#[test]
fn a_spot_frustum_shapes_its_cone_near_plane_and_spheres() {
let mut w = World::new();
w.insert_resource(LightUnit {
candela_per_unit: 1.0,
});
let e = w.spawn();
w.insert(
e,
Transform {
cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., 0.]],
},
);
let spot = Light {
kind: LightKind::Spot {
inner_cone: 0.3,
outer_cone: 0.6,
},
color: [1.0, 1.0, 1.0],
intensity: 1.0,
range: Some(6.0),
cast_shadows: None,
radius: None,
specular: None,
};
w.insert(e, spot.clone());
let frustum = crate::SpotFrustum {
horizontal_half_angle: std::f32::consts::FRAC_PI_4,
vertical_half_angle: std::f32::consts::FRAC_PI_6,
near: 2.0,
};
w.insert(e, frustum);
let t = table(
LightTableKind::Spot,
128,
vec![
(0, LightValue::ViewToCone),
(64, LightValue::ViewBoundingSphere),
(80, LightValue::VerticalConeSphere { margin: 0.001 }),
(96, LightValue::ViewDepthMin),
(100, LightValue::ViewDepthMax),
],
);
let f = floats(&t.pack(&w, &VIEW, &LocalShadows::none()).unwrap().bytes);
let corners = (4.0f64 / 3.0).sqrt();
let want = [
1.0,
0.0,
0.0,
0.0, 0.0,
3f64.sqrt(),
0.0,
0.0, 0.0,
0.0,
1.5,
-18.0, 0.0,
0.0,
1.0,
-10.0, 0.0,
0.0,
16.0,
6.0 * corners, 0.0,
0.0,
14.0,
4.001, 10.0,
16.0, ];
for (i, v) in want.into_iter().enumerate() {
close(f[i], v, &format!("float {i}"));
}
let row = |r: usize, d: f64| {
(0..4)
.map(|k| f64::from(f[4 * r + k]) * [0.0, 0.0, 10.0 + d, 1.0][k])
.sum::<f64>()
};
assert!(row(2, 1.0) < 0.0 && row(2, 3.0) > 0.0);
for bad in [
crate::SpotFrustum { near: 6.0, ..frustum },
crate::SpotFrustum {
vertical_half_angle: std::f32::consts::FRAC_PI_2,
..frustum
},
] {
w.insert(e, bad);
assert!(t.pack(&w, &VIEW, &LocalShadows::none()).is_err(), "{bad:?}");
}
w.insert(e, frustum);
w.insert(
e,
Light {
kind: LightKind::Point,
..spot
},
);
assert!(<crate::SpotFrustum as crate::Component>::verify(&frustum, e, &w).is_err());
let points = table(
LightTableKind::Point,
16,
vec![(0, LightValue::VerticalConeSphere { margin: 0.0 })],
);
assert!(points.verify(None).is_err());
}
#[test]
fn a_light_table_agrees_with_itself() {
let ok = table(LightTableKind::Point, 16, vec![(0, LightValue::ViewPosition)]);
assert_eq!(ok.verify(Some(16)), Ok(()));
assert!(ok.verify(Some(32)).is_err());
assert!(table(LightTableKind::Point, 14, vec![]).verify(None).is_err());
assert!(
table(LightTableKind::Point, 16, vec![(8, LightValue::ViewPosition)])
.verify(None)
.is_err()
);
assert!(
table(
LightTableKind::Point,
16,
vec![(0, LightValue::ViewPosition), (8, LightValue::Range)]
)
.verify(None)
.is_err()
);
assert!(
table(LightTableKind::Point, 64, vec![(0, LightValue::ViewToCone)])
.verify(None)
.is_err()
);
assert!(
table(
LightTableKind::Point,
16,
vec![(
0,
LightValue::Constant {
value: 0.0,
why: " ".into()
}
)]
)
.verify(None)
.is_err()
);
}
fn bits(offset: u32, shift: u8, bits: u8) -> ListBits {
ListBits { offset, shift, bits }
}
fn lists() -> (LightLookup, LightIndices) {
(
LightLookup {
stride: 12,
capacity: 4,
first: bits(0, 0, 32),
counts: vec![
(LightTableKind::Point, bits(4, 0, 16)),
(LightTableKind::Spot, bits(4, 16, 16)),
],
count_copies: Vec::new(),
constants: Vec::new(),
unknown: vec![ListUnknown {
bits: bits(8, 0, 32),
name: "word 2".into(),
why: "read only by a debug view".into(),
}],
},
LightIndices {
stride: 4,
capacity: 8,
index: bits(0, 0, 16),
slices: bits(0, 16, 15),
tile_pass: Some(bits(0, 31, 1)),
constants: Vec::new(),
unknown: Vec::new(),
},
)
}
fn words(bytes: &[u8]) -> Vec<u32> {
bytes
.as_chunks::<4>()
.0
.iter()
.map(|w| u32::from_le_bytes(*w))
.collect()
}
#[test]
fn every_light_is_listed_in_every_tile_and_slice() {
let (lookup, indices) = lists();
let packed = pack_light_lists(
&lookup,
&indices,
&[(LightTableKind::Spot, 1), (LightTableKind::Point, 2)],
&[],
3,
)
.unwrap();
assert_eq!(
words(&packed.lookup),
[0, 0x0001_0002, 0, 0, 0x0001_0002, 0, 0, 0x0001_0002, 0]
);
assert_eq!(words(&packed.indices), [0xffff_0000, 0xffff_0001, 0xffff_0000]);
assert_eq!(packed.unknown, ["word 2"]);
}
#[test]
fn copied_counts_keep_nonempty_tiles_and_do_not_duplicate_entries() {
let (mut lookup, indices) = lists();
lookup.unknown.clear();
lookup.count_copies = vec![
(LightTableKind::Point, bits(8, 0, 16)),
(LightTableKind::Spot, bits(8, 16, 16)),
];
let packed = pack_light_lists(
&lookup,
&indices,
&[(LightTableKind::Point, 2), (LightTableKind::Spot, 1)],
&[],
2,
)
.unwrap();
assert_eq!(
words(&packed.lookup),
[0, 0x0001_0002, 0x0001_0002, 0, 0x0001_0002, 0x0001_0002]
);
assert_eq!(words(&packed.indices), [0xffff_0000, 0xffff_0001, 0xffff_0000]);
assert!(packed.unknown.is_empty());
let empty = pack_light_lists(&lookup, &indices, &[], &[], 1).unwrap();
assert_eq!(words(&empty.lookup), [0, 0, 0]);
assert!(empty.indices.is_empty());
let mut invalid = lookup.clone();
invalid.counts.pop();
assert!(
LightListPart::Lookup(invalid)
.verify(None)
.unwrap_err()
.contains("unlisted")
);
let mut narrow = lookup.clone();
narrow.count_copies[0].1 = bits(8, 0, 1);
assert!(pack_light_lists(&narrow, &indices, &[(LightTableKind::Point, 2)], &[], 1).is_err());
lookup.count_copies[0].1 = bits(4, 0, 16);
assert!(
LightListPart::Lookup(lookup)
.verify(None)
.unwrap_err()
.contains("overlap")
);
}
#[test]
fn a_shadowed_light_leaves_the_tile_pass_but_stays_listed() {
let (mut lookup, mut indices) = lists();
lookup.unknown.clear();
lookup.count_copies = vec![
(LightTableKind::Point, bits(8, 0, 16)),
(LightTableKind::Spot, bits(8, 16, 16)),
];
let counts = [(LightTableKind::Point, 2), (LightTableKind::Spot, 1)];
let packed = pack_light_lists(&lookup, &indices, &counts, &[(LightTableKind::Point, 1)], 1).unwrap();
assert_eq!(words(&packed.lookup), [0, 0x0001_0002, 0x0001_0001]);
assert_eq!(words(&packed.indices), [0xffff_0000, 0x7fff_0001, 0xffff_0000]);
assert!(
pack_light_lists(&lookup, &indices, &counts, &[(LightTableKind::Spot, 1)], 1)
.unwrap_err()
.contains("not among")
);
indices.tile_pass = None;
assert!(
pack_light_lists(&lookup, &indices, &counts, &[(LightTableKind::Point, 0)], 1)
.unwrap_err()
.contains("no tile-pass bit")
);
}
#[test]
fn light_lists_refuse_what_they_cannot_hold() {
let (lookup, indices) = lists();
assert!(pack_light_lists(&lookup, &indices, &[(LightTableKind::Point, 1)], &[], 5).is_err());
assert!(pack_light_lists(&lookup, &indices, &[(LightTableKind::Point, 9)], &[], 1).is_err());
let mut narrow = lookup.clone();
narrow.counts[0].1 = bits(4, 0, 2);
assert!(pack_light_lists(&narrow, &indices, &[(LightTableKind::Point, 4)], &[], 1).is_err());
let mut overlap = indices.clone();
overlap.slices = bits(0, 15, 15);
assert!(LightListPart::Indices(overlap).verify(Some(4)).is_err());
assert!(LightListPart::Lookup(lookup).verify(Some(16)).is_err());
assert_eq!(LightListPart::Indices(indices).verify(Some(4)), Ok(()));
}
}