BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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