BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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