【Three.js】day118-performance-budget

作者:mario 发布时间: 2026-09-07 阅读量:6 评论数:0

Day 118 · 性能与体验优化 — 合并几何、FPS 预算与动效纪律:给效果记账

前四天把"好看"做到了极致:粒子、辉光、调色、着色器、LOD——但每一个效果都是成本。今天不写新特效,做本周的收尾功夫:先把"每个效果花了几毫秒"用一张 FPS 预算表 记清楚,再用 合并几何 / 纹理图集 从根上削减 draw call,最后用 动效纪律(时间驱动、失效节流、失活暂停)把性能守住。性能不是"优化出来的",是"预算出来的"——没有预算表的性能优化,都是凭感觉碰运气。


目录


一、性能的第一性原理:一帧只有 16.7ms

1.1 60fps 意味着什么

1 秒 ÷ 60 帧 = 16.67ms / 帧   ← 每帧的全部预算
├── 渲染(draw call + 着色器 + 后处理)   ≈ 8-10ms   ← GPU 干活
├── 更新(动画 / 数据 / 逻辑)            ≈ 3-4ms    ← CPU 干活
└── 余量(GC / 调度 / 帧间抖动)          ≈ 3-4ms    ← 呼吸空间

关键认知:预算不是"尽量少用",是"明确分配"。把 16.7ms 当成一个月薪——你花在粒子上的每一毫秒,都是从辉光那里借来的。谁超支,谁就掉帧。

1.2 工业大屏的独特处境

Web 游戏追求 60fps 是为手感,大屏的 60fps 是为"不丢人"——评审盯着画面看 5 分钟,任何一次卡顿都会被记住。而大屏内容密度极高(50 台设备 + 粒子 + 后处理),天然是性能压力测试。所以工业大屏的性能优化不是炫技,是基本功

1.3 三种性能杀手(今天逐一消灭)

杀手

本质

今天的手段

draw call 过多

每台设备一个 Mesh = 50 次调用

合并几何 / 纹理图集(第三、四节)

每帧都干活

动效、uniform 更新无差别跑

动效纪律(第五节)

全效果无差别开

好配置与烂机器跑同一套

降档开关(第六节)


二、FPS 预算表:给每个效果记账

2.1 为什么要"记账"

凭感觉的优化 = 优化了"你觉得贵的"。你可能为了省 1ms 砍掉了很想要的辉光,却不知道粒子系统其实花了 4ms。预算表的本质是把"我觉得"换成"我测过"——每个效果挂上实测数字,砍谁不砍谁,数据说了算。

2.2 预算表模板(本周核心交付物)

docs/fps-budget.md,按"开/关"两态实测(方法见第七节):

# 车间 3.0 FPS 预算表(Day 118)

> 机器:____(GPU/分辨率);基线:____fps
> 单位:每帧耗时 ms;数值 = 开启该效果 − 关闭该效果的增量

| 效果 | 归属 | 增量 ms | 占比 | 结论 |
|------|------|---------|------|------|
| 渲染主循环(无后处理) | 引擎 | 5.2 | 31% | 基线 |
| UnrealBloomPass 辉光 | 后处理 | 4.1 | 25% | ⚠ 最贵单项 |
| 色彩分级 ShaderPass | 后处理 | 0.8 | 5% | 便宜,保留 |
| OutlinePass 描边 | 后处理 | 2.3 | 14% | ⚠ 可降档 |
| 粉尘粒子 3000 颗 | 粒子 | 1.2 | 7% | 可减量 |
| 扫描线粒子 | 粒子 | 0.6 | 4% | 便宜 |
| 火花粒子 | 粒子 | 0.4 | 2% | 便宜 |
| 设备着色器特效(50 台) | 着色器 | 1.1 | 7% | 看台数 |
| LOD 切换 | 引擎 | 0.1 | <1% | 免费午餐 |

**预算线:16.7ms(60fps)/ 20ms(50fps 底线)**
**当前总计:____ms → 超出/达标:____**

