#60
性能分析:
• 证据已经够了,我在整理结论时会按四层来写:trace 现象、主线程热点、Worker/
归属/跨 chunk 逻辑成本、AO 是否能继续异步化。重点会把“真正拖慢 FPS 的链
路”和“只是会放大总体负载但不是首因的链路”分开。
• Updated Plan
└ ✔ 解析 trace,定位 chunk 加载期间的主线程长任务与热点调用
✔ 阅读 chunk、实体、AO、face culling、consolidation 相关代码并建立加载
链路
□ 将 trace 证据与代码路径对应,归纳主要性能因素与可异步化空间
• 结论
这次 chunk 加载掉帧,首因不是“地形生成 Worker 不够快”,而是“Worker 回包
后,主线程在同一段时间内做了太多装配工作”。你的 trace 里最明显的证据是:
- HandlePostMessage 总计 3522.2ms,96 次里有 57 次超过 16ms,最大 236.1ms
- DroppedFrame 有 92 次
- 主线程 ChunkConsolidation.js :: worldWorker.onmessage 单次最高 26.7ms
- Worker 侧 WorldWorker.js :: onmessage 单次最高 31.6ms,但总量明显低于主
线程装配压力
- 存在明显 GC 压力:MajorGC 6 次,总计 119.1ms,后台标记 827.1ms
所以 FPS 从 60 掉到 45~50,核心是“主线程 chunk 回包处理链路过重 + 内存分
配过多导致 GC 放大卡顿”。
- 主线程最重的是 chunk 回包后的装配,不是生成本身
chunk 初次加载时,Worker 回包后会在主线程同步做完整装配,入口在 src/
world/ChunkGenerator.js:40:
- 全量替换 blockData / visibleKeys / solidBlocks
- 调用 this.buildMeshes(d) 构建所有 InstancedMesh,见 src/world/
ChunkGenerator.js:65
- 处理真实树、gunman、rover、快照保存、运行时实体恢复、光源注册,见 src/
world/ChunkGenerator.js:70
其中最重的是 buildMeshes(),见 src/world/ChunkGenerator.js:236:
- 每个方块类型都要 filter(shouldRenderPos),这一步会频繁查询所属 chunk 是
否已 ready,见 src/world/ChunkGenerator.js:243
- 每个类型都新建一个 InstancedMesh
- 只要该类型启用 AO,就会:
- 分配 aoLowArray / aoHighArray / orientationArray
- geometry.clone()
- setAttribute(...)
见 src/world/ChunkGenerator.js:291
这意味着虽然 AO 数值本身已经在 Worker 里算好了,但主线程仍然要为每个类型
克隆 geometry、创建多份 TypedArray、写实例矩阵、挂 attribute。这个“AO 应
用成本”仍然在主线程上。
- AO 不是主因中的“计算主因”,但它确实仍在抢主线程
先说判断:
- “初次 chunk 加载时 AO 计算是否还在主线程大量发生?”
不是。首次 chunk 的 AO 数值在 Worker 里算,逻辑在 src/workers/
WorldWorker.js:1559 到 src/workers/WorldWorker.js:1641
- “主线程是否仍然承担 AO 相关重负?”
是。主要是 AO attribute 的分配、geometry clone、实例 attribute 写入;此
外动态方块/补面路径仍会同步算 AO
AO 计算函数本身是比较重的:每块 6 个面,每面 4 个角,每角 3 次邻居遮挡查
询,见 src/utils/AOUtils.js:85 和 src/utils/AOUtils.js:113。
主线程仍会同步算 AO 的地方有:
- 动态方块 mesh 创建时同步算 AO,见 src/world/Chunk.js:533
- 轻量补面 / 邻居暴露时同步算 AO,见 src/world/Chunk.js:365 和 src/world/
Chunk.js:428
- 批量邻居 AO 更新虽然放进 requestIdleCallback,但执行内容仍是主线程同步
AO,见 src/world/ChunkRenderUtils.js:206
所以结论是:
- 首次 chunk 加载的 AO“数值计算”已经大部分异步化
- 但 AO“应用阶段”没有异步化
- 动态路径 AO 仍有一部分在主线程同步执行
“能不能把 AO 全部异步化掉?”
理论上可以更进一步,但要分两层:
- 初次加载
这里最值得做的不是继续把 AO 数值异步化,而是把“AO attribute 应用 +
geometry clone + mesh 建立”继续拆帧/分批,否则 Worker 算完后主线程还是
会卡。
- 动态更新
可以继续异步化到 FaceCullingWorker,但会牺牲“当帧立即正确阴影”。目前代
码明显偏向“立即视觉正确”,所以主线程保留了同步 AO 兜底。
也就是说,真正该优化的是“AO 在主线程的落地方式”,不只是“AO 算法在哪个线
程”。
- 实体加载链路很重,而且会顺手触发额外系统
这里是第二个大头。chunk 回包后,除了方块网格,还会加载实体:
- 真实树:先记录,再实例化,见 src/world/ChunkGenerator.js:70
- gunman:clone() 模型、traverse() 全节点、生成碰撞体,见 src/world/
ChunkGenerator.js:82
- rover:clone() 模型、生成 45 个碰撞块,见 src/world/
ChunkGenerator.js:116
这里最关键的问题不是“模型多一个 clone”,而是碰撞体是通过
addBlockDynamic() 一块一块塞进去的,见:
- gunman 碰撞块调用 src/world/ChunkGenerator.js:106
- rover 碰撞块调用 src/world/ChunkGenerator.js:133
而 addBlockDynamic() 本身是完整动态编辑链路,见 src/world/Chunk.js:699:
- 更新持久化缓存
- saveDebounced()
- 邻居采样
- 旧网格清理
- dirtyBlocks++
- scheduleConsolidation()
- 可能触发 Face Culling 更新
- 可能触发光源更新
这对“加载期的实体碰撞体初始化”来说太重了。
尤其 rover 一辆车就是 45 次 addBlockDynamic(),多个实体叠加后,很容易:
- 把 dirtyBlocks 顶到阈值
- 提前触发 consolidation
- 给主线程追加一轮后续 Worker 回包装配
所以这里是一个非常明确的性能设计问题:
“加载期初始化数据”复用了“运行时交互编辑路径”。
- consolidation 的确可能和 chunk 加载叠加,形成第二波卡顿
你提到的这个方向是对的。
addBlockDynamic() 每次都会 dirtyBlocks++ 并 scheduleConsolidation(),见
src/world/Chunk.js:750 和 src/world/Chunk.js:770。
consolidation 的触发逻辑在 src/world/ChunkConsolidation.js:292:
- dirtyBlocks >= 50 立即触发
- 否则 1 秒后触发
而 consolidation 回包后主线程又会:
- _filterWorkerResult
- _syncVisibilityAndCollision
- _cleanupOldMeshes
- buildMeshes(d)
- 重新注册光源
见 src/world/ChunkConsolidation.js:362
trace 里 ChunkConsolidation.js :: worldWorker.onmessage 有 16 次,单次最
高 26.7ms。这已经直接说明:
consolidation 回包装配是实际存在的主线程开销,而且会和远端 chunk 持续加载
阶段重叠。
所以答案是:
- 初次加载时,批量 face culling 不是在主线程算的,主要在 Worker
- 但 consolidation 确实可能在玩家持续移动时与 chunk 加载叠加
- 叠加点不在“计算线程冲突”,而在“多个回包一起挤占主线程装配时段”
- 跨 chunk 实体显示、owner 重算、超大结构处理确实有成本,但主要在 Worker
这块代码主要在 src/workers/WorldWorker.js:1413 之后。
你关心的几件事都存在:
- structureCenters 去重与合并,见 src/workers/WorldWorker.js:1406
- 跨 chunk owner 判定,见 src/workers/WorldWorker.js:1416
- snapshot 回写时重新校验 owner,见 src/workers/WorldWorker.js:1510
- 最终遍历 blockMap 再次做 isBlockOwnedByCurrentChunk()、可见性、AO 打
包,见 src/workers/WorldWorker.js:1597
这块复杂度不低,尤其是:
- isBlockOwnedByCurrentChunk() 对越界块会走 structureCenters
- belongsToCrossChunkStructure() 会线性扫描中心列表
- 如果 chunk 携带很多大结构/树/实体中心,成本会放大
但从 trace 看,Worker 侧 WorldWorker.js :: onmessage 总计约 197.1ms,单次
峰值 31.6ms,说明它有成本,但不是造成 FPS 降到 45 的主因。
主因还是回到主线程装配。
- 实例化做了一部分,但还不够彻底
做得好的地方:
- 普通方块按类型 InstancedMesh,见 src/world/ChunkGenerator.js:272
- 真实树是实例化的,见 src/world/entity-system/
RealisticTreeManager.js:158
不足的地方:
- gunman / rover 仍然是逐个 clone(),不是实例化,见 src/world/
ChunkGenerator.js:83 和 src/world/ChunkGenerator.js:117
- 这些实体的碰撞体初始化复用了动态编辑链路
- 大量 frustumCulled = false 会抬高常驻渲染成本,见 src/world/
ChunkGenerator.js:289、src/world/entity-system/
RealisticTreeManager.js:170、src/world/entity-system/
RealisticTreeManager.js:197
frustumCulled = false 更偏向稳态成本,不一定是这次卡顿的首因,但它会让“远
端 chunk 一边加载,一边又被渲染”的总体压力更大。
- 还有两个被顺手带上的附带成本
一是持久化消息量偏大。
- 每个 chunk 初次加载先 getChunkData(),见 src/services/
PersistenceService.js:43
- chunk 回包后又立即 saveChunkData(),见 src/world/ChunkGenerator.js:142
- trace 里 PersistenceWorker.js :: self.onmessage 总计 392.0ms
它不一定直接卡主线程,但会带来额外 postMessage、结构化拷贝和调度噪音。
对“刚加载完立刻又保存”这件事,我认为收益和成本不成正比。
二是阴影刷新过于频繁。
World.update() 中:
- chunk topology 变化就 requestShadowMapUpdate(),见 src/world/
World.js:154
- ready chunk 数变化也 requestShadowMapUpdate(),见 src/world/
World.js:173
而 Engine 只是把 renderer.shadowMap.needsUpdate = true,见 src/core/
Engine.js:837。
这不是 CPU 长任务首因,但会增加加载阶段的渲染负担,尤其场景里实例很多时。
按重要性排序的性能因素
- 主线程 chunk 回包装配过重:buildMeshes + AO attribute 应用 + 实例矩阵
写入 + geometry clone
- 实体加载链路过重:gunman/rover clone + traverse + 逐碰撞块
addBlockDynamic
- consolidation 在加载阶段被实体碰撞体初始化顺手触发,形成第二波主线程回
包装配
- AO 虽然首次计算已在 Worker,但主线程仍承担 AO attribute 落地和动态同步
AO
- 跨 chunk owner / 超大结构打散 / snapshot 归属修正有成本,但主要在
Worker,不是首因
- 持久化与阴影刷新增加了加载期噪音
- frustumCulled = false 放大稳态渲染负担
关于“AO 能否全部异步化”
可以继续异步,但我建议你把目标拆成两步,不要只盯 AO 算法:
- 先把“首次 chunk 加载的主线程装配”拆帧
也就是 buildMeshes 按类型或按 chunk 分段提交,不要在一次
worldWorker.onmessage 里全部做完
- 再把“动态路径 AO”彻底 worker 化
主线程先显示无 AO/旧 AO,Worker 回来后补写 attribute
这是可行的,但你要接受一两帧的 AO 延迟,且要处理竞态版本号
如果你只把 AO 数值继续异步化,而 geometry.clone + TypedArray 分配 +
InstancedMesh 创建 + matrix 写入 还都留在主线程,卡顿改善会有限。
一句话判断
这不是单点瓶颈,而是一个典型的“Worker 计算没问题,但主线程回包装配、实体
初始化、consolidation 后续任务、GC、阴影刷新同时堆到一起”的问题。
如果只问首因,我给你的答案是:主线程在 chunk 回包后的构建与附带初始化过
重,AO 是其中一部分,但不是唯一元凶。
如果你要,我下一步可以继续给你出一版“按收益排序的优化方案”,直接列出哪些
点最值得先改。
#60
性能分析:
• 证据已经够了,我在整理结论时会按四层来写:trace 现象、主线程热点、Worker/
归属/跨 chunk 逻辑成本、AO 是否能继续异步化。重点会把“真正拖慢 FPS 的链
路”和“只是会放大总体负载但不是首因的链路”分开。
• Updated Plan
└ ✔ 解析 trace,定位 chunk 加载期间的主线程长任务与热点调用
✔ 阅读 chunk、实体、AO、face culling、consolidation 相关代码并建立加载
链路
□ 将 trace 证据与代码路径对应,归纳主要性能因素与可异步化空间
• 结论
这次 chunk 加载掉帧,首因不是“地形生成 Worker 不够快”,而是“Worker 回包
后,主线程在同一段时间内做了太多装配工作”。你的 trace 里最明显的证据是:
线程装配压力
所以 FPS 从 60 掉到 45~50,核心是“主线程 chunk 回包处理链路过重 + 内存分
配过多导致 GC 放大卡顿”。
chunk 初次加载时,Worker 回包后会在主线程同步做完整装配,入口在 src/
world/ChunkGenerator.js:40:
ChunkGenerator.js:65
world/ChunkGenerator.js:70
其中最重的是 buildMeshes(),见 src/world/ChunkGenerator.js:236:
否已 ready,见 src/world/ChunkGenerator.js:243
见 src/world/ChunkGenerator.js:291
这意味着虽然 AO 数值本身已经在 Worker 里算好了,但主线程仍然要为每个类型
克隆 geometry、创建多份 TypedArray、写实例矩阵、挂 attribute。这个“AO 应
用成本”仍然在主线程上。
先说判断:
不是。首次 chunk 的 AO 数值在 Worker 里算,逻辑在 src/workers/
WorldWorker.js:1559 到 src/workers/WorldWorker.js:1641
是。主要是 AO attribute 的分配、geometry clone、实例 attribute 写入;此
外动态方块/补面路径仍会同步算 AO
AO 计算函数本身是比较重的:每块 6 个面,每面 4 个角,每角 3 次邻居遮挡查
询,见 src/utils/AOUtils.js:85 和 src/utils/AOUtils.js:113。
主线程仍会同步算 AO 的地方有:
Chunk.js:428
AO,见 src/world/ChunkRenderUtils.js:206
所以结论是:
“能不能把 AO 全部异步化掉?”
理论上可以更进一步,但要分两层:
这里最值得做的不是继续把 AO 数值异步化,而是把“AO attribute 应用 +
geometry clone + mesh 建立”继续拆帧/分批,否则 Worker 算完后主线程还是
会卡。
可以继续异步化到 FaceCullingWorker,但会牺牲“当帧立即正确阴影”。目前代
码明显偏向“立即视觉正确”,所以主线程保留了同步 AO 兜底。
也就是说,真正该优化的是“AO 在主线程的落地方式”,不只是“AO 算法在哪个线
程”。
这里是第二个大头。chunk 回包后,除了方块网格,还会加载实体:
ChunkGenerator.js:82
ChunkGenerator.js:116
这里最关键的问题不是“模型多一个 clone”,而是碰撞体是通过
addBlockDynamic() 一块一块塞进去的,见:
而 addBlockDynamic() 本身是完整动态编辑链路,见 src/world/Chunk.js:699:
这对“加载期的实体碰撞体初始化”来说太重了。
尤其 rover 一辆车就是 45 次 addBlockDynamic(),多个实体叠加后,很容易:
所以这里是一个非常明确的性能设计问题:
“加载期初始化数据”复用了“运行时交互编辑路径”。
你提到的这个方向是对的。
addBlockDynamic() 每次都会 dirtyBlocks++ 并 scheduleConsolidation(),见
src/world/Chunk.js:750 和 src/world/Chunk.js:770。
consolidation 的触发逻辑在 src/world/ChunkConsolidation.js:292:
而 consolidation 回包后主线程又会:
见 src/world/ChunkConsolidation.js:362
trace 里 ChunkConsolidation.js :: worldWorker.onmessage 有 16 次,单次最
高 26.7ms。这已经直接说明:
consolidation 回包装配是实际存在的主线程开销,而且会和远端 chunk 持续加载
阶段重叠。
所以答案是:
这块代码主要在 src/workers/WorldWorker.js:1413 之后。
你关心的几件事都存在:
包,见 src/workers/WorldWorker.js:1597
这块复杂度不低,尤其是:
但从 trace 看,Worker 侧 WorldWorker.js :: onmessage 总计约 197.1ms,单次
峰值 31.6ms,说明它有成本,但不是造成 FPS 降到 45 的主因。
主因还是回到主线程装配。
做得好的地方:
RealisticTreeManager.js:158
不足的地方:
ChunkGenerator.js:83 和 src/world/ChunkGenerator.js:117
ChunkGenerator.js:289、src/world/entity-system/
RealisticTreeManager.js:170、src/world/entity-system/
RealisticTreeManager.js:197
frustumCulled = false 更偏向稳态成本,不一定是这次卡顿的首因,但它会让“远
端 chunk 一边加载,一边又被渲染”的总体压力更大。
一是持久化消息量偏大。
PersistenceService.js:43
它不一定直接卡主线程,但会带来额外 postMessage、结构化拷贝和调度噪音。
对“刚加载完立刻又保存”这件事,我认为收益和成本不成正比。
二是阴影刷新过于频繁。
World.update() 中:
World.js:154
World.js:173
而 Engine 只是把 renderer.shadowMap.needsUpdate = true,见 src/core/
Engine.js:837。
这不是 CPU 长任务首因,但会增加加载阶段的渲染负担,尤其场景里实例很多时。
按重要性排序的性能因素
写入 + geometry clone
addBlockDynamic
包装配
AO
Worker,不是首因
关于“AO 能否全部异步化”
可以继续异步,但我建议你把目标拆成两步,不要只盯 AO 算法:
也就是 buildMeshes 按类型或按 chunk 分段提交,不要在一次
worldWorker.onmessage 里全部做完
主线程先显示无 AO/旧 AO,Worker 回来后补写 attribute
这是可行的,但你要接受一两帧的 AO 延迟,且要处理竞态版本号
如果你只把 AO 数值继续异步化,而 geometry.clone + TypedArray 分配 +
InstancedMesh 创建 + matrix 写入 还都留在主线程,卡顿改善会有限。
一句话判断
这不是单点瓶颈,而是一个典型的“Worker 计算没问题,但主线程回包装配、实体
初始化、consolidation 后续任务、GC、阴影刷新同时堆到一起”的问题。
如果只问首因,我给你的答案是:主线程在 chunk 回包后的构建与附带初始化过
重,AO 是其中一部分,但不是唯一元凶。
如果你要,我下一步可以继续给你出一版“按收益排序的优化方案”,直接列出哪些
点最值得先改。