普通视图

发现新文章,点击刷新页面。
昨天以前萤火之森

Rust 学习资源

作者 猫冬
2022年1月18日 22:01

博主发现咸鱼咸鱼着居然到 2022 年了,忽然良心不安,因此先从这篇比较水的资源总结开始吧!

这篇文章主要是总结下学 Rust 参考过的资料,会随着博主对 Rust 的关注随缘更新。

基础

进阶

有潜力的教程

练习实战的小项目

游戏开发相关

其他领域相关

Rust 动态

各种汇总(Awesome 系列)

Podcast

博客

  • https://llever.com/
    • 包含很多 Rust 周报及相关博文的翻译,不过现在好像不更新了。
  • 芽之家
    • 同样是包含很多 Rust 周报及相关博文的翻译,同样好像不更新了😓

博客 RSS

名称订阅链接
This Week in Rusthttps://this-week-in-rust.org/atom.xml
Read Rusthttps://readrust.net/all/feed.rss
Rust Reddit Hothttps://reddit.0qz.fun/r/rust/hot.json
Rust.cchttps://rustcc.cn/rss
Awesome Rust Weeklyhttps://rust.libhunt.com/newsletter/feed
Rust精选https://rustmagazine.github.io/rust_magazine_2021/rss.xml
Rust on Mediumhttps://medium.com/feed/tag/rust
Rust GameDev WGhttps://gamedev.rs/rss.xml
知乎专栏-时光与精神小屋https://rsshub.app/zhihu/zhuanlan/time-and-spirit-hut
酷熊 Amos fasterthanlihttps://fasterthanli.me/index.xml
pretzelhammer/rust-bloghttps://www.ncameron.org/blog/rss/
Nick Cameronhttps://github.com/pretzelhammer/rust-blog/releases.atom
FOLYDhttps://folyd.com/blog/feed.xml
Alex Chihttps://www.skyzh.dev/posts/index.xml

作为参考的学习路线

各种方法入门

noxasaxon/learning_rust.md

jondot/rust-how-do-i-start

路线1

Rust Study RoadMap

作者在文中提供了两种学习路线。

路线2

  1. 通读 Rust by Example,把其中的例子都自己运行一遍,特别是对其中指出的错误用法也调试一遍。

  2. 通读 The Rust Programming Language,在进行了第一步后,已经基本对 Rust 的常用概念有所了解了,这个时候再读这本官方教程,进一步理解某些细节。

  3. 行了,到这一步后你就可以尝试做一个项目了,然后在做项目的过程中你一定会需要各种各样的库,请到 Crates上搜索,寻找适合你需求的 crate,了解它们的用法,必要时查阅它们的源码。一开始写实际代码时,你肯定会很痛苦,Rust 编译器一定会不断地折磨你,这个时候不要放弃,返回去再看 Rust by ExampleThe Rust Programming Language,然后终有通过编译的那一刻,恭喜你,入坑了!

常用站点

其他资料

本文参考

Compute Shader 简介

作者 猫冬
2021年4月18日 06:14

做游戏的时候,我们经常要面对各种优化问题。DOTS 技术栈的出现提供了一种 CPU 端的多线程方案,那么我们是否也能将一些计算转到 GPU 上面,从而平衡好对 CPU 和 GPU 的使用呢?对我而言,以前使用 GPU 无非是通过写 vert/frag shader、做好渲染相关的设置等操作,但实际上我们还能使用 GPU 的计算能力来帮我们解决问题。Compute Shader 就是我们跟 GPU 请求计算的一种手段。

本文将从并行架构开始,依次讲解一个最简单的 Compute Shader的编写、线程与线程组的概念、GPU 结构和其计算流水线,并讲解一个鸟群 Flocking 的实例,最后介绍 Compute Shader 的应用。全文较长,读者可以通过目录挑想看的看。

Compute Shader 也和传统着色器的写法十分不一样,写传统 Shader 写怕了的同学请放心~

介绍

当今的 GPU 已经针对单址或连续地址的大量内存处理(亦称为流式操作,streaming operation)进行了优化,这与 CPU 面向内存随机访问的设计理念则刚好背道而驰。再者,考虑到要对顶点与像素分别进行单独的处理,因此 GPU 现已经采用了大规模并行处理架构。例如,NVIDIA 公司开发的 “Fermi” 架构最多可支持 16 个流式多处理器(streaming multiprocessor, SM),而每个流式处理器又均含有 32 个 CUDA 核心,也就是共 512 个 CUDA 核心。

CUDA 与 OpenCL 其实就是通过访问 GPU 来编写通用计算程序的两组不同的 API。

CPU compare GPU

现代的 CPU 有 4-8 个 Core,每个 Core 可以同时执行 4-8 个浮点操作,因此我们假设 CPU 有 64 个浮点执行单元,然而 GPU 却可以有上千个这样的执行单元。仅仅只是比较 GPU 和 CPU 的 Core 数量是不公平的,因为它们的职能不同,组织形式也不同。

显然,图形的绘制优势完全得益于 GPU 架构,因为这架构就是专为绘图而精心设计的。但是,一些非图形应用程序同样可以从 GPU 并行架构所提供的强大计算能力中受益。我们将 GPU 用于非图形应用程序的情况称为通用 GPU 程序设计(通用 GPU 编程。General Purpose GPU programming, GPGPU programming)。当然,并不是所有的算法都适合由 GPU 来执行,只有数据并行算法(data-parallel algorithm) 才能发挥出 GPU 并行架构的优势。也就是说,仅当拥有大量待执行相同操作的数据时,才最适宜采用并行处理。[1]

粒子系统是一个例子,我们可简化粒子之间的关系模型,使它们彼此毫无关联,不会相互影响,以此使每个粒子的物理特征都可以分别独立地计算出来。

对于 GPGPU 编程而言,用户通常需要将计算结果返回 CPU 供其访问。这就需将数据由显存复制到系统内存,虽说这个过程的速度较慢(见下图),但是 GPU 在运算时所缩短的时间相比却是微不足道的。 针对图形处理任务来说,我们一般将运算结果作为渲染流水线的输入,所以无须再由 GPU 向 CPU 传输数据。例如,我们可以用计算着色器(Compute Shader)对纹理进行模糊处理(blur),再将着色器资源视图(shader resource view,DirectX 的概念),与模糊处理后的纹理相绑定,以作为着色器的输入。

CPU 与 GPU 的数据传输

计算着色器虽然是一种可编程的着色器,但 Direct3D 并没有将它直接归为渲染流水线中的一部分。虽然如此,但位于流水线之外的计算着色器却可以读写 GPU 资源。从本质上来说,计算着色器能够使我们访问 GPU 来实现数据并行算法,而不必渲染出任何图形。正如前文所说,这一点即为 GPGPU 编程中极为实用的功能。另外,计算着色器还能实现许多图形特效——因此对于图形程序员来说,它也是极具使用价值的。前面提到,由于计算着色器是 Direct3D 的组成部分,也可以读写 Direct3D 资源,由此我们就可以将其输出的数据直接绑定到渲染流水线上。

计算着色器并非渲染流水线的组成部分,但是却可以读写GPU 资源。而且计算着色器也可以参与图形的渲染或单独用于 GPGPU 编程

最简单的 Compute Shader

现在我们来看看一个最简单的 Compute Shader 的结构。

Unity 右键 → Create → Shader → Compute Shader 就可以创建一个最简单的 Compute Shader。

Compute Shader 文件扩展名为 .compute,它们是以 DirectX 11 样式 HLSL 语言编写的。

1
2
3
4
5
6
7
8
9
10
#pragma kernel CSMain

RWTexture2D<float4> Result;

