2D 矢量引擎深挖 · 第 八 篇

自跑二:SKP 回放与字体渲染差异

2026-09-2816,816 字27 张配图

本文由@溯光 出品,转载请注明出处 站点链接:技术博客页 | 公众号:溯光的图形笔记

亚像素渲染把一笔字摊到像素的哪几格
题图亚像素渲染把一笔字摊到像素的哪几格:一串字在像素网格上的落点,是这一篇的起点@ Wikimedia Commons · Original raster version created(CC BY-SA 3.0)

上一篇换的是后端。同一段场景代码,CPU 光栅跑一遍、GPU 后端跑一遍,然后把两条路的输出差、耗时口径与 overdraw 触达数逐条量出来。这一篇不换后端了,换输入。

输入有两样,都在 Skia 的「谁来画」与「画什么」之间那条缝上。第一样是 SKP——一份把绘制命令录下来、可以存成文件、可以在别的时候放出来重放的东西。它回答的是「录下来的到底是什么」:是像素,还是命令?如果只录命令,那录一份带中文的图,文件该有多大?第二样是文字——同一串字,交给同一个光栅器,只是把 hinting(提示)、edging(边缘处理)、亚像素(subpixel)这几个开关拨一下,画出来的像素会变多少?

这两样凑在一起,是因为它们共用同一个落点:「一份内容」从「被记下来」到「被画成像素」之间,到底有多少东西是你没写在代码里、却实实在在决定了结果的。SKP 那一侧,答案是环境里的字体;字体那一侧,答案是三个开关背后的那套机制。

这是「自跑」系列的第二篇。上一篇的读数全部来自一台机器——Windows 10.0.26200 | Python 3.10.11 | skia-python 144.0.post2 | GLFW 3.4.0(Win32 WGL)| NVIDIA 驱动 560.70 | GeForce RTX 3060 Laptop GPU。这一篇一模一样,环境、绑定、口径都不换,方便两篇的读数对着看。也正因为是同一台机器,边界也和上一篇一样必须写在最前面(见 0.4):这一篇里有两个实验的结果是零,还有一个章节的结论只能靠改字节而不是靠真文件来支撑——这两件事我都会照实写,不修饰。

先看看这一篇要回答的三个问题。

系列文章写作风格说明

  • 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
  • 结论带读数:凡是「更快/更差/更大」的说法,绑定本机读数并写明口径与条件;
  • 凡引必注:官方定义给链接,本机读数给环境、命令与脚本口径;
  • 本篇不涉及(边界见 0.3):Graphite 的运行(本机跑不了,见第六篇)、overdraw 触达计数与两条后端的耗时口径(已在第七篇做完)、 Vello 与 sparse strips(ep09/ep10)、GPU 抓帧与片元计数(本机无工具)、跨机型与跨平台的结论;
  • 篇幅规划:每篇 16,000 至 20,000 汉字、10 章左右,每章 1,400 到 2,300 字。

本篇配图三类:外部示意图取自 Wikimedia Commons(图注标作者与许可);本机真跑/真算的读数图由本篇的测量脚本产出(图注写明口径); 自绘图为本系列绘制,走 styles/qianmo/palette.json 色板单源。

全系列内容总览

  1. 【2D 矢量引擎深挖】(一) 2D 矢量引擎全景:五款引擎各在哪个生态
  2. 【2D 矢量引擎深挖】(二) Skia 架构:SkCanvas API 分层 + CPU(Raster) vs GPU backend
  3. 【2D 矢量引擎深挖】(三) 矢量引擎核心:贝塞尔细分、tessellation、analytic AA 与 coverage
  4. 【2D 矢量引擎深挖】(四) 字形光栅化:字形数据组织、blob 与 CJK
  5. 【2D 矢量引擎深挖】(五) Skia GPU backend:覆盖 approach 与 instanced tessellation
  6. 【2D 矢量引擎深挖】(六) Graphite:Skia 新一代 compute 后端
  7. 【2D 矢量引擎深挖】(七) 自跑一:Skia 路径 CPU vs GPU 与 overdraw 可视化
  8. 【2D 矢量引擎深挖】(八) 自跑二:SKP 回放与字体渲染差异
  9. 【2D 矢量引擎深挖】(九) Vello:GPU compute 矢量引擎的 Sparse Strips 与 prefix-scan
  10. 【2D 矢量引擎深挖】(十) 自跑三:Vello GPU、CPU、Hybrid 三路对比
  11. 【2D 矢量引擎深挖】(十一) 与 Godot 2D、Cairo、Rive 对照,以及与 Android 显示栈、VFX 的交叉索引

相关资源链接

  • 系列主题的坐标系(第一篇):【2D 矢量引擎深挖】(一) 2D 矢量引擎全景
  • 上一篇(两条后端与 overdraw 的读数):【2D 矢量引擎深挖】(七) 自跑一:Skia 路径 CPU vs GPU 与 overdraw 可视化
  • 本次用的绑定:skia-python 发行页 | skia.Picture 参考 | skia.Font 参考
  • 官方 SKP 工具:Skia Web Debugger | Chromium:如何保存 .skp 文件
  • 上一篇的姊妹篇主题(字形数据组织与 blob):【2D 矢量引擎深挖】(四) 字形光栅化:字形数据组织、blob 与 CJK

本篇只回答三件事

先从三个问题入手。它们不是三个并列的知识点,而是一条从「输入」到「像素」的动线:先把录下来的东西拆开看它装了什么,再看这份东西换一条路放出来会不会变形,最后把镜头推到最小的那一格——一个字形在像素网格上是怎么落地的。

0.1 全篇知识地图:三个问题,一条动线

本篇三问与各自的测量边界
图 8-1本篇的三问与各自的测量边界:三问不是并列知识点,是一条从输入到像素的动线@ 本系列绘制

好了,先把两份输入摆开。

第一件:一份 SKP 里到底存了什么? 这件事最容易被「文件格式」四个字糊过去。我们要盯住的是一个具体的选择:Skia 在录制的时候,存的是像素还是命令?官方对 SkPicture 的第一句定义就把答案给了——「SkPicture records drawing commands made to SkCanvas.」。存命令,就意味着这份文件的大小跟画面复杂度无关、跟画面里有没有字高度相关。这一章要把这件反直觉的事量出来:同一张 256×256 的画面,画一个矩形和画四个中文字,文件能差四个数量级。

接着看第二件。

第二件:同一份 SKP 换一条后端回放,结果会一样吗? 上一篇量过「同一段代码在两条后端上有 1.58% 的像素不一样」。这一篇的问题更刁一点:如果代码不是重跑一遍,而是从文件里读回来再放,那还能不能保证一样?换句话说,序列化(serialization)与反序列化(deserialization)这一段本身有没有损耗?如果有,那「SKP 是一份可靠的交付格式」这句话就不成立了。

OK,第三件换个方向。

第三件:那几个字体开关,各自在像素上改了什么? 这一件是第二件的放大镜。SKP 里存的字体信息,最终要落到 SkFont 的几个开关上——hinting 四档、edging 三档、亚像素定位开关。官方对它们的说明都很短,短到看不出代价。这一章要把「代价」量成数字:开一个开关,同一个字形在缓存里要多存几份?关一个开关,四个不同位置会不会塌成同一张图?

这三个问题说完了。

OK,把这套做法先定义清楚,再压成一句话。

0.2 一句话说清这篇换了什么做法

上一篇问「同一段代码交给两条路会怎样」,这一篇问「同一份内容交给两份输入会怎样」。

前一句里,「同一段代码」是活的——每次运行都重新走一遍绘制命令。后一句里,「同一份内容」是死的——它先被写成字节,再从字节被读回来。这中间多出来的一段路,就是这一篇要检查的东西。

所以这一篇的诚实标准又多了一条。上一篇的诚实是「这个数字是在什么环境、什么口径下测出来的」;这一篇要加上一条:「这个数字是在这份文件、这个版本、这套绑定上测出来的」。因为 SKP 自带版本号,读取方对版本是有闸门的;而字体是不是随文件走,又决定了这份文件能不能在另一台机器上还原出同一张图。

还有一件事得提前说清:自跑篇最容易犯的错,不是数算错了,而是把「本机这一版」当成了「Skia 的规矩」。这一篇里几乎所有结论都挂着版本号或绑定版本——skia-python 144.0.post2、头部版本号 109、Python 绑定没暴露的那几个接口。数字一旦离开这一层,就只剩「好不好看」这一个属性了;本篇会反复回到这句话。

先把不做的那部分定义清楚,直接让出去。

0.3 边界:本篇不涉及什么

