Skip to content

更好的摄像机

这个问题已经被推迟了一段时间。实现一个虚拟摄像机与正确使用 wgpu 关系不大,但它一直困扰着我,所以现在来实现它吧。

lib.rs 已经堆砌很多代码了,所以我们创建一个 camera.rs 文件来放置摄像机代码。先导入一些要用到的文件:

rust
use winit::event::*;
use winit::dpi::PhysicalPosition;
use instant::Duration;
use std::f32::consts::FRAC_PI_2;

const SAFE_FRAC_PI_2: f32 = FRAC_PI_2 - 0.0001;

在 WASM 中使用 std::time::instant 会导致程序恐慌,所以我们使用 instant 包来替代,在 Cargo.toml 引入此依赖:

toml
instant = "0.1"

虚拟摄像机

接下来,需要创建一个新的 Camera 结构体。我们将使用一个 FPS 风格的摄像机,所以要存储位置(position)、 yaw(偏航,水平旋转)以及 pitch(俯仰,垂直旋转), 定义并实现一个 calc_matrix 函数用于创建视图矩阵:

rust
#[derive(Debug)]
pub struct Camera {
    pub position: glam::Vec3,
    yaw: f32,
    pitch: f32,
}

impl Camera {
    pub fn new<V: Into<glam::Vec3>>(position: V, yaw: f32, pitch: f32) -> Self {
        Self {
            position: position.into(),
            yaw,
            pitch,
        }
    }

    pub fn calc_matrix(&self) -> glam::Mat4 {
        let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
        let (sin_yaw, cos_yaw) = self.yaw.sin_cos();

        glam::Mat4::look_to_rh(
            self.position,
            glam::Vec3::new(cos_pitch * cos_yaw, sin_pitch, cos_pitch * sin_yaw).normalize(),
            glam::Vec3::Y,
        )
    }
}

投影

只有在窗口调整大小时,投影(Projection)才真正需要改变,所以我们将投影与摄像机分开,创建一个 Projection 结构体:

rust
pub struct Projection {
    aspect: f32,
    fovy: f32,
    znear: f32,
    zfar: f32,
}

impl Projection {
    pub fn new(width: u32, height: u32, fovy: f32, znear: f32, zfar: f32) -> Self {
        Self {
            aspect: width as f32 / height as f32,
            fovy: fovy.to_radians(),
            znear,
            zfar,
        }
    }

    pub fn resize(&mut self, width: u32, height: u32) {
        self.aspect = width as f32 / height as f32;
    }

    pub fn calc_matrix(&self) -> glam::Mat4 {
        glam::Mat4::perspective_rh(self.fovy, self.aspect, self.znear, self.zfar)
    }
}

有一点需要注意:从 perspective_rh 函数返回的是右手坐标系(right-handed coordinate system)的投影矩阵。也就是说,Z 轴是指向屏幕外的,想让 Z 轴指向屏幕内(也就是左手坐标系的投影矩阵)需要使用 perspective_lh

可以这样分辨右手坐标系和左手坐标系的区别:在身体的正前方把你的拇指指向右边代表 X 轴,食指指向上方代表 Y 轴,伸出中指代表 Z 轴。此时在你的右手上,中指应该指是向你自己。而在左手上,应该是指向远方。

./left_right_hand.gif

摄像机控制器

现在,我们需要一个新的摄像机控制器,在 camera.rs 中添加以下代码:

rust
#[derive(Debug)]
pub struct CameraController {
    amount_left: f32,
    amount_right: f32,
    amount_forward: f32,
    amount_backward: f32,
    amount_up: f32,
    amount_down: f32,
    rotate_horizontal: f32,
    rotate_vertical: f32,
    scroll: f32,
    speed: f32,
    sensitivity: f32,
}

impl CameraController {
    pub fn new(speed: f32, sensitivity: f32) -> Self {
        Self {
            amount_left: 0.0,
            amount_right: 0.0,
            amount_forward: 0.0,
            amount_backward: 0.0,
            amount_up: 0.0,
            amount_down: 0.0,
            rotate_horizontal: 0.0,
            rotate_vertical: 0.0,
            scroll: 0.0,
            speed,
            sensitivity,
        }
    }

