MoreRSS

site iconLatias94 | 萤火之森修改

90 后游戏开发者,坐标深圳。魔力数娱
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Latias94 | 萤火之森的 RSS 预览

Rust 学习资源

2022-01-18 22:01:44

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

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

基础

进阶

有潜力的教程

练习实战的小项目

游戏开发相关

其他领域相关

Rust 动态

各种汇总(Awesome 系列)

Podcast

博客

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

博客 RSS

名称 订阅链接
This Week in Rust https://this-week-in-rust.org/atom.xml
Read Rust https://readrust.net/all/feed.rss
Rust Reddit Hot https://reddit.0qz.fun/r/rust/hot.json
Rust.cc https://rustcc.cn/rss
Awesome Rust Weekly https://rust.libhunt.com/newsletter/feed
Rust精选 https://rustmagazine.github.io/rust_magazine_2021/rss.xml
Rust on Medium https://medium.com/feed/tag/rust
Rust GameDev WG https://gamedev.rs/rss.xml
知乎专栏-时光与精神小屋 https://rsshub.app/zhihu/zhuanlan/time-and-spirit-hut
酷熊 Amos fasterthanli https://fasterthanli.me/index.xml
pretzelhammer/rust-blog https://www.ncameron.org/blog/rss/
Nick Cameron https://github.com/pretzelhammer/rust-blog/releases.atom
FOLYD https://folyd.com/blog/feed.xml
Alex Chi https://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,然后终有通过编译的那一刻,恭喜你,入坑了!

常用站点

其他资料

本文参考

用 Rust 实现简单的光线追踪

2021-05-05 08:48:30

学 Rust 十来天了,自己被这个语言惊艳到,就跟着教程 Ray Tracing in One Weekend 写了个很简陋的光线追踪示例练习,项目在 Latias94/rust_raytracing

学这门语言的时候,感觉就是上手容易遇到很多新概念,容易学不下去,跟编译器作斗争…不过作为一个还很新的系统编程语言,工具链如文章、包管理、格式化、编译器等很完善,官方教程很棒,社区也很活跃。

学 Rust 的契机其实是在 V2EX 上看到有人在纠结学 Go 还是 Rust,底下的帖子也有不少夸 Rust 语言的,因此自己也开始关注 Rust 语言。后来发现 Rust 的用武之地非常广,Github 上还能找到不少 Rust 做的游戏引擎,其中一部分主打 ECS 功能,例如:bevyengine/bevyRalith/hecs 等。

学习 Rust 语言,其实也是在了解一个现代化的语言该有的样子,了解 C++ 或其他语言部分设计上的不足,以及 Rust 是打算如何从根源解决这些问题的。这部分我作为一个初学者,不打算展开讲。大家有空可以了解一下 Rust 语言,看看官方的教程《Rust 程序设计语言》

总而言之,我觉得光线追踪的教程可以作为学一门新语言后严肃学习的项目,做完成就感也满满!

顺便推荐一篇好文:新技术学习不完全指北:以 Rust 为例

最后放下示例的渲染图:

1200*800 渲染图

五一劳动节快乐!

24 天像素画从入门到放弃

2021-04-18 20:33:30

前不久十分厌学,想着是不是学废了,就想找到其他的东西学学,于是有一天周日尝试了同时入门 Blender 和 像素画。

建模其实和 Unity 用初始模型搭积木差不多,入门也还好。但是自己从小就是对美术绝缘,只有初中的时候会和其他同龄人一样照着漫画书瞎画。后来就直到现在,因此开始画像素画的时候还是十分不适应,感觉每一个点都是为无用艺术界添砖加瓦。

后来决定画像素画的契机是听播客介绍到一个码农姐姐为了做游戏坚持 100 天画像素画,于是少年那颗不知天高地厚的心怦然地燃烧起来。

教练!我想学画画!

兴起

作为一个资深松鼠症患者,Steam 里早已经躺着不知道什么时候打折入手的像素画神器 “Asprite”,我原本以为一辈子再也不会下载它,这就是命运的安排吧。

很多软件能画像素画,毕竟像素画就只是由点构成的,不过好的工具能事半功倍。用习惯 PS 的美术朋友可能会选择继续用 PS 改笔触画像素画,对于我来说,功能太多的软件反而会打压我的一时兴起的兴趣,于是我跟着 Aseprite!超方便的像素画软件初学者教程 花半小时大概清楚 Aseprite 有的功能。

Aseprite 是收费的,在 Github 开源 ,如果你想自己编译,可以参考 [GUIDE] How to build Aseprite from source. (Aseprite free & legal) 。我是在 Steam 上买的,现在看了看是 70 元,买了 Win、Mac、Linus 都能用,不需要自己分开编译,更新也会更方便些。

自己一开始跟着 Youtube 博主的视频教程入门,其中有前面 Aseprite 初学者教程的作者 MortMort、开了 Pixel 101 系列教程Pixel Pete 等。