把话说在前面,避免把精力花在别处:

  • Graphite 的运行:本机绑定里没有 MakeDawn,所以第六篇讲的那套结构在本篇依旧跑不起来——本篇的 GPU 侧一律指 GL/Ganesh 那条路;
  • 两条后端的耗时口径与 overdraw 触达计数:这两件事在上一篇已经做完了,本篇不重复,只在需要对照时引用它的结论;
  • GPU 抓帧与片元计数:本机没有 RenderDoc、AGI 这类工具。跨后端的差异本篇只能停在「像素级」,停不到「片元级」;
  • 真·旧版本 SKP 文件:本机手上没有旧版本 Skia 产出的真实 SKP。第三章测的是读取闸门,不是旧文件本身(详见 §3.2);
  • Vello / sparse strips / prefix-scan:属 ep09/ep10;跨机型与跨平台的结论:本篇只报本机读数,不外推。

关键参数的取值范围(本篇反复用到的几个数,先立在这里):实验画布 256×256 与 512×256; 字形实验统一用 Microsoft YaHei 28px 的字串「字形Ag」(拉丁对照用 Arial 40px 的「Graphite」); 字号扫描取 8 / 12 / 16 / 24 / 32 / 48 / 64 七档;亚像素位置序列取 0 到 0.875、步长 0.125 共八档。

0.4 证据口径与能力边界

这一节要放在最前面,因为它决定了大家怎么读后面的每一个数字。

第一类:本机真跑。 所有读数取自同一份测量脚本,环境是:Windows 10.0.26200 | Python 3.10.11 | skia-python 144.0.post2 | GLFW 3.4.0(Win32 WGL)| NVIDIA 驱动 560.70 | GeForce RTX 3060 Laptop GPU。 GL 侧读回的是 GL_VERSION = 3.2.0 NVIDIA 560.70、GL_RENDERER = NVIDIA GeForce RTX 3060 Laptop GPU/PCIe/SSE2。 本机字族数 215。这是一台机器的读数。

第二类:本机真算。 「落墨像素」(与底色通道差大于 6 的像素总数)是自己算的,不是引擎给的统计接口。 它包含抗锯齿边缘那些半透明的像素,所以它量的是「这次绘制碰到的像素规模」,不等于「填充率」,也不等于「GPU 片元着色调用数」。 这两件事相关,但不等同——这条界线在上一篇已经划过,这里照旧。

本机明确拿不到的四样东西(逐条给原因):

  1. 真·旧版本 SKP 文件:手上没有旧版 Skia 产出的样本,也没有现成的跨版本语料;本机这一版只能写出当前版本的文件;
  2. strike cache 的实际条目数:Python 绑定没有暴露字形缓存的统计接口,本篇第十章给的键位数是推算,不是实测;
  3. 跨后端的片元级证据:需要抓帧工具,本机没有;本篇只能给到逐像素;
  4. Graphite 上的回放行为:绑定里没有 MakeDawn,跑不起来。

一条额外的自我约束:本篇把零结果当结果写。实验里有两次「本该有差别、实际没有」:hinting 的 kSlight / kNormal / kFull 三档在本机落在同一张图上;setLinearMetrics 在本机对像素的影响是 0。这两条不会因为「看起来不漂亮」被删掉, 也不会被换成「理论上应该有差别」——它们就是这台机器上的实测。

本机能力探针
图 8-2本机能力探针:13 个类全在,但 Graphite 与官方 overdraw 画布在绑定里缺位@ 本机真跑 · skia-python 144.0.post2 + GLFW(GL 3.2 / RTX 3060 Laptop)

0.5 本章收在哪里

下面我们进入第一阶段。 本篇的线索是「两份输入」:一份是录下来的绘制内容(SKP),一份是当场排的字。先从第一份开始,把它从「一个文件」拆成「一串字节」。

官方依据:SkPicture 的定义与「records drawing commands」的原话见 api.skia.org · SkPicture; 录制与回放的入口见 api.skia.org · SkPictureRecorder; Python 绑定侧的 API 见 skia-python · skia.Picture; 本篇的绑定版本与建面方式见参考资料第 25–27 条。


一、一份 SKP 到底是什么、有多大

这一章不写测量方法,只做一件准备工作:把「一份 SKP」这句话,落到「一串字节」和「一次往返」上。做完这件事,后面几章才有东西可拆。

1.1 录下来的是命令,不是像素

言归正传,从官方定义开始。

先看这一步的官方定义。SkPicture 的第一句是:

"SkPicture records drawing commands made to SkCanvas." —— api.skia.org · SkPicture

紧接着的第二句是「The command stream may be played in whole or in part at a later time.」——命令流可以在之后的某个时间被整体或部分地放出来。 两句连起来读,「录命令、放命令」这条链就完整了。录制的入口是 SkPictureRecorder::beginRecording, 官方说它「Returns the canvas that records the drawing commands.」——它返回的是一个 Canvas,也就是说录制过程里你面对的还是平时那套绘制 API; 收口的是 finishRecordingAsPicture,官方注明「The returned picture is immutable.」;回放则是 SkPicture::playback, 「Replays the drawing commands on the specified canvas.」。

这里有个细节值得停下来看一眼:录制的对象是命令,不是像素。这意味着文字在录制的那一刻会被转成什么,是有讲究的。 文字在 Skia 里有两副面孔:一副是 text blob(一段可绘制的文本),另一副是轮廓路径(glyph outline)。 画文字时用前者,引擎会记下「在哪儿画了哪串字、用了哪个字体」;如果开发者自己先把字形转成路径再画,引擎记下的就是一堆路径。

这两种记法,最终会落到两个数量级完全不同的文件上(§2.2 会把数字给出来)。这也是「文字光栅化」这条线与「SKP 回放」这条线能接上的地方: 第四篇讲字形数据怎么组织、blob 怎么走,本篇讲这些数据在录制时被怎么对待。

文字与轮廓路径的对照
图 8-3文字先被变成轮廓路径、再被光栅化;SKP 存的是其中一半@ Wikimedia Commons · Sameboat(CC0)

一条容易被忽略的边界:beginRecording 接受一个 bounds 参数,但它是 cull rect(裁剪提示),不是硬边界。 官方原文是「Any drawing that falls outside of this rect is undefined, and may be drawn or it may not.」—— 画到框外的内容,画不画是不确定的。这句话解释了为什么有的 SKP 回放出来会缺内容:录的时候框给小了, 放的时候引擎没有义务补齐。§3.2 会拿一次改字节的实验去验证这句话——把 cull rect 改到任意值,文件照样能被读进来。

1.2 一次往返:333 字节,进出像素一致

下面我们开始动手,让文件真的走一遍。

先看定义不够,得让文件真的走一遍。

下面这段是本篇测量脚本里的往返核心,完整的脚本在 .scratch/ep08_run.py:

rec = skia.PictureRecorder()
canvas = rec.beginRecording(skia.Rect.MakeWH(256, 256))
canvas.clear(skia.Color(247, 249, 252))
canvas.drawRect(skia.Rect.MakeXYWH(24, 24, 100, 100), paint_a)
canvas.drawPath(circle_path, paint_b)              # 一条描边圆弧
canvas.drawRect(skia.Rect.MakeXYWH(60, 150, 140, 60), paint_c)   # 半透明
picture = rec.finishRecordingAsPicture()

blob = bytes(picture.serialize())            # 写出去
back = skia.Picture.MakeFromData(blob)       # 读回来

四个 op(一次清屏 + 三笔绘制)写完,序列化出来的字节数是 333,文件开头的八个字节是 skia + pict。 把 blob 交给 MakeFromData 读回来,再在 CPU 光栅表面上回放一遍,与「录制后直接回放」的那张图逐像素比对: 不同的像素是 0 个,最大通道差 0。再把读回来的 picture 重新序列化一遍,还是 333 字节。

一份 SKP 的往返
图 8-4一份 SKP 的往返:录下来、写出去、读回来,像素完全一致(333 字节 / op 4)@ 本机真跑 · skia-python 144.0.post2 + GLFW(GL 3.2 / RTX 3060 Laptop)

这三个数放一起看,说明了一件对后面很要紧的事:序列化这一段本身是无损的。文件里有的东西,读回来一模一样;文件里没有的东西,读回来也不会有。 所以后面第三章问「为什么旧文件打不开」、第四章问「换后端会不会变」,问题的位置就清楚了——它们都不在这段路上。 换一句话说:SKP 的保真度不是本篇的疑点,它的体积与它对环境的依赖才是。

顺手还能读出一件事:blob 的第 9 到第 12 个字节,按小端整数读出来是 109。这是这份文件自己声明的版本号, 而 kCurrent_Version = kWorkingColorSpaceOutput = 109 正是本机这一版 Skia 的当前版本。写入方盖了个章,读取方要验章—— 第三章就是围绕着这个 109 与它下面的那个下界展开的。

矩形 SKP 的原始字节
图 8-5矩形 SKP 的全部 173 字节:magic、版本号与四字节标记各自的落位(标记为实测扫出)@ 本机真跑 · Picture.serialize() 的原始字节

