用 Vibe Coding 搭建中文位图字体懒加载方案

2026-03-30codingclaude codepixi中文字体vibe-coding

背景

在 WebGL 游戏引擎里渲染中文字体,一直是个不太优雅的事情。

以 PixiJS 为例,它渲染中文的方式是:先把文字画到一个隐藏的 Canvas 上,再把 Canvas 转成 Texture 送进 WebGL。每次改文本,都要重新走一遍这个流程。文本少的时候感受不到,但当场景里有成百上千个文本对象在移动、变色、换内容时,性能就崩了。

PixiJS 的解决方案是 BitmapText —— 把字体预渲染成图片(atlas),运行时直接当 Sprite 来用,性能好得多。但这个方案面对中文时有个致命问题:中文字符太多了。一个中文字体动辄上万个字符,全部预渲染成图片既不现实也没必要。

我之前做过一个 font-slice 项目,参考 Google Fonts 的思路把中文字体按 unicode-range 切片,实现了网页中文字体的按需加载。那 WebGL 场景下能不能也做类似的事?

于是我用 Claude Code,花了几天时间从空仓库搭出了 bitmap-font-generator —— 一个面向 PixiJS 的中文位图字体生成与懒加载方案。

我会把完整的会话放出来,介绍完整的 vibe coding 流程。

从空仓库到架构收敛

立项

第一条指令就是一大段完整的产品定义:

这是一个空仓库,你需要完成以下项目: 中文字体不仅仅在网络 html 中存在着展示难题,在 canvas 渲染,游戏渲染中依旧存在着大量问题。 比如像是 webgl 加速的游戏渲染引擎 pixi 中,中文字体的渲染其实就是先把字体渲染到 canvas 上,再把 canvas 转成 texture 来进行渲染。 这会导致文本的性能变得很差,同时更改文本时也会导致需要重新生成 texture,也会影响性能。而 webgl 渲染图片包括 Sprite 图时则是非常快的。 pixi 对于这种字体的解决方案是 Bitmap Text,也就是位图字体。大概原理就是以图片的形式加载字体。 以一种 fnt 字体为例,就是其中包含的字体集合和一张对应的图片,其格式如下。 info face=“Ark Pixel 12px monospaced zh_cn” size=32 bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=1 aa=1 padding=0,0,0,0 spacing=1,1 outline=0 common lineHeight=32 base=27 scaleW=256 scaleH=256 pages=1 packed=0 alphaChnl=1 redChnl=0 greenChnl=0 blueChnl=0 page id=0 file=“test_0.tga” chars count=94 char id=33 x=144 y=72 width=4 height=22 xoffset=5 yoffset=5 xadvance=16 page=0 chnl=15 这种字体,在处理符号以及英文字体时比较合适,但面对中文字符时,过多的字符数量就显得有些不切实际。 在 ~/js/font-slice 仓库里,可以把中文字体按照特定的集合分割,可以参考该仓库的分割方案。 那么能不能在字体渲染时,我需要将中文字体按照某个标准字号,比如 14 ,将中文字体生成一个 fnt 字体,和多张对应的 Sprite 图,每张图都是每个切割集合里的所有字体拼凑的。 像是在网页中加载字体一样加载位图字体,支持根据展示的内容自动加载对应的字体子集,以相对小的体积支持全量字体。 同时也要基于 pixi 实现字体的自动加载,即先不展示内容,可以展示 loading 占位符,在字体子集加载完成后再进行展示。 基于 pixi 实现字体的展示,同时需要支持基于标准字号的 Sprite 图,实现不同的 fontSize、bold、lighter、italic,及各种颜色的展示。 技术选项为 typescript,最终产物是一个 bitmap-font-generator 的 node 包,测试字体可以使用 ~/js/font-slice 中的仓库,测试页面使用 pixi 渲染生成的位图字体产物。 import { bitmapFontGenerator } from ‘bitmap-font-generator’; bitmapFontGenerator({ fontPath: ‘xxx’, outputDir: ‘xxx’ });

