图形引擎实战:移动平台海飞丝系统-渲染篇
大家好,我是来自搜狐畅游引擎部的puppet_master,很高兴有机会继续来分享我们最近在开发的新版移动平台头发渲染方案-海飞丝系统。上一篇《海飞丝-运动篇》中,我们介绍了游戏中头发的特性以及头发的进化史,并重点介绍了发丝的运动的实现。
我们还是先看一段小视频:
https://zhuanlan.zhihu.com/p/548225313
如果大家想更多的了解海飞丝系统的特性或者还没有看过运动篇的同学,欢迎来看一波前情提要哈:https://zhuanlan.zhihu.com/p/542644563
本篇是渲染篇,我将重点介绍一下头发的渲染方面的问题,本篇重点是分享一下我们在渲染中遇到的一些问题,以及解决这些问题的思路和原理,更多的是在各种方案间的权衡,无过多的示例,当然方案还在开发当中,有各种不足也欢迎大家批评指正。
发丝渲染的难点
在上一篇当中,我们总结了头发的几个让人秃头的特性
1)数量多:如果不秃头的话,成年人的头发数量在10万根左右
2)发丝细:单根头发的宽度在0.02-0.2毫米
3)运动杂:每根头发运动状态都可能不同,头发可能成绺也可能单丝
4)造型怪:千奇百怪的造型,飘逸同时发型不能乱
在运动篇中,我们解决了数量多,运动杂,造型怪的几个问题。理论上来说,在我们通过Compute Shader的计算结果,让头发随动后就可以得到一头飘逸的头发效果了,但是理想很美好,现实很骨感。如果我们直接来渲染头发的发丝,得到的结果只能说是惨不忍睹,因为超细的发丝会带来以下的几个问题:
1)发丝总量固定的情况下,过于细的发丝,会导致我们的头发很稀疏,让我们的角色有一种严重脱发的秃头的感觉
2)超细的三角面在光栅化体系中会有很严重的锯齿问题,细发丝会有很严重的锯齿或者闪烁。
3)超细的头发需要半透明的属性,而多层的半透明一直是渲染中的难点。
4)头发的微观很细,导致头发有特殊的光影表现,各向异性光照及散射,阴影等。
因此,渲染篇当中,我们主要面对的就是发丝细的特性带来的几个问题,下面我们分别来解决一下。
秃头问题
秃头的问题确实是一个让人秃头的问题。现实中,我们的发丝很细很细,人有十万根头发,只要他不脱发,那他的头发就能够完美的覆盖住头皮。但是移动平台的海飞丝由于性能的限制,自然不能1:1还原人的根数。而较少的发丝带来的就是头发稀疏,换句话说就是脱发,秃头。
Line Or Triangle
理想情况下,我们可以使用线形图元来绘制头发的,这样起码头发的数量会少很多的顶点量级,我们就可以有更多的发丝,但是实际直接用线性图元渲染,发丝的宽度固定就是1个像素,一方面我们无法控制头发的宽度,会导致有些角度下头发闪烁明显;另一方面纯线性图元的宽度过细,反而会让头发表现很稀疏。因此,我们所谓的发丝,实际仍然是发片,即使用三角形图元渲染:将头发发丝作为Billboard展开,而这样,头发的宽度实际上我们就可以自由控制了。
我们在Compute Shader中得到线性的点的位置后,在Vertex阶段,我们就可以按照奇数偶数来区分是左右的顶点,然后将发丝拓宽。而头发的宽度则是根据不同的发丝进行设置,且根据离相机的距离进行动态调整。这样,我们的发丝虽然不像一个像素那样细,但是适当的增加头发的宽度,可以让我们用有限的发丝覆盖住头皮,避免出现秃头的情况。而另一方面,既然发丝是我们自己控制的宽度,我们就可以动态的控制不同发丝是宽度,内层粗,外层细,让粗细发丝混合,进一步降低发丝的数量。
Tessellation
说到巨量级的三角形渲染,可能我们第一个想到的技术就是Tessellation,理想情况下可以用很少量的实际资源获得更多的细节。如Nvidia Hair Works等方案就是采用了Tessellation + Geometry Shader的配合。但是对于移动平台,一方面,Geometry+Tessellation的性能并不好,此外Metal不支持线性图元Tessellation这样的特性,也让我们无法纯GPU生成头发的图元。因此我们是使用CPU端生成数据+Vertex Shader模拟Geomerty Shader实现发丝结构的展开。
其实秃头问题是困扰了我们很久的问题,我们也是通过技术,设计,取巧结合的综合疗法来一步步治好了小姐姐脱发的毛病。关于秃头的问题,后续将由我的同事Marth为大家介绍。
Aliasing问题
Aliasing与 AntiAliasing
什么是Aliasing,我们需要先看一下光栅化渲染的原理。三角形光栅化到屏幕后,我们要把连续的信息转换到屏幕上离散的像素点的信息,而屏幕的分辨率是固定的,因而在三角形边缘的部位,就可能出现这样的锯齿感。
如果是常规的物体渲染,我们的最大的感受就是锯齿感;但是对于头发这类的超细三角形,问题将变得更严重,一方面,发丝很细,原本的锯齿只是在边缘的部分出现,而对于发丝这样的细小三角形来说,两侧都是边缘,边缘的占比已经比三角形内部更多,因而锯齿非常明显。另一方面,发丝是宽度甚至可能小于一个像素,因而可能导致头发忽隐忽现,出现强烈闪烁的情况。
AntiAliasing也是一个老生常谈的问题了,伴随着渲染的技术的进步,相关的技术也是层出不穷。SSAA,MSAA,FXAA,SMAA,TAA等等。但是实际上,真正能最好的解决闪烁的方案,表现最好的方案就是SSAA或者是他的进阶版MSAA,大力出奇迹,只要舍得下料,绝对会有最好的结果。如果我们直接开8x MSAA,那头发的效果自然就和用了海飞丝一样顺滑。他的原理实际就是多采样,将原本一次的采样改为多次采样均值化作为实际的结果,这样在边缘部分就不再是非0即1的二值,而是一个过渡的表现。
但是在移动平台开8xMSAA自然只是美好的理想,兼容性和开销等问题都是无法承受的。所以我们需要用一些取巧的方案来实现头发的抗锯齿表现。
发丝的AntiAliasing
头发的闪烁和锯齿主要是两个原因导致的,我们分别来看一下。
头发的闪烁是由于发丝过细,会导致光栅化之后,发丝可能小于一个像素,因而更加容易出现光栅化后像素点的不确定性,但是小于1像素的宽度在渲染中实际是无意义的,我们能在屏幕上看到的内容要求我们至少是一个像素的宽度。所以,我们需要让头发能够保持至少一个像素的宽度。这一步的操作,我们可以在上面所述的发丝Billboard展开的过程中进行一个额外的处理,即将发丝转换到屏幕空间进行宽度的计算,如果发丝的宽度小于一个像素,那么就让其至少有一个像素。
而我们拓宽了发丝之后,将面临的就是真正的锯齿的问题啦。其实对于头发的抗锯齿的方案,我们也是是用了类似多重采样均值化的方案,而均值化的的调制条件就是距离发丝边界的位置,我们根据距离发丝边界位置的距离来作为调制值,来控制头发的alpha使其有一个半透明的表现,让头发在发丝边缘是一个平滑过度的表现。
经过了我们抗锯齿处理后的头发效果如下。可以看出,左侧没有使用抗锯齿处理的头发非常粗糙和毛躁,而使用了抗锯齿后的头发则非常顺滑,也展现了发丝细的特性。
半透问题
为什么头发会有半透明的属性,但我们实际的头发感觉不到,只是我们的头发比较厚,由多根头发叠加起来之后导致头发是一个实体;但是从微观的角度来看,一根头发仅有0.02-0.2毫米粗,这么细的发丝实际是能透过后面的背景的,而其微观结构对光照也有影响。如果是鬓角,散发,刘海,发尾等部分,头发比较薄,就能稍微够体现出头发的这样的一个特性了。
我们使用半透明来渲染头发,一方面是因为头发确实有一些微观的属性,另一方面,如果使用不透明渲染,会有较强的毛躁感,而使用半透明渲染,头发和头发,头发和背景之间融合的更加柔和,光照表现也不是那样扎眼。但是半透明排序,尤其是涉及多层的半透明排序的问题,一直是渲染领域的一个难点。性能好的方法效果不好,效果好的方案性能不好,至今也没有一个两全其美的方法。所以我们只能在几种方案中进行权衡。
PerPixel Link List
我们需要在pixel shader中构建一个逐像素链表,每个像素的开头是链表头,然后如果有像素光栅化到该链表上,将其颜色和深度及指针挂在链表结尾。当所有头发渲染完毕后,使用一个全屏的Pass,针对每一个像素的深度在链表内进行排序,最终得到排序后的结果。
Tress FX早期版本使用的就是该方案的简化版本,从原理上看,这个方案理论上来说是能够完美解决半透明排序的问题的,但是仅仅是理论上。首先,我们需要开辟一个很大的空间来存储所有头发的像素的信息,原本我们只需要存储一个FrameBuffer大小的信息,但是现在不论多少层的头发,我们都需要将这个原始信息进行存储,近距离特写的情况下极限到8-10的FrameBuffer的大小,也就是说他的内存开销是不确定的,Adaptive Transparency的方案可以有所改进内存的问题,但是需要DX12的支持,移动则远不可能;另一方面,头发的重叠层数最高可能达到数十层,即使我们只针对前面几层进行排序,开销也是十分可怕的。加之像素级原子操作等内容在移动平台的兼容性问题,让我们不得不放弃这样一个最好的表现的排序算法。
Hybrid Transparency
精确的半透明排序用不了,那么我们是否可以考虑一些不是那么精确的排序方案呢?诸如Weight Blend OIT或者Weight Averange OIT等方案,借助反推Alpha Blend公式,来修改混合系数,让半透明之间的混合不那么明确,也就是和前后顺序无关。但是该类方案有一个缺点,就是可能导致混合的内容混做一团,因而这类方案更多的是用在云,雾等渲染的领域,但是我们的头发可是需要丝丝分明的。那有没有一个方案可以解决这个缺点呢,结果我真的发现了一篇冷门的Paper介绍的方案:Hybrid Transparency,混血半透明。正如其名字所说,实际上是混合了Per Pixel Link和常规的Weight Blend OIT或者Weight Averange OIT算法。
从前往后看的话,背后的层如果被半透物体覆盖了,就会显得更灰暗;但是随着前面越叠加越多,就会导致后面的内容对最终颜色表现的贡献越来越低。这个现象就可以让我们把半透明分为两个部分,一个核心层和一个尾层,核心层也就是离我们观察者更近的部分对结果的贡献更大,而尾层远离观察者,对结果的影响较小。Hybrid Transparency就是基于这个现象,结合了k层核心层做排序,其他层做近似OIT。没有明显的穿帮,但是内存bounded,用两个pass实现。
当然,这个方案的代价也不低,原子操作,MRT,双Pass,但是性能在移动上可接受,效果上可以达到媲美PerPixel Link List。
双Pass
移动平台,也得考虑用点经济又实惠的方案。我们回顾发片渲染的方案的半透明的处理,实际也是是源于AMD2004对头发的那篇分享,多个Pass绘制头发。我们的发丝的方案依然适用于这个思想,内层使用细发丝不透明渲染,外层使用粗发丝透明,渲染两次。这样,在该遮挡的地方有不透明的部分遮蔽,外层又有细的发丝透明的部分,二者的结合,虽然有些发尾部分可能有半透的穿插,但是本身这部分属于薄发丝,表现不明显。这样的权衡也让我们的效果和性能达到了一个平衡。
下图分别是未处理半透明穿插的效果(左),Hybrid Transparency的效果(中),双Pass的效果(右)的表现。
光影表现
头发是一个复杂的集合,如果单独看一根发丝,这种离散细小的发丝的特性我们需要考虑,而很多根发丝组成的聚合头发的整体的特性我们也需要考虑,因而头发的光影表现相较于普通模型稍许复杂。但是其实最重要的两个特性就是各向异性光照和散射透射的表现。
各向异性光照
各项同性的高光是一个聚拢的圆点,在各个角度上下观察,高光的形状不变;而各向异性,也就是大家经常称之为的“天使环”,经常表现的是环装或者条带,拉丝状的高光,在不同的观察角度下高光的形状越有改变。我们要在渲染中实现各向异性的话,就可以把原来的NdotH的高光计算改成TdotH的计算,通过引入Tangent方向额外增加了光照的变化性。
目前常见的几个各向异性的光照模型,诸如Kajiya-Kay,Aniso GGX都可以很好的实现头发的各向异性光照表现,Kajiya-Kay这个非常古老的光照模型在今天仍然是性价比最高的各向异性表现之一,当然Aniso GGX可以获得更加柔和的高光的表现。而头发由于其超级细的微观结构,除了表面的R分量外,还涉及到内层反射后再次出射的TRT方向,所以头发的高光我们都会计算两层高光进行叠加以得到更好的效果,如果非常追求效果,甚至可以叠加三层。
透射散射
上面情况下,我们头发已经满足了大部分情况下的需求了。但是实际上,我们还忽略了一项重要的光照分量,就是TT分量,也就是从头发一面射入,而从另一面射出的透射部分的光的分量,而这一部分效果在头发大背光的条件下会非常明显。而另一方面,出射的光线也并不是会完全射出,还会在头发之间发生Multi Scattering的现象,也就形成了更加复杂的头发的散射表现。
这里我们可以使用Marschner Model直接计算出R,TRT,TT分量。当然,也可以使用一些类似皮肤,树叶等的透光的算法来模拟这个现象,而且透光的强度甚至要使用HDR更高的高强度范围来达到更强的光线穿透的效果。如下图,夕阳西下的情况下阳光洒在角色的头发上的这种表现。
总结
本篇文章介绍了移动平台海飞丝系统中我们在渲染方面面临的一些问题,解决了由发丝细的导致的Aliasing,半透明,秃头,光影表现等的问题。当然,在移动平台来说我们的方案还是属于探索阶段,还有很多的不足之处。我们也尝试使用移动平台纯Compute进行软光栅渲染发丝,可以达到更加细的发丝表现,并且同时解决了半透,Aliasing等问题,但是由于移动平台的特性及机能的限制,这些更加激进的想法目前还没有办法落地。但是我们也相信,在不久的将来,我们曾经的激进的技术都会变成标配,而我们也会继续追寻更加前沿的技术。
海飞丝技术除了运动,渲染的方向外,还面临着美术工作流程上的改变,如发丝资源的制作,各种特殊头发的造型问题。以及优化方面的,秃头的问题,减面等问题,下一篇将由我们部门的Marth同学为大家带来海飞丝系统-流程篇的介绍。
欢迎加入我们! 23届春招已经开启啦,等你投递~
内推码:NTAI1kh