这张图把前文的三个说法一次落了地:头 8 个字节是 skia 加 pict(magic),紧接着 4 个字节是小端版本号 109, 再往后就是那一串带标记的分块。标记不是从源码里抄来的名字,是在这份文件的字节里扫出来的—— 它们的偏移与「后面跟着的那个数」都列在图的右半部分,下一章会逐条解释这些数各自是什么意思。

1.3 本章收在哪里

下面我们进入下一章。 现在我们知道「一份 SKP」是一串带版本号的字节,也知道这一段路是无损的。 接下来要把这串字节切开看:它里面按什么分块、每块装什么。

官方依据:SkPicture / SkPictureRecorder / playback 的定义与 bounds 是 cull rect 的那句,见 SkPicture、SkPictureRecorder 与 Skia 用户文档;序列化与反序列化在绑定侧的接口见参考资料第 19–20 条。


二、SKP 里面分了哪些块

上一章知道了一串字节的总长与版本号,这一章要把它切开。切法很简单也很硬:跟着源码里那些四字节标记走。

2.1 一套带 tag 的分块

Skia 的序列化代码用一组四字节标记(tag)把内容分类存放。源码里的写法是 write_tag_size(buffer, TAG, count)—— 先写标记、再写这个块的长度或条目数。SkPictureData::flattenToBuffer 里逐条写着这些块:扁平的 paint、path、文本 blob、slug、vertices、image 引用; 顶层还写出 op 流、工厂表、typeface、子 picture。

光有宏名不够,得看文件里到底有没有。本机做了一次扫描:对每个候选标记,取它的四字节反转形式,在一份份 SKP 里找, 再读它后面那四个字节的小端整数,看是不是一个像样的长度或计数。判定标准只有一条——内容增减,标记随之出现或消失。 四条用例分别是:只有矩形、矩形 + 一条路径、矩形 + 路径 + 一个圆、以及在前者基础上再加一段文字。

SKP 的分块对照
图 8-6SKP 的分块:源码宏名与本机在字节流里扫到的四字节标记逐条对照@ 本系列绘制

扫出来的结果很干净,逐条都对得上:

  • SK_PICT_PAINT_BUFFER_TAG 后面那个数是 2 / 2 / 3 / 4,正好等于四份 SKP 里各自的 paint 数(清屏也算一笔);
  • SK_PICT_PATH_BUFFER_TAG 只在画了 path 的那三份里出现,纯矩形那份里没有它;出现了就是 1;
  • SK_PICT_TEXTBLOB_BUFFER_TAG 只在画了文字的那一份里出现,别的三份里都找不到;
  • SK_PICT_TYPEFACE_TAG 在没画字的三个里都是 0,画了字的那份变成 1;
  • SK_PICT_READER_TAG 后面那个数从 32 涨到 64,跟着命令数一起长;SK_PICT_BUFFER_SIZE_TAG 从 72 涨到 276,跟着内容一起长;
  • SK_PICT_SLUG_BUFFER_TAG 四份里全是 0。这不是 bug——slug 是 GPU 侧的文本表示,CPU 录制不产生它。

其中:「内容增减 → 标记出现或消失」这条判据,比「在文件里搜到某四个字节」强得多。因为字体二进制里到处都是可打印的四字节窗口, 单看「有没有出现」,误判率很高;只有让它跟着内容一起进退,才算把标记坐实。

2.2 字节账:同一张 256×256,差四个数量级

先看看这笔账有多大。

块分清了,接着量每一块撞上字体之后会膨胀到什么程度。同样是 256×256 的画布,同样一次 serialize(),五种内容:

# 五次调用,只有画的东西不同;画布、底色、序列化方式完全一致
draw_rect_only(canvas)                      # 只有矩形
draw_string(canvas, "Graphite", arial_40)   # 拉丁文,Arial 40px
draw_glyph_paths(canvas, "Graphite", arial_40)   # 同一串字,先转轮廓路径再画
draw_string(canvas, "字形缓存", yahei_40)    # 中文,Microsoft YaHei 40px
draw_glyph_paths(canvas, "字形缓存", yahei_40)   # 同一串字,先转轮廓路径再画

读数是:只有矩形 173 字节;拉丁文画字 1,046,075 字节;拉丁文画轮廓 3,073 字节; 中文画字 19,704,677 字节;中文画轮廓 2,521 字节。

五种内容的 SKP 字节账
图 8-7五份 256×256 的 SKP 字节账:画字与画轮廓差 340 倍(拉丁)与 7,816 倍(中文)@ 本机真算(同一份测量脚本,正文与图注同源)

这组数把两件事同时说完了。

第一件:画字和画轮廓不是一个量级。拉丁文差 340 倍,中文差 7,816 倍。 注意方向——不是因为画字慢,而是因为画字把整份字体打进了文件。同一串「字形缓存」, 用 text blob 记录是 19.7 MB,用四条轮廓路径记录是 2.5 KB。差的那 19.7 MB 里没有一个是多余的像素。

第二件:中文字体的体量是拉丁字体的近二十倍。Arial 40px 那一份是 1.05 MB,YaHei 40px 那一份是 19.70 MB。 而 Category:Font hinting 这类「字体原理图」在素材库里全是几十像素的小位图(见素材清单),二者放在一起看很有意思: 原理图很小,原理本身很大。

2.3 那几十兆是什么:字体连同数据一起走

OK,查一下这几十兆到底是什么。

上一节的负数(19.7 MB)看着不像字节账,倒像有人在文件里塞了别的东西。查起来只差一步:把 SKP 的字节数与字体文件本身对一下。

本机 Windows 上,arial.ttf 是 1,045,720 字节,msyh.ttc 是 19,704,352 字节。两份画字的 SKP 分别是 1,046,075 与 19,704,677。 差值:+355 与 +325。

SKP 与字体文件对账
图 8-8SKP 与字体文件逐份对账:差值只有三百多字节,字体名就落在字节流里@ 本机真算(同一份测量脚本,正文与图注同源)

三百多个字节,就是一份 SKP 除了字体数据之外的全部开销(头部、op 流、paint 表、typeface 记录)。 换句话说:一份画了中文的 SKP,99.998% 的体积是字体文件本身。

源码那边有对应的写法。序列化 typeface 时用的是:

tf->serialize(stream, SkTypeface::SerializeBehavior::kDoIncludeData);

kDoIncludeData 这四个字就是字面意思:把字体数据一起写进去。本机还做了一步交叉验证——在这份 19.7 MB 的字节流里搜字体名, ASCII 形式能在偏移 92 处找到,UTF-16 形式能在偏移 19,520,750 处找到。名字都在里面,数据当然也在。

顺手记一条源码注释里写明的坑:「With the default serialization and deserialization behavior, kIncludeDataIfLocal does not always work because there is no default fontmgr to pass into SkTypeface::MakeDeserialize。」 ——只带描述符、不带数据时,反序列化可能因为找不到字体管理器而失败。这解释了为什么默认行为选了 kDoIncludeData 这条更重的路: 重,但是自洽;文件拿到别的机器上也能还原出同一张图,不依赖对方的字体库。

其中:这一节的结论不是「Skia 浪费空间」。它是一笔明确的取舍——用体积换可移植性。 反过来看也成立:如果一份 SKP 里没有字体数据(比如录的时候字形已经转成了路径),那它拿到别的机器上就不依赖字体, 但画出来的就不是「字」,而是「一串轮廓」。两条路各有各的代价,本篇把它们各自的代价都量成了字节数。

2.4 本章收在哪里

下面我们进入下一章。 现在这串字节里有什么、最重的那块是什么,都清楚了。再说一件它自带的东西:版本号。 它决定了这份文件在别的机器上还能不能被读进来。

官方依据:分块与 kDoIncludeData 的写法见 Skia 的 SkPictureData.cpp、SkPicturePriv.h; 「没有默认字体管理器时 kIncludeDataIfLocal 不总能工作」见 SkTypeface 序列化相关注释; 序列化行为的定义见参考资料第 28 条,字体管理器与反序列化的关系见第 15 条。


三、为什么旧 SKP 会打不开

第二章把那串字节拆成了块,也找到了最重的一块。这一章换一个角度:这串字节自己带着一个版本号, 而读取方对这个号是有闸门的。闸门在哪、边界多严,就是这一章要量的事。

3.1 版本区间 82–109,以及字体那一次搬家

先把官方那句话说完整:

"Only SKPs within the min/current picture version range (inclusive) can be read." —— Skia 源码中关于 picture version 的注释

区间是闭区间,两个端点各有一个常量:kMin_Version = kPictureShaderFilterParam_Version(82)、 kCurrent_Version = kWorkingColorSpaceOutput(109)。官方 Debugger 页面上也直接把下界印出来了——「Minimum version this build can open: 82」。

而「字体」这条线,正好在这个版本轴上搬过一次家。版本注释里有两条相邻的记录:

// V67: Blobs serialize fonts instead of paints
// V68: Paint doesn't serialize font-related stuff

其中:读法是这样的——从 V67 起,文本 blob 开始自己序列化字体;到 V68,paint 里不再带字体相关的东西。 所以「同一份带文字的 SKP 在不同版本上能不能打开」这件事,取决于字体信息当时住在哪儿。 如果在 V67 之前录的,字体信息挂在 paint 上;之后则挂在 blob 上。这不是兼容性问题,而是存放位置迁移带来的读取路径变化。

版本轴与读取区间
图 8-9版本轴上的字体搬家(V67/V68)与读取区间的两个端点@ 本系列绘制

那 82 与 109 之间隔着 27 个版本号。本机这一版写出来的文件,头部声明的正是 109。写入方永远盖当前版本这个章; 读取方要认的,是这个区间。这一节到这里只是读源码。下面把它变成一次可以重复的实验。

3.2 把版本号改掉,看闸门在哪一格

言归正传,先把手上的条件说清。

手上的问题是:本机没有旧版本产出的真实 SKP,怎么验证这个区间?

有一条不需要旧文件的验证路径:改字节。SKP 的头部是自描述的,本机从字节流里读出来的布局是:

偏移 0..4   'skia'            偏移 4..8   'pict'
偏移 8..12  uint32 版本号      偏移 12..16 保留
偏移 16..   float cullRect

一份 173 字节的矩形 SKP,前 24 字节的十六进制是 736b6961706963746d000000000000000000000000008043: 6d000000 就是小端整数 109。既然版本号是一个写在固定偏移上的整数,那就可以只改这 4 个字节, 其余部分一个不动,看读取方还认不认:

import struct
blob = bytearray(open("rect.skp", "rb").read())
for v in (60, 81, 82, 108, 109, 110, 200):
    b = bytearray(blob)
    struct.pack_into("<I", b, 8, v)          # 只动偏移 8 上的 4 字节
    try:
        pic = skia.Picture.MakeFromData(bytes(b))
    except ValueError:
        print(v, "拒收")

读数是:60 拒收、81 拒收、82 通过、108 通过、109 通过、110 拒收、200 拒收。

改版本号后的读取结果
图 8-10改头部版本号后的读取结果阶梯:实测能读区间恰好是 82 到 109@ 本机真跑 · skia-python 144.0.post2 + GLFW(GL 3.2 / RTX 3060 Laptop)(只改偏移 8 上的 4 字节)

这一组读数把源码里那两个常量从「注释里的数字」变成了「行为上的端点」。82 是能读的最小值,109 是能读的最大值,两个端点都是闭的。 再往下改、再往上改,都会拿到同一个结果——绑定抛 ValueError: Invalid data,也就是读取方连内容都没来得及看就退回了。 作为对照,把最前面的 skia 四个字节改坏,结果一样,所以这个异常本身没有区分「版本不对」与「文件坏了」——它只负责拒收。

同一个实验里还顺手验了另一件事:cull rect 真的只是提示。把偏移 16 起的那两个 float 分别改成 0、8、4096, 三份文件的读取结果全部通过。回看 1.1 里那句「may be drawn or it may not」——录的时候框小了,读取方不会因此拒收, 它只是不保证把框外的内容补回来。

一条必须写死的边界:这次测的是本机这一版 Skia 的读取闸门,不是「一份真实的旧版本 SKP 文件能不能读」。 两者相关但不重合:前者只动了 4 个字节,文件其余部分的编码方式仍是 109 这一版写出来的;真实的旧文件, 它的 op 流编码、块布局都可能不一样。所以本章的结论只说到「闸门端点在 82 与 109」为止, 不往下推「旧文件一定能/不能被完整还原」。

3.3 本章收在哪里

下面我们进入下一章。 版本号这道闸门看清了,接下来回到那份能读进来的文件身上,问一个更实际的问题: 同一份读回来的 picture,交给不同的后端去放,结果会不会变。

官方依据:版本区间与两个常量的注释见 Skia 源码的 SkPicturePriv.h / SkPictureData.cpp; Debugger 页面上的下界提示见 Skia Web Debugger; 版本历史中 V67 / V68 两条注释见参考资料第 7–8 条。


四、同一份 SKP 给两条后端回放,会一样吗

上一章那份文件能读回来了。这一章把它交给两条路去放:一条 CPU 光栅,一条 GPU(GL/Ganesh)。 问三件事——读写这一段有没有损耗、跨后端会不会变、如果会变,变在哪儿。

4.1 同一份反序列化出来的 picture,交给两条后端

先看看被试是什么。

被试是一份 512×256 的 picture:一串 64px 的中文(「字体渲染」)、一条描边的圆弧、一块半透明矩形。 它序列化出来是 19,704,893 字节,op 数 4——体积的大头依旧是那份字体(第二章的账)。

实验有三份产物,分别在三种条件下回放同一份从字节读回来的 picture:

back  = skia.Picture.MakeFromData(blob)        # 从字节读回来
cpu_b = play(back, backend="cpu")              # ① CPU 光栅
gpu_b = play(back, backend="gpu")              # ② GPU:MakeRenderTarget + GL
cpu_o = play(picture, backend="cpu")           # ③ 对照:录制后直接回放

第一组对照是「③ 录制后直接回放」对「① 读回来再回放」:不同的像素 0 个。这和第一章 §1.2 的结论一致—— 序列化这一段是无损的,字体数据一起走,读回来还是同一份东西。 第二组对照是「① CPU」对「② GPU」:131,072 个像素里有 2,070 个不一样,占 1.58%,最大通道差 77。

两条后端的回放并置
图 8-11同一份反序列化 picture 的两条后端输出并置:往返零误差,跨后端差 1.58%@ 本机真跑 · skia-python 144.0.post2 + GLFW(GL 3.2 / RTX 3060 Laptop)(512×256,CPU 光栅 vs GL/Ganesh)

把两组数放在一起,这一章的主结论就出来了:差别来自后端,不来自 SKP。 同一个文件在两条路上放出来的像素有 1.58% 不同,但同一份文件在读写一圈之后像素完全一致—— 所以如果你遇到「同一份录好的内容在两台机器上画得不一样」,问题的位置在后端,不在文件。

这个结论和上一篇量到的「同一段代码在两条后端上 1.58% 不同」是同一个量。两次测量的图不同、路径不同, 但比例落在同一个位置。这不是巧合,它说明这 1.58% 的量级是这两条后端之间稳定的一个差—— 差异的量级比差异的具体形态更值得记住。

4.2 差在哪些像素上

好了,再往下看一层。

比例只是第一层,第二层是位置。

本机对这两张图做了逐像素差分:取每个像素的 R、G、B 三个通道与基准的最大差,得到一张热图。

跨后端逐像素差分热图
图 8-12逐像素差分热图:差异全部压在字形边缘与圆弧描边上@ 本机真算(同一份测量脚本,正文与图注同源)(131,072 像素中 2,070 个不同)

热图上的亮区不散,全部压在字形边缘与圆弧描边上。画面内部(字形的笔画中心、矩形的实心区)是干净的: 那些位置的像素值在两条后端上完全相同。这印证了上一篇那条结论的同源机制—— 差异不来自「算错了」,来自「边缘那半个像素该给多少灰度」这件事上,两条路援引的依据不同。

其中:这里要区分两件事。像素级差异是能测的(本机能算,读数就是上面那 2,070 个像素); 片元级证据是测不了的(需要抓帧工具,本机没有)。所以这一节只能说到「差在字形边缘」, 说不出「差的是哪一次片元着色调用」。这条界线在 §0.4 已经划过了,这里照旧。

4.3 为什么两条后端的抗锯齿依据不同

OK,机制还得补一句。

差异的位置有了,机制还得补一句。Skia 官方邮件列表里有一段回帖把 GPU 侧的做法写得很清楚:

"The GPU implementation uses a few different AA techniques, depending on your surface configuration & the type of primitive being drawn. ... 1. MSAA ... 2. Geometric / analytic ..." —— skia-discuss 官方回帖

也就是说,GPU 侧的抗锯齿(anti-aliasing)不是一个机制,而是「MSAA / 几何解析」这几套里的某一套, 用哪一套要看 surface 的配置与被画的东西是什么;近年还加了 CCPR 这一条。而 CPU 侧走的是解析式的覆盖率计算—— 一套从头到尾一贯的算法。

同一串文字,一边的依据是「这个像素被覆盖了多少比例」,另一边的依据可能是「这个像素上叠了几个样本」。 两边都在回答「边缘该给多少灰度」,但问的不是同一句话。这就是那 1.58% 的来路。

有不抗锯齿的对照
图 8-13有不抗锯齿的对照:边缘像素该给多少灰度,是两条后端各自回答的那个问题@ Wikimedia Commons · Vierge Marie(Public domain)

