BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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