Skip to main content

pfx_bake/
reflection.rs

1use std::fs;
2use std::path::Path;
3
4use half::f16;
5use pfx_gpu::{Gpu, wgpu};
6use pfx_trace::bvh::{Bvh, Ray};
7use pfx_trace::shapes::intersect;
8use pfx_trace::{Camera, Scene, Trace};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::{Anchor, BakeScene, Paced, SceneSource, artifact::FileEntry};
13
14pub const REFLECTION_FORMAT: &str = "cube_array_rgba16f_ggx_mips_inverse_distance_face_major_le_v2";
15pub const REFLECTION_FORMAT_V1: &str = "cube_array_rgba16f_ggx_mips_face_major_le_v1";
16
17const FACES: [([f32; 3], [f32; 3], [f32; 3]); 6] = [
18    ([1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
19    ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
20    ([0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]),
21    ([0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
22    ([0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
23    ([0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
24];
25
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct ReflectionSpec {
29    pub name: Option<String>,
30    pub position: [f32; 3],
31    pub min: [f32; 3],
32    pub max: [f32; 3],
33    #[serde(default = "default_resolution")]
34    pub resolution: u32,
35    pub anchors: Option<Vec<f32>>,
36    #[serde(default = "default_fade")]
37    pub fade: f32,
38    #[serde(default)]
39    pub priority: f32,
40}
41
42fn default_resolution() -> u32 {
43    256
44}
45fn default_fade() -> f32 {
46    0.25
47}
48
49impl ReflectionSpec {
50    pub fn name(&self, index: usize) -> String {
51        self.name
52            .clone()
53            .unwrap_or_else(|| format!("reflection-{index:03}"))
54    }
55
56    pub fn validate(&self) -> Result<(), String> {
57        if self.resolution < 4 || self.resolution > 1024 || !self.resolution.is_power_of_two() {
58            return Err("reflection resolution must be a power of two from 4 to 1024".into());
59        }
60        if self.name.as_ref().is_some_and(|name| {
61            name.is_empty()
62                || !name
63                    .chars()
64                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
65        }) {
66            return Err("reflection name must be an ASCII label".into());
67        }
68        if !self.fade.is_finite() || self.fade < 0.0 || !self.priority.is_finite() {
69            return Err("reflection fade and priority must be finite".into());
70        }
71        for axis in 0..3 {
72            if !self.position[axis].is_finite()
73                || !self.min[axis].is_finite()
74                || !self.max[axis].is_finite()
75                || self.min[axis] >= self.max[axis]
76                || self.position[axis] < self.min[axis]
77                || self.position[axis] > self.max[axis]
78            {
79                return Err("reflection position must be inside finite box bounds".into());
80            }
81        }
82        if let Some(hours) = &self.anchors
83            && (hours.is_empty()
84                || hours
85                    .iter()
86                    .any(|v| !v.is_finite() || !(0.0..=24.0).contains(v))
87                || hours.windows(2).any(|v| v[0] >= v[1]))
88        {
89            return Err("reflection anchors must be sorted distinct hours from 0 to 24".into());
90        }
91        Ok(())
92    }
93
94    pub fn hours<'a>(&'a self, defaults: &'a [f32]) -> &'a [f32] {
95        self.anchors.as_deref().unwrap_or(defaults)
96    }
97}
98
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
100pub struct ReflectionManifest {
101    pub schema_version: u32,
102    pub scene_hash: String,
103    pub spec: ReflectionSpec,
104    pub anchors: Vec<f32>,
105    pub sample_count: u32,
106    pub seed: u32,
107    pub format: String,
108    pub color_space: String,
109    pub coordinates: String,
110    pub files: Vec<FileEntry>,
111}
112
113#[derive(Clone, Debug, PartialEq)]
114pub struct Cube {
115    pub resolution: u32,
116    pub levels: Vec<Vec<[f32; 4]>>,
117}
118
119impl Cube {
120    pub fn new(resolution: u32, levels: Vec<Vec<[f32; 4]>>) -> Result<Self, String> {
121        let mut size = resolution;
122        if size < 4 || !size.is_power_of_two() {
123            return Err("invalid cube resolution".into());
124        }
125        for level in &levels {
126            if level.len() != 6 * size as usize * size as usize
127                || level.iter().flatten().any(|v| !v.is_finite() || *v < 0.0)
128            {
129                return Err("invalid cube level".into());
130            }
131            size = (size / 2).max(1);
132        }
133        if levels.len() != resolution.ilog2() as usize + 1 {
134            return Err("invalid cube mip count".into());
135        }
136        Ok(Self { resolution, levels })
137    }
138
139    pub fn bytes(&self) -> Vec<u8> {
140        let mut bytes = Vec::new();
141        bytes.extend_from_slice(b"PITOREF1");
142        bytes.extend_from_slice(&self.resolution.to_le_bytes());
143        bytes.extend_from_slice(&(self.levels.len() as u32).to_le_bytes());
144        for level in &self.levels {
145            for pixel in level {
146                for value in pixel {
147                    bytes.extend_from_slice(
148                        &f16::from_f32(value.min(65504.0)).to_bits().to_le_bytes(),
149                    );
150                }
151            }
152        }
153        bytes
154    }
155
156    pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> {
157        if bytes.len() < 16 || &bytes[..8] != b"PITOREF1" {
158            return Err("invalid reflection header".into());
159        }
160        let resolution = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
161        let count = u32::from_le_bytes(bytes[12..16].try_into().unwrap());
162        if !(4..=1024).contains(&resolution)
163            || !resolution.is_power_of_two()
164            || count != resolution.ilog2() + 1
165        {
166            return Err("invalid reflection dimensions".into());
167        }
168        let mut at = 16;
169        let mut size = resolution as usize;
170        let mut levels = Vec::new();
171        for _ in 0..count {
172            let len = 6 * size * size;
173            if bytes.len().saturating_sub(at) < len * 8 {
174                return Err("short reflection payload".into());
175            }
176            let mut level = Vec::with_capacity(len);
177            for pixel in bytes[at..at + len * 8].chunks_exact(8) {
178                level.push(std::array::from_fn(|channel| {
179                    f16::from_bits(u16::from_le_bytes(
180                        pixel[channel * 2..channel * 2 + 2].try_into().unwrap(),
181                    ))
182                    .to_f32()
183                }));
184            }
185            levels.push(level);
186            at += len * 8;
187            size = (size / 2).max(1);
188        }
189        if at != bytes.len() {
190            return Err("trailing reflection payload".into());
191        }
192        Self::new(resolution, levels)
193    }
194}
195
196pub struct ReflectionArtifact {
197    pub manifest: ReflectionManifest,
198    pub cubes: Vec<Cube>,
199}
200
201fn digest(bytes: &[u8]) -> String {
202    hex::encode(Sha256::digest(bytes))
203}
204
205pub fn reflection_hash(
206    source: &SceneSource,
207    scene: &BakeScene,
208    spec: &ReflectionSpec,
209    samples: u32,
210) -> Result<String, String> {
211    let grid = crate::GridSpec {
212        min: spec.position,
213        max: spec.position,
214        spacing: 1.0,
215    };
216    let mut h = Sha256::new();
217    h.update(b"pito-engine-reflection-v2");
218    h.update(source.hash(grid, samples)?.as_bytes());
219    h.update(crate::scene_hash(scene, grid, samples, source.recipe.seed)?.as_bytes());
220    h.update(serde_json::to_vec(spec).map_err(|e| e.to_string())?);
221    Ok(hex::encode(h.finalize()))
222}
223
224pub fn face_direction(face: usize, x: u32, y: u32, size: u32) -> [f32; 3] {
225    let (forward, right, up) = FACES[face];
226    let u = (x as f32 + 0.5) * 2.0 / size as f32 - 1.0;
227    let v = 1.0 - (y as f32 + 0.5) * 2.0 / size as f32;
228    normalize(std::array::from_fn(|i| {
229        forward[i] + u * right[i] + v * up[i]
230    }))
231}
232
233fn normalize(v: [f32; 3]) -> [f32; 3] {
234    let length = v.iter().map(|x| x * x).sum::<f32>().sqrt();
235    v.map(|x| x / length)
236}
237
238fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
239    (0..3).map(|i| a[i] * b[i]).sum()
240}
241
242fn face_uv(direction: [f32; 3]) -> (usize, f32, f32) {
243    let axis = (0..3)
244        .max_by(|&a, &b| direction[a].abs().total_cmp(&direction[b].abs()))
245        .unwrap();
246    let face = axis * 2 + usize::from(direction[axis] < 0.0);
247    let (forward, right, up) = FACES[face];
248    let scale = dot(direction, forward);
249    (
250        face,
251        dot(direction, right) / scale,
252        dot(direction, up) / scale,
253    )
254}
255
256fn lookup(base: &[[f32; 4]], size: u32, direction: [f32; 3]) -> [f32; 4] {
257    let (face, u, v) = face_uv(direction);
258    let x = (((u + 1.0) * 0.5 * size as f32) as i32).clamp(0, size as i32 - 1) as usize;
259    let y = (((1.0 - v) * 0.5 * size as f32) as i32).clamp(0, size as i32 - 1) as usize;
260    base[face * size as usize * size as usize + y * size as usize + x]
261}
262
263fn radical_inverse(mut value: u32) -> f32 {
264    value = value.reverse_bits();
265    value as f32 * (1.0 / 4294967296.0)
266}
267
268pub fn prefilter(base: Vec<[f32; 4]>, resolution: u32) -> Result<Cube, String> {
269    if resolution < 4
270        || !resolution.is_power_of_two()
271        || base.len() != 6 * resolution as usize * resolution as usize
272    {
273        return Err("invalid base cube".into());
274    }
275    let count = resolution.ilog2() + 1;
276    let mut levels = vec![base.clone()];
277    for mip in 1..count {
278        let size = resolution >> mip;
279        let roughness = mip as f32 / (count - 1) as f32;
280        let alpha2 = roughness.powi(4).max(0.0001);
281        let mut pixels = Vec::with_capacity(6 * size as usize * size as usize);
282        for face in 0..6 {
283            for y in 0..size {
284                for x in 0..size {
285                    let n = face_direction(face, x, y, size);
286                    let helper = if n[1].abs() < 0.99 {
287                        [0.0, 1.0, 0.0]
288                    } else {
289                        [1.0, 0.0, 0.0]
290                    };
291                    let tangent = normalize([
292                        n[1] * helper[2] - n[2] * helper[1],
293                        n[2] * helper[0] - n[0] * helper[2],
294                        n[0] * helper[1] - n[1] * helper[0],
295                    ]);
296                    let bitangent = [
297                        n[1] * tangent[2] - n[2] * tangent[1],
298                        n[2] * tangent[0] - n[0] * tangent[2],
299                        n[0] * tangent[1] - n[1] * tangent[0],
300                    ];
301                    let mut sum = [0.0f32; 4];
302                    let mut weight = 0.0f32;
303                    for sample in 0..64 {
304                        let phi = std::f32::consts::TAU * sample as f32 / 64.0;
305                        let xi = radical_inverse(sample);
306                        let cos_theta = ((1.0 - xi) / (1.0 + (alpha2 - 1.0) * xi)).sqrt();
307                        let sin_theta = (1.0 - cos_theta * cos_theta).max(0.0).sqrt();
308                        let h = std::array::from_fn(|i| {
309                            tangent[i] * phi.cos() * sin_theta
310                                + bitangent[i] * phi.sin() * sin_theta
311                                + n[i] * cos_theta
312                        });
313                        let l = std::array::from_fn(|i| 2.0 * dot(n, h) * h[i] - n[i]);
314                        let w = dot(n, l).max(0.0);
315                        if w > 0.0 {
316                            let radiance = lookup(&base, resolution, l);
317                            for channel in 0..4 {
318                                sum[channel] += radiance[channel] * w;
319                            }
320                            weight += w;
321                        }
322                    }
323                    pixels.push(sum.map(|v| v / weight));
324                }
325            }
326        }
327        levels.push(pixels);
328    }
329    Cube::new(resolution, levels)
330}
331
332pub fn bake_reflection(
333    gpu: &Gpu,
334    scene: &BakeScene,
335    spec: &ReflectionSpec,
336    samples: u32,
337    seed: u32,
338    anchor_index: usize,
339) -> Result<Cube, String> {
340    Paced::quiet(|paced| {
341        bake_reflection_paced(gpu, scene, spec, samples, seed, anchor_index, paced)
342    })
343}
344
345pub fn bake_reflection_paced(
346    gpu: &Gpu,
347    scene: &BakeScene,
348    spec: &ReflectionSpec,
349    samples: u32,
350    seed: u32,
351    anchor_index: usize,
352    paced: &mut Paced<'_>,
353) -> Result<Cube, String> {
354    spec.validate()?;
355    if samples == 0 {
356        return Err("reflection samples must be positive".into());
357    }
358    let anchor = scene
359        .anchors
360        .get(anchor_index)
361        .ok_or("reflection anchor is missing")?;
362    let size = spec.resolution;
363    let mut base = Vec::with_capacity(6 * size as usize * size as usize);
364    let trace_scene = Scene {
365        triangles: scene.triangles.clone(),
366        shapes: scene.shapes.clone(),
367        materials: scene.materials.clone(),
368        sky: anchor.sky.clone(),
369        camera: Camera {
370            origin: spec.position,
371            forward: FACES[0].0,
372            right: FACES[0].1,
373            up: FACES[0].2,
374        },
375        sun: anchor.sun,
376    };
377    let mut trace = Trace::new(gpu, &trace_scene, size, size)?;
378    let bvh = Bvh::build(&scene.triangles);
379    for (face, &(forward, right, up)) in FACES.iter().enumerate() {
380        trace.set_camera(Camera {
381            origin: spec.position,
382            forward,
383            right,
384            up,
385        })?;
386        let ray_seed = seed
387            .wrapping_add((anchor_index as u32).wrapping_mul(0x9e37_79b9))
388            .wrapping_add(face as u32);
389        let mut failed = None::<String>;
390        let between = &mut *paced.between;
391        trace.sample_paced(gpu, samples, ray_seed, paced.pacer, |ms| {
392            match between(ms) {
393                Ok(turned) => turned,
394                Err(error) => {
395                    failed.get_or_insert(error);
396                    false
397                }
398            }
399        })?;
400        if let Some(error) = failed {
401            return Err(error);
402        }
403        let bytes = trace.readback_color(gpu)?;
404        for (index, pixel) in bytes.chunks_exact(16).enumerate() {
405            let rgb: [f32; 3] = std::array::from_fn(|channel| {
406                f32::from_le_bytes(pixel[channel * 4..channel * 4 + 4].try_into().unwrap())
407            });
408            let (x, y) = (index as u32 % size, index as u32 / size);
409            let inverse =
410                inverse_distance(scene, &bvh, spec.position, face_direction(face, x, y, size));
411            base.push([rgb[0].max(0.0), rgb[1].max(0.0), rgb[2].max(0.0), inverse]);
412        }
413    }
414    prefilter(base, size)
415}
416
417fn inverse_distance(scene: &BakeScene, bvh: &Bvh, origin: [f32; 3], direction: [f32; 3]) -> f32 {
418    let ray = Ray { origin, direction };
419    let mesh = bvh
420        .intersect(&scene.triangles, ray, f32::INFINITY)
421        .map(|hit| hit.distance);
422    let limit = mesh.unwrap_or(f32::INFINITY);
423    intersect(&scene.shapes, ray, limit)
424        .map(|hit| hit.distance)
425        .or(mesh)
426        .map_or(0.0, |distance| 1.0 / distance.max(0.001))
427}
428
429pub struct ReflectionWrite<'a> {
430    pub tmp_root: &'a Path,
431    pub relative: &'a Path,
432    pub scene_hash: String,
433    pub spec: &'a ReflectionSpec,
434    pub anchors: &'a [Anchor],
435    pub cubes: &'a [Cube],
436    pub samples: u32,
437    pub seed: u32,
438}
439
440pub fn write_reflection_artifact(input: ReflectionWrite<'_>) -> Result<ReflectionManifest, String> {
441    let ReflectionWrite {
442        tmp_root,
443        relative,
444        scene_hash,
445        spec,
446        anchors,
447        cubes,
448        samples,
449        seed,
450    } = input;
451    spec.validate()?;
452    if cubes.is_empty()
453        || cubes.len() != anchors.len()
454        || cubes.iter().any(|v| v.resolution != spec.resolution)
455    {
456        return Err("reflection cubes and anchors do not match".into());
457    }
458    let out = tmp_root.join(relative);
459    if out.exists()
460        || !out.starts_with(tmp_root)
461        || relative
462            .components()
463            .any(|v| !matches!(v, std::path::Component::Normal(_)))
464    {
465        return Err("invalid reflection output folder".into());
466    }
467    fs::create_dir_all(&out).map_err(|e| e.to_string())?;
468    let mut files = Vec::new();
469    for (index, cube) in cubes.iter().enumerate() {
470        let path = format!("cube-{index:03}.bin");
471        let bytes = cube.bytes();
472        fs::write(out.join(&path), &bytes).map_err(|e| e.to_string())?;
473        files.push(FileEntry {
474            path,
475            bytes: bytes.len() as u64,
476            sha256: digest(&bytes),
477        });
478    }
479    let manifest = ReflectionManifest {
480        schema_version: 1,
481        scene_hash,
482        spec: spec.clone(),
483        anchors: anchors.iter().map(|v| v.hour).collect(),
484        sample_count: samples,
485        seed,
486        format: REFLECTION_FORMAT.into(),
487        color_space: "linear_rec709_radiance".into(),
488        coordinates: "right_handed_y_up_px_nx_py_ny_pz_nz".into(),
489        files,
490    };
491    let json = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
492    fs::write(out.join("manifest.json"), &json).map_err(|e| e.to_string())?;
493    fs::write(out.join("manifest.sha256"), format!("{}\n", digest(&json)))
494        .map_err(|e| e.to_string())?;
495    Ok(manifest)
496}
497
498pub fn read_reflection_artifact(dir: &Path) -> Result<ReflectionArtifact, String> {
499    read_reflection_artifact_from(&|name| fs::read(dir.join(name)).ok())
500}
501
502pub fn read_reflection_artifact_from(
503    files: &dyn Fn(&str) -> Option<Vec<u8>>,
504) -> Result<ReflectionArtifact, String> {
505    let fetch = |name: &str| files(name).ok_or_else(|| format!("missing reflection file {name}"));
506    let json = fetch("manifest.json")?;
507    if String::from_utf8(fetch("manifest.sha256")?)
508        .map_err(|e| e.to_string())?
509        .trim()
510        != digest(&json)
511    {
512        return Err("reflection manifest checksum mismatch".into());
513    }
514    let manifest: ReflectionManifest = serde_json::from_slice(&json).map_err(|e| e.to_string())?;
515    manifest.spec.validate()?;
516    if manifest.schema_version != 1
517        || manifest.sample_count == 0
518        || manifest.anchors.is_empty()
519        || manifest.anchors.len() != manifest.files.len()
520        || (manifest.format != REFLECTION_FORMAT && manifest.format != REFLECTION_FORMAT_V1)
521        || manifest.color_space != "linear_rec709_radiance"
522        || manifest.coordinates != "right_handed_y_up_px_nx_py_ny_pz_nz"
523        || manifest
524            .anchors
525            .iter()
526            .any(|v| !v.is_finite() || !(0.0..=24.0).contains(v))
527        || manifest.anchors.windows(2).any(|v| v[0] >= v[1])
528    {
529        return Err("invalid reflection manifest".into());
530    }
531    let mut cubes = Vec::new();
532    for (index, file) in manifest.files.iter().enumerate() {
533        if file.path != format!("cube-{index:03}.bin") {
534            return Err("invalid reflection file name".into());
535        }
536        let bytes = fetch(&file.path)?;
537        if file.bytes != bytes.len() as u64 || file.sha256 != digest(&bytes) {
538            return Err("reflection checksum mismatch".into());
539        }
540        let cube = Cube::from_bytes(&bytes)?;
541        if cube.resolution != manifest.spec.resolution {
542            return Err("reflection resolution mismatch".into());
543        }
544        cubes.push(cube);
545    }
546    Ok(ReflectionArtifact { manifest, cubes })
547}
548
549#[repr(C)]
550#[derive(Clone, Copy, Debug, PartialEq)]
551pub struct ProbeRecord {
552    pub center: [f32; 3],
553    pub layer: i32,
554    pub box_min: [f32; 3],
555    pub priority: f32,
556    pub box_max: [f32; 3],
557    pub fade: f32,
558}
559
560pub struct ReflectionArray {
561    pub texture: wgpu::Texture,
562    pub view: wgpu::TextureView,
563    pub records: Vec<ProbeRecord>,
564    pub max_mip: f32,
565}
566
567pub fn upload_reflections(
568    device: &wgpu::Device,
569    queue: &wgpu::Queue,
570    artifacts: &[ReflectionArtifact],
571    hour: f32,
572) -> Result<ReflectionArray, String> {
573    if artifacts.is_empty() || !hour.is_finite() {
574        return Err("reflection array needs probes and an hour".into());
575    }
576    let size = artifacts[0].manifest.spec.resolution;
577    if artifacts.iter().any(|a| a.manifest.spec.resolution != size) {
578        return Err("reflection resolutions must match".into());
579    }
580    let texture = device.create_texture(&wgpu::TextureDescriptor {
581        label: Some("baked local reflections"),
582        size: wgpu::Extent3d {
583            width: size,
584            height: size,
585            depth_or_array_layers: (artifacts.len() * 6) as u32,
586        },
587        mip_level_count: size.ilog2() + 1,
588        sample_count: 1,
589        dimension: wgpu::TextureDimension::D2,
590        format: wgpu::TextureFormat::Rgba16Float,
591        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
592        view_formats: &[],
593    });
594    let mut records = Vec::new();
595    for (index, artifact) in artifacts.iter().enumerate() {
596        let blend = crate::blend_anchors(&artifact.manifest.anchors, hour)?;
597        let distance = artifact.manifest.format == REFLECTION_FORMAT;
598        let lower = &artifact.cubes[blend.lower];
599        let upper = &artifact.cubes[blend.upper];
600        for (mip, (a, b)) in lower.levels.iter().zip(&upper.levels).enumerate() {
601            let side = size >> mip;
602            for face in 0..6 {
603                let start = face * side as usize * side as usize;
604                let end = start + side as usize * side as usize;
605                let mut bytes = Vec::with_capacity((end - start) * 8);
606                for (pa, pb) in a[start..end].iter().zip(&b[start..end]) {
607                    for channel in 0..4 {
608                        let mut value = pa[channel] * (1.0 - blend.upper_weight)
609                            + pb[channel] * blend.upper_weight;
610                        if channel == 3 && distance {
611                            value = -1.0 - value.max(0.0);
612                        }
613                        bytes.extend_from_slice(&f16::from_f32(value).to_bits().to_le_bytes());
614                    }
615                }
616                queue.write_texture(
617                    wgpu::TexelCopyTextureInfo {
618                        texture: &texture,
619                        mip_level: mip as u32,
620                        origin: wgpu::Origin3d {
621                            x: 0,
622                            y: 0,
623                            z: (index * 6 + face) as u32,
624                        },
625                        aspect: wgpu::TextureAspect::All,
626                    },
627                    &bytes,
628                    wgpu::TexelCopyBufferLayout {
629                        offset: 0,
630                        bytes_per_row: Some(side * 8),
631                        rows_per_image: Some(side),
632                    },
633                    wgpu::Extent3d {
634                        width: side,
635                        height: side,
636                        depth_or_array_layers: 1,
637                    },
638                );
639            }
640        }
641        let spec = &artifact.manifest.spec;
642        records.push(ProbeRecord {
643            center: spec.position,
644            layer: index as i32,
645            box_min: spec.min,
646            priority: spec.priority,
647            box_max: spec.max,
648            fade: spec.fade,
649        });
650    }
651    let view = texture.create_view(&wgpu::TextureViewDescriptor {
652        dimension: Some(wgpu::TextureViewDimension::CubeArray),
653        ..Default::default()
654    });
655    Ok(ReflectionArray {
656        texture,
657        view,
658        records,
659        max_mip: size.ilog2() as f32,
660    })
661}
662
663pub fn nearest_two(records: &[ProbeRecord], world: [f32; 3]) -> [(usize, f32); 2] {
664    let mut weighted = records
665        .iter()
666        .enumerate()
667        .filter_map(|(index, probe)| {
668            let mut edge = f32::INFINITY;
669            for (axis, coordinate) in world.iter().enumerate() {
670                if *coordinate < probe.box_min[axis] || *coordinate > probe.box_max[axis] {
671                    return None;
672                }
673                edge = edge
674                    .min(*coordinate - probe.box_min[axis])
675                    .min(probe.box_max[axis] - *coordinate);
676            }
677            let distance = (0..3)
678                .map(|axis| (world[axis] - probe.center[axis]).powi(2))
679                .sum::<f32>()
680                .sqrt();
681            let seam = if probe.fade == 0.0 {
682                1.0
683            } else {
684                (edge / probe.fade).clamp(0.0, 1.0)
685            };
686            Some((
687                index,
688                seam * (1.0 + probe.priority.max(0.0)) / (distance + 0.001),
689                seam,
690            ))
691        })
692        .collect::<Vec<_>>();
693    weighted.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
694    let a = weighted.first().copied().unwrap_or((0, 0.0, 0.0));
695    let b = weighted.get(1).copied().unwrap_or((0, 0.0, 0.0));
696    let total = a.1 + b.1;
697    let coverage = a.2.max(b.2);
698    if total == 0.0 {
699        [(a.0, 0.0), (b.0, 0.0)]
700    } else {
701        [(a.0, coverage * a.1 / total), (b.0, coverage * b.1 / total)]
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use pfx_load::Sky;
709    use pfx_materials::Material;
710    use pfx_trace::{Sun, shapes::Shape};
711
712    #[test]
713    fn face_axes_match_cpu_reference() {
714        let expected = [
715            [1.0, 0.0, 0.0],
716            [-1.0, 0.0, 0.0],
717            [0.0, 1.0, 0.0],
718            [0.0, -1.0, 0.0],
719            [0.0, 0.0, 1.0],
720            [0.0, 0.0, -1.0],
721        ];
722        for (face, axis) in expected.into_iter().enumerate() {
723            let direction = face_direction(face, 127, 127, 256);
724            assert!(dot(direction, axis) > 0.999);
725            let (actual, _, _) = face_uv(direction);
726            assert_eq!(actual, face);
727        }
728        let corners = [
729            [1.0, 0.75, -0.75],
730            [-1.0, 0.75, 0.75],
731            [0.75, 1.0, -0.75],
732            [0.75, -1.0, 0.75],
733            [0.75, 0.75, 1.0],
734            [-0.75, 0.75, -1.0],
735        ];
736        for (face, reference) in corners.into_iter().enumerate() {
737            let actual = face_direction(face, 3, 0, 4);
738            let reference = normalize(reference);
739            assert!(dot(actual, reference) > 0.99999, "face {face}: {actual:?}");
740        }
741        assert_eq!(face_uv([1.0, 0.0, -0.5]).0, 0);
742        assert_eq!(face_uv([0.0, -1.0, 0.5]).0, 3);
743    }
744
745    #[test]
746    fn prefilter_spreads_bright_texel_and_is_deterministic() {
747        let mut base = vec![[0.0, 0.0, 0.0, 1.0]; 6 * 16 * 16];
748        base[7 * 16 + 7] = [8.0, 1.0, 1.0, 1.0];
749        let cube = prefilter(base.clone(), 16).unwrap();
750        assert_eq!(cube.bytes(), prefilter(base, 16).unwrap().bytes());
751        assert_eq!(
752            Cube::from_bytes(&cube.bytes()).unwrap().bytes(),
753            cube.bytes()
754        );
755        let peak =
756            |level: &Vec<[f32; 4]>| level.iter().map(|pixel| pixel[0]).fold(0.0f32, f32::max);
757        assert!(
758            cube.levels
759                .windows(2)
760                .all(|pair| peak(&pair[1]) <= peak(&pair[0]) + 0.01)
761        );
762    }
763
764    #[test]
765    fn nearest_blends_at_overlap_and_fades_at_edge() {
766        assert_eq!(std::mem::size_of::<ProbeRecord>(), 48);
767        let record = ProbeRecord {
768            center: [0.0; 3],
769            layer: 0,
770            box_min: [-1.0; 3],
771            priority: 0.0,
772            box_max: [1.0; 3],
773            fade: 0.25,
774        };
775        let mut other = record;
776        other.center = [0.2, 0.0, 0.0];
777        other.layer = 1;
778        let selected = nearest_two(&[record, other], [0.1, 0.0, 0.0]);
779        assert!((selected[0].1 - 0.5).abs() < 0.01);
780        assert!((selected[1].1 - 0.5).abs() < 0.01);
781        assert!(nearest_two(&[record], [0.99, 0.0, 0.0])[0].1 < 0.1);
782        assert_eq!(nearest_two(&[record], [2.0, 0.0, 0.0])[0].1, 0.0);
783        other.priority = 2.0;
784        let selected = nearest_two(&[record, other], [0.1, 0.0, 0.0]);
785        assert_eq!(selected[0].0, 1);
786        assert!(selected[0].1 > selected[1].1);
787    }
788
789    #[test]
790    fn recipe_parses_reflection_defaults_and_rejects_bad_bounds() {
791        let text = "scene = 'room.glb'\napp = 'test'\nanchors = [12.0]\nsamples = 2\n[[reflection]]\nposition = [0.0, 1.0, 0.0]\nmin = [-2.0, 0.0, -2.0]\nmax = [2.0, 2.0, 2.0]\n";
792        let recipe = crate::Recipe::parse(text.as_bytes()).unwrap();
793        assert_eq!(recipe.reflections[0].resolution, 256);
794        assert!(recipe.volumes.is_empty());
795        assert!(
796            crate::Recipe::parse(text.replace("2.0, 2.0, 2.0", "-2.0, 2.0, 2.0").as_bytes())
797                .is_err()
798        );
799    }
800
801    #[test]
802    fn artifact_checks_every_cube() {
803        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tmp");
804        fs::create_dir_all(&root).unwrap();
805        let folder = format!("reflection-artifact-test-{}", std::process::id());
806        let target = root.join(&folder);
807        if target.exists() {
808            fs::remove_dir_all(&target).unwrap();
809        }
810        let spec = ReflectionSpec {
811            name: None,
812            position: [0.0; 3],
813            min: [-1.0; 3],
814            max: [1.0; 3],
815            resolution: 4,
816            anchors: None,
817            fade: 0.25,
818            priority: 0.0,
819        };
820        let cube = prefilter(vec![[1.0, 0.0, 0.0, 1.0]; 6 * 4 * 4], 4).unwrap();
821        let anchors = vec![Anchor {
822            hour: 12.0,
823            sky: Sky {
824                width: 1,
825                height: 1,
826                texels: vec![[0.0; 4]],
827            },
828            sun: Sun {
829                direction: [0.0, 1.0, 0.0],
830                color: [0.0; 3],
831                intensity: 0.0,
832            },
833        }];
834        let manifest = write_reflection_artifact(ReflectionWrite {
835            tmp_root: &root,
836            relative: Path::new(&folder),
837            scene_hash: "example".into(),
838            spec: &spec,
839            anchors: &anchors,
840            cubes: std::slice::from_ref(&cube),
841            samples: 2,
842            seed: 7,
843        })
844        .unwrap();
845        assert_eq!(manifest.files.len(), 1);
846        assert_eq!(
847            read_reflection_artifact(&target).unwrap().cubes[0].bytes(),
848            cube.bytes()
849        );
850        let files: std::collections::HashMap<String, Vec<u8>> = fs::read_dir(&target)
851            .unwrap()
852            .map(|entry| {
853                let entry = entry.unwrap();
854                (
855                    entry.file_name().to_string_lossy().into_owned(),
856                    fs::read(entry.path()).unwrap(),
857                )
858            })
859            .collect();
860        let from_bytes = read_reflection_artifact_from(&|file| files.get(file).cloned()).unwrap();
861        let from_path = read_reflection_artifact(&target).unwrap();
862        assert_eq!(from_bytes.manifest, from_path.manifest);
863        assert_eq!(from_bytes.cubes[0].bytes(), cube.bytes());
864        assert_eq!(from_path.manifest.format, REFLECTION_FORMAT);
865        let mut older = files.clone();
866        let json = String::from_utf8(older["manifest.json"].clone())
867            .unwrap()
868            .replace(REFLECTION_FORMAT, REFLECTION_FORMAT_V1)
869            .into_bytes();
870        older.insert(
871            "manifest.sha256".into(),
872            format!("{}\n", digest(&json)).into_bytes(),
873        );
874        older.insert("manifest.json".into(), json);
875        let older = read_reflection_artifact_from(&|file| older.get(file).cloned()).unwrap();
876        assert_eq!(older.manifest.format, REFLECTION_FORMAT_V1);
877        assert_eq!(older.cubes[0].bytes(), cube.bytes());
878        for file in ["manifest.json", "cube-000.bin"] {
879            let mut changed = files.clone();
880            changed.get_mut(file).unwrap()[10] ^= 1;
881            let error = read_reflection_artifact_from(&|file| changed.get(file).cloned())
882                .err()
883                .unwrap();
884            assert!(error.contains("checksum"), "{file}: {error}");
885            let mut missing = files.clone();
886            missing.remove(file);
887            let error = read_reflection_artifact_from(&|file| missing.get(file).cloned())
888                .err()
889                .unwrap();
890            assert!(error.contains("missing reflection file"), "{file}: {error}");
891        }
892        let path = target.join("cube-000.bin");
893        let mut bytes = fs::read(&path).unwrap();
894        bytes[20] ^= 1;
895        fs::write(path, bytes).unwrap();
896        assert!(
897            read_reflection_artifact(&target)
898                .err()
899                .unwrap()
900                .contains("checksum")
901        );
902        fs::remove_dir_all(target).unwrap();
903    }
904
905    #[test]
906    #[ignore = "needs a GPU; run under pgpu"]
907    fn red_box_appears_in_positive_x_face() {
908        let gpu = pollster::block_on(Gpu::headless()).unwrap();
909        let scene = BakeScene {
910            triangles: Vec::new(),
911            shapes: vec![Shape::RoundedBox {
912                center: [2.0, 0.0, 0.0],
913                half: [0.5, 0.5, 0.5],
914                radius: 0.01,
915                material: 0,
916            }],
917            materials: vec![Material {
918                base: [1.0, 0.0, 0.0],
919                emission: [8.0, 0.0, 0.0],
920                ..Material::default()
921            }],
922            anchors: vec![Anchor {
923                hour: 12.0,
924                sky: Sky {
925                    width: 1,
926                    height: 1,
927                    texels: vec![[0.0; 4]],
928                },
929                sun: Sun {
930                    direction: [0.0, 1.0, 0.0],
931                    color: [0.0; 3],
932                    intensity: 0.0,
933                },
934            }],
935        };
936        let spec = ReflectionSpec {
937            name: None,
938            position: [0.0; 3],
939            min: [-3.0; 3],
940            max: [3.0; 3],
941            resolution: 16,
942            anchors: None,
943            fade: 0.25,
944            priority: 0.0,
945        };
946        let first = bake_reflection(&gpu, &scene, &spec, 4, 9, 0).unwrap();
947        let second = bake_reflection(&gpu, &scene, &spec, 4, 9, 0).unwrap();
948        assert_eq!(first.bytes(), second.bytes());
949        let pixel = first.levels[0][8 * 16 + 8];
950        assert!(pixel[0] > pixel[1] * 4.0 + 0.1, "red box pixel: {pixel:?}");
951        assert!(
952            (1.0 / pixel[3] - 1.5).abs() < 0.02,
953            "red box distance: {pixel:?}"
954        );
955        let behind = first.levels[0][16 * 16 + 8 * 16 + 8];
956        assert_eq!(behind[3], 0.0, "open sky distance: {behind:?}");
957    }
958
959    #[test]
960    #[ignore = "needs a GPU; run under pgpu"]
961    fn split_samples_bake_the_same_cube() {
962        let gpu = pollster::block_on(Gpu::headless()).unwrap();
963        let scene = BakeScene {
964            triangles: Vec::new(),
965            shapes: vec![Shape::RoundedBox {
966                center: [2.0, 0.0, 0.0],
967                half: [0.5, 0.5, 0.5],
968                radius: 0.01,
969                material: 0,
970            }],
971            materials: vec![Material {
972                base: [0.6, 0.3, 0.2],
973                emission: [2.0, 0.5, 0.0],
974                ..Material::default()
975            }],
976            anchors: vec![Anchor {
977                hour: 12.0,
978                sky: Sky {
979                    width: 2,
980                    height: 1,
981                    texels: vec![[0.2, 0.3, 0.5, 1.0], [0.1, 0.1, 0.1, 1.0]],
982                },
983                sun: Sun {
984                    direction: [0.3, 0.8, 0.5],
985                    color: [1.0; 3],
986                    intensity: 4.0,
987                },
988            }],
989        };
990        let spec = ReflectionSpec {
991            name: None,
992            position: [0.0; 3],
993            min: [-3.0; 3],
994            max: [3.0; 3],
995            resolution: 32,
996            anchors: None,
997            fade: 0.25,
998            priority: 0.0,
999        };
1000        let bake = |fixed: Option<u32>| {
1001            let mut pacer = pfx_gpu::pace::Pacer::default();
1002            pacer.set_fixed_units(fixed);
1003            let mut slices = 0;
1004            let mut turns = pfx_gpu::pace::Turns::default();
1005            let mut between = |ms: f64| {
1006                slices += 1;
1007                Ok(turns.add(ms))
1008            };
1009            let cube = bake_reflection_paced(
1010                &gpu,
1011                &scene,
1012                &spec,
1013                48,
1014                9,
1015                0,
1016                &mut Paced {
1017                    pacer: &mut pacer,
1018                    between: &mut between,
1019                },
1020            )
1021            .unwrap();
1022            (cube.bytes(), slices)
1023        };
1024        let (whole, whole_slices) = bake(Some(32 * 48));
1025        assert_eq!(whole_slices, 6);
1026        let (single, single_slices) = bake(Some(8));
1027        assert_eq!(single_slices, 6 * 32 * 48 / 8);
1028        assert!(single == whole, "one band of eight rows per submission");
1029        assert!(bake(Some(40)).0 == whole, "bands across samples");
1030        assert!(bake(None).0 == whole, "paced submissions");
1031    }
1032}