那些年,那些bug(二)

上文书说到,一个bug分身成四个的故事(叛逆者:那些年,那些bug(一))。这篇也是一次我遇到的很有代表性的情况,而方向正好相反。

四个bug

转眼到了2017年,我在DWM的团队里。Rendering组开始往DWM里加入HDR输出的支持。当年还很难买到HDR显示器,于是隔壁办公室的同事拿了一台三星的50寸电视来作为测试硬件。

那时候我也是刚好路过他办公室,又因为正在做一个材质编辑器(microsoft/WindowsCompositionSamples),就打算在那上面运行一下,看看在HDR显示器上,我的光照是什么效果。正确的情况下,光源是绑在鼠标上的,看起来应该是这样的:

https://www.zhihu.com/video/1199661766131933184

然而,当打开了HDR模式之后,光源位置偏了很多。一开始我以为是High DPI的问题,光源坐标的计算没有考虑DPI的设置。然而调成100% DPI之后,错误仍然存在。更严重的是,有的时候能看到非常奇怪的颜色,有的区域没有过渡直接变成黑色。当时组里并没有第二台HDR显示设备,只能先记下来,暂时搁置。反正还不到release的时间,还有的是机会修。

又过了几个月,到了纯修bug的阶段。老板的老板又报了一个HDR相关的bug。还是那个项目microsoft/WindowsCompositionSamples,问题出现于Pointer Enter/Exit Effects:

在RS4之后,系统显示设置里有个SDR content appearance,用来调节SDR的内容需要乘上多少之后才显示出来。这样SDR和HDR内容共存于屏幕上的时候,因为SDR内容的范围是[0.0f, 1.0f],而HDR内容的范围大于这个,比如[0.0f, 8.0f]。所以如果把SDR内容都乘个系数,可以使得SDR内容的亮度和HDR内容的亮度在一个范围。简单起见,下文称这个设置为SDR scale。

按理说,SDR scale越大,SDR的内容就会越亮。然而这个例子在HDR打开的时候,SDR scale开得越大,有的区域,图像亮度变大,有的区域,图像亮度不变,有的区域,图像亮度反而更暗。我看了一眼代码才发现这个bug划给我的原因是这个效果的名字里有个light。然而实际上这是用一个mask bitmap实现的,和我做的光照一点关系都没有(看microsoft/WindowsCompositionSamples这个文件,352行)。这帮外行,没法跟他们滞气。

当时我跟他说,不是不修,是我没有HDR显示设备啊,我没法测试。于是他给了我一台三星的34寸曲面显示器,分辨率不高,但支持HDR。我还说呢,修完bug我给你还回去。他说不用,我还有一台Dell的,比这台好,这台是我用剩下的,拿去就是了。

于是我接上设备,打开HDR模式,再次测试了那两个bug,全都重现了。这好办一些,比那些时不时才会出现的bug要好对付。

用了几分钟HDR模式,我就发现问题比我想象的严重。bug不止这两个。首先是FDS里的Acrylic效果。正确的时候应该是这样的,颜色均匀,过渡连续。(顺便说一下,这张图是我亲手调的。在做Acrylic原型的时候,我做了一个app,把设计师在after effect里跳出来的效果用代码表达出来,调参数使得它尽量像。现在成了Acrylic的代表图在文档中出现。)

然而在HDR打开之后,变成全是色带,就好像回到16色时代的样子。并且随着SDR scale的增大,色带愈加严重。显然又是一个bug。

除此之外,在UWP里的Acrylic效果,应该是只有顶层窗口启用,推到后台后变成一个纯色的色块。然而,在HDR打开之后,两者亮度不一致。Acrylic比它对应的纯色要暗很多,和第二个bug一样,亮度随着SDR scale变低,而且变暗程度比第二个bug还严重。这是第四个bug。

问题分析

至此,在短短的几分钟内,已经看到了四个和HDR模式相关的bug,而且症状很不相同。第一个和光照有关,第二个是特效的叠加,第三个是色带,第四个是亮度不一致。一个一个来吧,从我最熟悉的光照开始。

第一个bug

当时在Windows Composition的SceneLightingEffect里,我用的是最基本的光照公式,后来才加的PBR。光照效果是由diffuse lighting和specular lighting组成,而每一部分分别包含很多项。用伪公式表示,就是:

lighting = diffuse lighting + specular lighting

其中,

