BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / render_job.rs

render_job.rs

render_job.rs 16.5 KiB Download Raw
1//! Render queue for one body still.
2//!
3//! The worker walks the design order and emits an event after every pass.
4//! The full production order is recorded, but this still renderer executes
5//! and counts only its two real color passes. The GUI may coalesce events.
6
7use std::path::{Path, PathBuf};
8use std::sync::mpsc::{self, Receiver};
9use std::thread::{self, JoinHandle};
10
11use crate::preview::{
12 paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG,
13};
14use crate::still::raster_body;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum PassKind {
18 HighresImage,
19 DetailMap,
20 TargetResolution,
21 Diffuse,
22 Depth,
23 Normal,
24 Shiny,
25 RimHighlight,
26}
27
28impl PassKind {
29 pub fn label(self) -> &'static str {
30 match self {
31 Self::HighresImage => "highres",
32 Self::DetailMap => "detail",
33 Self::TargetResolution => "doelresolutie",
34 Self::Diffuse => "diffuse",
35 Self::Depth => "depth",
36 Self::Normal => "normal",
37 Self::Shiny => "shiny",
38 Self::RimHighlight => "rim",
39 }
40 }
41
42 pub fn produces_image(self) -> bool {
43 matches!(self, Self::HighresImage | Self::TargetResolution)
44 }
45}
46
47pub const PASS_ORDER: [PassKind; 8] = [
48 PassKind::HighresImage,
49 PassKind::DetailMap,
50 PassKind::TargetResolution,
51 PassKind::Diffuse,
52 PassKind::Depth,
53 PassKind::Normal,
54 PassKind::Shiny,
55 PassKind::RimHighlight,
56];
57
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct ViewAngle {
60 pub pitch_deg: f32,
61 pub yaw_deg: f32,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct PassStep {
66 pub pitch_deg: f32,
67 pub yaw_deg: f32,
68 pub kind: PassKind,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ImageBuf {
73 pub width: u32,
74 pub height: u32,
75 pub rgba: Vec<u8>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Progress {
80 pub completed: u32,
81 pub total: u32,
82 pub label: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum RenderEvent {
87 Step {
88 index: u32,
89 total: u32,
90 label: String,
91 },
92 Pass {
93 index: u32,
94 total: u32,
95 label: String,
96 image: Option<ImageBuf>,
97 },
98 Failed(String),
99 Finished {
100 saved: Option<String>,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RenderUiState {
106 pub progress: Progress,
107 pub preview: Option<ImageBuf>,
108 pub saved: Option<String>,
109 pub error: Option<String>,
110 pub running: bool,
111}
112
113impl RenderUiState {
114 pub fn idle() -> Self {
115 Self {
116 progress: Progress {
117 completed: 0,
118 total: 0,
119 label: String::new(),
120 },
121 preview: None,
122 saved: None,
123 error: None,
124 running: false,
125 }
126 }
127
128 pub fn apply(&mut self, event: RenderEvent) {
129 match event {
130 RenderEvent::Step {
131 index,
132 total,
133 label,
134 } => {
135 self.progress = Progress {
136 completed: index,
137 total,
138 label,
139 };
140 self.running = true;
141 }
142 RenderEvent::Pass {
143 index,
144 total,
145 label,
146 image,
147 } => {
148 self.progress = Progress {
149 completed: index + 1,
150 total,
151 label,
152 };
153 if let Some(image) = image {
154 self.preview = Some(image);
155 }
156 self.running = true;
157 }
158 RenderEvent::Failed(message) => {
159 self.error = Some(message);
160 self.running = false;
161 }
162 RenderEvent::Finished { saved } => {
163 self.saved = saved;
164 self.running = false;
165 if self.progress.total > 0 {
166 self.progress.completed = self.progress.total;
167 }
168 }
169 }
170 }
171}
172
173pub fn export_views() -> Vec<ViewAngle> {
174 let mut views = Vec::with_capacity(PAPERDOLL_PITCHES_DEG.len() * paperdoll_yaws_deg().len());
175 for pitch in PAPERDOLL_PITCHES_DEG {
176 for yaw in paperdoll_yaws_deg() {
177 views.push(ViewAngle {
178 pitch_deg: pitch,
179 yaw_deg: yaw,
180 });
181 }
182 }
183 views
184}
185
186pub fn pass_sequence(views: &[ViewAngle]) -> Vec<PassStep> {
187 let mut steps = Vec::with_capacity(views.len() * PASS_ORDER.len());
188 for view in views {
189 for kind in PASS_ORDER {
190 steps.push(PassStep {
191 pitch_deg: view.pitch_deg,
192 yaw_deg: view.yaw_deg,
193 kind,
194 });
195 }
196 }
197 steps
198}
199
200pub fn highres_extent(target_width: u32, target_height: u32) -> (u32, u32) {
201 let width = target_width.max(1);
202 let height = target_height.max(1);
203 let mut scale = 8u32;
204 while scale > 1 && (width.saturating_mul(scale) > 1024 || height.saturating_mul(scale) > 1024) {
205 scale -= 1;
206 }
207 (width * scale, height * scale)
208}
209
210/// Nearest-neighbor stretch. Every destination pixel is filled, so the
211/// preview box has no empty border.
212pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
213 let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
214 if sw == 0 || sh == 0 || dw == 0 || dh == 0 {
215 return out;
216 }
217 for y in 0..dh {
218 let sy = ((y as u64 * sh as u64) / dh as u64) as u32;
219 let sy = sy.min(sh - 1);
220 for x in 0..dw {
221 let sx = ((x as u64 * sw as u64) / dw as u64) as u32;
222 let sx = sx.min(sw - 1);
223 let from = ((sy * sw + sx) * 4) as usize;
224 let to = ((y * dw + x) * 4) as usize;
225 out[to..to + 4].copy_from_slice(&src[from..from + 4]);
226 }
227 }
228 out
229}
230
231#[derive(Debug, Clone, Copy, PartialEq)]
232pub struct PreviewFit {
233 pub size: [f32; 2],
234 pub offset: [f32; 2],
235}
236
237pub fn fit_preview(image: [u32; 2], bounds: [f32; 2]) -> PreviewFit {
238 if image.contains(&0) || bounds.iter().any(|v| !v.is_finite() || *v <= 0.0) {
239 return PreviewFit {
240 size: [0.; 2],
241 offset: [0.; 2],
242 };
243 }
244 let scale = (bounds[0] / image[0] as f32).min(bounds[1] / image[1] as f32);
245 let size = [image[0] as f32 * scale, image[1] as f32 * scale];
246 PreviewFit {
247 size,
248 offset: [(bounds[0] - size[0]) * 0.5, (bounds[1] - size[1]) * 0.5],
249 }
250}
251
252pub struct RenderJob {
253 pub character: PreviewCharacter,
254 pub controls: PreviewControls,
255 pub target_width: u32,
256 pub target_height: u32,
257 pub preview_width: u32,
258 pub preview_height: u32,
259 pub output: PathBuf,
260}
261
262impl RenderJob {
263 pub fn steps(&self) -> Vec<PassStep> {
264 pass_sequence(&[ViewAngle {
265 pitch_deg: self.controls.pitch_deg,
266 yaw_deg: self.controls.yaw_deg,
267 }])
268 }
269}
270
271pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {
272 if job.target_width == 0
273 || job.target_height == 0
274 || job.target_width > crate::project::MAX_DIMENSION
275 || job.target_height > crate::project::MAX_DIMENSION
276 {
277 emit(RenderEvent::Failed(
278 "ongeldige afmetingen voor de render".into(),
279 ));
280 return;
281 }
282 // Count only implemented passes. Never report a skipped map as completed.
283 let steps: Vec<_> = job
284 .steps()
285 .into_iter()
286 .filter(|step| step.kind.produces_image())
287 .collect();
288 let total = steps.len() as u32;
289 let mut highres: Option<ImageBuf> = None;
290 let mut saved = None;
291 for (index, step) in steps.iter().enumerate() {
292 let index = index as u32;
293 let label = format!(
294 "pitch {:.0} yaw {:.0} · {}",
295 step.pitch_deg,
296 step.yaw_deg,
297 step.kind.label()
298 );
299 emit(RenderEvent::Step {
300 index,
301 total,
302 label: label.clone(),
303 });
304 if !step.kind.produces_image() {
305 emit(RenderEvent::Pass {
306 index,
307 total,
308 label,
309 image: None,
310 });
311 continue;
312 }
313 let image = match step.kind {
314 PassKind::HighresImage => {
315 let mut controls = job.controls.clone();
316 controls.pitch_deg = step.pitch_deg;
317 controls.yaw_deg = step.yaw_deg;
318 let (width, height) = highres_extent(job.target_width, job.target_height);
319 match raster_body(&job.character, &controls, width, height) {
320 Ok(rgba) => {
321 let image = ImageBuf {
322 width,
323 height,
324 rgba,
325 };
326 highres = Some(image.clone());
327 Some(image)
328 }
329 Err(message) => {
330 emit(RenderEvent::Failed(message));
331 return;
332 }
333 }
334 }
335 PassKind::TargetResolution => {
336 let Some(source) = highres.as_ref() else {
337 emit(RenderEvent::Failed(
338 "de doelresolutie volgt op het highres-beeld".to_string(),
339 ));
340 return;
341 };
342 let rgba = nearest_stretch(
343 &source.rgba,
344 source.width,
345 source.height,
346 job.target_width.max(1),
347 job.target_height.max(1),
348 );
349 let target = ImageBuf {
350 width: job.target_width.max(1),
351 height: job.target_height.max(1),
352 rgba,
353 };
354 match write_png(&job.output, &target) {
355 Ok(()) => saved = Some(job.output.display().to_string()),
356 Err(message) => {
357 emit(RenderEvent::Failed(message));
358 return;
359 }
360 }
361 // Keep native dimensions; only the GUI scales for presentation.
362 Some(target)
363 }
364 _ => None,
365 };
366 emit(RenderEvent::Pass {
367 index,
368 total,
369 label,
370 image,
371 });
372 }
373 emit(RenderEvent::Finished { saved });
374}
375
376pub struct RenderThread {
377 pub rx: Receiver<RenderEvent>,
378 pub handle: JoinHandle<()>,
379}
380
381pub fn spawn(job: RenderJob) -> RenderThread {
382 let (tx, rx) = mpsc::channel();
383 let handle = thread::spawn(move || {
384 execute(&job, &mut |event| {
385 let _ = tx.send(event);
386 });
387 });
388 RenderThread { rx, handle }
389}
390
391fn write_png(path: &Path, image: &ImageBuf) -> Result<(), String> {
392 if let Some(parent) = path.parent() {
393 std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
394 }
395 let stamp = std::time::SystemTime::now()
396 .duration_since(std::time::UNIX_EPOCH)
397 .unwrap_or_default()
398 .as_nanos();
399 let temp = path.with_extension(format!("png.part-{}-{stamp}", std::process::id()));
400 let file = std::fs::OpenOptions::new()
401 .create_new(true)
402 .write(true)
403 .open(&temp)
404 .map_err(|err| err.to_string())?;
405 let mut encoder = png::Encoder::new(file, image.width, image.height);
406 encoder.set_color(png::ColorType::Rgba);
407 encoder.set_depth(png::BitDepth::Eight);
408 let mut writer = encoder.write_header().map_err(|err| err.to_string())?;
409 writer
410 .write_image_data(&image.rgba)
411 .map_err(|err| err.to_string())?;
412 drop(writer);
413 std::fs::rename(&temp, path).map_err(|err| err.to_string())?;
414 Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use crate::preview::load_gltf_file;
421 use std::time::{SystemTime, UNIX_EPOCH};
422
423 #[test]
424 fn full_view_order_is_pitch_then_yaw_then_passes() {
425 let views = export_views();
426 assert_eq!(views.len(), 36);
427 assert_eq!(views[0].pitch_deg, -30.0);
428 assert_eq!(views[0].yaw_deg, 0.0);
429 assert_eq!(views[11].yaw_deg, 330.0);
430 assert_eq!(views[12].pitch_deg, 0.0);
431 let steps = pass_sequence(&views);
432 assert_eq!(steps.len(), 36 * 8);
433 let first: Vec<_> = steps.iter().take(8).map(|step| step.kind).collect();
434 assert_eq!(first, PASS_ORDER.to_vec());
435 assert_eq!(steps[8].yaw_deg, 30.0);
436 assert_eq!(steps[8].kind, PassKind::HighresImage);
437 }
438
439 #[test]
440 fn preview_uniform_scale_centers_and_letterboxes_all_aspects() {
441 let square = fit_preview([128, 128], [480., 640.]);
442 assert_eq!(square.size, [480., 480.]);
443 assert_eq!(square.offset, [0., 80.]);
444 let landscape = fit_preview([640, 320], [300., 300.]);
445 assert_eq!(landscape.size, [300., 150.]);
446 assert_eq!(landscape.offset, [0., 75.]);
447 let portrait = fit_preview([64, 128], [400., 200.]);
448 assert_eq!(portrait.size, [100., 200.]);
449 assert_eq!(portrait.offset, [150., 0.]);
450 assert_eq!(fit_preview([0, 128], [400., 200.]).size, [0., 0.]);
451 assert_eq!(fit_preview([128, 128], [f32::NAN, 200.]).size, [0., 0.]);
452 }
453
454 #[test]
455 fn nearest_non_square_uses_source_height_for_rows() {
456 let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255];
457 let out = nearest_stretch(&src, 1, 3, 2, 6);
458 for y in 0..6 {
459 assert_eq!(&out[y * 8..y * 8 + 4], &src[(y / 2) * 4..(y / 2) * 4 + 4]);
460 }
461 }
462
463 #[test]
464 fn nearest_stretch_fills_the_box() {
465 let src = [
466 255, 0, 0, 255, 0, 0, 255, 255, // 2x1
467 ];
468 let out = nearest_stretch(&src, 2, 1, 4, 3);
469 assert_eq!(out.len(), 4 * 3 * 4);
470 for y in 0..3 {
471 for x in 0..4 {
472 let pixel = &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];
473 if x < 2 {
474 assert_eq!(pixel, &[255, 0, 0, 255]);
475 } else {
476 assert_eq!(pixel, &[0, 0, 255, 255]);
477 }
478 }
479 }
480 }
481
482 #[test]
483 fn progress_follows_steps_and_skips_do_not_replace_the_picture() {
484 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
485 .join("tests/fixtures/bodies/test-body-male.glb");
486 let character = load_gltf_file(&path).unwrap();
487 let nanos = SystemTime::now()
488 .duration_since(UNIX_EPOCH)
489 .unwrap()
490 .as_nanos();
491 let output = std::env::temp_dir().join(format!("paperdoll-still-{nanos}.png"));
492 let job = RenderJob {
493 character,
494 controls: PreviewControls::default(),
495 target_width: 32,
496 target_height: 40,
497 preview_width: 64,
498 preview_height: 80,
499 output: output.clone(),
500 };
501 let mut events = Vec::new();
502 execute(&job, &mut |event| events.push(event));
503 let mut ui = RenderUiState::idle();
504 for event in events {
505 ui.apply(event);
506 }
507 assert_eq!(ui.progress.completed, 2);
508 assert_eq!(ui.progress.total, 2);
509 assert!(ui.error.is_none());
510 let preview = ui.preview.expect("a real pass image");
511 assert_eq!((preview.width, preview.height), (32, 40));
512 let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();
513 assert_eq!(filled, 32 * 40);
514 assert!(output.is_file());
515 let _ = std::fs::remove_file(&output);
516 }
517
518 #[test]
519 fn skipped_maps_have_no_image() {
520 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
521 .join("tests/fixtures/bodies/test-body-male.glb");
522 let job = RenderJob {
523 character: load_gltf_file(&path).unwrap(),
524 controls: PreviewControls::default(),
525 target_width: 16,
526 target_height: 16,
527 preview_width: 16,
528 preview_height: 16,
529 output: std::env::temp_dir().join("paperdoll-skip-check.png"),
530 };
531 let mut images = Vec::new();
532 execute(&job, &mut |event| {
533 if let RenderEvent::Pass { label, image, .. } = event {
534 images.push((label, image.is_some()));
535 }
536 });
537 assert_eq!(images.len(), 2);
538 assert!(images.iter().all(|(_, has_image)| *has_image));
539 assert!(images[0].0.ends_with("highres"));
540 assert!(images[1].0.ends_with("doelresolutie"));
541 let _ = std::fs::remove_file(&job.output);
542 }
543}