Skip to main content

pfx_live/
probes.rs

1use std::cell::Cell;
2use std::path::Path;
3
4use bytemuck::{Pod, Zeroable};
5use pfx_bake::{
6    AnchorBlend, Artifact, Grid, Manifest, blend_anchors, read_artifact, with_emitters,
7};
8use wgpu::util::DeviceExt;
9
10pub const PROBE_CORNER_FLOOR: f32 = 0.05;
11pub const PROBE_LOCALS: usize = 4;
12pub const PROBE_WGSL: &str = r#"
13override use_probes: bool = true;
14struct ProbeLocal {
15    min_spacing: vec4f,
16    dims: vec4u,
17}
18struct ProbeGridInfo {
19    min_spacing: vec4f,
20    dims: vec4u,
21    fallback: vec4f,
22    blend: vec4f,
23    shade: vec4f,
24    locals: array<ProbeLocal, 4>,
25}
26@group(3) @binding(6) var<storage, read> probe_lower: array<u32>;
27@group(3) @binding(7) var<storage, read> probe_upper: array<u32>;
28@group(3) @binding(8) var<uniform> probe_grid: ProbeGridInfo;
29
30fn probe_word(anchor: u32, index: u32) -> u32 {
31    if (anchor == 0u) { return probe_lower[index]; }
32    return probe_upper[index];
33}
34
35fn probe_block(anchor: u32, offset: u32) -> array<u32, 18> {
36    var block: array<u32, 18>;
37    if (anchor == 0u) {
38        for (var k = 0u; k < 18u; k++) { block[k] = probe_lower[offset + k]; }
39    } else {
40        for (var k = 0u; k < 18u; k++) { block[k] = probe_upper[offset + k]; }
41    }
42    return block;
43}
44
45fn probe_index(xyz: vec3u, dims: vec4u) -> u32 {
46    return (xyz.z * dims.y + xyz.y) * dims.x + xyz.x;
47}
48
49fn sample_probe_volume(position: vec3f, n: vec3f, point: vec3f, anchor: u32, origin: vec4f, dims: vec4u, words: u32, toward: vec3f, seen: ptr<function, f32>) -> vec4f {
50    let last = vec3f(dims.xyz - vec3u(1u));
51    let surface = (position - origin.xyz) / origin.w;
52    let shifted = (point - origin.xyz) / origin.w;
53    let surface_out = any(surface < vec3f(0.0)) || any(surface > last);
54    let shifted_out = any(shifted < vec3f(0.0)) || any(shifted > last);
55    if (surface_out && shifted_out) { return vec4f(0.0); }
56    let coord = clamp(shifted, vec3f(0.0), last);
57    let base = min(vec3u(floor(coord)), max(dims.xyz, vec3u(2u)) - vec3u(2u));
58    var t = clamp(coord - vec3f(base), vec3f(0.0), vec3f(1.0));
59    for (var axis = 0u; axis < 3u; axis++) {
60        if (dims[axis] == 1u) { t[axis] = 0.0; }
61    }
62    let version_two = dims.w == 2u;
63    var color = vec3f(0.0);
64    var toward_luma = 0.0;
65    var total = 0.0;
66    for (var corner = 0u; corner < 8u; corner++) {
67        var xyz = base;
68        var weight = 1.0;
69        for (var axis = 0u; axis < 3u; axis++) {
70            let high = (corner & (1u << axis)) != 0u;
71            if (high && dims[axis] > 1u) { xyz[axis] += 1u; }
72            weight *= select(1.0 - t[axis], t[axis], high);
73        }
74        weight = max(weight - probe_grid.blend.z, 0.0);
75        if (weight == 0.0) { continue; }
76        let offset = words + probe_index(xyz, dims) * select(15u, 20u, version_two);
77        var center = origin.xyz + vec3f(xyz) * origin.w;
78        if (version_two) {
79            let last = probe_word(anchor, offset + 19u);
80            if (((last >> 16u) & 1u) == 0u) { continue; }
81            center += vec3f(unpack2x16float(probe_word(anchor, offset + 18u)), unpack2x16float(last).x) * origin.w;
82            let toward_probe = center - position + n * 0.0001;
83            let facing = clamp((dot(toward_probe / max(length(toward_probe), 0.000001), n) + 1.0) * 0.5, 0.0, 1.0);
84            weight *= facing * facing * facing;
85            if (weight == 0.0) { continue; }
86            let block = probe_block(anchor, offset);
87            let toward_point = point - center;
88            let distance = length(toward_point);
89            let direction = toward_point / max(distance, 0.000001);
90            var irradiance = vec3f(0.0);
91            var toward_irradiance = 0.0;
92            var mean = 0.0;
93            var second = 0.0;
94            for (var axis = 0u; axis < 3u; axis++) {
95                let normal_component = n[axis];
96                let up = normal_component >= 0.0;
97                let rg = unpack2x16float(select(block[axis * 4u + 2u], block[axis * 4u], up));
98                let b = unpack2x16float(select(block[axis * 4u + 3u], block[axis * 4u + 1u], up)).x;
99                irradiance += vec3f(rg, b) * normal_component * normal_component;
100                let toward_component = toward[axis];
101                let toward_up = toward_component >= 0.0;
102                let toward_rg = unpack2x16float(select(block[axis * 4u + 2u], block[axis * 4u], toward_up));
103                let toward_b = unpack2x16float(select(block[axis * 4u + 3u], block[axis * 4u + 1u], toward_up)).x;
104                toward_irradiance += dot(vec3f(toward_rg, toward_b), vec3f(0.2126, 0.7152, 0.0722)) * toward_component * toward_component;
105                let ray_component = direction[axis];
106                let pair = unpack2x16float(block[12u + axis]);
107                let depth = select(pair.y, pair.x, ray_component >= 0.0);
108                let ray_weight = ray_component * ray_component;
109                let squared = unpack2x16float(block[15u + axis]);
110                mean += ray_weight * depth * origin.w;
111                second += ray_weight * select(squared.y, squared.x, ray_component >= 0.0) * origin.w * origin.w;
112            }
113            let variance = max(second - mean * mean, origin.w * origin.w * 0.01);
114            let delta = max(distance - mean - origin.w * 0.1, 0.0);
115            let chance = variance / (variance + delta * delta);
116            weight *= chance * chance;
117            color += irradiance * weight;
118            toward_luma += toward_irradiance * weight;
119            total += weight;
120            continue;
121        }
122        let toward_probe = center - position + n * 0.0001;
123        let facing = clamp((dot(toward_probe / max(length(toward_probe), 0.000001), n) + 1.0) * 0.5, 0.0, 1.0);
124        weight *= facing * facing * facing;
125        if (weight == 0.0) { continue; }
126        let toward_point = point - center;
127        let distance = length(toward_point);
128        let direction = toward_point / max(distance, 0.000001);
129        var irradiance = vec3f(0.0);
130        var toward_irradiance = 0.0;
131        var visibility = 0.0;
132        for (var axis = 0u; axis < 3u; axis++) {
133            let normal_component = n[axis];
134            let normal_lobe = axis * 2u + select(1u, 0u, normal_component >= 0.0);
135            let rg = unpack2x16float(probe_word(anchor, offset + normal_lobe * 2u));
136            let b = unpack2x16float(probe_word(anchor, offset + normal_lobe * 2u + 1u)).x;
137            irradiance += vec3f(rg, b) * normal_component * normal_component;
138            let toward_component = toward[axis];
139            if (toward_component != 0.0) {
140                let toward_lobe = axis * 2u + select(1u, 0u, toward_component >= 0.0);
141                let toward_rg = unpack2x16float(probe_word(anchor, offset + toward_lobe * 2u));
142                let toward_b = unpack2x16float(probe_word(anchor, offset + toward_lobe * 2u + 1u)).x;
143                toward_irradiance += dot(vec3f(toward_rg, toward_b), vec3f(0.2126, 0.7152, 0.0722)) * toward_component * toward_component;
144            }
145            let ray_component = direction[axis];
146            let pair = unpack2x16float(probe_word(anchor, offset + 12u + axis));
147            let depth = select(pair.y, pair.x, ray_component >= 0.0);
148            let ray_weight = ray_component * ray_component;
149            let reach = depth + origin.w * 0.25;
150            visibility += ray_weight * clamp((reach - distance) / origin.w, 0.0, 1.0);
151        }
152        weight *= select(1.0, visibility, distance > 0.000001);
153        color += irradiance * weight;
154        toward_luma += toward_irradiance * weight;
155        total += weight;
156    }
157    if (total <= 0.000001) { return vec4f(0.0); }
158    *seen = toward_luma / total;
159    return vec4f(color / total, 1.0);
160}
161
162fn probe_local_fade(point: vec3f, local: ProbeLocal) -> f32 {
163    let far = local.min_spacing.xyz + vec3f(local.dims.xyz - vec3u(1u)) * local.min_spacing.w;
164    let inside = min(point - local.min_spacing.xyz, far - point);
165    return clamp(min(inside.x, min(inside.y, inside.z)) / local.min_spacing.w, 0.0, 1.0);
166}
167
168fn sample_probe_anchor(position: vec3f, normal: vec3f, anchor: u32) -> vec3f {
169    var unused = 0.0;
170    return sample_probe_anchor_toward(position, normal, vec3f(0.0), anchor, &unused);
171}
172
173fn sample_probe_anchor_toward(position: vec3f, normal: vec3f, toward: vec3f, anchor: u32, seen: ptr<function, f32>) -> vec3f {
174    let normal_length = length(normal);
175    if (normal_length < 0.000001 || normal_length != normal_length || normal_length > 1e20 || any(position != position) || any(abs(position) > vec3f(1e20))) { return probe_grid.fallback.xyz; }
176    let n = normal / normal_length;
177    let point = position + n * 0.006;
178    var local = vec4f(0.0);
179    var local_seen = 0.0;
180    var fade = 0.0;
181    let count = min(u32(probe_grid.blend.w), 4u);
182    for (var k = 0u; k < count; k++) {
183        let inner = probe_local_fade(point, probe_grid.locals[k]);
184        if (inner <= 0.0) { continue; }
185        let sampled = sample_probe_volume(position, n, point, anchor, probe_grid.locals[k].min_spacing, vec4u(probe_grid.locals[k].dims.xyz, 2u), probe_grid.locals[k].dims.w, toward, &local_seen);
186        if (sampled.w > 0.0) {
187            local = sampled;
188            fade = inner;
189            break;
190        }
191    }
192    if (fade >= 1.0) {
193        *seen = local_seen;
194        return local.xyz;
195    }
196    var main_seen = 0.0;
197    let main = sample_probe_volume(position, n, point, anchor, probe_grid.min_spacing, probe_grid.dims, 0u, toward, &main_seen);
198    if (main.w <= 0.0) {
199        if (fade > 0.0) {
200            *seen = local_seen;
201            return local.xyz;
202        }
203        return probe_grid.fallback.xyz;
204    }
205    if (fade <= 0.0) {
206        *seen = main_seen;
207        return main.xyz;
208    }
209    *seen = mix(main_seen, local_seen, fade);
210    return mix(main.xyz, local.xyz, fade);
211}
212
213fn probe_reflection_occlusion(grid: vec3f, reference: vec3f) -> f32 {
214    let threshold = probe_grid.shade.x;
215    if (threshold <= 0.0 || grid.x < 0.0) { return 1.0; }
216    let open = dot(reference, vec3f(0.2126, 0.7152, 0.0722)) * threshold;
217    if (!(open > 0.000001)) { return 1.0; }
218    return clamp(dot(grid, vec3f(0.2126, 0.7152, 0.0722)) / open, 0.0, 1.0);
219}
220
221fn sample_live_irradiance(position: vec3f, normal: vec3f) -> vec3f {
222    if (!use_probes || probe_grid.blend.y < 0.5) { return probe_grid.fallback.xyz; }
223    let lower = sample_probe_anchor(position, normal, 0u);
224    if (probe_grid.blend.x <= 0.0) { return lower; }
225    let upper = sample_probe_anchor(position, normal, 1u);
226    return mix(lower, upper, probe_grid.blend.x);
227}
228
229fn sample_live_irradiance_toward(position: vec3f, normal: vec3f, toward: vec3f, seen: ptr<function, f32>) -> vec3f {
230    if (!use_probes || probe_grid.blend.y < 0.5) { return probe_grid.fallback.xyz; }
231    var lower_seen = -1.0;
232    let lower = sample_probe_anchor_toward(position, normal, toward, 0u, &lower_seen);
233    if (probe_grid.blend.x <= 0.0) {
234        *seen = lower_seen;
235        return lower;
236    }
237    var upper_seen = -1.0;
238    let upper = sample_probe_anchor_toward(position, normal, toward, 1u, &upper_seen);
239    if (lower_seen >= 0.0 && upper_seen >= 0.0) {
240        *seen = mix(lower_seen, upper_seen, probe_grid.blend.x);
241    }
242    return mix(lower, upper, probe_grid.blend.x);
243}
244"#;
245
246#[repr(C)]
247#[derive(Clone, Copy, Pod, Zeroable)]
248struct LocalUniform {
249    min_spacing: [f32; 4],
250    dims: [u32; 4],
251}
252
253#[repr(C)]
254#[derive(Clone, Copy, Pod, Zeroable)]
255struct GridUniform {
256    min_spacing: [f32; 4],
257    dims: [u32; 4],
258    fallback: [f32; 4],
259    blend: [f32; 4],
260    shade: [f32; 4],
261    locals: [LocalUniform; PROBE_LOCALS],
262}
263
264pub fn verified_artifact(path: &Path) -> Result<Artifact, String> {
265    read_artifact(path)
266}
267
268const BAND_ZERO: f32 = 0.282095;
269
270#[derive(Clone, Copy, Debug, Default, PartialEq)]
271pub enum Ambient {
272    #[default]
273    Sky,
274    Constant([f32; 3]),
275    Sh([[f32; 3]; 9]),
276}
277
278impl Ambient {
279    pub fn valid(&self) -> bool {
280        match self {
281            Self::Sky => true,
282            Self::Constant(irradiance) => irradiance.iter().all(|v| v.is_finite() && *v >= 0.0),
283            Self::Sh(sh) => sh.iter().flatten().all(|v| v.is_finite()),
284        }
285    }
286
287    pub fn sh(&self, sky: [[f32; 3]; 9]) -> [[f32; 3]; 9] {
288        match self {
289            Self::Sky => sky,
290            Self::Constant(irradiance) => {
291                let mut sh = [[0.0; 3]; 9];
292                sh[0] = irradiance.map(|v| v / BAND_ZERO);
293                sh
294            }
295            Self::Sh(sh) => *sh,
296        }
297    }
298}
299
300#[derive(Clone, Copy, Debug, PartialEq)]
301pub struct ContactDesc {
302    pub size: [u32; 2],
303    pub min: [f32; 2],
304    pub max: [f32; 2],
305    pub tint: [f32; 3],
306}
307
308impl ContactDesc {
309    fn valid(&self) -> bool {
310        self.size.iter().all(|&n| n > 0)
311            && self.min.iter().chain(&self.max).all(|v| v.is_finite())
312            && self.max[0] > self.min[0]
313            && self.max[1] > self.min[1]
314            && self.tint.iter().all(|v| v.is_finite() && *v >= 0.0)
315    }
316}
317
318#[repr(C)]
319#[derive(Clone, Copy, Pod, Zeroable)]
320pub(crate) struct ContactUniform {
321    rect: [f32; 4],
322    tint: [f32; 4],
323}
324
325impl ContactUniform {
326    pub(crate) fn off() -> Self {
327        Self::zeroed()
328    }
329
330    pub(crate) fn on(desc: &ContactDesc) -> Self {
331        Self {
332            rect: [
333                desc.min[0],
334                desc.min[1],
335                1.0 / (desc.max[0] - desc.min[0]),
336                1.0 / (desc.max[1] - desc.min[1]),
337            ],
338            tint: [desc.tint[0], desc.tint[1], desc.tint[2], 1.0],
339        }
340    }
341}
342
343pub struct ContactField {
344    desc: ContactDesc,
345    texture: wgpu::Texture,
346    view: wgpu::TextureView,
347}
348
349impl ContactField {
350    pub fn new(
351        device: &wgpu::Device,
352        queue: &wgpu::Queue,
353        desc: ContactDesc,
354    ) -> Result<Self, String> {
355        if !desc.valid() {
356            return Err("a contact field needs a nonzero size, a finite box with min below max and a finite nonnegative tint".into());
357        }
358        let texture = device.create_texture(&wgpu::TextureDescriptor {
359            label: Some("live contact field"),
360            size: wgpu::Extent3d {
361                width: desc.size[0],
362                height: desc.size[1],
363                depth_or_array_layers: 1,
364            },
365            mip_level_count: 1,
366            sample_count: 1,
367            dimension: wgpu::TextureDimension::D2,
368            format: wgpu::TextureFormat::R16Float,
369            usage: wgpu::TextureUsages::TEXTURE_BINDING
370                | wgpu::TextureUsages::COPY_DST
371                | wgpu::TextureUsages::RENDER_ATTACHMENT,
372            view_formats: &[],
373        });
374        let field = Self {
375            view: texture.create_view(&Default::default()),
376            texture,
377            desc,
378        };
379        field.write(queue, &vec![1.0; (desc.size[0] * desc.size[1]) as usize])?;
380        Ok(field)
381    }
382
383    pub fn desc(&self) -> &ContactDesc {
384        &self.desc
385    }
386
387    pub fn texture(&self) -> &wgpu::Texture {
388        &self.texture
389    }
390
391    pub fn view(&self) -> &wgpu::TextureView {
392        &self.view
393    }
394
395    pub fn write(&self, queue: &wgpu::Queue, values: &[f32]) -> Result<(), String> {
396        let [width, height] = self.desc.size;
397        if values.len() != (width * height) as usize {
398            return Err(format!(
399                "a {width}x{height} contact field takes {} values, not {}",
400                width * height,
401                values.len()
402            ));
403        }
404        let bytes: Vec<u8> = values
405            .iter()
406            .flat_map(|v| half::f16::from_f32(v.clamp(0.0, 1.0)).to_le_bytes())
407            .collect();
408        queue.write_texture(
409            self.texture.as_image_copy(),
410            &bytes,
411            wgpu::TexelCopyBufferLayout {
412                offset: 0,
413                bytes_per_row: Some(width * 2),
414                rows_per_image: Some(height),
415            },
416            wgpu::Extent3d {
417                width,
418                height,
419                depth_or_array_layers: 1,
420            },
421        );
422        Ok(())
423    }
424}
425
426pub struct ProbeLighting {
427    pub manifest: Option<Manifest>,
428    layout: wgpu::BindGroupLayout,
429    group: wgpu::BindGroup,
430    uniform: wgpu::Buffer,
431    buffers: Vec<wgpu::Buffer>,
432    selected: Option<AnchorBlend>,
433    data: GridUniform,
434    floor: Cell<f32>,
435    fading: Option<Fading>,
436    emission: f32,
437}
438
439struct Fading {
440    grids: Vec<Grid>,
441    emitters: Grid,
442    scales: Vec<f32>,
443}
444
445impl ProbeLighting {
446    pub fn fallback(device: &wgpu::Device, queue: &wgpu::Queue, fallback: [f32; 3]) -> Self {
447        let layout = Self::make_layout(device);
448        let uniform = device.create_buffer(&wgpu::BufferDescriptor {
449            label: Some("live probe uniform"),
450            size: std::mem::size_of::<GridUniform>() as u64,
451            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
452            mapped_at_creation: false,
453        });
454        let dummy = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
455            label: Some("live probe fallback"),
456            contents: &[0; 60],
457            usage: wgpu::BufferUsages::STORAGE,
458        });
459        let data = GridUniform {
460            min_spacing: [0.0, 0.0, 0.0, 1.0],
461            dims: [1, 1, 1, 0],
462            fallback: [fallback[0], fallback[1], fallback[2], 0.0],
463            blend: [0.0; 4],
464            shade: [0.0, 1.0, 0.0, 0.0],
465            locals: [LocalUniform::zeroed(); PROBE_LOCALS],
466        };
467        queue.write_buffer(&uniform, 0, bytemuck::bytes_of(&data));
468        let group = Self::make_group(device, &layout, &dummy, &dummy, &uniform);
469        Self {
470            manifest: None,
471            layout,
472            group,
473            uniform,
474            buffers: vec![dummy],
475            selected: None,
476            data,
477            floor: Cell::new(PROBE_CORNER_FLOOR),
478            fading: None,
479            emission: 0.0,
480        }
481    }
482
483    pub fn load(
484        device: &wgpu::Device,
485        queue: &wgpu::Queue,
486        path: &Path,
487        hour: f32,
488        fallback: [f32; 3],
489    ) -> Result<Self, String> {
490        Self::from_artifact(device, queue, &verified_artifact(path)?, hour, fallback)
491    }
492
493    pub fn from_artifact(
494        device: &wgpu::Device,
495        queue: &wgpu::Queue,
496        artifact: &Artifact,
497        hour: f32,
498        fallback: [f32; 3],
499    ) -> Result<Self, String> {
500        Self::from_artifacts(device, queue, artifact, &[], hour, fallback)
501    }
502
503    pub fn from_artifacts(
504        device: &wgpu::Device,
505        queue: &wgpu::Queue,
506        artifact: &Artifact,
507        locals: &[Artifact],
508        hour: f32,
509        fallback: [f32; 3],
510    ) -> Result<Self, String> {
511        check_locals(artifact, locals)?;
512        let mut lighting = Self::fallback(device, queue, fallback);
513        lighting.data.min_spacing = [
514            artifact.manifest.grid.min[0],
515            artifact.manifest.grid.min[1],
516            artifact.manifest.grid.min[2],
517            artifact.manifest.grid.spacing,
518        ];
519        lighting.data.dims = [
520            artifact.manifest.dimensions[0],
521            artifact.manifest.dimensions[1],
522            artifact.manifest.dimensions[2],
523            artifact.manifest.schema_version,
524        ];
525        let mut words = (artifact.grids[0].bytes().len() - 64) / 4;
526        for (slot, local) in lighting.data.locals.iter_mut().zip(locals) {
527            let grid = &local.manifest;
528            *slot = LocalUniform {
529                min_spacing: [
530                    grid.grid.min[0],
531                    grid.grid.min[1],
532                    grid.grid.min[2],
533                    grid.grid.spacing,
534                ],
535                dims: [
536                    grid.dimensions[0],
537                    grid.dimensions[1],
538                    grid.dimensions[2],
539                    words as u32,
540                ],
541            };
542            words += (local.grids[0].bytes().len() - 64) / 4;
543        }
544        lighting.data.blend[3] = locals.len() as f32;
545        lighting.buffers = artifact
546            .grids
547            .iter()
548            .enumerate()
549            .map(|(anchor, grid)| {
550                let mut bytes = grid.bytes().split_off(64);
551                for local in locals {
552                    bytes.extend_from_slice(&local.grids[anchor].bytes()[64..]);
553                }
554                device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
555                    label: Some("live probe anchor"),
556                    contents: &bytes,
557                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
558                })
559            })
560            .collect();
561        if let (Some(emitters), Some(entry)) = (&artifact.emitters, &artifact.manifest.emitters) {
562            lighting.fading = Some(Fading {
563                grids: artifact.grids.clone(),
564                emitters: emitters.clone(),
565                scales: entry.scales.clone(),
566            });
567            lighting.set_emission(queue, 0.0)?;
568        }
569        lighting.manifest = Some(artifact.manifest.clone());
570        lighting.set_hour(device, queue, hour)?;
571        Ok(lighting)
572    }
573
574    pub fn set_hour(
575        &mut self,
576        device: &wgpu::Device,
577        queue: &wgpu::Queue,
578        hour: f32,
579    ) -> Result<(), String> {
580        let Some(manifest) = &self.manifest else {
581            return Err("no probe artifact is loaded".into());
582        };
583        let selection = blend_anchors(&manifest.anchors, hour)?;
584        if self
585            .selected
586            .is_none_or(|old| old.lower != selection.lower || old.upper != selection.upper)
587        {
588            self.group = Self::make_group(
589                device,
590                &self.layout,
591                &self.buffers[selection.lower],
592                &self.buffers[selection.upper],
593                &self.uniform,
594            );
595        }
596        self.data.blend = [
597            selection.upper_weight,
598            1.0,
599            self.floor.get(),
600            self.data.blend[3],
601        ];
602        queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.data));
603        self.selected = Some(selection);
604        Ok(())
605    }
606
607    pub fn set_emission(&mut self, queue: &wgpu::Queue, scale: f32) -> Result<(), String> {
608        if !scale.is_finite() || scale < 0.0 {
609            return Err("emission scale must be finite and nonnegative".into());
610        }
611        if let Some(fading) = &self.fading {
612            for ((grid, baked), buffer) in
613                fading.grids.iter().zip(&fading.scales).zip(&self.buffers)
614            {
615                let faded = with_emitters(grid, &fading.emitters, scale - baked)?;
616                queue.write_buffer(buffer, 0, &faded.bytes()[64..]);
617            }
618        }
619        self.emission = scale;
620        Ok(())
621    }
622
623    pub fn emission(&self) -> f32 {
624        self.emission
625    }
626
627    pub fn has_emitters(&self) -> bool {
628        self.fading.is_some()
629    }
630
631    pub fn set_fallback(&mut self, queue: &wgpu::Queue, fallback: [f32; 3]) {
632        self.data.fallback = [fallback[0], fallback[1], fallback[2], 0.0];
633        queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.data));
634    }
635
636    pub fn set_corner_floor(&self, queue: &wgpu::Queue, floor: f32) -> Result<(), String> {
637        if !floor.is_finite() || !(0.0..=0.25).contains(&floor) {
638            return Err("the probe corner floor must be between 0 and 0.25".into());
639        }
640        self.floor.set(floor);
641        let blend = [
642            self.data.blend[0],
643            self.data.blend[1],
644            floor,
645            self.data.blend[3],
646        ];
647        queue.write_buffer(&self.uniform, 48, bytemuck::bytes_of(&blend));
648        Ok(())
649    }
650
651    pub fn set_reflection_occlusion(
652        &mut self,
653        queue: &wgpu::Queue,
654        threshold: f32,
655    ) -> Result<(), String> {
656        if !threshold.is_finite() || !(0.0..=1.0).contains(&threshold) {
657            return Err("the reflection occlusion threshold must be between 0 and 1".into());
658        }
659        self.data.shade[0] = threshold;
660        queue.write_buffer(&self.uniform, 64, bytemuck::bytes_of(&self.data.shade));
661        Ok(())
662    }
663
664    pub fn reflection_occlusion(&self) -> f32 {
665        self.data.shade[0]
666    }
667
668    pub fn set_sky_visibility(&mut self, queue: &wgpu::Queue, on: bool) {
669        self.data.shade[1] = if on { 1.0 } else { 0.0 };
670        queue.write_buffer(&self.uniform, 64, bytemuck::bytes_of(&self.data.shade));
671    }
672
673    pub fn sky_visibility(&self) -> bool {
674        self.data.shade[1] > 0.0
675    }
676
677    pub fn corner_floor(&self) -> f32 {
678        self.floor.get()
679    }
680
681    pub fn local_volumes(&self) -> usize {
682        self.data.blend[3] as usize
683    }
684
685    pub fn live(&self) -> bool {
686        let off = self.data.blend[1] < 0.5;
687        !off
688    }
689
690    pub fn selection(&self) -> Option<AnchorBlend> {
691        self.selected
692    }
693
694    pub fn layout(&self) -> &wgpu::BindGroupLayout {
695        &self.layout
696    }
697
698    pub fn group(&self) -> &wgpu::BindGroup {
699        &self.group
700    }
701
702    pub fn bindings(&self) -> [&wgpu::Buffer; 3] {
703        match self.selected {
704            Some(selection) => [
705                &self.buffers[selection.lower],
706                &self.buffers[selection.upper],
707                &self.uniform,
708            ],
709            None => [&self.buffers[0], &self.buffers[0], &self.uniform],
710        }
711    }
712
713    fn make_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
714        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
715            label: Some("live probe layout"),
716            entries: &[
717                wgpu::BindGroupLayoutEntry {
718                    binding: 6,
719                    visibility: wgpu::ShaderStages::FRAGMENT,
720                    ty: wgpu::BindingType::Buffer {
721                        ty: wgpu::BufferBindingType::Storage { read_only: true },
722                        has_dynamic_offset: false,
723                        min_binding_size: None,
724                    },
725                    count: None,
726                },
727                wgpu::BindGroupLayoutEntry {
728                    binding: 7,
729                    visibility: wgpu::ShaderStages::FRAGMENT,
730                    ty: wgpu::BindingType::Buffer {
731                        ty: wgpu::BufferBindingType::Storage { read_only: true },
732                        has_dynamic_offset: false,
733                        min_binding_size: None,
734                    },
735                    count: None,
736                },
737                wgpu::BindGroupLayoutEntry {
738                    binding: 8,
739                    visibility: wgpu::ShaderStages::FRAGMENT,
740                    ty: wgpu::BindingType::Buffer {
741                        ty: wgpu::BufferBindingType::Uniform,
742                        has_dynamic_offset: false,
743                        min_binding_size: None,
744                    },
745                    count: None,
746                },
747            ],
748        })
749    }
750
751    fn make_group(
752        device: &wgpu::Device,
753        layout: &wgpu::BindGroupLayout,
754        lower: &wgpu::Buffer,
755        upper: &wgpu::Buffer,
756        uniform: &wgpu::Buffer,
757    ) -> wgpu::BindGroup {
758        device.create_bind_group(&wgpu::BindGroupDescriptor {
759            label: Some("live probe binding"),
760            layout,
761            entries: &[
762                wgpu::BindGroupEntry {
763                    binding: 6,
764                    resource: lower.as_entire_binding(),
765                },
766                wgpu::BindGroupEntry {
767                    binding: 7,
768                    resource: upper.as_entire_binding(),
769                },
770                wgpu::BindGroupEntry {
771                    binding: 8,
772                    resource: uniform.as_entire_binding(),
773                },
774            ],
775        })
776    }
777}
778
779fn check_locals(artifact: &Artifact, locals: &[Artifact]) -> Result<(), String> {
780    if artifact.grids.is_empty() {
781        return Err("a probe artifact needs at least one anchor".into());
782    }
783    if locals.is_empty() {
784        return Ok(());
785    }
786    if locals.len() > PROBE_LOCALS {
787        return Err(format!(
788            "at most {PROBE_LOCALS} local probe volumes, not {}",
789            locals.len()
790        ));
791    }
792    if artifact.emitters.is_some() || locals.iter().any(|local| local.emitters.is_some()) {
793        return Err("local probe volumes take no emitter layer".into());
794    }
795    for local in locals {
796        if local.manifest.schema_version != 2 {
797            return Err("a local probe volume needs schema 2, with depth moments".into());
798        }
799        if local.manifest.anchors != artifact.manifest.anchors
800            || local.grids.len() != artifact.grids.len()
801        {
802            return Err("a local probe volume needs the main volume's anchors".into());
803        }
804        if local.manifest.dimensions.iter().any(|&count| count < 3) {
805            return Err("a local probe volume needs at least three probes on every axis".into());
806        }
807    }
808    Ok(())
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use half::f16;
815    use pfx_bake::{Anchor, BakeScene, FileEntry, Grid, GridSpec, Probe, bake, write_artifact};
816    use pfx_gpu::Gpu;
817    use pfx_load::Sky;
818    use pfx_materials::Material;
819    use pfx_trace::bvh::Triangle;
820    use pfx_trace::{Camera, Scene, Sun, Trace};
821    use sha2::{Digest, Sha256};
822    use std::fs;
823
824    fn test_root(name: &str) -> std::path::PathBuf {
825        Path::new(env!("CARGO_MANIFEST_DIR"))
826            .join("../../tmp")
827            .join(name)
828    }
829
830    fn digest(bytes: &[u8]) -> String {
831        Sha256::digest(bytes)
832            .iter()
833            .map(|byte| format!("{byte:02x}"))
834            .collect()
835    }
836
837    #[test]
838    fn loader_rejects_bad_hash_and_version() {
839        let path = test_root("live-probe-loader-test");
840        if path.exists() {
841            fs::remove_dir_all(&path).unwrap();
842        }
843        fs::create_dir_all(&path).unwrap();
844        let spec = GridSpec {
845            min: [0.0; 3],
846            max: [0.0; 3],
847            spacing: 1.0,
848        };
849        let grid = Grid::new(spec, vec![Probe::default()]).unwrap();
850        let bytes = grid.bytes();
851        fs::write(path.join("probe-000.bin"), &bytes).unwrap();
852        let mut manifest = Manifest {
853            schema_version: 1,
854            scene_hash: "test".into(),
855            anchors: vec![12.0],
856            sample_count: 1,
857            seed: 0,
858            grid: spec,
859            dimensions: [1; 3],
860            format: "ambient_cube_rgb16f_visibility_f16_le_v1".into(),
861            color_space: "linear_rec709_irradiance".into(),
862            coordinates: "right_handed_y_up_xyz_x_fastest".into(),
863            files: vec![FileEntry {
864                path: "probe-000.bin".into(),
865                bytes: bytes.len() as u64,
866                sha256: digest(&bytes),
867            }],
868            emitters: None,
869        };
870        let save = |manifest: &Manifest| {
871            let json = serde_json::to_vec(manifest).unwrap();
872            fs::write(path.join("manifest.json"), &json).unwrap();
873            fs::write(path.join("manifest.sha256"), digest(&json)).unwrap();
874        };
875        save(&manifest);
876        assert!(verified_artifact(&path).is_ok());
877        let mut corrupt = bytes.clone();
878        corrupt[64] ^= 1;
879        fs::write(path.join("probe-000.bin"), &corrupt).unwrap();
880        assert!(verified_artifact(&path).err().unwrap().contains("checksum"));
881        fs::write(path.join("probe-000.bin"), &bytes).unwrap();
882        manifest.schema_version = 2;
883        save(&manifest);
884        assert!(verified_artifact(&path).err().unwrap().contains("manifest"));
885        fs::remove_dir_all(path).unwrap();
886    }
887
888    fn artifact(spacing: f32, count: u32, anchors: &[f32], version_two: bool) -> Artifact {
889        let spec = GridSpec {
890            min: [0.0; 3],
891            max: [spacing * (count - 1) as f32; 3],
892            spacing,
893        };
894        let probes = vec![Probe::default(); (count * count * count) as usize];
895        let grid = if version_two {
896            let extra = pfx_bake::ProbeExtra {
897                second: [0.0; 6],
898                offset: [0.0; 3],
899                enabled: true,
900                backfaces: 0,
901            };
902            Grid::with_extras(spec, probes.clone(), vec![extra; probes.len()]).unwrap()
903        } else {
904            Grid::new(spec, probes).unwrap()
905        };
906        Artifact {
907            manifest: Manifest {
908                schema_version: grid.version(),
909                scene_hash: "test".into(),
910                anchors: anchors.to_vec(),
911                sample_count: 1,
912                seed: 0,
913                grid: spec,
914                dimensions: [count; 3],
915                format: String::new(),
916                color_space: String::new(),
917                coordinates: String::new(),
918                files: Vec::new(),
919                emitters: None,
920            },
921            grids: vec![grid; anchors.len()],
922            emitters: None,
923            rounds: None,
924        }
925    }
926
927    #[test]
928    fn local_volumes_need_the_main_anchors_and_depth_moments() {
929        let main = artifact(0.05, 4, &[12.0, 16.0], true);
930        let fine = || artifact(0.01, 3, &[12.0, 16.0], true);
931        let local = fine();
932        assert!(check_locals(&main, &[]).is_ok());
933        assert!(check_locals(&main, std::slice::from_ref(&local)).is_ok());
934        assert!(
935            check_locals(
936                &main,
937                &(0..PROBE_LOCALS).map(|_| fine()).collect::<Vec<_>>()
938            )
939            .is_ok()
940        );
941        assert!(
942            check_locals(
943                &main,
944                &(0..=PROBE_LOCALS).map(|_| fine()).collect::<Vec<_>>()
945            )
946            .unwrap_err()
947            .contains("at most")
948        );
949        let other_hours = artifact(0.01, 3, &[12.0, 19.0], true);
950        assert!(
951            check_locals(&main, &[other_hours])
952                .unwrap_err()
953                .contains("anchors")
954        );
955        let flat = artifact(0.01, 3, &[12.0, 16.0], false);
956        assert!(
957            check_locals(&main, &[flat])
958                .unwrap_err()
959                .contains("schema 2")
960        );
961        let thin = artifact(0.01, 2, &[12.0, 16.0], true);
962        assert!(
963            check_locals(&main, &[thin])
964                .unwrap_err()
965                .contains("three probes")
966        );
967    }
968
969    #[test]
970    fn anchor_selection_tracks_live_hour() {
971        let hours = [8.0, 12.0, 16.0];
972        assert_eq!(blend_anchors(&hours, 10.0).unwrap().upper_weight, 0.5);
973        assert_eq!(blend_anchors(&hours, 14.0).unwrap().lower, 1);
974        assert_eq!(blend_anchors(&hours, 17.0).unwrap().upper, 2);
975        assert!(blend_anchors(&hours, f32::NAN).is_err());
976    }
977
978    #[test]
979    fn probe_shader_validates() {
980        let source = format!(
981            "{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); }}"
982        );
983        let module = naga::front::wgsl::parse_str(&source).unwrap();
984        naga::valid::Validator::new(
985            naga::valid::ValidationFlags::all(),
986            naga::valid::Capabilities::all(),
987        )
988        .validate(&module)
989        .unwrap();
990    }
991
992    fn quad(
993        triangles: &mut Vec<Triangle>,
994        a: [f32; 3],
995        b: [f32; 3],
996        c: [f32; 3],
997        d: [f32; 3],
998        material: u32,
999    ) {
1000        triangles.push(Triangle {
1001            vertices: [a, b, c],
1002            material,
1003        });
1004        triangles.push(Triangle {
1005            vertices: [a, c, d],
1006            material,
1007        });
1008    }
1009
1010    #[test]
1011    #[ignore = "needs a GPU; run under pgpu"]
1012    fn sunlit_floor_agrees_with_path_trace() {
1013        let gpu = pollster::block_on(Gpu::headless()).unwrap();
1014        let mut triangles = Vec::new();
1015        quad(
1016            &mut triangles,
1017            [-1.0, 0.0, -1.0],
1018            [1.0, 0.0, -1.0],
1019            [1.0, 0.0, 1.0],
1020            [-1.0, 0.0, 1.0],
1021            0,
1022        );
1023        quad(
1024            &mut triangles,
1025            [-1.0, 0.0, -1.0],
1026            [-1.0, 1.5, -1.0],
1027            [1.0, 1.5, -1.0],
1028            [1.0, 0.0, -1.0],
1029            1,
1030        );
1031        quad(
1032            &mut triangles,
1033            [1.0, 0.0, -1.0],
1034            [1.0, 1.5, -1.0],
1035            [1.0, 1.5, 1.0],
1036            [1.0, 0.0, 1.0],
1037            1,
1038        );
1039        quad(
1040            &mut triangles,
1041            [1.0, 0.0, 1.0],
1042            [1.0, 1.5, 1.0],
1043            [-1.0, 1.5, 1.0],
1044            [-1.0, 0.0, 1.0],
1045            1,
1046        );
1047        quad(
1048            &mut triangles,
1049            [-1.0, 0.0, 1.0],
1050            [-1.0, 1.5, 1.0],
1051            [-1.0, 1.5, -1.0],
1052            [-1.0, 0.0, -1.0],
1053            1,
1054        );
1055        let material = Material {
1056            base: [0.8; 3],
1057            specular: 0.0,
1058            roughness: 1.0,
1059            ..Material::default()
1060        };
1061        let wall = Material {
1062            base: [0.2; 3],
1063            specular: 0.0,
1064            roughness: 1.0,
1065            ..Material::default()
1066        };
1067        let anchor = Anchor {
1068            hour: 12.0,
1069            sky: Sky {
1070                width: 1,
1071                height: 1,
1072                texels: vec![[0.5, 0.5, 0.5, 1.0]],
1073            },
1074            sun: Sun {
1075                direction: [0.0, 1.0, 0.0],
1076                color: [1.0; 3],
1077                intensity: 0.1,
1078            },
1079        };
1080        let scene = BakeScene {
1081            triangles: triangles.clone(),
1082            shapes: Vec::new(),
1083            materials: vec![material, wall],
1084            anchors: vec![anchor.clone()],
1085        };
1086        let spec = GridSpec {
1087            min: [0.0, 0.01, 0.0],
1088            max: [0.0, 0.01, 0.0],
1089            spacing: 1.0,
1090        };
1091        let grids = bake(&gpu, &scene, spec, 16, 73).unwrap();
1092        let root = test_root("live-probe-gpu-test");
1093        let path = root.join("artifact");
1094        if root.exists() {
1095            fs::remove_dir_all(&root).unwrap();
1096        }
1097        fs::create_dir_all(&root).unwrap();
1098        write_artifact(
1099            root.parent().unwrap(),
1100            Path::new("live-probe-gpu-test/artifact"),
1101            &scene,
1102            &grids,
1103            16,
1104            73,
1105        )
1106        .unwrap();
1107        let lighting = ProbeLighting::load(&gpu.device, &gpu.queue, &path, 12.0, [0.0; 3]).unwrap();
1108        let empty = gpu
1109            .device
1110            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1111                label: None,
1112                entries: &[],
1113            });
1114        let empty_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1115            label: None,
1116            layout: &empty,
1117            entries: &[],
1118        });
1119        let layout = gpu
1120            .device
1121            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1122                label: None,
1123                bind_group_layouts: &[
1124                    Some(&empty),
1125                    Some(&empty),
1126                    Some(&empty),
1127                    Some(lighting.layout()),
1128                ],
1129                immediate_size: 0,
1130            });
1131        let source = format!(
1132            "{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 {{ let indirect = sample_live_irradiance(vec3f(0.0, 0.01, 0.0), vec3f(0.0, 1.0, 0.0)) * 0.8 / 3.14159265; return vec4f(indirect + vec3f(0.1 * 0.8 / 3.14159265), 1.0); }}"
1133        );
1134        let shader = gpu
1135            .device
1136            .create_shader_module(wgpu::ShaderModuleDescriptor {
1137                label: None,
1138                source: wgpu::ShaderSource::Wgsl(source.into()),
1139            });
1140        let pipeline = gpu
1141            .device
1142            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1143                label: None,
1144                layout: Some(&layout),
1145                vertex: wgpu::VertexState {
1146                    module: &shader,
1147                    entry_point: Some("vs"),
1148                    compilation_options: Default::default(),
1149                    buffers: &[],
1150                },
1151                primitive: wgpu::PrimitiveState::default(),
1152                depth_stencil: None,
1153                multisample: wgpu::MultisampleState::default(),
1154                fragment: Some(wgpu::FragmentState {
1155                    module: &shader,
1156                    entry_point: Some("fs"),
1157                    compilation_options: Default::default(),
1158                    targets: &[Some(wgpu::ColorTargetState {
1159                        format: wgpu::TextureFormat::Rgba16Float,
1160                        blend: None,
1161                        write_mask: wgpu::ColorWrites::ALL,
1162                    })],
1163                }),
1164                multiview_mask: None,
1165                cache: None,
1166            });
1167        let target = gpu
1168            .offscreen(1, 1, wgpu::TextureFormat::Rgba16Float)
1169            .unwrap();
1170        let mut encoder = gpu.device.create_command_encoder(&Default::default());
1171        {
1172            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1173                multiview_mask: None,
1174                label: None,
1175                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1176                    view: &target.view,
1177                    resolve_target: None,
1178                    ops: wgpu::Operations {
1179                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1180                        store: wgpu::StoreOp::Store,
1181                    },
1182                    depth_slice: None,
1183                })],
1184                depth_stencil_attachment: None,
1185                timestamp_writes: None,
1186                occlusion_query_set: None,
1187            });
1188            pass.set_pipeline(&pipeline);
1189            pass.set_bind_group(0, &empty_group, &[]);
1190            pass.set_bind_group(1, &empty_group, &[]);
1191            pass.set_bind_group(2, &empty_group, &[]);
1192            pass.set_bind_group(3, lighting.group(), &[]);
1193            pass.draw(0..3, 0..1);
1194        }
1195        gpu.queue.submit(Some(encoder.finish()));
1196        let live = f16::from_bits(gpu.readback_rgba16(&target).unwrap()[0]).to_f32();
1197        let reference_scene = Scene {
1198            triangles,
1199            shapes: Vec::new(),
1200            materials: vec![material, scene.materials[1]],
1201            sky: anchor.sky,
1202            camera: Camera {
1203                origin: [0.0, 1.0, 0.0],
1204                forward: [0.0, -1.0, 0.0],
1205                right: [1.0, 0.0, 0.0],
1206                up: [0.0, 0.0, 1.0],
1207            },
1208            sun: anchor.sun,
1209        };
1210        let mut trace = Trace::new(&gpu, &reference_scene, 1, 1).unwrap();
1211        trace.sample(&gpu, 4096, 74).unwrap();
1212        let bytes = trace.readback(&gpu).unwrap().color;
1213        let reference = f32::from_le_bytes(bytes[..4].try_into().unwrap());
1214        let error = (live - reference).abs() / reference.max(0.01);
1215        println!("sunlit floor live={live:.4} path={reference:.4} relative_error={error:.3}");
1216        assert!(
1217            error < 0.3,
1218            "live={live} reference={reference} error={error}"
1219        );
1220        fs::remove_dir_all(root).unwrap();
1221    }
1222
1223    fn irradiance_pixel(gpu: &Gpu, lighting: &ProbeLighting) -> Vec<u16> {
1224        irradiance_at(gpu, lighting, [0.0, 0.01, 0.0], [0.0, 1.0, 0.0])
1225    }
1226
1227    fn irradiance_at(
1228        gpu: &Gpu,
1229        lighting: &ProbeLighting,
1230        position: [f32; 3],
1231        normal: [f32; 3],
1232    ) -> Vec<u16> {
1233        let empty = gpu
1234            .device
1235            .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1236                label: None,
1237                entries: &[],
1238            });
1239        let empty_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
1240            label: None,
1241            layout: &empty,
1242            entries: &[],
1243        });
1244        let layout = gpu
1245            .device
1246            .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1247                label: None,
1248                bind_group_layouts: &[
1249                    Some(&empty),
1250                    Some(&empty),
1251                    Some(&empty),
1252                    Some(lighting.layout()),
1253                ],
1254                immediate_size: 0,
1255            });
1256        let source = format!(
1257            "{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({:?}, {:?}, {:?}), vec3f({:?}, {:?}, {:?})), 1.0); }}",
1258            position[0], position[1], position[2], normal[0], normal[1], normal[2]
1259        );
1260        let shader = gpu
1261            .device
1262            .create_shader_module(wgpu::ShaderModuleDescriptor {
1263                label: None,
1264                source: wgpu::ShaderSource::Wgsl(source.into()),
1265            });
1266        let pipeline = gpu
1267            .device
1268            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1269                label: None,
1270                layout: Some(&layout),
1271                vertex: wgpu::VertexState {
1272                    module: &shader,
1273                    entry_point: Some("vs"),
1274                    compilation_options: Default::default(),
1275                    buffers: &[],
1276                },
1277                primitive: wgpu::PrimitiveState::default(),
1278                depth_stencil: None,
1279                multisample: wgpu::MultisampleState::default(),
1280                fragment: Some(wgpu::FragmentState {
1281                    module: &shader,
1282                    entry_point: Some("fs"),
1283                    compilation_options: Default::default(),
1284                    targets: &[Some(wgpu::ColorTargetState {
1285                        format: wgpu::TextureFormat::Rgba16Float,
1286                        blend: None,
1287                        write_mask: wgpu::ColorWrites::ALL,
1288                    })],
1289                }),
1290                multiview_mask: None,
1291                cache: None,
1292            });
1293        let target = gpu
1294            .offscreen(1, 1, wgpu::TextureFormat::Rgba16Float)
1295            .unwrap();
1296        let mut encoder = gpu.device.create_command_encoder(&Default::default());
1297        {
1298            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1299                multiview_mask: None,
1300                label: None,
1301                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1302                    view: &target.view,
1303                    resolve_target: None,
1304                    ops: wgpu::Operations {
1305                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1306                        store: wgpu::StoreOp::Store,
1307                    },
1308                    depth_slice: None,
1309                })],
1310                depth_stencil_attachment: None,
1311                timestamp_writes: None,
1312                occlusion_query_set: None,
1313            });
1314            pass.set_pipeline(&pipeline);
1315            pass.set_bind_group(0, &empty_group, &[]);
1316            pass.set_bind_group(1, &empty_group, &[]);
1317            pass.set_bind_group(2, &empty_group, &[]);
1318            pass.set_bind_group(3, lighting.group(), &[]);
1319            pass.draw(0..3, 0..1);
1320        }
1321        gpu.queue.submit(Some(encoder.finish()));
1322        gpu.readback_rgba16(&target).unwrap()
1323    }
1324
1325    #[test]
1326    #[ignore = "needs a GPU; run under pgpu"]
1327    fn from_artifact_lights_a_scene_as_load_does() {
1328        let gpu = pollster::block_on(Gpu::headless()).unwrap();
1329        let anchors: Vec<Anchor> = [12.0, 18.0]
1330            .into_iter()
1331            .map(|hour| Anchor {
1332                hour,
1333                sky: Sky {
1334                    width: 1,
1335                    height: 1,
1336                    texels: vec![[0.5, 0.5, 0.5, 1.0]],
1337                },
1338                sun: Sun {
1339                    direction: [0.0, 1.0, 0.0],
1340                    color: [1.0; 3],
1341                    intensity: 0.1,
1342                },
1343            })
1344            .collect();
1345        let scene = BakeScene {
1346            triangles: Vec::new(),
1347            shapes: Vec::new(),
1348            materials: vec![Material::default()],
1349            anchors,
1350        };
1351        let spec = GridSpec {
1352            min: [0.0, 0.01, 0.0],
1353            max: [0.0, 0.01, 0.0],
1354            spacing: 1.0,
1355        };
1356        let grid = |level: f32| {
1357            let mut probe = Probe::default();
1358            for (index, lobe) in probe.lobes.iter_mut().enumerate() {
1359                *lobe = [level * (index + 1) as f32, level, level * 0.5];
1360            }
1361            Grid::new(spec, vec![probe]).unwrap()
1362        };
1363        let grids = [grid(0.25), grid(0.75)];
1364        let emitters = pfx_bake::Emitters {
1365            grid: grid(0.125),
1366            scales: vec![0.0, 0.0],
1367            direct: false,
1368        };
1369        let root = test_root("live-probe-bytes-test");
1370        if root.exists() {
1371            fs::remove_dir_all(&root).unwrap();
1372        }
1373        fs::create_dir_all(&root).unwrap();
1374        pfx_bake::write_artifact_with_emitters(
1375            root.parent().unwrap(),
1376            Path::new("live-probe-bytes-test/artifact"),
1377            &scene,
1378            &grids,
1379            &emitters,
1380            4,
1381            3,
1382        )
1383        .unwrap();
1384        let path = root.join("artifact");
1385        let files: std::collections::HashMap<String, Vec<u8>> = fs::read_dir(&path)
1386            .unwrap()
1387            .map(|entry| {
1388                let entry = entry.unwrap();
1389                (
1390                    entry.file_name().to_string_lossy().into_owned(),
1391                    fs::read(entry.path()).unwrap(),
1392                )
1393            })
1394            .collect();
1395        let artifact = pfx_bake::read_artifact_from(&|name| files.get(name).cloned()).unwrap();
1396        let mut loaded =
1397            ProbeLighting::load(&gpu.device, &gpu.queue, &path, 15.0, [0.0; 3]).unwrap();
1398        let mut built =
1399            ProbeLighting::from_artifact(&gpu.device, &gpu.queue, &artifact, 15.0, [0.0; 3])
1400                .unwrap();
1401        let base = irradiance_pixel(&gpu, &loaded);
1402        assert!(f16::from_bits(base[0]).to_f32() > 0.0);
1403        assert_eq!(irradiance_pixel(&gpu, &built), base);
1404        for scale in [0.5, 1.0] {
1405            loaded.set_emission(&gpu.queue, scale).unwrap();
1406            built.set_emission(&gpu.queue, scale).unwrap();
1407            assert_eq!(
1408                irradiance_pixel(&gpu, &built),
1409                irradiance_pixel(&gpu, &loaded)
1410            );
1411        }
1412        assert_ne!(irradiance_pixel(&gpu, &built), base);
1413        fs::remove_dir_all(root).unwrap();
1414    }
1415
1416    fn box_with_a_slot() -> Vec<Triangle> {
1417        let mut triangles = Vec::new();
1418        quad(
1419            &mut triangles,
1420            [-1.0, 0.0, -1.0],
1421            [-1.0, 0.0, 1.0],
1422            [1.0, 0.0, 1.0],
1423            [1.0, 0.0, -1.0],
1424            0,
1425        );
1426        let (low, high, top) = (0.005, 0.045, 0.03);
1427        for (a, b) in [
1428            ([low, low], [high, low]),
1429            ([high, low], [high, high]),
1430            ([high, high], [low, high]),
1431            ([low, high], [low, low]),
1432        ] {
1433            quad(
1434                &mut triangles,
1435                [a[0], 0.0, a[1]],
1436                [b[0], 0.0, b[1]],
1437                [b[0], top, b[1]],
1438                [a[0], top, a[1]],
1439                0,
1440            );
1441        }
1442        quad(
1443            &mut triangles,
1444            [low, top, low],
1445            [low, top, high],
1446            [0.035, top, high],
1447            [0.035, top, low],
1448            0,
1449        );
1450        triangles
1451    }
1452
1453    fn baked_artifact(
1454        gpu: &Gpu,
1455        scene: &BakeScene,
1456        spec: GridSpec,
1457        root: &Path,
1458        name: &str,
1459    ) -> Artifact {
1460        let grids = bake(gpu, scene, spec, 32, 11).unwrap();
1461        write_artifact(
1462            root.parent().unwrap(),
1463            &Path::new(root.file_name().unwrap()).join(name),
1464            scene,
1465            &grids,
1466            32,
1467            11,
1468        )
1469        .unwrap();
1470        verified_artifact(&root.join(name)).unwrap()
1471    }
1472
1473    fn rgb(pixel: &[u16]) -> [f32; 3] {
1474        std::array::from_fn(|channel| f16::from_bits(pixel[channel]).to_f32())
1475    }
1476
1477    fn luma(rgb: [f32; 3]) -> f32 {
1478        0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
1479    }
1480
1481    #[test]
1482    #[ignore = "needs a GPU; run under pgpu"]
1483    fn a_local_volume_keeps_the_light_out_of_a_slotted_box() {
1484        let gpu = pollster::block_on(Gpu::headless()).unwrap();
1485        let white = Material {
1486            base: [0.8; 3],
1487            specular: 0.0,
1488            roughness: 1.0,
1489            ..Material::default()
1490        };
1491        let scene = BakeScene {
1492            triangles: box_with_a_slot(),
1493            shapes: Vec::new(),
1494            materials: vec![white],
1495            anchors: vec![Anchor {
1496                hour: 12.0,
1497                sky: Sky {
1498                    width: 1,
1499                    height: 1,
1500                    texels: vec![[1.0, 1.0, 1.0, 1.0]],
1501                },
1502                sun: Sun {
1503                    direction: [0.0, 1.0, 0.0],
1504                    color: [1.0; 3],
1505                    intensity: 0.0,
1506                },
1507            }],
1508        };
1509        let root = test_root("live-probe-slot-test");
1510        if root.exists() {
1511            fs::remove_dir_all(&root).unwrap();
1512        }
1513        fs::create_dir_all(&root).unwrap();
1514        let main = baked_artifact(
1515            &gpu,
1516            &scene,
1517            GridSpec {
1518                min: [-0.15, -0.025, -0.15],
1519                max: [0.15, 0.125, 0.15],
1520                spacing: 0.05,
1521            },
1522            &root,
1523            "main",
1524        );
1525        let local = baked_artifact(
1526            &gpu,
1527            &scene,
1528            GridSpec {
1529                min: [-0.01, -0.015, -0.01],
1530                max: [0.06, 0.045, 0.06],
1531                spacing: 0.01,
1532            },
1533            &root,
1534            "local",
1535        );
1536        let inside = [0.015, 0.0, 0.025];
1537        let up = [0.0, 1.0, 0.0];
1538        let traced = bake(
1539            &gpu,
1540            &scene,
1541            GridSpec {
1542                min: [inside[0], 0.002, inside[2]],
1543                max: [inside[0], 0.002, inside[2]],
1544                spacing: 0.01,
1545            },
1546            64,
1547            12,
1548        )
1549        .unwrap()[0]
1550            .probes[0]
1551            .lobes[2];
1552        let coarse =
1553            ProbeLighting::from_artifact(&gpu.device, &gpu.queue, &main, 12.0, [0.0; 3]).unwrap();
1554        let fine = ProbeLighting::from_artifacts(
1555            &gpu.device,
1556            &gpu.queue,
1557            &main,
1558            std::slice::from_ref(&local),
1559            12.0,
1560            [0.0; 3],
1561        )
1562        .unwrap();
1563        assert_eq!(fine.local_volumes(), 1);
1564        let leaked = luma(rgb(&irradiance_at(&gpu, &coarse, inside, up))) / luma(traced);
1565        let kept = luma(rgb(&irradiance_at(&gpu, &fine, inside, up))) / luma(traced);
1566        println!(
1567            "slotted box interior: traced {:.4}, main grid {leaked:.2}x, with the local volume {kept:.2}x",
1568            luma(traced)
1569        );
1570        assert!(leaked > 1.5, "the 5 cm grid alone reads {leaked}x");
1571        assert!(
1572            (1.0 / 1.5..1.5).contains(&kept),
1573            "the local volume reads {kept}x"
1574        );
1575        for (position, normal) in [
1576            ([-0.1, 0.0, -0.1], up),
1577            ([0.12, 0.05, 0.0], [-1.0, 0.0, 0.0]),
1578            ([0.0, 0.0, 0.1], up),
1579        ] {
1580            assert_eq!(
1581                irradiance_at(&gpu, &fine, position, normal),
1582                irradiance_at(&gpu, &coarse, position, normal),
1583                "{position:?} is outside the local volume"
1584            );
1585        }
1586        fs::remove_dir_all(root).unwrap();
1587    }
1588}