[numthreads(8,8,1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
// 为了演示,我把模板中下面这行改了
Result[id.xy] = float4(0, 1, 1, 1.0);
}

第 1 行:一个计算着色器资源文件必须包含至少一个可以调用的 compute kernel,实际上这个 kernel 对应的就是一个函数,该函数由 #pragma 指示,名字要和函数名一致。一个 Shader 中可以有多个内核,只需定义多个 #pragma kernel functionName 和对应的函数即可,C# 脚本可以通过 kernel 的名字来找到对应要执行的函数( shader.FindKernel(functionName))。

第 3 行: RWTexture2D 是一种可供 Compute Shader 读写的纹理,C# 脚本可以通过 SetTexture() 设置一个可读写的 RenderTexture 供 Compute Shader 修改像素颜色。其中 RW 代表可读写。

第 5 行:numthreads 设置线程组中的线程数。组中的线程可以被设置为 1D、2D 或 3D 的网格布局。线程组和线程的概念下文会提到。

第 6 行:CSMain 为函数名,需要和 pragma 定义的 kernel 名一一对应。一个函数体代表一个线程要执行的语句,传进来的 SV_DispatchThreadID 是三维的线程 id,下文会提到。

第 9 行:根据当前线程 id 索引到可读写纹理对应的像素,并设置颜色。

C# 脚本这边

1
2
3
4
5
6
7
8
9
10
11
12
private void InitShader()
{
_image = GetComponent<Image>();
_kernelIndex = computeShader.FindKernel("CSMain");
int width = 1024, height = 1024;
_rt = new RenderTexture(width, height, 0) {enableRandomWrite = true};
_rt.Create();

_image.material.SetTexture("_MainTex", _rt);
computeShader.SetTexture(_kernelIndex, "Result", _rt);
computeShader.Dispatch(_kernelIndex, width / 8, height / 8, 1);
}

第 4 行:一个 Compute Shader 可能有多个 Kernel,这里根据名字找到需要的 KernelIndex,这样脚本才知道要把数据送给哪一个函数运算。

第 6、7 行:创建一个支持随机读写的 RenderTexture

第 10 行:为 Compute Shader 设置要读写的纹理。

第 11 行:设置好要执行的线程组的数量,并开始执行 Compute Shader。线程组数量的设置下文会提到。

将 Compute Shader 在 Inspector 赋值给脚本,然后将脚本挂在一个有 Image 组件的 GameObject 下,就能看到蓝色的图片。

简单的着色器示例

到现在我们应该大概明白了:

  • kernel 函数里面执行的是一个线程的要执行的逻辑。
  • 我们需要设置线程组的数量(Dispatch)、和线程组内线程的数量(numthreads)。
  • 我们可以为 Compute Shader 设置纹理等可读写资源。

那么什么是线程组和线程呢?我们又该如何设置数量?

如何划分工作:线程与线程组

在 GPU 编程的过程中,根据程序具体的执行需求,可将 线程 划分为由 线程组(thread group) 构成 的网格(grid)。

numthreadDispatch 的三维 Grid 的设置方式只是方便逻辑上的划分,硬件执行的时候还会把所有线程当成一维的。因此 numthread(8, 8, 1)numthread(64, 1, 1) 只是对我们来说索引线程的方式不一样而已,除外没区别。

线程组构成的 3D 网格

下图是 Dispatch(5,3,2)numthreads(10,8,3) 时的情况。

注意下图 Y 轴是 DirectX 的方向,向下递增,而 Compute Shader 中 Y 轴是相反的,向上递增,这里参考网格内的结构和线程组与线程的关系即可。

线程组 3D 网格

上图中还显示了 SV_DispatchThreadID 是如何计算的。

不难看出,我们能够根据需求定义出不同的线程组布局。例如,可以定义一个具有 X 个线程的单行线程组 [numthreads(X, 1, 1)] 或内含 Y 个线程的单列线程组 [numthreads(1, Y, 1)]

还可以通过将维度 z 设为 1 来定义规模为 X×YX × Y 的 2D 线程组,形如 [numthreads(X, Y, 1)]。我们应结合所遇到的具体问题来选择适当的线程组布局。

例如当我们处理 2D 图像时,需要让每一个线程单独处理一个像素,就可以定义 2D 的线程组。假设我们 numthreads 设置为 (8, 8, 1),那么一个线程组就有 8×88×8 个线程,能处理 8×88×8 的像素块(内含 64 个像素点)。

那么如果我们要处理一个 texResolution×texResolutiontexResolution × texResolution 分辨率的纹理,那么需要多少个线程组呢?

x 和 y 方向都需要 texResolution/8texResolution / 8 个线程组。

可以通过线程组来划分要处理哪些像素块(8×88×8

线程组解释

线程组解释

numthreads 有最大线程限制,具体查阅不同平台的文档:numthreads

前面介绍了如何设置线程组和线程的数量,现在介绍线程组和线程在硬件的运行形式。

线程组的 GPU 之旅

Fermi 架构

Ampere 架构

我们知道 GPU 会有上千个“核心”,用 NVIDIA 的说法就是 CUDA Core。

  • SP:最基本的处理单元,streaming processor,也称为 CUDA core。最后具体的指令和任务都是在 SP 上处理的。GPU 进行并行计算,也就是很多个 SP 同时做处理。我们所说的几百核心的 GPU 值指的都是 SP 的数量;
  • SM:多个 SP 加上其他的一些资源组成一个 streaming multiprocessor。也叫 GPU 大核,其他资源如:warp scheduler,register,shared memory 等。SM 可以看做 GPU 的心脏(对比 CPU 核心),register 和 shared memory 是 SM 的稀缺资源。CUDA 将这些资源分配给所有驻留在 SM 中的 threads。因此,这些有限的资源就使每个 SM 中 active warps 有非常严格的限制,也就限制了并行能力。

这些核心被组织在流式多处理器(streaming multiprocessor, SM)中,一个线程组运行于一个多处理器(SM)之上。每一个核心同一时间可以运行一个线程。

流式多处理器(streaming multiprocessor, SM)是 Nvidia 的说法,AMD 对应的单元则是 Compute Unit。

因此,对于拥有 16 个 SM 的 GPU 来说,我们至少应将任务分解为 16 个线程组,来让每个多处理器都充分地运转起来。但是,要获得更佳的性能,我们还应当令每个多处理器至少拥有两个线程组,使它能够切换到不同的线程组进行处理,以连续不停地工作(线程组在运行的过程中可能会发生停顿,例如,着色器在继续执行下一个指令之前会等待纹理的处理结果,此时即可切换至另一个线程组)。

SM 会将它从 Gigathread 引擎(NVIDIA 技术,专门管理整个流水线)那收到的大线程块,拆分成许多更小的堆,每个堆包含 32 个线程,这样的堆也被称为:warp (AMD 则称为 wavefront)。多处理器会以 SIMD32 的方式(即 32 个线程同时执行相同的指令序列)来处理 warp,每个 CUDA 核心都可处理一个线程。

“Fermi” 架构中的每个多处理器都具有 32 个 CUDA 核心。

每一个线程组都会被划分到一个 Compute Unit 来计算,线程组中的线程由 Compute Unit 中的 SIMD 部分来执行。
如果我们定义 numthreads(8, 2, 4),那么每个线程组就有 8×2×4=648×2×4=64 个线程,这一整个线程组会被分成两个 warp,调度到单个 SIMD 单元计算。

Memory Stall(内存延迟)

单个 SM 处理逐个 warp,当一个 warp 暂时需要等待数据的时候,就可以先换其他 warp 继续执行。

如何设置好线程组的大小

我们应当总是将线程组的大小设置为 warp 尺寸的整数倍。让 SM 同时容纳多个 warp,能够以防一些情况。例如有时候为了等待某些数据就绪,你不得不停下来。比如说,我们需要通过法线纹理贴图来计算法线光照,即使该法线纹理已经在 Cache 中了,访问该资源仍然会有所耗时,而如果它不在 Cache 中,那就更加耗时了。用专业术语讲就是 Memory Stall(内存延迟)。与其什么事情也不做,不如将当前的 Warp 换成其它已经准备就绪的 Warp 继续执行。[2]

Dispatch/Thread Group SIze Heuristics

上图来自:DirectCompute Lecture Series 210: GPU Optimizations and Performance

NVIDIA 在 Maxwell 更改了 SM 的组织方式,即 SMM——全新的 SM 架构。每个 SM 分为四个独立的处理块,每个处理块具备自己的指令缓冲区、调度器以及 32 个 CUDA 核心。因此 Maxwell 中可以同时运行 4 个以上的 Warp,实际上,在 GTC2013 大会上的一个 CUDA 优化视频里讲到,在常用 case 中推荐使用 30 个以上的有效 Warp,这样才能确保 Pipeline 的满载利用率。
—— Guohui Wang

NVIDIA 公司生产的图形硬件所用的 warp 单位共有 32 个线程。而 ATI 公司采用的 “wavefront” 单位则具有 64 个线程,且建议为其分配的线程组大小应总为 wavefront 尺寸的整数倍。另外,值得一提的是,不管是 warp 还是 wavefront,它们的大小在未来几代中都有可能发生改变。

总之,每个 SM 的操作度是 warp,但是每个 SM 可以同时处理多个 warp。然后因为有内存等待(memory stall)的问题,同一个 thread block 有可能需要等待内存才做,因此可以使用多个线程组交叉运行。warp 对我们是不可见和不可编程的,我们可编程的只有线程组。[3]

还可以参考 GPU Open 中 Compute Shader 部分

GPU Compute Unit

接下来我们看一下 GPU 内部的结构,这里的内容来自 Compute Shaders: Optimize your engine using compute / Lou Kramer, AMDLou Kramer 以 AMD 的 GCN 架构为例,介绍了 GPU 大体的结构。

这里 GCN 就是一个 Compute Unit,Vega 64 显卡有 64 个 Compute Unit。

gpu-talk-1

GCN 有 4 个 SIMD-16 单元(即 16 个线程同时执行相同的指令序列)。

gpu-talk-2

线程间交流

多个线程组间的交流

上面提到,线程并不能访问其他组中的共享内存。如果线程组需要互相交流,那么就需要 L2 cache 来支持。但是 L2 cache 性能肯定会有折扣,因此我们要保证组间的交流尽可能少。

gpu-talk-3

单个线程组内的交流

如果单个线程组内线程需要互相交流,则需要 Local Data Share (LDS) 来完成。

gpu-talk-4

LDS 会被其他着色阶段(shader stage)使用,例如像素着色器就需要 LDS 来插值。但是 Compute Shader 的用途和传统着色器不一样,不是必须要 LDS,因此我们可以随意地使用 LDS。

1
2
3
4
5
6
7
8
9
10
groupshared float data[8][8];

[numthreads(8,8,1)]
void main(ivec3 index : SV_GroupThreadID)
{
data[index.x][index.y] = 0.0;
GroupMemoryBarrierWithGroupSync();
data[index.y][index.x] += index.x;

}

需要组内共享的变量前加 groupshared ,同时为了保证其他线程也能读到数据,我们也需要通过 Barrier 来保证他们读的时候 LDS 里面有需要的数据。

LDS 比 L1 cache 还快!

Vector Register 和 Scalar Register

如果有些变量是线程独立的,我们称之为 “non-uniform” 变量。(如果一个线程组内有 64 个线程,就要存 64 份数据)

如果有些变量是线程间共享的,我们称之为 “uniform” 变量,例如线程组 id 是组内每个线程都一样的。(每个线程组内只存 1 份数据)

“non-uniform” 变量会被储存到 Vector Register(VGPR, vector general purpose register)中。

“uniform” 变量会被储存到 Scalar Register(SGPR, scalar general purpose register)中。

gpu-talk-5

如果用了过多 “non-uniform” 变量导致 Vector Register 装不下,就会导致分配给 SIMD 的线程组数量降低。

与传统着色器执行流程的异同

Vert-Frag Shader

  1. 首先 Command Processor 会收集并处理所有命令,发送到 GPU,并告知下一步要做什么。

  2. Draw() 命令发送后,Command Processor 告知 Graphics Processor 要做的事情。

    我们可以将 Graphics Processor 看作是输入装配器(Input Assembler)的硬件对应的部分。

  3. 然后类似于顶点着色器这些就会被送到 Compute Unit 去计算,处理完会到 Rasterizer (光栅器),并返回处理好的像素到 Compute Unit 执行像素着色(Pixel shader)。

  4. 最后才会输出到 RenderTarget 。

下图中,AMD 显卡架构中的 Compute Unit 相当于 nVIDIA GPUs 中的流式多处理器(streaming multiprocessor, SM)。

gpu-talk-6

Compute Shader

  1. 首先 Command Processor 仍会收集并处理所有命令,发送到 GPU。
  2. 我们不需要传数据到 Graphics Processor,因为这不是一个 Graphics Command,而是直接传到 Compute Unit。
  3. Compute Unit 开始处理 Compute Shader,输入可以有 constants 和 resources(对应 DirectX 的 Resource 可以绑定到渲染管线的资源,例如顶点数组等),输出可以有 writable resources(UAV, Unordered Access View 能被着色器写入的资源视图)。

总结

因此,如果我们用了 Compute Shader,可以不通过渲染管线,跳过 Render Output,使用更少硬件资源,利用 GPU 来完成一些渲染不相关的工作。

gpu-talk-7

此外,Compute Shader 的流水线需要的信息也更少

gpu-talk-8

Boids 示例

讲完了理论,这里来看看我们在 Unity 中使用 Compute Shader 来做一个鸟群(Boids)的 demo。

群落算法可以参考:Boids (Flocks, Herds, and Schools: a Distributed Behavioral Model)

代码示例地址:Latias94/FlockingComputeShaderCompare

群落算法简单来讲,就是模拟生物群落的自组织特性的移动。

Craig Reynolds 在 1986 年对诸如鱼群和鸟群的运动进行了建模,提出了三点特征来描述群落中个体的位置和速度:

  1. 排斥(separation):每个个体会避免离得太近。离得太近需要施加反方向的力使其分开。
  2. 对齐(Alignment):每个个体的方向会倾向于附近群落的平均方向。
  3. 凝聚(Cohesion):每个个体会倾向于移动到附近群落的平均位置。

在这个示例中,我们可以将每一只鸟的位置和方向用一个线程来计算,Compute Shader 负责遍历这只鸟的周围鸟的信息,计算出这只鸟的平均方向和位置。C# 脚本则负责每一帧传入凝聚(Cohesion)的位置、经过的时间,再从 Compute Shader 获取每一只鸟的位置和朝向,设置到每一只鸟的 Transform 上。

设置数据

文章开头的例子中,脚本给 Shader 设置了 RWTexture2D<float4> ,让 Compute Shader 能直接在 Render Tecture 设置颜色。

对于其他类型的数据,我们首先要定义一个结构(Struct),再通过 ComputeBuffer 与 Compute Shader 交流数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// FlockingGPU.cs
struct Boid
{
public Vector3 position;
public Vector3 direction;
};
public class FlockingGPU : MonoBehaviour
{
public ComputeShader shader;
private Boid[] _boidsArray;
private GameObject[] _boids;
private ComputeBuffer _boidsBuffer;
// ...
void Start()
{
_kernelHandle = shader.FindKernel("CSMain");

uint x;
// 获取 Compute Shader 中定义的 numthreads
shader.GetKernelThreadGroupSizes(_kernelHandle, out x, out _, out _);
_groupSizeX = Mathf.CeilToInt(boidsCount / (float) x);
// 塞满每个线程组,免得 Compute Shader 中有线程读不到数据,造成读取数据越界
_numOfBoids = _groupSizeX * (int) x;

InitBoids();
InitShader();
}

private void InitBoids()
{
// 初始化 _Boids GameObject[]、_boidsArray Boid[]
}

void InitShader()
{ // 定义大小,鸟的数量和每个鸟结构的大小,一个 Vector3 就是 3 * sizeof(float)
// 10000 只鸟,每只占6 * 4 bytes,总共也就占 0.234mib GPU 显存
_boidsBuffer = new ComputeBuffer(_numOfBoids, 6 * sizeof(float));
_boidsBuffer.SetData(_boidsArray); // 设置结构数组到 Compute Buffer 中
// 设置 buffer 到 Compute Shader,同时设置要调用的计算的函数 Kernel
shader.SetBuffer(_kernelHandle, "boidsBuffer", _boidsBuffer);
shader.SetFloat("boidSpeed", boidSpeed); // 设置其他常量
shader.SetVector("flockPosition", target.transform.position);
shader.SetFloat("neighbourDistance", neighbourDistance);
shader.SetInt("boidsCount", boidsCount);
}
// ...
void OnDestroy()
{
if (_boidsBuffer != null)
{ // 用完主动释放 buffer
_boidsBuffer.Dispose();
}
}
}

获取数据

在开头最简单的 Compute Shader 一节中,我介绍了需要 Dispatch 去执行 Compute Shader 的 Kernel。

下面的 Update,设置了每一帧会变的参数,Dispatch 之后,再通过 GetData 阻塞等待 Compute Shader kernel 的计算结果,最后对每一个 Boid 结构赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// FlockingGPU.cs
public class FlockingGPU : MonoBehaviour
{
// ...
void Update()
{ // 设置每一帧会变的变量
shader.SetFloat("deltaTime", Time.deltaTime);
shader.SetVector("flockPosition", target.transform.position);
shader.Dispatch(_kernelHandle, _groupSizeX, 1, 1); // 调用 Compute Shader Kernel 来计算
// 阻塞等待 Compute Shader 计算结果从 GPU 传回来
_boidsBuffer.GetData(_boidsArray);
for (int i = 0; i < _boidsArray.Length; i++)
{ // 设置鸟的 position 和 rotation
_boids[i].transform.localPosition = _boidsArray[i].position;
if (!_boidsArray[i].direction.Equals(Vector3.zero))
{
_boids[i].transform.rotation = Quaternion.LookRotation(_boidsArray[i].direction);
}
}
}
}

在 Compute Shader 中,也要定义一个 Boid 结构和相对应的 RWStructuredBuffer<Boid> 来用脚本传来的 Compute Buffer。Shader 主要就是对一只鸟遍历一定范围内的鸟群的信息,计算出结果返回给脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// SimpleFlocking.compute
#pragma kernel CSMain
#define GROUP_SIZE 256

struct Boid
{ // Compute Shader 也定义好相关的结构
float3 position;
float3 direction;
};

RWStructuredBuffer<Boid> boidsBuffer; // 允许读写的数据 buffer
float deltaTime;
float3 flockPosition;

[numthreads(GROUP_SIZE,1,1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
Boid boid = boidsBuffer[id.x];
// ...
for (int i = 0; i < boidsCount; i++)
{
if (i == id.x)
continue;
Boid tempBoid = boidsBuffer[i];
// 通过周围的鸟的信息,计算经过三个特性后,这一只鸟的方向和位置。
// ...
}
// ...
boid.direction = lerp(direction, normalize(boid.direction), 0.94);
boid.position += boid.direction * boidSpeed * deltaTime;
// 设置数据到 Buffer,等待 CPU 读取
boidsBuffer[id.x] = boid;
}

Dispatch 之后 GetData 是阻塞的,如果想异步地获取数据,Unity 2019 新引入一个 API:AsyncGPUReadbackRequest ,可以让我们先发送一个获取数据的请求,再每一帧去查询数据是否计算完。也有同学用了测出第一次调用耗时较多等问题,具体可以参考:Compute Shader 功能测试(二)

下面是 100 只鸟的结果:

100只鸟的结果

通过 Compute Shader,我们可以通过 Compute Shader 在 GPU 直接计算好需要计算的东西(例如位置、mesh 顶点等),并与传统着色器共享一个 ComputeBuffer ,直接在 GPU 渲染,这样就省去渲染时 CPU 再次传数据给 GPU 的耗时。我们也可以将 Compute Shader 计算后的数据返回给 CPU 再做额外的计算。总而言之,Compute Shader 十分灵活。

CPU 端计算 vs GPU 端计算

假设我们在 CPU 端不用任何 DOTS,直接在每个 Update 中 for 每个鸟计算朝向和位置,这样性能是非常差的。

下图是把计算都放到 C# Update 中的 Profile:

C# 每个 Update 中直接计算

如果放到 Compute Shader 计算,每个 Update 更新数据,这样 CPU 消耗小了很多。

Compute Shader 计算,每个 Update 更新数据

感兴趣的朋友可以对比下 FlockingCPU.cs 和 FlockingGPU.cs 的代码,会发现两者的代码其实十分相似,只不过前者把 for loop 放到脚本,后者放到了 Compute Shader 中而已,因此如果大家觉得有一些地方十分适合并行计算,就可以考虑把这部分计算放到 GPU 计算。

Profile Compute Shader

我们可以通过 Profiler 来看 GPU 利用情况,通常这个面板是隐藏的,需要手动打开。

也可以通过 RenderDoc 来看,这里不展示。

boid-profile-3

优化:DrawMeshInstanced

前面我们用 Instantiate 来初始化鸟群,其实我们也能通过 GPU instancing 来优化,用 Graphics.DrawMeshInstanced 来实例化 prefab。这个优化未包含在 Github 例子中,这里提供思路。

boid-profile-4

这么做的话,位置和旋转都要在传统 shader 中计算成变换矩阵应用在顶点上,因此为了防止 Compute Shader 数据传回 CPU 再传到 GPU 的传统 shader 的开销,需要两个 Shader 共享一个 StructuredBuffer

这样如果要给模型加动画的话,还得提前烘焙动画,将每一帧动画的顶点和当前帧数提前传到 vertex shader(or surface shader) 里做插值,这样做的话还能根据鸟的速度去控制动画的速率。

应用

  • 遮挡剔除(Occlusion Culling
  • 环境光遮蔽(Ambient Occlusion
  • 程序化生成:
    • terrain heightmap evaluation with noise, erosion, and voxel algorithms
  • AI 寻路
    • Compute Shader 做寻路有点不太好的就是往往游戏(CPU)需要知道计算结果,因此还要考虑 GPU 返回结果给 CPU 的延时。可以考虑做 CPU 端并行的方案,例如用 Job System。
  • GPU 光线追踪
  • 图像处理,例如模糊化等。
  • 其他你想放到 GPU,但是传统着色器干不了的并行的解决方案。

原神

Unity线上技术大会-游戏专场|从手机走向主机 -《原神》主机版渲染技术分享

Genshin 主机渲染管线简介

解压预烘焙的 Shadow Texture

在离线制作的时候,对于烘焙好的 shadow texture 做一个压缩,尽量地去保持精度,运行的时候解压的速度也非常快,用 Compute Shader 去解压的情况,1K×1K 的 shadow texture,解压只需要 0.05 毫秒。

解压预烘焙的 Shadow Texture

做模糊处理

在进行模糊处理的时候,每个像素需要采取周边多个像素的数值进行混合,可以看到,如果使用传统的 PS,每个像素都会需要多次贴图采样,且这些采样结果实际上是可以在相邻其他像素的计算中进行重用的,因此为了进一步提升计算性能,《原神》这里的做法是将模糊处理放到 Compute Shader 中来完成。

具体的做法是,将相邻像素的采样结果存储在 局部存储空间(Local Data Share) 中,之后再模糊的时候取用,一次性完成四个像素的模糊计算,并将结果输出。[4]

天涯明月刀

《天涯明月刀》手游引擎技术负责人:如何应用GPU Driven优化渲染效果?| TGDC 2020

gpu-driven-compute-1

做遮挡剔除(Occlusion Culling)时,CPU 只能做到 Object Level,而 GPU 可以通过切分 Mesh 做进一步的剔除。

gpu-driven-compute-2

知乎上也有人尝试了实现:Unity实现GPUDriven地形

斗罗大陆

三七研发,这款被称作 “目前最原汁原味的”《斗罗大陆》3D 手游都用到了哪些 Unity 技术?

利用 Compute Shader 对所有美术贴图逐像素对比,筛选出大量的重复、相似、屯余、大透明的贴图。

Clay Book

基于3D SDF 体渲染的黏土游戏:Claybook Game

演讲:DD2018: Sebastian Aaltonen - GPU based clay simulation and ray tracing tech in Claybook

动图:https://gfycat.com/gaseousterriblechupacabra

Jelly in the sky

Finished my compute shader based game 这帖子的哥们写了六千多行 HLSL 代码做了一个完全在 GPU 执行的基于物理模拟的游戏。

Steam:Jelly in the sky on Steam

动图:https://gfycat.com/validsolidcanine

开源项目

缺点

虽然 Unity 帮我们做了跨平台的工作,但是我们仍然需要面对一些平台差异。

本小节内容大部分来自 Compute Shader : Optimize your game using compute

  • 难 Debug
  • 数组越界,DX 上会返回 0,其它平台会出错。
  • 变量名与关键字/内置库函数重名,DX 无影响,其他平台会出错。
  • 如果 SBuffer 内结构的显存布局与内存布局不一致,DX 可能会转换,其他平台会出错。
  • 未初始化的 SBuffer 或 Texture,在某些平台上会全部是 0,但是另外一些可能是任意值,甚至是NaN。
  • Metal 不支持对纹理的原子操作,不支持对 SBuffer 调用 GetDimensions
  • ES 3.1 在一个 CS 里至少支持 4 个 SBuffer(所以,我们需要将相关联的数据定义为 struct)。
  • ES 从 3.1 开始支持 CS,也就是说,在手机上的支持率并不是很高。部分号称支持 es 3.1+ 的 Android 手机只支持在片元着色器内访问 StructuredBuffer。
    • 使用 SystemInfo.supportsComputeShaders 来判断支不支持[5]

最后

我相信 Compute Shader 这个词不少读者应该都会在其他地方见过,但是大都觉得这个技术离我们还很远。我身边的朋友问了问也没怎么了解过,更不要说在项目上用了,这也是这篇文章诞生的原因之一。

当我们面临使用 DOTS 还是 Compute Shader 的抉择时,更应该从项目本身出发,考虑计算应该放在 CPU 还是 GPU,Compute Shader 中跟 GPU 沟通的开销是否能够接受。读者也可以参考下 Unity Forum 中相关的讨论:Unity DOTS vs Compute Shader

开始碎碎念,去年的年终总结也没写,今年到现在就憋出一篇文章,十分不应该。其实也是自己没什么好分享的,自己还需要多学习。当然也很高兴通过博客认识到不同朋友,这是我写作的动力,谢谢你们。

参考


  1. 《DirectX 12 3D 游戏开发实战》第13章 计算着色器 ↩︎

  2. Render Hell —— 史上最通俗易懂的GPU入门教程(二) ↩︎

  3. 知乎 - “问个CUDA并行上的小白问题,既然SM只能同时处理一个WARP,那是不是有的SP处于闲置?”的评论 ↩︎

  4. 米哈游技术总监分享:《原神》主机版渲染技术要点和解决方案 ↩︎

  5. ComputeShader 手机兼容性报告 ↩︎

博客新增公开笔记部分

作者 猫冬
2020年10月3日 23:53

我认为博客应该放一些经过思考的、实践的、适合读者阅读的文章,自己有时候也会在看其他视频教程或文章时记一些笔记。有些笔记本身不太适合分享出来,因为做笔记不可避免的会按照自己的思路和现有知识来定制,可能和别人注意重点不太一样。

因此我打算将一些比较成文的、有结构性的、有参考价值的笔记分享出来,这也能锻炼我把笔记组织成文的能力。

这些公开的笔记我放在独立的一个 Notion 页面中,这个页面可以点击博客上方的公开笔记,或者这个链接找到:公开笔记

Notion 本身对公式、排版都比较友好,但是打开可能要科学上网,我是懒得把这些文章往博客搬了…不过用 Notion 有个好处就是,我对公开笔记的编辑都能实时更新到。

正文太空了也不好,就放个我笔记的主页图吧~

图形学常见的变换推导

作者 猫冬
2020年7月27日 01:46

注意:由于这个博客主题对 MathJax 支持不好,部分推导转用图片代替,或者可以移步我的 Notion 笔记:Transformation

本文是 Games101-现代计算机图形学入门 第三和第四节课的笔记,文中对二维变换、三维变换、视图变换、正交投影和透视投影做了推导,相关视频在下方。

GAMES101-Lecture03 Transformation

GAMES101-Lecture04 Transformation Cont.

本文同时参考了《Unity Shader 入门精要》的第四章,作者公开了第四章的 PDF,可以在下面下载到。

candycat1992/Unity_Shaders_Book

闫老师的推导十分简洁易懂,我也尽量把过程补充到文章中,读者看了我相信肯定也能跟着思路把变换公式推导出来。

在读本文的过程中,也推荐参考上面提到的视频和 pdf 互相参考,本文是视频中推导的详细笔记,冯乐乐的 pdf 中虽然没有投影变换的推导,但是在很多地方都把理论讲的十分清晰,例如必要的数学基础和各种图形学概念的讲解。

线性变换

x=ax+by y=cx+dy\begin{array}{l}x^{\prime}=a x+b y \\\ y^{\prime}=c x+d y\end{array}

如果我们可以把变换写成这样一种形式,矩阵乘以输入坐标等于输出坐标,这样可以叫做线性变换。

[x y]=[ab cd][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}a & b \\\ c & d\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right]

x=Mx\mathbf{x}^{\prime}=\mathbf{M} \mathbf{x}

Scale Matrix

Transformation%206c54d524cd134bc0943ed5335afa2508/Untitled.png

x=sx y=sy\begin{array}{l}x^{\prime}=s x \\\ y^{\prime}=s y\end{array}

其变换矩阵:

[x y]=[s0 0s][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}s & 0 \\\ 0 & s\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right]

Scale (Non-Uniform)

x y 可以不均匀地缩放

201.png

[x y]=[sx0 0sy][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}s_{x} & 0 \\\ 0 & s_{y}\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right]

Reflection Matrix

202.png

Horizontal reflection:

x=x y=y\begin{array}{l}x^{\prime}=-x \\\ y^{\prime}=y\end{array}

[x y]=[10 01][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{cc}-1 & 0 \\\ 0 & 1\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right]

Shear Matrix

203.png

2D Rotation Matrix

204.png

205.png

齐次坐标

Translation

平移变换非常特殊。

206.png

x=x+tx y=y+ty\begin{array}{l}x^{\prime}=x+t_{x} \\\ y^{\prime}=y+t_{y}\end{array}

写出来简单,但是两个式子不能写成线性变换的形式。

[x y]=[ab cd][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}a & b \\\ c & d\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right]

只能写成:

[x y]=[ab cd][x y]+[tx ty]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}a & b \\\ c & d\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right]+\left[\begin{array}{l}t_{x} \\\ t_{y}\end{array}\right]

