3A风光片《对马岛之魂》分享:在3A游戏中“种草的程序化技术”!

【GameLook专稿,未经授权不得转载!】

GameLook报道/《对马岛之魂》曾获得了很多奖项的提名,其在剧情、美术、音乐等多方面都得到了业内认可。此外,对马岛瑰丽的自然风光同样让很多玩家印象深刻。

《对马岛之魂》的艺术方向要求游戏里有大片在风中摇曳的茂盛草地,可以让Jin骑马通过。为实现这个效果,Sucker Punch Productions选择通过在GPU生成单片草叶来渲染草地,每一片草叶都有自己的程序生成外观和动画。

此前的GDC分享中,《对马岛之魂》图形程序员Eric Wohllaib谈到了如何在合理的内存和性能限制下,生成数英亩的草地,渲染和单个叶片的动画技术,以及让数十万叶片看起来非常自然的方法。

以下是Gamelook听译的完整内容:

Eric Wohllaib:

我叫Eric Wohllaib,是Sucker Punch Productions的图形程序员,今天分享的是我们在《对马岛之魂》里为实现我们的艺术方向而使用的程序化草皮系统。

《对马岛之魂》的设定是在古代日本海岸上的对马岛,从一开始我们的目标就是描绘这座岛屿美丽的自然风光。这意味着我们要做大量的树叶,由摇曳的树木、连绵起伏的草地组成的大片森林。

我们的方向还决定了倾向于彩绘场景,而非多种鲜花、灌木和蕨类植物组成的田地。我们选择大片单一的花朵,就像是由巨大的画笔描绘的一样。

很早的时候,我们尝试了传统的grass card,我们最终觉得由于一些原因,它并不能达到我们的目标。

首先,当我们真正想要推进一些东西的时候,将我们的动画与整个grass card绑定会带来很大的限制;其次,对我们在游戏世界里寻求的树叶密度而言,overdraw问题会比较严重。

那个时候,我们发现了Outerra的一篇非常出色的博客,标题是“procedural grass rendering”,这给我们的方法带来了很大启发,感兴趣的同行可以参考阅读图片中的链接。

这个场景包含了超过100万片草叶、并且渲染了其中的8.3万片。每一片草叶都随风而动且持续2.5ms。草皮是很耗美术资源的,甚至比花朵都要难做,因为这些草皮需要展示风向,让玩家知道该往哪里走,草丛让玩家很痛苦,敌人可以在草丛中看到他们。

今天的分享主要分为以下几个部分:我们首先会谈到计算(着色器)制作的是什么,以及这个信息是如何传递的,然后会讨论我们的顶点着色器以及它们是如何处理事情的,随后是我们如何决定像素渲染器里的材质数据,最后一部分是帮我们将整个系统在制作引擎中组合在一起的总结性的东西。

计算着色器

对我们来说,第一步就是将整个游戏世界分解为不同的贴图(tiles),这些贴图包含一系列的信息,包括地形高度、地形之下的材质,我们的目的是确定在这个位置放什么类型的草丛、以及这个草丛应该有多高。

这些贴图进一步分割为我们渲染的贴图,后者采样自他们父贴图的纹理部分。这些问题是512×512的,意味着我们每隔39里面就有一个纹素(texel)。

对于每个渲染贴图,我们都运行一次计算着色器。计算着色器中的每个通道都会在贴图内的网格上获得位置,并添加一个随机偏移来获取我们的叶片位置。一旦我们知道了它的位置,我们就做距离剔除(distance culling)和视锥体剔除(frustum culling)。

随后,我们从之前提到的纹理采样,来确定它是什么类型的草皮、以及它的高度应该是多少。既没有草皮类型又没有高度的通道则被丢弃。确定了草皮类型之后,我们开始做遮挡剔除(occlusion culling)。项目后期,我们发现在大多数场景中都比较完美。

每片草叶都有特定类型,这决定了它使用的艺术参数。每个贴图都有一个512×512的纹理将贴图位置映射到一个草皮类型,将8bit索引存储至草皮参数数组中。

