BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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