BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview_gl.rs

preview_gl.rs

preview_gl.rs 44.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 })
323 .collect();
324 PendingFrame {
325 generation: character_generation(character),
326 positions: character.positions.clone(),
327 normals: character.normals.clone(),
328 joints: character.joints.clone(),
329 weights: character.weights.clone(),
330 indices: character.indices.clone(),
331 bones,
332 view_proj,
333 color: character.base_color,
334 model: crate::preview::identity(),
335 albedo: None,
336 shiny: None,
337 specular: 0.0,
338 eye,
339 equipment,
340 uvs: character.uvs.clone(),
341 surfaces,
342 cutoff: 0.001,
343 }
344}
345
346fn character_generation(character: &PreviewCharacter) -> u64 {
347 use std::hash::{Hash, Hasher};
348 let mut hash = std::collections::hash_map::DefaultHasher::new();
349 for point in &character.positions {
350 point.map(f32::to_bits).hash(&mut hash);
351 }
352 for normal in &character.normals {
353 normal.map(f32::to_bits).hash(&mut hash);
354 }
355 for weights in &character.weights {
356 weights.map(f32::to_bits).hash(&mut hash);
357 }
358 character.joints.hash(&mut hash);
359 character.indices.hash(&mut hash);
360 for uv in &character.uvs {
361 uv.map(f32::to_bits).hash(&mut hash);
362 }
363 for s in &character.surfaces {
364 (s.first, s.count).hash(&mut hash);
365 if let Some(t) = &s.albedo {
366 (t.content_hash, t.width, t.height).hash(&mut hash);
367 }
368 if let Some(t) = &s.shiny {
369 (1u8, t.content_hash, t.width, t.height).hash(&mut hash);
370 }
371 }
372 hash.finish()
373}
374
375#[derive(Clone)]
376struct PendingFrame {
377 generation: u64,
378 positions: Vec<[f32; 3]>,
379 normals: Vec<[f32; 3]>,
380 joints: Vec<[u16; 4]>,
381 weights: Vec<[f32; 4]>,
382 indices: Vec<u32>,
383 bones: Vec<crate::preview::Mat4>,
384 view_proj: crate::preview::Mat4,
385 color: [f32; 4],
386 model: crate::preview::Mat4,
387 eye: [f32; 3],
388 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
389 shiny: Option<std::sync::Arc<crate::equipment::Texture>>,
390 specular: f32,
391 equipment: Vec<PendingFrame>,
392 uvs: Vec<[f32; 2]>,
393 /// Per-material index ranges (glTF primitives); empty = one draw of all
394 /// indices with `color`/`albedo`. Each has its own texture × factor.
395 surfaces: Vec<SurfaceDraw>,
396 cutoff: f32,
397}
398
399/// One material range of the body: indices `first..first+count`, base color
400/// factor, optional albedo texture and alpha cutoff (as loaded, unchanged).
401#[derive(Clone)]
402struct SurfaceDraw {
403 first: usize,
404 count: usize,
405 color: [f32; 4],
406 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
407 cutoff: f32,
408 specular: f32,
409 shiny: Option<std::sync::Arc<crate::equipment::Texture>>,
410}
411
412impl PendingFrame {
413 fn from_layer(
414 layer: crate::equipment::Layer,
415 view_proj: crate::preview::Mat4,
416 eye: [f32; 3],
417 ) -> Self {
418 use std::hash::{Hash, Hasher};
419 let mut hash = std::collections::hash_map::DefaultHasher::new();
420 for v in &layer.mesh.positions {
421 v.map(f32::to_bits).hash(&mut hash);
422 }
423 for v in &layer.mesh.normals {
424 v.map(f32::to_bits).hash(&mut hash);
425 }
426 for v in &layer.mesh.uvs {
427 v.map(f32::to_bits).hash(&mut hash);
428 }
429 layer.mesh.indices.hash(&mut hash);
430 if let Some(t) = &layer.albedo {
431 t.content_hash.hash(&mut hash);
432 t.width.hash(&mut hash);
433 t.height.hash(&mut hash);
434 }
435 if let Some(t) = &layer.shiny {
436 t.content_hash.hash(&mut hash);
437 t.width.hash(&mut hash);
438 t.height.hash(&mut hash);
439 }
440 Self {
441 generation: hash.finish(),
442 positions: layer.mesh.positions,
443 normals: layer.mesh.normals,
444 indices: layer.mesh.indices,
445 // Equipment meshes keep the OBJ/FBX bottom-left UV origin; the GPU
446 // path uses the glTF top-left origin (row 0 = top), like production.
447 uvs: layer.mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect(),
448 color: layer.mesh.color,
449 joints: Vec::new(),
450 weights: Vec::new(),
451 bones: Vec::new(),
452 model: layer.model,
453 view_proj,
454 eye,
455 albedo: layer.albedo,
456 shiny: layer.shiny,
457 specular: layer.specular,
458 equipment: Vec::new(),
459 surfaces: Vec::new(),
460 cutoff: EQUIPMENT_CUTOFF,
461 }
462 }
463}
464
465#[derive(Default)]
466struct GpuCache {
467 program: Option<glow::Program>,
468 vao: Option<glow::VertexArray>,
469 vbo: Option<glow::Buffer>,
470 ebo: Option<glow::Buffer>,
471 uploaded: u64,
472 index_count: i32,
473 pending: Option<PendingFrame>,
474 error: Option<String>,
475 textures: [Option<glow::Texture>; 2],
476 /// One albedo texture per body surface (same order as `surfaces`).
477 surface_textures: Vec<Option<glow::Texture>>,
478 /// One specular strength texture per body surface.
479 surface_shiny: Vec<Option<glow::Texture>>,
480 layers: Vec<GpuCache>,
481 texture_cache: crate::equipment::TextureCache,
482}
483
484impl GpuCache {
485 unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo, clear: bool) {
486 let Some(frame) = self.pending.clone() else {
487 return;
488 };
489 if let Err(error) = self.ensure_program(gl) {
490 self.error = Some(error);
491 return;
492 }
493 if self.uploaded != frame.generation {
494 if let Err(error) = self.upload(gl, &frame) {
495 self.error = Some(error);
496 return;
497 }
498 }
499 let Some(program) = self.program else {
500 return;
501 };
502 let viewport = info.viewport_in_pixels();
503 if viewport.width_px <= 0 || viewport.height_px <= 0 {
504 return;
505 }
506 gl.enable(glow::SCISSOR_TEST);
507 gl.scissor(
508 viewport.left_px,
509 viewport.from_bottom_px,
510 viewport.width_px,
511 viewport.height_px,
512 );
513 gl.viewport(
514 viewport.left_px,
515 viewport.from_bottom_px,
516 viewport.width_px,
517 viewport.height_px,
518 );
519 gl.enable(glow::DEPTH_TEST);
520 // Fixed-camera mode uses the narrow per-sex depth range; clamp, never clip.
521 gl.enable(glow::DEPTH_CLAMP);
522 gl.depth_mask(true);
523 gl.clear_depth_f32(1.0);
524 gl.depth_func(glow::LESS);
525 gl.disable(glow::BLEND);
526 gl.clear_color(0.08, 0.09, 0.12, 1.0);
527 if clear {
528 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
529 }
530 gl.use_program(Some(program));
531 let view = gl.get_uniform_location(program, "u_view_proj");
532 gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);
533 gl.uniform_matrix_4_f32_slice(
534 gl.get_uniform_location(program, "u_model").as_ref(),
535 false,
536 &frame.model,
537 );
538 gl.uniform_3_f32(
539 gl.get_uniform_location(program, "u_eye").as_ref(),
540 frame.eye[0],
541 frame.eye[1],
542 frame.eye[2],
543 );
544 gl.uniform_1_f32(
545 gl.get_uniform_location(program, "u_specular").as_ref(),
546 frame.specular,
547 );
548 for (unit, name, present) in [
549 (0, "u_albedo", frame.albedo.is_some()),
550 (1, "u_shiny", frame.shiny.is_some()),
551 ] {
552 gl.active_texture(glow::TEXTURE0 + unit as u32);
553 gl.bind_texture(glow::TEXTURE_2D, self.textures[unit]);
554 gl.uniform_1_i32(gl.get_uniform_location(program, name).as_ref(), unit as i32);
555 gl.uniform_1_i32(
556 gl.get_uniform_location(program, &format!("{name}_enabled"))
557 .as_ref(),
558 i32::from(present),
559 );
560 }
561 let color = gl.get_uniform_location(program, "u_color");
562 gl.uniform_4_f32(
563 color.as_ref(),
564 frame.color[0],
565 frame.color[1],
566 frame.color[2],
567 frame.color[3],
568 );
569 let mut bones = vec![crate::preview::identity(); MAX_BONES];
570 for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() {
571 bones[index] = *bone;
572 }
573 let flat: Vec<f32> = bones.iter().flat_map(|bone| *bone).collect();
574 let bones_loc = gl.get_uniform_location(program, "u_bones");
575 gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
576 gl.bind_vertex_array(self.vao);
577 let cutoff = gl.get_uniform_location(program, "u_cutoff");
578 if frame.surfaces.is_empty() {
579 gl.uniform_1_f32(cutoff.as_ref(), frame.cutoff);
580 gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
581 } else {
582 let albedo_enabled = gl.get_uniform_location(program, "u_albedo_enabled");
583 let shiny_enabled = gl.get_uniform_location(program, "u_shiny_enabled");
584 let specular = gl.get_uniform_location(program, "u_specular");
585 for (index, surface) in frame.surfaces.iter().enumerate() {
586 let end = (surface.first + surface.count).min(self.index_count as usize);
587 if surface.first >= end {
588 continue;
589 }
590 let texture = self.surface_textures.get(index).copied().flatten();
591 gl.active_texture(glow::TEXTURE0);
592 gl.bind_texture(glow::TEXTURE_2D, texture);
593 gl.uniform_1_i32(albedo_enabled.as_ref(), i32::from(texture.is_some()));
594 let shiny = self.surface_shiny.get(index).copied().flatten();
595 gl.active_texture(glow::TEXTURE1);
596 gl.bind_texture(glow::TEXTURE_2D, shiny);
597 gl.active_texture(glow::TEXTURE0);
598 gl.uniform_1_i32(shiny_enabled.as_ref(), i32::from(shiny.is_some()));
599 gl.uniform_1_f32(specular.as_ref(), surface.specular);
600 gl.uniform_4_f32(
601 color.as_ref(),
602 surface.color[0],
603 surface.color[1],
604 surface.color[2],
605 surface.color[3],
606 );
607 gl.uniform_1_f32(cutoff.as_ref(), surface.cutoff);
608 gl.draw_elements(
609 glow::TRIANGLES,
610 (end - surface.first) as i32,
611 glow::UNSIGNED_INT,
612 (surface.first * 4) as i32,
613 );
614 }
615 }
616 gl.bind_vertex_array(None);
617 while self.layers.len() > frame.equipment.len() {
618 if let Some(mut layer) = self.layers.pop() {
619 layer.destroy(gl);
620 }
621 }
622 while self.layers.len() < frame.equipment.len() {
623 self.layers.push(GpuCache::default());
624 }
625 for (cache, frame) in self.layers.iter_mut().zip(frame.equipment) {
626 cache.pending = Some(frame);
627 cache.paint(gl, info, false);
628 }
629 gl.active_texture(glow::TEXTURE0);
630 gl.disable(glow::DEPTH_TEST);
631 gl.disable(glow::DEPTH_CLAMP);
632 gl.disable(glow::SCISSOR_TEST);
633 gl.enable(glow::BLEND);
634 }
635
636 unsafe fn destroy(&mut self, gl: &glow::Context) {
637 for layer in &mut self.layers {
638 layer.destroy(gl);
639 }
640 for texture in self
641 .textures
642 .iter_mut()
643 .chain(self.surface_textures.iter_mut())
644 {
645 if let Some(t) = texture.take() {
646 gl.delete_texture(t)
647 }
648 }
649 self.surface_textures.clear();
650 self.surface_shiny.clear();
651 if let Some(v) = self.vao.take() {
652 gl.delete_vertex_array(v)
653 }
654 if let Some(v) = self.vbo.take() {
655 gl.delete_buffer(v)
656 }
657 if let Some(v) = self.ebo.take() {
658 gl.delete_buffer(v)
659 }
660 if let Some(p) = self.program.take() {
661 gl.delete_program(p)
662 }
663 }
664 unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {
665 if self.program.is_some() {
666 return Ok(());
667 }
668 let program = link(gl, VERTEX, FRAGMENT)?;
669 let vao = gl.create_vertex_array().map_err(|err| err.to_string())?;
670 let vbo = gl.create_buffer().map_err(|err| err.to_string())?;
671 let ebo = gl.create_buffer().map_err(|err| err.to_string())?;
672 self.program = Some(program);
673 self.vao = Some(vao);
674 self.vbo = Some(vbo);
675 self.ebo = Some(ebo);
676 Ok(())
677 }
678
679 unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {
680 let mut interleaved = Vec::with_capacity(frame.positions.len() * 16);
681 for index in 0..frame.positions.len() {
682 let position = frame.positions[index];
683 let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);
684 let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);
685 let weight = frame
686 .weights
687 .get(index)
688 .copied()
689 .unwrap_or([1.0, 0.0, 0.0, 0.0]);
690 interleaved.extend_from_slice(&position);
691 interleaved.extend_from_slice(&normal);
692 interleaved.extend([
693 joint[0] as f32,
694 joint[1] as f32,
695 joint[2] as f32,
696 joint[3] as f32,
697 ]);
698 interleaved.extend_from_slice(&weight);
699 interleaved.extend_from_slice(&frame.uvs.get(index).copied().unwrap_or([0.; 2]));
700 }
701 gl.bind_vertex_array(self.vao);
702 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
703 gl.buffer_data_u8_slice(
704 glow::ARRAY_BUFFER,
705 bytemuck_f32(&interleaved),
706 glow::DYNAMIC_DRAW,
707 );
708 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
709 gl.buffer_data_u8_slice(
710 glow::ELEMENT_ARRAY_BUFFER,
711 bytemuck_u32(&frame.indices),
712 glow::DYNAMIC_DRAW,
713 );
714 let stride = 16 * 4;
715 enable_float(gl, 0, 3, stride, 0);
716 enable_float(gl, 1, 3, stride, 3 * 4);
717 enable_float(gl, 2, 4, stride, 6 * 4);
718 enable_float(gl, 3, 4, stride, 10 * 4);
719 enable_float(gl, 4, 2, stride, 14 * 4);
720 for (unit, texture) in [&frame.albedo, &frame.shiny].iter().enumerate() {
721 if let Some(old) = self.textures[unit].take() {
722 gl.delete_texture(old);
723 }
724 if let Some(texture) = texture {
725 gl.active_texture(glow::TEXTURE0 + unit as u32);
726 self.textures[unit] = Some(upload_texture(gl, texture)?);
727 }
728 }
729 for old in self
730 .surface_textures
731 .drain(..)
732 .chain(self.surface_shiny.drain(..))
733 .flatten()
734 {
735 gl.delete_texture(old);
736 }
737 gl.active_texture(glow::TEXTURE0);
738 for surface in &frame.surfaces {
739 let id = match &surface.albedo {
740 Some(texture) => Some(upload_texture(gl, texture)?),
741 None => None,
742 };
743 self.surface_textures.push(id);
744 let shiny = match &surface.shiny {
745 Some(texture) => Some(upload_texture(gl, texture)?),
746 None => None,
747 };
748 self.surface_shiny.push(shiny);
749 }
750 self.uploaded = frame.generation;
751 self.index_count = frame.indices.len() as i32;
752 Ok(())
753 }
754}
755
756/// Nearest + REPEAT RGBA8 texture, bound to the active unit.
757unsafe fn upload_texture(
758 gl: &glow::Context,
759 texture: &crate::equipment::Texture,
760) -> Result<glow::Texture, String> {
761 let id = gl.create_texture().map_err(|e| e.to_string())?;
762 gl.bind_texture(glow::TEXTURE_2D, Some(id));
763 gl.tex_parameter_i32(
764 glow::TEXTURE_2D,
765 glow::TEXTURE_MIN_FILTER,
766 glow::NEAREST as i32,
767 );
768 gl.tex_parameter_i32(
769 glow::TEXTURE_2D,
770 glow::TEXTURE_MAG_FILTER,
771 glow::NEAREST as i32,
772 );
773 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
774 gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
775 gl.tex_image_2d(
776 glow::TEXTURE_2D,
777 0,
778 glow::RGBA8 as i32,
779 texture.width as i32,
780 texture.height as i32,
781 0,
782 glow::RGBA,
783 glow::UNSIGNED_BYTE,
784 Some(&texture.rgba),
785 );
786 Ok(id)
787}
788
789unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
790 gl.enable_vertex_attrib_array(index);
791 gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
792}
793
794fn bytemuck_f32(values: &[f32]) -> &[u8] {
795 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
796}
797
798fn bytemuck_u32(values: &[u32]) -> &[u8] {
799 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
800}
801
802unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result<glow::Program, String> {
803 let program = gl.create_program().map_err(|err| err.to_string())?;
804 let vs = compile(gl, glow::VERTEX_SHADER, vertex)?;
805 let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?;
806 gl.attach_shader(program, vs);
807 gl.attach_shader(program, fs);
808 gl.link_program(program);
809 if !gl.get_program_link_status(program) {
810 return Err(gl.get_program_info_log(program));
811 }
812 gl.delete_shader(vs);
813 gl.delete_shader(fs);
814 Ok(program)
815}
816
817unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result<glow::Shader, String> {
818 let shader = gl.create_shader(stage).map_err(|err| err.to_string())?;
819 gl.shader_source(shader, source);
820 gl.compile_shader(shader);
821 if !gl.get_shader_compile_status(shader) {
822 return Err(gl.get_shader_info_log(shader));
823 }
824 Ok(shader)
825}
826
827const VERTEX: &str = r#"#version 330
828layout(location = 0) in vec3 a_pos;
829layout(location = 1) in vec3 a_normal;
830layout(location = 2) in vec4 a_joints;
831layout(location = 3) in vec4 a_weights;
832layout(location = 4) in vec2 a_uv;
833uniform mat4 u_model;
834out vec2 v_uv;
835out vec3 v_world;
836uniform mat4 u_view_proj;
837uniform mat4 u_bones[128];
838out vec3 v_normal;
839void main() {
840 int j0 = int(a_joints.x);
841 int j1 = int(a_joints.y);
842 int j2 = int(a_joints.z);
843 int j3 = int(a_joints.w);
844 mat4 skin = a_weights.x * u_bones[j0]
845 + a_weights.y * u_bones[j1]
846 + a_weights.z * u_bones[j2]
847 + a_weights.w * u_bones[j3];
848 vec4 world = u_model * skin * vec4(a_pos, 1.0);
849 v_uv = a_uv; v_world=world.xyz;
850 v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;
851 gl_Position = u_view_proj * world;
852}
853"#;
854
855const FRAGMENT: &str = r#"#version 330
856in vec3 v_normal;
857uniform vec4 u_color;
858uniform sampler2D u_albedo;
859uniform sampler2D u_shiny;
860uniform bool u_albedo_enabled;
861uniform bool u_shiny_enabled;
862uniform float u_specular;
863uniform float u_cutoff;
864uniform vec3 u_eye;
865in vec2 v_uv;
866in vec3 v_world;
867out vec4 frag;
868void main() {
869 vec3 n = normalize(v_normal);
870 vec3 light = normalize(vec3(0.35, 0.85, 0.4));
871 float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
872 vec4 color=u_color;
873 if(u_albedo_enabled) color*=texture(u_albedo,v_uv);
874 if(color.a<u_cutoff) discard;
875 float strength=u_specular;
876 if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));
877 float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;
878 frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);
879}
880"#;
881
882/// Real GPU regression using the same cache/shader/paint path as the callback.
883/// Tests front-first/back-first overlap, depth writes, and the next-frame clear.
884///
885/// # Safety
886/// `gl` must be a current native OpenGL context. Run in a disposable test window;
887/// this probe changes GL state and restores the default framebuffer afterwards.
888pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
889 let native_bits = gl.get_framebuffer_attachment_parameter_i32(
890 glow::FRAMEBUFFER,
891 glow::DEPTH,
892 glow::FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE,
893 );
894 if native_bits < 24 {
895 return Err(format!(
896 "native depth attachment ontbreekt: {native_bits} bits"
897 ));
898 }
899 let framebuffer = gl.create_framebuffer()?;
900 let texture = gl.create_texture()?;
901 let depth = gl.create_renderbuffer()?;
902 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
903 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
904 gl.tex_parameter_i32(
905 glow::TEXTURE_2D,
906 glow::TEXTURE_MIN_FILTER,
907 glow::NEAREST as i32,
908 );
909 gl.tex_parameter_i32(
910 glow::TEXTURE_2D,
911 glow::TEXTURE_MAG_FILTER,
912 glow::NEAREST as i32,
913 );
914 gl.tex_image_2d(
915 glow::TEXTURE_2D,
916 0,
917 glow::RGBA8 as i32,
918 32,
919 32,
920 0,
921 glow::RGBA,
922 glow::UNSIGNED_BYTE,
923 None,
924 );
925 gl.framebuffer_texture_2d(
926 glow::FRAMEBUFFER,
927 glow::COLOR_ATTACHMENT0,
928 glow::TEXTURE_2D,
929 Some(texture),
930 0,
931 );
932 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(depth));
933 gl.renderbuffer_storage(glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, 32, 32);
934 gl.framebuffer_renderbuffer(
935 glow::FRAMEBUFFER,
936 glow::DEPTH_ATTACHMENT,
937 glow::RENDERBUFFER,
938 Some(depth),
939 );
940 gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
941 gl.read_buffer(glow::COLOR_ATTACHMENT0);
942 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
943 return Err("onvolledig offscreen depth-target".into());
944 }
945 let info = egui::PaintCallbackInfo {
946 viewport: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32., 32.)),
947 clip_rect: egui::Rect::EVERYTHING,
948 pixels_per_point: 1.,
949 screen_size_px: [32, 32],
950 };
951 let make_frame = |indices: Vec<u32>, generation: u64| PendingFrame {
952 generation,
953 positions: vec![
954 [-0.9, -0.9, -0.5],
955 [0.9, -0.9, -0.5],
956 [0., 0.9, -0.5],
957 [-0.9, -0.9, 0.5],
958 [0.9, -0.9, 0.5],
959 [0., 0.9, 0.5],
960 ],
961 normals: vec![
962 [0.35, 0.85, 0.4],
963 [0.35, 0.85, 0.4],
964 [0.35, 0.85, 0.4],
965 [-0.35, -0.85, -0.4],
966 [-0.35, -0.85, -0.4],
967 [-0.35, -0.85, -0.4],
968 ],
969 joints: vec![[0; 4]; 6],
970 weights: vec![[1., 0., 0., 0.]; 6],
971 indices,
972 bones: vec![crate::preview::identity()],
973 view_proj: crate::preview::identity(),
974 color: [1.; 4],
975 model: crate::preview::identity(),
976 eye: [0., 0., -3.],
977 albedo: None,
978 shiny: None,
979 specular: 0.,
980 uvs: vec![[0.; 2]; 6],
981 equipment: Vec::new(),
982 surfaces: Vec::new(),
983 cutoff: EQUIPMENT_CUTOFF,
984 };
985 let mut cache = GpuCache::default();
986 let read = || {
987 let mut rgba = [0u8; 4];
988 gl.read_pixels(
989 16,
990 16,
991 1,
992 1,
993 glow::RGBA,
994 glow::UNSIGNED_BYTE,
995 glow::PixelPackData::Slice(&mut rgba),
996 );
997 rgba
998 };
999 let result = (|| {
1000 // Deliberately poison the incoming state: the callback owns its depth setup.
1001 gl.depth_mask(false);
1002 gl.clear_depth_f32(0.0);
1003 gl.depth_func(glow::GREATER);
1004 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
1005 cache.paint(gl, &info, true);
1006 let near = read();
1007 if near[..3].iter().any(|c| *c < 250) {
1008 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
1009 }
1010 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
1011 cache.paint(gl, &info, true);
1012 let next = read();
1013 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
1014 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
1015 }
1016 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
1017 cache.paint(gl, &info, true);
1018 let reverse = read();
1019 if near != reverse {
1020 return Err(format!(
1021 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
1022 ));
1023 }
1024 let mut body = make_frame(vec![0, 1, 2], 4);
1025 let mut behind = make_frame(vec![3, 4, 5], 5);
1026 behind.color = [1., 0., 0., 1.];
1027 body.equipment.push(behind);
1028 cache.pending = Some(body);
1029 cache.paint(gl, &info, true);
1030 let hidden = read();
1031 if hidden != near {
1032 return Err(format!("equipment wist body-depth: {hidden:?}"));
1033 }
1034 let mut body = make_frame(vec![3, 4, 5], 6);
1035 let mut front = make_frame(vec![0, 1, 2], 7);
1036 front.albedo = Some(Arc::new(crate::equipment::Texture {
1037 content_hash: 1,
1038 width: 1,
1039 height: 1,
1040 rgba: vec![255, 0, 0, 255],
1041 }));
1042 body.equipment.push(front.clone());
1043 cache.pending = Some(body.clone());
1044 cache.paint(gl, &info, true);
1045 let textured = read();
1046 if textured[0] < 250 || textured[1] > 2 || textured[2] > 2 {
1047 return Err(format!("voorste equipmenttexture ontbreekt: {textured:?}"));
1048 }
1049 front.generation = 8;
1050 front.albedo = Some(Arc::new(crate::equipment::Texture {
1051 content_hash: 2,
1052 width: 1,
1053 height: 1,
1054 rgba: vec![255, 0, 0, 0],
1055 }));
1056 body.equipment = vec![front];
1057 cache.pending = Some(body);
1058 cache.paint(gl, &info, true);
1059 let cutout = read();
1060 if cutout != next {
1061 return Err(format!("alpha-cutout bedekt body: {cutout:?}"));
1062 }
1063 let mut body = make_frame(vec![3, 4, 5], 9);
1064 let mut front = make_frame(vec![0, 1, 2], 10);
1065 front.color = [0., 0., 0., 1.];
1066 front.specular = 1.;
1067 front.eye = [35000., 85000., 40000.];
1068 front.shiny = Some(Arc::new(crate::equipment::Texture {
1069 content_hash: 3,
1070 width: 1,
1071 height: 1,
1072 rgba: vec![255, 255, 255, 255],
1073 }));
1074 body.equipment = vec![front.clone()];
1075 cache.pending = Some(body.clone());
1076 cache.paint(gl, &info, true);
1077 let shiny = read();
1078 front.generation = 11;
1079 front.shiny = Some(Arc::new(crate::equipment::Texture {
1080 content_hash: 4,
1081 width: 1,
1082 height: 1,
1083 rgba: vec![0, 0, 0, 255],
1084 }));
1085 body.equipment = vec![front];
1086 cache.pending = Some(body);
1087 cache.paint(gl, &info, true);
1088 let matte = read();
1089 if shiny[..3].iter().any(|c| *c < 250) || matte[..3] != [0, 0, 0] {
1090 return Err(format!(
1091 "shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}"
1092 ));
1093 }
1094 // Body with two materials: each surface uses its own texture × factor.
1095 let cyan = Arc::new(crate::equipment::Texture {
1096 content_hash: 5,
1097 width: 1,
1098 height: 1,
1099 rgba: vec![0, 255, 255, 255],
1100 });
1101 let mut body = make_frame(vec![3, 4, 5, 0, 1, 2], 12);
1102 body.surfaces = vec![
1103 SurfaceDraw {
1104 first: 0,
1105 count: 3,
1106 color: [1., 0., 0., 1.],
1107 albedo: None,
1108 cutoff: 0.001,
1109 specular: 0.,
1110 shiny: None,
1111 },
1112 SurfaceDraw {
1113 first: 3,
1114 count: 3,
1115 color: [1., 0.5, 1., 1.],
1116 albedo: Some(cyan.clone()),
1117 cutoff: 0.001,
1118 specular: 0.,
1119 shiny: None,
1120 },
1121 ];
1122 cache.pending = Some(body.clone());
1123 cache.paint(gl, &info, true);
1124 let surface_front = read();
1125 if surface_front[0] > 2
1126 || !(120..=136).contains(&surface_front[1])
1127 || surface_front[2] < 250
1128 {
1129 return Err(format!(
1130 "voorste materiaal niet als texture × factor getekend: {surface_front:?}"
1131 ));
1132 }
1133 body.generation = 13;
1134 body.surfaces[1].albedo = None;
1135 body.surfaces[0].albedo = Some(cyan);
1136 body.surfaces.swap(0, 1);
1137 body.surfaces[0].first = 0;
1138 body.surfaces[1].first = 3;
1139 body.indices = vec![0, 1, 2, 3, 4, 5];
1140 cache.pending = Some(body);
1141 cache.paint(gl, &info, true);
1142 let surface_swap = read();
1143 // Front triangle is now the factor-only material [1, 0.5, 1].
1144 if surface_swap[0] < 250 || !(120..=136).contains(&surface_swap[1]) || surface_swap[2] < 250
1145 {
1146 return Err(format!(
1147 "materiaal per primitive verwisseld: {surface_swap:?}"
1148 ));
1149 }
1150 // UV origin (Tim t1009u): body UVs are glTF (v = 0 is the top row of
1151 // the image), equipment UVs are bottom-left (v = 0 is the bottom row).
1152 let red_top_blue_bottom = Arc::new(crate::equipment::Texture {
1153 content_hash: 6,
1154 width: 1,
1155 height: 2,
1156 rgba: vec![255, 0, 0, 255, 0, 0, 255, 255],
1157 });
1158 let mut body = make_frame(vec![0, 1, 2], 14);
1159 body.uvs = vec![[0.5, 0.25]; 6];
1160 body.surfaces = vec![SurfaceDraw {
1161 first: 0,
1162 count: 3,
1163 color: [1.; 4],
1164 albedo: Some(red_top_blue_bottom.clone()),
1165 cutoff: 0.001,
1166 specular: 0.,
1167 shiny: None,
1168 }];
1169 cache.pending = Some(body);
1170 cache.paint(gl, &info, true);
1171 let body_uv = read();
1172 if body_uv[0] < 250 || body_uv[2] > 2 {
1173 return Err(format!(
1174 "body-UV niet volgens glTF (v=0 boven): {body_uv:?}"
1175 ));
1176 }
1177 let mut body = make_frame(vec![3, 4, 5], 15);
1178 let mut front = make_frame(vec![0, 1, 2], 16);
1179 front.uvs = vec![[0.5, 0.75]; 6]; // already converted from bottom-left v = 0.25
1180 front.albedo = Some(red_top_blue_bottom);
1181 body.equipment = vec![front];
1182 cache.pending = Some(body);
1183 cache.paint(gl, &info, true);
1184 let equipment_uv = read();
1185 if equipment_uv[2] < 250 || equipment_uv[0] > 2 {
1186 return Err(format!(
1187 "equipment-UV verkeerd georiënteerd: {equipment_uv:?}"
1188 ));
1189 }
1190 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:?}");
1191 Ok(())
1192 })();
1193 cache.destroy(gl);
1194 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
1195 gl.delete_framebuffer(framebuffer);
1196 gl.delete_texture(texture);
1197 gl.delete_renderbuffer(depth);
1198 result
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 /// Regression (Tim t1007u): the live body used one merged color and no
1206 /// texture. Every glTF material must reach the GPU frame as its own range
1207 /// with its own base color factor and external albedo texture.
1208 #[test]
1209 fn live_body_frame_keeps_every_material_texture_and_factor() {
1210 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1211 .join("tests/fixtures/bodies/multi-material/body.gltf");
1212 let body = crate::preview::load_body_file(&path).unwrap();
1213 let frame = body_frame(
1214 &body,
1215 Vec::new(),
1216 crate::preview::identity(),
1217 [0.; 3],
1218 Vec::new(),
1219 );
1220 assert_eq!(frame.surfaces.len(), 3);
1221 assert_eq!(frame.uvs.len(), frame.positions.len());
1222 // glTF UVs reach the GPU unchanged (top-left origin, no V flip).
1223 assert_eq!(frame.uvs, body.uvs);
1224 let ranges: Vec<_> = frame.surfaces.iter().map(|s| (s.first, s.count)).collect();
1225 assert_eq!(ranges, [(0, 6), (6, 6), (12, 6)]);
1226 assert_eq!(frame.surfaces[0].color, [0.2, 0.4, 0.9, 1.0]);
1227 assert!(frame.surfaces[0].albedo.is_none());
1228 assert_eq!(frame.surfaces[1].color, [1.; 4]);
1229 assert_eq!(
1230 &frame.surfaces[1].albedo.as_ref().unwrap().rgba[..4],
1231 [0, 200, 0, 255]
1232 );
1233 assert_eq!(frame.surfaces[2].color, [0.5, 1.0, 1.0, 1.0]);
1234 assert_eq!(
1235 &frame.surfaces[2].albedo.as_ref().unwrap().rgba[..4],
1236 [200, 200, 0, 255]
1237 );
1238 // KHR_materials_specular (Tim t998u.4): factor and alpha texture per material.
1239 assert_eq!(frame.surfaces[0].specular, 0.);
1240 assert!(frame.surfaces[0].shiny.is_none());
1241 assert_eq!(frame.surfaces[1].specular, 0.5);
1242 assert!(frame.surfaces[1].shiny.is_none());
1243 assert_eq!(frame.surfaces[2].specular, 1.0);
1244 assert_eq!(
1245 &frame.surfaces[2].shiny.as_ref().unwrap().rgba[..4],
1246 [102, 102, 102, 255]
1247 );
1248 // A different texture must re-upload: it is part of the generation.
1249 let mut changed = body.clone();
1250 std::sync::Arc::make_mut(changed.surfaces[1].albedo.as_mut().unwrap()).content_hash ^= 1;
1251 assert_ne!(character_generation(&body), character_generation(&changed));
1252 }
1253}