BlueGit

golden-age paperdoll-renderer

Merge fix/multi-material-textures (Tim t1007u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
9d6b740e941ed8b9512930badf6f66e83d4af4a3
Pipeline
Passed
10 files changed +1038 −55
README.md +4 −1
@@ -221,8 +221,11 @@ xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp
221 221 cargo run --release --example resume_fixture --locked -- verify /tmp/resume
222 222 # FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set
223 223 xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
224 # Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor);
225 # screenshots the live view and checks every material colour in the production diffuse
226 xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --release --example textured_body_fixture --locked -- /tmp/tex /tmp/tex-proof tests/fixtures/bodies/multi-material body.gltf --expect-multi-material
224 227 # Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
225 228 xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
226 229 ```
227 230
228 The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.
231 The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The multi-material body in `tests/fixtures/bodies/multi-material/` (external `body.bin`, `textures/*.png`) comes from `scripts/generate_multi_material_body.py`. The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.
@@ -221,8 +221,11 @@ xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp
221cargo run --release --example resume_fixture --locked -- verify /tmp/resume
221cargo run --release --example resume_fixture --locked -- verify /tmp/resume
222# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set
222# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set
223xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
223xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
224# Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor);
225# screenshots the live view and checks every material colour in the production diffuse
226xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --release --example textured_body_fixture --locked -- /tmp/tex /tmp/tex-proof tests/fixtures/bodies/multi-material body.gltf --expect-multi-material
224# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
227# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
225xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
228xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
226```
229```
227
230
228The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.
231The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The multi-material body in `tests/fixtures/bodies/multi-material/` (external `body.bin`, `textures/*.png`) comes from `scripts/generate_multi_material_body.py`. The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.
examples/textured_body_fixture.rs added +224 −0
@@ -0,0 +1,224 @@
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).
10 use eframe::App;
11 use paperdoll_renderer::{
12 editor::EditorApp,
13 session::{Session, Sex},
14 };
15 use std::path::{Path, PathBuf};
16
17 const SETTLE_FRAMES: u64 = 40;
18
19 struct Fixture {
20 editor: EditorApp,
21 out: PathBuf,
22 phase: u8,
23 waited: u64,
24 shot_requested: bool,
25 error: Option<String>,
26 }
27
28 fn 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
36 fn 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
50 fn 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
69 impl 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
98 impl 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
128 fn 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 }
@@ -0,0 +1,224 @@
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}
scripts/generate_multi_material_body.py added +91 −0
@@ -0,0 +1,91 @@
1 #!/usr/bin/env python3
2 """CC0 regression fixture: a skinned glTF body with an external .bin, two
3 external PNG textures and three materials (factor only, texture, texture x
4 factor), one primitive per material. Three quads side by side facing +Z.
5
6 Writes tests/fixtures/bodies/multi-material/{body.gltf,body.bin,textures/*.png}.
7 """
8 import json, os, struct, zlib
9
10 OUT = os.path.join(os.path.dirname(__file__), "..", "tests", "fixtures", "bodies", "multi-material")
11
12
13 def png(path, w, h, pixel):
14 raw = b"".join(b"\0" + b"".join(bytes(pixel(x, y)) for x in range(w)) for y in range(h))
15 def chunk(t, d):
16 return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
17 data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
18 data += chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")
19 with open(path, "wb") as f:
20 f.write(data)
21
22
23 def main():
24 os.makedirs(os.path.join(OUT, "textures"), exist_ok=True)
25 # Texture A: solid green. Texture B: solid yellow (multiplied by factor 0.5,1,1).
26 png(os.path.join(OUT, "textures", "green_baseColor.png"), 4, 4, lambda x, y: (0, 200, 0))
27 png(os.path.join(OUT, "textures", "yellow_baseColor.png"), 4, 4, lambda x, y: (200, 200, 0))
28 blob = b""
29 views, accessors, prims = [], [], []
30
31 def add(data, target=None):
32 nonlocal blob
33 while len(blob) % 4:
34 blob += b"\0"
35 view = {"buffer": 0, "byteOffset": len(blob), "byteLength": len(data)}
36 if target:
37 view["target"] = target
38 views.append(view)
39 blob += data
40 return len(views) - 1
41
42 def accessor(view, ctype, count, typ, **kw):
43 accessors.append({"bufferView": view, "componentType": ctype, "count": count, "type": typ, **kw})
44 return len(accessors) - 1
45
46 for i, x0 in enumerate([-0.75, -0.25, 0.25]):
47 x1 = x0 + 0.5
48 pos = [(x0, 0.0, 0.0), (x1, 0.0, 0.0), (x1, 1.6, 0.0), (x0, 1.6, 0.0)]
49 p = accessor(add(b"".join(struct.pack("<3f", *v) for v in pos), 34962), 5126, 4, "VEC3",
50 min=[x0, 0.0, 0.0], max=[x1, 1.6, 0.0])
51 n = accessor(add(struct.pack("<3f", 0, 0, 1) * 4, 34962), 5126, 4, "VEC3")
52 uv = accessor(add(b"".join(struct.pack("<2f", *t) for t in [(0, 1), (1, 1), (1, 0), (0, 0)]), 34962), 5126, 4, "VEC2")
53 j = accessor(add(struct.pack("<4H", 0, 0, 0, 0) * 4, 34962), 5123, 4, "VEC4")
54 w = accessor(add(struct.pack("<4f", 1, 0, 0, 0) * 4, 34962), 5126, 4, "VEC4")
55 idx = accessor(add(struct.pack("<6H", 0, 1, 2, 0, 2, 3), 34963), 5123, 6, "SCALAR")
56 prims.append({"attributes": {"POSITION": p, "NORMAL": n, "TEXCOORD_0": uv, "JOINTS_0": j, "WEIGHTS_0": w},
57 "indices": idx, "material": i})
58 ibm = accessor(add(struct.pack("<16f", 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)), 5126, 1, "MAT4")
59 times = accessor(add(struct.pack("<2f", 0.0, 1.0)), 5126, 2, "SCALAR", min=[0.0], max=[1.0])
60 rots = accessor(add(struct.pack("<8f", 0, 0, 0, 1, 0, 0, 0, 1)), 5126, 2, "VEC4")
61 gltf = {
62 "asset": {"version": "2.0", "generator": "paperdoll generate_multi_material_body.py (CC0)"},
63 "extras": {"forward": "+Z"},
64 "scene": 0,
65 "scenes": [{"nodes": [0, 1]}],
66 "nodes": [{"name": "Root"}, {"name": "Body", "mesh": 0, "skin": 0}],
67 "skins": [{"joints": [0], "inverseBindMatrices": ibm}],
68 "meshes": [{"name": "Body", "primitives": prims}],
69 "materials": [
70 {"name": "FactorOnly", "pbrMetallicRoughness": {"baseColorFactor": [0.2, 0.4, 0.9, 1.0]}},
71 {"name": "Green", "pbrMetallicRoughness": {"baseColorTexture": {"index": 0}}},
72 {"name": "YellowTimesFactor", "pbrMetallicRoughness": {"baseColorTexture": {"index": 1},
73 "baseColorFactor": [0.5, 1.0, 1.0, 1.0]}},
74 ],
75 "textures": [{"sampler": 0, "source": 0}, {"sampler": 0, "source": 1}],
76 "samplers": [{"magFilter": 9728, "minFilter": 9728}],
77 "images": [{"uri": "textures/green_baseColor.png"}, {"uri": "textures/yellow_baseColor.png"}],
78 "animations": [{"name": "idle", "samplers": [{"input": times, "output": rots}],
79 "channels": [{"sampler": 0, "target": {"node": 0, "path": "rotation"}}]}],
80 "buffers": [{"uri": "body.bin", "byteLength": len(blob)}],
81 "bufferViews": views,
82 "accessors": accessors,
83 }
84 with open(os.path.join(OUT, "body.bin"), "wb") as f:
85 f.write(blob)
86 with open(os.path.join(OUT, "body.gltf"), "w") as f:
87 json.dump(gltf, f, indent=1)
88
89
90 if __name__ == "__main__":
91 main()
@@ -0,0 +1,91 @@
1#!/usr/bin/env python3
2"""CC0 regression fixture: a skinned glTF body with an external .bin, two
3external PNG textures and three materials (factor only, texture, texture x
4factor), one primitive per material. Three quads side by side facing +Z.
5
6Writes tests/fixtures/bodies/multi-material/{body.gltf,body.bin,textures/*.png}.
7"""
8import json, os, struct, zlib
9
10OUT = os.path.join(os.path.dirname(__file__), "..", "tests", "fixtures", "bodies", "multi-material")
11
12
13def png(path, w, h, pixel):
14 raw = b"".join(b"\0" + b"".join(bytes(pixel(x, y)) for x in range(w)) for y in range(h))
15 def chunk(t, d):
16 return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
17 data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
18 data += chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")
19 with open(path, "wb") as f:
20 f.write(data)
21
22
23def main():
24 os.makedirs(os.path.join(OUT, "textures"), exist_ok=True)
25 # Texture A: solid green. Texture B: solid yellow (multiplied by factor 0.5,1,1).
26 png(os.path.join(OUT, "textures", "green_baseColor.png"), 4, 4, lambda x, y: (0, 200, 0))
27 png(os.path.join(OUT, "textures", "yellow_baseColor.png"), 4, 4, lambda x, y: (200, 200, 0))
28 blob = b""
29 views, accessors, prims = [], [], []
30
31 def add(data, target=None):
32 nonlocal blob
33 while len(blob) % 4:
34 blob += b"\0"
35 view = {"buffer": 0, "byteOffset": len(blob), "byteLength": len(data)}
36 if target:
37 view["target"] = target
38 views.append(view)
39 blob += data
40 return len(views) - 1
41
42 def accessor(view, ctype, count, typ, **kw):
43 accessors.append({"bufferView": view, "componentType": ctype, "count": count, "type": typ, **kw})
44 return len(accessors) - 1
45
46 for i, x0 in enumerate([-0.75, -0.25, 0.25]):
47 x1 = x0 + 0.5
48 pos = [(x0, 0.0, 0.0), (x1, 0.0, 0.0), (x1, 1.6, 0.0), (x0, 1.6, 0.0)]
49 p = accessor(add(b"".join(struct.pack("<3f", *v) for v in pos), 34962), 5126, 4, "VEC3",
50 min=[x0, 0.0, 0.0], max=[x1, 1.6, 0.0])
51 n = accessor(add(struct.pack("<3f", 0, 0, 1) * 4, 34962), 5126, 4, "VEC3")
52 uv = accessor(add(b"".join(struct.pack("<2f", *t) for t in [(0, 1), (1, 1), (1, 0), (0, 0)]), 34962), 5126, 4, "VEC2")
53 j = accessor(add(struct.pack("<4H", 0, 0, 0, 0) * 4, 34962), 5123, 4, "VEC4")
54 w = accessor(add(struct.pack("<4f", 1, 0, 0, 0) * 4, 34962), 5126, 4, "VEC4")
55 idx = accessor(add(struct.pack("<6H", 0, 1, 2, 0, 2, 3), 34963), 5123, 6, "SCALAR")
56 prims.append({"attributes": {"POSITION": p, "NORMAL": n, "TEXCOORD_0": uv, "JOINTS_0": j, "WEIGHTS_0": w},
57 "indices": idx, "material": i})
58 ibm = accessor(add(struct.pack("<16f", 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)), 5126, 1, "MAT4")
59 times = accessor(add(struct.pack("<2f", 0.0, 1.0)), 5126, 2, "SCALAR", min=[0.0], max=[1.0])
60 rots = accessor(add(struct.pack("<8f", 0, 0, 0, 1, 0, 0, 0, 1)), 5126, 2, "VEC4")
61 gltf = {
62 "asset": {"version": "2.0", "generator": "paperdoll generate_multi_material_body.py (CC0)"},
63 "extras": {"forward": "+Z"},
64 "scene": 0,
65 "scenes": [{"nodes": [0, 1]}],
66 "nodes": [{"name": "Root"}, {"name": "Body", "mesh": 0, "skin": 0}],
67 "skins": [{"joints": [0], "inverseBindMatrices": ibm}],
68 "meshes": [{"name": "Body", "primitives": prims}],
69 "materials": [
70 {"name": "FactorOnly", "pbrMetallicRoughness": {"baseColorFactor": [0.2, 0.4, 0.9, 1.0]}},
71 {"name": "Green", "pbrMetallicRoughness": {"baseColorTexture": {"index": 0}}},
72 {"name": "YellowTimesFactor", "pbrMetallicRoughness": {"baseColorTexture": {"index": 1},
73 "baseColorFactor": [0.5, 1.0, 1.0, 1.0]}},
74 ],
75 "textures": [{"sampler": 0, "source": 0}, {"sampler": 0, "source": 1}],
76 "samplers": [{"magFilter": 9728, "minFilter": 9728}],
77 "images": [{"uri": "textures/green_baseColor.png"}, {"uri": "textures/yellow_baseColor.png"}],
78 "animations": [{"name": "idle", "samplers": [{"input": times, "output": rots}],
79 "channels": [{"sampler": 0, "target": {"node": 0, "path": "rotation"}}]}],
80 "buffers": [{"uri": "body.bin", "byteLength": len(blob)}],
81 "bufferViews": views,
82 "accessors": accessors,
83 }
84 with open(os.path.join(OUT, "body.bin"), "wb") as f:
85 f.write(blob)
86 with open(os.path.join(OUT, "body.gltf"), "w") as f:
87 json.dump(gltf, f, indent=1)
88
89
90if __name__ == "__main__":
91 main()
src/editor.rs +2 −2
@@ -1064,7 +1064,7 @@ impl EditorApp {
1064 1064 "{} / {} · {}",
1065 1065 self.render_ui.progress.completed,
1066 1066 self.render_ui.progress.total,
1067 self.render_ui.progress.label
1067 crate::i18n::t(&self.render_ui.progress.label)
1068 1068 )),
1069 1069 );
1070 1070 }
@@ -1814,7 +1814,7 @@ impl EditorApp {
1814 1814 });
1815 1815 ui.add(
1816 1816 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1817 .text("Glanssterkte"),
1817 .text(crate::i18n::t("Glanssterkte")),
1818 1818 );
1819 1819 let shiny = attachment
1820 1820 .shiny
@@ -1064,7 +1064,7 @@ impl EditorApp {
1064 "{} / {} · {}",
1064 "{} / {} · {}",
1065 self.render_ui.progress.completed,
1065 self.render_ui.progress.completed,
1066 self.render_ui.progress.total,
1066 self.render_ui.progress.total,
1067 self.render_ui.progress.label
1067 crate::i18n::t(&self.render_ui.progress.label)
1068 )),
1068 )),
1069 );
1069 );
1070 }
1070 }
@@ -1814,7 +1814,7 @@ impl EditorApp {
1814 });
1814 });
1815 ui.add(
1815 ui.add(
1816 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1816 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1817 .text("Glanssterkte"),
1817 .text(crate::i18n::t("Glanssterkte")),
1818 );
1818 );
1819 let shiny = attachment
1819 let shiny = attachment
1820 .shiny
1820 .shiny
src/i18n_table.rs +3 −0
@@ -813,4 +813,7 @@ pub const TABLE: &[(&str, &str)] = &[
813 813 ("Instellingen", "Settings"),
814 814 ("Taal", "Language"),
815 815 ("Sluiten", "Close"),
816 ("voorste materiaal niet als texture × factor getekend: {surface_front:?}", "front material not drawn as texture × factor: {surface_front:?}"),
817 ("materiaal per primitive verwisseld: {surface_swap:?}", "material per primitive swapped: {surface_swap:?}"),
818 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
816 819 ];
@@ -813,4 +813,7 @@ pub const TABLE: &[(&str, &str)] = &[
813 ("Instellingen", "Settings"),
813 ("Instellingen", "Settings"),
814 ("Taal", "Language"),
814 ("Taal", "Language"),
815 ("Sluiten", "Close"),
815 ("Sluiten", "Close"),
816 ("voorste materiaal niet als texture × factor getekend: {surface_front:?}", "front material not drawn as texture × factor: {surface_front:?}"),
817 ("materiaal per primitive verwisseld: {surface_swap:?}", "material per primitive swapped: {surface_swap:?}"),
818 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
816];
819];
src/preview_gl.rs +264 −52
@@ -116,7 +116,7 @@ impl Viewport {
116 116 animated,
117 117 egui::SelectableLabel::new(
118 118 controls.playing && animated,
119 if controls.playing { "Pauze" } else { "Speel" },
119 crate::i18n::t(if controls.playing { "Pauze" } else { "Speel" }),
120 120 ),
121 121 )
122 122 .clicked()
@@ -173,7 +173,7 @@ impl Viewport {
173 173 ui.painter().text(
174 174 rect.center(),
175 175 egui::Align2::CENTER_CENTER,
176 "Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture.",
176 crate::i18n::t("Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture."),
177 177 egui::FontId::proportional(14.0),
178 178 egui::Color32::from_rgb(210, 210, 210),
179 179 );
@@ -244,24 +244,7 @@ impl Viewport {
244 244 }
245 245 }
246 246 {
247 cache.pending = Some(PendingFrame {
248 generation: character_generation(character),
249 positions: character.positions.clone(),
250 normals: character.normals.clone(),
251 joints: character.joints.clone(),
252 weights: character.weights.clone(),
253 indices: character.indices.clone(),
254 bones,
255 view_proj,
256 color: character.base_color,
257 model: crate::preview::identity(),
258 albedo: None,
259 shiny: None,
260 specular: 0.0,
261 eye,
262 equipment,
263 uvs: Vec::new(),
264 });
247 cache.pending = Some(body_frame(character, bones, view_proj, eye, equipment));
265 248 }
266 249 drop(cache);
267 250 let cache = Arc::clone(&self.cache);
@@ -280,7 +263,7 @@ impl Viewport {
280 263 ui.painter().text(
281 264 rect.left_top() + egui::vec2(5., 5.),
282 265 egui::Align2::LEFT_TOP,
283 "Vast renderkader",
266 crate::i18n::t("Vast renderkader"),
284 267 egui::FontId::proportional(12.),
285 268 egui::Color32::YELLOW,
286 269 );
@@ -300,7 +283,11 @@ impl Viewport {
300 283 ui.painter().text(
301 284 rect.left_top() + egui::vec2(12., 12.),
302 285 egui::Align2::LEFT_TOP,
303 diagnostics.join("\n"),
286 diagnostics
287 .iter()
288 .map(crate::i18n::t)
289 .collect::<Vec<_>>()
290 .join("\n"),
304 291 egui::FontId::proportional(14.),
305 292 egui::Color32::LIGHT_RED,
306 293 );
@@ -308,6 +295,52 @@ impl Viewport {
308 295 }
309 296 }
310 297
298 /// Live equipment keeps its alpha-cutout threshold of 0.5.
299 const EQUIPMENT_CUTOFF: f32 = 0.5;
300
301 /// The body as one pending frame: every material range (glTF primitive) is
302 /// drawn with its own base color factor × albedo texture and cutoff, exactly
303 /// as production does; nothing is merged into a single color.
304 fn body_frame(
305 character: &PreviewCharacter,
306 bones: Vec<crate::preview::Mat4>,
307 view_proj: crate::preview::Mat4,
308 eye: [f32; 3],
309 equipment: Vec<PendingFrame>,
310 ) -> PendingFrame {
311 let surfaces = character
312 .surfaces
313 .iter()
314 .map(|s| SurfaceDraw {
315 first: s.first,
316 count: s.count,
317 color: s.color,
318 albedo: s.albedo.clone(),
319 cutoff: s.cutoff,
320 })
321 .collect();
322 PendingFrame {
323 generation: character_generation(character),
324 positions: character.positions.clone(),
325 normals: character.normals.clone(),
326 joints: character.joints.clone(),
327 weights: character.weights.clone(),
328 indices: character.indices.clone(),
329 bones,
330 view_proj,
331 color: character.base_color,
332 model: crate::preview::identity(),
333 albedo: None,
334 shiny: None,
335 specular: 0.0,
336 eye,
337 equipment,
338 uvs: character.uvs.clone(),
339 surfaces,
340 cutoff: 0.001,
341 }
342 }
343
311 344 fn character_generation(character: &PreviewCharacter) -> u64 {
312 345 use std::hash::{Hash, Hasher};
313 346 let mut hash = std::collections::hash_map::DefaultHasher::new();
@@ -322,6 +355,15 @@ fn character_generation(character: &PreviewCharacter) -> u64 {
322 355 }
323 356 character.joints.hash(&mut hash);
324 357 character.indices.hash(&mut hash);
358 for uv in &character.uvs {
359 uv.map(f32::to_bits).hash(&mut hash);
360 }
361 for s in &character.surfaces {
362 (s.first, s.count).hash(&mut hash);
363 if let Some(t) = &s.albedo {
364 (t.content_hash, t.width, t.height).hash(&mut hash);
365 }
366 }
325 367 hash.finish()
326 368 }
327 369
@@ -343,6 +385,21 @@ struct PendingFrame {
343 385 specular: f32,
344 386 equipment: Vec<PendingFrame>,
345 387 uvs: Vec<[f32; 2]>,
388 /// Per-material index ranges (glTF primitives); empty = one draw of all
389 /// indices with `color`/`albedo`. Each has its own texture × factor.
390 surfaces: Vec<SurfaceDraw>,
391 cutoff: f32,
392 }
393
394 /// One material range of the body: indices `first..first+count`, base color
395 /// factor, optional albedo texture and alpha cutoff (as loaded, unchanged).
396 #[derive(Clone)]
397 struct SurfaceDraw {
398 first: usize,
399 count: usize,
400 color: [f32; 4],
401 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
402 cutoff: f32,
346 403 }
347 404
348 405 impl PendingFrame {
@@ -390,6 +447,8 @@ impl PendingFrame {
390 447 shiny: layer.shiny,
391 448 specular: layer.specular,
392 449 equipment: Vec::new(),
450 surfaces: Vec::new(),
451 cutoff: EQUIPMENT_CUTOFF,
393 452 }
394 453 }
395 454 }
@@ -405,6 +464,8 @@ struct GpuCache {
405 464 pending: Option<PendingFrame>,
406 465 error: Option<String>,
407 466 textures: [Option<glow::Texture>; 2],
467 /// One albedo texture per body surface (same order as `surfaces`).
468 surface_textures: Vec<Option<glow::Texture>>,
408 469 layers: Vec<GpuCache>,
409 470 texture_cache: crate::equipment::TextureCache,
410 471 }
@@ -502,7 +563,37 @@ impl GpuCache {
502 563 let bones_loc = gl.get_uniform_location(program, "u_bones");
503 564 gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
504 565 gl.bind_vertex_array(self.vao);
505 gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
566 let cutoff = gl.get_uniform_location(program, "u_cutoff");
567 if frame.surfaces.is_empty() {
568 gl.uniform_1_f32(cutoff.as_ref(), frame.cutoff);
569 gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
570 } else {
571 let albedo_enabled = gl.get_uniform_location(program, "u_albedo_enabled");
572 for (index, surface) in frame.surfaces.iter().enumerate() {
573 let end = (surface.first + surface.count).min(self.index_count as usize);
574 if surface.first >= end {
575 continue;
576 }
577 let texture = self.surface_textures.get(index).copied().flatten();
578 gl.active_texture(glow::TEXTURE0);
579 gl.bind_texture(glow::TEXTURE_2D, texture);
580 gl.uniform_1_i32(albedo_enabled.as_ref(), i32::from(texture.is_some()));
581 gl.uniform_4_f32(
582 color.as_ref(),
583 surface.color[0],
584 surface.color[1],
585 surface.color[2],
586 surface.color[3],
587 );
588 gl.uniform_1_f32(cutoff.as_ref(), surface.cutoff);
589 gl.draw_elements(
590 glow::TRIANGLES,
591 (end - surface.first) as i32,
592 glow::UNSIGNED_INT,
593 (surface.first * 4) as i32,
594 );
595 }
596 }
506 597 gl.bind_vertex_array(None);
507 598 while self.layers.len() > frame.equipment.len() {
508 599 if let Some(mut layer) = self.layers.pop() {
@@ -527,11 +618,16 @@ impl GpuCache {
527 618 for layer in &mut self.layers {
528 619 layer.destroy(gl);
529 620 }
530 for texture in &mut self.textures {
621 for texture in self
622 .textures
623 .iter_mut()
624 .chain(self.surface_textures.iter_mut())
625 {
531 626 if let Some(t) = texture.take() {
532 627 gl.delete_texture(t)
533 628 }
534 629 }
630 self.surface_textures.clear();
535 631 if let Some(v) = self.vao.take() {
536 632 gl.delete_vertex_array(v)
537 633 }
@@ -606,41 +702,60 @@ impl GpuCache {
606 702 gl.delete_texture(old);
607 703 }
608 704 if let Some(texture) = texture {
609 let id = gl.create_texture().map_err(|e| e.to_string())?;
610 self.textures[unit] = Some(id);
611 705 gl.active_texture(glow::TEXTURE0 + unit as u32);
612 gl.bind_texture(glow::TEXTURE_2D, Some(id));
613 gl.tex_parameter_i32(
614 glow::TEXTURE_2D,
615 glow::TEXTURE_MIN_FILTER,
616 glow::NEAREST as i32,
617 );
618 gl.tex_parameter_i32(
619 glow::TEXTURE_2D,
620 glow::TEXTURE_MAG_FILTER,
621 glow::NEAREST as i32,
622 );
623 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
624 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
625 gl.tex_image_2d(
626 glow::TEXTURE_2D,
627 0,
628 glow::RGBA8 as i32,
629 texture.width as i32,
630 texture.height as i32,
631 0,
632 glow::RGBA,
633 glow::UNSIGNED_BYTE,
634 Some(&texture.rgba),
635 );
706 self.textures[unit] = Some(upload_texture(gl, texture)?);
636 707 }
637 708 }
709 for old in self.surface_textures.drain(..).flatten() {
710 gl.delete_texture(old);
711 }
712 gl.active_texture(glow::TEXTURE0);
713 for surface in &frame.surfaces {
714 let id = match &surface.albedo {
715 Some(texture) => Some(upload_texture(gl, texture)?),
716 None => None,
717 };
718 self.surface_textures.push(id);
719 }
638 720 self.uploaded = frame.generation;
639 721 self.index_count = frame.indices.len() as i32;
640 722 Ok(())
641 723 }
642 724 }
643 725
726 /// Nearest + REPEAT RGBA8 texture, bound to the active unit.
727 unsafe fn upload_texture(
728 gl: &glow::Context,
729 texture: &crate::equipment::Texture,
730 ) -> Result<glow::Texture, String> {
731 let id = gl.create_texture().map_err(|e| e.to_string())?;
732 gl.bind_texture(glow::TEXTURE_2D, Some(id));
733 gl.tex_parameter_i32(
734 glow::TEXTURE_2D,
735 glow::TEXTURE_MIN_FILTER,
736 glow::NEAREST as i32,
737 );
738 gl.tex_parameter_i32(
739 glow::TEXTURE_2D,
740 glow::TEXTURE_MAG_FILTER,
741 glow::NEAREST as i32,
742 );
743 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
744 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
745 gl.tex_image_2d(
746 glow::TEXTURE_2D,
747 0,
748 glow::RGBA8 as i32,
749 texture.width as i32,
750 texture.height as i32,
751 0,
752 glow::RGBA,
753 glow::UNSIGNED_BYTE,
754 Some(&texture.rgba),
755 );
756 Ok(id)
757 }
758
644 759 unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
645 760 gl.enable_vertex_attrib_array(index);
646 761 gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
@@ -715,6 +830,7 @@ uniform sampler2D u_shiny;
715 830 uniform bool u_albedo_enabled;
716 831 uniform bool u_shiny_enabled;
717 832 uniform float u_specular;
833 uniform float u_cutoff;
718 834 uniform vec3 u_eye;
719 835 in vec2 v_uv;
720 836 in vec3 v_world;
@@ -725,7 +841,7 @@ void main() {
725 841 float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
726 842 vec4 color=u_color;
727 843 if(u_albedo_enabled) color*=texture(u_albedo,v_uv);
728 if(color.a<0.5) discard;
844 if(color.a<u_cutoff) discard;
729 845 float strength=u_specular;
730 846 if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));
731 847 float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;
@@ -833,6 +949,8 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
833 949 specular: 0.,
834 950 uvs: vec![[0.; 2]; 6],
835 951 equipment: Vec::new(),
952 surfaces: Vec::new(),
953 cutoff: EQUIPMENT_CUTOFF,
836 954 };
837 955 let mut cache = GpuCache::default();
838 956 let read = || {
@@ -943,7 +1061,59 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
943 1061 "shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}"
944 1062 ));
945 1063 }
946 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}");
1064 // Body with two materials: each surface uses its own texture × factor.
1065 let cyan = Arc::new(crate::equipment::Texture {
1066 content_hash: 5,
1067 width: 1,
1068 height: 1,
1069 rgba: vec![0, 255, 255, 255],
1070 });
1071 let mut body = make_frame(vec![3, 4, 5, 0, 1, 2], 12);
1072 body.surfaces = vec![
1073 SurfaceDraw {
1074 first: 0,
1075 count: 3,
1076 color: [1., 0., 0., 1.],
1077 albedo: None,
1078 cutoff: 0.001,
1079 },
1080 SurfaceDraw {
1081 first: 3,
1082 count: 3,
1083 color: [1., 0.5, 1., 1.],
1084 albedo: Some(cyan.clone()),
1085 cutoff: 0.001,
1086 },
1087 ];
1088 cache.pending = Some(body.clone());
1089 cache.paint(gl, &info, true);
1090 let surface_front = read();
1091 if surface_front[0] > 2
1092 || !(120..=136).contains(&surface_front[1])
1093 || surface_front[2] < 250
1094 {
1095 return Err(format!(
1096 "voorste materiaal niet als texture × factor getekend: {surface_front:?}"
1097 ));
1098 }
1099 body.generation = 13;
1100 body.surfaces[1].albedo = None;
1101 body.surfaces[0].albedo = Some(cyan);
1102 body.surfaces.swap(0, 1);
1103 body.surfaces[0].first = 0;
1104 body.surfaces[1].first = 3;
1105 body.indices = vec![0, 1, 2, 3, 4, 5];
1106 cache.pending = Some(body);
1107 cache.paint(gl, &info, true);
1108 let surface_swap = read();
1109 // Front triangle is now the factor-only material [1, 0.5, 1].
1110 if surface_swap[0] < 250 || !(120..=136).contains(&surface_swap[1]) || surface_swap[2] < 250
1111 {
1112 return Err(format!(
1113 "materiaal per primitive verwisseld: {surface_swap:?}"
1114 ));
1115 }
1116 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}");
947 1117 Ok(())
948 1118 })();
949 1119 cache.destroy(gl);
@@ -953,3 +1123,45 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
953 1123 gl.delete_renderbuffer(depth);
954 1124 result
955 1125 }
1126
1127 #[cfg(test)]
1128 mod tests {
1129 use super::*;
1130
1131 /// Regression (Tim t1007u): the live body used one merged color and no
1132 /// texture. Every glTF material must reach the GPU frame as its own range
1133 /// with its own base color factor and external albedo texture.
1134 #[test]
1135 fn live_body_frame_keeps_every_material_texture_and_factor() {
1136 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1137 .join("tests/fixtures/bodies/multi-material/body.gltf");
1138 let body = crate::preview::load_body_file(&path).unwrap();
1139 let frame = body_frame(
1140 &body,
1141 Vec::new(),
1142 crate::preview::identity(),
1143 [0.; 3],
1144 Vec::new(),
1145 );
1146 assert_eq!(frame.surfaces.len(), 3);
1147 assert_eq!(frame.uvs.len(), frame.positions.len());
1148 let ranges: Vec<_> = frame.surfaces.iter().map(|s| (s.first, s.count)).collect();
1149 assert_eq!(ranges, [(0, 6), (6, 6), (12, 6)]);
1150 assert_eq!(frame.surfaces[0].color, [0.2, 0.4, 0.9, 1.0]);
1151 assert!(frame.surfaces[0].albedo.is_none());
1152 assert_eq!(frame.surfaces[1].color, [1.; 4]);
1153 assert_eq!(
1154 &frame.surfaces[1].albedo.as_ref().unwrap().rgba[..4],
1155 [0, 200, 0, 255]
1156 );
1157 assert_eq!(frame.surfaces[2].color, [0.5, 1.0, 1.0, 1.0]);
1158 assert_eq!(
1159 &frame.surfaces[2].albedo.as_ref().unwrap().rgba[..4],
1160 [200, 200, 0, 255]
1161 );
1162 // A different texture must re-upload: it is part of the generation.
1163 let mut changed = body.clone();
1164 std::sync::Arc::make_mut(changed.surfaces[1].albedo.as_mut().unwrap()).content_hash ^= 1;
1165 assert_ne!(character_generation(&body), character_generation(&changed));
1166 }
1167 }
@@ -116,7 +116,7 @@ impl Viewport {
116 animated,
116 animated,
117 egui::SelectableLabel::new(
117 egui::SelectableLabel::new(
118 controls.playing && animated,
118 controls.playing && animated,
119 if controls.playing { "Pauze" } else { "Speel" },
119 crate::i18n::t(if controls.playing { "Pauze" } else { "Speel" }),
120 ),
120 ),
121 )
121 )
122 .clicked()
122 .clicked()
@@ -173,7 +173,7 @@ impl Viewport {
173 ui.painter().text(
173 ui.painter().text(
174 rect.center(),
174 rect.center(),
175 egui::Align2::CENTER_CENTER,
175 egui::Align2::CENTER_CENTER,
176 "Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture.",
176 crate::i18n::t("Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture."),
177 egui::FontId::proportional(14.0),
177 egui::FontId::proportional(14.0),
178 egui::Color32::from_rgb(210, 210, 210),
178 egui::Color32::from_rgb(210, 210, 210),
179 );
179 );
@@ -244,24 +244,7 @@ impl Viewport {
244 }
244 }
245 }
245 }
246 {
246 {
247 cache.pending = Some(PendingFrame {
247 cache.pending = Some(body_frame(character, bones, view_proj, eye, equipment));
248 generation: character_generation(character),
249 positions: character.positions.clone(),
250 normals: character.normals.clone(),
251 joints: character.joints.clone(),
252 weights: character.weights.clone(),
253 indices: character.indices.clone(),
254 bones,
255 view_proj,
256 color: character.base_color,
257 model: crate::preview::identity(),
258 albedo: None,
259 shiny: None,
260 specular: 0.0,
261 eye,
262 equipment,
263 uvs: Vec::new(),
264 });
265 }
248 }
266 drop(cache);
249 drop(cache);
267 let cache = Arc::clone(&self.cache);
250 let cache = Arc::clone(&self.cache);
@@ -280,7 +263,7 @@ impl Viewport {
280 ui.painter().text(
263 ui.painter().text(
281 rect.left_top() + egui::vec2(5., 5.),
264 rect.left_top() + egui::vec2(5., 5.),
282 egui::Align2::LEFT_TOP,
265 egui::Align2::LEFT_TOP,
283 "Vast renderkader",
266 crate::i18n::t("Vast renderkader"),
284 egui::FontId::proportional(12.),
267 egui::FontId::proportional(12.),
285 egui::Color32::YELLOW,
268 egui::Color32::YELLOW,
286 );
269 );
@@ -300,7 +283,11 @@ impl Viewport {
300 ui.painter().text(
283 ui.painter().text(
301 rect.left_top() + egui::vec2(12., 12.),
284 rect.left_top() + egui::vec2(12., 12.),
302 egui::Align2::LEFT_TOP,
285 egui::Align2::LEFT_TOP,
303 diagnostics.join("\n"),
286 diagnostics
287 .iter()
288 .map(crate::i18n::t)
289 .collect::<Vec<_>>()
290 .join("\n"),
304 egui::FontId::proportional(14.),
291 egui::FontId::proportional(14.),
305 egui::Color32::LIGHT_RED,
292 egui::Color32::LIGHT_RED,
306 );
293 );
@@ -308,6 +295,52 @@ impl Viewport {
308 }
295 }
309}
296}
310
297
298/// Live equipment keeps its alpha-cutout threshold of 0.5.
299const EQUIPMENT_CUTOFF: f32 = 0.5;
300
301/// The body as one pending frame: every material range (glTF primitive) is
302/// drawn with its own base color factor × albedo texture and cutoff, exactly
303/// as production does; nothing is merged into a single color.
304fn body_frame(
305 character: &PreviewCharacter,
306 bones: Vec<crate::preview::Mat4>,
307 view_proj: crate::preview::Mat4,
308 eye: [f32; 3],
309 equipment: Vec<PendingFrame>,
310) -> PendingFrame {
311 let surfaces = character
312 .surfaces
313 .iter()
314 .map(|s| SurfaceDraw {
315 first: s.first,
316 count: s.count,
317 color: s.color,
318 albedo: s.albedo.clone(),
319 cutoff: s.cutoff,
320 })
321 .collect();
322 PendingFrame {
323 generation: character_generation(character),
324 positions: character.positions.clone(),
325 normals: character.normals.clone(),
326 joints: character.joints.clone(),
327 weights: character.weights.clone(),
328 indices: character.indices.clone(),
329 bones,
330 view_proj,
331 color: character.base_color,
332 model: crate::preview::identity(),
333 albedo: None,
334 shiny: None,
335 specular: 0.0,
336 eye,
337 equipment,
338 uvs: character.uvs.clone(),
339 surfaces,
340 cutoff: 0.001,
341 }
342}
343
311fn character_generation(character: &PreviewCharacter) -> u64 {
344fn character_generation(character: &PreviewCharacter) -> u64 {
312 use std::hash::{Hash, Hasher};
345 use std::hash::{Hash, Hasher};
313 let mut hash = std::collections::hash_map::DefaultHasher::new();
346 let mut hash = std::collections::hash_map::DefaultHasher::new();
@@ -322,6 +355,15 @@ fn character_generation(character: &PreviewCharacter) -> u64 {
322 }
355 }
323 character.joints.hash(&mut hash);
356 character.joints.hash(&mut hash);
324 character.indices.hash(&mut hash);
357 character.indices.hash(&mut hash);
358 for uv in &character.uvs {
359 uv.map(f32::to_bits).hash(&mut hash);
360 }
361 for s in &character.surfaces {
362 (s.first, s.count).hash(&mut hash);
363 if let Some(t) = &s.albedo {
364 (t.content_hash, t.width, t.height).hash(&mut hash);
365 }
366 }
325 hash.finish()
367 hash.finish()
326}
368}
327
369
@@ -343,6 +385,21 @@ struct PendingFrame {
343 specular: f32,
385 specular: f32,
344 equipment: Vec<PendingFrame>,
386 equipment: Vec<PendingFrame>,
345 uvs: Vec<[f32; 2]>,
387 uvs: Vec<[f32; 2]>,
388 /// Per-material index ranges (glTF primitives); empty = one draw of all
389 /// indices with `color`/`albedo`. Each has its own texture × factor.
390 surfaces: Vec<SurfaceDraw>,
391 cutoff: f32,
392}
393
394/// One material range of the body: indices `first..first+count`, base color
395/// factor, optional albedo texture and alpha cutoff (as loaded, unchanged).
396#[derive(Clone)]
397struct SurfaceDraw {
398 first: usize,
399 count: usize,
400 color: [f32; 4],
401 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
402 cutoff: f32,
346}
403}
347
404
348impl PendingFrame {
405impl PendingFrame {
@@ -390,6 +447,8 @@ impl PendingFrame {
390 shiny: layer.shiny,
447 shiny: layer.shiny,
391 specular: layer.specular,
448 specular: layer.specular,
392 equipment: Vec::new(),
449 equipment: Vec::new(),
450 surfaces: Vec::new(),
451 cutoff: EQUIPMENT_CUTOFF,
393 }
452 }
394 }
453 }
395}
454}
@@ -405,6 +464,8 @@ struct GpuCache {
405 pending: Option<PendingFrame>,
464 pending: Option<PendingFrame>,
406 error: Option<String>,
465 error: Option<String>,
407 textures: [Option<glow::Texture>; 2],
466 textures: [Option<glow::Texture>; 2],
467 /// One albedo texture per body surface (same order as `surfaces`).
468 surface_textures: Vec<Option<glow::Texture>>,
408 layers: Vec<GpuCache>,
469 layers: Vec<GpuCache>,
409 texture_cache: crate::equipment::TextureCache,
470 texture_cache: crate::equipment::TextureCache,
410}
471}
@@ -502,7 +563,37 @@ impl GpuCache {
502 let bones_loc = gl.get_uniform_location(program, "u_bones");
563 let bones_loc = gl.get_uniform_location(program, "u_bones");
503 gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
564 gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
504 gl.bind_vertex_array(self.vao);
565 gl.bind_vertex_array(self.vao);
505 gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
566 let cutoff = gl.get_uniform_location(program, "u_cutoff");
567 if frame.surfaces.is_empty() {
568 gl.uniform_1_f32(cutoff.as_ref(), frame.cutoff);
569 gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
570 } else {
571 let albedo_enabled = gl.get_uniform_location(program, "u_albedo_enabled");
572 for (index, surface) in frame.surfaces.iter().enumerate() {
573 let end = (surface.first + surface.count).min(self.index_count as usize);
574 if surface.first >= end {
575 continue;
576 }
577 let texture = self.surface_textures.get(index).copied().flatten();
578 gl.active_texture(glow::TEXTURE0);
579 gl.bind_texture(glow::TEXTURE_2D, texture);
580 gl.uniform_1_i32(albedo_enabled.as_ref(), i32::from(texture.is_some()));
581 gl.uniform_4_f32(
582 color.as_ref(),
583 surface.color[0],
584 surface.color[1],
585 surface.color[2],
586 surface.color[3],
587 );
588 gl.uniform_1_f32(cutoff.as_ref(), surface.cutoff);
589 gl.draw_elements(
590 glow::TRIANGLES,
591 (end - surface.first) as i32,
592 glow::UNSIGNED_INT,
593 (surface.first * 4) as i32,
594 );
595 }
596 }
506 gl.bind_vertex_array(None);
597 gl.bind_vertex_array(None);
507 while self.layers.len() > frame.equipment.len() {
598 while self.layers.len() > frame.equipment.len() {
508 if let Some(mut layer) = self.layers.pop() {
599 if let Some(mut layer) = self.layers.pop() {
@@ -527,11 +618,16 @@ impl GpuCache {
527 for layer in &mut self.layers {
618 for layer in &mut self.layers {
528 layer.destroy(gl);
619 layer.destroy(gl);
529 }
620 }
530 for texture in &mut self.textures {
621 for texture in self
622 .textures
623 .iter_mut()
624 .chain(self.surface_textures.iter_mut())
625 {
531 if let Some(t) = texture.take() {
626 if let Some(t) = texture.take() {
532 gl.delete_texture(t)
627 gl.delete_texture(t)
533 }
628 }
534 }
629 }
630 self.surface_textures.clear();
535 if let Some(v) = self.vao.take() {
631 if let Some(v) = self.vao.take() {
536 gl.delete_vertex_array(v)
632 gl.delete_vertex_array(v)
537 }
633 }
@@ -606,41 +702,60 @@ impl GpuCache {
606 gl.delete_texture(old);
702 gl.delete_texture(old);
607 }
703 }
608 if let Some(texture) = texture {
704 if let Some(texture) = texture {
609 let id = gl.create_texture().map_err(|e| e.to_string())?;
610 self.textures[unit] = Some(id);
611 gl.active_texture(glow::TEXTURE0 + unit as u32);
705 gl.active_texture(glow::TEXTURE0 + unit as u32);
612 gl.bind_texture(glow::TEXTURE_2D, Some(id));
706 self.textures[unit] = Some(upload_texture(gl, texture)?);
613 gl.tex_parameter_i32(
614 glow::TEXTURE_2D,
615 glow::TEXTURE_MIN_FILTER,
616 glow::NEAREST as i32,
617 );
618 gl.tex_parameter_i32(
619 glow::TEXTURE_2D,
620 glow::TEXTURE_MAG_FILTER,
621 glow::NEAREST as i32,
622 );
623 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
624 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
625 gl.tex_image_2d(
626 glow::TEXTURE_2D,
627 0,
628 glow::RGBA8 as i32,
629 texture.width as i32,
630 texture.height as i32,
631 0,
632 glow::RGBA,
633 glow::UNSIGNED_BYTE,
634 Some(&texture.rgba),
635 );
636 }
707 }
637 }
708 }
709 for old in self.surface_textures.drain(..).flatten() {
710 gl.delete_texture(old);
711 }
712 gl.active_texture(glow::TEXTURE0);
713 for surface in &frame.surfaces {
714 let id = match &surface.albedo {
715 Some(texture) => Some(upload_texture(gl, texture)?),
716 None => None,
717 };
718 self.surface_textures.push(id);
719 }
638 self.uploaded = frame.generation;
720 self.uploaded = frame.generation;
639 self.index_count = frame.indices.len() as i32;
721 self.index_count = frame.indices.len() as i32;
640 Ok(())
722 Ok(())
641 }
723 }
642}
724}
643
725
726/// Nearest + REPEAT RGBA8 texture, bound to the active unit.
727unsafe fn upload_texture(
728 gl: &glow::Context,
729 texture: &crate::equipment::Texture,
730) -> Result<glow::Texture, String> {
731 let id = gl.create_texture().map_err(|e| e.to_string())?;
732 gl.bind_texture(glow::TEXTURE_2D, Some(id));
733 gl.tex_parameter_i32(
734 glow::TEXTURE_2D,
735 glow::TEXTURE_MIN_FILTER,
736 glow::NEAREST as i32,
737 );
738 gl.tex_parameter_i32(
739 glow::TEXTURE_2D,
740 glow::TEXTURE_MAG_FILTER,
741 glow::NEAREST as i32,
742 );
743 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
744 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
745 gl.tex_image_2d(
746 glow::TEXTURE_2D,
747 0,
748 glow::RGBA8 as i32,
749 texture.width as i32,
750 texture.height as i32,
751 0,
752 glow::RGBA,
753 glow::UNSIGNED_BYTE,
754 Some(&texture.rgba),
755 );
756 Ok(id)
757}
758
644unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
759unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
645 gl.enable_vertex_attrib_array(index);
760 gl.enable_vertex_attrib_array(index);
646 gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
761 gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
@@ -715,6 +830,7 @@ uniform sampler2D u_shiny;
715uniform bool u_albedo_enabled;
830uniform bool u_albedo_enabled;
716uniform bool u_shiny_enabled;
831uniform bool u_shiny_enabled;
717uniform float u_specular;
832uniform float u_specular;
833uniform float u_cutoff;
718uniform vec3 u_eye;
834uniform vec3 u_eye;
719in vec2 v_uv;
835in vec2 v_uv;
720in vec3 v_world;
836in vec3 v_world;
@@ -725,7 +841,7 @@ void main() {
725 float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
841 float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
726 vec4 color=u_color;
842 vec4 color=u_color;
727 if(u_albedo_enabled) color*=texture(u_albedo,v_uv);
843 if(u_albedo_enabled) color*=texture(u_albedo,v_uv);
728 if(color.a<0.5) discard;
844 if(color.a<u_cutoff) discard;
729 float strength=u_specular;
845 float strength=u_specular;
730 if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));
846 if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));
731 float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;
847 float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;
@@ -833,6 +949,8 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
833 specular: 0.,
949 specular: 0.,
834 uvs: vec![[0.; 2]; 6],
950 uvs: vec![[0.; 2]; 6],
835 equipment: Vec::new(),
951 equipment: Vec::new(),
952 surfaces: Vec::new(),
953 cutoff: EQUIPMENT_CUTOFF,
836 };
954 };
837 let mut cache = GpuCache::default();
955 let mut cache = GpuCache::default();
838 let read = || {
956 let read = || {
@@ -943,7 +1061,59 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
943 "shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}"
1061 "shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}"
944 ));
1062 ));
945 }
1063 }
946 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}");
1064 // Body with two materials: each surface uses its own texture × factor.
1065 let cyan = Arc::new(crate::equipment::Texture {
1066 content_hash: 5,
1067 width: 1,
1068 height: 1,
1069 rgba: vec![0, 255, 255, 255],
1070 });
1071 let mut body = make_frame(vec![3, 4, 5, 0, 1, 2], 12);
1072 body.surfaces = vec![
1073 SurfaceDraw {
1074 first: 0,
1075 count: 3,
1076 color: [1., 0., 0., 1.],
1077 albedo: None,
1078 cutoff: 0.001,
1079 },
1080 SurfaceDraw {
1081 first: 3,
1082 count: 3,
1083 color: [1., 0.5, 1., 1.],
1084 albedo: Some(cyan.clone()),
1085 cutoff: 0.001,
1086 },
1087 ];
1088 cache.pending = Some(body.clone());
1089 cache.paint(gl, &info, true);
1090 let surface_front = read();
1091 if surface_front[0] > 2
1092 || !(120..=136).contains(&surface_front[1])
1093 || surface_front[2] < 250
1094 {
1095 return Err(format!(
1096 "voorste materiaal niet als texture × factor getekend: {surface_front:?}"
1097 ));
1098 }
1099 body.generation = 13;
1100 body.surfaces[1].albedo = None;
1101 body.surfaces[0].albedo = Some(cyan);
1102 body.surfaces.swap(0, 1);
1103 body.surfaces[0].first = 0;
1104 body.surfaces[1].first = 3;
1105 body.indices = vec![0, 1, 2, 3, 4, 5];
1106 cache.pending = Some(body);
1107 cache.paint(gl, &info, true);
1108 let surface_swap = read();
1109 // Front triangle is now the factor-only material [1, 0.5, 1].
1110 if surface_swap[0] < 250 || !(120..=136).contains(&surface_swap[1]) || surface_swap[2] < 250
1111 {
1112 return Err(format!(
1113 "materiaal per primitive verwisseld: {surface_swap:?}"
1114 ));
1115 }
1116 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}");
947 Ok(())
1117 Ok(())
948 })();
1118 })();
949 cache.destroy(gl);
1119 cache.destroy(gl);
@@ -953,3 +1123,45 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
953 gl.delete_renderbuffer(depth);
1123 gl.delete_renderbuffer(depth);
954 result
1124 result
955}
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 /// Regression (Tim t1007u): the live body used one merged color and no
1132 /// texture. Every glTF material must reach the GPU frame as its own range
1133 /// with its own base color factor and external albedo texture.
1134 #[test]
1135 fn live_body_frame_keeps_every_material_texture_and_factor() {
1136 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1137 .join("tests/fixtures/bodies/multi-material/body.gltf");
1138 let body = crate::preview::load_body_file(&path).unwrap();
1139 let frame = body_frame(
1140 &body,
1141 Vec::new(),
1142 crate::preview::identity(),
1143 [0.; 3],
1144 Vec::new(),
1145 );
1146 assert_eq!(frame.surfaces.len(), 3);
1147 assert_eq!(frame.uvs.len(), frame.positions.len());
1148 let ranges: Vec<_> = frame.surfaces.iter().map(|s| (s.first, s.count)).collect();
1149 assert_eq!(ranges, [(0, 6), (6, 6), (12, 6)]);
1150 assert_eq!(frame.surfaces[0].color, [0.2, 0.4, 0.9, 1.0]);
1151 assert!(frame.surfaces[0].albedo.is_none());
1152 assert_eq!(frame.surfaces[1].color, [1.; 4]);
1153 assert_eq!(
1154 &frame.surfaces[1].albedo.as_ref().unwrap().rgba[..4],
1155 [0, 200, 0, 255]
1156 );
1157 assert_eq!(frame.surfaces[2].color, [0.5, 1.0, 1.0, 1.0]);
1158 assert_eq!(
1159 &frame.surfaces[2].albedo.as_ref().unwrap().rgba[..4],
1160 [200, 200, 0, 255]
1161 );
1162 // A different texture must re-upload: it is part of the generation.
1163 let mut changed = body.clone();
1164 std::sync::Arc::make_mut(changed.surfaces[1].albedo.as_mut().unwrap()).content_hash ^= 1;
1165 assert_ne!(character_generation(&body), character_generation(&changed));
1166 }
1167}
tests/fixtures/bodies/multi-material/body.bin added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/bodies/multi-material/body.gltf added +450 −0
@@ -0,0 +1,450 @@
1 {
2 "asset": {
3 "version": "2.0",
4 "generator": "paperdoll generate_multi_material_body.py (CC0)"
5 },
6 "extras": {
7 "forward": "+Z"
8 },
9 "scene": 0,
10 "scenes": [
11 {
12 "nodes": [
13 0,
14 1
15 ]
16 }
17 ],
18 "nodes": [
19 {
20 "name": "Root"
21 },
22 {
23 "name": "Body",
24 "mesh": 0,
25 "skin": 0
26 }
27 ],
28 "skins": [
29 {
30 "joints": [
31 0
32 ],
33 "inverseBindMatrices": 18
34 }
35 ],
36 "meshes": [
37 {
38 "name": "Body",
39 "primitives": [
40 {
41 "attributes": {
42 "POSITION": 0,
43 "NORMAL": 1,
44 "TEXCOORD_0": 2,
45 "JOINTS_0": 3,
46 "WEIGHTS_0": 4
47 },
48 "indices": 5,
49 "material": 0
50 },
51 {
52 "attributes": {
53 "POSITION": 6,
54 "NORMAL": 7,
55 "TEXCOORD_0": 8,
56 "JOINTS_0": 9,
57 "WEIGHTS_0": 10
58 },
59 "indices": 11,
60 "material": 1
61 },
62 {
63 "attributes": {
64 "POSITION": 12,
65 "NORMAL": 13,
66 "TEXCOORD_0": 14,
67 "JOINTS_0": 15,
68 "WEIGHTS_0": 16
69 },
70 "indices": 17,
71 "material": 2
72 }
73 ]
74 }
75 ],
76 "materials": [
77 {
78 "name": "FactorOnly",
79 "pbrMetallicRoughness": {
80 "baseColorFactor": [
81 0.2,
82 0.4,
83 0.9,
84 1.0
85 ]
86 }
87 },
88 {
89 "name": "Green",
90 "pbrMetallicRoughness": {
91 "baseColorTexture": {
92 "index": 0
93 }
94 }
95 },
96 {
97 "name": "YellowTimesFactor",
98 "pbrMetallicRoughness": {
99 "baseColorTexture": {
100 "index": 1
101 },
102 "baseColorFactor": [
103 0.5,
104 1.0,
105 1.0,
106 1.0
107 ]
108 }
109 }
110 ],
111 "textures": [
112 {
113 "sampler": 0,
114 "source": 0
115 },
116 {
117 "sampler": 0,
118 "source": 1
119 }
120 ],
121 "samplers": [
122 {
123 "magFilter": 9728,
124 "minFilter": 9728
125 }
126 ],
127 "images": [
128 {
129 "uri": "textures/green_baseColor.png"
130 },
131 {
132 "uri": "textures/yellow_baseColor.png"
133 }
134 ],
135 "animations": [
136 {
137 "name": "idle",
138 "samplers": [
139 {
140 "input": 19,
141 "output": 20
142 }
143 ],
144 "channels": [
145 {
146 "sampler": 0,
147 "target": {
148 "node": 0,
149 "path": "rotation"
150 }
151 }
152 ]
153 }
154 ],
155 "buffers": [
156 {
157 "uri": "body.bin",
158 "byteLength": 812
159 }
160 ],
161 "bufferViews": [
162 {
163 "buffer": 0,
164 "byteOffset": 0,
165 "byteLength": 48,
166 "target": 34962
167 },
168 {
169 "buffer": 0,
170 "byteOffset": 48,
171 "byteLength": 48,
172 "target": 34962
173 },
174 {
175 "buffer": 0,
176 "byteOffset": 96,
177 "byteLength": 32,
178 "target": 34962
179 },
180 {
181 "buffer": 0,
182 "byteOffset": 128,
183 "byteLength": 32,
184 "target": 34962
185 },
186 {
187 "buffer": 0,
188 "byteOffset": 160,
189 "byteLength": 64,
190 "target": 34962
191 },
192 {
193 "buffer": 0,
194 "byteOffset": 224,
195 "byteLength": 12,
196 "target": 34963
197 },
198 {
199 "buffer": 0,
200 "byteOffset": 236,
201 "byteLength": 48,
202 "target": 34962
203 },
204 {
205 "buffer": 0,
206 "byteOffset": 284,
207 "byteLength": 48,
208 "target": 34962
209 },
210 {
211 "buffer": 0,
212 "byteOffset": 332,
213 "byteLength": 32,
214 "target": 34962
215 },
216 {
217 "buffer": 0,
218 "byteOffset": 364,
219 "byteLength": 32,
220 "target": 34962
221 },
222 {
223 "buffer": 0,
224 "byteOffset": 396,
225 "byteLength": 64,
226 "target": 34962
227 },
228 {
229 "buffer": 0,
230 "byteOffset": 460,
231 "byteLength": 12,
232 "target": 34963
233 },
234 {
235 "buffer": 0,
236 "byteOffset": 472,
237 "byteLength": 48,
238 "target": 34962
239 },
240 {
241 "buffer": 0,
242 "byteOffset": 520,
243 "byteLength": 48,
244 "target": 34962
245 },
246 {
247 "buffer": 0,
248 "byteOffset": 568,
249 "byteLength": 32,
250 "target": 34962
251 },
252 {
253 "buffer": 0,
254 "byteOffset": 600,
255 "byteLength": 32,
256 "target": 34962
257 },
258 {
259 "buffer": 0,
260 "byteOffset": 632,
261 "byteLength": 64,
262 "target": 34962
263 },
264 {
265 "buffer": 0,
266 "byteOffset": 696,
267 "byteLength": 12,
268 "target": 34963
269 },
270 {
271 "buffer": 0,
272 "byteOffset": 708,
273 "byteLength": 64
274 },
275 {
276 "buffer": 0,
277 "byteOffset": 772,
278 "byteLength": 8
279 },
280 {
281 "buffer": 0,
282 "byteOffset": 780,
283 "byteLength": 32
284 }
285 ],
286 "accessors": [
287 {
288 "bufferView": 0,
289 "componentType": 5126,
290 "count": 4,
291 "type": "VEC3",
292 "min": [
293 -0.75,
294 0.0,
295 0.0
296 ],
297 "max": [
298 -0.25,
299 1.6,
300 0.0
301 ]
302 },
303 {
304 "bufferView": 1,
305 "componentType": 5126,
306 "count": 4,
307 "type": "VEC3"
308 },
309 {
310 "bufferView": 2,
311 "componentType": 5126,
312 "count": 4,
313 "type": "VEC2"
314 },
315 {
316 "bufferView": 3,
317 "componentType": 5123,
318 "count": 4,
319 "type": "VEC4"
320 },
321 {
322 "bufferView": 4,
323 "componentType": 5126,
324 "count": 4,
325 "type": "VEC4"
326 },
327 {
328 "bufferView": 5,
329 "componentType": 5123,
330 "count": 6,
331 "type": "SCALAR"
332 },
333 {
334 "bufferView": 6,
335 "componentType": 5126,
336 "count": 4,
337 "type": "VEC3",
338 "min": [
339 -0.25,
340 0.0,
341 0.0
342 ],
343 "max": [
344 0.25,
345 1.6,
346 0.0
347 ]
348 },
349 {
350 "bufferView": 7,
351 "componentType": 5126,
352 "count": 4,
353 "type": "VEC3"
354 },
355 {
356 "bufferView": 8,
357 "componentType": 5126,
358 "count": 4,
359 "type": "VEC2"
360 },
361 {
362 "bufferView": 9,
363 "componentType": 5123,
364 "count": 4,
365 "type": "VEC4"
366 },
367 {
368 "bufferView": 10,
369 "componentType": 5126,
370 "count": 4,
371 "type": "VEC4"
372 },
373 {
374 "bufferView": 11,
375 "componentType": 5123,
376 "count": 6,
377 "type": "SCALAR"
378 },
379 {
380 "bufferView": 12,
381 "componentType": 5126,
382 "count": 4,
383 "type": "VEC3",
384 "min": [
385 0.25,
386 0.0,
387 0.0
388 ],
389 "max": [
390 0.75,
391 1.6,
392 0.0
393 ]
394 },
395 {
396 "bufferView": 13,
397 "componentType": 5126,
398 "count": 4,
399 "type": "VEC3"
400 },
401 {
402 "bufferView": 14,
403 "componentType": 5126,
404 "count": 4,
405 "type": "VEC2"
406 },
407 {
408 "bufferView": 15,
409 "componentType": 5123,
410 "count": 4,
411 "type": "VEC4"
412 },
413 {
414 "bufferView": 16,
415 "componentType": 5126,
416 "count": 4,
417 "type": "VEC4"
418 },
419 {
420 "bufferView": 17,
421 "componentType": 5123,
422 "count": 6,
423 "type": "SCALAR"
424 },
425 {
426 "bufferView": 18,
427 "componentType": 5126,
428 "count": 1,
429 "type": "MAT4"
430 },
431 {
432 "bufferView": 19,
433 "componentType": 5126,
434 "count": 2,
435 "type": "SCALAR",
436 "min": [
437 0.0
438 ],
439 "max": [
440 1.0
441 ]
442 },
443 {
444 "bufferView": 20,
445 "componentType": 5126,
446 "count": 2,
447 "type": "VEC4"
448 }
449 ]
450 }
@@ -0,0 +1,450 @@
1{
2 "asset": {
3 "version": "2.0",
4 "generator": "paperdoll generate_multi_material_body.py (CC0)"
5 },
6 "extras": {
7 "forward": "+Z"
8 },
9 "scene": 0,
10 "scenes": [
11 {
12 "nodes": [
13 0,
14 1
15 ]
16 }
17 ],
18 "nodes": [
19 {
20 "name": "Root"
21 },
22 {
23 "name": "Body",
24 "mesh": 0,
25 "skin": 0
26 }
27 ],
28 "skins": [
29 {
30 "joints": [
31 0
32 ],
33 "inverseBindMatrices": 18
34 }
35 ],
36 "meshes": [
37 {
38 "name": "Body",
39 "primitives": [
40 {
41 "attributes": {
42 "POSITION": 0,
43 "NORMAL": 1,
44 "TEXCOORD_0": 2,
45 "JOINTS_0": 3,
46 "WEIGHTS_0": 4
47 },
48 "indices": 5,
49 "material": 0
50 },
51 {
52 "attributes": {
53 "POSITION": 6,
54 "NORMAL": 7,
55 "TEXCOORD_0": 8,
56 "JOINTS_0": 9,
57 "WEIGHTS_0": 10
58 },
59 "indices": 11,
60 "material": 1
61 },
62 {
63 "attributes": {
64 "POSITION": 12,
65 "NORMAL": 13,
66 "TEXCOORD_0": 14,
67 "JOINTS_0": 15,
68 "WEIGHTS_0": 16
69 },
70 "indices": 17,
71 "material": 2
72 }
73 ]
74 }
75 ],
76 "materials": [
77 {
78 "name": "FactorOnly",
79 "pbrMetallicRoughness": {
80 "baseColorFactor": [
81 0.2,
82 0.4,
83 0.9,
84 1.0
85 ]
86 }
87 },
88 {
89 "name": "Green",
90 "pbrMetallicRoughness": {
91 "baseColorTexture": {
92 "index": 0
93 }
94 }
95 },
96 {
97 "name": "YellowTimesFactor",
98 "pbrMetallicRoughness": {
99 "baseColorTexture": {
100 "index": 1
101 },
102 "baseColorFactor": [
103 0.5,
104 1.0,
105 1.0,
106 1.0
107 ]
108 }
109 }
110 ],
111 "textures": [
112 {
113 "sampler": 0,
114 "source": 0
115 },
116 {
117 "sampler": 0,
118 "source": 1
119 }
120 ],
121 "samplers": [
122 {
123 "magFilter": 9728,
124 "minFilter": 9728
125 }
126 ],
127 "images": [
128 {
129 "uri": "textures/green_baseColor.png"
130 },
131 {
132 "uri": "textures/yellow_baseColor.png"
133 }
134 ],
135 "animations": [
136 {
137 "name": "idle",
138 "samplers": [
139 {
140 "input": 19,
141 "output": 20
142 }
143 ],
144 "channels": [
145 {
146 "sampler": 0,
147 "target": {
148 "node": 0,
149 "path": "rotation"
150 }
151 }
152 ]
153 }
154 ],
155 "buffers": [
156 {
157 "uri": "body.bin",
158 "byteLength": 812
159 }
160 ],
161 "bufferViews": [
162 {
163 "buffer": 0,
164 "byteOffset": 0,
165 "byteLength": 48,
166 "target": 34962
167 },
168 {
169 "buffer": 0,
170 "byteOffset": 48,
171 "byteLength": 48,
172 "target": 34962
173 },
174 {
175 "buffer": 0,
176 "byteOffset": 96,
177 "byteLength": 32,
178 "target": 34962
179 },
180 {
181 "buffer": 0,
182 "byteOffset": 128,
183 "byteLength": 32,
184 "target": 34962
185 },
186 {
187 "buffer": 0,
188 "byteOffset": 160,
189 "byteLength": 64,
190 "target": 34962
191 },
192 {
193 "buffer": 0,
194 "byteOffset": 224,
195 "byteLength": 12,
196 "target": 34963
197 },
198 {
199 "buffer": 0,
200 "byteOffset": 236,
201 "byteLength": 48,
202 "target": 34962
203 },
204 {
205 "buffer": 0,
206 "byteOffset": 284,
207 "byteLength": 48,
208 "target": 34962
209 },
210 {
211 "buffer": 0,
212 "byteOffset": 332,
213 "byteLength": 32,
214 "target": 34962
215 },
216 {
217 "buffer": 0,
218 "byteOffset": 364,
219 "byteLength": 32,
220 "target": 34962
221 },
222 {
223 "buffer": 0,
224 "byteOffset": 396,
225 "byteLength": 64,
226 "target": 34962
227 },
228 {
229 "buffer": 0,
230 "byteOffset": 460,
231 "byteLength": 12,
232 "target": 34963
233 },
234 {
235 "buffer": 0,
236 "byteOffset": 472,
237 "byteLength": 48,
238 "target": 34962
239 },
240 {
241 "buffer": 0,
242 "byteOffset": 520,
243 "byteLength": 48,
244 "target": 34962
245 },
246 {
247 "buffer": 0,
248 "byteOffset": 568,
249 "byteLength": 32,
250 "target": 34962
251 },
252 {
253 "buffer": 0,
254 "byteOffset": 600,
255 "byteLength": 32,
256 "target": 34962
257 },
258 {
259 "buffer": 0,
260 "byteOffset": 632,
261 "byteLength": 64,
262 "target": 34962
263 },
264 {
265 "buffer": 0,
266 "byteOffset": 696,
267 "byteLength": 12,
268 "target": 34963
269 },
270 {
271 "buffer": 0,
272 "byteOffset": 708,
273 "byteLength": 64
274 },
275 {
276 "buffer": 0,
277 "byteOffset": 772,
278 "byteLength": 8
279 },
280 {
281 "buffer": 0,
282 "byteOffset": 780,
283 "byteLength": 32
284 }
285 ],
286 "accessors": [
287 {
288 "bufferView": 0,
289 "componentType": 5126,
290 "count": 4,
291 "type": "VEC3",
292 "min": [
293 -0.75,
294 0.0,
295 0.0
296 ],
297 "max": [
298 -0.25,
299 1.6,
300 0.0
301 ]
302 },
303 {
304 "bufferView": 1,
305 "componentType": 5126,
306 "count": 4,
307 "type": "VEC3"
308 },
309 {
310 "bufferView": 2,
311 "componentType": 5126,
312 "count": 4,
313 "type": "VEC2"
314 },
315 {
316 "bufferView": 3,
317 "componentType": 5123,
318 "count": 4,
319 "type": "VEC4"
320 },
321 {
322 "bufferView": 4,
323 "componentType": 5126,
324 "count": 4,
325 "type": "VEC4"
326 },
327 {
328 "bufferView": 5,
329 "componentType": 5123,
330 "count": 6,
331 "type": "SCALAR"
332 },
333 {
334 "bufferView": 6,
335 "componentType": 5126,
336 "count": 4,
337 "type": "VEC3",
338 "min": [
339 -0.25,
340 0.0,
341 0.0
342 ],
343 "max": [
344 0.25,
345 1.6,
346 0.0
347 ]
348 },
349 {
350 "bufferView": 7,
351 "componentType": 5126,
352 "count": 4,
353 "type": "VEC3"
354 },
355 {
356 "bufferView": 8,
357 "componentType": 5126,
358 "count": 4,
359 "type": "VEC2"
360 },
361 {
362 "bufferView": 9,
363 "componentType": 5123,
364 "count": 4,
365 "type": "VEC4"
366 },
367 {
368 "bufferView": 10,
369 "componentType": 5126,
370 "count": 4,
371 "type": "VEC4"
372 },
373 {
374 "bufferView": 11,
375 "componentType": 5123,
376 "count": 6,
377 "type": "SCALAR"
378 },
379 {
380 "bufferView": 12,
381 "componentType": 5126,
382 "count": 4,
383 "type": "VEC3",
384 "min": [
385 0.25,
386 0.0,
387 0.0
388 ],
389 "max": [
390 0.75,
391 1.6,
392 0.0
393 ]
394 },
395 {
396 "bufferView": 13,
397 "componentType": 5126,
398 "count": 4,
399 "type": "VEC3"
400 },
401 {
402 "bufferView": 14,
403 "componentType": 5126,
404 "count": 4,
405 "type": "VEC2"
406 },
407 {
408 "bufferView": 15,
409 "componentType": 5123,
410 "count": 4,
411 "type": "VEC4"
412 },
413 {
414 "bufferView": 16,
415 "componentType": 5126,
416 "count": 4,
417 "type": "VEC4"
418 },
419 {
420 "bufferView": 17,
421 "componentType": 5123,
422 "count": 6,
423 "type": "SCALAR"
424 },
425 {
426 "bufferView": 18,
427 "componentType": 5126,
428 "count": 1,
429 "type": "MAT4"
430 },
431 {
432 "bufferView": 19,
433 "componentType": 5126,
434 "count": 2,
435 "type": "SCALAR",
436 "min": [
437 0.0
438 ],
439 "max": [
440 1.0
441 ]
442 },
443 {
444 "bufferView": 20,
445 "componentType": 5126,
446 "count": 2,
447 "type": "VEC4"
448 }
449 ]
450}
tests/fixtures/bodies/multi-material/textures/green_baseColor.png added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/bodies/multi-material/textures/yellow_baseColor.png added Binary file +0 −0

The contents of this binary file are not shown.