    pub fn process_keyboard(&mut self, physical_key: &PhysicalKey, logical_key: &Key, state: ElementState) -> bool {
        let amount = if state == ElementState::Pressed {
            1.0
        } else {
            0.0
        };
        match logical_key {
            Key::Named(NamedKey::Space) => {
                self.amount_up = amount;
                return true;
            }
            _ => {}
        }
        match physical_key {
            PhysicalKey::Code(KeyCode::ShiftLeft) => {
                self.amount_down = amount;
                true
            }
            PhysicalKey::Code(KeyCode::KeyW) | PhysicalKey::Code(KeyCode::ArrowUp) => {
                self.amount_forward = amount;
                true
            }
            PhysicalKey::Code(KeyCode::KeyA) | PhysicalKey::Code(KeyCode::ArrowLeft) => {
                self.amount_left = amount;
                true
            }
            PhysicalKey::Code(KeyCode::KeyS) | PhysicalKey::Code(KeyCode::ArrowDown) => {
                self.amount_backward = amount;
                true
            }
            PhysicalKey::Code(KeyCode::KeyD) | PhysicalKey::Code(KeyCode::ArrowRight) => {
                self.amount_right = amount;
                true
            }
            _ => false,
        }
    }

    pub fn process_mouse(&mut self, mouse_dx: f64, mouse_dy: f64) {
        self.rotate_horizontal = mouse_dx as f32;
        self.rotate_vertical = mouse_dy as f32;
    }

    pub fn process_scroll(&mut self, delta: &MouseScrollDelta) {
        self.scroll = -match delta {
            // 假定一行为 100 个像素,你可以随意修改这个值
            MouseScrollDelta::LineDelta(_, scroll) => scroll * 100.0,
            MouseScrollDelta::PixelDelta(PhysicalPosition {
                y: scroll,
                ..
            }) => *scroll as f32,
        };
    }

    pub fn update_camera(&mut self, camera: &mut Camera, dt: Duration) {
        let dt = dt.as_secs_f32();

        // 前后左右移动
        let (yaw_sin, yaw_cos) = camera.yaw.sin_cos();
        let forward = glam::Vec3::new(yaw_cos, 0.0, yaw_sin).normalize();
        let right = glam::Vec3::new(-yaw_sin, 0.0, yaw_cos).normalize();
        camera.position += forward * (self.amount_forward - self.amount_backward) * self.speed * dt;
        camera.position += right * (self.amount_right - self.amount_left) * self.speed * dt;

        // 变焦(缩放)
        // 注意:这不是一个真实的变焦。
        // 通过摄像机的位置变化来模拟变焦,使你更容易靠近想聚焦的物体。
        let (pitch_sin, pitch_cos) = camera.pitch.sin_cos();
        let scrollward = glam::Vec3::new(pitch_cos * yaw_cos, pitch_sin, pitch_cos * yaw_sin).normalize();
        camera.position += scrollward * self.scroll * self.speed * self.sensitivity * dt;
        self.scroll = 0.0;

        // 由于我们没有使用滚动,所以直接修改 y 坐标来上下移动。
        camera.position.y += (self.amount_up - self.amount_down) * self.speed * dt;

        // 旋转
        camera.yaw += self.rotate_horizontal * self.sensitivity * dt;
        camera.pitch += -self.rotate_vertical * self.sensitivity * dt;

        // 重置旋转值为 0。没有鼠标移动发生时,摄像机就停止旋转。
        self.rotate_horizontal = 0.0;
        self.rotate_vertical = 0.0;

        // 保持摄像机的角度不要太高/太低。
        if camera.pitch < -SAFE_FRAC_PI_2 {
            camera.pitch = -SAFE_FRAC_PI_2;
        } else if camera.pitch > SAFE_FRAC_PI_2 {
            camera.pitch = SAFE_FRAC_PI_2;
        }
    }
}

清理 lib.rs

首先,我们从 lib.rs 中删除 CameraCameraController,然后导入 camera.rs

rust
mod model;
mod texture;
mod camera; // 新增!

接着更新 update_view_proj 以使用新的 CameraProjection

rust

struct CameraUniform {
    view_position: [f32; 4],
    view_proj: [[f32; 4]; 4],
}

impl CameraUniform {

    fn new() -> Self {
        Self {
            view_position: [0.0; 4],
            view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
        }
    }

    // 更新!
    fn update_view_proj(&mut self, camera: &camera::Camera, projection: &camera::Projection) {
        self.view_position = camera.position.extend(1.0).into();
        self.view_proj = (projection.calc_matrix() * camera.calc_matrix()).to_cols_array_2d();
    }
}

