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 三种性能杀手(今天逐一消灭)
二、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 面板(宏观)
打开 DevTools → Performance → 点录制 → 跑 10 秒 → 停止
看 FPS 曲线(红条 = 掉帧)与"每帧耗时"摘要
对每个效果做"开/关"两次录制,差值 = 该效果的增量(预算表数据源)
路径 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(补上)。
十、总结
今天的产出不是新特效,是"特效的账本":一张预算表、一组合并工具、一套降档开关、几条动效纪律。明天把这些全部装进"车间 3.0"——本周最后也是最难的 BOSS 战。
明日预告:Day 119 BOSS 战——车间 3.0 交付日:氛围(粒子+辉光)+ 特效(着色器)+ 性能(预算达标)三线合流验收,录演示视频、写 README、出简历条目,阶段 3 收官。