N Numbrush 下载

数字填色 App 是怎么把轮廓存下来的

不是矢量图,也不是油漆桶。而是另一张图,图里像素的颜色值就是区域编号,再配上一条十二像素的调色带和一个 GPU 着色器。

一个填色 App 在一幅画里要回答成千上万次的问题只有两个:用户刚才点的是哪一块,以及这个像素此刻应该是什么颜色。两个问题都必须瞬间有答案,而画布有两百多万像素,跑在手机上,还不能让电池察觉到。

直觉上的两种做法都不成立,而真正能用的那种做法,比它们俩都有意思。

为什么直觉上的答案不行

矢量图形

把每个区域存成一条闭合路径,是最自然的想法,而且原画大概率就是这么画出来的。但一幅精细的曼陀罗有好几百个区域,边界都很曲折。于是判断一次点击落在哪里,意味着要拿这个点去和几百条复杂路径做「点是否在多边形内」的测试;而绘制意味着 GPU 每一帧都要把它们全部栅格化一遍。画得越精细就越糟,而这恰好是这类画作前进的方向。

油漆桶(flood fill)

点一个像素,从那里往外扩散,碰到边界就停,把走过的地方填上。这是画图软件的做法,放在这里有两个致命问题。它是一次按需的大面积遍历,也就是说它恰好在最不该慢的那一刻慢。而且它很脆:轮廓上只要有一个像素的缺口,颜色就会漏出去把邻居那一块一起灌满——这种 bug 会顺利上线,然后只在某一张画上出现一次。

真正的答案:把区域编号存成一张图

与其去描述这些区域,不如提前把它们算好,然后把结果存成另一张和原画等大的图。这张图的每一个像素,存的是「该像素属于哪个区域」的编号。

一个像素有三个可用的颜色通道,各八位,而区域编号需要超过 256 个取值,所以编号被拆到两个通道里。在 Numbrush 里,红通道放高字节,绿通道放低字节,合起来是一个 16 位编号;蓝通道等于 255 则表示这个像素属于轮廓。编号 0 是背景。

这个文件必须是无损的——只能是 PNG,绝不能是 JPEG。有损压缩的工作方式,是按人眼能原谅的程度轻微改动像素值;而在这里,像素值是数字,不是颜色。一张 JPEG 版的编号图,就是一张噪点的照片,里面的编号已经悄悄地互相取整成了别人。

加载时,一次线性扫描就把 RGBA 字节转换成一个扁平的 16 位编号数组。从那一刻起,「这个像素属于哪一块」就是按算出来的下标做一次数组读取。不是搜索,不是遍历——一次读。

调色板也是一张纹理

这个把戏的另一半,是 App 怎么知道该画什么。每幅画自带十二个颜色,它们变成一张十二像素宽、一像素高的纹理,每个像素存一个调色板颜色。

有意思的是这条带子的透明度通道,它压根没被用来表示透明。它承载的是一个状态标志位:一个取值表示这个颜色在这里已经填过了,另一个表示还没填,还有一个表示这是当前被选中的颜色、需要高亮。三种状态,塞进了一个本来闲着的字节里。

把两者缝起来的那个着色器

于是绘制就是一段跑在 GPU 上的小程序,每个像素跑一次,每一帧都跑。对屏幕上的每个像素,它大致做这几件事:

  1. 在这个位置上采样编号图,把红绿两个字节重新拼回 16 位的区域编号。
  2. 如果蓝字节说这是轮廓,就画出那个近乎黑色的线条颜色,然后结束。
  3. 否则拿这个编号去十二像素的调色带上查。
  4. 从查到的那一格里读出透明度字节中的标志位。
  5. 已填就画调色板颜色;未填就画未填的底色;如果这是当前选中的颜色,就画高亮的样子。

因为「要不要高亮」是在着色器内部决定的,所以换一个选中的颜色几乎不花成本——你改的是一张十二像素纹理里的一个字节,整张画布在下一帧就重新变了样。三种高亮样式(纯色、棋盘格、呼吸描边)也是同样的道理:它们是着色器里的几条分支,不是三套不同的素材。

为什么填一块是瞬间的

收益在这里。填一块区域意味着:在一个很小的状态数组里改一个字节,然后重建那条调色带——几 KB 的纹理。整个写入就这么多。

那张两百万像素的编号图从头到尾没被碰过。它不会被重新扫描,不会被修改,也不会被重新上传。没有位图被重绘,也没有任何大块内存被申请。这就是为什么在老机器上填色也是瞬间完成,以及为什么连着填一个小时手机也不发烫:昂贵的那个东西是只读的,而可变的那个东西非常小。

有两个细节,弄错了就全毁

这套做法有两种失败方式,它们产生的是坏掉的区域而不是明显的崩溃,所以值得单独点名。

第一个是纹理过滤。GPU 默认会在相邻像素之间做平滑,这对照片是对的,对编号来说是灾难:在编号 40 和 41 之间插值会得到 40.5,取整之后指向的那个区域,可能在画面上完全另一个地方。编号图必须用最近邻采样,并且关掉 mipmap。它的症状很好认——每一条边界旁边都会出现一圈错色的细边。

第二个是色彩管理。如果这张编号纹理上传时带着色彩空间标记,系统就可能对它做转换——而把一次色彩转换施加在一堆数字上,改掉的就是这些数字本身。这张图必须原样上传,不带标记,不做预乘。它的症状是「到处都有一点点不对」,这比「某个地方明显不对」难诊断得多。

这些编号图是从哪儿来的

编号图不是手画的。一张插画先被缩放,用保边的滤波器抹平,量化成一块块纯色面,然后被打上标签:每一个单色的连通区域成为一个有编号的区域。小到点不中、或者细到点不中的区域会被并进相邻区域,轮廓被刻出来,标签的锚点也会被算好——好让号码坐在这一块的视觉中心,而不是它包围盒的几何中心。

最后一步对质量最要紧:一道校验流程会把生成好的文件从磁盘上重新读回来,对着格式规则逐条检查。不合格的画会被直接打回,而不是发出去,因为一张坏掉的编号图是看不见的,直到某个用户正好点到它坏掉的那一块。

常见问题

为什么不干脆每个区域用一条 SVG 路径?

那样判断一次点击,就要拿这个点去跟几百条曲折路径做「点是否在多边形内」的测试;绘制则要每一帧把它们全部栅格化。预先烘焙好的编号图把命中判定变成一次数组读取,把绘制变成一次纹理查表,两者都是常数时间,跟画得多精细无关。

编号图为什么不能是 JPEG?

因为它的像素值是数字,不是颜色。有损压缩改动数值的方式,对照片来说察觉不到,对数据来说是致命的——编号会沿着每一条边界跟邻居混在一起。它必须是 PNG 这类无损格式。

16 位编号能表示多少个区域?

最多 65535 个,再加上背景,远远超出一幅需要用手指去点的画所需要的量。Numbrush 的画通常在几百个区域这个级别,因为再细下去,它先失去的是「点得中」,而不是「编得下」。

App 会保存一张填好的图吗?

不会,而这正是要点。你的进度是每个区域一个字节——填没填、填的是哪个颜色——你看到的画面,是它加上编号图每一帧现算出来的。保存一幅画保存的是几百个字节,不是一张图片。

拿真画布试一遍

Numbrush 是给成年人的数字填色:曼陀罗、玫瑰花窗和几何纹样,每幅十二色,窄的地方有放大镜,点错了没有任何代价。免费、离线、无广告、无账号。

在 App Store 下载

相关文章

全部指南