Skip to main content

repame_view2d/
light.rs

1//! 2D lighting data, validation, and the per-frame lighting plan.
2//!
3//! This is everything about lights that does not need a GPU: the snapshot
4//! types a game fills in, the occluder validation, the shadow-wedge geometry,
5//! and [`plan_lighting`], the deterministic CPU plan the GPU pass in
6//! [`lightpass`](crate::lightpass) uploads. Keeping it pure means the same
7//! code decides the pixels and answers the tests, and that a game can inspect
8//! exactly what a frame will do before it draws.
9//!
10//! # Units
11//!
12//! * `position`, `radius`, `half_extent`, `shadow_softness`, and occluder
13//!   points are world units in the same y-down space as
14//!   [`SpriteInstance::center`](crate::SpriteInstance).
15//! * `LightStop::at` is a normalized distance over `0..=1` of the light's
16//!   `radius` (so a stop list is unit-free).
17//! * `color` and `energy` are linear, unbounded multipliers; a light's
18//!   contribution is `color * energy * ramp`.
19//! * `LightStop::color` is `(rgb, a)`: rgb scales the light colour and `a`
20//!   scales how much of the light reaches the pixel, matching the deleted
21//!   `light2d.rs` convention.
22//!
23//! # Shading model
24//!
25//! A point light contributes, per fragment, `lit_rgb = color * ramp.rgb` and
26//! `lit_a = energy * ramp.a * shadow`, where `ramp` is the
27//! [`LightFalloff`] evaluated at `distance / radius` (`0` at the centre, `1`
28//! at the rim) or sampled from the falloff texture in light-local uv. The
29//! per-light [`LightBlend`] then decides what `lit` does to the accumulator
30//! (Godot `light_blend_compute`):
31//!
32//! * [`LightBlend::Add`]: `acc.rgb += lit_rgb * lit_a`
33//! * [`LightBlend::Sub`]: `acc.rgb -= lit_rgb * lit_a`
34//! * [`LightBlend::Mix`]: `acc.rgb = mix(acc.rgb, lit_rgb, lit_a)`;
35//!   `acc.a *= 1 - lit_a`
36//!
37//! The accumulator is `(rgb = light, a = item multiplier)`, which is what
38//! makes all three modes expressible with fixed-function blend states in one
39//! render target. Items consume it as
40//! `final.rgb = item.rgb * modulate * acc.a + acc.rgb`.
41//!
42//! # Masking
43//!
44//! [`Light2d::light_mask`] and [`Occluder2d::light_mask`] are Godot
45//! `item_cull_mask` / `occluder_light_mask` bits. A light is accumulated into
46//! one accumulator layer per bit it sets ([`MAX_LIGHT_LAYERS`] layers,
47//! bits `0..8`), and an item is lit by it exactly when the item's mask shares
48//! that bit: the item reads the layer of its lowest set bit
49//! ([`item_layer`]), which is the layer every light with that bit was
50//! accumulated into. Items whose mask is `0` (or whose bits are all outside
51//! the layer range) read the trailing identity layer and render unlit.
52//!
53//! # Not supported
54//!
55//! Each of these is a deliberate boundary, not an oversight:
56//!
57//! * No directional-light shadows, and no `shadow_color`: a shadowed fragment
58//!   simply loses the light ([`ShadowFilter`] softens the edge with an angular
59//!   penumbra of [`Light2d::shadow_softness`] world units). The global
60//!   [`AmbientLight`] is the only unpositioned light and never shadows.
61//! * No normal maps, no specular, and no per-item light culling: the
62//!   accumulator is one light map covering the visible world rect, consumed by
63//!   the sprite shader once per fragment, so a fragment cannot ask *which*
64//!   light reached it.
65//! * No SDF occluders and no occluder cull modes: every edge of a valid
66//!   polygon shadows (see [`Occluder2d`]), which is what makes winding
67//!   irrelevant.
68//! * The accumulator is 8-bit linear per channel: a [`LightBlend::Sub`] light
69//!   saturates at black, and a sum of additive lights saturates at white.
70//! * Lights do not light the canvas path, only batches that name a light
71//!   source (see [`SpriteBatch::set_light_source`](crate::SpriteBatch::set_light_source)).
72//! * World coordinates far from the origin lose ramp precision, because the
73//!   shader interpolates world positions as `f32`: keep the play area within
74//!   roughly a million units of the origin.
75//!
76//! # Determinism and budget
77//!
78//! The plan walks the input slices in order and never consults a hash map, so
79//! the same snapshot always yields the same plan, the same layer assignment,
80//! and the same accumulation order (which matters for `Sub`/`Mix`). Nothing
81//! consults a clock or a random number generator, the WGSL is one embedded
82//! source compiled for every profile, and `stops_at` is the same expression
83//! the shader evaluates, so a light looks the same in a dev and a packaged
84//! run. Every limit in [`LightBudget`] is enforced by dropping entries in that
85//! order and recording a typed [`LightIssue`] for each drop; nothing is
86//! truncated silently. Occluders that are invalid are rejected once per frame,
87//! again with a typed reason, instead of producing garbage shadows.
88
89use glam::{Mat4, Vec2, Vec3};
90
91/// Stops accepted in one [`LightFalloff::Stops`] ramp.
92pub const MAX_FALLOFF_STOPS: usize = 8;
93
94/// Accumulator layers a light can be masked into (bits `0..8`).
95///
96/// A light with a bit outside this range lights nothing on that bit; the plan
97/// reports it as [`LightIssueKind::MaskBeyondLayerRange`].
98pub const MAX_LIGHT_LAYERS: u32 = 8;
99
100/// Points accepted in one [`Occluder2d`] before it is rejected.
101pub const MAX_OCCLUDER_POINTS: usize = 256;
102
103/// Tap channels one light's shadow mask holds: one per
104/// [`ShadowFilter`] tap. A filter needing more taps than this cannot be
105/// represented, which the const assertion below rules out.
106pub const MAX_MASK_TAPS: usize = 4;
107
108const _: () = assert!(MAX_MASK_TAPS >= 4, "Pcf4 needs one mask channel per tap");
109
110/// Polygons up to this many points get the `O(edges^2)` self-intersection
111/// check; larger ones keep the fast checks only.
112pub const SELF_INTERSECT_CHECK_POINTS: usize = 64;
113
114/// One stop of a light's radial ramp, matching the deleted `light2d.rs`.
115#[derive(Clone, Copy, Debug, Default, PartialEq)]
116pub struct LightStop {
117    /// Normalized distance from the light centre in `0..=1`.
118    pub at: f32,
119    /// Ramp value: rgb scales the light colour, a scales the ramp.
120    pub color: [f32; 4],
121}
122
123/// How a light's value varies over its footprint.
124#[derive(Clone, Debug, PartialEq)]
125pub enum LightFalloff {
126    /// A radial ramp over `0..=1` of the light's `radius`, interpolated
127    /// linearly between ascending stops (`at` strictly ascending, all in
128    /// `0..=1`). `t` below the first stop is the first colour, `t` above the
129    /// last is the last colour. If the last stop is not zero the light still
130    /// ends at the rim: fragments past `radius` are discarded.
131    Stops(Vec<LightStop>),
132    /// A falloff texture (Godot `PointLight2D.texture`) sampled in
133    /// light-local uv: the texture covers `position + offset +- half_extent`,
134    /// `rgb` scales the light colour and `a` scales the ramp, and fragments
135    /// outside the texture footprint are discarded. `slot` selects an upload
136    /// queued on the pass ([`LightTextureUpload`](crate::LightTextureUpload));
137    /// a slot with no texture paints nothing.
138    Texture {
139        slot: u32,
140        half_extent: Vec2,
141        offset: Vec2,
142    },
143}
144
145impl LightFalloff {
146    /// The deleted `light2d.rs` default ramp: white at the centre, half at
147    /// 0.5, black at the rim.
148    pub fn stops(stops: impl Into<Vec<LightStop>>) -> Self {
149        Self::Stops(stops.into())
150    }
151
152    /// Footprint half extent in world units: `radius` for stops, the
153    /// texture's own half extent for a texture.
154    pub fn half_extent(&self, radius: f32) -> Vec2 {
155        match self {
156            Self::Stops(_) => Vec2::splat(radius),
157            Self::Texture { half_extent, .. } => *half_extent,
158        }
159    }
160}
161
162impl Default for LightFalloff {
163    fn default() -> Self {
164        Self::Stops(vec![
165            LightStop {
166                at: 0.0,
167                color: [1.0, 1.0, 1.0, 1.0],
168            },
169            LightStop {
170                at: 0.5,
171                color: [0.5, 0.5, 0.5, 1.0],
172            },
173            LightStop {
174                at: 1.0,
175                color: [0.0, 0.0, 0.0, 1.0],
176            },
177        ])
178    }
179}
180
181/// How a light combines with what is already lit (Godot `Light2D.BlendMode`).
182#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
183pub enum LightBlend {
184    /// `acc += lit` (Godot default).
185    #[default]
186    Add,
187    /// `acc -= lit`; the 8-bit accumulator saturates at zero.
188    Sub,
189    /// `acc = mix(acc, lit_rgb, lit_a)`, the accumulator multiplier included.
190    Mix,
191}
192
193/// Shadow edge filtering (Godot `Light2D.ShadowFilter`).
194///
195/// The filter is angular: the silhouette is re-derived for
196/// [`ShadowFilter::tap_count`] rotations around the light, spaced over the
197/// penumbra and each weighted `1 / taps`, so the penumbra width in world units
198/// is [`Light2d::shadow_softness`] at every occluder distance instead of a
199/// screen-space blur.
200///
201/// Within one tap the edges of a solid occluder overlap, and the shadow there
202/// is their *union* (a point is shadowed when the ray to the light crosses any
203/// edge); only the taps themselves sum, as the average over the rotations. The
204/// mask pass in [`lightpass`](crate::lightpass) implements exactly that: one
205/// channel per tap, combined with max blending, summed by the light shader.
206///
207/// Each edge spaces its taps from its own midpoint's distance to the light,
208/// not from one rotation for the whole occluder, so on a solid body two
209/// silhouette edges' bands overlap and the union can reach full cover a few
210/// pixels sooner on the shadow side than a lone edge would. The penumbra's mid
211/// level still sits on the silhouette: the closed-polygon golden measures it
212/// 1.5 px from the geometric edge, against a 3 px tolerance.
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub enum ShadowFilter {
215    /// One hard edge.
216    #[default]
217    None,
218    /// Four taps: a stepped penumbra, the cheapest soft edge.
219    Pcf4,
220}
221
222impl ShadowFilter {
223    /// Number of geometry copies drawn per silhouette edge.
224    pub fn tap_count(self) -> u32 {
225        match self {
226            Self::None => 1,
227            Self::Pcf4 => 4,
228        }
229    }
230
231    /// Weight of one tap: taps sum to 1, so a fully covered fragment is fully
232    /// shadowed.
233    pub fn tap_weight(self) -> f32 {
234        1.0 / self.tap_count() as f32
235    }
236}
237
238/// A point light (Godot `PointLight2D`) in world space.
239///
240/// See the module docs for the shading contract. A light with a non-finite
241/// position, a non-positive `radius`/`half_extent`, an empty/descending stop
242/// list, or an all-zero `light_mask` is dropped by [`plan_lighting`] with a
243/// typed issue rather than shaded half-way.
244#[derive(Clone, Debug, PartialEq)]
245pub struct Light2d {
246    /// World-space light centre.
247    pub position: Vec2,
248    /// World-unit radius: the ramp's `1.0`, the shadow extrusion's bound, and
249    /// the discard radius for [`LightFalloff::Stops`] (ignored when the
250    /// falloff is a texture, whose footprint sets its own extent, but still
251    /// used for nothing else).
252    pub radius: f32,
253    /// Peak light colour (linear rgb).
254    pub color: [f32; 3],
255    /// Brightness multiplier applied to the ramped colour.
256    pub energy: f32,
257    /// Radial ramp or falloff texture.
258    pub falloff: LightFalloff,
259    /// How the light combines with the lit result.
260    pub blend: LightBlend,
261    /// Cast shadows from the occluders whose mask it shares.
262    pub shadows: bool,
263    /// Shadow edge filtering.
264    pub shadow_filter: ShadowFilter,
265    /// Penumbra width in world units for [`ShadowFilter::Pcf4`]; ignored when
266    /// the filter is [`ShadowFilter::None`].
267    pub shadow_softness: f32,
268    /// Layer bits this light lights and shadows cast against `0` = nothing.
269    pub light_mask: u32,
270}
271
272impl Default for Light2d {
273    fn default() -> Self {
274        Self {
275            position: Vec2::ZERO,
276            radius: 128.0,
277            color: [1.0, 1.0, 1.0],
278            energy: 1.0,
279            falloff: LightFalloff::default(),
280            blend: LightBlend::Add,
281            shadows: false,
282            shadow_filter: ShadowFilter::None,
283            shadow_softness: 4.0,
284            light_mask: 1,
285        }
286    }
287}
288
289impl Light2d {
290    /// A white additive light with the default ramp.
291    pub fn point(position: Vec2, radius: f32) -> Self {
292        Self {
293            position,
294            radius,
295            ..Default::default()
296        }
297    }
298
299    /// Ramp value at normalized distance `t` for a stops falloff, or `None`
300    /// when the falloff is a texture (nothing to evaluate on the CPU).
301    pub fn falloff_at(&self, t: f32) -> Option<[f32; 4]> {
302        match &self.falloff {
303            LightFalloff::Stops(stops) => Some(stops_at(stops, t)),
304            LightFalloff::Texture { .. } => None,
305        }
306    }
307
308    /// Light-local uv of a world point for a texture falloff: `(0,0)` is the
309    /// texture's top-left, `(1,1)` its bottom-right, outside `0..=1` is off
310    /// the light. Meaningless (but finite) for a stops falloff.
311    pub fn texture_uv(&self, world: Vec2) -> Vec2 {
312        match &self.falloff {
313            LightFalloff::Texture {
314                half_extent,
315                offset,
316                ..
317            } => texture_uv(self.position, *offset, *half_extent, world),
318            LightFalloff::Stops(_) => Vec2::splat(0.5),
319        }
320    }
321}
322
323/// Global light with no position (Godot `DirectionalLight2D` for flat 2D
324/// items): `color * energy` is added to every lit layer it masks into, before
325/// any light is accumulated.
326///
327/// It is additive, exactly like a covering [`LightBlend::Add`] light, so it
328/// cannot darken a scene: darken items with their tint or a batch's
329/// [`light_modulate`](crate::SpriteBatch::set_light_modulate) instead. It has
330/// no shadows.
331#[derive(Clone, Copy, Debug, PartialEq)]
332pub struct AmbientLight {
333    /// Linear rgb.
334    pub color: [f32; 3],
335    /// Brightness multiplier.
336    pub energy: f32,
337    /// Layer bits this ambient reaches. Bits also define layers, exactly like
338    /// a light's mask does.
339    pub light_mask: u32,
340}
341
342impl Default for AmbientLight {
343    fn default() -> Self {
344        Self {
345            color: [1.0, 1.0, 1.0],
346            energy: 0.0,
347            light_mask: 1,
348        }
349    }
350}
351
352/// A shadow-casting polygon (Godot `LightOccluder2D` + `OccluderPolygon2D`).
353///
354/// Every edge extrudes a shadow wedge away from the light, which gives *solid*
355/// occluders: a point is shadowed exactly when the segment from the light to
356/// the point crosses the polygon, regardless of the polygon's winding. That
357/// is why there is no cull mode here; requiring a consistent winding is the
358/// fragile part of a classic shadow volume, and dropping it means a game can
359/// hand the engine any ordering of points from a level editor.
360///
361/// [`ValidatedOccluder::validate`] rejects polygons that would make the wedges
362/// meaningless (too few points, non-finite, zero area, self-intersecting) and
363/// the plan reports each rejection once per frame.
364#[derive(Clone, Debug, PartialEq)]
365pub struct Occluder2d {
366    /// World-space corners in order.
367    pub points: Vec<Vec2>,
368    /// Whether the last point connects back to the first. `false` is an open
369    /// polyline: a thin wall casts a shadow but has no interior.
370    pub closed: bool,
371    /// Layer bits this occluder shadows (`light.light_mask & light_mask`).
372    pub light_mask: u32,
373}
374
375impl Default for Occluder2d {
376    fn default() -> Self {
377        Self {
378            points: Vec::new(),
379            closed: true,
380            light_mask: 1,
381        }
382    }
383}
384
385impl Occluder2d {
386    /// Occluder spanning the two corners (any corner order).
387    pub fn rect(min: Vec2, max: Vec2) -> Self {
388        let min = min.min(max);
389        let max = min.max(max);
390        Self {
391            points: vec![
392                min,
393                Vec2::new(max.x, min.y),
394                max,
395                Vec2::new(min.x, max.y),
396            ],
397            closed: true,
398            light_mask: 1,
399        }
400    }
401
402    /// One level cell: `[col, row]` index at `cell_size` world units.
403    pub fn cell(cell: [i32; 2], cell_size: f32) -> Self {
404        let min = Vec2::new(cell[0] as f32 * cell_size, cell[1] as f32 * cell_size);
405        Self::rect(min, min + Vec2::splat(cell_size))
406    }
407
408    /// A closed polygon through `points`.
409    pub fn polygon(points: impl Into<Vec<Vec2>>) -> Self {
410        Self {
411            points: points.into(),
412            closed: true,
413            light_mask: 1,
414        }
415    }
416
417    /// Inclusive point-in-polygon test (fills the deleted `light2d.rs`
418    /// helper). Open polylines and invalid polygons report `false`.
419    pub fn contains(&self, p: Vec2) -> bool {
420        if !self.closed || self.points.len() < 3 {
421            return false;
422        }
423        let mut inside = false;
424        let n = self.points.len();
425        for i in 0..n {
426            let a = self.points[i];
427            let b = self.points[(i + 1) % n];
428            if (a.y > p.y) != (b.y > p.y) {
429                let t = (p.y - a.y) / (b.y - a.y);
430                if p.x < a.x + t * (b.x - a.x) {
431                    inside = !inside;
432                }
433            }
434        }
435        inside
436    }
437
438    /// [`Self::rect`] with an explicit layer mask.
439    pub fn rect_masked(min: Vec2, max: Vec2, light_mask: u32) -> Self {
440        Self {
441            light_mask,
442            ..Self::rect(min, max)
443        }
444    }
445
446    /// World-space bounds as `[min_x, min_y, max_x, max_y]`.
447    pub fn aabb(&self) -> [f32; 4] {
448        aabb_of(&self.points)
449    }
450
451    /// Check what [`plan_lighting`] checks, without a plan.
452    pub fn validate(&self) -> Result<(), OccluderRejectReason> {
453        ValidatedOccluder::validate(self).map(|_| ())
454    }
455}
456
457/// Why an occluder was dropped for the frame.
458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
459pub enum OccluderRejectReason {
460    /// Closed needs 3 points, open needs 2.
461    TooFewPoints,
462    /// More than [`MAX_OCCLUDER_POINTS`] points.
463    TooManyPoints,
464    /// A NaN or infinite point.
465    NonFinitePoint,
466    /// A closed polygon with zero area (collinear or duplicate points).
467    DegenerateArea,
468    /// The polygon crosses itself, which makes the silhouette ill-defined.
469    SelfIntersecting,
470    /// `light_mask` is `0`: it shadows no light, so it is not a caster.
471    ZeroMask,
472}
473
474/// An occluder that passed validation, with its cached bounds.
475#[derive(Clone, Debug, PartialEq)]
476pub struct ValidatedOccluder {
477    points: Vec<Vec2>,
478    closed: bool,
479    light_mask: u32,
480    aabb: [f32; 4],
481}
482
483impl ValidatedOccluder {
484    /// Validate a snapshot occluder.
485    pub fn validate(occluder: &Occluder2d) -> Result<Self, OccluderRejectReason> {
486        let n = occluder.points.len();
487        if n > MAX_OCCLUDER_POINTS {
488            return Err(OccluderRejectReason::TooManyPoints);
489        }
490        let min_points = if occluder.closed { 3 } else { 2 };
491        if n < min_points {
492            return Err(OccluderRejectReason::TooFewPoints);
493        }
494        if occluder.points.iter().any(|p| !p.is_finite()) {
495            return Err(OccluderRejectReason::NonFinitePoint);
496        }
497        if occluder.light_mask == 0 {
498            // Nothing is shadowed by it, so it is not a caster at all.
499            return Err(OccluderRejectReason::ZeroMask);
500        }
501        let aabb = aabb_of(&occluder.points);
502        if occluder.closed {
503            // Crossings are checked before the area: the shoelace sum of a
504            // bowtie cancels to zero (its lobes wind opposite ways), so a
505            // degenerate-area test first would report a self-crossing polygon
506            // as collinear. Collinear and duplicate-point polygons cast no
507            // proper crossings, so they still fall through to the area test.
508            if n <= SELF_INTERSECT_CHECK_POINTS && self_intersects(&occluder.points) {
509                return Err(OccluderRejectReason::SelfIntersecting);
510            }
511            let width = aabb[2] - aabb[0];
512            let height = aabb[3] - aabb[1];
513            let scale = width.max(height);
514            let area = signed_area(&occluder.points).abs();
515            if !(area > scale * scale * 1e-6) {
516                return Err(OccluderRejectReason::DegenerateArea);
517            }
518        }
519        Ok(Self {
520            points: occluder.points.clone(),
521            closed: occluder.closed,
522            light_mask: occluder.light_mask,
523            aabb,
524        })
525    }
526
527    /// Corners in world space.
528    pub fn points(&self) -> &[Vec2] {
529        &self.points
530    }
531
532    /// Whether the last corner connects back to the first.
533    pub fn closed(&self) -> bool {
534        self.closed
535    }
536
537    /// Layer bits this occluder shadows.
538    pub fn light_mask(&self) -> u32 {
539        self.light_mask
540    }
541
542    /// Bounds as `[min_x, min_y, max_x, max_y]`.
543    pub fn aabb(&self) -> [f32; 4] {
544        self.aabb
545    }
546}
547
548/// One triangle-fan quad of a light's shadow, in world space, drawn as
549/// `[near_a, near_b, far_b, far_a]`.
550#[derive(Clone, Copy, Debug, PartialEq)]
551pub struct ShadowWedge {
552    /// Corners: two on the occluder edge, two extruded away from the light.
553    pub quad: [Vec2; 4],
554    /// This tap's share of the shadow: taps sum to `1.0`.
555    pub weight: f32,
556    /// Which tap this quad belongs to, `0..taps-1` in the order
557    /// [`shadow_wedges`] emitted them.
558    pub tap: u32,
559}
560
561impl ShadowWedge {
562    /// The quad's contribution to the shadow mask, one entry per mask channel:
563    /// `weight` in [`Self::tap`]'s channel and zero in the others.
564    ///
565    /// Overlapping quads of one tap max-blend into their shared channel, so a
566    /// solid occluder's edges union per tap (a point is shadowed when the ray
567    /// to the light crosses any edge), and the light shader sums the channels,
568    /// which is the taps' average.
569    pub fn mask_weights(&self) -> [f32; MAX_MASK_TAPS] {
570        let mut out = [0.0; MAX_MASK_TAPS];
571        if let Some(slot) = out.get_mut(self.tap as usize) {
572            *slot = self.weight;
573        }
574        out
575    }
576}
577
578/// How many occluders a light's shadow pass consumed and left behind.
579#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
580pub struct ShadowCasters {
581    /// Occluders whose mask and bounds qualified.
582    pub used: usize,
583    /// Qualified occluders dropped by `max_casters`.
584    pub dropped: usize,
585}
586
587/// Per-frame limits, all enforced in input order.
588#[derive(Clone, Copy, Debug, PartialEq)]
589pub struct LightBudget {
590    /// Lights shaded per frame; extra lights are reported and skipped.
591    pub max_lights: usize,
592    /// Layers the accumulator may have (`1..=`[`MAX_LIGHT_LAYERS`]; each light
593    /// costs one pass per layer it lights).
594    pub max_layers: u32,
595    /// Occluders considered per light.
596    pub max_shadow_casters: usize,
597    /// Shadow vertices (`6` per wedge) across the frame; the light that would
598    /// exceed it is shaded without shadows.
599    pub max_shadow_vertices: usize,
600}
601
602impl Default for LightBudget {
603    fn default() -> Self {
604        Self {
605            max_lights: 16,
606            max_layers: MAX_LIGHT_LAYERS,
607            max_shadow_casters: 64,
608            max_shadow_vertices: 12_288,
609        }
610    }
611}
612
613/// A light that made it into the frame, in the order it is accumulated.
614#[derive(Clone, Debug, PartialEq)]
615pub struct PlannedLight {
616    /// Index into the input slice the plan was built from.
617    pub source: usize,
618    /// Layer bits this light is accumulated into (a subset of its mask).
619    pub layers: u32,
620    /// World-space centre.
621    pub center: Vec2,
622    /// Footprint half extent (see [`LightFalloff::half_extent`]).
623    pub half_extent: Vec2,
624    /// Distance from the centre to the farthest footprint corner.
625    pub extent: f32,
626    /// Peak colour.
627    pub color: [f32; 3],
628    /// Brightness multiplier.
629    pub energy: f32,
630    /// Blend into the accumulator.
631    pub blend: LightBlend,
632    /// Validated ramp stops (`stop_count` of `MAX_FALLOFF_STOPS` are live).
633    pub stops: [LightStop; MAX_FALLOFF_STOPS],
634    /// Live stop count; `0` means the falloff is a texture.
635    pub stop_count: u32,
636    /// Falloff texture slot, used when `stop_count == 0`.
637    pub texture_slot: u32,
638    /// Falloff texture offset from the light centre (texture mode).
639    pub texture_offset: Vec2,
640    /// Falloff texture half extent (texture mode; `1.0` square otherwise).
641    pub texture_half_extent: Vec2,
642    /// Whether shadows were built (a light with no casters is unshadowed).
643    pub shadowed: bool,
644    /// Shadow edge filter used for the wedges.
645    pub shadow_filter: ShadowFilter,
646    /// World-space shadow wedges, `shadow_filter.tap_count()` per silhouette
647    /// edge.
648    pub wedges: Vec<ShadowWedge>,
649}
650
651impl PlannedLight {
652    /// Vertices the wedges need (`6` per quad, triangle list).
653    pub fn wedge_vertex_count(&self) -> usize {
654        self.wedges.len() * 6
655    }
656
657    /// Layer indices this light is accumulated into, ascending.
658    pub fn layer_indices(&self) -> impl Iterator<Item = u32> + '_ {
659        (0..MAX_LIGHT_LAYERS).filter(move |bit| self.layers & (1 << bit) != 0)
660    }
661}
662
663/// Why a light was dropped or altered for the frame.
664#[derive(Clone, Copy, Debug, PartialEq)]
665pub enum LightIssueKind {
666    /// `light_mask` was `0`: it lights nothing.
667    ZeroMask,
668    /// Mask bits at or above [`MAX_LIGHT_LAYERS`] were dropped.
669    MaskBeyondLayerRange {
670        /// The bits that could not be used.
671        dropped: u32,
672    },
673    /// A position that is not finite.
674    NonFinitePosition,
675    /// A colour, energy, or texture offset that is not finite.
676    NonFiniteValue,
677    /// The ambient light's colour or energy is not finite; it is treated as
678    /// black for the frame. Reported with `source == usize::MAX`.
679    NonFiniteAmbient,
680    /// `radius` (stops) or `half_extent` (texture) is not a positive, finite
681    /// extent, so the light has no footprint.
682    ZeroExtent,
683    /// A stops falloff with no stops.
684    StopsEmpty,
685    /// More stops than [`MAX_FALLOFF_STOPS`].
686    TooManyStops {
687        /// Stops supplied.
688        count: usize,
689        /// Stops allowed.
690        max: usize,
691    },
692    /// A stop's `at` was not finite.
693    StopNotFinite,
694    /// A stop's `at` was outside `0..=1`.
695    StopOutOfRange {
696        /// The offending `at`.
697        at: f32,
698    },
699    /// A stop's `at` did not ascend.
700    StopsNotAscending {
701        /// The offending `at`.
702        at: f32,
703        /// The previous stop's `at`.
704        previous: f32,
705    },
706    /// More lights than [`LightBudget::max_lights`].
707    OverLightBudget {
708        /// The budget.
709        max: usize,
710    },
711    /// Qualified occluders dropped by [`LightBudget::max_shadow_casters`].
712    ShadowCastersExhausted {
713        /// Occluders used.
714        used: usize,
715        /// Occluders dropped.
716        dropped: usize,
717    },
718    /// The light's wedges would exceed [`LightBudget::max_shadow_vertices`];
719    /// the light is accumulated without shadows.
720    ShadowVertexBudget {
721        /// Vertices the light needed.
722        needed: usize,
723        /// The budget.
724        max: usize,
725    },
726}
727
728/// A typed reason one light was altered or dropped this frame.
729#[derive(Clone, Copy, Debug, PartialEq)]
730pub struct LightIssue {
731    /// Index into the input slice the plan was built from, or
732    /// [`AMBIENT_SOURCE`] when the issue is the ambient light's.
733    pub source: usize,
734    /// What happened.
735    pub kind: LightIssueKind,
736}
737
738/// The [`LightIssue::source`] an ambient-light issue carries: the ambient is
739/// not one of the indexed lights.
740pub const AMBIENT_SOURCE: usize = usize::MAX;
741
742/// An occluder rejected for the frame.
743#[derive(Clone, Copy, Debug, PartialEq, Eq)]
744pub struct OccluderReject {
745    /// Index into the input slice the plan was built from.
746    pub index: usize,
747    /// Why it was rejected.
748    pub reason: OccluderRejectReason,
749}
750
751/// Everything the GPU pass needs for one frame, decided on the CPU.
752#[derive(Clone, Debug, PartialEq)]
753pub struct LightingPlan {
754    /// Visible world rect the accumulator covers: `[x, y, w, h]` with `x/y`
755    /// the top-left (the accumulator's uv origin).
756    pub world_rect: [f32; 4],
757    /// Accumulator layers, including the trailing identity layer items with
758    /// no layer read. At least `2`, because a one-layer array texture is
759    /// unsafe to sample on GL/ANGLE (see
760    /// [`atlas_shape`](crate::batch::atlas_shape)).
761    pub layers: u32,
762    /// `layers - 1`: the highest index an item may sample.
763    pub layer_clamp: u32,
764    /// Lights in accumulation order.
765    pub lights: Vec<PlannedLight>,
766    /// The ambient light, resolved per layer by [`Self::ambient_rgb`].
767    pub ambient: AmbientLight,
768    /// Every light the plan altered or dropped, in input order.
769    pub issues: Vec<LightIssue>,
770    /// Occluders rejected before any light was planned.
771    pub occluder_rejects: Vec<OccluderReject>,
772    /// Lights skipped because their footprint misses the visible rect. Culling
773    /// is not a budget drop, so these are counted, not reported.
774    pub culled: usize,
775}
776
777impl LightingPlan {
778    /// Vertices all lights' wedges need.
779    pub fn wedge_vertices(&self) -> usize {
780        self.lights.iter().map(PlannedLight::wedge_vertex_count).sum()
781    }
782
783    /// Ambient rgb added to `layer` before any light: `color * energy` when
784    /// the ambient's mask reaches the layer. The trailing identity layer is
785    /// always `[0, 0, 0]`, so unlit items stay exactly unlit.
786    pub fn ambient_rgb(&self, layer: u32) -> [f32; 3] {
787        if layer + 1 >= self.layers || self.ambient.light_mask & (1u32 << layer) == 0 {
788            return [0.0; 3];
789        }
790        [
791            self.ambient.color[0] * self.ambient.energy,
792            self.ambient.color[1] * self.ambient.energy,
793            self.ambient.color[2] * self.ambient.energy,
794        ]
795    }
796}
797
798/// Ramp value at normalized distance `t` for a stops list (clamped, linearly
799/// interpolated, ascending stops assumed: [`plan_lighting`] rejects the rest).
800pub fn stops_at(stops: &[LightStop], t: f32) -> [f32; 4] {
801    let Some(first) = stops.first() else {
802        return [0.0; 4];
803    };
804    let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
805    let mut ramp = first.color;
806    let mut previous = 0.0f32;
807    for stop in &stops[1..] {
808        // `max(...)` rather than a branch so this is character for character
809        // what `shaders/light_disc.wgsl::stops_ramp` computes.
810        let span = (stop.at - previous).max(1e-6);
811        let w = ((t - previous) / span).clamp(0.0, 1.0);
812        ramp = [
813            ramp[0] + (stop.color[0] - ramp[0]) * w,
814            ramp[1] + (stop.color[1] - ramp[1]) * w,
815            ramp[2] + (stop.color[2] - ramp[2]) * w,
816            ramp[3] + (stop.color[3] - ramp[3]) * w,
817        ];
818        previous = stop.at;
819    }
820    ramp
821}
822
823/// Light-local uv of `world` for a texture falloff centred on `center +
824/// offset` and covering `+-half_extent`. Exact `0..=1` inside the footprint.
825pub fn texture_uv(center: Vec2, offset: Vec2, half_extent: Vec2, world: Vec2) -> Vec2 {
826    let half = Vec2::new(
827        if half_extent.x.abs() > 1e-6 {
828            half_extent.x
829        } else {
830            1.0
831        },
832        if half_extent.y.abs() > 1e-6 {
833            half_extent.y
834        } else {
835            1.0
836        },
837    );
838    (world - center - offset) / (half * 2.0) + Vec2::splat(0.5)
839}
840
841/// Layer an item with `light_mask` reads: its lowest set bit, or
842/// `MAX_LIGHT_LAYERS` (the identity layer) when no bit is in range.
843pub fn item_layer(light_mask: u32) -> u32 {
844    light_mask.trailing_zeros().min(MAX_LIGHT_LAYERS)
845}
846
847/// The visible world rect of a y-down orthographic `view_proj`, or `None` when
848/// the matrix is not invertible or maps the view to a degenerate area.
849pub fn visible_world_rect(view_proj: Mat4) -> Option<[f32; 4]> {
850    if !view_proj.is_finite() {
851        return None;
852    }
853    let inverse = view_proj.inverse();
854    if !inverse.is_finite() || inverse.determinant() == 0.0 {
855        return None;
856    }
857    let mut min = Vec2::splat(f32::INFINITY);
858    let mut max = Vec2::splat(f32::NEG_INFINITY);
859    for corner in [[-1.0f32, -1.0f32], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]] {
860        let p = inverse.project_point3(Vec3::new(corner[0], corner[1], 0.0));
861        if !p.is_finite() {
862            return None;
863        }
864        let p = p.truncate();
865        min = min.min(p);
866        max = max.max(p);
867    }
868    let size = max - min;
869    if !(size.x > 1e-3 && size.y > 1e-3) {
870        return None;
871    }
872    Some([min.x, min.y, size.x, size.y])
873}
874
875/// Build every wedge a light's shadows need, in world space.
876///
877/// Each edge of each qualifying occluder contributes one quad per tap. The
878/// quad's far corners are pushed to `1.5 * extent + 2 * softness` away from
879/// the light (and never closer than 1.05x their own distance), which covers
880/// the footprint of the light plus the filter's angular spread. A corner
881/// exactly at the light keeps its position, so a light sitting on an occluder
882/// vertex produces a degenerate quad instead of a NaN.
883///
884/// Wedges come out in occluder order, then edge order, then tap order, and
885/// each carries its [`ShadowWedge::tap`] so the mask pass can put it in the
886/// right channel whatever order a game's occluders arrive in.
887///
888/// Occluders are taken in slice order up to `max_casters`; `out` is appended
889/// to.
890#[allow(clippy::too_many_arguments)]
891pub fn shadow_wedges(
892    center: Vec2,
893    extent: f32,
894    filter: ShadowFilter,
895    softness: f32,
896    light_mask: u32,
897    light_aabb: [f32; 4],
898    occluders: &[ValidatedOccluder],
899    max_casters: usize,
900    out: &mut Vec<ShadowWedge>,
901) -> ShadowCasters {
902    let mut casters = ShadowCasters::default();
903    if !(extent.is_finite() && extent > 0.0) {
904        return casters;
905    }
906    let softness = if softness.is_finite() {
907        softness.max(0.0)
908    } else {
909        0.0
910    };
911    let taps = filter.tap_count();
912    let weight = filter.tap_weight();
913    debug_assert!(
914        taps as usize <= MAX_MASK_TAPS,
915        "a filter with more taps than mask channels would lose its penumbra"
916    );
917    let reach = extent * 1.5 + softness * 2.0;
918    for occluder in occluders {
919        if occluder.light_mask & light_mask == 0 || !aabb_overlap(occluder.aabb, light_aabb) {
920            continue;
921        }
922        if casters.used >= max_casters {
923            casters.dropped += 1;
924            continue;
925        }
926        casters.used += 1;
927        let points = &occluder.points;
928        let n = points.len();
929        let edges = if occluder.closed { n } else { n - 1 };
930        for edge in 0..edges {
931            let a = points[edge];
932            let b = points[(edge + 1) % n];
933            let mid = (a + b) * 0.5;
934            let delta = if taps > 1 && softness > 0.0 {
935                (softness / (mid - center).length().max(1e-3)).min(0.5)
936            } else {
937                0.0
938            };
939            for tap in 0..taps {
940                let offset = if taps == 1 {
941                    0.0
942                } else {
943                    TAP_OFFSETS[tap as usize % TAP_OFFSETS.len()]
944                };
945                let angle = offset * delta;
946                let ar = rotate_about(center, a, angle);
947                let br = rotate_about(center, b, angle);
948                let af = extrude(center, ar, reach);
949                let bf = extrude(center, br, reach);
950                out.push(ShadowWedge {
951                    quad: [ar, br, bf, af],
952                    weight,
953                    tap,
954                });
955            }
956        }
957    }
958    casters
959}
960
961/// Tap offsets as fractions of the penumbra's angular half width, ascending
962/// and symmetric; fixed so a frame is reproducible.
963const TAP_OFFSETS: [f32; 4] = [-0.75, -0.25, 0.25, 0.75];
964
965/// Plan one frame: validate, cull, budget, and build the shadow geometry.
966///
967/// Pure and deterministic: input order in, same output out, no hash iteration
968/// and no time or randomness. Drops and rejections are returned as typed
969/// entries in [`LightingPlan::issues`] / [`LightingPlan::occluder_rejects`]
970/// and never truncate quietly.
971pub fn plan_lighting(
972    lights: &[Light2d],
973    occluders: &[Occluder2d],
974    ambient: &AmbientLight,
975    world_rect: [f32; 4],
976    budget: &LightBudget,
977) -> LightingPlan {
978    let mut plan = LightingPlan {
979        world_rect,
980        layers: 2,
981        layer_clamp: 1,
982        lights: Vec::new(),
983        ambient: *ambient,
984        issues: Vec::new(),
985        occluder_rejects: Vec::new(),
986        culled: 0,
987    };
988    if !ambient.color.iter().all(|c| c.is_finite()) || !ambient.energy.is_finite() {
989        // A NaN would reach the layer clears, so the frame falls back to black
990        // and reports it.
991        plan.ambient = AmbientLight {
992            color: [0.0; 3],
993            ..*ambient
994        };
995        plan.issues.push(LightIssue {
996            source: AMBIENT_SOURCE,
997            kind: LightIssueKind::NonFiniteAmbient,
998        });
999    }
1000    let mut validated: Vec<ValidatedOccluder> = Vec::with_capacity(occluders.len());
1001    for (index, occluder) in occluders.iter().enumerate() {
1002        match ValidatedOccluder::validate(occluder) {
1003            Ok(valid) => validated.push(valid),
1004            Err(reason) => plan.occluder_rejects.push(OccluderReject { index, reason }),
1005        }
1006    }
1007
1008    let max_layers = budget.max_layers.clamp(1, MAX_LIGHT_LAYERS);
1009    let layer_bits = if max_layers >= 32 {
1010        u32::MAX
1011    } else {
1012        (1u32 << max_layers) - 1
1013    };
1014    let mut used_layers = if ambient.light_mask & layer_bits != 0 {
1015        (32 - (ambient.light_mask & layer_bits).leading_zeros()).max(1)
1016    } else {
1017        0
1018    };
1019    let view_bounds = [
1020        world_rect[0],
1021        world_rect[1],
1022        world_rect[0] + world_rect[2],
1023        world_rect[1] + world_rect[3],
1024    ];
1025    let mut wedge_vertices = 0usize;
1026
1027    for (source, light) in lights.iter().enumerate() {
1028        let issue = |plan: &mut LightingPlan, kind: LightIssueKind| {
1029            plan.issues.push(LightIssue { source, kind });
1030        };
1031        if plan.lights.len() >= budget.max_lights {
1032            issue(
1033                &mut plan,
1034                LightIssueKind::OverLightBudget {
1035                    max: budget.max_lights,
1036                },
1037            );
1038            continue;
1039        }
1040        if light.light_mask == 0 {
1041            issue(&mut plan, LightIssueKind::ZeroMask);
1042            continue;
1043        }
1044        if !light.position.is_finite() {
1045            issue(&mut plan, LightIssueKind::NonFinitePosition);
1046            continue;
1047        }
1048        if !light.color.iter().all(|c| c.is_finite()) || !light.energy.is_finite() {
1049            issue(&mut plan, LightIssueKind::NonFiniteValue);
1050            continue;
1051        }
1052        let dropped = light.light_mask & !layer_bits;
1053        if dropped != 0 {
1054            issue(&mut plan, LightIssueKind::MaskBeyondLayerRange { dropped });
1055        }
1056        let layers = light.light_mask & layer_bits;
1057        if layers == 0 {
1058            issue(&mut plan, LightIssueKind::ZeroMask);
1059            continue;
1060        }
1061        let mut stops = [LightStop::default(); MAX_FALLOFF_STOPS];
1062        let mut stop_count = 0u32;
1063        let mut texture_slot = 0u32;
1064        let mut texture_offset = Vec2::ZERO;
1065        let mut texture_half_extent = Vec2::ONE;
1066        match &light.falloff {
1067            LightFalloff::Stops(list) => {
1068                if list.is_empty() {
1069                    issue(&mut plan, LightIssueKind::StopsEmpty);
1070                    continue;
1071                }
1072                if list.len() > MAX_FALLOFF_STOPS {
1073                    issue(
1074                        &mut plan,
1075                        LightIssueKind::TooManyStops {
1076                            count: list.len(),
1077                            max: MAX_FALLOFF_STOPS,
1078                        },
1079                    );
1080                    continue;
1081                }
1082                let mut ok = true;
1083                for (i, stop) in list.iter().enumerate() {
1084                    if !stop.at.is_finite() || stop.color.iter().any(|c| !c.is_finite()) {
1085                        issue(&mut plan, LightIssueKind::StopNotFinite);
1086                        ok = false;
1087                        break;
1088                    }
1089                    if !(0.0..=1.0).contains(&stop.at) {
1090                        issue(&mut plan, LightIssueKind::StopOutOfRange { at: stop.at });
1091                        ok = false;
1092                        break;
1093                    }
1094                    if i > 0 && stop.at <= list[i - 1].at {
1095                        issue(
1096                            &mut plan,
1097                            LightIssueKind::StopsNotAscending {
1098                                at: stop.at,
1099                                previous: list[i - 1].at,
1100                            },
1101                        );
1102                        ok = false;
1103                        break;
1104                    }
1105                    stops[i] = *stop;
1106                }
1107                if !ok {
1108                    continue;
1109                }
1110                stop_count = list.len() as u32;
1111            }
1112            LightFalloff::Texture {
1113                slot,
1114                half_extent,
1115                offset,
1116            } => {
1117                texture_slot = *slot;
1118                texture_offset = *offset;
1119                texture_half_extent = *half_extent;
1120            }
1121        }
1122        let half_extent = light.falloff.half_extent(light.radius);
1123        if !half_extent.is_finite()
1124            || !(half_extent.x > 0.0 && half_extent.y > 0.0)
1125            || !light.radius.is_finite()
1126        {
1127            issue(&mut plan, LightIssueKind::ZeroExtent);
1128            continue;
1129        }
1130        if !texture_offset.is_finite() {
1131            issue(&mut plan, LightIssueKind::NonFiniteValue);
1132            continue;
1133        }
1134        let extent = half_extent.length();
1135        let aabb = [
1136            light.position.x - half_extent.x,
1137            light.position.y - half_extent.y,
1138            light.position.x + half_extent.x,
1139            light.position.y + half_extent.y,
1140        ];
1141        if !aabb_overlap(aabb, view_bounds) {
1142            plan.culled += 1;
1143            continue;
1144        }
1145
1146        let mut wedges = Vec::new();
1147        let mut shadowed = false;
1148        if light.shadows && light.shadow_filter.tap_count() > 0 {
1149            let casters = shadow_wedges(
1150                light.position,
1151                extent,
1152                light.shadow_filter,
1153                light.shadow_softness,
1154                light.light_mask,
1155                aabb,
1156                &validated,
1157                budget.max_shadow_casters,
1158                &mut wedges,
1159            );
1160            if casters.dropped > 0 {
1161                issue(
1162                    &mut plan,
1163                    LightIssueKind::ShadowCastersExhausted {
1164                        used: casters.used,
1165                        dropped: casters.dropped,
1166                    },
1167                );
1168            }
1169            let needed = wedges.len() * 6;
1170            if needed > 0 && wedge_vertices + needed > budget.max_shadow_vertices {
1171                wedges.clear();
1172                issue(
1173                    &mut plan,
1174                    LightIssueKind::ShadowVertexBudget {
1175                        needed,
1176                        max: budget.max_shadow_vertices,
1177                    },
1178                );
1179            } else {
1180                wedge_vertices += needed;
1181                shadowed = !wedges.is_empty();
1182            }
1183        }
1184
1185        used_layers = used_layers.max(32 - layers.leading_zeros());
1186        plan.lights.push(PlannedLight {
1187            source,
1188            layers,
1189            center: light.position,
1190            half_extent,
1191            extent,
1192            color: light.color,
1193            energy: light.energy,
1194            blend: light.blend,
1195            stops,
1196            stop_count,
1197            texture_slot,
1198            texture_offset,
1199            texture_half_extent,
1200            shadowed,
1201            shadow_filter: light.shadow_filter,
1202            wedges,
1203        });
1204    }
1205
1206    plan.layers = (used_layers + 1).max(2);
1207    plan.layer_clamp = plan.layers - 1;
1208    plan
1209}
1210
1211fn aabb_of(points: &[Vec2]) -> [f32; 4] {
1212    let mut aabb = [
1213        f32::INFINITY,
1214        f32::INFINITY,
1215        f32::NEG_INFINITY,
1216        f32::NEG_INFINITY,
1217    ];
1218    for p in points {
1219        aabb[0] = aabb[0].min(p.x);
1220        aabb[1] = aabb[1].min(p.y);
1221        aabb[2] = aabb[2].max(p.x);
1222        aabb[3] = aabb[3].max(p.y);
1223    }
1224    aabb
1225}
1226
1227/// Whether two axis-aligned boxes overlap. Both boxes are corners, not
1228/// corner-plus-size: `[min_x, min_y, max_x, max_y]`, the same convention as
1229/// [`Occluder2d::aabb`], a light's footprint in [`plan_lighting`], and its
1230/// visible world rect.
1231fn aabb_overlap(a_min_max: [f32; 4], b_min_max: [f32; 4]) -> bool {
1232    a_min_max[0] <= b_min_max[2]
1233        && a_min_max[2] >= b_min_max[0]
1234        && a_min_max[1] <= b_min_max[3]
1235        && a_min_max[3] >= b_min_max[1]
1236}
1237
1238fn signed_area(points: &[Vec2]) -> f32 {
1239    let n = points.len();
1240    let mut area = 0.0;
1241    for i in 0..n {
1242        let a = points[i];
1243        let b = points[(i + 1) % n];
1244        area += a.x * b.y - b.x * a.y;
1245    }
1246    area * 0.5
1247}
1248
1249/// Proper crossings only: two edges that merely touch at a point are a pinch,
1250/// not a self-intersection, and the wedge geometry survives a pinch.
1251fn self_intersects(points: &[Vec2]) -> bool {
1252    let n = points.len();
1253    for i in 0..n {
1254        let a0 = points[i];
1255        let a1 = points[(i + 1) % n];
1256        for j in (i + 1)..n {
1257            if j == i || (j + 1) % n == i || j == (i + 1) % n {
1258                continue;
1259            }
1260            let b0 = points[j];
1261            let b1 = points[(j + 1) % n];
1262            if segments_cross(a0, a1, b0, b1) {
1263                return true;
1264            }
1265        }
1266    }
1267    false
1268}
1269
1270fn segments_cross(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2) -> bool {
1271    let d1 = cross(b1 - b0, a0 - b0);
1272    let d2 = cross(b1 - b0, a1 - b0);
1273    let d3 = cross(a1 - a0, b0 - a0);
1274    let d4 = cross(a1 - a0, b1 - a0);
1275    d1 * d2 < 0.0 && d3 * d4 < 0.0
1276}
1277
1278fn cross(a: Vec2, b: Vec2) -> f32 {
1279    a.x * b.y - a.y * b.x
1280}
1281
1282fn rotate_about(center: Vec2, p: Vec2, angle: f32) -> Vec2 {
1283    if angle == 0.0 {
1284        return p;
1285    }
1286    let (s, c) = angle.sin_cos();
1287    let d = p - center;
1288    center + Vec2::new(d.x * c - d.y * s, d.x * s + d.y * c)
1289}
1290
1291/// Push `p` away from `c` to `reach` (never closer than its own distance).
1292/// A point exactly at the centre is returned unchanged: the caller's quad
1293/// degenerates to a triangle, which still covers the right angle.
1294fn extrude(c: Vec2, p: Vec2, reach: f32) -> Vec2 {
1295    let d = p - c;
1296    let len = d.length();
1297    if len > 1e-6 {
1298        c + d / len * reach.max(len * 1.05)
1299    } else {
1300        p
1301    }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    fn stops3() -> Vec<LightStop> {
1309        vec![
1310            LightStop {
1311                at: 0.0,
1312                color: [1.0, 1.0, 1.0, 1.0],
1313            },
1314            LightStop {
1315                at: 0.5,
1316                color: [0.5, 0.5, 0.5, 1.0],
1317            },
1318            LightStop {
1319                at: 1.0,
1320                color: [0.0, 0.0, 0.0, 1.0],
1321            },
1322        ]
1323    }
1324
1325    #[test]
1326    fn stops_ramp_interpolates_at_known_distances() {
1327        let ramp = stops3();
1328        assert_eq!(stops_at(&ramp, 0.0), [1.0, 1.0, 1.0, 1.0]);
1329        assert_eq!(stops_at(&ramp, 0.5), [0.5, 0.5, 0.5, 1.0]);
1330        assert_eq!(stops_at(&ramp, 1.0), [0.0, 0.0, 0.0, 1.0]);
1331        // Mid-segment: halfway between stop 0 and stop 1.
1332        let mid = stops_at(&ramp, 0.25);
1333        assert!((mid[0] - 0.75).abs() < 1e-6, "got {mid:?}");
1334        // Clamped outside the ramp; NaN cannot escape.
1335        assert_eq!(stops_at(&ramp, -1.0), [1.0, 1.0, 1.0, 1.0]);
1336        assert_eq!(stops_at(&ramp, 4.0), [0.0, 0.0, 0.0, 1.0]);
1337        assert_eq!(stops_at(&ramp, f32::NAN), [0.0, 0.0, 0.0, 1.0]);
1338        // A single stop is a flat light.
1339        let flat = [LightStop {
1340            at: 0.3,
1341            color: [0.25, 0.25, 0.25, 0.5],
1342        }];
1343        assert_eq!(stops_at(&flat, 0.0), [0.25, 0.25, 0.25, 0.5]);
1344        assert_eq!(stops_at(&flat, 1.0), [0.25, 0.25, 0.25, 0.5]);
1345        // No stops at all is zero light, not a panic.
1346        assert_eq!(stops_at(&[], 0.5), [0.0; 4]);
1347    }
1348
1349    #[test]
1350    fn radius_ramp_matches_stops_at_known_distances() {
1351        let light = Light2d {
1352            position: Vec2::ZERO,
1353            radius: 100.0,
1354            falloff: LightFalloff::stops(stops3()),
1355            ..Default::default()
1356        };
1357        let at = |d: f32| light.falloff_at(d / light.radius).expect("stops ramp");
1358        assert_eq!(at(0.0), [1.0, 1.0, 1.0, 1.0]);
1359        assert_eq!(at(50.0), [0.5, 0.5, 0.5, 1.0]);
1360        assert_eq!(at(100.0), [0.0, 0.0, 0.0, 1.0]);
1361        assert!((at(25.0)[0] - 0.75).abs() < 1e-6);
1362        // Past the rim the ramp keeps its last value: the GPU discards there.
1363        assert_eq!(at(250.0), [0.0, 0.0, 0.0, 1.0]);
1364    }
1365
1366    #[test]
1367    fn texture_falloff_maps_world_to_local_uv() {
1368        let light = Light2d {
1369            position: Vec2::new(100.0, 100.0),
1370            radius: 64.0,
1371            falloff: LightFalloff::Texture {
1372                slot: 0,
1373                half_extent: Vec2::new(64.0, 32.0),
1374                offset: Vec2::new(8.0, -4.0),
1375            },
1376            ..Default::default()
1377        };
1378        assert_eq!(light.falloff_at(0.5), None, "texture ramps stay on the GPU");
1379        let uv = |x: f32, y: f32| light.texture_uv(Vec2::new(x, y));
1380        let center = Vec2::new(108.0, 96.0);
1381        assert!((uv(center.x, center.y) - Vec2::splat(0.5)).length() < 1e-6);
1382        assert!((uv(center.x - 64.0, center.y) - Vec2::new(0.0, 0.5)).length() < 1e-6);
1383        assert!((uv(center.x, center.y + 32.0) - Vec2::new(0.5, 1.0)).length() < 1e-6);
1384        // Outside the footprint stays outside, so the shader can discard.
1385        assert!(uv(center.x + 100.0, center.y).x > 1.0);
1386        // Zero extents cannot produce NaN.
1387        let degenerate = Light2d {
1388            falloff: LightFalloff::Texture {
1389                slot: 0,
1390                half_extent: Vec2::ZERO,
1391                offset: Vec2::ZERO,
1392            },
1393            ..Default::default()
1394        };
1395        assert!(degenerate.texture_uv(Vec2::new(3.0, 4.0)).is_finite());
1396    }
1397
1398    #[test]
1399    fn item_layers_follow_the_lowest_shared_bit() {
1400        assert_eq!(item_layer(1), 0);
1401        assert_eq!(item_layer(0b010), 1);
1402        assert_eq!(item_layer(0b1001), 0);
1403        assert_eq!(item_layer(0b1000_0000), 7);
1404        // Nothing in range reads the identity layer.
1405        assert_eq!(item_layer(0), MAX_LIGHT_LAYERS);
1406        assert_eq!(item_layer(1 << 9), MAX_LIGHT_LAYERS);
1407    }
1408
1409    #[test]
1410    fn mask_filtering_selects_lit_layers() {
1411        let dim = AmbientLight {
1412            color: [1.0, 1.0, 1.0],
1413            energy: 0.25,
1414            light_mask: 0b101,
1415        };
1416        let lights = vec![
1417            Light2d {
1418                position: Vec2::new(0.0, 0.0),
1419                light_mask: 0b011,
1420                ..Default::default()
1421            },
1422            Light2d {
1423                position: Vec2::new(0.0, 0.0),
1424                light_mask: 0b100,
1425                ..Default::default()
1426            },
1427        ];
1428        let plan = plan_lighting(
1429            &lights,
1430            &[],
1431            &dim,
1432            [-100.0, -100.0, 200.0, 200.0],
1433            &LightBudget::default(),
1434        );
1435        assert_eq!(plan.lights.len(), 2);
1436        assert_eq!(plan.lights[0].layer_indices().collect::<Vec<_>>(), [0, 1]);
1437        assert_eq!(plan.lights[1].layer_indices().collect::<Vec<_>>(), [2]);
1438        // Layers up to the highest bit, plus the identity layer.
1439        assert_eq!((plan.layers, plan.layer_clamp), (4, 3));
1440        // An item on bit 1 sees light 0; an item on bit 2 sees light 1 only.
1441        assert_eq!(item_layer(0b010), 1);
1442        assert_eq!(plan.lights[0].layers & (1 << item_layer(0b010)), 1 << 1);
1443        assert_eq!(plan.lights[1].layers & (1 << item_layer(0b010)), 0);
1444        assert_eq!(plan.lights[1].layers & (1 << item_layer(0b100)), 1 << 2);
1445        // Ambient mask 0b101: layers 0 and 2, not 1, and never the identity.
1446        assert_eq!(plan.ambient_rgb(0), [0.25, 0.25, 0.25]);
1447        assert_eq!(plan.ambient_rgb(1), [0.0, 0.0, 0.0]);
1448        assert_eq!(plan.ambient_rgb(2), [0.25, 0.25, 0.25]);
1449        assert_eq!(plan.ambient_rgb(3), [0.0, 0.0, 0.0]);
1450        assert_eq!(plan.ambient_rgb(9), [0.0, 0.0, 0.0]);
1451    }
1452
1453    #[test]
1454    fn zero_mask_lights_and_out_of_range_bits_are_reported() {
1455        let lights = vec![
1456            Light2d {
1457                light_mask: 0,
1458                ..Default::default()
1459            },
1460            Light2d {
1461                light_mask: 0x100,
1462                ..Default::default()
1463            },
1464            Light2d {
1465                light_mask: 0x101,
1466                ..Default::default()
1467            },
1468        ];        let plan = plan_lighting(
1469            &lights,
1470            &[],
1471            &AmbientLight::default(),
1472            [-10.0, -10.0, 20.0, 20.0],
1473            &LightBudget::default(),
1474        );
1475        assert_eq!(plan.lights.len(), 1);
1476        assert_eq!(plan.lights[0].source, 2);
1477        assert_eq!(plan.lights[0].layers, 1);
1478        assert_eq!(
1479            plan.issues,
1480            [
1481                LightIssue {
1482                    source: 0,
1483                    kind: LightIssueKind::ZeroMask
1484                },
1485                LightIssue {
1486                    source: 1,
1487                    kind: LightIssueKind::MaskBeyondLayerRange { dropped: 0x100 }
1488                },
1489                LightIssue {
1490                    source: 1,
1491                    kind: LightIssueKind::ZeroMask
1492                },
1493                LightIssue {
1494                    source: 2,
1495                    kind: LightIssueKind::MaskBeyondLayerRange { dropped: 0x100 }
1496                },
1497            ]
1498        );
1499    }
1500
1501    #[test]
1502    fn degenerate_lights_are_dropped_with_typed_issues() {
1503        let budget = LightBudget::default();
1504        let world = [-1e4, -1e4, 2e4, 2e4];
1505        let ambient = AmbientLight::default();
1506        let cases: Vec<(Light2d, LightIssueKind)> = vec![
1507            (
1508                Light2d {
1509                    radius: 0.0,
1510                    ..Default::default()
1511                },
1512                LightIssueKind::ZeroExtent,
1513            ),
1514            (
1515                Light2d {
1516                    radius: f32::NAN,
1517                    ..Default::default()
1518                },
1519                LightIssueKind::ZeroExtent,
1520            ),
1521            (
1522                Light2d {
1523                    position: Vec2::new(f32::INFINITY, 0.0),
1524                    ..Default::default()
1525                },
1526                LightIssueKind::NonFinitePosition,
1527            ),
1528            (
1529                Light2d {
1530                    color: [f32::NAN, 1.0, 1.0],
1531                    ..Default::default()
1532                },
1533                LightIssueKind::NonFiniteValue,
1534            ),
1535            (
1536                Light2d {
1537                    energy: f32::INFINITY,
1538                    ..Default::default()
1539                },
1540                LightIssueKind::NonFiniteValue,
1541            ),
1542            (
1543                Light2d {
1544                    falloff: LightFalloff::Texture {
1545                        slot: 0,
1546                        half_extent: Vec2::splat(4.0),
1547                        offset: Vec2::new(f32::NAN, 0.0),
1548                    },
1549                    ..Default::default()
1550                },
1551                LightIssueKind::NonFiniteValue,
1552            ),
1553            (
1554                Light2d {
1555                    falloff: LightFalloff::stops(Vec::new()),
1556                    ..Default::default()
1557                },
1558                LightIssueKind::StopsEmpty,
1559            ),
1560            (
1561                Light2d {
1562                    falloff: LightFalloff::stops(vec![LightStop::default(); 9]),
1563                    ..Default::default()
1564                },
1565                LightIssueKind::TooManyStops { count: 9, max: 8 },
1566            ),
1567            (
1568                Light2d {
1569                    falloff: LightFalloff::stops(vec![
1570                        LightStop {
1571                            at: 0.0,
1572                            color: [1.0; 4],
1573                        },
1574                        LightStop {
1575                            at: 0.2,
1576                            color: [1.0; 4],
1577                        },
1578                        LightStop {
1579                            at: 0.1,
1580                            color: [1.0; 4],
1581                        },
1582                    ]),
1583                    ..Default::default()
1584                },
1585                LightIssueKind::StopsNotAscending {
1586                    at: 0.1,
1587                    previous: 0.2,
1588                },
1589            ),
1590            (
1591                Light2d {
1592                    falloff: LightFalloff::stops(vec![
1593                        LightStop {
1594                            at: 0.0,
1595                            color: [1.0; 4],
1596                        },
1597                        LightStop {
1598                            at: 1.5,
1599                            color: [1.0; 4],
1600                        },
1601                    ]),
1602                    ..Default::default()
1603                },
1604                LightIssueKind::StopOutOfRange { at: 1.5 },
1605            ),
1606        ];
1607        for (light, expected) in cases {
1608            let plan = plan_lighting(&[light], &[], &ambient, world, &budget);
1609            assert!(plan.lights.is_empty(), "{expected:?} must not be shaded");
1610            assert_eq!(plan.issues.len(), 1, "{expected:?}");
1611            assert_eq!(plan.issues[0].kind, expected);
1612        }
1613    }
1614
1615    #[test]
1616    fn non_finite_ambient_falls_back_to_black_and_reports_it() {
1617        let dim = AmbientLight {
1618            color: [f32::NAN, 1.0, 1.0],
1619            energy: 1.0,
1620            light_mask: 1,
1621        };
1622        let plan = plan_lighting(
1623            &[Light2d::point(Vec2::ZERO, 8.0)],
1624            &[],
1625            &dim,
1626            [-10.0, -10.0, 20.0, 20.0],
1627            &LightBudget::default(),
1628        );
1629        assert_eq!(plan.ambient_rgb(0), [0.0, 0.0, 0.0], "a NaN never clears");
1630        assert_eq!(
1631            plan.issues,
1632            [LightIssue {
1633                source: AMBIENT_SOURCE,
1634                kind: LightIssueKind::NonFiniteAmbient
1635            }]
1636        );
1637        assert_eq!(plan.lights.len(), 1, "the light itself is unaffected");
1638    }
1639
1640    #[test]
1641    fn occluder_validation_rejects_degenerate_shapes() {
1642        assert_eq!(
1643            Occluder2d::polygon(Vec::new()).validate(),
1644            Err(OccluderRejectReason::TooFewPoints)
1645        );
1646        assert_eq!(
1647            Occluder2d::polygon(vec![Vec2::ZERO, Vec2::X]).validate(),
1648            Err(OccluderRejectReason::TooFewPoints)
1649        );
1650        assert_eq!(
1651            Occluder2d::polygon(vec![Vec2::ZERO, Vec2::X, Vec2::new(f32::NAN, 0.0)]).validate(),
1652            Err(OccluderRejectReason::NonFinitePoint)
1653        );
1654        // Collinear corners: no area to cast from.
1655        assert_eq!(
1656            Occluder2d::polygon(vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)]).validate(),
1657            Err(OccluderRejectReason::DegenerateArea)
1658        );
1659        assert_eq!(
1660            Occluder2d::polygon(vec![Vec2::ZERO, Vec2::ZERO, Vec2::ZERO]).validate(),
1661            Err(OccluderRejectReason::DegenerateArea)
1662        );
1663        // A bowtie crosses itself.
1664        assert_eq!(
1665            Occluder2d::polygon(vec![
1666                Vec2::ZERO,
1667                Vec2::new(2.0, 0.0),
1668                Vec2::new(0.0, 2.0),
1669                Vec2::new(2.0, 2.0),
1670            ])
1671            .validate(),
1672            Err(OccluderRejectReason::SelfIntersecting)
1673        );
1674        assert_eq!(
1675            Occluder2d::polygon(vec![Vec2::ZERO; MAX_OCCLUDER_POINTS + 1]).validate(),
1676            Err(OccluderRejectReason::TooManyPoints)
1677        );
1678        // A mask of zero shadows nothing, so it is not a caster.
1679        assert_eq!(
1680            Occluder2d::rect_masked(Vec2::ZERO, Vec2::splat(4.0), 0).validate(),
1681            Err(OccluderRejectReason::ZeroMask)
1682        );
1683        // Valid shapes pass, and keep their mask and bounds.
1684        let valid = ValidatedOccluder::validate(&Occluder2d::rect_masked(
1685            Vec2::new(-1.0, -2.0),
1686            Vec2::new(3.0, 4.0),
1687            0b101,
1688        ))
1689        .expect("rect is valid");
1690        assert_eq!(valid.light_mask(), 0b101);
1691        assert_eq!(valid.aabb(), [-1.0, -2.0, 3.0, 4.0]);
1692        assert!(valid.closed());
1693        let open = ValidatedOccluder::validate(&Occluder2d {
1694            points: vec![Vec2::ZERO, Vec2::new(10.0, 0.0)],
1695            closed: false,
1696            light_mask: 1,
1697        });
1698        assert!(open.is_ok(), "an open polyline is a thin wall, not a reject");
1699        let pinched = Occluder2d::polygon(vec![
1700            Vec2::ZERO,
1701            Vec2::new(4.0, 0.0),
1702            Vec2::new(2.0, 2.0),
1703            Vec2::new(4.0, 4.0),
1704            Vec2::new(0.0, 4.0),
1705            Vec2::new(2.0, 2.0),
1706        ]);
1707        assert!(
1708            pinched.validate().is_ok(),
1709            "touching at a point is not a self-crossing"
1710        );
1711        // The rejected occluders land in the plan's report, once.
1712        let plan = plan_lighting(
1713            &[],
1714            &[
1715                Occluder2d::polygon(Vec::new()),
1716                Occluder2d::rect(Vec2::ZERO, Vec2::splat(1.0)),
1717            ],
1718            &AmbientLight::default(),
1719            [-10.0, -10.0, 20.0, 20.0],
1720            &LightBudget::default(),
1721        );
1722        assert_eq!(
1723            plan.occluder_rejects,
1724            [OccluderReject {
1725                index: 0,
1726                reason: OccluderRejectReason::TooFewPoints
1727            }]
1728        );
1729    }
1730
1731    #[test]
1732    fn over_budget_lights_drop_in_input_order() {
1733        let lights: Vec<Light2d> = (0..5)
1734            .map(|i| Light2d {
1735                position: Vec2::new(i as f32 * 4.0, 0.0),
1736                radius: 8.0,
1737                ..Default::default()
1738            })
1739            .collect();
1740        let budget = LightBudget {
1741            max_lights: 2,
1742            ..Default::default()
1743        };
1744        let world = [-100.0, -100.0, 200.0, 200.0];
1745        let first = plan_lighting(
1746            &lights,
1747            &[],
1748            &AmbientLight::default(),
1749            world,
1750            &budget,
1751        );
1752        let again = plan_lighting(
1753            &lights,
1754            &[],
1755            &AmbientLight::default(),
1756            world,
1757            &budget,
1758        );
1759        assert_eq!(first, again, "the plan is deterministic");
1760        assert_eq!(
1761            first
1762                .lights
1763                .iter()
1764                .map(|light| light.source)
1765                .collect::<Vec<_>>(),
1766            [0, 1]
1767        );
1768        assert_eq!(
1769            first
1770                .issues
1771                .iter()
1772                .map(|issue| (issue.source, issue.kind))
1773                .collect::<Vec<_>>(),
1774            [
1775                (2, LightIssueKind::OverLightBudget { max: 2 }),
1776                (3, LightIssueKind::OverLightBudget { max: 2 }),
1777                (4, LightIssueKind::OverLightBudget { max: 2 }),
1778            ]
1779        );
1780        // Culling is counted, not reported as a budget drop.
1781        let offscreen = LightBudget::default();
1782        let plan = plan_lighting(
1783            &[Light2d::point(Vec2::new(10_000.0, 10_000.0), 4.0)],
1784            &[],
1785            &AmbientLight::default(),
1786            [-10.0, -10.0, 20.0, 20.0],
1787            &offscreen,
1788        );
1789        assert!(plan.lights.is_empty() && plan.issues.is_empty() && plan.culled == 1);
1790    }
1791
1792    #[test]
1793    fn light_on_an_occluder_vertex_keeps_finite_wedges() {
1794        // The light sits exactly on the occluder's corner (10, 5): two edges
1795        // start there, and neither may produce a NaN direction.
1796        let light = Light2d {
1797            position: Vec2::new(10.0, 5.0),
1798            radius: 10.0,
1799            shadows: true,
1800            ..Default::default()
1801        };
1802        let occluder = Occluder2d::rect(Vec2::new(10.0, -5.0), Vec2::new(20.0, 5.0));
1803        let valid = ValidatedOccluder::validate(&occluder).expect("rect is valid");
1804        let mut wedges = Vec::new();
1805        let casters = shadow_wedges(
1806            light.position,
1807            light.radius,
1808            ShadowFilter::None,
1809            light.shadow_softness,
1810            light.light_mask,
1811            occluder.aabb(),
1812            std::slice::from_ref(&valid),
1813            64,
1814            &mut wedges,
1815        );
1816        assert_eq!((casters.used, casters.dropped), (1, 0));
1817        assert_eq!(wedges.len(), 4, "one wedge per edge");
1818        for wedge in &wedges {
1819            assert!(
1820                wedge.quad.iter().all(|p| p.is_finite()),
1821                "no NaN from a light exactly on a vertex: {:?}",
1822                wedge.quad
1823            );
1824        }
1825        // The two edges that meet at the light keep that corner pinned: the
1826        // quad degenerates to a triangle instead of extruding a NaN.
1827        let corner = light.position;
1828        let touching = wedges
1829            .iter()
1830            .filter(|w| w.quad.iter().filter(|p| **p == corner).count() == 2)
1831            .count();
1832        assert_eq!(touching, 2, "the two edges at the vertex stay finite");
1833        // The far corners still extrude, so the shadow behind the wall is
1834        // covered rather than collapsing onto the light.
1835        assert!(wedges.iter().any(|w| w.quad[1].x > 20.0 || w.quad[2].x > 20.0));
1836        // With a soft filter the taps also stay finite and sum to one.
1837        let mut soft = Vec::new();
1838        shadow_wedges(
1839            light.position,
1840            light.radius,
1841            ShadowFilter::Pcf4,
1842            4.0,
1843            light.light_mask,
1844            occluder.aabb(),
1845            std::slice::from_ref(&valid),
1846            64,
1847            &mut soft,
1848        );
1849        assert_eq!(soft.len(), 16, "four taps per edge");
1850        for (index, wedge) in soft.iter().enumerate() {
1851            assert!(wedge.quad.iter().all(|p| p.is_finite()));
1852            assert!((wedge.weight - 0.25).abs() < 1e-6);
1853            // Taps cycle inside each edge's run, and every wedge names its own
1854            // mask channel with its weight in it.
1855            assert_eq!(wedge.tap, index as u32 % 4);
1856            let mut want = [0.0; MAX_MASK_TAPS];
1857            want[wedge.tap as usize] = 0.25;
1858            assert_eq!(wedge.mask_weights(), want);
1859        }
1860    }
1861
1862    #[test]
1863    fn wedges_cover_the_shadow_behind_a_wall_and_stop_at_the_light() {
1864        // Light left of a wall; the shadow is the wall's projection away from
1865        // the light, and nothing between light and wall is shadowed.
1866        let light = Light2d {
1867            position: Vec2::new(-20.0, 0.0),
1868            radius: 100.0,
1869            shadows: true,
1870            ..Default::default()
1871        };
1872        let wall = Occluder2d::rect(Vec2::new(0.0, -10.0), Vec2::new(10.0, 10.0));
1873        let valid = ValidatedOccluder::validate(&wall).expect("rect is valid");
1874        let mut wedges = Vec::new();
1875        shadow_wedges(
1876            light.position,
1877            light.radius,
1878            ShadowFilter::None,
1879            0.0,
1880            light.light_mask,
1881            wall.aabb(),
1882            std::slice::from_ref(&valid),
1883            64,
1884            &mut wedges,
1885        );
1886        let covered = |p: Vec2| {
1887            wedges
1888                .iter()
1889                .any(|w| point_in_quad(p, w.quad[0], w.quad[1], w.quad[2], w.quad[3]))
1890        };
1891        assert!(covered(Vec2::new(20.0, 0.0)), "behind the wall");
1892        assert!(covered(Vec2::new(5.0, 0.0)), "inside the wall is solid");
1893        assert!(!covered(Vec2::new(-1.0, 0.0)), "in front of the wall stays lit");
1894        // Culling by mask: a wall on another layer casts nothing here.
1895        let mut masked = Vec::new();
1896        let other = ValidatedOccluder::validate(&Occluder2d::rect_masked(
1897            Vec2::new(0.0, -10.0),
1898            Vec2::new(10.0, 10.0),
1899            0b010,
1900        ))
1901        .expect("rect is valid");
1902        let casters = shadow_wedges(
1903            light.position,
1904            light.radius,
1905            ShadowFilter::None,
1906            0.0,
1907            light.light_mask,
1908            wall.aabb(),
1909            std::slice::from_ref(&other),
1910            64,
1911            &mut masked,
1912        );
1913        assert_eq!((casters.used, masked.len()), (0, 0));
1914        // The per-light caster budget is applied in slice order.
1915        let mut drops = Vec::new();
1916        let many: Vec<ValidatedOccluder> = (0..4)
1917            .map(|i| {
1918                ValidatedOccluder::validate(&Occluder2d::rect(
1919                    Vec2::new(i as f32 - 1.0, -1.0),
1920                    Vec2::new(i as f32, 1.0),
1921                ))
1922                .expect("valid")
1923            })
1924            .collect();
1925        let casters = shadow_wedges(
1926            light.position,
1927            light.radius,
1928            ShadowFilter::None,
1929            0.0,
1930            light.light_mask,
1931            [-4.0, -4.0, 8.0, 8.0],
1932            &many,
1933            2,
1934            &mut drops,
1935        );
1936        assert_eq!((casters.used, casters.dropped), (2, 2));
1937        assert_eq!(drops.len(), 2 * 4);
1938    }
1939
1940    #[test]
1941    fn shadow_vertex_budget_unshadows_loudly() {
1942        let mut lights = Vec::new();
1943        for i in 0..2 {
1944            let mut light = Light2d::point(Vec2::new(i as f32 * 4.0, 0.0), 20.0);
1945            light.shadows = true;
1946            lights.push(light);
1947        }
1948        let budget = LightBudget {
1949            max_shadow_vertices: 32,
1950            ..Default::default()
1951        };
1952        let plan = plan_lighting(
1953            &lights,
1954            &[Occluder2d::rect(Vec2::new(1.0, -2.0), Vec2::new(2.0, 2.0))],
1955            &AmbientLight::default(),
1956            [-100.0, -100.0, 200.0, 200.0],
1957            &budget,
1958        );
1959        assert_eq!(plan.lights.len(), 2);
1960        // The first light's 24 vertices fit; the second's do not.
1961        assert!(plan.lights[0].shadowed);
1962        assert!(!plan.lights[1].shadowed);
1963        assert_eq!(
1964            plan.issues,
1965            [LightIssue {
1966                source: 1,
1967                kind: LightIssueKind::ShadowVertexBudget {
1968                    needed: 24,
1969                    max: 32
1970                }
1971            }]
1972        );
1973    }
1974
1975    /// Both boxes of `aabb_overlap` are corners, not corner-plus-size. Reading
1976    /// the second one as a size makes a box whose min is large positive overlap
1977    /// one it is nowhere near, and skips one whose min is negative.
1978    #[test]
1979    fn aabb_overlap_reads_min_max_corners() {
1980        assert!(!aabb_overlap([70.0, 70.0, 80.0, 80.0], [50.0, 50.0, 60.0, 60.0]));
1981        assert!(aabb_overlap([0.0, 0.0, 10.0, 10.0], [-100.0, -100.0, 50.0, 50.0]));
1982        assert!(aabb_overlap([0.0, 0.0, 10.0, 10.0], [5.0, 5.0, 15.0, 15.0]));
1983        assert!(
1984            aabb_overlap([0.0, 0.0, 10.0, 10.0], [10.0, 10.0, 20.0, 20.0]),
1985            "touching corners count as overlap"
1986        );
1987        assert!(!aabb_overlap([0.0, 0.0, 10.0, 10.0], [11.0, 0.0, 20.0, 10.0]));
1988        assert!(!aabb_overlap([0.0, 0.0, 10.0, 10.0], [0.0, 11.0, 10.0, 20.0]));
1989    }
1990
1991    /// The scene that caught the bounds convention: a light at the origin with
1992    /// its caster to the `+x` side (where `2 * light.position` is zero, so a
1993    /// size read skipped every caster) and a light inside a panned view (whose
1994    /// min is negative, so a size read of the view culled it).
1995    #[test]
1996    fn casters_and_lights_are_bounds_tested_against_corners() {
1997        let light = Light2d {
1998            position: Vec2::ZERO,
1999            radius: 24.0,
2000            shadows: true,
2001            shadow_filter: ShadowFilter::None,
2002            ..Default::default()
2003        };
2004        let occluder = Occluder2d::rect(Vec2::new(8.0, -4.0), Vec2::new(16.0, 4.0));
2005        let plan = plan_lighting(
2006            std::slice::from_ref(&light),
2007            std::slice::from_ref(&occluder),
2008            &AmbientLight::default(),
2009            [-64.0, -64.0, 128.0, 128.0],
2010            &LightBudget::default(),
2011        );
2012        assert_eq!(plan.culled, 0);
2013        assert_eq!(plan.lights.len(), 1);
2014        let planned = &plan.lights[0];
2015        assert!(planned.shadowed, "the caster past the light was skipped");
2016        assert_eq!(planned.wedges.len(), 4, "one wedge per rect edge, not none");
2017        // The point behind the caster is in the shadow, so the wedges that
2018        // survived are the real ones.
2019        let behind = Vec2::new(30.0, 0.0);
2020        assert!(
2021            planned.wedges.iter().any(|w| point_in_quad(
2022                behind,
2023                w.quad[0],
2024                w.quad[1],
2025                w.quad[2],
2026                w.quad[3]
2027            )),
2028            "no wedge covers the point behind the caster"
2029        );
2030
2031        // The same convention culls lights against the view: this one sits
2032        // inside a camera panned to negative coordinates.
2033        let panned = plan_lighting(
2034            &[Light2d::point(Vec2::new(-350.0, -275.0), 10.0)],
2035            &[],
2036            &AmbientLight::default(),
2037            [-400.0, -300.0, 100.0, 50.0],
2038            &LightBudget::default(),
2039        );
2040        assert_eq!(panned.culled, 0, "a light inside the view was culled");
2041        assert_eq!(panned.lights.len(), 1);
2042    }
2043
2044    #[test]
2045    fn visible_world_rect_inverts_a_y_down_camera() {
2046        let camera = crate::screen_camera([800.0, 600.0]);
2047        let rect = visible_world_rect(camera).expect("an ortho camera has a rect");
2048        assert!((rect[0]).abs() < 1e-3 && (rect[1]).abs() < 1e-3);
2049        assert!((rect[2] - 800.0).abs() < 1e-3 && (rect[3] - 600.0).abs() < 1e-3);
2050        assert_eq!(visible_world_rect(Mat4::ZERO), None);
2051        assert_eq!(visible_world_rect(Mat4::from_scale(Vec3::ZERO)), None);
2052    }
2053
2054    #[test]
2055    fn occluder_contains_matches_solid_polygons() {
2056        let wall = Occluder2d::rect(Vec2::new(2.0, -2.0), Vec2::new(5.0, 2.0));
2057        assert!(wall.contains(Vec2::new(3.0, 0.0)));
2058        assert!(!wall.contains(Vec2::new(1.0, 0.0)));
2059        assert!(!Occluder2d::polygon(vec![Vec2::ZERO, Vec2::X]).contains(Vec2::ZERO));
2060        // A cell is one cell wide and aligned to the grid.
2061        let cell = Occluder2d::cell([-1, 2], 8.0);
2062        assert_eq!(cell.aabb(), [-8.0, 16.0, 0.0, 24.0]);
2063    }
2064
2065    /// Point-in-convex-quad for the wedge coverage assertions above.
2066    fn point_in_quad(p: Vec2, a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> bool {
2067        let side = |p: Vec2, q: Vec2, r: Vec2| cross(q - p, r - p);
2068        let s1 = side(a, b, p) >= 0.0;
2069        let s2 = side(b, c, p) >= 0.0;
2070        let s3 = side(c, d, p) >= 0.0;
2071        let s4 = side(d, a, p) >= 0.0;
2072        (s1 && s2 && s3 && s4) || (!s1 && !s2 && !s3 && !s4)
2073    }
2074}