Skip to content

性能优化 #61

Description

@jayli

#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 放大卡顿”。

  1. 主线程最重的是 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 应
用成本”仍然在主线程上。

  1. 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 全部异步化掉?”
理论上可以更进一步,但要分两层:

  1. 初次加载
    这里最值得做的不是继续把 AO 数值异步化,而是把“AO attribute 应用 +
    geometry clone + mesh 建立”继续拆帧/分批,否则 Worker 算完后主线程还是
    会卡。
  2. 动态更新
    可以继续异步化到 FaceCullingWorker,但会牺牲“当帧立即正确阴影”。目前代
    码明显偏向“立即视觉正确”,所以主线程保留了同步 AO 兜底。

也就是说,真正该优化的是“AO 在主线程的落地方式”,不只是“AO 算法在哪个线
程”。

  1. 实体加载链路很重,而且会顺手触发额外系统

这里是第二个大头。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 回包装配

所以这里是一个非常明确的性能设计问题:
“加载期初始化数据”复用了“运行时交互编辑路径”。

  1. 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 加载叠加
  • 叠加点不在“计算线程冲突”,而在“多个回包一起挤占主线程装配时段”
  1. 跨 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 的主因。
主因还是回到主线程装配。

  1. 实例化做了一部分,但还不够彻底

做得好的地方:

  • 普通方块按类型 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 一边加载,一边又被渲染”的总体压力更大。

  1. 还有两个被顺手带上的附带成本

一是持久化消息量偏大。

  • 每个 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 长任务首因,但会增加加载阶段的渲染负担,尤其场景里实例很多时。

按重要性排序的性能因素

  1. 主线程 chunk 回包装配过重:buildMeshes + AO attribute 应用 + 实例矩阵
    写入 + geometry clone
  2. 实体加载链路过重:gunman/rover clone + traverse + 逐碰撞块
    addBlockDynamic
  3. consolidation 在加载阶段被实体碰撞体初始化顺手触发,形成第二波主线程回
    包装配
  4. AO 虽然首次计算已在 Worker,但主线程仍承担 AO attribute 落地和动态同步
    AO
  5. 跨 chunk owner / 超大结构打散 / snapshot 归属修正有成本,但主要在
    Worker,不是首因
  6. 持久化与阴影刷新增加了加载期噪音
  7. frustumCulled = false 放大稳态渲染负担

关于“AO 能否全部异步化”

可以继续异步,但我建议你把目标拆成两步,不要只盯 AO 算法:

  1. 先把“首次 chunk 加载的主线程装配”拆帧
    也就是 buildMeshes 按类型或按 chunk 分段提交,不要在一次
    worldWorker.onmessage 里全部做完
  2. 再把“动态路径 AO”彻底 worker 化
    主线程先显示无 AO/旧 AO,Worker 回来后补写 attribute
    这是可行的,但你要接受一两帧的 AO 延迟,且要处理竞态版本号

如果你只把 AO 数值继续异步化,而 geometry.clone + TypedArray 分配 +
InstancedMesh 创建 + matrix 写入 还都留在主线程,卡顿改善会有限。

一句话判断

这不是单点瓶颈,而是一个典型的“Worker 计算没问题,但主线程回包装配、实体
初始化、consolidation 后续任务、GC、阴影刷新同时堆到一起”的问题。
如果只问首因,我给你的答案是:主线程在 chunk 回包后的构建与附带初始化过
重,AO 是其中一部分,但不是唯一元凶。

如果你要,我下一步可以继续给你出一版“按收益排序的优化方案”,直接列出哪些
点最值得先改。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions