BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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