最近发现移动平台上经常出现不明黑色色块,像是除零错误。排查之后定位到GGX函数上:
inline half GGXTerm(half NdotH, half roughness)
{
half a = roughness * roughness;
half a2 = a * a;
half d = NdotH * NdotH * (a2 - 1.0f) + 1.0f;
return UNITY_INV_PI * (a2 / (d * d));
}
果然roughness为0的时候d就可能为0,继而出现除零错误。同样代码PC上就不会出现肉眼可见的问题,可能是tile中一个像素出错会导致整个tile都输出黑色,看来mobile gpu真的很敏感。看起来只要限制最小粗糙度*或者在除数上附加一个简单的小数就能修复这个问题了:
inline half GGXTerm(half NdotH, half roughness)
{
half a = roughness * roughness;
half a2 = a * a;
half d = NdotH * NdotH * (a2 - 1.0f) + 1.0f;
return UNITY_INV_PI * (a2 / (d * d + EPSILON));
}
意料之外的是:真机测试上,即使EPSILON大到0.01,仍然有除零错误。再看一遍代码,作为除数的d竟然为粗糙度的8次方,这对精度提出了相当的要求。是否移动平台上half的精度和pc上有巨大差异呢?
上网搜索一番,发现已经有人严谨的研究过这个问题,并且制作了有趣的试验,让精度问题能以图形化的方式在不同设备上可视化(见参考资料)。文章中提到了三个影响精度的因素:
1)有效位数
2)取整方式(RTZ和RNE,后者提供双倍精度)
3)0洞和对Subnormal(低于最小精度)的支持
在PC和mobile上重新进行Tom Olson的试验发现:
PC对half和float的表示是一致的,都是8位指数,23位有效位;
mobile上的float是23位有效位,half是5位指数,10位有效位。
平台 | 取整方式 | Subnormal |
PC(GTX1080) | RTZ | 支持 |
红米3s(Adreno 430) | RTZ | 不支持 |
荣耀5C(Mali T830MP2) | RTZ | 支持 |
Iphone5s | RNE | 支持 |
Iphone6 | RNE | 不支持 |
苹果竟然开倒车,5s还是实行高标准的RNE+Subnormal,6竟然不支持Subnormal了。
指定在计算过程中使用float可以避免这个问题**,如下所示。
inline half NN4_GGXTerm(half NdotH, half roughness)
{
float a = roughness * roughness;
float a2 = a * a;
float d = NdotH * NdotH * (a2 - 1.0f) + 1.0f;
return (half)(a2 / (d * d));
}
附注:
*
不支持Subnormal的half能支持的最小粗糙度数值:
5位指数位,可以表示的范围是[-14,15].
按d=roughness8计算,d的最小精度为2-14. 粗糙度的最小值为0.2973.而测试结果表明可以支持比这低得多的数值。
再看一遍代码注意到:
half a2 = a * a;
half d = NdotH * NdotH * (a2 - 1.0f) + 1.0f;
return UNITY_INV_PI * (a2 / (d * d));
其实可以先计算a/d,然后再平方。a/d的最小精度为2-14,计算出粗糙度的最小值为2-14/4=0.08838835,与测试结果完全吻合。
注意!
如果使用
return UNITY_INV_PI * (a2 / (d * d + EPSILON));
这种优化形式,编译器就无法先算a/d,再平方了。也就大大限制了粗糙度的能取的最小值。
**
事实上,即使使用float计算ggx,对normal的精度要求也是16位的half无法满足的。如果在计算ggx时使用32位float,但normal和view还是16位精度,某些平台会出现下图这样的噪点,完全无法表现ggx的高光形状。
***
写到这里的时候我已经把distribution项换成blinn-phong了。还有ggx anisotropic也换成了ward。没办法ggx的高光形状在粗糙度很小时非常敏感,美术经常想调到0.1以下。还有某些奇葩的平台要兼容呢(说的就是你,红米3s)。还有还有据说移动平台上float转half挺费的。
****
补充:D term的输出结果也要限制一个最大值。不然在hdr模式下可能非常大,然后toonmapping里一算倒数就进0洞了。。。