我们还要修改 WgpuApp 来使用新的 CameraCameraProjectionProjection,再添加一个mouse_pressed 字段来存储鼠标是否被按下:

rust
struct WgpuApp {
    // ...
    camera: camera::Camera, // 更新!
    projection: camera::Projection, // 新增!
    camera_controller: camera::CameraController, // 更新!
    // ...
    // 新增!
    mouse_pressed: bool,
}

别忘了需要导入 winit::dpi::PhysicalPosition

然后更新 new() 函数:

rust
impl WgpuAppAction for WgpuApp {
    async fn new(window: Arc<winit::window::Window>) -> Self {
        // ...

        // 更新!
        let camera = camera::Camera::new((0.0, 5.0, 10.0), -90.0, -20.0);
        let projection = camera::Projection::new(config.width, config.height, 45.0, 0.1, 100.0);
        let camera_controller = camera::CameraController::new(4.0, 0.4);

        // ...

        camera_uniform.update_view_proj(&camera, &projection); // 更新!

        // ...

        Self {
            // ...
            camera,
            projection, // 新增!
            camera_controller,
            // ...
            mouse_pressed: false, // 新增!
        }
    }
}

接着在 resize_surface_if_needed 函数中更新投影矩阵 projection

rust
fn resize_surface_if_needed(&mut self) {
    // 更新!
    self.projection.resize(self.size.width, self.size.height);
    // ...
}

事件输入函数也需要被更新。 到目前为止,我们一直在使用 WindowEvent 来控制摄像机,这很有效,但它并不是最好的解决方案。winit 文档告诉我们,操作系统通常会对 CursorMoved 事件的数据进行转换,以实现光标加速等效果。

现在为了解决这个问题,可以修改输入函数函数来处理 DeviceEvent 而不是 WindowEvent

rust
// 更新!
// UPDATED!
fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
    self.camera_controller.process_keyboard(
        &event.physical_key,
        &event.logical_key,
        event.state,
    );
    true
}

fn mouse_click(&mut self, state: ElementState, button: MouseButton) -> bool {
    if button == MouseButton::Left {
        self.mouse_pressed = state == ElementState::Pressed;
        true
    } else {
        false
    }
}

fn mouse_wheel(&mut self, delta: MouseScrollDelta, _phase: TouchPhase) -> bool {
    self.camera_controller.process_scroll(&delta);
    true
}

fn device_input(&mut self, event: &DeviceEvent) -> bool {
    if let DeviceEvent::MouseMotion { delta } = event {
        if self.mouse_pressed {
            self.camera_controller.process_mouse(delta.0, delta.1);
            return true;
        }
    }
    false
}

update 函数需要多解释一下:CameraController 上的 update_camera 函数有一个参数 dt,它是之间的时间差(delta time,也可以说是时间间隔),用来辅助实现摄像机的平滑移动,使其不被帧速率所锁定。所以将它作为一个参数传入 update

rust
fn update(&mut self, dt: instant::Duration) {
    // 更新!
    self.camera_controller.update_camera(&mut self.camera, dt);
    self.camera_uniform.update_view_proj(&self.camera, &self.projection);

    // ..
}

既然如此,我们也用 dt 来平滑光源的旋转:

rust
self.light_uniform.position =
    (glam::Quat::from_axis_angle(glam::Vec3::Y, (60.0 * dt.as_secs_f32()).to_radians())
    * old_position).into(); // 更新!

让我们在 WgpuAppHandlerwindow_event 函数中来实现 dt 的具体计算:

rust
struct WgpuAppHandler<A: WgpuAppAction> {
    // ...

    /// 上次执行渲染的时间
    last_render_time: instant::Instant,
}

impl<A: WgpuAppAction + 'static> ApplicationHandler for WgpuAppHandler<A> {
    // ...

    fn window_event(
        &mut self,
        event_loop: &ActiveEventLoop,
        _window_id: WindowId,
        event: WindowEvent,
    ) {
        match event {
            // ...
            // 更新!
            WindowEvent::RedrawRequested => {
                let now = instant::Instant::now();
                let dt = now - self.last_render_time;
                self.last_render_time = now;
                app.update(dt);
                // ...
            }
            _ => {}
        }
    }
}

现在,我们应该可以自由控制摄像机了:

./screenshot.png