因此平移变换并不是线性变换。

但是我们不希望将平移变换看作一个特殊的例子,那么有没有办法将缩放、错切、平移等变换用一种统一的方式来表示?

在计算机科学,永远要考虑 “Trade-Off”。数据结构中不同降低时间复杂度的办法都会引入空间复杂度。如果两者都能低就很好,但更多时候是非此即彼的事情。“No Free Lunch Theory”。

207.png

引入齐次坐标,可以通过增加一个维度来将平移变换也写成矩阵乘一个点的形式。

向量具有平移不变性,因此后面是 (x, y, 0),平移变换后也不变。

我们也可以通过 w 分量来推出我们操作的结果:

Valid operation if w-coordinate of result is 1 or 0

  • vector + vector = vector
  • point – point = vector
  • point + vector = point
  • point + point = ??

Affine Transformations 仿射变换

Affine map = linear map + translation

(x y)=(ab cd)(x y)+(tx ty)\left(\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right)=\left(\begin{array}{ll}a & b \\\ c & d\end{array}\right) \cdot\left(\begin{array}{l}x \\\ y\end{array}\right)+\left(\begin{array}{l}t_{x} \\\ t_{y}\end{array}\right)

Using homogenous coordinates:

(x y 1)=(abtx cdty 001)(x y 1)\left(\begin{array}{l}x^{\prime} \\\ y^{\prime} \\\ 1\end{array}\right)=\left(\begin{array}{ccc}a & b & t_{x} \\\ c & d & t_{y} \\\ 0 & 0 & 1\end{array}\right) \cdot\left(\begin{array}{l}x \\\ y \\\ 1\end{array}\right)

