Repository navigation
Optimize font startup for 1.21.4 优化字体首次加载与图集上传 - #270
Conversation
- 分离字体度量和字形生成,提前预热常用样式并按需生成字形 - 使用有界后台任务队列处理字形,缩短图集发布锁并保持文字宽度稳定 - 复用字体纹理并只上传变化区域,避免整页转换和重复创建,及时释放临时图像 - 保留完整文本布局缓存,补充异步生成、并发请求及增量上传回归测试
🌿 Roseau API Breaking Change Report
|
Gugle2308
left a comment
There was a problem hiding this comment.
结论:可以合并(APPROVE)。这是把 dev/1.21.1 已合并 PR #269 向 dev/1.21.4 的忠实移植:除了两处必须改的 1.21.4 差异外,8 个文件中至少有 6 个文件与 #269 逐字节相同;这两处针对 1.21.4 的适配已通过字节码核实正确,未发现阻塞问题,只有 1 项需要作者确认的取舍和若干清理建议。
已完成的前置检查:state=open / merged=false / draft=false / commits=1 / changed_files=8 / +295 −135,CI build / build = success。patch-diff(693 行 / 8 个 diff --git)与 API 统计一致。
✅ 一、已验证:移植忠实度与 1.21.4 API 适配
1. 与已合并 #269 的逐文件对账(git rev-parse 比较 blob):
| 文件 | 与 #269 的关系 |
|---|---|
SdfGlyphPage.java / SdfGlyphAtlas.java / SdfTextLayout.java / AnvilLibFont.java / FontStartupTest.java |
逐字节相同 |
SdfAtlasTexture.java / SdfTextRenderer.java |
有差异,需逐一确认(见下) |
两处差异都是 1.21.4 必需的适配,且方向正确:
SdfAtlasTexture.java:83→backing.upload(0, x, y, x, y, w, h, false)。#269 是..., true, true, false, false)。反汇编fev.b(IIIIIIIZ)V(113 字节)确认 1.21.4 只剩 1 个尾随布尔 =freeImage(if (iload 8) close()),filter/wrap 两个布尔已移到AbstractTexture.setFilter,而SdfTexture构造器:76已调setFilter(true, false)。写法正确。SdfAtlasTexture.java:98→image.setPixel(x, y, 0xFF000000 | …)。#269 是setPixelRGBA(...)。反汇编fev.a(III)V=this.setPixelABGR(x, y, ARGB.toABGR(pixel))(11 字节),所以传入 ARGB 布局的0xFF000000|gray<<16|gray<<8|gray与基线同色。正确。
2. DynamicTexture 在 1.21.4 上是必须手动 upload,不是冗余调用:heg 只 implements hef (Dumpable),没有 hew (Tickable),即不做每帧自动重传。因此 SdfTexture.upload() 里的 bind() + upload() 是唯一让增量像素上 GPU 的路径 —— 这点若误判会得出“多余”的错误结论,特此记录。
3. copyRect 方向正确:反汇编 copyRect(NativeImage,int×6,boolean,boolean)(101 字节)确认循环内读 aload_0(接收者 = 源)、写 aload_1(参数 = 目标),所以 image.copyRect(backing, 0, 0, x, y, w, h, false, false) 是把 image 拷进 backing 的 (x,y),方向与注释一致。
4. setFilter 不会被 TextureManager.register 冲掉:register 只做 map.put + safeClose(旧纹理) + instanceof Tickable 判定,不触碰 filter/clamp,所以构造器里的 setFilter(true, false) 在注册后仍然生效。
5. 并发设计没有丢唤醒:glyph():180 的 pendingGlyphs.add → glyphQueue.add → scheduleWorker(),配合 drainGlyphs():197 的 finally { workerScheduled.set(false); if (!queue.isEmpty()) scheduleWorker(); },逐条推演“生产者 add 与 worker poll 返回 null 交错”的各种次序,都能收敛到“要么本次 CAS 成功执行、要么 worker 在收尾时的 isEmpty() 看到新元素”,不存在丢唤醒。pages 用 CopyOnWriteArrayList、snapshot() 在锁外做 native 分配、uploaded() 用版本号戳记拒绝陈旧 ack(SdfGlyphPage:74-79),这三处都是正向设计。
6. 测试确实进入了 CI:.github/modules.json:10 有 font 模块,gradle/scripts/ci.gradle:21 执行 dependsOn(":${target.path}:build"),module.font/build.gradle:270-277 补上了 JUnit BOM + useJUnitPlatform(),因此 :font:build 会跑 test;配合 build / build success,这 4 个测试在本轮 CI 中实际执行并通过(module.font 在 1.21.4 线上首次有测试)。根 build.gradle:22-24 已有全局 test { useJUnitPlatform() },模块里再写一次是冗余但无害,且与 module.cube/build.gradle:278-290、module.math/build.gradle:113-137 的既有写法一致 —— 不作为不一致上报。
⚠️ 二、请确认:运行时切换字体的图集不再预热
基线里 preWarmAscii() 是写在 SdfGlyphAtlas 构造器里的,所以任何路径新建的图集都被预热;本 PR 把预热上移到 AnvilLibFont 构造器(AnvilLibFont.java:29、:31),只覆盖启动时创建的那 4 个图集(base + BOLD / ITALIC / BOLD|ITALIC)。
问题在于 getSelectFont() 自身不预热(AnvilLibFont.java:35-41 只调 getOrCreate(font)),而配置界面改字体正是走这条路径:FontConfigScreen.java:169 CONFIG.setFont(selected.id()) → :115 AnvilLibFont.getSelectFont() → 新 key → 只等 metrics、不预热的新图集。
结果对玩家是可见的:换字体后首次绘制该字体时,字形尚未栅格化,computeLayout():95 拿到 null → 按 scaledAdvance 占位、不产生 quad,文字会“逐帧长出来”(宽度稳定、不跳版,但会有短时间缺字)。基线的表现则是首次 join() 一次性卡 200–400 ms 之后全暖 —— 这个 PR 正是要消掉那个卡顿,所以这属于有意的取舍而非 bug,但预热覆盖面比基线窄了,建议:
// getSelectFont() 内,让所有创建路径都预热,构造器里的 4 行可删
SdfGlyphAtlas.getOrCreate(font).thenAccept(SdfGlyphAtlas::preWarmAscii);preWarmAscii() 内部经 glyph() 的 glyphMap / pendingGlyphs 双重去重,重复调用无副作用。
⚠️ 三、本 PR 造成的死代码(应清理)
git grep -c 全模块计数,base → head:
| 符号 | base | head | 说明 |
|---|---|---|---|
updateHash() |
6 | 1(仅声明) | SdfGlyphPage.java:55,所有调用点(含 preWarmAscii、createGlyphAsync、measureText、SdfTextLayout)都被本次重构移除 |
invalidateAtlas(String) |
2 | 1(仅声明) | SdfTextLayout.java:62,基线由 SdfGlyphAtlas 在字形创建后调用 |
两者所在类都是 @ApiStatus.Internal,删除安全。另外 updateHash() 的语义也变了(现在除了 version++ 还会把脏区重置为整页并置 dirty = true,见 SdfGlyphPage:55-60)—— 名字叫 “hash”、实际是“整页作废”,一旦被误用就是整页 4 MiB 重传。建议直接删,或至少加 @Deprecated + javadoc 写明现在的语义。
(已核对:getIfReady() 与 clearCache() 在 base 上就已经是 0 调用者,属既有死代码,不计入本 PR。)
💡 四、建议(均不阻塞)
-
SdfAtlasTexture.java:33if (upload == null) return id;返回的是可能从未register过的ResourceLocation。当前不可达(create == false要求版本不一致或dirty,而dirty == true时脏区必然非空 ——placeGlyph置脏同时扩框、uploaded()清脏同时清框、updateHash()也是整页框);且uploadPage的唯一调用点ensureUploaded():64忽略返回值,所以不影响行为。建议改成return entry != null ? entry.id : id;或加assert,避免后人按返回值语义依赖。 -
SdfGlyphPage.image(:14)在生产路径上只剩宽高读者:placeGlyph():38仍image.getRaster().setRect(...)写入,但唯一读取像素内容的只有测试FontStartupTest:87;生产侧SdfTextLayout:107-110,130只取getWidth()/getHeight()(恒等于PAGE_SIZE)。可考虑把SdfTextLayout改用SdfGlyphAtlas.PAGE_SIZE常量、让image退化为测试专用 oracle 并在字段上注明 —— 顺带省掉每字形一次paddedCellSize²的 BufferedImage 拷贝和每页 1 MiB 常驻。属于“要不要保留调试基线”的取舍,请作者定。 -
FontStartupTest:119-121的assertEquals(codepoints.length, revisions)偏脆:它隐含“该图集从未被preWarmAscii()、updateHash()动过”,且SdfGlyphAtlas.CACHE是静态无清理入口(连测试也无法重置)。今天成立(4 个测试各用不同字号 60/61/62/63,key 不撞),但后续任一新测试复用同字号或加入预热就会莫名失败。建议改成assertTrue(revisions <= codepoints.length),或加注释声明字号必须唯一 + 提供一个包私有clearCacheForTests()。同理:43-44的第二条断言(measureCodepoint(cp) == glyph.advance())在当前实现下是恒等式(createGlyph就是用measureCodepoint填advance),真正有信息量的是:43的metrics.charWidth == measureCodepoint。 -
预热与首帧请求共用一条 FIFO:4 个图集 × 95 码位 = 380 个字形排在
GLYPH_EXECUTOR(1–2 条平台线程)上,首帧输入的中日韩字符会排在其后。按cellSize ≈ 64估算blitSdfGlyph是 O(w²) 两遍距离变换,总时长在百毫秒量级,通常在进世界前跑完,风险不大 —— 但建议在preWarmAscii()的 javadoc 里点明这条 FIFO 顺序,便于以后有人遇到“首帧缺字”时不必重新推导。 -
module.font/build.gradle:268-269连续两个空行,属笔误级;另外这里的test { useJUnitPlatform() }与根build.gradle:22-24重复,保留也符合module.cube先例,只是可以顺手去掉。
验证边界
- 无本地 Gradle / JDK 执行,未运行
runClient,未运行 JUnit。结论来源:静态源码阅读 + patch-diff 与 API 统计对账 + 对 1.21.4 官方映射表与混淆客户端 jar 的常量池/操作码反汇编(NativeImage/DynamicTexture/AbstractTexture/TextureManager的copyRect、upload、setPixel、register、filter语义)+ CI 状态与modules.json/ci.gradle配置推断。 - “这 4 个测试在 CI 中通过”是由 CI 配置链路推断(
modules.json含font→ci.gradle跑:font:build→build.gradle接了test)+build / buildsuccess 得出,不是直接读到 test 报告。 - 未验证项:GPU 侧实际上传结果(需要实机)、
AWT FontMetrics在无头 CI 环境下的字体可用性(测试固定用Dialog逻辑字体,通常安全)。
perf(font): 优化字体首次加载与图集上传