2.3 砍单的顺序决策(降档优先级)

先降档(不动内容):辉光强度/半径、粒子数量减半、描边强度
再降档(动质量):LOD 切近、后处理分辨率减半(HalfRes)
最后才砍内容(动观感):关描边、关火花、关色彩分级

纪律:砍的顺序从"最贵且最可牺牲"开始——数据里辉光最贵但它是"高级感"门面,所以优先降参数而非砍功能。


三、合并几何:把 50 次 draw call 变成 1 次

3.1 为什么 draw call 贵

每次 renderer.render(mesh) 都是一次状态切换 + 指令提交:绑定几何、绑定材质、上传 uniform…… 100 次调用 = 100 次 CPU 开销,即使画面很简单。draw call 是 CPU 侧的瓶颈,与 GPU 快不快无关。

3.2 什么时候能合并,什么时候不能

能合并(同材质、同状态):
  ✔ 50 台同款设备(同几何、同材质)
  ✔ 同颜色的标准件、地面、护栏

不能合并(状态不同):
  ✘ 不同材质(光照材质 vs 自定义着色器)
  ✘ 需要独立变换/独立交互(设备要动、要 hover)
  ✘ 需要独立透明度/独立动画

规则:静态 + 同质 → 合并;动态 + 独立 → 保留。车间里"设备"要独立(要动、要发光),但"地面网格线、护栏、基础结构"是纯静态,可合并。

3.3 合并几何实操

// src/core/three/optimize/merge-geometry.ts(纯逻辑,不 import vue)
import * as THREE from "three";

/**
 * 合并一组静态 Mesh 为一个 Mesh,把 N 次 draw call 降为 1 次
 * 适用:同材质、纯静态、无需独立变换的对象(地面/护栏/标准件)
 * @param meshes  要合并的 Mesh 数组(会被从场景移除)
 * @param material 合并后共用的材质(保持原状即可,用 clone 避免互相污染)
 */
export function mergeStaticMeshes(
  meshes: THREE.Mesh[],
  material: THREE.Material
): THREE.Mesh {
  // 1. 收集所有几何(合并前确保各自 applyMatrix4 已烘焙到世界坐标)
  const geoms: THREE.BufferGeometry[] = [];
  for (const mesh of meshes) {
    // 关键:把每个 Mesh 的变换"烤进"顶点,否则合并后位置全乱
    mesh.updateMatrixWorld();
    geoms.push(mesh.geometry.clone().applyMatrix4(mesh.matrixWorld));
    mesh.removeFromParent();
    mesh.geometry.dispose();
  }

  // 2. 用 BufferGeometryUtils 合并(three/examples/jsm/utils)
  const merged = BufferGeometryUtils.mergeGeometries(geoms);
  geoms.forEach((g) => g.dispose());

  // 3. 生成合并后的 Mesh
  const mesh = new THREE.Mesh(merged, material);
  return mesh;
}
// 使用:把车间的静态结构合并
const fenceMeshes = await loadFence();          // 20 段护栏
const mergedFence = mergeStaticMeshes(fenceMeshes, fenceMaterial);
scene.add(mergedFence);   // 20 次 draw call → 1 次

3.4 合并后的代价(必须知道)

  • 失去独立变换:合并后整体只能一起动

  • 失去独立剔除:合并体按整体包围盒剔除,部分不可见时仍整体渲染(大合并体慎用)

  • 失去独立交互:raycast 命中只能到"合并体"级别,需要独立点击的对象别合并

结论:合并是"静态资产"的优化,不是万能药。车间里最划算的合并对象是——地面网格、护栏、管廊、同类标准件。


四、纹理图集:贴图合批的另一面

4.1 图集解决的问题

多个小纹理 → 每张都是一个独立纹理单元、多次采样切换。把多张小图拼成一张大图(Texture Atlas),再通过 UV 偏移采样其中一块——一次纹理绑定、多次采样。