2D Transformations

Scale

S(sx,sy)=(sx00 0sy0 00 1 )\mathbf{S}\left(s_{x}, s_{y}\right)=\left(\begin{array}{ccc}s_{x} & 0 & 0 \\\ 0 & s_{y} & 0 \\\ 0 & 0 & \text { 1 }\end{array}\right)

Rotation

R(α)=(cosαsinα0 sinαcosα0 001)\mathbf{R}(\alpha)=\left(\begin{array}{ccc}\cos \alpha & -\sin \alpha & 0 \\\ \sin \alpha & \cos \alpha & 0 \\\ 0 & 0 & 1\end{array}\right)

Translation

T(tx,ty)=(10tx 01ty 001)\mathbf{T}\left(t_{x}, t_{y}\right)=\left(\begin{array}{ccc}1 & 0 & t_{x} \\\ 0 & 1 & t_{y} \\\ 0 & 0 & 1\end{array}\right)

逆变换

209.png

2010.png

2011.png

因此变换顺序是很重要的,不满足交换律。

R45T(1,0)T(1,0)R45R_{45} \cdot T_{(1,0)} \neq T_{(1,0)} \cdot R_{45}

矩阵是从右到左运算的:

T(1,0)R45[x y 1]=[101 010 001][cos45sin450 sin45cos450 001][x y 1]T_{(1,0)} \cdot R_{45}\left[\begin{array}{l}x \\\ y \\\ 1\end{array}\right]=\left[\begin{array}{ccc}1 & 0 & 1 \\\ 0 & 1 & 0 \\\ 0 & 0 & 1\end{array}\right]\left[\begin{array}{ccc}\cos 45^{\circ} & -\sin 45^{\circ} & 0 \\\ \sin 45^{\circ} & \cos 45^{\circ} & 0 \\\ 0 & 0 & 1\end{array}\right]\left[\begin{array}{l}x \\\ y \\\ 1\end{array}\right]