4.4 本章收在哪里

下面我们进入下一章。 到这里,「录下来的那一侧」算讲完了:文件是无损的、自带版本号、 换后端会变但只在边缘变。镜头现在推到最小的一格——那些字本身。下一章就从字体的三个开关开始。

官方依据:GPU 侧抗锯齿的多种机制见 skia-discuss 回帖; 两条后端的建法与 MakeRenderTarget 的用法见 SkCanvas Creation; surface 配置如何影响 AA 的选择见参考资料第 17 条。


五、字体那侧有哪三个开关

先换到另一侧。

现在换输入。SKP 那一侧讲的是「录下来的内容」,这一侧讲的是「当场排的字」。这一章先把三个开关的官方定义列清楚, 再往下两章逐个量它们各自的像素代价。

5.1 hinting、edging、subpixel

先看看这三个地方分别是什么。

字体在像素上落地时,有三个地方可以拨。它们的官方定义都很短。

第一个是 hinting(提示),四档:

kNone,   //!< glyph outlines unchanged
kSlight, //!< minimal modification to improve constrast
kNormal, //!< glyph outlines modified to improve constrast
kFull,   //!< modifies glyph outlines for maximum constrast

(原文里 "contrast" 拼成了 "constrast",照录。)这四行注释里最要紧的是「glyph outlines」这个词—— hinting 改的是轮廓本身,不是给已经画好的图加一层滤波。kNone 那行写的是 "unchanged",也就是不动轮廓。

第二个是 edging(边缘处理),三档:kAlias(字形边缘没有透明像素)、kAntiAlias、kSubpixelAntiAlias。 官方对第二档的描述是「glyph positioned in pixel using transparency」——用透明度来表达亚像素位置。 而 setEdging 的措辞比取值本身更重要:

"Requests, but does not require, that edge pixels draw opaque or with partial transparency."

它只是请求,不是要求——后端可以不满足。这一句解释了后面两章为什么会出现「我明明设了,但看起来没变」的情况: 设的是意图,不是结果。

第三个是 subpixel(亚像素) 开关。它管的是「字形落在像素内部的哪个位置」—— 一支笔画的位置是 10.0 还是 10.375,落在亚像素这一层。这个开关表面上看只是精度问题,代价却在第十章才显影。

字体那侧的三个开关
图 8-14字体那侧三个开关的取值与官方原话摘引@ 本系列绘制

5.2 一个像素里的三个子像素

说白了,得先看一个像素在物理上长什么样。

要理解第三个开关在做什么,得先看一个像素在物理上是什么。液晶屏的一个「像素」其实由红、绿、蓝三个并排的子像素组成; 当渲染器知道自己面对的是这样的屏幕时,它可以把一个像素内部的水平分辨率用满——这就是亚像素渲染的物理前提。

一个像素里的三个子像素
图 8-15一个像素里的三个子像素:亚像素渲染借用的那份额外水平分辨率@ Wikimedia Commons · User:Mercury13(Public domain)

官方对 edging 的描述「glyph positioned in pixel using transparency」正是建立在这件事上: 字形不是只能落在整数像素边界,它可以落在某个像素内部的偏移上,代价是这个像素要按比例分配给左右的笔画。 本机测到了这个代价(§7.1),也测到了它的来路(§7.3)。

5.3 还有两件相关的事:blob 不做回退,也不做复杂排版

好了,顺带说清两件容易踩的事。

顺手把两件容易踩的事说清楚,它们和「字为什么画得和想象的不一样」直接相关。

第一件:SkTextBlob::MakeFromText 的官方注释把两件事说死了——

"It does not perform typeface fallback for characters not found in the SkTypeface. It does not perform kerning or other complex shaping; glyphs are positioned based on their default advances."

不替你把缺字的字符换个字体,也不做 kerning 或复杂排版;字形位置一律按默认推进宽度排。 所以在 Skia 这一层,一段带中文的文本如果没有指定中文字体,缺字的部分不会被自动补上—— 这和操作系统里那种「自动挑一个字体顶上」的行为是两回事。

第二件:SkTextBlob 本身是「immutable container」,并且源码注明它可以作为缓存条目的键,blob 被删掉时相关的缓存可以随之清理。 这让「文本」在 Skia 里有了一个和「一串字符」不同的身份——它是一段已经定好位置、可以被引用计数的东西。 SKP 里那块 SK_PICT_TEXTBLOB_BUFFER_TAG,装的就是它。

5.4 本章收在哪里

下面我们进入下一章。 三个开关的定义到这里就齐了,但定义看不出代价。下一章从最容易被误读的那个开始——hinting。

官方依据:hinting 四档的逐字注释见 SkFontTypes.h;setEdging 的「Requests, but does not require」见 SkFont.h; SkTextBlob 不做字体回退与复杂排版的原文见 api.skia.org · SkTextBlob; 本篇引用的头文件版本见参考资料第 11–16 条。


六、hinting 到底改了多少像素

先看看它到底改了多少。

第五章说 hinting 改的是轮廓。这一章要把它量出来:改了多少、改在哪里、以及一个本机上出现的意外结果。

6.1 四档并置

先看看这次实验怎么设。

实验的设定尽量干净:同一台机器、同一个字体(Microsoft YaHei)、同一个字号(28px)、同一串字(「字形Ag」)、 抗锯齿一律打开、位置一律落在整数像素上,只改 hinting 这一档。

hinting 四档并置与放大
图 8-16hinting 四档并置与同区域放大:本机 kSlight / kNormal / kFull 落在同一张图上@ 本机真跑 · skia-python 144.0.post2 + GLFW(GL 3.2 / RTX 3060 Laptop)(Microsoft YaHei 28px)

读数如下:

hinting 落墨像素 相对 kNone 不同的像素 最大通道差
kNone 1,062 0(基准) 0
kSlight 951 521 148
kNormal 951 521 148
kFull 951 521 148

两两差异矩阵更直白:kNone 对另外三档分别是 521 / 521 / 521,而 kSlight、kNormal、kFull 三档彼此之间全是 0。

其中:kSlight、kNormal、kFull 三档落在同一张图上——这不是我调错了,也不是实验没做对。 它说的是:在这台机器、这个字体、这个字号下,官方那三档描述里的「minimal / modified / maximum」并没有落到不同的像素上。 本机只能报出「改与不改差 521 个像素」,报不出「改多少档差多少像素」。这条零结果照实写在这里,不修饰。

6.2 差异落在哪、量级多大

OK,把这两个数放进上下文里读。

521 个像素、最大通道差 148,这两个数需要放进上下文才有意义。

hinting 的落墨像素与差异矩阵
图 8-17hinting 的落墨像素与两两差异矩阵:三档重合在矩阵上一眼可见@ 本机真算(同一份测量脚本,正文与图注同源)(落墨=与底色差大于 6 的像素)

先看分母。kNone 的落墨像素是 1,062,改成 hinting 之后是 951。落墨像素少了 111 个,减了 10.4%。 这个方向是对的:hinting 会把笔画对齐到像素网格,边缘那些「只盖住一点点」的像素更容易被抹掉—— 字形会变得更结实、更细。这与社区里那些「换后端之后字变细了」的报告方向一致: Mozilla 的 Bug 1230366 就是一个例子——macOS 上换用 Skia 后端之后,字形被报告为明显偏细,当作渲染回归处理。

再看分子。521 个像素对 1,062 个落墨像素,相当于基准图上一半的落墨像素都变了;最大通道差 148(满值 255 的 58%)说明这不是浮点误差, 而是实打实的灰度重排。所以「hinting 是个微调」这个印象是错的——它是在轮廓层面动刀。 官方那句 "glyph outlines modified" 在这里有了数字。

至于「多大字号以下 hinting 才明显」——官方没有给阈值。本机也不敢给:一台机器、一个字体、一档字号推不出这条线。 唯一的相近表述出自 SkiaSharp(不是 Skia 本体),按取材规矩不能当官方口径引用,所以本篇不给这个阈值。

6.3 本章收在哪里

下面我们进入下一章。 hinting 动的是轮廓,代价是「字形不再严格线性」。下一章换到亚像素那一侧—— 那个开关看起来只管精度,但它买的和花的都得算。

官方依据:hinting 四档注释见 SkFontTypes.h;Typeface.getBounds 里「不考虑任何 hinting 或其它逐字号调整」的表述见 api.skia.org · SkFont;「换后端后字形变细」的社区报告见 Mozilla Bug 1230366。


七、亚像素定位买到的是什么、代价是什么

第六章量的是「改轮廓」的代价,这一章量的是「改位置」的代价。这两件事经常被混为一谈,但它们动的是完全不同的东西。

7.1 八个位置,开关两态

先看看这次怎么设计。

实验设计得比上一章狠一点:把同一个字形放在八个只差 0.125 像素的位置上,分别在「亚像素关」和「亚像素开」两种状态下渲染, 然后数一件事——八个位置到底落出了几种互不相同的图。