4.2 一个最小图集工具(Canvas 拼接)

// src/core/three/optimize/texture-atlas.ts
/**
 * 用 Canvas 把多张图片拼成一张图集
 * 返回:大纹理 + 每张小图的 UV 范围(供材质/着色器偏移采样)
 */
export function createTextureAtlas(
  images: HTMLImageElement[],
  cols: number
): { texture: THREE.Texture; uvRects: THREE.Vector4[] } {
  const rows = Math.ceil(images.length / cols);
  const cell = 256;                              // 每格 256px(保持 2 的幂便于 mipmap)
  const canvas = document.createElement("canvas");
  canvas.width = cols * cell;
  canvas.height = rows * cell;
  const ctx = canvas.getContext("2d")!;
  const uvRects: THREE.Vector4[] = [];

  images.forEach((img, i) => {
    const cx = (i % cols) * cell;
    const cy = Math.floor(i / cols) * cell;
    ctx.drawImage(img, cx, cy, cell, cell);
    // 存 (u, v, w, h) 归一化范围——片元着色器里偏移采样用
    uvRects.push(
      new THREE.Vector4(cx / canvas.width, cy / canvas.height, cell / canvas.width, cell / canvas.height)
    );
  });

  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return { texture, uvRects };
}

4.3 图集的使用场景

  • 粒子系统的多帧贴图(火花/粉尘/烟雾多态合一张)

  • 同材质多种表面(设备面板的不同纹理区域)

  • 精灵动画序列(HUD 数字、状态图标)

注意:图集是"贴图级"的优化,与"材质合批"(同材质共享一次 draw call)配合使用。优先级:同材质合批 > 图集 > 各自独立


五、动效纪律:时间驱动 / 失效节流 / 失活暂停

5.1 时间驱动 vs 帧驱动

错误的帧驱动(动画速度与帧率绑定):

// ✘ 每帧固定步进:掉帧时动画变慢,帧率高时动画变快
mesh.rotation.y += 0.01;

正确的时间驱动(速度与帧率无关):

// ✓ 用 delta 时间累积:60fps 与 30fps 下动画速度一致
// 渲染循环三段式中的"更新段"(Day 104)
const delta = clock.getDelta();
mesh.rotation.y += 0.6 * delta;   // 每秒 0.6 弧度,与帧率无关

纪律:所有动画(含 uniform 里的 uTime)都用时间驱动——不仅正确,还天然支持"暂停"(不给 delta 就停住)。

5.2 失效节流:不必要就别更新

每帧更新 uniform 的真相:很多 uniform(uTime 驱动)确实每帧要变,但状态类 uniform(uState、uColor)只在状态变化时更新

// ✘ 每帧无条件重传(浪费 CPU 提交)
material.uniforms.uState.value = currentState;   // 每帧都做

// ✓ 只在变化时更新(脏检查,Day 103 思路的 3D 版)
let lastState: number = -1;
function updateDevice(device, state) {
  if (state !== lastState) {
    device.material.uniforms.uState.value = state;   // 变了才传
    lastState = state;
  }
}

同理:后处理参数(辉光强度/阈值)只在滑杆变化时 set,不要每帧 set。

5.3 失活暂停:看不见就不算

页面切走 / 屏幕失活时,渲染循环该停——这是"动效纪律"里最容易被忽略、收益最大的一条:

// src/core/three/optimize/visibility.ts
/**
 * 页面可见性管理:hidden 时暂停 rAF 渲染循环
 * 收益:切走标签页时 CPU/GPU 占用归零(大屏轮播切屏时的隐形性能开销)
 */
export function bindVisibility(renderLoop: { start(): void; stop(): void }) {
  let lastTick = 0;
  const onVis = () => {
    if (document.hidden) {
      renderLoop.stop();              // 不可见:停渲染(动画停在原地)
    } else {
      renderLoop.start();             // 可见:恢复(用时间驱动,衔接无跳变)
    }
  };
  document.addEventListener("visibilitychange", onVis);
  return () => document.removeEventListener("visibilitychange", onVis);
}

