内测过程中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);

都可以解决问题。

又一个类型提升引起的Bug

在好几年前我已经中招过一次了, 没想到最近一不留神又中招一次。不过这次的花样和上次又不太一样。

Bug的起因是,我需要一个函数,根据指定速度(可能不是整数)和距离来获取到达目的点的时间,于是就有了下面这样一段代码。

//#define TIME time(NULL)
#define TIME 1526796356
time_t foo(int distance, float speed)
{
        return TIME + distance / speed;
}
int main()
{
        printf("%ld\n", foo(30, 3.0f));
}

咋一看这代码几乎没毛病,严格遵循牛顿大神给出来的公式来算。

然而他的输出却是`1526796416`这样一个值。在思考了将近20分钟之后,我才恍然大悟。

根据《The C Programming Language》第二版中的A6.5节算术转换一节的内容

`
First, if either operand is long double, the other is converted to long double. Otherwise, if either operand is double, the other is converted to double.

Otherwise, if either operand is float, the other is converted to float.

Otherwise, the integral promotions are performed on’ both operands; then, if either operand is unsigned long int, the other is converted to unsigned long int

Otherwise, if one operand is long int and the other is unsigned int, the effect depends on whether a long int can represent all values of an unsigned int; if so, the unsigned int operand is converted to long int; if not, both are converted to unsigned long int

Otherwise, if one operand is long int, the other is converted to long int

Otherwise, if either operand is unsigned int, the other is converted to unsigned int

Otherwise, both operands have type int

There are two changes here. First, arithmetic on float operands may be done in single precision, rather than double; the first edition specified that all floating arithmetic was double precision. Second, shorter unsigned types, when combined with a larger signed type, do not propagate the unsigned property to the result type; in the first edition, the unsigned always dominated. The new rules are slightly more complicated, but reduce somewhat the surprises that may occur when an unsigned quantity meets signed. Unexpected results may still occur when an unsigned expression is compared to a signed expression of the same size.
`

代码`return TIME + distance / speed`在实际执行时会被转换成`return (float)TIME + (float)distance / speed`来执行。

之所以思考了那么久才发现问题,是因为在写代码时就知道编译器会进行类型提升。而从理论上来讲,编译器在进行数学运算时,总是会向最大值更大的类型进行转换,以保证隐式转换的正确性。所以首先就把这种错误是类型提升造成的可能给排除了。

这种理论本身也是正确的,因为float的最大值为`340282346638528859811704183484516925440`,可是我忽略了一个很重要的事实,就是float即然叫单精度,就说明它本身是有精度的。


来看一下float的构成.


Float example.svg

float由1个符号位,8个指数位和23位尾数位构成,而精度完全是由23位尾数控制的,因此虽然float可以表示很大,但那是通过指数位进行跳跃(乘以2的N次方)来得到了,换句话说,float能表示的数其实是不连续的,而我们现在的time(NULL)值`1526796356(0101 1011 0000 0001 0000 0100 0100b)`已经使用了31位二进制了,它的二进制有效数字位为29位,已经超出了float中的尾数位。因此在int转向float时必然会丢失精度。

弄清楚了问题之后,只需要加个类型强制转换就可以解决问题了:

//#define TIME time(NULL)
#define TIME 1526796356
time_t foo(int distance, float speed)
{
        return TIME + (time_t)(distance / speed);
}

BTW. 在查阅文档时,发现了一个有意思的事,《The C Program Language》第二版 在算术类型转换一节中特意标注,在第一版中,所有的float类型算术运算都是先转换成double再进行,而第二版的描述是float类型算术运算‘有可能’直接进行,而不是转换成double之后再进行。

当class遇上union

今天同事又踩到一个以前设计时留下的坑,这次是关于union和class中的。 虽然这种设计我并不认同, 但是至少我觉得设计者对于c++的成员内存布局相当了解。

由于面向对象的存在, 在代码中常常有这样一种用于存储属性的类,类A,类B, 类C,类B继承自类A,类C继承自类B。 而类A, 类B, 类C等这些类的实例都是从socket层传过来的。

作者在设计时为了代码的复用性, 采用了如下设计:

union object {
class A a;
class B b;
class C c;
};

//read_objectX_from_socket函数为伪码, 其实现为逐个读出某个类的成员, 至于这个函数为什么会是这样实现, 这是socket层上的另一个设计问题了, 暂且不谈

void readA(union object &o)
{
read_objectA_from_socket(o.a);
}

void readB(union object &o)
{
readA(o);
read_objectB_from_socket(o.b);
}

void readC(union object &o)
{
readA(o);
readB(o);
read_objectC_from_socket(o.c);
}

从上面看出作者对于C++中的成员变量的内存布局相当有信心, 才会想到使用union的方式来复用代码。

