Skip to main content

pfx_live/
lights.rs

1use std::hash::{Hash, Hasher};
2
3use bytemuck::{Pod, Zeroable};
4use pfx_gpu::{GpuProfiler, wgpu};
5use pfx_load::scene::Light;
6use pfx_materials::Material;
7
8use crate::frame::{Matrix, multiply};
9use crate::shadow::{CASCADE_COUNT, FaceTiles};
10
11pub const MAX_LIGHTS: usize = 4;
12pub const FACES: usize = 6;
13pub const CASTER_SLOTS: usize = CASCADE_COUNT + MAX_LIGHTS * FACES;
14pub const CASTER_BYTES: u64 = 80;
15pub const DEFAULT_FACE_RESOLUTION: u32 = 1024;
16const MARGIN_TEXELS: f32 = 8.0;
17const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
18const FACE_AXES: [([f32; 3], [f32; 3]); FACES] = [
19    ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
20    ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
21    ([0.0, 1.0, 0.0], [0.0, 0.0, -1.0]),
22    ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
23    ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
24    ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
25];
26
27pub const LIGHTS_WGSL: &str = r#"
28const FIRST_TILE_LAYER: u32 = 3u;
29struct LocalLight {
30    position_radius: vec4f,
31    colour_range: vec4f,
32    shadow: vec4f,
33    bias: vec4f,
34}
35struct LocalLights {
36    info: vec4u,
37    lights: array<LocalLight, 4>,
38    faces: array<mat4x4f, 24>,
39}
40@group(1) @binding(4) var shadow_transmission: texture_2d_array<u32>;
41@group(1) @binding(5) var<uniform> local_lights: LocalLights;
42@group(1) @binding(6) var light_faces: texture_depth_2d_array;
43@group(1) @binding(7) var light_sampler: sampler_comparison;
44@group(1) @binding(8) var<storage, read> caster_tints: array<vec4f>;
45
46fn caster_skips(material: u32) -> bool {
47    return caster.mode.x > 0.5 && caster_tints[material].w > 0.5;
48}
49
50fn light_face(d: vec3f) -> u32 {
51    let a = abs(d);
52    if (a.x >= a.y && a.x >= a.z) {
53        return select(1u, 0u, d.x > 0.0);
54    }
55    if (a.y >= a.z) {
56        return select(3u, 2u, d.y > 0.0);
57    }
58    return select(5u, 4u, d.z > 0.0);
59}
60
61fn light_shadow(light: LocalLight, world: vec3f, normal: vec3f, toward: vec3f) -> f32 {
62    if (light.shadow.x < 0.0) {
63        return 1.0;
64    }
65    let offset = world - light.position_radius.xyz;
66    let reach = max(abs(offset.x), max(abs(offset.y), abs(offset.z)));
67    let texel = reach * light.shadow.y;
68    let shifted = world + normal * texel * light.bias.x + toward * texel * light.bias.y;
69    let layer = u32(light.shadow.x) + light_face(shifted - light.position_radius.xyz);
70    let clip = local_lights.faces[layer] * vec4f(shifted, 1.0);
71    if (clip.w <= 0.0) {
72        return 1.0;
73    }
74    let ndc = clip.xyz / clip.w;
75    let uv = vec2f(ndc.x, -ndc.y) * 0.5 + vec2f(0.5);
76    let step = light.shadow.w / vec2f(textureDimensions(light_faces));
77    var lit = 0.0;
78    for (var y = -1; y <= 1; y++) {
79        for (var x = -1; x <= 1; x++) {
80            lit += textureSampleCompareLevel(light_faces, light_sampler, uv + vec2f(f32(x), f32(y)) * step, layer, ndc.z);
81        }
82    }
83    return lit / 9.0;
84}
85
86fn light_tint(light: LocalLight, world: vec3f, normal: vec3f, toward: vec3f) -> vec3f {
87    let tile = local_lights.info.z;
88    if (tile == 0u || light.shadow.x < 0.0) {
89        return vec3f(1.0);
90    }
91    let offset = world - light.position_radius.xyz;
92    let reach = max(abs(offset.x), max(abs(offset.y), abs(offset.z)));
93    let texel = reach * light.shadow.y;
94    let shifted = world + normal * texel * light.bias.x + toward * texel * light.bias.y;
95    let face = u32(light.shadow.x) + light_face(shifted - light.position_radius.xyz);
96    let clip = local_lights.faces[face] * vec4f(shifted, 1.0);
97    if (clip.w <= 0.0) {
98        return vec3f(1.0);
99    }
100    let ndc = clip.xyz / clip.w;
101    let uv = clamp(vec2f(ndc.x, -ndc.y) * 0.5 + vec2f(0.5), vec2f(0.0), vec2f(1.0));
102    let columns = max(local_lights.info.w, 1u);
103    let per = columns * columns;
104    let slot = face % per;
105    let corner = vec2i(vec2u(slot % columns, slot / columns) * tile);
106    let at = corner + min(vec2i(uv * f32(tile)), vec2i(i32(tile) - 1));
107    let stored = textureLoad(shadow_transmission, at, FIRST_TILE_LAYER + face / per, 0);
108    return select(vec3f(1.0), unpack4x8unorm(stored.x).xyz, ndc.z > bitcast<f32>(stored.y));
109}
110
111fn local_light_radiance(surface: Shaded, wo: vec3f, t: vec3f, b: vec3f, n: vec3f, world: vec3f, geometric: vec3f, film: vec3f) -> vec3f {
112    var total = vec3f(0.0);
113    let count = min(local_lights.info.x, 4u);
114    for (var index = 0u; index < count; index++) {
115        let light = local_lights.lights[index];
116        let to_light = light.position_radius.xyz - world;
117        let distance = length(to_light);
118        let range = light.colour_range.w;
119        if (distance >= range || distance <= 1e-6) {
120            continue;
121        }
122        let l = to_light / distance;
123        let ndl = dot(n, l);
124        if (ndl <= 0.0 || dot(geometric, l) <= 0.0) {
125            continue;
126        }
127        let ratio = distance / range;
128        let window = clamp(1.0 - ratio * ratio * ratio * ratio, 0.0, 1.0);
129        let reach = max(distance, light.position_radius.w);
130        var lit = surface;
131        lit.roughness = max(surface.roughness, sqrt(clamp(light.position_radius.w / (2.0 * distance), 0.0, 1.0)));
132        let wi = vec3f(dot(l, t), dot(l, b), dot(l, n));
133        let radiance = light.colour_range.xyz * window * window / (reach * reach);
134        total += eval_filmed(lit, wo, wi, film) * radiance * ndl * light_shadow(light, world, geometric, l) * light_tint(light, world, geometric, l);
135    }
136    return total;
137}
138
139fn shadow_tint_one(world: vec3f, normal: vec3f, instance: u32, cascade: i32) -> vec3f {
140    var view_proj = shadow_setting.view_proj_0;
141    var texel = shadow_setting.texels.x;
142    if (cascade == 1) {
143        view_proj = shadow_setting.view_proj_1;
144        texel = shadow_setting.texels.y;
145    } else if (cascade == 2) {
146        view_proj = shadow_setting.view_proj_2;
147        texel = shadow_setting.texels.z;
148    }
149    let shifted = shadow_offset(world, normal, shadow_setting.sun.xyz, shadow_setting.filter_params, texel, -1.0);
150    let projected = shadow_project(view_proj, shifted);
151    if (any(projected < vec3f(0.0)) || any(projected > vec3f(1.0))) {
152        return vec3f(1.0);
153    }
154    let size = vec2i(textureDimensions(shadow_transmission));
155    let at = clamp(vec2i(projected.xy * vec2f(size)), vec2i(0), size - vec2i(1));
156    let stored = textureLoad(shadow_transmission, at, cascade, 0);
157    let behind = projected.z - shadow_setting.sun.w > bitcast<f32>(stored.y) && stored.z != instance + 1u;
158    return select(vec3f(1.0), unpack4x8unorm(stored.x).xyz, behind);
159}
160
161fn shadow_tint(world: vec3f, normal: vec3f, instance: u32) -> vec3f {
162    if (local_lights.info.y == 0u || shadow_setting.texels.w > 0.5) {
163        return vec3f(1.0);
164    }
165    let distance = length(world - frame.camera_position.xyz);
166    let choice = shadow_select(distance, shadow_setting.splits, shadow_setting.filter_params.w);
167    let tint = shadow_tint_one(world, normal, instance, i32(choice.x));
168    if (choice.z <= 0.0) {
169        return tint;
170    }
171    return mix(tint, shadow_tint_one(world, normal, instance, i32(choice.y)), choice.z);
172}
173
174struct TransmitOutput {
175    @builtin(position) position: vec4f,
176    @location(0) world_position: vec3f,
177    @location(1) @interpolate(flat) instance: u32,
178    @location(2) @interpolate(flat) tint: vec4f,
179}
180
181@vertex fn transmit_vertex(input: VertexInput) -> TransmitOutput {
182    let instance = instances[input.instance_index];
183    var output: TransmitOutput;
184    output.tint = caster_tints[instance.fields.x];
185    output.instance = input.instance_index;
186    if (output.tint.w < 0.5) {
187        output.position = vec4f(2.0, 2.0, 2.0, 1.0);
188        return output;
189    }
190    let position = caster_position(input, instance);
191    let world = instance.model * vec4f(position, 1.0);
192    output.position = caster.view_proj * world;
193    output.world_position = world.xyz;
194    return output;
195}
196
197@fragment fn transmit_fragment(input: TransmitOutput) -> @location(0) vec4u {
198    if (clipped(input.instance, input.world_position)) {
199        discard;
200    }
201    return vec4u(pack4x8unorm(vec4f(input.tint.xyz, 1.0)), bitcast<u32>(input.position.z), input.instance + 1u, 0u);
202}
203"#;
204
205#[derive(Clone, Copy, Debug, PartialEq)]
206pub struct LocalLight {
207    pub position: [f32; 3],
208    pub colour: [f32; 3],
209    pub intensity: f32,
210    pub radius: f32,
211    pub range: f32,
212    pub shadow: bool,
213}
214
215pub fn local_light(light: &Light) -> LocalLight {
216    LocalLight {
217        position: light.position,
218        colour: light.color,
219        intensity: light.intensity,
220        radius: light.radius,
221        range: light.range,
222        shadow: light.shadow,
223    }
224}
225
226impl LocalLight {
227    pub fn faded(self, fade: f32) -> Self {
228        Self {
229            intensity: self.intensity * fade.max(0.0),
230            ..self
231        }
232    }
233
234    pub fn lit(&self) -> bool {
235        self.intensity > 0.0 && self.range > 0.0 && self.colour.iter().any(|&value| value > 0.0)
236    }
237
238    fn valid(&self) -> bool {
239        self.position
240            .iter()
241            .chain(&self.colour)
242            .chain([&self.intensity, &self.radius, &self.range])
243            .all(|value| value.is_finite())
244            && self.colour.iter().all(|&value| value >= 0.0)
245            && self.intensity >= 0.0
246            && self.radius >= 0.0
247            && self.range >= 0.0
248    }
249
250    pub fn near(&self) -> f32 {
251        (self.radius * 0.5).clamp(0.002, (self.range * 0.5).max(0.002))
252    }
253}
254
255#[derive(Clone, Copy, Debug, PartialEq)]
256pub struct LightQuality {
257    pub resolution: u32,
258    pub pcf_radius: f32,
259    pub normal_bias: f32,
260    pub depth_bias: f32,
261}
262
263impl Default for LightQuality {
264    fn default() -> Self {
265        Self {
266            resolution: DEFAULT_FACE_RESOLUTION,
267            pcf_radius: 1.5,
268            normal_bias: 1.5,
269            depth_bias: 1.5,
270        }
271    }
272}
273
274pub fn sheet_tint(material: &Material) -> Option<[f32; 3]> {
275    (material.subsurface > 0.0).then(|| {
276        let share = (2.0 * material.subsurface).min(1.0);
277        material
278            .base
279            .map(|channel| (channel * share).clamp(0.0, 1.0))
280    })
281}
282
283pub fn tan_half(resolution: u32) -> f32 {
284    let margin = (2.0 * MARGIN_TEXELS / resolution.max(32) as f32).min(0.5);
285    1.0 / (1.0 - margin)
286}
287
288pub fn face_view_proj(light: &LocalLight, face: usize, resolution: u32) -> Matrix {
289    let (forward, up) = FACE_AXES[face % FACES];
290    let right = cross(forward, up);
291    let eye = light.position;
292    let view = [
293        [right[0], up[0], -forward[0], 0.0],
294        [right[1], up[1], -forward[1], 0.0],
295        [right[2], up[2], -forward[2], 0.0],
296        [-dot(right, eye), -dot(up, eye), dot(forward, eye), 1.0],
297    ];
298    let near = light.near();
299    let far = light.range.max(near * 2.0);
300    let f = 1.0 / tan_half(resolution);
301    let projection = [
302        [f, 0.0, 0.0, 0.0],
303        [0.0, f, 0.0, 0.0],
304        [0.0, 0.0, far / (near - far), -1.0],
305        [0.0, 0.0, far * near / (near - far), 0.0],
306    ];
307    multiply(projection, view)
308}
309
310pub fn face_of(direction: [f32; 3]) -> usize {
311    let a = direction.map(f32::abs);
312    if a[0] >= a[1] && a[0] >= a[2] {
313        return if direction[0] > 0.0 { 0 } else { 1 };
314    }
315    if a[1] >= a[2] {
316        return if direction[1] > 0.0 { 2 } else { 3 };
317    }
318    if direction[2] > 0.0 { 4 } else { 5 }
319}
320
321pub fn falloff(light: &LocalLight, distance: f32) -> f32 {
322    if distance >= light.range || distance <= 1e-6 {
323        return 0.0;
324    }
325    let ratio = distance / light.range;
326    let window = (1.0 - ratio.powi(4)).clamp(0.0, 1.0);
327    let reach = distance.max(light.radius);
328    light.intensity * window * window / (reach * reach)
329}
330
331fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
332    [
333        a[1] * b[2] - a[2] * b[1],
334        a[2] * b[0] - a[0] * b[2],
335        a[0] * b[1] - a[1] * b[0],
336    ]
337}
338
339fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
340    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
341}
342
343#[repr(C)]
344#[derive(Clone, Copy, Pod, Zeroable)]
345struct GpuLight {
346    position_radius: [f32; 4],
347    colour_range: [f32; 4],
348    shadow: [f32; 4],
349    bias: [f32; 4],
350}
351
352#[repr(C)]
353#[derive(Clone, Copy, Pod, Zeroable)]
354struct GpuLights {
355    info: [u32; 4],
356    lights: [GpuLight; MAX_LIGHTS],
357    faces: [Matrix; MAX_LIGHTS * FACES],
358}
359
360struct FaceMaps {
361    resolution: u32,
362    layers: u32,
363    texture: wgpu::Texture,
364    backup: wgpu::Texture,
365    array: wgpu::TextureView,
366    views: Vec<wgpu::TextureView>,
367}
368
369pub type FaceDraw<'d> = dyn FnMut(&mut wgpu::RenderPass<'_>, usize, bool) + 'd;
370
371pub struct Lights {
372    lights: Vec<LocalLight>,
373    quality: LightQuality,
374    uniform: wgpu::Buffer,
375    maps: Option<FaceMaps>,
376    empty_faces: wgpu::TextureView,
377    empty_transmission: wgpu::TextureView,
378    sampler: wgpu::Sampler,
379    tints: Vec<Option<[f32; 3]>>,
380    table: wgpu::Buffer,
381    table_capacity: usize,
382    written: Vec<[f32; 4]>,
383    cache: Option<u64>,
384    clean: bool,
385    reused: bool,
386    extended: bool,
387}
388
389impl Lights {
390    pub fn new(device: &wgpu::Device) -> Self {
391        let uniform = device.create_buffer(&wgpu::BufferDescriptor {
392            label: Some("live local lights"),
393            size: std::mem::size_of::<GpuLights>() as u64,
394            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
395            mapped_at_creation: false,
396        });
397        let empty_faces = device
398            .create_texture(&wgpu::TextureDescriptor {
399                label: Some("live empty light faces"),
400                size: wgpu::Extent3d {
401                    width: 1,
402                    height: 1,
403                    depth_or_array_layers: 1,
404                },
405                mip_level_count: 1,
406                sample_count: 1,
407                dimension: wgpu::TextureDimension::D2,
408                format: DEPTH_FORMAT,
409                usage: wgpu::TextureUsages::TEXTURE_BINDING
410                    | wgpu::TextureUsages::RENDER_ATTACHMENT,
411                view_formats: &[],
412            })
413            .create_view(&wgpu::TextureViewDescriptor {
414                dimension: Some(wgpu::TextureViewDimension::D2Array),
415                ..Default::default()
416            });
417        let empty_transmission = device
418            .create_texture(&wgpu::TextureDescriptor {
419                label: Some("live empty transmission"),
420                size: wgpu::Extent3d {
421                    width: 1,
422                    height: 1,
423                    depth_or_array_layers: 1,
424                },
425                mip_level_count: 1,
426                sample_count: 1,
427                dimension: wgpu::TextureDimension::D2,
428                format: crate::shadow::TRANSMISSION_FORMAT,
429                usage: wgpu::TextureUsages::TEXTURE_BINDING,
430                view_formats: &[],
431            })
432            .create_view(&wgpu::TextureViewDescriptor {
433                dimension: Some(wgpu::TextureViewDimension::D2Array),
434                ..Default::default()
435            });
436        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
437            label: Some("live light compare"),
438            address_mode_u: wgpu::AddressMode::ClampToEdge,
439            address_mode_v: wgpu::AddressMode::ClampToEdge,
440            address_mode_w: wgpu::AddressMode::ClampToEdge,
441            mag_filter: wgpu::FilterMode::Linear,
442            min_filter: wgpu::FilterMode::Linear,
443            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
444            compare: Some(wgpu::CompareFunction::LessEqual),
445            ..Default::default()
446        });
447        let table_capacity = 1;
448        Self {
449            lights: Vec::new(),
450            quality: LightQuality::default(),
451            uniform,
452            maps: None,
453            empty_faces,
454            empty_transmission,
455            sampler,
456            tints: Vec::new(),
457            table: table_buffer(device, table_capacity),
458            table_capacity,
459            written: Vec::new(),
460            cache: None,
461            clean: false,
462            reused: false,
463            extended: false,
464        }
465    }
466
467    pub fn set(&mut self, lights: &[LocalLight]) -> Result<(), String> {
468        if lights.len() > MAX_LIGHTS {
469            return Err(format!("at most {MAX_LIGHTS} local lights"));
470        }
471        if !lights.iter().all(LocalLight::valid) {
472            return Err("local lights need finite, nonnegative values".into());
473        }
474        self.lights = lights.to_vec();
475        Ok(())
476    }
477
478    pub fn lights(&self) -> &[LocalLight] {
479        &self.lights
480    }
481
482    pub fn set_quality(&mut self, quality: LightQuality) -> Result<(), String> {
483        if quality.resolution < 32
484            || ![quality.pcf_radius, quality.normal_bias, quality.depth_bias]
485                .iter()
486                .all(|value| value.is_finite() && *value >= 0.0)
487        {
488            return Err("light shadow quality needs at least 32 texels and finite biases".into());
489        }
490        self.quality = quality;
491        Ok(())
492    }
493
494    pub fn quality(&self) -> LightQuality {
495        self.quality
496    }
497
498    pub fn set_transmission(&mut self, tints: &[Option<[f32; 3]>]) -> Result<(), String> {
499        if tints
500            .iter()
501            .flatten()
502            .flatten()
503            .any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))
504        {
505            return Err("transmission tints must be fractions".into());
506        }
507        self.tints = tints.to_vec();
508        Ok(())
509    }
510
511    pub fn transmissive(&self, material: u32) -> bool {
512        self.tints
513            .get(material as usize)
514            .is_some_and(Option::is_some)
515    }
516
517    pub fn any_transmissive(&self) -> bool {
518        self.tints.iter().any(Option::is_some)
519    }
520
521    pub fn table(&self) -> &wgpu::Buffer {
522        &self.table
523    }
524
525    pub fn table_key(&self) -> u64 {
526        let mut hasher = std::collections::hash_map::DefaultHasher::new();
527        for tint in &self.tints {
528            tint.map(|value| value.map(f32::to_bits)).hash(&mut hasher);
529        }
530        hasher.finish()
531    }
532
533    pub fn prepare(
534        &mut self,
535        device: &wgpu::Device,
536        queue: &wgpu::Queue,
537        materials: usize,
538    ) -> bool {
539        let rows: Vec<[f32; 4]> = (0..materials.max(1))
540            .map(|index| match self.tints.get(index).copied().flatten() {
541                Some(tint) => [tint[0], tint[1], tint[2], 1.0],
542                None => [1.0, 1.0, 1.0, 0.0],
543            })
544            .collect();
545        let mut grown = false;
546        if rows.len() > self.table_capacity {
547            self.table_capacity = rows.len().next_power_of_two();
548            self.table = table_buffer(device, self.table_capacity);
549            self.written.clear();
550            grown = true;
551        }
552        if rows != self.written {
553            queue.write_buffer(&self.table, 0, bytemuck::cast_slice(&rows));
554            self.written = rows;
555        }
556        grown
557    }
558
559    pub fn shadowed(&self) -> Vec<(usize, u32)> {
560        self.lights
561            .iter()
562            .enumerate()
563            .filter(|(_, light)| light.lit() && light.shadow)
564            .enumerate()
565            .map(|(slot, (index, _))| (index, (slot * FACES) as u32))
566            .collect()
567    }
568
569    pub fn faces(&self) -> Vec<(u32, Matrix)> {
570        let resolution = self.quality.resolution;
571        self.shadowed()
572            .into_iter()
573            .flat_map(|(index, first)| {
574                let light = self.lights[index];
575                (0..FACES).map(move |face| {
576                    (
577                        first + face as u32,
578                        face_view_proj(&light, face, resolution),
579                    )
580                })
581            })
582            .collect()
583    }
584
585    pub fn uniform(&self) -> &wgpu::Buffer {
586        &self.uniform
587    }
588
589    pub fn faces_view(&self) -> &wgpu::TextureView {
590        self.maps
591            .as_ref()
592            .map_or(&self.empty_faces, |maps| &maps.array)
593    }
594
595    pub fn empty_transmission(&self) -> &wgpu::TextureView {
596        &self.empty_transmission
597    }
598
599    pub fn sampler(&self) -> &wgpu::Sampler {
600        &self.sampler
601    }
602
603    pub fn reused(&self) -> bool {
604        self.reused
605    }
606
607    pub fn extended(&self) -> bool {
608        self.extended
609    }
610
611    pub fn write_uniform(&mut self, queue: &wgpu::Queue, transmission: bool, tiles: FaceTiles) {
612        let mut data = GpuLights::zeroed();
613        let shadowed = self.shadowed();
614        let resolution = self.quality.resolution;
615        let mut count = 0;
616        for (index, light) in self.lights.iter().enumerate() {
617            if !light.lit() {
618                continue;
619            }
620            let layer = shadowed
621                .iter()
622                .find(|(shadowed, _)| *shadowed == index)
623                .map_or(-1.0, |(_, first)| *first as f32);
624            data.lights[count] = GpuLight {
625                position_radius: [
626                    light.position[0],
627                    light.position[1],
628                    light.position[2],
629                    light.radius,
630                ],
631                colour_range: [
632                    light.colour[0] * light.intensity,
633                    light.colour[1] * light.intensity,
634                    light.colour[2] * light.intensity,
635                    light.range,
636                ],
637                shadow: [
638                    layer,
639                    2.0 * tan_half(resolution) / resolution as f32,
640                    0.0,
641                    self.quality.pcf_radius,
642                ],
643                bias: [self.quality.normal_bias, self.quality.depth_bias, 0.0, 0.0],
644            };
645            count += 1;
646        }
647        for (layer, matrix) in self.faces() {
648            data.faces[layer as usize] = matrix;
649        }
650        let tiles = if transmission {
651            tiles
652        } else {
653            FaceTiles::default()
654        };
655        data.info = [
656            count as u32,
657            u32::from(transmission),
658            tiles.tile,
659            tiles.columns,
660        ];
661        self.extended = count > 0 || transmission;
662        queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&data));
663    }
664
665    pub fn render(
666        &mut self,
667        device: &wgpu::Device,
668        encoder: &mut wgpu::CommandEncoder,
669        content: u64,
670        moving: bool,
671        draw: &mut FaceDraw<'_>,
672        profiler: Option<&mut GpuProfiler>,
673    ) {
674        let shadowed = self.shadowed();
675        if shadowed.is_empty() {
676            self.reused = false;
677            return;
678        }
679        let layers = (shadowed.len() * FACES) as u32;
680        let resolution = self
681            .quality
682            .resolution
683            .min(device.limits().max_texture_dimension_2d);
684        if self
685            .maps
686            .as_ref()
687            .is_none_or(|maps| maps.layers != layers || maps.resolution != resolution)
688        {
689            self.maps = Some(face_maps(device, resolution, layers));
690            self.cache = None;
691            self.clean = false;
692        }
693        let mut hasher = std::collections::hash_map::DefaultHasher::new();
694        content.hash(&mut hasher);
695        resolution.hash(&mut hasher);
696        for (index, _) in &shadowed {
697            let light = self.lights[*index];
698            for value in light.position.iter().chain([&light.range, &light.radius]) {
699                value.to_bits().hash(&mut hasher);
700            }
701        }
702        let key = hasher.finish();
703        let hit = self.cache == Some(key);
704        if hit && self.clean && !moving {
705            self.reused = true;
706            return;
707        }
708        self.reused = hit;
709        let Some(maps) = &self.maps else {
710            return;
711        };
712        let total = (usize::from(!hit) + usize::from(moving)) * maps.views.len();
713        let mut profiler = profiler;
714        let timing = if total > 0 {
715            profiler
716                .as_deref_mut()
717                .and_then(|timer| timer.pass("light shadows"))
718        } else {
719            None
720        };
721        let mut faces = Faces {
722            done: 0,
723            total,
724            timing,
725            profiler: profiler.as_deref(),
726        };
727        if hit {
728            if !self.clean {
729                copy_layers(encoder, &maps.backup, &maps.texture, resolution, layers);
730            }
731        } else {
732            faces.encode(encoder, maps, true, draw);
733            copy_layers(encoder, &maps.texture, &maps.backup, resolution, layers);
734        }
735        if moving {
736            faces.encode(encoder, maps, false, draw);
737        }
738        self.cache = Some(key);
739        self.clean = !moving;
740    }
741}
742
743struct Faces<'p> {
744    done: usize,
745    total: usize,
746    timing: Option<u32>,
747    profiler: Option<&'p GpuProfiler>,
748}
749
750impl Faces<'_> {
751    fn encode(
752        &mut self,
753        encoder: &mut wgpu::CommandEncoder,
754        maps: &FaceMaps,
755        statics: bool,
756        draw: &mut FaceDraw<'_>,
757    ) {
758        for (layer, view) in maps.views.iter().enumerate() {
759            let writes = self
760                .profiler
761                .and_then(|timer| timer.render_writes(self.timing))
762                .map(|writes| wgpu::RenderPassTimestampWrites {
763                    query_set: writes.query_set,
764                    beginning_of_pass_write_index: writes
765                        .beginning_of_pass_write_index
766                        .filter(|_| self.done == 0),
767                    end_of_pass_write_index: writes
768                        .end_of_pass_write_index
769                        .filter(|_| self.done + 1 == self.total),
770                })
771                .filter(|writes| {
772                    writes.beginning_of_pass_write_index.is_some()
773                        || writes.end_of_pass_write_index.is_some()
774                });
775            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
776                multiview_mask: None,
777                label: Some("light shadow face"),
778                color_attachments: &[],
779                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
780                    view,
781                    depth_ops: Some(wgpu::Operations {
782                        load: if statics {
783                            wgpu::LoadOp::Clear(1.0)
784                        } else {
785                            wgpu::LoadOp::Load
786                        },
787                        store: wgpu::StoreOp::Store,
788                    }),
789                    stencil_ops: None,
790                }),
791                timestamp_writes: writes,
792                occlusion_query_set: None,
793            });
794            draw(&mut pass, layer, statics);
795            self.done += 1;
796        }
797    }
798}
799
800fn table_buffer(device: &wgpu::Device, rows: usize) -> wgpu::Buffer {
801    device.create_buffer(&wgpu::BufferDescriptor {
802        label: Some("live caster tints"),
803        size: (rows.max(1) * 16) as u64,
804        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
805        mapped_at_creation: false,
806    })
807}
808
809fn face_maps(device: &wgpu::Device, resolution: u32, layers: u32) -> FaceMaps {
810    let size = wgpu::Extent3d {
811        width: resolution,
812        height: resolution,
813        depth_or_array_layers: layers,
814    };
815    let make = |label| {
816        device.create_texture(&wgpu::TextureDescriptor {
817            label: Some(label),
818            size,
819            mip_level_count: 1,
820            sample_count: 1,
821            dimension: wgpu::TextureDimension::D2,
822            format: DEPTH_FORMAT,
823            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
824                | wgpu::TextureUsages::TEXTURE_BINDING
825                | wgpu::TextureUsages::COPY_SRC
826                | wgpu::TextureUsages::COPY_DST,
827            view_formats: &[],
828        })
829    };
830    let texture = make("live light faces");
831    let backup = make("live light faces static");
832    let array = texture.create_view(&wgpu::TextureViewDescriptor {
833        dimension: Some(wgpu::TextureViewDimension::D2Array),
834        ..Default::default()
835    });
836    let views = (0..layers)
837        .map(|layer| {
838            texture.create_view(&wgpu::TextureViewDescriptor {
839                dimension: Some(wgpu::TextureViewDimension::D2),
840                base_array_layer: layer,
841                array_layer_count: Some(1),
842                ..Default::default()
843            })
844        })
845        .collect();
846    FaceMaps {
847        resolution,
848        layers,
849        texture,
850        backup,
851        array,
852        views,
853    }
854}
855
856fn copy_layers(
857    encoder: &mut wgpu::CommandEncoder,
858    from: &wgpu::Texture,
859    to: &wgpu::Texture,
860    resolution: u32,
861    layers: u32,
862) {
863    encoder.copy_texture_to_texture(
864        from.as_image_copy(),
865        to.as_image_copy(),
866        wgpu::Extent3d {
867            width: resolution,
868            height: resolution,
869            depth_or_array_layers: layers,
870        },
871    );
872}
873
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use crate::frame::{Camera, Frame, Instance, MeshData, Scene, SceneWind, Sun, transform};
878    use crate::probes::{PROBE_WGSL, ProbeLighting};
879    use crate::shadow::{Quality, Shadows, View};
880    use half::f16;
881    use pfx_bake::{BakeScene, Emitters, Grid, GridSpec, Probe, write_artifact_with_emitters};
882    use pfx_gpu::Gpu;
883    use std::path::Path;
884
885    const SIZE: u32 = 256;
886
887    #[test]
888    fn light_tiles_start_after_the_cascades() {
889        assert!(LIGHTS_WGSL.contains(&format!("const FIRST_TILE_LAYER: u32 = {CASCADE_COUNT}u;")));
890    }
891
892    fn light() -> LocalLight {
893        LocalLight {
894            position: [0.5, 1.0, 0.0],
895            colour: [1.0; 3],
896            intensity: 1.0,
897            radius: 0.0,
898            range: 10.0,
899            shadow: true,
900        }
901    }
902
903    #[test]
904    fn each_face_sees_its_own_axis_inside_the_margin() {
905        let light = light();
906        let resolution = 512;
907        for direction in [
908            [1.0, 0.2, -0.3],
909            [-0.7, 0.69, 0.1],
910            [0.1, 0.9, 0.3],
911            [0.2, -1.0, 0.999],
912            [0.4, -0.2, 0.6],
913            [-0.3, 0.3, -0.31],
914            [0.577, 0.577, 0.577],
915        ] {
916            let face = face_of(direction);
917            let matrix = face_view_proj(&light, face, resolution);
918            let mut previous = -1.0;
919            for distance in [0.05, 0.5, 2.0, 9.0] {
920                let length = dot(direction, direction).sqrt();
921                let point = [0, 1, 2]
922                    .map(|axis| light.position[axis] + direction[axis] / length * distance);
923                let clip = transform(matrix, [point[0], point[1], point[2], 1.0]);
924                let ndc = [clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]];
925                let inside = 1.0 / tan_half(resolution) + 1e-5;
926                assert!(
927                    ndc[0].abs() <= inside && ndc[1].abs() <= inside,
928                    "{direction:?} {ndc:?}"
929                );
930                assert!(ndc[2] > previous && ndc[2] < 1.0, "{direction:?} {ndc:?}");
931                previous = ndc[2];
932            }
933        }
934        let margin = (1.0 - 1.0 / tan_half(resolution)) * 0.5 * resolution as f32;
935        assert!((margin - MARGIN_TEXELS).abs() < 1e-3);
936    }
937
938    #[test]
939    fn falloff_is_inverse_square_and_ends_at_the_range() {
940        let light = LocalLight {
941            intensity: 0.88,
942            radius: 0.06,
943            range: 3.0,
944            ..light()
945        };
946        let near = falloff(&light, 0.5);
947        assert!((near - 0.88 / 0.25).abs() / near < 2e-3);
948        assert_eq!(falloff(&light, 3.0), 0.0);
949        assert!(falloff(&light, 0.01) <= 0.88 / (0.06 * 0.06));
950        assert!(!light.faded(0.0).lit());
951        assert_eq!(light.faded(4.0).intensity, 0.88 * 4.0);
952    }
953
954    #[test]
955    fn sheet_tint_is_base_times_twice_the_translucency() {
956        let paper = Material {
957            base: [0.9, 0.85, 0.7],
958            subsurface: 0.22,
959            ..Material::default()
960        };
961        let tint = sheet_tint(&paper).unwrap();
962        for (channel, base) in tint.iter().zip(paper.base) {
963            assert!((channel - base * 0.44).abs() < 1e-6);
964        }
965        let thick = Material {
966            subsurface: 0.7,
967            ..paper
968        };
969        assert_eq!(sheet_tint(&thick).unwrap(), paper.base);
970        assert_eq!(sheet_tint(&Material::default()), None);
971    }
972
973    #[test]
974    fn light_settings_are_checked() {
975        let broken = LocalLight {
976            intensity: f32::NAN,
977            ..light()
978        };
979        assert!(!broken.valid());
980        assert!(light().valid());
981        assert!(
982            !LocalLight {
983                range: -1.0,
984                ..light()
985            }
986            .valid()
987        );
988    }
989
990    fn quad(centre: [f32; 3], half: [f32; 2]) -> (Vec<[f32; 3]>, Vec<u32>) {
991        let [x, y, z] = centre;
992        (
993            vec![
994                [x - half[0], y, z + half[1]],
995                [x + half[0], y, z + half[1]],
996                [x + half[0], y, z - half[1]],
997                [x - half[0], y, z - half[1]],
998            ],
999            vec![0, 1, 2, 0, 2, 3],
1000        )
1001    }
1002
1003    fn upload(frame: &mut Frame, centre: [f32; 3], half: [f32; 2]) -> crate::frame::MeshHandle {
1004        let (positions, indices) = quad(centre, half);
1005        frame
1006            .upload_mesh(MeshData {
1007                positions: &positions,
1008                normals: &[[0.0, 1.0, 0.0]; 4],
1009                tangents: &[[1.0, 0.0, 0.0, 1.0]; 4],
1010                uvs: &[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
1011                uvs1: None,
1012                alpha: None,
1013                indices: &indices,
1014            })
1015            .unwrap()
1016    }
1017
1018    fn identity() -> Matrix {
1019        [
1020            [1.0, 0.0, 0.0, 0.0],
1021            [0.0, 1.0, 0.0, 0.0],
1022            [0.0, 0.0, 1.0, 0.0],
1023            [0.0, 0.0, 0.0, 1.0],
1024        ]
1025    }
1026
1027    fn camera() -> Camera {
1028        let near = 0.1;
1029        let far = 100.0;
1030        let f = 1.0 / (50.0_f32.to_radians() * 0.5).tan();
1031        let projection = [
1032            [f, 0.0, 0.0, 0.0],
1033            [0.0, f, 0.0, 0.0],
1034            [0.0, 0.0, far / (near - far), -1.0],
1035            [0.0, 0.0, far * near / (near - far), 0.0],
1036        ];
1037        let view = [
1038            [1.0, 0.0, 0.0, 0.0],
1039            [0.0, 0.0, 1.0, 0.0],
1040            [0.0, -1.0, 0.0, 0.0],
1041            [0.0, 0.0, -2.0, 1.0],
1042        ];
1043        Camera {
1044            view,
1045            projection,
1046            previous_view_projection: multiply(projection, view),
1047            position: [0.0, 2.0, 0.0],
1048        }
1049    }
1050
1051    fn pixel(x: f32, z: f32) -> usize {
1052        let extent = 2.0 * (25.0_f32).to_radians().tan();
1053        let column = ((x / extent * 0.5 + 0.5) * SIZE as f32) as usize;
1054        let row = ((z / extent * 0.5 + 0.5) * SIZE as f32) as usize;
1055        row * SIZE as usize + column
1056    }
1057
1058    fn matte(base: [f32; 3]) -> Material {
1059        Material {
1060            base,
1061            roughness: 1.0,
1062            specular: 0.0,
1063            ..Material::default()
1064        }
1065    }
1066
1067    struct Bench {
1068        frame: Frame,
1069        shadows: Shadows,
1070        instances: Vec<Instance>,
1071        materials: Vec<Material>,
1072    }
1073
1074    impl Bench {
1075        fn render(&mut self, sun: f32) -> Vec<[f32; 3]> {
1076            let view = View {
1077                eye: [0.0, 2.0, 0.0],
1078                forward: [0.0, -1.0, 0.0],
1079                up: [0.0, 0.0, -1.0],
1080                fov_y: 50.0_f32.to_radians(),
1081                aspect: 1.0,
1082                near: 0.1,
1083                far: 100.0,
1084            };
1085            let toward = [0.70710677, 0.70710677, 0.0];
1086            let fit = self.shadows.fit(&view, toward);
1087            let scene = Scene {
1088                camera: camera(),
1089                time: 0.0,
1090                seed: 7,
1091                sun: Sun {
1092                    direction: toward,
1093                    colour: [1.0; 3],
1094                    intensity: sun,
1095                },
1096                instances: &self.instances,
1097                materials: &self.materials,
1098                deformers: &[],
1099                wind: SceneWind::default(),
1100            };
1101            let mut encoder = self
1102                .frame
1103                .gpu
1104                .device
1105                .create_command_encoder(&Default::default());
1106            self.frame
1107                .encode(&scene, &mut encoder, Some((&mut self.shadows, &fit)), None)
1108                .unwrap();
1109            self.frame.gpu.queue.submit(Some(encoder.finish()));
1110            self.frame
1111                .gpu
1112                .readback_rgba16(&self.frame.targets.hdr)
1113                .unwrap()
1114                .chunks_exact(4)
1115                .map(|texel| [0, 1, 2].map(|k| f16::from_bits(texel[k]).to_f32()))
1116                .collect()
1117        }
1118    }
1119
1120    fn bench() -> Bench {
1121        let gpu = pollster::block_on(Gpu::headless()).unwrap();
1122        let mut frame = Frame::new(gpu, SIZE, SIZE).unwrap();
1123        let shadows = Shadows::new(
1124            &frame.gpu.device,
1125            Quality {
1126                resolution: 1024,
1127                receiver: Some(crate::shadow::ReceiverBox {
1128                    min: [-1.0, -0.1, -1.0],
1129                    max: [1.0, 1.0, 1.0],
1130                }),
1131                ..Default::default()
1132            },
1133        );
1134        let floor = upload(&mut frame, [0.0, 0.0, 0.0], [2.0, 2.0]);
1135        Bench {
1136            frame,
1137            shadows,
1138            instances: vec![Instance::new(floor, identity(), 0, 1)],
1139            materials: vec![matte([0.5; 3])],
1140        }
1141    }
1142
1143    fn lambert(albedo: f32, light: &LocalLight, point: [f32; 3]) -> f32 {
1144        let to = [0, 1, 2].map(|k| light.position[k] - point[k]);
1145        let distance = dot(to, to).sqrt();
1146        albedo / std::f32::consts::PI * falloff(light, distance) * to[1] / distance
1147    }
1148
1149    #[test]
1150    #[ignore = "needs a GPU; run under pgpu"]
1151    fn a_point_light_shadows_a_plane() {
1152        let mut bench = bench();
1153        let blocker = upload(&mut bench.frame, [0.25, 0.5, 0.0], [0.1, 0.1]);
1154        bench
1155            .instances
1156            .push(Instance::new(blocker, identity(), 0, 2));
1157        let dark = bench.render(0.0);
1158        bench.frame.set_lights(&[light()]).unwrap();
1159        let lit = bench.render(0.0);
1160        let again = bench.render(0.0);
1161        assert!(bench.frame.lights().reused());
1162        assert_eq!(lit, again);
1163        bench
1164            .frame
1165            .set_lights(&[LocalLight {
1166                shadow: false,
1167                ..light()
1168            }])
1169            .unwrap();
1170        let open = bench.render(0.0);
1171        let lamp =
1172            |image: &[[f32; 3]], x: f32, z: f32| image[pixel(x, z)][1] - dark[pixel(x, z)][1];
1173        let side = lambert(0.5, &light(), [-0.5, 0.0, 0.0]);
1174        let centre = lambert(0.5, &light(), [0.0, 0.0, 0.0]);
1175        println!(
1176            "lamp at the side {:.4} (expected {side:.4}), centre shadowed {:.4}, centre open {:.4} (expected {centre:.4})",
1177            lamp(&lit, -0.5, 0.0),
1178            lamp(&lit, 0.0, 0.0),
1179            lamp(&open, 0.0, 0.0)
1180        );
1181        assert!((lamp(&lit, -0.5, 0.0) - side).abs() < side * 0.03);
1182        assert!((lamp(&open, 0.0, 0.0) - centre).abs() < centre * 0.03);
1183        assert!(lamp(&lit, 0.0, 0.0).abs() < centre * 0.03);
1184        assert!(lamp(&lit, 0.0, 0.3).abs() > centre * 0.5);
1185        bench.frame.set_lights(&[]).unwrap();
1186        assert_eq!(bench.render(0.0), dark);
1187    }
1188
1189    #[test]
1190    #[ignore = "needs a GPU; run under pgpu"]
1191    fn thin_casters_tint_the_sun_and_opaque_casters_still_block_it() {
1192        let mut bench = bench();
1193        let paper = upload(&mut bench.frame, [0.3, 0.5, -0.35], [0.2, 0.25]);
1194        let card = upload(&mut bench.frame, [0.3, 0.5, 0.35], [0.2, 0.25]);
1195        let stock = Material {
1196            subsurface: 0.3,
1197            ..matte([0.9, 0.6, 0.3])
1198        };
1199        bench.materials.extend([stock, matte([0.4; 3])]);
1200        bench.instances.extend([
1201            Instance::new(paper, identity(), 1, 2),
1202            Instance::new(card, identity(), 2, 3),
1203        ]);
1204        let ambient = bench.render(0.0);
1205        let opaque = bench.render(1.0);
1206        let tints: Vec<Option<[f32; 3]>> = bench.materials.iter().map(sheet_tint).collect();
1207        bench.frame.set_transmission(&tints).unwrap();
1208        let through = bench.render(1.0);
1209        let sun = |image: &[[f32; 3]], x: f32, z: f32| {
1210            let at = pixel(x, z);
1211            [0, 1, 2].map(|k| image[at][k] - ambient[at][k])
1212        };
1213        let open = sun(&through, -0.7, -0.35);
1214        let behind_paper = sun(&through, -0.2, -0.35);
1215        let behind_card = sun(&through, -0.2, 0.35);
1216        let expected = sheet_tint(&stock).unwrap();
1217        println!(
1218            "open {open:?}, behind paper {behind_paper:?} (tint {expected:?}), behind card {behind_card:?}"
1219        );
1220        for k in 0..3 {
1221            assert!((behind_paper[k] / open[k] - expected[k]).abs() < 0.02);
1222            assert!(behind_card[k].abs() < open[k] * 0.02);
1223            assert!(sun(&opaque, -0.2, -0.35)[k].abs() < open[k] * 0.02);
1224        }
1225        for (x, z) in [(-0.7, -0.35), (-0.2, 0.35), (-0.7, 0.6), (0.8, -0.8)] {
1226            assert_eq!(sun(&opaque, x, z), sun(&through, x, z), "{x} {z}");
1227        }
1228        bench.frame.set_transmission(&[]).unwrap();
1229        assert_eq!(bench.render(1.0), opaque);
1230    }
1231
1232    #[test]
1233    #[ignore = "needs a GPU; run under pgpu"]
1234    fn emission_scale_fades_the_baked_lamp() {
1235        let gpu = pollster::block_on(Gpu::headless()).unwrap();
1236        let spec = GridSpec {
1237            min: [0.0; 3],
1238            max: [0.0; 3],
1239            spacing: 1.0,
1240        };
1241        let grid = |value: f32| {
1242            Grid::new(
1243                spec,
1244                vec![Probe {
1245                    lobes: [[value; 3]; 6],
1246                    visibility: [100.0; 6],
1247                }],
1248            )
1249            .unwrap()
1250        };
1251        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp");
1252        std::fs::create_dir_all(&root).unwrap();
1253        let sample = |baked: f32, live: f32| {
1254            let name = format!("live-emission-test-{baked}-{live}");
1255            let path = root.join(&name);
1256            if path.exists() {
1257                std::fs::remove_dir_all(&path).unwrap();
1258            }
1259            let scene = BakeScene {
1260                triangles: Vec::new(),
1261                shapes: Vec::new(),
1262                materials: vec![Material::default()],
1263                anchors: vec![pfx_bake::Anchor {
1264                    hour: 22.0,
1265                    sky: pfx_load::Sky {
1266                        width: 1,
1267                        height: 1,
1268                        texels: vec![[0.0; 4]],
1269                    },
1270                    sun: pfx_trace::Sun {
1271                        direction: [0.0, 1.0, 0.0],
1272                        color: [0.0; 3],
1273                        intensity: 0.0,
1274                    },
1275                }],
1276            };
1277            write_artifact_with_emitters(
1278                &root,
1279                Path::new(&name),
1280                &scene,
1281                &[grid(0.1 + 0.5 * baked)],
1282                &Emitters {
1283                    grid: grid(0.5),
1284                    scales: vec![baked],
1285                    direct: false,
1286                },
1287                1,
1288                1,
1289            )
1290            .unwrap();
1291            let mut lighting =
1292                ProbeLighting::load(&gpu.device, &gpu.queue, &path, 22.0, [0.0; 3]).unwrap();
1293            lighting.set_emission(&gpu.queue, live).unwrap();
1294            let value = probe_value(&gpu, &lighting);
1295            std::fs::remove_dir_all(&path).unwrap();
1296            value
1297        };
1298        let off = sample(0.0, 0.0);
1299        let on = sample(0.0, 2.0);
1300        let kept = sample(1.0, 1.0);
1301        let removed = sample(1.0, 0.0);
1302        println!("off {off:.4}, on {on:.4}, kept {kept:.4}, removed {removed:.4}");
1303        assert!((off - 0.1).abs() < 1e-3);
1304        assert!((on - 1.1).abs() < 1e-2);
1305        assert!((kept - 0.6).abs() < 1e-2);
1306        assert!((removed - 0.1).abs() < 1e-3);
1307    }
1308
1309    fn probe_value(gpu: &Gpu, lighting: &ProbeLighting) -> f32 {
1310        let empty = gpu
1311            .device
1312            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1313                label: None,
1314                entries: &[],
1315            });
1316        let empty_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1317            label: None,
1318            layout: &empty,
1319            entries: &[],
1320        });
1321        let layout = gpu
1322            .device
1323            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1324                label: None,
1325                bind_group_layouts: &[
1326                    Some(&empty),
1327                    Some(&empty),
1328                    Some(&empty),
1329                    Some(lighting.layout()),
1330                ],
1331                immediate_size: 0,
1332            });
1333        let source = format!(
1334            "{PROBE_WGSL}\n@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {{ var p = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0)); return vec4f(p[i], 0.0, 1.0); }}\n@fragment fn fs() -> @location(0) vec4f {{ return vec4f(sample_live_irradiance(vec3f(0.0), vec3f(0.0, 1.0, 0.0)), 1.0); }}"
1335        );
1336        let shader = gpu
1337            .device
1338            .create_shader_module(wgpu::ShaderModuleDescriptor {
1339                label: None,
1340                source: wgpu::ShaderSource::Wgsl(source.into()),
1341            });
1342        let pipeline = gpu
1343            .device
1344            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1345                label: None,
1346                layout: Some(&layout),
1347                vertex: wgpu::VertexState {
1348                    module: &shader,
1349                    entry_point: Some("vs"),
1350                    compilation_options: Default::default(),
1351                    buffers: &[],
1352                },
1353                primitive: wgpu::PrimitiveState::default(),
1354                depth_stencil: None,
1355                multisample: wgpu::MultisampleState::default(),
1356                fragment: Some(wgpu::FragmentState {
1357                    module: &shader,
1358                    entry_point: Some("fs"),
1359                    compilation_options: Default::default(),
1360                    targets: &[Some(wgpu::ColorTargetState {
1361                        format: wgpu::TextureFormat::Rgba16Float,
1362                        blend: None,
1363                        write_mask: wgpu::ColorWrites::ALL,
1364                    })],
1365                }),
1366                multiview_mask: None,
1367                cache: None,
1368            });
1369        let target = gpu
1370            .offscreen(1, 1, wgpu::TextureFormat::Rgba16Float)
1371            .unwrap();
1372        let mut encoder = gpu.device.create_command_encoder(&Default::default());
1373        {
1374            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1375                multiview_mask: None,
1376                label: None,
1377                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1378                    view: &target.view,
1379                    resolve_target: None,
1380                    ops: wgpu::Operations {
1381                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1382                        store: wgpu::StoreOp::Store,
1383                    },
1384                    depth_slice: None,
1385                })],
1386                depth_stencil_attachment: None,
1387                timestamp_writes: None,
1388                occlusion_query_set: None,
1389            });
1390            pass.set_pipeline(&pipeline);
1391            pass.set_bind_group(0, &empty_group, &[]);
1392            pass.set_bind_group(1, &empty_group, &[]);
1393            pass.set_bind_group(2, &empty_group, &[]);
1394            pass.set_bind_group(3, lighting.group(), &[]);
1395            pass.draw(0..3, 0..1);
1396        }
1397        gpu.queue.submit(Some(encoder.finish()));
1398        f16::from_bits(gpu.readback_rgba16(&target).unwrap()[0]).to_f32()
1399    }
1400}