1use std::collections::VecDeque;
2use std::fs;
3use std::path::Path;
4
5use pfx_materials::{At, Material, resolve};
6use serde_json::{Value, json};
7use sha2::{Digest, Sha256};
8
9pub mod atlas;
10pub mod ktx2;
11
12pub const LIMIT: u32 = 4096;
13pub const FLOOR: u32 = 64;
14
15const NEAR: f32 = 0.4;
16const FAR: f32 = 6.0;
17
18pub struct Input<'a> {
19 pub positions: &'a [[f32; 3]],
20 pub normals: &'a [[f32; 3]],
21 pub tangents: &'a [[f32; 4]],
22 pub uvs: &'a [[f32; 2]],
23 pub uvs1: Option<&'a [[f32; 2]]>,
24 pub indices: &'a [u32],
25 pub material: &'a Material,
26}
27
28impl Input<'_> {
29 pub fn raster(&self) -> &[[f32; 2]] {
30 self.uvs1.unwrap_or(self.uvs)
31 }
32
33 pub fn uv_set(&self) -> u32 {
34 u32::from(self.uvs1.is_some())
35 }
36}
37
38pub struct View {
39 pub matrix: [[f32; 4]; 4],
40 pub size: [u32; 2],
41}
42
43impl View {
44 pub fn look(camera: &crate::recipe::DetailView) -> Self {
45 let eye = camera.position;
46 let forward = unit(sub(camera.target, eye));
47 let right = unit(cross(forward, [0.0, 1.0, 0.0]));
48 let up = cross(right, forward);
49 let view = [
50 [right[0], up[0], -forward[0], 0.0],
51 [right[1], up[1], -forward[1], 0.0],
52 [right[2], up[2], -forward[2], 0.0],
53 [-dot(right, eye), -dot(up, eye), dot(forward, eye), 1.0],
54 ];
55 let tan_y = (camera.fov_y_deg.to_radians() * 0.5).tan();
56 let tan_x = tan_y * camera.width as f32 / camera.height as f32;
57 let projection = [
58 [1.0 / tan_x, 0.0, 0.0, 0.0],
59 [0.0, 1.0 / tan_y, 0.0, 0.0],
60 [camera.shift[0], camera.shift[1], FAR / (NEAR - FAR), -1.0],
61 [0.0, 0.0, FAR * NEAR / (NEAR - FAR), 0.0],
62 ];
63 let mut matrix = [[0.0; 4]; 4];
64 for (column, out) in matrix.iter_mut().enumerate() {
65 for (row, value) in out.iter_mut().enumerate() {
66 *value = (0..4).map(|k| projection[k][row] * view[column][k]).sum();
67 }
68 }
69 Self {
70 matrix,
71 size: [camera.width, camera.height],
72 }
73 }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct Part {
78 pub path: String,
79 pub node: String,
80 pub size: u32,
81 pub crop: Crop,
82 pub page: u32,
83 pub bytes: u64,
84 pub input_hash: String,
85 pub uv_set: u32,
86}
87
88#[derive(Clone, Debug, PartialEq)]
89pub struct Report {
90 pub parts: Vec<Part>,
91 pub total_bytes: u64,
92 pub vram_bytes: u64,
93 pub decoded_vram_bytes: u64,
94 pub full_bytes: u64,
95 pub page: [u32; 2],
96 pub pages: u32,
97 pub psnr: [Option<f64>; 3],
98}
99
100pub fn map_bytes(size: u32) -> u64 {
101 3 * 4 * u64::from(size) * u64::from(size)
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
105pub struct Crop {
106 pub x: u32,
107 pub y: u32,
108 pub width: u32,
109 pub height: u32,
110}
111
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct Region {
114 pub size: u32,
115 pub crop: Crop,
116 pub uv_set: u32,
117 pub base: Vec<u8>,
118 pub roughness: Vec<u8>,
119 pub normal: Vec<u8>,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct Maps {
124 pub size: u32,
125 pub base: Vec<u8>,
126 pub roughness: Vec<u8>,
127 pub normal: Vec<u8>,
128}
129
130fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
131 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
132}
133
134fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
135 [
136 a[1] * b[2] - a[2] * b[1],
137 a[2] * b[0] - a[0] * b[2],
138 a[0] * b[1] - a[1] * b[0],
139 ]
140}
141
142fn unit(v: [f32; 3]) -> [f32; 3] {
143 let length = dot(v, v).sqrt();
144 if length > 1e-12 {
145 v.map(|x| x / length)
146 } else {
147 [0.0, 0.0, 1.0]
148 }
149}
150
151fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
152 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
153}
154
155fn mix3(values: [[f32; 3]; 3], weight: [f32; 3]) -> [f32; 3] {
156 std::array::from_fn(|axis| {
157 (0..3)
158 .map(|corner| values[corner][axis] * weight[corner])
159 .sum()
160 })
161}
162
163fn mix2(values: [[f32; 2]; 3], weight: [f32; 3]) -> [f32; 2] {
164 std::array::from_fn(|axis| {
165 (0..3)
166 .map(|corner| values[corner][axis] * weight[corner])
167 .sum()
168 })
169}
170
171fn project(view: &View, position: [f32; 3]) -> Option<[f32; 2]> {
172 let clip: [f32; 4] = std::array::from_fn(|axis| {
173 view.matrix[3][axis]
174 + (0..3)
175 .map(|component| view.matrix[component][axis] * position[component])
176 .sum::<f32>()
177 });
178 if clip[3] <= 0.0 || !clip[3].is_finite() {
179 return None;
180 }
181 Some([
182 (clip[0] / clip[3] * 0.5 + 0.5) * view.size[0] as f32,
183 (0.5 - clip[1] / clip[3] * 0.5) * view.size[1] as f32,
184 ])
185}
186
187pub fn resolution(input: &Input<'_>, view: &View, largest: u32) -> u32 {
188 let largest = largest.clamp(FLOOR, LIMIT);
189 let mut need = FLOOR as f32;
190 for triangle in input.indices.chunks_exact(3) {
191 let ids: [usize; 3] = std::array::from_fn(|corner| triangle[corner] as usize);
192 let Some(screen) = ids
193 .map(|id| project(view, input.positions[id]))
194 .into_iter()
195 .collect::<Option<Vec<_>>>()
196 else {
197 continue;
198 };
199 for edge in [(0, 1), (1, 2), (2, 0)] {
200 let a = edge.0;
201 let b = edge.1;
202 let uv = input.raster()[ids[a]];
203 let next = input.raster()[ids[b]];
204 let du = ((uv[0] - next[0]).powi(2) + (uv[1] - next[1]).powi(2)).sqrt();
205 let ds = ((screen[a][0] - screen[b][0]).powi(2)
206 + (screen[a][1] - screen[b][1]).powi(2))
207 .sqrt();
208 if du > 1e-5 {
209 need = need.max(ds / du);
210 }
211 }
212 }
213 (need.ceil() as u32)
214 .clamp(FLOOR, largest)
215 .next_power_of_two()
216 .min(largest)
217}
218
219fn edge(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 {
220 (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0])
221}
222
223fn byte(value: f32) -> u8 {
224 (value.clamp(0.0, 1.0) * 255.0).round() as u8
225}
226
227fn set_texel(map: &mut [u8], index: usize, value: [u8; 4]) {
228 map[index * 4..index * 4 + 4].copy_from_slice(&value);
229}
230
231fn texel(map: &[u8], index: usize) -> [u8; 4] {
232 map[index * 4..index * 4 + 4].try_into().unwrap()
233}
234
235pub fn repeats(input: &Input<'_>, size: u32) -> bool {
236 let uvs = input.raster();
237 if size == 0
238 || uvs.len() != input.positions.len()
239 || uvs.is_empty()
240 || uvs
241 .iter()
242 .flatten()
243 .any(|v| !v.is_finite() || *v < -0.1 || *v > 1.1)
244 {
245 return true;
246 }
247 let mut owner = vec![u32::MAX; (size * size) as usize];
248 for (number, triangle) in input.indices.chunks_exact(3).enumerate() {
249 let Some(uv) = triangle
250 .iter()
251 .map(|&id| uvs.get(id as usize).copied())
252 .collect::<Option<Vec<_>>>()
253 else {
254 return true;
255 };
256 let area = edge(uv[0], uv[1], uv[2]);
257 if area.abs() < 1e-12 {
258 continue;
259 }
260 let min: [u32; 2] = std::array::from_fn(|axis| {
261 (uv.iter().map(|p| p[axis]).fold(f32::INFINITY, f32::min) * size as f32 - 0.5)
262 .ceil()
263 .max(0.0) as u32
264 });
265 let max: [u32; 2] = std::array::from_fn(|axis| {
266 (uv.iter().map(|p| p[axis]).fold(f32::NEG_INFINITY, f32::max) * size as f32 - 0.5)
267 .floor()
268 .min(size as f32 - 1.0) as u32
269 });
270 for y in min[1]..=max[1] {
271 for x in min[0]..=max[0] {
272 let at = [
273 (x as f32 + 0.5) / size as f32,
274 (y as f32 + 0.5) / size as f32,
275 ];
276 let weight = [
277 edge(uv[1], uv[2], at) / area,
278 edge(uv[2], uv[0], at) / area,
279 edge(uv[0], uv[1], at) / area,
280 ];
281 if weight.iter().all(|&v| v > 1e-5) {
282 let index = (y * size + x) as usize;
283 if owner[index] != u32::MAX && owner[index] != number as u32 {
284 return true;
285 }
286 owner[index] = number as u32;
287 }
288 }
289 }
290 }
291 false
292}
293
294fn check_input(input: &Input<'_>, size: u32) -> Result<(), String> {
295 if size == 0 || size > LIMIT || !size.is_power_of_two() {
296 return Err(format!(
297 "detail size must be a power of two through {LIMIT}"
298 ));
299 }
300 if input.positions.len() != input.normals.len()
301 || input.positions.len() != input.tangents.len()
302 || input.positions.len() != input.uvs.len()
303 || input.positions.len() != input.raster().len()
304 || input
305 .indices
306 .iter()
307 .any(|&i| i as usize >= input.positions.len())
308 {
309 return Err("detail mesh attributes are incomplete".into());
310 }
311 Ok(())
312}
313
314pub fn crop(input: &Input<'_>, size: u32, padding: u32) -> Result<Crop, String> {
315 check_input(input, size)?;
316 let scale = size as f32;
317 let mut low = [i64::MAX; 2];
318 let mut high = [i64::MIN; 2];
319 for triangle in input.indices.chunks_exact(3) {
320 let uv = [0, 1, 2].map(|corner| input.raster()[triangle[corner] as usize]);
321 if edge(uv[0], uv[1], uv[2]).abs() < 1e-12 {
322 continue;
323 }
324 let min = std::array::from_fn::<_, 2, _>(|axis| {
325 (uv.iter().map(|p| p[axis]).fold(f32::INFINITY, f32::min) * scale - 0.5)
326 .ceil()
327 .max(0.0) as u32
328 });
329 let max = std::array::from_fn::<_, 2, _>(|axis| {
330 (uv.iter().map(|p| p[axis]).fold(f32::NEG_INFINITY, f32::max) * scale - 0.5)
331 .floor()
332 .min(scale - 1.0) as u32
333 });
334 if min[0] > max[0] || min[1] > max[1] {
335 continue;
336 }
337 for axis in 0..2 {
338 low[axis] = low[axis].min(i64::from(min[axis]));
339 high[axis] = high[axis].max(i64::from(max[axis]));
340 }
341 }
342 if low.iter().zip(&high).any(|(low, high)| low > high) {
343 return Err("detail mesh has no UV coverage".into());
344 }
345 let align = i64::from(atlas::ALIGN);
346 let start = low.map(|value| (value - i64::from(padding)).max(0) / align * align);
347 let end = high.map(|value| {
348 ((value + i64::from(padding) + 1 + align - 1) / align * align).min(i64::from(size))
349 });
350 Ok(Crop {
351 x: start[0] as u32,
352 y: start[1] as u32,
353 width: (end[0] - start[0]) as u32,
354 height: (end[1] - start[1]) as u32,
355 })
356}
357
358pub fn bake(input: &Input<'_>, size: u32, padding: u32) -> Result<Maps, String> {
359 let region = bake_crop(
360 input,
361 size,
362 padding,
363 Crop {
364 x: 0,
365 y: 0,
366 width: size,
367 height: size,
368 },
369 )?;
370 Ok(Maps {
371 size,
372 base: region.base,
373 roughness: region.roughness,
374 normal: region.normal,
375 })
376}
377
378pub fn bake_crop(input: &Input<'_>, size: u32, padding: u32, crop: Crop) -> Result<Region, String> {
379 check_input(input, size)?;
380 if crop.width == 0
381 || crop.height == 0
382 || crop.x + crop.width > size
383 || crop.y + crop.height > size
384 {
385 return Err("detail crop lies outside the map".into());
386 }
387 let count = (crop.width * crop.height) as usize;
388 let mut region = Region {
389 size,
390 crop,
391 uv_set: input.uv_set(),
392 base: vec![0; count * 4],
393 roughness: vec![0; count * 4],
394 normal: vec![0; count * 4],
395 };
396 let mut covered = vec![false; count];
397 let scale = size as f32;
398 for triangle in input.indices.chunks_exact(3) {
399 let ids: [usize; 3] = std::array::from_fn(|corner| triangle[corner] as usize);
400 let uv = ids.map(|id| input.raster()[id]);
401 let area = edge(uv[0], uv[1], uv[2]);
402 if area.abs() < 1e-12 {
403 continue;
404 }
405 let min = std::array::from_fn::<_, 2, _>(|axis| {
406 (uv.iter().map(|p| p[axis]).fold(f32::INFINITY, f32::min) * scale - 0.5)
407 .ceil()
408 .max(0.0) as u32
409 });
410 let max = std::array::from_fn::<_, 2, _>(|axis| {
411 (uv.iter().map(|p| p[axis]).fold(f32::NEG_INFINITY, f32::max) * scale - 0.5)
412 .floor()
413 .min(scale - 1.0) as u32
414 });
415 let min = [min[0].max(crop.x), min[1].max(crop.y)];
416 let max = [
417 max[0].min(crop.x + crop.width - 1),
418 max[1].min(crop.y + crop.height - 1),
419 ];
420 for y in min[1]..=max[1] {
421 for x in min[0]..=max[0] {
422 let at_uv = [(x as f32 + 0.5) / scale, (y as f32 + 0.5) / scale];
423 let weight = [
424 edge(uv[1], uv[2], at_uv) / area,
425 edge(uv[2], uv[0], at_uv) / area,
426 edge(uv[0], uv[1], at_uv) / area,
427 ];
428 if weight.iter().any(|&value| value < -1e-5) {
429 continue;
430 }
431 let index = ((y - crop.y) * crop.width + x - crop.x) as usize;
432 if covered[index] {
433 continue;
434 }
435 let position = mix3(ids.map(|id| input.positions[id]), weight);
436 let normal = unit(mix3(ids.map(|id| input.normals[id]), weight));
437 let tangent = ids.map(|id| input.tangents[id]);
438 let tangent_xyz = unit(mix3(tangent.map(|v| [v[0], v[1], v[2]]), weight));
439 let tangent_xyz = unit(sub(
440 tangent_xyz,
441 normal.map(|v| v * dot(normal, tangent_xyz)),
442 ));
443 let handedness: f32 = (0..3).map(|i| tangent[i][3] * weight[i]).sum();
444 let bitangent = cross(normal, tangent_xyz).map(|v| v * handedness.signum());
445 let surface_uv = match input.uvs1 {
446 Some(_) => mix2(ids.map(|id| input.uvs[id]), weight),
447 None => at_uv,
448 };
449 let resolved = resolve(
450 input.material,
451 0.0,
452 &At {
453 position,
454 uv: surface_uv,
455 normal,
456 edge: 0.0,
457 time: 0.0,
458 },
459 );
460 let mapped = [
461 dot(resolved.normal, tangent_xyz),
462 dot(resolved.normal, bitangent),
463 dot(resolved.normal, normal),
464 ];
465 set_texel(
466 &mut region.base,
467 index,
468 resolved.material.base.map(byte).map_with_alpha(),
469 );
470 let rough = byte(resolved.material.roughness);
471 set_texel(&mut region.roughness, index, [rough, rough, rough, 255]);
472 set_texel(
473 &mut region.normal,
474 index,
475 [
476 byte(mapped[0] * 0.5 + 0.5),
477 byte(mapped[1] * 0.5 + 0.5),
478 byte(mapped[2] * 0.5 + 0.5),
479 255,
480 ],
481 );
482 covered[index] = true;
483 }
484 }
485 }
486 if !covered.iter().any(|&value| value) {
487 return Err("detail mesh has no UV coverage".into());
488 }
489 pad(&mut region, &mut covered, padding);
490 Ok(region)
491}
492
493trait Alpha {
494 fn map_with_alpha(self) -> [u8; 4];
495}
496
497impl Alpha for [u8; 3] {
498 fn map_with_alpha(self) -> [u8; 4] {
499 [self[0], self[1], self[2], 255]
500 }
501}
502
503fn pad(region: &mut Region, covered: &mut [bool], padding: u32) {
504 let width = region.crop.width as usize;
505 let height = region.crop.height as usize;
506 let mut queue = VecDeque::new();
507 for (index, &filled) in covered.iter().enumerate() {
508 if filled {
509 queue.push_back((index, index, 0u32));
510 }
511 }
512 while let Some((index, source, distance)) = queue.pop_front() {
513 if distance >= padding {
514 continue;
515 }
516 let x = index % width;
517 let y = index / width;
518 for next in [
519 (x > 0).then_some(index.wrapping_sub(1)),
520 (x + 1 < width).then_some(index + 1),
521 (y > 0).then_some(index.wrapping_sub(width)),
522 (y + 1 < height).then_some(index + width),
523 ]
524 .into_iter()
525 .flatten()
526 {
527 if covered[next] {
528 continue;
529 }
530 covered[next] = true;
531 let base = texel(®ion.base, source);
532 let roughness = texel(®ion.roughness, source);
533 let normal = texel(®ion.normal, source);
534 set_texel(&mut region.base, next, base);
535 set_texel(&mut region.roughness, next, roughness);
536 set_texel(&mut region.normal, next, normal);
537 queue.push_back((next, source, distance + 1));
538 }
539 }
540}
541
542fn digest(bytes: &[u8]) -> String {
543 let hash = Sha256::digest(bytes);
544 hash.iter().map(|byte| format!("{byte:02x}")).collect()
545}
546
547pub fn input_hash(input: &Input<'_>, size: u32, padding: u32) -> Result<String, String> {
548 let mut h = Sha256::new();
549 h.update(b"pito-engine-detail-v1");
550 h.update(env!("CARGO_PKG_VERSION"));
551 h.update(include_str!("detail.rs"));
552 h.update(size.to_le_bytes());
553 h.update(padding.to_le_bytes());
554 for position in input.positions {
555 for value in position {
556 h.update(value.to_bits().to_le_bytes());
557 }
558 }
559 for normal in input.normals {
560 for value in normal {
561 h.update(value.to_bits().to_le_bytes());
562 }
563 }
564 for tangent in input.tangents {
565 for value in tangent {
566 h.update(value.to_bits().to_le_bytes());
567 }
568 }
569 for uv in input.uvs {
570 for value in uv {
571 h.update(value.to_bits().to_le_bytes());
572 }
573 }
574 if let Some(uvs1) = input.uvs1 {
575 h.update(b"uvs1");
576 for uv in uvs1 {
577 for value in uv {
578 h.update(value.to_bits().to_le_bytes());
579 }
580 }
581 }
582 for index in input.indices {
583 h.update(index.to_le_bytes());
584 }
585 h.update(serde_json::to_vec(input.material).map_err(|e| e.to_string())?);
586 Ok(h.finalize()
587 .iter()
588 .map(|byte| format!("{byte:02x}"))
589 .collect())
590}
591
592pub fn write(path: &Path, hash: &str, maps: &Maps) -> Result<(), String> {
593 if path.exists() {
594 return Err(format!("{} already exists", path.display()));
595 }
596 fs::create_dir_all(path).map_err(|e| e.to_string())?;
597 let files = [
598 ("base.rgba", &maps.base),
599 ("roughness.rgba", &maps.roughness),
600 ("normal.rgba", &maps.normal),
601 ];
602 let entries = files
603 .iter()
604 .map(|(name, data)| {
605 fs::write(path.join(name), data).map_err(|e| e.to_string())?;
606 Ok(json!({"path": name, "bytes": data.len(), "sha256": digest(data)}))
607 })
608 .collect::<Result<Vec<Value>, String>>()?;
609 let manifest = serde_json::to_vec_pretty(&json!({
610 "schema_version": 1,
611 "input_hash": hash,
612 "size": maps.size,
613 "format": "rgba8-linear",
614 "files": entries,
615 }))
616 .map_err(|e| e.to_string())?;
617 fs::write(path.join("manifest.json"), &manifest).map_err(|e| e.to_string())?;
618 fs::write(path.join("manifest.sha256"), digest(&manifest)).map_err(|e| e.to_string())?;
619 read(path)?;
620 Ok(())
621}
622
623pub fn read(path: &Path) -> Result<(String, Maps), String> {
624 let manifest = fs::read(path.join("manifest.json")).map_err(|e| e.to_string())?;
625 let expected = fs::read_to_string(path.join("manifest.sha256")).map_err(|e| e.to_string())?;
626 if expected != digest(&manifest) {
627 return Err("detail manifest checksum differs".into());
628 }
629 let value: Value = serde_json::from_slice(&manifest).map_err(|e| e.to_string())?;
630 if value["schema_version"] != 1 || value["format"] != "rgba8-linear" {
631 return Err("unsupported detail artifact".into());
632 }
633 let hash = value["input_hash"]
634 .as_str()
635 .ok_or("detail input hash is missing")?;
636 let size = value["size"].as_u64().ok_or("detail size is missing")?;
637 let size = u32::try_from(size).map_err(|_| "detail size is invalid")?;
638 if size == 0 || size > LIMIT || !size.is_power_of_two() {
639 return Err("detail size is invalid".into());
640 }
641 let entries = value["files"]
642 .as_array()
643 .ok_or("detail files are missing")?;
644 if entries.len() != 3 {
645 return Err("detail needs three maps".into());
646 }
647 let mut maps = Vec::new();
648 for (entry, name) in entries
649 .iter()
650 .zip(["base.rgba", "roughness.rgba", "normal.rgba"])
651 {
652 if entry["path"] != name {
653 return Err("detail map name differs".into());
654 }
655 let bytes = fs::read(path.join(name)).map_err(|e| e.to_string())?;
656 if bytes.len() != (size * size * 4) as usize
657 || entry["bytes"].as_u64() != Some(bytes.len() as u64)
658 || entry["sha256"].as_str() != Some(digest(&bytes).as_str())
659 {
660 return Err(format!("detail {name} checksum differs"));
661 }
662 maps.push(bytes);
663 }
664 Ok((
665 hash.to_owned(),
666 Maps {
667 size,
668 base: maps.remove(0),
669 roughness: maps.remove(0),
670 normal: maps.remove(0),
671 },
672 ))
673}
674
675struct Candidate<'a> {
676 index: u32,
677 node: String,
678 geometry: pfx_geom::mesh::Mesh,
679 uvs1: Option<Vec<[f32; 2]>>,
680 material: &'a Material,
681 size: u32,
682 hash: String,
683}
684
685impl Candidate<'_> {
686 fn input(&self) -> Input<'_> {
687 Input {
688 positions: &self.geometry.positions,
689 normals: &self.geometry.normals,
690 tangents: &self.geometry.tangents,
691 uvs: &self.geometry.uvs,
692 uvs1: self.uvs1.as_deref(),
693 indices: &self.geometry.indices,
694 material: self.material,
695 }
696 }
697}
698
699pub fn report(atlas: &atlas::Atlas, summary: &atlas::Summary) -> Report {
700 let parts: Vec<Part> = atlas
701 .parts
702 .iter()
703 .map(|part| Part {
704 path: format!("part-{:04}", part.index),
705 node: part.node.clone(),
706 size: part.density,
707 crop: part.crop,
708 page: part.page,
709 bytes: atlas.part_bytes(part),
710 input_hash: part.input_hash.clone(),
711 uv_set: part.uv_set,
712 })
713 .collect();
714 Report {
715 full_bytes: parts.iter().map(|part| map_bytes(part.size)).sum(),
716 parts,
717 total_bytes: summary.total_bytes,
718 vram_bytes: summary.vram_bytes,
719 decoded_vram_bytes: atlas.decoded_vram_bytes(),
720 page: [atlas.width, atlas.height],
721 pages: atlas.pages(),
722 psnr: summary.psnr,
723 }
724}
725
726fn replaceable(path: &Path) -> Result<(), String> {
727 let mut files = vec!["manifest.json".to_string(), "manifest.sha256".to_string()];
728 let mut folders = Vec::new();
729 if let Ok((_, atlas)) = atlas::read(path) {
730 files.extend(atlas::file_names(atlas.pages() as usize));
731 } else {
732 atlas::load(path)?;
733 let manifest: Value = serde_json::from_slice(
734 &fs::read(path.join("manifest.json")).map_err(|e| e.to_string())?,
735 )
736 .map_err(|e| e.to_string())?;
737 for part in manifest["parts"].as_array().into_iter().flatten() {
738 folders.push(part["path"].as_str().unwrap_or_default().to_owned());
739 }
740 }
741 let part_files = [
742 "manifest.json",
743 "manifest.sha256",
744 "base.rgba",
745 "roughness.rgba",
746 "normal.rgba",
747 ];
748 for entry in fs::read_dir(path).map_err(|e| e.to_string())? {
749 let entry = entry.map_err(|e| e.to_string())?;
750 let name = entry.file_name().to_string_lossy().into_owned();
751 let kind = entry.file_type().map_err(|e| e.to_string())?;
752 let expected = if kind.is_file() {
753 files.contains(&name)
754 } else if kind.is_dir() && folders.contains(&name) {
755 fs::read_dir(entry.path())
756 .map_err(|e| e.to_string())?
757 .all(|inner| {
758 inner.is_ok_and(|inner| {
759 part_files.contains(&inner.file_name().to_string_lossy().as_ref())
760 && inner.file_type().is_ok_and(|kind| kind.is_file())
761 })
762 })
763 } else {
764 false
765 };
766 if !expected {
767 return Err(format!("{} contains non-artifact files", path.display()));
768 }
769 }
770 Ok(())
771}
772
773fn mesh_node_name<'a>(mesh: &'a pfx_load::Mesh, node: &'a pfx_load::Node) -> Option<&'a str> {
774 let group = node.group?;
775 Some(if node.name.is_empty() {
776 mesh.groups[group as usize].name.as_str()
777 } else {
778 node.name.as_str()
779 })
780}
781
782pub fn bake_scene(
783 folder: &Path,
784 scene_file: &str,
785 materials: &[Material],
786 recipe: &crate::recipe::DetailRecipe,
787 out: &Path,
788 force: bool,
789) -> Result<Report, String> {
790 use pfx_geom::mesh::Mesh as Geometry;
791 use pfx_load::Mesh;
792
793 recipe.check()?;
794 let view = recipe.view.as_ref().map(View::look);
795 let root = folder.canonicalize().map_err(|e| e.to_string())?;
796 let bytes = crate::recipe::read_inside(&root, scene_file)?;
797 let animated: std::collections::BTreeSet<usize> = gltf::Gltf::from_slice(&bytes)
798 .map_err(|e| e.to_string())?
799 .animations()
800 .flat_map(|animation| {
801 animation
802 .channels()
803 .map(|channel| channel.target().node().index())
804 })
805 .collect();
806 let mesh = Mesh::parse_with(&bytes, |uri| {
807 let base = Path::new(scene_file).parent().unwrap_or(Path::new(""));
808 let name = base.join(uri).to_string_lossy().into_owned();
809 crate::recipe::read_inside(&root, &name)
810 })?;
811 if let Some(missing) = recipe.sizes.keys().find(|name| {
812 !mesh
813 .nodes
814 .iter()
815 .any(|node| mesh_node_name(&mesh, node) == Some(name.as_str()))
816 }) {
817 return Err(format!(
818 "detail size names {missing:?}, which is not a mesh node"
819 ));
820 }
821 if let Some(missing) = recipe.parts.iter().find(|name| {
822 !mesh
823 .nodes
824 .iter()
825 .any(|node| mesh_node_name(&mesh, node) == Some(name.as_str()))
826 }) {
827 return Err(format!(
828 "detail parts names {missing:?}, which is not a mesh node"
829 ));
830 }
831 let mut candidates = Vec::new();
832 let mut stack: Vec<u32> = mesh.roots.iter().rev().copied().collect();
833 let mut part_index = 0u32;
834 while let Some(index) = stack.pop() {
835 let node = mesh
836 .nodes
837 .get(index as usize)
838 .ok_or("detail node is missing")?;
839 stack.extend(node.children.iter().rev().copied());
840 let Some(group_index) = node.group else {
841 continue;
842 };
843 let group = &mesh.groups[group_index as usize];
844 let name = if node.name.is_empty() {
845 &group.name
846 } else {
847 &node.name
848 };
849 let world = mesh.world(index).ok_or("invalid detail node transform")?;
850 for primitive in &group.primitives {
851 let this_part = part_index;
852 part_index += 1;
853 let mut ancestor = Some(index);
854 let mut animated_part = false;
855 while let Some(node_index) = ancestor {
856 if animated.contains(&(node_index as usize)) {
857 animated_part = true;
858 break;
859 }
860 ancestor = mesh.nodes[node_index as usize].parent;
861 }
862 if animated_part || recipe.dynamic_nodes.iter().any(|dynamic| dynamic == name) {
863 println!("detail bake: {name} keeps procedural detail because it moves");
864 continue;
865 }
866 if !recipe.parts.is_empty() && recipe.parts.iter().all(|part| part != name) {
867 continue;
868 }
869 let material = materials
870 .get(primitive.material.unwrap_or(materials.len() as u32 - 1) as usize)
871 .ok_or("detail material is missing")?;
872 if !material.layers.iter().any(|layer| layer.amplitude != 0.0) {
873 continue;
874 }
875 let positions: Vec<[f32; 3]> = primitive
876 .positions
877 .iter()
878 .map(|point| {
879 std::array::from_fn(|axis| {
880 world[3][axis] + (0..3).map(|k| world[k][axis] * point[k]).sum::<f32>()
881 })
882 })
883 .collect();
884 let a = |row: usize, column: usize| world[column][row];
885 let cofactor = |row: usize, column: usize| {
886 let (r0, r1) = ((row + 1) % 3, (row + 2) % 3);
887 let (c0, c1) = ((column + 1) % 3, (column + 2) % 3);
888 a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
889 };
890 let determinant: f32 = (0..3)
891 .map(|column| a(0, column) * cofactor(0, column))
892 .sum();
893 let sign = if determinant < 0.0 { -1.0 } else { 1.0 };
894 let normals: Vec<[f32; 3]> = primitive
895 .normals
896 .iter()
897 .map(|normal| {
898 let transformed: [f32; 3] = std::array::from_fn(|row| {
899 (0..3)
900 .map(|column| cofactor(row, column) * normal[column] * sign)
901 .sum()
902 });
903 let length = transformed.iter().map(|v| v * v).sum::<f32>().sqrt();
904 transformed.map(|v| v / length.max(1e-20))
905 })
906 .collect();
907 let normals = if normals.is_empty() {
908 let mut flat = vec![[0.0; 3]; positions.len()];
909 for triangle in primitive.indices.chunks_exact(3) {
910 let [a, b, c] =
911 [triangle[0], triangle[1], triangle[2]].map(|id| positions[id as usize]);
912 let face = cross(sub(b, a), sub(c, a));
913 for &id in triangle {
914 for axis in 0..3 {
915 flat[id as usize][axis] += face[axis];
916 }
917 }
918 }
919 flat.into_iter().map(unit).collect()
920 } else {
921 normals
922 };
923 let geometry = Geometry::new(
924 positions,
925 normals,
926 Vec::new(),
927 primitive.uvs.clone(),
928 primitive.indices.clone(),
929 );
930 let mut candidate = Candidate {
931 index: this_part,
932 node: name.to_owned(),
933 geometry,
934 uvs1: primitive.uvs1.clone(),
935 material,
936 size: 0,
937 hash: String::new(),
938 };
939 let input = candidate.input();
940 let size = match (recipe.sizes.get(name), &view) {
941 (Some(&size), _) => size,
942 (None, Some(view)) => resolution(&input, view, recipe.size),
943 (None, None) => recipe.size,
944 };
945 if recipe.padding > size / 4 {
946 return Err(format!(
947 "detail padding {} is more than a quarter of {name}'s {size}² map",
948 recipe.padding
949 ));
950 }
951 if repeats(&input, size) {
952 println!(
953 "detail bake: {name} keeps procedural detail because its UVs repeat or are missing"
954 );
955 continue;
956 }
957 if input.uvs1.is_some() {
958 println!("detail bake: {name} bakes through TEXCOORD_1");
959 }
960 let hash = input_hash(&input, size, recipe.padding)?;
961 candidate.size = size;
962 candidate.hash = hash;
963 candidates.push(candidate);
964 }
965 }
966 if candidates.is_empty() {
967 println!("detail bake: no static part with unique UVs has procedural detail to bake");
968 return Ok(Report {
969 parts: Vec::new(),
970 total_bytes: 0,
971 vram_bytes: 0,
972 decoded_vram_bytes: 0,
973 full_bytes: 0,
974 page: [0, 0],
975 pages: 0,
976 psnr: [None; 3],
977 });
978 }
979 let keys: Vec<(u32, &str, &str)> = candidates
980 .iter()
981 .map(|candidate| {
982 (
983 candidate.index,
984 candidate.node.as_str(),
985 candidate.hash.as_str(),
986 )
987 })
988 .collect();
989 let hash = atlas::hash(&keys, recipe.page, recipe.padding);
990 if out.exists() {
991 if let Ok((summary, saved)) = atlas::read(out)
992 && summary.input_hash == hash
993 {
994 println!(
995 "detail bake: {} unchanged, {} parts on {} pages",
996 out.display(),
997 saved.parts.len(),
998 saved.pages()
999 );
1000 return Ok(report(&saved, &summary));
1001 }
1002 if !force {
1003 return Err(format!(
1004 "{} already exists; use --force to replace it",
1005 out.display()
1006 ));
1007 }
1008 replaceable(out)?;
1009 }
1010 let mut pieces = Vec::with_capacity(candidates.len());
1011 for candidate in &candidates {
1012 let input = candidate.input();
1013 let crop = crop(&input, candidate.size, recipe.padding)?;
1014 let region = bake_crop(&input, candidate.size, recipe.padding, crop)?;
1015 println!(
1016 "detail bake: {} part-{:04} {}² crop {}×{} at ({}, {}) through TEXCOORD_{}",
1017 candidate.node,
1018 candidate.index,
1019 candidate.size,
1020 crop.width,
1021 crop.height,
1022 crop.x,
1023 crop.y,
1024 input.uv_set()
1025 );
1026 pieces.push(atlas::Piece {
1027 index: candidate.index,
1028 node: candidate.node.clone(),
1029 input_hash: candidate.hash.clone(),
1030 region,
1031 });
1032 }
1033 let plain = atlas::assemble(pieces, recipe.page)?;
1034 let packed = plain.compress()?;
1035 let psnr = atlas::Kind::ALL.map(|kind| atlas::psnr(&plain, &packed, kind));
1036 let name = out
1037 .file_name()
1038 .ok_or("detail output needs a folder name")?
1039 .to_string_lossy()
1040 .into_owned();
1041 let parent = out.parent().unwrap_or(Path::new(""));
1042 let next = parent.join(format!(".{name}.next"));
1043 let old = parent.join(format!(".{name}.old"));
1044 for path in [&next, &old] {
1045 if path.exists() {
1046 return Err(format!("{} already exists", path.display()));
1047 }
1048 }
1049 let summary = atlas::write(&next, &packed, &hash, recipe.padding, psnr)?;
1050 atlas::read(&next)?;
1051 if out.exists() {
1052 fs::rename(out, &old).map_err(|e| e.to_string())?;
1053 if let Err(error) = fs::rename(&next, out) {
1054 let _ = fs::rename(&old, out);
1055 return Err(error.to_string());
1056 }
1057 fs::remove_dir_all(&old).map_err(|e| e.to_string())?;
1058 } else {
1059 fs::rename(&next, out).map_err(|e| e.to_string())?;
1060 }
1061 println!(
1062 "detail bake: {} parts on {} pages of {}×{}",
1063 packed.parts.len(),
1064 packed.pages(),
1065 packed.width,
1066 packed.height
1067 );
1068 Ok(report(&packed, &summary))
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074 use pfx_materials::{NoiseKind, NoiseLayer};
1075
1076 fn mesh<'a>(material: &'a Material, uvs: &'a [[f32; 2]; 3]) -> Input<'a> {
1077 static POSITIONS: [[f32; 3]; 3] = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1078 static NORMALS: [[f32; 3]; 3] = [[0.0, 0.0, 1.0]; 3];
1079 static TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3];
1080 static INDICES: [u32; 3] = [0, 1, 2];
1081 Input {
1082 positions: &POSITIONS,
1083 normals: &NORMALS,
1084 tangents: &TANGENTS,
1085 uvs,
1086 uvs1: None,
1087 indices: &INDICES,
1088 material,
1089 }
1090 }
1091
1092 #[test]
1093 fn baked_texel_matches_procedural_evaluation() {
1094 let mut material = Material::default();
1095 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 8.0, 0.7, 19);
1096 let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1097 let input = mesh(&material, &uvs);
1098 let maps = bake(&input, 8, 0).unwrap();
1099 let uv = [1.5 / 8.0, 1.5 / 8.0];
1100 let resolved = resolve(
1101 &material,
1102 0.0,
1103 &At {
1104 position: [uv[0], uv[1], 0.0],
1105 uv,
1106 normal: [0.0, 0.0, 1.0],
1107 edge: 0.0,
1108 time: 0.0,
1109 },
1110 );
1111 assert_eq!(texel(&maps.base, 9)[..3], resolved.material.base.map(byte));
1112 assert_eq!(
1113 texel(&maps.roughness, 9)[0],
1114 byte(resolved.material.roughness)
1115 );
1116 }
1117
1118 #[test]
1119 fn padding_extends_covered_texels_without_crossing_the_limit() {
1120 let material = Material::default();
1121 let uvs = [[0.125, 0.125], [0.375, 0.125], [0.125, 0.375]];
1122 let maps = bake(&mesh(&material, &uvs), 8, 1).unwrap();
1123 assert_eq!(texel(&maps.base, 8), texel(&maps.base, 9));
1124 assert_eq!(texel(&maps.normal, 8), texel(&maps.normal, 9));
1125 assert_eq!(texel(&maps.base, 63), [0; 4]);
1126 }
1127
1128 #[test]
1129 fn overlapping_uv_islands_remain_procedural() {
1130 let material = Material::default();
1131 let positions = [[0.0, 0.0, 0.0]; 6];
1132 let normals = [[0.0, 0.0, 1.0]; 6];
1133 let tangents = [[1.0, 0.0, 0.0, 1.0]; 6];
1134 let uvs = [
1135 [0.0, 0.0],
1136 [1.0, 0.0],
1137 [0.0, 1.0],
1138 [0.0, 0.0],
1139 [1.0, 0.0],
1140 [0.0, 1.0],
1141 ];
1142 let input = Input {
1143 positions: &positions,
1144 normals: &normals,
1145 tangents: &tangents,
1146 uvs: &uvs,
1147 uvs1: None,
1148 indices: &[0, 1, 2, 3, 4, 5],
1149 material: &material,
1150 };
1151 assert!(repeats(&input, 16));
1152 }
1153
1154 #[test]
1155 fn scene_bake_writes_checked_maps_and_skips_repeated_uvs() {
1156 use pfx_materials::{NoiseKind, NoiseLayer};
1157
1158 let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1159 let root = Path::new("tmp").join(format!("detail-scene-test-{}", std::process::id()));
1160 if root.exists() {
1161 fs::remove_dir_all(&root).unwrap();
1162 }
1163 fs::create_dir_all(&root).unwrap();
1164 fs::copy(source.join("triangle.gltf"), root.join("triangle.gltf")).unwrap();
1165 fs::copy(source.join("triangle.bin"), root.join("triangle.bin")).unwrap();
1166 let mut material = Material::default();
1167 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1168 let recipe = crate::recipe::DetailRecipe {
1169 size: 64,
1170 padding: 2,
1171 dynamic_nodes: Vec::new(),
1172 parts: Vec::new(),
1173 sizes: Default::default(),
1174 view: None,
1175 page: atlas::PAGE,
1176 };
1177 let out = root.join("out");
1178 let report = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1179 assert_eq!(report.parts.len(), 1);
1180 assert_eq!(report.full_bytes, 3 * 4 * 64 * 64);
1181 assert_eq!(report.page, [64, 64]);
1182 assert_eq!(report.pages, 1);
1183 let levels: u64 = (0..atlas::LEVELS)
1184 .map(|level| (64u64 >> level).pow(2))
1185 .sum();
1186 assert_eq!(report.vram_bytes, levels * 5 / 2);
1187 let first = atlas::read(&out).unwrap();
1188 assert_eq!(
1189 first.1.parts[0].crop,
1190 Crop {
1191 x: 0,
1192 y: 0,
1193 width: 64,
1194 height: 64
1195 }
1196 );
1197 assert!(first.1.compressed());
1198 assert_eq!(first.1.parts[0].uv_set, 0);
1199 assert_eq!(report.parts[0].uv_set, 0);
1200 assert_eq!(first.1.schema(), atlas::SCHEMA);
1201 let again = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1202 assert_eq!(again, report);
1203 assert_eq!(first, atlas::read(&out).unwrap());
1204 let mut bin = fs::read(root.join("triangle.bin")).unwrap();
1205 bin[120..124].copy_from_slice(&2.0f32.to_le_bytes());
1206 fs::write(root.join("triangle.bin"), bin).unwrap();
1207 let none = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1208 assert!(none.parts.is_empty());
1209 assert_eq!(first, atlas::read(&out).unwrap());
1210 fs::remove_dir_all(root).unwrap();
1211 }
1212
1213 const SECOND: [[f32; 2]; 3] = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75]];
1214
1215 #[test]
1216 fn a_part_with_a_second_uv_set_bakes_through_it() {
1217 let mut material = Material::default();
1218 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 8.0, 0.7, 19);
1219 let tiled = [[-3.0, 0.0], [5.0, 0.0], [-3.0, 8.0]];
1220 let first = mesh(&material, &tiled);
1221 let second = Input {
1222 uvs1: Some(&SECOND),
1223 ..mesh(&material, &tiled)
1224 };
1225 assert!(repeats(&first, 64));
1226 assert!(!repeats(&second, 64));
1227 assert_eq!((first.uv_set(), second.uv_set()), (0, 1));
1228 assert_eq!(second.raster(), &SECOND[..]);
1229 let alone = mesh(&material, &SECOND);
1230 assert_eq!(
1231 crop(&second, 256, 2).unwrap(),
1232 crop(&alone, 256, 2).unwrap()
1233 );
1234 assert_eq!(
1235 crop(&second, 256, 2).unwrap(),
1236 Crop {
1237 x: 48,
1238 y: 48,
1239 width: 160,
1240 height: 160
1241 }
1242 );
1243 assert_ne!(
1244 input_hash(&second, 64, 2).unwrap(),
1245 input_hash(&first, 64, 2).unwrap()
1246 );
1247 let whole = Crop {
1248 x: 0,
1249 y: 0,
1250 width: 64,
1251 height: 64,
1252 };
1253 let region = bake_crop(&second, 64, 0, whole).unwrap();
1254 assert_eq!(region.uv_set, 1);
1255 assert_eq!(bake_crop(&alone, 64, 0, whole).unwrap().uv_set, 0);
1256 let (x, y) = (25u32, 20u32);
1257 let at = [(x as f32 + 0.5) / 64.0, (y as f32 + 0.5) / 64.0];
1258 let area = edge(SECOND[0], SECOND[1], SECOND[2]);
1259 let weight = [
1260 edge(SECOND[1], SECOND[2], at) / area,
1261 edge(SECOND[2], SECOND[0], at) / area,
1262 edge(SECOND[0], SECOND[1], at) / area,
1263 ];
1264 assert!(weight.iter().all(|&w| w > 0.0));
1265 let resolved = resolve(
1266 &material,
1267 0.0,
1268 &At {
1269 position: mix3([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], weight),
1270 uv: mix2(tiled, weight),
1271 normal: [0.0, 0.0, 1.0],
1272 edge: 0.0,
1273 time: 0.0,
1274 },
1275 );
1276 let index = (y * 64 + x) as usize;
1277 assert_eq!(
1278 texel(®ion.base, index)[..3],
1279 resolved.material.base.map(byte)
1280 );
1281 assert_eq!(
1282 texel(®ion.roughness, index)[0],
1283 byte(resolved.material.roughness)
1284 );
1285 assert_eq!(texel(®ion.base, 63 * 64 + 63), [0; 4]);
1286 }
1287
1288 fn with_second_uvs(bin: &[u8], json: &str) -> (Vec<u8>, String) {
1289 let mut bin = bin.to_vec();
1290 while !bin.len().is_multiple_of(4) {
1291 bin.push(0);
1292 }
1293 let offset = bin.len();
1294 for value in SECOND.iter().flatten() {
1295 bin.extend_from_slice(&value.to_le_bytes());
1296 }
1297 let json = json
1298 .replace("\"byteLength\":150}]", &format!("\"byteLength\":{}}}]", bin.len()))
1299 .replace("\"TEXCOORD_0\":3}", "\"TEXCOORD_0\":3,\"TEXCOORD_1\":5}")
1300 .replace(
1301 "\"type\":\"SCALAR\"}]",
1302 "\"type\":\"SCALAR\"},{\"bufferView\":5,\"componentType\":5126,\"count\":3,\"type\":\"VEC2\"}]",
1303 )
1304 .replace(
1305 "\"byteLength\":6}]",
1306 &format!("\"byteLength\":6}},{{\"buffer\":0,\"byteOffset\":{offset},\"byteLength\":24}}]"),
1307 );
1308 (bin, json)
1309 }
1310
1311 #[test]
1312 fn a_scene_part_with_texcoord_1_bakes_through_it_and_the_manifest_says_so() {
1313 let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1314 let root = Path::new("tmp").join(format!("detail-uv1-test-{}", std::process::id()));
1315 if root.exists() {
1316 fs::remove_dir_all(&root).unwrap();
1317 }
1318 fs::create_dir_all(&root).unwrap();
1319 let (bin, json) = with_second_uvs(
1320 &fs::read(source.join("triangle.bin")).unwrap(),
1321 &fs::read_to_string(source.join("triangle.gltf")).unwrap(),
1322 );
1323 assert!(json.contains("TEXCOORD_1") && json.contains(&format!("{}", bin.len())));
1324 fs::write(root.join("triangle.bin"), &bin).unwrap();
1325 fs::write(root.join("triangle.gltf"), &json).unwrap();
1326 let mut material = Material::default();
1327 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1328 let recipe = crate::recipe::DetailRecipe {
1329 size: 256,
1330 padding: 2,
1331 dynamic_nodes: Vec::new(),
1332 parts: Vec::new(),
1333 sizes: Default::default(),
1334 view: None,
1335 page: atlas::PAGE,
1336 };
1337 let out = root.join("out");
1338 let report = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1339 assert_eq!(report.parts.len(), 1);
1340 assert_eq!(report.parts[0].uv_set, 1);
1341 assert_eq!(
1342 report.parts[0].crop,
1343 Crop {
1344 x: 48,
1345 y: 48,
1346 width: 160,
1347 height: 160
1348 }
1349 );
1350 let (_, atlas) = atlas::read(&out).unwrap();
1351 assert_eq!(atlas.parts[0].uv_set, 1);
1352 assert_eq!(atlas.schema(), atlas::SCHEMA_UV1);
1353 let manifest: Value =
1354 serde_json::from_slice(&fs::read(out.join("manifest.json")).unwrap()).unwrap();
1355 assert_eq!(manifest["schema_version"], atlas::SCHEMA_UV1);
1356 assert_eq!(manifest["parts"][0]["uv_set"], 1);
1357 fs::remove_dir_all(root).unwrap();
1358 }
1359
1360 fn camera(distance: f32) -> crate::recipe::DetailView {
1361 facing([1.5, 2.5, 3.0], distance)
1362 }
1363
1364 fn facing(target: [f32; 3], distance: f32) -> crate::recipe::DetailView {
1365 crate::recipe::DetailView {
1366 position: [target[0], target[1], target[2] + distance],
1367 target,
1368 fov_y_deg: 60.0,
1369 shift: [0.0, 0.0],
1370 width: 1000,
1371 height: 1000,
1372 }
1373 }
1374
1375 #[test]
1376 fn the_view_projects_the_target_to_the_middle_and_the_shift_moves_it() {
1377 let mut look = facing([0.5, 0.5, 0.0], 2.0);
1378 let view = View::look(&look);
1379 let [x, y] = project(&view, look.target).unwrap();
1380 assert!(
1381 (x - 500.0).abs() < 1e-2 && (y - 500.0).abs() < 1e-2,
1382 "{x} {y}"
1383 );
1384 look.shift = [0.2, -0.5];
1385 let [x, y] = project(&View::look(&look), look.target).unwrap();
1386 assert!(
1387 (x - 400.0).abs() < 1e-2 && (y - 250.0).abs() < 1e-2,
1388 "{x} {y}"
1389 );
1390 assert!(project(&view, [0.5, 0.5, 4.0]).is_none());
1391 }
1392
1393 #[test]
1394 fn the_footprint_picks_a_power_of_two_between_the_floor_and_the_largest() {
1395 let material = Material::default();
1396 let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1397 let input = mesh(&material, &uvs);
1398 let at = [0.5, 0.5, 0.0];
1399 let near = View::look(&facing(at, 0.5));
1400 let closest = View::look(&facing(at, 0.2));
1401 let far = View::look(&facing(at, 200.0));
1402 let middle = View::look(&facing(at, 5.0));
1403 assert_eq!(resolution(&input, &near, 2048), 2048);
1404 assert_eq!(resolution(&input, &near, 256), 256);
1405 assert_eq!(resolution(&input, &far, 2048), FLOOR);
1406 let fit = resolution(&input, &middle, 4096);
1407 assert!(
1408 fit.is_power_of_two() && (FLOOR..4096).contains(&fit),
1409 "{fit}"
1410 );
1411 assert!(fit > resolution(&input, &far, 4096));
1412 assert_eq!(resolution(&input, &near, 4096), 2048);
1413 assert_eq!(resolution(&input, &closest, 4096), 4096);
1414 assert_eq!(resolution(&input, &closest, 99999), 4096);
1415 }
1416
1417 #[test]
1418 fn each_part_takes_its_footprint_unless_a_node_is_named() {
1419 use pfx_materials::{NoiseKind, NoiseLayer};
1420
1421 let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1422 let root = Path::new("tmp").join(format!("detail-sizes-test-{}", std::process::id()));
1423 if root.exists() {
1424 fs::remove_dir_all(&root).unwrap();
1425 }
1426 fs::create_dir_all(&root).unwrap();
1427 fs::copy(source.join("triangle.gltf"), root.join("triangle.gltf")).unwrap();
1428 fs::copy(source.join("triangle.bin"), root.join("triangle.bin")).unwrap();
1429 let mut material = Material::default();
1430 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1431 let mut recipe = crate::recipe::DetailRecipe {
1432 size: 256,
1433 padding: 2,
1434 dynamic_nodes: Vec::new(),
1435 parts: Vec::new(),
1436 sizes: Default::default(),
1437 view: Some(camera(200.0)),
1438 page: atlas::PAGE,
1439 };
1440 let out = root.join("out");
1441 let far = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1442 assert_eq!(far.parts.len(), 1);
1443 assert_eq!(far.parts[0].size, FLOOR);
1444 assert_eq!(far.parts[0].node, "triangle");
1445 assert_eq!(far.full_bytes, map_bytes(FLOOR));
1446 let manifest = fs::read(out.join("manifest.json")).unwrap();
1447 assert_eq!(
1448 fs::read_to_string(out.join("manifest.sha256")).unwrap(),
1449 digest(&manifest)
1450 );
1451 let value: Value = serde_json::from_slice(&manifest).unwrap();
1452 assert_eq!(value["schema_version"], 2);
1453 assert_eq!(value["total_bytes"], far.total_bytes);
1454 assert_eq!(value["parts"][0]["size"], FLOOR);
1455 assert_eq!(value["parts"][0]["node"], "triangle");
1456 assert_eq!(value["maps"]["base"], "bc7-srgb");
1457
1458 recipe.sizes.insert("triangle".into(), 128);
1459 assert!(
1460 bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false)
1461 .unwrap_err()
1462 .contains("already exists")
1463 );
1464 let forced = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).unwrap();
1465 assert_eq!(forced.parts[0].size, 128);
1466 assert_eq!(forced.full_bytes, map_bytes(128));
1467 assert_eq!(atlas::read(&out).unwrap().1.parts[0].density, 128);
1468
1469 recipe.view = None;
1470 recipe.sizes.clear();
1471 let flat = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).unwrap();
1472 assert_eq!(flat.parts[0].size, 256);
1473
1474 recipe.sizes.insert("missing".into(), 128);
1475 assert!(
1476 bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true)
1477 .unwrap_err()
1478 .contains("not a mesh node")
1479 );
1480 recipe.sizes.clear();
1481 recipe.padding = 40;
1482 recipe.view = Some(camera(200.0));
1483 recipe.size = 256;
1484 assert!(
1485 bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true)
1486 .unwrap_err()
1487 .contains("padding")
1488 );
1489 recipe.padding = 2;
1490 recipe.view = None;
1491 recipe.sizes.insert("triangle".into(), 100);
1492 assert!(bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).is_err());
1493 recipe.sizes.insert("triangle".into(), 8192);
1494 assert!(bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).is_err());
1495 fs::remove_dir_all(root).unwrap();
1496 }
1497
1498 #[test]
1499 fn the_recipe_reads_a_view_and_sizes_and_refuses_bad_ones() {
1500 let parsed: crate::recipe::DetailRecipe = toml::from_str(
1501 "size = 2048\n[sizes]\n\"mv_knob\" = 256\n[view]\nposition = [0.0, 0.1, 1.6]\ntarget = [0.0, 0.1, 0.0]\nfov_y_deg = 13.4\nshift = [0.0, -0.64]\n",
1502 )
1503 .unwrap();
1504 parsed.check().unwrap();
1505 assert_eq!(parsed.sizes["mv_knob"], 256);
1506 let view = parsed.view.unwrap();
1507 assert_eq!((view.width, view.height), (3840, 2160));
1508 let bad = |text: &str| {
1509 toml::from_str::<crate::recipe::DetailRecipe>(text)
1510 .unwrap()
1511 .check()
1512 .is_err()
1513 };
1514 assert!(bad("size = 32"));
1515 assert!(bad("size = 8192"));
1516 assert!(bad("size = 1000"));
1517 assert!(bad("size = 1024\n[sizes]\nknob = 48"));
1518 assert!(bad("size = 1024\n[sizes]\nknob = 8192"));
1519 assert!(bad(
1520 "size = 1024\n[view]\nposition = [0.0, 0.0, 1.0]\ntarget = [0.0, 0.0, 1.0]\nfov_y_deg = 30.0"
1521 ));
1522 assert!(bad(
1523 "size = 1024\n[view]\nposition = [0.0, 0.0, 1.0]\ntarget = [0.0, 0.0, 0.0]\nfov_y_deg = 0.0"
1524 ));
1525 assert!(toml::from_str::<crate::recipe::DetailRecipe>("size = 64\nsizess = 1").is_err());
1526 let listed: crate::recipe::DetailRecipe =
1527 toml::from_str("size = 64\nparts = [\"knob\", \"lip\"]\n").unwrap();
1528 assert_eq!(listed.parts, ["knob", "lip"]);
1529 listed.check().unwrap();
1530 assert!(parsed.parts.is_empty());
1531 assert!(
1532 toml::from_str::<crate::recipe::DetailRecipe>("size = 64\nparts = [\"\"]\n")
1533 .unwrap()
1534 .check()
1535 .unwrap_err()
1536 .contains("parts")
1537 );
1538 }
1539
1540 fn folder_bytes(path: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
1541 let mut files = std::collections::BTreeMap::new();
1542 for entry in fs::read_dir(path).unwrap() {
1543 let entry = entry.unwrap();
1544 if entry.file_type().unwrap().is_file() {
1545 let name = entry.file_name().into_string().unwrap();
1546 files.insert(name, fs::read(entry.path()).unwrap());
1547 }
1548 }
1549 files
1550 }
1551
1552 #[test]
1553 fn parts_bakes_exactly_the_named_nodes_and_matches_skipping_the_rest() {
1554 use pfx_materials::{NoiseKind, NoiseLayer};
1555
1556 let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1557 let root = Path::new("tmp").join(format!("detail-parts-test-{}", std::process::id()));
1558 if root.exists() {
1559 fs::remove_dir_all(&root).unwrap();
1560 }
1561 fs::create_dir_all(&root).unwrap();
1562 fs::copy(source.join("triangle.bin"), root.join("triangle.bin")).unwrap();
1563 let json = fs::read_to_string(source.join("triangle.gltf"))
1564 .unwrap()
1565 .replace(r#""children":[1]"#, r#""children":[1,2]"#)
1566 .replace(
1567 r#"{"name":"triangle","mesh":0,"translation":[1,2,3]}]"#,
1568 r#"{"name":"triangle","mesh":0,"translation":[1,2,3]},{"name":"wedge","mesh":0,"translation":[4,0,0]}]"#,
1569 );
1570 assert!(json.contains("wedge") && json.contains("[1,2]"));
1571 fs::write(root.join("triangle.gltf"), json).unwrap();
1572 let mut material = Material::default();
1573 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1574 let mut recipe = crate::recipe::DetailRecipe {
1575 size: 64,
1576 padding: 2,
1577 dynamic_nodes: Vec::new(),
1578 parts: Vec::new(),
1579 sizes: Default::default(),
1580 view: None,
1581 page: 64,
1582 };
1583 let bake = |recipe: &crate::recipe::DetailRecipe, name: &str| {
1584 let out = root.join(name);
1585 let report =
1586 bake_scene(&root, "triangle.gltf", &[material], recipe, &out, false).unwrap();
1587 (report, folder_bytes(&out))
1588 };
1589 recipe.parts = vec!["triangle".into()];
1590 let (named, named_bytes) = bake(&recipe, "named");
1591 recipe.parts.clear();
1592 recipe.dynamic_nodes = vec!["wedge".into()];
1593 let (_, skipped_bytes) = bake(&recipe, "skipped");
1594 assert_eq!(named.parts.len(), 1);
1595 assert_eq!(named.parts[0].node, "triangle");
1596 assert_eq!(named_bytes, skipped_bytes);
1597 assert_eq!(
1598 atlas::read(&root.join("named")).unwrap().1.parts[0].index,
1599 0
1600 );
1601
1602 recipe.dynamic_nodes.clear();
1603 recipe.parts = vec!["wedge".into()];
1604 let (wedge, wedge_bytes) = bake(&recipe, "wedge");
1605 recipe.parts.clear();
1606 recipe.dynamic_nodes = vec!["triangle".into()];
1607 let (_, triangle_skipped) = bake(&recipe, "triangle-skipped");
1608 assert_eq!(wedge.parts.len(), 1);
1609 assert_eq!(wedge.parts[0].node, "wedge");
1610 assert_eq!(wedge_bytes, triangle_skipped);
1611 assert_eq!(
1612 atlas::read(&root.join("wedge")).unwrap().1.parts[0].index,
1613 1
1614 );
1615
1616 recipe.dynamic_nodes.clear();
1617 recipe.parts = vec!["wedge".into(), "triangle".into()];
1618 let (both, both_bytes) = bake(&recipe, "both");
1619 recipe.parts = vec!["triangle".into(), "triangle".into(), "wedge".into()];
1620 let (_, repeated) = bake(&recipe, "repeated");
1621 recipe.parts.clear();
1622 let (all, all_bytes) = bake(&recipe, "all");
1623 assert_eq!(both.parts.len(), 2);
1624 assert_eq!(all.parts.len(), 2);
1625 assert_eq!(both_bytes, all_bytes);
1626 assert_eq!(repeated, both_bytes);
1627 let nodes: Vec<&str> = all.parts.iter().map(|part| part.node.as_str()).collect();
1628 assert_eq!(nodes, ["triangle", "wedge"]);
1629
1630 recipe.parts = vec!["missing".into()];
1631 assert!(
1632 bake_scene(
1633 &root,
1634 "triangle.gltf",
1635 &[material],
1636 &recipe,
1637 &root.join("missing"),
1638 false
1639 )
1640 .unwrap_err()
1641 .contains("not a mesh node")
1642 );
1643 recipe.parts = vec!["root".into()];
1644 assert!(
1645 bake_scene(
1646 &root,
1647 "triangle.gltf",
1648 &[material],
1649 &recipe,
1650 &root.join("root-part"),
1651 false
1652 )
1653 .unwrap_err()
1654 .contains("not a mesh node")
1655 );
1656 fs::remove_dir_all(root).unwrap();
1657 }
1658
1659 #[test]
1660 fn a_crop_is_the_uv_box_with_padding_on_the_alignment_and_bakes_like_the_tile() {
1661 let mut material = Material::default();
1662 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 9.0, 0.6, 3);
1663 let uvs = [[0.30, 0.55], [0.45, 0.55], [0.30, 0.70]];
1664 let input = mesh(&material, &uvs);
1665 let boxed = crop(&input, 256, 8).unwrap();
1666 assert_eq!(
1667 boxed,
1668 Crop {
1669 x: 64,
1670 y: 128,
1671 width: 64,
1672 height: 64
1673 }
1674 );
1675 for value in [boxed.x, boxed.y, boxed.width, boxed.height] {
1676 assert_eq!(value % atlas::ALIGN, 0);
1677 }
1678 let full = bake(&input, 256, 8).unwrap();
1679 let region = bake_crop(&input, 256, 8, boxed).unwrap();
1680 for y in 0..256u32 {
1681 for x in 0..256u32 {
1682 let tile = texel(&full.base, (y * 256 + x) as usize);
1683 let inside = (boxed.x..boxed.x + boxed.width).contains(&x)
1684 && (boxed.y..boxed.y + boxed.height).contains(&y);
1685 if !inside {
1686 assert_eq!(tile[3], 0, "texel {x},{y} outside the crop is covered");
1687 continue;
1688 }
1689 let at = ((y - boxed.y) * boxed.width + x - boxed.x) as usize;
1690 assert_eq!(texel(®ion.base, at), tile);
1691 assert_eq!(
1692 texel(®ion.normal, at),
1693 texel(&full.normal, (y * 256 + x) as usize)
1694 );
1695 assert_eq!(
1696 texel(®ion.roughness, at),
1697 texel(&full.roughness, (y * 256 + x) as usize)
1698 );
1699 }
1700 }
1701 let covered = atlas::covered_crop(256, &full.base).unwrap();
1702 assert!(covered.x >= boxed.x && covered.x + covered.width <= boxed.x + boxed.width);
1703 let edge_uvs = [[0.9, 0.9], [1.0, 0.9], [0.9, 1.0]];
1704 let edge = crop(&mesh(&material, &edge_uvs), 64, 8).unwrap();
1705 assert_eq!(
1706 edge,
1707 Crop {
1708 x: 48,
1709 y: 48,
1710 width: 16,
1711 height: 16
1712 }
1713 );
1714 let whole = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1715 assert_eq!(
1716 crop(&mesh(&material, &whole), 64, 2).unwrap(),
1717 Crop {
1718 x: 0,
1719 y: 0,
1720 width: 64,
1721 height: 64
1722 }
1723 );
1724 let flat = [[0.2, 0.2], [0.2, 0.2], [0.2, 0.2]];
1725 assert!(crop(&mesh(&material, &flat), 64, 2).is_err());
1726 assert!(
1727 bake_crop(
1728 &input,
1729 256,
1730 8,
1731 Crop {
1732 x: 240,
1733 y: 0,
1734 width: 32,
1735 height: 16
1736 }
1737 )
1738 .is_err()
1739 );
1740 }
1741
1742 #[test]
1743 fn a_detail_folder_of_the_first_format_still_loads_as_an_atlas() {
1744 let mut material = Material::default();
1745 material.layers[0] = NoiseLayer::new(NoiseKind::Value, 6.0, 0.5, 11);
1746 let uvs = [[0.1, 0.1], [0.4, 0.1], [0.1, 0.3]];
1747 let input = mesh(&material, &uvs);
1748 let maps = bake(&input, 128, 4).unwrap();
1749 let hash = input_hash(&input, 128, 4).unwrap();
1750 let root = Path::new("tmp").join(format!("detail-v1-test-{}", std::process::id()));
1751 if root.exists() {
1752 fs::remove_dir_all(&root).unwrap();
1753 }
1754 write(&root.join("part-0003"), &hash, &maps).unwrap();
1755 let summary = serde_json::to_vec_pretty(&json!({
1756 "schema_version": 1,
1757 "total_bytes": map_bytes(128),
1758 "parts": [{
1759 "path": "part-0003",
1760 "node": "sheet",
1761 "size": 128,
1762 "bytes": map_bytes(128),
1763 "input_hash": hash,
1764 }],
1765 }))
1766 .unwrap();
1767 fs::write(root.join("manifest.json"), &summary).unwrap();
1768 fs::write(root.join("manifest.sha256"), digest(&summary)).unwrap();
1769 let loaded = atlas::load(&root).unwrap();
1770 assert_eq!(loaded.parts.len(), 1);
1771 let part = &loaded.parts[0];
1772 assert_eq!(
1773 (part.index, part.node.as_str(), part.density),
1774 (3, "sheet", 128)
1775 );
1776 let crop = part.crop;
1777 assert_eq!(Some(crop), atlas::covered_crop(128, &maps.base));
1778 let region = atlas::cut(&maps, crop);
1779 let expected = atlas::chain(atlas::Kind::Base, crop.width, crop.height, ®ion.base, 1);
1780 let page = &loaded.base[0].levels[0];
1781 for y in 0..crop.height {
1782 let from = (y * crop.width * 4) as usize;
1783 let to = (((part.at[1] + y) * loaded.width + part.at[0]) * 4) as usize;
1784 let row = (crop.width * 4) as usize;
1785 assert_eq!(page[to..to + row], expected[0][from..from + row]);
1786 }
1787 assert!(!loaded.compressed());
1788 let replaced = root.join("manifest.json");
1789 let mut bytes = fs::read(&replaced).unwrap();
1790 bytes[0] = b' ';
1791 fs::write(&replaced, bytes).unwrap();
1792 assert!(atlas::load(&root).is_err());
1793 fs::remove_dir_all(root).unwrap();
1794 }
1795
1796 #[test]
1797 fn artifact_checks_each_payload() {
1798 let material = Material::default();
1799 let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1800 let input = mesh(&material, &uvs);
1801 let maps = bake(&input, 8, 2).unwrap();
1802 let hash = input_hash(&input, 8, 2).unwrap();
1803 let root = Path::new("tmp").join(format!("detail-artifact-test-{}", std::process::id()));
1804 if root.exists() {
1805 fs::remove_dir_all(&root).unwrap();
1806 }
1807 write(&root, &hash, &maps).unwrap();
1808 assert_eq!(read(&root).unwrap(), (hash, maps));
1809 let file = root.join("base.rgba");
1810 let mut bytes = fs::read(&file).unwrap();
1811 bytes[0] ^= 1;
1812 fs::write(file, bytes).unwrap();
1813 assert!(read(&root).is_err());
1814 fs::remove_dir_all(root).unwrap();
1815 }
1816}