BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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