说在前面
这是什么?
这是一本WGPU教程(理论上),面向图形学初学者和闲的没事的酱油党。但是由于一时兴起,作者决定后续再写游戏开发的教程。目前该书正在施工中。
示例代码
可以执行的示例代码在本书源代码仓库的wgpu-tutor-src
文件夹内。
引言
阅读本文需要Rust基础,以及一小部分Rust Async的知识,未满足要求者请阅读Rust Book
警告: 切忌将本教程当作一本正经的教程,否则后果自负。文中笔者可能会使用模糊不清甚至粗俗的语言,若有不适,请适度阅读。
以上的话请务必不要当真。实话实说,Rust中称得上尽人意的安全图形库确实不多,其中的先锋gfx早已停止维护,转而进行gfx-hal的开发GL,狗都不用
WGPU 可能不是这些库中性能最高的,却绝对称得上是最易用的,也是笔者(下文中统称"我")最喜欢的图形库。
在这篇教程中,我会尽量用通俗易懂的语言向读者(下文中或许会简称"你")介绍WGPU的基本使用。鉴于图形库的性质,我会相对详细地介绍其中涉及的数学知识以及图形库工作的方式,并且会提及到其他图形库中的写法。图形学初学者和其他图形库用户过来打酱油都可以放心食用。
程序,窗口和循环
在我们开始激动人心的WGPU之旅前,我们需要为其准备一个舞台,也就是窗口。写过其他图形应用程序的读者应该知道,不同平台对窗口初始化的方式千差万别,而句柄的差异又让后续的图形库初始化难以统一。在Rust中,多亏了winit和RawWindowHandle的包装,用户得以避免为每个平台写一份代码的悲伤状况(甚至支持Android和iOS)。在本章节中,我们将介绍使用winit初始化窗口和控制循环的方式。
窗口初始化
现在我们将介绍如何用winit初始化窗口。
提示: 在
main.rs
中加入#![windows_subsystem = "window"]
标签将会在Windows平台编译时将入口点改为WINMAIN
,因而使得程序启动时不会弹出一个CMD。不需要的读者可以自行删去这一行。其他平台不会受到该标签的影响。
首先我们需要将winit加入到依赖中
# Cargo.toml
# ...
[dependencies]
winit = "0.30"
然后,神说:
#![windows_subsystem = "window"] use std::sync::Arc; use winit::application::ApplicationHandler; struct Application { window: Arc<winit::window::Window>, } #[derive(Default)] struct State { app: Option<Application>, } impl ApplicationHandler for State { fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) { self.app = Some(Application { window: Arc::new( event_loop .create_window( winit::window::Window::default_attributes().with_title("窗口标题"), ) .unwrap(), ), }) } fn window_event( &mut self, _event_loop: &winit::event_loop::ActiveEventLoop, _window_id: winit::window::WindowId, _event: winit::event::WindowEvent, ) { } } fn main() -> anyhow::Result<()> { let event_loop = winit::event_loop::EventLoop::new()?; let mut state = State::default(); event_loop.run_app(&mut state)?; Ok(()) }
于是就有了窗口。
如果你是从旧版的winit迁移过来的,就会发现窗口的初始化麻烦了不少。这是因为winit在
0.30.0
版本进行了一次重构。为了适应多平台(主要是Android),将事件处理和应用状态融合在了一起。
让我们解释一下上面的代码,winit中的类型winit::event_loop::EventLoop
是winit中用来传递窗口事件的类型,一个winit应用可以创建多个窗口,而这多个窗口的事件都经由创建它们的EventLoop
处理。值得注意的是EventLoop
可以传入用户自定义的事件,详情请阅读EventLoop的文档。
EventLoop
通过EventLoop::run_app
方法来处理事件。run_app
会接受一个ApplicationHandler
结构的可变引用,而这个实现了ApplicationHandler
的结构则定义了事件具体如何被处理。
ApplicationHandler
中有多个方法,每种方法对应了一类特定的事件。在其文档中亦有记载。其中最特殊的两个,也是没有默认实现的两个,是resumed
和window_event
。
winit推荐我们在resumed
中创建窗口和其他应用实例,这是因为在一些移动平台中(如Android),应用启动后还并不一定具备可以开始渲染内容的条件,而且也可能会在运行过程中丢失后重新获得渲染能力(如回到桌面屏幕再点开)。这些失去渲染能力再重新获得渲染能力的情况皆由resumed
事件类型传递,因此我们应当在其中创建窗口和初始化渲染相关的内容。resumed
在所有平台上都会至少在开始应用时被传递一次,因此我们在其中创建一个窗口即可。由于本教程并不打算支持移动平台,因此没有处理渲染能力丢失的情况。若有感兴趣的读者可以查看并试着处理suspended
事件。在resumed
中创建的winit::window::Window
便是窗口的主体了,主要获取和操作窗口句柄,窗口大小,光标位置等,具体可以设置以及获取的数据请参见Window的文档。值得一提的是Window
实现了HasRawWindowHandle
,所以可以直接将&Window
和Arc<Window>
等引用作为各类图形库的窗口句柄输入。为了后续在初始化 WGPU 时生命周期的处理,我们加上了一层Arc
。
window_event
则负责传递和窗口直接相关的各种事件,如大小改变,拖拽,失去聚焦,缩放率变化等。窗口的渲染请求也在其中处理。正如我们之前所说,一个winit应用可以拥有多个窗口,window_event
会传入一个WindowId
方便我们区分这些窗口。
如果你翻阅了事件相关的文档,那么你或许发现了
WindowEvent
和DeviceEvent
里面都有键盘、鼠标相关的事件。值得注意的是,DeviceEvent
获取到的事件是直接来源于系统外设的信号的,因而并不受窗口聚焦等的限制。在游戏中,我们更倾向于使用后者,而在应用中我们更倾向于使用前者。而如果你是Wayland用户,则DeviceEvent
中根本不会收到键盘事件。
下一节,我们将会介绍如何处理窗口发送的事件,并且确定程序循环的主体。
事件发生!
在上一节中,我们初始化了一个窗口。如果有读者运行了上节的例程,就会发现运行后弹出了一个空白的窗口,但是不会对你的操作做出任何响应(除了移动和缩放),甚至点右上角的都没用。这是因为我们没有处理winit发来的事件,所有响应都是空的,这一节,我们将会学习处理一些基本的事件。
我们先来看一个例子
#![allow(unused)] fn main() { fn window_event( &mut self, event_loop: &winit::event_loop::ActiveEventLoop, window_id: winit::window::WindowId, event: winit::event::WindowEvent, ) { if let Some(Application { window }) = &mut self.app { if window.id() != window_id { return; } match event { WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::Resized(physical_size) => { resize(physical_size); } WindowEvent::RedrawRequested(_) => { render(); window.request_redraw(); } _ => (), } } }= }
这是一个渲染程序最基础的循环,现在我们来逐个分析这个回调中干了什么
首先,我们用match event {...}
来匹配事件,其中处理了三种事件:
WindowEvent::CloseRequested
会处理窗口收到的关闭请求,如点击。正是因为没处理这个事件,窗口才对根本不作响应。WindowEvent::RedrawRequested
事件会在窗口被要求重新绘制窗口内容时被调用,这也将会是渲染部分被调用的位置。我们在每次渲染结束时都调用一次window.request_redraw()
,这得以让我们的渲染自发地持续下去。WindowEvent::Resized
事件在窗口大小改变时传入,我们通常用其调整我们的帧缓冲大小(这是什么?一切皆在 3.1 揭晓)。- 剩下的事件暂时管不着,我们直接
_
这便是这个循环的主体,简单明了,却会是整个程序的主干,不容忽视。
现在我们的程序已经能进行基本的循环了。下一小节我们会介绍如何维持定时长的更新,这并非必要,却是游戏中常见的需求,读者可以自行选择跳过与否。
太快了!
控制恒定的更新速度是游戏中常见的需求,这一节我们将介绍一种简单的控制更新频率的方法。
玩过 Minecraft™ 吗?Minecraft中的游戏逻辑循环是以20次/秒的频率进行的,称为 20 tps(ticks per second),而渲染循环却以不同的频率(常见的为60个画面(称为 帧)每秒,称为60 fps(frames per second))刷新。较低的逻辑更新频率可以减少计算的负担,而更高的渲染频率则可以使游戏显得更加流畅。如果更新的速度不均匀,则可能使游戏显示的画面有违和的跳动。那么,我们应该如何控制更新的频率呢?
考虑这样一个问题,我们希望一个循环以60次每秒的速度进行更新,那么每次循环的平均用时应当为 ,现在有两种情况:
- 这次循环的用时比少,那我们自然可以等到时间为,时再开始下一次循环。其中是上一次循环结束的时间戳
- 如果这次循环用时比多呢?不妨假设这一次循环所耗的时间为我们整整缺少了次循环!一种简单的解决方案是,当场补上这次循环的更新并且直接进入下一个循环,而补上的循环的更新间隔。这样可以一定程度上保证更新的正确性,并且如果后续更新没有再消耗大于的时间的话也能顺利地接上
现在,我们来实现一下
use std::time::{Duration, Instant}; const TPS: f64 = 60.0; const TIMEOUT: f32 = 3.0; fn update(dt: Duration) { println!("delta tau: {:?}", dt); } fn main() { let tau = Duration::from_secs_f64(1.0 / TPS); let timeout = Duration::from_secs_f32(TIMEOUT); let begin = Instant::now(); let mut now = Instant::now(); let mut lag = Duration::ZERO; loop { let dt = now.elapsed(); now = Instant::now(); lag += dt; while lag > tau { update(tau); lag -= tau; } if begin.elapsed() > timeout { break; } } }
是不是非常简单呢?唯一值得注意的一点是我们并不用sleep
来控制间隔,而不终止循环的运行。因此我们可以有不同的频率同时运行。而主循环内部就是运行的最快的部分了。
use std::time::{Duration, Instant}; const TPS_0: f64 = 60.0; const TPS_1: f64 = 20.0; const TIMEOUT: f32 = 3.0; fn update(dt: Duration) { println!("60 tps delta tau: {:?}", dt); } fn slow_update(dt: Duration) { println!("20 tps delta tau: {:?}", dt); } fn fast_update(dt: Duration) { // 以最快可能速度运行的逻辑,通常不会使用 } fn main() { let tau_0 = Duration::from_secs_f64(1.0 / TPS_0); let tau_1 = Duration::from_secs_f64(1.0 / TPS_1); let timeout = Duration::from_secs_f32(TIMEOUT); let begin = Instant::now(); let mut now = Instant::now(); let mut lag_0 = Duration::ZERO; let mut lag_1 = Duration::ZERO; loop { let dt = now.elapsed(); now = Instant::now(); lag_0 += dt; lag_1 += dt; while lag_0 > tau_0 { update(tau_0); lag_0 -= tau_0; } while lag_1 > tau_1 { slow_update(tau_1); lag_1 -= tau_1; } fast_update(dt); // 为了使其能够在 playground 中运行,加上了超时限制。在实际应用中并不需要这几行 if begin.elapsed() > timeout { break; } } }
放进事件循环中也是同理,快去试试吧!
设备,队列,交换链?
从这一章开始,我们将正式进入WGPU的使用。本章将先介绍图形学库的基本工作方式,随后将再次介绍WGPU这个库和其基本的结构。最后,我们将为上一章创建的窗口初始化WGPU环境,并且做好渲染的准备工作。
于本章我希望读者可以对图形学引擎的工作方式有更深的理解,并且初步熟悉WGPU的结构。本章可能更偏向于理论和文字。
图形学,从入门到入土
注意: 本章节含有较大量
mermaid-js
的可视化代码,如果你看到了裸露的mermaid
代码块,请静等一段时间使得mermaid
被正常加载。如果较长时间后仍然无法看到图形,请检查您所处的网络环境下mermaid-js
有没有从jsdelivr
成功加载,必要时请使用恰当的方式使其可以加载。
说在前面
入什么门,直接入土得了
基本概念
帧缓冲 Frame Buffer
我们先来考虑一下要绘制一个图形的整体过程
flowchart LR
图形数据 -- 某些过程 --> 屏幕
我们知道,屏幕实际上是一系列的发光单元阵列(下文或称为 “像素”),屏幕依靠改变各发光单元(各组分)的亮度来显示不同的图形。因此,我们显示在屏幕(当然实际上操作系统会帮我们显示到对应的窗口)上的东西,实际上应该是一个矩阵,而矩阵元便是亮度。我们称存放这个矩阵的缓冲为 帧缓冲
flowchart LR
图形数据 -. 某些过程 .-> 帧缓冲 --> 屏幕
交换链 Swap Chain
有没有接触过老式的显像管电视机?或者一些老电脑?如果有,那你一定对扫描纹不陌生。扫描纹,是因为图像更新不及时而产生的一种显示现象。
我们先来说一个故事,假设你在和你的朋友玩一个游戏。现在你和你的朋友面前有一个 的棋盘,上面摆满了正面黑色,背面白色的棋子。你不能接触这个棋盘,但可以命令你的朋友翻转棋子,你的朋友不能自作主张,但是一定会听从你的命令,但是他不能同时翻转多个棋子。现在,你需要把全部棋子变白,然后交给评委检查。
于是,你向你的朋友下达命令:把全部棋子翻过来。但是你的朋友并不知道要以什么顺序翻转。你经过一番思考,认为天时地利人和,应该从左到右逐行翻转。
于是,你的朋友开始操作了。尽管你的朋友手速非常快,一秒能翻四个棋子,但为了翻完整个棋盘,还是花了4秒时间。这期间,评委一直对着空气干瞪眼,等得黄花菜都凉了,于是对你们非常不满意。
你怒了,既然评委那么喜欢看,那就让你的朋友当着评委的面翻棋子。于是评委看到了一个一行一行变白的棋盘。好在你人品爆棚,评委觉得还算可以接受。
然后评委又下达了一个任务,他想看到黑白不停交换的棋盘。于是你如法炮制,对朋友下达命令
- 把全部棋子从左到右翻白
- 然后把全部棋子从左到右翻黑
- 回到第一步
于是你的朋友照做,评委看到了一个四秒渐渐变成全白,再花四秒变成全黑的棋盘。评委对你的动态美学大受震撼,并且给你打了0分。
于是你嚷嚷道:你是故意找茬是不是?一个 棋盘怎么可能做到你的要求
好在你不是开水果摊的,评委也不姓刘。他好好思考了一下,认为你说得对。于是他又给了你一个棋盘和16个棋子,但是这次评委会自己随机提出要显示的图形。
然后你思考了一下,发现可以这么干:
- 命令你的朋友在棋盘A上翻转出评委要求的图形
- 把翻转好的棋盘B小心地交给评委,耗时大约4秒
- 把棋盘A交给评委,把棋盘B交给朋友,接受评委要求的新图形
- 重复上述步骤
于是,评委眼前就总有一个翻好的棋盘。评委非常满意,并且告诉你奖金会在笔者找到女朋友时发到你的徽信,你满意地离开了。
上述故事看似扯淡,实则
维护两个(或以上)的帧缓冲,在每一时刻,都有其中一个帧缓冲作为显示缓冲被发送到屏幕上,而另一缓冲则在后台接受程序填充。当填充结束时,置换显示缓冲的指针和置换缓冲的指针。
普遍而言,程序填充缓冲(下文或称为 “渲染” )的过程是远慢于指针交换的速度的,因而在两个缓冲的分工合作下,用户就不必在屏幕上痛苦地看到逐个填充像素的过程了。而 交换链 就是负责维护这个过程的对象。
flowchart LR
O[程序] -. 某些过程 .-> A([填充命令])
O -. 某些过程 .-> E([填充命令])
C -- 显示 --> D[屏幕]
F -- 显示 --> D
B -.-> D
G -.-> D
subgraph 第N+1帧
subgraph 交换链
E -.- F[帧缓冲A]
E -- 渲染 --> G[帧缓冲B]
end
end
subgraph 第N帧
subgraph 交换链
A -- 渲染 --> B[帧缓冲A]
A -.-> C[帧缓冲B]
end
end
非常值得一提的是,WGPU 0.10 起,交换链和屏幕(
Surface
)被封装到了一起,不过并不影响大体的工作机制。
适配器 Adapter
有时候,一台电脑可能有多张显卡(或集显,这里指能被驱动识别为显卡的设备)。适配器是你的操作系统与各个硬件设备之间的桥梁。
设备 Device
设备是表征一个硬件设备(比如一个GPU)的对象。在广大图形库(除了GL)中,都存在设备(或等价于设备的)对象。该对象通常用于各类需要在GPU上分配资源的对象的创建(例如 GPU上的数据缓冲,图像纹理等)。这也将是我们最常接触的对象之一。一个 Adapter 可以创建多个设备,哪怕其使用的都是同一个硬件,但是彼此之间的资源是独立的。
队列 Queue
当然,队列可以指很多东西,具体而言,在本文中(除个别情况)代指 命令队列 (Command Queue)。这可能对不少人是一个比较陌生的概念,却是Vulkan和DirectX12的核心概念之一。命令队列是为了方便并发渲染而引入的。正如其名称指示,命令队列就是GPU需要执行的命令的队列。命令队列是 线程安全 的,意味着它被允许在不同的线程间发送和访问(Send + Sync
)。当然GPU上真正的命令队列本身并不是线程安全的,然而代码中的命令队列仅是操作GPU上命令队列的一个桥梁。我们通常会构筑一个由多条渲染命令组成的 命令缓冲 (Command Buffer),然后将其发送(submit)到命令队列中(这个过程是线程安全的)。GPU会依次执行其中的命令。这很显然是两个并行的过程,而且在CPU的操作并不非常耗时,因而在DirectX12和Vulkan中,有信号Signal
(DirectX12)/Semaphore
(Vulkan)和栅栏Fence
的概念,来阻挡CPU的进度超前GPU过多。而在WGPU中,我们通常并不用关注这个问题。
关系
上述各概念之间有一定程度的关系,如下图所示
flowchart LR
图形API实例 -- 创建 --> A[适配器] -- 创建 --> B
A -- "创建 (WGPU)" --> C
subgraph GPU资源
B[设备]
C[队列]
B -- "创建 (DirectX12)" --> C
B -- 创建 --> D[交换链] -- 包含 --> F[帧缓冲]
B -- 创建 --> E[命令缓冲]
E -- 发送到 --> C
C -- 控制 --> O[GPU]
O -- 输出到 --> F
end
渲染流程概要
由于详细过程会在后面的章节提及,本小节不涉及详细流程
参见流程图:
flowchart TB
subgraph 渲染管线
direction TB
图形数据 -- 顶点着色器/几何着色器/.. --> 在帧缓冲上要渲染的图形信息 -- 光栅化 --> 像素位置 -- 像素/片元着色器 --> 颜色数据 -- 混合 --> 帧缓冲
end
- 顶点着色器阶段将原始的模型数据进行一些变换后生成需要在帧缓冲上渲染的图形信息
- 光栅化是将图形信息转化为帧缓冲上对应像素区域的过程
- 片元(像素)着色器将决定各个像素应该填充什么颜色
- 在混合阶段,该图形渲染出的像素数据会和该帧缓冲上已渲染像素的数据进行混合。这个过程通常用于控制一些叠加效果(例如透明度)的渲染。
通常,我们会将上面叙述的流程称为一个 渲染管线(Graphics Pipeline)
本节的内容至此结束,下一节,我们将着重介绍 WGPU 这个库。
认识 WGPU
从本节开始,我们将正式开始接触WGPU
这个库。这一小节只是对库的介绍,并没有过多知识。
什么是WGPU?
Wgpu-rs is an idiomatic Rust wrapper over wgpu-core. It's designed to be suitable for general purpose graphics and computation needs of Rust community.
Wgpu-rs can target both the natively supported backends and WebAssembly directly.——wgpu.rs
WGPU 视其语境可以代指不同的对象。在Rust社区以及本文中,通常代指wgpu-rs。wgpu-rs
是对wgpu-core
的包装,后者是一个更加底层的包装层,负责直接与图形库底层进行交互。而wgpu-core
又依赖于wgpu-hal
,HAL是硬件抽象层(Hardware Abstraction Layer)的简称。顾名思义,HAL将各个图形库最核心的观念提取成了一个抽象层,并且后端由不同的底层库实现。这也是wgpu-rs
得以在不改变代码的情况下改变运行底层的根本。不过由于其设计模式最接近于Vulkan,因此目前使用Vulkan底层才能得到最佳性能。
事实上,WGPU名称的来源是WebGPU标准。后者旨在为Web环境提供一个可以调用硬件GPU进行渲染和计算的标准。它的标准是为JavaScript设计的,这也解释了WGPU库中偶尔出现的一两个async
函数,以及为何WGPU支持WebAssembly。wgpu-rs
是WebGPU
对Rust用户的包装,而其后的wgpu-core
则亦成为了一些浏览器实现WebGPU
功能的后端(如 FireFox)。
wgpu-core曾经依赖的是gfx-hal。后者是一个基于Vulkan设计的硬件抽象层。后来,wgpu开发组发现gfx-hal对于wgpu而言过于冗余,于是贴合wgpu的需求开发了wgpu-hal。目前gfx-hal进入了维护模式。
Chrome 浏览器使用的WebGPU实现并非
wgpu-core
而是C++编写的Dawn
当然,在本书中,我们只会涉及到wgpu-rs
的部分。
它长什么样?
我们来看一眼wgpu的文档。可以发现,能用的大部分结构都在wgpu::
根模块下。只有少部分实用工具在wgpu::util::
模块下。所以你大部分时候只要输入wgpu::
就能找到你想要的东西。
在文档的一长串结构列表中,我们可以看到熟悉的几个身影,例如
wgpu::Instance
wgpu::Adapter
wgpu::Device
wgpu::Queue
wgpu::CommandBuffer
- etc.
诚然,他们对应了上一节所讲述的内容。当然,更多的还有我们暂不熟悉的结构,我们将会在之后小节的学习中逐渐认识他们。
可能可以用到的链接
本小节至此结束,下一节,我们将正式开始初始化WGPU并开始我们的渲染旅途。
一切的起点——初始化
本小节,我们将正式开始初始化WGPU,并且用其给我们的窗口染上颜色。
准备工作
当然,在开始之前我们需要添加一些依赖!
# Cargo.toml
[package]
# ...
resolver = "2" #!IMPORTANT 这对 wgpu >= 0.10 是必要的
# UPDATE: 从rust edition 2021开始 resolver = 2 是缺省的
[dependencies]
winit = "0.30"
wgpu = "0.20"
pollster = "0.3"
可以看到,除了winit
外,我们迎来了两位新朋友。第一位当然是我们的主角wgpu
,而出于对 WebGPU 规范的遵从,wgpu
包含了少量异步(async
)函数。当然我们希望我们能将这些异步函数像同步函数一样处理(当然这会不可避免地造成阻塞,有异步需求的同学请自行摸索),因而我们需要有一个Executor
实现来阻塞地运行某个Future
,出于方便,我们选择了pollster
,其提供了一个简单的pollster::block_on
函数。
准备完毕,我们可以开始了。
正式开始
看过上一节以及上上节的读者应该已经知道,WGPU的一切应当从wgpu::Instance
开始。WGPU的代码都相当直接,所以我们先直接看代码吧。
#![allow(unused)] fn main() { use pollster::FutureExt; // 有了这个我们就可以对任意Future使用block_on()了 fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) { let window = Arc::new( event_loop .create_window(winit::window::Window::default_attributes().with_title("窗口标题")) .unwrap(), ); let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::PRIMARY, dx12_shader_compiler: wgpu::Dx12Compiler::Fxc, flags: wgpu::InstanceFlags::default(), gles_minor_version: wgpu::Gles3MinorVersion::Automatic, }); // ... } }
wgpu::InstanceDescriptor
描述了我们需要创建的实例的基本信息。wgpu::Backends
是一个BitSet
,每个不同的位表示了尝试使用这个后端(1)与否(0)。当然,如果你全选了WGPU通常会根据你的系统自动帮你挑一个,有需要的就自己指定吧。剩下的字段请读者自行翻看文档,我们不是很关心,所以都使用默认值即可。接下来,我们有了实例,该获取Surface
了
#![allow(unused)] fn main() { // ... let surface = instance.create_surface(window.clone()).unwrap(); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, compatible_surface: Some(&surface), force_fallback_adapter: false, }) .block_on() .unwrap(); }
我们接着获取适配器,request_adapter
这个函数会让Instance
帮你挑一个满足你要求的适配器。好像参数也没啥好解释的……wgpu::PowerPreference
会决定WGPU倾向于选择独显还是集显,看你咯。
当然,如果你想自己枚举适配器,也是可以的。方法大致如下:
#![allow(unused)] fn main() { let adapter = instance .enumerate_adapters(wgpu::Backends::all()) .filter(|adapter| { // 筛选你需要的适配器 }) .first() .unwrap() }
好,底层干部基本就位了,接下来我们就要请出我们的一线工人Queue
和Device
。
#![allow(unused)] fn main() { let (device, queue) = adapter .request_device( &wgpu::DeviceDescriptor { label: None, // 如果你给他起个名字,调试的时候可能比较有用 required_features: adapter.features(), // 根据需要的特性自行调整 required_limits: adapter.limits(), // 根据需要的限定自行调整 }, None, ) .block_on() .unwrap(); }
简单、直白、明了。当然,我们理所当然地注意到了Features
和Limits
。Features
是显卡设备需要支持的功能,例如深度裁剪等等。而Limits
是一些喜闻乐见的限制,比如允许创建的材质的数量之类。当WGPU无法创建满足条件的设备时,会果断丢出一个Error
。当然,我们这里方便起见直接unwrap()
了=_=
当然,如果你没在这里声明你需要用到的功能而在后面的程序中使用到了,则WGPU会在执行此功能时panic。这样一定程度上避免了WGPU在不同的设备上有不同的行为。
万事俱备!……吗?我们好像还没告诉WGPU咱们的帧缓冲得多大啊……
#![allow(unused)] fn main() { let capabilities = surface.get_capabilities(&adapter); let surface_config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: capabilities.formats[0], width: window.inner_size().width, height: window.inner_size().height, present_mode: wgpu::PresentMode::AutoVsync, alpha_mode: wgpu::CompositeAlphaMode::Auto, view_formats: vec![], desired_maximum_frame_latency: 2, }; surface.configure(&device, &surface_config); }
我们先获取了我们的Surface
的SurfaceCapabilities
,其中包含了我们的设备和平面支持的像素格式、呈现模式和alpha值模式。
我们将Surface
的帧缓冲配置为我们窗口的大小,并告诉他我们的帧缓冲可以用来当RENDER_ATTACHMENT
,人话说就是可以当渲染目标的东西。然后给他挑了个他能用的像素格式。查询 PresentMode
的文档你会发现有多种模式,详情请参照文档。其中几种是 垂直同步 的,也就是说当窗口需要被显示时程序会等到该帧被完全显示,通常这取决于显示屏的刷新率,这会减少画面割裂的产生。而最后一种则是立即显示,这种情况下最能反应当前设备下能达到的最优帧率。虽然不一定好就是了。view_formats
则规定了我们创建帧缓冲视图时可以使用哪些格式。而desired_maximum_frame_latency
则会决定交换链将提前渲染多少帧的内容,我们这里设为2
,那么交换链维护的样子就是我们上章所示了。是的,视图的格式可以和缓冲本身不同。但是我们通常只会用到格式相同的情况,而这种情况永远都是被支持的,所以我们留空就行了。
这下真万事俱备了,但是我们还需要对我们的循环做一点小调整。
#![allow(unused)] fn main() { fn window_event( &mut self, event_loop: &winit::event_loop::ActiveEventLoop, window_id: winit::window::WindowId, event: winit::event::WindowEvent, ) { if let Some(Application { window, surface, surface_config, device, queue, }) = &mut self.app { if window.id() != window_id { return; } match event { winit::event::WindowEvent::CloseRequested => event_loop.exit(), winit::event::WindowEvent::Resized(new_size) => { if new_size.width > 0 && new_size.height > 0 { surface_config.width = new_size.width; surface_config.height = new_size.height; surface.configure(&device, &surface_config); } } winit::event::WindowEvent::RedrawRequested => { // 渲染代码 window.request_redraw(); } _ => (), } } } }
上面的代码,说人话,就是在窗口大小变化的时候重新配置一下咱们的Surface
。熟悉了渲染流程的读者可能已经猜到,如果不这么做,很有可能导致巨大的窗口上只寥寥显示了几个巨大的像素的惨剧……
万事俱备,终于……
开始我们的渲染吧~
渲染一个窗口需要几步?
- 获取要渲染对象的视图
- 发送渲染命令
- 把渲染命令丢件队列里面
这就是我们的代码将要做的。
#![allow(unused)] fn main() { let output = surface.get_current_texture().unwrap(); let view = output .texture .create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = device .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); { // 注意这个 '{' let _render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: None, color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::GREEN), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); } queue.submit(std::iter::once(encoder.finish())); output.present(); }
也挺直白的,不是么?
我们来看看我们到底做了啥。
首先,我们向Surface
请求了交换链中的下一个帧缓冲,然后创建了它的视图。接下来。我们创建了一个命令编码器,来创建一个命令缓冲。然后,我们开始了一个渲染阶段,并且告诉WGPU我们需要渲染到哪些RENDER_ATTACHMENT
上面,顺便告诉WGPU怎么清除这些渲染对象,包括在读取的时候清除并设置默认值,然后对其的操作要写入到缓冲中。resolve_target
是MSAA可能会使用到的技术,我们在此不做赘述。depth_stencil_attachment
将在后文提及并使用。
然后,我们暂时不用进行别的渲染操作,所以我们让编码器创建了个命令缓冲,然后把它丢进了队列。
为了强调
Queue::submit
接受的是迭代器,我使用了std::iter::once
,然而我们实际上可以直接使用Some(encoder.finish())
,因为Option<T>
实现了IntoIterator<Item=T>
SO EZ!
另外值得我们注意的是,为了内存安全需要,当然也是因为其内部操作的必要,RenderPass
内部保留着一个 &mut CommandEncoder
,换而言之,我们的编码器的数据是流入RenderPass
中的,因此我们需要稍微控制其生命周期,以防止Rust编译器对你狂暴鸿儒大量错误。这也是为什么我们打了一组看似多余的{}
。
不出意外的话,我们的程序现在可以顺利运行,并且你会看到一片绿到发光的屏幕
顶点,像素,着色器!
从本章开始,我们将正式开始用WGPU画一些东西。不过,在此之前,我们需要先了解一下计算机是如何理解并绘制图形的。
软件推荐
RenderDoc 可以在图形软件运行中截获快照并查看各种缓冲和材质的状态,也可以对着色器的运行进行调试。是图形程序调试的不二之选。
七巧板?——图形与三角
理论
我们现在想在平面内确定一个图形,应该怎么做?不妨以三角形为例:
在初中我们学过,确定三边,或者两边一夹角等等就可以确定一个三角形的形状。母庸置疑的是,确定三角形的形状需要至少3个已知量,而为了在平面上确定这个三角形,我们还需要三角形上某一点的坐标以及绕其的旋转角度。坐标需要两个量,所以总共至少需要6个量。换句话说,一个平面内的三角形具有6个自由度,6个线性不相关的量就能准确描述一个三角形。在计算机中,我们会很自然地选择三个顶点的坐标。
同理,四边形具有8个自由度,例如4个顶点的坐标,我们就可以向计算机准确地传达我们需要的形状。
那圆呢?
你或许会说,圆不是只有3个自由度吗?这不简单,原点坐标和半径不就可以了?是,但是为了后续处理的方便,我们需要一个统一的描述方式,比如坐标。那为什么不直接随便选圆上三个点呢(这里出现了6个量,但是这并不影响圆的自由度是3。因为不同的三个点可以确定同一个圆,是对称性使然)?但是这样我们就需要有更多的计算来从这三个点计算出整个圆所占的区域,实在是有点憨。
最后,我们发现,想要处理起来方便,最好还是横平竖直的东西……
于是,早期计算机图形学家们干脆选择了最简单的方法:都用三角形!那要是边缘看着不够圆滑咋办捏?那就加三角形的量呗……遂,我们就需要用这种类似七巧板的方式,使用微元法的思想,大致拼凑出我们想要的图形。当然,现在已经有各式各样的建模软件会帮我们自动处理这些过程。我们平常说的高模和低模,也是在代指三角形的数量(面数)。
当然,三角形并不是我们唯一的选择。图形引擎通常也支持四边形和五边形等,不少游戏也使用混合不同面形状的模型来改善精度。然而不可否认的是,三角形仍然是业界使用最广泛的基本图形。
上手操作!
事不宜迟,我们赶紧试着在窗口里面画一个三角形!
在这之前,我们先引入一个方便我们使用的库bytemuck
。
# Cargo.toml
[dependencies]
# ...
bytemuck = "1"
这个库将会帮助我们将结构体直接转化为字节数组切片&[u8]
,并保证我们的操作是安全的。
我们来定义我们的顶点
#![allow(unused)] fn main() { #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] struct Vertex { position: [f32; 2], color: [f32; 3], } }
我们定义了一个顶点结构,其中包括顶点的坐标和顶点的颜色。其内容具体如何使用我们将在之后的章节详细讨论。同时注意到我们derive了bytemuck::Pod
和bytemuck::Zeroable
两个宏,他们将会允许我们安全地转换我们的结构为&[u8]
。当然,为此我们还引入了#[repr(C)]
来保证我们内存的对齐是符合我们预期的。详情请读者自行参见bytemuck的文档和Rust Nomicon。
接下来,让我们进行一个三角形的编:
#![allow(unused)] fn main() { // resumed let triangle = [ Vertex { position: [0.0, 0.5], color: [1.0, 1.0, 1.0], }, Vertex { position: [0.5, -0.5], color: [1.0, 1.0, 1.0], }, Vertex { position: [-0.5, -0.5], color: [1.0, 1.0, 1.0], }, ]; let vertices_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytemuck::cast_slice(&triangle), usage: wgpu::BufferUsages::VERTEX, }); }
首先我们创建了一个顶点的数组,其次我们将其用bytemuck
转化为字节后上传到了一个缓冲中。
有些读者可能会好奇为何我们为三角形的顶点坐标选择了这几个数值。粗略地说,在WGPU中,我们会把一个中的点线性映射到窗口的像素中。具体而言,会被平移到窗口中央,而和分别对应屏幕右方向和上方向。在渲染过程中涉及的各个坐标系将会在后面的章节详细讲述。
这里我们首次使用了Device::create_buffer_init
。事实上,这是一个扩展函数,是不位于WebGPU标准中,WGPU库为了方便使用而添加的一些方法。类似这些的方法被定义在DeviceExt
这个trait中。
那么,create_buffer_init
到底为我们干了什么呢?它主要干了这样两件事:
- 创建一个 合适 大小的缓冲
- 把我们的数据扔进去
通过翻阅代码(我也鼓励读者如此操作),我们可以发现create_buffer_init
的所作所为不外乎上面两件事。那么为什么我要强调 合适 大小的缓冲呢?这和Vulkan等WGPU使用的底层API的限定有关。如果我们查看create_buffer_init
实现中的注释,我们会发现如果要拷贝数据进入缓冲中(换句话说,有BufferUsages::COPY
),Vulkan要求创建的缓冲的大小必须是COPY_BUFFER_ALIGNMENT
的倍数。create_buffer_init
会自动帮我们把缓冲的大小垫(padding)到距离我们传入的数据大小大于且最近的满足要求的大小。换句话说,我们通过这个方法创建的缓冲未必就是我们传入的数据的大小!如果读者需要更精确地控制缓冲的大小,我们建议使用create_buffer
方法并手动上传数据。
接下来,你可能会兴致冲冲地去准备把三角形画出来。可是,WGPU怎么知道怎么理解我们上传的数据呢?如你所见,我们仅仅是上传了很多字节而已。不出意外的话,我们应当得手动描述缓冲的形状(layout)才对。
你说得对,接下来我们将开始处理一些棘手的部分,这些部分和后面的章节挂钩。我会尽量通俗地解释这些概念。如果你有什么疑问,读下去,我相信都会得到解答。同时,我也欢迎大家在评论区提问。
首先,我们得创建一个渲染管线(还记得吗,在之前的章节的最后有提到),来告诉WGPU我们将如何渲染这个三角形。
让我们开始吧:
#![allow(unused)] fn main() { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[], push_constant_ranges: &[], }); let shader_module = device.create_shader_module(wgpu::include_wgsl!("triangle/triangle.wgsl")); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: None, layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader_module, entry_point: "vs_main", buffers: &[wgpu::VertexBufferLayout { array_stride: std::mem::size_of::<Vertex>() as _, step_mode: wgpu::VertexStepMode::Vertex, attributes: &wgpu::vertex_attr_array![ 0 => Float32x2, 1 => Float32x3 ], }], }, primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader_module, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format: surface_config.format, blend: None, write_mask: wgpu::ColorWrites::ALL, })], }), multiview: None, }); }
这段代码涉及了众多之后才能理解的概念,我会尽量使用通俗的语言描述。
首先我们需要为管线创建一个布局(layout),这个布局描述了我们之后将会向管线的着色器内传入什么样的额外数据。这里额外数据指任何顶点数据以外的用户指定的数据。由于我们现在并不需要向着色器内传入额外的数据,我们先全部留空。
接下来,我们将我们的着色器文件载入到GPU中。在之前的章节最后对渲染管线的描述中我们提到过,着色器是一种告诉GPU如何完成传入的顶点数据的变化和如何给像素着色等任务的语言。在接下来的几个章节中我们将会更详细地了解着色器。创建好着色器以后我们会得到着色器模块(ShaderModule),相当于对GPU内着色器资源的引用。在示例创建着色器的过程中,我们使用了wgpu::include_wgsl!
这个宏。这个宏会在 编译期 将你的着色器载入到程序中,并用其创建一个wgpu::ShaderModuleDescriptor
。如果你翻阅了ShaderModuleDescriptor的文档,会发现其中的wgpu::ShaderSource
支持相当数量的着色器语言。归功于naga项目,不同的着色器语言最终都会被转译成WGPU可以理解的格式。在接下来的教程中,我们只会使用wgsl
。
接下来我们就在正式创建渲染管线了。因为用途平凡或内容不会涉及,我们将不会在此深入解释下面几个字段的意义:
- label
- layout
- multisample
- multiview
让我们把注意力放到剩下的几个字段上:
vertex 会用来描述该渲染管线对顶点数据的处理,也就是渲染流程概要里前两个节点之间的部分。在示例代码中,我们指定了着色器存在的模块module
和着色器的入口点entry_point
(也就是会被调用的着色器函数,过会我们会在着色器中看到我们指定的名字),还有对顶点缓冲的描述。你可能会好奇为什么顶点缓冲的描述是一个数组,这是因为一次渲染命令可以同时传入多个顶点缓冲,这被用于一些特殊的渲染中。让我们研究一下单个顶点缓冲的描述:
- array_stride 描述多少字节表示一个顶点,在我们的情况下自然是每个
Vertex
结构在内存中的大小 - step_mode 描述这个顶点缓冲内数据在什么情况步进一次并传入顶点着色器处理。这句话听上去莫名其妙,难道顶点缓冲不是一个一个顶点传入的吗?不完全是。目前
wgpu::VertexStepMode
有两个可能值,分别是VertexStepMode::Vertex
和VertexStepMode::Instance
。前者便是我们直觉意义上的顶点,后者则是我们称为实例(Instance)。在之后我们发起渲染请求时,除了指定顶点范围外,还会指定实例范围。在最终渲染时会对每个实例步进一次实例顶点缓冲,然后进行对顶点的处理。在批量绘制静态物品时可以用实例顶点缓冲来储存姿态数据来获得更高的效率。其效果类似下面这段代码:
#![allow(unused)] fn main() { const INSTANCES: usize = 100; const VERTICES: usize = 3; struct PerVertexData { //每个顶点的数据,在GPU中就是顶点缓冲内每隔一个stride存放的内容 } struct PerInstanceData { //每个实例的数据,在GPU中就是实例顶点缓冲内每隔一个stride存放的内容 } struct VertexShaderOutput { //顶点着色器的输出内容 } fn vertex_shader(vertex: PerVertexData, instance: PerInstanceData) -> VertexShaderOutput { //处理顶点数据并输出 unimplemented!() } let vertex_buffer = [PerVertexData; VERTICES]; let instance_buffer = [PerInstanceData; INSTANCES]; for i in 0..INSTANCES { for j in 0..VERTICES { let output_vertex = vertex_shader(vertex_buffer[j], instance_buffer[i]); // ... } } }
-
attributes 接受一个
wgpu::VertexAttribute
数组切片。其作用是告诉WGPU如何理解每个顶点的数据。在我们的例程中,一个顶点数据在内存中是五个连续存放的单精度浮点,其中前两个描述顶点坐标,后三个描述顶点颜色。在每一个wgpu::VertexAttribute
中,我们需要指定下面这几个量:- format 接受一个
wgpu::VertexFormat
枚举类型。其描述顶点数据中这一部分应当如何对应到顶点着色器理解的类型。例如我们为顶点坐标选择VertexFormat::Float32x2
,这个类型会直接将两个单精度浮点长度(字节)的数据对应到着色器中的二维单精度浮点向量中。除此之外还有很多类型,请读者自行翻看VertexFormat的文档 - offset 表示我们描述的部分应当从单个顶点内存数据的何处算起,这个量和 format 对应的数据大小一起指定了这个属性的内存区域。例如我们有一个顶点数据的字节数组
vertex_bytes: &[u8]
,那么我们的数据便是vertex_bytes[offset..(offset + format.size())]
- shader_location 是一个
u32
类型,指定我们的数据对应在顶点着色器输入中的“位置”,我们之后将会在顶点着色器中看到其对应的量
而在例程中,我们使用了WGPU提供的一个非常方便的工具宏
wgpu::vertex_attr_array!
,其中会接受一个loc => format
形式的列表,loc
对应 shader_location 而format
就对应 format 。offset 会由宏内部自动计算,算法是将列表前面每一项的VertexFormat::size()
累加。因此,只要你的单个顶点数据中的各个部分排布是连续的,就可以使用这个宏!在我们的例程中,手写的效果如下:
- format 接受一个
#![allow(unused)] fn main() { [ wgpu::VertexAttribute { // 坐标 format: wgpu::VertexFormat::Float32x2, offset: 0 as _, shader_location: 0, }, wgpu::VertexAttribute { // 颜色 format: wgpu::VertexFormat::Float32x3, offset: 8 as _, shader_location: 1, }, ] }
松散的顶点数据存储
注意: 此部分为之前 学习过别的图形API 的读者准备,涉及一些3D渲染的内容。建议初学的读者先跳过这一段。
之前学习过OpenGL的读者可能见到过另一种传入顶点数据的方法,即将不同的顶点数据的成分存放在不同的缓冲中,各自组分于内存中是连续的。WGPU也可以做到这一点!为了实现这种做法,我们需要定义多个 vertex 中的wgpu::VertexBufferLayout
。不妨设我们的一个顶点由下面三个量组成(类型采用GLSL
):
- 位置
layout(location=0) in vec3 position
- 贴图UV
layout(location=1) in vec2 tex_coord
- 法向量
layout(location=2) in vec3 normal
layout(location=x)
对应于前文的wgpu::VertexAttribute.shader_location: x
那么我们的wgpu::VertexState.buffers
应当赋予以下值
#![allow(unused)] fn main() { [ wgpu::VertexBufferLayout { array_stride: std::mem::size_of::<[f32; 3]>() as _, step_mode: wgpu::VertexStepMode::Vertex, attributes: &wgpu::vertex_attr_array![0 => Float32x3] }, wgpu::VertexBufferLayout { array_stride: std::mem::size_of::<[f32; 2]>() as _, step_mode: wgpu::VertexStepMode::Vertex, attributes: &wgpu::vertex_attr_array![1 => Float32x2] }, wgpu::VertexBufferLayout { array_stride: std::mem::size_of::<[f32; 3]>() as _, step_mode: wgpu::VertexStepMode::Vertex, attributes: &wgpu::vertex_attr_array![2 => Float32x3] }, ] }
之后渲染时在对应的槽位绑定数据缓冲即可。
内容从这里继续
primitive 是对渲染采用的基础形状的描述。看看wgpu::PrimitiveState
里面都有什么!
- topology 是一个
wgpu::PrimitiveTopology
枚举类型,负责描述我们的顶点数据应当被理解为什么几何图形,目前WGPU仅支持点、线和三角形。读者可以注意到除了点以外的形状都有一个*Strip
变种。简单来说,非Strip
的类型会将顶点数据划分成不交的一个一个子集,以PrimitiveTopology::TriangleList
为例,便是三个三个理解为一个三角形,因此顶点缓冲中顶点的数量也必须是3的整数倍。而Strip
类型则相当于一个滑动窗口,在三角形的情况会尝试从每一个顶点开始往后取三个点(如果可能的话)理解成一个三角形,因而除了顶点数量至少得组成一个三角形以外并没有明显的限制(当然,为了保证面的朝向相同,顶点取出后排列的顺序会有一些调整,详情请参照文档)。Strip
类型的用途在于用尽可能少的顶点数量描述一个连成一片的区域。不过,似乎现实情况下出于泛用性的考虑用得并不是很多。该字段默认为TriangleList
- strip_index_format 在使用
Strip
类型的图形时索引缓冲的类型。索引缓冲的概念我们将会在下一节学习,目前可以不用在意 - front_face 图形的定向。默认为逆时针取正向,符合右手螺旋规则。在我们的例程给出的缓冲中,由于顶点是上方右下左下的顺序给出的,我们的三角形是被判定为背向,也就是背对屏幕的。该选项在下一个字段 cull_mode 启用时发挥作用
- cull_mode 剔除模式。默认为
None
,即不剔除。若开启剔除模式,我们可以选择剔除正向图形或者背向图形。通常我们选择剔除背向图形,因为假定我们选取的正向正确对应了曲面的外法向量,在一个连续不自交的闭合曲面上,一定存在正向图形遮挡了背向图形。然而如果只绘制一个不闭合的图形,那么我们就会有一些方向使得原本看得到的图形被剔除掉。该选项是为了减少绘制看不到的图形而造成的渲染资源浪费设立的,请根据需求选择。 - unclipped_depth 选择深度是否被裁剪到。关于深度的详细知识我们会在3D渲染学习。
- polygon_mode 多边形模式。这个枚举类型可以决定多边形将怎样被对应到像素上。默认为
PolygonMode::Fill
,也就是填充。根据需求可以选择PolygonMode::Line
或PolygonMode::Point
,分别只绘制边线和顶点。注意使用后两者需要启用对应的特性,详情请看文档 - conservative 保守光栅化。如果设置为真,则这个多边形最后到屏幕上的投影一定会被其生成的所有像素包含。这可能会导致一些多边形的边缘看着比较粗糙难看。仅当使用
PolygonMode::Fill
时有效,且需要开启对应特性,请看文档
depth_stencil 是控制我们称为深度模板缓冲的一个功能的选项,让我们在3D渲染的章节再介绍他吧!
fragment 是渲染管线中负责将最终确定要在窗口和屏幕中显示的部分上色的控制,也就是渲染流程概要里最后两个节点之间的部分。他可以为None
,因为有些情况我们完全不需要输出具体的像素。这虽然听上去很奇怪,但是我们将会在本书中用到这个技巧!其中抛去平凡的和 vertex 意义相同的字段以外,我们还剩下一个target
字段。target
字段是一个数组切片,因为WGPU支持用一组顶点数据使用不同的片元着色器对多个渲染目标进行输出,从而存储不同的量到最终的输出缓冲里面。目前为止我们只需要输出到唯一一个我们作为帧缓冲的目标里面,所以我们只需要乖乖写入一个wgpu::ColorTargetState
即可。
那么wgpu::ColorTargetState
又描述了一些什么呢?
- format 表示将会输出到的目标缓冲的格式。既然我们是要输出到
Surface
维护的帧缓冲,那我们自然要使用和Surface
相同的格式 - blend 描述如何把这次渲染输出的像素颜色和之前存在于缓冲中的像素颜色混合。通常是用于带透明度图片的渲染,也可以用于一些特效的制作。熟悉图片处理的读者可能知道例如正片叠底之类的混合效果,实际上便是对应该字段描述的内容。对于该字段的细节,我们不妨在接下来的章节介绍
- write_mask 对这个目标的写入操作的哪些通道有效。是一个
BitSet
,每一位分别代表RGBA通道
很好!我们终于讲完了这个冗长的渲染管线初始化的内容。想必大家读也读累了,喝口水休息一下吧。
最后我们要干的事就很简单了,告诉GPU渲染我们的顶点缓冲。
#![allow(unused)] fn main() { // ... let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: None, color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::GREEN), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); render_pass.set_pipeline(&pipeline); render_pass.set_vertex_buffer(0, vertices_buffer.slice(..)); render_pass.draw(0..3, 0..1); }
我们告诉WGPU使用刚刚我们创建的渲染管线,然后将槽位0(对应wgpu::RenderPipelineDescriptor.vertex.buffers
里面的索引,因为我们只有一个元素所以就是0)的顶点缓冲设置为我们创建的顶点缓冲的全部(是的,你可以在这里切出一部分顶点缓冲来用),最后我们指定了渲染顶点的范围(因为我们缓冲内仅有三个顶点来描述我们要画的三角形,需要用到全体顶点,所以是0..3
,有些需要优化的渲染可以在这里选出顶点缓冲的一部分进行渲染)和实例(我们没有实例缓冲,而且只需要画一个,所以是0..1
)并创建了渲染命令。
哦,我们好像忘了写着色器了:
// triangle.wgsl
// 暂时不要在意这些@是干什么的
struct VertexOut {
@builtin(position) position: vec4<f32>,
@location(0) color: vec3<f32>,
}
//下面两个函数的函数名就是创建管线的时候指定的 entry_point
//顶点着色器中 @location(x) 里面的 x 对应了 wgpu::VertexAttribute.shader_location
@vertex
fn vs_main(@location(0) position: vec2<f32>, @location(1) color: vec3<f32>) -> VertexOut {
var out: VertexOut;
out.position = vec4<f32>(position, 0.0, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(pin: VertexOut) -> @location(0) vec4<f32> {
return vec4<f32>(pin.color, 1.0);
}
为了给本章接下来的章节留一些内容,我们并不会详细解释这个着色器在干什么。不过读者大概可以看出来,我们仅仅是将传进来的顶点位置变成了一个四维的坐标并原样传了出去,并在片元着色器里面直接将顶点传入的颜色当作了输出的颜色。
将着色器放置于适当的位置并编译运行,我们成功在绿的发光的屏幕上看到了白得瞎眼的三角形。这是我们的图形学生涯中令人难忘的第一个三角形!为什么不喝点可乐🥤庆祝一下呢?
提示: 如果出了问题,别忘了和仓库里面能跑的代码核对一下
一闪一闪亮晶晶——像素与窗口
如有些读者已经察觉的,我们的渲染过程中充斥着各种各样的坐标系。在这一个大章节的后面,我们会花更多章节解释各种坐标系和之间的关系。然而,我们目前为止只涉及了二维的渲染,从而涉及的坐标系大幅减少。在这个章节,我想向读者介绍我们目前主要接触到的几个坐标系。
在图形学,我能看到各种各样的坐标
易!悟!
——理塘王子
窗口坐标
我们渲染的结果最终都是呈现到窗口上的。由于窗口的平移之类是系统负责,我们可以不关注屏幕上的绝对坐标,但是我们自然要有办法描述我们渲染的结果在窗口上的相对位置。国际惯例是将窗口的左上角像素取为,横向右一个像素为坐标单位,纵向下一个像素为坐标单位,WGPU中也并不例外。我们的帧缓冲便对应到这个坐标上。
视口 Viewport
有些情况下,我们希望能在一个窗口上输出不止一个结果。比如左半边渲染一个玩家的视野,右半边渲染另一个玩家的视野之类。尽管这种操作有很多实现方案,视口是其中最直接的一种。视口相当于在窗口中框选出一个区域当作一整个窗口渲染。因此,大部分时候,我们指的“窗口坐标”原则上应当理解成视口坐标(然而也有情况不是)。在我们的教程中,我们的视口永远都是选择整个窗口,所以窗口坐标和视口坐标是一致的。在WGPU中,我们可以通过wgpu::RenderPass::set_viewport
方法来设置视口。前四个参数分别是左上角的位置的x和y窗口坐标,视口的长和宽。后两个参数在3D渲染时才有意义,我们平常将其设置为各自默认值0.0
和1.0
即可。不过我们正常不会说“视口坐标”,因为视口内部的坐标从来不出现在我们的计算当中。
归一设备坐标 Normalized Device Coordinates
归一设备坐标(NDC)是一个三维坐标系。其Z轴仅在3D渲染时有意义,故我们暂时忽略Z轴。NDC的坐标会被线性映射到视口坐标中。换句话说,会对应到视口的左上角,而相应的会对应到视口的右下角。我们在上一节的顶点的坐标事实上就是NDC里面的坐标。然而请注意,顶点着色器输出的坐标通常 不是 NDC,我们将会在3D渲染中更详细地讨论这一事实。
顶点着色器和他的坐标朋友们
虽然在二维渲染中我们并不需要处理太多坐标系相关的内容,但是我们已经有了展示顶点着色器作用的绝佳例子了!在通常的二维渲染中,我们需要以像素为单位描述位置,然而这在NDC中是不好做到的。抛开其他的不谈,如果我们采用一个固定的坐标,就会导致我们的图形随着窗口缩放而拉伸。如果我们想要一个固定的大小,就需要我们反复地计算像素宽度对应的NDC宽度。想象一下如果我们有成千上万个(其实通常可以比这更多)图形需要渲染,那我们就需要每一帧计算成千上万的坐标然后将其重新上传至GPU,低效不堪。然而稍微一想,我们就会发现端倪:渲染过程的最后无非是GPU统一把NDC坐标线性变换到视口(在我们的情况下是窗口)中,那我们先让GPU统一进行其逆变换不就可以了吗?顶点着色器就担任了这个对一些顶点统一进行变换的作用。听上去要算的东西还是差不多的,为什么我们更喜欢这样做呢?一是因为这样可以大幅减少CPU和GPU之间相对比较慢的数据交换,因为我们的顶点数据和变换用的数据都可以存在GPU中;二是因为GPU的硬件是对矩阵乘法特化的(不知道为什么和矩阵乘法扯上关系?下一个大章节你就知道了)。这个说法有个地方听上去有些弱智:既然我们本来就想直接输出到窗口坐标,为什么还要有一个NDC呢?在二维渲染的语境下,我没有想到什么为其开脱的理由。但是在三维渲染中,我们将会轻易注意到其重要地位。
因为我不想翻转坐标,所以下面像素坐标都是以屏幕左下角为原点,向上为,向右为
我们还是先实现再说吧!先考虑我们需要什么样一个矩阵进行变换。假设我们窗口的宽度是,高度是(像素),而一个像素的坐标是。这个像素最终应该到达的NDC中的位置记为,则有:
是一个缩放加上平移。但平移并不是一个线性变换,这怎么办呢?
下面是给有线代基础同学的数学内容,不感兴趣可以选择跳过
理塘DJ的完美数学教室
在数学中,线性变换配上平移组成的变换被称为 仿射变换(Affine Transformation),而仿射变换也是可以用矩阵表示的!一般地说,如果我们想表示一个上的平移,那么我们使用一个维向量表示
并用如下矩阵作用于其上
不难验证
成功进行了平移。形如的矩阵被称为平移矩阵,我们完全可以把它当作一般的线性变换处理。更一般地说,具有如下形状的分块矩阵:
作用于向量
有效果(由分块矩阵乘法易证)
因此由于在我们的情况下,为矩阵,为向量。因而我们想要的矩阵便是
但是因为我们需要给三维处理留下一点空间,我们填上的单位映射得到
这便是我们最终需要使用的矩阵了!
下课
给跳过的同学贴上省流版本:我们只需要将这个矩阵
乘到
上,就能在前两个分量中得到我们想要的结果。其中的值随意,但是由于NDC的取值范围不能超过,这里的也不可以超过。
实操时间
为了方便在程序中操作矩阵,我们引入一个新的依赖
# Cargo.toml
[dependencies]
# ...
cgmath = "0.18"
我们先更新一下我们的顶点数据:
#![allow(unused)] fn main() { let triangle = [ Vertex { position: [50.0, 100.0], color: [1.0, 1.0, 1.0], }, Vertex { position: [0.0, 0.0], color: [1.0, 1.0, 1.0], }, Vertex { position: [100.0, 0.0], color: [1.0, 1.0, 1.0], }, ]; }
记住,经过我们的操作后,我们现在采取的是窗口的像素坐标:原点在窗口左下角,单位为像素。因此,这个三角形是一个顶点位于窗口左下角,底边贴着窗口底边,底为100像素,高为100像素的白色三角形。
然后在初始化代码中的某一段生成矩阵并把它写入缓冲
#![allow(unused)] fn main() { let vertices_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytemuck::cast_slice(&triangle), usage: wgpu::BufferUsages::VERTEX, }); // 从这里继续 let window_dimension = window.inner_size(); let pixel_matrix = cgmath::Matrix4::new( 2.0 as f32 / window_dimension.width as f32, 0., 0., 0., 0., 2.0 / window_dimension.height as f32, 0., 0., 0., 0., 1., 0., -1., -1., 0., 1., ); // 虽然我们最终还是把矩阵转成了二维数组,但是反正我们迟早需要在CPU里面计算矩阵乘法,不如早点引入cgmath let projection_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: bytemuck::cast_slice(&<cgmath::Matrix4<f32> as Into<[[f32; 4]; 4]>>::into( pixel_matrix, )), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); }
cgmath
的内部存储结构和输入时的顺序都是 列优先(Column Major) 的,和WGPU一致。换句话说,我们输入的数字四个四个分为一组分别为第一列到第四列。如果你把它每四个换一行看,那看到的就是我们需要的矩阵的转置。这个比较误导人,请务必注意!
注意到我们的缓冲的用途是wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST
。前者告诉WGPU我们的缓冲将会用于向着色器内传递至少在一次渲染调用内保持不变的数据,后者则允许我们在必要时更新缓存的内容:例如窗口大小变化时。
接下来,我们需要想办法将数据传递进着色器中。还记得上一节基本留空的管线布局(Pipeline Layout)吗?我们提到过向着色器传入数据和它有关。准确的说,和其中的绑定组(Bind Group)有关。
绑定组存在的目的是向着色器传递在一次或者一次以上绘制请求之间不变的数据(与顶点数据不同)。为了顺利传递一个绑定组,我们需要如下操作:
- 初始化时
- 创建绑定组布局(Bind Group Layout)
- 创建拥有绑定组布局引用的管线布局
- 创建拥有对应绑定组布局的管线
- 创建绑定组
- 渲染时
- 使用对应的管线
- 将绑定组设置到对应槽位
- 发起渲染请求
那么我们便开始吧。我们先创建好绑定组布局和绑定组
#![allow(unused)] fn main() { let matrix_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }], }); let matrix_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: None, layout: &matrix_bind_group_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: projection_buffer.as_entire_binding(), }], }); }
如你所见,我们的绑定组布局和绑定组都拥有多个条目(entry),而且他们得是对应的。这就是说,一个绑定组可以同时传入多种不同的数据。因此,我们可以用绑定组将一些永远同时出现的数据放在一起传入(比如渲染一个物体时,我们可以将其姿态和材质放在同一个绑定组传入)。每个不管是绑定组布局还是绑定组, entry 都拥有一个binding
字段,其指示着色器之后将索引该绑定组里面的数据,我们之后将会看到这一点。
太棒了,现在我们可以更新我们的管线布局来告诉WGPU我们想要在使用这个管线时传入这一种绑定组了:
#![allow(unused)] fn main() { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[&matrix_bind_group_layout], // 改动了这里 push_constant_ranges: &[], }); }
数组顺序
尽管我们这里并不需要传入多个绑定组布局,但是我仍需要提醒的是,在传入多个绑定组布局时,bind_group_layouts
中元素的顺序必须和绑定组的槽位编号一致。之后我们需要用传入多个绑定组时会再强调这一点。
也别忘了更新我们的着色器:
#![allow(unused)] fn main() { let shader_module = device.create_shader_module(wgpu::include_wgsl!("triangle/triangle-pixel.wgsl")); }
渲染管线本身的创造则并没有什么需要改动的地方。
最后在渲染之前记得设置一下绑定组
#![allow(unused)] fn main() { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: None, color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::GREEN), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); render_pass.set_pipeline(&pipeline); render_pass.set_vertex_buffer(0, vertices_buffer.slice(..)); render_pass.set_bind_group(0, &matrix_bind_group, &[]); // 在这里绑定 render_pass.draw(0..3, 0..1); }
设置绑定组的第一个参数是绑定组的槽位,和这里所说是对应的,第二个参数是对绑定组的引用(注意不是布局,只有绑定组本身才负责掌管具体的数据),第三个参数则是动态偏移需要的,我们用不到。
原则上只要在你更新了着色器的内容(在下文)以后,我们的渲染其实已经符合我们的预期了:我们会看到一个贴在屏幕左下角的,底边和高都是100像素的三角形。然而一旦窗口的大小发生变化,我们的矩阵也是需要更新的:因为它的值依赖于窗口的长和宽。为了无论如何缩放窗口,我们都能有正确的三角形大小,我们需要的合适的时候更新矩阵缓冲的内容。而这个更新,正应该发生在窗口大小缩放时:
#![allow(unused)] fn main() { winit::event::WindowEvent::Resized(new_size) => { if new_size.width > 0 && new_size.height > 0 { surface_config.width = new_size.width; surface_config.height = new_size.height; surface.configure(&device, &surface_config); // 新增内容 let pixel_matrix = cgmath::Matrix4::new( 2.0 as f32 / new_size.width as f32, 0., 0., 0., 0., 2.0 / new_size.height as f32, 0., 0., 0., 0., 1., 0., -1., -1., 0., 1., ); queue.write_buffer( &projection_buffer, 0, bytemuck::cast_slice(&<cgmath::Matrix4<f32> as Into< [[f32; 4]; 4], >>::into( pixel_matrix )), ); } } }
几乎就是我们创造矩阵缓冲时代码的翻版。Queue::write_buffer
会在当前队列的最开始,也就是下一次渲染开始之前,插入一条上传缓冲的指令。它的第一个参数是需要写入的缓冲的引用,第二条是写入位置从缓冲开始位置的偏移值(这个参数是为了方便局部更新),第三个参数是需要写入的字节数据。
最后我们来看一下着色器需要有什么变化:
// triangle/triangle-pixel.wgsl
struct VertexOut {
@builtin(position) position: vec4<f32>,
@location(0) color: vec3<f32>,
}
@group(0)
@binding(0)
var<uniform> u_projection: mat4x4<f32>;
@vertex
fn vs_main(@location(0) position: vec2<f32>, @location(1) color: vec3<f32>) -> VertexOut {
var out: VertexOut;
out.position = u_projection * vec4<f32>(position, 0.0, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(pin: VertexOut) -> @location(0) vec4<f32> {
return vec4<f32>(pin.color, 1.0);
}
我们仍然暂时忽略在上一节便出现过的各种@location
和@builtin
,因为这是下一节的内容,而把注意力集中在多出来的部分。首先我们可以注意到的改动是
@group(0)
@binding(0)
var<uniform> u_projection: mat4x4<f32>;
这一部分会负责接收从绑定组传来的数据。@group(0)
表示我们的绑定组会从槽位0
被传入,与这里所说对应,也和render_pass.set_bind_group
的参数对应。而@binding(0)
则表示我们接收到这个变量的数据是这个绑定组的第0
个条目。由于我们从槽位0
传入的绑定组的布局是入口点0
为一个缓冲,着色器会尝试将这个缓冲理解为我们下面指定的类型。接下来的var<uniform>
则告诉着色器接下来定义的变量是从绑定组接收的数据,这和GLSL中的uniform
意义一致。然后我们定义其名称为u_projection
,类型为mat4x4<f32>
,也就是内容是f32
的矩阵。变量名字是可以随意起的。在接下来几个章节,我们将会面对条目越来越多的绑定组,也会同时用到多个绑定组。
总的来说,这一段就是在表示:我要用第0
绑定组的第0
个条目的数据,而且要把那一段内存理解成的单精度浮点矩阵。而经过这一番操作,我们在程序中传入的矩阵就可以在着色器中被使用了,而我们的用法也很简单,就是像前文说的那样,把它乘到顶点坐标上而已。
out.position = u_projection * vec4<f32>(position, 0.0, 1.0);
真不错!
现在,你窗口的左下角有了一个大小不变而且死皮赖脸贴着窗口的小三角形了。你肯定早已注意到我们的顶点数据中有着描述颜色的字段,而你肯定充满了好奇:三个顶点的颜色,怎么能决定整个三角形每个像素的颜色?同时,我们的着色器中还充斥着各种神奇的标记,我们还没对它们做出任何解释。不用怕,一切都会在下一节真相大白!拖更道,堂堂连载!
我变色了——着色器初步
从这一章伊始,我们便多次接触到 着色器(Shader) 这个概念。然而,我们一直没能揭开其神秘的面纱。在本节中,我们将具体介绍着色器在WGPU中的应用。