我们要证明的东西
Sigma 的 3D 场景在 Android 上一直是文档里一个长期挂着的方框:引擎侧的代码是齐的,但从未在真实运行时上跑过一次,也没有一张截图能证明「画面里有东西」。
要补这件事,先得回答一个看起来很简单的问题:怎么把「它画出来了」变成一个会失败的断言。
「画面非空」是第一反应。它也是错的,而且错在一个具体的地方。
两种平凡的真相
3D 场景在 Android 上不是自己开窗,而是宿主给一块 TextureView,引擎把 wgpu 的画面画上去。而在 wgpu 画出第一帧之前,那块矩形上什么都没有。于是 TextureView 把宿主页面自己的底色露了出来。
于是我们面对的画面可能是这样的:
中心 = 四角 = rgb(78,158,160) 量化色数 = 1
一整片纯平铺。这在两种意义上都不是空的:它不是零,它也不是纯白。它有颜色。
这正是判据最容易被平凡事实满足的地方。「非空」在这种画面上恒真,「非纯白」也是恒真,所以它们不是通过的判据——它们是永不失败的判据。一个永不失败的断言不提供信息,它只是让「有没有测过」看起来像是「测过了」。
把判据绑到内容
修法是让判据说出它期望看到什么,而它期望看到的是一个球,在一片背景上。于是判据全部变成可测的量:
中心与四角必须不同。 中心取画面中间 40% 的区域,四角各取 8%,取每块的平均色,算它们之间的欧氏色距。平铺底色会让这个数等于 0;画出来的星球离背景色很远;噪声与压缩抖动只有个位数。球和背景的实际色距是 135.7,我们把它卡在 30。
四角彼此必须一致。 这一条挡住另一半的假阳性。如果整幅画面被噪声填满,中心与四角也会有色距,但它不是「一个球在背景上」,它是别的东西。四个角是同一片平铺背景,彼此的色距应该很小,我们卡在 20。
整幅必须有足够多种颜色。 把每个像素量化到每通道 5 bit,然后数种类。上界只有 32768,而噪声与压缩抖动几乎不贡献新颜色。如果只清了背景色而几何一个没画,种类数会接近 1。这一条卡在 32。
三条各自都在说「画出来了」的不同证据面:主体、背景、有细节。它们合起来是一个关于内容的事实,而不是一个关于亮度的统计量。
活着和死了不是同一张图
画面里还有东西在动。这条更难,因为它需要两张帧,而**「在动」不等于「任何地方在动」**。
场景本身是静止的:转的角度是渲染循环每帧推进的,不经过引擎的状态。所以两帧之间的差异就是「渲染循环还在跑」的直接证据。
但我们没有停在「整幅变化占比 > 0.005」。整幅变化可能来自边角的一点点抖动、一次曝光变化、一次再压缩。我们要证明的是那个球在自转,于是我们只比中心区域的变化,要求它 > 0.01。
为什么是中心而不是边角?因为边角是背景,背景不动。这让判据不是「画面有变化」,而是「我们认为在动的东西在动」。
抓帧本身是坑
判据写完之后,做一条会红三次的测试。
第一坑是启动。 测试框架提供的启动方式,内部在等主 Looper「没有待处理消息了」。而这个场景每一帧都在 post 一帧消息到主线程。于是主 Looper 永远不 idle,启动永远不会返回。面包屑停在那里,各种超时都救不了,整条仪器测试被挂死。
改成直接启动、再轮询生命周期状态机就解决了。轮询本身在测试线程里做,对主线程只有一次调用——取窗口和矩形。取帧之后完全不碰主线程。
第二坑是同步读回。 TextureView 自己的取图方法是一次同步 GPU 读回,会和主线程上那条被 wgpu 喂满的交换链抢。换成走合成器的异步拷贝之后就没有这个竞争了:拷出去就撒手,不排队。
第三坑是回调线程。 异步拷贝的回调默认排在主线程。软件 Vulkan 下一帧要 90ms,主线程正被渲染循环占着,回调排队在那里同样会被饿死。所以拷贝的回调走一条自己的线程。
这三个坑有一个共同点:它们都不是「断言错了」,而是「你测的那个东西压根没被拿到」。测试没有失败,测试消失了。而消失和通过,在报告里看起来是不同的东西,在等待里不是。
等条件,而不是等时间
框架提供了等待:waitFor,等到某个条件成立。但这类等待有一个默认前提——条件一旦成立,测试就往下走。这里的前提是相反的:条件会成立,只是要渲染起来要时间。
我们反过来用了这个接口:每 250ms 抓一帧,直到它符合预期,抓到就往下走。抢在引擎初始化之前取帧会拿到空的纹理,而那种偶发失败比多等一会儿糟糕得多。
等待也有代价,所以等待本身带着几样东西:每次尝试的像素统计都进日志,失败时最后一次的画面描述和这次尝试的错误一起进消息。你不需要复现就能知道它卡在哪一帧。
最终验收里我们留了两条「不得」:判据不得绑到整图统计量,也不得写成「非纯白」。它们都是这次踩过的坑,而人会重新踩坑。
形状
去掉安卓、去掉 GPU,这套东西说的是一件事:
一个断言要么绑在一个具体事实上,要么它就会被某个平凡事实满足。
「非空」「有变化」「看起来对了」,这些都是关于全图统计量的说法,而全图统计量是最容易被平凡事实满足的地方:一个背景、一层底色、一个还没初始化成功的空白。绑到具体事实上的说法——「主体与背景不同」「背景自身均匀」「主体在动」——它必须说出一个只有成功渲染才可能成立的组合。
代价是你要先知道成功长什么样。写「非空」的时候你不需要知道;写「中心和四角不同」的时候,你必须知道球会在中心、背景是 #4E9EA0、它不会转。而这种知识,正是测试应该固定下来的东西——因为界面一重做,它就会漂移。
小结
要证明「它画出来了」,先问一句你的判据会被什么满足。答案是「一个平铺的底色」,那这条判据就永远绿着。
绑到内容上:中心与四角不同,四角彼此一致,整幅有足够多种颜色。两张帧之间还要证明应该动的东西在动,而不是画面上的某处有抖动。
抓帧是另一件独立的事,值得和判据分开想。启动永远返回不了、同步读回挂在交换链后面、回调排在被占满的主线程上——这三者都不会让断言变红,它们会让断言消失。而消失是一种你必须能认出来的失败。
并且不要把你确定的那条记下来,别记「这次让判据变绿了」。 要记的是「判据不得写成非纯白,也不得绑到整图统计量」。写下你学到的形状,写下你不接受的形状,下一个人就不必再踩一遍。