BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview_gl.rs

preview_gl.rs

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