diffuse lighting = light color * diffuse amount * dot(normal, light direction)
specular lighting = light color * specular amount * dot(normal, half way vector) ^ shininess

所以,这里只要通过改参数,一项一项排查,就能知道到底是那一项在HDR模式上出问题。

光是白色的,所以light color = (1, 1, 1),可以忽略不计。那么接下来就把specular amount设置成0,也就是去掉了整个specular lighting。测试发现问题仍然存在,也就是说问题在只有diffuse的时候也会发生。继续把diffuse amount设置成1,问题仍然存在,也就是normal和light direction有问题。我之前的猜测是光源坐标不知什么原因错了,以至于light direction没搞对。然而,当我改shader,输出light direction后,发现不管HDR是否开启,结果是相同的。所以,真相只有一个,normal错了。

既然normal来自于normal map,而normal map和HDR的状态无关,那么问题就集中在normal map读取之后是否又经过了什么,使得里面的数值发生了变化。既然其他三个bug都和SDR scale有关,会不会这里也是?于是我把shader改成只输出normal,把normal map换成一张单色的,这就让整个条件变成完全可控。测试的结果证实了我的想法,屏幕上看到的结果会随着SDR scale的改变而改变。也就是说,normal从normal map都出来之后,和SDR scale做了一个操作,之后才送到lighting shader。在normal map里,normal存的范围是[0, 255],表示[-1.0f, 1.0f]的范围,也就是GPU在读取normal后,得到[0, 1.0f]的数值,需要在shader里做

normal = normal * 2 - 1

才能得到[-1.0f, 1.0f]的结果。如果normal一读出来,就被乘或者除了SDR scale,那么实际上就变成了

normal = (normal * scale) * 2 - 1

自然就相当于normal歪了。比如平的normal是(0, 0, 1),存在normal map里就是(128, 128, 255),读取出来得到(0.5f, 0.5f, 1),经过乘二减一,还原出(0, 0, 1)。但如果经过scale,就是(scale - 1, scale - 1, scale * 2 - 1)。如果scale = 0.125,那么还原出来的normal就会是个(-0.875f, -0.875f, -0.75f)这个奇怪的数值。这解释了之前看到的两个现象,第一是光照效果看起来是偏的;第二是颜色可能无过渡直接变成黑色,因为偏了的normal可能突然就翻到另一个方向,算不了光照。

既然normal错误地于SDR scale做了操作,那么要修正也不难,在乘二减一之前先把normal掰回去就行。试了一下发现这里实际上是除了SDR scale,所以我在shader里再乘上SDR scale就解决了。结果正常,不管HDR开还是关,光照的结果都一样。问题暂时解决。

第二个bug

既然第一个bug已经有了个虽然奇怪但work的解法,我就开始转战第二个bug。从现象上看,SDR scale调大,大部分区域都是会正确地跟着变亮,而少数区域却相反,变得更暗。就好像有的区域是乘,有些区域是除一样。看了一下输出的代码,确实都是乘上SDR scale,不是这里的问题

于是我开始写个简单的app,用于直接复现这个问题。在里面,有各种Visual和Brush类型的组合,以发现到底是哪个组合出了问题。用这个app可以一眼就看出问题出在带有 CompositionEffectBrush的SpriteVisual这个组合。其他的Visual类型,或者带有CompositionColorBrush或CompositionSurfaceBrush的SpriteVisual都没问题。于是这个app可以简化成左右三个SpriteVisual,

  1. 左边的用的CompositionSurfaceBrush放一个bitmap
  2. 中间的用CompositeEffect这个CompositionEffectBrush,把那张bitmap和0做加法
  3. 右边的也用CompositeEffect,把那张bitmap和一张纯白色的bitmap做乘法。

如果正确的话,这三个应该完全相同。然而实际情况却是,调整SDR scale,左边的会正确变亮变暗,中间的亮度不变,而右边的亮暗程度正好相反。完美地复现了需要的情况。

在Windows Composition内部,这三种CompositionBrush是不同的code path。CompositionColorBrush读取纯色(都是SDR)后,乘上SDR scale输出。CompositionSurfaceBrush读取bitmap后,如果bitmap是SDR,就乘上SDR scale输出,否则直接输出。最复杂的是CompositionEffectBrush,因为它的输入来自于其他brush的输出。所以为了同时兼顾SDR和HDR,就假设所有输入都是在[0.0f, 1.0f]的。如果是HDR的,就除SDR scale,把它变到SDR的取值范围,这样后续跟其他SDR操作就还能用原先的代码,效果上也向下兼容。而CompositionEffectBrush的输出一定会乘上SDR scale,并把自己标记成HDR的内容。所以即便多个CompositionEffectBrush串联,这个机制也能有效地保证结果正确。

