第 5-6 周:实战项目应用
📅 学习计划
本周目标: 学习性能优化技巧,理解自定义渲染系统的设计,并回顾总结。
时间安排: 每天 1-2 小时,共 10 天
第 1-3 天:性能优化
📖 理论:为什么会卡?
WebGL 渲染的瓶颈通常在:
Draw Calls 太多:CPU 通知 GPU 渲染的次数太多。画 1000 个小球,调用 1000 次绘制指令,CPU 会累死。
❓ 疑问:绘制不是异步的吗?为什么 CPU 会累?
虽然 GPU 执行绘制是异步的,但 CPU 发送 Draw Call 的过程是同步且耗时的:
- 状态验证:每次 Draw Call 前,CPU 要检查材质、纹理、Shader 是否准备好(不能把错误的状态发给 GPU)。
- 资源绑定:需要把纹理、顶点缓冲区、索引缓冲区等“绑定”到 GPU(
gl.bindTexture(),gl.bindBuffer()等),这些是 CPU 端的同步操作。 - 驱动层开销:WebGL → OpenGL → 显卡驱动 → GPU,每一层都要做参数验证、状态检查、命令打包,这些都在 CPU 上跑。
- 状态切换:如果这次 Draw Call 的材质和上次不一样,CPU 需要切换状态(比如换 Shader、换纹理),这些切换操作很耗时。
类比:就像你点外卖,虽然外卖员送餐是异步的,但你每次下单都要:
- 填地址(绑定资源)
- 选菜品(设置状态)
- 确认支付(验证状态)
- 提交订单(驱动层处理)
如果点 1000 次外卖,即使送餐是异步的,你下单的过程也会累死。Draw Call 同理:GPU 执行是异步的,但 CPU 发送命令的过程是同步阻塞的。
几何体太复杂:顶点数太多,GPU 算不过来。
- 瓶颈位置:GPU(Vertex Shader 或 Rasterizer)。
- 原理:GPU 虽然并行能力强,但如果一个模型有 1000 万个面,光是把这些顶点坐标算一遍(Vertex Shader),或者把这些三角形填色(Fragment Shader),GPU 也会累趴下。
- 举例:
- 反例:直接加载一个影视级的 3D 扫描雕像(几百万个面)到网页里,帧率瞬间掉到 5 FPS。
- 正例:使用 LOD (Level of Detail) 技术。离远了看时,用只有 100 个面的简化模型;离近了才加载高清模型。
频繁的数据上传:JS 每帧都把大量数据传给 GPU。
- 瓶颈位置:CPU 到 GPU 的传输带宽 (PCI-E 总线)。
- 原理:数据从 CPU 内存传到 GPU 显存(
gl.bufferData)是需要走物理线路的。虽然显卡很快,CPU 很快,但中间的路(带宽)是有限的。如果每帧都把几兆的数据往 GPU 塞,路就被堵死了。 - 举例:
- 反例 (CPU 粒子):由 JS 计算 10000 个粒子的位置,每帧都把这 10000 个坐标重新
gl.bufferData传给 GPU。这会让 CPU 和带宽都不堪重负。 - 正例 (GPU 粒子):JS 只在初始时传一次初始位置和速度。之后粒子的运动逻辑写在 Shader 里,GPU 自己算下一帧在哪里。数据都在显存里打转,不需要从 CPU 传。
- 反例 (CPU 粒子):由 JS 计算 10000 个粒子的位置,每帧都把这 10000 个坐标重新
💡 总结:性能瓶颈三巨头对比
| 瓶颈类型 | 关键点 | 比喻 | 解决方法 |
|---|---|---|---|
| Draw Call 太多 | CPU 发令太慢 | 老板(CPU)发一句话,员工(GPU)干一下。老板每秒只能喊 100 次,员工却能干 10000 次活。员工在等老板喊话。 | 合并几何体(一次喊话干完所有活) |
| 几何体太复杂 | GPU 干活太慢 | 老板喊了一次“把这座山移走”,员工累吐血了也搬不完。 | 减面 / LOD(少干点活) |
| 数据上传频繁 | 快递路上堵车 | 老板每秒钟都要寄 1 吨的原材料给员工。快递车堵在路上了。 | 减少上传 / GPU 计算(材料一次给够,别总寄) |
💻 实践:项目中的优化策略
1. 几何体合并 (Geometry Merging)
查看 LineRender.ts:402-437:
const compressData: { key: string; data: PolylineType[] }[] = [];
// ...
// 把相同样式(颜色、线宽、类型)的线归类
const line = compressData.find((d) => d.key === key);
if (line) {
line.data.push(data);
}
原理:如果不合并,每条车道线都是一个 Mesh,可能有几百个 Draw Call。合并后,所有红实线是一个 Mesh,所有白虚线是一个 Mesh,Draw Call 降到个位数。
2. 对象池 (Object Pooling)
查看 LineRender.ts:22:
const newLine = newThreeLine2D(THREE); // 这其实是一个生成器
// ...
this.clear(); // 并不销毁 Geometry,可能只是隐藏或重用
虽然项目里没有显式的对象池类,但 Three.js 开发中常复用 Mesh 和 Geometry,避免频繁 new 和 GC(垃圾回收)。
3. 资源释放 (Dispose)
查看 LineRender.ts:192:
disposeGroup(group: THREE.Object3D) {
// 必须显式调用 dispose() 通知 GPU 释放内存
mesh.geometry.dispose();
// material 也可以 dispose,但要小心复用问题
}
WebGL 中,JS 对象删了,GPU 里的显存不会自动清空,必须调 dispose()。
⚠️ 警告:频繁 dispose 也会卡!
❓ 疑问:既然要释放内存,为什么频繁释放反而会卡?
- GC 压力:
dispose()后,JS 端的对象变成了垃圾,频繁触发浏览器的垃圾回收 (GC),导致掉帧。 - 重建开销(最致命):如果你上一帧
dispose()了一个几何体,下一帧又new了一个一样的:- CPU 要重新计算几何数据。
- PCI-E 总线要重新上传数据到 GPU(这是最慢的)。
- GPU 要重新分配显存。
✅ 最佳实践:
- 不要“用完即扔”:如果一个物体只是暂时看不见(比如车开出了屏幕),不要 dispose,而是
visible = false。等下次要有新车时,拿这个旧车改改位置直接用(这就是对象池)。 - 批量释放:只有在场景彻底切换(比如从“主驾视角”切到“地图模式”)时,才遍历场景树进行一次性的大规模 dispose。
🧠 进阶:主流智驾 HMI 内存管理方案
❓ 疑问:后台定期清理有用吗?
有用,但不能“傻傻地定时清”。 简单的 setInterval 清理会导致每隔几分钟就卡顿一次(GC Spike)。
🚗 主流方案:分层管理策略
动态对象(车、人、障碍物)—— 绝对不清理!
- 策略:对象池 (Object Pool)。
- 逻辑:屏幕上最多也就显示 100-200 辆车。初始化时直接申请 200 个车的 Mesh。
- 有新车 -> 从池里拿一个
visible = true。 - 车消失 -> 仅仅设为
visible = false,不 dispose。
- 有新车 -> 从池里拿一个
- 原因:这些东西产生和消失太频繁(每秒几十次),哪怕一次 dispose 都会导致掉帧。
静态地图(车道线、路面)—— 瓦片化 (Tiling) + LRU
策略:像 2D 地图一样,把 3D 高精地图切成块(Tile)。
逻辑:
- 车开到哪里,加载周围 9 宫格的 Tile。
- 车开远了,把背后的 Tile 放入“待回收列表”。
- LRU (Least Recently Used):如果显存够用,待回收列表里的东西先留着(防止用户倒车回来又要重新加载)。如果显存紧张(比如超过 500MB),才删掉最远的那个 Tile。
🚗 针对“自车坐标系”的特别优化:RTC (Relative To Center)
问题:自车坐标系下,车不动,世界在动。如果每帧都更新所有地图点的坐标(CPU 计算),性能会崩。
解决:GPU 矩阵变换。
- 数据不动:地图数据上传时,保存的是相对于瓦片中心的坐标(静态的)。
- 只动矩阵:每一帧,只计算一个变换矩阵(View Matrix 或 World Matrix),传给 Vertex Shader。
- GPU 计算:Shader 里做
gl_Position = Matrix * position。 - 结果:虽然画面上地图在动,但显存里的顶点数据一次都没变过!CPU 0 开销。
🚨 实战坑点:后端推什么数据?
- 反例:后端每帧推给前端“相对于自车的坐标”。这会导致前端每帧都得更新顶点,完全无法优化。
- 正例:后端推绝对坐标(如 UTM 或 经纬度)或分块 ID + 块内偏移。前端收到后生成 Mesh 就不再动了,全靠矩阵动。
🚨 精度坑点:大坐标抖动 (Floating Point Precision)
- 如果直接用 UTM 坐标(如 x = 500,000.0),GPU 的 float 精度不够,会导致画面疯狂抖动。
- 必须做 RTC:上传给 GPU 的顶点数据必须是
(x - tileCenter.x, ...)这种小数值。
💡 深度解析:全靠矩阵动——具体怎么算?
1. 数据准备 (RTC)
- 假设地图上有一个点 $P_{world} = (500010, 20)$。
- 这一块瓦片的中心 $TileCenter = (500000, 0)$。
- 上传给 GPU 的顶点数据:$P{local} = P{world} - TileCenter = (10, 20)$。
- 注意:这个数据只传一次,存在显存里就不动了。
2. 每帧更新 (CPU)
- 假设当前自车位置 $Car_{world} = (500005, 5)$。
- 每一帧,我们需要算:相机相对于瓦片中心的偏移。
- $CameraOffset = Car_{world} - TileCenter = (5, 5)$。
- 构造一个模型矩阵 (Model Matrix),把瓦片平移到正确的位置:
- 我们的目标是让点显示在 $P{ego} = P{world} - Car_{world} = (5, 15)$。
- 而 $P_{local} - CameraOffset = (10, 20) - (5, 5) = (5, 15)$。对了!
- 所以,我们只需要把这个 $CameraOffset$ 放到矩阵里(或者作为 Uniform 传给 Shader)。
3. Shader 计算 (GPU)
// Vertex Shader
uniform vec3 cameraOffset; // (5, 5, 0)
attribute vec3 position; // (10, 20, 0) -> P_local
void main() {
// 这一步在 GPU 里算,快得飞起
vec3 finalPos = position - cameraOffset;
gl_Position = projectionMatrix * viewMatrix * vec4(finalPos, 1.0);
}结论:车动了 -> $Car_{world}$ 变了 -> $cameraOffset$ 变了 -> 传给 GPU 一个新的
vec3-> 画面动了。海量的顶点数据一点没变。
大资源(模型、纹理)—— 引用计数 + 延迟销毁
- 策略:
Reference Counting。 - 逻辑:
- 加载了“红绿灯模型”,引用数 = 1。
- 场景里用到了 5 个红绿灯,引用数 = 5。
- 当所有红绿灯都消失时,引用数 = 0。
- 关键点:引用归零时,不要马上 dispose! 而是开启一个 30 秒倒计时。如果 30 秒内又用到了,直接复用;如果 30 秒后还没人用,再真删。
Three.js 代码示例(引用计数 + 30s 延迟销毁):
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { SkeletonUtils } from "three/examples/jsm/utils/SkeletonUtils.js";
type Entry = {
refs: number;
timer: ReturnType<typeof setTimeout> | null;
gltf: any;
};
export class GltfCache {
private loader = new GLTFLoader();
private cache = new Map<string, Entry>();
private ttlMs = 30_000;
async acquire(url: string) {
const hit = this.cache.get(url);
if (hit) {
hit.refs++;
if (hit.timer) clearTimeout(hit.timer);
hit.timer = null;
// 复用“原始资源”,每次取用 clone 一个实例挂到场景里
return SkeletonUtils.clone(hit.gltf.scene) as THREE.Object3D;
}
const gltf = await this.loader.loadAsync(url);
this.cache.set(url, { refs: 1, timer: null, gltf });
return SkeletonUtils.clone(gltf.scene) as THREE.Object3D;
}
release(url: string) {
const e = this.cache.get(url);
if (!e) return;
e.refs--;
if (e.refs > 0) return;
// 引用归零:延迟销毁,短时间内再次 acquire 可直接复用
e.timer = setTimeout(() => {
// 真正释放 GPU 资源:geometry/material/texture
e.gltf.scene.traverse((obj: any) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
mesh.geometry?.dispose?.();
const mats = Array.isArray(mesh.material)
? mesh.material
: [mesh.material];
for (const m of mats) {
if (!m) continue;
// 释放常见贴图(按需补全)
for (const k of [
"map",
"normalMap",
"roughnessMap",
"metalnessMap",
"emissiveMap",
"alphaMap",
]) {
const tex = (m as any)[k] as THREE.Texture | undefined;
tex?.dispose?.();
}
m.dispose?.();
}
});
this.cache.delete(url);
}, this.ttlMs);
}
}使用方式(创建/复用/释放):
const cache = new GltfCache();
// 用到资源:acquire(引用 +1;如果 30s 内用过会走复用路径)
const light = await cache.acquire("/assets/models/traffic_light.glb");
scene.add(light);
// 不用了:先从 scene 移除,再 release(引用 -1;归零后进入延迟销毁)
scene.remove(light);
cache.release("/assets/models/traffic_light.glb");- 策略:
🎯 练习 5.1:性能测试
- 在场景中创建 10,000 个 Cube。
- 方法 A:创建 10,000 个 Mesh。观察 FPS。
- 方法 B:使用
InstancedMesh或合并 Geometry。观察 FPS。 - 结果通常是惊人的:方法 A 可能只有 10 FPS,方法 B 满帧 60 FPS。
📝 今日任务
- 理解 Draw Call 是性能杀手
- 明白合并几何体的原理
- 记住一定要 dispose 不用的资源
第 4-6 天:自定义渲染系统
📖 理论:为什么需要 BaseRender?
直接用 Three.js 虽然方便,但在复杂应用中(如自动驾驶可视化),我们需要统一管理:
- 数据的生命周期(收到 WebSocket -> 渲染 -> 过期清理)。
- 坐标转换(所有物体都要应用同一个变换矩阵)。
- 显隐控制。
💻 实践:项目架构分析
基类 BaseRender (base_render.ts):
- 提供
update(data),clear(),transform()等通用接口。 - 管理
scene_引用。
子类 LineRender (LineRender.ts):
- 继承
BaseRender。 - 专注于“线”的绘制逻辑(生成几何体、设置 Shader)。
- 实现了特定的
update逻辑:解析数据 -> 合并数据 -> 创建/更新 Mesh。
材质工厂 (line_shader/index.ts):
- 封装了 Shader 的创建细节。
- 外部只需要调
SolidShader({...}),不需要关心 GLSL 代码。
🎯 练习 5.2:设计一个小系统
假设你要做一个简单的粒子系统:
- ParticleSystem (基类):管理更新循环。
- FireEffect (子类):实现火焰的逻辑和 Shader。
- RainEffect (子类):实现下雨的逻辑。
📝 今日任务
- 理解面向对象在渲染系统中的应用
- 读懂
BaseRender和LineRender的继承关系
📊 课程总结与复习
恭喜你,坚持到了最后!🎉
核心知识点回顾
数学:
- 向量 (Vector) 用于表示位置和方向。
- 矩阵 (Matrix) 用于存储变换(T/R/S)。
- 点积判断方向,叉积求法线。
管线:
- 顶点着色器 (位置) -> 光栅化 -> 片段着色器 (颜色)。
- Uniform (全局) vs Attribute (顶点)。
Three.js:
- Scene, Camera, Renderer 三大件。
- Mesh = Geometry + Material。
Web 3D 开发心法:
- 坐标系转换是基础。
- 性能优化靠合并。
- Shader 是实现特效的魔法。
下一步建议
- 多看源码:没事多翻翻
node_modules/three/src,Three.js 的源码写得很漂亮。 - 写点东西:尝试复刻一个简单的 3D 效果(比如 ShaderToy 上的)。
- 深入 WebGPU:未来的方向,有了 WebGL 基础会好学很多。