从零开始手敲次世代游戏引擎(三十四)

从零开始手敲次世代游戏引擎(三十四)

既然我们已经可以从Blender当中导入模型、光照和摄像机信息,那么我们可以开始尝试导入更多更复杂的模型/场景来测试我们的代码,发现并解决问题。

我是一个码农,虽然学过一点点美术,但是还是没能有啥大的发展。所以我只能依靠网上的资源了。网上有很多同样使用CC协议共享的好的Asset。比如Blend Swap这个网站。很多码农都是萝莉控,所以我们尝试导入ermmus制作的小萝莉(AILI)一只。感谢🙏

首先请注意一下这个Asset的授权信息。是CC-BY,和我们这个系列文章是一样的。所以我在本文开始按照这个授权的要求,提及了制作者,并加入了到原页面的链接。任何原创性活动都是应该得到充分尊重的,因为只有这样,我们才能分享到更多的更精彩的内容。

点击Download下载下来一个ZIP包,解压之后,双击其中的aili_cycle.blend。这会自动启动Blender(前提是你已经安装了它。安装链接在上一篇文章里面有)

按下F12,就可以进行渲染出图了。但是你会很快注意到,裙子和眼睛等都是紫红色的。

紫红色在3D图形渲染领域好像是一个不成文的约定,表示shader的缺失或者不正常。具体到这个例子,如果我们查看裙子的材质,会发现贴图不见了。查看解压后的目录,可以看到虽然有一个名为tex的目录,但是里面是空的;贴图都在项目的根目录下,而不是tex里面。

尝试着将所有贴图移动到tex目录之下,然后重新打开项目,发现问题解决了。按下F12进行渲染。这个渲染是采用的路径跟踪(Path Tracing)的方式,也就是严格按照光学方程进行的离散采样(或者说蒙特卡罗数值求解)过程,要求反复迭代直到收束到一定误差范围之内,计算量是很大的(相对的,效果也是惊艳的)。在我的mac pro上面,渲染出图大约需要40分钟的时间(插上电源的情况。用电池会更长)

我也尝试了使用台式机的GTX1080显卡进行计算渲染,速度方面并没有很大的提升。这其实就是为什么目前在游戏当中还不能大范围采用路径跟踪的渲染方式的原因之一。路径跟踪算法是一个非常典型的基于CPU的算法,在GPU上执行的效率并不高。我所理解的主要原因,一个是因为其复杂的程序分支结构,另外一个是因为其需要双精度(double)的计算精度。一般来说,GPU适合的是直统统的,大量并列重复的,要求精度低的情景。因为GPU相对于CPU来说,其实是在差不多的半导体材料上面,在总晶体管数量一定的前提下,采用“多计算核心”但是“少分支预测”的策略进行设计的。而CPU正好相反。(当然,近年N卡有专门用于计算的双精度专业卡,谷歌也搞了基于FPGA的专用计算ASIC,这个是另外一个事情了)

好了,写完上面这些文字,图也渲染好了。最终效果如下:

AILI: Model by ermmus Creative Commons Attribution 3.0 CC-BY

是不是很不错呢?

不过到这里为止我们并没有做什么。接下来才是正文。

如果我们直接如同文章三十二那样将这个场景导出为OGEX,然后用我们写的引擎进行渲染的话,会发现效果是这样的:



https://www.zhihu.com/video/919561620395556864



小女孩晒黑了,胸(👕)没有了,头发没有了,裙子是透的;天空🀄️浮着不明的黑板子。

回到Blender,我们首先来看看场景的构成:

首先可以注意到场景并没有光源。但是没有光源,为什么在Blender里面可以渲染出那么漂亮的图呢?其实答案就在天空当中的那些板子上。选取Plane,查看它的属性:

看到这个白色的圆了么?这个叫材质球,是用来显示被选中的物体的材质的。注意看球下面的地板,是不是被浅蓝色照亮了呢?再往下看,可以看到Surface被设置为Emission,Color恰恰是浅蓝色。所以,对,这个板子就是光源。

