Live preview draws every glTF material with its own texture × factor and cutoff (Tim t1007u); multi-material external-texture fixture, GPU probe and fixture example
@@ -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 /tmp221
cargo run --release --example resume_fixture --locked -- verify /tmp/resume221
cargo run --release --example resume_fixture --locked -- verify /tmp/resume222
# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set222
# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set223
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4223
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4224
# 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 diffuse226
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-material224
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set227
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set225
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4228
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4226
```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`.@@ -0,0 +1,222 @@
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
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
126
fn 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
}
@@ -0,0 +1,222 @@1
//! Real native GPU proof that every material of a glTF body is drawn with its2
//! 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 and6
//! 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 production9
//! 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
None67
}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
true95
}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
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
126
fn 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 = buf210
.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
}@@ -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 python32
"""CC0 regression fixture: a skinned glTF body with an external .bin, two3
external PNG textures and three materials (factor only, texture, texture x4
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, zlib9
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 blob33
while len(blob) % 4:34
blob += b"\0"35
view = {"buffer": 0, "byteOffset": len(blob), "byteLength": len(data)}36
if target:37
view["target"] = target38
views.append(view)39
blob += data40
return len(views) - 141
42
def accessor(view, ctype, count, typ, **kw):43
accessors.append({"bufferView": view, "componentType": ctype, "count": count, "type": typ, **kw})44
return len(accessors) - 145
46
for i, x0 in enumerate([-0.75, -0.25, 0.25]):47
x1 = x0 + 0.548
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()@@ -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.label1067
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 = attachment1819
let shiny = attachment1820
.shiny1820
.shiny@@ -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
diagnostics287
.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.299
const EQUIPMENT_CUTOFF: f32 = 0.5;300
301
/// The body as one pending frame: every material range (glTF primitive) is302
/// drawn with its own base color factor × albedo texture and cutoff, exactly303
/// 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 = character312
.surfaces313
.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
fn character_generation(character: &PreviewCharacter) -> u64 {344
fn 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 all389
/// 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 color395
/// 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
impl PendingFrame {405
impl 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 self622
.textures623
.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.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
unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {759
unsafe 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;715
uniform bool u_albedo_enabled;830
uniform bool u_albedo_enabled;716
uniform bool u_shiny_enabled;831
uniform bool u_shiny_enabled;717
uniform float u_specular;832
uniform float u_specular;833
uniform float u_cutoff;718
uniform vec3 u_eye;834
uniform vec3 u_eye;719
in vec2 v_uv;835
in vec2 v_uv;720
in vec3 v_world;836
in 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] > 21092
|| !(120..=136).contains(&surface_front[1])1093
|| surface_front[2] < 2501094
{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] < 2501111
{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
result1124
result955
}1125
}1126
1127
#[cfg(test)]1128
mod tests {1129
use super::*;1130
1131
/// Regression (Tim t1007u): the live body used one merged color and no1132
/// texture. Every glTF material must reach the GPU frame as its own range1133
/// 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
}The contents of this binary file are not shown.
@@ -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
115
]16
}17
],18
"nodes": [19
{20
"name": "Root"21
},22
{23
"name": "Body",24
"mesh": 0,25
"skin": 026
}27
],28
"skins": [29
{30
"joints": [31
032
],33
"inverseBindMatrices": 1834
}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": 447
},48
"indices": 5,49
"material": 050
},51
{52
"attributes": {53
"POSITION": 6,54
"NORMAL": 7,55
"TEXCOORD_0": 8,56
"JOINTS_0": 9,57
"WEIGHTS_0": 1058
},59
"indices": 11,60
"material": 161
},62
{63
"attributes": {64
"POSITION": 12,65
"NORMAL": 13,66
"TEXCOORD_0": 14,67
"JOINTS_0": 15,68
"WEIGHTS_0": 1669
},70
"indices": 17,71
"material": 272
}73
]74
}75
],76
"materials": [77
{78
"name": "FactorOnly",79
"pbrMetallicRoughness": {80
"baseColorFactor": [81
0.2,82
0.4,83
0.9,84
1.085
]86
}87
},88
{89
"name": "Green",90
"pbrMetallicRoughness": {91
"baseColorTexture": {92
"index": 093
}94
}95
},96
{97
"name": "YellowTimesFactor",98
"pbrMetallicRoughness": {99
"baseColorTexture": {100
"index": 1101
},102
"baseColorFactor": [103
0.5,104
1.0,105
1.0,106
1.0107
]108
}109
}110
],111
"textures": [112
{113
"sampler": 0,114
"source": 0115
},116
{117
"sampler": 0,118
"source": 1119
}120
],121
"samplers": [122
{123
"magFilter": 9728,124
"minFilter": 9728125
}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": 20142
}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": 812159
}160
],161
"bufferViews": [162
{163
"buffer": 0,164
"byteOffset": 0,165
"byteLength": 48,166
"target": 34962167
},168
{169
"buffer": 0,170
"byteOffset": 48,171
"byteLength": 48,172
"target": 34962173
},174
{175
"buffer": 0,176
"byteOffset": 96,177
"byteLength": 32,178
"target": 34962179
},180
{181
"buffer": 0,182
"byteOffset": 128,183
"byteLength": 32,184
"target": 34962185
},186
{187
"buffer": 0,188
"byteOffset": 160,189
"byteLength": 64,190
"target": 34962191
},192
{193
"buffer": 0,194
"byteOffset": 224,195
"byteLength": 12,196
"target": 34963197
},198
{199
"buffer": 0,200
"byteOffset": 236,201
"byteLength": 48,202
"target": 34962203
},204
{205
"buffer": 0,206
"byteOffset": 284,207
"byteLength": 48,208
"target": 34962209
},210
{211
"buffer": 0,212
"byteOffset": 332,213
"byteLength": 32,214
"target": 34962215
},216
{217
"buffer": 0,218
"byteOffset": 364,219
"byteLength": 32,220
"target": 34962221
},222
{223
"buffer": 0,224
"byteOffset": 396,225
"byteLength": 64,226
"target": 34962227
},228
{229
"buffer": 0,230
"byteOffset": 460,231
"byteLength": 12,232
"target": 34963233
},234
{235
"buffer": 0,236
"byteOffset": 472,237
"byteLength": 48,238
"target": 34962239
},240
{241
"buffer": 0,242
"byteOffset": 520,243
"byteLength": 48,244
"target": 34962245
},246
{247
"buffer": 0,248
"byteOffset": 568,249
"byteLength": 32,250
"target": 34962251
},252
{253
"buffer": 0,254
"byteOffset": 600,255
"byteLength": 32,256
"target": 34962257
},258
{259
"buffer": 0,260
"byteOffset": 632,261
"byteLength": 64,262
"target": 34962263
},264
{265
"buffer": 0,266
"byteOffset": 696,267
"byteLength": 12,268
"target": 34963269
},270
{271
"buffer": 0,272
"byteOffset": 708,273
"byteLength": 64274
},275
{276
"buffer": 0,277
"byteOffset": 772,278
"byteLength": 8279
},280
{281
"buffer": 0,282
"byteOffset": 780,283
"byteLength": 32284
}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.0296
],297
"max": [298
-0.25,299
1.6,300
0.0301
]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.0342
],343
"max": [344
0.25,345
1.6,346
0.0347
]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.0388
],389
"max": [390
0.75,391
1.6,392
0.0393
]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.0438
],439
"max": [440
1.0441
]442
},443
{444
"bufferView": 20,445
"componentType": 5126,446
"count": 2,447
"type": "VEC4"448
}449
]450
}The contents of this binary file are not shown.
The contents of this binary file are not shown.