我们不能对这个数据做线性采样,因为最高插值4个索引是没有意义的,它会看起来是简单的点采样,你可以清晰看到控制草皮类型的纹理。

所以相反,我们进行收集,然后随机从四个纹素中间相关位置选择一个草皮类型,如果我们只是做点采样,这可以给我们呈现更流畅的转接。

为了生成一片草叶,我们需要加入16个浮点的instance data,位置用三个浮点、草叶朝向2个浮点。这个2D朝向矢量决定了草叶的指向。我们将风力放到了草叶位置以驱动动画,我们还基于Hash放置了一个Per-blade位置驱动该草叶上的很多东西,包括动画在内。

草皮类型决定了它使用的艺术参数,草丛信息给出了更多细节,最后是控制草叶形状的各种参数。这些参数受到上层树叶hash、草叶属于哪个草丛、下层地形的风坡、通过草皮的事物以及相机位置等多种因素的影响。草叶与这些事物互动的方式都是由艺术参数驱动的,而且每个类型的草皮都不相同。

我希望强调此前提到的草丛值,加入草丛代码之前,我们所有的草皮看起来都像是高尔夫球场,你可以增加每片草叶的随机性让它看起来更凌乱,但它看起来并不像一片自然的土地,只是看起来更随机。

真正自然的土地是会变化的,或许这片阴影下的草皮长的更慢、或许那片草丛土壤里有更多养分是的草皮长的更高。为了模拟这些东西,我们决定将草皮按草丛划分,然后根据草叶所述的草丛来影响其他参数。

我们是通过程序化算法实现的,在2D空间内的任何一个位置,我们关注网格内离它最近的9个点,每个点都根据hash抖动以得到不同的形态,然后我们将这个2D采样点分配到最近的点丛,附近相同的丛可以影响这片草丛的各种参数。

我们可以得到高度、让草丛都指向同一个方向。

我们可以让草叶离丛点更近,也可以让草叶方向与丛点相反,也可以将这些综合起来。

数据管线

我们的着色器流程看起来如上图所示,第一个计算着色器填入instance data并计算我们的草叶数。第二个计算着色器在首个完成之后运行,它为这个贴图的调用将草叶数移动到间接提取参数(indirect draw args)。

第二个计算着色器的运行是单向的,几乎不需要时间。它完成之后,顶点着色器就会开启,并根据instance data渲染,最后像素着色器只需要完成渲染。

不过,我们并不同时处理所有贴图,因为那样的内存开销会非常高。相反,我们的instance data缓存可以保存8个贴图的草皮数据,第一次通过计算着色器运行4个贴图,然后,在顶点着色器和像素着色器运行的时候,我们通过计算着色器运行另外4个贴图。这种双缓存设计可以让GPU一直工作,又不会占用太多内存。

顶点着色器

我们必须在顶点着色器完成计算,顶点着色器是用instant index调用绘制,它们只通过index和instance ID生成输出数据,每个贴图都是一次绘制调用,不管是高LOD还是低LOD。高LOD草皮每个草叶有15个顶点,而低LOD草叶只有7个。对于每个顶点,我们都需要知道0-1值,它确定了草叶长度以及在左侧还是右侧。

两种LOD草叶的转接有些棘手,因为我们的草皮可能有很高的弯曲度,当我们从低LOD切换至高LOD的时候可能发生爆裂。为了解决这个问题,高LOD向附近的低LOD倾斜。

值得注意的是,低LOD贴图的尺寸是高LOD贴图的两倍,但草叶数量相同,意味着它们的草叶分布距离相差两倍,为了流畅转换,高LOD贴图在在转换成低LOD贴图之前,需要先遮挡每四片草叶中的三个。

通常而言,我们所有的顶点都在同一个草叶上,但如果草叶足够短,我们折叠顶点以形成两片草叶,这是我们从Outerra得到最好的想法之一,让短草丛区域看起来更密集。因此,在草叶的左侧或右侧摆放之外,还有一种情况是两片树叶折叠,我们通过顶点ID来判定。

