GPGPU Voxel Engine (1)

image

 “Cube world”

啊,我并不会…起标题233333

这标题一眼看上去到底是个什么鬼啊hhhh

emm…(划掉

代码(C++ / OpenGL): https://github.com/linkzeldagg/GLPlayground

Voxel Engine


说实话我不太清楚Voxel Engine这个词用中文应该怎么翻译,不过一个很通俗的解释是(虽然很不严谨而且只是一个子集,但是反正我想做的就是这个所以无所谓了吧2333),类似minecraft的东西(可以做出“看起来很像minecraft的东西”的东西(x。

比如本文最开始的那张图片。

(不知道有没有什么版权什么的问题hhh这个大概是PV截图)

cw大概是2013年…的游戏了?到现在还只有alpha版。作者已经死了4(5)年没更新了。

推特倒是时不时冒个泡报个进度(最近一次大概在7月份ww?),很气。

(非常赞。)

Voxel Engine不只局限于方块。Voxel非要翻译的话大概叫“体素”(大概就是3d的“像素”),区别于普通3d渲染中用多边形来表示几何体,Voxel engine通常使用许多某种“体积元”——通常是立方体,可以理解为3D的像素——来表示几何体。虽然渲染的时候一般会转换成多边形进行渲染(或者用类似Ray tracing的方法渲染)。

Voxel_2

另一种形式的Voxel Engine (https://www.youtube.com/watch?v=VwPyMzuu7tk)

Voxel Engine要解决的主要问题


嗯,进入正文了。

继续上文的内容,Voxel Engine(方块版)的主要任务,就是把3D体素数据(通常保存在一个三维数组里)转化为多边形进行渲染工作。

通常这类Voxel Engine会将整个场景的信息分块(chunk)保存,来方便内存等的管理。

比如在minecraft中,一个chunk的大小(我记得)是16 x 16 x 256(宽 x 长 x 高)

有的时候存档坏掉了,可能地图上什么地方就出来了一个16×16的大洞,就是这个chunk的数据损坏了读不出来。

而一个chunk里面的信息呢,比如说目前只有一个最基本的方块种类。

比如说 0 = 空气,1 = 石头,2 = 草地,3 = 泥土。

这些数据保存在一个三维数组里面,我们要做的就是把这个三维数组的数据拿出来,生成一个对应的网格(就是3D模型)(绕着边界画一圈的那种),进行后续的渲染工作。

而将数组中的数据转化成网格的这一步工作,开销是十分大的。引入chunk分块的一个主要原因就是渲染和更新上的困难 ——

如果我们不分chunk,一个一个方块的进行绘制,DrawCall瞬间就爆炸给你看。

DrawCall: CPU向GPU发送一条用于绘制的指令,命令GPU进行一些操作(比如绘制某个网格)。然而发送指令也是需要消耗CPU时间的,如果一帧内的DrawCall数量过多,会严重影响性能。这大概也是比较常见的一个性能瓶颈。

如果我们分chunk,把渲染的工作简化到每个chunk一个DrawCall,可以解决问题。这时,我们需要事先生成好每个chunk的网格。但是这也带来一个问题,当玩家破坏了chunk内的某个方块时,整个chunk的模型需要重新计算(你没法只改那一小部分)。

所以当chunk过大时,更新上的开销将变得十分惊人。

(破坏一个方块,和同时破坏一堆同一个chunk的方块带来的开销是一样的)

3D网格的组成:基本上由顶点数据和顶点索引(可选)组成。本质上是顶点数据(顶点位置、法线、颜色、纹理坐标等)所组成的一个大数组。GPU在绘制时,将会按照数组中的内容,把顶点组合成图元(最常见的:数组中相连的每3个顶点组成一个三角形)进行绘制(如果有顶点索引,则按照顶点索引的顺序组合)。

所以你没有办法删除网格中的某一个部分而不影响数组中其后的部分。

使用CPU生成网格的方法

其实没什么好说的,大概就是遍历一遍数组,同时往网格中不断的添加新的顶点。

当一个方块的六面(周围6个方向)上都被实心方块堵住时,那么这个方块不会被添加到网格中。并且每个方块只有露出来的面才会被添加到网格中。

在制作完网格之后,把网格提交到GPU中,然后每一帧去绘制这个静态网格就可以了。

能不能…快一点?

在大概每帧只会最多更新一两个chunk的时候,这种方法没有什么显而易见的弊端。重新生成几个chunk(16x16x256)的网格,对于现代CPU来讲可能只会消耗掉几毫秒的时间。(在60帧时,每帧大约持续16毫秒,在16毫秒内干完活都是好的(?

但是,当我们想每帧都进行大量的更新的时候…CPU就不够用了。帧率会呈现断崖式下跌(误),显而易见开销十分巨大。

所以,来用高度并行化的GPU计算网格吧。(虽然它就没法在老机器上跑了hhh)

GPGPU Voxel Engine


使用GPU来计算网格,并不是什么特别困难的事情。(w

最开始看到cubeworld的时候,还以为它一定用了什么高级的技巧(比如用GPU算)来渲染的。毕竟给人的感觉上,cubeworld中的可视范围十分的广阔,画面也很精良。

直到我用NSight看了一眼…(5.2看这个还会崩溃,更新了一波orz

cw_nsight.png

没了??

没了。

毫无Dispatch痕迹,也就是说CW用的也是朴素的CPU计算网格 -> 上传 -> 渲染的过程。

而且渲染写的好…简洁(嗯,每帧900个DrawCall(没啥毛病

(然后一想,这不是废话嘛,人家CW是DX9,还全是静态的数据犯不着什么大更新,为什么要费劲上GPU)

好吧。

基本的实现方式有两种:

  1. 使用GPU直接计算出网格,也就是和上文提到过的用CPU计算出的网格是一个东西。网格直接作为Compute shader(GPU计算)的输出。(输入是存着方块ID的数组)
  2. 使用GPU计算出所有要画的面(或者更简单一些,方块)的位置,然后使用批量渲染(Instancing)一次性把这些面(方块)画出来。

在这里,用的是第二种方法。至少听起来,它要比第一种方法开销低一点。

听起来。我并没有试过第一种方法,之后打算试一试。没准它比较快呢

批量渲染

批量渲染是一种渲染大量相同网格的技术。比如,渲染大量的方块。

拿方块来举例,其基本思想是:

  • 首先你得有一个保存着每个方块和其他方块之间区别的数组。比如,每个方块的位置、旋转、大小、颜色,等等。这称为Instance Buffer。
  • 然后,通过一条命令(一次DrawCall),让GPU绘制一定数量(可以很多)的方块。
  • 最后,在绘制每个方块的时候,你可以知道这是第几个被绘制的方块。你需要在shader中把你的属性应用到不同的方块上去(根据数组,对每个方块进行位移、旋转、缩放、着色等等。)

在这里,我们想要用GPU计算出每个方块的位置,存到一个数组(保存在GPU上的一块缓冲(buffer))里面。由于GPU是并行化的 —— 整个计算过程可以理解为每个线程都有一个坐标,对应着方块ID数组中的某一个位置。每个线程需要检测这个方块需不需要绘制,并(如果需要绘制)把它的位置添加到某个数组中。

但是,由于我们事先不知道哪个方块会被绘制,哪个方块不会,所以我们不能确定某个需要绘制的方块在数组中的位置。我们只能在运行过程中不断地往数组末尾添加数据,我们需要知道这个数组现在多长,就需要一个原子计数器。

计数的过程类似于a = a + 1。

假设有两个线程X和Y。一种很可能会发生的情况是,当X在计算a + 1的时候,Y计算出了a + 1的结果并更新了a的值。但这个时候,X已经用旧的a算出了a + 1,X再次更新的时候就相当于盖掉了Y在a上加的那个1。

原子计数器就相当于给变量加上了互斥锁,使每个线程计的数都不会被覆盖。

好在API直接提供了这样的操作,可以很方便的用类似atomicAdd(a, 1)的方式来进行计数。

虽然在NV的Maxwell架构(好像是这一代?)中,存放在shared memory中的变量在进行原子操作的时候想比global memory有性能提升(但是无所谓嗯

所以整个渲染过程大概是:

  1. 在CPU上准备好方块ID数组
  2. 把方块ID数组上传到GPU
  3. 使用GPU计算出Instance Buffer,并得到Instance Count(需要重复绘制的网格数量)
  4. 使用GPU进行批量渲染

嘛…并不打算说一些实现上的细节ww不过有几个地方比较坑:

从GPU往回读一些数据,很慢。很慢。很慢。

而一般的批量渲染函数中,有一个参数是需要渲染的对象个数。这就需要,CPU读回GPU计数的结果…很慢。

所以要用Indirect的渲染函数,比如glDrawArraysIndirect()。它可以从GPU接收参数,免去了读回来的麻烦。

即使函数名里没有Instanced也是可能可以支持批量渲染的。

(说的就是你!glDrawArraysIndirect…亏我还纠结了半天怎么会没有DrawInstancedIndirect这种东西orz)

而相对的,从CPU上传数据到GPU,出奇的快…(如果用glBufferSubData,没试过直接用Map

谁能给我解释一下为什么(

以及在每次更新的时候,需要初始化(重置)Indirect buffer。

应该直接从CPU上传数据覆盖掉就可以了。

结果


现在的绘制方法是绘制许多方块,这样那些本来会被其他方块挡住的面也会被画出来了。

这引来几个问题:效率上做了很多不必要的事情,结果上会产生一些类似噪点的东西(某些点重叠了)。

嘛但是大体上就是这么个意思hhhhh嗯。

我很惊讶它竟然能跑的这么快23333

animated.gif

在生成blockId数组这一步我用了8个线程x(终于能让CPU飙到100%了hhh

大概是,每帧都在从0开始生成chunk里的方块信息,然后提交到GPU渲染。

在我的1060 + 7700HQ上可以跑到55fps(如果更新chunk信息的过程麻烦一点可能会掉到40,再麻烦一点就再掉hhh)

每个chunk是32x32x32,图中一共有100(10×10)个chunk。(320x320x32 = 3276800)

骨架搭好了,之后…改一改,撒一点调味料hhhh就差不多了嗯ww比如…

  • 渲染以面为单位而不是方块(或许可以试试直接生成网格)
  • Deferred rendering (+透明)
  • AO / DOF / PBR / SSR …
  • 各种贴图
  • 装饰用的小部件(草啊花啊什么的x

或许还有另一个方向…

  • blockGroup(chunk)之间的物理模拟
  • 温度,热源(误)之类的能量传递(??
  • chunk中的非方块物体
  • LOD(远景改用边长2的方块渲染,再远边长为4,…

然后或许我终于就能在里面砍倒一棵树了ww(误

最重要的大概是抛弃OpenGL(毕竟只是为了试东西比较方便而不用像Vulkan / DX12那样要铺垫一两千行才能写东西x

嘛hhhh哎ww以上w(并没有什么完成的希望2333

发表评论

电子邮件地址不会被公开。