BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / examples / textured_body_fixture.rs

textured_body_fixture.rs

textured_body_fixture.rs 8.0 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 match self.phase {
103 0 => {
104 if self.screenshot(ctx, "live-body.png") {
105 self.editor.render_all_angles();
106 self.phase = 1;
107 }
108 }
109 1 => {
110 if !self.editor.render_is_running() {
111 self.error = self.editor.render_error().map(str::to_string);
112 self.phase = 2;
113 }
114 }
115 2 => {
116 if self.screenshot(ctx, "production-ui.png") {
117 self.phase = 3;
118 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
119 }
120 }
121 _ => {}
122 }
123 }
124}
125
126fn main() -> Result<(), Box<dyn std::error::Error>> {
127 let a = std::env::args().skip(1).collect::<Vec<_>>();
128 let expect = a.iter().any(|v| v == "--expect-multi-material");
129 let a: Vec<String> = a.into_iter().filter(|v| !v.starts_with("--")).collect();
130 if a.len() < 4 {
131 return Err("ROOT OUTDIR MODEL_DIR MODEL_FILE [WIDTH HEIGHT FPS]".into());
132 }
133 let root = PathBuf::from(&a[0]);
134 let out = PathBuf::from(&a[1]);
135 let model_dir = PathBuf::from(&a[2]);
136 let model_file = &a[3];
137 std::fs::create_dir_all(&out)?;
138 let width: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(48);
139 let height: u32 = a.get(5).map(|v| v.parse()).transpose()?.unwrap_or(64);
140 let fps: u32 = a.get(6).map(|v| v.parse()).transpose()?.unwrap_or(1);
141 let mut s = Session::new();
142 s.install_new_project(&root)?;
143 copy_dir(&model_dir, &root.join("assets/body/model"))?;
144 let relative = format!("assets/body/model/{model_file}");
145 s.set_body_path(Sex::Male, relative.clone());
146 s.set_body_path(Sex::Female, relative);
147 s.set_render(width, height, fps);
148 s.save()?;
149 let root2 = root.clone();
150 let out2 = out.clone();
151 eframe::run_native(
152 "Paperdoll Textured Body Fixture",
153 eframe::NativeOptions {
154 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
155 ..paperdoll_renderer::preview_gl::native_options()
156 },
157 Box::new(move |_| {
158 let mut editor = EditorApp::default();
159 editor.open_path(root2.join("character.ppd"));
160 editor.open_body_configuration(Sex::Male);
161 Ok(Box::new(Fixture {
162 editor,
163 out: out2,
164 phase: 0,
165 waited: 0,
166 shot_requested: false,
167 error: None,
168 }))
169 }),
170 )?;
171 let raw = root.join("renders/male-body-atlas/raw files");
172 let front = find(&raw, "p+00_y000_diffuse.png").ok_or("no production front diffuse")?;
173 let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&front)?));
174 let mut reader = decoder.read_info()?;
175 let mut buf = vec![0; reader.output_buffer_size().ok_or("png size")?];
176 let info = reader.next_frame(&mut buf)?;
177 let (w, h) = (info.width as usize, info.height as usize);
178 let channels = buf.len() / (w * h);
179 let scale = 8;
180 let mut big = vec![0u8; w * h * scale * scale * 4];
181 for y in 0..h * scale {
182 for x in 0..w * scale {
183 let src = ((y / scale) * w + x / scale) * channels;
184 let px = &buf[src..src + channels];
185 let rgba = if channels == 4 {
186 [px[0], px[1], px[2], px[3]]
187 } else {
188 [px[0], px[1], px[2], 255]
189 };
190 big[(y * w * scale + x) * 4..][..4].copy_from_slice(&rgba);
191 }
192 }
193 save_png(
194 &out.join("production-front-diffuse.png"),
195 w * scale,
196 h * scale,
197 &big,
198 );
199 println!("PRODUCTION {}", front.display());
200 if expect {
201 // Unlit sRGB albedo; factors are linear (glTF), textures sRGB:
202 // factor (0.2, 0.4, 0.9) → sRGB (124, 170, 243); texture (0, 200, 0);
203 // texture (200, 200, 0) × factor (0.5, 1, 1) in linear → (146, 200, 0).
204 for (name, want) in [
205 ("FactorOnly", [124u8, 170, 243]),
206 ("Green", [0, 200, 0]),
207 ("YellowTimesFactor", [146, 200, 0]),
208 ] {
209 let found = buf
210 .chunks_exact(channels)
211 .any(|px| px[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2));
212 println!(
213 "MATERIAL {name} {want:?}: {}",
214 if found { "ok" } else { "MISSING" }
215 );
216 if !found {
217 return Err(format!("production diffuse mist materiaal {name}").into());
218 }
219 }
220 }
221 Ok(())
222}