今天跟大家分享下UE4手游如何渲染与优化环境反射?!本文介绍了四个小主题,分别是UE4 Mobile端的Skylight和ReflectionCapture之间的关系,如何让ReflectionCapture采集天光,ReflectionCapture的亮度校正算法分析及在期在移动端可能的优化。值得一提的是ReflectionCapture IBL的压缩是UE4.26中已被实现。
这篇文章是UE4 反射球系列文章的完结篇,内容包含以下几个部分:
1. SkyLight和Reflection Capture的Cubemap有何不同?
2. Reflection Capture为何采集不到天空球Mesh?
3. Reflection Capture的亮度是如何确定的?
4. 对移动端来说,Skylight/Reflection Capture有哪些可用的优化?
一、SkyLight和Reflectioin Capture
SkyLight所提供的是直接光照,同时包含漫反射和高光反射,而Relfection Capture提供的是环境反射,只有间接高光且不包含漫反射。严格来说,这两部分光照信息的频度定位不同,是叠加关系,不能放一起比较。
凡事都有意外,UE4在移动端的标准光照模型中SkyLight Cubemap和Reflection Capture Cubemap是互斥的——要么存在的是Skylight,要么存在的是Reflection Capture,且在选择时Relfection Capture的优先级高于Skylight。不光如此,移动端的Relfection Capture的范围也无效。因为两者的互斥关系移动端PBR渲染过程只需要采样一次cubemap,相比多次采样cubemap更廉价,能耗更低。
但同时这些做法也带来了一些问题:
1. 场景中同时存在Reflection Capture&Skylight时,因为Reflection Capture优先级高且范围无效,永远只可能Reflection Capture有效。
2. 相对于非移动端来说,移动端的场景中IBL所提供的光照往往会来得更暗一些。
3. 因为Reflection Capture的范围无效,物体在渲染时只取离它最近的那一个,也导致在场景制作过程中,需要区分室内室外,楼上楼下,多变的环境氛围时工作流几乎不可能实现,因为无法精确控制范围。这个问题对于想要制作高品质游戏场景来说,说致命并不为过。
二、Reflection Capture 为何采集不到天空球
在场景和TA的强烈要求下,我们在移动端修复了Reflection Capture的作用范围,想要在移动端让范围起作用,只需要在FScene::FindClosestReflectionCapture里查找和物体包围盒相交的的Reflection Capture并处理好Mobilebasepass的ShaderBinding参数设定即可。
这个问题修复之后,TA很快又发现两个新的问题:
1. 同样的一样IBL图,当它作为Skylight输入存在时比作为Reflection Capture输入时对亮度的贡献大很多。
2. 放在室外的Reflection Capture,上半部分是黑的,一查原来是采集不到Stationary的天空球。
第一个问题留到第三部分去说,第二个Reflection Capture采集不到天空球的问题,则是因为Skylight有一个选项用来控制天空球的Threshold。
这个选项的直接意思是:距离原点多远之后的场景物体属于天空球,这个默认值是1500米,即1500米以外的所有物体,都属于天空。因为UE4在非移动端的实现中Skylight和Reflection Capture相互叠加,且当Reflection Capture和SkyLight同时可见时,优先选用的是Skylight的部分。这样的话,在存在Skylight的室外,采集ReflectionCapture时确实不需要采集和存储天空球。
这个设计初衷所带来的问题在于:你要是移动端的话,你就完蛋了——你室外的光滑物体和纯金属,上半球一片黑。黑夜给了你的黑色眼睛,是你看到了自己心理的阴影?
我们来看看UE4在采集Reflection Capture时,是如何丢掉天空球信息的。
1. 实现代码在ReflectionEnvironmentShader.usf 的CopySceneColorToCubeFaceColorPS函数中,代码如下所示:
这段代码的解释为:当当前采样到的位置距离< 0.8 * threshold时,IBL的Alpha值为1,否则小于1,按1-Smoothstep曲线(似乎看到的实现,大多数SmoothStep都是三次曲线)方式趋向于0,当距离大于等于threshold时,Alpha必然为0,Alpha 值会被写入Cubemap的Alpha通道。
2. SkyLightParametersValue参数传递的入口在ReflectionEnvironmentCapture.cpp中的FCopySceneColorToCubeFacePS类的SetParameters函数中,代码如下:
可以看到这儿的SkyLightParametersValue.x即为 Skylight上的SkyDistance Threshold值。
3. 在FilterReflectionEnvironment函数中一开始执行一次premultiply alpha,这时alpha值同会乘以rgb值,所以会造成最终的cubemap中alpha为0的值也变成了全黑色,代码如下:
即:Color = 0 * srcColor + destAlpha * destColor
经此一步之后,不管是移动端还是非移动端,其生成的Cubemap中被判为天空的部分已经全部为0,所以在反射球为移动端存储编码为RGBM时,早已没有了这部分颜色信息……
三、移动端的IBL亮度计算
上文说到使用同一张Cubemap作为Skylight和Reflection Capture输入时,得到的结果亮度不一致,于是乎TA提了一个BUG单。
没过多久,场景美术发现无论是接受CSM实时阴影或是烘焙的ShadowMask,金属物体上的阴影总是比非金属上的阴影来得更黑一些,于是乎场景美术不光提了一个BUG单,还抱怨说UE4怎么这么多乱七八糟的问题,比隔壁另一个U字头的引擎还不如?
Skylight和Reflection Capture亮度不一样的问题,是因为Skylight的cubemap在渲染时直接使用的是cubemap的原始亮度,而Reflection Capture在渲染时亮度经过了缩放(大部分时候,这个缩放值都是小数,也就是说:它都会变暗)。具体的实现在MobileBasePassPixelShader.usf的GetImageBasedReflectionLighting函数中。
可以看到在Reflection Capture的情况下,SpecularIBL会乘以缩放值。这个值是通过MobileComputeMixingWeight函数计算出来的——如果你用的UE4版本在4.23之前,那么这个函数是不带Mobile的ComputeMixingWeight,Mobile版本只是简单的把ComputeMixingWeight的所有数据类型,由float改为了half*,算法完全一致。
接下来我们详细拆一拆MobileComputeMixingWeight的算法,先看看这函数的全貌(因为源代码中的注释有很强的误导性,所以注释去被我去掉了,同时我也简化了一下代码的布局)。
算法分为这几步:
1. 计算一个0~1之间的MIxingAlpha值。
这个值由ReflectionEnvironmentRoughnessMixingScaleBiasAndLargestWeight的x、y及当前像素的Roughness值来确定。Ref*Weight参数的计算过程。
写成数学公式:
x = 1.0/(b-a) ,b为结束Roughness值,a为开始Roughness值
y = – a / (b-a)
把上述x,y代入到MobileComputeMixingWeight中的MixingAlpha式中得
MixingAlpha
= smoothstep(0 ,1 , Roughness/(b-a) – a/(b-a)
= smoothstep(0 , 1 , (Roughness – a)/(b-a))
可以看到MixingAlpha值和Roughness值成正比,Roughness越大,则MixingAlpha值也越大,最大值不超过1(staturate所限)。
2. 计算MixingWeight(Normalized Cubemap)
这一个变量的命名和原始的注释非常迷惑,按主流的说法,它该叫Normalized Cubemap,其作用是把当前IBL的间接漫反射亮度缩放到和从Lightmap或SH(ILC)中接收到的间接GI亮度一致。indirect_irradiance来源是物体当前像素的光照图的亮度(静态物体)或ILC的亮度(动态物体)。
算法简化成公式如下:
normalized_scale = indirect_irradiance / ibl_average_brightness
一般的最终specular_IBL计算公式(UE4不完全一样,见步骤3说明):
specular_IBL = sampled_cube * normalized_scale
其中ibl_average_brightness来源是当前经过卷积后的cubemap所计算出来的平均亮度,即roughness为1时所采的这张1*1的最小mipmap的亮度。
UE4中计算该IBL亮度时不是使用普通的亮度计算公式,而是使rgb的贡献平均化。
ibl_average_brightness = dot(color.rgb ,float3(0.333,0.333,0.333)
UE4的亮度计算之所以使用均值,是因为如果使用标准的亮度计算公式,无法处理一些特殊的IBL边界情形:当IBL中只存在蓝色时,亮度会非常小,而当IBL中只存在绿色时,亮度又会非常大。但这么做却也并不是最优的选择,我们将在第四部分进行说明。
3. 插值得到最终的缩放系数
注意到步骤1中结论:Roughness值越大,MixingAlpha越大,当Roughness大于等于结束Roughness时,缩放系数完全等于步骤2中计算出来的normalized_scale;当roughness小于等于开始Roughnes时,缩放系数等于1; 缩放系数其它情形下处于[1,normalized_scale]之间。
由于在大部分情形下,normalized_scale小于1,所以也就可以看到一个现象:越光滑的物体,反射球对它的影响就越强,越粗糙的物体,反射球对它的亮度影响就越弱。
至于场景美术所提第二个问题——“金属物体上的阴影总是比非金属上的阴影来得更黑”,经查是TA做了一个很不PBR的材质规范:非金属材质没有AO通道,AO图直接乘到了BaseColor上;金属材质留有材质AO通道输入了AO图,BaseColor上不带光照和遮挡信息。由于材质AO会同时作用于BaseColor和间接光照的亮度(indirect_irradiance *= AO),所以金属物体的IBL缩放值会更小,从而更黑。
四、UE4移动端IBL的可用优化
1. 压缩格式 :UE4移动端的Skylight cubemap是用的float原生的图,既然定位等同于Reflection Capture,可以考虑使用RGBM的方式同样的压一压?Reflection Capture的RGBM在Cook时也不接受ASTC/ETC/PVR方式的压缩,要不要统一压成硬件支持的格式?(UE4.26 Preview中,移动端已实现Reflection Capture的Cubemap压缩,压缩格式为ETC2)
2. Cubemap转2D纹理:到ES3.1都不支持CubemapArray,故IBL的变化会导致动态Instance失效,是否可以一下把 Cubemap转为经纬图或椭球纹理,从而使用TextureArray+Custom PrimitiveData进行动态Instance合批的最大化,从而有效的降低Drawcall ?转为2D纹理有2个小风险,经纬图的纹理浪费比cubemap大概有30%左右,有同事在小米6/小米9实测,相对于Cubemap,经纬图纹理采样的CacheMiss也更高。
3.算法优化/效果优化:UE4的IBL Normalized算法使用的是标量的ibl_average_brightness和标量的indirect_irradiance来计算,这种方式计算所带来的问题:
在未开启Lightmap方向性的情况下,lightmap的亮度只会计算上半图的亮度,而ILC中的亮度可能来自于任意方向,这可能会给使用ILC和Lightmap的物体带来不同的IBL亮度。
由于ibl_average_brightness和indirect_irradiance两部分的亮度计算公式不一致,normalized的效果可能会因间接GI的颜色而使IBL反射出来的亮度不一致。
由于ibl_average_brightness来源于cubemap全球面的总和,而indirect_irradiance只可能来源于当前像素法向上半球的输入,故其亮度normalized的结果并不会使两者的亮度相等,而可能会导致IBL未能表现出应有的亮度,同时也会降低IBL的方向性。
MobileComputeMixingWeight的MixingWeight计算代码实现上未防止越界~~
COD黑色行动和战神4中的IBL Normalize使用的是3阶SH。
本文来源:知乎专栏“图形游戏和宅”
文 | Jiff