BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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