矩阵没有交换律,但有结合律。

三维变换

齐次坐标系下的三维变换可以写成下面的形式

(x y z 1)=(abctx defty ghitz 0001)(x y z 1)\left(\begin{array}{l}x^{\prime} \\\ y^{\prime} \\\ z^{\prime} \\\ 1\end{array}\right)=\left(\begin{array}{llll}a & b & c & t_{x} \\\ d & e & f & t_{y} \\\ g & h & i & t_{z} \\\ 0 & 0 & 0 & 1\end{array}\right) \cdot\left(\begin{array}{l}x \\\ y \\\ z \\\ 1\end{array}\right)

Scale

S(sx,sy,sz)=(sx000 0sy00 00sz0 0001)\mathbf{S}\left(s_{x}, s_{y}, s_{z}\right)=\left(\begin{array}{cccc}s_{x} & 0 & 0 & 0 \\\ 0 & s_{y} & 0 & 0 \\\ 0 & 0 & s_{z} & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)

Translation

T(tx,ty,tz)=(100tx 010ty 001tz 0001)\mathbf{T}\left(t_{x}, t_{y}, t_{z}\right)=\left(\begin{array}{cccc}1 & 0 & 0 & t_{x} \\\ 0 & 1 & 0 & t_{y} \\\ 0 & 0 & 1 & t_{z} \\\ 0 & 0 & 0 & 1\end{array}\right)

Rotation

绕轴旋转

Rotation around x-, y-, or z-axis

Rx(α)=(1000 0cosαsinα0 0sinαcosα0 0001)\mathbf{R}_{x}(\alpha)=\left(\begin{array}{cccc}1 & 0 & 0 & 0 \\\ 0 & \cos \alpha & -\sin \alpha & 0 \\\ 0 & \sin \alpha & \cos \alpha & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)

Ry(α)=(cosα0sinα0 0100 sinα0cosα0 0001)\mathbf{R}_{y}(\alpha)=\left(\begin{array}{cccc}\cos \alpha & 0 & \sin \alpha & 0 \\\ 0 & 1 & 0 & 0 \\\ -\sin \alpha & 0 & \cos \alpha & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)

Rz(α)=(cosαsinα00 sinαcosα00 0010 0001)\mathbf{R}_{z}(\alpha)=\left(\begin{array}{cccc}\cos \alpha & -\sin \alpha & 0 & 0 \\\ \sin \alpha & \cos \alpha & 0 & 0 \\\ 0 & 0 & 1 & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)

2012.png

绕着 x 轴旋转,说明 y 和 z 都是在进行旋转的,但 x 不变。因此绕 x 轴的旋转矩阵相比二维的旋转矩阵,第一行是不变的。中间部分和二维旋转矩阵一样。

绕 y 轴旋转不一样,这里涉及到我们要如何思考轴的相互顺序。

根据右手螺旋定则,x 叉乘 y 得到 z,y 叉乘 z 得到 x。但 z 叉乘 x 才能得到 y,是反的,因此 Ry 部分不一样。

Rodrigues’ Rotation Formula

我们能够解决一些简单的问题,复杂的问题可以转化成一些简单问题的组合。

给定根据三个轴的旋转,能否将某一个方向旋转到任意一个方向上去?

2013.png

Rotation by angle α round axis n

有人将任意一个旋转分解成通过 x y z 轴分别做旋转。

R(n,α)=cos(α)I+(1cos(α))nnT+sin(α)(0nzny nz0nx nynx0)\mathbf{R}(\mathbf{n}, \alpha)=\cos (\alpha) \mathbf{I}+(1-\cos (\alpha)) \mathbf{n} \mathbf{n}^{T}+\sin (\alpha)\left(\begin{array}{ccc}0 & -n_{z} & n_{y} \\\ n_{z} & 0 & -n_{x} \\\ -n_{y} & n_{x} & 0\end{array}\right)

