Logo

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 有个好处就是,我对公开笔记的编辑都能实时更新到。

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