查看另外两个板子,也是类似的设置。

那么为什么在我们的场景当中几乎是黑的呢?我们再来看看实际导出的OGEX文件。用vim打开我们导出的aili.ogex文件,冒头3个Node便是这三个板子。

GeometryNode $node1
{   
    Name {string {"Plane.002"}}
    ObjectRef {ref {$geometry1}} 
    MaterialRef (index = 0) {ref {$material1}}
    
    Transform
    {   
        float[16]
        {   
            {35.663089752197266, 2.4165026245623984e-15, -5.694919294712934e-15, 0.0,
             2.416503048078872e-15, 24.780128479003906, 25.64763641357422, 0.0,
             5.694919294712934e-15, -25.64763641357422, 24.780128479003906, 0.0,
             -1.4071846008300781, -45.10104751586914, 48.62202072143555, 1.0}
        }
    }
}

GeometryNode $node2
{
    Name {string {"Plane.001"}}
    ObjectRef {ref {$geometry2}}
    MaterialRef (index = 0) {ref {$material2}}

    Transform
    {
        float[16]
        {
            {-21.7617244720459, 8.345382690429688, 0.20872513949871063, 0.0,
             2.053757905960083, 5.621259689331055, -10.6276216506958, 0.0,
             6.464678764343262, 16.606595993041992, 10.032995223999023, 0.0,
             8.224732398986816, 22.414443969726562, 41.55237579345703, 1.0}
        }
    }
}

