Files
yawn/src/camera.rs
T
Akash Shakdwipeea cb65a52e95 gltf models loader (#10)
* use channels for comms

this also refactors the app setup and messaging modules

* remove double logging

* load single model gltf files

* add gltf loader

* add sponza model

* camera with rotors (#11)

* orbit with rotors

* add dolly behaviour to emulate zoom
2025-10-04 12:47:30 +05:30

299 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::f32::consts::PI;
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3, Vec4};
use wgpu::util::DeviceExt;
use crate::{message::WheelMessage, renderer::scene::UniformResource};
const MIN_DISTANCE: f32 = 0.1;
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
const ORBIT_SENSITIVITY: f32 = 0.005;
const ZOOM_SENSITIVITY: f32 = 0.002;
#[repr(C)]
pub struct Camera {
// Hot data - cached computed matrix (64 bytes, 1 cache line)
pub view_proj: [[f32; 4]; 4],
// Warm data - frequently accessed vectors (36 bytes)
position: Vec3,
target: Vec3,
up: Vec3,
// Cold data - projection parameters (16 bytes)
fov: f32,
aspect_ratio: f32,
z_near: f32,
z_far: f32,
// Rotor orientation + spherical coordinates for orbit camera behaviour
rotor: Rotor3,
distance: f32,
yaw: f32,
pitch: f32,
// Dirty flag for lazy evaluation
dirty: bool,
}
struct OrthonormalBasis {
right: Vec3,
up: Vec3,
forward: Vec3,
}
impl OrthonormalBasis {
pub fn new(right: Vec3, up: Vec3, forward: Vec3) -> Self {
Self { right, up, forward }
}
pub fn from_camera(camera: &Camera) -> Self {
let mut forward_offset = camera.target - camera.position;
if forward_offset.mag_sq() <= f32::EPSILON {
forward_offset = -Vec3::unit_z();
}
let forward = forward_offset.normalized();
let mut right = forward.cross(camera.up);
// Check if right vector is near zero (forward and up are parallel)
if right.mag_sq() < 1e-10 {
// Try alternate axes to find a valid right vector
let alternate_axes = [Vec3::unit_y(), Vec3::unit_x()];
for axis in alternate_axes.iter() {
right = forward.cross(*axis);
if right.mag_sq() >= 1e-10 {
break;
}
}
}
right = right.normalized();
let up = right.cross(forward).normalized();
Self::new(right, up, forward)
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
impl Camera {
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
position: Vec3::new(0.0, 1.5, 0.0),
target: Vec3::zero(),
up: Vec3::unit_y(),
fov: PI / 3.0,
aspect_ratio,
z_near: 0.1,
z_far: 100000.0,
rotor: Rotor3::identity(),
distance: 1.0,
yaw: 0.0,
pitch: 0.0,
dirty: true,
};
camera.compute_rotor();
camera.compute_view_proj_mat();
camera
}
pub fn compute_view_proj_mat(&mut self) {
let view = Mat4::look_at(self.position, self.target, self.up);
let proj = projection::rh_yup::perspective_wgpu_dx(
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
);
self.view_proj = (proj * view).into();
self.dirty = false;
}
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
self.position = position;
self.target = target;
self.up = Vec3::unit_y();
self.compute_rotor();
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn set_depth_range(&mut self, z_near: f32, z_far: f32) {
self.z_near = z_near;
self.z_far = z_far.max(z_near + f32::EPSILON);
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn position(&self) -> Vec3 {
self.position
}
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
// Skip tiny movements to reduce unnecessary computations
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
return;
}
let yaw_theta = delta_x * ORBIT_SENSITIVITY;
let yaw_rotor =
Rotor3::from_angle_plane(yaw_theta, Bivec3::from_normalized_axis(Vec3::unit_y()));
let basis = OrthonormalBasis::from_camera(self);
let desired_pitch = (self.pitch - delta_y * ORBIT_SENSITIVITY).clamp(-MAX_PITCH, MAX_PITCH);
let applied_pitch = desired_pitch - self.pitch;
let pitch_rotor =
Rotor3::from_angle_plane(applied_pitch, Bivec3::from_normalized_axis(basis.right));
let orbit_rotor = (yaw_rotor * pitch_rotor).normalized();
self.rotor = (orbit_rotor * self.rotor).normalized();
let mut offset = self.position - self.target;
if offset.mag_sq() <= f32::EPSILON {
offset = Vec3::unit_z() * self.distance.max(MIN_DISTANCE);
}
orbit_rotor.rotate_vec(&mut offset);
self.distance = offset.mag().max(MIN_DISTANCE);
self.position = offset + self.target;
self.yaw += yaw_theta;
self.pitch = desired_pitch;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn zoom(&mut self, msg: &WheelMessage) {
let mut delta = msg.delta_y as f32;
// Match browser delta modes so the wheel delta is always roughly pixels.
match msg.delta_mode {
1 => delta *= 16.0,
2 => delta *= 800.0,
_ => {}
}
// Scrolling up should zoom in.
delta = -delta;
if delta.abs() <= f32::EPSILON {
return;
}
// Get forward direction from camera position to target
let mut forward_vec = self.target - self.position;
if forward_vec.mag_sq() <= f32::EPSILON {
forward_vec = Vec3::unit_z();
}
let forward_dir = forward_vec.normalized();
let current_distance = forward_vec.mag();
// Scale dolly movement by distance to target for consistent perceived zoom speed
let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance;
let dolly_translation = forward_dir * dolly_distance;
self.position += dolly_translation;
self.target += dolly_translation;
self.compute_rotor();
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: "camera uniform buffer".into(),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
contents: bytemuck::cast_slice(&[self.view_proj]),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Uniform bind group layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Uniform bind group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 1,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
fn compute_rotor(&mut self) {
let offset = self.position - self.target;
let distance = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
self.distance = distance.max(MIN_DISTANCE);
// to compute the initial rotor we will do two rotations
// these will orient the camera to the new coordinates
//
// but first we need the orthonormal basis for the current camera
let basis = OrthonormalBasis::from_camera(self);
// first rotation
// this is the swing to make position face the target
let camera_local_up = Vec3::unit_z();
let swing_rotor = Rotor3::from_rotation_between(camera_local_up, -basis.forward);
// now we need a twist rotor which aligns the camera up
let mut up_after_swing = self.up.clone();
swing_rotor.rotate_vec(&mut up_after_swing);
// to rotate a vector by a rotor we need
// - a bivector (represents the axis of rotation)
// - angle of rotation
let twist_axis = (-basis.forward).normalized();
let twist_plane = Bivec3::from_normalized_axis(twist_axis);
// Calculate twist angle between the up vectors:
// u1 × uc ⋅ (-f)
// θ = atan2( ————————————— , u1 ⋅ uc )
// ‖u1 × uc‖
//
// Where:
// u1 = up vector after swing rotation
// uc = camera's current up vector
// f = forward vector (twist axis)
let theta = up_after_swing
.cross(self.up)
.dot(twist_axis)
.atan2(up_after_swing.dot(self.up));
let twist_rotor = Rotor3::from_angle_plane(theta, twist_plane);
self.rotor = (swing_rotor * twist_rotor).normalized();
}
}