//! OpenGL viewport for one skinned glTF body. //! //! The shaded mesh is a placement preview. It is not the unlit paperdoll //! diffuse/depth/normal export. use std::sync::{Arc, Mutex}; use eframe::egui; use egui_glow::CallbackFn; use glow::HasContext; use crate::preview::{ clip_duration, eye_offset, mul, perspective, sample_skin_matrices, view_matrix, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT, }; const MAX_BONES: usize = crate::preview::MAX_JOINTS; pub struct Viewport { cache: Arc>, } impl Default for Viewport { fn default() -> Self { Self::new() } } impl Viewport { pub fn new() -> Self { Self { cache: Arc::new(Mutex::new(GpuCache::default())), } } pub fn show( &self, ui: &mut egui::Ui, controls: &mut PreviewControls, character: Option<&PreviewCharacter>, ) { ui.horizontal_wrapped(|ui| { if let Some(character) = character { if !character .clips .iter() .any(|clip| clip.name == controls.clip_name) { if let Some(clip) = character.clips.first() { controls.select_clip(&clip.name); } } for clip in &character.clips { if ui .selectable_label(controls.clip_name == clip.name, &clip.name) .clicked() { controls.select_clip(&clip.name); } } } if ui .selectable_label( controls.playing, if controls.playing { "Pauze" } else { "Speel" }, ) .clicked() { controls.playing = !controls.playing; } if ui.button("Yaw −").clicked() { controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1); } if ui.button("Yaw +").clicked() { controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1); } for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() { if ui.button(format!("pitch {pitch:.0}")).clicked() { controls.snap_pitch_row(index as i32); } } ui.label(format!( "yaw {:.0}° / {} pitch {:.0}°", controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg )); }); ui.label( "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.", ); let available = ui.available_rect_before_wrap(); let rect = egui::Rect::from_min_size( available.min, egui::vec2(available.width().max(240.0), available.height().max(240.0)), ); let response = ui.allocate_rect(rect, egui::Sense::click_and_drag()); if response.dragged() { controls.drag(response.drag_delta().x, response.drag_delta().y); } let scroll = ui.input(|input| input.smooth_scroll_delta.y); if response.hovered() && scroll.abs() > 0.0 { controls.zoom(scroll); } let Some(character) = character else { ui.painter() .rect_filled(rect, 4.0, egui::Color32::from_rgb(18, 22, 28)); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, "Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.", egui::FontId::proportional(14.0), egui::Color32::from_rgb(210, 210, 210), ); return; }; let duration = clip_duration(character, &controls.clip_name); let dt = ui.input(|input| input.stable_dt); controls.advance(dt, duration); ui.ctx().request_repaint(); let bones = sample_skin_matrices(character, &controls.clip_name, controls.time); let aspect = rect.width() / rect.height().max(1.0); let target = [0.0, 0.9, 0.0]; let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance); let eye = [ target[0] + offset[0], target[1] + offset[1], target[2] + offset[2], ]; let view_proj = mul( perspective(38.0, aspect, 0.05, 40.0), view_matrix(eye, target), ); { let mut cache = self.cache.lock().expect("preview gpu lock"); cache.pending = Some(PendingFrame { generation: character_generation(character), positions: character.positions.clone(), normals: character.normals.clone(), joints: character.joints.clone(), weights: character.weights.clone(), indices: character.indices.clone(), bones, view_proj, color: character.base_color, }); } let cache = Arc::clone(&self.cache); let callback = CallbackFn::new(move |info, painter| { if let Ok(mut cache) = cache.lock() { unsafe { cache.paint(painter.gl(), &info) }; } }); ui.painter().add(egui::PaintCallback { rect, callback: Arc::new(callback), }); } } fn character_generation(character: &PreviewCharacter) -> u64 { use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); for point in &character.positions { point.map(f32::to_bits).hash(&mut hash); } for normal in &character.normals { normal.map(f32::to_bits).hash(&mut hash); } for weights in &character.weights { weights.map(f32::to_bits).hash(&mut hash); } character.joints.hash(&mut hash); character.indices.hash(&mut hash); hash.finish() } #[derive(Clone)] struct PendingFrame { generation: u64, positions: Vec<[f32; 3]>, normals: Vec<[f32; 3]>, joints: Vec<[u16; 4]>, weights: Vec<[f32; 4]>, indices: Vec, bones: Vec, view_proj: crate::preview::Mat4, color: [f32; 4], } #[derive(Default)] struct GpuCache { program: Option, vao: Option, vbo: Option, ebo: Option, uploaded: u64, index_count: i32, pending: Option, error: Option, } impl GpuCache { unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) { let Some(frame) = self.pending.clone() else { return; }; if let Err(error) = self.ensure_program(gl) { self.error = Some(error); return; } if self.uploaded != frame.generation { if let Err(error) = self.upload(gl, &frame) { self.error = Some(error); return; } } let Some(program) = self.program else { return; }; let viewport = info.viewport_in_pixels(); if viewport.width_px <= 0 || viewport.height_px <= 0 { return; } gl.enable(glow::SCISSOR_TEST); gl.scissor( viewport.left_px, viewport.from_bottom_px, viewport.width_px, viewport.height_px, ); gl.viewport( viewport.left_px, viewport.from_bottom_px, viewport.width_px, viewport.height_px, ); gl.enable(glow::DEPTH_TEST); gl.depth_func(glow::LESS); gl.disable(glow::BLEND); gl.clear_color(0.08, 0.09, 0.12, 1.0); gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); gl.use_program(Some(program)); let view = gl.get_uniform_location(program, "u_view_proj"); gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj); let color = gl.get_uniform_location(program, "u_color"); gl.uniform_4_f32( color.as_ref(), frame.color[0], frame.color[1], frame.color[2], frame.color[3], ); let mut bones = vec![crate::preview::identity(); MAX_BONES]; for (index, bone) in frame.bones.iter().take(MAX_BONES).enumerate() { bones[index] = *bone; } let flat: Vec = bones.iter().flat_map(|bone| *bone).collect(); let bones_loc = gl.get_uniform_location(program, "u_bones"); gl.uniform_matrix_4_f32_slice(bones_loc.as_ref(), false, &flat); gl.bind_vertex_array(self.vao); gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0); gl.bind_vertex_array(None); gl.disable(glow::DEPTH_TEST); gl.disable(glow::SCISSOR_TEST); gl.enable(glow::BLEND); } unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> { if self.program.is_some() { return Ok(()); } let program = link(gl, VERTEX, FRAGMENT)?; let vao = gl.create_vertex_array().map_err(|err| err.to_string())?; let vbo = gl.create_buffer().map_err(|err| err.to_string())?; let ebo = gl.create_buffer().map_err(|err| err.to_string())?; self.program = Some(program); self.vao = Some(vao); self.vbo = Some(vbo); self.ebo = Some(ebo); Ok(()) } unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> { let mut interleaved = Vec::with_capacity(frame.positions.len() * 14); for index in 0..frame.positions.len() { let position = frame.positions[index]; let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]); let joint = frame.joints.get(index).copied().unwrap_or([0, 0, 0, 0]); let weight = frame .weights .get(index) .copied() .unwrap_or([1.0, 0.0, 0.0, 0.0]); interleaved.extend_from_slice(&position); interleaved.extend_from_slice(&normal); interleaved.extend([ joint[0] as f32, joint[1] as f32, joint[2] as f32, joint[3] as f32, ]); interleaved.extend_from_slice(&weight); } gl.bind_vertex_array(self.vao); gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo); gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, bytemuck_f32(&interleaved), glow::DYNAMIC_DRAW, ); gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo); gl.buffer_data_u8_slice( glow::ELEMENT_ARRAY_BUFFER, bytemuck_u32(&frame.indices), glow::DYNAMIC_DRAW, ); let stride = 14 * 4; enable_float(gl, 0, 3, stride, 0); enable_float(gl, 1, 3, stride, 3 * 4); enable_float(gl, 2, 4, stride, 6 * 4); enable_float(gl, 3, 4, stride, 10 * 4); self.uploaded = frame.generation; self.index_count = frame.indices.len() as i32; Ok(()) } } unsafe fn enable_float(gl: &glow::Context, index: u32, size: i32, stride: i32, offset: i32) { gl.enable_vertex_attrib_array(index); gl.vertex_attrib_pointer_f32(index, size, glow::FLOAT, false, stride, offset); } fn bytemuck_f32(values: &[f32]) -> &[u8] { unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) } } fn bytemuck_u32(values: &[u32]) -> &[u8] { unsafe { std::slice::from_raw_parts(values.as_ptr().cast(), std::mem::size_of_val(values)) } } unsafe fn link(gl: &glow::Context, vertex: &str, fragment: &str) -> Result { let program = gl.create_program().map_err(|err| err.to_string())?; let vs = compile(gl, glow::VERTEX_SHADER, vertex)?; let fs = compile(gl, glow::FRAGMENT_SHADER, fragment)?; gl.attach_shader(program, vs); gl.attach_shader(program, fs); gl.link_program(program); if !gl.get_program_link_status(program) { return Err(gl.get_program_info_log(program)); } gl.delete_shader(vs); gl.delete_shader(fs); Ok(program) } unsafe fn compile(gl: &glow::Context, stage: u32, source: &str) -> Result { let shader = gl.create_shader(stage).map_err(|err| err.to_string())?; gl.shader_source(shader, source); gl.compile_shader(shader); if !gl.get_shader_compile_status(shader) { return Err(gl.get_shader_info_log(shader)); } Ok(shader) } const VERTEX: &str = r#"#version 330 layout(location = 0) in vec3 a_pos; layout(location = 1) in vec3 a_normal; layout(location = 2) in vec4 a_joints; layout(location = 3) in vec4 a_weights; uniform mat4 u_view_proj; uniform mat4 u_bones[128]; out vec3 v_normal; void main() { int j0 = int(a_joints.x); int j1 = int(a_joints.y); int j2 = int(a_joints.z); int j3 = int(a_joints.w); mat4 skin = a_weights.x * u_bones[j0] + a_weights.y * u_bones[j1] + a_weights.z * u_bones[j2] + a_weights.w * u_bones[j3]; vec4 world = skin * vec4(a_pos, 1.0); v_normal = mat3(skin) * a_normal; gl_Position = u_view_proj * world; } "#; const FRAGMENT: &str = r#"#version 330 in vec3 v_normal; uniform vec4 u_color; out vec4 frag; void main() { vec3 n = normalize(v_normal); vec3 light = normalize(vec3(0.35, 0.85, 0.4)); float shade = 0.28 + 0.72 * max(dot(n, light), 0.0); frag = vec4(u_color.rgb * shade, 1.0); } "#;