由于我们的草叶顶点数是奇数,其中一个草叶最终会有一个顶点比另一个更少,这就导致我们一侧有一个奇怪的三角形。对于低LOD,更少的顶点数,我们没有任何建议,因为做动画的时候能够看到它是多么的低多边形,哪怕是远距离看也是如此。

现在,我们知道了草叶类型和顶点的相关细节,我们需要确定这些顶点出现在世界的位置,每个草叶的形状都是一个贝塞尔曲线(cubic bezier curve),它包含很多有用的属性。草叶的顶点位置计算起来很简单,衍生物也很容易计算,这让我们的法线很容易确定。控制点对草叶形状有很高的控制,这让草皮动画做起来很容易,还可以做出大量的草丛形状。

但是,我们将贝塞尔曲线放在哪呢?作为开始,我们决定基线相关的倾斜位置,这是由之前的倾斜参数以及朝向参数控制。

接下来我们定义由弯曲参数控制的中点(midpoint),如果弯曲为0,中点就在基线和顶点之间。数值超过0,就会让中点从这条线推上去。对两片草叶我们保持同样的整体形状,但将它们推开,这让它们保持同样的整体方向,但增加了覆盖面积。

确定了顶点位置之后,这时候需要确定顶点的世界空间位置,我们确定0到1的值,然后放到贝塞尔曲线函数里,接下来是朝向位置翻转x和y,并取消一个为我们的朝向找到正交法线。我们在法线方向上步进顶点,距离取决于计算着色器中计算的草叶宽度,并按草叶的位置缩放,我们想在达到顶点的时候逐渐缩小。

接下来,为找到顶点法线,我们我们位置找到派生贝塞尔曲线,用找到的法线与之交叉。

但是,我们该如何让它动起来?关于我们的风力系统,我们很早就决定做一个统一的风力系统,它可以在CPU和GPU采样并拥有相对最小化的开销。为此,我们希望做到简单化。风实际上是由用户参数塑造的2D柏林噪声(Perlin Noise),并沿着风吹的方向滚动。柏林噪声在一个位置给了我们单一的风推力值,我们将其与2D风向矢量结合,用作对风做出反应的各种系统的输入。

对于一些粒子系统里的草皮,我们多做一层柏林噪声以获得更复杂的动态,如果你想知道我们风力系统的更多细节,可以参考我同事专门做的风力系统分享。

回到我们的草叶,我们的朝向已经在计算着色器里受到了风力影响,因此我们现在制作一些简单的上下摆动,相位偏移受per-blade hash以及沿草叶位置的影响,给动态以摆动效果,per-blade hash偏移确保每个草叶动态都是不同的。这里需要指出的是,这时候贝塞尔曲线的弧长并不容易计算,而且难以控制,随着草叶摆动,弧长也会各有不同。然而,如果动画保持相对受限,这种情况就不明显。

这是将其组合在一起的高层次要点,但还有一些东西带来了帮助。首先,我们将草叶的法线向外倾斜一点,这有助于使草叶看起来更自然、更圆,而且比添加更多的顶点开销低很多。

我们在让田地看起来更满的时候也遇到了困难,增加更多的草叶当然是一个选择,但却是开销很昂贵的一个。

相反,当草叶的法线与视图向量正交时,我们在视图空间中稍微移动草叶的顶点,从用户的角度来看,这巧妙地增加了草叶的厚度,这既意味着我们花更少的时间光栅化非常薄的三角形,也意味着田地看起来更饱满。

在中远距离,特别是下雨的时候,我们也很难处理锯齿镜面反射高光。远处草地的法线最终在屏幕空间中发生了巨大变化,由于草地非常光滑,因此会产生大量噪点。随着草地动起来,它会开始闪闪发光。随着摄像机距离的增加,为了帮助实现这一点,我们开始将输出的法线向草丛的公共法线拖动。这在保持田地形状的同时,还减少了噪点。

另外,我们在像素着色器降低了光泽,如果将光泽视为曲面法线在子像素细节变化方式的呈现,这就是合理的。由于正太方差(normal variance)不断增加,我们降低了光泽度。