for sub in (False, True):
    font = skia.Font(typeface, 28)
    font.setHinting(skia.FontHinting.kNone)
    font.setSubpixel(sub)
    imgs = [render(font, x=10.0 + i / 8) for i in range(8)]   # 0, 0.125, ..., 0.875
    distinct = count_distinct(imgs)                          # 互不相同的图有几张

读数是:亚像素关——八个位置只落出 3 种图,八档里只有 2 档真的动了(其余六档落在同一张图上), 动的两档是整格的跳变,最大通道差 231;亚像素开——八个位置落出 8 种图,八档里 7 档都动了, 最大通道差只有 100。

亚像素开关两态下的位置序列
图 8-18亚像素开关两态下的八个位置序列:关时塌成三张、开时八张全不同@ 本机真跑 · skia-python 144.0.post2 + GLFW(GL 3.2 / RTX 3060 Laptop)(x 从 10.0 起每档 +0.125)

这组对照是这一章最值得看的一张。它把两个开关的差别说成了一句人话:

  • 关掉亚像素,位置信息被四舍五入吃掉了。0.0 到 0.875 这八个值,落到像素上只剩两三个不同的结果, 而且要么不动、要么整格跳(最大通道差 231,几乎是黑与白之间的一跳)。
  • 打开亚像素,位置信息被摊进了像素内部的权重里。八档各不相同,但每一次变化都是小步的(最大通道差 100), 也就是「相邻位置之间只挪一点点灰度」。

其中:注意「3 种」这个数在别的条件下会变。研究阶段用 24px 字串试的时候得到过 4 个位置落出 3 种的结果, 现在用 28px 得到 8 个位置落 3 种——具体数字取决于字号与字串里各个字形的推进宽度加起来落在哪儿。 所以本篇报的是「在这个设定下,关掉亚像素把八档压成三档」,而不是「关掉亚像素一定压成三档」。 这条自我约束在本篇反复出现,原因只有一个:这台机器上量到的数,只能替这台机器说话。

7.2 亚像素排布的两种方向

好了,它还有一个前提。

亚像素渲染还有一个前提条件被经常忽略:它要知道屏幕的子像素是按什么顺序排的。 常见的两种是 RGB(红绿蓝,从左到右)与 BGR(蓝绿红)。排列方向搞反了,字形边缘会出现彩色镶边, 而画面本身看起来是「对的」,只是颜色不对——这类问题最难查。

子像素的两种排列顺序
图 8-19子像素的两种排列顺序(RGB / BGR):亚像素定位要按屏幕真实方向取值@ Wikimedia Commons · Jooja(CC BY-SA 4.0)

这也解释了一件事:亚像素渲染是一种与设备绑定的优化。它不是「把字画得更好」,而是「把字按这块屏的物理结构画」。 所以它只在知道屏幕排布时才有意义——本机这个实验是在 CPU 光栅表面上做的, 用的是一个抽象的像素模型,没有真实屏幕的排布信息。这也是本机读数必须带条件的原因之一。

7.3 代价在哪:键位变多

言归正传,代价在哪。

买到了「八个位置八种结果」,代价是什么?

代价藏在字形缓存(glyph cache)的键里。Skia 的缓存不是按「字形」来存的,而是按「键」来存的; 键的构造方式决定了同一份字形数据在缓存里能被切成几份。SkGlyph.h 里有一行注释加一行常量,专门讲这件事:

// A combination of SkGlyphID and sub-pixel position information.
kSubPixelPosLen = 2u,

其中:SkPackedGlyphID 把「字形 ID」与「亚像素位置」拼在一起当键。每个轴用 2 位来存位置,也就是每轴 4 档; 两个轴合起来,同一个字形最多能对应 16 种不同的键。键不一样,缓存里的条目就不一样,同一份轮廓数据会被重复存上多份。

字形缓存的键怎么变多
图 8-20字形缓存的键怎么变多:字形身份加两轴亚像素位置,一个字形最多 16 种键@ 本系列绘制

这一句话把亚像素的代价说透了:打开亚像素买到的「位置精度」,付的钱是「键空间乘 16」。 关掉亚像素的时候,一个字形在缓存里通常只有一种形态;打开之后,它可能以十六种不同的键各占一格。 而且这笔账不是一次性的——它不是「多存一份就完了」,而是每多一个字形、每多一个字号、每多一个变体,都要再乘一次。 第十章会把这个乘数和缓存的总量上限放在一起,算清楚一段普通中文能在多快的时间里把它顶满。

7.4 本章收在哪里

下面我们进入下一章。 到这里,「位置」这条线量完了:关掉它会把位置吃掉,打开它要按 16 份来存。 下一章回到「字号」这条更常见的线上——字号变了,度量和像素各怎么变。

官方依据:SkPackedGlyphID 与 kSubPixelPosLen = 2u 的注释见 SkGlyph.h; setSubpixel / setHinting 的取值见 api.skia.org · SkFont; Python 绑定侧的字体接口见 skia-python · skia.Font。


八、字号变了,度量与像素各怎么变

前两章拨的是开关,这一章拨的是字号。这是最普通的一个参数,也是误会最多的一个:「字号翻倍,字就大了一倍」—— 这句话在度量上成立,在像素上不成立。这一章把两条线分开量。

8.1 度量那条线:严格线性

先看看度量这条线。

先量度量。用 Font.measureText 拿同一串字在不同字号下的推进宽度,再除以字号:

for size in (8, 12, 16, 24, 32, 48, 64):
    font = skia.Font(typeface, size)
    w = font.measureText("字形Ag")
    print(size, w, w / size)          # 第三列是关键

第三列在七档字号上全是一样的:3.3433。

七档字号的度量线性
图 8-21七档字号的度量严格线性:measureText ÷ 字号恒定 3.3433@ 本机真算(同一份测量脚本,正文与图注同源)(Microsoft YaHei)

这个数的意义在于「恒定」。8px 的宽度是 26.7461,64px 是 213.9688——比值 8.0,两边严格成比例。 官方对度量的说明和这个读数对得上:「Results are scaled by text size but does not take into account dimensions required by text scale, text skew, fake bold, style stroke, and SkPathEffect.」——按字号缩放,同时明确不管倾斜、假粗、描边这些变形。 换句话说,度量是一条干净的直线。

其中:3.3433 这个常数只对「这个字体 + 这串字」成立,换成别的字串当然会变。 它要证的不是「有一个普适常数」,而是「同一串字、同一个字体下,度量随字号严格线性」—— 以及,后文要拿它当一个「本该如此」的参照,去对照像素那条线。

8.2 像素那条线:放大一次,就要重新算一次

好了,再看像素那条线。

度量是线性的,像素却不是。原因不在字号,在放大这件事本身怎么实现。

位图放大和矢量放大是两种完全不同的操作。位图放大是插值:新像素是从邻近的老像素加权算出来的, 信息量不增加,所以放得越大越糊。矢量放大是重新光栅化:轮廓是数学曲线,放大之后重新在更大的网格上算覆盖率, 新像素是算出来的,不是插出来的。

位图放大与矢量放大
图 8-22位图放大靠插值、矢量放大靠重新光栅化:字号变大时新像素从哪来@ Wikimedia Commons · Fbj 与衍生作者(CC BY-SA 3.0)

这条区别决定了一件很实际的事:字号变化在 Skia 里不是一个「缩放」操作,而是一次完整的重新光栅化。 把 16px 的字改成 64px,不是把一张小图拉大,而是拿同一份轮廓在一个四倍大的网格上重走一遍覆盖率计算。 所以它不会变糊——但它要重算的东西,比「把小图放大」多得多。

官方的 Typeface.getBounds 里还有一句可以直接接上:「This may be conservatively large, and will not take into account any hinting or other size-specific adjustments.」——逐字号的调整这件事是被明写出来的。 这句话就是前一章 hinting 的伏笔:hinting 不是一个全局开关,它是按字号做的一套调整。

8.3 本章收在哪里

下面我们进入下一章。 到这里,度量线性、像素不线性,两条线都摆出来了。下一章把这两条线和 hinting 放在一起, 回答一个更刁的问题:hinting 为什么会让「缩放」这件事变得不可靠。

官方依据:getMetrics 的「scaled by text size」与 getBounds 的「不考虑任何 hinting 或其它逐字号调整」见 api.skia.org · SkFont;逐字号调整与 SurfaceProps 的关系见 SkSurfaceProps.h; 度量接口在绑定侧的对应物见 skia-python · skia.FontMetrics。


九、hinting 与「线性性」到底是什么关系

先看看问题的形式。

这一章要把第八章那两条线合起来用。问题的形式是这样的:如果我有一张 12px 的图,把它放大一倍,能不能当作 24px 的图用? 如果能,引擎就省掉了重新光栅化的开销;如果不能,那「缓存一张小图、需要时拉大」这条优化路线在字体上就走不通。

