Flutter Scene 室内三维场景性能优化记录:静态合批、离线贴图与双态替身
记录三维书房在 Web 端从启动期像素回读、细碎节点提交与动态纹理泄漏,收敛到离线烘焙、时间片合批、静动态双态替身与阴影掩码隔离的过程。
目录
把整间维多利亚书房搬进浏览器(Flutter Web 3.47.9 + flutter_scene 0.24.3)之后,最初能跑通功能的一版在性能上存在不少问题:冷启动时主线程要先同步生成二十多张程序化贴图并拉取完整中文字体,稳态下全屋护墙板、书柜与打字机零件每帧产生逾千次绘制调用(Draw Calls),长时间保持雷雨天气或反复开关桌面书籍后甚至会出现三维视口黑屏。
在不动整体视觉细节的前提下,按“先拆清启动 I/O、CPU 编码、GPU 阴影光栅化与 WebGL 资源生命周期各自占多少开销,再逐项验证”的顺序做了一轮整理。下面把几处收益最明确、边界也最清楚的改动记录下来。
1. 启动期主线程减负:离线烘焙贴图与双层子集字体
首屏加载慢最初并不是卡在网络带宽上,而是卡在两件纯主线程同步工作上:
第一件是运行期程序化贴图回读。早期为了方便调参,分类书封面烫金与凹凸遮罩、二十面骰字面图集、天球仪贴图、染织书布和窗外云带等 20 多张贴图,都是在启动阶段通过 Canvas 绘制、Picture.toImage() 栅格化,再调用 toByteData(format: ImageByteFormat.rawRgba) 回读成字节数组上传给 GPU。仅书房一处,启动期主线程累计回读的像素体积就达到 60.26 MB。
后续把这套生成逻辑全部迁到了构建测试期的离线烘焙脚本中,输出为 51 张静态 .jpg / .png 资源文件(不带透明通道的底色与法线贴图统一转存为高画质 .jpg,并从发布包里剔除中间 .png 与重复的 .glb 源模型)。运行期直接交由浏览器多线程硬件解码上传,启动阶段的主线程像素回读降到了 0 MB。
第二件是首屏中文字体阻塞与 CanvasKit 缺字边界。桌面上摆着 4 本按文章数量决定书脊厚度的分类精装书,最初为了算出每本书装订后有多少页,首屏会直接拉取 8.33 MB 的完整 NotoSansSC-Regular.otf 并把所有文章排版一遍。对此做了两步拆分:
- 免字体页数快算:由于桌面精装书设有默认最低装订容量(
248页),在BookTypesetter.countBoundPages中先按每行保守字数做一次纯字符串上界估算;只要上界不超过容量下限,就直接返回固定页数,首屏TextPainter.layout()次数降为0。 - 静态子集与完整字库分层:编写构建脚本(基于
fontTools.subset)扫描仓库内全部公开 Markdown 文章与界面代码,提取出一份约571 KB的静态子集字体NotoSansSC-Subset.otf(体积缩减约93%),首屏只加载这份子集;而8.33 MB的完整字库推迟到用户真正聚焦打字机或打开留言输入面板时再后台异步加载。因为子集与完整字库源自同一字体文件,水平字宽(hmtx)与垂直度量完全一致,加载完成切换时已输入的文字不会产生排版跳动。
在做字体子集裁剪时还遇到过一个细节问题:Flutter Web(CanvasKit)不像普通 DOM 文本那样会自动借用系统字体补齐标点。当初版子集漏掉中文全角冒号 :(U+FF1A),且局部组件显式写了 TextStyle(fontFamily: 'NotoSansSC') 而未附带 fontFamilyFallback 时,界面上的全角冒号全部渲染成了带叉的 .notdef 方框;而且常规 Widget 单元测试默认使用 Ahem 方块字体,无法检出这类缺字问题。后来一方面在 ThemeData 顶层统一配齐中文字体回退栈,另一方面在子集生成脚本里把常用中文全角标点列为强制校验项,缺任一码位即构建失败,才彻底解决了漏字问题。
顺带一提,后续发布的 Flutter 3.50 引入了基于浏览器实验性 Enhanced TextMetrics API 的 WebParagraph,可以把文本塑形与排版直接交由浏览器调用宿主操作系统的本地字体与彩色 Emoji,从而省去远端回退字体下载并避免缺字方框。不过截至 3.50,该能力仍需要客户端 Chrome 手动开启 #enable-experimental-web-platform-features 实验开关(未开启的浏览器及 Safari、Firefox 会自动回退到常规 CanvasKit 路径),且三维书页排版仍需固定思源黑体子集以保证跨系统折行一致;等未来浏览器默认启用这项接口后,打字机与留言面板按需加载的那份 8.33 MB 完整字库就有望省去。
2. 静态网格时间片合批与光照通道掩码隔离
书房里的护墙板线脚、书柜层板、雕花立柱与静态陈设最初由数百个独立网格节点组成,未合批时单帧提交高达 1,249 次 Draw Calls。
为此写了 StaticBatcher,在场景组装阶段遍历节点树,跳过需要独立旋转拾取的交互件、蒙皮与形态键网格,将其余同构静态节点按 (material, castsShadow, lightChannelMask, vertexSignature) 原地合并顶点缓冲,并统一标记 shadowStatic = true 以复用平行光级联阴影缓存,将稳态 Draw Calls 降低到了 385–419 次。
不过把合批放到浏览器实测时,如果一次性同步变换并拼接几十万个顶点,又会在首屏产生一段明显的主线程长任务。因此在 Web 端改用了带帧预算的分片异步合批:
class StaticBatcher {
StaticBatcher({this.keep = const {}, this.moving = const {}});
final Set<String> keep;
final Set<String> moving;
/// Web 端按时间片分帧合并子树,避免一次性变换数十万顶点阻塞事件循环。
Future<void> batchAsync(scene.Node root, {int budgetMs = 12}) async {
final sw = Stopwatch()..start();
await _groupAsync(root, _isMoving(root), sw, budgetMs);
}
static Future<void> _maybeYield(Stopwatch sw, int budgetMs) async {
if (sw.elapsedMilliseconds >= budgetMs) {
await Future<void>.delayed(const Duration(milliseconds: 8));
sw
..reset()
..start();
}
}
}
除了主视图绘制,另一块容易被忽视的开销在台灯动态聚光灯(SpotLight)阴影上。平行太阳光可以缓存静态阴影,但书桌上的台灯属于近景局部聚光灯,没有静态阴影缓存机制。未做通道隔离前,后方整面书柜(约 38.2 万 顶点)和窗帘(约 4 万 顶点)每帧都会进入台灯的 1024×1024 阴影贴图通道重新计算。
通过把光照与阴影通道拆成三级位掩码——远景大件家具仅走 0x01(只受太阳光与环境光影响,不参与台灯阴影)、书桌近景交互件走 0x02、台灯灯罩自身走 0x04(防止灯罩内壁自遮挡)——每帧直接从动态聚光灯阴影通道里剔除了超过 42.2 万 个无关远景顶点,同时关掉了表针、玻璃外壳等腔内微件的阴影投射。
3. 闭合分类书与 54 键打字机的静动态双态替身
书桌上有两类外观静止、但内部节点与顶点开销较大的对象:
- 4 本可形变圆背精装书:完整
BookModel为了支持平滑翻页和圆背展开,内部维护了数十个GeometryStorage.updatable动态顶点缓冲,仅单侧闭合纸芯就有超过15,360个索引。 - 老式机械打字机:为了还原 54 枚按键、44 根扇形字杆、字车、滚筒与色带架的机械联动,完整模型包含
308个子节点;即使一字不打,常态每帧也要跑一遍pose()遍历并提交百余次主绘制与250+次微件动态阴影。
实际上,访客大部分时间只是在书房里环顾或查看其他物件,并不会同时翻开所有书或一直使用打字机。于是给这两类复杂模型都做了静动态双态替身(Proxy):
- 分类书常态只用
ClosedBookLayout生成纯静态外切面替身(闭合纸芯索引从> 15,360降到216,减少98.6%,首屏动态 GPU 缓冲为0),直到首次点击某本书阅读时才惰性实例化对应的BookModel。 - 打字机常态只渲染合并后的
7个静态网格批次(节点数减少97.7%,闲置pose()调用降为0);仅在聚焦打字或机械回位动画尚未结束的几百毫秒内切回完整可动节点树,并关闭3 mm键帽与细字杆的阴影投射。
这种做法的代价是需要额外维护替身与真身之间的几何与材质一致性:假设闭合替身的包围盒或书脊高度与真身相差哪怕一两毫米,点击翻书切换的瞬间就会看到模型轻微跳动。为此专门补了包围盒对齐单测,要求替身与闭合态真身的世界坐标误差控制在 0.05 mm 以内。
4. WebGL 阴影缓存与动态纹理的显存泄漏排查
性能稳定下来之后,在长时间运行测试里遇到过一个更隐蔽的问题:在夜间“雷雨”天气持续运行十几分钟,或者反复打开、合上桌面分类书十几次后,三维书房视口会突然变成纯深棕黑底,而外层的 2D 界面按钮依然能正常点击。
顺着 flutter_scene 0.24.3 的 WebGL 后端源码往下查,才定位到具体原因:当前版本的 WebGlTexture 没有实现底层 gl.deleteTexture 销毁接口。只要 Dart 对象丢弃了纹理引用,底层显存并不会随垃圾回收释放。当时代码里恰好有三处操作在持续新建大尺寸纹理:
- 闪电脉冲反复切换
sun.castsShadow:光照逻辑原本写着sun.castsShadow = sunTotal > 0.08。夜间雷雨平时没有日光,闪电亮起的200 ms内亮度短暂冲高,castsShadow变成true,引擎随即新建级联阴影缓存(分配48 MB阴影纹理);200 ms后闪光衰减回0,castsShadow变回false,SceneEncoder在帧末执行_directionalShadowCache = null直接丢弃缓存引用——每打一次雷就永久泄漏48 MB显存。 - 窗外天空与街景画布重绘:每次时段过渡重绘外景时都调用
createTexture新建纹理(每次泄漏约26.8 MB)。 - 书页纹理反复重建:每次打开桌面书阅读都重新创建 6 张页面纹理(每次泄漏约
67 MB)。
定位清楚之后,修复规则就很明确了:
- 平行主光的
sun.castsShadow保持常驻true(收敛为单级联1024分辨率),夜间只通过把intensity置为0来熄灭日照,不在闪电等瞬态脉冲中切换castsShadow布尔值; - 尺寸固定的动态画布纹理(天空、远景、阅读器页面池)只在首次分配一次,后续更新一律调用
gpuTexture.overwrite(bytes)原地覆写像素字节。
改完后再做长时间雷雨天气运行与几十次开关书籍测试,WebGL 显存占用始终维持在平稳水平,不再出现黑屏退化。
5. 视锥剔除与静止阶梯休眠的取舍
最后一类优化主要针对离屏与静止状态下的无效渲染开销:
- 窗洞门户剔除与体积光关停:给后墙哥特窗洞建了一个精确的世界空间门户包围盒(
windowPortalBounds)。当镜头转向室内左右书墙使窗洞完全离开视锥体,或者用户拉严了双侧窗帘时,直接暂停窗外 9 个远景/街景图层与雨雪粒子发射器,并同步关停全屏光线步进的godRays通道(避免白昼拉上窗帘后因室外太阳强度仍大于阈值而继续执行无效的全屏采样)。 - 四档阶梯静止帧率(
60 -> 30 -> 12 -> 0 fps):当页面退作站点菜单半透明背景,或访客在书房内无任何交互停留满60 s、180 s时,按阶梯逐步降低渲染帧率直至定格休眠,指针移动、滚轮或按键时立即唤醒回60 fps。
这里也有一处需要在产品体验上主动接受的取舍:定格到 0 fps 虽然能让后台或长时停留状态下的集显功耗降到零、笔记本风扇彻底安静下来,但也意味着静置满 3 分钟后,书桌上的烛火摇曳与空气浮尘会一起静止,直到用户再次移动鼠标。相比让一个个人博客页面在后台一直满载跑满 GPU,我还是倾向于优先保证安静和低功耗。