BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview_gl.rs

preview_gl.rs

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