BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

production.rs 125.0 KiB Download Raw
1//! Bounded orthographic production rendering. GPU work runs on the native
2//! current context, one request per UI update; sampling/filtering/PNG I/O run
3//! on the worker. The worker never waits for a preview acknowledgment.
4use crate::{
5 equipment,
6 preview::{self, Mat4, PreviewCharacter, PreviewControls},
7 project::CameraFraming,
8 render_job::{ImageBuf, RenderEvent},
9};
10use glow::HasContext;
11use std::{
12 path::{Path, PathBuf},
13 sync::{
14 atomic::{AtomicBool, Ordering},
15 mpsc, Arc, Mutex,
16 },
17 thread,
18};
19
20pub const SCALE: u32 = 8;
21pub const MAX_HIGHRES: u32 = 2048;
22pub const PASSES: [&str; 6] = [
23 "highres",
24 "detail",
25 "target-res / diffuse",
26 "depth",
27 "normal",
28 "specular",
29];
30#[derive(Clone)]
31pub struct Draw {
32 pub positions: Arc<Vec<[f32; 3]>>,
33 pub normals: Arc<Vec<[f32; 3]>>,
34 pub uvs: Arc<Vec<[f32; 2]>>,
35 pub vertex_colors: Arc<Vec<[f32; 4]>>,
36 pub indices: Vec<u32>,
37 pub color: [f32; 4],
38 pub albedo: Option<Arc<equipment::Texture>>,
39 pub shiny: Option<Arc<equipment::Texture>>,
40 pub specular: f32,
41 pub cutoff: f32,
42 pub flat_normals: bool,
43}
44#[derive(Clone)]
45pub struct Scene {
46 pub draws: Vec<Draw>,
47 pub view: Mat4,
48 pub projection: Mat4,
49 pub width: u32,
50 pub height: u32,
51}
52#[derive(Debug)]
53pub struct Buffers {
54 pub width: u32,
55 pub height: u32,
56 pub diffuse: Vec<u8>,
57 pub normal: Vec<u8>,
58 pub specular: Vec<u8>,
59 pub depth: Vec<f32>,
60}
61#[derive(Debug)]
62pub struct Target {
63 pub diffuse: ImageBuf,
64 pub normal: ImageBuf,
65 pub specular: ImageBuf,
66 pub depth: Vec<u16>,
67 /// Unquantized normalized depth t (0..1) per pixel, for the export encoding.
68 pub depth_t: Vec<f32>,
69}
70/// Content fingerprint of a source file used for a production job.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct SourceFingerprint {
73 pub path: String,
74 pub sha256: String,
75}
76
77#[derive(Clone)]
78pub struct RenderJob {
79 pub character: PreviewCharacter,
80 pub sex: String,
81 pub preset: Option<equipment::Preset>,
82 pub target_width: u32,
83 pub target_height: u32,
84 pub fps: u32,
85 pub framing: CameraFraming,
86 pub output: PathBuf,
87 pub project_id: String,
88 pub project_name: String,
89 pub body_model: SourceFingerprint,
90 /// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.
91 pub preset_sha256: Option<String>,
92 /// Depth PNG bit depth + encoding (project setting).
93 pub depth_format: crate::camera::DepthFormat,
94}
95#[derive(Debug, Clone, PartialEq)]
96pub struct ClipPlan {
97 pub source_name: String,
98 pub token: String,
99 pub frames: u32,
100}
101#[derive(Debug, Clone, PartialEq)]
102pub struct Plan {
103 pub clips: Vec<ClipPlan>,
104 pub images: u32,
105 pub passes: u32,
106 pub files: u32,
107}
108pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
109 if fps == 0 || fps > 240 {
110 return Err("fps moet 1–240 zijn".into());
111 }
112 let clips = if body.clips.is_empty() {
113 vec![ClipPlan {
114 source_name: String::new(),
115 token: "clip000-bind".into(),
116 frames: 1,
117 }]
118 } else {
119 body.clips
120 .iter()
121 .enumerate()
122 .map(|(i, c)| {
123 let duration = c.duration;
124 if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. {
125 return Err("ongeldige of te lange animatie".into());
126 }
127 // Exporter float timestamps commonly put a nominal 1-second
128 // endpoint one ULP above 1. Do not duplicate that terminal pose.
129 let samples = f64::from(duration) * f64::from(fps);
130 let nearest = samples.round();
131 let samples = if (samples - nearest).abs()
132 <= 4. * f64::from(f32::EPSILON) * samples.max(1.)
133 {
134 nearest
135 } else {
136 samples
137 };
138 Ok(ClipPlan {
139 source_name: c.name.clone(),
140 token: clip_token(i, &c.name),
141 frames: samples.ceil().max(1.) as u32,
142 })
143 })
144 .collect::<Result<Vec<_>, String>>()?
145 };
146 let frames = clips
147 .iter()
148 .try_fold(0u32, |n, c| n.checked_add(c.frames))
149 .ok_or("te veel frames")?;
150 let images = frames
151 .checked_mul(preview::PRODUCTION_VIEWS)
152 .ok_or("te veel beelden")?;
153 let passes = images.checked_mul(6).ok_or("te veel passes")?;
154 let files = images.checked_mul(4).ok_or("te veel bestanden")?;
155 if files > 500000 {
156 return Err("batch groter dan 500000 uitvoerbestanden".into());
157 }
158 Ok(Plan {
159 clips,
160 images,
161 passes,
162 files,
163 })
164}
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct Sample {
167 pub clip: usize,
168 pub frame: u32,
169 pub pitch: i32,
170 pub yaw: u32,
171}
172pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
173 plan.clips.iter().enumerate().flat_map(|(clip, c)| {
174 (0..c.frames).flat_map(move |frame| {
175 preview::PRODUCTION_PITCHES
176 .into_iter()
177 .flat_map(move |pitch| {
178 (0..12).map(move |y| Sample {
179 clip,
180 frame,
181 pitch,
182 yaw: y * 30,
183 })
184 })
185 })
186 })
187}
188
189/// Production yaw values in degrees, atlas column order within one pitch.
190pub fn production_yaws() -> [u32; 12] {
191 std::array::from_fn(|i| i as u32 * 30)
192}
193
194pub fn clip_token(index: usize, name: &str) -> String {
195 let slug: String = name
196 .chars()
197 .map(|c| {
198 if c.is_ascii_alphanumeric() {
199 c.to_ascii_lowercase()
200 } else {
201 '-'
202 }
203 })
204 .take(48)
205 .collect();
206 format!(
207 "clip{index:03}-{}",
208 if slug.is_empty() { "animation" } else { &slug }
209 )
210}
211pub fn output_path(
212 sex: &str,
213 set: &str,
214 clip: &str,
215 frame: u32,
216 pitch: i32,
217 yaw: u32,
218 map: &str,
219) -> Result<PathBuf, String> {
220 if !["male", "female"].contains(&sex)
221 || !(set == "body" || equipment::valid_name(set))
222 || !equipment::valid_name(clip)
223 || !preview::PRODUCTION_PITCHES.contains(&pitch)
224 || yaw >= 360
225 || !yaw.is_multiple_of(30)
226 || !["diffuse", "normal", "depth", "specular"].contains(&map)
227 {
228 return Err("ongeldig uitvoerpad/token".into());
229 }
230 Ok(PathBuf::from(crate::resume::RAW_DIR)
231 .join(sex)
232 .join(set)
233 .join(clip)
234 .join(format!("f{frame:06}"))
235 .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
236}
237pub use crate::camera::matrices as camera;
238
239fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
240 std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
241}
242fn normalize(v: [f32; 3]) -> [f32; 3] {
243 let l = v.iter().map(|x| x * x).sum::<f32>().sqrt();
244 if l > 1e-10 {
245 v.map(|x| x / l)
246 } else {
247 [0., 0., 1.]
248 }
249}
250fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] {
251 let a = [m[0], m[1], m[2]];
252 let b = [m[4], m[5], m[6]];
253 let c = [m[8], m[9], m[10]];
254 let cross = |a: [f32; 3], b: [f32; 3]| {
255 [
256 a[1] * b[2] - a[2] * b[1],
257 a[2] * b[0] - a[0] * b[2],
258 a[0] * b[1] - a[1] * b[0],
259 ]
260 };
261 let x = cross(b, c);
262 let y = cross(c, a);
263 let z = cross(a, b);
264 let det = a.iter().zip(x).map(|(a, b)| a * b).sum::<f32>();
265 if det.abs() < 1e-12 {
266 return normalize(n);
267 }
268 normalize(std::array::from_fn(|i| {
269 (x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det
270 }))
271}
272pub fn scene(
273 job: &RenderJob,
274 clip: &str,
275 time: f32,
276 pitch: f32,
277 yaw: f32,
278 cache: &mut equipment::TextureCache,
279) -> Result<Scene, String> {
280 let body = &job.character;
281 let mut draws = Vec::new();
282 if let Some(preset) = &job.preset {
283 let controls = PreviewControls {
284 clip_name: clip.into(),
285 time,
286 ..Default::default()
287 };
288 for layer in equipment::layers_cached(
289 preset,
290 usize::from(job.sex == "female"),
291 body,
292 &controls,
293 cache,
294 )? {
295 let m = layer.model;
296 draws.push(Draw {
297 positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()),
298 normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()),
299 uvs: Arc::new(
300 layer
301 .mesh
302 .uvs
303 .into_iter()
304 .map(|[u, v]| [u, 1. - v])
305 .collect(),
306 ),
307 vertex_colors: Arc::new(Vec::new()),
308 indices: layer.mesh.indices,
309 color: layer.mesh.color,
310 albedo: layer.albedo,
311 shiny: layer.shiny,
312 specular: layer.specular,
313 cutoff: 0.5,
314 flat_normals: false,
315 });
316 }
317 } else {
318 let bones = preview::sample_skin_matrices(body, clip, time);
319 let mut positions = Vec::with_capacity(body.positions.len());
320 let mut normals = Vec::with_capacity(body.positions.len());
321 for (i, p) in body.positions.iter().enumerate() {
322 let mut m = preview::identity();
323 if !bones.is_empty() && body.weights.len() == body.positions.len() {
324 m = [0.; 16];
325 let joints = body.joints.get(i).copied().unwrap_or([0; 4]);
326 for (j, w) in joints.into_iter().zip(body.weights[i]) {
327 let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?;
328 for k in 0..16 {
329 m[k] += b[k] * w;
330 }
331 }
332 }
333 positions.push(point(&m, *p));
334 normals.push(normal(
335 &m,
336 body.normals.get(i).copied().unwrap_or([0., 1., 0.]),
337 ));
338 }
339 let positions = Arc::new(positions);
340 let normals = Arc::new(normals);
341 let uvs = Arc::new(body.uvs.clone());
342 let colors = Arc::new(body.colors.clone());
343 if body.surfaces.is_empty() {
344 draws.push(Draw {
345 positions,
346 normals,
347 uvs,
348 vertex_colors: colors,
349 indices: body.indices.clone(),
350 color: body.base_color,
351 albedo: None,
352 shiny: None,
353 specular: 0.,
354 cutoff: 0.001,
355 flat_normals: !body.source_normals_complete,
356 });
357 } else {
358 for surface in &body.surfaces {
359 draws.push(Draw {
360 positions: positions.clone(),
361 normals: normals.clone(),
362 uvs: uvs.clone(),
363 vertex_colors: colors.clone(),
364 indices: body.indices[surface.first..surface.first + surface.count].to_vec(),
365 color: surface.color,
366 albedo: surface.albedo.clone(),
367 shiny: surface.shiny.clone(),
368 specular: surface.specular,
369 cutoff: surface.cutoff,
370 flat_normals: surface.flat_normals,
371 });
372 }
373 }
374 }
375 let (view, projection) = camera(
376 crate::camera::resolve(body, job.framing).framing,
377 yaw,
378 pitch,
379 job.target_width as f32 / job.target_height as f32,
380 );
381 Ok(Scene {
382 draws,
383 view,
384 projection,
385 width: job.target_width * SCALE,
386 height: job.target_height * SCALE,
387 })
388}
389
390impl Scene {
391 pub fn validate(&self) -> Result<(), String> {
392 if self.width == 0
393 || self.height == 0
394 || self.width > MAX_HIGHRES
395 || self.height > MAX_HIGHRES
396 || self.draws.len() > 4096
397 || self
398 .view
399 .iter()
400 .chain(&self.projection)
401 .any(|v| !v.is_finite())
402 {
403 return Err("ongeldige GPU-scène/afmetingen".into());
404 }
405 for d in &self.draws {
406 if d.positions.len() > 1_000_000
407 || d.positions.len() != d.normals.len()
408 || d.indices.len() > 3_000_000
409 || !d.indices.len().is_multiple_of(3)
410 || d.indices.iter().any(|i| *i as usize >= d.positions.len())
411 || d.positions
412 .iter()
413 .chain(d.normals.iter())
414 .flatten()
415 .chain(d.uvs.iter().flatten())
416 .chain(d.vertex_colors.iter().flatten())
417 .chain(d.color.iter())
418 .any(|v| !v.is_finite())
419 || !d.specular.is_finite()
420 || !d.cutoff.is_finite()
421 {
422 return Err("ongeldige of te grote GPU-geometrie".into());
423 }
424 for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() {
425 if t.width == 0
426 || t.height == 0
427 || t.width > 2048
428 || t.height > 2048
429 || t.rgba.len() != t.width as usize * t.height as usize * 4
430 {
431 return Err("ongeldige GPU-texture".into());
432 }
433 }
434 }
435 Ok(())
436 }
437}
438
439#[derive(Default)]
440pub struct GpuRenderer {
441 program: Option<glow::Program>,
442 vao: Option<glow::VertexArray>,
443 vbo: Option<glow::Buffer>,
444 ebo: Option<glow::Buffer>,
445 fbo: Option<glow::Framebuffer>,
446 colors: Vec<glow::Texture>,
447 depth: Option<glow::Renderbuffer>,
448 extent: [u32; 2],
449}
450impl GpuRenderer {
451 /// # Safety
452 /// A native OpenGL 3.3 context must be current on this thread.
453 pub unsafe fn destroy(&mut self, gl: &glow::Context) {
454 self.destroy_target(gl);
455 if let Some(x) = self.program.take() {
456 gl.delete_program(x);
457 }
458 if let Some(x) = self.vao.take() {
459 gl.delete_vertex_array(x);
460 }
461 if let Some(x) = self.vbo.take() {
462 gl.delete_buffer(x);
463 }
464 if let Some(x) = self.ebo.take() {
465 gl.delete_buffer(x);
466 }
467 }
468 unsafe fn destroy_target(&mut self, gl: &glow::Context) {
469 for x in self.colors.drain(..) {
470 gl.delete_texture(x);
471 }
472 if let Some(x) = self.depth.take() {
473 gl.delete_renderbuffer(x);
474 }
475 if let Some(x) = self.fbo.take() {
476 gl.delete_framebuffer(x);
477 }
478 self.extent = [0, 0];
479 }
480 unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
481 if self.program.is_some() {
482 return Ok(());
483 }
484 let p = gl.create_program()?;
485 let mut shaders = Vec::new();
486 for (kind, source) in [
487 (glow::VERTEX_SHADER, VERTEX),
488 (glow::FRAGMENT_SHADER, FRAGMENT),
489 ] {
490 let shader = gl.create_shader(kind)?;
491 gl.shader_source(shader, source);
492 gl.compile_shader(shader);
493 if !gl.get_shader_compile_status(shader) {
494 let error = gl.get_shader_info_log(shader);
495 gl.delete_shader(shader);
496 for s in shaders {
497 gl.delete_shader(s);
498 }
499 gl.delete_program(p);
500 return Err(error);
501 }
502 gl.attach_shader(p, shader);
503 shaders.push(shader);
504 }
505 gl.link_program(p);
506 let result = if gl.get_program_link_status(p) {
507 Ok(())
508 } else {
509 Err(gl.get_program_info_log(p))
510 };
511 for s in shaders {
512 gl.detach_shader(p, s);
513 gl.delete_shader(s);
514 }
515 if let Err(e) = result {
516 gl.delete_program(p);
517 return Err(e);
518 }
519 self.program = Some(p);
520 self.vao = Some(gl.create_vertex_array()?);
521 self.vbo = Some(gl.create_buffer()?);
522 self.ebo = Some(gl.create_buffer()?);
523 Ok(())
524 }
525 unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
526 if self.extent == [w, h] {
527 return Ok(());
528 }
529 self.destroy_target(gl);
530 self.fbo = Some(gl.create_framebuffer()?);
531 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
532 for slot in 0..3 {
533 let t = gl.create_texture()?;
534 self.colors.push(t);
535 gl.bind_texture(glow::TEXTURE_2D, Some(t));
536 gl.tex_parameter_i32(
537 glow::TEXTURE_2D,
538 glow::TEXTURE_MIN_FILTER,
539 glow::NEAREST as i32,
540 );
541 gl.tex_parameter_i32(
542 glow::TEXTURE_2D,
543 glow::TEXTURE_MAG_FILTER,
544 glow::NEAREST as i32,
545 );
546 gl.tex_image_2d(
547 glow::TEXTURE_2D,
548 0,
549 glow::RGBA8 as i32,
550 w as i32,
551 h as i32,
552 0,
553 glow::RGBA,
554 glow::UNSIGNED_BYTE,
555 None,
556 );
557 gl.framebuffer_texture_2d(
558 glow::FRAMEBUFFER,
559 glow::COLOR_ATTACHMENT0 + slot,
560 glow::TEXTURE_2D,
561 Some(t),
562 0,
563 );
564 }
565 let d = gl.create_renderbuffer()?;
566 self.depth = Some(d);
567 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d));
568 gl.renderbuffer_storage(
569 glow::RENDERBUFFER,
570 glow::DEPTH_COMPONENT24,
571 w as i32,
572 h as i32,
573 );
574 gl.framebuffer_renderbuffer(
575 glow::FRAMEBUFFER,
576 glow::DEPTH_ATTACHMENT,
577 glow::RENDERBUFFER,
578 Some(d),
579 );
580 gl.draw_buffers(&[
581 glow::COLOR_ATTACHMENT0,
582 glow::COLOR_ATTACHMENT1,
583 glow::COLOR_ATTACHMENT2,
584 ]);
585 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
586 return Err("productie-FBO onvolledig".into());
587 }
588 self.extent = [w, h];
589 Ok(())
590 }
591 /// Render and read back unlit color, view-space normals, scalar specular and
592 /// the actual depth attachment. All four use the same winning surface.
593 /// # Safety
594 /// `gl` is current on the UI thread. GL state is reset for egui painting.
595 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
596 if scene.width == 0
597 || scene.height == 0
598 || scene.width > MAX_HIGHRES
599 || scene.height > MAX_HIGHRES
600 {
601 return Err("highres moet 1–2048 pixels zijn (8× target)".into());
602 }
603 scene.validate()?;
604 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
605 let mut vp = [0; 4];
606 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
607 let result = (|| {
608 self.init(gl)?;
609 self.target(gl, scene.width, scene.height)?;
610 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
611 gl.draw_buffers(&[
612 glow::COLOR_ATTACHMENT0,
613 glow::COLOR_ATTACHMENT1,
614 glow::COLOR_ATTACHMENT2,
615 ]);
616 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
617 gl.disable(glow::SCISSOR_TEST);
618 gl.disable(glow::CULL_FACE);
619 gl.disable(glow::BLEND);
620 gl.enable(glow::DEPTH_TEST);
621 gl.depth_mask(true);
622 gl.depth_func(glow::LESS);
623 // Geometry outside the fixed per-sex interval is clamped, not clipped
624 // away; production counts and reports clamped coverage.
625 gl.enable(glow::DEPTH_CLAMP);
626 gl.clear_depth_f32(1.);
627 gl.clear_color(0., 0., 0., 0.);
628 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
629 let p = self.program.ok_or("shader ontbreekt")?;
630 gl.use_program(Some(p));
631 gl.uniform_matrix_4_f32_slice(
632 gl.get_uniform_location(p, "u_view").as_ref(),
633 false,
634 &scene.view,
635 );
636 gl.uniform_matrix_4_f32_slice(
637 gl.get_uniform_location(p, "u_projection").as_ref(),
638 false,
639 &scene.projection,
640 );
641 gl.bind_vertex_array(self.vao);
642 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
643 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
644 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
645 gl.enable_vertex_attrib_array(slot);
646 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
647 }
648 for draw in &scene.draws {
649 if draw.positions.len() != draw.normals.len()
650 || draw
651 .indices
652 .iter()
653 .any(|i| *i as usize >= draw.positions.len())
654 || draw.indices.len() > i32::MAX as usize
655 {
656 return Err("ongeldige GPU-geometrie".into());
657 }
658 let mut data = Vec::with_capacity(draw.positions.len() * 12);
659 for (i, pos) in draw.positions.iter().enumerate() {
660 data.extend_from_slice(pos);
661 data.extend_from_slice(&draw.normals[i]);
662 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
663 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
664 }
665 gl.buffer_data_u8_slice(
666 glow::ARRAY_BUFFER,
667 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
668 glow::STREAM_DRAW,
669 );
670 gl.buffer_data_u8_slice(
671 glow::ELEMENT_ARRAY_BUFFER,
672 std::slice::from_raw_parts(
673 draw.indices.as_ptr().cast(),
674 draw.indices.len() * 4,
675 ),
676 glow::STREAM_DRAW,
677 );
678 gl.uniform_4_f32(
679 gl.get_uniform_location(p, "u_color").as_ref(),
680 draw.color[0],
681 draw.color[1],
682 draw.color[2],
683 draw.color[3],
684 );
685 gl.uniform_1_f32(
686 gl.get_uniform_location(p, "u_specular").as_ref(),
687 draw.specular,
688 );
689 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
690 gl.uniform_1_i32(
691 gl.get_uniform_location(p, "u_flat").as_ref(),
692 i32::from(draw.flat_normals),
693 );
694 let mut temp = Vec::new();
695 for (unit, name, texture) in [
696 (0, "u_albedo", draw.albedo.as_ref()),
697 (1, "u_shiny", draw.shiny.as_ref()),
698 ] {
699 gl.active_texture(glow::TEXTURE0 + unit);
700 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
701 gl.uniform_1_i32(
702 gl.get_uniform_location(p, &format!("{name}_enabled"))
703 .as_ref(),
704 i32::from(texture.is_some()),
705 );
706 if let Some(image) = texture {
707 let t = gl.create_texture()?;
708 temp.push(t);
709 gl.bind_texture(glow::TEXTURE_2D, Some(t));
710 gl.tex_parameter_i32(
711 glow::TEXTURE_2D,
712 glow::TEXTURE_MIN_FILTER,
713 glow::NEAREST as i32,
714 );
715 gl.tex_parameter_i32(
716 glow::TEXTURE_2D,
717 glow::TEXTURE_MAG_FILTER,
718 glow::NEAREST as i32,
719 );
720 gl.tex_parameter_i32(
721 glow::TEXTURE_2D,
722 glow::TEXTURE_WRAP_S,
723 glow::REPEAT as i32,
724 );
725 gl.tex_parameter_i32(
726 glow::TEXTURE_2D,
727 glow::TEXTURE_WRAP_T,
728 glow::REPEAT as i32,
729 );
730 gl.tex_image_2d(
731 glow::TEXTURE_2D,
732 0,
733 if unit == 0 {
734 glow::SRGB8_ALPHA8 as i32
735 } else {
736 glow::RGBA8 as i32
737 },
738 image.width as i32,
739 image.height as i32,
740 0,
741 glow::RGBA,
742 glow::UNSIGNED_BYTE,
743 Some(&image.rgba),
744 );
745 } else {
746 gl.bind_texture(glow::TEXTURE_2D, None);
747 }
748 }
749 gl.draw_elements(
750 glow::TRIANGLES,
751 draw.indices.len() as i32,
752 glow::UNSIGNED_INT,
753 0,
754 );
755 for t in temp {
756 gl.delete_texture(t);
757 }
758 }
759 let pixels = scene.width as usize * scene.height as usize;
760 let mut maps = Vec::new();
761 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
762 for slot in 0..3 {
763 let mut data = vec![0; pixels * 4];
764 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
765 gl.read_pixels(
766 0,
767 0,
768 scene.width as i32,
769 scene.height as i32,
770 glow::RGBA,
771 glow::UNSIGNED_BYTE,
772 glow::PixelPackData::Slice(&mut data),
773 );
774 flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);
775 maps.push(data);
776 }
777 let mut depth = vec![1f32; pixels];
778 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
779 gl.read_pixels(
780 0,
781 0,
782 scene.width as i32,
783 scene.height as i32,
784 glow::DEPTH_COMPONENT,
785 glow::FLOAT,
786 glow::PixelPackData::Slice(bytes),
787 );
788 flip_rows(&mut depth, scene.width as usize, scene.height as usize);
789 let error = gl.get_error();
790 if error != glow::NO_ERROR {
791 return Err(format!("productie-GL fout 0x{error:x}"));
792 }
793 let specular = maps.pop().unwrap();
794 let normal = maps.pop().unwrap();
795 let diffuse = maps.pop().unwrap();
796 Ok(Buffers {
797 width: scene.width,
798 height: scene.height,
799 diffuse,
800 normal,
801 specular,
802 depth,
803 })
804 })();
805 gl.bind_framebuffer(
806 glow::FRAMEBUFFER,
807 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
808 );
809 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
810 gl.bind_vertex_array(None);
811 gl.use_program(None);
812 gl.active_texture(glow::TEXTURE0);
813 gl.disable(glow::DEPTH_TEST);
814 gl.disable(glow::DEPTH_CLAMP);
815 gl.disable(glow::SCISSOR_TEST);
816 gl.enable(glow::BLEND);
817 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
818 result
819 }
820}
821/// Covered highres pixels whose depth hit the fixed interval's near or far
822/// plane (GL depth clamp), i.e. geometry outside `[depth_near, depth_far]`.
823pub fn clamped_depth_pixels(raw: &Buffers) -> usize {
824 raw.depth
825 .iter()
826 .enumerate()
827 .filter(|(i, d)| {
828 raw.diffuse.get(i * 4 + 3).is_some_and(|a| *a > 0) && (**d <= 0. || **d >= 1.)
829 })
830 .count()
831}
832fn flip_rows<T>(data: &mut [T], stride: usize, height: usize) {
833 for y in 0..height / 2 {
834 let split = (height - 1 - y) * stride;
835 let (a, b) = data.split_at_mut(split);
836 a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]);
837 }
838}
839const VERTEX: &str = r#"#version 330
840layout(location=0)in vec3 a_position;layout(location=1)in vec3 a_normal;layout(location=2)in vec2 a_uv;layout(location=3)in vec4 a_color;
841uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color;
842void main(){v_color=a_color;v_normal=mat3(u_view)*a_normal;v_uv=a_uv;vec4 position=u_view*vec4(a_position,1.0);v_view_position=position.xyz;gl_Position=u_projection*position;}
843"#;
844const FRAGMENT: &str = r#"#version 330
845in vec3 v_normal;in vec3 v_view_position;uniform bool u_flat;in vec2 v_uv;in vec4 v_color;uniform vec4 u_color;uniform float u_cutoff;uniform vec3 u_light_direction; // deliberately unused: production maps are unlit
846uniform float u_specular;
847uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled;
848layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular;
849vec3 encode_srgb(vec3 x){return mix(x*12.92,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x));}
850void main(){vec3 n=normalize(v_normal);if(u_flat){n=normalize(cross(dFdx(v_view_position),dFdy(v_view_position)));if(!gl_FrontFacing)n=-n;}vec4 c=u_color*v_color;if(u_albedo_enabled)c*=texture(u_albedo,v_uv);if(c.a<u_cutoff)discard;float s=u_specular;if(u_shiny_enabled)s*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));diffuse=vec4(encode_srgb(clamp(c.rgb,0.0,1.0)),c.a);normal=vec4(n*0.5+0.5,c.a);specular=vec4(vec3(clamp(s,0.0,1.0)),c.a);}
851"#;
852
853/// Live-preview image of a depth PNG exactly as written (Tim t1005u): the
854/// stored channels in display form, no depth decoding.
855pub fn stored_depth_preview(
856 png_bytes: &[u8],
857 w: u32,
858 h: u32,
859 fmt: crate::camera::DepthFormat,
860) -> Result<ImageBuf, String> {
861 let native =
862 crate::export_set::decode_depth_native(png_bytes, w, h, crate::export_set::MAX_TARGET, fmt)
863 .map_err(|e| e.to_string())?;
864 Ok(image(
865 w,
866 h,
867 crate::export_set::depth_display_rgba(&native, fmt),
868 ))
869}
870
871fn image(w: u32, h: u32, rgba: Vec<u8>) -> ImageBuf {
872 ImageBuf {
873 width: w,
874 height: h,
875 rgba,
876 }
877}
878fn linear(x: u8) -> f32 {
879 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
880 LUT.get_or_init(|| {
881 std::array::from_fn(|i| {
882 let v = i as f32 / 255.;
883 if v <= 0.04045 {
884 v / 12.92
885 } else {
886 ((v + 0.055) / 1.055).powf(2.4)
887 }
888 })
889 })[x as usize]
890}
891
892fn srgb(x: f32) -> u8 {
893 let v = if x <= 0.0031308 {
894 x * 12.92
895 } else {
896 1.055 * x.powf(1. / 2.4) - 0.055
897 };
898 (v * 255.).round().clamp(0., 255.) as u8
899}
900impl Buffers {
901 pub fn validate(&self) -> Result<(), String> {
902 let pixels = (self.width as usize)
903 .checked_mul(self.height as usize)
904 .ok_or("te veel pixels")?;
905 if self.width == 0
906 || self.height == 0
907 || self.width > MAX_HIGHRES
908 || self.height > MAX_HIGHRES
909 || self.diffuse.len() != pixels * 4
910 || self.normal.len() != pixels * 4
911 || self.specular.len() != pixels * 4
912 || self.depth.len() != pixels
913 || self
914 .depth
915 .iter()
916 .any(|d| !d.is_finite() || !(0.0..=1.0).contains(d))
917 {
918 return Err("ongeldige GPU-mapafmetingen/data".into());
919 }
920 Ok(())
921 }
922}
923pub fn detail_map(raw: &Buffers) -> Result<Vec<u8>, String> {
924 raw.validate()?;
925 let w = raw.width as usize;
926 let h = raw.height as usize;
927 let mut out = vec![0; w * h];
928 for y in 0..h {
929 for x in 0..w {
930 let i = y * w + x;
931 let mut edge = 0f32;
932 for (nx, ny) in [
933 (x.saturating_sub(1), y),
934 ((x + 1).min(w - 1), y),
935 (x, y.saturating_sub(1)),
936 (x, (y + 1).min(h - 1)),
937 ] {
938 let j = ny * w + nx;
939 let a = raw.diffuse[i * 4 + 3] as f32 / 255.;
940 let b = raw.diffuse[j * 4 + 3] as f32 / 255.;
941 edge = edge.max((a - b).abs());
942 if a > 0. && b > 0. {
943 for c in 0..3 {
944 edge = edge.max(
945 (linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(),
946 );
947 edge = edge.max(
948 (raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs()
949 / 255.,
950 );
951 }
952 edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.));
953 }
954 }
955 out[i] = (edge * 255.).round().clamp(0., 255.) as u8;
956 }
957 }
958 Ok(out)
959}
960pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf {
961 let mut rgba = Vec::with_capacity(detail.len() * 4);
962 for (i, v) in detail.iter().enumerate() {
963 rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]);
964 }
965 image(raw.width, raw.height, rgba)
966}
967pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, String> {
968 raw.validate()?;
969 if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE {
970 return Err("ongeldige target-afmetingen".into());
971 }
972 if raw.width != w * SCALE
973 || raw.height != h * SCALE
974 || detail.len() != raw.width as usize * raw.height as usize
975 {
976 return Err("ongeldige 8× resolve-afmetingen".into());
977 }
978 let count = w as usize * h as usize;
979 let mut diffuse = vec![0; count * 4];
980 let mut normals = diffuse.clone();
981 let mut specular = diffuse.clone();
982 let mut depth = vec![0u16; count * 2];
983 let mut depth_t = vec![0f32; count];
984 for y in 0..h {
985 for x in 0..w {
986 let i = (y * w + x) as usize;
987 let mut closest = 1f32;
988 let mut coverage = 0f32;
989 for dy in 0..SCALE {
990 for dx in 0..SCALE {
991 let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
992 let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
993 coverage += a;
994 if a > 0. && raw.depth[j].is_finite() {
995 closest = closest.min(raw.depth[j]);
996 }
997 }
998 }
999 let alpha = (coverage / (SCALE * SCALE) as f32 * 255.)
1000 .round()
1001 .clamp(0., 255.) as u8;
1002 if alpha == 0 {
1003 continue;
1004 }
1005 let mut color = [0.; 3];
1006 let mut n = [0.; 3];
1007 let mut shiny = 0.;
1008 let mut sum = 0.;
1009 // One coherent front-surface cluster; never average foreground with a rear
1010 // surface. Edges get extra weight, and any thin covered feature survives.
1011 for dy in 0..SCALE {
1012 for dx in 0..SCALE {
1013 let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
1014 let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
1015 if a == 0. || raw.depth[j] > closest + 0.0025 {
1016 continue;
1017 }
1018 let weight = a * (1. + 4. * detail[j] as f32 / 255.);
1019 sum += weight;
1020 for c in 0..3 {
1021 color[c] += linear(raw.diffuse[j * 4 + c]) * weight;
1022 n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight;
1023 }
1024 shiny += raw.specular[j * 4] as f32 * weight;
1025 }
1026 }
1027 if sum <= 0. {
1028 return Err("ongeldige depth/coverage in GPU-resultaat".into());
1029 }
1030 n = normalize(n);
1031 let d = (closest.clamp(0., 1.) * 65535.).round() as u16;
1032 for c in 0..3 {
1033 diffuse[i * 4 + c] = srgb(color[c] / sum);
1034 normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8;
1035 specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8;
1036 }
1037 for map in [&mut diffuse, &mut normals, &mut specular] {
1038 map[i * 4 + 3] = alpha;
1039 }
1040 depth[i * 2] = d;
1041 depth_t[i] = closest.clamp(0., 1.);
1042 depth[i * 2 + 1] = u16::from(alpha) * 257;
1043 }
1044 }
1045 Ok(Target {
1046 diffuse: image(w, h, diffuse),
1047 normal: image(w, h, normals),
1048 specular: image(w, h, specular),
1049 depth,
1050 depth_t,
1051 })
1052}
1053
1054fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1055 let relative = relative
1056 .to_str()
1057 .ok_or("ongeldig uitvoerpad")?
1058 .replace('\\', "/");
1059 let relative = crate::project::portable_relative(&relative)?;
1060 let relative = relative.as_str();
1061 let canonical = root.canonicalize().map_err(|e| e.to_string())?;
1062 let path = root.join(relative);
1063 let parent = path.parent().ok_or("uitvoermap ontbreekt")?;
1064 let mut prefix = root.to_path_buf();
1065 for part in Path::new(relative)
1066 .parent()
1067 .unwrap_or(Path::new(""))
1068 .components()
1069 {
1070 prefix.push(part);
1071 if !prefix.exists() {
1072 std::fs::create_dir(&prefix).map_err(|e| e.to_string())?;
1073 }
1074 let real = prefix.canonicalize().map_err(|e| e.to_string())?;
1075 if !real.starts_with(&canonical) {
1076 return Err("uitvoer verlaat projectmap".into());
1077 }
1078 }
1079 if !parent
1080 .canonicalize()
1081 .map_err(|e| e.to_string())?
1082 .starts_with(canonical)
1083 {
1084 return Err("onveilig uitvoerpad".into());
1085 }
1086 Ok(path)
1087}
1088pub(crate) fn atomic_file(
1089 root: &Path,
1090 relative: &Path,
1091 write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
1092) -> Result<(), String> {
1093 let path = safe_parent(root, relative)?;
1094 static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1095 let temp = path.with_extension(format!(
1096 "part-{}-{}",
1097 std::process::id(),
1098 TEMP_SEQ.fetch_add(1, Ordering::Relaxed)
1099 ));
1100 let mut owned = false;
1101 let result = (|| {
1102 let mut file = std::fs::OpenOptions::new()
1103 .write(true)
1104 .create_new(true)
1105 .open(&temp)
1106 .map_err(|e| e.to_string())?;
1107 owned = true;
1108 write(&mut file)?;
1109 file.sync_all().map_err(|e| e.to_string())?;
1110 std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
1111 Ok(())
1112 })();
1113 if owned {
1114 let _ = std::fs::remove_file(temp);
1115 }
1116 result
1117}
1118fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result<Vec<u8>, String> {
1119 if img.width == 0
1120 || img.height == 0
1121 || img.width > MAX_HIGHRES / SCALE
1122 || img.height > MAX_HIGHRES / SCALE
1123 || img.rgba.len() != img.width as usize * img.height as usize * 4
1124 {
1125 return Err("ongeldige PNG-afmetingen".into());
1126 }
1127 let mut cursor = std::io::Cursor::new(Vec::new());
1128 {
1129 let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height);
1130 encoder.set_color(png::ColorType::Rgba);
1131 encoder.set_depth(png::BitDepth::Eight);
1132 if diffuse_srgb {
1133 encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
1134 }
1135 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1136 writer
1137 .write_image_data(&img.rgba)
1138 .map_err(|e| e.to_string())?;
1139 writer.finish().map_err(|e| e.to_string())?;
1140 }
1141 Ok(cursor.into_inner())
1142}
1143
1144fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result<Vec<u8>, String> {
1145 if w == 0
1146 || h == 0
1147 || w > MAX_HIGHRES / SCALE
1148 || h > MAX_HIGHRES / SCALE
1149 || depth.len() != w as usize * h as usize * 2
1150 {
1151 return Err("ongeldige 16-bit depthafmetingen".into());
1152 }
1153 let samples: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();
1154 let mut cursor = std::io::Cursor::new(Vec::new());
1155 {
1156 let mut encoder = png::Encoder::new(&mut cursor, w, h);
1157 encoder.set_color(png::ColorType::GrayscaleAlpha);
1158 encoder.set_depth(png::BitDepth::Sixteen);
1159 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1160 writer
1161 .write_image_data(&samples)
1162 .map_err(|e| e.to_string())?;
1163 writer.finish().map_err(|e| e.to_string())?;
1164 }
1165 Ok(cursor.into_inner())
1166}
1167
1168/// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes.
1169pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String, String> {
1170 let diffuse_srgb = relative
1171 .file_name()
1172 .and_then(|p| p.to_str())
1173 .map(|p| p.ends_with("_diffuse.png"))
1174 .unwrap_or(false);
1175 let bytes = encode_rgba_png(img, diffuse_srgb)?;
1176 let digest = crate::digest::sha256_hex(&bytes);
1177 atomic_file(root, relative, |file| {
1178 use std::io::Write;
1179 file.write_all(&bytes).map_err(|e| e.to_string())
1180 })?;
1181 Ok(digest)
1182}
1183
1184/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.
1185/// Encode depth pixels `(t, alpha)` (both 0..1) as a PNG in `fmt`.
1186pub fn encode_depth_png_fmt(
1187 w: u32,
1188 h: u32,
1189 pixels: &[(f64, f64)],
1190 fmt: crate::camera::DepthFormat,
1191) -> Result<Vec<u8>, String> {
1192 if w == 0 || h == 0 || pixels.len() != w as usize * h as usize {
1193 return Err("ongeldige depthafmetingen".into());
1194 }
1195 let mut samples = Vec::with_capacity(pixels.len() * fmt.bytes_per_pixel());
1196 for &(t, a) in pixels {
1197 // Transparent pixels carry no depth (alpha rule of the map contract).
1198 let t = if a > 0. { t } else { 0. };
1199 fmt.encode_pixel(t, a, &mut samples);
1200 }
1201 let mut cursor = std::io::Cursor::new(Vec::new());
1202 {
1203 let mut encoder = png::Encoder::new(&mut cursor, w, h);
1204 encoder.set_color(fmt.color_type());
1205 encoder.set_depth(fmt.bit_depth());
1206 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1207 writer
1208 .write_image_data(&samples)
1209 .map_err(|e| e.to_string())?;
1210 writer.finish().map_err(|e| e.to_string())?;
1211 }
1212 Ok(cursor.into_inner())
1213}
1214
1215/// Atomically publish a depth PNG in `fmt` from `(t, alpha)` pixels; returns SHA-256 hex.
1216pub fn write_depth_fmt(
1217 root: &Path,
1218 relative: &Path,
1219 w: u32,
1220 h: u32,
1221 pixels: &[(f64, f64)],
1222 fmt: crate::camera::DepthFormat,
1223) -> Result<String, String> {
1224 let bytes = encode_depth_png_fmt(w, h, pixels, fmt)?;
1225 let digest = crate::digest::sha256_hex(&bytes);
1226 atomic_file(root, relative, |file| {
1227 use std::io::Write;
1228 file.write_all(&bytes).map_err(|e| e.to_string())
1229 })?;
1230 Ok(digest)
1231}
1232
1233pub fn write_depth(
1234 root: &Path,
1235 relative: &Path,
1236 w: u32,
1237 h: u32,
1238 depth: &[u16],
1239) -> Result<String, String> {
1240 let bytes = encode_depth_png(w, h, depth)?;
1241 let digest = crate::digest::sha256_hex(&bytes);
1242 atomic_file(root, relative, |file| {
1243 use std::io::Write;
1244 file.write_all(&bytes).map_err(|e| e.to_string())
1245 })?;
1246 Ok(digest)
1247}
1248
1249/// Atomic no-clobber publication of already encoded bytes under the run root.
1250pub fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1251 atomic_file(root, relative, |file| {
1252 use std::io::Write;
1253 file.write_all(bytes).map_err(|e| e.to_string())
1254 })
1255}
1256
1257fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1258 use std::io::Write;
1259 let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
1260 atomic_file(root, Path::new(name), |f| {
1261 f.write_all(&data).map_err(|e| e.to_string())
1262 })
1263}
1264
1265pub struct GpuRequest {
1266 pub scene: Scene,
1267 reply: mpsc::SyncSender<Result<Buffers, String>>,
1268}
1269impl GpuRequest {
1270 pub fn fail(self, error: String) {
1271 let _ = self.reply.send(Err(error));
1272 }
1273 /// # Safety
1274 /// `gl` must be current on this UI thread.
1275 pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1276 let result = renderer.render(gl, &self.scene);
1277 let _ = self.reply.send(result);
1278 }
1279}
1280#[derive(Default)]
1281struct Pending {
1282 pass: Option<RenderEvent>,
1283 progress: Option<RenderEvent>,
1284 terminal: Option<RenderEvent>,
1285}
1286#[derive(Clone, Default)]
1287pub struct Mailbox(Arc<Mutex<Pending>>);
1288impl Mailbox {
1289 pub fn publish(&self, event: RenderEvent) {
1290 if let Ok(mut state) = self.0.lock() {
1291 match event {
1292 RenderEvent::Pass { .. } => {
1293 state.pass = Some(event);
1294 state.progress = None;
1295 }
1296 RenderEvent::Step { .. } => state.progress = Some(event),
1297 _ => state.terminal = Some(event),
1298 }
1299 }
1300 }
1301 pub fn drain(&self) -> Vec<RenderEvent> {
1302 let mut events = Vec::new();
1303 if let Ok(mut s) = self.0.lock() {
1304 events.extend(s.pass.take());
1305 events.extend(s.progress.take());
1306 events.extend(s.terminal.take());
1307 }
1308 events
1309 }
1310}
1311pub struct RenderThread {
1312 pub mailbox: Mailbox,
1313 pub gpu: mpsc::Receiver<GpuRequest>,
1314 pub cancel: Arc<AtomicBool>,
1315 pub handle: Option<thread::JoinHandle<()>>,
1316}
1317impl Drop for RenderThread {
1318 fn drop(&mut self) {
1319 self.cancel.store(true, Ordering::Relaxed);
1320 }
1321}
1322/// Where a production batch writes: a fresh run directory, or an existing
1323/// incomplete run that is recovered after contract and checksum verification.
1324#[derive(Debug, Clone, PartialEq, Eq)]
1325pub enum RunTarget {
1326 Fresh,
1327 Resume(PathBuf),
1328}
1329
1330pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {
1331 spawn_target(job, RunTarget::Fresh, ctx)
1332}
1333
1334/// Resume a selected incomplete run (see `resume`).
1335pub fn spawn_resume(
1336 job: RenderJob,
1337 run_dir: PathBuf,
1338 ctx: Option<eframe::egui::Context>,
1339) -> RenderThread {
1340 spawn_target(job, RunTarget::Resume(run_dir), ctx)
1341}
1342
1343fn spawn_target(
1344 job: RenderJob,
1345 target: RunTarget,
1346 ctx: Option<eframe::egui::Context>,
1347) -> RenderThread {
1348 let mailbox = Mailbox::default();
1349 let output = mailbox.clone();
1350 let cancel = Arc::new(AtomicBool::new(false));
1351 let flag = cancel.clone();
1352 let (tx, gpu) = mpsc::channel();
1353 let handle = thread::spawn(move || {
1354 let mut emit = |event| {
1355 output.publish(event);
1356 if let Some(ctx) = &ctx {
1357 ctx.request_repaint();
1358 }
1359 };
1360 let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);
1361 if let Err(error) = result {
1362 if flag.load(Ordering::Relaxed) {
1363 emit(RenderEvent::Cancelled);
1364 } else {
1365 emit(RenderEvent::Failed(error));
1366 }
1367 }
1368 });
1369 RenderThread {
1370 mailbox,
1371 gpu,
1372 cancel,
1373 handle: Some(handle),
1374 }
1375}
1376
1377/// The immutable export contract of a job: validated/resolved job, plan,
1378/// set token and the exact `render.json` document.
1379pub struct Contract {
1380 pub job: RenderJob,
1381 pub plan: Plan,
1382 pub set: String,
1383 pub render_doc: serde_json::Value,
1384}
1385
1386/// Validate a job and build its `render.json` contract without touching disk.
1387pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1388 job.framing.validate()?;
1389 crate::camera::DepthFormat::new(job.depth_format.bits, job.depth_format.encoding)?;
1390 let pivot = crate::camera::resolve(&job.character, job.framing);
1391 job.framing = pivot.framing;
1392 if job.target_width == 0
1393 || job.target_height == 0
1394 || job.target_width > MAX_HIGHRES / SCALE
1395 || job.target_height > MAX_HIGHRES / SCALE
1396 {
1397 return Err("8× highres vereist targetafmetingen 1–256".into());
1398 }
1399 if !["male", "female"].contains(&job.sex.as_str()) {
1400 return Err("ongeldig geslacht".into());
1401 }
1402 if let Some(p) = &job.preset {
1403 if job.character.is_static() {
1404 return Err(
1405 "statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen".into(),
1406 );
1407 }
1408 p.validate()?;
1409 if !p.applies[usize::from(job.sex == "female")] {
1410 return Err("equipment niet geldig voor dit geslacht".into());
1411 }
1412 if p.attachments.is_empty() {
1413 return Err("equipment heeft geen onderdelen".into());
1414 }
1415 }
1416 let plan = plan(&job.character, job.fps)?;
1417 let output_bytes =
1418 u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16;
1419 if output_bytes > 16 * 1024 * 1024 * 1024 {
1420 return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());
1421 }
1422 let set = job
1423 .preset
1424 .as_ref()
1425 .map(|p| p.name.as_str())
1426 .unwrap_or("body")
1427 .to_string();
1428 // Refuse up front when the per-map atlas would exceed the PNG/GPU limit,
1429 // instead of rendering for hours and failing (or splitting) at the end.
1430 crate::atlas::AtlasLayout::for_plan(&plan, job.target_width, job.target_height)?;
1431 if job.project_id.is_empty() || job.project_id.len() > 128 {
1432 return Err("project.id ongeldig voor export-identiteit".into());
1433 }
1434 if job.project_name.len() > 256 {
1435 return Err("project.name te lang voor export-identiteit".into());
1436 }
1437 if !crate::digest::is_sha256_hex(&job.body_model.sha256) {
1438 return Err("body_model.sha256 ongeldig".into());
1439 }
1440 crate::project::portable_relative(&job.body_model.path)?;
1441 match (&job.preset, &job.preset_sha256) {
1442 (None, None) => {}
1443 (Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {}
1444 _ => return Err("equipment fingerprint past niet bij de job".into()),
1445 }
1446 let equipment_preset = job.preset.as_ref().map(|p| {
1447 serde_json::json!({
1448 "name": p.name,
1449 "sha256": job.preset_sha256.as_ref().expect("checked"),
1450 })
1451 });
1452 let clips_json = plan
1453 .clips
1454 .iter()
1455 .enumerate()
1456 .map(|(i, c)| {
1457 serde_json::json!({
1458 "name": c.source_name,
1459 "token": c.token,
1460 "frames": c.frames,
1461 "duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.),
1462 })
1463 })
1464 .collect::<Vec<_>>();
1465 let (depth_near, depth_far) = crate::camera::depth_range(job.framing);
1466 let render_doc = serde_json::json!({
1467 "format_version": crate::export_set::OUTPUT_META_VERSION,
1468 "status": "incomplete",
1469 "project": {"id": job.project_id, "name": job.project_name},
1470 "sex": job.sex,
1471 "set": set,
1472 "body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256},
1473 "equipment_preset": equipment_preset,
1474 "target": [job.target_width, job.target_height],
1475 "fps": job.fps,
1476 "highres_scale": SCALE,
1477 "framing": job.framing,
1478 "pivot_source": pivot.source,
1479 "near": depth_near,
1480 "far": depth_far,
1481 "depth_near": depth_near,
1482 "depth_far": depth_far,
1483 "depth_encoding": job.depth_format.encoding.token(),
1484 "depth_bits": job.depth_format.bits,
1485 "normal_space": "view (+Y up), PNG rows top-down",
1486 "camera_distance": crate::camera::CAMERA_DISTANCE,
1487 "yaw_zero": "camera on +Z",
1488 "pitches": preview::PRODUCTION_PITCHES,
1489 "yaws": production_yaws(),
1490 "yaw_step": 30,
1491 "sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained",
1492 "alpha": "straight, opaque/cutout, coverage resolve",
1493 "diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",
1494 "depth": job.depth_format.description(),
1495 "specular": "linear scalar; body defaults zero",
1496 "pass_order": PASSES,
1497 "saved_maps": ["diffuse", "depth", "normal", "specular"],
1498 "clips": clips_json,
1499 "images": plan.images,
1500 "passes": plan.passes,
1501 "png_files": plan.files,
1502 });
1503 Ok(Contract {
1504 job,
1505 plan,
1506 set,
1507 render_doc,
1508 })
1509}
1510
1511/// Saved map tokens in per-image publication order.
1512pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"];
1513
1514/// Every planned target PNG (relative, `/`-separated) in render order, four per image.
1515pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result<Vec<String>, String> {
1516 let mut out = Vec::with_capacity(plan.files as usize);
1517 for s in samples(plan) {
1518 for map in SAVED_ORDER {
1519 let path = output_path(
1520 sex,
1521 set,
1522 &plan.clips[s.clip].token,
1523 s.frame,
1524 s.pitch,
1525 s.yaw,
1526 map,
1527 )?;
1528 out.push(path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/"));
1529 }
1530 }
1531 Ok(out)
1532}
1533
1534#[cfg(test)]
1535fn execute(
1536 job: RenderJob,
1537 gpu: &mpsc::Sender<GpuRequest>,
1538 cancel: &AtomicBool,
1539 ctx: Option<&eframe::egui::Context>,
1540 emit: &mut dyn FnMut(RenderEvent),
1541) -> Result<(), String> {
1542 execute_target(job, RunTarget::Fresh, gpu, cancel, ctx, emit)
1543}
1544
1545fn execute_target(
1546 job: RenderJob,
1547 target: RunTarget,
1548 gpu: &mpsc::Sender<GpuRequest>,
1549 cancel: &AtomicBool,
1550 ctx: Option<&eframe::egui::Context>,
1551 emit: &mut dyn FnMut(RenderEvent),
1552) -> Result<(), String> {
1553 let Contract {
1554 mut job,
1555 plan,
1556 set,
1557 render_doc,
1558 } = contract(job)?;
1559 let planned = planned_paths(&plan, &job.sex, &set)?;
1560 std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1561 for note in crate::resume::recover(&job.output)? {
1562 emit(RenderEvent::Step {
1563 index: 0,
1564 total: plan.passes,
1565 label: format!("Herstel: {note}"),
1566 });
1567 }
1568 let mut session = match target {
1569 RunTarget::Fresh => {
1570 for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? {
1571 emit(RenderEvent::Step {
1572 index: 0,
1573 total: plan.passes,
1574 label: format!("Oude onvoltooide render verwijderd: {name}"),
1575 });
1576 }
1577 let stamp = std::time::SystemTime::now()
1578 .duration_since(std::time::UNIX_EPOCH)
1579 .map_err(|e| e.to_string())?
1580 .as_nanos();
1581 let root = job.output.join(crate::resume::in_progress_name(
1582 stamp,
1583 std::process::id(),
1584 &job.sex,
1585 &set,
1586 ));
1587 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1588 let session = crate::resume::RunSession::fresh(&root)?;
1589 write_json(&root, "render.json", render_doc.clone())?;
1590 session
1591 }
1592 RunTarget::Resume(dir) => {
1593 let total = plan.passes;
1594 crate::resume::RunSession::resume(
1595 &job.output,
1596 &dir,
1597 &render_doc,
1598 &planned,
1599 &mut |done, all| {
1600 emit(RenderEvent::Step {
1601 index: 0,
1602 total,
1603 label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"),
1604 })
1605 },
1606 )?
1607 }
1608 };
1609 for clip in &job.character.clips {
1610 for reason in &clip.skipped {
1611 emit(RenderEvent::Step {
1612 index: 0,
1613 total: plan.passes,
1614 label: format!(
1615 "Let op: clip '{}': kanaal niet toegepast: {reason}",
1616 clip.name
1617 ),
1618 });
1619 }
1620 }
1621 let root = session.root.clone();
1622 // Internal unique names select every source clip, even duplicate display names.
1623 for (i, c) in job.character.clips.iter_mut().enumerate() {
1624 c.name = format!("render-clip-{i}");
1625 }
1626 let mut completed = 0u32;
1627 let mut clamped_views = 0u32;
1628 let mut clamped_pixels = 0u64;
1629 let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
1630 let mut cache = equipment::TextureCache::default();
1631 let result = (|| {
1632 for (index, sample) in samples(&plan).enumerate() {
1633 let clip = &plan.clips[sample.clip];
1634 let frame = sample.frame;
1635 let pitch = sample.pitch;
1636 let yaw = sample.yaw;
1637 let paths = &planned[index * 4..index * 4 + 4];
1638 if cancel.load(Ordering::Relaxed) {
1639 return Err("geannuleerd".into());
1640 }
1641 let pose = format!(
1642 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1643 job.sex, clip.source_name, clip.frames
1644 );
1645 if paths.iter().all(|p| session.verified.contains_key(p)) {
1646 for p in paths {
1647 png_sha256.insert(p.clone(), session.verified[p].clone());
1648 }
1649 completed += PASSES.len() as u32;
1650 emit(RenderEvent::Step {
1651 index: completed,
1652 total: plan.passes,
1653 label: format!("{pose} · al aanwezig, checksums kloppen"),
1654 });
1655 continue;
1656 }
1657 let key = if job.character.clips.is_empty() {
1658 String::new()
1659 } else {
1660 format!("render-clip-{}", sample.clip)
1661 };
1662 emit(RenderEvent::Step {
1663 index: completed,
1664 total: plan.passes,
1665 label: format!("{pose} · highres"),
1666 });
1667 let scene = scene(
1668 &job,
1669 &key,
1670 frame as f32 / job.fps as f32,
1671 pitch as f32,
1672 yaw as f32,
1673 &mut cache,
1674 )?;
1675 let (tx, rx) = mpsc::sync_channel(1);
1676 gpu.send(GpuRequest { scene, reply: tx })
1677 .map_err(|_| "GPU-service gesloten")?;
1678 if let Some(ctx) = ctx {
1679 ctx.request_repaint();
1680 }
1681 let raw = loop {
1682 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1683 Ok(result) => break result?,
1684 Err(mpsc::RecvTimeoutError::Timeout) => {
1685 if cancel.load(Ordering::Relaxed) {
1686 return Err("geannuleerd".into());
1687 }
1688 }
1689 Err(_) => return Err("GPU-service gestopt".into()),
1690 }
1691 };
1692 let clamped = clamped_depth_pixels(&raw);
1693 if clamped > 0 {
1694 clamped_views += 1;
1695 clamped_pixels += clamped as u64;
1696 emit(RenderEvent::Step {
1697 index: completed,
1698 total: plan.passes,
1699 label: format!(
1700 "Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)",
1701 render_doc["depth_near"].as_f64().unwrap_or(0.),
1702 render_doc["depth_far"].as_f64().unwrap_or(0.)
1703 ),
1704 });
1705 }
1706 completed += 1;
1707 emit(RenderEvent::Pass {
1708 index: completed - 1,
1709 total: plan.passes,
1710 label: format!("{pose} · highres"),
1711 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1712 });
1713 if cancel.load(Ordering::Relaxed) {
1714 return Err("geannuleerd".into());
1715 }
1716 emit(RenderEvent::Step {
1717 index: completed,
1718 total: plan.passes,
1719 label: format!("{pose} · detail"),
1720 });
1721 let detail = detail_map(&raw)?;
1722 completed += 1;
1723 emit(RenderEvent::Pass {
1724 index: completed - 1,
1725 total: plan.passes,
1726 label: format!("{pose} · detail"),
1727 image: Some(detail_image(&raw, &detail)),
1728 });
1729 if cancel.load(Ordering::Relaxed) {
1730 return Err("geannuleerd".into());
1731 }
1732 emit(RenderEvent::Step {
1733 index: completed,
1734 total: plan.passes,
1735 label: format!("{pose} · target-res / diffuse"),
1736 });
1737 let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1738 let depth_placeholder = image(job.target_width, job.target_height, Vec::new());
1739 for ((map, mut preview), rel) in [
1740 ("diffuse", target.diffuse.clone()),
1741 ("depth", depth_placeholder),
1742 ("normal", target.normal.clone()),
1743 ("specular", target.specular.clone()),
1744 ]
1745 .into_iter()
1746 .zip(paths)
1747 {
1748 if cancel.load(Ordering::Relaxed) {
1749 return Err("geannuleerd".into());
1750 }
1751 emit(RenderEvent::Step {
1752 index: completed,
1753 total: plan.passes,
1754 label: format!("{pose} · {map}"),
1755 });
1756 debug_assert!(rel.ends_with(&format!("_{map}.png")));
1757 let kept = session.verified.contains_key(rel);
1758 let digest = if kept {
1759 if map == "depth" {
1760 let stored = std::fs::read(session.root.join(rel))
1761 .map_err(|e| format!("{rel}: {e}"))?;
1762 preview = stored_depth_preview(
1763 &stored,
1764 job.target_width,
1765 job.target_height,
1766 job.depth_format,
1767 )?;
1768 }
1769 session.verified[rel].clone()
1770 } else {
1771 let bytes = if map == "depth" {
1772 let pixels: Vec<(f64, f64)> = target
1773 .depth_t
1774 .iter()
1775 .enumerate()
1776 .map(|(i, t)| {
1777 (f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)
1778 })
1779 .collect();
1780 let bytes = encode_depth_png_fmt(
1781 job.target_width,
1782 job.target_height,
1783 &pixels,
1784 job.depth_format,
1785 )?;
1786 preview = stored_depth_preview(
1787 &bytes,
1788 job.target_width,
1789 job.target_height,
1790 job.depth_format,
1791 )?;
1792 bytes
1793 } else {
1794 encode_rgba_png(&preview, map == "diffuse")?
1795 };
1796 session.publish(rel, &bytes)?
1797 };
1798 png_sha256.insert(rel.clone(), digest);
1799 completed += 1;
1800 let pass = if map == "diffuse" {
1801 "target-res / diffuse"
1802 } else {
1803 map
1804 };
1805 emit(RenderEvent::Pass {
1806 index: completed - 1,
1807 total: plan.passes,
1808 label: if kept {
1809 format!("{pose} · {pass} (bestaand bestand behouden)")
1810 } else {
1811 format!("{pose} · {pass}")
1812 },
1813 image: Some(preview),
1814 });
1815 }
1816 }
1817 if png_sha256.len() as u32 != plan.files {
1818 return Err(format!(
1819 "checksumtelling {} wijkt af van geplande {} PNGs",
1820 png_sha256.len(),
1821 plan.files
1822 ));
1823 }
1824 // Atlases + .ppda only after every loose map is published and verified
1825 // (also after a resume); complete.json stays the final marker.
1826 crate::atlas::write_atlases(
1827 &crate::atlas::AtlasJob {
1828 root: &root,
1829 render_doc: &render_doc,
1830 plan: &plan,
1831 sex: &job.sex,
1832 set: &set,
1833 cell_width: job.target_width,
1834 cell_height: job.target_height,
1835 png_sha256: &png_sha256,
1836 attempt: session.attempt,
1837 cancel,
1838 },
1839 &mut |label| {
1840 emit(RenderEvent::Step {
1841 index: completed,
1842 total: plan.passes,
1843 label,
1844 })
1845 },
1846 )
1847 })();
1848 match result {
1849 Ok(atlas) => {
1850 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1851 // In-progress completion marker only; removed again by
1852 // strip_bookkeeping once the .ppda (which embeds the export
1853 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
1854 write_json(
1855 &root,
1856 crate::resume::COMPLETE_MARKER,
1857 serde_json::json!({
1858 "format_version": crate::export_set::OUTPUT_META_VERSION,
1859 "status": "complete",
1860 "identity": identity,
1861 "passes": completed,
1862 "png_files": plan.files,
1863 "png_sha256": png_sha256,
1864 "atlas": atlas.to_json(),
1865 "depth_clamped": {
1866 "views": clamped_views,
1867 "highres_pixels": clamped_pixels,
1868 "note": "rendered this attempt; kept files from a resumed run are not re-counted",
1869 },
1870 }),
1871 )?;
1872 if clamped_views > 0 {
1873 emit(RenderEvent::Step {
1874 index: completed,
1875 total: plan.passes,
1876 label: format!(
1877 "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)"
1878 ),
1879 });
1880 }
1881 // Verify the finished build before it replaces the last good result.
1882 crate::atlas::AtlasSet::open_run(&root)
1883 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1884 crate::export_set::ExportRun::open(&root)
1885 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1886 drop(session);
1887 // Lock, journal and resume bookkeeping stay out of the result folder.
1888 crate::resume::strip_bookkeeping(&root)?;
1889 let final_dir = crate::resume::promote(
1890 &job.output,
1891 &root,
1892 &crate::resume::result_dir_name(&job.sex, &set),
1893 )?;
1894 emit(RenderEvent::Finished {
1895 saved: Some(final_dir.display().to_string()),
1896 });
1897 Ok(())
1898 }
1899 Err(error) => {
1900 let name = if cancel.load(Ordering::Relaxed) {
1901 "cancelled.json"
1902 } else {
1903 "failed.json"
1904 };
1905 let _ = write_json(
1906 &root,
1907 name,
1908 serde_json::json!({
1909 "error": error,
1910 "completed_passes": completed,
1911 "total_passes": plan.passes,
1912 "png_files_done": png_sha256.len(),
1913 "png_files": plan.files,
1914 "attempt": session.attempt,
1915 }),
1916 );
1917 Err(error)
1918 }
1919 }
1920}
1921
1922/// Real readback regression: nearest-surface depth/normal/specular, preserved
1923/// texture × vertex albedo/alpha and independence from opposed light directions.
1924/// # Safety
1925/// Run on a current disposable native context.
1926pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> {
1927 let triangle = |z, normal, color, specular| Draw {
1928 positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]),
1929 normals: Arc::new(vec![normal; 3]),
1930 uvs: Arc::new(vec![[0.; 2]; 3]),
1931 vertex_colors: Arc::new(Vec::new()),
1932 indices: vec![0, 1, 2],
1933 color,
1934 albedo: None,
1935 shiny: None,
1936 specular,
1937 cutoff: 0.001,
1938 flat_normals: false,
1939 };
1940 let mut renderer = GpuRenderer::default();
1941 let mut scene = Scene {
1942 draws: vec![
1943 triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75),
1944 triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.),
1945 ],
1946 view: preview::identity(),
1947 projection: preview::identity(),
1948 width: 32,
1949 height: 32,
1950 };
1951 let result = (|| {
1952 let raw = renderer.render(gl, &scene)?;
1953 let i = 16 * 32 + 16;
1954 if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255]
1955 || (raw.depth[i] - 0.25).abs() > 1e-5
1956 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255]
1957 || raw.specular[i * 4].abs_diff(191) > 1
1958 {
1959 return Err(format!(
1960 "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}",
1961 &raw.diffuse[i * 4..i * 4 + 4],
1962 raw.depth[i],
1963 &raw.normal[i * 4..i * 4 + 4],
1964 raw.specular[i * 4]
1965 ));
1966 }
1967 scene.draws.reverse();
1968 let reverse = renderer.render(gl, &scene)?;
1969 if reverse.diffuse != raw.diffuse
1970 || reverse.normal != raw.normal
1971 || reverse.depth != raw.depth
1972 {
1973 return Err("GPU map winner depends on submission order".into());
1974 }
1975 scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)];
1976 scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]);
1977 scene.draws[0].albedo = Some(Arc::new(equipment::Texture {
1978 content_hash: 99,
1979 width: 1,
1980 height: 1,
1981 rgba: vec![128, 255, 64, 192],
1982 }));
1983 let expected = [
1984 srgb(linear(128) * 0.25),
1985 srgb(0.75),
1986 srgb(linear(64) * 0.5),
1987 192,
1988 ];
1989 // Production intentionally has no lighting input. This optional conventional
1990 // uniform is optimized out now; a future accidental lighting term using it
1991 // must fail these actual GPU readbacks under opposed light directions.
1992 for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] {
1993 let p = renderer.program.ok_or("missing probe program")?;
1994 gl.use_program(Some(p));
1995 gl.uniform_3_f32(
1996 gl.get_uniform_location(p, "u_light_direction").as_ref(),
1997 light[0],
1998 light[1],
1999 light[2],
2000 );
2001 let output = renderer.render(gl, &scene)?;
2002 let pixel = &output.diffuse[i * 4..i * 4 + 4];
2003 if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) {
2004 return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"));
2005 }
2006 }
2007 let detail = detail_map(&raw)?;
2008 let target = resolve(&raw, &detail, 4, 4)?;
2009 if target.diffuse.width != 4
2010 || target.depth.len() != 32
2011 || target
2012 .normal
2013 .rgba
2014 .chunks_exact(4)
2015 .filter(|p| p[3] > 0)
2016 .any(|p| {
2017 let n = p[..3]
2018 .iter()
2019 .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2))
2020 .sum::<f32>()
2021 .sqrt();
2022 (n - 1.).abs() > 0.03
2023 })
2024 {
2025 return Err("target map dimensions/normals invalid".into());
2026 }
2027 let mut flat = Scene {
2028 draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)],
2029 view: preview::identity(),
2030 projection: preview::identity(),
2031 width: 32,
2032 height: 32,
2033 };
2034 flat.draws[0].flat_normals = true;
2035 let derived = renderer.render(gl, &flat)?;
2036 if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] {
2037 return Err(format!(
2038 "flat normals are not geometry-derived: {:?}",
2039 &derived.normal[i * 4..i * 4 + 4]
2040 ));
2041 }
2042 println!("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives");
2043 Ok(())
2044 })();
2045 renderer.destroy(gl);
2046 result
2047}
2048
2049#[cfg(test)]
2050mod tests {
2051 use super::*;
2052 struct Temp(PathBuf);
2053 impl Temp {
2054 fn new() -> Self {
2055 let p = std::env::temp_dir().join(format!(
2056 "paperdoll-production-{}-{}",
2057 std::process::id(),
2058 std::time::SystemTime::now()
2059 .duration_since(std::time::UNIX_EPOCH)
2060 .unwrap()
2061 .as_nanos()
2062 ));
2063 std::fs::create_dir(&p).unwrap();
2064 Self(p)
2065 }
2066 }
2067 impl Drop for Temp {
2068 fn drop(&mut self) {
2069 let _ = std::fs::remove_dir_all(&self.0);
2070 }
2071 }
2072 fn body() -> PreviewCharacter {
2073 preview::load_gltf_file(
2074 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
2075 )
2076 .unwrap()
2077 }
2078 fn raw(w: u32, h: u32) -> Buffers {
2079 let n = (w * h) as usize;
2080 Buffers {
2081 width: w,
2082 height: h,
2083 diffuse: [255, 0, 0, 255].repeat(n),
2084 normal: [128, 128, 255, 255].repeat(n),
2085 specular: [191, 191, 191, 255].repeat(n),
2086 depth: vec![0.25; n],
2087 }
2088 }
2089 #[test]
2090 fn job_counts_every_clip_frame_angle_and_exact_order() {
2091 let mut b = body();
2092 let mut c = b.clips[0].clone();
2093 c.name = "../A \u{2603}".into();
2094 c.duration = 0.5;
2095 b.clips = vec![c.clone(), c];
2096 let p = plan(&b, 3).unwrap();
2097 // 2 clips × 2 frames × 60 views.
2098 assert_eq!((p.images, p.passes, p.files), (240, 1440, 960));
2099 let s = samples(&p).collect::<Vec<_>>();
2100 assert_eq!(s.len(), 240);
2101 assert_eq!(
2102 s[0],
2103 Sample {
2104 clip: 0,
2105 frame: 0,
2106 pitch: -60,
2107 yaw: 0
2108 }
2109 );
2110 assert_eq!(s[11].yaw, 330);
2111 assert_eq!(s[12].pitch, -30);
2112 assert_eq!(s[24].pitch, 0);
2113 assert_eq!(s[36].pitch, 30);
2114 assert_eq!(s[59].pitch, 60);
2115 assert_eq!(s[60].frame, 1);
2116 assert_eq!(s[120].clip, 1);
2117 assert_ne!(p.clips[0].token, p.clips[1].token);
2118 assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
2119 assert_eq!(
2120 PASSES,
2121 [
2122 "highres",
2123 "detail",
2124 "target-res / diffuse",
2125 "depth",
2126 "normal",
2127 "specular"
2128 ]
2129 );
2130 b.clips.clear();
2131 let p = plan(&b, 12).unwrap();
2132 assert_eq!((p.images, p.files), (60, 240));
2133 }
2134 #[test]
2135 fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
2136 let mut b = body();
2137 b.clips.truncate(1);
2138 b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1);
2139 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2);
2140 b.clips[0].duration = 1.00001;
2141 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3);
2142 }
2143 #[test]
2144 fn planner_rejects_invalid_or_unbounded_inputs() {
2145 let mut b = body();
2146 assert!(plan(&b, 0).is_err());
2147 assert!(plan(&b, 241).is_err());
2148 for duration in [f32::NAN, f32::INFINITY, -1., 10000.] {
2149 b.clips[0].duration = duration;
2150 assert!(plan(&b, 12).is_err());
2151 }
2152 }
2153 #[test]
2154 fn output_tokens_only_four_target_maps() {
2155 let expected =
2156 PathBuf::from("raw files/male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
2157 assert_eq!(
2158 output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
2159 expected
2160 );
2161 assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok());
2162 assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err());
2163 assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err());
2164 for map in ["diffuse", "normal", "depth", "specular"] {
2165 assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
2166 }
2167 for map in ["highres", "detail", "shiny", "../x"] {
2168 assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err());
2169 }
2170 for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] {
2171 assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err());
2172 }
2173 assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err());
2174 assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());
2175 }
2176 #[test]
2177 fn resolve_preserves_thin_foreground_and_matching_maps() {
2178 let mut r = raw(8, 8);
2179 r.diffuse = [0, 255, 0, 255].repeat(64);
2180 r.depth.fill(0.75);
2181 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
2182 r.depth[0] = 0.25;
2183 let d = detail_map(&r).unwrap();
2184 assert!(d.iter().any(|x| *x > 0));
2185 let t = resolve(&r, &d, 1, 1).unwrap();
2186 assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]);
2187 assert_eq!(t.specular.rgba, [191, 191, 191, 255]);
2188 assert!((t.depth[0] as i32 - 16384).abs() <= 1);
2189 let n = &t.normal.rgba;
2190 let len = (0..3)
2191 .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2))
2192 .sum::<f32>()
2193 .sqrt();
2194 assert!((len - 1.).abs() < 0.01);
2195 r.diffuse.fill(0);
2196 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
2197 let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap();
2198 assert_eq!(t.diffuse.rgba[3], 4);
2199 assert_eq!(t.depth[1], 1028);
2200 assert_eq!(t.normal.rgba[3], 4);
2201 assert_eq!(t.specular.rgba[3], 4);
2202 }
2203 fn meta_framing(meta: &serde_json::Value) -> CameraFraming {
2204 serde_json::from_value(meta["framing"].clone()).unwrap()
2205 }
2206
2207 #[test]
2208 fn clamped_depth_counts_only_covered_pixels_on_the_planes() {
2209 let mut raw = Buffers {
2210 width: 4,
2211 height: 1,
2212 diffuse: vec![255; 16],
2213 normal: vec![0; 16],
2214 specular: vec![0; 16],
2215 depth: vec![0., 0.5, 1., 1.],
2216 };
2217 raw.diffuse[15] = 0; // uncovered background at far plane
2218 assert_eq!(clamped_depth_pixels(&raw), 2);
2219 }
2220
2221 #[test]
2222 fn invalid_map_dimensions_and_depth_fail_without_panics() {
2223 let mut r = raw(8, 8);
2224 r.normal.pop();
2225 assert!(detail_map(&r).is_err());
2226 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
2227 let mut r = raw(8, 8);
2228 r.depth[0] = f32::NAN;
2229 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
2230 let r = raw(8, 8);
2231 assert!(resolve(&r, &[0; 63], 1, 1).is_err());
2232 assert!(resolve(&r, &[0; 64], 0, 1).is_err());
2233 assert!(resolve(&r, &[0; 64], 257, 1).is_err());
2234 }
2235 #[test]
2236 fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() {
2237 let t = Temp::new();
2238 let p = Path::new("map_diffuse.png");
2239 write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap();
2240 let original = std::fs::read(t.0.join(p)).unwrap();
2241 assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err());
2242 assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original);
2243 let mut reader = png::Decoder::new(std::io::Cursor::new(original))
2244 .read_info()
2245 .unwrap();
2246 assert!(reader.info().srgb.is_some());
2247 let mut out = vec![0; reader.output_buffer_size().unwrap()];
2248 reader.next_frame(&mut out).unwrap();
2249 assert_eq!(out, [128, 64, 32, 192]);
2250 write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap();
2251 let mut reader = png::Decoder::new(std::io::BufReader::new(
2252 std::fs::File::open(t.0.join("depth.png")).unwrap(),
2253 ))
2254 .read_info()
2255 .unwrap();
2256 assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen);
2257 assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha);
2258 assert!(reader.info().srgb.is_none());
2259 let mut out = vec![0; reader.output_buffer_size().unwrap()];
2260 reader.next_frame(&mut out).unwrap();
2261 assert_eq!(out, [64, 0, 128, 128]);
2262 let tmp =
2263 t.0.join("blocked.png")
2264 .with_extension(format!("part-{}", std::process::id()));
2265 std::fs::write(&tmp, b"keep").unwrap();
2266 // A foreign/stale temp (e.g. left by a crashed process with a reused PID)
2267 // is never deleted or reused; per-write unique temp names still publish.
2268 write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap();
2269 assert_eq!(std::fs::read(tmp).unwrap(), b"keep");
2270 let temps = std::fs::read_dir(&t.0)
2271 .unwrap()
2272 .filter_map(|e| e.ok())
2273 .filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-"))
2274 .count();
2275 assert_eq!(temps, 1, "own temp files are removed, foreign ones kept");
2276 assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());
2277 assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());
2278 assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());
2279 }
2280 #[cfg(unix)]
2281 #[test]
2282 fn output_rejects_escaping_symlinks() {
2283 let root = Temp::new();
2284 let outside = Temp::new();
2285 std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap();
2286 assert!(write_rgba(
2287 &root.0,
2288 Path::new("link/sub/x.png"),
2289 &image(1, 1, vec![0; 4])
2290 )
2291 .is_err());
2292 assert!(!outside.0.join("sub").exists());
2293 }
2294 #[test]
2295 fn latest_mailbox_is_bounded_and_retains_terminal() {
2296 let m = Mailbox::default();
2297 for i in 0..1000 {
2298 m.publish(RenderEvent::Pass {
2299 index: i,
2300 total: 1000,
2301 label: i.to_string(),
2302 image: Some(image(1, 1, vec![0; 4])),
2303 });
2304 }
2305 m.publish(RenderEvent::Step {
2306 index: 999,
2307 total: 1000,
2308 label: "working".into(),
2309 });
2310 m.publish(RenderEvent::Cancelled);
2311 let events = m.drain();
2312 assert_eq!(events.len(), 3);
2313 assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. }));
2314 assert!(matches!(&events[2], RenderEvent::Cancelled));
2315 assert!(m.drain().is_empty());
2316 }
2317
2318 #[test]
2319 fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {
2320 let temp = Temp::new();
2321 let mut job = RenderJob {
2322 depth_format: crate::camera::DepthFormat::GRAY16,
2323 character: body(),
2324 sex: "male".into(),
2325 preset: None,
2326 target_width: 64,
2327 target_height: 96,
2328 fps: 12,
2329 framing: CameraFraming {
2330 height_milli: 3100,
2331 center_y_milli: 1200,
2332 pivot_y_milli: Some(1200),
2333 depth_range_milli: None,
2334 },
2335 output: temp.0.clone(),
2336 project_id: "test-project".into(),
2337 project_name: "Test".into(),
2338 body_model: SourceFingerprint {
2339 path: "assets/body/test.glb".into(),
2340 sha256: crate::digest::sha256_hex(b"test-body"),
2341 },
2342 preset_sha256: None,
2343 };
2344 let mut cache = equipment::TextureCache::default();
2345 let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2346 let mut preset = equipment::Preset::new("sword".into());
2347 let fixture =
2348 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj");
2349 equipment::import_obj(&mut preset, &fixture).unwrap();
2350 let joint = job
2351 .character
2352 .joint_names
2353 .iter()
2354 .position(|n| n == "RightHand")
2355 .unwrap();
2356 preset.bind(0, &job.character, 0, joint).unwrap();
2357 let count = preset
2358 .attachments
2359 .iter()
2360 .map(|a| a.meshes.len())
2361 .sum::<usize>();
2362 job.preset = Some(preset);
2363 let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2364 assert_eq!(e.draws.len(), count);
2365 assert_eq!(b.view, e.view);
2366 assert_eq!(b.projection, e.projection);
2367 assert_eq!((e.width, e.height), (512, 768));
2368 assert_eq!(e.projection[5], 2. / 3.1);
2369 let center = point(&e.view, [0., 1.2, 0.]);
2370 assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001);
2371 assert!((center[2] + 10.).abs() < 0.0001);
2372 let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap();
2373 let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap();
2374 assert_eq!(early.view, late.view);
2375 assert_eq!(early.projection, late.projection);
2376 assert_ne!(early.draws[0].positions, late.draws[0].positions);
2377 let mut invalid = e.clone();
2378 invalid.view[0] = f32::NAN;
2379 assert!(invalid.validate().is_err());
2380 }
2381 #[test]
2382 fn body_import_retains_texture_material_and_linear_vertex_color() {
2383 let temp = Temp::new();
2384 let source = std::fs::read(
2385 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
2386 )
2387 .unwrap();
2388 let glb = gltf::binary::Glb::from_slice(&source).unwrap();
2389 let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap();
2390 let mut bin = glb.bin.unwrap().into_owned();
2391 while !bin.len().is_multiple_of(4) {
2392 bin.push(0);
2393 }
2394 let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"]
2395 .as_u64()
2396 .unwrap() as usize;
2397 let count = json["accessors"][position]["count"].as_u64().unwrap() as usize;
2398 let offset = bin.len();
2399 for _ in 0..count {
2400 for value in [0.25f32, 0.75, 0.5, 0.5] {
2401 bin.extend(value.to_le_bytes());
2402 }
2403 }
2404 let view = json["bufferViews"].as_array().unwrap().len();
2405 json["bufferViews"]
2406 .as_array_mut()
2407 .unwrap()
2408 .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16}));
2409 let accessor = json["accessors"].as_array().unwrap().len();
2410 json["accessors"].as_array_mut().unwrap().push(
2411 serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}),
2412 );
2413 json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor);
2414 json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()});
2415 json["images"] = serde_json::json!([{"uri":"albedo.png"}]);
2416 json["textures"] = serde_json::json!([{"source":0}]);
2417 let material = json["meshes"][0]["primitives"][0]["material"]
2418 .as_u64()
2419 .unwrap() as usize;
2420 json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] =
2421 serde_json::json!({"index":0});
2422 json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] =
2423 serde_json::json!([0.5, 0.75, 1., 1.]);
2424 std::fs::write(temp.0.join("geometry.bin"), bin).unwrap();
2425 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2426 write_rgba(
2427 &temp.0,
2428 Path::new("albedo.png"),
2429 &image(1, 1, vec![128, 255, 64, 192]),
2430 )
2431 .unwrap();
2432 let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2433 assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]);
2434 assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]);
2435 assert_eq!(
2436 body.surfaces[0].albedo.as_ref().unwrap().rgba,
2437 [128, 255, 64, 192]
2438 );
2439 assert_eq!(body.colors.len(), body.positions.len());
2440 assert_eq!(body.uvs.len(), body.positions.len());
2441 assert!(body.surfaces[0].flat_normals);
2442 json["meshes"][0]["primitives"][0]["attributes"]
2443 .as_object_mut()
2444 .unwrap()
2445 .remove("NORMAL");
2446 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2447 let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2448 assert!(!missing.source_normals_complete);
2449 let job = RenderJob {
2450 depth_format: crate::camera::DepthFormat::GRAY16,
2451 character: missing,
2452 sex: "male".into(),
2453 preset: None,
2454 target_width: 1,
2455 target_height: 1,
2456 fps: 12,
2457 framing: CameraFraming::default(),
2458 output: temp.0.clone(),
2459 project_id: "test-project".into(),
2460 project_name: "Test".into(),
2461 body_model: SourceFingerprint {
2462 path: "assets/body/test.glb".into(),
2463 sha256: crate::digest::sha256_hex(b"test-body"),
2464 },
2465 preset_sha256: None,
2466 };
2467 assert!(
2468 scene(
2469 &job,
2470 "",
2471 0.,
2472 0.,
2473 0.,
2474 &mut equipment::TextureCache::default()
2475 )
2476 .unwrap()
2477 .draws[0]
2478 .flat_normals
2479 );
2480 }
2481 #[test]
2482 fn complete_and_cancelled_batches_have_truthful_atomic_outputs() {
2483 for stop in [false, true] {
2484 let tmp = Temp::new();
2485 let mut b = body();
2486 b.clips.clear();
2487 let job = RenderJob {
2488 depth_format: crate::camera::DepthFormat::GRAY16,
2489 character: b,
2490 sex: "male".into(),
2491 preset: None,
2492 target_width: 1,
2493 target_height: 1,
2494 fps: 12,
2495 framing: CameraFraming::default(),
2496 output: tmp.0.clone(),
2497 project_id: "test-project".into(),
2498 project_name: "Test".into(),
2499 body_model: SourceFingerprint {
2500 path: "assets/body/test.glb".into(),
2501 sha256: crate::digest::sha256_hex(b"test-body"),
2502 },
2503 preset_sha256: None,
2504 };
2505 let flag = AtomicBool::new(false);
2506 let (tx, rx) = mpsc::channel::<GpuRequest>();
2507 let gpu = std::thread::spawn(move || {
2508 for request in rx {
2509 let _ = request
2510 .reply
2511 .send(Ok(raw(request.scene.width, request.scene.height)));
2512 }
2513 });
2514 let mut labels = Vec::new();
2515 let result = execute(job, &tx, &flag, None, &mut |event| {
2516 if let RenderEvent::Pass { label, .. } = event {
2517 labels.push(label);
2518 if stop && labels.len() == 3 {
2519 flag.store(true, Ordering::Relaxed);
2520 }
2521 }
2522 });
2523 drop(tx);
2524 gpu.join().unwrap();
2525 let run = std::fs::read_dir(&tmp.0)
2526 .unwrap()
2527 .next()
2528 .unwrap()
2529 .unwrap()
2530 .path();
2531 // A finished folder has no bookkeeping JSON; a cancelled build keeps it.
2532 assert_eq!(run.join("render.json").exists(), stop);
2533 assert!(!run.join("complete.json").exists());
2534 assert_eq!(run.join(crate::resume::JOURNAL).exists(), stop);
2535 assert_eq!(run.join(crate::resume::LOCK).exists(), stop);
2536 assert_eq!(run.join("cancelled.json").exists(), stop);
2537 assert_eq!(result.is_ok(), !stop);
2538 let ppda_export = (!stop).then(|| {
2539 crate::atlas::AtlasSet::open_run(&run)
2540 .unwrap()
2541 .ppda
2542 .export
2543 .unwrap()
2544 });
2545 let meta: serde_json::Value = match &ppda_export {
2546 Some(export) => export.render.clone(),
2547 None => {
2548 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2549 .unwrap()
2550 }
2551 };
2552 assert_eq!(meta["format_version"], 6);
2553 let (near, far) = crate::camera::depth_range(meta_framing(&meta));
2554 assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);
2555 assert_eq!(meta["depth_far"].as_f64().unwrap() as f32, far);
2556 assert_eq!(meta["near"], meta["depth_near"]);
2557 assert_eq!(meta["depth_encoding"], "gray");
2558 assert_eq!(meta["depth_bits"], 16);
2559 assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2560 assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2561 assert_eq!(meta["fps"], 12);
2562 assert_eq!(meta["project"]["id"], "test-project");
2563 assert_eq!(
2564 meta["body_model"]["sha256"],
2565 crate::digest::sha256_hex(b"test-body")
2566 );
2567 assert_eq!(meta["framing"]["pivot_y_milli"], 880);
2568 assert_eq!(meta["pivot_source"]["kind"], "bounds_center");
2569 let (lo, hi) = (
2570 meta["pivot_source"]["min_y_milli"].as_i64().unwrap(),
2571 meta["pivot_source"]["max_y_milli"].as_i64().unwrap(),
2572 );
2573 assert_eq!((lo + hi) / 2, 880);
2574 assert_eq!(
2575 meta["saved_maps"]
2576 .as_array()
2577 .unwrap()
2578 .iter()
2579 .map(|v| v.as_str().unwrap())
2580 .collect::<Vec<_>>(),
2581 ["diffuse", "depth", "normal", "specular"]
2582 );
2583 if !stop {
2584 let checksums = &ppda_export.as_ref().unwrap().png_sha256;
2585 assert_eq!(checksums.len(), 240);
2586 for (rel, digest) in checksums {
2587 assert!(rel.starts_with("raw files/"), "{rel}");
2588 assert!(crate::digest::is_sha256_hex(digest));
2589 let bytes = std::fs::read(run.join(rel)).unwrap();
2590 assert_eq!(*digest, crate::digest::sha256_hex(&bytes));
2591 }
2592 let mut top: Vec<String> = std::fs::read_dir(&run)
2593 .unwrap()
2594 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2595 .filter(|n| !n.ends_with(".png"))
2596 .collect();
2597 top.sort();
2598 assert_eq!(top, ["male-body-atlas.ppda", "raw files"]);
2599 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2600 let sample = opened
2601 .load_map(
2602 "clip000-bind",
2603 0,
2604 -60,
2605 0,
2606 crate::export_set::MapKind::Diffuse,
2607 )
2608 .unwrap();
2609 assert_eq!(sample.width(), 1);
2610 // Atlases + .ppda: one per map, written before complete.json.
2611 for map in ["diffuse", "normal", "depth", "specular"] {
2612 let name = format!("male-body-{map}-atlas.png");
2613 assert!(run.join(&name).is_file(), "{name}");
2614 assert_eq!(opened.complete.atlas.maps[map].file, name);
2615 }
2616 assert!(!stop);
2617 let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
2618 assert_eq!(atlas.ppda.fps, 12);
2619 assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1));
2620 let loaded = atlas.load().unwrap();
2621 for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] {
2622 for kind in crate::atlas::ATLAS_MAPS {
2623 assert_eq!(
2624 loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(),
2625 opened
2626 .load_map("clip000-bind", 0, pitch, yaw, kind)
2627 .unwrap()
2628 );
2629 }
2630 }
2631 } else {
2632 assert!(!run.join("male-body-atlas.ppda").exists());
2633 assert!(!run.join("male-body-diffuse-atlas.png").exists());
2634 }
2635 let frame = run.join("raw files/male/body/clip000-bind/f000000");
2636 assert!(frame.join("p-60_y000_diffuse.png").exists());
2637 assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2638 assert!(!frame.join("p-60_y000_highres.png").exists());
2639 assert!(!frame.join("p-60_y000_detail.png").exists());
2640 if !stop {
2641 assert_eq!(labels.len(), 360);
2642 for labels in labels.chunks_exact(6) {
2643 for (label, pass) in labels.iter().zip([
2644 "highres",
2645 "detail",
2646 "target-res / diffuse",
2647 "depth",
2648 "normal",
2649 "specular",
2650 ]) {
2651 assert!(label.ends_with(pass));
2652 }
2653 }
2654 }
2655 }
2656 }
2657
2658 // ---- Resume (design §7/§12.6) ----
2659 fn resume_job(out: &Path) -> RenderJob {
2660 let mut b = body();
2661 b.clips.clear();
2662 RenderJob {
2663 depth_format: crate::camera::DepthFormat::GRAY16,
2664 character: b,
2665 sex: "male".into(),
2666 preset: None,
2667 target_width: 2,
2668 target_height: 2,
2669 fps: 12,
2670 framing: CameraFraming::default(),
2671 output: out.to_path_buf(),
2672 project_id: "resume-project".into(),
2673 project_name: "Resume".into(),
2674 body_model: SourceFingerprint {
2675 path: "assets/body/test.glb".into(),
2676 sha256: crate::digest::sha256_hex(b"resume-body"),
2677 },
2678 preset_sha256: None,
2679 }
2680 }
2681 /// Deterministic pose-dependent fake GPU output (view matrix drives the colour).
2682 fn fake_raw(scene: &Scene) -> Buffers {
2683 let mut b = raw(scene.width, scene.height);
2684 let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.)
2685 as u32
2686 % 200;
2687 for px in b.diffuse.chunks_exact_mut(4) {
2688 px[1] = v as u8;
2689 px[2] = (255 - v) as u8;
2690 }
2691 b
2692 }
2693 /// Run a job; cancel after `stop` completed Pass events (None = run to the end).
2694 fn drive(
2695 job: RenderJob,
2696 target: RunTarget,
2697 stop: Option<usize>,
2698 ) -> (Result<(), String>, Vec<RenderEvent>) {
2699 let flag = AtomicBool::new(false);
2700 let (tx, rx) = mpsc::channel::<GpuRequest>();
2701 let gpu = std::thread::spawn(move || {
2702 for request in rx {
2703 let reply = fake_raw(&request.scene);
2704 let _ = request.reply.send(Ok(reply));
2705 }
2706 });
2707 let mut events = Vec::new();
2708 let mut passes = 0;
2709 let result = execute_target(job, target, &tx, &flag, None, &mut |event| {
2710 if matches!(event, RenderEvent::Pass { .. }) {
2711 passes += 1;
2712 if stop == Some(passes) {
2713 flag.store(true, Ordering::Relaxed);
2714 }
2715 }
2716 events.push(event);
2717 });
2718 drop(tx);
2719 gpu.join().unwrap();
2720 (result, events)
2721 }
2722 fn runs(out: &Path) -> Vec<PathBuf> {
2723 let mut v = std::fs::read_dir(out)
2724 .unwrap()
2725 .map(|e| e.unwrap().path())
2726 .collect::<Vec<_>>();
2727 v.sort();
2728 v
2729 }
2730 fn checksums(run: &Path) -> serde_json::Value {
2731 let export = crate::atlas::AtlasSet::open_run(run)
2732 .unwrap()
2733 .ppda
2734 .export
2735 .unwrap();
2736 serde_json::to_value(export.png_sha256).unwrap()
2737 }
2738 fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2739 let mut out = std::collections::BTreeMap::new();
2740 let mut stack = vec![run.to_path_buf()];
2741 while let Some(dir) = stack.pop() {
2742 for e in std::fs::read_dir(dir).unwrap() {
2743 let p = e.unwrap().path();
2744 if p.is_dir() {
2745 stack.push(p);
2746 } else {
2747 out.insert(
2748 p.clone(),
2749 crate::digest::sha256_hex(&std::fs::read(&p).unwrap()),
2750 );
2751 }
2752 }
2753 }
2754 out
2755 }
2756 #[cfg(unix)]
2757 fn inode(p: &Path) -> u64 {
2758 use std::os::unix::fs::MetadataExt;
2759 std::fs::metadata(p).unwrap().ino()
2760 }
2761
2762 #[test]
2763 #[cfg(unix)]
2764 fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() {
2765 let reference_dir = Temp::new();
2766 let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);
2767 ok.unwrap();
2768 let reference = checksums(&runs(&reference_dir.0)[0]);
2769 assert_eq!(reference.as_object().unwrap().len(), 240);
2770
2771 let tmp = Temp::new();
2772 // An earlier completed run in the same output must stay byte-identical.
2773 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2774 let earlier = runs(&tmp.0)[0].clone();
2775 let earlier_tree = tree(&earlier);
2776 std::thread::sleep(std::time::Duration::from_millis(2));
2777 let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40));
2778 assert!(first.is_err());
2779 let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap();
2780 let cancelled: serde_json::Value =
2781 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2782 let done_first = cancelled["png_files_done"].as_u64().unwrap();
2783 assert!(done_first > 0 && done_first < 240);
2784 assert_eq!(cancelled["attempt"], 0);
2785 let first_file = run.join("raw files/male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2786 let first_inode = inode(&first_file);
2787 assert!(crate::export_set::ExportRun::open(&run).is_err());
2788
2789 let found =
2790 crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc)
2791 .unwrap();
2792 assert_eq!(found.len(), 1);
2793 assert_eq!(found[0].state, "geannuleerd");
2794 assert_eq!(found[0].journaled as u64, done_first);
2795 assert!(found[0].blocked.is_none());
2796
2797 // Second attempt: cancelled again after more work.
2798 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60));
2799 assert!(second.is_err());
2800 // Progress resumes at the verified work, not at zero, and skipped images are labelled.
2801 let first_step = events
2802 .iter()
2803 .find_map(|e| match e {
2804 RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => {
2805 Some(*index)
2806 }
2807 _ => None,
2808 })
2809 .unwrap();
2810 assert_eq!(first_step, 6);
2811 let cancelled2: serde_json::Value =
2812 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2813 assert_eq!(cancelled2["attempt"], 1);
2814 assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first);
2815 assert!(run.join("attempt-000-cancelled.json").exists());
2816 assert_eq!(
2817 inode(&first_file),
2818 first_inode,
2819 "verified PNG was rewritten"
2820 );
2821
2822 // Cancelled/unfinished builds never touch the last good result.
2823 assert_eq!(tree(&earlier), earlier_tree);
2824 // Third attempt completes and replaces the result folder.
2825 let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2826 third.unwrap();
2827 let built = run.clone();
2828 let done = tmp.0.join("male-body-atlas");
2829 assert_eq!(earlier, done);
2830 assert!(!built.exists());
2831 assert_eq!(
2832 runs(&tmp.0),
2833 vec![done.clone()],
2834 "only the newest result remains"
2835 );
2836 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2837 let run = done;
2838 // Resume bookkeeping (markers, logs, journal, lock) stays out of the result.
2839 for name in [
2840 "cancelled.json",
2841 "attempt-001-cancelled.json",
2842 "render.json",
2843 "complete.json",
2844 crate::resume::RESUME_LOG,
2845 crate::resume::JOURNAL,
2846 crate::resume::LOCK,
2847 ] {
2848 assert!(!run.join(name).exists(), "{name}");
2849 }
2850 assert_eq!(inode(&first_file), first_inode);
2851 assert_eq!(checksums(&run), reference);
2852 crate::export_set::ExportRun::open(&run).unwrap();
2853 // A completed run is no longer offered and cannot be resumed.
2854 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2855 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2856 let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2857 assert!(again.unwrap_err().contains("uitvoermap"));
2858 let (gone, _) = drive(resume_job(&tmp.0), RunTarget::Resume(built), None);
2859 assert!(gone.is_err());
2860 }
2861
2862 #[test]
2863 fn live_depth_preview_shows_the_written_depth_bytes_in_every_format() {
2864 use crate::camera::{DepthEncoding, DepthFormat};
2865 for bits in [8u8, 16] {
2866 for encoding in DepthEncoding::ALL {
2867 let fmt = DepthFormat::new(bits, encoding).unwrap();
2868 let tmp = Temp::new();
2869 let mut job = resume_job(&tmp.0);
2870 job.depth_format = fmt;
2871 let (result, events) = drive(job, RunTarget::Fresh, None);
2872 result.unwrap();
2873 let run = tmp.0.join("male-body-atlas");
2874 let export = crate::atlas::AtlasSet::open_run(&run)
2875 .unwrap()
2876 .ppda
2877 .export
2878 .unwrap();
2879 let written: Vec<Vec<u8>> = export
2880 .png_sha256
2881 .keys()
2882 .filter(|k| k.ends_with("_depth.png"))
2883 .map(|k| {
2884 let bytes = std::fs::read(run.join(k)).unwrap();
2885 let native = crate::export_set::decode_depth_native(
2886 &bytes,
2887 2,
2888 2,
2889 crate::export_set::MAX_TARGET,
2890 fmt,
2891 )
2892 .unwrap();
2893 crate::export_set::depth_display_rgba(&native, fmt)
2894 })
2895 .collect();
2896 let shown: Vec<&ImageBuf> = events
2897 .iter()
2898 .filter_map(|e| match e {
2899 RenderEvent::Pass {
2900 label,
2901 image: Some(img),
2902 ..
2903 } if label.ends_with("· depth") => Some(img),
2904 _ => None,
2905 })
2906 .collect();
2907 assert_eq!(shown.len(), written.len(), "{fmt:?}");
2908 for img in shown {
2909 assert_eq!((img.width, img.height), (2, 2));
2910 assert!(
2911 written.contains(&img.rgba),
2912 "{fmt:?}: preview is not the written bytes"
2913 );
2914 }
2915 }
2916 }
2917 }
2918
2919 #[test]
2920 #[cfg(unix)]
2921 fn fresh_render_discards_older_unfinished_builds_of_the_same_target_only() {
2922 let tmp = Temp::new();
2923 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2924 let done = tmp.0.join("male-body-atlas");
2925 let done_tree = tree(&done);
2926 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
2927 .0
2928 .is_err());
2929 let first: Vec<PathBuf> = runs(&tmp.0).into_iter().filter(|r| *r != done).collect();
2930 assert_eq!(first.len(), 1);
2931 // Another target and a locked build of this target must survive.
2932 let other = tmp
2933 .0
2934 .join(crate::resume::in_progress_name(1, 1, "female", "body"));
2935 std::fs::create_dir(&other).unwrap();
2936 std::fs::write(
2937 other.join("render.json"),
2938 br#"{"sex":"female","set":"body"}"#,
2939 )
2940 .unwrap();
2941 let locked = tmp
2942 .0
2943 .join(crate::resume::in_progress_name(2, 2, "male", "body"));
2944 std::fs::create_dir(&locked).unwrap();
2945 let lock = std::fs::File::create(locked.join(crate::resume::LOCK)).unwrap();
2946 lock.try_lock().unwrap();
2947 std::thread::sleep(std::time::Duration::from_millis(2));
2948 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10));
2949 assert!(second.is_err());
2950 assert!(!first[0].exists(), "older cancelled build kept");
2951 assert!(other.exists() && locked.exists());
2952 assert!(events.iter().any(|e| matches!(e,
2953 RenderEvent::Step { label, .. } if label.contains("Oude onvoltooide render verwijderd"))));
2954 assert_eq!(tree(&done), done_tree, "finished result touched");
2955 drop(lock);
2956 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2957 let offered = crate::resume::candidates(&tmp.0, &doc).unwrap();
2958 let newest = offered.iter().find(|c| c.journaled > 0).unwrap();
2959 assert!(newest.dir.exists() && newest.dir != locked && newest.dir != first[0]);
2960 // Resuming deletes nothing.
2961 let before = runs(&tmp.0);
2962 drive(
2963 resume_job(&tmp.0),
2964 RunTarget::Resume(newest.dir.clone()),
2965 Some(1),
2966 )
2967 .0
2968 .unwrap_err();
2969 assert_eq!(runs(&tmp.0), before);
2970 }
2971
2972 #[test]
2973 #[cfg(unix)]
2974 fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
2975 let reference_dir = Temp::new();
2976 drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
2977 .0
2978 .unwrap();
2979 let reference = checksums(&runs(&reference_dir.0)[0]);
2980
2981 let tmp = Temp::new();
2982 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60))
2983 .0
2984 .is_err());
2985 let run = runs(&tmp.0)[0].clone();
2986 // Simulate a hard crash: no terminal marker, torn last journal line.
2987 std::fs::remove_file(run.join("cancelled.json")).unwrap();
2988 let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap();
2989 let mut lines = journal.lines().map(str::to_string).collect::<Vec<_>>();
2990 let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
2991 let torn_path = run.join(torn["path"].as_str().unwrap());
2992 let last = lines.pop().unwrap();
2993 let keep = lines.join("\n") + "\n" + &last[..last.len() / 2];
2994 std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
2995 let torn_inode = inode(&torn_path);
2996 // Corrupt one journaled PNG, delete another, leave a foreign temp file.
2997 let frame = run.join("raw files/male/body/clip000-bind/f000000");
2998 let corrupt = frame.join("p-60_y030_normal.png");
2999 std::fs::write(&corrupt, b"not a png").unwrap();
3000 let missing = frame.join("p-60_y060_depth.png");
3001 std::fs::remove_file(&missing).unwrap();
3002 let stale = frame.join("p-60_y090_diffuse.part-999999-0");
3003 std::fs::write(&stale, b"partial").unwrap();
3004
3005 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
3006 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
3007 assert_eq!(found[0].state, "afgebroken");
3008
3009 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3010 .0
3011 .unwrap();
3012 let done = tmp.0.join("male-body-atlas");
3013 let rebase = |p: &Path| done.join(p.strip_prefix(&run).unwrap());
3014 let (frame, missing, stale, torn_path) = (
3015 rebase(&frame),
3016 rebase(&missing),
3017 rebase(&stale),
3018 rebase(&torn_path),
3019 );
3020 let run = done.clone();
3021 assert_eq!(checksums(&run), reference);
3022 crate::export_set::ExportRun::open(&run).unwrap();
3023 assert!(missing.exists());
3024 assert_eq!(
3025 std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(),
3026 b"not a png"
3027 );
3028 assert_eq!(std::fs::read(&stale).unwrap(), b"partial");
3029 assert_eq!(
3030 inode(&torn_path),
3031 torn_inode,
3032 "identical unjournaled file was replaced"
3033 );
3034 assert!(!run.join(crate::resume::RESUME_LOG).exists());
3035 }
3036
3037 #[test]
3038 fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() {
3039 let tmp = Temp::new();
3040 let mut job = resume_job(&tmp.0);
3041 job.character = body();
3042 let total: f32 = job.character.clips.iter().map(|c| c.duration).sum();
3043 assert!(total > 0.);
3044 // ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap.
3045 job.fps = (100. / total).ceil() as u32;
3046 job.target_width = 256;
3047 job.target_height = 256;
3048 let err = contract(job).err().unwrap();
3049 assert!(err.contains("atlas") && err.contains("16384"), "{err}");
3050 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
3051 }
3052
3053 #[test]
3054 #[cfg(unix)]
3055 fn recover_cleans_and_places_builds_that_crashed_after_completion() {
3056 let tmp = Temp::new();
3057 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
3058 let reference = tree(&runs(&tmp.0)[0]);
3059 let consumer = |dir: &Path| {
3060 let mut names: Vec<String> = std::fs::read_dir(dir)
3061 .unwrap()
3062 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
3063 .collect();
3064 names.sort();
3065 names
3066 };
3067 let clean = consumer(&runs(&tmp.0)[0]);
3068 assert_eq!(
3069 clean,
3070 [
3071 "male-body-atlas.ppda",
3072 "male-body-depth-atlas.png",
3073 "male-body-diffuse-atlas.png",
3074 "male-body-normal-atlas.png",
3075 "male-body-specular-atlas.png",
3076 "raw files",
3077 ]
3078 );
3079 // (a) crash after complete.json, before cleanup: all bookkeeping present.
3080 // (b) crash after cleanup, before the swap: only consumer output.
3081 for with_bookkeeping in [true, false] {
3082 let built = tmp
3083 .0
3084 .join(crate::resume::in_progress_name(7, 7, "male", "body"));
3085 std::fs::rename(tmp.0.join("male-body-atlas"), &built).unwrap();
3086 if with_bookkeeping {
3087 for name in [
3088 "render.json",
3089 "complete.json",
3090 crate::resume::JOURNAL,
3091 crate::resume::LOCK,
3092 crate::resume::RESUME_LOG,
3093 "attempt-000-cancelled.json",
3094 ] {
3095 std::fs::write(built.join(name), b"{}").unwrap();
3096 }
3097 }
3098 let notes = crate::resume::recover(&tmp.0).unwrap();
3099 assert_eq!(notes.len(), 1, "{notes:?}");
3100 let done = tmp.0.join("male-body-atlas");
3101 assert!(!built.exists());
3102 assert_eq!(consumer(&done), clean);
3103 let rebase = |t: std::collections::BTreeMap<PathBuf, String>| {
3104 t.into_values().collect::<Vec<_>>()
3105 };
3106 assert_eq!(rebase(tree(&done)), rebase(reference.clone()));
3107 crate::export_set::ExportRun::open(&done).unwrap();
3108 }
3109 }
3110
3111 #[test]
3112 #[cfg(unix)]
3113 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
3114 let reference_dir = Temp::new();
3115 drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
3116 .0
3117 .unwrap();
3118 let reference_run = runs(&reference_dir.0)[0].clone();
3119 let tmp = Temp::new();
3120 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
3121 // Simulate a crash after the atlases/.ppda were published but before
3122 // complete.json (so still the in-progress build), one atlas damaged.
3123 let run = tmp
3124 .0
3125 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
3126 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
3127 // Recreate the in-progress bookkeeping that existed before complete.json.
3128 let export = crate::atlas::AtlasSet::open_run(&run)
3129 .unwrap()
3130 .ppda
3131 .export
3132 .unwrap();
3133 std::fs::write(
3134 run.join("render.json"),
3135 serde_json::to_vec_pretty(&export.render).unwrap(),
3136 )
3137 .unwrap();
3138 let journal: String = export
3139 .png_sha256
3140 .iter()
3141 .map(|(path, sha)| serde_json::json!({"path": path, "sha256": sha}).to_string() + "\n")
3142 .collect();
3143 std::fs::write(run.join(crate::resume::JOURNAL), journal).unwrap();
3144 let diffuse = run.join("male-body-diffuse-atlas.png");
3145 let normal = run.join("male-body-normal-atlas.png");
3146 let normal_inode = inode(&normal);
3147 let good = std::fs::read(&diffuse).unwrap();
3148 std::fs::remove_file(&diffuse).unwrap();
3149 std::fs::write(&diffuse, b"damaged").unwrap();
3150 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3151 .0
3152 .unwrap();
3153 let built = run;
3154 let run = tmp.0.join("male-body-atlas");
3155 let (normal, diffuse) = (
3156 run.join(normal.file_name().unwrap()),
3157 run.join(diffuse.file_name().unwrap()),
3158 );
3159 assert!(!built.exists());
3160 assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
3161 assert_eq!(std::fs::read(&diffuse).unwrap(), good);
3162 assert_eq!(
3163 std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(),
3164 b"damaged"
3165 );
3166 crate::atlas::AtlasSet::open_run(&run).unwrap();
3167 crate::export_set::ExportRun::open(&run).unwrap();
3168 // Byte-identical to an uninterrupted run (deterministic encoding).
3169 for name in [
3170 "male-body-atlas.ppda",
3171 "male-body-diffuse-atlas.png",
3172 "male-body-depth-atlas.png",
3173 ] {
3174 assert_eq!(
3175 std::fs::read(run.join(name)).unwrap(),
3176 std::fs::read(reference_run.join(name)).unwrap(),
3177 "{name}"
3178 );
3179 }
3180 }
3181
3182 #[test]
3183 fn result_folder_swap_keeps_last_good_and_recovers_interrupted_swaps() {
3184 let tmp = Temp::new();
3185 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
3186 let done = tmp.0.join("male-body-atlas");
3187 let good = tree(&done);
3188 // A legacy run-* folder is left alone.
3189 let legacy = tmp.0.join("run-123-4");
3190 std::fs::create_dir(&legacy).unwrap();
3191 std::fs::write(legacy.join("x"), b"old").unwrap();
3192 // Cancelled re-render: last good result untouched, build stays resumable.
3193 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
3194 .0
3195 .is_err());
3196 assert_eq!(tree(&done), good);
3197 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
3198 assert_eq!(crate::resume::candidates(&tmp.0, &doc).unwrap().len(), 1);
3199 // Crash after moving the old result aside but before placing the new one:
3200 // the old result is restored.
3201 let aside = tmp.0.join(".replaced-male-body-atlas-77");
3202 std::fs::rename(&done, &aside).unwrap();
3203 let notes = crate::resume::recover(&tmp.0).unwrap();
3204 assert_eq!(notes.len(), 1, "{notes:?}");
3205 assert!(!aside.exists());
3206 assert_eq!(tree(&done), good);
3207 // Crash after completing a build but before the swap: promoted, old removed.
3208 let fresh = Temp::new();
3209 drive(resume_job(&fresh.0), RunTarget::Fresh, None)
3210 .0
3211 .unwrap();
3212 let built = tmp
3213 .0
3214 .join(crate::resume::in_progress_name(9, 9, "male", "body"));
3215 std::fs::rename(fresh.0.join("male-body-atlas"), &built).unwrap();
3216 std::fs::write(tmp.0.join("male-body-atlas/marker-old"), b"1").unwrap();
3217 let notes = crate::resume::recover(&tmp.0).unwrap();
3218 assert_eq!(notes.len(), 1, "{notes:?}");
3219 assert!(!built.exists());
3220 assert!(!done.join("marker-old").exists());
3221 crate::atlas::AtlasSet::open_run(&done).unwrap();
3222 assert!(std::fs::read_dir(&tmp.0).unwrap().all(|e| !e
3223 .unwrap()
3224 .file_name()
3225 .to_string_lossy()
3226 .starts_with(".replaced-")));
3227 assert_eq!(std::fs::read(legacy.join("x")).unwrap(), b"old");
3228 }
3229
3230 #[test]
3231 fn unjournaled_different_file_is_quarantined_not_overwritten() {
3232 let tmp = Temp::new();
3233 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6))
3234 .0
3235 .is_err());
3236 let run = runs(&tmp.0)[0].clone();
3237 // A planned PNG appears that the journal never recorded and that differs.
3238 let foreign = run.join("raw files/male/body/clip000-bind/f000000/p-60_y030_specular.png");
3239 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
3240 std::fs::write(&foreign, b"foreign").unwrap();
3241 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3242 .0
3243 .unwrap();
3244 let done = tmp.0.join("male-body-atlas");
3245 let foreign = done.join(foreign.strip_prefix(&run).unwrap());
3246 let run = done;
3247 crate::export_set::ExportRun::open(&run).unwrap();
3248 assert_eq!(
3249 std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
3250 b"foreign"
3251 );
3252 }
3253
3254 #[test]
3255 fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() {
3256 let tmp = Temp::new();
3257 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12))
3258 .0
3259 .is_err());
3260 let run = runs(&tmp.0)[0].clone();
3261 let before = std::fs::read(run.join("cancelled.json")).unwrap();
3262 // Contract drift: FPS, body bytes, target size.
3263 let mut job = resume_job(&tmp.0);
3264 job.fps = 24;
3265 let err = drive(job, RunTarget::Resume(run.clone()), None)
3266 .0
3267 .unwrap_err();
3268 assert!(err.contains("fps"), "{err}");
3269 let mut job = resume_job(&tmp.0);
3270 job.body_model.sha256 = crate::digest::sha256_hex(b"edited body");
3271 let err = drive(job, RunTarget::Resume(run.clone()), None)
3272 .0
3273 .unwrap_err();
3274 assert!(err.contains("body_model"), "{err}");
3275 let mut job = resume_job(&tmp.0);
3276 job.target_width = 3;
3277 let doc = contract(job.clone()).unwrap().render_doc;
3278 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
3279 assert!(found[0].blocked.as_ref().unwrap().contains("target"));
3280 assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err());
3281 // Refusals leave the run and its marker untouched.
3282 assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before);
3283 assert!(!run.join(crate::resume::RESUME_LOG).exists());
3284 // Directory not directly inside the output directory.
3285 let other = Temp::new();
3286 let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None)
3287 .0
3288 .unwrap_err();
3289 assert!(err.contains("uitvoermap"), "{err}");
3290 // Locked by a running worker.
3291 let lock = std::fs::OpenOptions::new()
3292 .write(true)
3293 .open(run.join(crate::resume::LOCK))
3294 .unwrap();
3295 lock.lock().unwrap();
3296 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3297 .0
3298 .unwrap_err();
3299 assert!(err.contains("run.lock"), "{err}");
3300 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
3301 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0]
3302 .blocked
3303 .is_some());
3304 drop(lock);
3305 // Journal naming a path outside the plan.
3306 use std::io::Write;
3307 let mut j = std::fs::OpenOptions::new()
3308 .append(true)
3309 .open(run.join(crate::resume::JOURNAL))
3310 .unwrap();
3311 writeln!(
3312 j,
3313 "{}",
3314 serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")})
3315 )
3316 .unwrap();
3317 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3318 .0
3319 .unwrap_err();
3320 assert!(err.contains("onbekend pad"), "{err}");
3321 }
3322}