BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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