BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / examples / fbx_live_fixture.rs

fbx_live_fixture.rs

fbx_live_fixture.rs 11.4 KiB Download Raw
1//! Real native GPU proof for FBX bodies and rigid FBX equipment (no input automation).
2//! Usage: xvfb-run -a cargo run --example fbx_live_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS]
3//! Builds a project with the FBX male body and an FBX sword bound to its RightHand,
4//! screenshots the live viewport (body, then equipment), runs the production batch
5//! for the body set and the equipment set, and verifies both runs with `export_set`.
6use eframe::App;
7use paperdoll_renderer::{
8 editor::EditorApp,
9 equipment, export_set, preview,
10 session::{adopt_file, Session, Sex},
11};
12use std::path::{Path, PathBuf};
13
14const SETTLE_FRAMES: u64 = 40;
15
16struct Fixture {
17 editor: EditorApp,
18 out: PathBuf,
19 phase: u8,
20 waited: u64,
21 shot_requested: bool,
22 results: Vec<serde_json::Value>,
23 last: u32,
24}
25
26fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) {
27 let file = std::fs::File::create(path).unwrap();
28 let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
29 enc.set_color(png::ColorType::Rgba);
30 enc.set_depth(png::BitDepth::Eight);
31 enc.write_header().unwrap().write_image_data(rgba).unwrap();
32}
33
34impl Fixture {
35 /// Returns true once the screenshot was written.
36 fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool {
37 self.waited += 1;
38 if self.waited < SETTLE_FRAMES {
39 return false;
40 }
41 if !self.shot_requested {
42 self.shot_requested = true;
43 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);
44 return false;
45 }
46 let shot = ctx.input(|i| {
47 i.raw.events.iter().find_map(|e| match e {
48 eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),
49 _ => None,
50 })
51 });
52 let Some(image) = shot else { return false };
53 let [w, h] = image.size;
54 let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
55 let path = self.out.join(name);
56 save_png(&path, w, h, &rgba);
57 println!("SCREENSHOT {}", path.display());
58 self.waited = 0;
59 self.shot_requested = false;
60 true
61 }
62 fn render_done(&mut self, label: &str) -> bool {
63 let (completed, total, pass) = self.editor.render_progress();
64 if completed != self.last {
65 if completed % 50 == 0 || completed == total {
66 println!("PASS {label} {completed}/{total}: {pass}");
67 }
68 self.last = completed;
69 }
70 if self.editor.render_is_running() {
71 return false;
72 }
73 let error = self.editor.render_error().map(str::to_string);
74 let success = error.is_none() && total > 0 && completed == total;
75 self.results.push(serde_json::json!({
76 "set": label, "success": success, "completed": completed,
77 "total": total, "last_pass": pass, "error": error,
78 }));
79 self.last = 0;
80 true
81 }
82}
83
84impl App for Fixture {
85 fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
86 self.editor.update(ctx, frame);
87 ctx.request_repaint();
88 match self.phase {
89 0 => {
90 if self.screenshot(ctx, "live-fbx-body.png") {
91 self.editor.open_equipment_configuration(0, Sex::Male);
92 self.phase = 1;
93 }
94 }
95 1 => {
96 if self.screenshot(ctx, "live-fbx-equipment.png") {
97 self.editor.open_body_configuration(Sex::Male);
98 self.editor.render_all_angles();
99 self.phase = 2;
100 }
101 }
102 2 => {
103 if self.render_done("body") {
104 self.editor.open_equipment_configuration(0, Sex::Male);
105 self.editor.render_all_angles();
106 self.phase = 3;
107 }
108 }
109 3 => {
110 if self.render_done("fbxsword") {
111 self.phase = 4;
112 }
113 }
114 4 => {
115 let written = self.screenshot(ctx, "production-fbx-equipment-ui.png");
116 if written {
117 self.phase = 5;
118 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
119 }
120 }
121 _ => {}
122 }
123 }
124 fn on_exit(&mut self, gl: Option<&glow::Context>) {
125 let value = serde_json::Value::Array(self.results.clone());
126 std::fs::write(
127 self.out.join("gui-result.json"),
128 serde_json::to_vec_pretty(&value).unwrap(),
129 )
130 .unwrap();
131 self.editor.on_exit(gl);
132 }
133}
134
135fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, String> {
136 let mut runs = Vec::new();
137 let mut dirs = std::fs::read_dir(root.join("renders"))
138 .map_err(|e| e.to_string())?
139 .map(|e| e.map(|e| e.path()).map_err(|e| e.to_string()))
140 .collect::<Result<Vec<_>, _>>()?;
141 dirs.sort();
142 for dir in dirs.into_iter().filter(|d| d.is_dir()) {
143 let run =
144 export_set::ExportRun::open(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
145 let mut counts = std::collections::BTreeMap::<String, u32>::new();
146 let mut opaque = 0u64;
147 let mut textured = 0u64;
148 let mut stack = vec![dir.clone()];
149 while let Some(d) = stack.pop() {
150 for entry in std::fs::read_dir(&d).map_err(|e| e.to_string())? {
151 let path = entry.map_err(|e| e.to_string())?.path();
152 if path.is_dir() {
153 stack.push(path);
154 continue;
155 }
156 let name = path.file_name().unwrap().to_string_lossy().to_string();
157 if !name.ends_with(".png") || name.ends_with("-atlas.png") {
158 // Atlases are verified cell-by-cell by atlas::cross_check_run.
159 continue;
160 }
161 let map = name
162 .rsplit_once('_')
163 .map(|(_, m)| m.trim_end_matches(".png").to_string())
164 .unwrap_or_default();
165 if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) {
166 return Err(format!("onverwachte map {name}"));
167 }
168 *counts.entry(map.clone()).or_default() += 1;
169 let decoder = png::Decoder::new(std::io::BufReader::new(
170 std::fs::File::open(&path).map_err(|e| e.to_string())?,
171 ));
172 let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
173 let (w, h) = (reader.info().width, reader.info().height);
174 if (w, h) != (width, height) {
175 return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
176 }
177 if map == "diffuse" {
178 let mut buf = vec![0; reader.output_buffer_size().unwrap()];
179 let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
180 if info.color_type == png::ColorType::Rgba {
181 for px in buf[..info.buffer_size()].chunks_exact(4) {
182 if px[3] > 0 {
183 opaque += 1;
184 // Brass guard albedo (0.75,0.55,0.2) or checker blade: colourful.
185 if px[0] as i32 - px[2] as i32 > 60 {
186 textured += 1;
187 }
188 }
189 }
190 }
191 }
192 }
193 }
194 let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)?;
195 runs.push(serde_json::json!({
196 "atlas": atlas,
197 "dir": dir.display().to_string(),
198 "png_files": run.complete.png_files,
199 "maps": counts,
200 "diffuse_opaque_pixels": opaque,
201 "diffuse_warm_pixels": textured,
202 "body_model": run.render.body_model.path.clone(),
203 "set": run.render.set.clone(),
204 "clips": run.render.clips.len(),
205 "images": run.render.images,
206 }));
207 }
208 Ok(serde_json::Value::Array(runs))
209}
210
211fn main() -> Result<(), Box<dyn std::error::Error>> {
212 let a = std::env::args().skip(1).collect::<Vec<_>>();
213 if a.len() < 2 {
214 return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into());
215 }
216 let root = PathBuf::from(&a[0]);
217 let out = PathBuf::from(&a[1]);
218 std::fs::create_dir_all(&out)?;
219 let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32);
220 let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48);
221 let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4);
222 let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
223 let mut s = Session::new();
224 s.install_new_project(&root)?;
225 for (sex, file) in [
226 (Sex::Male, "tests/fixtures/bodies/test-body-male.fbx"),
227 (Sex::Female, "tests/fixtures/bodies/test-body-female.glb"),
228 ] {
229 let path = adopt_file(&root, &repo.join(file), "assets/body")?;
230 s.set_body_path(sex, path);
231 }
232 s.set_render(width, height, fps);
233 let body = preview::load_body_file(&root.join(s.body_path(Sex::Male).unwrap()))?;
234 let mut preset = equipment::Preset::new("fbxsword".into());
235 preset.provenance = "CC0-1.0 generated fixture (Blender FBX, embedded PNG)".into();
236 equipment::import_model(
237 &mut preset,
238 &repo.join("tests/fixtures/equipment/sword-fbx.fbx"),
239 )?;
240 let joint = body
241 .joint_names
242 .iter()
243 .position(|n| n == "RightHand")
244 .ok_or("RightHand ontbreekt")?;
245 preset.bind(0, &body, 0, joint)?;
246 preset.applies = [true, false];
247 preset.attachments[0].rotation_deg[0] = [0., 0., -15.];
248 let i = s.add_equipment();
249 s.equipment[i].slug = "fbxsword".into();
250 s.equipment[i].preset = Some(preset);
251 s.save()?;
252 println!(
253 "PLAN male FBX: {:?}",
254 paperdoll_renderer::production::plan(&body, fps)?
255 );
256 let (root2, out2) = (root.clone(), out.clone());
257 eframe::run_native(
258 "Paperdoll FBX Live Fixture",
259 eframe::NativeOptions {
260 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
261 ..paperdoll_renderer::preview_gl::native_options()
262 },
263 Box::new(move |_| {
264 let mut editor = EditorApp::default();
265 editor.open_path(root2.join("character.ppd"));
266 editor.open_body_configuration(Sex::Male);
267 Ok(Box::new(Fixture {
268 editor,
269 out: out2,
270 phase: 0,
271 waited: 0,
272 shot_requested: false,
273 results: Vec::new(),
274 last: 0,
275 }))
276 }),
277 )?;
278 let gui: serde_json::Value =
279 serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?;
280 let runs = verify(&root, width, height)?;
281 let report = serde_json::json!({"gui": gui, "runs": runs});
282 std::fs::write(
283 out.join("fbx-live-proof.json"),
284 serde_json::to_vec_pretty(&report)?,
285 )?;
286 println!("REPORT {report}");
287 let ok = gui
288 .as_array()
289 .is_some_and(|g| g.len() == 2 && g.iter().all(|r| r["success"] == true))
290 && runs.as_array().is_some_and(|r| r.len() == 2);
291 if !ok {
292 return Err("FBX-proof mislukt; zie fbx-live-proof.json".into());
293 }
294 Ok(())
295}