Claude Code 很快搭出了项目骨架:生成器、运行时、demo 页面、TypeScript 构建配置。

这一阶段产出了项目最初的骨架:

  • 生成器:src/*
  • 运行时:runtime/*
  • 测试与 demo:test/generate.tstest/web/*
  • 构建配置:package.jsontsconfig.jsontsup.config.ts

fnt 是必要的吗?

初版方案是”每个字符子集 → 一个 fnt 文件 + 一张图片”。跑通之后我开始连续追问:

为什么需要给每一个子集生成一个 fnt 文件,可以只生成一个 fnt 文件,图片拆分成多个吗

单 fnt + 多 png,不能支持当字体使用时才按需加载对应图片吗

fnt 能压缩吗

有必要做 fnt 格式吗,听起来直接 json 格式,好像使用起来没区别

fonts/HYWenHei_manifest.json 有必要加载吗,是干嘛的

是不是也可以只保留 manifest,不保留 fnt

pixi 加载这套字体,使用方法是什么样,和直接使用 bitmap Text 有什么区别

LazyBitmapText 的原理

如果保留 fnt 文件,manifest 也是必要的吗

是不是自己封装一个格式,压缩一下,体积更小效率更高

如果保留 manifest,是不是 fnt 格式可以去掉了

能直接让 BitmapText 懒加载吗

最后给出了明确的方向:

不需要 fnt 文件,只保留一个 manifest 文件和图片,同时直接加载目录对应的 /manifest.json,不需要再声明一遍 loadManifest,用法改为 BitmapFontManager.loadFont(‘/fonts/HYWenHei/‘)

这一步决定了整个项目的架构走向:运行时不再依赖 fnt,而是 manifest.json + atlas PNG。manifest 里存字符指标和子集索引,图片存预渲染好的字形。AI 按我说的方向完成了重构。

图片压缩实验

架构定了之后,开始折腾图片体积。这一轮实验比较密集:

如果对图片进行一次 pngquant 压缩,图片体积能减少多少,展示效果会有变化吗

把 pngquant 集成到流程里,能用 npm 包吗,不要求机器本身装了 pngquant

不同平台系统,安装这个包,pngquant 都能正常执行吗

换成 sharp 压缩,开启 palette,是不是对于字体图,只需要 colors:2 就可以了,compressionLevel: 9

为啥 colors:2 还不如 pngquant 压缩

colors 改为 16 看下呢

抗锯齿可能是什么原因,只保留 alpha通道的信息是不是就可以了

LA 格式再只保留 colors: 4 和普通格式保留 colors:4 对比一下尺寸

colors=4 palette 生成一下

图片压缩,使用 sharp 转为 webp, quality=50, alphaQuality=60, effort=6

webP q=0 alphaQ=10 effort=6 再试一下

q=0 alphaQ=0 再生成一下

WebP 在极限参数下体积确实更小,但兼容性和质量都不太令人满意。最后回到了 PNG:

还是改成 png 来处理吧,确保生成的 png 颜色通道都为纯白,透明通道保留 16 阶,看看尺寸大小

能不能生成单通道单灰度图,而且只保留 16 阶

png grayscale,colors=16 应该对应的是 4bit

下面的生成参数呢 const singleChannelBuffer = await sharp(canvasBuffer) .ensureAlpha() // 确保输入有透明通道 .extractChannel(‘alpha’) // 1. 关键:提取 Alpha 通道,将其转为像素亮度 .toColorspace(‘b-w’) // 2. 强制转为黑白(单通道)色空间 .png({ palette: false, // 单通道不需要调色板 colors: 16, // 保持 4-bit 深度 compressionLevel: 9, // 最大压缩 adaptiveFiltering: false, // 对于单通道,通常简单过滤更小 force: true // 强制应用 PNG 格式 }) .toBuffer();

又试了灰度图、单通道、alpha-only 等各种思路,最终收敛到 sharp palette colors=4。这个参数在体积、抗锯齿质量和跨平台兼容性之间取得了最好的平衡。

整个压缩实验的过程就是:AI 负责跑参数、生成样本、计算体积对比,我负责看渲染效果做判断。

HiDPI 支持

如果希望图片的 resolution 为 2,应该怎么生成

传入 resolution:2 生成一遍,计算下图片总大小

加上 resolution: 2 后遇到了渲染错乱的问题 —— 字体紧凑在一起、每个字符只渲染了一部分。原因是逻辑坐标和物理像素坐标混用。修正了 metrics 和 frame 的换算之后,HiDPI 支持就通了。

占位符体验 + 性能测试

未加载字符的展示

字体是按需懒加载的,那用户看到的文字在加载完成前应该长什么样?

当字体还没加载出来时,使用点占位符,保持每个字符长度都和最终渲染的长度一致。当一部分字体加载出来时,一部分字体正常展示,还没加载完成的字体还是点占位符,在一段话中。

第一版实现是把一段文字拆成多个 BitmapText 片段(已加载的用真实字形,未加载的用点)。但这带来了额外的 draw call。于是我问:

能不能一直只用一个 BitmapText,加载中的字符先被映射到一个 Loading 的字符,加载完成后再映射到正确的字符,加载完成后再重新渲染 BitmapText

这样的话相比之前有什么优劣势吗

可以在渲染字体时,如果没加载的字符注册对应的 dot 条目吗,而不是开始就全量注册

这个思路更好 —— 始终只维护一个 BitmapText 实例,未加载的字符映射到一个占位 glyph(小圆点),加载完成后再替换成真实字形。布局一致性、复杂度和 draw call 都更优。

接着推动了实现上的进一步收敛:

现在 LazyBitmapText 是 Container 中包一个 BitmapText 吗

有办法让 BitmapText 原生支持字体懒加载吗,而不是现在需要使用 LazyBitmapText

改为 extends BitmapText 吧

从 Container 包装改成直接继承 BitmapText,减少了一层嵌套。

性能测试

帮我在 demo 中新增一个性能测试页面,有大量的不同中文字体的渲染和移动和特效比如颜色变化,对比 pixi 常规的 Text 和 LazyBitmapText 的性能

benchmark 搭起来之后,发现了一个尴尬的事实:

为啥数量 5000 时,PixiJS.Text 还性能更好,LazyBitmapText 帧只有 30 了,Text 还有 100

进一步排查:

现在为什么 LazyBitmapText 比 Text 性能更差,当有字体更新时,不应该让已完成渲染的 BitmapText 重新渲染

为什么每个 subset 加载完毕都会导致已有实例重新更新,应该只有依赖该 subset 的实例才更新

现在为什么 LazyBitmapText 比 Text 性能更差,当有字体更新时,不应该让已完成渲染的 BitmapText 重新渲染,优化一下当前 LazyBitmapText 性能

benchmark 中 LazyBitmapText 性能差于 Text, 而且 fps 会越来越慢

问题定位清楚了:每个子集加载完成时,所有 LazyBitmapText 实例都会重新渲染,而不是只有真正依赖该子集的实例才刷新。这就是”更新粒度过粗”。

增量更新 + Pixi v8 迁移

增量更新

基于之前定位的问题,把字体更新从全量重建改成增量补丁:

帮我看下当前 LazyBitmapText 的实现,初始化时或字体更新时判断是否所有字体 subset 都已成功加载,仅当存在 subset 没加载时,才 subscribe 对应依赖的 subset 加载完成时,刷新字体渲染。

Font 在子集加载完成返回后,怎么更新字体的,字体更新会导致依赖该字体的所有 BitmapText 都被重新渲染吗

每个字体子集文件加载成功后就重建 BitmapFontData,这一步会有性能问题吗,可以只改新加载的字符对应的 Texture 吗

最终实现:新子集到达后,只把新增字符的 glyph 数据和对应的 Texture 补进已有的 BitmapFont,而不是整套推倒重来。同时 benchmark 也做了公平性修正 —— 两边使用同样的字体:

性能对比测试也应该两边使用一样的字体,Text 直接全量加载字体吧

Pixi v8 迁移

将本项目改为使用 pixi v8 实现,可以列出 v8 有没有相关 api 的支持,针对中文位图字体的最佳实践

迁移过程中遇到了不少兼容问题:app.init API 变化、BitmapFont 初始化方式不同、resolution 导致纹理裁切等。其中最诡异的一个 bug 是迁移后 demo 字体一片空白且控制台无报错,最终定位到是把 letterSpacing: undefinedalign: undefined 原样塞进了 TextStyle,覆盖了 Pixi 的默认值,导致布局宽度变成 NaN

收尾打磨

最后再做一些细节收敛:

  • atlas 命名规则 —— 单页不带 _0 后缀,多页从 _1 开始
  • atlas 尺寸自动选择,最大 2048,大子集按 page 拆分
  • demo 的 breakWords: true、benchmark 统计口径调整
  • README、部署链路

最终形态

最终项目收敛成了这样一个方案:

  1. 构建时:读取字体文件,按 Google Fonts 的 unicode-range 切分子集,每个子集渲染成 atlas PNG,输出 manifest.json
  2. 运行时:先加载 manifest.json(很小),根据实际要显示的文字按需加载对应子集的图片
  3. 用户体验:未加载的字符显示占位点(宽度与最终字符一致),加载完成后无缝替换
// 加载字体目录
await BitmapFontManager.loadFont('/fonts/HYWenHei/');

// 使用 LazyBitmapText,自动触发懒加载
const text = new LazyBitmapText({
  text: '你好世界',
  style: { fontFamily: 'HYWenHei', fontSize: 32 }
});

体积示例

以下数据来自仓库内的示例字体 HYWenHei-55W.ttf,生成参数为每个字符 32pxresolution: 2

  • 原始 TTF 文件约 3.1 MB
  • 生成后的 atlas PNG 总计约 2.47 MB
  • manifest.json gzip 压缩后约 47 KB
  • 单张 atlas 图片大多在 10 KB52 KB 之间

重点不在于把“整套资源的总和”压到极致,而是在于运行时只按需加载少量子集图片。实际页面通常不会一次请求全部 atlas。

三年前我做 font-slice 时,解决的是网页中文字体的按需加载 —— 利用 CSS unicode-range 让浏览器自动按需请求字体子集文件。

两年前我写了一篇文章,提到当时想做基于 pixi 做的中文 BitmapText 想法,当时还是 pixi v7,一直没有时间以及觉得有点麻烦而没有去写实际的代码。

今天我告诉 Claude code,纯 vibe coding,让它帮我完成了这部分的搭建,两三天时间里,做得又快又好。(尽管可能在实现中有过一些问题,让它改了很多轮,但还是值得一句又快又好)。

时代变了,但是我也不觉得,一个不懂代码的人,如果来做这个项目,能做得比我更好。

这次的 bitmap-font-generator 解决的是 WebGL/Canvas 游戏引擎里的同类问题。底层的切字策略可以复用(都基于 Google Fonts 的 unicode-range 数据),基于该项目,再做一些 cocos 2d 等的 BitmapText 接入,应该也不会太麻烦。

如果需要更小的字体体积,可以在最后资源打包时,扫描整个项目使用的中文字符,再打包对应的图片。

这个还有不少可以优化的地方,但我估计也没啥人用,等有人用有需求时再 call 一下吧,几年前埋下的坑,AI 终于帮我填了一下。

感谢看到这里~

点赞 0