BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

production.rs 112.2 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 for note in crate::resume::recover(&job.output)? {
1495 emit(RenderEvent::Step {
1496 index: 0,
1497 total: plan.passes,
1498 label: format!("Herstel: {note}"),
1499 });
1500 }
1501 let mut session = match target {
1502 RunTarget::Fresh => {
1503 let stamp = std::time::SystemTime::now()
1504 .duration_since(std::time::UNIX_EPOCH)
1505 .map_err(|e| e.to_string())?
1506 .as_nanos();
1507 let root = job.output.join(crate::resume::in_progress_name(
1508 stamp,
1509 std::process::id(),
1510 &job.sex,
1511 &set,
1512 ));
1513 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1514 let session = crate::resume::RunSession::fresh(&root)?;
1515 write_json(&root, "render.json", render_doc.clone())?;
1516 session
1517 }
1518 RunTarget::Resume(dir) => {
1519 let total = plan.passes;
1520 crate::resume::RunSession::resume(
1521 &job.output,
1522 &dir,
1523 &render_doc,
1524 &planned,
1525 &mut |done, all| {
1526 emit(RenderEvent::Step {
1527 index: 0,
1528 total,
1529 label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"),
1530 })
1531 },
1532 )?
1533 }
1534 };
1535 let root = session.root.clone();
1536 // Internal unique names select every source clip, even duplicate display names.
1537 for (i, c) in job.character.clips.iter_mut().enumerate() {
1538 c.name = format!("render-clip-{i}");
1539 }
1540 let mut completed = 0u32;
1541 let mut clamped_views = 0u32;
1542 let mut clamped_pixels = 0u64;
1543 let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
1544 let mut cache = equipment::TextureCache::default();
1545 let result = (|| {
1546 for (index, sample) in samples(&plan).enumerate() {
1547 let clip = &plan.clips[sample.clip];
1548 let frame = sample.frame;
1549 let pitch = sample.pitch;
1550 let yaw = sample.yaw;
1551 let paths = &planned[index * 4..index * 4 + 4];
1552 if cancel.load(Ordering::Relaxed) {
1553 return Err("geannuleerd".into());
1554 }
1555 let pose = format!(
1556 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1557 job.sex, clip.source_name, clip.frames
1558 );
1559 if paths.iter().all(|p| session.verified.contains_key(p)) {
1560 for p in paths {
1561 png_sha256.insert(p.clone(), session.verified[p].clone());
1562 }
1563 completed += PASSES.len() as u32;
1564 emit(RenderEvent::Step {
1565 index: completed,
1566 total: plan.passes,
1567 label: format!("{pose} · al aanwezig, checksums kloppen"),
1568 });
1569 continue;
1570 }
1571 let key = if job.character.clips.is_empty() {
1572 String::new()
1573 } else {
1574 format!("render-clip-{}", sample.clip)
1575 };
1576 emit(RenderEvent::Step {
1577 index: completed,
1578 total: plan.passes,
1579 label: format!("{pose} · highres"),
1580 });
1581 let scene = scene(
1582 &job,
1583 &key,
1584 frame as f32 / job.fps as f32,
1585 pitch as f32,
1586 yaw as f32,
1587 &mut cache,
1588 )?;
1589 let (tx, rx) = mpsc::sync_channel(1);
1590 gpu.send(GpuRequest { scene, reply: tx })
1591 .map_err(|_| "GPU-service gesloten")?;
1592 if let Some(ctx) = ctx {
1593 ctx.request_repaint();
1594 }
1595 let raw = loop {
1596 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1597 Ok(result) => break result?,
1598 Err(mpsc::RecvTimeoutError::Timeout) => {
1599 if cancel.load(Ordering::Relaxed) {
1600 return Err("geannuleerd".into());
1601 }
1602 }
1603 Err(_) => return Err("GPU-service gestopt".into()),
1604 }
1605 };
1606 let clamped = clamped_depth_pixels(&raw);
1607 if clamped > 0 {
1608 clamped_views += 1;
1609 clamped_pixels += clamped as u64;
1610 emit(RenderEvent::Step {
1611 index: completed,
1612 total: plan.passes,
1613 label: format!(
1614 "Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)",
1615 render_doc["depth_near"].as_f64().unwrap_or(0.),
1616 render_doc["depth_far"].as_f64().unwrap_or(0.)
1617 ),
1618 });
1619 }
1620 completed += 1;
1621 emit(RenderEvent::Pass {
1622 index: completed - 1,
1623 total: plan.passes,
1624 label: format!("{pose} · highres"),
1625 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1626 });
1627 if cancel.load(Ordering::Relaxed) {
1628 return Err("geannuleerd".into());
1629 }
1630 emit(RenderEvent::Step {
1631 index: completed,
1632 total: plan.passes,
1633 label: format!("{pose} · detail"),
1634 });
1635 let detail = detail_map(&raw)?;
1636 completed += 1;
1637 emit(RenderEvent::Pass {
1638 index: completed - 1,
1639 total: plan.passes,
1640 label: format!("{pose} · detail"),
1641 image: Some(detail_image(&raw, &detail)),
1642 });
1643 if cancel.load(Ordering::Relaxed) {
1644 return Err("geannuleerd".into());
1645 }
1646 emit(RenderEvent::Step {
1647 index: completed,
1648 total: plan.passes,
1649 label: format!("{pose} · target-res / diffuse"),
1650 });
1651 let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1652 for ((map, preview), rel) in [
1653 ("diffuse", target.diffuse),
1654 ("depth", target.depth_preview),
1655 ("normal", target.normal),
1656 ("specular", target.specular),
1657 ]
1658 .into_iter()
1659 .zip(paths)
1660 {
1661 if cancel.load(Ordering::Relaxed) {
1662 return Err("geannuleerd".into());
1663 }
1664 emit(RenderEvent::Step {
1665 index: completed,
1666 total: plan.passes,
1667 label: format!("{pose} · {map}"),
1668 });
1669 debug_assert!(rel.ends_with(&format!("_{map}.png")));
1670 let kept = session.verified.contains_key(rel);
1671 let digest = if kept {
1672 session.verified[rel].clone()
1673 } else {
1674 let bytes = if map == "depth" {
1675 encode_depth_png(job.target_width, job.target_height, &target.depth)?
1676 } else {
1677 encode_rgba_png(&preview, map == "diffuse")?
1678 };
1679 session.publish(rel, &bytes)?
1680 };
1681 png_sha256.insert(rel.clone(), digest);
1682 completed += 1;
1683 let pass = if map == "diffuse" {
1684 "target-res / diffuse"
1685 } else {
1686 map
1687 };
1688 emit(RenderEvent::Pass {
1689 index: completed - 1,
1690 total: plan.passes,
1691 label: if kept {
1692 format!("{pose} · {pass} (bestaand bestand behouden)")
1693 } else {
1694 format!("{pose} · {pass}")
1695 },
1696 image: Some(preview),
1697 });
1698 }
1699 }
1700 if png_sha256.len() as u32 != plan.files {
1701 return Err(format!(
1702 "checksumtelling {} wijkt af van geplande {} PNGs",
1703 png_sha256.len(),
1704 plan.files
1705 ));
1706 }
1707 // Atlases + .ppda only after every loose map is published and verified
1708 // (also after a resume); complete.json stays the final marker.
1709 crate::atlas::write_atlases(
1710 &crate::atlas::AtlasJob {
1711 root: &root,
1712 render_doc: &render_doc,
1713 plan: &plan,
1714 sex: &job.sex,
1715 set: &set,
1716 cell_width: job.target_width,
1717 cell_height: job.target_height,
1718 png_sha256: &png_sha256,
1719 attempt: session.attempt,
1720 cancel,
1721 },
1722 &mut |label| {
1723 emit(RenderEvent::Step {
1724 index: completed,
1725 total: plan.passes,
1726 label,
1727 })
1728 },
1729 )
1730 })();
1731 match result {
1732 Ok(atlas) => {
1733 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1734 write_json(
1735 &root,
1736 "complete.json",
1737 serde_json::json!({
1738 "format_version": crate::export_set::OUTPUT_META_VERSION,
1739 "status": "complete",
1740 "identity": identity,
1741 "passes": completed,
1742 "png_files": plan.files,
1743 "png_sha256": png_sha256,
1744 "atlas": atlas.to_json(),
1745 "depth_clamped": {
1746 "views": clamped_views,
1747 "highres_pixels": clamped_pixels,
1748 "note": "rendered this attempt; kept files from a resumed run are not re-counted",
1749 },
1750 }),
1751 )?;
1752 if clamped_views > 0 {
1753 emit(RenderEvent::Step {
1754 index: completed,
1755 total: plan.passes,
1756 label: format!(
1757 "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)"
1758 ),
1759 });
1760 }
1761 // Verify the finished build before it replaces the last good result.
1762 crate::atlas::AtlasSet::open_run(&root)
1763 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1764 drop(session);
1765 let final_dir = crate::resume::promote(
1766 &job.output,
1767 &root,
1768 &crate::resume::result_dir_name(&job.sex, &set),
1769 )?;
1770 emit(RenderEvent::Finished {
1771 saved: Some(final_dir.display().to_string()),
1772 });
1773 Ok(())
1774 }
1775 Err(error) => {
1776 let name = if cancel.load(Ordering::Relaxed) {
1777 "cancelled.json"
1778 } else {
1779 "failed.json"
1780 };
1781 let _ = write_json(
1782 &root,
1783 name,
1784 serde_json::json!({
1785 "error": error,
1786 "completed_passes": completed,
1787 "total_passes": plan.passes,
1788 "png_files_done": png_sha256.len(),
1789 "png_files": plan.files,
1790 "attempt": session.attempt,
1791 }),
1792 );
1793 Err(error)
1794 }
1795 }
1796}
1797
1798/// Real readback regression: nearest-surface depth/normal/specular, preserved
1799/// texture × vertex albedo/alpha and independence from opposed light directions.
1800/// # Safety
1801/// Run on a current disposable native context.
1802pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> {
1803 let triangle = |z, normal, color, specular| Draw {
1804 positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]),
1805 normals: Arc::new(vec![normal; 3]),
1806 uvs: Arc::new(vec![[0.; 2]; 3]),
1807 vertex_colors: Arc::new(Vec::new()),
1808 indices: vec![0, 1, 2],
1809 color,
1810 albedo: None,
1811 shiny: None,
1812 specular,
1813 cutoff: 0.001,
1814 flat_normals: false,
1815 };
1816 let mut renderer = GpuRenderer::default();
1817 let mut scene = Scene {
1818 draws: vec![
1819 triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75),
1820 triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.),
1821 ],
1822 view: preview::identity(),
1823 projection: preview::identity(),
1824 width: 32,
1825 height: 32,
1826 };
1827 let result = (|| {
1828 let raw = renderer.render(gl, &scene)?;
1829 let i = 16 * 32 + 16;
1830 if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255]
1831 || (raw.depth[i] - 0.25).abs() > 1e-5
1832 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255]
1833 || raw.specular[i * 4].abs_diff(191) > 1
1834 {
1835 return Err(format!(
1836 "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}",
1837 &raw.diffuse[i * 4..i * 4 + 4],
1838 raw.depth[i],
1839 &raw.normal[i * 4..i * 4 + 4],
1840 raw.specular[i * 4]
1841 ));
1842 }
1843 scene.draws.reverse();
1844 let reverse = renderer.render(gl, &scene)?;
1845 if reverse.diffuse != raw.diffuse
1846 || reverse.normal != raw.normal
1847 || reverse.depth != raw.depth
1848 {
1849 return Err("GPU map winner depends on submission order".into());
1850 }
1851 scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)];
1852 scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]);
1853 scene.draws[0].albedo = Some(Arc::new(equipment::Texture {
1854 content_hash: 99,
1855 width: 1,
1856 height: 1,
1857 rgba: vec![128, 255, 64, 192],
1858 }));
1859 let expected = [
1860 srgb(linear(128) * 0.25),
1861 srgb(0.75),
1862 srgb(linear(64) * 0.5),
1863 192,
1864 ];
1865 // Production intentionally has no lighting input. This optional conventional
1866 // uniform is optimized out now; a future accidental lighting term using it
1867 // must fail these actual GPU readbacks under opposed light directions.
1868 for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] {
1869 let p = renderer.program.ok_or("missing probe program")?;
1870 gl.use_program(Some(p));
1871 gl.uniform_3_f32(
1872 gl.get_uniform_location(p, "u_light_direction").as_ref(),
1873 light[0],
1874 light[1],
1875 light[2],
1876 );
1877 let output = renderer.render(gl, &scene)?;
1878 let pixel = &output.diffuse[i * 4..i * 4 + 4];
1879 if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) {
1880 return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"));
1881 }
1882 }
1883 let detail = detail_map(&raw)?;
1884 let target = resolve(&raw, &detail, 4, 4)?;
1885 if target.diffuse.width != 4
1886 || target.depth.len() != 32
1887 || target
1888 .normal
1889 .rgba
1890 .chunks_exact(4)
1891 .filter(|p| p[3] > 0)
1892 .any(|p| {
1893 let n = p[..3]
1894 .iter()
1895 .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2))
1896 .sum::<f32>()
1897 .sqrt();
1898 (n - 1.).abs() > 0.03
1899 })
1900 {
1901 return Err("target map dimensions/normals invalid".into());
1902 }
1903 let mut flat = Scene {
1904 draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)],
1905 view: preview::identity(),
1906 projection: preview::identity(),
1907 width: 32,
1908 height: 32,
1909 };
1910 flat.draws[0].flat_normals = true;
1911 let derived = renderer.render(gl, &flat)?;
1912 if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] {
1913 return Err(format!(
1914 "flat normals are not geometry-derived: {:?}",
1915 &derived.normal[i * 4..i * 4 + 4]
1916 ));
1917 }
1918 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");
1919 Ok(())
1920 })();
1921 renderer.destroy(gl);
1922 result
1923}
1924
1925#[cfg(test)]
1926mod tests {
1927 use super::*;
1928 struct Temp(PathBuf);
1929 impl Temp {
1930 fn new() -> Self {
1931 let p = std::env::temp_dir().join(format!(
1932 "paperdoll-production-{}-{}",
1933 std::process::id(),
1934 std::time::SystemTime::now()
1935 .duration_since(std::time::UNIX_EPOCH)
1936 .unwrap()
1937 .as_nanos()
1938 ));
1939 std::fs::create_dir(&p).unwrap();
1940 Self(p)
1941 }
1942 }
1943 impl Drop for Temp {
1944 fn drop(&mut self) {
1945 let _ = std::fs::remove_dir_all(&self.0);
1946 }
1947 }
1948 fn body() -> PreviewCharacter {
1949 preview::load_gltf_file(
1950 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1951 )
1952 .unwrap()
1953 }
1954 fn raw(w: u32, h: u32) -> Buffers {
1955 let n = (w * h) as usize;
1956 Buffers {
1957 width: w,
1958 height: h,
1959 diffuse: [255, 0, 0, 255].repeat(n),
1960 normal: [128, 128, 255, 255].repeat(n),
1961 specular: [191, 191, 191, 255].repeat(n),
1962 depth: vec![0.25; n],
1963 }
1964 }
1965 #[test]
1966 fn job_counts_every_clip_frame_angle_and_exact_order() {
1967 let mut b = body();
1968 let mut c = b.clips[0].clone();
1969 c.name = "../A \u{2603}".into();
1970 c.duration = 0.5;
1971 b.clips = vec![c.clone(), c];
1972 let p = plan(&b, 3).unwrap();
1973 // 2 clips × 2 frames × 60 views.
1974 assert_eq!((p.images, p.passes, p.files), (240, 1440, 960));
1975 let s = samples(&p).collect::<Vec<_>>();
1976 assert_eq!(s.len(), 240);
1977 assert_eq!(
1978 s[0],
1979 Sample {
1980 clip: 0,
1981 frame: 0,
1982 pitch: -60,
1983 yaw: 0
1984 }
1985 );
1986 assert_eq!(s[11].yaw, 330);
1987 assert_eq!(s[12].pitch, -30);
1988 assert_eq!(s[24].pitch, 0);
1989 assert_eq!(s[36].pitch, 30);
1990 assert_eq!(s[59].pitch, 60);
1991 assert_eq!(s[60].frame, 1);
1992 assert_eq!(s[120].clip, 1);
1993 assert_ne!(p.clips[0].token, p.clips[1].token);
1994 assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
1995 assert_eq!(
1996 PASSES,
1997 [
1998 "highres",
1999 "detail",
2000 "target-res / diffuse",
2001 "depth",
2002 "normal",
2003 "specular"
2004 ]
2005 );
2006 b.clips.clear();
2007 let p = plan(&b, 12).unwrap();
2008 assert_eq!((p.images, p.files), (60, 240));
2009 }
2010 #[test]
2011 fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
2012 let mut b = body();
2013 b.clips.truncate(1);
2014 b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1);
2015 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2);
2016 b.clips[0].duration = 1.00001;
2017 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3);
2018 }
2019 #[test]
2020 fn planner_rejects_invalid_or_unbounded_inputs() {
2021 let mut b = body();
2022 assert!(plan(&b, 0).is_err());
2023 assert!(plan(&b, 241).is_err());
2024 for duration in [f32::NAN, f32::INFINITY, -1., 10000.] {
2025 b.clips[0].duration = duration;
2026 assert!(plan(&b, 12).is_err());
2027 }
2028 }
2029 #[test]
2030 fn output_tokens_only_four_target_maps() {
2031 let expected = PathBuf::from("male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
2032 assert_eq!(
2033 output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
2034 expected
2035 );
2036 assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok());
2037 assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err());
2038 assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err());
2039 for map in ["diffuse", "normal", "depth", "specular"] {
2040 assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
2041 }
2042 for map in ["highres", "detail", "shiny", "../x"] {
2043 assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err());
2044 }
2045 for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] {
2046 assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err());
2047 }
2048 assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err());
2049 assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());
2050 }
2051 #[test]
2052 fn resolve_preserves_thin_foreground_and_matching_maps() {
2053 let mut r = raw(8, 8);
2054 r.diffuse = [0, 255, 0, 255].repeat(64);
2055 r.depth.fill(0.75);
2056 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
2057 r.depth[0] = 0.25;
2058 let d = detail_map(&r).unwrap();
2059 assert!(d.iter().any(|x| *x > 0));
2060 let t = resolve(&r, &d, 1, 1).unwrap();
2061 assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]);
2062 assert_eq!(t.specular.rgba, [191, 191, 191, 255]);
2063 assert!((t.depth[0] as i32 - 16384).abs() <= 1);
2064 let n = &t.normal.rgba;
2065 let len = (0..3)
2066 .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2))
2067 .sum::<f32>()
2068 .sqrt();
2069 assert!((len - 1.).abs() < 0.01);
2070 r.diffuse.fill(0);
2071 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
2072 let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap();
2073 assert_eq!(t.diffuse.rgba[3], 4);
2074 assert_eq!(t.depth[1], 1028);
2075 assert_eq!(t.normal.rgba[3], 4);
2076 assert_eq!(t.specular.rgba[3], 4);
2077 }
2078 fn meta_framing(meta: &serde_json::Value) -> CameraFraming {
2079 serde_json::from_value(meta["framing"].clone()).unwrap()
2080 }
2081
2082 #[test]
2083 fn clamped_depth_counts_only_covered_pixels_on_the_planes() {
2084 let mut raw = Buffers {
2085 width: 4,
2086 height: 1,
2087 diffuse: vec![255; 16],
2088 normal: vec![0; 16],
2089 specular: vec![0; 16],
2090 depth: vec![0., 0.5, 1., 1.],
2091 };
2092 raw.diffuse[15] = 0; // uncovered background at far plane
2093 assert_eq!(clamped_depth_pixels(&raw), 2);
2094 }
2095
2096 #[test]
2097 fn invalid_map_dimensions_and_depth_fail_without_panics() {
2098 let mut r = raw(8, 8);
2099 r.normal.pop();
2100 assert!(detail_map(&r).is_err());
2101 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
2102 let mut r = raw(8, 8);
2103 r.depth[0] = f32::NAN;
2104 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
2105 let r = raw(8, 8);
2106 assert!(resolve(&r, &[0; 63], 1, 1).is_err());
2107 assert!(resolve(&r, &[0; 64], 0, 1).is_err());
2108 assert!(resolve(&r, &[0; 64], 257, 1).is_err());
2109 }
2110 #[test]
2111 fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() {
2112 let t = Temp::new();
2113 let p = Path::new("map_diffuse.png");
2114 write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap();
2115 let original = std::fs::read(t.0.join(p)).unwrap();
2116 assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err());
2117 assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original);
2118 let mut reader = png::Decoder::new(std::io::Cursor::new(original))
2119 .read_info()
2120 .unwrap();
2121 assert!(reader.info().srgb.is_some());
2122 let mut out = vec![0; reader.output_buffer_size().unwrap()];
2123 reader.next_frame(&mut out).unwrap();
2124 assert_eq!(out, [128, 64, 32, 192]);
2125 write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap();
2126 let mut reader = png::Decoder::new(std::io::BufReader::new(
2127 std::fs::File::open(t.0.join("depth.png")).unwrap(),
2128 ))
2129 .read_info()
2130 .unwrap();
2131 assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen);
2132 assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha);
2133 assert!(reader.info().srgb.is_none());
2134 let mut out = vec![0; reader.output_buffer_size().unwrap()];
2135 reader.next_frame(&mut out).unwrap();
2136 assert_eq!(out, [64, 0, 128, 128]);
2137 let tmp =
2138 t.0.join("blocked.png")
2139 .with_extension(format!("part-{}", std::process::id()));
2140 std::fs::write(&tmp, b"keep").unwrap();
2141 // A foreign/stale temp (e.g. left by a crashed process with a reused PID)
2142 // is never deleted or reused; per-write unique temp names still publish.
2143 write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap();
2144 assert_eq!(std::fs::read(tmp).unwrap(), b"keep");
2145 let temps = std::fs::read_dir(&t.0)
2146 .unwrap()
2147 .filter_map(|e| e.ok())
2148 .filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-"))
2149 .count();
2150 assert_eq!(temps, 1, "own temp files are removed, foreign ones kept");
2151 assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());
2152 assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());
2153 assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());
2154 }
2155 #[cfg(unix)]
2156 #[test]
2157 fn output_rejects_escaping_symlinks() {
2158 let root = Temp::new();
2159 let outside = Temp::new();
2160 std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap();
2161 assert!(write_rgba(
2162 &root.0,
2163 Path::new("link/sub/x.png"),
2164 &image(1, 1, vec![0; 4])
2165 )
2166 .is_err());
2167 assert!(!outside.0.join("sub").exists());
2168 }
2169 #[test]
2170 fn latest_mailbox_is_bounded_and_retains_terminal() {
2171 let m = Mailbox::default();
2172 for i in 0..1000 {
2173 m.publish(RenderEvent::Pass {
2174 index: i,
2175 total: 1000,
2176 label: i.to_string(),
2177 image: Some(image(1, 1, vec![0; 4])),
2178 });
2179 }
2180 m.publish(RenderEvent::Step {
2181 index: 999,
2182 total: 1000,
2183 label: "working".into(),
2184 });
2185 m.publish(RenderEvent::Cancelled);
2186 let events = m.drain();
2187 assert_eq!(events.len(), 3);
2188 assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. }));
2189 assert!(matches!(&events[2], RenderEvent::Cancelled));
2190 assert!(m.drain().is_empty());
2191 }
2192
2193 #[test]
2194 fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {
2195 let temp = Temp::new();
2196 let mut job = RenderJob {
2197 character: body(),
2198 sex: "male".into(),
2199 preset: None,
2200 target_width: 64,
2201 target_height: 96,
2202 fps: 12,
2203 framing: CameraFraming {
2204 height_milli: 3100,
2205 center_y_milli: 1200,
2206 pivot_y_milli: Some(1200),
2207 },
2208 output: temp.0.clone(),
2209 project_id: "test-project".into(),
2210 project_name: "Test".into(),
2211 body_model: SourceFingerprint {
2212 path: "assets/body/test.glb".into(),
2213 sha256: crate::digest::sha256_hex(b"test-body"),
2214 },
2215 preset_sha256: None,
2216 };
2217 let mut cache = equipment::TextureCache::default();
2218 let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2219 let mut preset = equipment::Preset::new("sword".into());
2220 let fixture =
2221 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj");
2222 equipment::import_obj(&mut preset, &fixture).unwrap();
2223 let joint = job
2224 .character
2225 .joint_names
2226 .iter()
2227 .position(|n| n == "RightHand")
2228 .unwrap();
2229 preset.bind(0, &job.character, 0, joint).unwrap();
2230 let count = preset
2231 .attachments
2232 .iter()
2233 .map(|a| a.meshes.len())
2234 .sum::<usize>();
2235 job.preset = Some(preset);
2236 let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2237 assert_eq!(e.draws.len(), count);
2238 assert_eq!(b.view, e.view);
2239 assert_eq!(b.projection, e.projection);
2240 assert_eq!((e.width, e.height), (512, 768));
2241 assert_eq!(e.projection[5], 2. / 3.1);
2242 let center = point(&e.view, [0., 1.2, 0.]);
2243 assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001);
2244 assert!((center[2] + 10.).abs() < 0.0001);
2245 let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap();
2246 let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap();
2247 assert_eq!(early.view, late.view);
2248 assert_eq!(early.projection, late.projection);
2249 assert_ne!(early.draws[0].positions, late.draws[0].positions);
2250 let mut invalid = e.clone();
2251 invalid.view[0] = f32::NAN;
2252 assert!(invalid.validate().is_err());
2253 }
2254 #[test]
2255 fn body_import_retains_texture_material_and_linear_vertex_color() {
2256 let temp = Temp::new();
2257 let source = std::fs::read(
2258 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
2259 )
2260 .unwrap();
2261 let glb = gltf::binary::Glb::from_slice(&source).unwrap();
2262 let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap();
2263 let mut bin = glb.bin.unwrap().into_owned();
2264 while !bin.len().is_multiple_of(4) {
2265 bin.push(0);
2266 }
2267 let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"]
2268 .as_u64()
2269 .unwrap() as usize;
2270 let count = json["accessors"][position]["count"].as_u64().unwrap() as usize;
2271 let offset = bin.len();
2272 for _ in 0..count {
2273 for value in [0.25f32, 0.75, 0.5, 0.5] {
2274 bin.extend(value.to_le_bytes());
2275 }
2276 }
2277 let view = json["bufferViews"].as_array().unwrap().len();
2278 json["bufferViews"]
2279 .as_array_mut()
2280 .unwrap()
2281 .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16}));
2282 let accessor = json["accessors"].as_array().unwrap().len();
2283 json["accessors"].as_array_mut().unwrap().push(
2284 serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}),
2285 );
2286 json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor);
2287 json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()});
2288 json["images"] = serde_json::json!([{"uri":"albedo.png"}]);
2289 json["textures"] = serde_json::json!([{"source":0}]);
2290 let material = json["meshes"][0]["primitives"][0]["material"]
2291 .as_u64()
2292 .unwrap() as usize;
2293 json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] =
2294 serde_json::json!({"index":0});
2295 json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] =
2296 serde_json::json!([0.5, 0.75, 1., 1.]);
2297 std::fs::write(temp.0.join("geometry.bin"), bin).unwrap();
2298 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2299 write_rgba(
2300 &temp.0,
2301 Path::new("albedo.png"),
2302 &image(1, 1, vec![128, 255, 64, 192]),
2303 )
2304 .unwrap();
2305 let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2306 assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]);
2307 assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]);
2308 assert_eq!(
2309 body.surfaces[0].albedo.as_ref().unwrap().rgba,
2310 [128, 255, 64, 192]
2311 );
2312 assert_eq!(body.colors.len(), body.positions.len());
2313 assert_eq!(body.uvs.len(), body.positions.len());
2314 assert!(body.surfaces[0].flat_normals);
2315 json["meshes"][0]["primitives"][0]["attributes"]
2316 .as_object_mut()
2317 .unwrap()
2318 .remove("NORMAL");
2319 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2320 let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2321 assert!(!missing.source_normals_complete);
2322 let job = RenderJob {
2323 character: missing,
2324 sex: "male".into(),
2325 preset: None,
2326 target_width: 1,
2327 target_height: 1,
2328 fps: 12,
2329 framing: CameraFraming::default(),
2330 output: temp.0.clone(),
2331 project_id: "test-project".into(),
2332 project_name: "Test".into(),
2333 body_model: SourceFingerprint {
2334 path: "assets/body/test.glb".into(),
2335 sha256: crate::digest::sha256_hex(b"test-body"),
2336 },
2337 preset_sha256: None,
2338 };
2339 assert!(
2340 scene(
2341 &job,
2342 "",
2343 0.,
2344 0.,
2345 0.,
2346 &mut equipment::TextureCache::default()
2347 )
2348 .unwrap()
2349 .draws[0]
2350 .flat_normals
2351 );
2352 }
2353 #[test]
2354 fn complete_and_cancelled_batches_have_truthful_atomic_outputs() {
2355 for stop in [false, true] {
2356 let tmp = Temp::new();
2357 let mut b = body();
2358 b.clips.clear();
2359 let job = RenderJob {
2360 character: b,
2361 sex: "male".into(),
2362 preset: None,
2363 target_width: 1,
2364 target_height: 1,
2365 fps: 12,
2366 framing: CameraFraming::default(),
2367 output: tmp.0.clone(),
2368 project_id: "test-project".into(),
2369 project_name: "Test".into(),
2370 body_model: SourceFingerprint {
2371 path: "assets/body/test.glb".into(),
2372 sha256: crate::digest::sha256_hex(b"test-body"),
2373 },
2374 preset_sha256: None,
2375 };
2376 let flag = AtomicBool::new(false);
2377 let (tx, rx) = mpsc::channel::<GpuRequest>();
2378 let gpu = std::thread::spawn(move || {
2379 for request in rx {
2380 let _ = request
2381 .reply
2382 .send(Ok(raw(request.scene.width, request.scene.height)));
2383 }
2384 });
2385 let mut labels = Vec::new();
2386 let result = execute(job, &tx, &flag, None, &mut |event| {
2387 if let RenderEvent::Pass { label, .. } = event {
2388 labels.push(label);
2389 if stop && labels.len() == 3 {
2390 flag.store(true, Ordering::Relaxed);
2391 }
2392 }
2393 });
2394 drop(tx);
2395 gpu.join().unwrap();
2396 let run = std::fs::read_dir(&tmp.0)
2397 .unwrap()
2398 .next()
2399 .unwrap()
2400 .unwrap()
2401 .path();
2402 assert!(run.join("render.json").exists());
2403 assert_eq!(run.join("complete.json").exists(), !stop);
2404 assert_eq!(run.join("cancelled.json").exists(), stop);
2405 assert_eq!(result.is_ok(), !stop);
2406 let meta: serde_json::Value =
2407 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2408 .unwrap();
2409 assert_eq!(meta["format_version"], 5);
2410 let (near, far) = crate::camera::depth_range(meta_framing(&meta));
2411 assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);
2412 assert_eq!(meta["depth_far"].as_f64().unwrap() as f32, far);
2413 assert_eq!(meta["near"], meta["depth_near"]);
2414 assert_eq!(meta["depth_encoding"], crate::camera::DEPTH_ENCODING);
2415 if !stop {
2416 let done: serde_json::Value = serde_json::from_str(
2417 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2418 )
2419 .unwrap();
2420 assert_eq!(done["depth_clamped"]["views"], 0);
2421 }
2422 assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2423 assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2424 assert_eq!(meta["fps"], 12);
2425 assert_eq!(meta["project"]["id"], "test-project");
2426 assert_eq!(
2427 meta["body_model"]["sha256"],
2428 crate::digest::sha256_hex(b"test-body")
2429 );
2430 assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
2431 assert_eq!(meta["pivot_source"]["kind"], "neck");
2432 assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
2433 assert_eq!(
2434 meta["saved_maps"]
2435 .as_array()
2436 .unwrap()
2437 .iter()
2438 .map(|v| v.as_str().unwrap())
2439 .collect::<Vec<_>>(),
2440 ["diffuse", "depth", "normal", "specular"]
2441 );
2442 if !stop {
2443 let complete: serde_json::Value = serde_json::from_str(
2444 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2445 )
2446 .unwrap();
2447 assert_eq!(complete["format_version"], 5);
2448 assert_eq!(complete["status"], "complete");
2449 assert_eq!(complete["png_files"], 240);
2450 let checksums = complete["png_sha256"].as_object().unwrap();
2451 assert_eq!(checksums.len(), 240);
2452 for (rel, digest) in checksums {
2453 assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2454 let bytes = std::fs::read(run.join(rel)).unwrap();
2455 assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes));
2456 }
2457 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2458 let sample = opened
2459 .load_map(
2460 "clip000-bind",
2461 0,
2462 -60,
2463 0,
2464 crate::export_set::MapKind::Diffuse,
2465 )
2466 .unwrap();
2467 assert_eq!(sample.width(), 1);
2468 // Atlases + .ppda: one per map, written before complete.json.
2469 for map in ["diffuse", "normal", "depth", "specular"] {
2470 let name = format!("male-body-{map}-atlas.png");
2471 assert!(run.join(&name).is_file(), "{name}");
2472 assert_eq!(complete["atlas"]["maps"][map]["file"], name);
2473 }
2474 assert!(!stop);
2475 let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
2476 assert_eq!(atlas.ppda.fps, 12);
2477 assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1));
2478 let loaded = atlas.load().unwrap();
2479 for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] {
2480 for kind in crate::atlas::ATLAS_MAPS {
2481 assert_eq!(
2482 loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(),
2483 opened
2484 .load_map("clip000-bind", 0, pitch, yaw, kind)
2485 .unwrap()
2486 );
2487 }
2488 }
2489 } else {
2490 assert!(!run.join("male-body-atlas.ppda").exists());
2491 assert!(!run.join("male-body-diffuse-atlas.png").exists());
2492 }
2493 let frame = run.join("male/body/clip000-bind/f000000");
2494 assert!(frame.join("p-60_y000_diffuse.png").exists());
2495 assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2496 assert!(!frame.join("p-60_y000_highres.png").exists());
2497 assert!(!frame.join("p-60_y000_detail.png").exists());
2498 if !stop {
2499 assert_eq!(labels.len(), 360);
2500 for labels in labels.chunks_exact(6) {
2501 for (label, pass) in labels.iter().zip([
2502 "highres",
2503 "detail",
2504 "target-res / diffuse",
2505 "depth",
2506 "normal",
2507 "specular",
2508 ]) {
2509 assert!(label.ends_with(pass));
2510 }
2511 }
2512 }
2513 }
2514 }
2515
2516 // ---- Resume (design §7/§12.6) ----
2517 fn resume_job(out: &Path) -> RenderJob {
2518 let mut b = body();
2519 b.clips.clear();
2520 RenderJob {
2521 character: b,
2522 sex: "male".into(),
2523 preset: None,
2524 target_width: 2,
2525 target_height: 2,
2526 fps: 12,
2527 framing: CameraFraming::default(),
2528 output: out.to_path_buf(),
2529 project_id: "resume-project".into(),
2530 project_name: "Resume".into(),
2531 body_model: SourceFingerprint {
2532 path: "assets/body/test.glb".into(),
2533 sha256: crate::digest::sha256_hex(b"resume-body"),
2534 },
2535 preset_sha256: None,
2536 }
2537 }
2538 /// Deterministic pose-dependent fake GPU output (view matrix drives the colour).
2539 fn fake_raw(scene: &Scene) -> Buffers {
2540 let mut b = raw(scene.width, scene.height);
2541 let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.)
2542 as u32
2543 % 200;
2544 for px in b.diffuse.chunks_exact_mut(4) {
2545 px[1] = v as u8;
2546 px[2] = (255 - v) as u8;
2547 }
2548 b
2549 }
2550 /// Run a job; cancel after `stop` completed Pass events (None = run to the end).
2551 fn drive(
2552 job: RenderJob,
2553 target: RunTarget,
2554 stop: Option<usize>,
2555 ) -> (Result<(), String>, Vec<RenderEvent>) {
2556 let flag = AtomicBool::new(false);
2557 let (tx, rx) = mpsc::channel::<GpuRequest>();
2558 let gpu = std::thread::spawn(move || {
2559 for request in rx {
2560 let reply = fake_raw(&request.scene);
2561 let _ = request.reply.send(Ok(reply));
2562 }
2563 });
2564 let mut events = Vec::new();
2565 let mut passes = 0;
2566 let result = execute_target(job, target, &tx, &flag, None, &mut |event| {
2567 if matches!(event, RenderEvent::Pass { .. }) {
2568 passes += 1;
2569 if stop == Some(passes) {
2570 flag.store(true, Ordering::Relaxed);
2571 }
2572 }
2573 events.push(event);
2574 });
2575 drop(tx);
2576 gpu.join().unwrap();
2577 (result, events)
2578 }
2579 fn runs(out: &Path) -> Vec<PathBuf> {
2580 let mut v = std::fs::read_dir(out)
2581 .unwrap()
2582 .map(|e| e.unwrap().path())
2583 .collect::<Vec<_>>();
2584 v.sort();
2585 v
2586 }
2587 fn checksums(run: &Path) -> serde_json::Value {
2588 let c: serde_json::Value =
2589 serde_json::from_slice(&std::fs::read(run.join("complete.json")).unwrap()).unwrap();
2590 c["png_sha256"].clone()
2591 }
2592 fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2593 let mut out = std::collections::BTreeMap::new();
2594 let mut stack = vec![run.to_path_buf()];
2595 while let Some(dir) = stack.pop() {
2596 for e in std::fs::read_dir(dir).unwrap() {
2597 let p = e.unwrap().path();
2598 if p.is_dir() {
2599 stack.push(p);
2600 } else {
2601 out.insert(
2602 p.clone(),
2603 crate::digest::sha256_hex(&std::fs::read(&p).unwrap()),
2604 );
2605 }
2606 }
2607 }
2608 out
2609 }
2610 #[cfg(unix)]
2611 fn inode(p: &Path) -> u64 {
2612 use std::os::unix::fs::MetadataExt;
2613 std::fs::metadata(p).unwrap().ino()
2614 }
2615
2616 #[test]
2617 #[cfg(unix)]
2618 fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() {
2619 let reference_dir = Temp::new();
2620 let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);
2621 ok.unwrap();
2622 let reference = checksums(&runs(&reference_dir.0)[0]);
2623 assert_eq!(reference.as_object().unwrap().len(), 240);
2624
2625 let tmp = Temp::new();
2626 // An earlier completed run in the same output must stay byte-identical.
2627 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2628 let earlier = runs(&tmp.0)[0].clone();
2629 let earlier_tree = tree(&earlier);
2630 std::thread::sleep(std::time::Duration::from_millis(2));
2631 let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40));
2632 assert!(first.is_err());
2633 let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap();
2634 let cancelled: serde_json::Value =
2635 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2636 let done_first = cancelled["png_files_done"].as_u64().unwrap();
2637 assert!(done_first > 0 && done_first < 240);
2638 assert_eq!(cancelled["attempt"], 0);
2639 let first_file = run.join("male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2640 let first_inode = inode(&first_file);
2641 assert!(crate::export_set::ExportRun::open(&run).is_err());
2642
2643 let found =
2644 crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc)
2645 .unwrap();
2646 assert_eq!(found.len(), 1);
2647 assert_eq!(found[0].state, "geannuleerd");
2648 assert_eq!(found[0].journaled as u64, done_first);
2649 assert!(found[0].blocked.is_none());
2650
2651 // Second attempt: cancelled again after more work.
2652 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60));
2653 assert!(second.is_err());
2654 // Progress resumes at the verified work, not at zero, and skipped images are labelled.
2655 let first_step = events
2656 .iter()
2657 .find_map(|e| match e {
2658 RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => {
2659 Some(*index)
2660 }
2661 _ => None,
2662 })
2663 .unwrap();
2664 assert_eq!(first_step, 6);
2665 let cancelled2: serde_json::Value =
2666 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2667 assert_eq!(cancelled2["attempt"], 1);
2668 assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first);
2669 assert!(run.join("attempt-000-cancelled.json").exists());
2670 assert_eq!(
2671 inode(&first_file),
2672 first_inode,
2673 "verified PNG was rewritten"
2674 );
2675
2676 // Cancelled/unfinished builds never touch the last good result.
2677 assert_eq!(tree(&earlier), earlier_tree);
2678 // Third attempt completes and replaces the result folder.
2679 let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2680 third.unwrap();
2681 let built = run.clone();
2682 let done = tmp.0.join("male-body-atlas");
2683 assert_eq!(earlier, done);
2684 assert!(!built.exists());
2685 assert_eq!(
2686 runs(&tmp.0),
2687 vec![done.clone()],
2688 "only the newest result remains"
2689 );
2690 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2691 let run = done;
2692 assert!(!run.join("cancelled.json").exists());
2693 assert!(run.join("attempt-001-cancelled.json").exists());
2694 assert_eq!(inode(&first_file), first_inode);
2695 assert_eq!(checksums(&run), reference);
2696 crate::export_set::ExportRun::open(&run).unwrap();
2697 let log = std::fs::read_to_string(run.join(crate::resume::RESUME_LOG)).unwrap();
2698 assert_eq!(log.lines().count(), 2);
2699 // A completed run is no longer offered and cannot be resumed.
2700 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2701 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2702 let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2703 assert!(again.unwrap_err().contains("uitvoermap"));
2704 let (gone, _) = drive(resume_job(&tmp.0), RunTarget::Resume(built), None);
2705 assert!(gone.is_err());
2706 }
2707
2708 #[test]
2709 #[cfg(unix)]
2710 fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
2711 let reference_dir = Temp::new();
2712 drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
2713 .0
2714 .unwrap();
2715 let reference = checksums(&runs(&reference_dir.0)[0]);
2716
2717 let tmp = Temp::new();
2718 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60))
2719 .0
2720 .is_err());
2721 let run = runs(&tmp.0)[0].clone();
2722 // Simulate a hard crash: no terminal marker, torn last journal line.
2723 std::fs::remove_file(run.join("cancelled.json")).unwrap();
2724 let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap();
2725 let mut lines = journal.lines().map(str::to_string).collect::<Vec<_>>();
2726 let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
2727 let torn_path = run.join(torn["path"].as_str().unwrap());
2728 let last = lines.pop().unwrap();
2729 let keep = lines.join("\n") + "\n" + &last[..last.len() / 2];
2730 std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
2731 let torn_inode = inode(&torn_path);
2732 // Corrupt one journaled PNG, delete another, leave a foreign temp file.
2733 let frame = run.join("male/body/clip000-bind/f000000");
2734 let corrupt = frame.join("p-60_y030_normal.png");
2735 std::fs::write(&corrupt, b"not a png").unwrap();
2736 let missing = frame.join("p-60_y060_depth.png");
2737 std::fs::remove_file(&missing).unwrap();
2738 let stale = frame.join("p-60_y090_diffuse.part-999999-0");
2739 std::fs::write(&stale, b"partial").unwrap();
2740
2741 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2742 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
2743 assert_eq!(found[0].state, "afgebroken");
2744
2745 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2746 .0
2747 .unwrap();
2748 let done = tmp.0.join("male-body-atlas");
2749 let rebase = |p: &Path| done.join(p.strip_prefix(&run).unwrap());
2750 let (frame, missing, stale, torn_path) = (
2751 rebase(&frame),
2752 rebase(&missing),
2753 rebase(&stale),
2754 rebase(&torn_path),
2755 );
2756 let run = done.clone();
2757 assert_eq!(checksums(&run), reference);
2758 crate::export_set::ExportRun::open(&run).unwrap();
2759 assert!(missing.exists());
2760 assert_eq!(
2761 std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(),
2762 b"not a png"
2763 );
2764 assert_eq!(std::fs::read(&stale).unwrap(), b"partial");
2765 assert_eq!(
2766 inode(&torn_path),
2767 torn_inode,
2768 "identical unjournaled file was replaced"
2769 );
2770 let log: serde_json::Value = serde_json::from_str(
2771 std::fs::read_to_string(run.join(crate::resume::RESUME_LOG))
2772 .unwrap()
2773 .lines()
2774 .next()
2775 .unwrap(),
2776 )
2777 .unwrap();
2778 assert_eq!(log["previous_marker"], serde_json::Value::Null);
2779 assert_eq!(log["damaged_journal_lines"], 1);
2780 assert_eq!(log["unjournaled_existing"], 1);
2781 assert_eq!(log["missing"].as_u64().unwrap(), 240 - 60 / 6 * 4 + 1);
2782 assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2783 }
2784
2785 #[test]
2786 fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() {
2787 let tmp = Temp::new();
2788 let mut job = resume_job(&tmp.0);
2789 job.character = body();
2790 let total: f32 = job.character.clips.iter().map(|c| c.duration).sum();
2791 assert!(total > 0.);
2792 // ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap.
2793 job.fps = (100. / total).ceil() as u32;
2794 job.target_width = 256;
2795 job.target_height = 256;
2796 let err = contract(job).err().unwrap();
2797 assert!(err.contains("atlas") && err.contains("16384"), "{err}");
2798 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
2799 }
2800
2801 #[test]
2802 #[cfg(unix)]
2803 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
2804 let reference_dir = Temp::new();
2805 drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
2806 .0
2807 .unwrap();
2808 let reference_run = runs(&reference_dir.0)[0].clone();
2809 let tmp = Temp::new();
2810 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2811 // Simulate a crash after the atlases/.ppda were published but before
2812 // complete.json (so still the in-progress build), one atlas damaged.
2813 let run = tmp
2814 .0
2815 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
2816 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
2817 std::fs::remove_file(run.join("complete.json")).unwrap();
2818 let diffuse = run.join("male-body-diffuse-atlas.png");
2819 let normal = run.join("male-body-normal-atlas.png");
2820 let normal_inode = inode(&normal);
2821 let good = std::fs::read(&diffuse).unwrap();
2822 std::fs::remove_file(&diffuse).unwrap();
2823 std::fs::write(&diffuse, b"damaged").unwrap();
2824 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2825 .0
2826 .unwrap();
2827 let built = run;
2828 let run = tmp.0.join("male-body-atlas");
2829 let (normal, diffuse) = (
2830 run.join(normal.file_name().unwrap()),
2831 run.join(diffuse.file_name().unwrap()),
2832 );
2833 assert!(!built.exists());
2834 assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
2835 assert_eq!(std::fs::read(&diffuse).unwrap(), good);
2836 assert_eq!(
2837 std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(),
2838 b"damaged"
2839 );
2840 crate::atlas::AtlasSet::open_run(&run).unwrap();
2841 crate::export_set::ExportRun::open(&run).unwrap();
2842 // Byte-identical to an uninterrupted run (deterministic encoding).
2843 for name in [
2844 "male-body-atlas.ppda",
2845 "male-body-diffuse-atlas.png",
2846 "male-body-depth-atlas.png",
2847 ] {
2848 assert_eq!(
2849 std::fs::read(run.join(name)).unwrap(),
2850 std::fs::read(reference_run.join(name)).unwrap(),
2851 "{name}"
2852 );
2853 }
2854 }
2855
2856 #[test]
2857 fn result_folder_swap_keeps_last_good_and_recovers_interrupted_swaps() {
2858 let tmp = Temp::new();
2859 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2860 let done = tmp.0.join("male-body-atlas");
2861 let good = tree(&done);
2862 // A legacy run-* folder is left alone.
2863 let legacy = tmp.0.join("run-123-4");
2864 std::fs::create_dir(&legacy).unwrap();
2865 std::fs::write(legacy.join("x"), b"old").unwrap();
2866 // Cancelled re-render: last good result untouched, build stays resumable.
2867 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
2868 .0
2869 .is_err());
2870 assert_eq!(tree(&done), good);
2871 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2872 assert_eq!(crate::resume::candidates(&tmp.0, &doc).unwrap().len(), 1);
2873 // Crash after moving the old result aside but before placing the new one:
2874 // the old result is restored.
2875 let aside = tmp.0.join(".replaced-male-body-atlas-77");
2876 std::fs::rename(&done, &aside).unwrap();
2877 let notes = crate::resume::recover(&tmp.0).unwrap();
2878 assert_eq!(notes.len(), 1, "{notes:?}");
2879 assert!(!aside.exists());
2880 assert_eq!(tree(&done), good);
2881 // Crash after completing a build but before the swap: promoted, old removed.
2882 let fresh = Temp::new();
2883 drive(resume_job(&fresh.0), RunTarget::Fresh, None)
2884 .0
2885 .unwrap();
2886 let built = tmp
2887 .0
2888 .join(crate::resume::in_progress_name(9, 9, "male", "body"));
2889 std::fs::rename(fresh.0.join("male-body-atlas"), &built).unwrap();
2890 std::fs::write(tmp.0.join("male-body-atlas/marker-old"), b"1").unwrap();
2891 let notes = crate::resume::recover(&tmp.0).unwrap();
2892 assert_eq!(notes.len(), 1, "{notes:?}");
2893 assert!(!built.exists());
2894 assert!(!done.join("marker-old").exists());
2895 crate::atlas::AtlasSet::open_run(&done).unwrap();
2896 assert!(std::fs::read_dir(&tmp.0).unwrap().all(|e| !e
2897 .unwrap()
2898 .file_name()
2899 .to_string_lossy()
2900 .starts_with(".replaced-")));
2901 assert_eq!(std::fs::read(legacy.join("x")).unwrap(), b"old");
2902 }
2903
2904 #[test]
2905 fn unjournaled_different_file_is_quarantined_not_overwritten() {
2906 let tmp = Temp::new();
2907 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6))
2908 .0
2909 .is_err());
2910 let run = runs(&tmp.0)[0].clone();
2911 // A planned PNG appears that the journal never recorded and that differs.
2912 let foreign = run.join("male/body/clip000-bind/f000000/p-60_y030_specular.png");
2913 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
2914 std::fs::write(&foreign, b"foreign").unwrap();
2915 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2916 .0
2917 .unwrap();
2918 let done = tmp.0.join("male-body-atlas");
2919 let foreign = done.join(foreign.strip_prefix(&run).unwrap());
2920 let run = done;
2921 crate::export_set::ExportRun::open(&run).unwrap();
2922 assert_eq!(
2923 std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
2924 b"foreign"
2925 );
2926 }
2927
2928 #[test]
2929 fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() {
2930 let tmp = Temp::new();
2931 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12))
2932 .0
2933 .is_err());
2934 let run = runs(&tmp.0)[0].clone();
2935 let before = std::fs::read(run.join("cancelled.json")).unwrap();
2936 // Contract drift: FPS, body bytes, target size.
2937 let mut job = resume_job(&tmp.0);
2938 job.fps = 24;
2939 let err = drive(job, RunTarget::Resume(run.clone()), None)
2940 .0
2941 .unwrap_err();
2942 assert!(err.contains("fps"), "{err}");
2943 let mut job = resume_job(&tmp.0);
2944 job.body_model.sha256 = crate::digest::sha256_hex(b"edited body");
2945 let err = drive(job, RunTarget::Resume(run.clone()), None)
2946 .0
2947 .unwrap_err();
2948 assert!(err.contains("body_model"), "{err}");
2949 let mut job = resume_job(&tmp.0);
2950 job.target_width = 3;
2951 let doc = contract(job.clone()).unwrap().render_doc;
2952 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
2953 assert!(found[0].blocked.as_ref().unwrap().contains("target"));
2954 assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err());
2955 // Refusals leave the run and its marker untouched.
2956 assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before);
2957 assert!(!run.join(crate::resume::RESUME_LOG).exists());
2958 // Directory not directly inside the output directory.
2959 let other = Temp::new();
2960 let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None)
2961 .0
2962 .unwrap_err();
2963 assert!(err.contains("uitvoermap"), "{err}");
2964 // Locked by a running worker.
2965 let lock = std::fs::OpenOptions::new()
2966 .write(true)
2967 .open(run.join(crate::resume::LOCK))
2968 .unwrap();
2969 lock.lock().unwrap();
2970 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2971 .0
2972 .unwrap_err();
2973 assert!(err.contains("run.lock"), "{err}");
2974 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2975 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0]
2976 .blocked
2977 .is_some());
2978 drop(lock);
2979 // Journal naming a path outside the plan.
2980 use std::io::Write;
2981 let mut j = std::fs::OpenOptions::new()
2982 .append(true)
2983 .open(run.join(crate::resume::JOURNAL))
2984 .unwrap();
2985 writeln!(
2986 j,
2987 "{}",
2988 serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")})
2989 )
2990 .unwrap();
2991 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2992 .0
2993 .unwrap_err();
2994 assert!(err.contains("onbekend pad"), "{err}");
2995 }
2996}