我喜欢看视频教程入门的原因有几点:

  1. 我想看他们是怎么开始构思一幅画的(像素画的型)
  2. 画像素画的过程(先画什么后画什么,颜色如何选择)
  3. 对软件的使用,如何适当地使用软件的功能更快地画出你想要的画。
  4. 了解像素画独有的技巧,如:像素抖动等

一开始我看的是 Pixel 101 的前几个教程,Pixel Pete 在像素极其有限的情况下很快就能画出不同的物品,让我知道精准控制地像素就能通过不同颜色影响画在人们脑海中的想象。

因此我对像素画的理解就是:易入门、精准控制少量颜色和少量像素、费时少易出货,需要的基础也比原画板绘少非常多。

由于我当前工作室的游戏就是像素风格,因此问了几位原画同事,都说像素画是最简单的,他们一开始有原画基础,临摹下像素画的画法,很快就都能上手画像素画了。

于是我决定开始 100 天像素画挑战,每天花一个小时左右鼠绘像素画。

学习

第一天开始画之后,我发现很多问题:

  • 一个像素能表现的东西是有限的,我要决定这个像素应该表现成什么,从而决定是什么颜色。
  • 像素画的分辨率十分低,阴影过渡和线条可能会非常生硬,画太多又会有色块现象。
  • 像素画中颜色的数量要有限制,阴影可以用两到三个颜色过渡,太多颜色会很乱。

于是找了本书 《Make Your Own Pixel Art》 参考,这本书面对的是没绘画基础的新手,用的软件也是 Aseprite。这本书讲的非常详细,我印象最深的就是我需要先学会画出不同基本模型的光照,例如圆柱、正方体等,这些形状和光照会画之后,再将它们组合起来,创造出自己的物品或角色。

对于调色板的颜色,可以在 Lospec 中选一些经典的颜色,这样我们暂时不用考虑颜色的对比、饱和度挑选等,先限制自己在调色板中用色,后期学颜色理论(Color Theory)了再自己配色。

下面是第三天时做的书中的练习,给出一个角色的剪影,我来上色。

第三天

第五天我尝试画自己的角色,画布是 $ 64 × 64 $ ,主题是太空。其中用到刚学的像素抖动,星星的表示,不过有些光影的地方还是错的。

于是每天画画,时不时尝试不同风格的像素画,中途还尝试了下画动画帧。每天也会用 Eagle 收集一下参考用的素材,这个软件看动画帧也非常方便。

Eagle

挑战途中,我还找了一些素材参考,其中十分有用的是风农翻译的蔚蓝主美 Pedra Medeiros 的一系列像素画教程:saint11像素宝典JKLLIN像素画学习系列。像素宝典里面还教了很多游戏中很有用的动画帧画法,比如攻击时的准备帧、不同风格如光魔法和黑暗魔法的表现等。

如果还想深入,我十分推荐 Michael Azzi 写的 Pixel Logic 这本书,B 站的物暗先生汉化过。这本书里面介绍了很多像素画的专有概念,还引用了很多像素游戏作为参考,如果想做一个像素游戏,你能从中获益很多。

我还经常逛逛 Twitter 的像素画标签,如:pixelartドット絵等,Artstation 的 Pixel Art 画,还有 Deviant Art 的 Pixel Art 主题。找到参考的同时,还能进一步找到自己喜欢的风格,这样可以跟着画的作者进一步了解这种风格的像素画画法。

放弃

直到第 24 天我决定放弃,其实还是时间的问题,回到家自己的时间也就两三个小时,有时候纠结一下颜色和参考,一堆时间就过去了,我希望把时间更多地重新放在技术上。

当然这 24 天我也是收获了不少,对像素画有了基本的了解,对颜色也开始有了那么点概念。工作中做游戏系统原型的时候都能不等美术资源,自己随便画图凑合用了 hhh。游戏开发者在学像素画的时候,也能请教下工作室中的美术,以后说不定还能靠着自己的像素画做独立开发,一箭双雕!

大家在一开始画的时候从小图画起,$ 16 × 16 $ 到 $ 64 × 64 $ 的画布就已经足够了,这样的画布也不需要绘画板,鼠绘就足够了,我相信画到一定程度,就会知道自己需不要一个绘画板了。

最后

本文作为学习过程的记录,希望能给读者激起学像素画的兴趣,避免走一些弯路。博主学像素画也是为了点一下独立开发的技能,虽然画的不怎么样,至少不怕画画了!

如果你对我 24 天的像素画感兴趣,可以点击下方按钮,或者博客右上角的相对应的标签查看。

Compute Shader 简介

2021-04-18 06:14:30

做游戏的时候,我们经常要面对各种优化问题。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-03 23:53:30

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

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

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

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

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

图形学常见的变换推导

2020-07-27 01:46:35

注意:由于这个博客主题对 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 软光栅渲染器,我近期也在准备写一个软光栅,把必要的过程都推导一遍,到时候再写博文分享一下。