我们有了草叶和顶点,现在我们只需要对三角形着色,我们的草皮输出到延迟渲染器的G-Buffer,所以我么要做的就是拿出我们的材质数据。光泽是简单的一维纹理,我们在叶片的宽度上拉伸,并在长度上重复。

对于扩散,我们有两个纹理,第一个原理和光泽度一样,给沿着草叶向下延伸以及宽度上的叶脉一些变化。第二个是包含了实际颜色的2D纹理,纹理的V维度随着叶片长度的变化赋予其颜色,这就让草叶根部颜色更甚,到叶片尖端颜色逐渐更亮。该纹理的U维度则是由该叶片所属的草丛控制。这有助于我们在一个可以由美术师偏好控制的田地里做出变化,而不是每个叶片都有自己随机的颜色。

实践中,为了保持我们的绘画风格外观,草丛之间的色差被设计得很小,但在未来,我认为这值得投入更多的实验。对于半透明和环境光遮蔽,我们输出随草叶长度变化的恒定值。半透明在叶片最厚的根部很低,到顶点则越来越薄。环境光遮蔽功能也是如此,根部更暗,或者光线也可能被其他叶片挡住,然后往叶尖更亮。

你们可能会问,既然我们的草皮并不向我们的速度缓存写入速度,为什么我们输出环境光遮蔽值而不是依赖SSAO(屏幕空间环境光遮蔽)呢?草叶的无状态属性使它有点困难,但可行。为了找到上一帧顶点的位置,我们需要缓存上一帧的风数据,因为风速和方向可能会变化,我们还需要从上一帧开始设置位移缓冲区,以防玩家走过这片草地。

由于大量的风和位移信息都是在计算着色器里处理的,我们需要为顶点着色器存储每个叶片的处理数据。这些都是有可能的,但性能与内存限制让它显得不切实际。即便如此,如果我们真的为临时计算的SSAO产出了速度数据,草皮的运行方式使其成为临时累计效果的糟糕目标,叶片一直来回挥舞,永不休止地遮蔽和反遮蔽。

所以,哪怕我们校正速度做到了完美,我们的环境光遮蔽最终成为一团又脏又亮的东西。

不过,大多数时候,田野并不总是数不清的叶片。为了补充场景,我们还需要在田地里程序化放置完全由美术师做的资源,这可以让我们很轻易加入一些东西,如彼岸花、微型花朵或者经常是潘帕斯草(Pampas grass)。

我们的成长系统使用gpu实例绘制系统(instance draw system),而不是创造完整的游戏对象。增长系统有效地输出了一系列最小的数据,只有位置、方向和剔除信息,然后从中调用大量资源。

当我们加载一个贴图的时候,我们运行一个为程序化草地资源生成同样流数据的计算着色器,这个资源被增长系统使用。我们将距离摄像机最近的3×3贴图保留在内存中,并丢掉此外的任何东西,这样就不用保留太多的远距离资源。

摆放算法与草叶的工作方式相似,贴图里的一个位置随机生成,然后检查那里的草皮类型是否与其匹配,如果匹配,它就增加程序化转换数据准备后续渲染。

我起初只是在随机抖动中尝试回来做一些更复杂的事情,但当我找到时间这样做时,美术师们已经使用这个系统来摆放这些水稻作物,效果非常好,所以我就没有动它。

我们在草皮问题是遇到的最大困难,是如何处理非常远的LOD,对马岛有一些玩家可以到达的场景,让他们一次就能看到整个岛屿,而渲染那么远距离的草皮显然是不切实际的。我尝试了一些视图方法运行这些由草丛信息驱动的地形,毕竟,如果草丛驱动法线,它们应该能够控制法线为主导的远景视图。

不幸的是,这最终代价昂贵到不切实际。另外,一旦我们加入了之前提到过的美术师资源,这种方法就是不可行的,大片的彼岸花都会变成绿色草地。因此,我们选择在地形中的那个位置渲染和艺术家创作的纹理,而不是底层材质。这种方法的开销不高,而且效果足够好,只是这里依然有可以提高的空间。

