BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / examples / textured_body_fixture.rs

textured_body_fixture.rs

textured_body_fixture.rs 8.1 KiB Download Raw
1//! Real native GPU proof that every material of a glTF body is drawn with its
2//! own base color texture × factor, in the live 3D preview and in production.
3//! Usage: xvfb-run -a cargo run --example textured_body_fixture -- ROOT OUTDIR MODEL_DIR MODEL_FILE [WIDTH HEIGHT FPS]
4//! Copies MODEL_DIR (model + external .bin/textures) into ROOT/assets/body/,
5//! screenshots the live body view (`live-body.png`), renders all angles and
6//! writes the production front diffuse (`production-front-diffuse.png`, ×8)
7//! plus a final UI screenshot (`production-ui.png`). With `--expect-multi-material`
8//! (for `tests/fixtures/bodies/multi-material`) it checks that the production
9//! front diffuse holds all three material colors (factor, texture, texture × factor).
10use eframe::App;
11use paperdoll_renderer::{
12 editor::EditorApp,
13 session::{Session, Sex},
14};
15use std::path::{Path, PathBuf};
16
17const SETTLE_FRAMES: u64 = 40;
18
19struct Fixture {
20 editor: EditorApp,
21 out: PathBuf,
22 phase: u8,
23 waited: u64,
24 shot_requested: bool,
25 error: Option<String>,
26}
27
28fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) {
29 let file = std::fs::File::create(path).unwrap();
30 let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
31 enc.set_color(png::ColorType::Rgba);
32 enc.set_depth(png::BitDepth::Eight);
33 enc.write_header().unwrap().write_image_data(rgba).unwrap();
34}
35
36fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
37 std::fs::create_dir_all(to)?;
38 for entry in std::fs::read_dir(from)? {
39 let entry = entry?;
40 let target = to.join(entry.file_name());
41 if entry.file_type()?.is_dir() {
42 copy_dir(&entry.path(), &target)?;
43 } else {
44 std::fs::copy(entry.path(), target)?;
45 }
46 }
47 Ok(())
48}
49
50fn find(dir: &Path, suffix: &str) -> Option<PathBuf> {
51 let mut entries: Vec<_> = std::fs::read_dir(dir)
52 .ok()?
53 .flatten()
54 .map(|e| e.path())
55 .collect();
56 entries.sort();
57 for path in entries {
58 if path.is_dir() {
59 if let Some(found) = find(&path, suffix) {
60 return Some(found);
61 }
62 } else if path.to_string_lossy().ends_with(suffix) {
63 return Some(path);
64 }
65 }
66 None
67}
68
69impl Fixture {
70 fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool {
71 self.waited += 1;
72 if self.waited < SETTLE_FRAMES {
73 return false;
74 }
75 if !self.shot_requested {
76 self.shot_requested = true;
77 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);
78 return false;
79 }
80 let shot = ctx.input(|i| {
81 i.raw.events.iter().find_map(|e| match e {
82 eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),
83 _ => None,
84 })
85 });
86 let Some(image) = shot else { return false };
87 let [w, h] = image.size;
88 let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
89 let path = self.out.join(name);
90 save_png(&path, w, h, &rgba);
91 println!("SCREENSHOT {}", path.display());
92 self.waited = 0;
93 self.shot_requested = false;
94 true
95 }
96}
97
98impl App for Fixture {
99 fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
100 self.editor.update(ctx, frame);
101 ctx.request_repaint();
102 let phase = self.phase;
103 match phase {
104 0 => {
105 if self.screenshot(ctx, "live-body.png") {
106 self.editor.render_all_angles();
107 self.phase = 1;
108 }
109 }
110 1 => {
111 if !self.editor.render_is_running() {
112 self.error = self.editor.render_error().map(str::to_string);
113 self.phase = 2;
114 }
115 }
116 2 if self.screenshot(ctx, "production-ui.png") => {
117 if let Some(error) = &self.error {
118 println!("RENDER ERROR {error}");
119 }
120 self.phase = 3;
121 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
122 }
123 _ => {}
124 }
125 }
126}
127
128fn main() -> Result<(), Box<dyn std::error::Error>> {
129 let a = std::env::args().skip(1).collect::<Vec<_>>();
130 let expect = a.iter().any(|v| v == "--expect-multi-material");
131 let a: Vec<String> = a.into_iter().filter(|v| !v.starts_with("--")).collect();
132 if a.len() < 4 {
133 return Err("ROOT OUTDIR MODEL_DIR MODEL_FILE [WIDTH HEIGHT FPS]".into());
134 }
135 let root = PathBuf::from(&a[0]);
136 let out = PathBuf::from(&a[1]);
137 let model_dir = PathBuf::from(&a[2]);
138 let model_file = &a[3];
139 std::fs::create_dir_all(&out)?;
140 let width: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(48);
141 let height: u32 = a.get(5).map(|v| v.parse()).transpose()?.unwrap_or(64);
142 let fps: u32 = a.get(6).map(|v| v.parse()).transpose()?.unwrap_or(1);
143 let mut s = Session::new();
144 s.install_new_project(&root)?;
145 copy_dir(&model_dir, &root.join("assets/body/model"))?;
146 let relative = format!("assets/body/model/{model_file}");
147 s.set_body_path(Sex::Male, relative.clone());
148 s.set_body_path(Sex::Female, relative);
149 s.set_render(width, height, fps);
150 s.save()?;
151 let root2 = root.clone();
152 let out2 = out.clone();
153 eframe::run_native(
154 "Paperdoll Textured Body Fixture",
155 eframe::NativeOptions {
156 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
157 ..paperdoll_renderer::preview_gl::native_options()
158 },
159 Box::new(move |_| {
160 let mut editor = EditorApp::default();
161 editor.open_path(root2.join("character.ppd"));
162 editor.open_body_configuration(Sex::Male);
163 Ok(Box::new(Fixture {
164 editor,
165 out: out2,
166 phase: 0,
167 waited: 0,
168 shot_requested: false,
169 error: None,
170 }))
171 }),
172 )?;
173 let raw = root.join("renders/male-body-atlas/raw files");
174 let front = find(&raw, "p+00_y000_diffuse.png").ok_or("no production front diffuse")?;
175 let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&front)?));
176 let mut reader = decoder.read_info()?;
177 let mut buf = vec![0; reader.output_buffer_size().ok_or("png size")?];
178 let info = reader.next_frame(&mut buf)?;
179 let (w, h) = (info.width as usize, info.height as usize);
180 let channels = buf.len() / (w * h);
181 let scale = 8;
182 let mut big = vec![0u8; w * h * scale * scale * 4];
183 for y in 0..h * scale {
184 for x in 0..w * scale {
185 let src = ((y / scale) * w + x / scale) * channels;
186 let px = &buf[src..src + channels];
187 let rgba = if channels == 4 {
188 [px[0], px[1], px[2], px[3]]
189 } else {
190 [px[0], px[1], px[2], 255]
191 };
192 big[(y * w * scale + x) * 4..][..4].copy_from_slice(&rgba);
193 }
194 }
195 save_png(
196 &out.join("production-front-diffuse.png"),
197 w * scale,
198 h * scale,
199 &big,
200 );
201 println!("PRODUCTION {}", front.display());
202 if expect {
203 // Unlit sRGB albedo; factors are linear (glTF), textures sRGB:
204 // factor (0.2, 0.4, 0.9) → sRGB (124, 170, 243); texture (0, 200, 0);
205 // texture (200, 200, 0) × factor (0.5, 1, 1) in linear → (146, 200, 0).
206 for (name, want) in [
207 ("FactorOnly", [124u8, 170, 243]),
208 ("Green", [0, 200, 0]),
209 ("YellowTimesFactor", [146, 200, 0]),
210 ] {
211 let found = buf
212 .chunks_exact(channels)
213 .any(|px| px[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2));
214 println!(
215 "MATERIAL {name} {want:?}: {}",
216 if found { "ok" } else { "MISSING" }
217 );
218 if !found {
219 return Err(format!("production diffuse mist materiaal {name}").into());
220 }
221 }
222 }
223 Ok(())
224}