假设这三个类的成员定义如下:class A {int a;}, class B : public A {int b;}, class C : public B {int c;}.

那么此union中的内存布局其实就是A::a, B::b, C::c。
object::a所占的空间就是A::a在union中所占的内存
object::b所占的空间就是A::a和B::b在union中所占的内存
object::c所占的空间就是A::a和B::b和B::c在union中所占的内存

设计者巧妙的利用了union的重叠特性和class的继承特性来完成了代码复用。

大约在上学的时候我也喜欢去hack内存布局(当然没有这种用法这么巧妙), 后来我便渐渐不大喜欢这种做法了.

因为这种代码虽然写起来有种炫技的自豪感, 但事实上一旦出了bug是极难发现的, 人类在汇编语言基础之上又发明了高级语言, 我想也正是因为他们觉得人们需要更多的规则来帮人们减少出错的可能性, 所以我后来便一直主张写出更多可以让编译器检查出错误的代码。


同事踩的坑也正验证了hack内存布局易错不易查的事实。 由于某种偷懒原因, 他实现了class D : public B {int d;}, class F : public C, public D { int f;}。而readF的实现代码如下:

void readF(union object &o)
{
readA(o);
readB(o);
readC(o);
readD(o);

read_objectF_from_socket(o.c);
}

在实现readD时代码看起来依然正常运行, 但是在实现readF时, 明明看到有数据读入,但是类F中继承自C和D的成员总是莫名其妙乱掉(当然这不是我发现的, 这个bug只是我事后知道的罢了)。

此时重新看一下union, 那么C::c和D::d占用的是同一片内存, 那么其实在readF中调用readC和readD时, 覆盖的总是同一块内存。

再看class F的内存布局应该是A::a, b::b, C::c, D::d, f, 也就是说整个readF执行下来, 其实F::D::d这个变量从来就没被操作过, 也就不可能赋值, 由于栈中的随机数, 所以F::D::d这个变量也就变得随机了, 由于object::C::c所占的内存总是会被readC和readD同时操作。因此看上去数据也不那么的有迹可寻。


当第一眼看到这种设计时, 虽然觉得不妥, 但是我并没有找到一种可以不通过hack内存来达到最大代码复用的方式。

在下班回来的路上, 我总觉得应该可以不通过hack内存来达到同样的目的, 终于在快到住的地方时被我想到的了。 其实很简单, 之所以想不到一方面是我不常用C++, 另一方面大概是之前看了这段代码, 一时间先入为主, 思维没有缓过劲来。

其实只需要按照C++最常规的dynamic_cast就可以完成代码的最大复用。

代码接口大概实现如下:


void readA(class A *a)
{
//read a members
}

....

void readD(class F *a)
{
readA(a);
readB(a);
//read D members
}

由于是dynamic_cast, 因此编译器可以帮我们自动去计算每个成员的偏移量, 避免了手动hack可能出现的各种错误。

btw,使用dynamic_cast的方式仅仅能依靠编译器发现这种hack内存时容易出现的bug。 在碰到类似class F这种使用多重继承机制的类时,编译器仅仅会报语法错误,并不能对其父类的readX函数进行复用。

权限问题引发的bug

大清早还在睡着, 被电话叫出来说软件出问题了T_T, 说软件不能解析某个文件.

软件挑文件这毛病还从没遇到过, 先把管理员权限, 文件路径是否存在等问题全部确认后没有发现异常.

万般无奈情况下, 荒谬的猜测代码打开文件时要求了写权限(如:fopen(file_path, “rb+”)), 而这个文件恰好被设置了只读属性, 会导致打开失败, 打开代码一看果然如此, 使用CreateFile, 但是却带有GENERIC_WRITE权限的要求.


发一下牢骚 🙂

这其实是一个典型的权限问题, 在此之前曾经碰到过, 在Win7以上系统下看不到网络驱动器, 后来发现在Win7以上Admin权限是不显示网络驱动器的, 这也是Windows自身提供的一种保护机制.

因此在对于与权限有关的代码上要格外小心, 如果打开一个文件只需要读, 那么你就以只读打开就好了, 不必用读写或者以不存在就创建方式来打开. 对于只读数据场合来讲, 在你要了写权限之后就有一定风险去窜改, 这是一件极度危险的事.

说到权限就再扯两句.
目前软件的文件浏览是client通过socket远程查询server那台电脑上的所有文件, 然后以自定义的文件浏览框来显示, vincnet曾指出在以windows下的domain的组网方式中, 有些domain访问是要输入特别的帐号与密码的, 以这种C/S的方式去加载文件必然不能解决domain的帐号密码问题, 事实上, 如一些大的生产厂商正是使用此种方式将烧录文件放在domain服务器上, OP人员禁止私自将烧录文件copy到本机电脑的, 如果需要烧录某文件, 必须使用网络驱动器直接加载. 目前他们还是手动copy到本地再烧录的, 这势必会成为另一个大坑.