GeometryNode $node3
{
    Name {string {"Plane"}}
    ObjectRef {ref {$geometry3}}
    MaterialRef (index = 0) {ref {$material3}}

注意它们的类型:GeometryNode。也就是说,他们是被作为几何体导出的,而不是作为光源。

在Blender的Path Tracing当中,进行的是全局光照计算。所谓全局光照计算,指不仅仅考虑了直接来自光源的光线,还考虑了来自其他非光源物体的光。这主要包括两个方面:

  1. 反射/折射光线
  2. 自发光物体

而在我们的引擎当中(更为准确地说是我们写的basic Shader当中),目前我们只考虑了“光源类型”的光源,而没有考虑来自其他非光源物体的光线,所以在这个特别的场景当中,对我们的引擎来说,就是没有光源。(但是渲染结果当中仍然能够看到有一点光,这是因为我在编写代码的时候,为了方便区分因为场景的引擎缺省补了一个点光源)

至于天空当中的板子出现在画面当中,这是因为我们旋转了画面。Blender里面是固定视角,板子都在视野之外。我们可以通过关闭板子的渲染开关,使其在导出的OGEX当中的Visibility属性为FALSE。并且修改我们的RHI/OpenGL/OpenGLGraphicsManager.cpp,让它忽略Visibility为FALSE的对象来从渲染结果当中去除掉它。(注意这也会导致Blender里面的渲染变暗)

点击Plane右侧的照相机图标使其不参与渲染

由于自发光板子属于面光源。面光源的照射计算比较复杂,我们在后面(zhuanlan.zhihu.com/p/38 )进行介绍。(但是它的确很好看,因为会产生很柔和的光场和阴影。一般照相馆或者拍电视电影的时候,都会在人物边上支很多白板子,就是这个道理)

接下来我们来看头发。我们可以注意到,在Blender当中,代表头发的节点的左侧的图标是不太一样的:

在Blender当中,三角形图标表示Mesh,而弧线表示这是一个Path。所谓Path,就是一条线段(或者曲线)。它本身是没有体积的。没有体积,那么在渲染结果当中体现不出来,是很正常的。

但是为什么在Blender里面就能看到呢?

头发在Blender当中是有体积的

这其实是因为Blender在显示/渲染的时候,对Path进行了实体化计算。但是其内部实际的数据结构,仍然只是一个Path。我们如果将当前的编辑模式从Object Mode改成Edit Mode,我们就可以看到这些头发的真实面目:


https://www.zhihu.com/video/919572222321004544



导出到OGEX当中的,正是这些黄绿色/橙色的折线。因此我们在渲染结果当中看不到它们。

解决的方法有两种:

  1. 在我们的引擎当中实现这些Path的实体化计算,就如同Blender那样;
  2. 让Blender将实体化计算的结果保存下来。

这两种方法都是可以实现的。但是对于游戏引擎来说,一般希望尽量轻量(因为我们是软实时系统)。所以一般能在DDC工具当中解决的工作,尽量不要放到引擎当中。除非有别的理由(比如需要做头发飘动的动画。显然移动一根线段比移动一个mesh实体要方便)

这里我们先介绍方法2。用鼠标右键选中头发,然后按下Alt+C,会出现一个弹出式菜单:

在其中选择Mesh from Curve/Meta/Surf/Text之后,就可以将对象以Mesh的形式固定下来。对所有的头发进行这个操作(注意只需要处理名称为hair_*的模型。其它的几个放在头部右侧空中的是用来生成头发的放样曲线,不需要变换)。然后重新导出OGEX。这样我们的渲染结果就变成了这个样子:


https://www.zhihu.com/video/919576251432333312



可以看到头发的显示已经正常了。但是裙子依然不正常。上半身的裙子完全没有,下半身的裙子是透的。

首先来看下半身裙子的问题。之前也提到过,在3D渲染当中,为了减少无谓的渲染量,会根据表面的方向进行裁剪。即:凡是背对相机的Mesh,都不会进行渲染。

判断一个表面是面对相机还是背对相机的方法是,看这个表面的法线是指向相机的还是指向相反的方向。然而,不见得所有的模型都定义了法线参数,所以实际上GPU是根据多边形(三角形)的顶点顺序来判断表面的朝向的。

当三角形的顶点坐标被变化到摄像机坐标系当中之后,如果顶点出现的顺序是按照逆时针(当摄像机坐标系为右手坐标系)/顺时针(当摄像机坐标系为左手坐标系),那么表面就是面朝摄像机的。反之,则是背离摄像机的。

仔细观察小女孩的裙摆,可以看到我们能看到的是裙子的内表面,看不到的是裙子的外表面。这就说明目前法线是反的。

但是为什么在Blender里面看起来没有问题呢?我们还是来看这条裙子的属性。右键选中裙子,然后选择属性面板当中的Data活页:


https://www.zhihu.com/video/919580211018891264



我们可以看到有个Double Sided的属性是处于被勾选的状态。这就是原因了。这个属性目前并没有存在于导出的OGEX当中,我们的引擎也没有对应这种双面的材质(事实上,我们的引擎目前还没有对应材质)。

这里让我们首先来修正法线。嗯,能用图说明的不废话:


https://www.zhihu.com/video/919581418013732864



好了,这样就修正好了。再次导出OGEX,看看效果:


https://www.zhihu.com/video/919582797566451712



的确修好了。但是上半身仍然是那样。

上半身是不是也是法线问题呢?答案是否定的。因为上半身完全是透的,既看不到正面,也看不到反面。

是不是上半身的数据没有被成功导出呢?我们来看看上半身节点的名字:

“skirt_b”。在我们导出的OGEX当中,通过vim的查找命令“/skirt_b”(如果你用别的图形界面编辑器,一般是Ctrl+F)查找,发现是存在的:

GeometryNode $node19
{
    Name {string {"skirt_b"}}
    ObjectRef {ref {$geometry15}}
    MaterialRef (index = 0) {ref {$material9}}
    MaterialRef (index = 1) {ref {$material7}}

    Transform
    {
        float[16]
        {
            {1.0587974786758423, 0.0, 0.0, 0.0,
             0.0, -1.7250000894364348e-07, 1.0587974786758423, 0.0,
             0.0, -1.0587974786758423, -1.7250000894364348e-07, 0.0,
             0.0, 0.0, -0.5038204193115234, 1.0}
        }
    }
}

进一步,该节点引用的场景对象“geometry15”,也是存在的:

那么问题在哪里呢?

作为码农,解决问题不能靠猜。我们需要有洞察力。让我们盯着小姑娘看上3小时。。。


https://www.zhihu.com/video/919585277633249280



看哪里?看胸。。。当然不是,看衣领。嗯。衣领!?

回到Blender,我们可以确认,衣领确实是上裙的一部分:


https://www.zhihu.com/video/919586746331693056



再回来看我们渲染的结果。如果看得足够仔细,我们会发现其实胸前的领结,以及腰部前方的系带,也都是有的:


https://www.zhihu.com/video/919587467852644352



再对比我们用Blender渲染的结果,应该不难发现有的都是白色的地方,没有的都是黑色的地方。

我们可以通过将Blender的3D视图显示模式从“Solid”调整为“Material”来确认:


https://www.zhihu.com/video/919588302905020416



进一步确认的话,我们可以观察到上裙其实引用了两种材质:

所以呢?其实我们需要回到从零开始手敲次世代游戏引擎(二十八)

事实上,对于复杂的模型,其往往也是包含了到不同材质的引用。比如一个人物的模型,其裸露的脸部和身上着衣的部分的材质就很可能是不同的。况且考虑到动画的需要,我们需要将模型分割为可动的部分和不可动的部分。我们在用maya或者3dmax等DCC工具进行建模的时候,往往会将顶点进行分组,指定不同的材质或者子材质,这些都是很自然很好的分割依据。

我们再仔细看看导出的OGEX文件,在代表上裙的geometry15当中:

有两个IndexArray!

而我们目前的代码是(RHI/OpenGL/OpenGLGraphicsManager.cp):

注目这一行:

const SceneObjectIndexArray& index_array      = pMesh->GetIndexArray(0);

知道问题在哪里了吧。我们现在是写死的,每个Mesh只渲染第一个顶点数组。

我在now the aili mesh can be properly rendered · netwarm007/GameEngineFromScratch@8a1a44f 当中修正了这个问题。同时也修正了缺省的光源在模型身后的问题。最终的效果如下:


https://www.zhihu.com/video/919345026281136128



另外,在这个过程当中我发现Windows版本在渲染复杂模型的时候会Crash。通过两天的调试,发现这是因为我们的SceneObject当中对于顶点Buffer尺寸的计算不正确,导致将顶点数据从主内存拷贝到GPU显存的时候访问了实际上并没有初始化的内存导致的。这个问题在macOS版以及Linux版上以极低的概率出现,而Window上100%出现。进一步调查发现上因为我们现在编译的是Debug版,用Visual Studio编译的Debug版的Windows程序会对分配的内存前后未使用的空间填充cdcdcd这样的调试内容,当这个内容被解释为指针地址的时候,就会出现保护错误。而macOS和Linux不会进行这样的填充,所以,依据执行时内存上残留的数据情况,可能crash可能不crash。

(如果记得我们之前写的内存管理模块的话,在Debug模式的时候,我们也是会对未分配的空间进行这样的填充的。只不过目前我们还没有对接内存管理模块。这也从一方面证明了在跨平台开发当中写自己的内存管理模块的好处)


#P.S.

啊,忘记了一个比较重要的事情。我们之前场景文件的路径都是Hard Coding到main.cpp里面的。现在我们场景资源比较多了,这显然不是什么好事情,所以从这篇开始我们的代码是通过启动命令行参数加载场景文件的。比如要加载aili,需要这样运行(场景文件根目录固定在Asset目录下,所以文件路径是相对于Asset目录的相对目录):

./build/Platform/Darwin/MyGameEngineCocoaOpenGL.app/Contents/MacOS/MyGameEngineCocoaOpenGL Scene/aili.ogex


参考引用:

  1. Aili | Blend Swap
  2. How to Flip normals in Blender-
编辑于 2018-08-16

文章被以下专栏收录

    本专栏收集高品质游戏开发方面的原创文章,并不局限于技术文章,尤其欢迎美术类策划类管理类文章。投资市场营销类暂不接受。欢迎投稿但是否采纳全看运气。如无特别声明,本专栏作品采用知识共享署名 4.0 国际许可协议进行许可。