Skip to main content

第 5-6 周:实战项目应用

📅 学习计划

本周目标: 学习性能优化技巧,理解自定义渲染系统的设计,并回顾总结。

时间安排: 每天 1-2 小时,共 10 天


第 1-3 天:性能优化

📖 理论:为什么会卡?

WebGL 渲染的瓶颈通常在:

  1. 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 发送命令的过程是同步阻塞的

  2. 几何体太复杂:顶点数太多,GPU 算不过来。

    • 瓶颈位置GPU(Vertex Shader 或 Rasterizer)。
    • 原理:GPU 虽然并行能力强,但如果一个模型有 1000 万个面,光是把这些顶点坐标算一遍(Vertex Shader),或者把这些三角形填色(Fragment Shader),GPU 也会累趴下。
    • 举例
      • 反例:直接加载一个影视级的 3D 扫描雕像(几百万个面)到网页里,帧率瞬间掉到 5 FPS。
      • 正例:使用 LOD (Level of Detail) 技术。离远了看时,用只有 100 个面的简化模型;离近了才加载高清模型。
  3. 频繁的数据上传: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 传。

💡 总结:性能瓶颈三巨头对比

瓶颈类型关键点比喻解决方法
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 也会卡!

❓ 疑问:既然要释放内存,为什么频繁释放反而会卡?

  1. GC 压力dispose() 后,JS 端的对象变成了垃圾,频繁触发浏览器的垃圾回收 (GC),导致掉帧。
  2. 重建开销(最致命):如果你上一帧 dispose() 了一个几何体,下一帧又 new 了一个一样的:
    • CPU 要重新计算几何数据。
    • PCI-E 总线要重新上传数据到 GPU(这是最慢的)。
    • GPU 要重新分配显存。

✅ 最佳实践:

  • 不要“用完即扔”:如果一个物体只是暂时看不见(比如车开出了屏幕),不要 dispose,而是 visible = false。等下次要有新车时,拿这个旧车改改位置直接用(这就是对象池)。
  • 批量释放:只有在场景彻底切换(比如从“主驾视角”切到“地图模式”)时,才遍历场景树进行一次性的大规模 dispose。

🧠 进阶:主流智驾 HMI 内存管理方案

❓ 疑问:后台定期清理有用吗?

有用,但不能“傻傻地定时清”。 简单的 setInterval 清理会导致每隔几分钟就卡顿一次(GC Spike)。

🚗 主流方案:分层管理策略

  1. 动态对象(车、人、障碍物)—— 绝对不清理!

    • 策略对象池 (Object Pool)
    • 逻辑:屏幕上最多也就显示 100-200 辆车。初始化时直接申请 200 个车的 Mesh。
      • 有新车 -> 从池里拿一个 visible = true
      • 车消失 -> 仅仅设为 visible = false不 dispose
    • 原因:这些东西产生和消失太频繁(每秒几十次),哪怕一次 dispose 都会导致掉帧。
  2. 静态地图(车道线、路面)—— 瓦片化 (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 -> 画面动了。海量的顶点数据一点没变。

  3. 大资源(模型、纹理)—— 引用计数 + 延迟销毁

    • 策略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:性能测试

  1. 在场景中创建 10,000 个 Cube。
  2. 方法 A:创建 10,000 个 Mesh。观察 FPS。
  3. 方法 B:使用 InstancedMesh 或合并 Geometry。观察 FPS。
  4. 结果通常是惊人的:方法 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:设计一个小系统

假设你要做一个简单的粒子系统:

  1. ParticleSystem (基类):管理更新循环。
  2. FireEffect (子类):实现火焰的逻辑和 Shader。
  3. RainEffect (子类):实现下雨的逻辑。

📝 今日任务

  • 理解面向对象在渲染系统中的应用
  • 读懂 BaseRenderLineRender 的继承关系

📊 课程总结与复习

恭喜你,坚持到了最后!🎉

核心知识点回顾

  1. 数学

    • 向量 (Vector) 用于表示位置和方向。
    • 矩阵 (Matrix) 用于存储变换(T/R/S)。
    • 点积判断方向,叉积求法线。
  2. 管线

    • 顶点着色器 (位置) -> 光栅化 -> 片段着色器 (颜色)。
    • Uniform (全局) vs Attribute (顶点)。
  3. Three.js

    • Scene, Camera, Renderer 三大件。
    • Mesh = Geometry + Material。
  4. Web 3D 开发心法

    • 坐标系转换是基础。
    • 性能优化靠合并。
    • Shader 是实现特效的魔法。

下一步建议

  1. 多看源码:没事多翻翻 node_modules/three/src,Three.js 的源码写得很漂亮。
  2. 写点东西:尝试复刻一个简单的 3D 效果(比如 ShaderToy 上的)。
  3. 深入 WebGPU:未来的方向,有了 WebGL 基础会好学很多。