过了一遍原理之后,我反而更觉得奇怪了。我的输入bitmap是SDR的,难道被误判成HDR的,以至于被除了?如果那样的话,为什么用了同样bitmap读取代码的CompositionSurfaceBrush却没这个问题?

带着这些疑问,我开始看代码。那部分代码是另一组的领地,并且刚经过一次大规模的重构。当新代码看就是了。之前看了输出部分,应该没问题,现在就集中在看输入部分。代码里的巨量注释也说明了这个行为,HDR的输入会除SDR scale之后才参与计算,而SDR的输入不变。虽然注释很长、写得天花乱坠,其实实现只有一行,伪代码是这样的:

inputColor = isHDR ? inputColor : inputColor / sdrScale;

我看了好几遍才发现,尼玛啊,什么情况,这个条件反了吧。这样的代码,变成HDR的内容不变,SDR的内容反而除SDR scale。这在搞什么啊。那么,我们把这样的条件带入刚才那个app的情况,看看是否符合我们所看到的。

  1. 左边是CompositionSurfaceBrush,不走这条code path,所以不受影响,最终输出是bitmap * sdrScale,也就是sdrScale越大,输出亮度越大。符合观测结果。
  2. 中间的把bitmap和0做加法,由于输入被错误地除了一次,最终输出是(bitmap / sdrScale + 0) * sdrScale = bitmap,也就是亮度不因为SDR scale的改变而改变。符合观测结果。
  3. 右边的把bitmap和和一个纯白的bitmap做乘法,两个输入都被错误地出了一次,最终输出是(bitmap / sdrScale * 1 / sdrScale) * sdrScale = bitmap / sdrScale。也就是,sdrScale越大,输出亮度越小。符合观测结果。

看起来就是这么回事了,这个条件写反的愚蠢bug造成了这些看似复杂的输出结果。修正也很简单,把两个表达式反一下就行了。

继续分析

改好之后,在编译的过程中,我还在继续这前面那种条件带入的思维。(对,编译一次几十分钟,即便你只改一行,这工程系统就这么SB。)难道说,这几个bug都是因为这个?

  • 好,回顾第一个bug,normal被除了SDR scale。SceneLightingEffect是一种CompositionEffectBrush,会走这个code path。Normal map是个SDR的bitmap,这里会被错误地除SDR scale,和前面观察到的一样。所以revert掉我前面对第一个bug的修改,应该仍然能成。第一个bug和第二个bug是同一个。
  • 第三个bug呢,Acrylic的核心是一个gaussian blur,而gaussian blur内部是通过两个blur effect串联起来,X方向和Y方向。如果第一次错误地除SDR Scale,而结果又被保存到了一个SDR的bitmap,这时候就被量化到8 bit。结果就是明显的色带。比如[0, 255]被除8,得到[0, 32],存入bitmap。结果就是0-255范围内连续的输入,变成0-32的阶跃。所以也是第二个bug造成的。
  • 第四个bug,是第二个bug和第三个bug的组合。同样是Acrylic的gaussian blur,X方向的输入出了一次SDR scale,在Y方向又除了一次SDR scale,结果就是变暗得多了。

所以,这四个症状迥异的bug全是由一行代码引起的,都是一个简单到不能更简单的条件写反。

之后,编译完成,部署到测试机器上,再跑一遍,发现四个bug全部消失,证实了我的推导。最终的修正也是就这么简单,一行而已。

总结

这次真可谓,一行修正打死四个bug。按说这种弱智bug应该在那行代码被写出的时候,就有个自动测试来捕捉才对。然而,Windows的自动测试覆盖率极低,运行条件也很有限。服务器上是无法开启HDR模式的,所以HDR模式可谓完全没有自动测试覆盖。导致了出问题后几个月,还是只能由人工测试来发现问题。在修正了这处之后,整个Windows UI看起来都有脱胎换骨的感觉。开始菜单的Acrylic颜色变得平滑,窗口切换也不再明暗闪烁。

下一篇我会讲一种非常常见的导致bug的原因,以及导致的恶果。尽情期待。

(后来啊,那台显示器被我用来看2018世界杯了。)

发布于 2020-01-13

文章被以下专栏收录