5.4 节流:低频任务别用 rAF

数据轮询、告警检查这类毫秒级不敏感的任务,用 setInterval 而非每帧跑:

// ✘ 在 rAF 循环里做轮询(每帧白跑一次)
// ✓ 独立定时器:数据 1s 一刷,动画 60fps 跑,互不抢占
setInterval(() => checkAlerts(), 1000);

“数据频率 ≠ 渲染频率”——这是 Day 68 订阅分频思想在 3D 端的延续。


六、降档开关系统:质量档位与兜底

6.1 为什么需要降档

同一套代码要跑在不同机器上:评审机可能是高配,也可能是一台办公笔记本。降档开关 = 运行时把"预算表"变成"档位预设",一档不够降到二档。

6.2 档位设计

// src/core/three/optimize/quality.ts
/** 质量档位:高(全效果)/ 中(减粒子)/ 低(关后处理) */
export type QualityLevel = "high" | "medium" | "low";

/** 档位预设:把"降档优先级"固化成配置 */
export const QUALITY_PRESETS: Record<QualityLevel, {
  bloom: { strength: number; enabled: boolean };  // 辉光
  outline: boolean;                                // 描边
  particleCount: number;                           // 粒子数量
  pixelRatio: number;                              // 渲染分辨率
  lodBias: number;                                 // LOD 切换距离倍率
}> = {
  high:   { bloom: { strength: 0.6, enabled: true }, outline: true,  particleCount: 3000, pixelRatio: 1.5, lodBias: 1.0 },
  medium: { bloom: { strength: 0.4, enabled: true }, outline: false, particleCount: 1500, pixelRatio: 1.0, lodBias: 0.8 },
  low:    { bloom: { strength: 0,   enabled: false }, outline: false, particleCount: 500,  pixelRatio: 0.75, lodBias: 0.6 },
};

6.3 自动档位探测 + 手动覆盖

/**
 * 自动探测能力,并允许手动覆盖(大屏控制台可加"画质"按钮)
 * 探测依据:fps 持续低于阈值时自动降一档
 */
export class QualityController {
  private level: QualityLevel = "high";
  private frames = 0;
  private lowCount = 0;

  /** 每帧调用:统计低帧率次数,连续超标自动降档 */
  tick(fps: number) {
    this.frames++;
    if (fps < 45) this.lowCount++;
    if (this.lowCount >= 90) {          // 连续 ~1.5s 低于 45fps
      this.downgrade();
      this.lowCount = 0;
    }
  }

  private downgrade() {
    const order: QualityLevel[] = ["high", "medium", "low"];
    const idx = order.indexOf(this.level);
    if (idx < order.length - 1) {
      this.level = order[idx + 1];
      console.warn(`[quality] 自动降档 → ${this.level}`);
      this.onChange?.(this.level);
    }
  }

  /** 手动设档:返回当前档位的全部参数 */
  set(level: QualityLevel) { this.level = level; this.onChange?.(level); }
  get config() { return QUALITY_PRESETS[this.level]; }
  onChange?: (level: QualityLevel) => void;
}

降档的兜底价值:烂机器自动降到 low 也能 50fps——"能跑"比"好看"优先,降档是让"能跑"不靠赌


七、性能实测:不靠感觉,靠面板

7.1 两条实测路径

路径 A:浏览器 Performance 面板(宏观)

  1. 打开 DevTools → Performance → 点录制 → 跑 10 秒 → 停止

  2. 看 FPS 曲线(红条 = 掉帧)与"每帧耗时"摘要

  3. 对每个效果做"开/关"两次录制,差值 = 该效果的增量(预算表数据源)

路径 B:Three.js 渲染统计(微观)

