BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview_gl.rs

preview_gl.rs

preview_gl.rs 47.2 KiB Download Raw
1//! OpenGL viewport for a skinned glTF/GLB or FBX body, a static (unskinned) OBJ/glTF/FBX body, and rigid textured equipment.
2//!
3//! The shaded mesh is a placement preview. It is not the unlit paperdoll
4//! diffuse/depth/normal export.
5
6use std::sync::{Arc, Mutex};
7
8use eframe::egui;
9use egui_glow::CallbackFn;
10use glow::HasContext;
11
12use crate::preview::{
13 clip_duration, eye_offset, mul, perspective, sample_skin_matrices, view_matrix,
14 PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,
15};
16
17/// egui itself requests no depth buffer. The custom 3D callback needs a real
18/// 24-bit attachment in the native Glow render target.
19pub fn native_options() -> eframe::NativeOptions {
20 eframe::NativeOptions {
21 depth_buffer: 24,
22 renderer: eframe::Renderer::Glow,
23 ..Default::default()
24 }
25}
26
27const MAX_BONES: usize = crate::preview::MAX_JOINTS;
28
29pub struct Viewport {
30 cache: Arc<Mutex<GpuCache>>,
31}
32
33impl Default for Viewport {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl Viewport {
40 pub fn new() -> Self {
41 Self {
42 cache: Arc::new(Mutex::new(GpuCache::default())),
43 }
44 }
45
46 pub fn destroy(&self, gl: &glow::Context) {
47 if let Ok(mut cache) = self.cache.lock() {
48 unsafe { cache.destroy(gl) }
49 }
50 }
51
52 pub fn show(
53 &self,
54 ui: &mut egui::Ui,
55 controls: &mut PreviewControls,
56 character: Option<&PreviewCharacter>,
57 presets: &[&crate::equipment::Preset],
58 ) {
59 self.show_framed(ui, controls, character, presets, None);
60 }
61
62 pub fn show_framed(
63 &self,
64 ui: &mut egui::Ui,
65 controls: &mut PreviewControls,
66 character: Option<&PreviewCharacter>,
67 presets: &[&crate::equipment::Preset],
68 framing: Option<(crate::project::CameraFraming, [u32; 2])>,
69 ) {
70 let framing = framing
71 .filter(|(c, r)| c.validate().is_ok() && r[0] > 0 && r[1] > 0)
72 .map(|(c, r)| {
73 (
74 character
75 .map(|body| crate::camera::resolve(body, c).framing)
76 .unwrap_or(c),
77 r,
78 )
79 });
80 ui.horizontal_wrapped(|ui| {
81 if let Some(character) = character {
82 if !character
83 .clips
84 .iter()
85 .any(|clip| clip.name == controls.clip_name)
86 {
87 if let Some(clip) = character.clips.first() {
88 controls.select_clip(&clip.name);
89 }
90 }
91 for clip in &character.clips {
92 if ui
93 .selectable_label(
94 controls.clip_name == clip.name,
95 crate::i18n::t(&clip.name),
96 )
97 .clicked()
98 {
99 controls.select_clip(&clip.name);
100 }
101 }
102 if character.clips.is_empty() {
103 let why = if character.is_static() {
104 "Geen animaties: statisch lichaam zonder skeleton (bindpose)"
105 } else {
106 "Geen animaties: bindpose"
107 };
108 ui.add_enabled(false, egui::Button::new(crate::i18n::t("Animatie")))
109 .on_disabled_hover_text(crate::i18n::t(why));
110 ui.label(crate::i18n::t(why));
111 }
112 }
113 let animated = character.is_some_and(|c| !c.clips.is_empty());
114 if ui
115 .add_enabled(
116 animated,
117 egui::SelectableLabel::new(
118 controls.playing && animated,
119 crate::i18n::t(if controls.playing { "Pauze" } else { "Speel" }),
120 ),
121 )
122 .clicked()
123 {
124 controls.playing = !controls.playing;
125 }
126 if ui.button(crate::i18n::t("Yaw −")).clicked() {
127 controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);
128 }
129 if ui.button(crate::i18n::t("Yaw +")).clicked() {
130 controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);
131 }
132 for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {
133 if ui
134 .button(crate::i18n::t(format!("pitch {pitch:.0}")))
135 .clicked()
136 {
137 controls.snap_pitch_row(index as i32);
138 }
139 }
140 ui.label(crate::i18n::t(format!(
141 "yaw {:.0}° / {} pitch {:.0}°",
142 controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg
143 )));
144 });
145 ui.label(
146 crate::i18n::t("Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (+Z, glTF-standaard). De knoppen zetten de vijf pitches en de twaalf yaws."),
147 );
148 let available = ui.available_rect_before_wrap();
149 let rect = egui::Rect::from_min_size(
150 available.min,
151 egui::vec2(available.width().max(240.0), available.height().max(240.0)),
152 );
153 let rect = if let Some((_, size)) = framing {
154 let fit = crate::render_job::fit_preview(size, [rect.width(), rect.height()]);
155 egui::Rect::from_min_size(
156 rect.min + egui::vec2(fit.offset[0], fit.offset[1]),
157 egui::vec2(fit.size[0], fit.size[1]),
158 )
159 } else {
160 rect
161 };
162 let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
163 if response.dragged() {
164 controls.drag(response.drag_delta().x, response.drag_delta().y);
165 }
166 let scroll = ui.input(|input| input.smooth_scroll_delta.y);
167 if framing.is_none() && response.hovered() && scroll.abs() > 0.0 {
168 controls.zoom(scroll);
169 }
170 let Some(character) = character else {
171 ui.painter()
172 .rect_filled(rect, 4.0, egui::Color32::from_rgb(18, 22, 28));
173 ui.painter().text(
174 rect.center(),
175 egui::Align2::CENTER_CENTER,
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),
178 egui::Color32::from_rgb(210, 210, 210),
179 );
180 return;
181 };
182 let duration = clip_duration(character, &controls.clip_name);
183 let dt = ui.input(|input| input.stable_dt);
184 controls.advance(dt, duration);
185 ui.ctx().request_repaint();
186 let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
187 let aspect = rect.width() / rect.height().max(1.0);
188 let target = [
189 0.0,
190 framing
191 .map(|(c, _)| c.pivot_y_milli.unwrap_or(c.center_y_milli) as f32 / 1000.)
192 .unwrap_or(0.9),
193 0.0,
194 ];
195 let offset = eye_offset(
196 controls.yaw_deg,
197 controls.pitch_deg,
198 if framing.is_some() {
199 10.
200 } else {
201 controls.distance * crate::preview::FREE_DISTANCE_FACTOR
202 },
203 );
204 let eye = [
205 target[0] + offset[0],
206 target[1] + offset[1],
207 target[2] + offset[2],
208 ];
209 let view_proj = mul(
210 perspective(crate::preview::free_fov_deg(), aspect, 0.05, 100.0),
211 view_matrix(eye, target),
212 );
213 let view_proj = if let Some((camera, _)) = framing {
214 let (v, p) =
215 crate::production::camera(camera, controls.yaw_deg, controls.pitch_deg, aspect);
216 mul(p, v)
217 } else {
218 view_proj
219 };
220 let mut cache = self.cache.lock().expect("preview gpu lock");
221 let mut diagnostics = Vec::new();
222 let mut equipment = Vec::new();
223 let sex = if controls.sex == crate::preview::PreviewSex::Male {
224 0
225 } else {
226 1
227 };
228 for preset in presets {
229 match crate::equipment::layers_cached(
230 preset,
231 sex,
232 character,
233 controls,
234 &mut cache.texture_cache,
235 ) {
236 Ok(layers) => {
237 for layer in layers {
238 equipment.push(PendingFrame::from_layer(layer, view_proj, eye));
239 }
240 }
241 Err(error) => {
242 diagnostics.push(error);
243 }
244 }
245 }
246 {
247 cache.pending = Some(body_frame(character, bones, view_proj, eye, equipment));
248 }
249 drop(cache);
250 let cache = Arc::clone(&self.cache);
251 let callback = CallbackFn::new(move |info, painter| {
252 if let Ok(mut cache) = cache.lock() {
253 unsafe { cache.paint(painter.gl(), &info, true) };
254 }
255 });
256 ui.painter().add(egui::PaintCallback {
257 rect,
258 callback: Arc::new(callback),
259 });
260 if framing.is_some() {
261 ui.painter()
262 .rect_stroke(rect, 0., egui::Stroke::new(2., egui::Color32::YELLOW));
263 ui.painter().text(
264 rect.left_top() + egui::vec2(5., 5.),
265 egui::Align2::LEFT_TOP,
266 crate::i18n::t("Vast renderkader"),
267 egui::FontId::proportional(12.),
268 egui::Color32::YELLOW,
269 );
270 }
271
272 if let Ok(cache) = self.cache.lock() {
273 if let Some(error) = &cache.error {
274 diagnostics.push(error.clone());
275 }
276 for layer in &cache.layers {
277 if let Some(error) = &layer.error {
278 diagnostics.push(error.clone());
279 }
280 }
281 }
282 if !diagnostics.is_empty() {
283 ui.painter().text(
284 rect.left_top() + egui::vec2(12., 12.),
285 egui::Align2::LEFT_TOP,
286 diagnostics
287 .iter()
288 .map(crate::i18n::t)
289 .collect::<Vec<_>>()
290 .join("\n"),
291 egui::FontId::proportional(14.),
292 egui::Color32::LIGHT_RED,
293 );
294 }
295 }
296}
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 specular: s.specular,
321 shiny: s.shiny.clone(),
322 blend: s.blend,
323 })
324 .collect();
325 PendingFrame {
326 generation: character_generation(character),
327 positions: character.positions.clone(),
328 normals: character.normals.clone(),
329 joints: character.joints.clone(),
330 weights: character.weights.clone(),
331 indices: character.indices.clone(),
332 bones,
333 view_proj,
334 color: character.base_color,
335 model: crate::preview::identity(),
336 albedo: None,
337 shiny: None,
338 specular: 0.0,
339 eye,
340 equipment,
341 uvs: character.uvs.clone(),
342 surfaces,
343 cutoff: 0.001,
344 }
345}
346
347fn character_generation(character: &PreviewCharacter) -> u64 {
348 use std::hash::{Hash, Hasher};
349 let mut hash = std::collections::hash_map::DefaultHasher::new();
350 for point in &character.positions {
351 point.map(f32::to_bits).hash(&mut hash);
352 }
353 for normal in &character.normals {
354 normal.map(f32::to_bits).hash(&mut hash);
355 }
356 for weights in &character.weights {
357 weights.map(f32::to_bits).hash(&mut hash);
358 }
359 character.joints.hash(&mut hash);
360 character.indices.hash(&mut hash);
361 for uv in &character.uvs {
362 uv.map(f32::to_bits).hash(&mut hash);
363 }
364 for s in &character.surfaces {
365 (s.first, s.count).hash(&mut hash);
366 if let Some(t) = &s.albedo {
367 (t.content_hash, t.width, t.height).hash(&mut hash);
368 }
369 if let Some(t) = &s.shiny {
370 (1u8, t.content_hash, t.width, t.height).hash(&mut hash);
371 }
372 }
373 hash.finish()
374}
375
376#[derive(Clone)]
377struct PendingFrame {
378 generation: u64,
379 positions: Vec<[f32; 3]>,
380 normals: Vec<[f32; 3]>,
381 joints: Vec<[u16; 4]>,
382 weights: Vec<[f32; 4]>,
383 indices: Vec<u32>,
384 bones: Vec<crate::preview::Mat4>,
385 view_proj: crate::preview::Mat4,
386 color: [f32; 4],
387 model: crate::preview::Mat4,
388 eye: [f32; 3],
389 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
390 shiny: Option<std::sync::Arc<crate::equipment::Texture>>,
391 specular: f32,
392 equipment: Vec<PendingFrame>,
393 uvs: Vec<[f32; 2]>,
394 /// Per-material index ranges (glTF primitives); empty = one draw of all
395 /// indices with `color`/`albedo`. Each has its own texture × factor.
396 surfaces: Vec<SurfaceDraw>,
397 cutoff: f32,
398}
399
400/// One material range of the body: indices `first..first+count`, base color
401/// factor, optional albedo texture and alpha cutoff (as loaded, unchanged).
402#[derive(Clone)]
403struct SurfaceDraw {
404 first: usize,
405 count: usize,
406 color: [f32; 4],
407 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
408 cutoff: f32,
409 specular: f32,
410 shiny: Option<std::sync::Arc<crate::equipment::Texture>>,
411 /// glTF BLEND: drawn after the opaque ranges, blended, no depth write.
412 blend: bool,
413}
414
415impl PendingFrame {
416 fn from_layer(
417 layer: crate::equipment::Layer,
418 view_proj: crate::preview::Mat4,
419 eye: [f32; 3],
420 ) -> Self {
421 use std::hash::{Hash, Hasher};
422 let mut hash = std::collections::hash_map::DefaultHasher::new();
423 for v in &layer.mesh.positions {
424 v.map(f32::to_bits).hash(&mut hash);
425 }
426 for v in &layer.mesh.normals {
427 v.map(f32::to_bits).hash(&mut hash);
428 }
429 for v in &layer.mesh.uvs {
430 v.map(f32::to_bits).hash(&mut hash);
431 }
432 layer.mesh.indices.hash(&mut hash);
433 if let Some(t) = &layer.albedo {
434 t.content_hash.hash(&mut hash);
435 t.width.hash(&mut hash);
436 t.height.hash(&mut hash);
437 }
438 if let Some(t) = &layer.shiny {
439 t.content_hash.hash(&mut hash);
440 t.width.hash(&mut hash);
441 t.height.hash(&mut hash);
442 }
443 Self {
444 generation: hash.finish(),
445 positions: layer.mesh.positions,
446 normals: layer.mesh.normals,
447 indices: layer.mesh.indices,
448 // Equipment meshes keep the OBJ/FBX bottom-left UV origin; the GPU
449 // path uses the glTF top-left origin (row 0 = top), like production.
450 uvs: layer.mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect(),
451 color: layer.mesh.color,
452 joints: Vec::new(),
453 weights: Vec::new(),
454 bones: Vec::new(),
455 model: layer.model,
456 view_proj,
457 eye,
458 albedo: layer.albedo,
459 shiny: layer.shiny,
460 specular: layer.specular,
461 equipment: Vec::new(),
462 surfaces: Vec::new(),
463 cutoff: EQUIPMENT_CUTOFF,
464 }
465 }
466}
467
468#[derive(Default)]
469struct GpuCache {
470 program: Option<glow::Program>,
471 vao: Option<glow::VertexArray>,
472 vbo: Option<glow::Buffer>,
473 ebo: Option<glow::Buffer>,
474 uploaded: u64,
475 index_count: i32,
476 pending: Option<PendingFrame>,
477 error: Option<String>,
478 textures: [Option<glow::Texture>; 2],
479 /// One albedo texture per body surface (same order as `surfaces`).
480 surface_textures: Vec<Option<glow::Texture>>,
481 /// One specular strength texture per body surface.
482 surface_shiny: Vec<Option<glow::Texture>>,
483 layers: Vec<GpuCache>,
484 texture_cache: crate::equipment::TextureCache,
485}
486
487impl GpuCache {
488 unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo, clear: bool) {
489 let Some(frame) = self.pending.clone() else {
490 return;
491 };
492 if let Err(error) = self.ensure_program(gl) {
493 self.error = Some(error);
494 return;
495 }
496 if self.uploaded != frame.generation {
497 if let Err(error) = self.upload(gl, &frame) {
498 self.error = Some(error);
499 return;
500 }
501 }
502 let Some(program) = self.program else {
503 return;
504 };
505 let viewport = info.viewport_in_pixels();
506 if viewport.width_px <= 0 || viewport.height_px <= 0 {
507 return;
508 }
509 gl.enable(glow::SCISSOR_TEST);
510 gl.scissor(
511 viewport.left_px,
512 viewport.from_bottom_px,
513 viewport.width_px,
514 viewport.height_px,
515 );
516 gl.viewport(
517 viewport.left_px,
518 viewport.from_bottom_px,
519 viewport.width_px,
520 viewport.height_px,
521 );
522 gl.enable(glow::DEPTH_TEST);
523 // Fixed-camera mode uses the narrow per-sex depth range; clamp, never clip.
524 gl.enable(glow::DEPTH_CLAMP);
525 gl.depth_mask(true);
526 gl.clear_depth_f32(1.0);
527 gl.depth_func(glow::LESS);
528 gl.disable(glow::BLEND);
529 gl.clear_color(0.08, 0.09, 0.12, 1.0);
530 if clear {
531 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
532 }
533 gl.use_program(Some(program));
534 let view = gl.get_uniform_location(program, "u_view_proj");
535 gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);
536 gl.uniform_matrix_4_f32_slice(
537 gl.get_uniform_location(program, "u_model").as_ref(),
538 false,
539 &frame.model,
540 );
541 gl.uniform_3_f32(
542 gl.get_uniform_location(program, "u_eye").as_ref(),
543 frame.eye[0],
544 frame.eye[1],
545 frame.eye[2],
546 );
547 gl.uniform_1_f32(
548 gl.get_uniform_location(program, "u_specular").as_ref(),
549 frame.specular,
550 );
551 for (unit, name, present) in [
552 (0, "u_albedo", frame.albedo.is_some()),
553 (1, "u_shiny", frame.shiny.is_some()),
554 ] {
555 gl.active_texture(glow::TEXTURE0 + unit as u32);
556 gl.bind_texture(glow::TEXTURE_2D, self.textures[unit]);
557 gl.uniform_1_i32(gl.get_uniform_location(program, name).as_ref(), unit as i32);
558 gl.uniform_1_i32(
559 gl.get_uniform_location(program, &format!("{name}_enabled"))
560 .as_ref(),
561 i32::from(present),
562 );
563 }
564 let color = gl.get_uniform_location(program, "u_color");
565 gl.uniform_4_f32(
566 color.as_ref(),
567 frame.color[0],
568 frame.color[1],
569 frame.color[2],
570 frame.color[3],
571 );
572 let mut bones = vec![crate::preview::identity(); MAX_BONES];
573 for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() {
574 bones[index] = *bone;
575 }
576 let flat: Vec<f32> = bones.iter().flat_map(|bone| *bone).collect();
577 let bones_loc = gl.get_uniform_location(program, "u_bones");
578 gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
579 gl.bind_vertex_array(self.vao);
580 let cutoff = gl.get_uniform_location(program, "u_cutoff");
581 if frame.surfaces.is_empty() {
582 gl.uniform_1_f32(cutoff.as_ref(), frame.cutoff);
583 gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
584 } else {
585 let albedo_enabled = gl.get_uniform_location(program, "u_albedo_enabled");
586 let shiny_enabled = gl.get_uniform_location(program, "u_shiny_enabled");
587 let specular = gl.get_uniform_location(program, "u_specular");
588 // glTF BLEND (design.md t1016u): opaque first, then BLEND blended
589 // without depth writes, then BLEND depth only.
590 let order: Vec<(usize, &SurfaceDraw, u8)> = (0..3u8)
591 .flat_map(|pass| {
592 frame
593 .surfaces
594 .iter()
595 .enumerate()
596 .filter(move |(_, s)| s.blend == (pass > 0))
597 .map(move |(i, s)| (i, s, pass))
598 })
599 .collect();
600 for (index, surface, pass) in order {
601 match pass {
602 0 => {
603 gl.disable(glow::BLEND);
604 gl.depth_mask(true);
605 gl.color_mask(true, true, true, true);
606 }
607 1 => {
608 gl.enable(glow::BLEND);
609 gl.blend_func_separate(
610 glow::SRC_ALPHA,
611 glow::ONE_MINUS_SRC_ALPHA,
612 glow::ZERO,
613 glow::ONE,
614 );
615 gl.depth_mask(false);
616 gl.color_mask(true, true, true, true);
617 }
618 _ => {
619 gl.disable(glow::BLEND);
620 gl.depth_mask(true);
621 gl.color_mask(false, false, false, false);
622 }
623 }
624 gl.uniform_1_i32(
625 gl.get_uniform_location(program, "u_blend").as_ref(),
626 i32::from(pass == 1),
627 );
628 let end = (surface.first + surface.count).min(self.index_count as usize);
629 if surface.first >= end {
630 continue;
631 }
632 let texture = self.surface_textures.get(index).copied().flatten();
633 gl.active_texture(glow::TEXTURE0);
634 gl.bind_texture(glow::TEXTURE_2D, texture);
635 gl.uniform_1_i32(albedo_enabled.as_ref(), i32::from(texture.is_some()));
636 let shiny = self.surface_shiny.get(index).copied().flatten();
637 gl.active_texture(glow::TEXTURE1);
638 gl.bind_texture(glow::TEXTURE_2D, shiny);
639 gl.active_texture(glow::TEXTURE0);
640 gl.uniform_1_i32(shiny_enabled.as_ref(), i32::from(shiny.is_some()));
641 gl.uniform_1_f32(specular.as_ref(), surface.specular);
642 gl.uniform_4_f32(
643 color.as_ref(),
644 surface.color[0],
645 surface.color[1],
646 surface.color[2],
647 surface.color[3],
648 );
649 gl.uniform_1_f32(cutoff.as_ref(), surface.cutoff);
650 gl.draw_elements(
651 glow::TRIANGLES,
652 (end - surface.first) as i32,
653 glow::UNSIGNED_INT,
654 (surface.first * 4) as i32,
655 );
656 }
657 gl.disable(glow::BLEND);
658 gl.depth_mask(true);
659 gl.color_mask(true, true, true, true);
660 gl.uniform_1_i32(gl.get_uniform_location(program, "u_blend").as_ref(), 0);
661 }
662 gl.bind_vertex_array(None);
663 while self.layers.len() > frame.equipment.len() {
664 if let Some(mut layer) = self.layers.pop() {
665 layer.destroy(gl);
666 }
667 }
668 while self.layers.len() < frame.equipment.len() {
669 self.layers.push(GpuCache::default());
670 }
671 for (cache, frame) in self.layers.iter_mut().zip(frame.equipment) {
672 cache.pending = Some(frame);
673 cache.paint(gl, info, false);
674 }
675 gl.active_texture(glow::TEXTURE0);
676 gl.disable(glow::DEPTH_TEST);
677 gl.disable(glow::DEPTH_CLAMP);
678 gl.disable(glow::SCISSOR_TEST);
679 gl.enable(glow::BLEND);
680 }
681
682 unsafe fn destroy(&mut self, gl: &glow::Context) {
683 for layer in &mut self.layers {
684 layer.destroy(gl);
685 }
686 for texture in self
687 .textures
688 .iter_mut()
689 .chain(self.surface_textures.iter_mut())
690 {
691 if let Some(t) = texture.take() {
692 gl.delete_texture(t)
693 }
694 }
695 self.surface_textures.clear();
696 self.surface_shiny.clear();
697 if let Some(v) = self.vao.take() {
698 gl.delete_vertex_array(v)
699 }
700 if let Some(v) = self.vbo.take() {
701 gl.delete_buffer(v)
702 }
703 if let Some(v) = self.ebo.take() {
704 gl.delete_buffer(v)
705 }
706 if let Some(p) = self.program.take() {
707 gl.delete_program(p)
708 }
709 }
710 unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {
711 if self.program.is_some() {
712 return Ok(());
713 }
714 let program = link(gl, VERTEX, FRAGMENT)?;
715 let vao = gl.create_vertex_array().map_err(|err| err.to_string())?;
716 let vbo = gl.create_buffer().map_err(|err| err.to_string())?;
717 let ebo = gl.create_buffer().map_err(|err| err.to_string())?;
718 self.program = Some(program);
719 self.vao = Some(vao);
720 self.vbo = Some(vbo);
721 self.ebo = Some(ebo);
722 Ok(())
723 }
724
725 unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {
726 let mut interleaved = Vec::with_capacity(frame.positions.len() * 16);
727 for index in 0..frame.positions.len() {
728 let position = frame.positions[index];
729 let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);
730 let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);
731 let weight = frame
732 .weights
733 .get(index)
734 .copied()
735 .unwrap_or([1.0, 0.0, 0.0, 0.0]);
736 interleaved.extend_from_slice(&position);
737 interleaved.extend_from_slice(&normal);
738 interleaved.extend([
739 joint[0] as f32,
740 joint[1] as f32,
741 joint[2] as f32,
742 joint[3] as f32,
743 ]);
744 interleaved.extend_from_slice(&weight);
745 interleaved.extend_from_slice(&frame.uvs.get(index).copied().unwrap_or([0.; 2]));
746 }
747 gl.bind_vertex_array(self.vao);
748 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
749 gl.buffer_data_u8_slice(
750 glow::ARRAY_BUFFER,
751 bytemuck_f32(&interleaved),
752 glow::DYNAMIC_DRAW,
753 );
754 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
755 gl.buffer_data_u8_slice(
756 glow::ELEMENT_ARRAY_BUFFER,
757 bytemuck_u32(&frame.indices),
758 glow::DYNAMIC_DRAW,
759 );
760 let stride = 16 * 4;
761 enable_float(gl, 0, 3, stride, 0);
762 enable_float(gl, 1, 3, stride, 3 * 4);
763 enable_float(gl, 2, 4, stride, 6 * 4);
764 enable_float(gl, 3, 4, stride, 10 * 4);
765 enable_float(gl, 4, 2, stride, 14 * 4);
766 for (unit, texture) in [&frame.albedo, &frame.shiny].iter().enumerate() {
767 if let Some(old) = self.textures[unit].take() {
768 gl.delete_texture(old);
769 }
770 if let Some(texture) = texture {
771 gl.active_texture(glow::TEXTURE0 + unit as u32);
772 self.textures[unit] = Some(upload_texture(gl, texture)?);
773 }
774 }
775 for old in self
776 .surface_textures
777 .drain(..)
778 .chain(self.surface_shiny.drain(..))
779 .flatten()
780 {
781 gl.delete_texture(old);
782 }
783 gl.active_texture(glow::TEXTURE0);
784 for surface in &frame.surfaces {
785 let id = match &surface.albedo {
786 Some(texture) => Some(upload_texture(gl, texture)?),
787 None => None,
788 };
789 self.surface_textures.push(id);
790 let shiny = match &surface.shiny {
791 Some(texture) => Some(upload_texture(gl, texture)?),
792 None => None,
793 };
794 self.surface_shiny.push(shiny);
795 }
796 self.uploaded = frame.generation;
797 self.index_count = frame.indices.len() as i32;
798 Ok(())
799 }
800}
801
802/// Nearest + REPEAT RGBA8 texture, bound to the active unit.
803unsafe fn upload_texture(
804 gl: &glow::Context,
805 texture: &crate::equipment::Texture,
806) -> Result<glow::Texture, String> {
807 let id = gl.create_texture().map_err(|e| e.to_string())?;
808 gl.bind_texture(glow::TEXTURE_2D, Some(id));
809 gl.tex_parameter_i32(
810 glow::TEXTURE_2D,
811 glow::TEXTURE_MIN_FILTER,
812 glow::NEAREST as i32,
813 );
814 gl.tex_parameter_i32(
815 glow::TEXTURE_2D,
816 glow::TEXTURE_MAG_FILTER,
817 glow::NEAREST as i32,
818 );
819 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
820 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
821 gl.tex_image_2d(
822 glow::TEXTURE_2D,
823 0,
824 glow::RGBA8 as i32,
825 texture.width as i32,
826 texture.height as i32,
827 0,
828 glow::RGBA,
829 glow::UNSIGNED_BYTE,
830 Some(&texture.rgba),
831 );
832 Ok(id)
833}
834
835unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
836 gl.enable_vertex_attrib_array(index);
837 gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
838}
839
840fn bytemuck_f32(values: &[f32]) -> &[u8] {
841 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
842}
843
844fn bytemuck_u32(values: &[u32]) -> &[u8] {
845 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
846}
847
848unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result<glow::Program, String> {
849 let program = gl.create_program().map_err(|err| err.to_string())?;
850 let vs = compile(gl, glow::VERTEX_SHADER, vertex)?;
851 let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?;
852 gl.attach_shader(program, vs);
853 gl.attach_shader(program, fs);
854 gl.link_program(program);
855 if !gl.get_program_link_status(program) {
856 return Err(gl.get_program_info_log(program));
857 }
858 gl.delete_shader(vs);
859 gl.delete_shader(fs);
860 Ok(program)
861}
862
863unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result<glow::Shader, String> {
864 let shader = gl.create_shader(stage).map_err(|err| err.to_string())?;
865 gl.shader_source(shader, source);
866 gl.compile_shader(shader);
867 if !gl.get_shader_compile_status(shader) {
868 return Err(gl.get_shader_info_log(shader));
869 }
870 Ok(shader)
871}
872
873const VERTEX: &str = r#"#version 330
874layout(location = 0) in vec3 a_pos;
875layout(location = 1) in vec3 a_normal;
876layout(location = 2) in vec4 a_joints;
877layout(location = 3) in vec4 a_weights;
878layout(location = 4) in vec2 a_uv;
879uniform mat4 u_model;
880out vec2 v_uv;
881out vec3 v_world;
882uniform mat4 u_view_proj;
883uniform mat4 u_bones[128];
884out vec3 v_normal;
885void main() {
886 int j0 = int(a_joints.x);
887 int j1 = int(a_joints.y);
888 int j2 = int(a_joints.z);
889 int j3 = int(a_joints.w);
890 mat4 skin = a_weights.x * u_bones[j0]
891 + a_weights.y * u_bones[j1]
892 + a_weights.z * u_bones[j2]
893 + a_weights.w * u_bones[j3];
894 vec4 world = u_model * skin * vec4(a_pos, 1.0);
895 v_uv = a_uv; v_world=world.xyz;
896 v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;
897 gl_Position = u_view_proj * world;
898}
899"#;
900
901const FRAGMENT: &str = r#"#version 330
902in vec3 v_normal;
903uniform vec4 u_color;
904uniform sampler2D u_albedo;
905uniform sampler2D u_shiny;
906uniform bool u_albedo_enabled;
907uniform bool u_shiny_enabled;
908uniform float u_specular;
909uniform float u_cutoff;
910uniform vec3 u_eye;
911in vec2 v_uv;
912in vec3 v_world;
913out vec4 frag;
914uniform bool u_blend;
915void main() {
916 vec3 n = normalize(v_normal);
917 vec3 light = normalize(vec3(0.35, 0.85, 0.4));
918 float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
919 vec4 color=u_color;
920 if(u_albedo_enabled) color*=texture(u_albedo,v_uv);
921 if(color.a<u_cutoff) discard;
922 float strength=u_specular;
923 if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));
924 float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;
925 frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), u_blend ? color.a : 1.0);
926}
927"#;
928
929/// Real GPU regression using the same cache/shader/paint path as the callback.
930/// Tests front-first/back-first overlap, depth writes, and the next-frame clear.
931///
932/// # Safety
933/// `gl` must be a current native OpenGL context. Run in a disposable test window;
934/// this probe changes GL state and restores the default framebuffer afterwards.
935pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
936 let native_bits = gl.get_framebuffer_attachment_parameter_i32(
937 glow::FRAMEBUFFER,
938 glow::DEPTH,
939 glow::FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE,
940 );
941 if native_bits < 24 {
942 return Err(format!(
943 "native depth attachment ontbreekt: {native_bits} bits"
944 ));
945 }
946 let framebuffer = gl.create_framebuffer()?;
947 let texture = gl.create_texture()?;
948 let depth = gl.create_renderbuffer()?;
949 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
950 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
951 gl.tex_parameter_i32(
952 glow::TEXTURE_2D,
953 glow::TEXTURE_MIN_FILTER,
954 glow::NEAREST as i32,
955 );
956 gl.tex_parameter_i32(
957 glow::TEXTURE_2D,
958 glow::TEXTURE_MAG_FILTER,
959 glow::NEAREST as i32,
960 );
961 gl.tex_image_2d(
962 glow::TEXTURE_2D,
963 0,
964 glow::RGBA8 as i32,
965 32,
966 32,
967 0,
968 glow::RGBA,
969 glow::UNSIGNED_BYTE,
970 None,
971 );
972 gl.framebuffer_texture_2d(
973 glow::FRAMEBUFFER,
974 glow::COLOR_ATTACHMENT0,
975 glow::TEXTURE_2D,
976 Some(texture),
977 0,
978 );
979 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(depth));
980 gl.renderbuffer_storage(glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, 32, 32);
981 gl.framebuffer_renderbuffer(
982 glow::FRAMEBUFFER,
983 glow::DEPTH_ATTACHMENT,
984 glow::RENDERBUFFER,
985 Some(depth),
986 );
987 gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
988 gl.read_buffer(glow::COLOR_ATTACHMENT0);
989 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
990 return Err("onvolledig offscreen depth-target".into());
991 }
992 let info = egui::PaintCallbackInfo {
993 viewport: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32., 32.)),
994 clip_rect: egui::Rect::EVERYTHING,
995 pixels_per_point: 1.,
996 screen_size_px: [32, 32],
997 };
998 let make_frame = |indices: Vec<u32>, generation: u64| PendingFrame {
999 generation,
1000 positions: vec![
1001 [-0.9, -0.9, -0.5],
1002 [0.9, -0.9, -0.5],
1003 [0., 0.9, -0.5],
1004 [-0.9, -0.9, 0.5],
1005 [0.9, -0.9, 0.5],
1006 [0., 0.9, 0.5],
1007 ],
1008 normals: vec![
1009 [0.35, 0.85, 0.4],
1010 [0.35, 0.85, 0.4],
1011 [0.35, 0.85, 0.4],
1012 [-0.35, -0.85, -0.4],
1013 [-0.35, -0.85, -0.4],
1014 [-0.35, -0.85, -0.4],
1015 ],
1016 joints: vec![[0; 4]; 6],
1017 weights: vec![[1., 0., 0., 0.]; 6],
1018 indices,
1019 bones: vec![crate::preview::identity()],
1020 view_proj: crate::preview::identity(),
1021 color: [1.; 4],
1022 model: crate::preview::identity(),
1023 eye: [0., 0., -3.],
1024 albedo: None,
1025 shiny: None,
1026 specular: 0.,
1027 uvs: vec![[0.; 2]; 6],
1028 equipment: Vec::new(),
1029 surfaces: Vec::new(),
1030 cutoff: EQUIPMENT_CUTOFF,
1031 };
1032 let mut cache = GpuCache::default();
1033 let read = || {
1034 let mut rgba = [0u8; 4];
1035 gl.read_pixels(
1036 16,
1037 16,
1038 1,
1039 1,
1040 glow::RGBA,
1041 glow::UNSIGNED_BYTE,
1042 glow::PixelPackData::Slice(&mut rgba),
1043 );
1044 rgba
1045 };
1046 let result = (|| {
1047 // Deliberately poison the incoming state: the callback owns its depth setup.
1048 gl.depth_mask(false);
1049 gl.clear_depth_f32(0.0);
1050 gl.depth_func(glow::GREATER);
1051 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
1052 cache.paint(gl, &info, true);
1053 let near = read();
1054 if near[..3].iter().any(|c| *c < 250) {
1055 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
1056 }
1057 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
1058 cache.paint(gl, &info, true);
1059 let next = read();
1060 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
1061 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
1062 }
1063 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
1064 cache.paint(gl, &info, true);
1065 let reverse = read();
1066 if near != reverse {
1067 return Err(format!(
1068 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
1069 ));
1070 }
1071 let mut body = make_frame(vec![0, 1, 2], 4);
1072 let mut behind = make_frame(vec![3, 4, 5], 5);
1073 behind.color = [1., 0., 0., 1.];
1074 body.equipment.push(behind);
1075 cache.pending = Some(body);
1076 cache.paint(gl, &info, true);
1077 let hidden = read();
1078 if hidden != near {
1079 return Err(format!("equipment wist body-depth: {hidden:?}"));
1080 }
1081 let mut body = make_frame(vec![3, 4, 5], 6);
1082 let mut front = make_frame(vec![0, 1, 2], 7);
1083 front.albedo = Some(Arc::new(crate::equipment::Texture {
1084 content_hash: 1,
1085 width: 1,
1086 height: 1,
1087 rgba: vec![255, 0, 0, 255],
1088 }));
1089 body.equipment.push(front.clone());
1090 cache.pending = Some(body.clone());
1091 cache.paint(gl, &info, true);
1092 let textured = read();
1093 if textured[0] < 250 || textured[1] > 2 || textured[2] > 2 {
1094 return Err(format!("voorste equipmenttexture ontbreekt: {textured:?}"));
1095 }
1096 front.generation = 8;
1097 front.albedo = Some(Arc::new(crate::equipment::Texture {
1098 content_hash: 2,
1099 width: 1,
1100 height: 1,
1101 rgba: vec![255, 0, 0, 0],
1102 }));
1103 body.equipment = vec![front];
1104 cache.pending = Some(body);
1105 cache.paint(gl, &info, true);
1106 let cutout = read();
1107 if cutout != next {
1108 return Err(format!("alpha-cutout bedekt body: {cutout:?}"));
1109 }
1110 let mut body = make_frame(vec![3, 4, 5], 9);
1111 let mut front = make_frame(vec![0, 1, 2], 10);
1112 front.color = [0., 0., 0., 1.];
1113 front.specular = 1.;
1114 front.eye = [35000., 85000., 40000.];
1115 front.shiny = Some(Arc::new(crate::equipment::Texture {
1116 content_hash: 3,
1117 width: 1,
1118 height: 1,
1119 rgba: vec![255, 255, 255, 255],
1120 }));
1121 body.equipment = vec![front.clone()];
1122 cache.pending = Some(body.clone());
1123 cache.paint(gl, &info, true);
1124 let shiny = read();
1125 front.generation = 11;
1126 front.shiny = Some(Arc::new(crate::equipment::Texture {
1127 content_hash: 4,
1128 width: 1,
1129 height: 1,
1130 rgba: vec![0, 0, 0, 255],
1131 }));
1132 body.equipment = vec![front];
1133 cache.pending = Some(body);
1134 cache.paint(gl, &info, true);
1135 let matte = read();
1136 if shiny[..3].iter().any(|c| *c < 250) || matte[..3] != [0, 0, 0] {
1137 return Err(format!(
1138 "shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}"
1139 ));
1140 }
1141 // Body with two materials: each surface uses its own texture × factor.
1142 let cyan = Arc::new(crate::equipment::Texture {
1143 content_hash: 5,
1144 width: 1,
1145 height: 1,
1146 rgba: vec![0, 255, 255, 255],
1147 });
1148 let mut body = make_frame(vec![3, 4, 5, 0, 1, 2], 12);
1149 body.surfaces = vec![
1150 SurfaceDraw {
1151 first: 0,
1152 count: 3,
1153 color: [1., 0., 0., 1.],
1154 albedo: None,
1155 cutoff: 0.001,
1156 specular: 0.,
1157 shiny: None,
1158 blend: false,
1159 },
1160 SurfaceDraw {
1161 first: 3,
1162 count: 3,
1163 color: [1., 0.5, 1., 1.],
1164 albedo: Some(cyan.clone()),
1165 cutoff: 0.001,
1166 specular: 0.,
1167 shiny: None,
1168 blend: false,
1169 },
1170 ];
1171 cache.pending = Some(body.clone());
1172 cache.paint(gl, &info, true);
1173 let surface_front = read();
1174 if surface_front[0] > 2
1175 || !(120..=136).contains(&surface_front[1])
1176 || surface_front[2] < 250
1177 {
1178 return Err(format!(
1179 "voorste materiaal niet als texture × factor getekend: {surface_front:?}"
1180 ));
1181 }
1182 body.generation = 13;
1183 body.surfaces[1].albedo = None;
1184 body.surfaces[0].albedo = Some(cyan);
1185 body.surfaces.swap(0, 1);
1186 body.surfaces[0].first = 0;
1187 body.surfaces[1].first = 3;
1188 body.indices = vec![0, 1, 2, 3, 4, 5];
1189 cache.pending = Some(body);
1190 cache.paint(gl, &info, true);
1191 let surface_swap = read();
1192 // Front triangle is now the factor-only material [1, 0.5, 1].
1193 if surface_swap[0] < 250 || !(120..=136).contains(&surface_swap[1]) || surface_swap[2] < 250
1194 {
1195 return Err(format!(
1196 "materiaal per primitive verwisseld: {surface_swap:?}"
1197 ));
1198 }
1199 // UV origin (Tim t1009u): body UVs are glTF (v = 0 is the top row of
1200 // the image), equipment UVs are bottom-left (v = 0 is the bottom row).
1201 let red_top_blue_bottom = Arc::new(crate::equipment::Texture {
1202 content_hash: 6,
1203 width: 1,
1204 height: 2,
1205 rgba: vec![255, 0, 0, 255, 0, 0, 255, 255],
1206 });
1207 let mut body = make_frame(vec![0, 1, 2], 14);
1208 body.uvs = vec![[0.5, 0.25]; 6];
1209 body.surfaces = vec![SurfaceDraw {
1210 first: 0,
1211 count: 3,
1212 color: [1.; 4],
1213 albedo: Some(red_top_blue_bottom.clone()),
1214 cutoff: 0.001,
1215 specular: 0.,
1216 shiny: None,
1217 blend: false,
1218 }];
1219 cache.pending = Some(body);
1220 cache.paint(gl, &info, true);
1221 let body_uv = read();
1222 if body_uv[0] < 250 || body_uv[2] > 2 {
1223 return Err(format!(
1224 "body-UV niet volgens glTF (v=0 boven): {body_uv:?}"
1225 ));
1226 }
1227 let mut body = make_frame(vec![3, 4, 5], 15);
1228 let mut front = make_frame(vec![0, 1, 2], 16);
1229 front.uvs = vec![[0.5, 0.75]; 6]; // already converted from bottom-left v = 0.25
1230 front.albedo = Some(red_top_blue_bottom);
1231 body.equipment = vec![front];
1232 cache.pending = Some(body);
1233 cache.paint(gl, &info, true);
1234 let equipment_uv = read();
1235 if equipment_uv[2] < 250 || equipment_uv[0] > 2 {
1236 return Err(format!(
1237 "equipment-UV verkeerd georiënteerd: {equipment_uv:?}"
1238 ));
1239 }
1240 // glTF BLEND (Tim t1016u): a nearly transparent front layer listed
1241 // first (the cornea) must not hide the opaque surface behind it.
1242 let mut body = make_frame(vec![0, 1, 2, 3, 4, 5], 17);
1243 let plain = |first, color: [f32; 4], blend| SurfaceDraw {
1244 first,
1245 count: 3,
1246 color,
1247 albedo: None,
1248 cutoff: 0.001,
1249 specular: 0.,
1250 shiny: None,
1251 blend,
1252 };
1253 body.surfaces = vec![
1254 plain(0, [1., 1., 1., 0.1], true),
1255 plain(3, [1., 0., 0., 1.], false),
1256 ];
1257 cache.pending = Some(body);
1258 cache.paint(gl, &info, true);
1259 let blend_over = read();
1260 if blend_over[1] < 5 || blend_over[1] > 60 || blend_over[0] < 2 * blend_over[1] {
1261 return Err(format!(
1262 "BLEND-laag niet over het ondoorzichtige oppervlak gemengd: {blend_over:?}"
1263 ));
1264 }
1265 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:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}, blend-over={blend_over:?}");
1266 Ok(())
1267 })();
1268 cache.destroy(gl);
1269 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
1270 gl.delete_framebuffer(framebuffer);
1271 gl.delete_texture(texture);
1272 gl.delete_renderbuffer(depth);
1273 result
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 /// Regression (Tim t1007u): the live body used one merged color and no
1281 /// texture. Every glTF material must reach the GPU frame as its own range
1282 /// with its own base color factor and external albedo texture.
1283 #[test]
1284 fn live_body_frame_keeps_every_material_texture_and_factor() {
1285 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1286 .join("tests/fixtures/bodies/multi-material/body.gltf");
1287 let body = crate::preview::load_body_file(&path).unwrap();
1288 let frame = body_frame(
1289 &body,
1290 Vec::new(),
1291 crate::preview::identity(),
1292 [0.; 3],
1293 Vec::new(),
1294 );
1295 assert_eq!(frame.surfaces.len(), 3);
1296 assert_eq!(frame.uvs.len(), frame.positions.len());
1297 // glTF UVs reach the GPU unchanged (top-left origin, no V flip).
1298 assert_eq!(frame.uvs, body.uvs);
1299 let ranges: Vec<_> = frame.surfaces.iter().map(|s| (s.first, s.count)).collect();
1300 assert_eq!(ranges, [(0, 6), (6, 6), (12, 6)]);
1301 assert_eq!(frame.surfaces[0].color, [0.2, 0.4, 0.9, 1.0]);
1302 assert!(frame.surfaces[0].albedo.is_none());
1303 assert_eq!(frame.surfaces[1].color, [1.; 4]);
1304 assert_eq!(
1305 &frame.surfaces[1].albedo.as_ref().unwrap().rgba[..4],
1306 [0, 200, 0, 255]
1307 );
1308 assert_eq!(frame.surfaces[2].color, [0.5, 1.0, 1.0, 1.0]);
1309 assert_eq!(
1310 &frame.surfaces[2].albedo.as_ref().unwrap().rgba[..4],
1311 [200, 200, 0, 255]
1312 );
1313 // KHR_materials_specular (Tim t998u.4): factor and alpha texture per material.
1314 assert_eq!(frame.surfaces[0].specular, 0.);
1315 assert!(frame.surfaces[0].shiny.is_none());
1316 assert_eq!(frame.surfaces[1].specular, 0.5);
1317 assert!(frame.surfaces[1].shiny.is_none());
1318 assert_eq!(frame.surfaces[2].specular, 1.0);
1319 assert_eq!(
1320 &frame.surfaces[2].shiny.as_ref().unwrap().rgba[..4],
1321 [102, 102, 102, 255]
1322 );
1323 // A different texture must re-upload: it is part of the generation.
1324 let mut changed = body.clone();
1325 std::sync::Arc::make_mut(changed.surfaces[1].albedo.as_mut().unwrap()).content_hash ^= 1;
1326 assert_ne!(character_generation(&body), character_generation(&changed));
1327 }
1328}