分享《生死狙击2》的大场景草渲染
- 作者:admin
- /
- 时间:2022年10月25日
- /
- 浏览:2323 次
- /
- 分类:厚积薄发
重写引擎的草渲染的缘由
1. 效果图
2. 为什么决定重写引擎的草渲染
项目立项较早引擎版本为Unity 5.6,这里讨论Terrain自带草的问题,以及我一一对应的解决方案。以下的问题不是每个都必须重写渲染机制才能解决,但主要的几个需求就决定重写了。
- 法线问题,不带Mesh法线,用的是地表法线作为渲染法线(因为Mesh或Billboard都会用插片来做,不传法线贴图,面数限制下不可能正确表达草叶子的真实法线方向,这种模式可以基本保证光照不发黑,地表基本处于可以被阳光找到的半球)。
- 动态Mesh问题,一般选Collect Detail Patches运行时CPU动态创建一堆Mesh,即有顶点数上限,又会引起卡顿(引擎源码修改成异步可以减少卡顿但不减少计算量会发热,PC发热则让一些机器CPU降频)。
- Hi-Z低效问题,动态创建一堆Mesh。无法最高程度剔除每个不渲染的草,不能最大发挥Hi-Z剔除功能。
- PBR材质问题,因为没有Smooth贴图无法实现PBR渲染,导致材质效果非常差(这里单独传图也是可以实现)。
- 淡入淡出问题,因为不支持淡入淡出,所以120米外草的消失与出现无法做到PUBG那样的抖动半透明过渡。
- LOD问题,自带的草不支持LOD,改引擎也不好实现,因为合并Mesh渲染的,所以无法逐棵切换LOD。
- Shadowmask问题,延迟渲染下,草会获取地形的Shadowmask进行计算到G-buffer的RT4,这里可以省略优化。
- Shadowmap问题,草无法投递阴影。
- AO问题,SSAO固然可以增加一点草的AO,但那个精度表现的效果不明显且效果不好。
- 草的生长方向问题,植被虽然是应该反重力生长,但游戏中的草一片表达的是一小堆草,当这些草很矮时,希望草的成长方向按地表方向生长,类似苔藓。这样做可以有效避免底部与地形切出一条直线的问题,看起来更贴合地面。
- Mipmap问题,草在远处,常规的Mipmap计算会导致剔除过多而提前消失。应该使用距离场贴图。
- PreZ效率问题,PreZ阶段只需要透明度信息,采样因Albedo精度需要而创建贴图性能不是最省,需要独立的Alpha图。
解决方案
1. 法线方案
下图中上半部分是Unity法线策略,导致画面较平,下半部分是半球Mesh法线。树和草经常会用半球法线(原理不再赘述),直接用Mesh法线需要较细致的建模或法线贴图并实现双面渲染的带简单透射的光照逻辑(即法线取反再算一次亮度)。因为我是采样GPU Instance绘制,所以顶点自带了法线,如果要沿用Terrain的渲染草方式,可以把草顶点组成Buffer传Shader,然后根据VertexID来取。
近处需要法线是Mesh法线,远处需要用地形法线作为草的法线,这样可以让草与底部在远处更融为一体,所以需要对Normal做过渡。
2. Mesh与Hi-Z方案
Mesh采用ComputeShader+GPU Instance绘制,每帧给ComputeShader传入周围7x7虚拟网格草的数据(一个格子40米,这样可以做到120米都有草),只传发生变化的格子,一帧传一个格子。ComputeShader每帧对这些小范围的草进行距离裁剪、LOD判断、视锥裁剪与Hi-Z裁剪。实现最大程度剔除。这里具体做法有之前文章介绍。
《Compute Shader 进阶应用:结合Hi-Z 剔除海量草渲染》
3. PBR材质方案
因为草金属度都为0,所以只要传Smooth贴图,把它放在Albedo的a通道,因为它总是和 Albedo同一个Pass被访问。而且只有 Alpha>0像素的Smooth数据才有意义,而草一般用Alphatest,所以需要的透明度只要不是0即可。所以可以压缩在同一个a通道,后面提到a会单独存储。传了Smooth因为延迟渲染是统一的后期Lighting,所以这里不需要写光照,只需要在dot(normal,lightDir)<0的地方加一点自发光模拟背光面亮度避免发黑。
下图中上半部分是Unity的草统一Smooth,下半部分是Smooth贴图在a通道,效果对比不是特别明显。这里可以去看开头美术调的PBR效果图。
4. 淡入淡出方案
仔细观察其他游戏效果,发现用抖动半透明(Dithering Alphatest) 可以很好地实现草的出现与消失效果,而Unity自带的Alphatest也会因为距离越远裁剪面积比就越大。扣除边缘比例增加来做的消失,就类似缩放,效果肯定不如挖一堆小孔(Dithering )好,Dithering的变化更均匀,但是在性能上小小取胜,建议草的可视距离在100米内的采用Dithering ,反之太远了看不清可用Unity这套。但有一种效果与性能平衡做法可以对Clip参数做个根据距离变化的函数。比如:
clip(a - lerp(_cutout, 1, smoothstep(hzbGrassDistance - 5, hzbGrassDistance, disCmr))); 代https://blog.uwa4d.com/admin/write-post.php#wmd-preview替简单clip(a-_cutout).
对比效果:
这是引擎直接Clip效果
会出现一条明显切线
这是模仿PUBG罗马之子等抖动半透明效果
停止时候可看到纱窗效应
这是性能更好利用距离动态计算Clip值的结果
但透明区域先消失,常常是顶部先消失,所以效果不如抖动半透明
抖动半透明淡入淡出算法
5. Shadowmask方案
这个版本的GPU Instance是不带Shadowmask的,草也是不参与烘焙的,因为它的Lightmap、Shadowmask和贴着的地表非常一致,直接取对应地表的这2个图采样即可。但是这一步可以简化算法,不做任何采样。延迟渲染的Shadowmask逻辑是这样的,烘焙的对象会采样Shadowmask图,并记录屏幕空间的Shadowmask到G-buffer的RT4,表示自己是否在阴影里,动态对象如果没Lightprobe,会绘制白色表示自己不在任何阴影里(因为没通过烘焙提前计算好,系统不知道其在不在阴影里,所以一律忽略阴影),但如果存在Lightprobe,那么会根据附近的Probe是否在阴影里来插值计算。同样写到RT4。如果在项目中用到Bakey烘焙,那么关于是否让Probe烘焙遮挡信息是可选的,若不勾上就会丢失Mask数据。
接下来用案例解释:
上面这张是简单烘焙的场景,带了Lightmap、Shadowmask和Lightprobe。它们是怎么表达自己在阴影里的呢?
1.渲染完底板、墙壁和小方块这3个静态物体,它们会读取烘焙的Shadowmask来写到RT4 如下图,我们只有一个带Shadowmask烘焙的光所以只看红色通道。这里表达很清楚,黑色为阴影内区域。
2.渲染小球,把它想成一堆草贴地摆放(下图),可以看到是青色(这里只讨论红色通道),截图可知是0,这套就是Unity对草的Shadowmask计算逻辑。
3.也许你会发现一个问题:草可不可以不去写RT4呢?让地表黑色的区域依然保持黑色就好,最后屏幕空间计算光照时,反正都是要用平行光计算光照前获取这个像素的Mask值当作Atten用,当参与Shadowmask烘焙的只有一个平行光的时候,青色和黑色就完全是一个结果。或者说,不需要知道草绘制哪些区域,如果绘制了,取草背后(绝大部分都是地表)的Mask作为自己是否在阴影里的判断。这样对于海量的草即使少了这个采样依然有肉眼可见的提升的。
4.那么这样省略有没有问题?当然有。Unity不会白白多计算一次的,它需要考虑完全正确性,所以性能不敢省,我们自己的项目可以根据实际情况取舍。这里有1个小问题,当我们从侧面看上坡上草的时候,草的背景是天空或远方就有问题了。这时候它的后面直接用的Mask不再是地表了,会让明明在阴影里的草显得和阳光下一样亮。如下图红框内的球体部分,如果取后面的白色,表示不在阴影里,如果自己采样计算并写入RT4,就是青色(红色为0)表示在阴影里。但是Shadowmask发生在实时阴影外,少说也有70到120米,所以那时候你理解为侧面的草的透光也已经非常不明显了,需要看项目接受程度。
5.假如你接受了这点瑕疵,避免了Shadowmask的采样开销,那么要如何不写RT4呢?frag/vert 模式的Shader很简单,把out float4 RT4的写入直接注释掉即可。如果是surface shader则更简单,#pragmasurface surfaceFunction lightModel noshadowmask 即可。
6.展开聊一聊Shadowmask,Unity的Shadowmask有个很有趣的特点,它是一个超级宅男,可以轻易地让3年、5年、7年、10年Unity程序、主程序、甚至TA都认不全它。先说什么叫Mask,一般渲染里能够直接查图就知道自己是不是某个属性,或是多少这种属性的叫Mask,所以Shadowmask表达是不是在阴影里。而Shadowmap不是记录是否在阴影里,而是记录相机空间深度,得到深度不是表达是否在阴影里所以并不算Mask。
关于Unity 5.6,我所知道的至少有4种Shadowmask,有学习遗漏的同学可以根据这个查询具体内容,补上知识空缺。
- 烘焙出静态物体Shadowmask贴图
- 烘焙出给动态物体用的,利用Lightprobe记录Shadowmask插值信息
- 平行光的Shadowmap单独转换的Screenspaceshadowmask
- G-buffer中RT4 全屏动态静态都参与计算的Shadowmask
以下是支持Shadowmask前后对比:
没有Shadowmask支持的草在实时阴影距离外效果
实现Shadowmask支持的草在实时阴影距离外的效果
6. Shadowmap与Contact Shadow方案
因为用了GPU Instance来绘制草,这里比较简单,可以让Graphics.DrawMeshInstancedIndirect 分3次调用:
- 30米内的Instance,选CastShadow为true的参数,提交MeshLOD0。
- 30米外LOD Distance内的Instance,选CastShadow为false的参数,提交MeshLOD0。
- LOD Distance外的Instance,选CastShadow为false的参数,提交MeshLOD1。
以下是开启阴影与否的对比图,一般做个阴影浓度越远越衰减为好,衰减方式可以是ShadowCast pass里Clip值的变化。
Unity引擎不支持投递阴影的效果
自己实现Shadowmap后效果
Shadowmap是用来实现对地表的投影,但因为原理与精度的限制,用来表现细小叶片的自阴影效果并不好,这方面可采用ContactShadow弥补。而关于ContactShadwo可参考:
《超越育碧品质的接触阴影(contact shadow)》
Shadowmap+ContactShadow效果
7. AO方案
草的AO方案很少被提及,前面提到SSAO/HBAO+不能精细表达好草和周围地表的AO及其它颜色变化。长草的土与不长草的土,每天受到的日照并不同,所以它们的颜色或细微植被情况也不同。而这不适合做在地表贴图上,因为同一种地表有部分长草、有部分不长草。所以为了这个区分,同时为了模拟光线进入草堆后各种衰减的模拟,单个阳光对植被本身的衰减可以用植被的半球法线简单模拟,但这份衰减也应该让地表变暗。这个计算就往往被丢弃了。想了一种简单的做法,就是离线统计草的密度,根据不同密度生成一张地表AO图。类似另一张Shadowmask,但是不同于Shadowmask,它在实时阴影范围内也有效果。为了明显点,把参数调夸张后效果如下图:
根据草密度高度换算成光线衰减程度的图并在PS里加个高斯模糊
8. 草的生长方向方案
草的生长方向前面提到需要根据植被来单独设计,是偏向重力反方向;还是偏向地表方向。一般瘦高的(多数单子叶植物)适合反重力方向生成,低矮的或根茎面积大的(多为双子叶植物)适合贴地表生长。用叉乘来计算:
重力权重为1,植被up方向与引擎默认相同
重力权重为0,采用地表方向为植被up方向
9. Mipmap与PreZ方案
Unity的Alphatest的Mipmap可能为了降噪默认有个问题越远剔除越多,导致SpeedTree官方给的资源都不得不对每个LOD设置不同材质球,就为了单独设置不同距离的Cutout的数值。这里有我早期提到的一些解决办法《解决AlphaTest 远处消失问题》。
但是对于草,这样却有个意外收获,即会有简单版的淡入淡出,但是为了更好是变化效果,最好还是自己控制应该的LOD或自己计算动态的Clip值。淡入淡出里,提到后者的做法这里不重复了。
PreZ是什么可以简单理解为强制版显式的Early Z。在引擎改造过程中,我们会强制渲染一遍深度,然后在绘制Opaque的对象时,采用ZTest Equal来绘制 G-buffer,所以Clip的过程只发生在PreZ的Pass。这个Pass只需要透明度不关注颜色。所以我们把透明度单独存一张图,为什么单独出来能提高性能呢?因为我们可以让这张图尺寸比Albedo小。为什么缩小却不会严重失真呢?因为我会用半透明的有向距离场SDF数据代替半透明数据来存放,如果了解距离场会知道它是更精确的表达,不是它原始数据精确而是对插值更精确,所以 Mipmaps采样时效果更好,且允许用更小的图。
额外小技巧
根部的透明度,人为故意擦除一些,仿佛断了一些。这样视觉错觉会让玩家脑补草的前后关系,否则都在一个面上很生硬。
这是美术制作给到我的草,根部齐平很难看
这是我手动擦除了些根部产生的立体感
当贴近朝上看的时候需要慢慢下降一点,避免浮空位置穿帮
以上是整个项目的开发过程中积累到的关于草的总结,希望对其他人有部分启发。
这是侑虎科技第1229篇文章,感谢作者偶尔不帅供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.zhihu.com/people/jackie-93-85-85
再次感谢偶尔不帅的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)