证明过程可以参考闫令琪老师的证明:

GAMES101_Lecture_04_supp.pdf

公式给了我们一个旋转矩阵,定义中给了我们一个旋转轴 n 和旋转角度 α。旋转角度好理解,但旋转轴似乎不能这么简单地定义。因为一个旋转轴首先跟起点有关系,然后跟方向有关系,只给一个向量是不是不太合适?

假如说沿着 y 轴旋转,跟沿着 x 和 n 各等于 1 并且也是沿着 y 方向的向量。方向一样,但起点不一样,结果肯定也是不一样的。因此我们说沿着某个轴的方向旋转,就默认了是过原点的,这样起点就在原点上,方向就是 n 方向。

那么如果轴 n 可以平移怎么办?那么我们可以将其进行变换的分解。如果我们要沿着任意轴旋转且轴的起点不在原点,我们可以将所有的东西移到起点为原点的条件下,再旋转,再移回去。

四元数相关

我们上面所用到的旋转矩阵是不太适合做插值的,例如二维旋转 10 度的旋转矩阵加二维旋转 20 度的旋转矩阵求平均,不能得到二维旋转 15 度的旋转矩阵。四元数在这方面方便很多。

View/Camera Transformation 视图变换

定义相机

2014.png

定义一个相机需要三个变量,位置,朝向,和一个向上的方向。

视图变换

2015.png

当相机和要拍的东西一起移动的时候,那拍出来的相片是一样的。也就是说,当我们移动物体时,只要同时以相同的方式移动相机,没有相对位置,那么得出来的结果就是一样的。

如果我们将相机放在一个固定的位置上,那么所有东西在移动时,都可以认为是其他东西在移动,而相机一直在原点不动。相机永远往 -z 方向看,以 y 轴为向上方向(右手坐标系,符合 OpenGL 传统)。这是约定俗成的。相机放在原点有很多好处,能简化计算。

从坐标空间的角度来看,就是将物体和相机从世界空间转到观察空间(摄像机空间)。

2016.png

我们要将相机移到原点,就需要先把相机中心 e 平移到原点,还得把观察的方向 g 移到 -z 上,再把向上方向 t 旋转到 y 方向上,把 g X t 的方向移到 x 方向上。

下面将这系列操作转为矩阵操作。

求视图变换矩阵

  1. 先把相机中心 e 平移到原点

Tview=[100xe 010ye 001ze 0001]T_{v i e w}=\left[\begin{array}{cccc}1 & 0 & 0 & -x_{e} \\\ 0 & 1 & 0 & -y_{e} \\\ 0 & 0 & 1 & -z_{e} \\\ 0 & 0 & 0 & 1\end{array}\right]

平移矩阵写好后,接下来写旋转矩阵。

  1. 把观察的方向 g 旋转到 -z 上,把向上方向 t 旋转到 y 方向上,g X t (g 叉乘 t)的方向旋转到 x 方向上
  • Rotate g to -z , t to y, g X t To x (世界空间到观察空间)
  • Consider its inverse rotation: x to g X t , y to t, z to -g (观察空间到世界空间)

我们可以反过来写,例如把 x 轴 (1,0,0 ) 旋转到 g X t 方向上的旋转矩阵,就比 g X t 移到 x 轴的旋转矩阵要好写很多,而这两个旋转矩阵是互逆的。写出 x 轴旋转到 g X t 方向的旋转矩阵后,再求其逆变换就是我们所需要的 g X t 移到 x 轴的旋转矩阵。

x to g X t , y to t, z to -g 的旋转矩阵就是:

这里 z to -g 是因为我们定义相机的坐标空间为右手坐标系。

Rview1=[xg^×t^xtxg0 yg^×t^ytyg0 zg^×t^ztzg0 0001]R_{v i e w}^{-1}=\left[\begin{array}{cccc}x_{\hat{g} \times \hat{t}} & x_{t} & x_{-g} & 0 \\\ y_{\hat{g} \times \hat{t}} & y_{t} & y_{-g} & 0 \\\ z_{\hat{g} \times \hat{t}} & z_{t} & z_{-g} & 0 \\\ 0 & 0 & 0 & 1\end{array}\right]

要验证也很简单,用该旋转矩阵变换 x 轴就能得到 g X t 的方向。

那么我们的旋转矩阵就能通过对上面的矩阵求逆得出:

因为旋转矩阵是正交矩阵,因此要求逆矩阵,对其转置即可。

Rview=[xg^×t^yg^×t^zg^×t^0 xtytzt0 xgygzg0 0001]R_{v i e w}=\left[\begin{array}{cccc}x_{\hat{g} \times \hat{t}} & y_{\hat{g} \times \hat{t}} & z_{\hat{g} \times \hat{t}} & 0 \\\ x_{t} & y_{t} & z_{t} & 0 \\\ x_{-g} & y_{-g} & z_{-g} & 0 \\\ 0 & 0 & 0 & 1\end{array}\right]

这样我们世界空间到观察空间的变换矩阵就能得出来了:M_view=R_view·T_view

其中 V_g×t 为 g×t 的向量,V_e 为相机原点。

相机需要进行这种变换,变换到约定俗成的位置(原点)上去,那么其他所有物体也需要做这样的变换,这样相对运动不变。这个就是视图变换

模型变换和视图变换经常一起被称为模型视图变换(ModelView Transformation)

Projection Transformation 投影变换

Projection in Computer Graphics

  • 3D to 2D
  • Orthographic projection
  • Perspective projection

2017.png

Perspective projection vs. orthographic projection

2018.png

Orthographic Projection 正交投影

方法一

A simple way of understanding

  • Camera located at origin, looking at -Z, up at Y (looks familiar?)
  • Drop Z coordinate
  • Translate and scale the resulting rectangle to [1,1]2[-1,1]^{2}

2019.png

将坐标中的 z 扔掉,如何区分物体的前和后?

感兴趣可以参考 Catlikecoding Render 1 中 Orthographic Camera 部分。

方法二

In general, we want to map a cuboid [l, r] x [b, t] x [f, n] to the “canonical (正则、规范、标准)” cube [-1,1]^3

我们在 x 轴上定义左和右 [l, r] (左比右小),y 轴上定义下和上 [b, t](下比上小),z 轴上定义远和近 [f, n](远比近小)。

不管 x, y 多大,都将其映射到 [-1, 1] 之间。这也是个约定俗成的事情,能方便计算。这样任何空间中的长方体,都可以映射成一个标准的立方体。

这也是**标准化设备坐标(NDC)**的定义。

上面的左比右小是相对于 x 轴来说的,下比上小是相对于 y 轴说的,但 z 轴上不太直观,因为我们推导的 NDC 是右手坐标系,(相机)看的是 -z 方向,因此一个面离我们远,说明 z 值更小。离我们近,说明 z 值更大。

2020.png

在标准化设备坐标系中 OpenGL 使用的是左手坐标系,因为左手系在这一点上会比较方便。但也会造成别的问题:x × y ≠ z。

这里可以参考:LearnOpenGL 进入3D 的 右手坐标系 部分。

Slightly different orders (to the “simple way”)

  • Center cuboid by translating 移到原点
  • Scale into “canonical” cube 映射到 [-1, 1],也就是缩放

Translate (center to origin) first, then scale (length/width/height to 2) 因为 -1 到 1 的长度就是 2。

因此我们可以用一个平移矩阵和缩放矩阵来求出正交投影矩阵,先平移,再缩放:

如果把长方体范围缩成立方体,物体不会被拉伸吗?
会,这就涉及到另外一个变换。在所有变换做完之后,还要做一个视口变换,还要做一次拉伸。

Perspective Projection 透视投影

  • Most common in Computer Graphics, art, visual system
  • Further objects are smaller
  • Parallel lines not parallel; converge to single point

2021.png

2022.png

平行线就是永不相交的两条线,但照片上铁轨是平行的,却交于一点。透视投影的情况下,一个平面相当于被投影到了另外一个平面上,这种情况下就不是平行线了。

Recall

  • Before we move on
  • Recall: property of homogeneous coordinates
    • (x, y, z, 1),(k x, k y, k z, k !=0), (x z, y z, z^2, z !=0) all represent the same point (x, y, z) in 3D
      • 只要一个点乘于一个不为零的 k,那么它们还是一个点。那么我们还可以将其乘以 z,其表示的点还是空间中同样的点。下面我们会用到。
    • e.g. (1, 0, 0, 1) and (2, 0, 0, 2) both represent (1, 0, 0)
  • Simple, but useful

怎么做透视投影

How to do perspective projection

  • First “squish” the frustum into a cuboid (n→n, f→f) (M_persp→ortho)
  • Do orthographic projection ( M_ortho, already known!)

2023.png

透视投影的视锥体中,远的平面比近的平面要大。

我们可以把远的平面往里“挤”,“挤”到同一高度且同近平面大小,“挤”成空间中的长方体,再做正交投影就解决了。

我们已经知道正交投影怎么做了,因此剩下的就是“挤”这个操作。