其实也并非没有解决方案, 但是对于目前的软件结构来讲, 要变的东西太多, 就只能先使用鸵鸟算法了 ^_^!

一个类型提升的bug

今天碰到一个由于类型提升的bug, 即使找到bug后, 仍花了好大功夫才找到解释, 感觉此坑比较隐蔽, 在此小记一下.

有类似下面一段代码:

unsigned char a;
unsigned char b;

a = -1;
b = 0;
if (a == b - 1)
printf("equal");
else
printf("not equal");

理论上当a为-1, b为0时应该打印出equal. 可事实恰恰相反, 打印出来的是not equal.

C语言中有以下类型提升规则:
2. 一般常数为int型
1. 二元操作符如果两个操作数具有不同的类型, 那么将较低的类型提升为较高的类型, 运算的结果为较高的类型
2. 将char与short类型的操作数转换为int类型

a,b被提升为int.
因为a是无符号型char, 因此a不会被进行符号扩展, 因此会被提升为0x000000ff
因为b是无符号型char, 因此a不会被进行符号扩展, 因此会被提升为0x00000000

if (a == b - 1)
//等价于
if (0x000000ff == 0xffffffff)

因此在有可能出现类型的地方一定要格外小心.

多线程调DLL

最近写代码一不小心又着了多线程的道, 背景如下:
前不久写了这样一个DLL:

const wchar_t *a = L"xxxx";
const wchar_t *b = L"xxxx";

int do_something_a(struct axx *param_a)
{
...
}
int do_something_b(struct bxx *param_b)
{
...
}


在do_something_a与do_something_b中分别用到了字符串a, b.本来这样相安无事, 可是很多地方会用到这个DLL的代码, 但是字符串a, b并不一样, 而字符中a, b可以根据param_a, param_b中的信息来生成, 本着代码正交性的原则, 将DLL重构如下:

wchar_t a[..];
wchar_t b[..];

int do_something_a(struct axx *param_a)
{
gen_a(param_a, a);
...
}
int do_something_b(struct bxx *param_b)
{
gen_b(param_b, b);
...
}

这样咋一看是没什么问题, 代码简洁了, 程序完美了. 可是我忽略了两个问题, 一个进程中不管调LoadLibrary多少次, DLL只会被加载一次. 而我这个DLL是会在多个线程同时加载使用的.
这样一来, 问题就来了, 由于全局数组a与b的存在, 所有的函数都不是线程安全的, 在低并发量的线程中冲突并不严重, 所以问题很难发现, 但是在高并发的线程中两个函数就会大量执行失败.

找到问题了后将代码重构如下:

int do_something_a(struct axx *param_a)
{
wchar_t a[..];
gen_a(param_a, a);
...
}
int do_something_b(struct bxx *param_b)
{
wchar_t b[..];
gen_b(param_b, b);
...
}

这次bug再次给我提了醒, 多线程代码要处处小心, 一不小心就会掉坑里.

一个变量引发的血案

左天提交了代码, 大致测一下, 看起来来都OK啦. 今天准备放出去呢, 结果一来就各种不正常, 程序直接乱崩, Firmware各种跑死, 等等各种现象层出不穷.
所以今天可以是称为史上最悲剧的一天啦, 还是说原因吧, 希望以后不要再犯.

————————————————————————–

在之前的软件中使用了类似这样一个结构体:

struct a {
int a;
int b;
int c;
int d;
};

这个结构体类似一个全局结构体, 在a.exe与b.dll中同时使用. 但是a.exe与b.dll是由不同的人维护的. 在近期发现要实现某种功能必须要向struct a中加入字段a1, 于是我就将上述代码修改如下:

struct a {
int a;
int a1;
int b;
int c;
int d;
};

然后我编译了a.exe然后将之前的b.dll拷入安装目录下, 测试软件功能时发现都OK, 因为这时候Firmware压根儿就没跑, 今天在具体测试时, 是Firmware各种死啊(b.dll中从struct a中的变量导出另一种结构体, 给Firmware使用). 下班前才发现, 因为我把变量int a1放在了 int a后面, 而b.dll还是以原来的结构体来取数据, 这样他取出来的数据永远偏了4个byte.而Firmware获得信息不正确, 所以才各种死.
原因很简单, 但是死的很难看.

————————————————————————–

所以如果新增加功能时代码中有类似成员变量需要添加, 那么尽可能的将新加的成员变量放在结构体的最后, 保正向低版本代码兼容. 正确的修改如下:

struct a {
int a;
int b;
int c;
int d;
int a1;
};

这样就能兼容旧的代码, 因为旧的代码中有可能某些DLL没有用到这些字段, 那么只要不影响他们的正确取值程序就能证明, 道理很简单, 坑也很明显, 谁踩谁知道…

由于滥用void *引发的bug

我一向认为在写代码时,void *滥用是有问题的,在最近的一次代码中, 有类似这样一段代码:

 
[cc lang=”C”]int send(void *buff, unsigned long size);

int xx_func(char *buff, unsigned long size)
{
unsigned send_size;
………

send(&buff, send_size);
return 0;
}[/cc]

暂且不论为什么作者会错写成取地址,但其原意是想发送经过处理后的buff里面的内容, 但是编译器是不会报错的,因为void *默认兼容所有类型,如果把代码改成下面这样:

[cc lang=”C”]int send(unsigned char *buff, unsigned long size);

int xx_func(char *buff, unsigned long size)
{
unsigned long send_size;
………

//send(&buff, send_size);
//send(buff, send_size);
send((unsigned char *)buff, send_size);
}[/cc]
其实前两种编译在进行参数检查时,都会报警的,只有写成第三种形式,编译器才会真正通过,如你写成第三种形式时不会去看buff, 到底是什么,那我也没话说了。当然如果你忽略警告我也没啥可说的了哈哈,我只想说应该尽可能的去利用编译器来发现潜在bug.

注:从语义上来说,send(void *buff, unsigned long size)会使人疑惑, 这个size是byte? word? dword?, 如果是unsigned char *,那size当然就是byte, 如果是unsigned short *, 那size当然就是word.(X86 platform)

一次手误引出的bug

  在工作中写了这样一段代码:

 1 struct xx_param {
 2        int index1;
 3        int index2;
 4 };
 5 
 6 //func1, func2, func3为三个函数指针
 7 
 8 int init(a_func_t *func1, b_func_t *func2, c_func_t *func3, void *param)
 9 {
10        struct xx_param *p = (struct xx_param *)param;
11         func1(p->index1, p->index2);
12 }
13 
14 int prepare_init(int xx_index_1, int xx_index_2)
15 {
16         int err;
17         struct xx_param        xx;
18         
19        ......
20 
21         xx.index1 = xx_index1;
22         xx.index2 = xx_index2;
23 
24         if(init(func1, func2, func3, &xx_index1))
25                err = -xxx;
26 20        ......
27 
28         return err;
29 }

  很明显这个错误很普通,就是第24行应该为&xx,但是我手误错写成了&xx_index1。但是编译器不会报警,下面来说一下这个错误的神奇之处,在Debug下永远是正确的,但是Release下永远跑飞。下面来分析一下为什么:

  在Debug模式下参数是由栈来传递的,那么参数xx_index_1, xx_index_2在栈中的布局 与 xx结构体变量在栈中的布局完全一样,因此虽然我取错了地址,但是根据&xx_index_1处得到的地址来获到xx_index1, xx_index_2,其实相当于将结构体变量xx赋值然后从&xx地址处取xx.index1,xx.index2变量(貌似还少了几次内存拷贝,虽然他是一个bug, ^_&),因此在Debug模式下这断代码永远是正确的。

  但是在Release下却不正确,因为在VS下,O2的优化级别下xx_index_1, xx_index_2是由寄存器ecx, edx来传递的,xx_index_1, xx_index_2两个变量的值不会全部在栈中分配(由于对xx_index1进行取地址,因此会导致编译器为xx_index1分配内存,但是xx_index_2确不会分配内存),这样从&xx地址去取数据时xx.index2变量取到的数据总是垃圾数据,因此软件在Release下总是挂掉,但如果将优化关掉却又能良好运行。

  这个Bug很简单,但是现象却很诡异,因此感觉值得一记。

  

 

类似sprintf这类变参可能出现的bug

  中午吃完饭照例去云风大神的blog上去逛一圈,果然有新发现,如题:

1     char buff[3];
2     char data;
3     sprintf(buff, "%02x", data);

  咋一看,data最大等于0xff应该不会错,可以如果编译器默认char为signed char,而且data = -1,以十六进制看应该为0xff,这么看也没有错。

  关键在于变参,在C语言的变参中,小于int长度的数据压栈时一律扩展为int型, 那么问题来了,符号型数据在进行类型扩展时是会扩展符号的,这么看其实

data = -1;
sprintf(buff, "%02x", data);
//(int)-1的16进制等效于0xffffffff,因此等效于下面这句话
sprintf(buff, "%02x", 0xffffffff);

如此看来,溢出了,这种问题极易出现,而且不易发现。