Skip to main content

repame_view2d/
lightpass.rs

1//! GPU light accumulation: [`Light2dPass`] builds a light map, batches consume
2//! it.
3//!
4//! # Technique
5//!
6//! Derived from Godot 4's canvas light path
7//! (`RendererCanvasRenderRD::light_update_shadow`, `renderer_canvas_render_rd.cpp`,
8//! and `light_blend_compute` in `shaders/canvas.glsl`), rearranged for a
9//! batched 2D renderer:
10//!
11//! * Godot renders occluders from each light into a shadow atlas and the item
12//!   shader ray-marches that map while it loops the lights. A sprite batch
13//!   cannot afford a per-item light loop (that is one light's worth of work
14//!   *per sprite*), so this crate accumulates instead: every frame the pass
15//!   renders the lights into an offscreen light map and the sprite shader
16//!   samples it once per fragment. Cost is per light, never per sprite.
17//! * Shadows are built on the CPU as shadow-volume wedges (one quad per
18//!   occluder edge, extruded away from the light) and rasterised into a small
19//!   per-light mask, which that light's own quad then samples. The mask holds
20//!   one channel per filter tap, max-blended so the edges overlapped by one
21//!   tap are a union, and the light shader sums the channels, which is the
22//!   taps' average. Godot instead draws the occluders into a polar distance
23//!   map and marches it; wedges were chosen here because they are exact at any
24//!   occlusion *depth* (a wall behind a wall needs no extra storage), cost one
25//!   extra pass per shadowed light, and are pure data the CPU tests can check
26//!   without a GPU. Geometry is clipped by the rasteriser, so a wedge that
27//!   runs off the light's footprint is free.
28//! * `LightBlend` is Godot's `light_blend_compute`, expressed as
29//!   fixed-function blend states on an `(rgb = light, a = item multiplier)`
30//!   accumulator, and the ambient light is the clear value of each layer,
31//!   before any light is added.
32//! * `light_mask` is Godot's `item_cull_mask`: a light is accumulated once per
33//!   layer bit it sets and an item reads the layer of its own lowest bit, so
34//!   "the light and the item share a bit" is exactly Godot's rule.
35//!
36//! # Frame structure
37//!
38//! `prepare` records everything into the renderer's prepare encoder, which the
39//! renderer submits before the main pass, so the light map is ready by the
40//! time any batch paints, whatever order the nodes were in. Per frame: one
41//! clear pass per layer, then per light one mask pass (shadowed lights only)
42//! and one load-and-draw pass per layer bit. The light map is world-space (its
43//! uv comes from world coordinates), so a consumer needs no rect arithmetic
44//! and a sub-rectangle batch lines up as long as it shares this pass's camera.
45//!
46//! A frame with no lights and a black ambient renders nothing and publishes
47//! nothing, and consumers fall back to the identity map (unlit).
48//!
49//! # What is verified here
50//!
51//! Everything CPU-side is: the plan, the wedge geometry, the vertex and
52//! uniform packing, and the scissor maths have tests. The WGSL is *not*
53//! executed anywhere in this crate's checks (there is no GPU in CI): it is
54//! kept to straight-line maths derived from the reference above, and the
55//! pipeline state it depends on is asserted by construction (formats, blend
56//! factors, one render pass per attachment) rather than by rendering.
57
58use std::collections::{HashMap, HashSet};
59use std::sync::Arc;
60
61use bytemuck::{Pod, Zeroable};
62use glam::{Mat4, Vec2};
63use repose_render_wgpu::{CallbackResources, ScreenDescriptor, WgpuCallback};
64use wgpu::util::DeviceExt;
65
66use super::TextureFilter;
67use super::light::{
68    AmbientLight, Light2d, LightBlend, LightBudget, LightingPlan, MAX_MASK_TAPS, Occluder2d,
69    plan_lighting, visible_world_rect,
70};
71
72/// Falloff texture upload: tight `w`*`h`*4 RGBA8 rows, top first, mirroring
73/// [`FullscreenTexture`](crate::FullscreenTexture). The texture's `rgb` scales
74/// the light colour and its `a` scales the ramp; a light selects one with
75/// [`LightFalloff::Texture::slot`]. Malformed uploads are dropped with a
76/// warning.
77#[derive(Clone, Debug)]
78pub struct LightTextureUpload {
79    /// Texture slot a light names.
80    pub slot: u32,
81    pub w: u32,
82    pub h: u32,
83    pub rgba: Vec<u8>,
84}
85
86/// Construction parameters for one [`Light2dPass`].
87#[derive(Clone, Copy, Debug)]
88pub struct Light2dDesc {
89    /// Light map resolution as a fraction of the render target, clamped to
90    /// `0.25..=1.0`. Half resolution is usually indistinguishable and a
91    /// quarter of the memory.
92    pub lightmap_scale: f32,
93    /// Shadow mask edge in pixels, clamped to `64..=2048`. The mask is
94    /// stretched over each light's footprint, so a smaller mask softens shadow
95    /// edges and never moves a shadow.
96    pub mask_size: u32,
97    /// Sampling for falloff textures; the shadow mask is always linear.
98    pub filter: TextureFilter,
99    /// Generation of the queued textures. Uploads are applied when it changes,
100    /// so a game that queues its level's textures once pays for them once.
101    pub textures_gen: u64,
102    /// Per-frame limits, enforced by
103    /// [`plan_lighting`](crate::light::plan_lighting) with typed issues.
104    pub budget: LightBudget,
105}
106
107impl Default for Light2dDesc {
108    fn default() -> Self {
109        Self {
110            lightmap_scale: 0.5,
111            mask_size: 512,
112            filter: TextureFilter::Linear,
113            textures_gen: 0,
114            budget: LightBudget::default(),
115        }
116    }
117}
118
119/// The light map one frame produced, as its consumers see it.
120///
121/// Published in [`CallbackResources`] under the pass id and read back by
122/// [`SpriteBatch::set_light_source`](crate::SpriteBatch::set_light_source).
123/// `view` is a `texture_2d_array<f32>` of `layer_clamp + 1` layers; the last
124/// layer is always cleared to `(0, 0, 0, 1)`, the identity for the consumer's
125/// maths, so an item that is lit by nothing can simply be pointed at it.
126pub struct LightmapInfo {
127    /// Accumulator array texture view (`Rgba8Unorm`, one layer per mask bit).
128    pub view: wgpu::TextureView,
129    /// World rect the map covers: `[x, y, w, h]`, uv origin at its top-left.
130    pub world_rect: [f32; 4],
131    /// Highest layer an item may sample (the identity layer).
132    pub layer_clamp: u32,
133    /// Bumped whenever the underlying texture is recreated. Consumers compare
134    /// it to know when a cached bind group is stale; the rect changes every
135    /// frame and travels in the consumer's own uniform instead.
136    pub generation: u64,
137}
138
139/// Read the light map a pass published this frame, if any.
140///
141/// Consumers call this from the renderer's `finish_prepare` hook, which runs
142/// after every callback's `prepare`, so scene order between a pass and its
143/// consumers does not matter; the first frame a pass exists consumers fall
144/// back to the identity map.
145pub fn lookup_lightmap(resources: &CallbackResources, id: &str) -> Option<Arc<LightmapInfo>> {
146    resources.get::<LightResources>()?.maps.get(id).cloned()
147}
148
149/// Marker whose `TypeId`, together with [`SHARED_CALLBACK_KEY`], puts
150/// [`Light2dPass`] and the [`SpriteBatch`](crate::SpriteBatch)es that consume
151/// it in one renderer callback resource scope.
152///
153/// The renderer keys callback resources by
154/// `(resource_type_id, resource_key, target)`, and the defaults make each
155/// callback type its own scope, so a producer/consumer pair has to report one
156/// identity to see each other's resources. Both ends also have to render into
157/// the same target, which is part of the documented contract anyway.
158pub(crate) struct LightScope;
159
160/// The shared resource key of the sprite batch and the light pass; see
161/// [`LightScope`].
162pub(crate) const SHARED_CALLBACK_KEY: &str = "repame.sprite_batch.resources";
163
164/// A light pass's CPU snapshot: camera, lights, occluders, ambient light, and
165/// the level's falloff textures. Rebuilt every frame exactly like
166/// [`SpriteBatch`](crate::SpriteBatch); `Send + Sync` for the compositor
167/// thread.
168///
169/// Like [`FullscreenPass`](crate::FullscreenPass), an id keeps its pipelines,
170/// shadow mask, light map and falloff textures alive for the life of the
171/// renderer, so a game should reuse a handful of ids (one per viewport) and
172/// vary the snapshot, not mint a new id per frame.
173///
174/// ```ignore
175/// // One pass per viewport, then point every batch that shares its camera at
176/// // it. Both nodes fill the target; scene order between them does not matter.
177/// let mut lights = Light2dPass::with_id("main.lights", Light2dDesc::default());
178/// lights.set_camera(camera.fit_matrix(canvas_dp, world_size));
179/// lights.set_ambient(AmbientLight { color: DUSK, energy: 1.0, light_mask: 0b1 });
180/// lights.push_light(Light2d {
181///     position: player.pos,
182///     radius: 148.0,
183///     falloff: LightFalloff::stops(vec![
184///         LightStop { at: 0.0, color: [1.0, 0.94, 0.7, 1.0] },
185///         LightStop { at: 0.4, color: [0.95, 0.78, 0.45, 0.5] },
186///         LightStop { at: 1.0, color: [0.0, 0.0, 0.0, 0.0] },
187///     ]),
188///     shadows: true,
189///     shadow_filter: ShadowFilter::Pcf4,
190///     shadow_softness: 8.0,
191///     ..Default::default()
192/// });
193/// for wall in level.walls {
194///     lights.push_occluder(Occluder2d::rect(wall.min, wall.max));
195/// }
196///
197/// let mut batch = SpriteBatch::new(BatchDesc::default());
198/// batch.set_camera(camera.fit_matrix(canvas_dp, world_size));
199/// batch.set_light_source(lights.id());
200/// for sprite in frame.sprites {
201///     batch.push_sprite(&sprite);   // `sprite.light_mask` picks its layer
202/// }
203///
204/// let full = Rect { x: 0.0, y: 0.0, w: canvas_dp[0], h: canvas_dp[1] };
205/// vec![
206///     lights.node(full),
207///     BatchFrame::new(batch, uploads.clone()).node(full),
208/// ]
209/// ```
210pub struct Light2dPass {
211    id: String,
212    desc: Light2dDesc,
213    camera: Mat4,
214    lights: Vec<Light2d>,
215    occluders: Vec<Occluder2d>,
216    ambient: AmbientLight,
217    textures: Vec<LightTextureUpload>,
218}
219
220impl Light2dPass {
221    /// Default id (`light2d.default`); use [`Self::with_id`] when a scene has
222    /// several viewports.
223    pub fn new(desc: Light2dDesc) -> Self {
224        Self::with_id("light2d.default", desc)
225    }
226
227    pub fn with_id(id: impl Into<String>, desc: Light2dDesc) -> Self {
228        Self {
229            id: id.into(),
230            desc,
231            camera: Mat4::IDENTITY,
232            lights: Vec::new(),
233            occluders: Vec::new(),
234            ambient: AmbientLight::default(),
235            textures: Vec::new(),
236        }
237    }
238
239    /// The pass id a batch names in
240    /// [`set_light_source`](crate::SpriteBatch::set_light_source).
241    pub fn id(&self) -> &str {
242        &self.id
243    }
244
245    /// View-projection of the world this light map covers. It must be the same
246    /// camera the consuming batches use, or the light map lands offset from
247    /// what it lights.
248    pub fn set_camera(&mut self, view_proj: Mat4) {
249        self.camera = view_proj;
250    }
251
252    /// Drop last frame's lights and occluders. Textures stay: they are level
253    /// data, applied when `textures_gen` changes.
254    pub fn clear(&mut self) {
255        self.lights.clear();
256        self.occluders.clear();
257    }
258
259    pub fn push_light(&mut self, light: Light2d) {
260        self.lights.push(light);
261    }
262
263    pub fn extend_lights(&mut self, lights: impl IntoIterator<Item = Light2d>) {
264        self.lights.extend(lights);
265    }
266
267    pub fn push_occluder(&mut self, occluder: Occluder2d) {
268        self.occluders.push(occluder);
269    }
270
271    pub fn extend_occluders(&mut self, occluders: impl IntoIterator<Item = Occluder2d>) {
272        self.occluders.extend(occluders);
273    }
274
275    /// The global light every lit layer starts from.
276    pub fn set_ambient(&mut self, ambient: AmbientLight) {
277        self.ambient = ambient;
278    }
279
280    /// Queue one falloff texture, applied when `textures_gen` changes.
281    pub fn queue_texture(&mut self, upload: LightTextureUpload) {
282        self.textures.push(upload);
283    }
284
285    pub fn extend_textures(&mut self, uploads: impl IntoIterator<Item = LightTextureUpload>) {
286        self.textures.extend(uploads);
287    }
288
289    /// The frame's plan, without a GPU: what will be lit, occluded, dropped,
290    /// and how many layers the accumulator needs. Pure, so a game (or a test)
291    /// can assert on the frame before it draws.
292    pub fn plan(&self) -> LightingPlan {
293        plan_lighting(
294            &self.lights,
295            &self.occluders,
296            &self.ambient,
297            visible_world_rect(self.camera).unwrap_or([0.0, 0.0, 1.0, 1.0]),
298            &self.desc.budget,
299        )
300    }
301
302    /// Scene node for this pass. Its rect is presentation only: the light map
303    /// is sized from the render target, so `fill_max_size` (or any non-empty
304    /// rect) is fine.
305    pub fn node(self, rect: repose_core::Rect) -> repose_core::SceneNode {
306        repose_core::SceneNode::Callback {
307            rect,
308            payload: repose_render_wgpu::Callback::new(self),
309        }
310    }
311
312    /// Report each distinct problem once per pass id, so a per-frame mistake is
313    /// loud without flooding the log.
314    fn warn_once(resources: &mut CallbackResources, id: &str, message: String) {
315        let all = resources.get_or_insert_with::<LightResources>();
316        if all.warned.insert((id.to_string(), message.clone())) {
317            log::warn!("light2d[{id}]: {message}");
318        }
319    }
320}
321
322/// World-space vertex shared by the mask and light pipelines: a clip-space
323/// position the CPU baked for the pass at hand, the world position of the same
324/// corner, and the corner's shadow mask value (`0` on light quads).
325///
326/// One mask channel per [`ShadowFilter`] tap: a wedge puts its
327/// [`ShadowWedge::weight`](crate::light::ShadowWedge::weight) in its own tap's
328/// channel and zero in the others, so the mask pass max-blends overlapping
329/// wedges of a tap into a union while the light shader sums the taps. See
330/// [`shadow_wedges`](crate::light::shadow_wedges).
331#[repr(C)]
332#[derive(Clone, Copy, Debug, Pod, Zeroable)]
333struct LightVertex {
334    clip: [f32; 2],
335    world: [f32; 2],
336    weights: [f32; MAX_MASK_TAPS],
337}
338
339/// Matches `shaders/light_disc.wgsl::Light` field for field.
340#[repr(C)]
341#[derive(Clone, Copy, Debug, Pod, Zeroable)]
342struct LightUniform {
343    center_half: [f32; 4],
344    color_energy: [f32; 4],
345    params: [f32; 4],
346    mask_xform: [f32; 4],
347    tex: [f32; 4],
348    stop_at: [[f32; 4]; 2],
349    stop_rgba: [[f32; 4]; 8],
350}
351
352const _: () = assert!(size_of::<LightUniform>() == 240);
353const _: () = assert!(size_of::<LightVertex>() == 32);
354
355struct RampTexture {
356    /// Written by uploads; the view lives in the slot's bind group.
357    texture: wgpu::Texture,
358    size: [u32; 2],
359}
360
361struct LightTarget {
362    size: [u32; 2],
363    layers: u32,
364    /// Kept alive while layer views and the published info exist.
365    #[allow(dead_code)]
366    texture: wgpu::Texture,
367    views: Vec<wgpu::TextureView>,
368    info: Arc<LightmapInfo>,
369}
370
371struct PassEntry {
372    pipeline_mask: wgpu::RenderPipeline,
373    /// Add, sub, mix (`blend_index`).
374    pipelines: [wgpu::RenderPipeline; 3],
375    ramp_layout: wgpu::BindGroupLayout,
376    vertices: wgpu::Buffer,
377    vertex_cap: usize,
378    uniforms: wgpu::Buffer,
379    uniform_bind: wgpu::BindGroup,
380    uniform_stride: u32,
381    uniform_slots: usize,
382    #[allow(dead_code)]
383    mask_texture: wgpu::Texture,
384    mask_view: wgpu::TextureView,
385    mask_bind: wgpu::BindGroup,
386    mask_bind_empty: wgpu::BindGroup,
387    ramp_bind_empty: wgpu::BindGroup,
388    ramp_binds: HashMap<u32, (RampTexture, wgpu::BindGroup)>,
389    applied_textures_gen: Option<u64>,
390    sampler: wgpu::Sampler,
391    target: Option<LightTarget>,
392    generation: u64,
393}
394
395struct LightResources {
396    passes: HashMap<String, PassEntry>,
397    maps: HashMap<String, Arc<LightmapInfo>>,
398    warned: HashSet<(String, String)>,
399}
400
401impl Default for LightResources {
402    fn default() -> Self {
403        Self {
404            passes: HashMap::new(),
405            maps: HashMap::new(),
406            warned: HashSet::new(),
407        }
408    }
409}
410
411const ADD: wgpu::BlendComponent = wgpu::BlendComponent {
412    src_factor: wgpu::BlendFactor::SrcAlpha,
413    dst_factor: wgpu::BlendFactor::One,
414    operation: wgpu::BlendOperation::Add,
415};
416
417const KEEP_MULTIPLIER: wgpu::BlendComponent = wgpu::BlendComponent {
418    src_factor: wgpu::BlendFactor::Zero,
419    dst_factor: wgpu::BlendFactor::One,
420    operation: wgpu::BlendOperation::Add,
421};
422
423/// One [`LightBlend`] as fixed-function state on `(rgb = light, a = item
424/// multiplier)`. See the module docs for the derivation.
425fn blend_state(blend: LightBlend) -> wgpu::BlendState {
426    match blend {
427        LightBlend::Add => wgpu::BlendState {
428            color: ADD,
429            alpha: KEEP_MULTIPLIER,
430        },
431        // Godot: `color -= light`. The accumulator is 8-bit, so this clamps at
432        // black exactly like a per-item subtract on an 8-bit target would.
433        LightBlend::Sub => wgpu::BlendState {
434            color: wgpu::BlendComponent {
435                operation: wgpu::BlendOperation::ReverseSubtract,
436                ..ADD
437            },
438            alpha: KEEP_MULTIPLIER,
439        },
440        LightBlend::Mix => wgpu::BlendState {
441            color: wgpu::BlendComponent {
442                src_factor: wgpu::BlendFactor::One,
443                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
444                operation: wgpu::BlendOperation::Add,
445            },
446            alpha: wgpu::BlendComponent {
447                src_factor: wgpu::BlendFactor::Zero,
448                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
449                operation: wgpu::BlendOperation::Add,
450            },
451        },
452    }
453}
454
455fn blend_index(blend: LightBlend) -> usize {
456    match blend {
457        LightBlend::Add => 0,
458        LightBlend::Sub => 1,
459        LightBlend::Mix => 2,
460    }
461}
462
463/// Shadow mask target format: one linear channel per tap (`MAX_MASK_TAPS`).
464/// Linear, not sRGB: the tap weights are coverage fractions, not colours.
465const MASK_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
466
467/// Shadow mask blend: per channel, `max(src, dst)`.
468///
469/// One channel per tap. Overlapping wedges of the same tap touch the same
470/// channel, and the shadow there is their union, not their sum: a solid
471/// occluder's own edges overlap, and adding them would darken the lit side of
472/// a silhouette. Max blending makes each channel the union of its tap, and the
473/// light shader resumes the summing across taps, which is the taps' average.
474const MASK_BLEND: wgpu::BlendState = wgpu::BlendState {
475    color: wgpu::BlendComponent {
476        src_factor: wgpu::BlendFactor::One,
477        dst_factor: wgpu::BlendFactor::One,
478        operation: wgpu::BlendOperation::Max,
479    },
480    alpha: wgpu::BlendComponent {
481        src_factor: wgpu::BlendFactor::One,
482        dst_factor: wgpu::BlendFactor::One,
483        operation: wgpu::BlendOperation::Max,
484    },
485};
486
487fn sampler_desc(filter: TextureFilter, label: &'static str) -> wgpu::SamplerDescriptor<'static> {
488    let f = match filter {
489        TextureFilter::Nearest => wgpu::FilterMode::Nearest,
490        TextureFilter::Linear => wgpu::FilterMode::Linear,
491    };
492    wgpu::SamplerDescriptor {
493        label: Some(label),
494        address_mode_u: wgpu::AddressMode::ClampToEdge,
495        address_mode_v: wgpu::AddressMode::ClampToEdge,
496        address_mode_w: wgpu::AddressMode::ClampToEdge,
497        mag_filter: f,
498        min_filter: f,
499        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
500        lod_min_clamp: 0.0,
501        lod_max_clamp: 1.0,
502        compare: None,
503        anisotropy_clamp: 1,
504        border_color: None,
505    }
506}
507
508/// World -> light map clip space (`x` right, `y` up, `[-1, 1]` per axis).
509fn lightmap_clip(world: Vec2, rect: [f32; 4]) -> [f32; 2] {
510    [
511        (world.x - rect[0]) * (2.0 / rect[2]) - 1.0,
512        1.0 - (world.y - rect[1]) * (2.0 / rect[3]),
513    ]
514}
515
516/// World -> shadow mask clip space for a light whose footprint covers
517/// `center +- half_extent`.
518fn mask_clip(world: Vec2, center: Vec2, half_extent: Vec2) -> [f32; 2] {
519    [
520        (world.x - (center.x - half_extent.x)) / half_extent.x - 1.0,
521        1.0 - (world.y - (center.y - half_extent.y)) / half_extent.y,
522    ]
523}
524
525/// World bounds -> pixel scissor `[x, y, w, h]` in the light map, clamped to
526/// it; `None` when the box misses the map entirely.
527fn scissor_px(aabb: [f32; 4], rect: [f32; 4], size: [u32; 2]) -> Option<[u32; 4]> {
528    let sx = size[0] as f32 / rect[2];
529    let sy = size[1] as f32 / rect[3];
530    let x0 = ((aabb[0] - rect[0]) * sx).floor().max(0.0);
531    let y0 = ((aabb[1] - rect[1]) * sy).floor().max(0.0);
532    let x1 = ((aabb[2] - rect[0]) * sx).ceil().min(size[0] as f32);
533    let y1 = ((aabb[3] - rect[1]) * sy).ceil().min(size[1] as f32);
534    if !(x1 > x0 && y1 > y0) {
535        return None;
536    }
537    Some([x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32])
538}
539
540fn align_up(value: u32, align: u32) -> u32 {
541    let align = align.max(1);
542    value.div_ceil(align) * align
543}
544
545/// Per-light vertex ranges: the light quad and, when shadowed, its wedges.
546#[derive(Clone, Copy, Debug, PartialEq, Eq)]
547struct LightRanges {
548    quad: (u32, u32),
549    mask: Option<(u32, u32)>,
550}
551
552impl Light2dPass {
553    fn uniform_stride(device: &wgpu::Device) -> u32 {
554        align_up(
555            size_of::<LightUniform>() as u32,
556            device.limits().min_uniform_buffer_offset_alignment,
557        )
558    }
559
560    fn ensure_resources(
561        &self,
562        device: &wgpu::Device,
563        queue: &wgpu::Queue,
564        resources: &mut CallbackResources,
565    ) {
566        let stride = Self::uniform_stride(device);
567        let slots = self.desc.budget.max_lights.max(1);
568        let stale = resources
569            .get::<LightResources>()
570            .and_then(|all| all.passes.get(self.id.as_str()))
571            .is_some_and(|entry| entry.uniform_stride != stride || entry.uniform_slots != slots);
572        if stale {
573            log::warn!(
574                "light2d[{}]: rebuilding pipelines for a budget/alignment change",
575                self.id
576            );
577            if let Some(all) = resources.get_mut::<LightResources>() {
578                all.passes.remove(self.id.as_str());
579            }
580        }
581        let present = resources
582            .get::<LightResources>()
583            .is_some_and(|all| all.passes.contains_key(self.id.as_str()));
584        if present {
585            return;
586        }
587        let all = resources.get_or_insert_with::<LightResources>();
588        let generation = all
589            .passes
590            .values()
591            .map(|entry| entry.generation)
592            .max()
593            .unwrap_or(0)
594            + 1;
595        let entry = create_entry(self, device, queue, stride, slots, generation);
596        all.passes.insert(self.id.clone(), entry);
597    }
598
599    /// Create or refresh the falloff textures when `textures_gen` advanced.
600    fn apply_textures(&self, device: &wgpu::Device, queue: &wgpu::Queue, entry: &mut PassEntry) {
601        if entry.applied_textures_gen == Some(self.desc.textures_gen) {
602            return;
603        }
604        entry.applied_textures_gen = Some(self.desc.textures_gen);
605        for upload in &self.textures {
606            let valid = upload.w > 0
607                && upload.h > 0
608                && upload.rgba.len() == upload.w as usize * upload.h as usize * 4;
609            if !valid {
610                log::warn!(
611                    "light2d: dropping malformed falloff texture slot={} {}x{} ({} bytes)",
612                    upload.slot,
613                    upload.w,
614                    upload.h,
615                    upload.rgba.len()
616                );
617                continue;
618            }
619            let size = [upload.w, upload.h];
620            let reuse = entry
621                .ramp_binds
622                .get(&upload.slot)
623                .is_some_and(|(texture, _)| texture.size == size);
624            if !reuse {
625                let texture = device.create_texture(&wgpu::TextureDescriptor {
626                    label: Some("light2d_ramp"),
627                    size: wgpu::Extent3d {
628                        width: upload.w,
629                        height: upload.h,
630                        depth_or_array_layers: 1,
631                    },
632                    mip_level_count: 1,
633                    sample_count: 1,
634                    dimension: wgpu::TextureDimension::D2,
635                    format: wgpu::TextureFormat::Rgba8UnormSrgb,
636                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
637                    view_formats: &[],
638                });
639                let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
640                let bind = create_texture_bind(
641                    device,
642                    &entry.ramp_layout,
643                    &view,
644                    &entry.sampler,
645                    "light2d_ramp_bg",
646                );
647                entry
648                    .ramp_binds
649                    .insert(upload.slot, (RampTexture { texture, size }, bind));
650            }
651            let (ramp, _) = entry
652                .ramp_binds
653                .get_mut(&upload.slot)
654                .expect("inserted above");
655            queue.write_texture(
656                wgpu::TexelCopyTextureInfo {
657                    texture: &ramp.texture,
658                    mip_level: 0,
659                    origin: wgpu::Origin3d::ZERO,
660                    aspect: wgpu::TextureAspect::All,
661                },
662                &upload.rgba,
663                wgpu::TexelCopyBufferLayout {
664                    offset: 0,
665                    bytes_per_row: Some(upload.w * 4),
666                    rows_per_image: Some(upload.h),
667                },
668                wgpu::Extent3d {
669                    width: upload.w,
670                    height: upload.h,
671                    depth_or_array_layers: 1,
672                },
673            );
674        }
675    }
676
677    fn prepare_frame(
678        &self,
679        device: &wgpu::Device,
680        queue: &wgpu::Queue,
681        encoder: &mut wgpu::CommandEncoder,
682        screen: &ScreenDescriptor,
683        resources: &mut CallbackResources,
684    ) {
685        let Some(world_rect) = visible_world_rect(self.camera) else {
686            Self::warn_once(
687                resources,
688                &self.id,
689                "camera does not map a visible world rect; skipping lights this frame".to_string(),
690            );
691            return;
692        };
693        let plan = plan_lighting(
694            &self.lights,
695            &self.occluders,
696            &self.ambient,
697            world_rect,
698            &self.desc.budget,
699        );
700        for issue in &plan.issues {
701            Self::warn_once(
702                resources,
703                &self.id,
704                format!("light {} dropped: {:?}", issue.source, issue.kind),
705            );
706        }
707        for reject in &plan.occluder_rejects {
708            Self::warn_once(
709                resources,
710                &self.id,
711                format!(
712                    "occluder {} rejected: {:?}",
713                    reject.index, reject.reason
714                ),
715            );
716        }
717
718        let scale = if self.desc.lightmap_scale.is_finite() {
719            self.desc.lightmap_scale.clamp(0.25, 1.0)
720        } else {
721            0.5
722        };
723        let max_dim = device.limits().max_texture_dimension_2d;
724        let size = [
725            ((screen.size_in_pixels[0] as f32 * scale).round() as u32).clamp(1, max_dim),
726            ((screen.size_in_pixels[1] as f32 * scale).round() as u32).clamp(1, max_dim),
727        ];
728
729        self.ensure_resources(device, queue, resources);
730        let stride = Self::uniform_stride(device) as usize;
731        let (vertices, ranges) = build_vertices(&plan, world_rect);
732        let (uniforms, light_count) = build_uniforms(&plan, stride);
733
734        let LightResources { passes, maps, .. } = resources
735            .get_mut::<LightResources>()
736            .expect("ensure_resources inserted them");
737        let Some(entry) = passes.get_mut(self.id.as_str()) else {
738            return;
739        };
740        if light_count > entry.uniform_slots {
741            log::error!(
742                "light2d[{}]: {} lights exceed the {} uniform slots; drawing {}",
743                self.id,
744                light_count,
745                entry.uniform_slots,
746                entry.uniform_slots
747            );
748        }
749        self.apply_textures(device, queue, entry);
750        Self::ensure_target(entry, device, size, plan.layers, world_rect);
751        Self::ensure_vertices(entry, device, vertices.len());
752        if !vertices.is_empty() {
753            queue.write_buffer(&entry.vertices, 0, bytemuck::cast_slice(&vertices));
754        }
755        if !uniforms.is_empty() {
756            queue.write_buffer(&entry.uniforms, 0, &uniforms);
757        }
758
759        let info = entry
760            .target
761            .as_ref()
762            .expect("target exists after ensure_target")
763            .info
764            .clone();
765        maps.insert(self.id.clone(), info);
766
767        let target = entry.target.as_ref().expect("target exists");
768        record_frame(
769            encoder,
770            entry,
771            target,
772            &plan,
773            &ranges,
774            &world_rect,
775            light_count,
776        );
777    }
778
779    fn ensure_vertices(entry: &mut PassEntry, device: &wgpu::Device, len: usize) {
780        if len <= entry.vertex_cap {
781            return;
782        }
783        let cap = len.next_power_of_two().max(64);
784        entry.vertices = device.create_buffer(&wgpu::BufferDescriptor {
785            label: Some("light2d_vertices"),
786            size: (cap * size_of::<LightVertex>()) as u64,
787            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
788            mapped_at_creation: false,
789        });
790        entry.vertex_cap = cap;
791    }
792
793    fn ensure_target(
794        entry: &mut PassEntry,
795        device: &wgpu::Device,
796        size: [u32; 2],
797        layers: u32,
798        world_rect: [f32; 4],
799    ) {
800        let stale = entry
801            .target
802            .as_ref()
803            .is_none_or(|target| target.size != size || target.layers != layers);
804        if stale {
805            let texture = device.create_texture(&wgpu::TextureDescriptor {
806                label: Some("light2d_map"),
807                size: wgpu::Extent3d {
808                    width: size[0],
809                    height: size[1],
810                    depth_or_array_layers: layers,
811                },
812                mip_level_count: 1,
813                sample_count: 1,
814                dimension: wgpu::TextureDimension::D2,
815                format: wgpu::TextureFormat::Rgba8Unorm,
816                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
817                    | wgpu::TextureUsages::TEXTURE_BINDING,
818                view_formats: &[],
819            });
820            let views: Vec<wgpu::TextureView> = (0..layers)
821                .map(|layer| {
822                    texture.create_view(&wgpu::TextureViewDescriptor {
823                        label: Some("light2d_map_layer"),
824                        dimension: Some(wgpu::TextureViewDimension::D2),
825                        base_array_layer: layer,
826                        array_layer_count: Some(1),
827                        ..Default::default()
828                    })
829                })
830                .collect();
831            let array_view = texture.create_view(&wgpu::TextureViewDescriptor {
832                label: Some("light2d_map"),
833                dimension: Some(wgpu::TextureViewDimension::D2Array),
834                ..Default::default()
835            });
836            entry.generation += 1;
837            entry.target = Some(LightTarget {
838                size,
839                layers,
840                texture,
841                views,
842                info: Arc::new(LightmapInfo {
843                    view: array_view,
844                    world_rect,
845                    layer_clamp: layers - 1,
846                    generation: entry.generation,
847                }),
848            });
849        }
850        let target = entry.target.as_mut().expect("created above");
851        // A fresh `Arc` when the rect moves (the camera) or the layer count
852        // changes; consumers key their bind groups on `(id, generation)`, not
853        // on this handle, so a new one costs one allocation and no churn.
854        if target.info.world_rect != world_rect || target.info.layer_clamp != layers - 1 {
855            target.info = Arc::new(LightmapInfo {
856                view: target.info.view.clone(),
857                world_rect,
858                layer_clamp: layers - 1,
859                generation: target.info.generation,
860            });
861        }
862    }
863}
864
865fn create_entry(
866    pass: &Light2dPass,
867    device: &wgpu::Device,
868    queue: &wgpu::Queue,
869    uniform_stride: u32,
870    uniform_slots: usize,
871    generation: u64,
872) -> PassEntry {
873    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
874        label: Some("light2d_shader"),
875        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/light_disc.wgsl").into()),
876    });
877    let mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
878        label: Some("light2d_mask_shader"),
879        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/light_mask.wgsl").into()),
880    });
881    let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
882        label: Some("light2d_uniform_bgl"),
883        entries: &[wgpu::BindGroupLayoutEntry {
884            binding: 0,
885            visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
886            ty: wgpu::BindingType::Buffer {
887                ty: wgpu::BufferBindingType::Uniform,
888                has_dynamic_offset: true,
889                min_binding_size: None,
890            },
891            count: None,
892        }],
893    });
894    let stereo_layout = |label: &'static str| {
895        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
896            label: Some(label),
897            entries: &[
898                wgpu::BindGroupLayoutEntry {
899                    binding: 0,
900                    visibility: wgpu::ShaderStages::FRAGMENT,
901                    ty: wgpu::BindingType::Texture {
902                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
903                        view_dimension: wgpu::TextureViewDimension::D2,
904                        multisampled: false,
905                    },
906                    count: None,
907                },
908                wgpu::BindGroupLayoutEntry {
909                    binding: 1,
910                    visibility: wgpu::ShaderStages::FRAGMENT,
911                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
912                    count: None,
913                },
914            ],
915        })
916    };
917    let mask_layout = stereo_layout("light2d_mask_bgl");
918    let ramp_layout = stereo_layout("light2d_ramp_bgl");
919    let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
920        label: Some("light2d_uniforms"),
921        size: (uniform_slots as u32 * uniform_stride) as u64,
922        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
923        mapped_at_creation: false,
924    });
925    let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
926        label: Some("light2d_uniform_bg"),
927        layout: &uniform_layout,
928        entries: &[wgpu::BindGroupEntry {
929            binding: 0,
930            resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
931                buffer: &uniforms,
932                offset: 0,
933                size: std::num::NonZeroU64::new(size_of::<LightUniform>() as u64),
934            }),
935        }],
936    });
937    let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
938        label: Some("light2d_vertices"),
939        contents: bytemuck::cast_slice(&[LightVertex::zeroed(); 6]),
940        usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
941    });
942    let sampler = device.create_sampler(&sampler_desc(pass.desc.filter, "light2d_sampler"));
943    let mask_size = pass.desc.mask_size.clamp(64, 2048);
944    let mask_texture = device.create_texture(&wgpu::TextureDescriptor {
945        label: Some("light2d_mask"),
946        size: wgpu::Extent3d {
947            width: mask_size,
948            height: mask_size,
949            depth_or_array_layers: 1,
950        },
951        mip_level_count: 1,
952        sample_count: 1,
953        dimension: wgpu::TextureDimension::D2,
954        format: MASK_FORMAT,
955        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
956        view_formats: &[],
957    });
958    let mask_view = mask_texture.create_view(&wgpu::TextureViewDescriptor::default());
959    // A one-texel mask of zero is "no shadow at all": every light without
960    // shadows binds it, so one shader path covers both cases.
961    let mask_empty_view = solid_view(device, queue, [0, 0, 0, 0], MASK_FORMAT);
962    // A one-texel transparent black ramp: a texture falloff with no upload
963    // paints nothing, like Godot's missing light texture.
964    let ramp_empty_view = solid_view(
965        device,
966        queue,
967        [0, 0, 0, 0],
968        wgpu::TextureFormat::Rgba8UnormSrgb,
969    );
970    let mask_bind = create_texture_bind(
971        device,
972        &mask_layout,
973        &mask_view,
974        &sampler,
975        "light2d_mask_bg",
976    );
977    let mask_bind_empty = create_texture_bind(
978        device,
979        &mask_layout,
980        &mask_empty_view,
981        &sampler,
982        "light2d_mask_empty_bg",
983    );
984    let ramp_bind_empty = create_texture_bind(
985        device,
986        &ramp_layout,
987        &ramp_empty_view,
988        &sampler,
989        "light2d_ramp_empty_bg",
990    );
991    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
992        label: Some("light2d_pl"),
993        bind_group_layouts: &[
994            Some(&uniform_layout),
995            Some(&mask_layout),
996            Some(&ramp_layout),
997        ],
998        immediate_size: 0,
999    });
1000    let mask_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1001        label: Some("light2d_mask_pl"),
1002        // The mask pass needs no bindings at all: the CPU baked its vertices
1003        // into mask clip space.
1004        bind_group_layouts: &[],
1005        immediate_size: 0,
1006    });
1007    let vertex_buffers = [Some(wgpu::VertexBufferLayout {
1008        array_stride: size_of::<LightVertex>() as u64,
1009        step_mode: wgpu::VertexStepMode::Vertex,
1010        attributes: &[
1011            wgpu::VertexAttribute {
1012                format: wgpu::VertexFormat::Float32x2,
1013                offset: 0,
1014                shader_location: 0,
1015            },
1016            wgpu::VertexAttribute {
1017                format: wgpu::VertexFormat::Float32x2,
1018                offset: 8,
1019                shader_location: 1,
1020            },
1021            wgpu::VertexAttribute {
1022                format: wgpu::VertexFormat::Float32x4,
1023                offset: 16,
1024                shader_location: 2,
1025            },
1026        ],
1027    })];
1028    let mk_pipeline = |label: &'static str,
1029                       module: &wgpu::ShaderModule,
1030                       layout: &wgpu::PipelineLayout,
1031                       format: wgpu::TextureFormat,
1032                       blend: wgpu::BlendState,
1033                       write_mask: wgpu::ColorWrites|
1034     -> wgpu::RenderPipeline {
1035        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1036            label: Some(label),
1037            layout: Some(layout),
1038            vertex: wgpu::VertexState {
1039                module,
1040                entry_point: Some("vs_main"),
1041                buffers: &vertex_buffers,
1042                compilation_options: Default::default(),
1043            },
1044            fragment: Some(wgpu::FragmentState {
1045                module,
1046                entry_point: Some("fs_main"),
1047                targets: &[Some(wgpu::ColorTargetState {
1048                    format,
1049                    blend: Some(blend),
1050                    write_mask,
1051                })],
1052                compilation_options: Default::default(),
1053            }),
1054            primitive: wgpu::PrimitiveState {
1055                topology: wgpu::PrimitiveTopology::TriangleList,
1056                ..Default::default()
1057            },
1058            depth_stencil: None,
1059            multisample: wgpu::MultisampleState {
1060                count: 1,
1061                mask: !0,
1062                alpha_to_coverage_enabled: false,
1063            },
1064            multiview_mask: None,
1065            cache: None,
1066        })
1067    };
1068    let pipeline_mask = mk_pipeline(
1069        "light2d_mask_pipeline",
1070        &mask_shader,
1071        &mask_pipeline_layout,
1072        MASK_FORMAT,
1073        MASK_BLEND,
1074        wgpu::ColorWrites::ALL,
1075    );
1076    let pipelines = [
1077        mk_pipeline(
1078            "light2d_add",
1079            &shader,
1080            &pipeline_layout,
1081            wgpu::TextureFormat::Rgba8Unorm,
1082            blend_state(LightBlend::Add),
1083            wgpu::ColorWrites::ALL,
1084        ),
1085        mk_pipeline(
1086            "light2d_sub",
1087            &shader,
1088            &pipeline_layout,
1089            wgpu::TextureFormat::Rgba8Unorm,
1090            blend_state(LightBlend::Sub),
1091            wgpu::ColorWrites::ALL,
1092        ),
1093        mk_pipeline(
1094            "light2d_mix",
1095            &shader,
1096            &pipeline_layout,
1097            wgpu::TextureFormat::Rgba8Unorm,
1098            blend_state(LightBlend::Mix),
1099            wgpu::ColorWrites::ALL,
1100        ),
1101    ];
1102    PassEntry {
1103        pipeline_mask,
1104        pipelines,
1105        ramp_layout,
1106        vertices,
1107        vertex_cap: 6,
1108        uniforms,
1109        uniform_bind,
1110        uniform_stride,
1111        uniform_slots,
1112        mask_texture,
1113        mask_view,
1114        mask_bind,
1115        mask_bind_empty,
1116        ramp_bind_empty,
1117        ramp_binds: HashMap::new(),
1118        applied_textures_gen: None,
1119        sampler,
1120        target: None,
1121        generation,
1122    }
1123}
1124
1125fn create_texture_bind(
1126    device: &wgpu::Device,
1127    layout: &wgpu::BindGroupLayout,
1128    view: &wgpu::TextureView,
1129    sampler: &wgpu::Sampler,
1130    label: &'static str,
1131) -> wgpu::BindGroup {
1132    device.create_bind_group(&wgpu::BindGroupDescriptor {
1133        label: Some(label),
1134        layout,
1135        entries: &[
1136            wgpu::BindGroupEntry {
1137                binding: 0,
1138                resource: wgpu::BindingResource::TextureView(view),
1139            },
1140            wgpu::BindGroupEntry {
1141                binding: 1,
1142                resource: wgpu::BindingResource::Sampler(sampler),
1143            },
1144        ],
1145    })
1146}
1147
1148/// A 1x1 texture of one solid colour, uploaded right away.
1149fn solid_view(
1150    device: &wgpu::Device,
1151    queue: &wgpu::Queue,
1152    rgba: [u8; 4],
1153    format: wgpu::TextureFormat,
1154) -> wgpu::TextureView {
1155    let texture = device.create_texture(&wgpu::TextureDescriptor {
1156        label: Some("light2d_solid"),
1157        size: wgpu::Extent3d {
1158            width: 1,
1159            height: 1,
1160            depth_or_array_layers: 1,
1161        },
1162        mip_level_count: 1,
1163        sample_count: 1,
1164        dimension: wgpu::TextureDimension::D2,
1165        format,
1166        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1167        view_formats: &[],
1168    });
1169    queue.write_texture(
1170        wgpu::TexelCopyTextureInfo {
1171            texture: &texture,
1172            mip_level: 0,
1173            origin: wgpu::Origin3d::ZERO,
1174            aspect: wgpu::TextureAspect::All,
1175        },
1176        &rgba,
1177        wgpu::TexelCopyBufferLayout {
1178            offset: 0,
1179            bytes_per_row: Some(4),
1180            rows_per_image: Some(1),
1181        },
1182        wgpu::Extent3d {
1183            width: 1,
1184            height: 1,
1185            depth_or_array_layers: 1,
1186        },
1187    );
1188    texture.create_view(&wgpu::TextureViewDescriptor::default())
1189}
1190
1191/// Bake every light's quad and wedges, in accumulation order, and report the
1192/// ranges the draw calls use. Light quads go into light map clip space, wedges
1193/// into that light's mask clip space.
1194fn build_vertices(
1195    plan: &LightingPlan,
1196    world_rect: [f32; 4],
1197) -> (Vec<LightVertex>, Vec<LightRanges>) {
1198    let mut vertices = Vec::with_capacity(plan.lights.len() * 6 + plan.wedge_vertices());
1199    let mut ranges = Vec::with_capacity(plan.lights.len());
1200    for light in &plan.lights {
1201        let quad_start = vertices.len() as u32;
1202        let corners = [
1203            light.center - light.half_extent,
1204            Vec2::new(
1205                light.center.x + light.half_extent.x,
1206                light.center.y - light.half_extent.y,
1207            ),
1208            light.center + light.half_extent,
1209            Vec2::new(
1210                light.center.x - light.half_extent.x,
1211                light.center.y + light.half_extent.y,
1212            ),
1213        ];
1214        for corner in [
1215            corners[0], corners[1], corners[2], corners[0], corners[2], corners[3],
1216        ] {
1217            vertices.push(LightVertex {
1218                clip: lightmap_clip(corner, world_rect),
1219                world: [corner.x, corner.y],
1220                weights: [0.0; MAX_MASK_TAPS],
1221            });
1222        }
1223        let mask = if light.shadowed {
1224            let start = vertices.len() as u32;
1225            for wedge in &light.wedges {
1226                let quad = wedge.quad;
1227                for corner in [
1228                    quad[0], quad[1], quad[2], quad[0], quad[2], quad[3],
1229                ] {
1230                    vertices.push(LightVertex {
1231                        clip: mask_clip(corner, light.center, light.half_extent),
1232                        world: [corner.x, corner.y],
1233                        weights: wedge.mask_weights(),
1234                    });
1235                }
1236            }
1237            Some((start, vertices.len() as u32 - start))
1238        } else {
1239            None
1240        };
1241        ranges.push(LightRanges {
1242            quad: (quad_start, 6),
1243            mask,
1244        });
1245    }
1246    (vertices, ranges)
1247}
1248
1249/// Pack one uniform per light, padded to the device's dynamic-offset stride.
1250fn build_uniforms(plan: &LightingPlan, stride: usize) -> (Vec<u8>, usize) {
1251    let stride = stride.max(size_of::<LightUniform>());
1252    let mut out = Vec::with_capacity(plan.lights.len() * stride);
1253    for light in &plan.lights {
1254        let mut stop_at = [[0.0f32; 4]; 2];
1255        let mut stop_rgba = [[0.0f32; 4]; 8];
1256        // Stops past the live count repeat the last one, so a miscount reads a
1257        // flat ramp rather than whatever zeroed memory holds.
1258        let last = if light.stop_count == 0 {
1259            0
1260        } else {
1261            light.stop_count as usize - 1
1262        };
1263        for i in 0..8 {
1264            let stop = light.stops[i.min(last)];
1265            stop_at[i / 4][i % 4] = stop.at;
1266            stop_rgba[i] = stop.color;
1267        }
1268        let uniform = LightUniform {
1269            center_half: [
1270                light.center.x,
1271                light.center.y,
1272                1.0 / light.half_extent.x,
1273                1.0 / light.half_extent.y,
1274            ],
1275            color_energy: [
1276                light.color[0],
1277                light.color[1],
1278                light.color[2],
1279                light.energy,
1280            ],
1281            params: [
1282                if light.stop_count == 0 { 1.0 } else { 0.0 },
1283                light.stop_count as f32,
1284                0.0,
1285                0.0,
1286            ],
1287            mask_xform: [
1288                light.center.x - light.half_extent.x,
1289                light.center.y - light.half_extent.y,
1290                1.0 / (2.0 * light.half_extent.x),
1291                1.0 / (2.0 * light.half_extent.y),
1292            ],
1293            tex: [
1294                light.texture_offset.x,
1295                light.texture_offset.y,
1296                1.0 / light.texture_half_extent.x,
1297                1.0 / light.texture_half_extent.y,
1298            ],
1299            stop_at,
1300            stop_rgba,
1301        };
1302        out.extend_from_slice(bytemuck::bytes_of(&uniform));
1303        out.resize((out.len() + stride - 1) / stride * stride, 0);
1304    }
1305    (out, plan.lights.len())
1306}
1307
1308fn record_frame(
1309    encoder: &mut wgpu::CommandEncoder,
1310    entry: &PassEntry,
1311    target: &LightTarget,
1312    plan: &LightingPlan,
1313    ranges: &[LightRanges],
1314    world_rect: &[f32; 4],
1315    light_count: usize,
1316) {
1317    let clear_value = |layer: u32| {
1318        let rgb = plan.ambient_rgb(layer);
1319        wgpu::Color {
1320            r: rgb[0] as f64,
1321            g: rgb[1] as f64,
1322            b: rgb[2] as f64,
1323            a: 1.0,
1324        }
1325    };
1326    // Every layer starts at the ambient light for that layer, before any
1327    // light is blended in. The last layer is the identity by construction:
1328    // `ambient_rgb` never answers for it.
1329    for (layer, view) in target.views.iter().enumerate() {
1330        let _clear = begin_pass(
1331            encoder,
1332            "light2d_clear",
1333            view,
1334            clear_value(layer as u32),
1335            None,
1336        );
1337    }
1338    for (slot, light) in plan
1339        .lights
1340        .iter()
1341        .enumerate()
1342        .take(light_count.min(entry.uniform_slots))
1343    {
1344        let Some(range) = ranges.get(slot) else {
1345            continue;
1346        };
1347        if let Some((start, count)) = range.mask {
1348            let mut pass = begin_pass(
1349                encoder,
1350                "light2d_mask",
1351                &entry.mask_view,
1352                wgpu::Color::TRANSPARENT,
1353                None,
1354            );
1355            pass.set_pipeline(&entry.pipeline_mask);
1356            pass.set_vertex_buffer(0, entry.vertices.slice(..));
1357            pass.draw(start..start + count, 0..1);
1358        }
1359        let aabb = [
1360            light.center.x - light.half_extent.x,
1361            light.center.y - light.half_extent.y,
1362            light.center.x + light.half_extent.x,
1363            light.center.y + light.half_extent.y,
1364        ];
1365        let scissor = scissor_px(aabb, *world_rect, target.size);
1366        let mask_bind = if range.mask.is_some() {
1367            &entry.mask_bind
1368        } else {
1369            &entry.mask_bind_empty
1370        };
1371        let ramp_bind = entry
1372            .ramp_binds
1373            .get(&light.texture_slot)
1374            .map(|(_, bind)| bind)
1375            .unwrap_or(&entry.ramp_bind_empty);
1376        for layer in light.layer_indices() {
1377            let Some(view) = target.views.get(layer as usize) else {
1378                continue;
1379            };
1380            let mut pass = begin_pass_load(encoder, "light2d_light", view, scissor);
1381            pass.set_pipeline(&entry.pipelines[blend_index(light.blend)]);
1382            pass.set_bind_group(0, &entry.uniform_bind, &[slot as u32 * entry.uniform_stride]);
1383            pass.set_bind_group(1, mask_bind, &[]);
1384            pass.set_bind_group(2, ramp_bind, &[]);
1385            pass.set_vertex_buffer(0, entry.vertices.slice(..));
1386            pass.draw(range.quad.0..range.quad.0 + range.quad.1, 0..1);
1387        }
1388    }
1389}
1390
1391fn begin_pass<'a>(
1392    encoder: &'a mut wgpu::CommandEncoder,
1393    label: &'static str,
1394    view: &'a wgpu::TextureView,
1395    clear: wgpu::Color,
1396    scissor: Option<[u32; 4]>,
1397) -> wgpu::RenderPass<'a> {
1398    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1399        label: Some(label),
1400        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1401            view,
1402            depth_slice: None,
1403            resolve_target: None,
1404            ops: wgpu::Operations {
1405                load: wgpu::LoadOp::Clear(clear),
1406                store: wgpu::StoreOp::Store,
1407            },
1408        })],
1409        depth_stencil_attachment: None,
1410        timestamp_writes: None,
1411        occlusion_query_set: None,
1412        multiview_mask: None,
1413    });
1414    if let Some([x, y, w, h]) = scissor {
1415        pass.set_scissor_rect(x, y, w, h);
1416    }
1417    pass
1418}
1419
1420fn begin_pass_load<'a>(
1421    encoder: &'a mut wgpu::CommandEncoder,
1422    label: &'static str,
1423    view: &'a wgpu::TextureView,
1424    scissor: Option<[u32; 4]>,
1425) -> wgpu::RenderPass<'a> {
1426    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1427        label: Some(label),
1428        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1429            view,
1430            depth_slice: None,
1431            resolve_target: None,
1432            ops: wgpu::Operations {
1433                load: wgpu::LoadOp::Load,
1434                store: wgpu::StoreOp::Store,
1435            },
1436        })],
1437        depth_stencil_attachment: None,
1438        timestamp_writes: None,
1439        occlusion_query_set: None,
1440        multiview_mask: None,
1441    });
1442    if let Some([x, y, w, h]) = scissor {
1443        pass.set_scissor_rect(x, y, w, h);
1444    }
1445    pass
1446}
1447
1448impl WgpuCallback for Light2dPass {
1449    fn resource_key(&self) -> Option<&str> {
1450        Some(SHARED_CALLBACK_KEY)
1451    }
1452
1453    fn resource_type_id(&self) -> std::any::TypeId {
1454        std::any::TypeId::of::<LightScope>()
1455    }
1456
1457    fn prepare(
1458        &self,
1459        device: &wgpu::Device,
1460        queue: &wgpu::Queue,
1461        encoder: &mut wgpu::CommandEncoder,
1462        screen: &ScreenDescriptor,
1463        resources: &mut CallbackResources,
1464    ) -> Vec<wgpu::CommandBuffer> {
1465        self.prepare_frame(device, queue, encoder, screen, resources);
1466        Vec::new()
1467    }
1468
1469    fn paint(
1470        &self,
1471        _info: repose_core::PaintCallbackInfo,
1472        _rpass: &mut repose_render_wgpu::CallbackRenderPass<'_, '_>,
1473        _resources: &CallbackResources,
1474    ) {
1475        // This pass only produces the light map batches consume; it paints
1476        // nothing of its own into the main target.
1477    }
1478}
1479
1480#[cfg(test)]
1481mod tests {
1482    use super::super::light::{
1483        AmbientLight, Light2d, LightBudget, LightFalloff, LightStop, stops_at,
1484    };
1485    use super::*;
1486
1487    fn light_plan(lights: &[Light2d], occluders: &[Occluder2d]) -> LightingPlan {
1488        plan_lighting(
1489            lights,
1490            occluders,
1491            &AmbientLight::default(),
1492            [-100.0, -50.0, 200.0, 100.0],
1493            &LightBudget::default(),
1494        )
1495    }
1496
1497    #[test]
1498    fn lightmap_clip_puts_the_world_rect_on_the_unit_square() {
1499        let rect = [100.0, 200.0, 40.0, 20.0];
1500        // Top-left of the rect is clip (-1, +1); bottom-right (1, -1).
1501        assert_eq!(lightmap_clip(Vec2::new(100.0, 200.0), rect), [-1.0, 1.0]);
1502        let br = lightmap_clip(Vec2::new(140.0, 220.0), rect);
1503        assert!(
1504            (br[0] - 1.0).abs() < 1e-5 && (br[1] + 1.0).abs() < 1e-5,
1505            "bottom-right lands on (1, -1): {br:?}"
1506        );
1507        let centre = lightmap_clip(Vec2::new(120.0, 210.0), rect);
1508        assert!(centre[0].abs() < 1e-6 && centre[1].abs() < 1e-6);
1509    }
1510
1511    #[test]
1512    fn mask_clip_stretches_the_footprint_over_the_mask() {
1513        let centre = Vec2::new(10.0, -4.0);
1514        let half = Vec2::new(8.0, 2.0);
1515        assert_eq!(mask_clip(centre - half, centre, half), [-1.0, 1.0]);
1516        assert_eq!(mask_clip(centre + half, centre, half), [1.0, -1.0]);
1517        // The disc shader's uv and the mask clip agree on the same corner.
1518        let corner = Vec2::new(2.0, -6.0);
1519        let uv = (corner - (centre - half)) / (half * 2.0);
1520        let clip = mask_clip(corner, centre, half);
1521        assert!((clip[0] - (uv.x * 2.0 - 1.0)).abs() < 1e-6);
1522        assert!((clip[1] - (1.0 - uv.y * 2.0)).abs() < 1e-6);
1523    }
1524
1525    #[test]
1526    fn scissor_covers_the_footprint_and_clamps_to_the_map() {
1527        let rect = [0.0, 0.0, 100.0, 50.0];
1528        let size = [200, 100];
1529        // A footprint in the middle: 2 px per world unit, y down.
1530        assert_eq!(
1531            scissor_px([10.0, 10.0, 20.0, 20.0], rect, size),
1532            Some([20, 20, 20, 20])
1533        );
1534        // Off-map boxes are skipped instead of clamped to a sliver.
1535        assert_eq!(scissor_px([-50.0, -50.0, -10.0, -10.0], rect, size), None);
1536        assert_eq!(scissor_px([200.0, 0.0, 300.0, 10.0], rect, size), None);
1537        // Partially visible boxes clamp.
1538        assert_eq!(
1539            scissor_px([-10.0, -10.0, 10.0, 10.0], rect, size),
1540            Some([0, 0, 20, 20])
1541        );
1542        assert_eq!(
1543            scissor_px([90.0, 40.0, 200.0, 100.0], rect, size),
1544            Some([180, 80, 20, 20])
1545        );
1546    }
1547
1548    #[test]
1549    fn vertex_packing_orders_quads_then_wedges_per_light() {
1550        let lights = [
1551            Light2d {
1552                position: Vec2::new(0.0, 0.0),
1553                radius: 10.0,
1554                shadows: true,
1555                ..Default::default()
1556            },
1557            Light2d {
1558                position: Vec2::new(30.0, 0.0),
1559                radius: 5.0,
1560                ..Default::default()
1561            },
1562        ];
1563        let occluders = [Occluder2d::rect(Vec2::new(2.0, -2.0), Vec2::new(4.0, 2.0))];
1564        let plan = light_plan(&lights, &occluders);
1565        assert_eq!(plan.lights.len(), 2);
1566        assert!(plan.lights[0].shadowed && !plan.lights[1].shadowed);
1567        let world_rect = plan.world_rect;
1568        let (vertices, ranges) = build_vertices(&plan, world_rect);
1569        assert_eq!(vertices.len(), 6 + 4 * 6 + 6, "two quads and four wedges");
1570        assert_eq!(ranges.len(), 2);
1571        assert_eq!(ranges[0].quad, (0, 6));
1572        assert_eq!(ranges[0].mask, Some((6, 24)));
1573        assert_eq!(ranges[1].quad, (30, 6));
1574        assert_eq!(ranges[1].mask, None);
1575        // Every vertex is finite, and the light quads land on their world
1576        // corners.
1577        for vertex in &vertices {
1578            assert!(vertex.clip[0].is_finite() && vertex.clip[1].is_finite());
1579        }
1580        let top_left = vertices[0];
1581        assert_eq!(top_left.world, [-10.0, -10.0]);
1582        assert_eq!(
1583            top_left.clip,
1584            lightmap_clip(Vec2::new(-10.0, -10.0), world_rect)
1585        );
1586        assert_eq!(vertices[5].world, [-10.0, 10.0]);
1587        // Wedge vertices carry the tap weight in its own mask channel and the
1588        // mask clip coordinates.
1589        let wedge = vertices[6];
1590        assert_eq!(wedge.weights, [1.0, 0.0, 0.0, 0.0], "one hard tap");
1591        assert_eq!(
1592            wedge.clip,
1593            mask_clip(
1594                Vec2::from_array(wedge.world),
1595                Vec2::ZERO,
1596                Vec2::splat(10.0)
1597            )
1598        );
1599    }
1600
1601    #[test]
1602    fn uniform_packing_matches_the_shader_layout() {
1603        let light = Light2d {
1604            position: Vec2::new(4.0, -2.0),
1605            radius: 8.0,
1606            color: [1.0, 0.5, 0.25],
1607            energy: 0.75,
1608            shadows: true,
1609            falloff: LightFalloff::stops(vec![
1610                LightStop {
1611                    at: 0.0,
1612                    color: [1.0, 1.0, 1.0, 1.0],
1613                },
1614                LightStop {
1615                    at: 1.0,
1616                    color: [0.0, 0.0, 0.0, 0.0],
1617                },
1618            ]),
1619            ..Default::default()
1620        };
1621        let plan = light_plan(&[light], &[]);
1622        let stride = 512;
1623        let (bytes, count) = build_uniforms(&plan, stride);
1624        assert_eq!(count, 1);
1625        assert_eq!(bytes.len(), stride);
1626        let packed: &LightUniform = bytemuck::from_bytes(&bytes[..size_of::<LightUniform>()]);
1627        assert_eq!(packed.center_half, [4.0, -2.0, 0.125, 0.125]);
1628        assert_eq!(packed.color_energy, [1.0, 0.5, 0.25, 0.75]);
1629        // Mode 0 = stops, two live stops, and the unused slots repeat the last.
1630        assert_eq!(packed.params, [0.0, 2.0, 0.0, 0.0]);
1631        assert_eq!(packed.mask_xform, [-4.0, -10.0, 1.0 / 16.0, 1.0 / 16.0]);
1632        assert_eq!(packed.stop_at[0], [0.0, 1.0, 1.0, 1.0]);
1633        assert_eq!(packed.stop_rgba[1], [0.0, 0.0, 0.0, 0.0]);
1634        assert_eq!(packed.stop_rgba[7], [0.0, 0.0, 0.0, 0.0]);
1635        // Padding is wider than the uniform: the next slot starts at `stride`.
1636        let (bytes, _) = build_uniforms(&plan, stride);
1637        assert!(bytes[size_of::<LightUniform>()..].iter().all(|b| *b == 0));
1638    }
1639
1640    #[test]
1641    fn packed_uniform_reproduces_the_shader_ramp_and_uvs() {
1642        // The shader is not executed by these checks, so its arithmetic is
1643        // replayed here against the CPU helpers it must agree with.
1644        let stops = vec![
1645            LightStop {
1646                at: 0.0,
1647                color: [1.0, 0.8, 0.6, 1.0],
1648            },
1649            LightStop {
1650                at: 0.4,
1651                color: [0.5, 0.4, 0.3, 0.5],
1652            },
1653            LightStop {
1654                at: 1.0,
1655                color: [0.0, 0.0, 0.0, 0.0],
1656            },
1657        ];
1658        let ramp_light = Light2d {
1659            position: Vec2::new(10.0, 20.0),
1660            radius: 40.0,
1661            falloff: LightFalloff::stops(stops.clone()),
1662            ..Default::default()
1663        };
1664        let plan = light_plan(&[ramp_light], &[]);
1665        let (bytes, _) = build_uniforms(&plan, 256);
1666        let packed: &LightUniform = bytemuck::from_bytes(&bytes[..size_of::<LightUniform>()]);
1667        assert_eq!(packed.params[0], 0.0, "stops mode");
1668        assert_eq!(packed.params[1], 3.0, "three live stops");
1669        // `light_disc.wgsl::stops_ramp`, replayed.
1670        let shader_ramp = |t: f32| {
1671            let mut ramp = packed.stop_rgba[0];
1672            let mut previous = 0.0f32;
1673            for i in 1..8 {
1674                if i as f32 >= packed.params[1] {
1675                    break;
1676                }
1677                let at = packed.stop_at[i / 4][i % 4];
1678                let span = (at - previous).max(1e-6);
1679                let w = ((t - previous) / span).clamp(0.0, 1.0);
1680                for c in 0..4 {
1681                    ramp[c] += (packed.stop_rgba[i][c] - ramp[c]) * w;
1682                }
1683                previous = at;
1684            }
1685            ramp
1686        };
1687        for t in [0.0, 0.1, 0.25, 0.4, 0.5, 0.75, 1.0, 1.5] {
1688            let cpu = stops_at(&stops, t);
1689            let gpu = shader_ramp(t);
1690            assert!(
1691                cpu.iter().zip(gpu.iter()).all(|(a, b)| (a - b).abs() < 1e-5),
1692                "t={t}: cpu {cpu:?} vs shader {gpu:?}"
1693            );
1694        }
1695
1696        let texture_light = Light2d {
1697            position: Vec2::new(10.0, 20.0),
1698            radius: 40.0,
1699            falloff: LightFalloff::Texture {
1700                slot: 3,
1701                half_extent: Vec2::new(16.0, 8.0),
1702                offset: Vec2::new(2.0, -1.0),
1703            },
1704            ..Default::default()
1705        };
1706        let plan = light_plan(&[texture_light.clone()], &[]);
1707        let (bytes, _) = build_uniforms(&plan, 256);
1708        let packed: &LightUniform = bytemuck::from_bytes(&bytes[..size_of::<LightUniform>()]);
1709        assert_eq!(packed.params[0], 1.0, "texture mode");
1710        // `light_disc.wgsl`'s uv: (world - centre - offset) * tex.zw * 0.5 + 0.5.
1711        let shader_uv = |world: Vec2| {
1712            let d = world - Vec2::new(packed.center_half[0], packed.center_half[1])
1713                - Vec2::new(packed.tex[0], packed.tex[1]);
1714            Vec2::new(d.x * packed.tex[2], d.y * packed.tex[3]) * 0.5 + Vec2::splat(0.5)
1715        };
1716        for world in [
1717            Vec2::new(12.0, 19.0),
1718            Vec2::new(11.0, 20.0),
1719            Vec2::new(26.0, 28.0),
1720            Vec2::new(-4.0, 12.0),
1721        ] {
1722            let cpu = super::super::light::texture_uv(
1723                texture_light.position,
1724                Vec2::new(2.0, -1.0),
1725                Vec2::new(16.0, 8.0),
1726                world,
1727            );
1728            let gpu = shader_uv(world);
1729            assert!(
1730                (cpu - gpu).length() < 1e-5,
1731                "uv mismatch at {world:?}: cpu {cpu:?} vs shader {gpu:?}"
1732            );
1733        }
1734    }
1735
1736    #[test]
1737    fn blend_states_are_godots_three_modes() {
1738        // Add: src * src.a + dst, multiplier channel untouched.
1739        let add = blend_state(LightBlend::Add);
1740        assert_eq!(add.color, ADD);
1741        assert_eq!(add.alpha, KEEP_MULTIPLIER);
1742        // Sub: dst - src * src.a.
1743        let sub = blend_state(LightBlend::Sub);
1744        assert_eq!(sub.color.operation, wgpu::BlendOperation::ReverseSubtract);
1745        assert_eq!(sub.color.src_factor, wgpu::BlendFactor::SrcAlpha);
1746        // Mix: src + dst * (1 - src.a), multiplier scaled the same way.
1747        let mix = blend_state(LightBlend::Mix);
1748        assert_eq!(mix.color.dst_factor, wgpu::BlendFactor::OneMinusSrcAlpha);
1749        assert_eq!(mix.color.src_factor, wgpu::BlendFactor::One);
1750        assert_eq!(mix.alpha.dst_factor, wgpu::BlendFactor::OneMinusSrcAlpha);
1751        assert_eq!(mix.alpha.src_factor, wgpu::BlendFactor::Zero);
1752        assert_eq!(
1753            [0, 1, 2],
1754            [
1755                blend_index(LightBlend::Add),
1756                blend_index(LightBlend::Sub),
1757                blend_index(LightBlend::Mix)
1758            ]
1759        );
1760        // The mask unions instead of adding: each tap has its own channel, and
1761        // the light shader sums the channels.
1762        assert_eq!(MASK_BLEND.color.operation, wgpu::BlendOperation::Max);
1763        assert_eq!(MASK_BLEND.alpha.operation, wgpu::BlendOperation::Max);
1764        assert_eq!(MASK_FORMAT, wgpu::TextureFormat::Rgba8Unorm);
1765        assert_eq!(MAX_MASK_TAPS, 4, "one channel per Pcf4 tap");
1766    }
1767}