在这个过程中,需要规定:

  • 近平面上任何一个点不变。
  • Z 值不变
  • 远平面的中心也不会发生变化

求出任何一个点挤压后的 x’, y’ 值

要做“挤”的操作,首先要知道任何一个点的 x, y 值是怎么变化的。因为我们任何一个面都要挤成近平面大小,我们也可以将 (x,y,z) 投影到近平面上求出变换后的 x’, y’ 值。对于 x, y 值来说,这种变换是线性的。

因此,在视锥体的上面一部分中,我们可以通过相似三角形求出变换后的 x’, y’ 值。(z’ 值不是线性变化的,后面会提到)

2024.png

上图中,n 为近平面的 z 值,z 为任何一个点(x,y,z)中的 z 值。

挤压后的 y’ 值,我们可以通过相似三角形原理得出:

y=nzyy^{\prime}=\frac{n}{z} y

同理可得挤压后的 x’ 值:

x=nzxx^{\prime}=\frac{n}{z} x

在齐次坐标系中,对于变换后的 (x’, y’, z’) 我们只剩下 z’ 未知。

这里给矩阵乘了 z,其表示的点还是空间中同样的点。

也就是说 (x,y,z,1) 经过 Mpersp→ortho 矩阵“挤压”后,会被映射到 (nx,ny,??,z):

Mpersportho(4×4)(x y z 1)=(nx ny  unknown  z)M_{p e r s p \rightarrow o r t h o}^{(4 \times 4)}\left(\begin{array}{c}x \\\ y \\\ z \\\ 1\end{array}\right)=\left(\begin{array}{c}n x \\\ n y \\\ \text { unknown } \\\ z\end{array}\right)

根据上式,我们可以得出部分的 Mpersp→ortho 矩阵:

Mpersportho=(n000 0n00 ???? 0010)M_{p e r s p \rightarrow o r t h o}=\left(\begin{array}{llll}n & 0 & 0 & 0 \\\ 0 & n & 0 & 0 \\\ ? & ? & ? & ? \\\ 0 & 0 & 1 & 0\end{array}\right)

对于 z,我们不知道 z 会怎么变,我们只规定了近的平面上和远的平面上 z 不变。

Observation: the third row is responsible for z’

  • Any point on the near plane will not change
    • 近平面的点不变,对于任何 (x,y,n,1) 运算完了一定还是 (x,y,n,1)
  • Any point’s z on the far plane will not change
    • 远平面的点,虽然 x, y 会变化,但是 z 没有变。

求出任何一个点挤压后的 z’ 值

由“近平面的点不变,对于任何 (x,y,n,1) 运算完了一定还是 (x,y,n,1)”可得:

这里给矩阵乘了 n,其表示的点还是空间中同样的点。

因此 Mpersp→ortho 第三行一定是 (0,0,A,B) 的形式,因为:

由上式可得:

An+B=n2A n+B=n^{2}

前面我们已经知道第三行前两个数是 0。

我们前面已经规定了远平面的中心经过 Mpersp→ortho 变换后也不会发生变化。

另外一个等式可以用远平面可以用其特殊的中心点得出,给中心点再乘个 f 可得:

平截头体(Frustum)被压缩成长方体以后,内部的点的 z 值是更偏向于近平面还是更偏向于远平面?

可以参考 ScratchAPixel 的 The Perspective and Orthographic Projection Matrix

2025.png

Depth Precision Visualized

定义视锥

前面提到了长方体近平面的 l, r, b, t,有没有更好的方法去定义这些呢?

vertical field-of-view (fovY) and aspect ratio

我们现实中相机有视角的定义,也就是可以看到的角度的范围,也就是 field of view。广角相机就是可视角度比较大,对于视锥体来说,就是张的比较开。

垂直的可视角度就是 fovY。而相机的长宽比就是 aspect ratio。

我们也可以通过 fovY 和 aspect ratio,来推出水平的可视角度。

2026.png

How to convert from fovY and aspect to l, r, b, t?

2027.png

完成推导正交投影矩阵

2028.png

正交投影没有 fovY,在 Unity 中,正交投影的参数由 Camera 组件中的参数 Size, Near, Far(Viewport Rect 暂时忽略)和 Game 视图的横纵比(aspect ratio)共同决定。

这里的 Near 是近裁面的距离,也就是 -n,Far 同理,等于 -f。

Size 属性用来更改视锥体竖直方向上高度的一半,也就是前面近平面的高度 t。

由此可得正交投影近远平面的高度 t-b 为:2·Size=2·t

正交投影近远平面的宽度 r-l 为:

Aspect近远平面的高度=2AspectSize=2AspecttAspect\cdot \text{近远平面的高度}=2\cdot Aspect\cdot Size=2\cdot Aspect\cdot t

2029.png

注意:这里的 n 和 f 是 -z 轴上的,代表近裁面和远裁面的 z 值,值为负数。

完成推导透视投影矩阵

前面已经得出:

注意:这里的 n 和 f 是 -z 轴上的,代表近裁面和远裁面的 z 值,值为负数。

通常我们透视投影的参数除了近裁面远裁面的距离外,还会有 fov 和 Aspect,且 r+l=0,因此整理公式可得:

后记

在长文的最后,我强烈推荐大家也手推一下各种变换,n, f 取 -z 轴上的 z 值或绝对值(也就是距离)得出来的变换矩阵也不一样,都推导一遍可以理解更深刻。

此外,我们也可以开始实现一个简单的 CPU 软光栅渲染器,我近期也在准备写一个软光栅,把必要的过程都推导一遍,到时候再写博文分享一下。

2020年4月技术导读

作者 猫冬
2020年4月14日 05:14

首先,感谢所有将自己时间贡献在知识分享的人们。

本文将简单地介绍下最近我看到的想学的、有意思的教程、视频等,内容不仅限于游戏开发。大家可以做个参考,说不定其中就能找到适合自己的教程,进一步打算接下来的学习。

这个文章类型和之前写的都不一样,一方面有自己时间少、沉淀不够、不能持续输出的原因,另一方面是因为这些东西都比较碎,也不好在记录生活的微博上发布一些专业的东西。至于这个导读会不会成为一个系列来更新,还要看看我有没有足够东西来分享啦。

书籍

《程序员修炼之道(第2版)》

豆瓣地址

在我经过了入门阶段之后,发现很多以前学的很多教程,都很难在工作中进行参考。恰巧云风翻译的第二版已经出版了,里面的内容让我对编程“修炼”有了更深的理解。

投资知识,收益最佳。
——本杰明·富兰克林

http://img.frankorz.com/invest-knowledge.png

学习学习再学习!

游戏开发:世嘉新人培训教材

豆瓣地址

这本书我从 17 年等到现在,三年了,拿到手之后觉得所有的等待都是值得的。

从第一章就能看出作者踏实的编程功底和力求和对优化的不倦追求。一个命令行推箱子游戏从过程式的实现,到运用 C++风格的面向对象的实现,到章节末为了讲解底层内存存储结构而把项目中所有变量都存在一个内存数组中的实现。从中作者讲解基础的 C++ 语法、位运算、指针和引用的异同等概念。

接下来的章节,作者还讲解 2D 图形处理、状态、碰撞、数学等等游戏密不可分的章节,由于作者都为章节提供了类库,因此不必担心书是否会过时,读者只需踏踏实实跟着作者打基础即可。

虽然我很少写 C++,也清楚学 C++ 在游戏开发中是逃不开的,干脆就啃这本书的同时把 C++ 也给上手了,一箭双雕!

Data-Oriented Design

一本深度讲解面向数据设计的书籍,当你熟悉了面向对象编程,可以尝试看看这本书,用一种不同的思维方式看待你的数据。Unity 开发者在 ECS 正式版公布之前,也可以先深入了解一下面向数据设计的模式。

作者在其网站公开了书籍内容,地址为:Data-Oriented Design

同时也可以参考这篇导读:Highly recommended read : dataorienteddesign.com/dodbook

公开课

图形学

零基础如何学习计算机图形学?——闫令琪的回答

游戏编程类书籍涉及的内容太杂,不适合用作专门学习图形学的参考资料。OpenGL / Shader 确实是应该学习的内容,但是以我的理解来看,更适合先入门图形学基础之后再去进一步学习,这样会简单很多。这也是我个人的理念:我们要学的是图形学,而不是图形学 API,这两者应该完全分开看待。另外 OpenGL / Shader 这块学多了会容易让人产生理解偏差,觉得图形学就等于实时渲染(当然是错的,但是很多人真的这么认为!)。

于是闫老师发布了一门公开课:“GAMES101: 现代计算机图形学入门”。

本课程将全面而系统地介绍现代计算机图形学的四大组成部分:(1)光栅化成像,(2)几何表示,(3)光的传播理论,以及(4)动画与模拟。每个方面都会从基础原理出发讲解到实际应用,并介绍前沿的理论研究。通过本课程,你可以学习到计算机图形学背后的数学和物理知识,并锻炼实际的编程能力。

官网:GAMES101: 现代计算机图形学入门

B站录播:GAMES101-现代计算机图形学入门-闫令琪

课程作业系统:GAMES2020在线课程:计算机图形学(闫令琪)课程作业系统

国外 MIT CMU 部分课程翻译