9.1 半缩对齐:一个能区分两种说法的实验

说白了,就是把两张图叠起来比。

实验的做法很直接:12px 直接渲染一张图;24px 也渲染一张,然后把它缩回一半。两张图逐像素比—— 如果线性成立,二者应该几乎重合;如果不成立,差异像素数就会冒出来。

a12 = render(font_12px, w=260, h=48)                       # 12px 直接画
a24 = render(font_24px, w=520, h=96)                       # 24px 画
small = bilinear_resize(a24, (260, 48))                    # 缩一半
diff = count_pixels(|a12 - small| > 8)                     # 差异像素数

读数是:hinting = kNone 时 285 个差异像素(平均通道差 0.71,最大 115);hinting = kFull 时 320 个(平均 1.71,最大 205)。

半缩对齐实验
图 8-23半缩对齐实验:hinting 让「缩放替代重绘」更不成立,右下含一次零结果@ 本机真算(同一份测量脚本,正文与图注同源)(半缩用双线性,数字只作相对比较)

OK,两个方向都能读出来。

两个方向都能读出来。差异像素数从 285 涨到 320——涨的不多,只有 12%;但平均通道差从 0.71 涨到 1.71, 涨了 1.4 倍,最大通道差从 115 涨到 205。也就是说,hinting 让「差得不多的像素」变成了「差得挺多的像素」: 它把差异从「边缘上的细碎抖动」推成了「整片灰度重排」。

这正是社区那篇「为什么 Android 上缩放时文字会抖」文章在讲的事——hinting 破坏了文本的线性性, 于是缩放动画里字形会一跳一跳。那篇文章的可信度不错(Wear OS 团队内部的调研,致谢了 HarfBuzz 与 Skia 团队), 但它完全没有 SKP、没有跨后端、也没有缓存机制,只有症状。本篇在这里把它量化了。

其中:这条读数有一个方法学前提必须写明——半缩用的是双线性插值,不是理想的面积平均。 所以 285 与 320 这两个数只能相对比较(同一个缩放口径下,hinting 开与关谁更差),不能当绝对误差读。 换一种缩放算法,两个数都会变,但「hinting 让差异变大」这个方向仍然是可复核的。

9.2 一个零结果:setLinearMetrics 对像素没影响

先看看官方是怎么说的。

官方对 setLinearMetrics 的说明是:「For outline fonts 'true' means font and glyph metrics should ignore hinting and rounding.」 ——听起来像是「打开它就能拿到不受 hinting 干扰的度量」,很容易被读成「打开它渲染会更规整」。

本机把四种组合都跑了一遍:hinting(kNone / kFull)× linearMetrics(False / True)。读数:

hinting linearMetrics 差异像素 平均通道差 最大通道差
kNone False 285 0.71 115
kNone True 285 0.71 115
kFull False 320 1.71 205
kFull True 320 1.71 205

上下两行的数字完全一样。也就是说:在这台机器上,setLinearMetrics 对渲染出来的像素影响是 0。

其中:这是一个零结果,它照样是结果。它说的是:setLinearMetrics 管的是度量侧的行为(让度量忽略 hinting 与舍入), 不是像素侧的行为(不改轮廓怎么画)。所以不能把它写成「改善了渲染」—— 这句话不是谦虚,是本机量到的。要拿到「不受 hinting 影响的渲染」,得改的是 hinting 那一档,不是这个开关。

9.3 度量线性了,像素却不线性

好了,再换个角度检查一遍。

第九章开头那个问题——「小图拉大能不能顶替重绘」——还可以从另一个角度检查。第九章 9.1 是从「缩」的方向看; 其实从「放」的方向看会更清楚:字号变大时,落墨像素按什么规律涨?

本机把七档字号的落墨像素也记了下来:8px 130 个、12px 254 个、16px 395 个、24px 798 个、32px 1,320 个、48px 2,648 个、64px 4,419 个。

落墨像素随字号的变化
图 8-24落墨像素随字号的变化:度量乘四,像素乘十一倍多@ 本机真算(同一份测量脚本,正文与图注同源)(16px 395 个 → 64px 4,419 个)

取两头看:字号从 16px 到 64px 是 4 倍,落墨像素从 395 到 4,419 是 11.2 倍。 而同一个区间里,度量只老老实实乘了 4(3.3433 × 16 到 3.3433 × 64)。

这就把「两条线」说死了:

  • 度量按长度涨(一维),所以随字号线性;
  • 落墨像素按面积涨(二维),所以涨得更快——而且实际比 4² = 16 倍还低一点,因为笔画有粗细下限、 边缘的覆盖率也有取整,大字号下这些「折扣」占比更小。

所以「字号翻倍,字就大了一倍」这句话,对度量成立,对要画的像素数不成立。 同一张画布上,小字号与大字号给光栅器的负担从来不是同一个量级——这一点在移动端排版里是实打实的开销。

9.4 本章收在哪里

下面我们进入下一章。 「位置」和「字号」两条线都量过了,它们最后都汇到同一个地方:字形缓存。 下一章把这个账算清楚——上限是多少、一段文字能产生多少键。

官方依据:setLinearMetrics 的「ignore hinting and rounding」见 SkFont.h; 「缩放时文本抖动」的社区调研见 halilibo · Why text gets jittery when scaled on Android; 另一个方向的同类症状(换后端后字形偏细)见 Mozilla Bug 1230366。


十、字形缓存为什么会被撑爆

言归正传,把前面几章的账合成一本。

前九章攒下的东西,这一章要合成一个账。因为「亚像素」和「字号」这两条线,最后都会落到同一张表上:缓存条目的键。

10.1 两个上限,和一条乘法

言归正传,先把两个上限摆出来。

缓存这一侧,官方给的是硬上限,逐字可查(SkStrikeCache.h):

SK_DEFAULT_FONT_CACHE_COUNT_LIMIT  =  2048        // 条数上限
SK_DEFAULT_FONT_CACHE_LIMIT        =  2 * 1024 * 1024   // 字节上限(2 MB)

然后接上一台乘法。§7.3 已经拿到了乘数:SkPackedGlyphID 的 kSubPixelPosLen = 2u, 每个轴用 2 位存亚像素位置,也就是每轴 4 档;两个轴合起来,同一个字形最多对应 16 种键。

缓存上限与键位的乘法
图 8-25缓存的两个上限与键位的乘法:撑满条目上限只需 128 个不同字形(本格为推算)@ 本机真算(同一份测量脚本,正文与图注同源)

两个数一除,结论就出来了:

其中:2048 ÷ 16 = 128。也就是说,只要一段文字里有 128 个不同的字形,光靠「同一个字形在不同亚像素位置上的变体」,就能把缓存的条数上限占满。 而 128 个不同汉字,在中文里大约是一两句话的量。这就是「亚像素定位会让缓存条目变多」的机制级来路—— 不是感觉上变多,是键空间直接乘了 16。

这一格是推算,不是实测。本机绑定没有暴露 strike cache 的统计接口,所以「实际条目数」这个量本篇拿不到。 能坐实的只有两件事:乘数(2 位每轴,来自源码)与上限(2048 / 2 MB,来自源码),中间那一步乘法是本篇自己做的。 把它标成推算而不含糊过去,是因为这两个数的量级差太悬殊——一个说「一两句话就能顶满」,一个可能说「其实还早」, 不该让读者以为这是量出来的。

顺带说清一件事:缓存的清理粒度比条目本身大。源码里 SkTextBlob 被注明可以作为缓存条目的键, blob 被删掉时相关的缓存可以随之清理(§5.3)。也就是说,缓存的寿命不完全由「用了多久」决定, 也由「引用它的那段文本还在不在」决定。这条线索在本篇只记下来,不展开——它需要另一套实验才能量。

10.2 本章收在哪里

下面我们进入下一章。 到这里,本篇想量的事基本量完了:SKP 的字节账、往返的无损性、换后端的 1.58%、三个开关的像素代价、 字号两条线、以及缓存那一笔乘法。最后一章要把镜头拉开,逐条交代这套方法测不了什么。

官方依据:两个缓存上限见 SkStrikeCache.h;SkPackedGlyphID 与 kSubPixelPosLen 见 SkGlyph.h; strike 规格与键的构成见 SkStrikeSpec.cpp; blob 作为缓存键的说法见 api.skia.org · SkTextBlob。


十一、这套方法测不了什么

先看看这一章的规矩。

这一章是「自跑」类文章的固定收尾:把给不出数字的量逐条列出来,并给原因。 理由很简单——不用能测的量去近似不能测的量,是这类文章唯一值得坚持的纪律。

11.1 能测与测不了

能测与测不了
图 8-26本篇能测的量与测不了的量,以及各自的判定依据@ 本系列绘制