// 挂一个统计小屏(three/examples/jsm/libs/stats.module.js)
import Stats from "three/examples/jsm/libs/stats.module.js";
const stats = new Stats();
document.body.appendChild(stats.dom);

// 渲染循环里:
function loop() {
  stats.begin();          // 开始统计本帧
  composer.render();      // 渲染
  stats.end();            // 结束:stats.dom 显示 fps / ms / draw call
  requestAnimationFrame(loop);
}

Stats 面板的三个数字FPS(帧率)、MS(每帧毫秒)、CALLS(draw call 数)——后两个正是预算表和合并优化的验证指标。

7.2 实测流程(可复现的测量)

1. 固定机器/分辨率(别在不同机器间比较)
2. 固定场景(同一视角、同一帧画面,别一边录一边动)
3. 逐个效果"开/关"测增量 → 填入预算表
4. 全开测总预算 → 对照 16.7ms/20ms 线
5. 记录机器信息(GPU/分辨率)→ 预算表留档

八、常见坑点

坑 1:合并后模型位置全乱了

原因:没 applyMatrix4 把变换烤进顶点。修法:合并前 mesh.updateMatrixWorld() + clone().applyMatrix4(matrixWorld)(第三节)。

坑 2:合并后想单独动一个部件——动不了

原因:合并后是整体。修法:需要独立变换/独立交互的对象不合并(第三节"不能合并"清单)。

坑 3:帧驱动动画在降帧时"慢动作"

原因:+= 0.01 每帧步进。修法:全部改时间驱动(* delta(第五节 5.1)。

坑 4:页面切走 CPU 还在 100%

原因:渲染循环没随 visibilitychange 停止。修法:绑定 document.hidden 停渲染(第五节 5.3)。

坑 5:自动降档"频繁跳档"闪来闪去

原因:阈值边缘反复横跳。修法:加迟滞——降档后短期内不升档,或低帧率计数窗口拉长(第六节)。

坑 6:测性能时一边拖动一边录,数据全废

原因:测量过程不固定。修法:固定视角/场景,逐效果开关测增量(第七节)。


九、自测挑战

T1 · 预算表实战(60 分钟)

按第七节流程,对车间的粒子/辉光/描边/色彩分级逐个"开/关"实测,填满第二节的预算表。目标:能指着表格说出"哪个最贵、能砍谁、砍完还剩多少"——这就是 Day 119 验收的硬数据。

T2 · 合并护栏(40 分钟)

把车间的护栏/地面网格等静态件用 mergeStaticMeshes 合并,记录合并前后 Stats 面板的 CALLS 数字。截图前后对比。

T3 · 降档开关(40 分钟)

实现 QualityController:加一个"画质"按钮切换三档,每档下 FPS 与画面对比截图。验证 low 档在弱机器上仍流畅。

T4 · 动效纪律检查(30 分钟)

过一遍渲染循环与所有 uniform 更新:找出帧驱动动画(改时间驱动)、每帧白传的 uniform(加脏检查)、未绑定 visibilitychange(补上)


十、总结

环节

要点

预算思维

一帧 16.7ms,效果逐项记账,砍单按"降档优先级"

draw call

静态同质 → 合并几何;动态独立 → 保留

图集

多张小图拼一张,配合同材质合批

动效纪律

时间驱动 / 脏检查 / 失活暂停 / 低频任务不占 rAF

降档

三档预设 + 自动探测 + 手动覆盖,兜住"能跑"底线

实测

Stats 面板(fps/ms/calls)+ 开关注册量,可复现

今天的产出不是新特效,是"特效的账本":一张预算表、一组合并工具、一套降档开关、几条动效纪律。明天把这些全部装进"车间 3.0"——本周最后也是最难的 BOSS 战。


明日预告:Day 119 BOSS 战——车间 3.0 交付日:氛围(粒子+辉光)+ 特效(着色器)+ 性能(预算达标)三线合流验收,录演示视频、写 README、出简历条目,阶段 3 收官。

评论