内测过程中Shader出现的问题

兜兜转转一年多, 终于再次内测了。

这次在客户端开发中,我们的指导思想是能用GPU做的坚决不用CPU做,除非GPU出现了瓶颈。因此我们大量使用了自定义Shader。

由于我之前其实没有太多Shader的编写经验,这次上线之后暴露了不少实践性问题。


首先遇到的就是精度问题。

在地表渲染过程中, 如果碰到下雨天,我们会在地面湿滑到一定程度之后生成涟漪。

这个功能是直接做在地形Shader中的,与涟漪Bug相关的代码如下:

//ripple.a = 0.4117647
float f1 = frac(ripple.a + _Time.y);

上线之后,我们发现在小米系列手机上,当_Time.y的值大于300之后, f1的值会产生跳变。

经过抓帧之后发现。

_Time.y``300.033``f`等于`0.5019608`, 此时`f`的正确值应该是`0.4447647

_Time.y``300.066`时,`f`的值还是等于`0.5019608`, 此时`f`的正确值应该是`0.4777647

将代码改为如下:

//ripple.a = 0.4117647
float f1 = frac(ripple.a + frac(_Time.y));

_Time.y``300.033``300.066`时,f1的值分别为`0.4431373``0.4784314

与正确值相比,误差分别是0.0016274``0.0006667

这些数值是通过颜色调试法取得,而像素的颜色精度只有1/255(0.0039216), 因此可以认为误差是颜色调试法带来的,而整个计算是精准的。

这说明了高通系列的GPU,其float在计算过程中,要比IEEE 754标准的浮点型精度更低,可能远小于7位有效数字。

这也给我提了一个醒,当我们的Shader需要长时间运行时,一定要注意_Time.y过大之后,在运算过程中会精度丢失的问题。即使GPU完全按照IEEE 754标准来实现,只要运行的时间足够久,也会出现这个问题(比如我们的树,在所有客户端上,只要运行超过4个小时之后,就会静止不动)。

有些情况下,不是简单加一个frac函数就能解决问题的。这时,就需要将与_Time.y相关的数值移到C#中去计算,然后在每一帧的Update中,向Shader设置变量,这么做会有一个额外好处,可以将对_Time.y相关的计算减少到每帧一次。如果在shader中计算_Time.y相关的逻辑,则每一个顶点或像素都需要重新计算一次。


另外一个Bug还是与精度有关,不过是以另一种方式存在。

在世界地图中,如果玩家立国,需要将国家的颜色铺满整个行省,而行省的形状是异形的,如果使用Quad的方式去铺满整个地图,会带来大量的Overdraw。

因此在实现过程中,我们给整个大地图设计了一张IDMap, 每一个像素都会有一个整数ID来代表他所在的行省。

在FragmentShader中,我们采样IDMap之后,并不直接用于渲染,而是将他转换成整数ID,然后使用ID来当索引查询当前行省的颜色。将查询到的颜色用于渲染。

大概代码如下:

fixed4 frag (v2f i) : SV_Target
{
    fixed4 c = tex2D(_MainTex, i.uv);
    int n = clamp(c.a * 255, 0.0, 45.0);
    return _Colors[n];
}

上线之后,我们发现在华为系列手机,这个n会有偏差(安卓系统和鸿蒙系统表现还不太一样),但是在国内其他主流手机,如小米,Oppo上不会出现。

在问题排查过程中,我一度怀疑是精度问题。因此不停地在图片格式上做文章。直到最后我才发现我犯了一些常识性错误。

首先,RGBA32格式的图片是指RGBA的4个通道分别占用一个byte(8bit)来表示一个通道颜色值。

图片文件中,实际存储的颜色值是0~255的整型,而不是0~1的浮点型,也就是说单通道精度最高也只能到1/255。

而我们实际使用过程中n的值只是0~45,远低于1/255,不可能是图片精度问题。

其次,在计算过程中 1/255*255 `的结果实际上并不是`1`而是`0.99999999999975左右。

在Intel、AMD、高通系列芯片上,int a = (int)(1.0 / 255.0 * 255.0), a是会等于1的。

在麒麟系列芯片,a则会等于0,我不能说麒麟系列芯片的精度够或是不够,只能说我写的代码不规范。

这次的教训告诉我,浮点型在不同平台的实现过程中,会有平台相关性。

定位到了问题,修复自然就是一件很简单的事。

int n = clamp(round(c.a * 255), 0.0, 45.0);

或者

int n = clamp(c.a * 255 + 0.0000001, 0.0, 45.0);

都可以解决问题。

linux下调试内存问题

因为linux下没有QQ,刚好我的mac mini在闲置, 于是尝试着在OSX下开发silly

令人奇怪的是在我的linux机器下运行好好的程序, 在osx下只要client对着silly发送几个数据包就会导致silly直接崩溃, 调试半夜未果。

今天早上在地铁上我都甚至开始怀疑osx下的malloc库默认不是线程安全的, 但是地铁上查了一路依然不能确定。

到了公司后, info程序员还没到,我就又寻思着我那个bug, 在centos下又测了一次,依然无果。

不死心看了一下’男人’对malloc怎么说, 无意间看到linux下有一个环境变量叫MALLOC_CHECK_, 可以为下面的值:

0 – 不产生错误信息,也不中止这个程序
1 – 产生错误信息,但是不中止这个程序
2 – 不产生错误信息,但是中止这个程序
3 – 产生错误信息,并中止这个程序

在终端下执行了export MALLOC_CHECK_=1, 然后再次启动silly, 当客户端连接时silly直接报错, 终于找到根源所在。

犯了个低级错误,在向worker发消息会首先使用malloc分配silly_message结构体所占用的空间, 然后再分配silly_socket_message所占用的空间, 在分配silly_socket_message所占的内存空间时我本意是要使用sizeof(*socket)来计算需要使用的内存大小(socket为一个struct silly_socket_message结构体的指针),却因为手误打成了sizeof(socket)。这实际上才分配了8个字节(64位机器),所有针对这地内存的操作实际上都是溢出行为。

由于64位机器上的指针长度要比32位机器上的指针大一倍, struct silly_socket_message仅仅才16个字节,那么在64位机器上溢出的程度要小于32位机器,这也解释了当初没有在意的silly在清风同学的32位VPS机器上很大概率崩溃的现象,其实是因为溢出太多导致。