本机能测的(都在前文出现过):

  • SKP 的字节账与往返一致性:§1.2 的 333 字节、§2.2 的五个数量级、§2.3 的 ±355 字节差值;
  • 同一份 picture 的两条后端回放差:§4.1 的 2,070 / 131,072 = 1.58%,以及 §4.2 的差异位置;
  • hinting 与亚像素的像素级对照:§6.1 的 521 个差异像素、§7.1 的「八档塌成三档」;
  • 头部版本号的读取闸门:§3.2 的 [82, 109] 闭区间,以及 cull rect 可任意改写。

本机测不了的(逐条给原因):

  1. 真·旧版本 SKP 文件能不能读:手上没有旧版本 Skia 产出的真实样本。§3.2 测的是闸门端点,不是旧文件本身;
  2. strike cache 的实际条目数:Python 绑定没有暴露统计接口。§10.1 给的是推算;
  3. 跨后端的片元级证据:没有抓帧工具。差异只能停到「哪些像素」,停不到「哪一次着色调用」;
  4. Graphite 上的回放行为:绑定里没有 MakeDawn,跑不起来;
  5. 跨机型、跨驱动、跨平台的结论:全篇读数来自一台机器、一块显卡、一个驱动版本。

为什么不做这五件事,比「测不了」本身更重要。前四件都有一条共同的性质:它们能测的量与想测的量之间隔着一个不可忽略的近似。 比如,「用 CPU 侧的光栅化种类数去代表缓存条目数」听起来顺,但 §7.1 已经量到了这个数会随字号和字串浮动; 「用像素差去代表片元差」听起来顺,但 §4.2 已经说了前者是能算的、后者是要抓帧的。 把它们混起来,读数就会变成「看起来很硬的软数字」——这才是自跑类文章最该防的错。

其中:这里有一条判断可以带走——当一个量给不出数字时,给原因,不给估算。 估算不是不能给,但必须像 §10.1 那样标死「这是推算」,并且把推算用的两个输入项(乘数与上限)都摆出来让人复核。

11.2 本章收在哪里

下面我们进入下一章。 能测的、测不了的,都摆在桌面上了。最后用三句话把这一篇收住,再把题域让出去。

官方依据:Python 绑定未暴露的接口可由 skia-python · skia.Picture 与 skia.Font 两个参考页逐个核对; GPU 侧「用哪一套 AA 要看 surface 配置」的原文见 skia-discuss 回帖; 本节引用的打包工具与调试器见参考资料第 30–32 条。


十二、边界回指与预告

好了,用三句话收住这一篇。

这一篇只做了三件事,用三句话收住。

第一句给 SKP:它录的是命令,所以它是无损的;但它把字体一起带走了,所以它是重的。 一份中文字串的 19.7 MB 与一份矩形的 173 字节,差的不是画面复杂度,是那台机器上的那份字体。 这条账把「SKP 是不是一个好的交付格式」这个问题拆成了两半——保真度不是问题,体积与字体依赖才是。

第二句给回放:同一份文件换后端会变,变的那 1.58% 全在边缘。 两篇「自跑」量到的比例落在同一个量级上,说明这个差是两条后端之间稳定的差异,而不是某次运行的噪声。 再往回追一层,差异的根在抗锯齿的依据不同:一条路算覆盖率,一条路可能叠样本。

第三句给字体:几个开关的代价都不在「画得快不快」,在「同一份结果要不要重算」。 hinting 动轮廓(§6.1 的 521 个像素),亚像素动位置(§7.1 的「八档塌成三档」),字号动的是「要重算多少格」(§9.3 的 11.2 倍)。 这三件事共同解释了同一个现象:在字体这一层,「把一张图缩放着用」是一条走不通的路。

把题域让出去之前,先标清这篇的两条自我约束。其一,零结果也是结果:§6.1 的三档重合、§9.2 的 zero 影响, 都照实写了,没有换成「理论上应该有差别」。其二,同一个数必须能追溯到它的来源: 本篇所有读数都落在同一份读数文件里,正文与图注里的数字都以它为准——这样任何一条读数对不上时, 能立刻知道是正文写错了还是图注写错了。

下面我们进入下一篇。

下篇预告:第九篇换一个引擎——Vello。它走的是另一条路:GPU compute 加 sparse strips 与 prefix-scan, 把「哪些像素要填」变成一份可以直接并行处理的条带清单。第八篇量到的那些「边缘上必然对不齐」的差, 在 Vello 那条路上会变成什么样,是下一篇要看的事。

官方依据:本篇所有机制性结论的出处见参考资料第 1–18 条;本机读数所依赖的绑定与建面方式见第 25–27 条; 外部示意图的来源与许可见第 38–47 条。


参考资料

  1. api.skia.org · SkPicture(records drawing commands 与 playback)
  2. api.skia.org · SkPictureRecorder(beginRecording 与 finishRecordingAsPicture)
  3. Skia 用户文档(SkCanvas 与后端相关章节)
  4. Skia 开发者提示(dm / skpbench 的逐字命令)
  5. Skia 官方调试工具文档(Debugger 支持 .skp 与 .mskp)
  6. skpbench README(回放 SKP 的基准工具)
  7. Skia 源码 · SkPictureData.cpp(分块与 flattenToBuffer)
  8. Skia 源码 · SkPicturePriv.h(tag 宏与版本区间常量)
  9. api.skia.org · SkFont(setEdging / setSubpixel / getMetrics)
  10. api.skia.org · SkTextBlob(immutable container 与不做回退)
  11. Skia 源码 · SkFontTypes.h(hinting 四档逐字注释)
  12. Skia 源码 · SkFont.h(setEdging 的 Requests, but does not require)
  13. Skia 源码 · SkGlyph.h(SkPackedGlyphID 与 kSubPixelPosLen)
  14. Skia 源码 · SkStrikeCache.h(2048 条与 2 MB 两个上限)
  15. Skia 源码 · SkFontMgr.h(字体管理器与反序列化)
  16. Skia 源码 · SkTextBlob.h(blob 的官方注释)
  17. Skia 源码 · SkSurfaceProps.h(surface 配置与 AA 选择的关系)
  18. skia-discuss · GPU 侧抗锯齿的多种机制(官方回帖)
  19. skia-python · skia.Picture(serialize 与 MakeFromData)
  20. skia-python · skia.PictureRecorder
  21. skia-python · skia.Font
  22. skia-python · skia.Typeface
  23. skia-python · skia.TextBlob
  24. skia-python · skia.FontMetrics
  25. skia-python 发行页(本篇绑定版本 144.0.post2)
  26. skia-python 教程 · Canvas Creation(建面与建上下文)
  27. GLFW 官网(本机建 GL 上下文用的库)
  28. Skia 源码 · SkSerialProcs.h(序列化行为与字体数据)
  29. Skia 源码 · SkStrikeSpec.cpp(strike 规格与缓存键)
  30. Skia Web Debugger(在线打开 SKP 逐条看 op)
  31. Skia Fiddle(只能导出 SKP,不能载入回放)
  32. Chromium · 如何保存 .skp 文件
  33. halilibo · Why text gets jittery when scaled on Android(hinting 与线性性)
  34. Mozilla Bug 1230366 · Skia Text is thin on OS X(换后端后字形偏细)
  35. Flutter CanvasKit 字体渲染不一致(社区报告)
  36. CSDN · 一次字体渲染差异的排查(中文梳理,二手参考)
  37. deepwiki · Skia 的 dm 测试运行器与 nanobench
  38. Commons · Category:Font rasterization(本篇 EXT 的主类目)
  39. Commons · Category:Subpixel rendering(亚像素排列与 LCD 原理)
  40. Commons · Category:Font hinting(hinting 对照样张,尺寸偏小)
  41. Commons · Comparison raw text converted path.svg(文本与轮廓路径的对照)
  42. Commons · ClearTypePixels.svg(题图:亚像素渲染的像素级放大)
  43. Commons · ClearType white pixel.svg(一个像素的三个子像素)
  44. Commons · Subpixel arrangement-RG-B-GR.svg(子像素排列方向)
  45. Commons · Bitmap vs vector.svg(位图与矢量放大)
  46. Commons · Aliasing 2.svg(有不抗锯齿对照)
  47. Commons · Category:ClearType(真 ClearType 样张,多数长边不足 1280)

本篇配图三类:外部示意图取自 Wikimedia Commons(图注标作者与许可);本机真跑/真算的读数图由本篇测量脚本产出(图注写明口径); 自绘图为本系列绘制,走 styles/qianmo/palette.json 色板单源。

站点链接:技术博客页

下篇预告:下一篇换一个引擎——Vello 走的是 GPU compute 加 sparse strips 与 prefix-scan,把「哪些像素要填」变成一份可并行处理的条带清单。

本文由@溯光 出品,转载请注明出处。本文同步发布于公众号与个人博客。

With Best Wishes.