BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

production.rs 79.6 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];
30pub const NEAR: f32 = 0.05;
31pub const FAR: f32 = 40.;
32#[derive(Clone)]
33pub struct Draw {
34 pub positions: Arc<Vec<[f32; 3]>>,
35 pub normals: Arc<Vec<[f32; 3]>>,
36 pub uvs: Arc<Vec<[f32; 2]>>,
37 pub vertex_colors: Arc<Vec<[f32; 4]>>,
38 pub indices: Vec<u32>,
39 pub color: [f32; 4],
40 pub albedo: Option<Arc<equipment::Texture>>,
41 pub shiny: Option<Arc<equipment::Texture>>,
42 pub specular: f32,
43 pub cutoff: f32,
44 pub flat_normals: bool,
45}
46#[derive(Clone)]
47pub struct Scene {
48 pub draws: Vec<Draw>,
49 pub view: Mat4,
50 pub projection: Mat4,
51 pub width: u32,
52 pub height: u32,
53}
54#[derive(Debug)]
55pub struct Buffers {
56 pub width: u32,
57 pub height: u32,
58 pub diffuse: Vec<u8>,
59 pub normal: Vec<u8>,
60 pub specular: Vec<u8>,
61 pub depth: Vec<f32>,
62}
63#[derive(Debug)]
64pub struct Target {
65 pub diffuse: ImageBuf,
66 pub normal: ImageBuf,
67 pub specular: ImageBuf,
68 pub depth: Vec<u16>,
69 pub depth_preview: ImageBuf,
70}
71/// Content fingerprint of a source file used for a production job.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SourceFingerprint {
74 pub path: String,
75 pub sha256: String,
76}
77
78#[derive(Clone)]
79pub struct RenderJob {
80 pub character: PreviewCharacter,
81 pub sex: String,
82 pub preset: Option<equipment::Preset>,
83 pub target_width: u32,
84 pub target_height: u32,
85 pub fps: u32,
86 pub framing: CameraFraming,
87 pub output: PathBuf,
88 pub project_id: String,
89 pub project_name: String,
90 pub body_model: SourceFingerprint,
91 /// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.
92 pub preset_sha256: Option<String>,
93}
94#[derive(Debug, Clone, PartialEq)]
95pub struct ClipPlan {
96 pub source_name: String,
97 pub token: String,
98 pub frames: u32,
99}
100#[derive(Debug, Clone, PartialEq)]
101pub struct Plan {
102 pub clips: Vec<ClipPlan>,
103 pub images: u32,
104 pub passes: u32,
105 pub files: u32,
106}
107pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
108 if fps == 0 || fps > 240 {
109 return Err("fps moet 1–240 zijn".into());
110 }
111 let clips = if body.clips.is_empty() {
112 vec![ClipPlan {
113 source_name: String::new(),
114 token: "clip000-bind".into(),
115 frames: 1,
116 }]
117 } else {
118 body.clips
119 .iter()
120 .enumerate()
121 .map(|(i, c)| {
122 let duration = c.duration;
123 if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. {
124 return Err("ongeldige of te lange animatie".into());
125 }
126 // Exporter float timestamps commonly put a nominal 1-second
127 // endpoint one ULP above 1. Do not duplicate that terminal pose.
128 let samples = f64::from(duration) * f64::from(fps);
129 let nearest = samples.round();
130 let samples = if (samples - nearest).abs()
131 <= 4. * f64::from(f32::EPSILON) * samples.max(1.)
132 {
133 nearest
134 } else {
135 samples
136 };
137 Ok(ClipPlan {
138 source_name: c.name.clone(),
139 token: clip_token(i, &c.name),
140 frames: samples.ceil().max(1.) as u32,
141 })
142 })
143 .collect::<Result<Vec<_>, String>>()?
144 };
145 let frames = clips
146 .iter()
147 .try_fold(0u32, |n, c| n.checked_add(c.frames))
148 .ok_or("te veel frames")?;
149 let images = frames.checked_mul(36).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 [-30, 0, 30].into_iter().flat_map(move |pitch| {
173 (0..12).map(move |y| Sample {
174 clip,
175 frame,
176 pitch,
177 yaw: y * 30,
178 })
179 })
180 })
181 })
182}
183
184pub fn clip_token(index: usize, name: &str) -> String {
185 let slug: String = name
186 .chars()
187 .map(|c| {
188 if c.is_ascii_alphanumeric() {
189 c.to_ascii_lowercase()
190 } else {
191 '-'
192 }
193 })
194 .take(48)
195 .collect();
196 format!(
197 "clip{index:03}-{}",
198 if slug.is_empty() { "animation" } else { &slug }
199 )
200}
201pub fn output_path(
202 sex: &str,
203 set: &str,
204 clip: &str,
205 frame: u32,
206 pitch: i32,
207 yaw: u32,
208 map: &str,
209) -> Result<PathBuf, String> {
210 if !["male", "female"].contains(&sex)
211 || !(set == "body" || equipment::valid_name(set))
212 || !equipment::valid_name(clip)
213 || ![-30, 0, 30].contains(&pitch)
214 || yaw >= 360
215 || !yaw.is_multiple_of(30)
216 || !["diffuse", "normal", "depth", "specular"].contains(&map)
217 {
218 return Err("ongeldig uitvoerpad/token".into());
219 }
220 Ok(PathBuf::from(sex)
221 .join(set)
222 .join(clip)
223 .join(format!("f{frame:06}"))
224 .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
225}
226pub use crate::camera::matrices as camera;
227
228fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
229 std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
230}
231fn normalize(v: [f32; 3]) -> [f32; 3] {
232 let l = v.iter().map(|x| x * x).sum::<f32>().sqrt();
233 if l > 1e-10 {
234 v.map(|x| x / l)
235 } else {
236 [0., 0., 1.]
237 }
238}
239fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] {
240 let a = [m[0], m[1], m[2]];
241 let b = [m[4], m[5], m[6]];
242 let c = [m[8], m[9], m[10]];
243 let cross = |a: [f32; 3], b: [f32; 3]| {
244 [
245 a[1] * b[2] - a[2] * b[1],
246 a[2] * b[0] - a[0] * b[2],
247 a[0] * b[1] - a[1] * b[0],
248 ]
249 };
250 let x = cross(b, c);
251 let y = cross(c, a);
252 let z = cross(a, b);
253 let det = a.iter().zip(x).map(|(a, b)| a * b).sum::<f32>();
254 if det.abs() < 1e-12 {
255 return normalize(n);
256 }
257 normalize(std::array::from_fn(|i| {
258 (x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det
259 }))
260}
261pub fn scene(
262 job: &RenderJob,
263 clip: &str,
264 time: f32,
265 pitch: f32,
266 yaw: f32,
267 cache: &mut equipment::TextureCache,
268) -> Result<Scene, String> {
269 let body = &job.character;
270 let mut draws = Vec::new();
271 if let Some(preset) = &job.preset {
272 let controls = PreviewControls {
273 clip_name: clip.into(),
274 time,
275 ..Default::default()
276 };
277 for layer in equipment::layers_cached(
278 preset,
279 usize::from(job.sex == "female"),
280 body,
281 &controls,
282 cache,
283 )? {
284 let m = layer.model;
285 draws.push(Draw {
286 positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()),
287 normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()),
288 uvs: Arc::new(
289 layer
290 .mesh
291 .uvs
292 .into_iter()
293 .map(|[u, v]| [u, 1. - v])
294 .collect(),
295 ),
296 vertex_colors: Arc::new(Vec::new()),
297 indices: layer.mesh.indices,
298 color: layer.mesh.color,
299 albedo: layer.albedo,
300 shiny: layer.shiny,
301 specular: layer.specular,
302 cutoff: 0.5,
303 flat_normals: false,
304 });
305 }
306 } else {
307 let bones = preview::sample_skin_matrices(body, clip, time);
308 let mut positions = Vec::with_capacity(body.positions.len());
309 let mut normals = Vec::with_capacity(body.positions.len());
310 for (i, p) in body.positions.iter().enumerate() {
311 let mut m = preview::identity();
312 if !bones.is_empty() && body.weights.len() == body.positions.len() {
313 m = [0.; 16];
314 let joints = body.joints.get(i).copied().unwrap_or([0; 4]);
315 for (j, w) in joints.into_iter().zip(body.weights[i]) {
316 let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?;
317 for k in 0..16 {
318 m[k] += b[k] * w;
319 }
320 }
321 }
322 positions.push(point(&m, *p));
323 normals.push(normal(
324 &m,
325 body.normals.get(i).copied().unwrap_or([0., 1., 0.]),
326 ));
327 }
328 let positions = Arc::new(positions);
329 let normals = Arc::new(normals);
330 let uvs = Arc::new(body.uvs.clone());
331 let colors = Arc::new(body.colors.clone());
332 if body.surfaces.is_empty() {
333 draws.push(Draw {
334 positions,
335 normals,
336 uvs,
337 vertex_colors: colors,
338 indices: body.indices.clone(),
339 color: body.base_color,
340 albedo: None,
341 shiny: None,
342 specular: 0.,
343 cutoff: 0.001,
344 flat_normals: !body.source_normals_complete,
345 });
346 } else {
347 for surface in &body.surfaces {
348 draws.push(Draw {
349 positions: positions.clone(),
350 normals: normals.clone(),
351 uvs: uvs.clone(),
352 vertex_colors: colors.clone(),
353 indices: body.indices[surface.first..surface.first + surface.count].to_vec(),
354 color: surface.color,
355 albedo: surface.albedo.clone(),
356 shiny: None,
357 specular: 0.,
358 cutoff: surface.cutoff,
359 flat_normals: surface.flat_normals,
360 });
361 }
362 }
363 }
364 let (view, projection) = camera(
365 crate::camera::resolve(body, job.framing).framing,
366 yaw,
367 pitch,
368 job.target_width as f32 / job.target_height as f32,
369 );
370 Ok(Scene {
371 draws,
372 view,
373 projection,
374 width: job.target_width * SCALE,
375 height: job.target_height * SCALE,
376 })
377}
378
379impl Scene {
380 pub fn validate(&self) -> Result<(), String> {
381 if self.width == 0
382 || self.height == 0
383 || self.width > MAX_HIGHRES
384 || self.height > MAX_HIGHRES
385 || self.draws.len() > 4096
386 || self
387 .view
388 .iter()
389 .chain(&self.projection)
390 .any(|v| !v.is_finite())
391 {
392 return Err("ongeldige GPU-scène/afmetingen".into());
393 }
394 for d in &self.draws {
395 if d.positions.len() > 1_000_000
396 || d.positions.len() != d.normals.len()
397 || d.indices.len() > 3_000_000
398 || !d.indices.len().is_multiple_of(3)
399 || d.indices.iter().any(|i| *i as usize >= d.positions.len())
400 || d.positions
401 .iter()
402 .chain(d.normals.iter())
403 .flatten()
404 .chain(d.uvs.iter().flatten())
405 .chain(d.vertex_colors.iter().flatten())
406 .chain(d.color.iter())
407 .any(|v| !v.is_finite())
408 || !d.specular.is_finite()
409 || !d.cutoff.is_finite()
410 {
411 return Err("ongeldige of te grote GPU-geometrie".into());
412 }
413 for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() {
414 if t.width == 0
415 || t.height == 0
416 || t.width > 2048
417 || t.height > 2048
418 || t.rgba.len() != t.width as usize * t.height as usize * 4
419 {
420 return Err("ongeldige GPU-texture".into());
421 }
422 }
423 }
424 Ok(())
425 }
426}
427
428#[derive(Default)]
429pub struct GpuRenderer {
430 program: Option<glow::Program>,
431 vao: Option<glow::VertexArray>,
432 vbo: Option<glow::Buffer>,
433 ebo: Option<glow::Buffer>,
434 fbo: Option<glow::Framebuffer>,
435 colors: Vec<glow::Texture>,
436 depth: Option<glow::Renderbuffer>,
437 extent: [u32; 2],
438}
439impl GpuRenderer {
440 /// # Safety
441 /// A native OpenGL 3.3 context must be current on this thread.
442 pub unsafe fn destroy(&mut self, gl: &glow::Context) {
443 self.destroy_target(gl);
444 if let Some(x) = self.program.take() {
445 gl.delete_program(x);
446 }
447 if let Some(x) = self.vao.take() {
448 gl.delete_vertex_array(x);
449 }
450 if let Some(x) = self.vbo.take() {
451 gl.delete_buffer(x);
452 }
453 if let Some(x) = self.ebo.take() {
454 gl.delete_buffer(x);
455 }
456 }
457 unsafe fn destroy_target(&mut self, gl: &glow::Context) {
458 for x in self.colors.drain(..) {
459 gl.delete_texture(x);
460 }
461 if let Some(x) = self.depth.take() {
462 gl.delete_renderbuffer(x);
463 }
464 if let Some(x) = self.fbo.take() {
465 gl.delete_framebuffer(x);
466 }
467 self.extent = [0, 0];
468 }
469 unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
470 if self.program.is_some() {
471 return Ok(());
472 }
473 let p = gl.create_program()?;
474 let mut shaders = Vec::new();
475 for (kind, source) in [
476 (glow::VERTEX_SHADER, VERTEX),
477 (glow::FRAGMENT_SHADER, FRAGMENT),
478 ] {
479 let shader = gl.create_shader(kind)?;
480 gl.shader_source(shader, source);
481 gl.compile_shader(shader);
482 if !gl.get_shader_compile_status(shader) {
483 let error = gl.get_shader_info_log(shader);
484 gl.delete_shader(shader);
485 for s in shaders {
486 gl.delete_shader(s);
487 }
488 gl.delete_program(p);
489 return Err(error);
490 }
491 gl.attach_shader(p, shader);
492 shaders.push(shader);
493 }
494 gl.link_program(p);
495 let result = if gl.get_program_link_status(p) {
496 Ok(())
497 } else {
498 Err(gl.get_program_info_log(p))
499 };
500 for s in shaders {
501 gl.detach_shader(p, s);
502 gl.delete_shader(s);
503 }
504 if let Err(e) = result {
505 gl.delete_program(p);
506 return Err(e);
507 }
508 self.program = Some(p);
509 self.vao = Some(gl.create_vertex_array()?);
510 self.vbo = Some(gl.create_buffer()?);
511 self.ebo = Some(gl.create_buffer()?);
512 Ok(())
513 }
514 unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
515 if self.extent == [w, h] {
516 return Ok(());
517 }
518 self.destroy_target(gl);
519 self.fbo = Some(gl.create_framebuffer()?);
520 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
521 for slot in 0..3 {
522 let t = gl.create_texture()?;
523 self.colors.push(t);
524 gl.bind_texture(glow::TEXTURE_2D, Some(t));
525 gl.tex_parameter_i32(
526 glow::TEXTURE_2D,
527 glow::TEXTURE_MIN_FILTER,
528 glow::NEAREST as i32,
529 );
530 gl.tex_parameter_i32(
531 glow::TEXTURE_2D,
532 glow::TEXTURE_MAG_FILTER,
533 glow::NEAREST as i32,
534 );
535 gl.tex_image_2d(
536 glow::TEXTURE_2D,
537 0,
538 glow::RGBA8 as i32,
539 w as i32,
540 h as i32,
541 0,
542 glow::RGBA,
543 glow::UNSIGNED_BYTE,
544 None,
545 );
546 gl.framebuffer_texture_2d(
547 glow::FRAMEBUFFER,
548 glow::COLOR_ATTACHMENT0 + slot,
549 glow::TEXTURE_2D,
550 Some(t),
551 0,
552 );
553 }
554 let d = gl.create_renderbuffer()?;
555 self.depth = Some(d);
556 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d));
557 gl.renderbuffer_storage(
558 glow::RENDERBUFFER,
559 glow::DEPTH_COMPONENT24,
560 w as i32,
561 h as i32,
562 );
563 gl.framebuffer_renderbuffer(
564 glow::FRAMEBUFFER,
565 glow::DEPTH_ATTACHMENT,
566 glow::RENDERBUFFER,
567 Some(d),
568 );
569 gl.draw_buffers(&[
570 glow::COLOR_ATTACHMENT0,
571 glow::COLOR_ATTACHMENT1,
572 glow::COLOR_ATTACHMENT2,
573 ]);
574 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
575 return Err("productie-FBO onvolledig".into());
576 }
577 self.extent = [w, h];
578 Ok(())
579 }
580 /// Render and read back unlit color, view-space normals, scalar specular and
581 /// the actual depth attachment. All four use the same winning surface.
582 /// # Safety
583 /// `gl` is current on the UI thread. GL state is reset for egui painting.
584 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
585 if scene.width == 0
586 || scene.height == 0
587 || scene.width > MAX_HIGHRES
588 || scene.height > MAX_HIGHRES
589 {
590 return Err("highres moet 1–2048 pixels zijn (8× target)".into());
591 }
592 scene.validate()?;
593 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
594 let mut vp = [0; 4];
595 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
596 let result = (|| {
597 self.init(gl)?;
598 self.target(gl, scene.width, scene.height)?;
599 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
600 gl.draw_buffers(&[
601 glow::COLOR_ATTACHMENT0,
602 glow::COLOR_ATTACHMENT1,
603 glow::COLOR_ATTACHMENT2,
604 ]);
605 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
606 gl.disable(glow::SCISSOR_TEST);
607 gl.disable(glow::CULL_FACE);
608 gl.disable(glow::BLEND);
609 gl.enable(glow::DEPTH_TEST);
610 gl.depth_mask(true);
611 gl.depth_func(glow::LESS);
612 gl.clear_depth_f32(1.);
613 gl.clear_color(0., 0., 0., 0.);
614 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
615 let p = self.program.ok_or("shader ontbreekt")?;
616 gl.use_program(Some(p));
617 gl.uniform_matrix_4_f32_slice(
618 gl.get_uniform_location(p, "u_view").as_ref(),
619 false,
620 &scene.view,
621 );
622 gl.uniform_matrix_4_f32_slice(
623 gl.get_uniform_location(p, "u_projection").as_ref(),
624 false,
625 &scene.projection,
626 );
627 gl.bind_vertex_array(self.vao);
628 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
629 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
630 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
631 gl.enable_vertex_attrib_array(slot);
632 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
633 }
634 for draw in &scene.draws {
635 if draw.positions.len() != draw.normals.len()
636 || draw
637 .indices
638 .iter()
639 .any(|i| *i as usize >= draw.positions.len())
640 || draw.indices.len() > i32::MAX as usize
641 {
642 return Err("ongeldige GPU-geometrie".into());
643 }
644 let mut data = Vec::with_capacity(draw.positions.len() * 12);
645 for (i, pos) in draw.positions.iter().enumerate() {
646 data.extend_from_slice(pos);
647 data.extend_from_slice(&draw.normals[i]);
648 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
649 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
650 }
651 gl.buffer_data_u8_slice(
652 glow::ARRAY_BUFFER,
653 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
654 glow::STREAM_DRAW,
655 );
656 gl.buffer_data_u8_slice(
657 glow::ELEMENT_ARRAY_BUFFER,
658 std::slice::from_raw_parts(
659 draw.indices.as_ptr().cast(),
660 draw.indices.len() * 4,
661 ),
662 glow::STREAM_DRAW,
663 );
664 gl.uniform_4_f32(
665 gl.get_uniform_location(p, "u_color").as_ref(),
666 draw.color[0],
667 draw.color[1],
668 draw.color[2],
669 draw.color[3],
670 );
671 gl.uniform_1_f32(
672 gl.get_uniform_location(p, "u_specular").as_ref(),
673 draw.specular,
674 );
675 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
676 gl.uniform_1_i32(
677 gl.get_uniform_location(p, "u_flat").as_ref(),
678 i32::from(draw.flat_normals),
679 );
680 let mut temp = Vec::new();
681 for (unit, name, texture) in [
682 (0, "u_albedo", draw.albedo.as_ref()),
683 (1, "u_shiny", draw.shiny.as_ref()),
684 ] {
685 gl.active_texture(glow::TEXTURE0 + unit);
686 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
687 gl.uniform_1_i32(
688 gl.get_uniform_location(p, &format!("{name}_enabled"))
689 .as_ref(),
690 i32::from(texture.is_some()),
691 );
692 if let Some(image) = texture {
693 let t = gl.create_texture()?;
694 temp.push(t);
695 gl.bind_texture(glow::TEXTURE_2D, Some(t));
696 gl.tex_parameter_i32(
697 glow::TEXTURE_2D,
698 glow::TEXTURE_MIN_FILTER,
699 glow::NEAREST as i32,
700 );
701 gl.tex_parameter_i32(
702 glow::TEXTURE_2D,
703 glow::TEXTURE_MAG_FILTER,
704 glow::NEAREST as i32,
705 );
706 gl.tex_parameter_i32(
707 glow::TEXTURE_2D,
708 glow::TEXTURE_WRAP_S,
709 glow::REPEAT as i32,
710 );
711 gl.tex_parameter_i32(
712 glow::TEXTURE_2D,
713 glow::TEXTURE_WRAP_T,
714 glow::REPEAT as i32,
715 );
716 gl.tex_image_2d(
717 glow::TEXTURE_2D,
718 0,
719 if unit == 0 {
720 glow::SRGB8_ALPHA8 as i32
721 } else {
722 glow::RGBA8 as i32
723 },
724 image.width as i32,
725 image.height as i32,
726 0,
727 glow::RGBA,
728 glow::UNSIGNED_BYTE,
729 Some(&image.rgba),
730 );
731 } else {
732 gl.bind_texture(glow::TEXTURE_2D, None);
733 }
734 }
735 gl.draw_elements(
736 glow::TRIANGLES,
737 draw.indices.len() as i32,
738 glow::UNSIGNED_INT,
739 0,
740 );
741 for t in temp {
742 gl.delete_texture(t);
743 }
744 }
745 let pixels = scene.width as usize * scene.height as usize;
746 let mut maps = Vec::new();
747 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
748 for slot in 0..3 {
749 let mut data = vec![0; pixels * 4];
750 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
751 gl.read_pixels(
752 0,
753 0,
754 scene.width as i32,
755 scene.height as i32,
756 glow::RGBA,
757 glow::UNSIGNED_BYTE,
758 glow::PixelPackData::Slice(&mut data),
759 );
760 flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);
761 maps.push(data);
762 }
763 let mut depth = vec![1f32; pixels];
764 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
765 gl.read_pixels(
766 0,
767 0,
768 scene.width as i32,
769 scene.height as i32,
770 glow::DEPTH_COMPONENT,
771 glow::FLOAT,
772 glow::PixelPackData::Slice(bytes),
773 );
774 flip_rows(&mut depth, scene.width as usize, scene.height as usize);
775 let error = gl.get_error();
776 if error != glow::NO_ERROR {
777 return Err(format!("productie-GL fout 0x{error:x}"));
778 }
779 let specular = maps.pop().unwrap();
780 let normal = maps.pop().unwrap();
781 let diffuse = maps.pop().unwrap();
782 Ok(Buffers {
783 width: scene.width,
784 height: scene.height,
785 diffuse,
786 normal,
787 specular,
788 depth,
789 })
790 })();
791 gl.bind_framebuffer(
792 glow::FRAMEBUFFER,
793 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
794 );
795 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
796 gl.bind_vertex_array(None);
797 gl.use_program(None);
798 gl.active_texture(glow::TEXTURE0);
799 gl.disable(glow::DEPTH_TEST);
800 gl.disable(glow::SCISSOR_TEST);
801 gl.enable(glow::BLEND);
802 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
803 result
804 }
805}
806fn flip_rows<T>(data: &mut [T], stride: usize, height: usize) {
807 for y in 0..height / 2 {
808 let split = (height - 1 - y) * stride;
809 let (a, b) = data.split_at_mut(split);
810 a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]);
811 }
812}
813const VERTEX: &str = r#"#version 330
814layout(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;
815uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color;
816void 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;}
817"#;
818const FRAGMENT: &str = r#"#version 330
819in 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
820uniform float u_specular;
821uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled;
822layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular;
823vec3 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));}
824void 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);}
825"#;
826
827fn image(w: u32, h: u32, rgba: Vec<u8>) -> ImageBuf {
828 ImageBuf {
829 width: w,
830 height: h,
831 rgba,
832 }
833}
834fn linear(x: u8) -> f32 {
835 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
836 LUT.get_or_init(|| {
837 std::array::from_fn(|i| {
838 let v = i as f32 / 255.;
839 if v <= 0.04045 {
840 v / 12.92
841 } else {
842 ((v + 0.055) / 1.055).powf(2.4)
843 }
844 })
845 })[x as usize]
846}
847
848fn srgb(x: f32) -> u8 {
849 let v = if x <= 0.0031308 {
850 x * 12.92
851 } else {
852 1.055 * x.powf(1. / 2.4) - 0.055
853 };
854 (v * 255.).round().clamp(0., 255.) as u8
855}
856impl Buffers {
857 pub fn validate(&self) -> Result<(), String> {
858 let pixels = (self.width as usize)
859 .checked_mul(self.height as usize)
860 .ok_or("te veel pixels")?;
861 if self.width == 0
862 || self.height == 0
863 || self.width > MAX_HIGHRES
864 || self.height > MAX_HIGHRES
865 || self.diffuse.len() != pixels * 4
866 || self.normal.len() != pixels * 4
867 || self.specular.len() != pixels * 4
868 || self.depth.len() != pixels
869 || self
870 .depth
871 .iter()
872 .any(|d| !d.is_finite() || !(0.0..=1.0).contains(d))
873 {
874 return Err("ongeldige GPU-mapafmetingen/data".into());
875 }
876 Ok(())
877 }
878}
879pub fn detail_map(raw: &Buffers) -> Result<Vec<u8>, String> {
880 raw.validate()?;
881 let w = raw.width as usize;
882 let h = raw.height as usize;
883 let mut out = vec![0; w * h];
884 for y in 0..h {
885 for x in 0..w {
886 let i = y * w + x;
887 let mut edge = 0f32;
888 for (nx, ny) in [
889 (x.saturating_sub(1), y),
890 ((x + 1).min(w - 1), y),
891 (x, y.saturating_sub(1)),
892 (x, (y + 1).min(h - 1)),
893 ] {
894 let j = ny * w + nx;
895 let a = raw.diffuse[i * 4 + 3] as f32 / 255.;
896 let b = raw.diffuse[j * 4 + 3] as f32 / 255.;
897 edge = edge.max((a - b).abs());
898 if a > 0. && b > 0. {
899 for c in 0..3 {
900 edge = edge.max(
901 (linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(),
902 );
903 edge = edge.max(
904 (raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs()
905 / 255.,
906 );
907 }
908 edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.));
909 }
910 }
911 out[i] = (edge * 255.).round().clamp(0., 255.) as u8;
912 }
913 }
914 Ok(out)
915}
916pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf {
917 let mut rgba = Vec::with_capacity(detail.len() * 4);
918 for (i, v) in detail.iter().enumerate() {
919 rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]);
920 }
921 image(raw.width, raw.height, rgba)
922}
923pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, String> {
924 raw.validate()?;
925 if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE {
926 return Err("ongeldige target-afmetingen".into());
927 }
928 if raw.width != w * SCALE
929 || raw.height != h * SCALE
930 || detail.len() != raw.width as usize * raw.height as usize
931 {
932 return Err("ongeldige 8× resolve-afmetingen".into());
933 }
934 let count = w as usize * h as usize;
935 let mut diffuse = vec![0; count * 4];
936 let mut normals = diffuse.clone();
937 let mut specular = diffuse.clone();
938 let mut depth = vec![0u16; count * 2];
939 let mut depth_preview = diffuse.clone();
940 for y in 0..h {
941 for x in 0..w {
942 let i = (y * w + x) as usize;
943 let mut closest = 1f32;
944 let mut coverage = 0f32;
945 for dy in 0..SCALE {
946 for dx in 0..SCALE {
947 let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
948 let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
949 coverage += a;
950 if a > 0. && raw.depth[j].is_finite() {
951 closest = closest.min(raw.depth[j]);
952 }
953 }
954 }
955 let alpha = (coverage / (SCALE * SCALE) as f32 * 255.)
956 .round()
957 .clamp(0., 255.) as u8;
958 if alpha == 0 {
959 continue;
960 }
961 let mut color = [0.; 3];
962 let mut n = [0.; 3];
963 let mut shiny = 0.;
964 let mut sum = 0.;
965 // One coherent front-surface cluster; never average foreground with a rear
966 // surface. Edges get extra weight, and any thin covered feature survives.
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 if a == 0. || raw.depth[j] > closest + 0.0025 {
972 continue;
973 }
974 let weight = a * (1. + 4. * detail[j] as f32 / 255.);
975 sum += weight;
976 for c in 0..3 {
977 color[c] += linear(raw.diffuse[j * 4 + c]) * weight;
978 n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight;
979 }
980 shiny += raw.specular[j * 4] as f32 * weight;
981 }
982 }
983 if sum <= 0. {
984 return Err("ongeldige depth/coverage in GPU-resultaat".into());
985 }
986 n = normalize(n);
987 let d = (closest.clamp(0., 1.) * 65535.).round() as u16;
988 for c in 0..3 {
989 diffuse[i * 4 + c] = srgb(color[c] / sum);
990 normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8;
991 specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8;
992 depth_preview[i * 4 + c] = (d >> 8) as u8;
993 }
994 for map in [
995 &mut diffuse,
996 &mut normals,
997 &mut specular,
998 &mut depth_preview,
999 ] {
1000 map[i * 4 + 3] = alpha;
1001 }
1002 depth[i * 2] = d;
1003 depth[i * 2 + 1] = u16::from(alpha) * 257;
1004 }
1005 }
1006 Ok(Target {
1007 diffuse: image(w, h, diffuse),
1008 normal: image(w, h, normals),
1009 specular: image(w, h, specular),
1010 depth,
1011 depth_preview: image(w, h, depth_preview),
1012 })
1013}
1014
1015fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1016 let relative = relative
1017 .to_str()
1018 .ok_or("ongeldig uitvoerpad")?
1019 .replace('\\', "/");
1020 let relative = crate::project::portable_relative(&relative)?;
1021 let relative = relative.as_str();
1022 let canonical = root.canonicalize().map_err(|e| e.to_string())?;
1023 let path = root.join(relative);
1024 let parent = path.parent().ok_or("uitvoermap ontbreekt")?;
1025 let mut prefix = root.to_path_buf();
1026 for part in Path::new(relative)
1027 .parent()
1028 .unwrap_or(Path::new(""))
1029 .components()
1030 {
1031 prefix.push(part);
1032 if !prefix.exists() {
1033 std::fs::create_dir(&prefix).map_err(|e| e.to_string())?;
1034 }
1035 let real = prefix.canonicalize().map_err(|e| e.to_string())?;
1036 if !real.starts_with(&canonical) {
1037 return Err("uitvoer verlaat projectmap".into());
1038 }
1039 }
1040 if !parent
1041 .canonicalize()
1042 .map_err(|e| e.to_string())?
1043 .starts_with(canonical)
1044 {
1045 return Err("onveilig uitvoerpad".into());
1046 }
1047 Ok(path)
1048}
1049fn atomic_file(
1050 root: &Path,
1051 relative: &Path,
1052 write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
1053) -> Result<(), String> {
1054 let path = safe_parent(root, relative)?;
1055 let temp = path.with_extension(format!("part-{}", std::process::id()));
1056 let mut owned = false;
1057 let result = (|| {
1058 let mut file = std::fs::OpenOptions::new()
1059 .write(true)
1060 .create_new(true)
1061 .open(&temp)
1062 .map_err(|e| e.to_string())?;
1063 owned = true;
1064 write(&mut file)?;
1065 file.sync_all().map_err(|e| e.to_string())?;
1066 std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
1067 Ok(())
1068 })();
1069 if owned {
1070 let _ = std::fs::remove_file(temp);
1071 }
1072 result
1073}
1074fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result<Vec<u8>, String> {
1075 if img.width == 0
1076 || img.height == 0
1077 || img.width > MAX_HIGHRES / SCALE
1078 || img.height > MAX_HIGHRES / SCALE
1079 || img.rgba.len() != img.width as usize * img.height as usize * 4
1080 {
1081 return Err("ongeldige PNG-afmetingen".into());
1082 }
1083 let mut cursor = std::io::Cursor::new(Vec::new());
1084 {
1085 let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height);
1086 encoder.set_color(png::ColorType::Rgba);
1087 encoder.set_depth(png::BitDepth::Eight);
1088 if diffuse_srgb {
1089 encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
1090 }
1091 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1092 writer
1093 .write_image_data(&img.rgba)
1094 .map_err(|e| e.to_string())?;
1095 writer.finish().map_err(|e| e.to_string())?;
1096 }
1097 Ok(cursor.into_inner())
1098}
1099
1100fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result<Vec<u8>, String> {
1101 if w == 0
1102 || h == 0
1103 || w > MAX_HIGHRES / SCALE
1104 || h > MAX_HIGHRES / SCALE
1105 || depth.len() != w as usize * h as usize * 2
1106 {
1107 return Err("ongeldige 16-bit depthafmetingen".into());
1108 }
1109 let samples: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();
1110 let mut cursor = std::io::Cursor::new(Vec::new());
1111 {
1112 let mut encoder = png::Encoder::new(&mut cursor, w, h);
1113 encoder.set_color(png::ColorType::GrayscaleAlpha);
1114 encoder.set_depth(png::BitDepth::Sixteen);
1115 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1116 writer
1117 .write_image_data(&samples)
1118 .map_err(|e| e.to_string())?;
1119 writer.finish().map_err(|e| e.to_string())?;
1120 }
1121 Ok(cursor.into_inner())
1122}
1123
1124/// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes.
1125pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String, String> {
1126 let diffuse_srgb = relative
1127 .file_name()
1128 .and_then(|p| p.to_str())
1129 .map(|p| p.ends_with("_diffuse.png"))
1130 .unwrap_or(false);
1131 let bytes = encode_rgba_png(img, diffuse_srgb)?;
1132 let digest = crate::digest::sha256_hex(&bytes);
1133 atomic_file(root, relative, |file| {
1134 use std::io::Write;
1135 file.write_all(&bytes).map_err(|e| e.to_string())
1136 })?;
1137 Ok(digest)
1138}
1139
1140/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.
1141pub fn write_depth(
1142 root: &Path,
1143 relative: &Path,
1144 w: u32,
1145 h: u32,
1146 depth: &[u16],
1147) -> Result<String, String> {
1148 let bytes = encode_depth_png(w, h, depth)?;
1149 let digest = crate::digest::sha256_hex(&bytes);
1150 atomic_file(root, relative, |file| {
1151 use std::io::Write;
1152 file.write_all(&bytes).map_err(|e| e.to_string())
1153 })?;
1154 Ok(digest)
1155}
1156
1157fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1158 use std::io::Write;
1159 let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
1160 atomic_file(root, Path::new(name), |f| {
1161 f.write_all(&data).map_err(|e| e.to_string())
1162 })
1163}
1164
1165pub struct GpuRequest {
1166 pub scene: Scene,
1167 reply: mpsc::SyncSender<Result<Buffers, String>>,
1168}
1169impl GpuRequest {
1170 pub fn fail(self, error: String) {
1171 let _ = self.reply.send(Err(error));
1172 }
1173 /// # Safety
1174 /// `gl` must be current on this UI thread.
1175 pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1176 let result = renderer.render(gl, &self.scene);
1177 let _ = self.reply.send(result);
1178 }
1179}
1180#[derive(Default)]
1181struct Pending {
1182 pass: Option<RenderEvent>,
1183 progress: Option<RenderEvent>,
1184 terminal: Option<RenderEvent>,
1185}
1186#[derive(Clone, Default)]
1187pub struct Mailbox(Arc<Mutex<Pending>>);
1188impl Mailbox {
1189 pub fn publish(&self, event: RenderEvent) {
1190 if let Ok(mut state) = self.0.lock() {
1191 match event {
1192 RenderEvent::Pass { .. } => {
1193 state.pass = Some(event);
1194 state.progress = None;
1195 }
1196 RenderEvent::Step { .. } => state.progress = Some(event),
1197 _ => state.terminal = Some(event),
1198 }
1199 }
1200 }
1201 pub fn drain(&self) -> Vec<RenderEvent> {
1202 let mut events = Vec::new();
1203 if let Ok(mut s) = self.0.lock() {
1204 events.extend(s.pass.take());
1205 events.extend(s.progress.take());
1206 events.extend(s.terminal.take());
1207 }
1208 events
1209 }
1210}
1211pub struct RenderThread {
1212 pub mailbox: Mailbox,
1213 pub gpu: mpsc::Receiver<GpuRequest>,
1214 pub cancel: Arc<AtomicBool>,
1215 pub handle: Option<thread::JoinHandle<()>>,
1216}
1217impl Drop for RenderThread {
1218 fn drop(&mut self) {
1219 self.cancel.store(true, Ordering::Relaxed);
1220 }
1221}
1222pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {
1223 let mailbox = Mailbox::default();
1224 let output = mailbox.clone();
1225 let cancel = Arc::new(AtomicBool::new(false));
1226 let flag = cancel.clone();
1227 let (tx, gpu) = mpsc::channel();
1228 let handle = thread::spawn(move || {
1229 let mut emit = |event| {
1230 output.publish(event);
1231 if let Some(ctx) = &ctx {
1232 ctx.request_repaint();
1233 }
1234 };
1235 let result = execute(job, &tx, &flag, ctx.as_ref(), &mut emit);
1236 if let Err(error) = result {
1237 if flag.load(Ordering::Relaxed) {
1238 emit(RenderEvent::Cancelled);
1239 } else {
1240 emit(RenderEvent::Failed(error));
1241 }
1242 }
1243 });
1244 RenderThread {
1245 mailbox,
1246 gpu,
1247 cancel,
1248 handle: Some(handle),
1249 }
1250}
1251fn execute(
1252 mut job: RenderJob,
1253 gpu: &mpsc::Sender<GpuRequest>,
1254 cancel: &AtomicBool,
1255 ctx: Option<&eframe::egui::Context>,
1256 emit: &mut dyn FnMut(RenderEvent),
1257) -> Result<(), String> {
1258 job.framing.validate()?;
1259 let pivot = crate::camera::resolve(&job.character, job.framing);
1260 job.framing = pivot.framing;
1261 if job.target_width == 0
1262 || job.target_height == 0
1263 || job.target_width > MAX_HIGHRES / SCALE
1264 || job.target_height > MAX_HIGHRES / SCALE
1265 {
1266 return Err("8× highres vereist targetafmetingen 1–256".into());
1267 }
1268 if !["male", "female"].contains(&job.sex.as_str()) {
1269 return Err("ongeldig geslacht".into());
1270 }
1271 if let Some(p) = &job.preset {
1272 p.validate()?;
1273 if !p.applies[usize::from(job.sex == "female")] {
1274 return Err("equipment niet geldig voor dit geslacht".into());
1275 }
1276 if p.attachments.is_empty() {
1277 return Err("equipment heeft geen onderdelen".into());
1278 }
1279 }
1280 let plan = plan(&job.character, job.fps)?;
1281 let output_bytes =
1282 u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16;
1283 if output_bytes > 16 * 1024 * 1024 * 1024 {
1284 return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());
1285 }
1286 std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1287 let stamp = std::time::SystemTime::now()
1288 .duration_since(std::time::UNIX_EPOCH)
1289 .map_err(|e| e.to_string())?
1290 .as_nanos();
1291 let root = job
1292 .output
1293 .join(format!("run-{stamp}-{}", std::process::id()));
1294 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1295 let set = job
1296 .preset
1297 .as_ref()
1298 .map(|p| p.name.as_str())
1299 .unwrap_or("body")
1300 .to_string();
1301 if job.project_id.is_empty() || job.project_id.len() > 128 {
1302 return Err("project.id ongeldig voor export-identiteit".into());
1303 }
1304 if job.project_name.len() > 256 {
1305 return Err("project.name te lang voor export-identiteit".into());
1306 }
1307 if !crate::digest::is_sha256_hex(&job.body_model.sha256) {
1308 return Err("body_model.sha256 ongeldig".into());
1309 }
1310 crate::project::portable_relative(&job.body_model.path)?;
1311 match (&job.preset, &job.preset_sha256) {
1312 (None, None) => {}
1313 (Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {}
1314 _ => return Err("equipment fingerprint past niet bij de job".into()),
1315 }
1316 let equipment_preset = job.preset.as_ref().map(|p| {
1317 serde_json::json!({
1318 "name": p.name,
1319 "sha256": job.preset_sha256.as_ref().expect("checked"),
1320 })
1321 });
1322 let clips_json = plan
1323 .clips
1324 .iter()
1325 .enumerate()
1326 .map(|(i, c)| {
1327 serde_json::json!({
1328 "name": c.source_name,
1329 "token": c.token,
1330 "frames": c.frames,
1331 "duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.),
1332 })
1333 })
1334 .collect::<Vec<_>>();
1335 let render_doc = serde_json::json!({
1336 "format_version": crate::export_set::OUTPUT_META_VERSION,
1337 "status": "incomplete",
1338 "project": {"id": job.project_id, "name": job.project_name},
1339 "sex": job.sex,
1340 "set": set,
1341 "body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256},
1342 "equipment_preset": equipment_preset,
1343 "target": [job.target_width, job.target_height],
1344 "fps": job.fps,
1345 "highres_scale": SCALE,
1346 "framing": job.framing,
1347 "pivot_source": pivot.source,
1348 "near": NEAR,
1349 "far": FAR,
1350 "normal_space": "view (+Y up), PNG rows top-down",
1351 "camera_distance": 10,
1352 "yaw_zero": "camera on -Z",
1353 "pitches": [-30, 0, 30],
1354 "yaw_step": 30,
1355 "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",
1356 "alpha": "straight, opaque/cutout, coverage resolve",
1357 "diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",
1358 "depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",
1359 "specular": "linear scalar; body defaults zero",
1360 "pass_order": PASSES,
1361 "saved_maps": ["diffuse", "depth", "normal", "specular"],
1362 "clips": clips_json,
1363 "images": plan.images,
1364 "passes": plan.passes,
1365 "png_files": plan.files,
1366 });
1367 write_json(&root, "render.json", render_doc.clone())?;
1368 // Internal unique names select every source clip, even duplicate display names.
1369 for (i, c) in job.character.clips.iter_mut().enumerate() {
1370 c.name = format!("render-clip-{i}");
1371 }
1372 let mut completed = 0u32;
1373 let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
1374 let mut cache = equipment::TextureCache::default();
1375 let result = (|| {
1376 for sample in samples(&plan) {
1377 let clip = &plan.clips[sample.clip];
1378 let frame = sample.frame;
1379 let pitch = sample.pitch;
1380 let yaw = sample.yaw;
1381 let key = if job.character.clips.is_empty() {
1382 String::new()
1383 } else {
1384 format!("render-clip-{}", sample.clip)
1385 };
1386 if cancel.load(Ordering::Relaxed) {
1387 return Err("geannuleerd".into());
1388 }
1389 let pose = format!(
1390 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1391 job.sex, clip.source_name, clip.frames
1392 );
1393 emit(RenderEvent::Step {
1394 index: completed,
1395 total: plan.passes,
1396 label: format!("{pose} · highres"),
1397 });
1398 let scene = scene(
1399 &job,
1400 &key,
1401 frame as f32 / job.fps as f32,
1402 pitch as f32,
1403 yaw as f32,
1404 &mut cache,
1405 )?;
1406 let (tx, rx) = mpsc::sync_channel(1);
1407 gpu.send(GpuRequest { scene, reply: tx })
1408 .map_err(|_| "GPU-service gesloten")?;
1409 if let Some(ctx) = ctx {
1410 ctx.request_repaint();
1411 }
1412 let raw = loop {
1413 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1414 Ok(result) => break result?,
1415 Err(mpsc::RecvTimeoutError::Timeout) => {
1416 if cancel.load(Ordering::Relaxed) {
1417 return Err("geannuleerd".into());
1418 }
1419 }
1420 Err(_) => return Err("GPU-service gestopt".into()),
1421 }
1422 };
1423 completed += 1;
1424 emit(RenderEvent::Pass {
1425 index: completed - 1,
1426 total: plan.passes,
1427 label: format!("{pose} · highres"),
1428 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1429 });
1430 if cancel.load(Ordering::Relaxed) {
1431 return Err("geannuleerd".into());
1432 }
1433 emit(RenderEvent::Step {
1434 index: completed,
1435 total: plan.passes,
1436 label: format!("{pose} · detail"),
1437 });
1438 let detail = detail_map(&raw)?;
1439 completed += 1;
1440 emit(RenderEvent::Pass {
1441 index: completed - 1,
1442 total: plan.passes,
1443 label: format!("{pose} · detail"),
1444 image: Some(detail_image(&raw, &detail)),
1445 });
1446 if cancel.load(Ordering::Relaxed) {
1447 return Err("geannuleerd".into());
1448 }
1449 emit(RenderEvent::Step {
1450 index: completed,
1451 total: plan.passes,
1452 label: format!("{pose} · target-res / diffuse"),
1453 });
1454 let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1455 for (map, preview) in [
1456 ("diffuse", target.diffuse),
1457 ("depth", target.depth_preview),
1458 ("normal", target.normal),
1459 ("specular", target.specular),
1460 ] {
1461 if cancel.load(Ordering::Relaxed) {
1462 return Err("geannuleerd".into());
1463 }
1464 emit(RenderEvent::Step {
1465 index: completed,
1466 total: plan.passes,
1467 label: format!("{pose} · {map}"),
1468 });
1469 let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?;
1470 let digest = if map == "depth" {
1471 write_depth(
1472 &root,
1473 &path,
1474 job.target_width,
1475 job.target_height,
1476 &target.depth,
1477 )?
1478 } else {
1479 write_rgba(&root, &path, &preview)?
1480 };
1481 let key = path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");
1482 png_sha256.insert(key, digest);
1483 completed += 1;
1484 emit(RenderEvent::Pass {
1485 index: completed - 1,
1486 total: plan.passes,
1487 label: format!(
1488 "{pose} · {}",
1489 if map == "diffuse" {
1490 "target-res / diffuse"
1491 } else {
1492 map
1493 }
1494 ),
1495 image: Some(preview),
1496 });
1497 }
1498 }
1499 Ok(())
1500 })();
1501 match result {
1502 Ok(()) => {
1503 if png_sha256.len() as u32 != plan.files {
1504 return Err(format!(
1505 "checksumtelling {} wijkt af van geplande {} PNGs",
1506 png_sha256.len(),
1507 plan.files
1508 ));
1509 }
1510 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1511 write_json(
1512 &root,
1513 "complete.json",
1514 serde_json::json!({
1515 "format_version": crate::export_set::OUTPUT_META_VERSION,
1516 "status": "complete",
1517 "identity": identity,
1518 "passes": completed,
1519 "png_files": plan.files,
1520 "png_sha256": png_sha256,
1521 }),
1522 )?;
1523 emit(RenderEvent::Finished {
1524 saved: Some(root.display().to_string()),
1525 });
1526 Ok(())
1527 }
1528 Err(error) => {
1529 let name = if cancel.load(Ordering::Relaxed) {
1530 "cancelled.json"
1531 } else {
1532 "failed.json"
1533 };
1534 let _ = write_json(
1535 &root,
1536 name,
1537 serde_json::json!({"error":error,"completed_passes":completed,"total_passes":plan.passes}),
1538 );
1539 Err(error)
1540 }
1541 }
1542}
1543
1544/// Real readback regression: nearest-surface depth/normal/specular, preserved
1545/// texture × vertex albedo/alpha and independence from opposed light directions.
1546/// # Safety
1547/// Run on a current disposable native context.
1548pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> {
1549 let triangle = |z, normal, color, specular| Draw {
1550 positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]),
1551 normals: Arc::new(vec![normal; 3]),
1552 uvs: Arc::new(vec![[0.; 2]; 3]),
1553 vertex_colors: Arc::new(Vec::new()),
1554 indices: vec![0, 1, 2],
1555 color,
1556 albedo: None,
1557 shiny: None,
1558 specular,
1559 cutoff: 0.001,
1560 flat_normals: false,
1561 };
1562 let mut renderer = GpuRenderer::default();
1563 let mut scene = Scene {
1564 draws: vec![
1565 triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75),
1566 triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.),
1567 ],
1568 view: preview::identity(),
1569 projection: preview::identity(),
1570 width: 32,
1571 height: 32,
1572 };
1573 let result = (|| {
1574 let raw = renderer.render(gl, &scene)?;
1575 let i = 16 * 32 + 16;
1576 if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255]
1577 || (raw.depth[i] - 0.25).abs() > 1e-5
1578 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255]
1579 || raw.specular[i * 4].abs_diff(191) > 1
1580 {
1581 return Err(format!(
1582 "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}",
1583 &raw.diffuse[i * 4..i * 4 + 4],
1584 raw.depth[i],
1585 &raw.normal[i * 4..i * 4 + 4],
1586 raw.specular[i * 4]
1587 ));
1588 }
1589 scene.draws.reverse();
1590 let reverse = renderer.render(gl, &scene)?;
1591 if reverse.diffuse != raw.diffuse
1592 || reverse.normal != raw.normal
1593 || reverse.depth != raw.depth
1594 {
1595 return Err("GPU map winner depends on submission order".into());
1596 }
1597 scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)];
1598 scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]);
1599 scene.draws[0].albedo = Some(Arc::new(equipment::Texture {
1600 content_hash: 99,
1601 width: 1,
1602 height: 1,
1603 rgba: vec![128, 255, 64, 192],
1604 }));
1605 let expected = [
1606 srgb(linear(128) * 0.25),
1607 srgb(0.75),
1608 srgb(linear(64) * 0.5),
1609 192,
1610 ];
1611 // Production intentionally has no lighting input. This optional conventional
1612 // uniform is optimized out now; a future accidental lighting term using it
1613 // must fail these actual GPU readbacks under opposed light directions.
1614 for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] {
1615 let p = renderer.program.ok_or("missing probe program")?;
1616 gl.use_program(Some(p));
1617 gl.uniform_3_f32(
1618 gl.get_uniform_location(p, "u_light_direction").as_ref(),
1619 light[0],
1620 light[1],
1621 light[2],
1622 );
1623 let output = renderer.render(gl, &scene)?;
1624 let pixel = &output.diffuse[i * 4..i * 4 + 4];
1625 if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) {
1626 return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"));
1627 }
1628 }
1629 let detail = detail_map(&raw)?;
1630 let target = resolve(&raw, &detail, 4, 4)?;
1631 if target.diffuse.width != 4
1632 || target.depth.len() != 32
1633 || target
1634 .normal
1635 .rgba
1636 .chunks_exact(4)
1637 .filter(|p| p[3] > 0)
1638 .any(|p| {
1639 let n = p[..3]
1640 .iter()
1641 .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2))
1642 .sum::<f32>()
1643 .sqrt();
1644 (n - 1.).abs() > 0.03
1645 })
1646 {
1647 return Err("target map dimensions/normals invalid".into());
1648 }
1649 let mut flat = Scene {
1650 draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)],
1651 view: preview::identity(),
1652 projection: preview::identity(),
1653 width: 32,
1654 height: 32,
1655 };
1656 flat.draws[0].flat_normals = true;
1657 let derived = renderer.render(gl, &flat)?;
1658 if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] {
1659 return Err(format!(
1660 "flat normals are not geometry-derived: {:?}",
1661 &derived.normal[i * 4..i * 4 + 4]
1662 ));
1663 }
1664 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");
1665 Ok(())
1666 })();
1667 renderer.destroy(gl);
1668 result
1669}
1670
1671#[cfg(test)]
1672mod tests {
1673 use super::*;
1674 struct Temp(PathBuf);
1675 impl Temp {
1676 fn new() -> Self {
1677 let p = std::env::temp_dir().join(format!(
1678 "paperdoll-production-{}-{}",
1679 std::process::id(),
1680 std::time::SystemTime::now()
1681 .duration_since(std::time::UNIX_EPOCH)
1682 .unwrap()
1683 .as_nanos()
1684 ));
1685 std::fs::create_dir(&p).unwrap();
1686 Self(p)
1687 }
1688 }
1689 impl Drop for Temp {
1690 fn drop(&mut self) {
1691 let _ = std::fs::remove_dir_all(&self.0);
1692 }
1693 }
1694 fn body() -> PreviewCharacter {
1695 preview::load_gltf_file(
1696 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1697 )
1698 .unwrap()
1699 }
1700 fn raw(w: u32, h: u32) -> Buffers {
1701 let n = (w * h) as usize;
1702 Buffers {
1703 width: w,
1704 height: h,
1705 diffuse: [255, 0, 0, 255].repeat(n),
1706 normal: [128, 128, 255, 255].repeat(n),
1707 specular: [191, 191, 191, 255].repeat(n),
1708 depth: vec![0.25; n],
1709 }
1710 }
1711 #[test]
1712 fn job_counts_every_clip_frame_angle_and_exact_order() {
1713 let mut b = body();
1714 let mut c = b.clips[0].clone();
1715 c.name = "../A \u{2603}".into();
1716 c.duration = 0.5;
1717 b.clips = vec![c.clone(), c];
1718 let p = plan(&b, 3).unwrap();
1719 assert_eq!((p.images, p.passes, p.files), (144, 864, 576));
1720 let s = samples(&p).collect::<Vec<_>>();
1721 assert_eq!(s.len(), 144);
1722 assert_eq!(
1723 s[0],
1724 Sample {
1725 clip: 0,
1726 frame: 0,
1727 pitch: -30,
1728 yaw: 0
1729 }
1730 );
1731 assert_eq!(s[11].yaw, 330);
1732 assert_eq!(s[12].pitch, 0);
1733 assert_eq!(s[35].pitch, 30);
1734 assert_eq!(s[36].frame, 1);
1735 assert_eq!(s[72].clip, 1);
1736 assert_ne!(p.clips[0].token, p.clips[1].token);
1737 assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
1738 assert_eq!(
1739 PASSES,
1740 [
1741 "highres",
1742 "detail",
1743 "target-res / diffuse",
1744 "depth",
1745 "normal",
1746 "specular"
1747 ]
1748 );
1749 b.clips.clear();
1750 let p = plan(&b, 12).unwrap();
1751 assert_eq!((p.images, p.files), (36, 144));
1752 }
1753 #[test]
1754 fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
1755 let mut b = body();
1756 b.clips.truncate(1);
1757 b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1);
1758 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2);
1759 b.clips[0].duration = 1.00001;
1760 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3);
1761 }
1762 #[test]
1763 fn planner_rejects_invalid_or_unbounded_inputs() {
1764 let mut b = body();
1765 assert!(plan(&b, 0).is_err());
1766 assert!(plan(&b, 241).is_err());
1767 for duration in [f32::NAN, f32::INFINITY, -1., 10000.] {
1768 b.clips[0].duration = duration;
1769 assert!(plan(&b, 12).is_err());
1770 }
1771 }
1772 #[test]
1773 fn output_tokens_only_four_target_maps() {
1774 let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");
1775 assert_eq!(
1776 output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),
1777 expected
1778 );
1779 for map in ["diffuse", "normal", "depth", "specular"] {
1780 assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
1781 }
1782 for map in ["highres", "detail", "shiny", "../x"] {
1783 assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err());
1784 }
1785 for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] {
1786 assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err());
1787 }
1788 assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err());
1789 assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());
1790 }
1791 #[test]
1792 fn resolve_preserves_thin_foreground_and_matching_maps() {
1793 let mut r = raw(8, 8);
1794 r.diffuse = [0, 255, 0, 255].repeat(64);
1795 r.depth.fill(0.75);
1796 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
1797 r.depth[0] = 0.25;
1798 let d = detail_map(&r).unwrap();
1799 assert!(d.iter().any(|x| *x > 0));
1800 let t = resolve(&r, &d, 1, 1).unwrap();
1801 assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]);
1802 assert_eq!(t.specular.rgba, [191, 191, 191, 255]);
1803 assert!((t.depth[0] as i32 - 16384).abs() <= 1);
1804 let n = &t.normal.rgba;
1805 let len = (0..3)
1806 .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2))
1807 .sum::<f32>()
1808 .sqrt();
1809 assert!((len - 1.).abs() < 0.01);
1810 r.diffuse.fill(0);
1811 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
1812 let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap();
1813 assert_eq!(t.diffuse.rgba[3], 4);
1814 assert_eq!(t.depth[1], 1028);
1815 assert_eq!(t.normal.rgba[3], 4);
1816 assert_eq!(t.specular.rgba[3], 4);
1817 }
1818 #[test]
1819 fn invalid_map_dimensions_and_depth_fail_without_panics() {
1820 let mut r = raw(8, 8);
1821 r.normal.pop();
1822 assert!(detail_map(&r).is_err());
1823 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
1824 let mut r = raw(8, 8);
1825 r.depth[0] = f32::NAN;
1826 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
1827 let r = raw(8, 8);
1828 assert!(resolve(&r, &[0; 63], 1, 1).is_err());
1829 assert!(resolve(&r, &[0; 64], 0, 1).is_err());
1830 assert!(resolve(&r, &[0; 64], 257, 1).is_err());
1831 }
1832 #[test]
1833 fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() {
1834 let t = Temp::new();
1835 let p = Path::new("map_diffuse.png");
1836 write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap();
1837 let original = std::fs::read(t.0.join(p)).unwrap();
1838 assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err());
1839 assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original);
1840 let mut reader = png::Decoder::new(std::io::Cursor::new(original))
1841 .read_info()
1842 .unwrap();
1843 assert!(reader.info().srgb.is_some());
1844 let mut out = vec![0; reader.output_buffer_size().unwrap()];
1845 reader.next_frame(&mut out).unwrap();
1846 assert_eq!(out, [128, 64, 32, 192]);
1847 write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap();
1848 let mut reader = png::Decoder::new(std::io::BufReader::new(
1849 std::fs::File::open(t.0.join("depth.png")).unwrap(),
1850 ))
1851 .read_info()
1852 .unwrap();
1853 assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen);
1854 assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha);
1855 assert!(reader.info().srgb.is_none());
1856 let mut out = vec![0; reader.output_buffer_size().unwrap()];
1857 reader.next_frame(&mut out).unwrap();
1858 assert_eq!(out, [64, 0, 128, 128]);
1859 let tmp =
1860 t.0.join("blocked.png")
1861 .with_extension(format!("part-{}", std::process::id()));
1862 std::fs::write(&tmp, b"keep").unwrap();
1863 assert!(write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).is_err());
1864 assert_eq!(std::fs::read(tmp).unwrap(), b"keep");
1865 assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());
1866 assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());
1867 assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());
1868 }
1869 #[cfg(unix)]
1870 #[test]
1871 fn output_rejects_escaping_symlinks() {
1872 let root = Temp::new();
1873 let outside = Temp::new();
1874 std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap();
1875 assert!(write_rgba(
1876 &root.0,
1877 Path::new("link/sub/x.png"),
1878 &image(1, 1, vec![0; 4])
1879 )
1880 .is_err());
1881 assert!(!outside.0.join("sub").exists());
1882 }
1883 #[test]
1884 fn latest_mailbox_is_bounded_and_retains_terminal() {
1885 let m = Mailbox::default();
1886 for i in 0..1000 {
1887 m.publish(RenderEvent::Pass {
1888 index: i,
1889 total: 1000,
1890 label: i.to_string(),
1891 image: Some(image(1, 1, vec![0; 4])),
1892 });
1893 }
1894 m.publish(RenderEvent::Step {
1895 index: 999,
1896 total: 1000,
1897 label: "working".into(),
1898 });
1899 m.publish(RenderEvent::Cancelled);
1900 let events = m.drain();
1901 assert_eq!(events.len(), 3);
1902 assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. }));
1903 assert!(matches!(&events[2], RenderEvent::Cancelled));
1904 assert!(m.drain().is_empty());
1905 }
1906
1907 #[test]
1908 fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {
1909 let temp = Temp::new();
1910 let mut job = RenderJob {
1911 character: body(),
1912 sex: "male".into(),
1913 preset: None,
1914 target_width: 64,
1915 target_height: 96,
1916 fps: 12,
1917 framing: CameraFraming {
1918 height_milli: 3100,
1919 center_y_milli: 1200,
1920 pivot_y_milli: Some(1200),
1921 },
1922 output: temp.0.clone(),
1923 project_id: "test-project".into(),
1924 project_name: "Test".into(),
1925 body_model: SourceFingerprint {
1926 path: "assets/body/test.glb".into(),
1927 sha256: crate::digest::sha256_hex(b"test-body"),
1928 },
1929 preset_sha256: None,
1930 };
1931 let mut cache = equipment::TextureCache::default();
1932 let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
1933 let mut preset = equipment::Preset::new("sword".into());
1934 let fixture =
1935 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj");
1936 equipment::import_obj(&mut preset, &fixture).unwrap();
1937 let joint = job
1938 .character
1939 .joint_names
1940 .iter()
1941 .position(|n| n == "RightHand")
1942 .unwrap();
1943 preset.bind(0, &job.character, 0, joint).unwrap();
1944 let count = preset
1945 .attachments
1946 .iter()
1947 .map(|a| a.meshes.len())
1948 .sum::<usize>();
1949 job.preset = Some(preset);
1950 let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
1951 assert_eq!(e.draws.len(), count);
1952 assert_eq!(b.view, e.view);
1953 assert_eq!(b.projection, e.projection);
1954 assert_eq!((e.width, e.height), (512, 768));
1955 assert_eq!(e.projection[5], 2. / 3.1);
1956 let center = point(&e.view, [0., 1.2, 0.]);
1957 assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001);
1958 assert!((center[2] + 10.).abs() < 0.0001);
1959 let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap();
1960 let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap();
1961 assert_eq!(early.view, late.view);
1962 assert_eq!(early.projection, late.projection);
1963 assert_ne!(early.draws[0].positions, late.draws[0].positions);
1964 let mut invalid = e.clone();
1965 invalid.view[0] = f32::NAN;
1966 assert!(invalid.validate().is_err());
1967 }
1968 #[test]
1969 fn body_import_retains_texture_material_and_linear_vertex_color() {
1970 let temp = Temp::new();
1971 let source = std::fs::read(
1972 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
1973 )
1974 .unwrap();
1975 let glb = gltf::binary::Glb::from_slice(&source).unwrap();
1976 let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap();
1977 let mut bin = glb.bin.unwrap().into_owned();
1978 while !bin.len().is_multiple_of(4) {
1979 bin.push(0);
1980 }
1981 let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"]
1982 .as_u64()
1983 .unwrap() as usize;
1984 let count = json["accessors"][position]["count"].as_u64().unwrap() as usize;
1985 let offset = bin.len();
1986 for _ in 0..count {
1987 for value in [0.25f32, 0.75, 0.5, 0.5] {
1988 bin.extend(value.to_le_bytes());
1989 }
1990 }
1991 let view = json["bufferViews"].as_array().unwrap().len();
1992 json["bufferViews"]
1993 .as_array_mut()
1994 .unwrap()
1995 .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16}));
1996 let accessor = json["accessors"].as_array().unwrap().len();
1997 json["accessors"].as_array_mut().unwrap().push(
1998 serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}),
1999 );
2000 json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor);
2001 json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()});
2002 json["images"] = serde_json::json!([{"uri":"albedo.png"}]);
2003 json["textures"] = serde_json::json!([{"source":0}]);
2004 let material = json["meshes"][0]["primitives"][0]["material"]
2005 .as_u64()
2006 .unwrap() as usize;
2007 json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] =
2008 serde_json::json!({"index":0});
2009 json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] =
2010 serde_json::json!([0.5, 0.75, 1., 1.]);
2011 std::fs::write(temp.0.join("geometry.bin"), bin).unwrap();
2012 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2013 write_rgba(
2014 &temp.0,
2015 Path::new("albedo.png"),
2016 &image(1, 1, vec![128, 255, 64, 192]),
2017 )
2018 .unwrap();
2019 let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2020 assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]);
2021 assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]);
2022 assert_eq!(
2023 body.surfaces[0].albedo.as_ref().unwrap().rgba,
2024 [128, 255, 64, 192]
2025 );
2026 assert_eq!(body.colors.len(), body.positions.len());
2027 assert_eq!(body.uvs.len(), body.positions.len());
2028 assert!(body.surfaces[0].flat_normals);
2029 json["meshes"][0]["primitives"][0]["attributes"]
2030 .as_object_mut()
2031 .unwrap()
2032 .remove("NORMAL");
2033 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2034 let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2035 assert!(!missing.source_normals_complete);
2036 let job = RenderJob {
2037 character: missing,
2038 sex: "male".into(),
2039 preset: None,
2040 target_width: 1,
2041 target_height: 1,
2042 fps: 12,
2043 framing: CameraFraming::default(),
2044 output: temp.0.clone(),
2045 project_id: "test-project".into(),
2046 project_name: "Test".into(),
2047 body_model: SourceFingerprint {
2048 path: "assets/body/test.glb".into(),
2049 sha256: crate::digest::sha256_hex(b"test-body"),
2050 },
2051 preset_sha256: None,
2052 };
2053 assert!(
2054 scene(
2055 &job,
2056 "",
2057 0.,
2058 0.,
2059 0.,
2060 &mut equipment::TextureCache::default()
2061 )
2062 .unwrap()
2063 .draws[0]
2064 .flat_normals
2065 );
2066 }
2067 #[test]
2068 fn complete_and_cancelled_batches_have_truthful_atomic_outputs() {
2069 for stop in [false, true] {
2070 let tmp = Temp::new();
2071 let mut b = body();
2072 b.clips.clear();
2073 let job = RenderJob {
2074 character: b,
2075 sex: "male".into(),
2076 preset: None,
2077 target_width: 1,
2078 target_height: 1,
2079 fps: 12,
2080 framing: CameraFraming::default(),
2081 output: tmp.0.clone(),
2082 project_id: "test-project".into(),
2083 project_name: "Test".into(),
2084 body_model: SourceFingerprint {
2085 path: "assets/body/test.glb".into(),
2086 sha256: crate::digest::sha256_hex(b"test-body"),
2087 },
2088 preset_sha256: None,
2089 };
2090 let flag = AtomicBool::new(false);
2091 let (tx, rx) = mpsc::channel::<GpuRequest>();
2092 let gpu = std::thread::spawn(move || {
2093 for request in rx {
2094 let _ = request
2095 .reply
2096 .send(Ok(raw(request.scene.width, request.scene.height)));
2097 }
2098 });
2099 let mut labels = Vec::new();
2100 let result = execute(job, &tx, &flag, None, &mut |event| {
2101 if let RenderEvent::Pass { label, .. } = event {
2102 labels.push(label);
2103 if stop && labels.len() == 3 {
2104 flag.store(true, Ordering::Relaxed);
2105 }
2106 }
2107 });
2108 drop(tx);
2109 gpu.join().unwrap();
2110 let run = std::fs::read_dir(&tmp.0)
2111 .unwrap()
2112 .next()
2113 .unwrap()
2114 .unwrap()
2115 .path();
2116 assert!(run.join("render.json").exists());
2117 assert_eq!(run.join("complete.json").exists(), !stop);
2118 assert_eq!(run.join("cancelled.json").exists(), stop);
2119 assert_eq!(result.is_ok(), !stop);
2120 let meta: serde_json::Value =
2121 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2122 .unwrap();
2123 assert_eq!(meta["format_version"], 3);
2124 assert_eq!(meta["project"]["id"], "test-project");
2125 assert_eq!(
2126 meta["body_model"]["sha256"],
2127 crate::digest::sha256_hex(b"test-body")
2128 );
2129 assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
2130 assert_eq!(meta["pivot_source"]["kind"], "neck");
2131 assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
2132 assert_eq!(
2133 meta["saved_maps"]
2134 .as_array()
2135 .unwrap()
2136 .iter()
2137 .map(|v| v.as_str().unwrap())
2138 .collect::<Vec<_>>(),
2139 ["diffuse", "depth", "normal", "specular"]
2140 );
2141 if !stop {
2142 let complete: serde_json::Value = serde_json::from_str(
2143 &std::fs::read_to_string(run.join("complete.json")).unwrap(),
2144 )
2145 .unwrap();
2146 assert_eq!(complete["format_version"], 3);
2147 assert_eq!(complete["status"], "complete");
2148 assert_eq!(complete["png_files"], 144);
2149 let checksums = complete["png_sha256"].as_object().unwrap();
2150 assert_eq!(checksums.len(), 144);
2151 for (rel, digest) in checksums {
2152 assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2153 let bytes = std::fs::read(run.join(rel)).unwrap();
2154 assert_eq!(digest.as_str().unwrap(), crate::digest::sha256_hex(&bytes));
2155 }
2156 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2157 let sample = opened
2158 .load_map(
2159 "clip000-bind",
2160 0,
2161 -30,
2162 0,
2163 crate::export_set::MapKind::Diffuse,
2164 )
2165 .unwrap();
2166 assert_eq!(sample.width(), 1);
2167 }
2168 let frame = run.join("male/body/clip000-bind/f000000");
2169 assert!(frame.join("p-30_y000_diffuse.png").exists());
2170 assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
2171 assert!(!frame.join("p-30_y000_highres.png").exists());
2172 assert!(!frame.join("p-30_y000_detail.png").exists());
2173 if !stop {
2174 assert_eq!(labels.len(), 216);
2175 for labels in labels.chunks_exact(6) {
2176 for (label, pass) in labels.iter().zip([
2177 "highres",
2178 "detail",
2179 "target-res / diffuse",
2180 "depth",
2181 "normal",
2182 "specular",
2183 ]) {
2184 assert!(label.ends_with(pass));
2185 }
2186 }
2187 }
2188 }
2189 }
2190}