在《对马岛之魂》里,玩家可以隐藏在草丛中出其不意地刺杀蒙古人,整体上做到悄无声息。由于我们所有的草皮数据都存储在对GPU友好的纹理中,而这些纹理在cpu上不方便访问,因此无论我们在哪里加载贴图,都会运行一个计算着色器,将一些信息从快速gpu纹理复制到更适合cpu的纹理中,从高度信息我们生成可视化网格来判定玩家是否可以被看到。

我们最初返回了未经修改的草皮高度,但发现这可能会导致非常不连贯,相反,每一种草皮都会被标为潜行草丛或装饰性草丛,并基于这一点返回持续的高度。出于连贯性的考虑,每个潜行草皮内都有潘帕斯草,但真实的潜行来自于草皮本身。

我们还对阴影做了一些特别的优化,我们支持运行完整的计算着色器和顶点像素着色器管线用于照明,但考虑到它为每次照明都需要运行至少1次,这样做的开销非常大。虽然极少情况下我们也会这么做,但整体还是依赖于一个使用表层下地形的Imposter系统,我们有效提升了地形顶点来匹配那个位置的草皮高度,然后以抖动模式补偿我们写到阴影贴图的深度。

当我们将其与阴影过滤(shadow filtering)结合的时候,大致可以得到与草皮密度相匹配的阴影。不过,这也并非没有问题,代理网格的离散性质可能最终导致难以解析的硬边缘。但在游戏里的大部分场景中,结果都很好,表现也很棒。

更多的细节依赖于屏幕空间阴影(screen space shadow)做出改变,屏幕空间阴影无法理解物体的厚度,不过我们的草非常薄,它们仅限于短距离和屏幕空间,但我们在屏幕空间的草皮大部分时候都非常小。它们不考虑屏幕外的几何体,但我们的草都很小,屏幕外没有任何东西能够对屏幕上的内容带来很大的影响。

因此,在我们的Imposter几何体和屏幕空间阴影之间,我们得到了非常高质量的阴影,且不会影响性能预算。可以看游戏内效果:

对于未来,我希望对这个系统做出一些提升。

首先,在游戏制作期间,很多时候美术师都会放置一个他们希望有草皮的资源,但我们在地形上对草皮有很严格的限制。资源可以选择在于地表交叉的时候对地形材质采样,并且与地形材质混合以获得从高度贴图控制的地形美术师创作资源更好的过渡效果。

但是,由于草皮无法被放置在这些资源之上,通常会有一个尴尬的边缘或者糟糕的从资源底下有草丛弹出。未来,我认为将美术师创作几何体标记为草皮表面是值得的,我们可以在上面程序化生成草叶,这样资源与地形之间的承接就会真正做到无缝化。

其次,我们现在制作的贝塞尔顶点(Cubic bezier vertices)非常灵活且快速,但还有其他类型的树叶密度我们可以程序化摆放。蕨类植物、银杏叶甚至小岩石的顶点数量可能与我们现在的草皮非常相似,但程序化生成起来比较困难。美术师创作资源支持,尤其是可以放到任意表面的资源支持,可能会非常强大,或许我们可以为高度细节化的动物做fur card,我也不确定,但这是未来可以尝试的事情。

第三,由于每个渲染贴图大小都是此前贴图的两倍,到了我们切换到下一个尺寸的时候,我们放弃了四个草叶中的三个,每个贴图都有同样的草叶数量,这很美观也很简单。然而,这意味着我们依赖于在一个并不那么容易改变的距离遮蔽四分之三的草叶,未来,我们想要将遮蔽与贴图大小去掉关联,这样就能够更容易推进草皮距离。

这就是我们在预算之内渲染大片草地并达到我们艺术方向目标的方法。我们用计算着色器生成每片草叶的instance data,这是可以被美术师修改的,然后用间接绘制调用(indirect drawcall)在屏幕上得到接近10万个贝塞尔曲线。

我们用程序化放置的艺术家资产补充了这些简单的草叶,并使用非常简单的替代物来制作阴影和花朵LOD。尽管依然还有提升空间,但我们对实现的效果感到满意。

如若转载,请注明出处:http://www.gamelook.com.cn/2023/06/520095

关注微信