最新一期的「Unity 大咖作客」分享会上,完美世界的资深技术美术&引擎工程师徐行跟大家详细解读了完美和 Unity 合作的毛发系统。
本文节选了部分精彩内容,完整内容已上传至 Unity 社区中的技术专栏。滑至文末,点击“阅读原文”,即可跳转至技术专栏学习:
https://developer.unity.cn/projects/hairworks
![](http://dingyue.ws.126.net/2021/1014/0a083c5ej00r0yjih001cd200u000a5g00zk00c0.jpg)
大家好,我是来自完美世界移动项目支持部的徐行。今天跟大家分享一下我在完美世界研发的 Unity 毛发系统,在讲 PPT 之前先看一个由我的虚拟闺女小花出演的视频。
其实这个作品还在很初期的阶段,还有很大的提升空间,这次主要是让她帮忙展示一下毛发。这个是本工作的大概时间线,其实前期还不配被称之为自研毛发系统,只能称得上是一个英伟达 HairWorks SDK 的集成。随着后期需求越来越深入,自研的部分也越来越多。现在只是思想上部分参考了 HairWorks,代码已经全部重写了。
![](http://dingyue.ws.126.net/2021/1014/1e2cf485j00r0yjih0015d200u000arg00zk00cq.jpg)
说到毛发,现在游戏中比较传统的毛发解决方案有两类,第一类就是网格头发,这类头发其实渲染效果还不错,但是往往物理效果不太好,而且也不太适合做短毛。
![](http://dingyue.ws.126.net/2021/1014/7b3a162ej00r0yjii002md200u000dyg00yc00fy.jpg)
Uncharted by NaughtyDog
另一类是 FurShell,专门用来做短毛的。但是凑近了看,层状瑕疵也非常明显。做毛发的终极解决方案,肯定就是基于发丝的毛发系统。这是一些有代表性的案例,比如《最终幻想》、《怪兽公司》、《长发奇缘》、《古墓丽影》和《巫师 3》。
![](http://dingyue.ws.126.net/2021/1014/b6c90bafj00r0yjij003cd200u000gwg00zk00k0.jpg)
虽然基于发丝的方案效果很棒,但是问题也是显而易见的。那就是发丝数量太多,导致无论是物理模拟还是渲染还是存储等等都有比较大的困难。
HairWorks 是如何应对这些挑战的呢?这就得从它的资源表示方式讲起了。HairWorks 的毛发资源并不是直接存储每根发丝的信息,而是主要存储了两个东西,图中的黄线被称之为导发,图中的网格被称为生长网格。而物理模拟则在导发上进行,发丝则是导发间插值出来的,这样就能极大的减少计算量和存储间的需求。
![](http://dingyue.ws.126.net/2021/1014/ca9fa562j00r0yjik004jd200u000g5g00ya00if.jpg)
发丝具体是怎么插值的呢?其实生长网格是由一些三角面构成的。生长网格上面每一个顶点对应一根导发,随机生成一定数量的重心坐标,在渲染的时候就可以利用重心坐标作为权重,在三根导发间进行插值了。
![](http://dingyue.ws.126.net/2021/1014/efcb503aj00r0yjik001md200u000h0g00xl00j1.jpg)
为了让生成的发丝更加平滑,在生成发丝前还可以对导发做一些平滑,使用平滑过的导发进行插值。
![](http://dingyue.ws.126.net/2021/1014/0510d51dj00r0yjil001qd200u000exg00z800hi.jpg)
物理模拟又是怎么做的呢?HairWorks 选择了一种非常易于理解的物理模拟方法,也就是质点弹簧法。图中的红线就是进行物理模拟的导发,红线上的小圆圈就是质点,发根处的质点是完全受骨骼蒙皮控制的,其他的质点则会受到诸如风力、重力等等的影响,也会跟碰撞体发生碰撞。
为了使基于质点的物理模拟能够体现毛发的感觉,HairWorks 为质点间增加了一系列的约束,也就是俗称的弹簧。比如图左中,同一个头发上相邻质点间有长度约束,太近了会排斥,太远了会吸引,这有助于保持头发的大致长度。
![](http://dingyue.ws.126.net/2021/1014/9cc3c414j00r0yjil001kd200u000f3g00zk00hv.jpg)
再比如黄色折线,它表示的是完全受骨骼蒙皮控制的毛发。它跟红色这条线,也就是物理模拟控制的毛发间也有约束,这有助于保持美术所制作的造型。
另外,HairWorks 还有一个比较有特色的约束,就是在相邻的导发之间也有距离约束,有助于保持毛发的体积感,一定程度上避免穿插。当然还有很多其他种类的约束,今天时间关系就不展开了。
为了加速物理模拟,HairWorks 的物理模拟是在 Computer Shader 中并行进行的。一个控制点,也就是一个质点对应一个线程,一个导发对应一个线程组,这就便于使用效率比较高的共享存储。
![](http://dingyue.ws.126.net/2021/1014/c486b0f4j00r0yjim001kd200u000hig00wo00j1.jpg)
但是有些约束是有先后顺序依赖关系的,例如同一个质点上的两个长度约束,如果调换执行顺序,那么执行结果就不一样了。正确的做法只能是串行解算这些约束,但是串行显然是不如并行快的。为了加速物理模拟,HairWorks 还是想办法做到了并行。
![](http://dingyue.ws.126.net/2021/1014/39e46795j00r0yjin001ed200u000itg00ub00iz.jpg)
如图,它把长度约束分为两组,一组内每个约束都互不相临。这样一组的约束就可以并行解算,一组解算完之后,再解算另一组,交替迭代几次就能获得比较稳定的结果。
![](http://dingyue.ws.126.net/2021/1014/dc8b61c4j00r0yjin001ld200u000ilg00uf00iu.jpg)
当然后面我们还做了一些物理模拟的拓展和优化,例如加入了 3D 风场以及允许传入不稳定的物理模拟 TimeStep 等等,由于时间关系也不展开了。
接下来讲一下引擎与跨平台,这张图是我们把 HairWorks 集成到自研引擎之后,在《笑傲江湖》和一些 Demo 中的效果。当时另一个端游项目看到了,觉得效果不错,挺想用。他们是基于 Unity 开发的,所以接下来我就开始了向 Unity 的集成。
![](http://dingyue.ws.126.net/2021/1014/e32de405j00r0yjio003ud200u000e8g00yk00gd.jpg)
其实远在我之前,已经有很多团队进行了这项工作。比如图中是 Unity Japan 团队的成果,但是他们做的集成时间都比较早了,当时条件有限,所以也存在一些问题。比如无法使用 Unity 内部的材质,或者光照不全,没有平行光的投影等等。
![](http://dingyue.ws.126.net/2021/1014/a1c69e81j00r0yjip001rd200u000eig00x100fy.jpg)
HairWorks Integration by Unity Japan
他们为什么会遇到问题呢?这就要讲到他们集成的实现原理。
他们选择了原生插件这种集成方式,是由于 HairWorks 要用到诸如 Computer、Tessellation 这类很底层的图形功能。所以说使用能达到底层图形设备接口,例如 D3D Device 的原生插件机制还是比较稳妥的。
![](http://dingyue.ws.126.net/2021/1014/71600944j00r0yjiq001gd200u000hog00vz00it.jpg)
但是也正是因为他们是使用底层图形设备接口,例如 D3D Device,进行渲染,而不是使用 Unity 提供的接口例如 DrawRenderer,所以 Unity 内的材质、灯光、环境以及渲染管线内部的很多信息,都很难传过去,也就很难在原生插件里面重现 Unity 的渲染效果,所以原生插件渲染出来的东西往往光照不全。
![](http://dingyue.ws.126.net/2021/1014/85a16e6ej00r0yjir001wd200u000hjg00wa00iu.jpg)
另外由于 Built-In 管线中提供的插入点有限,所以没有办法渲染阴影深度。上述问题如果按原有思路做下去,是很难解决的。但是这些问题不解决又没有办法实际投产,所以这些集成最后基本上就被搁置了。
我最初也是一筹莫展的,差点因此放弃,但是最后还是想到了一个解决方案,那就是既然 Unity 里面的东西很难拿出来,我是不是可以不把他们拿出来放到原生插件里面?而是想办法把原生插件生成的 HairWorks 的几何信息传到 Unity 里面,这样就可以在 Unity 里做光照了,这样可行吗?
![](http://dingyue.ws.126.net/2021/1014/10f1ccabj00r0yjir001id200u000f0g00zk00hs.jpg)
我想的办法其实很简单,我给它起名叫渲染代理。所谓的渲染代理其实就是一个在 Unity 里面创建的头发的包围体,原生插件把 HairWorks 的几何信息渲染到了我自己创建的 GBuffer 中。
然后渲染代理挂上了 Unity 内的材质,直接参与 Unity 的渲染。与普通材质有所不同的仅仅是在渲染的时候会读取我的 GBuffer 中的几何信息,并把它伪装成自己的进行光照。这样的话,就可以直接利用 Unity 自带的渲染机制,所以渲染出来的东西跟 Unity 是可以完美融合的。
![](http://dingyue.ws.126.net/2021/1014/8d45c7a4j00r0yjis001cd200u000fsg00yf00i3.jpg)
这个机制还有另外的几个好处,第一是对项目的渲染管线没有影响,可以直接使用 Unity 材质,也支持 Shader Graph。所以不管对美术用户还是技术用户都比较友好。
另外,Unity 很多时候需要把一个物体渲染多次,而使用渲染代理的话,就可以避免多次提交复杂的毛发几何体去渲染了。因为我每次提交的,都只是一个很简单的包围体。
最后一个优势,大家可以看出来这个机制很类似于延迟渲染,每个像素上只需要进行一次着色,所以是比较高效的。渲染可以使用代理,投影也可以使用代理,如此一来集成就变得很简单了,就不存在之前所说的诸多问题了。当然,后来有了 SRP,就可以在渲影子的时候直接调用原生插件渲染了,不再需要投影代理了。
接下来说一下跨平台,其实刚刚不管是 Unity Japan,还是我们的集成,一直是通过原生插件对接 Nvidia HairWorks SDK 做的。在 SRP 诞生之前,这可能是唯一能够跑通的方法。HairWorks SDK 预留了跨平台的设计,但是 Nvidia 只实现的 DX11 和 DX12 的版本,剩下所有的图形接口都需要去重新实现一份 HairWorks SDK 的底层,这个工程量是比较大的。而且由于很底层,所以难度也比较高。我花几个月的时间实现了一次 PS4 平台,虽然是做完了,但是做完之后觉得不能再用这种方式继续往下做了。
![](http://dingyue.ws.126.net/2021/1014/3b0fa073j00r0yjis0028d200u000fsg00zk00ip.jpg)
得益于近些年 Unity 的进化,特别是 SRP 的加入,让直接在 Unity 内实现这种复杂功能变成了可能。我利用近期 Unity 提供的一些新功能,例如 Computer Shader、CommandBuffer、RenderFeature、CustomPass 等等,直接把红框内的整个流程包括资源加载、约束初始化、物理模拟、几何体渲染,这些全部都在 Unity 内部重新实现了一遍。
![](http://dingyue.ws.126.net/2021/1014/31d1fe05j00r0yjit001nd200u000f5g00zk00hx.jpg)
这个流程比之前的 PS4 移植要顺利得多,因为现在 Unity 的渲染开发是很高效的,做任何修改都不需要关编辑器,也不需要花很长的时间来编译,可以即时看到效果。
另外由于有像 CustomPass 和 RenderFeature 这样方便的机制,我对 SRP 的源码修改其实只有几行。
接下来讲一下毛发着色,可以先看一下最终的效果。
![](http://dingyue.ws.126.net/2021/1014/7fd40d5bj00r0yjiv001td200u000fwg00y800i4.jpg)
说到毛发着色,我们首先会想到的就是 Kajiya-Kay 模型,它用毛发的切线替代了常用的法线,把 cos 换成了 sin,快速实现了类似于头发的效果。
但是如图所示,虽然它产生了类似于头发的高光,但是塑料感很强。在 2003 年 Marschner 提出了一种更接近物理真实的毛发着色模型,中间就是 Marschner 成果,右边是真人照片。在不考虑造型的情况下,可以说效果已经很接近真实的照片了。
![](http://dingyue.ws.126.net/2021/1014/b70803cdj00r0yjiv001kd200u000blg00y600d6.jpg)
Image by Stephen R. Marschner
它首先是对与毛发产生的交互光线进行分类,打在毛发表面就被反射的光线被称为 R。打入头发,然后又从头发背面射出的光线被称为 TT。打入头发,在头发内部被反射,又从头发正面透出来的光线被称为 TRT,这里面 R 和 T 分别代表反射和透射。
![](http://dingyue.ws.126.net/2021/1014/81cbc523j00r0yjiw001xd200u000e4g00y900g3.jpg)
Image by Stephen R. Marschner & Limin Tong
光线在头发内部行进的过程中,他的部分能量会被头发的内核吸收,所以说图中的 TT 和 TRT 都会带有头发的颜色,而R就类似于一般的高光,不受头发颜色的影响。
另外,由于头发表面的鳞片与毛发切线形成了一定的角度,这导致出射的 R 和 TRT 的角度产生了一定的偏差。直观地来说,就是他们两个的中心是分离的。
如中间的部分所示,白色的高光是 R,最上面呈现发色的高光就是 TRT,可以看出他们的中心是有一定偏差的。
![](http://dingyue.ws.126.net/2021/1014/0a7fce6dj00r0yjiw001ld200u000bqg00y800dd.jpg)
Image by Stephen R. Marschner
Marschner 的另一项重要贡献,就是将散射光线在毛发横截面和纵截面的能量分布分开建模,这极大的简化了建模的难度,减少了单个分布函数的参数。
![](http://dingyue.ws.126.net/2021/1014/aa2a7882j00r0yjix000ud200u000f3g00vq00fy.jpg)
Image by Cem Yuksel
尽管如此,这个模型的数学计算还是十分复杂的。在 Shader 实现一套比较麻烦,性能也比较低。
![](http://dingyue.ws.126.net/2021/1014/c9ec2e3bj00r0yjiy000yd200u000ecg00xr00g4.jpg)
Image by Cem Yuksel & Stephen R. Marschner
为了提高计算性能,英伟达很早就提出了一种简便的方法,就是把横截面和纵截面出射光的能量分布烘培到两张纹理里面,渲染的时候查询即可。这种方法又被称之为查找表法。图中就是英伟达用这种技术制作的美人鱼 Demo。
![](http://dingyue.ws.126.net/2021/1014/b4da549dj00r0yjiz000td200u0006zg00y3007x.jpg)
Image by Cem Yuksel & Nvidia
![](http://dingyue.ws.126.net/2021/1014/da0e976cj00r0yjiz001jd200u000dpg00y700fl.jpg)
Nalu by Nvidia
但是这个方法有一定的问题,也就是只支持一种头发颜色和一种粗糙度。
受到英伟达思路的启发,我写了一个很简单的光追小程序,从各个方向向毛发发射光线,再在各个方向统计出射光线的能量分布,最后把能量分布存入纹理中。
![](http://dingyue.ws.126.net/2021/1014/b3a35adfj00r0yjj0000ud200u000d2g00zk00fh.jpg)
但是在这个思路的基础上,我又做了两个扩展。第一是我希望美术能够调节粗糙度,所以我将 32 个不同粗糙度的能量分布都烘焙了,排成了一个序列。
第二是在能量分布图中,没有包含毛发中心对光线吸收,而是用了一组新的纹理来记录光线在毛发中行进的平均距离。根据这个距离和美术设定的毛发颜色换算得到的吸收率,就可以对毛发光线能量进行衰减,从而体现不同的毛发颜色。
![](http://dingyue.ws.126.net/2021/1014/bb394133j00r0yjj10024d200u000cgg00zk00eq.jpg)
最终的效果如图,大家可以从图左中观察到 R 和 TRT,可以在图右中观察到明显的 TT。
![](http://dingyue.ws.126.net/2021/1014/67809168j00r0yjj2002md200u000fzg00y900i8.jpg)
![](http://dingyue.ws.126.net/2021/1014/32d2804fp00r0yjj20007d200u0003jg00zk0046.png)
徐老师还分享了多根毛发与光的交互,自研的毛发抗锯齿的方法等精彩内容。欢迎点击“阅读原文”跳转社区专栏学习完整版:
https://developer.unity.cn/projects/hairworks
热门跟贴