Uniform 缓冲区与 3D 虚拟摄像机
虽然我们之前的渲染似乎都是在 2D 空间下进行的,但实际上我们一直都是在 3D 空间下渲染的!这就是为什么 Vertex
结构体的 position
是 3 个浮点数的数组而不是 2 个。由于我们是在正面观察,所以才无法真正看到场景的立体感。下面将通过创建一个虚拟摄像机(Camera
)来改变我们的观察视角。
透视摄像机
本教程聚焦于 wgpu 的教学,而不是线性代数,所以会略过很多涉及的数学知识。如果你对线性代数感兴趣,网上有大量的阅读材料。我们将使用 glam 来处理所有数学问题,在 Cargo.toml
中添加以下依赖:
[dependencies]
# other deps...
glam = "0.29"
现在让我们开始使用此数学包!在 WgpuApp
结构体上方创建摄像机结构体:
struct Camera {
eye: glam::Vec3,
target: glam::Vec3,
up: glam::Vec3,
aspect: f32,
fovy: f32,
znear: f32,
zfar: f32,
}
impl Camera {
fn build_view_projection_matrix(&self) -> glam::Mat4 {
// 1.
let view = glam::Mat4::look_at_rh(self.eye, self.target, self.up);
// 2.
let proj = glam::Mat4::perspective_rh(self.fovy.to_radians(), self.aspect, self.znear, self.zfar);
// 3.
return proj * view;
}
}
build_view_projection_matrix
函数实现了视图投影矩阵。
- 视图矩阵移动并旋转世界坐标到摄像机所观察的位置。它本质上是摄像机变换的逆矩阵。
- 投影矩阵变换场景空间,以产生景深的效果。如果没有它,近处的物对象将与远处的大小相同。
- wgpu 的坐标系统是基于 DirectX 和 Metal 的坐标系,在归一化设备坐标中,x 轴和 y 轴的范围是 [-1.0, 1.0],而 z 轴是 [0.0, 1.0]。 移植 OpenGL 程序时需要注意:在 OpenGL 的归一化设备坐标中 z 轴的范围是 [-1.0, 1.0]。
现在我们来给 WgpuApp
添加上 camera
字段:
struct WgpuApp {
// ...
camera: Camera,
}
async fn new(window: Arc<Window>) -> Self {
// let diffuse_bind_group ...
let camera = Camera {
// 将摄像机向上移动 1 个单位,向后移动 2 个单位
// +z 朝向屏幕外
eye: (0.0, 1.0, 2.0).into(),
// 摄像机看向原点
target: (0.0, 0.0, 0.0).into(),
// 定义哪个方向朝上
up: glam::Vec3::Y,
aspect: config.width as f32 / config.height as f32,
fovy: 45.0,
znear: 0.1,
zfar: 100.0,
};
Self {
// ...
camera,
}
}
有了可以提供视图投影矩阵的摄像机,我们还需要一些方法将其引入着色器。
Uniform 缓冲区
到目前为止,我们已经使用缓冲区来存储顶点和索引数据,甚至加载纹理。我们将再次使用它来创建一个称之为 uniform
的缓冲区。Uniform 缓冲区也是一个数据块,在一组着色器的每个调用中都可以使用,从技术的角度来看,我们已经为纹理和采样器使用了 Uniform 缓冲区。下面将再次使用它们来存储视图投影矩阵,我们先创建一个结构体来保存 uniform:
// 此属性标注数据的内存布局兼容 C-ABI,令其可用于着色器
#[repr(C)]
// derive 属性自动导入的这些 trait,令其可被存入缓冲区
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct CameraUniform {
// glam 的数据类型不能直接用于 bytemuck
// 需要先将 Matrix4 矩阵转为一个 4x4 的浮点数数组
view_proj: [[f32; 4]; 4],
}
impl CameraUniform {
fn new() -> Self {
Self {
view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(),
}
}
fn update_view_proj(&mut self, camera: &Camera) {
self.view_proj = camera.build_view_projection_matrix().to_cols_array_2d();
}
}
封装好了数据,接下来创建一个名为 camera_buffer
的 Uniform 缓冲区:
// 在 new() 函数中创建 `camera` 后
let mut camera_uniform = CameraUniform::new();
camera_uniform.update_view_proj(&camera);
let camera_buffer = device.create_buffer_init(
&wgpu::util::BufferInitDescriptor {
label: Some("Camera Buffer"),
contents: bytemuck::cast_slice(&[camera_uniform]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
}
);
Uniform 缓冲区和绑定组
现在有了一个 Uniform 缓冲区,那该如何使用呢?答案是为它创建一个绑定组。我们得先创建绑定组的布局:
let camera_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX, // 1
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false, // 2
min_binding_size: None,
},
count: None,
}
],
label: Some("camera_bind_group_layout"),
});
- 我们只在顶点着色器中需要虚拟摄像机信息,因为要用它来操作顶点。
has_dynamic_offset
字段表示这个缓冲区是否会动态改变偏移量。如果我们想一次性在 Uniform 中存储多组数据,并实时修改偏移量来告诉着色器当前使用哪组数据时,这就很有用。
现在,我们可以创建实际的绑定组了:
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
layout: &camera_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}
],
label: Some("camera_bind_group"),
});
就像对纹理所做的那样,我们需要在管线布局描述符中注册 camera_bind_group_layout
:
let render_pipeline_layout = device.create_pipeline_layout(
&wgpu::PipelineLayoutDescriptor {
label: Some("Render Pipeline Layout"),
bind_group_layouts: &[
&texture_bind_group_layout,
&camera_bind_group_layout,
],
push_constant_ranges: &[],
}
);
现在,需要将 camera_buffer
和 camera_bind_group
添加到 WgpuApp
中:
struct WgpuApp {
// ...
camera: Camera,
camera_uniform: CameraUniform,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
}
async fn new(window: Arc<Window>) -> Self {
// ...
Self {
// ...
camera,
camera_uniform,
camera_buffer,
camera_bind_group,
}
}
在进入着色器之前,我们要做的最后一件事就是在 render()
函数中使用绑定组:
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_bind_group(0, &self.diffuse_bind_group, &[]);
// 新添加!
render_pass.set_bind_group(1, &self.camera_bind_group, &[]);
render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
render_pass.draw_indexed(0..self.num_indices, 0, 0..1);
在顶点着色器中使用 uniform
修改顶点着色器以加入如下代码:
// 顶点着色器
struct CameraUniform {
view_proj: mat4x4f,
};
@group(1) @binding(0) // 1.
var<uniform> camera: CameraUniform;
struct VertexInput {
@location(0) position: vec3f,
@location(1) tex_coords: vec2f,
}
struct VertexOutput {
@builtin(position) clip_position: vec4f,
@location(0) tex_coords: vec2f,
}
@vertex
fn vs_main(
model: VertexInput,
) -> VertexOutput {
var out: VertexOutput;
out.tex_coords = model.tex_coords;
out.clip_position = camera.view_proj * vec4f(model.position, 1.0); // 2.
return out;
}
- 因为我们已经创建了一个新的绑定组,所以需要指定在着色器中使用哪一个。这个数字由我们的
render_pipeline_layout
决定。texture_bind_group_layout
被列在第一位,因此它是group(0)
,而camera_bind_group
是第二位,因此它是group(1)
。 - 当涉及到矩阵时,乘法的顺序很重要。向量在最右边,矩阵按重要性顺序在左边(裁剪空间坐标 = 投影矩阵 x 模型视图矩阵 x 位置向量)。
摄像机控制器
如果现在运行代码,看到的将是如下渲染效果:
形状的拉伸度降低了,但它仍然是静态的。你可以尝试移动摄像机的位置使画面动起来,就像游戏中的摄像机通常所做的那样。由于本教程聚焦于 wgpu 的使用,而非用户输入事件的处理,所以仅在此贴出摄像机控制器(CameraController)的代码:
struct CameraController {
speed: f32,
is_forward_pressed: bool,
is_backward_pressed: bool,
is_left_pressed: bool,
is_right_pressed: bool,
}
impl CameraController {
fn new(speed: f32) -> Self {
Self {
speed,
is_forward_pressed: false,
is_backward_pressed: false,
is_left_pressed: false,
is_right_pressed: false,
}
}
fn process_events(&mut self, event: &WindowEvent) -> bool {
match event {
WindowEvent::KeyboardInput {
event:
KeyEvent {
state,
logical_key,
physical_key,
..
},
..
} => {
let is_pressed = *state == ElementState::Pressed;
match logical_key {
Key::Named(NamedKey::Space) => {
self.is_up_pressed = is_pressed;
return true;
}
_ => {}
}
match physical_key {
PhysicalKey::Code(KeyCode::ShiftLeft) => {
self.is_down_pressed = is_pressed;
true
}
PhysicalKey::Code(KeyCode::KeyW) | PhysicalKey::Code(KeyCode::ArrowUp) => {
self.is_forward_pressed = is_pressed;
true
}
PhysicalKey::Code(KeyCode::KeyA) | PhysicalKey::Code(KeyCode::ArrowLeft) => {
self.is_left_pressed = is_pressed;
true
}
PhysicalKey::Code(KeyCode::KeyS) | PhysicalKey::Code(KeyCode::ArrowDown) => {
self.is_backward_pressed = is_pressed;
true
}
PhysicalKey::Code(KeyCode::KeyD) | PhysicalKey::Code(KeyCode::ArrowRight) => {
self.is_right_pressed = is_pressed;
true
}
_ => false,
}
}
_ => false,
}
}
fn update_camera(&self, camera: &mut Camera) {
let forward = camera.target - camera.eye;
let forward_norm = forward.normalize();
let forward_mag = forward.length();
// 防止摄像机离场景中心太近时出现问题
if self.is_forward_pressed && forward_mag > self.speed {
camera.eye += forward_norm * self.speed;
}
if self.is_backward_pressed {
camera.eye -= forward_norm * self.speed;
}
let right = forward_norm.cross(camera.up);
// 在按下前进或后退键时重做半径计算
let forward = camera.target - camera.eye;
let forward_mag = forward.length();
if self.is_right_pressed {
// 重新调整目标和眼睛之间的距离,以便其不发生变化。
// 因此,眼睛仍然位于目标和眼睛形成的圆圈上。
camera.eye = camera.target - (forward + right * self.speed).normalize() * forward_mag;
}
if self.is_left_pressed {
camera.eye = camera.target - (forward - right * self.speed).normalize() * forward_mag;
}
}
}
这段代码并不完美。当你旋转摄像机时,摄像机会慢慢向后移动。虽然已达到了我们的目的,但你还是可以自由地改进它!
我们仍然需要把它插入到现有的代码中使其生效。将控制器添加到 WgpuApp
中,并在 new()
函数中创建它的实例:
struct WgpuApp {
// ...
camera: Camera,
// 新添加!
camera_controller: CameraController,
}
// ...
impl WgpuAppAction for WgpuApp {
async fn new(window: Arc<winit::window::Window>) -> Self {
// ...
let camera_controller = CameraController::new(0.2);
// ...
Self {
// ...
camera_controller,
}
}
}
将下边这行代码添加到 keyboard_input()
函数中。
fn keyboard_input(&mut self, event: &KeyEvent) -> bool {
self.camera_controller.process_events(event)
}
到目前为止,摄像机控制器还没有真正工作起来。uniform 缓冲区中的值需要被更新。有几种方式可以做到这一点:
- 可以创建一个单独的缓冲区,并将其数据复制到
camera_buffer
。这个新的缓冲区被称为中继缓冲区(Staging Buffer)。这种方法允许主缓冲区(在这里是指camera_buffer
)的数据只被 GPU 访问,从而令 GPU 能做一些速度上的优化。如果缓冲区能被 CPU 访问,就无法实现此类优化。 - 可以在缓冲区本身调用内存映射函数
map_read_async
和map_write_async
。此方式允许我们直接访问缓冲区的数据,但是需要处理异步代码,也需要缓冲区使用BufferUsages::MAP_READ
和/或BufferUsages::MAP_WRITE
。在此不再详述,如果你想了解更多,可以查看 wgpu without a window 教程。 - 可以在
queue
上使用write_buffer
函数。
我们将使用第 3 种方式。
fn update(&mut self) {
self.camera_controller.update_camera(&mut self.camera);
self.camera_uniform.update_view_proj(&self.camera);
self.app.queue.write_buffer(&self.camera_buffer, 0, bytemuck::cast_slice(&[self.camera_uniform]));
}
这就是要做的全部工作了。现在运行代码,将能看到一个带有树木纹理的五边形,并可以用 wasd/arrow 键来旋转和缩放。
挑战
让上面的五边形独立于摄像机进行旋转。提示:你需要另一个矩阵来实现这一点。