Simviso 是一个翻译国外课程的民间组织,目前已经翻译了:MIT 6.004 计算机组成原理MIT 6.824 分布式系统 2020CMU数据库15-445/645斯坦福编译原理 等顶级教程。他们接下来的翻译计划也在 simviso 国外MIT CMU 斯坦福计算机科班顶级课程翻译系列 中提到。(我在想这一段字的知识浓度得有多高 Orz)

原理性的东西参考这些教程最好不过了,我们还可以跟着课程手写一个数据库、CPU 等等。

教程

Custom SRP

Catlike Coding > Custom SRP

Catlike Coding 的教程一向是从基础讲到进阶应用,在 SRP 的主题中,他写了不少基于 Unity 2019 版本的自定义渲染流水线的教程,目前还在持续更新。

Ray Tracing In One Week

跟着 Ray Tracing In One Week 系列教程一步步来做个光线追踪器,也可以在学完上面图形学公开课中的光线追踪部分后来看这个教程作为补充。这个教程也可以用 Unity 来做,例如我的 Latias94/RayTracingInOneWeekWithUnity,虽然只跟到第八章 = =。

跟完这个教程之后,如果对进一步的 DOTS、优化方面学习感兴趣的话,我还提供了下面四篇文章/项目作为参考。

Go 语言探索万物原理

了解了当前使用的技术,可以扩宽领域,学习一些和项目不相关的东西,说不定会带来灵感。

老司机带你飞系列

Github 地址:happyer/distributed-computing

  1. 《老司机带你用 Go 语言实现 MapReduce 框架》
  2. 《老司机带你用 Go 语言实现 Raft 分布式一致性协议》
  3. 《老司机带你用 Go 语言实现 Paxos 算法》
  4. 《老司机带你用 Go 语言实现分布式数据库》

7 天用 Go 动手写/从零实现系列

Github 地址:geektutu/7days-golang

  1. 7天用Go从零实现Web框架 - Gee
  2. 7天用Go从零实现分布式缓存 GeeCache
  3. 7天用Go从零实现ORM框架 GeeORM

视频

文章

  • Ray Wenderlich - Introduction to Shaders in Unity
    • Ray Wenderlich 的教程通常会提供初始项目,手把手带你写代码,加上独特风格的配图,很适合用来入门某个技术。这次带来的是如何写 Unity Shaders,实现一个简单的水流 Shader。

最后

看了看内容,这次分享的视频和文章都偏少了,其实主要是想分享前面的书籍和公开课,有机会的话有价值的视频和文章我也会多关注,并且分享到导读中。如果你喜欢这系列,或者想对这个系列有其他的想法,欢迎在评论区告诉我。

属于 Unity 的 Flutter——UIWidgets

作者 猫冬
2019年4月1日 08:13

介绍

UIWidgets 是 Unity 的一个插件包,是一个从 Google 的移动 UI 框架 Flutter 演变过来的 UI 框架。

相对于原生开发的高开发成本(不同平台都需要不同的一套代码),Flutter、React-Native 等这种跨平台 UI 框架应运而生。

Flutter 自 2018 年 3 月发布以来,社区不断壮大。由于 Flutter 自身设计理念的出色,Unity 中国已经着手将其移植过来。当然了,也因为这两个东西都非常的年轻,因此开发的时候都像开荒一样。

框架图

Flutter 有自己的一套渲染系统,那么 Unity 作为一个游戏引擎,底层的图形 API 用自己的一套东西就行了,因此移植过来更方便了。

Flutter 框架结构

UIWidgets 框架结构

执行效率

这里提一些基础的知识:

Batch 就是 DrawCall 的另一种说法,了解渲染流水线的同学会知道流水线在 CPU 与 GPU 之间通信时,一般有三个步骤:

  1. 把数据加载到显存中。
  2. 设置渲染状态。
  3. 调用 Draw Call

Draw Call 就是一个调用命令,让 CPU 告诉 GPU 要怎么样用给定的渲染状态和输入的顶点信息来计算。Batch 里面装着顶点信息,也就是 DrawCall 中 GPU 需要的顶点信息。

DrawCall 可以在 Profiler 中看,Batches 可以在 Stats 窗口看,大家可以仔细看看上面动图(右键在新标签页打开图片)里面的数据变化。

在我随便写的一个例子中间,可以看到 Batches 数只有 1 。即使在有动画的时候 Batches 会多一点,但动画停止后 DrawCall 和 Batches 都马上下来了。这也有我这个应用写的太简单的原因,但是这种效率还是非常值得期待的。

组件树

学过前端的同学应该熟悉组件树,这里就不介绍了。

为了更高的渲染效率,Unity 采用了 Render Object Compositiing 的技术。

如果一个子树没有发生改变,Unity 就会将其渲染到一个离屏的 Render Texture 上缓存下来,需要的时候再将其贴到屏幕上。

相比之下,以前的做法是,Canvas 只要有 UI element 改动了,整个 Canvas 都需要重新绘制。即使也有一种优化做法是准备两个 Canvas 分别绘制动态 UI element 和 静态 UI element,但这样也存在很多手动管理的地方。

另外一方面,你可能也意识到了,我们不需要再管什么用同一个材质等等来优化图的合批,UIWidgets 会自动来管理这些事情。这方面也跟 FairyGUI 非常像,开发者能专注在生产效率上,让插件来管理麻烦的事情。

优点

  • 能开发游戏以外的 APP
  • 游戏中的 UI
  • 新的用户体验
  • 不用管渲染过程,提升效率
  • 因为是 Unity 的插件,可以轻松加各种粒子效果和其他骚操作。
  • 一套代码能跑在游戏中、APP 中、网页中和 Unity 的 Editor 窗口中。(开发者还用其做了一个 Unity 中文文档的网站,一套代码能用在网页上和 APP 端)
  • 在静态页面进行降帧的优化,有动画效果再把帧率提上来。
  • 和 Flutter 的 API 几乎一样,可以参考 Flutter 教程来用 UIWidgets 搭应用。

缺点

  • 无论是 Flutter 还是 UIWidgets 都还很年轻,有很多组件 UIWidgets 还没移植过来(GridView、Circle Avatar 等等)
  • 官方示例、文档还没完善
  • 开发时是开荒模式,所以可能忍不住直接转用 Flutter 去了…

我的示例

这里借用了 ミライ小町 的模型,所以代码窗口大小会比较大。(项目里面还有ミライ小町的跳舞动画 animation!)

项目仓库:Latias94/UIWidget-Practice

UIWidgets:UnityTech/UIWidgets

官方讲解录播:[官方直播] UIWidgets - 不止游戏!如何使用 Unity 开发跨平台应用

不越狱在 iOS 12.113.3 设备安装 Kodi

作者 猫冬
2019年1月14日 02:01

2020 年 2 月 11 日更新另外一种方法

今天一不小心发现 Kodi 这个播放神器居然还有 iPad 版!

但是苹果 App Store 不允许 Kodi 应用商家,于是自己在网上找了些方法:

  1. 越狱(手动再见)
  2. 下载官方提供的安装包用 Xcode 打包进去(太麻烦)
  3. 国内同步助手等提供的“VIP 服务”,购买服务后,用它们提供企业证书来下载(吃相难看)

2020 年 2 月 11 日更新

最近又想装回 Kodi,但是 Tweakbox 自己用不了了,于是找了找发现了 AppCake。

AppCake 声称能在越狱和非越狱的手机上使用,直接在设备上给应用签名。

  1. 手机 Safari 打开 https://iphonecake.com/app/,点 Install。
  2. 安装描述文件
  3. 打开应用后应该还要再要你安装另一个描述文件用来给应用签名
  4. Tweaked Tab 标签页中下载安装 Kodi!

介绍 Tweakbox

最终发现了国外一个不错的免费服务:Tweakbox,官方介绍也很令人振奋:

TweakBox is an app store where you can download apps for iOS devices that are not available in the official Apple app store. TweakBox App is completely free and it has tons of features that make it a very popular choice among the other third party app stores.

简而言之,Tweakbox 允许你:

  • 不需要越狱,苹果 ID
  • 下载部分 Appstore 上没有或者付费的软件和游戏
  • 下载部分主流的修改增强版软件和游戏

安装

  1. iPhone 和 iPad 设备上在 Safari 浏览器中进入网页 Download
  2. 点 “Install Now” 按钮安装描述文件
  3. 然后就会下载 Tweakbox 应用
  4. 打开设置 -> 通用 -> 描述文件与设备管理 -> TweakBox 点击信任
  5. 之后就能打开 Tweakbox 应用来安装应用了!
  6. 打开 Tweakbox,点击屏幕上方的 Apps 选项,点击 Tweakbox Apps 一列,Kodi 应用就在其中。

发现 Kodi!

点进去 install 就能下载应用了,下载完还需要再次去信任描述文件才能使用。

唯一有点缺陷的是 Kodi 有时会弹出插屏广告,断网使用即可。

汉化参考:KODI 播放器 V17 设置中文

更多

前面说到 Tweakbox 还能安装其他实用的应用:

增强版 Spotify

碎碎念

周更博客真的难 = =,有些主题觉得没比别人写的好,于是就不想写了…

这周先水过去,再次赞下 Tweakbox!

❌
❌