BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / render_job.rs

render_job.rs

render_job.rs 15.4 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
231pub struct RenderJob {
232 pub character: PreviewCharacter,
233 pub controls: PreviewControls,
234 pub target_width: u32,
235 pub target_height: u32,
236 pub preview_width: u32,
237 pub preview_height: u32,
238 pub output: PathBuf,
239}
240
241impl RenderJob {
242 pub fn steps(&self) -> Vec<PassStep> {
243 pass_sequence(&[ViewAngle {
244 pitch_deg: self.controls.pitch_deg,
245 yaw_deg: self.controls.yaw_deg,
246 }])
247 }
248}
249
250pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {
251 if job.target_width == 0
252 || job.target_height == 0
253 || job.target_width > crate::project::MAX_DIMENSION
254 || job.target_height > crate::project::MAX_DIMENSION
255 {
256 emit(RenderEvent::Failed(
257 "ongeldige afmetingen voor de render".into(),
258 ));
259 return;
260 }
261 // Count only implemented passes. Never report a skipped map as completed.
262 let steps: Vec<_> = job
263 .steps()
264 .into_iter()
265 .filter(|step| step.kind.produces_image())
266 .collect();
267 let total = steps.len() as u32;
268 let mut highres: Option<ImageBuf> = None;
269 let mut saved = None;
270 for (index, step) in steps.iter().enumerate() {
271 let index = index as u32;
272 let label = format!(
273 "pitch {:.0} yaw {:.0} · {}",
274 step.pitch_deg,
275 step.yaw_deg,
276 step.kind.label()
277 );
278 emit(RenderEvent::Step {
279 index,
280 total,
281 label: label.clone(),
282 });
283 if !step.kind.produces_image() {
284 emit(RenderEvent::Pass {
285 index,
286 total,
287 label,
288 image: None,
289 });
290 continue;
291 }
292 let image = match step.kind {
293 PassKind::HighresImage => {
294 let mut controls = job.controls.clone();
295 controls.pitch_deg = step.pitch_deg;
296 controls.yaw_deg = step.yaw_deg;
297 let (width, height) = highres_extent(job.target_width, job.target_height);
298 match raster_body(&job.character, &controls, width, height) {
299 Ok(rgba) => {
300 let image = ImageBuf {
301 width,
302 height,
303 rgba,
304 };
305 highres = Some(image.clone());
306 Some(image)
307 }
308 Err(message) => {
309 emit(RenderEvent::Failed(message));
310 return;
311 }
312 }
313 }
314 PassKind::TargetResolution => {
315 let Some(source) = highres.as_ref() else {
316 emit(RenderEvent::Failed(
317 "de doelresolutie volgt op het highres-beeld".to_string(),
318 ));
319 return;
320 };
321 let rgba = nearest_stretch(
322 &source.rgba,
323 source.width,
324 source.height,
325 job.target_width.max(1),
326 job.target_height.max(1),
327 );
328 let target = ImageBuf {
329 width: job.target_width.max(1),
330 height: job.target_height.max(1),
331 rgba,
332 };
333 match write_png(&job.output, &target) {
334 Ok(()) => saved = Some(job.output.display().to_string()),
335 Err(message) => {
336 emit(RenderEvent::Failed(message));
337 return;
338 }
339 }
340 let (box_w, box_h) = (job.preview_width.max(1), job.preview_height.max(1));
341 Some(ImageBuf {
342 width: box_w,
343 height: box_h,
344 rgba: nearest_stretch(&target.rgba, target.width, target.height, box_w, box_h),
345 })
346 }
347 _ => None,
348 };
349 emit(RenderEvent::Pass {
350 index,
351 total,
352 label,
353 image,
354 });
355 }
356 emit(RenderEvent::Finished { saved });
357}
358
359pub struct RenderThread {
360 pub rx: Receiver<RenderEvent>,
361 pub handle: JoinHandle<()>,
362}
363
364pub fn spawn(job: RenderJob) -> RenderThread {
365 let (tx, rx) = mpsc::channel();
366 let handle = thread::spawn(move || {
367 execute(&job, &mut |event| {
368 let _ = tx.send(event);
369 });
370 });
371 RenderThread { rx, handle }
372}
373
374fn write_png(path: &Path, image: &ImageBuf) -> Result<(), String> {
375 if let Some(parent) = path.parent() {
376 std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
377 }
378 let stamp = std::time::SystemTime::now()
379 .duration_since(std::time::UNIX_EPOCH)
380 .unwrap_or_default()
381 .as_nanos();
382 let temp = path.with_extension(format!("png.part-{}-{stamp}", std::process::id()));
383 let file = std::fs::OpenOptions::new()
384 .create_new(true)
385 .write(true)
386 .open(&temp)
387 .map_err(|err| err.to_string())?;
388 let mut encoder = png::Encoder::new(file, image.width, image.height);
389 encoder.set_color(png::ColorType::Rgba);
390 encoder.set_depth(png::BitDepth::Eight);
391 let mut writer = encoder.write_header().map_err(|err| err.to_string())?;
392 writer
393 .write_image_data(&image.rgba)
394 .map_err(|err| err.to_string())?;
395 drop(writer);
396 std::fs::rename(&temp, path).map_err(|err| err.to_string())?;
397 Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use crate::preview::load_gltf_file;
404 use std::time::{SystemTime, UNIX_EPOCH};
405
406 #[test]
407 fn full_view_order_is_pitch_then_yaw_then_passes() {
408 let views = export_views();
409 assert_eq!(views.len(), 36);
410 assert_eq!(views[0].pitch_deg, -30.0);
411 assert_eq!(views[0].yaw_deg, 0.0);
412 assert_eq!(views[11].yaw_deg, 330.0);
413 assert_eq!(views[12].pitch_deg, 0.0);
414 let steps = pass_sequence(&views);
415 assert_eq!(steps.len(), 36 * 8);
416 let first: Vec<_> = steps.iter().take(8).map(|step| step.kind).collect();
417 assert_eq!(first, PASS_ORDER.to_vec());
418 assert_eq!(steps[8].yaw_deg, 30.0);
419 assert_eq!(steps[8].kind, PassKind::HighresImage);
420 }
421
422 #[test]
423 fn nearest_non_square_uses_source_height_for_rows() {
424 let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255];
425 let out = nearest_stretch(&src, 1, 3, 2, 6);
426 for y in 0..6 {
427 assert_eq!(&out[y * 8..y * 8 + 4], &src[(y / 2) * 4..(y / 2) * 4 + 4]);
428 }
429 }
430
431 #[test]
432 fn nearest_stretch_fills_the_box() {
433 let src = [
434 255, 0, 0, 255, 0, 0, 255, 255, // 2x1
435 ];
436 let out = nearest_stretch(&src, 2, 1, 4, 3);
437 assert_eq!(out.len(), 4 * 3 * 4);
438 for y in 0..3 {
439 for x in 0..4 {
440 let pixel = &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4];
441 if x < 2 {
442 assert_eq!(pixel, &[255, 0, 0, 255]);
443 } else {
444 assert_eq!(pixel, &[0, 0, 255, 255]);
445 }
446 }
447 }
448 }
449
450 #[test]
451 fn progress_follows_steps_and_skips_do_not_replace_the_picture() {
452 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
453 .join("tests/fixtures/bodies/test-body-male.glb");
454 let character = load_gltf_file(&path).unwrap();
455 let nanos = SystemTime::now()
456 .duration_since(UNIX_EPOCH)
457 .unwrap()
458 .as_nanos();
459 let output = std::env::temp_dir().join(format!("paperdoll-still-{nanos}.png"));
460 let job = RenderJob {
461 character,
462 controls: PreviewControls::default(),
463 target_width: 32,
464 target_height: 40,
465 preview_width: 64,
466 preview_height: 80,
467 output: output.clone(),
468 };
469 let mut events = Vec::new();
470 execute(&job, &mut |event| events.push(event));
471 let mut ui = RenderUiState::idle();
472 for event in events {
473 ui.apply(event);
474 }
475 assert_eq!(ui.progress.completed, 2);
476 assert_eq!(ui.progress.total, 2);
477 assert!(ui.error.is_none());
478 let preview = ui.preview.expect("a real pass image");
479 assert_eq!((preview.width, preview.height), (64, 80));
480 let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();
481 assert_eq!(filled, 64 * 80);
482 assert!(output.is_file());
483 let _ = std::fs::remove_file(&output);
484 }
485
486 #[test]
487 fn skipped_maps_have_no_image() {
488 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
489 .join("tests/fixtures/bodies/test-body-male.glb");
490 let job = RenderJob {
491 character: load_gltf_file(&path).unwrap(),
492 controls: PreviewControls::default(),
493 target_width: 16,
494 target_height: 16,
495 preview_width: 16,
496 preview_height: 16,
497 output: std::env::temp_dir().join("paperdoll-skip-check.png"),
498 };
499 let mut images = Vec::new();
500 execute(&job, &mut |event| {
501 if let RenderEvent::Pass { label, image, .. } = event {
502 images.push((label, image.is_some()));
503 }
504 });
505 assert_eq!(images.len(), 2);
506 assert!(images.iter().all(|(_, has_image)| *has_image));
507 assert!(images[0].0.ends_with("highres"));
508 assert!(images[1].0.ends_with("doelresolutie"));
509 let _ = std::fs::remove_file(&job.output);
510 }
511}