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}