Skip to content

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

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

PigeonNian merged 1 commit into
Anvil-Dev:dev/1.21.1from
WhereisFff:codex/font-startup-1.21.1

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.

@PigeonNian
PigeonNian merged commit 0e2171d into Anvil-Dev:dev/1.21.1 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.

结论:内容正确、可以合并(审查期间已由 0e2171db squash 合入 dev/1.21.1,2026-11-13T15:30:35Z);8 文件 / +293 −138 与 patch-diff 逐条对得上,CI build / build 通过,无阻塞缺陷。以下为合并后追记,重点是几个建议后续清理的冗余与死钩子。

✅ 一、已验证

项 证据
合并状态与范围 API:state=closed、merged=true、merge_commit_sha=0e2171db305cfcb0ffd412a152c39ed25a07fd7d = 当前 dev/1.21.1 tip,squash 提交标题 perf(font): 优化字体首次加载与图集上传 (#269);changed_files=8、+293/−138、commits=1,与 pr269.diff(8 个 diff --git、md5 49b971c4…)一致
分页增量上传的方向 反汇编 com.mojang.blaze3d.platform.NativeImage(1.21.1 混淆名 faj):_upload 用 _pixelStore(3316 /*SKIP_PIXELS*/, iload 4)、_pixelStore(3315 /*SKIP_ROWS*/, iload 5)、_pixelStore(3314 /*ROW_LENGTH*/, iload 6 == getWidth() ? 0 : getWidth()),而 _texSubImage2D 的偏移取 iload_2/iload_3 ⇒ 形参为 (level, xOffset, yOffset, x, y, width, height, …),x/y 是源图原点、xOffset/yOffset 是贴图落点。SdfAtlasTexture.java:81 的 image.copyRect(backing, 0, 0, x, y, w, h, false, false) 正是把补丁写进 backing 的 (x,y),:83 再 backing.upload(0, x, y, x, y, w, h, …) 从 backing 的 (x,y) 取 w×h 贴到纹理 (x,y) —— 方向正确,且 ROW_LENGTH 取的是 backing 的 1024 而不是补丁的 w,行跨距也对
上传参数不会引入 mipmap/翻转 同一方法里 iload 8/iload 10 进私有静态 a(ZZ)V(MIN_FILTER= LINEAR / LINEAR_MIPMAP_LINEAR、MAG_FILTER=LINEAR),iload 9 控制 WRAP_S/WRAP_T = GL_CLAMP_TO_EDGE(33071),iload 11 控制上传后 close();faj.a(IIIIIIIZZ)V(9 参便捷重载)对前两个布尔恒压 iconst_0, iconst_0。本 PR 传的 true, true, false, false ⇒ LINEAR + CLAMP + 不请求 mipmap + 不在这里 close,与构造器 setFilter(true, false) 自洽(翻转语义在 copyRect 的布尔上,不在这里)
脏矩形与补丁尺寸 SdfGlyphPage.java:37-46 的脏盒恰为 (x, y, x+size, y+size)(size = paddedCellSize),:67-70 按 width/height 逐行拷贝;FontStartupTest.java:83 断言补丁长度 == paddedCellSize²、:85-89 与 page.image 逐像素相等,:84 还断言 < 整页/16 —— 与实现一致
并发发布无丢失 SdfGlyphAtlas.java:191-193 CAS + :209-212 finally 里先 set(false) 再查队列,逐序推演不存在丢唤醒(唯一失活序列要求 workerScheduled 为 false 而 CAS 失败,矛盾);SdfGlyphPage 的 pixels/image 全在 synchronized(this) 内写,snapshot/uploaded 同一监视器 ⇒ 与 placeGlyph 之间有 happens-before;dirty 为 volatile,:76 用 version 比对避免把新字形标成已上传
纹理关闭后可恢复 faj/gpy 字节码:DynamicTexture.close() 置空其 NativeImage、upload() 在 image 为 null 时只打警告不再 GL 调用;因此 :30 的 `create = entry == null
新测试真的在 CI 里跑 .github/modules.json 含 "module": "font";gradle/scripts/publication-projects.gradle:7-8 由它生成构建目标,ci.gradle:21 dependsOn(":${target.path}:build") → check → test(build.gradle:296-303 已接 junit-bom 5.10.2 + useJUnitPlatform() + addModdingDependenciesTo(sourceSets.test),测试里用到 ResourceLocation 所以后者必要);check 另挂 fontRegressionTest(:291-293)。sourceSets.test 显式 exclude FontRegressionTest.java、fontRegression 只 include 它,两个 source set 不重叠
度量/栅格拆分与宽度稳定 :235-237 measureCodepoint = advances.computeIfAbsent(codepoint, fontMetrics::charWidth),measureText 不再调 atlas.glyph(...) ⇒ 度量不产生页面(FontStartupTest:33,37 的 pageCount()==0 成立)。旧实现 base:221 对未生成字形回落 cellSize/2 占位宽,新实现直接用 AWT 真值 ⇒ 这是本 PR 修掉的首帧抖动,也是对外的宽度行为变更(同一字符在字形就绪前后宽度一致了)

💡 二、建议(均为合并后清理项,不构成缺陷)

  • SdfGlyphPage.image 已成为「只写 + 只用来取宽高」的 1 MiB 冗余(SdfGlyphPage.java:14):git grep "\.image" 在 module.font/src/main 只剩 :38 的 getRaster().setRect(...) 写、SdfTextLayout.java:107-110,130 的 getWidth()/getHeight() 读,以及测试 FontStartupTest.java:87 的逐像素比对 —— 宽高就是常量 SdfGlyphAtlas.PAGE_SIZE。现在每个字形既要 setRect 拷一遍、又要 System.arraycopy 写 pixels,还常驻一张 1024×1024 的 TYPE_BYTE_GRAY。建议删字段、宽高改静态常量、测试改为与 pixels 对账,能同时省掉一次整格光栅拷贝与每页 1 MiB。
  • SdfAtlasTexture.java:33 的早退返回了一个从未注册的 ResourceLocation:if (upload == null) return id; 里 id 是本地拼出来的名字,此时既没 register 也没写 page.textureId,调用方(:64 的 ensureUploaded 忽略返回值)才没出问题;一旦有人按 javadoc「returning its texture location」拿去用就会拿到绑不上的 id。实际路径上不可达(snapshot(false) 只在 !dirty 且脏盒为空时返回 null,二者与 version 不匹配不共存),建议改成 return entry != null ? entry.id : id,或直接返回 null 并同步改注释。
  • 两个公开钩子已成死代码:SdfGlyphPage.updateHash()(SdfGlyphPage.java:54-60)全仓 0 调用点(base 上有 7 处),且它会把脏盒重置成整页并 version++ —— 后人误用一次就是一次整页 4 MiB 重传;SdfTextLayout.invalidateAtlas(String)(SdfTextLayout.java:62-64)同样 0 调用点(base 上 3 处)。两者都在 @ApiStatus.Internal 类上,建议直接删除,或保留则补 @Deprecated + 说明「仅用于外部强制失效,会让下一帧整页重传」。
  • 布局缓存从「会失效」变成「只增」(SdfTextLayout.java:42-57、:73-83):字形位置不可变,缓存条目确实永远有效,但也意味着再没有任何东西回收它们 —— 会稳定涨到 MAX_CACHE_SIZE = 1024 后每次插入都从 ConcurrentHashMap.keySet() 迭代器里任意摘掉一半(在渲染线程上),动态文本多的场景(F3、聊天、数字)会一直在这个阈值上抖动。建议改 LRU 或按 atlasKey 设上限。
  • 预热规模值得写进注释(AnvilLibFont.java:28-32、SdfGlyphAtlas.java:260-263):base + 3 个样式 = 4 个图集 × 95 个 ASCII = 380 个格子全部塞进共享的 1~2 线程 GLYPH_EXECUTOR。按 ATLAS_FONT_SIZE = 64 估:cellSize ≈ 79、padding ≈ 13、paddedCellSize ≈ 105 ⇒ 每页 9×9 = 81 格 ⇒ 每样式约 2 页、合计约 5–8 页;每页常驻 pixels 1 MiB + image 1 MiB + GPU 侧 4 MiB(SdfAtlasTexture.CACHE 与 SdfGlyphAtlas.CACHE 都没有淘汰)≈ 6 MiB/页。同时首帧请求的 CJK 单字会排在 380 个预热项之后(单队列 FIFO)——这是本 PR 有意拿「首帧延迟」换「启动不卡」的取舍,建议在 preWarmAscii 上注明,避免后人以为是按优先级调度。
  • module.font/build.gradle 尾部排版(:294-303):两个空行 + 第二个顶层 dependencies { },且 neoForge { … } / test { … } 被放到 tasks.named('check') 之后,而不是像 module.math/build.gradle:114,117,127 那样分别归位到已有的 neoForge/test/dependencies 块。功能无碍(CI 绿),纯一致性问题。
  • FontStartupTest.concurrentRequestsPublishEachCellOnceAcrossPages 的 revisions == codepoints.length(:121)依赖两个隐含前提:该图集没被 preWarmAscii() 过、且 updateHash() 没人调用(两者都会白增 version)。测试图集是直接 getOrCreate 得来的所以成立,但一旦将来构造器恢复内联预热(或有人手调 updateHash),这条断言会以「多出 95 次 revision」这种看不懂的方式挂掉。建议加一行注释点明前提,或改断言 revisions <= codepoints.length 并单独断言每格唯一(:113-117 已经在做)。

验证边界

本轮为静态审查:patch-diff 与合并后 dev/1.21.1 tip 的 blob 逐文件对账、module.font 全部生产代码 + 新测试全文阅读、原版 NativeImage/DynamicTexture/TextureManager 的 1.21.1 字节码(纯 Python 常量池 + 操作码解析,本环境无 JDK/javap)判读、CI build / build = success。未在本机执行 Gradle/runClient,因此「首帧是否仍有可感知的空白字形」「380 个预热格子实际占多少显存」属于推算而非实测;合并后如需继续跟进,建议以真实客户端的 F3 内存曲线与字体首次渲染帧为准。

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