BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview_gl.rs

preview_gl.rs

preview_gl.rs 18.9 KiB Download Raw
1//! OpenGL viewport for one skinned glTF body.
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 show(
47 &self,
48 ui: &mut egui::Ui,
49 controls: &mut PreviewControls,
50 character: Option<&PreviewCharacter>,
51 ) {
52 ui.horizontal_wrapped(|ui| {
53 if let Some(character) = character {
54 if !character
55 .clips
56 .iter()
57 .any(|clip| clip.name == controls.clip_name)
58 {
59 if let Some(clip) = character.clips.first() {
60 controls.select_clip(&clip.name);
61 }
62 }
63 for clip in &character.clips {
64 if ui
65 .selectable_label(controls.clip_name == clip.name, &clip.name)
66 .clicked()
67 {
68 controls.select_clip(&clip.name);
69 }
70 }
71 }
72 if ui
73 .selectable_label(
74 controls.playing,
75 if controls.playing { "Pauze" } else { "Speel" },
76 )
77 .clicked()
78 {
79 controls.playing = !controls.playing;
80 }
81 if ui.button("Yaw −").clicked() {
82 controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);
83 }
84 if ui.button("Yaw +").clicked() {
85 controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);
86 }
87 for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {
88 if ui.button(format!("pitch {pitch:.0}")).clicked() {
89 controls.snap_pitch_row(index as i32);
90 }
91 }
92 ui.label(format!(
93 "yaw {:.0}° / {} pitch {:.0}°",
94 controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg
95 ));
96 });
97 ui.label(
98 "Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",
99 );
100 let available = ui.available_rect_before_wrap();
101 let rect = egui::Rect::from_min_size(
102 available.min,
103 egui::vec2(available.width().max(240.0), available.height().max(240.0)),
104 );
105 let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
106 if response.dragged() {
107 controls.drag(response.drag_delta().x, response.drag_delta().y);
108 }
109 let scroll = ui.input(|input| input.smooth_scroll_delta.y);
110 if response.hovered() && scroll.abs() > 0.0 {
111 controls.zoom(scroll);
112 }
113 let Some(character) = character else {
114 ui.painter()
115 .rect_filled(rect, 4.0, egui::Color32::from_rgb(18, 22, 28));
116 ui.painter().text(
117 rect.center(),
118 egui::Align2::CENTER_CENTER,
119 "Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.",
120 egui::FontId::proportional(14.0),
121 egui::Color32::from_rgb(210, 210, 210),
122 );
123 return;
124 };
125 let duration = clip_duration(character, &controls.clip_name);
126 let dt = ui.input(|input| input.stable_dt);
127 controls.advance(dt, duration);
128 ui.ctx().request_repaint();
129 let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
130 let aspect = rect.width() / rect.height().max(1.0);
131 let target = [0.0, 0.9, 0.0];
132 let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);
133 let eye = [
134 target[0] + offset[0],
135 target[1] + offset[1],
136 target[2] + offset[2],
137 ];
138 let view_proj = mul(
139 perspective(38.0, aspect, 0.05, 40.0),
140 view_matrix(eye, target),
141 );
142 {
143 let mut cache = self.cache.lock().expect("preview gpu lock");
144 cache.pending = Some(PendingFrame {
145 generation: character_generation(character),
146 positions: character.positions.clone(),
147 normals: character.normals.clone(),
148 joints: character.joints.clone(),
149 weights: character.weights.clone(),
150 indices: character.indices.clone(),
151 bones,
152 view_proj,
153 color: character.base_color,
154 });
155 }
156 let cache = Arc::clone(&self.cache);
157 let callback = CallbackFn::new(move |info, painter| {
158 if let Ok(mut cache) = cache.lock() {
159 unsafe { cache.paint(painter.gl(), &info) };
160 }
161 });
162 ui.painter().add(egui::PaintCallback {
163 rect,
164 callback: Arc::new(callback),
165 });
166 }
167}
168
169fn character_generation(character: &PreviewCharacter) -> u64 {
170 use std::hash::{Hash, Hasher};
171 let mut hash = std::collections::hash_map::DefaultHasher::new();
172 for point in &character.positions {
173 point.map(f32::to_bits).hash(&mut hash);
174 }
175 for normal in &character.normals {
176 normal.map(f32::to_bits).hash(&mut hash);
177 }
178 for weights in &character.weights {
179 weights.map(f32::to_bits).hash(&mut hash);
180 }
181 character.joints.hash(&mut hash);
182 character.indices.hash(&mut hash);
183 hash.finish()
184}
185
186#[derive(Clone)]
187struct PendingFrame {
188 generation: u64,
189 positions: Vec<[f32; 3]>,
190 normals: Vec<[f32; 3]>,
191 joints: Vec<[u16; 4]>,
192 weights: Vec<[f32; 4]>,
193 indices: Vec<u32>,
194 bones: Vec<crate::preview::Mat4>,
195 view_proj: crate::preview::Mat4,
196 color: [f32; 4],
197}
198
199#[derive(Default)]
200struct GpuCache {
201 program: Option<glow::Program>,
202 vao: Option<glow::VertexArray>,
203 vbo: Option<glow::Buffer>,
204 ebo: Option<glow::Buffer>,
205 uploaded: u64,
206 index_count: i32,
207 pending: Option<PendingFrame>,
208 error: Option<String>,
209}
210
211impl GpuCache {
212 unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {
213 let Some(frame) = self.pending.clone() else {
214 return;
215 };
216 if let Err(error) = self.ensure_program(gl) {
217 self.error = Some(error);
218 return;
219 }
220 if self.uploaded != frame.generation {
221 if let Err(error) = self.upload(gl, &frame) {
222 self.error = Some(error);
223 return;
224 }
225 }
226 let Some(program) = self.program else {
227 return;
228 };
229 let viewport = info.viewport_in_pixels();
230 if viewport.width_px <= 0 || viewport.height_px <= 0 {
231 return;
232 }
233 gl.enable(glow::SCISSOR_TEST);
234 gl.scissor(
235 viewport.left_px,
236 viewport.from_bottom_px,
237 viewport.width_px,
238 viewport.height_px,
239 );
240 gl.viewport(
241 viewport.left_px,
242 viewport.from_bottom_px,
243 viewport.width_px,
244 viewport.height_px,
245 );
246 gl.enable(glow::DEPTH_TEST);
247 gl.depth_mask(true);
248 gl.clear_depth_f32(1.0);
249 gl.depth_func(glow::LESS);
250 gl.disable(glow::BLEND);
251 gl.clear_color(0.08, 0.09, 0.12, 1.0);
252 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
253 gl.use_program(Some(program));
254 let view = gl.get_uniform_location(program, "u_view_proj");
255 gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);
256 let color = gl.get_uniform_location(program, "u_color");
257 gl.uniform_4_f32(
258 color.as_ref(),
259 frame.color[0],
260 frame.color[1],
261 frame.color[2],
262 frame.color[3],
263 );
264 let mut bones = vec![crate::preview::identity(); MAX_BONES];
265 for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() {
266 bones[index] = *bone;
267 }
268 let flat: Vec<f32> = bones.iter().flat_map(|bone| *bone).collect();
269 let bones_loc = gl.get_uniform_location(program, "u_bones");
270 gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat);
271 gl.bind_vertex_array(self.vao);
272 gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
273 gl.bind_vertex_array(None);
274 gl.disable(glow::DEPTH_TEST);
275 gl.disable(glow::SCISSOR_TEST);
276 gl.enable(glow::BLEND);
277 }
278
279 unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {
280 if self.program.is_some() {
281 return Ok(());
282 }
283 let program = link(gl, VERTEX, FRAGMENT)?;
284 let vao = gl.create_vertex_array().map_err(|err| err.to_string())?;
285 let vbo = gl.create_buffer().map_err(|err| err.to_string())?;
286 let ebo = gl.create_buffer().map_err(|err| err.to_string())?;
287 self.program = Some(program);
288 self.vao = Some(vao);
289 self.vbo = Some(vbo);
290 self.ebo = Some(ebo);
291 Ok(())
292 }
293
294 unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {
295 let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);
296 for index in 0..frame.positions.len() {
297 let position = frame.positions[index];
298 let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);
299 let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]);
300 let weight = frame
301 .weights
302 .get(index)
303 .copied()
304 .unwrap_or([1.0, 0.0, 0.0, 0.0]);
305 interleaved.extend_from_slice(&position);
306 interleaved.extend_from_slice(&normal);
307 interleaved.extend([
308 joint[0] as f32,
309 joint[1] as f32,
310 joint[2] as f32,
311 joint[3] as f32,
312 ]);
313 interleaved.extend_from_slice(&weight);
314 }
315 gl.bind_vertex_array(self.vao);
316 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
317 gl.buffer_data_u8_slice(
318 glow::ARRAY_BUFFER,
319 bytemuck_f32(&interleaved),
320 glow::DYNAMIC_DRAW,
321 );
322 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
323 gl.buffer_data_u8_slice(
324 glow::ELEMENT_ARRAY_BUFFER,
325 bytemuck_u32(&frame.indices),
326 glow::DYNAMIC_DRAW,
327 );
328 let stride = 14 * 4;
329 enable_float(gl, 0, 3, stride, 0);
330 enable_float(gl, 1, 3, stride, 3 * 4);
331 enable_float(gl, 2, 4, stride, 6 * 4);
332 enable_float(gl, 3, 4, stride, 10 * 4);
333 self.uploaded = frame.generation;
334 self.index_count = frame.indices.len() as i32;
335 Ok(())
336 }
337}
338
339unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) {
340 gl.enable_vertex_attrib_array(index);
341 gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset);
342}
343
344fn bytemuck_f32(values: &[f32]) -> &[u8] {
345 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
346}
347
348fn bytemuck_u32(values: &[u32]) -> &[u8] {
349 unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) }
350}
351
352unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result<glow::Program, String> {
353 let program = gl.create_program().map_err(|err| err.to_string())?;
354 let vs = compile(gl, glow::VERTEX_SHADER, vertex)?;
355 let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?;
356 gl.attach_shader(program, vs);
357 gl.attach_shader(program, fs);
358 gl.link_program(program);
359 if !gl.get_program_link_status(program) {
360 return Err(gl.get_program_info_log(program));
361 }
362 gl.delete_shader(vs);
363 gl.delete_shader(fs);
364 Ok(program)
365}
366
367unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result<glow::Shader, String> {
368 let shader = gl.create_shader(stage).map_err(|err| err.to_string())?;
369 gl.shader_source(shader, source);
370 gl.compile_shader(shader);
371 if !gl.get_shader_compile_status(shader) {
372 return Err(gl.get_shader_info_log(shader));
373 }
374 Ok(shader)
375}
376
377const VERTEX: &str = r#"#version 330
378layout(location = 0) in vec3 a_pos;
379layout(location = 1) in vec3 a_normal;
380layout(location = 2) in vec4 a_joints;
381layout(location = 3) in vec4 a_weights;
382uniform mat4 u_view_proj;
383uniform mat4 u_bones[128];
384out vec3 v_normal;
385void main() {
386 int j0 = int(a_joints.x);
387 int j1 = int(a_joints.y);
388 int j2 = int(a_joints.z);
389 int j3 = int(a_joints.w);
390 mat4 skin = a_weights.x * u_bones[j0]
391 + a_weights.y * u_bones[j1]
392 + a_weights.z * u_bones[j2]
393 + a_weights.w * u_bones[j3];
394 vec4 world = skin * vec4(a_pos, 1.0);
395 v_normal = mat3(skin) * a_normal;
396 gl_Position = u_view_proj * world;
397}
398"#;
399
400const FRAGMENT: &str = r#"#version 330
401in vec3 v_normal;
402uniform vec4 u_color;
403out vec4 frag;
404void main() {
405 vec3 n = normalize(v_normal);
406 vec3 light = normalize(vec3(0.35, 0.85, 0.4));
407 float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
408 frag = vec4(u_color.rgb * shade, 1.0);
409}
410"#;
411
412/// Real GPU regression using the same cache/shader/paint path as the callback.
413/// Tests front-first/back-first overlap, depth writes, and the next-frame clear.
414///
415/// # Safety
416/// `gl` must be a current native OpenGL context. Run in a disposable test window;
417/// this probe changes GL state and restores the default framebuffer afterwards.
418pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
419 let native_bits = gl.get_framebuffer_attachment_parameter_i32(
420 glow::FRAMEBUFFER,
421 glow::DEPTH,
422 glow::FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE,
423 );
424 if native_bits < 24 {
425 return Err(format!(
426 "native depth attachment ontbreekt: {native_bits} bits"
427 ));
428 }
429 let framebuffer = gl.create_framebuffer()?;
430 let texture = gl.create_texture()?;
431 let depth = gl.create_renderbuffer()?;
432 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
433 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
434 gl.tex_parameter_i32(
435 glow::TEXTURE_2D,
436 glow::TEXTURE_MIN_FILTER,
437 glow::NEAREST as i32,
438 );
439 gl.tex_parameter_i32(
440 glow::TEXTURE_2D,
441 glow::TEXTURE_MAG_FILTER,
442 glow::NEAREST as i32,
443 );
444 gl.tex_image_2d(
445 glow::TEXTURE_2D,
446 0,
447 glow::RGBA8 as i32,
448 32,
449 32,
450 0,
451 glow::RGBA,
452 glow::UNSIGNED_BYTE,
453 None,
454 );
455 gl.framebuffer_texture_2d(
456 glow::FRAMEBUFFER,
457 glow::COLOR_ATTACHMENT0,
458 glow::TEXTURE_2D,
459 Some(texture),
460 0,
461 );
462 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(depth));
463 gl.renderbuffer_storage(glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, 32, 32);
464 gl.framebuffer_renderbuffer(
465 glow::FRAMEBUFFER,
466 glow::DEPTH_ATTACHMENT,
467 glow::RENDERBUFFER,
468 Some(depth),
469 );
470 gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
471 gl.read_buffer(glow::COLOR_ATTACHMENT0);
472 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
473 return Err("onvolledig offscreen depth-target".into());
474 }
475 let info = egui::PaintCallbackInfo {
476 viewport: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32., 32.)),
477 clip_rect: egui::Rect::EVERYTHING,
478 pixels_per_point: 1.,
479 screen_size_px: [32, 32],
480 };
481 let make_frame = |indices: Vec<u32>, generation: u64| PendingFrame {
482 generation,
483 positions: vec![
484 [-0.9, -0.9, -0.5],
485 [0.9, -0.9, -0.5],
486 [0., 0.9, -0.5],
487 [-0.9, -0.9, 0.5],
488 [0.9, -0.9, 0.5],
489 [0., 0.9, 0.5],
490 ],
491 normals: vec![
492 [0.35, 0.85, 0.4],
493 [0.35, 0.85, 0.4],
494 [0.35, 0.85, 0.4],
495 [-0.35, -0.85, -0.4],
496 [-0.35, -0.85, -0.4],
497 [-0.35, -0.85, -0.4],
498 ],
499 joints: vec![[0; 4]; 6],
500 weights: vec![[1., 0., 0., 0.]; 6],
501 indices,
502 bones: vec![crate::preview::identity()],
503 view_proj: crate::preview::identity(),
504 color: [1.; 4],
505 };
506 let mut cache = GpuCache::default();
507 let read = || {
508 let mut rgba = [0u8; 4];
509 gl.read_pixels(
510 16,
511 16,
512 1,
513 1,
514 glow::RGBA,
515 glow::UNSIGNED_BYTE,
516 glow::PixelPackData::Slice(&mut rgba),
517 );
518 rgba
519 };
520 let result = (|| {
521 // Deliberately poison the incoming state: the callback owns its depth setup.
522 gl.depth_mask(false);
523 gl.clear_depth_f32(0.0);
524 gl.depth_func(glow::GREATER);
525 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
526 cache.paint(gl, &info);
527 let near = read();
528 if near[..3].iter().any(|c| *c < 250) {
529 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
530 }
531 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
532 cache.paint(gl, &info);
533 let next = read();
534 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
535 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
536 }
537 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
538 cache.paint(gl, &info);
539 let reverse = read();
540 if near != reverse {
541 return Err(format!(
542 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
543 ));
544 }
545 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}");
546 Ok(())
547 })();
548 if let Some(p) = cache.program {
549 gl.delete_program(p)
550 }
551 if let Some(v) = cache.vao {
552 gl.delete_vertex_array(v)
553 }
554 if let Some(v) = cache.vbo {
555 gl.delete_buffer(v)
556 }
557 if let Some(v) = cache.ebo {
558 gl.delete_buffer(v)
559 }
560 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
561 gl.delete_framebuffer(framebuffer);
562 gl.delete_texture(texture);
563 gl.delete_renderbuffer(depth);
564 result
565}