Skip to content

Optimize font startup for 1.21.4 优化字体首次加载与图集上传 - #270

Merged
Gu-ZT merged 1 commit into
Anvil-Dev:dev/1.21.4from
WhereisFff:codex/font-startup-1.21.4
Nov 13, 2026
Merged

Gu-ZT merged 1 commit into
Anvil-Dev:dev/1.21.4from
WhereisFff:codex/font-startup-1.21.4

Conversation

@WhereisFff

Copy link
Copy Markdown
Contributor

perf(font): 优化字体首次加载与图集上传

  • 分离字体度量和字形生成,提前预热常用样式并按需生成字形
  • 使用有界后台任务队列处理字形,缩短图集发布锁并保持文字宽度稳定
  • 复用字体纹理并只上传变化区域,避免整页转换和重复创建,及时释放临时图像
  • 保留完整文本布局缓存,补充异步生成、并发请求及增量上传回归测试

- 分离字体度量和字形生成,提前预热常用样式并按需生成字形
- 使用有界后台任务队列处理字形,缩短图集发布锁并保持文字宽度稳定
- 复用字体纹理并只上传变化区域,避免整页转换和重复创建,及时释放临时图像
- 保留完整文本布局缓存,补充异步生成、并发请求及增量上传回归测试
@Gugle2308

Copy link
Copy Markdown

🌿 Roseau API Breaking Change Report

Module Status Breaking Changes
codec ✅ Compatible 0
collision ✅ Compatible 0
cube ✅ Compatible 0
config ✅ Compatible 0
integration ✅ Compatible 0
moveable-entity-block ✅ Compatible 0
network ✅ Compatible 0
rendering ✅ Compatible 0
font ✅ Compatible 0
explosion ✅ Compatible 0
rpc ✅ Compatible 0
space-select ✅ Compatible 0
util ✅ Compatible 0
math ✅ Compatible 0
multiblock ✅ Compatible 0
recipe ✅ Compatible 0
registrum ✅ Compatible 0
sync ✅ Compatible 0
ui ✅ Compatible 0
wheel ✅ Compatible 0
main ✅ Compatible 0

Full CSVs: see the Artifacts section of this workflow run.

@Gu-ZT
Gu-ZT merged commit 9218d9e into Anvil-Dev:dev/1.21.4 Nov 13, 2026
1 check passed

@Gugle2308 Gugle2308 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论:可以合并(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。)


💡 四、建议(均不阻塞)

  1. SdfAtlasTexture.java:33 if (upload == null) return id; 返回的是可能从未 register 过的 ResourceLocation。当前不可达(create == false 要求版本不一致或 dirty,而 dirty == true 时脏区必然非空 —— placeGlyph 置脏同时扩框、uploaded() 清脏同时清框、updateHash() 也是整页框);且 uploadPage 的唯一调用点 ensureUploaded():64 忽略返回值,所以不影响行为。建议改成 return entry != null ? entry.id : id; 或加 assert,避免后人按返回值语义依赖。

  2. 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 常驻。属于“要不要保留调试基线”的取舍,请作者定。

  3. 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。

  4. 预热与首帧请求共用一条 FIFO:4 个图集 × 95 码位 = 380 个字形排在 GLYPH_EXECUTOR(1–2 条平台线程)上,首帧输入的中日韩字符会排在其后。按 cellSize ≈ 64 估算 blitSdfGlyph 是 O(w²) 两遍距离变换,总时长在百毫秒量级,通常在进世界前跑完,风险不大 —— 但建议在 preWarmAscii() 的 javadoc 里点明这条 FIFO 顺序,便于以后有人遇到“首帧缺字”时不必重新推导。

  5. 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 / build success 得出,不是直接读到 test 报告。
  • 未验证项:GPU 侧实际上传结果(需要实机)、AWT FontMetrics 在无头 CI 环境下的字体可用性(测试固定用 Dialog 逻辑字体,通常安全)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants