Skip to main content

primase_core/shader/
lights.rs

1//! The scene's lights as a program reads them: a buffer of one record per light ([`super::BufferSource::Lights`]),
2//! each record's fields by what they hold ([`LightValue`]), and the one packing of a world's `core.light`s into it
3//! ([`LightTable::pack`]), so every consumer fills the table the same way.
4
5use serde::Serialize;
6use std::collections::BTreeMap;
7
8use crate::components::{Light, LightKind, Transform};
9use crate::light_unit::LightUnit;
10use crate::world::{Entity, World};
11
12/// Which lights a [`LightTable`] holds: one record per `core.light` of this kind.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum LightTableKind {
16    Point,
17    Spot,
18}
19
20impl LightTableKind {
21    /// Whether `light` is of this kind: a point light for points, a spot for spots.
22    pub fn holds(self, light: &Light) -> bool {
23        matches!(
24            (self, light.kind),
25            (LightTableKind::Point, LightKind::Point) | (LightTableKind::Spot, LightKind::Spot { .. })
26        )
27    }
28}
29
30/// The scene's lights of one kind as a program reads them from a buffer: one record of `stride` bytes per `core.light` of
31/// `kind`, in no order the programs rely on (they read how many there are from a
32/// [`super::FrameParameter::LightCount`] constant and visit each), each holding `fields` as 32-bit floats. A byte no field
33/// covers is read by no program of the source; a consumer writes zero there. The source's buffer holds at most
34/// `capacity` records.
35#[derive(Clone, Debug, PartialEq, Serialize)]
36pub struct LightTable {
37    pub kind: LightTableKind,
38    /// Bytes per record: the buffer's element size.
39    pub stride: u32,
40    /// The most records the source's buffer holds.
41    pub capacity: u32,
42    pub fields: Vec<LightField>,
43}
44
45/// One field of a light record: what it holds, starting at byte `offset` of the record.
46#[derive(Clone, Debug, PartialEq, Serialize)]
47pub struct LightField {
48    pub offset: u32,
49    pub value: LightValue,
50}
51
52/// What a field of a light record holds, from the light's `core.light`, its placement (`core.transform`: the light at
53/// its origin, a spot shining along its local -Z with its cone's horizontal and vertical axes its local X and Y) and the
54/// frame's view: "view space" is the space the frame's world-to-view matrix ([`super::ViewParameter::View`]) takes a
55/// world position to, and a view-space depth is a position's z there.
56#[derive(Clone, Debug, PartialEq, Serialize)]
57#[serde(rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum LightValue {
60    /// Three floats: the light's position in view space.
61    ViewPosition,
62    /// One float: the light's range, in world units (where its light stops).
63    Range,
64    /// One float: `1 / range^2`.
65    InverseRangeSquared,
66    /// One float: `1 / core.light_falloff.distance`, a source-stated normalization of distance, independent of
67    /// emitter radius and light range. A light with no stated falloff keeps zero and is named in `PackedLights::unknown`.
68    InverseFalloffDistance,
69    /// The light's source-stated transmission amplitude (`core.light_translucency.strength`).
70    TranslucencyStrength,
71    /// Multiplier of the source shader's angular exponent (`core.light_translucency.exponent_scale`).
72    TranslucencyExponentScale,
73    /// Three floats: the light's colour times its intensity over the world's [`LightUnit::candela_per_unit`], the value
74    /// the source stored.
75    Colour,
76    /// Three floats: the light's specular colour times its specular intensity over the world's
77    /// [`LightUnit::candela_per_unit`] ([`Light::specular`]); for a light that states no specular of its own, its colour
78    /// as [`LightValue::Colour`] packs it (counted: [`PackedLights::specular_from_colour`]).
79    SpecularColour,
80    /// Sixteen floats, four vectors of four at 16-byte steps: a spot's cone. The dot product of vector k with a view-space
81    /// position `(x, y, z, 1)` is component k of the position in the cone's space: components 0 and 1 are the position's
82    /// offsets along the cone's horizontal and vertical axes, each over the tangent of its axis's half-angle
83    /// ([`crate::SpotFrustum`]'s, else the outer cone's for both); component 3 is its distance `d` along the beam from
84    /// the light; component 2 is `(d - near) * range / (range - near)` for the frustum's near plane (`d` itself for a
85    /// light with none), above 0 past it. For a circular cone, components 0 and 1 over component 3 lie on the unit circle
86    /// at the cone's edge.
87    ViewToCone,
88    /// Four floats: a view-space sphere containing the light's volume, its centre then its radius. A point light's is
89    /// its range's sphere. A spot's volume is the cone of height `range` along the beam whose base reaches
90    /// `range * tan(outer)` from the beam (for a pyramid, [`crate::SpotFrustum`], `outer` is the angle of its base's
91    /// corners, `atan(sqrt(tan^2 h + tan^2 v))`): for an outer angle up to 45 degrees the smallest sphere through its apex
92    /// and its base's rim (radius `range / (2 cos^2(outer))`, centred that far along the beam), above that the base's own
93    /// sphere.
94    ViewBoundingSphere,
95    /// Four floats: the view-space sphere through a spot's apex and the rim of the circular cone of its vertical
96    /// half-angle `v` ([`crate::SpotFrustum::vertical_half_angle`], else its outer cone) at `range` along the beam:
97    /// centre `a = range / (2 cos^2 v)` along the beam, radius `a + margin`. It contains the light's volume only where the
98    /// horizontal half-angle is not the wider: a source whose light lists cull with such a sphere states it.
99    VerticalConeSphere { margin: f32 },
100    /// One float: the least view-space depth of the light's volume. A point light's is its position's less its range. A
101    /// spot's is over its apex and its base's corners, `range` along the beam and `range * tan` of each axis's half-angle
102    /// across its horizontal and vertical axes ([`crate::SpotFrustum`]'s, else the outer cone's for both).
103    ViewDepthMin,
104    /// One float: the greatest view-space depth of the light's volume, as [`LightValue::ViewDepthMin`].
105    ViewDepthMax,
106    /// One float: whether the consumer draws the light with a local shadow this frame ([`LocalShadows`]), 1 where it
107    /// does and 0 where it does not. A source's programs compare it as a float: a light-organize program lists the
108    /// light for the unshadowed tile pass only where it is 0, a shadowed program samples the light's shadow only where it
109    /// is above 0.
110    Shadowed,
111    /// `floats` floats: the depth projection terms of the light's shadow ([`LocalShadow::projection`]), 0 for a light
112    /// drawn without one.
113    ShadowProjection { floats: u8 },
114    /// Four floats: the point the light's shadow is rendered from ([`LocalShadow::centre`]), 0 for a light drawn without
115    /// one.
116    ShadowCentre,
117    /// `faces` groups of four floats: where each face of the light's shadow lies in the atlas ([`LocalShadow::faces`]),
118    /// 0 for a light drawn without one.
119    ShadowFaces { faces: u8 },
120    /// One float the source's programs take as fixed, and what it means to them.
121    Constant { value: f32, why: String },
122    /// `floats` floats the programs read that `core.light` does not give: the source's name for them, and why. A consumer
123    /// states the stand-in it writes ([`PackedLights::unknown`]: the packing writes 0).
124    Unknown { floats: u8, name: String, why: String },
125}
126
127impl LightValue {
128    /// The 32-bit floats the field takes.
129    pub fn floats(&self) -> u32 {
130        match self {
131            LightValue::ViewPosition | LightValue::Colour | LightValue::SpecularColour => 3,
132            LightValue::Range
133            | LightValue::InverseRangeSquared
134            | LightValue::InverseFalloffDistance
135            | LightValue::TranslucencyStrength
136            | LightValue::TranslucencyExponentScale
137            | LightValue::ViewDepthMin
138            | LightValue::ViewDepthMax
139            | LightValue::Shadowed
140            | LightValue::Constant { .. } => 1,
141            LightValue::ViewToCone => 16,
142            LightValue::ViewBoundingSphere
143            | LightValue::VerticalConeSphere { .. }
144            | LightValue::ShadowCentre => 4,
145            LightValue::ShadowProjection { floats } | LightValue::Unknown { floats, .. } => {
146                u32::from(*floats)
147            }
148            LightValue::ShadowFaces { faces } => 4 * u32::from(*faces),
149        }
150    }
151
152    /// Whether the field is part of a shadowed light's place in the atlas ([`LocalShadow`]).
153    fn is_shadow_placement(&self) -> bool {
154        matches!(
155            self,
156            LightValue::ShadowProjection { .. } | LightValue::ShadowCentre | LightValue::ShadowFaces { .. }
157        )
158    }
159}
160
161/// A light's shadow as the consumer draws it this frame, in the terms the source's shadowed programs read: its place in
162/// the consumer's local-light shadow atlas. The consumer that renders the atlas states every value; the packing derives
163/// none.
164#[derive(Clone, Debug, PartialEq, Serialize)]
165pub struct LocalShadow {
166    /// The depth projection terms of the shadow's faces, at least as many as the table's
167    /// [`LightValue::ShadowProjection`] takes.
168    pub projection: Vec<f32>,
169    /// The point the shadow is rendered from, in the frame the source's programs take a view-space position to with the
170    /// frame's view-to-world matrix, then 1.
171    pub centre: [f32; 4],
172    /// Each face's scale (x, y) and offset (z, w) in the atlas, at least as many as the table's
173    /// [`LightValue::ShadowFaces`] takes, in its order.
174    pub faces: Vec<[f32; 4]>,
175}
176
177/// The lights the consumer draws with a local shadow this frame, each with its shadow ([`LightTable::pack`]). Empty
178/// ([`LocalShadows::none`]): the consumer shadows no light, and every light packs unshadowed.
179#[derive(Clone, Debug, Default, PartialEq)]
180pub struct LocalShadows(pub BTreeMap<Entity, LocalShadow>);
181
182impl LocalShadows {
183    /// No light shadowed.
184    pub fn none() -> Self {
185        Self::default()
186    }
187}
188
189/// A world's lights packed into a [`LightTable`]'s records.
190#[derive(Clone, Debug, PartialEq)]
191pub struct PackedLights {
192    /// The records, `stride` bytes each, little-endian.
193    pub bytes: Vec<u8>,
194    /// The light of each record, in record order.
195    pub lights: Vec<Entity>,
196    /// Lights of the table's kind beyond its capacity, farthest from the view first, not packed.
197    pub left_out: usize,
198    /// The fields the packing wrote 0 into because `core.light` does not give them ([`LightValue::Unknown`]), by the
199    /// source's name.
200    pub unknown: Vec<String>,
201    /// Packed lights whose [`LightValue::SpecularColour`] fields took their colour, the light stating no specular of its
202    /// own (0 where the table has no such field).
203    pub specular_from_colour: usize,
204    /// The records packed with their light's shadow ([`LocalShadows`]), by index, in record order: the light lists take
205    /// them ([`pack_light_lists`]).
206    pub shadowed: Vec<u32>,
207    /// Packed lights that cast shadows (`core.light`'s `cast_shadows` is `Some(true)`) packed without one: the consumer
208    /// draws them unshadowed and reports them. Every such light where the consumer shadows none.
209    pub unshadowed_casters: Vec<Entity>,
210}
211
212type Vec3 = [f64; 3];
213
214/// A spot's shape in view space, as the packing takes it: its axes, the tangents of its half-angles, its vertical
215/// half-angle, the angle of its widest reach from the beam and its near plane.
216struct Cone {
217    right: Vec3,
218    up: Vec3,
219    beam: Vec3,
220    tan_horizontal: f64,
221    tan_vertical: f64,
222    vertical: f64,
223    widest: f64,
224    near: f64,
225}
226
227fn dot(a: Vec3, b: Vec3) -> f64 {
228    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
229}
230
231fn unit(v: Vec3) -> Result<Vec3, String> {
232    let n = dot(v, v).sqrt();
233    if n > 0.0 && n.is_finite() {
234        Ok(v.map(|x| x / n))
235    } else {
236        Err("a degenerate light axis".into())
237    }
238}
239
240impl LightTable {
241    /// Whether `light` is of the table's kind: a point light for a point table, a spot for a spot table.
242    pub fn holds(&self, light: &Light) -> bool {
243        self.kind.holds(light)
244    }
245
246    /// The table agrees with itself and with its buffer: a stride of whole floats that a structured buffer's elements
247    /// equal (`element_stride`, `None` for a raw or typed buffer), a capacity, each field whole floats inside the record and
248    /// no two overlapping, a spot's fields only in a spot table, a constant finite and every constant and unknown saying
249    /// why, and a shadow's place only in a table that says whether the light is shadowed ([`LightValue::Shadowed`]).
250    pub fn verify(&self, element_stride: Option<u32>) -> Result<(), String> {
251        if self.stride == 0 || !self.stride.is_multiple_of(4) {
252            return Err(format!(
253                "a light record of {} bytes, not whole floats",
254                self.stride
255            ));
256        }
257        if let Some(element) = element_stride
258            && element != self.stride
259        {
260            return Err(format!(
261                "light records of {} bytes in a buffer of {element}-byte elements",
262                self.stride
263            ));
264        }
265        if self.capacity == 0 {
266            return Err("a light table that holds no records".into());
267        }
268        let mut spans = Vec::with_capacity(self.fields.len());
269        for f in &self.fields {
270            let floats = f.value.floats();
271            let end = f.offset.checked_add(floats * 4);
272            if !f.offset.is_multiple_of(4) || floats == 0 || end.is_none_or(|end| end > self.stride) {
273                return Err(format!(
274                    "light field {:?} at {} ({floats} floats) is not inside the {}-byte record",
275                    f.value, f.offset, self.stride
276                ));
277            }
278            match &f.value {
279                LightValue::ViewToCone | LightValue::VerticalConeSphere { .. }
280                    if self.kind != LightTableKind::Spot =>
281                {
282                    return Err("a cone in a table of point lights".into());
283                }
284                LightValue::VerticalConeSphere { margin } if !(margin.is_finite() && *margin >= 0.0) => {
285                    return Err(format!(
286                        "a cone sphere's margin {margin} is not finite and non-negative"
287                    ));
288                }
289                LightValue::Constant { value, why } if !value.is_finite() || why.trim().is_empty() => {
290                    return Err(format!(
291                        "light constant {value} at {} is not finite or says not why",
292                        f.offset
293                    ));
294                }
295                LightValue::Unknown { name, why, .. } if name.trim().is_empty() || why.trim().is_empty() => {
296                    return Err(format!(
297                        "unknown light field at {} has no name or says not why",
298                        f.offset
299                    ));
300                }
301                _ => {}
302            }
303            if f.value.is_shadow_placement() && !self.reads_shadowed() {
304                return Err(format!(
305                    "light field {:?} at {} places a shadow in a table that does not say which lights are shadowed",
306                    f.value, f.offset
307                ));
308            }
309            spans.push((f.offset, f.offset + floats * 4));
310        }
311        spans.sort_unstable();
312        if let Some(pair) = spans.windows(2).find(|p| p[1].0 < p[0].1) {
313            return Err(format!(
314                "light fields overlap: bytes {}..{} and {}..{}",
315                pair[0].0, pair[0].1, pair[1].0, pair[1].1
316            ));
317        }
318        Ok(())
319    }
320
321    /// Whether the records say which lights are shadowed ([`LightValue::Shadowed`]).
322    pub fn reads_shadowed(&self) -> bool {
323        self.fields.iter().any(|f| f.value == LightValue::Shadowed)
324    }
325
326    /// The world's lights of the table's kind as its records, seen from `view` (the frame's world-to-view matrix, by rows:
327    /// view-space component r of a world position `p` is `view[r] . (p, 1)`; its rotation is taken as rigid). Each light
328    /// is a `core.light` of the kind with a `core.transform`, a range and, for a spot, an outer cone below 90 degrees; the
329    /// colours need the world's `core.light_unit`. A spot's `core.spot_frustum`, where it has one, is its shape: its
330    /// two half-angles and near plane take the place of the outer cone. Beyond `capacity` the lights nearest the view's
331    /// origin are kept.
332    ///
333    /// `shadows` are the lights the consumer draws with a local shadow this frame ([`LocalShadows::none`] where it
334    /// draws none): in a table that says which lights are shadowed, such a light's record takes [`LightValue::Shadowed`]
335    /// 1 and its shadow's place, every other record 0 and zeros. Refused: a shadow for a light whose `core.light` casts
336    /// none (`cast_shadows` `Some(false)`), and a shadow with fewer projection terms or faces than the record holds.
337    pub fn pack(
338        &self,
339        w: &World,
340        view: &[[f32; 4]; 4],
341        shadows: &LocalShadows,
342    ) -> Result<PackedLights, String> {
343        self.verify(None)?;
344        let unit_value = w.resource::<LightUnit>().map(|u| f64::from(u.candela_per_unit));
345        let row = |r: usize| view[r].map(f64::from);
346        let to_view = |p: Vec3| -> Vec3 {
347            [0, 1, 2].map(|r| {
348                let v = row(r);
349                v[0] * p[0] + v[1] * p[1] + v[2] * p[2] + v[3]
350            })
351        };
352        let turn = |d: Vec3| -> Vec3 {
353            [0, 1, 2].map(|r| {
354                let v = row(r);
355                v[0] * d[0] + v[1] * d[1] + v[2] * d[2]
356            })
357        };
358        // the lights of the kind, nearest the view first
359        let mut lights = Vec::new();
360        for (e, light) in w.iter::<Light>() {
361            if !self.holds(light) {
362                continue;
363            }
364            let t = w
365                .get::<Transform>(e)
366                .ok_or_else(|| format!("{e:?}: a light without a core.transform"))?;
367            let at = to_view(t.cols[3].map(f64::from));
368            lights.push((dot(at, at), e, light, t, at));
369        }
370        lights.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
371        let left_out = lights.len().saturating_sub(self.capacity as usize);
372        lights.truncate(self.capacity as usize);
373        let mut out = PackedLights {
374            bytes: vec![0; lights.len() * self.stride as usize],
375            lights: Vec::with_capacity(lights.len()),
376            left_out,
377            unknown: Vec::new(),
378            specular_from_colour: 0,
379            shadowed: Vec::new(),
380            unshadowed_casters: Vec::new(),
381        };
382        let reads_shadowed = self.reads_shadowed();
383        let reads_specular = self.fields.iter().any(|f| f.value == LightValue::SpecularColour);
384        for f in &self.fields {
385            if let LightValue::Unknown { name, .. } = &f.value {
386                out.unknown.push(name.clone());
387            }
388        }
389        let colour = |color: [f32; 3], intensity: f32| -> Result<Vec<f64>, String> {
390            let unit = unit_value
391                .ok_or("the world's lights have no core.light_unit: a light record's colour has no value")?;
392            Ok(color
393                .iter()
394                .map(|c| f64::from(*c) * f64::from(intensity) / unit)
395                .collect())
396        };
397        for (n, (_, e, light, t, at)) in lights.into_iter().enumerate() {
398            if reads_specular && light.specular.is_none() {
399                out.specular_from_colour += 1;
400            }
401            let shadow = shadows.0.get(&e).filter(|_| reads_shadowed);
402            if shadow.is_some() && light.cast_shadows == Some(false) {
403                return Err(format!("{e:?}: a shadow for a light that casts none"));
404            }
405            if shadow.is_some() {
406                out.shadowed.push(n as u32);
407            } else if light.cast_shadows == Some(true) {
408                out.unshadowed_casters.push(e);
409            }
410            let range = light
411                .range
412                .map(f64::from)
413                .filter(|r| r.is_finite() && *r > 0.0)
414                .ok_or_else(|| format!("{e:?}: a light with no range, which a light record holds"))?;
415            // a spot's axes in view space and its shape: the pyramid its source states, else its outer cone on both axes
416            let cone = match light.kind {
417                LightKind::Spot { outer_cone, .. } => {
418                    let frustum = w.get::<crate::SpotFrustum>(e);
419                    if let Some(f) = frustum {
420                        <crate::SpotFrustum as crate::Component>::verify(f, e, w)?;
421                    }
422                    let outer = f64::from(outer_cone);
423                    let (horizontal, vertical, near) = frustum.map_or((outer, outer, 0.0), |f| {
424                        (
425                            f64::from(f.horizontal_half_angle),
426                            f64::from(f.vertical_half_angle),
427                            f64::from(f.near),
428                        )
429                    });
430                    let (tan_horizontal, tan_vertical) = (horizontal.tan(), vertical.tan());
431                    for tan in [tan_horizontal, tan_vertical] {
432                        if !(tan.is_finite() && tan > 0.0) {
433                            return Err(format!(
434                                "{e:?}: a spot's cone {horizontal} by {vertical} has no tangent above 0"
435                            ));
436                        }
437                    }
438                    if near >= range {
439                        return Err(format!(
440                            "{e:?}: a spot's near plane {near} not before its range {range}"
441                        ));
442                    }
443                    let axis = |c: usize| unit(turn(t.cols[c].map(f64::from)));
444                    let beam = axis(2)?.map(|x| -x);
445                    Some(Cone {
446                        right: axis(0)?,
447                        up: axis(1)?,
448                        beam,
449                        tan_horizontal,
450                        tan_vertical,
451                        vertical,
452                        // the angle of the volume's widest reach from the beam: a pyramid's corners
453                        widest: match frustum {
454                            Some(_) => tan_horizontal.hypot(tan_vertical).atan(),
455                            None => outer,
456                        },
457                        near,
458                    })
459                }
460                _ => None,
461            };
462            let mut values: Vec<(u32, Vec<f64>)> = Vec::with_capacity(self.fields.len());
463            for f in &self.fields {
464                let v: Vec<f64> = match &f.value {
465                    LightValue::ViewPosition => at.to_vec(),
466                    LightValue::Range => vec![range],
467                    LightValue::InverseRangeSquared => vec![1.0 / (range * range)],
468                    LightValue::InverseFalloffDistance => match w.get::<crate::LightFalloff>(e) {
469                        Some(falloff) => vec![f64::from(
470                            falloff
471                                .inverse_distance()
472                                .map_err(|why| format!("{e:?}: {why}"))?,
473                        )],
474                        None => {
475                            let name = w
476                                .get::<crate::Name>(e)
477                                .map(|n| n.0.as_str())
478                                .or_else(|| w.get::<crate::Source>(e).map(|s| s.0.as_str()));
479                            out.unknown.push(format!("light {e:?}{}: source falloff distance not stated; inverse falloff distance supplied as zero",
480                                name.map(|n| format!(" ({n})")).unwrap_or_default()));
481                            vec![0.]
482                        }
483                    },
484                    LightValue::TranslucencyStrength | LightValue::TranslucencyExponentScale => {
485                        let source = w
486                            .get::<crate::LightTranslucency>(e)
487                            .ok_or_else(|| format!("{e:?}: light translucency parameters are not stated"))?;
488                        <crate::LightTranslucency as crate::Component>::verify(source, e, w)?;
489                        vec![f64::from(if f.value == LightValue::TranslucencyStrength {
490                            source.strength
491                        } else {
492                            source.exponent_scale
493                        })]
494                    }
495                    LightValue::Colour => colour(light.color, light.intensity)?,
496                    LightValue::SpecularColour => match &light.specular {
497                        Some(s) => colour(s.color, s.intensity)?,
498                        None => colour(light.color, light.intensity)?,
499                    },
500                    LightValue::ViewToCone => {
501                        let c = cone.as_ref().ok_or("a cone for a light that is no spot")?;
502                        // the near plane's row: (d - near) * range / (range - near), d itself where near is 0
503                        let depth = range / (range - c.near);
504                        let mut m = Vec::with_capacity(16);
505                        for (axis, scale, shift) in [
506                            (c.right, 1.0 / c.tan_horizontal, 0.0),
507                            (c.up, 1.0 / c.tan_vertical, 0.0),
508                            (c.beam, depth, c.near),
509                            (c.beam, 1.0, 0.0),
510                        ] {
511                            m.extend(axis.map(|x| x * scale));
512                            m.push((-dot(axis, at) - shift) * scale);
513                        }
514                        m
515                    }
516                    LightValue::ViewBoundingSphere => match &cone {
517                        None => vec![at[0], at[1], at[2], range],
518                        Some(c) => {
519                            let outer = c.widest;
520                            let radius = if outer <= std::f64::consts::FRAC_PI_4 {
521                                range / (2.0 * outer.cos().powi(2))
522                            } else {
523                                range * outer.tan()
524                            };
525                            let along = if outer <= std::f64::consts::FRAC_PI_4 {
526                                radius
527                            } else {
528                                range
529                            };
530                            vec![
531                                at[0] + c.beam[0] * along,
532                                at[1] + c.beam[1] * along,
533                                at[2] + c.beam[2] * along,
534                                radius,
535                            ]
536                        }
537                    },
538                    LightValue::VerticalConeSphere { margin } => {
539                        let c = cone.as_ref().ok_or("a cone sphere for a light that is no spot")?;
540                        let along = range / (2.0 * c.vertical.cos().powi(2));
541                        vec![
542                            at[0] + c.beam[0] * along,
543                            at[1] + c.beam[1] * along,
544                            at[2] + c.beam[2] * along,
545                            along + f64::from(*margin),
546                        ]
547                    }
548                    LightValue::ViewDepthMin | LightValue::ViewDepthMax => {
549                        let (least, most) = match &cone {
550                            None => (at[2] - range, at[2] + range),
551                            Some(c) => {
552                                let base = at[2] + c.beam[2] * range;
553                                let reach = range
554                                    * (c.tan_horizontal * c.right[2].abs() + c.tan_vertical * c.up[2].abs());
555                                (at[2].min(base - reach), at[2].max(base + reach))
556                            }
557                        };
558                        vec![if f.value == LightValue::ViewDepthMin {
559                            least
560                        } else {
561                            most
562                        }]
563                    }
564                    LightValue::Shadowed => vec![if shadow.is_some() { 1.0 } else { 0.0 }],
565                    LightValue::ShadowProjection { floats } => match shadow {
566                        Some(s) => s
567                            .projection
568                            .get(..usize::from(*floats))
569                            .ok_or_else(|| {
570                                format!("{e:?}: a shadow with fewer than {floats} projection terms")
571                            })?
572                            .iter()
573                            .map(|x| f64::from(*x))
574                            .collect(),
575                        None => vec![0.0; usize::from(*floats)],
576                    },
577                    LightValue::ShadowCentre => match shadow {
578                        Some(s) => s.centre.map(f64::from).to_vec(),
579                        None => vec![0.0; 4],
580                    },
581                    LightValue::ShadowFaces { faces } => match shadow {
582                        Some(s) => s
583                            .faces
584                            .get(..usize::from(*faces))
585                            .ok_or_else(|| format!("{e:?}: a shadow with fewer than {faces} faces"))?
586                            .iter()
587                            .flatten()
588                            .map(|x| f64::from(*x))
589                            .collect(),
590                        None => vec![0.0; 4 * usize::from(*faces)],
591                    },
592                    LightValue::Constant { value, .. } => vec![f64::from(*value)],
593                    LightValue::Unknown { floats, .. } => vec![0.0; usize::from(*floats)],
594                };
595                values.push((f.offset, v));
596            }
597            let record = &mut out.bytes[n * self.stride as usize..(n + 1) * self.stride as usize];
598            for (offset, v) in values {
599                for (k, x) in v.into_iter().enumerate() {
600                    let x = x as f32;
601                    if !x.is_finite() {
602                        return Err(format!("{e:?}: a light record value at {offset} is not finite"));
603                    }
604                    let byte = offset as usize + 4 * k;
605                    record[byte..byte + 4].copy_from_slice(&x.to_le_bytes());
606                }
607            }
608            out.lights.push(e);
609        }
610        Ok(out)
611    }
612}
613
614/// Where a value lies in a light list's record: `bits` bits from bit `shift` (0 the lowest) of the little-endian 32-bit
615/// unsigned word at byte `offset`.
616#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
617pub struct ListBits {
618    pub offset: u32,
619    pub shift: u8,
620    pub bits: u8,
621}
622
623impl ListBits {
624    /// The largest value the bits hold.
625    pub fn max(&self) -> u32 {
626        if self.bits >= 32 {
627            u32::MAX
628        } else {
629            (1u32 << self.bits) - 1
630        }
631    }
632
633    fn mask(&self) -> u32 {
634        self.max() << self.shift
635    }
636
637    fn verify(&self, stride: u32, what: &str) -> Result<(), String> {
638        let end = u32::from(self.shift) + u32::from(self.bits);
639        if self.bits == 0 || end > 32 || !self.offset.is_multiple_of(4) || self.offset + 4 > stride {
640            return Err(format!(
641                "{what}: bits {}..{end} of the word at {} are not inside a {stride}-byte record",
642                self.shift, self.offset
643            ));
644        }
645        Ok(())
646    }
647
648    fn write(&self, record: &mut [u8], value: u32) {
649        let at = self.offset as usize;
650        let mut word = u32::from_le_bytes([record[at], record[at + 1], record[at + 2], record[at + 3]]);
651        word = (word & !self.mask()) | ((value & self.max()) << self.shift);
652        record[at..at + 4].copy_from_slice(&word.to_le_bytes());
653    }
654}
655
656/// A value of a light list's record the packing writes as stated: what it means to the source's programs.
657#[derive(Clone, Debug, PartialEq, Serialize)]
658pub struct ListConstant {
659    pub bits: ListBits,
660    pub value: u32,
661    pub why: String,
662}
663
664/// Bits of a light list's record the source's programs read that the packing cannot fill: the source's name, and why.
665/// The packing writes 0.
666#[derive(Clone, Debug, PartialEq, Serialize)]
667pub struct ListUnknown {
668    pub bits: ListBits,
669    pub name: String,
670    pub why: String,
671}
672
673/// One of the two buffers of the frame's per-tile light lists ([`super::BufferSource::LightLists`]): which lights a
674/// program visits at a pixel. The screen is cut into squares of [`super::FrameParameter::LightTileSize`] pixels, tile
675/// `(x, y)` (from the top left, `x = pixel x / size`) being record `x + y * LightTilesPerRow` of the lookup; the
676/// lookup's record says where the tile's entries start in the index list and how many lights of each kind follow, the
677/// kinds one after the other in the lookup's `counts` order; each entry names a light's record in the kind's light table
678/// ([`super::BufferSource::Lights`]) and the depth slices of the tile its volume reaches.
679#[derive(Clone, Debug, PartialEq, Serialize)]
680#[serde(rename_all = "snake_case")]
681pub enum LightListPart {
682    Lookup(LightLookup),
683    Indices(LightIndices),
684}
685
686/// The lookup: one record of `stride` bytes per tile, at most `capacity`.
687#[derive(Clone, Debug, PartialEq, Serialize)]
688pub struct LightLookup {
689    pub stride: u32,
690    pub capacity: u32,
691    /// The tile's first entry in the index list.
692    pub first: ListBits,
693    /// How many lights of each kind the tile lists, in the order their entries follow each other.
694    pub counts: Vec<(LightTableKind, ListBits)>,
695    /// Additional fields counting a listed kind's entries whose [`LightIndices::tile_pass`] bit is set (its lights the
696    /// consumer does not shadow), without adding index entries; with no such bit, every entry.
697    #[serde(skip_serializing_if = "Vec::is_empty")]
698    pub count_copies: Vec<(LightTableKind, ListBits)>,
699    pub constants: Vec<ListConstant>,
700    pub unknown: Vec<ListUnknown>,
701}
702
703/// The index list: entries of `stride` bytes, at most `capacity`.
704#[derive(Clone, Debug, PartialEq, Serialize)]
705pub struct LightIndices {
706    pub stride: u32,
707    pub capacity: u32,
708    /// The light's record in its kind's table.
709    pub index: ListBits,
710    /// One bit per depth slice of the tile (bit k of the value is slice k): a program skips the entry at a pixel whose
711    /// slice's bit is clear.
712    pub slices: ListBits,
713    /// Set on the entry of a light the consumer does not shadow, clear on one it does ([`PackedLights::shadowed`]): the
714    /// source's unshadowed tile pass visits only entries with it set, a shadowed light being drawn by a shadowed pass of
715    /// its own. `None`: the source's entries have no such bit.
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub tile_pass: Option<ListBits>,
718    pub constants: Vec<ListConstant>,
719    pub unknown: Vec<ListUnknown>,
720}
721
722/// The frame's light lists packed: the lookup's records and the index list's entries, little-endian.
723#[derive(Clone, Debug, PartialEq)]
724pub struct PackedLightLists {
725    pub lookup: Vec<u8>,
726    pub indices: Vec<u8>,
727    /// The records' bits the packing wrote 0 into ([`ListUnknown`]), by the source's name.
728    pub unknown: Vec<String>,
729}
730
731/// Fields of a record: whole words inside it, no two overlapping, constants that fit and say why, unknowns named.
732fn verify_record(
733    stride: u32,
734    capacity: u32,
735    fields: &[(ListBits, &str)],
736    constants: &[ListConstant],
737    unknown: &[ListUnknown],
738    what: &str,
739) -> Result<(), String> {
740    if stride == 0 || !stride.is_multiple_of(4) {
741        return Err(format!(
742            "a light {what} record of {stride} bytes, not whole words"
743        ));
744    }
745    if capacity == 0 {
746        return Err(format!("a light {what} that holds no records"));
747    }
748    let mut all: Vec<(ListBits, String)> = fields.iter().map(|(b, n)| (*b, (*n).to_owned())).collect();
749    for c in constants {
750        if c.why.trim().is_empty() || c.value > c.bits.max() {
751            return Err(format!(
752                "light {what} constant {} does not fit its bits or says not why",
753                c.value
754            ));
755        }
756        all.push((c.bits, format!("constant {}", c.value)));
757    }
758    for u in unknown {
759        if u.name.trim().is_empty() || u.why.trim().is_empty() {
760            return Err(format!(
761                "an unknown light {what} field has no name or says not why"
762            ));
763        }
764        all.push((u.bits, u.name.clone()));
765    }
766    for (i, (b, name)) in all.iter().enumerate() {
767        b.verify(stride, &format!("light {what} {name}"))?;
768        if let Some((_, other)) = all[..i]
769            .iter()
770            .find(|(o, _)| o.offset == b.offset && o.mask() & b.mask() != 0)
771        {
772            return Err(format!("light {what} fields {other} and {name} overlap"));
773        }
774    }
775    Ok(())
776}
777
778impl LightListPart {
779    /// The part agrees with itself and its buffer (`element_stride` the structured buffer's element size, `None` for
780    /// another kind of buffer): whole-word records of a capacity, every field inside one, none overlapping, the lookup
781    /// counting each kind once.
782    pub fn verify(&self, element_stride: Option<u32>) -> Result<(), String> {
783        let stride = match self {
784            LightListPart::Lookup(l) => {
785                let mut fields = vec![(l.first, "first entry")];
786                let mut kinds = Vec::new();
787                for (kind, bits) in &l.counts {
788                    if kinds.contains(kind) {
789                        return Err(format!("a light lookup that counts {kind:?} lights twice"));
790                    }
791                    kinds.push(*kind);
792                    fields.push((*bits, "count"));
793                }
794                if l.counts.is_empty() {
795                    return Err("a light lookup that counts no lights".into());
796                }
797                for (kind, bits) in &l.count_copies {
798                    if !kinds.contains(kind) {
799                        return Err(format!("a light lookup copies an unlisted {kind:?} count"));
800                    }
801                    fields.push((*bits, "count copy"));
802                }
803                verify_record(l.stride, l.capacity, &fields, &l.constants, &l.unknown, "lookup")?;
804                l.stride
805            }
806            LightListPart::Indices(x) => {
807                let mut fields = vec![(x.index, "index"), (x.slices, "slices")];
808                if let Some(bits) = x.tile_pass {
809                    fields.push((bits, "tile pass"));
810                }
811                verify_record(
812                    x.stride,
813                    x.capacity,
814                    &fields,
815                    &x.constants,
816                    &x.unknown,
817                    "index list",
818                )?;
819                x.stride
820            }
821        };
822        match element_stride {
823            Some(element) if element != stride => Err(format!(
824                "light list records of {stride} bytes in a buffer of {element}-byte elements"
825            )),
826            _ => Ok(()),
827        }
828    }
829}
830
831/// The frame's light lists listing every light in every tile and every depth slice: each tile's record starts at entry
832/// 0 and counts every packed light of each kind (`counts`: the number of records the consumer packed in each kind's
833/// table), and the index list holds each kind's records in order, every slice bit set. A light's own record still
834/// bounds what it lights (its range), so a program shades as the source's culled lists would, visiting more entries.
835/// `shadowed` are the records the consumer packed with their shadow, by kind ([`PackedLights::shadowed`]; none where
836/// it shadows none): their entries' [`LightIndices::tile_pass`] bit is clear and the lookup's count copies leave them
837/// out, every other entry's set. `tiles` is how many tiles the frame has (tiles per row times rows). Refused: a part
838/// that does not verify, more tiles or entries than the buffers hold, a count or index its bits cannot hold, and a
839/// shadowed record the counts do not hold or lists with no tile-pass bit to mark it.
840pub fn pack_light_lists(
841    lookup: &LightLookup,
842    indices: &LightIndices,
843    counts: &[(LightTableKind, u32)],
844    shadowed: &[(LightTableKind, u32)],
845    tiles: u32,
846) -> Result<PackedLightLists, String> {
847    LightListPart::Lookup(lookup.clone()).verify(None)?;
848    LightListPart::Indices(indices.clone()).verify(None)?;
849    if tiles == 0 || tiles > lookup.capacity {
850        return Err(format!(
851            "{tiles} tiles for a light lookup of {} records",
852            lookup.capacity
853        ));
854    }
855    let count_of =
856        |kind: LightTableKind| -> u32 { counts.iter().filter(|(k, _)| *k == kind).map(|(_, n)| *n).sum() };
857    let mut marked: Vec<(LightTableKind, u32)> = shadowed.to_vec();
858    marked.sort_unstable();
859    marked.dedup();
860    for (kind, index) in &marked {
861        if *index >= count_of(*kind) {
862            return Err(format!(
863                "shadowed {kind:?} record {index} is not among the {} the lists count",
864                count_of(*kind)
865            ));
866        }
867    }
868    if !marked.is_empty() && indices.tile_pass.is_none() {
869        return Err("the light lists have no tile-pass bit to leave a shadowed light out with".into());
870    }
871    let shadowed_of =
872        |kind: LightTableKind| -> u32 { marked.iter().filter(|(k, _)| *k == kind).count() as u32 };
873    let mut entries = Vec::new();
874    for (kind, bits) in &lookup.counts {
875        let n = count_of(*kind);
876        if n > bits.max() || n.saturating_sub(1) > indices.index.max() {
877            return Err(format!("{n} {kind:?} lights do not fit the light lists' bits"));
878        }
879        entries.extend((0..n).map(|i| (*kind, i)));
880    }
881    for (kind, bits) in &lookup.count_copies {
882        let n = count_of(*kind);
883        if n > bits.max() {
884            return Err(format!(
885                "{n} {kind:?} lights do not fit the lookup's copied count bits"
886            ));
887        }
888    }
889    if entries.len() as u64 > u64::from(indices.capacity) {
890        return Err(format!(
891            "{} light list entries for an index list of {}",
892            entries.len(),
893            indices.capacity
894        ));
895    }
896    let mut out = PackedLightLists {
897        lookup: vec![0; tiles as usize * lookup.stride as usize],
898        indices: vec![0; entries.len() * indices.stride as usize],
899        unknown: lookup
900            .unknown
901            .iter()
902            .chain(&indices.unknown)
903            .map(|u| u.name.clone())
904            .collect(),
905    };
906    for record in out.lookup.chunks_exact_mut(lookup.stride as usize) {
907        lookup.first.write(record, 0);
908        for (kind, bits) in &lookup.counts {
909            bits.write(record, count_of(*kind));
910        }
911        for (kind, bits) in &lookup.count_copies {
912            bits.write(record, count_of(*kind) - shadowed_of(*kind));
913        }
914        for c in &lookup.constants {
915            c.bits.write(record, c.value);
916        }
917    }
918    for (record, (kind, index)) in out.indices.chunks_exact_mut(indices.stride as usize).zip(entries) {
919        indices.index.write(record, index);
920        indices.slices.write(record, indices.slices.max());
921        if let Some(bits) = indices.tile_pass {
922            bits.write(record, u32::from(!marked.contains(&(kind, index))));
923        }
924        for c in &indices.constants {
925            c.bits.write(record, c.value);
926        }
927    }
928    Ok(out)
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::World;
935    use crate::components::LightSpecular;
936
937    fn floats(bytes: &[u8]) -> Vec<f32> {
938        bytes
939            .as_chunks::<4>()
940            .0
941            .iter()
942            .map(|b| f32::from_le_bytes(*b))
943            .collect()
944    }
945
946    fn close(got: f32, want: f64, what: &str) {
947        assert!(
948            (f64::from(got) - want).abs() <= 1e-5 * want.abs().max(1.0),
949            "{what}: {got}, worked by hand {want}"
950        );
951    }
952
953    /// The view: world x stays x, world y stays y, world z becomes -z, then 10 is added to z (a camera at world z 10
954    /// looking down -z).
955    const VIEW: [[f32; 4]; 4] = [
956        [1.0, 0.0, 0.0, 0.0],
957        [0.0, 1.0, 0.0, 0.0],
958        [0.0, 0.0, -1.0, 10.0],
959        [0.0, 0.0, 0.0, 1.0],
960    ];
961
962    fn table(kind: LightTableKind, stride: u32, fields: Vec<(u32, LightValue)>) -> LightTable {
963        LightTable {
964            kind,
965            stride,
966            capacity: 4,
967            fields: fields
968                .into_iter()
969                .map(|(offset, value)| LightField { offset, value })
970                .collect(),
971        }
972    }
973
974    /// Independent source parameters survive packing; no component keeps a named zero stand-in, not a guessed size.
975    #[test]
976    fn source_falloff_and_translucency_pack_without_changing_range_or_radius() {
977        let mut w = World::new();
978        let e = w.spawn();
979        w.insert(e, crate::Name("measured light".into()));
980        w.insert(
981            e,
982            Transform {
983                cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0.; 3]],
984            },
985        );
986        w.insert(
987            e,
988            Light {
989                kind: LightKind::Point,
990                color: [1.; 3],
991                intensity: 1.,
992                range: Some(20.),
993                radius: Some(3.),
994                cast_shadows: None,
995                specular: None,
996            },
997        );
998        w.insert(
999            e,
1000            crate::LightTranslucency {
1001                strength: 0.25,
1002                exponent_scale: 0.01,
1003            },
1004        );
1005        let t = table(
1006            LightTableKind::Point,
1007            16,
1008            vec![
1009                (0, LightValue::InverseFalloffDistance),
1010                (4, LightValue::TranslucencyStrength),
1011                (8, LightValue::TranslucencyExponentScale),
1012                (12, LightValue::Range),
1013            ],
1014        );
1015        let missing = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
1016        assert_eq!(floats(&missing.bytes), vec![0., 0.25, 0.01, 20.]);
1017        assert_eq!(missing.unknown.len(), 1);
1018        assert!(missing.unknown[0].contains("measured light"));
1019        for distance in [8., 1.5, 0.5] {
1020            w.insert(e, crate::LightFalloff { distance });
1021            let packed = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
1022            assert!(packed.unknown.is_empty());
1023            assert_eq!(floats(&packed.bytes), vec![distance.recip(), 0.25, 0.01, 20.]);
1024            assert_eq!(w.get::<Light>(e).unwrap().radius, Some(3.));
1025        }
1026        for distance in [0., -1., f32::NAN, f32::INFINITY, f32::from_bits(1)] {
1027            w.insert(e, crate::LightFalloff { distance });
1028            assert!(
1029                t.pack(&w, &VIEW, &LocalShadows::none())
1030                    .unwrap_err()
1031                    .contains("falloff distance")
1032            );
1033        }
1034    }
1035
1036    /// A table that says which lights are shadowed, with a shadow's place: three point lights at view depth 7, 8 and 9,
1037    /// casting shadows (the first two) or not saying (the third). Shadowing none, every record holds 0 and zeros and both
1038    /// casters are reported unshadowed; shadowing the second, its record holds 1 and its shadow's stated terms, centre and
1039    /// faces, and only the first is reported. Refused: a shadow for a light that casts none, a shadow with fewer faces than
1040    /// the record holds, a shadow's place in a table that does not say which lights are shadowed.
1041    #[test]
1042    fn a_shadowed_light_packs_its_flag_and_its_place() {
1043        let mut w = World::new();
1044        w.insert_resource(LightUnit {
1045            candela_per_unit: 1.0,
1046        });
1047        let place = |w: &mut World, z: f32, cast_shadows: Option<bool>| {
1048            let e = w.spawn();
1049            w.insert(
1050                e,
1051                Transform {
1052                    cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., z]],
1053                },
1054            );
1055            w.insert(
1056                e,
1057                Light {
1058                    kind: LightKind::Point,
1059                    color: [1.0; 3],
1060                    intensity: 1.0,
1061                    range: Some(4.0),
1062                    cast_shadows,
1063                    radius: None,
1064                    specular: None,
1065                },
1066            );
1067            e
1068        };
1069        let first = place(&mut w, 3., Some(true));
1070        let second = place(&mut w, 2., Some(true));
1071        let quiet = place(&mut w, 1., None);
1072        let t = table(
1073            LightTableKind::Point,
1074            72,
1075            vec![
1076                (0, LightValue::ViewPosition),
1077                (12, LightValue::Shadowed),
1078                (16, LightValue::ShadowProjection { floats: 2 }),
1079                (24, LightValue::ShadowCentre),
1080                (40, LightValue::ShadowFaces { faces: 2 }),
1081            ],
1082        );
1083        assert_eq!(t.verify(None), Ok(()));
1084        let record = |packed: &PackedLights, e: Entity| -> Vec<f32> {
1085            let i = packed.lights.iter().position(|l| *l == e).unwrap();
1086            floats(&packed.bytes[72 * i..72 * (i + 1)])
1087        };
1088        let none = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
1089        assert_eq!(none.lights, vec![first, second, quiet]);
1090        assert!(none.shadowed.is_empty());
1091        assert_eq!(none.unshadowed_casters, vec![first, second]);
1092        for e in [first, second, quiet] {
1093            assert!(record(&none, e)[3..].iter().all(|x| *x == 0.0));
1094        }
1095        let shadow = LocalShadow {
1096            projection: vec![1.001, -0.008, 7.0],
1097            centre: [-34.25, 8.0, -0.5, 1.0],
1098            faces: vec![[0.25, -0.25, 0.5, 0.5], [0.25, -0.25, 1.5, 0.5]],
1099        };
1100        let shadows = LocalShadows([(second, shadow.clone())].into());
1101        let one = t.pack(&w, &VIEW, &shadows).unwrap();
1102        assert_eq!(one.shadowed, vec![1]);
1103        assert_eq!(one.unshadowed_casters, vec![first]);
1104        assert_eq!(
1105            record(&one, second)[3..],
1106            [
1107                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
1108            ]
1109        );
1110        assert!(record(&one, first)[3..].iter().all(|x| *x == 0.0));
1111
1112        w.get_mut::<Light>(second).unwrap().cast_shadows = Some(false);
1113        assert!(t.pack(&w, &VIEW, &shadows).unwrap_err().contains("casts none"));
1114        w.get_mut::<Light>(second).unwrap().cast_shadows = Some(true);
1115        let mut short = shadow;
1116        short.faces.pop();
1117        let refused = t.pack(&w, &VIEW, &LocalShadows([(second, short)].into()));
1118        assert!(refused.unwrap_err().contains("fewer than 2 faces"));
1119        let unsaid = table(LightTableKind::Point, 16, vec![(0, LightValue::ShadowCentre)]);
1120        assert!(unsaid.verify(None).unwrap_err().contains("does not say"));
1121    }
1122
1123    /// A point light at world (1, 2, 3), colour (0.5, 0.25, 1), intensity 8, range 4, in a world whose unit is 2 candela:
1124    /// view position (1, 2, 7), colour (2, 1, 4), 1 / 16, range 4, a constant 0 and an unknown pair written 0; a second
1125    /// point light farther away is left out by a capacity of 1.
1126    #[test]
1127    fn a_point_light_packs_into_its_record() {
1128        let mut w = World::new();
1129        let place = |w: &mut World, at: [f32; 3]| {
1130            let e = w.spawn();
1131            w.insert(
1132                e,
1133                Transform {
1134                    cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], at],
1135                },
1136            );
1137            w.insert(
1138                e,
1139                Light {
1140                    kind: LightKind::Point,
1141                    color: [0.5, 0.25, 1.0],
1142                    intensity: 8.0,
1143                    range: Some(4.0),
1144                    cast_shadows: None,
1145                    radius: None,
1146                    specular: None,
1147                },
1148            );
1149            e
1150        };
1151        let near = place(&mut w, [1., 2., 3.]);
1152        place(&mut w, [1., 2., -30.]);
1153        let mut t = table(
1154            LightTableKind::Point,
1155            48,
1156            vec![
1157                (0, LightValue::ViewPosition),
1158                (12, LightValue::InverseRangeSquared),
1159                (16, LightValue::Colour),
1160                (
1161                    28,
1162                    LightValue::Unknown {
1163                        floats: 2,
1164                        name: "Wrap".into(),
1165                        why: "test".into(),
1166                    },
1167                ),
1168                (36, LightValue::Range),
1169                (
1170                    40,
1171                    LightValue::Constant {
1172                        value: 0.0,
1173                        why: "test".into(),
1174                    },
1175                ),
1176            ],
1177        );
1178        // no unit: the colour has no value
1179        assert!(t.pack(&w, &VIEW, &LocalShadows::none()).is_err());
1180        w.insert_resource(LightUnit {
1181            candela_per_unit: 2.0,
1182        });
1183        t.capacity = 1;
1184        let packed = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
1185        assert_eq!((packed.lights.clone(), packed.left_out), (vec![near], 1));
1186        assert_eq!(packed.unknown, vec!["Wrap".to_owned()]);
1187        let f = floats(&packed.bytes);
1188        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];
1189        for (i, v) in want.into_iter().enumerate() {
1190            close(f[i], v, &format!("float {i}"));
1191        }
1192        assert_eq!(f.len(), 12);
1193        assert_eq!(f[11], 0.0, "a byte no field covers is zero");
1194    }
1195
1196    /// Two point lights of colour (0.5, 0.25, 1) and intensity 8 in a world whose unit is 2 candela: one with its own
1197    /// specular, colour (1, 0.5, 0) at intensity 4, packs specular (2, 1, 0); the other, which states none, packs its
1198    /// colour (2, 1, 4) there, and is the one counted. Both pack colour (2, 1, 4).
1199    #[test]
1200    fn a_light_packs_its_own_specular_and_one_without_packs_its_colour() {
1201        let mut w = World::new();
1202        w.insert_resource(LightUnit {
1203            candela_per_unit: 2.0,
1204        });
1205        let place = |w: &mut World, z: f32, specular: Option<LightSpecular>| {
1206            let e = w.spawn();
1207            w.insert(
1208                e,
1209                Transform {
1210                    cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., z]],
1211                },
1212            );
1213            w.insert(
1214                e,
1215                Light {
1216                    kind: LightKind::Point,
1217                    color: [0.5, 0.25, 1.0],
1218                    intensity: 8.0,
1219                    range: Some(4.0),
1220                    cast_shadows: None,
1221                    radius: None,
1222                    specular,
1223                },
1224            );
1225            e
1226        };
1227        let own = place(
1228            &mut w,
1229            0.0,
1230            Some(LightSpecular {
1231                color: [1.0, 0.5, 0.0],
1232                intensity: 4.0,
1233            }),
1234        );
1235        let plain = place(&mut w, -5.0, None);
1236        let t = table(
1237            LightTableKind::Point,
1238            24,
1239            vec![(0, LightValue::Colour), (12, LightValue::SpecularColour)],
1240        );
1241        let packed = t.pack(&w, &VIEW, &LocalShadows::none()).unwrap();
1242        assert_eq!(packed.lights, vec![own, plain]);
1243        assert_eq!(packed.specular_from_colour, 1);
1244        let f = floats(&packed.bytes);
1245        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];
1246        for (i, v) in want.into_iter().enumerate() {
1247            close(f[i], v, &format!("float {i}"));
1248        }
1249        // a table with no specular field counts none
1250        let colour_only = table(LightTableKind::Point, 12, vec![(0, LightValue::Colour)]);
1251        assert_eq!(
1252            colour_only
1253                .pack(&w, &VIEW, &LocalShadows::none())
1254                .unwrap()
1255                .specular_from_colour,
1256            0
1257        );
1258    }
1259
1260    /// A spot at world (0, 0, 0) with an identity placement, so its beam is world -Z (view +Z, from the camera at
1261    /// view depth 10), outer cone 30 degrees, range 6: the cone's vectors are worked below, axis by axis; its
1262    /// sphere for an outer angle of 30 degrees has radius 6 / (2 cos^2 30) = 4 and centre 4 along the beam (view depth
1263    /// 14); its depth range runs from the apex (10) to the base (16) plus 6 tan 30 (|x.z| + |y.z| = 0, so 16).
1264    #[test]
1265    fn a_spot_packs_its_cone_sphere_and_depths() {
1266        let mut w = World::new();
1267        w.insert_resource(LightUnit {
1268            candela_per_unit: 1.0,
1269        });
1270        let e = w.spawn();
1271        w.insert(
1272            e,
1273            Transform {
1274                cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., 0.]],
1275            },
1276        );
1277        w.insert(
1278            e,
1279            Light {
1280                kind: LightKind::Spot {
1281                    inner_cone: 0.4,
1282                    outer_cone: std::f32::consts::FRAC_PI_6,
1283                },
1284                color: [1.0, 1.0, 1.0],
1285                intensity: 1.0,
1286                range: Some(6.0),
1287                cast_shadows: None,
1288                radius: None,
1289                specular: None,
1290            },
1291        );
1292        let t = table(
1293            LightTableKind::Spot,
1294            112,
1295            vec![
1296                (0, LightValue::ViewToCone),
1297                (64, LightValue::ViewBoundingSphere),
1298                (80, LightValue::ViewDepthMin),
1299                (84, LightValue::ViewDepthMax),
1300            ],
1301        );
1302        let f = floats(&t.pack(&w, &VIEW, &LocalShadows::none()).unwrap().bytes);
1303        let cot = 1.0 / (std::f64::consts::FRAC_PI_6).tan();
1304        // view axes: right (1, 0, 0), up (0, 1, 0), beam: world -Z turned by the view is (0, 0, 1); apex at (0, 0, 10)
1305        let want = [
1306            cot, 0.0, 0.0, 0.0, // right / tan, less right . apex (0)
1307            0.0, cot, 0.0, 0.0, // up / tan
1308            0.0, 0.0, 1.0, -10.0, // beam, less beam . apex
1309            0.0, 0.0, 1.0, -10.0, // beam again: no near plane
1310            0.0, 0.0, 14.0, 4.0, // sphere
1311            10.0, 16.0, // depths
1312        ];
1313        for (i, v) in want.into_iter().enumerate() {
1314            close(f[i], v, &format!("float {i}"));
1315        }
1316        // a position on the cone's edge, 3 along the beam and 3 tan 30 across: (x / w)^2 + (y / w)^2 = 1
1317        let p = [3.0 * (std::f64::consts::FRAC_PI_6).tan(), 0.0, 13.0, 1.0];
1318        let row = |r: usize| (0..4).map(|k| f64::from(f[4 * r + k]) * p[k]).sum::<f64>();
1319        close((row(0) / row(3)) as f32, 1.0, "the cone's edge");
1320    }
1321
1322    /// The same spot shaped by a `core.spot_frustum`: half-angles 45 degrees across (tan 1) and 30 up (tan 1 / sqrt 3),
1323    /// near plane 2, range 6. Each axis's row is over its own tangent; the near plane's row is
1324    /// `(d - 2) * 6 / (6 - 2)`; the bounding sphere is the base's own (the corners' angle, `atan(sqrt(1 + 1 / 3))`, is
1325    /// above 45 degrees: radius 6 sqrt(4 / 3), centred at the base); the vertical cone's sphere is the 30-degree cone's,
1326    /// radius 6 / (2 cos^2 30) = 4 and the margin, centred 4 along the beam; the depths are the apex's and the base's, as
1327    /// both axes lie across the view.
1328    #[test]
1329    fn a_spot_frustum_shapes_its_cone_near_plane_and_spheres() {
1330        let mut w = World::new();
1331        w.insert_resource(LightUnit {
1332            candela_per_unit: 1.0,
1333        });
1334        let e = w.spawn();
1335        w.insert(
1336            e,
1337            Transform {
1338                cols: [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 0., 0.]],
1339            },
1340        );
1341        let spot = Light {
1342            kind: LightKind::Spot {
1343                inner_cone: 0.3,
1344                outer_cone: 0.6,
1345            },
1346            color: [1.0, 1.0, 1.0],
1347            intensity: 1.0,
1348            range: Some(6.0),
1349            cast_shadows: None,
1350            radius: None,
1351            specular: None,
1352        };
1353        w.insert(e, spot.clone());
1354        let frustum = crate::SpotFrustum {
1355            horizontal_half_angle: std::f32::consts::FRAC_PI_4,
1356            vertical_half_angle: std::f32::consts::FRAC_PI_6,
1357            near: 2.0,
1358        };
1359        w.insert(e, frustum);
1360        let t = table(
1361            LightTableKind::Spot,
1362            128,
1363            vec![
1364                (0, LightValue::ViewToCone),
1365                (64, LightValue::ViewBoundingSphere),
1366                (80, LightValue::VerticalConeSphere { margin: 0.001 }),
1367                (96, LightValue::ViewDepthMin),
1368                (100, LightValue::ViewDepthMax),
1369            ],
1370        );
1371        let f = floats(&t.pack(&w, &VIEW, &LocalShadows::none()).unwrap().bytes);
1372        let corners = (4.0f64 / 3.0).sqrt();
1373        let want = [
1374            1.0,
1375            0.0,
1376            0.0,
1377            0.0, // right / tan 45
1378            0.0,
1379            3f64.sqrt(),
1380            0.0,
1381            0.0, // up / tan 30
1382            0.0,
1383            0.0,
1384            1.5,
1385            -18.0, // the near plane: (beam . p - beam . apex - 2) * 6 / 4
1386            0.0,
1387            0.0,
1388            1.0,
1389            -10.0, // beam, less beam . apex
1390            0.0,
1391            0.0,
1392            16.0,
1393            6.0 * corners, // the base's sphere
1394            0.0,
1395            0.0,
1396            14.0,
1397            4.001, // the vertical cone's sphere and its margin
1398            10.0,
1399            16.0, // depths
1400        ];
1401        for (i, v) in want.into_iter().enumerate() {
1402            close(f[i], v, &format!("float {i}"));
1403        }
1404        // the near plane: a position 1 along the beam is before it, 3 along is past it
1405        let row = |r: usize, d: f64| {
1406            (0..4)
1407                .map(|k| f64::from(f[4 * r + k]) * [0.0, 0.0, 10.0 + d, 1.0][k])
1408                .sum::<f64>()
1409        };
1410        assert!(row(2, 1.0) < 0.0 && row(2, 3.0) > 0.0);
1411
1412        // refused: a frustum whose near plane is not before the range, a half-angle of 90 degrees, a frustum on a light
1413        // that is no spot, and a cone sphere in a table of point lights
1414        for bad in [
1415            crate::SpotFrustum { near: 6.0, ..frustum },
1416            crate::SpotFrustum {
1417                vertical_half_angle: std::f32::consts::FRAC_PI_2,
1418                ..frustum
1419            },
1420        ] {
1421            w.insert(e, bad);
1422            assert!(t.pack(&w, &VIEW, &LocalShadows::none()).is_err(), "{bad:?}");
1423        }
1424        w.insert(e, frustum);
1425        w.insert(
1426            e,
1427            Light {
1428                kind: LightKind::Point,
1429                ..spot
1430            },
1431        );
1432        assert!(<crate::SpotFrustum as crate::Component>::verify(&frustum, e, &w).is_err());
1433        let points = table(
1434            LightTableKind::Point,
1435            16,
1436            vec![(0, LightValue::VerticalConeSphere { margin: 0.0 })],
1437        );
1438        assert!(points.verify(None).is_err());
1439    }
1440
1441    /// A table refuses a record of no whole floats, a buffer of another stride, fields outside the record or over one
1442    /// another, a cone for point lights and a constant or unknown that says not why.
1443    #[test]
1444    fn a_light_table_agrees_with_itself() {
1445        let ok = table(LightTableKind::Point, 16, vec![(0, LightValue::ViewPosition)]);
1446        assert_eq!(ok.verify(Some(16)), Ok(()));
1447        assert!(ok.verify(Some(32)).is_err());
1448        assert!(table(LightTableKind::Point, 14, vec![]).verify(None).is_err());
1449        assert!(
1450            table(LightTableKind::Point, 16, vec![(8, LightValue::ViewPosition)])
1451                .verify(None)
1452                .is_err()
1453        );
1454        assert!(
1455            table(
1456                LightTableKind::Point,
1457                16,
1458                vec![(0, LightValue::ViewPosition), (8, LightValue::Range)]
1459            )
1460            .verify(None)
1461            .is_err()
1462        );
1463        assert!(
1464            table(LightTableKind::Point, 64, vec![(0, LightValue::ViewToCone)])
1465                .verify(None)
1466                .is_err()
1467        );
1468        assert!(
1469            table(
1470                LightTableKind::Point,
1471                16,
1472                vec![(
1473                    0,
1474                    LightValue::Constant {
1475                        value: 0.0,
1476                        why: " ".into()
1477                    }
1478                )]
1479            )
1480            .verify(None)
1481            .is_err()
1482        );
1483    }
1484
1485    fn bits(offset: u32, shift: u8, bits: u8) -> ListBits {
1486        ListBits { offset, shift, bits }
1487    }
1488
1489    /// A lookup of 12-byte records (the first entry in word 0, the point and spot counts in the low and high halves of
1490    /// word 1, word 2 unknown) and an index list of 4-byte entries (the index in the low 16 bits, 15 slice bits from bit
1491    /// 16, bit 31 a flag the source's programs require set).
1492    fn lists() -> (LightLookup, LightIndices) {
1493        (
1494            LightLookup {
1495                stride: 12,
1496                capacity: 4,
1497                first: bits(0, 0, 32),
1498                counts: vec![
1499                    (LightTableKind::Point, bits(4, 0, 16)),
1500                    (LightTableKind::Spot, bits(4, 16, 16)),
1501                ],
1502                count_copies: Vec::new(),
1503                constants: Vec::new(),
1504                unknown: vec![ListUnknown {
1505                    bits: bits(8, 0, 32),
1506                    name: "word 2".into(),
1507                    why: "read only by a debug view".into(),
1508                }],
1509            },
1510            LightIndices {
1511                stride: 4,
1512                capacity: 8,
1513                index: bits(0, 0, 16),
1514                slices: bits(0, 16, 15),
1515                tile_pass: Some(bits(0, 31, 1)),
1516                constants: Vec::new(),
1517                unknown: Vec::new(),
1518            },
1519        )
1520    }
1521
1522    fn words(bytes: &[u8]) -> Vec<u32> {
1523        bytes
1524            .as_chunks::<4>()
1525            .0
1526            .iter()
1527            .map(|w| u32::from_le_bytes(*w))
1528            .collect()
1529    }
1530
1531    /// Two point lights and a spot in three tiles: every tile starts at entry 0 and counts 2 points and 1 spot; the
1532    /// entries are the points' records 0 and 1, then the spot's record 0, each with every slice bit and the flag set.
1533    #[test]
1534    fn every_light_is_listed_in_every_tile_and_slice() {
1535        let (lookup, indices) = lists();
1536        let packed = pack_light_lists(
1537            &lookup,
1538            &indices,
1539            &[(LightTableKind::Spot, 1), (LightTableKind::Point, 2)],
1540            &[],
1541            3,
1542        )
1543        .unwrap();
1544        assert_eq!(
1545            words(&packed.lookup),
1546            [0, 0x0001_0002, 0, 0, 0x0001_0002, 0, 0, 0x0001_0002, 0]
1547        );
1548        assert_eq!(words(&packed.indices), [0xffff_0000, 0xffff_0001, 0xffff_0000]);
1549        assert_eq!(packed.unknown, ["word 2"]);
1550    }
1551
1552    /// A second eligible-light count lets a shader reject an empty tile before walking the main counts. Listing all
1553    /// lights makes both counts equal; copying them must not duplicate the three entries.
1554    #[test]
1555    fn copied_counts_keep_nonempty_tiles_and_do_not_duplicate_entries() {
1556        let (mut lookup, indices) = lists();
1557        lookup.unknown.clear();
1558        lookup.count_copies = vec![
1559            (LightTableKind::Point, bits(8, 0, 16)),
1560            (LightTableKind::Spot, bits(8, 16, 16)),
1561        ];
1562        let packed = pack_light_lists(
1563            &lookup,
1564            &indices,
1565            &[(LightTableKind::Point, 2), (LightTableKind::Spot, 1)],
1566            &[],
1567            2,
1568        )
1569        .unwrap();
1570        assert_eq!(
1571            words(&packed.lookup),
1572            [0, 0x0001_0002, 0x0001_0002, 0, 0x0001_0002, 0x0001_0002]
1573        );
1574        assert_eq!(words(&packed.indices), [0xffff_0000, 0xffff_0001, 0xffff_0000]);
1575        assert!(packed.unknown.is_empty());
1576        let empty = pack_light_lists(&lookup, &indices, &[], &[], 1).unwrap();
1577        assert_eq!(words(&empty.lookup), [0, 0, 0]);
1578        assert!(empty.indices.is_empty());
1579
1580        let mut invalid = lookup.clone();
1581        invalid.counts.pop();
1582        assert!(
1583            LightListPart::Lookup(invalid)
1584                .verify(None)
1585                .unwrap_err()
1586                .contains("unlisted")
1587        );
1588        let mut narrow = lookup.clone();
1589        narrow.count_copies[0].1 = bits(8, 0, 1);
1590        assert!(pack_light_lists(&narrow, &indices, &[(LightTableKind::Point, 2)], &[], 1).is_err());
1591        lookup.count_copies[0].1 = bits(4, 0, 16);
1592        assert!(
1593            LightListPart::Lookup(lookup)
1594                .verify(None)
1595                .unwrap_err()
1596                .contains("overlap")
1597        );
1598    }
1599
1600    /// The consumer shadows point record 1 of 2: its entry's tile-pass bit is clear, the other entries' set, and the
1601    /// lookup's copied counts leave it out while its counts still list it. Refused: a shadowed record the counts do not
1602    /// hold, and a shadowed record in lists with no tile-pass bit.
1603    #[test]
1604    fn a_shadowed_light_leaves_the_tile_pass_but_stays_listed() {
1605        let (mut lookup, mut indices) = lists();
1606        lookup.unknown.clear();
1607        lookup.count_copies = vec![
1608            (LightTableKind::Point, bits(8, 0, 16)),
1609            (LightTableKind::Spot, bits(8, 16, 16)),
1610        ];
1611        let counts = [(LightTableKind::Point, 2), (LightTableKind::Spot, 1)];
1612        let packed = pack_light_lists(&lookup, &indices, &counts, &[(LightTableKind::Point, 1)], 1).unwrap();
1613        assert_eq!(words(&packed.lookup), [0, 0x0001_0002, 0x0001_0001]);
1614        assert_eq!(words(&packed.indices), [0xffff_0000, 0x7fff_0001, 0xffff_0000]);
1615        assert!(
1616            pack_light_lists(&lookup, &indices, &counts, &[(LightTableKind::Spot, 1)], 1)
1617                .unwrap_err()
1618                .contains("not among")
1619        );
1620        indices.tile_pass = None;
1621        assert!(
1622            pack_light_lists(&lookup, &indices, &counts, &[(LightTableKind::Point, 0)], 1)
1623                .unwrap_err()
1624                .contains("no tile-pass bit")
1625        );
1626    }
1627
1628    /// Refused: more tiles or entries than the buffers hold, a count its bits cannot hold, overlapping fields, a part
1629    /// whose records are not its buffer's elements.
1630    #[test]
1631    fn light_lists_refuse_what_they_cannot_hold() {
1632        let (lookup, indices) = lists();
1633        assert!(pack_light_lists(&lookup, &indices, &[(LightTableKind::Point, 1)], &[], 5).is_err());
1634        assert!(pack_light_lists(&lookup, &indices, &[(LightTableKind::Point, 9)], &[], 1).is_err());
1635        let mut narrow = lookup.clone();
1636        narrow.counts[0].1 = bits(4, 0, 2);
1637        assert!(pack_light_lists(&narrow, &indices, &[(LightTableKind::Point, 4)], &[], 1).is_err());
1638        let mut overlap = indices.clone();
1639        overlap.slices = bits(0, 15, 15);
1640        assert!(LightListPart::Indices(overlap).verify(Some(4)).is_err());
1641        assert!(LightListPart::Lookup(lookup).verify(Some(16)).is_err());
1642        assert_eq!(LightListPart::Indices(indices).verify(Some(4)), Ok(()));
1643    }
1644}