打造高性能二维图纸渲染引擎系列(一):Batched Geometry 助你轻松渲染百万实体

最近开源了高性能在线 DWG/DXF 查看器 CAD-Viewer (Gitlab or Github) 后,许多人问我如何在浏览器中实现高性能的渲染上百万个实体的。一个典型的 DWG/DXF 文件可能包含几十万甚至上百万的 Geometry,如线段、圆弧、填充图案、符号、标注等,每一个在 WebGL 中都可能映射为单独的几何体。如果为每个 Geometry 都创建一次绘制调用 (draw call),渲染器将在达到交互帧率之前就被压垮。

解决这个问题的关键在于 Batched Geometry Processing :将相似的原语合并到大的 GPU 缓冲区中,以尽可能少的绘制调用渲染它们。本文将讲述我们如何在开源项目 CAD‑Viewer 中实现 Batched Geometry Processing ,该实现受 Three.js 的 BatchedMesh 启发。

本文将覆盖以下内容:

  • 为什么合并 Geometry 可以提升性能
  • Batched Geometry 的结构
  • 添加、更新和移除子几何体
  • 渲染整个 Batch 或部分 Batch
  • 高亮与可见性控制

注意:

1. 为什么合并 Geometry 可以大幅度提升渲染性能?

在探索如何构建 Batched Geometry 前,理解为什么 Batched Geometry 对高性能渲染 DWG/DXF 图纸至关重要是非常必要的。

一个典型的 CAD 图纸可能包含数十万个 Geometry:线、弧、圆、多段线、文字、填充图案等等。如果我们将每个 Geometry 都作为单独的 THREE.MeshTHREE.Line 发送给 GPU,性能会崩溃 ------ 并不是因为 GPU 无法绘制它们,而是因为 CPU → GPU 通信开销成为瓶颈。

1.1 绘制调用 (Draw Calls) 很昂贵

一个绘制调用意味着 CPU 告诉 GPU ------ "用这些材质渲染这个几何体"。每次调用都需要:

  • 绑定顶点 / 索引缓冲区
  • 绑定着色器
  • 上传 uniform(矩阵、颜色、变换等)
  • 向 GPU 发出命令

即使几何体非常小(如一条线段),绘制调用的成本与大型网格基本相同。

如果有 100,000 个 Geometry,那么就是每帧 100,000 次绘制调用。CPU 会被压垮,帧率可能降至 < 1 FPS。

1.2 CPU 与 GPU 之间的带宽是瓶颈

现代 GPU 擅长高速绘制数百万个三角形,真正的瓶颈在于从 CPU 向 GPU 传输命令与数据的带宽与延迟。

想象一下:

  • GPU 可以以每秒数百 GB 的速度吞吐几何数据
  • CPU 每帧只能发送数万个绘制调用,否则就成为瓶颈

因此,如果你逐个发送几何体给 GPU,GPU 大部分时间将处于等待状态,等待 CPU 下一个指令。

1.3 合并几何体 = 更少的绘制调用

解决方案是 Batched Geometry:

  • 将许多小几何体合并到一个大的顶点缓冲区和一个索引缓冲区中
  • 对整个 Batch 发出一次绘制调用
  • 使用元数据数组(metadata)记录每个 Geometry 在缓冲区的偏移位置

举例:

  • 原本对 10,000 条线做 10,000 次绘制调用
  • 我们把它们合并成 1 个 Batched Geometry 只需 1 次绘制调用

性能差异惊人:

  • 100,000 次绘制调用:帧率 < 1 FPS
  • 用 1 次绘制调用表示 100,000 条线:轻松达到 60+ FPS

这样,Geometry 的单个开销就变得很小,GPU 利用率大幅提升。这就是像 Three.js 的 BatchedMeshcad‑viewer 中的 AcTrBatchedLine/Mesh/Point 在 Web CAD 查看器中至关重要的原因。

2. Batched Geometry的结构

可以把一个 Batched Geometry 看成是一整块大的顶点数组 + 索引数组。每个独立的 Geometry 在数组中占据连续的片段。在原始数组之上,我们维护一张表,描述每个 Geometry 的数据在何处。

scss 复制代码
顶点缓冲区 (Vertices Buffer) 
┌──────────────┬─────────────────────┬─────────┐  
│ Arc Vertices │  Polyline Vertices  │    ...    │  
└──────────────┴─────────────────────┴─────────┘  
        ↑                ↑                ↑
┌──────────────┬─────────────────────┬─────────┐  
│ Arc Indices  │  Polyline Indices   │    ...    │  
└──────────────┴─────────────────────┴─────────┘  
索引缓冲区 (Indices Buffer)  

每个 我们用一个 BatchedGeometryInfo 记录其信息:

ts 复制代码
interface BatchedGeometryInfo {
  id: number;             // 唯一 ID
  vertexOffset: number;   // 在顶点缓冲区的起始偏移 (顶点数偏移)
  vertexCount: number;    // 顶点个数
  indexOffset: number;    // 在索引缓冲区的起始偏移
  indexCount: number;     // 索引个数
  visible: boolean;       // 是否可见
}

我们把所有这些记录存入一个数组:

ts 复制代码
const geometryInfos: BatchedGeometryInfo[] = [];

3. 添加 Geometry

假设我们要合并两个 Geometry:

  • 圆弧(用 8 条线段近似)
  • 多段线(定义为一个矩形)

步骤 1 --- 将 Geometry 拆分为顶点 + 索引

Arc (圆弧,中心 (0,0),半径 1,角度 0°--90°)

生成如下顶点(局部坐标):

scss 复制代码
V0 (1,0)
V1 (0.92,0.38)
V2 (0.71,0.71)
V3 (0.38,0.92)
V4 (0,1)

生成索引(GL.LINES):

scss 复制代码
(0,1), (1,2), (2,3), (3,4)

Polyline (矩形)顶点:

scss 复制代码
P0 (2,0)
P1 (3,0)
P2 (3,1)
P3 (2,1)

索引(GL.LINE_LOOP):

scss 复制代码
(0,1), (1,2), (2,3), (3,0)

步骤 2 --- 放入共享缓冲区

假设缓冲区最初为空,加入两者:

css 复制代码
顶点缓冲区:[ Arc 的 V0--V4, Polyline 的 P0--P3 ]

索引缓冲区:[ Arc 的索引, Polyline 的索引(偏移调整后) ]

注意,Polyline 的索引从 5 开始,因为 Arc 占用了前 5 个顶点。

步骤 3 --- 记录元数据

我们向 geometryInfos 推入:

ts 复制代码
geometryInfos.push({
  id: 101,
  vertexOffset: 0,
  vertexCount: 5,
  indexOffset: 0,
  indexCount: 8,
  visible: true
});

geometryInfos.push({
  id: 102,
  vertexOffset: 5,
  vertexCount: 4,
  indexOffset: 8,
  indexCount: 8,
  visible: true
});

现在 geometryInfos 描述了圆弧与多段线在缓冲区中的位置。

4. 缓冲区自动扩容

假设初始缓冲区容量较小:

  • 顶点缓冲区有 6 个槽
  • 索引缓冲区有 12 个槽

步骤 1 --- 添加圆弧 (5 顶点, 8 索引)

完全适配,使用后剩余

步骤 2 --- 尝试加入多段线 (4 顶点, 8 索引)

顶点槽只有 1 个空闲,不足以容纳 4 个顶点

步骤 3 --- 扩容

  • 顶点缓冲区扩大为 12 槽
  • 索引缓冲区扩大为 24 槽

复制旧数据至新缓冲区

ini 复制代码
顶点缓冲区 (12 slots):  
[ (1,0),(0.92,0.38),(0.71,0.71),(0.38,0.92),(0,1),_,_,_,_,_,_,_ ]  
  
索引缓冲区 (24 slots):  
[ (0,1),(1,2),(2,3),(3,4),_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_ ]

步骤 4 --- 插入多段线

修改后的缓冲区如下:

css 复制代码
顶点缓冲区:[ Arc 顶点 ..., Polyline 顶点 ... ]
索引缓冲区:[ Arc 索引 ..., Polyline 索引 ... ]

元数据也在前面步骤中依次设置。

5. 更新几何体

假设我们要修改多段线,其最初有 4 个顶点:

scss 复制代码
P0 (2,0), P1 (3,0), P2 (3,1), P3 (2,1)

情况 A --- 顶点数不变

例如将 P2 从 (3,1) 拖动至 (3,1.5):

  • 顶点仍然是 4 个
  • 我们可以直接在原始缓冲区片段内就地覆盖
  • 元数据不需修改
css 复制代码
更新前的顶点缓冲区:  
[ ... Arc V0--V4, P0(2,0), P1(3,0), P2(3,1), P3(2,1), ... ]  
  
更新后的顶点缓冲区: 
[ ... Arc V0--V4, P0(2,0), P1(3,0), P2(3,1.5), P3(2,1), ... ]

情况 B --- 顶点数变化(例如 变为五边形)

新增一个点 P3':

scss 复制代码
P0 (2,0)
P1 (3,0)
P2 (3,1)
P3 (2.5,1.5)   // 新顶
P4 (2,1)

此时顶点数变为 5,原先片段只有空间容纳 4 个顶点:

处理方式:

  • 在缓冲区末尾分配新的片段(例如 [9--13])
  • 将新顶点复制过去
  • 更新元数据:
ts 复制代码
geometryInfos[polylineIndex] = {
  ...oldInfo,
  vertexOffset: 9,
  vertexCount: 5,
  indexOffset: 16,
  indexCount: 10
};

旧区域索引可覆盖为 -1(表示无效),这样这些顶点虽然在缓冲区内,但不会被绘制。旧片段可在未来压缩时回收。

6. 移除几何体

假设我们要移除圆弧。

选项 A --- 延迟移除 (Lazy removal)

  • 不修改缓冲区
  • 仅将对应的 geometryInfos[arcIndex].visible 设为 false
  • 在渲染时由 shader 丢弃不可见 Geometry

优点:速度快

缺点:缓冲区仍占用空间

选项 B --- 压缩 (Compaction)

  • 实际回收内存,将后续几何体前移
  • 删除 arcInfo
  • 将多段线顶点自 [5...] 移至 [0...]
  • 更新多段线的偏移信息

这样缓冲区保持紧凑,但代价是需要重写所有受影响几何体的索引与偏移信息。

7. 高亮、可见性 & 部分渲染

在真实的 CAD 场景中,我们经常需要交互式地选择、高亮或隐藏某些 Geometry。在批处理结构中不能像 Three.js 那样单独切换 Mesh.visible,因为所有 Geometry 共用同一个缓冲区。我们通过元数据与 GPU 技巧实现这些功能。

7.1 存储可见性与高亮标志

GeometryInfo 中扩展:

ts 复制代码
interface GeometryInfo {
  id: number;
  startVertex: number;
  vertexCount: number;
  startIndex: number;
  indexCount: number;
  visible: boolean;
  highlighted: boolean;
  color: THREE.Color;
}

例如:

  • 圆弧(id=...):visible = true, highlighted = false
  • 多段线:同样标志

7.2 使几何体不可见

选项 A:把顶点 alpha 设为 0

ts 复制代码
function setVisibility(info: GeometryInfo, visible: boolean) {
  info.visible = visible;
  for (let i = 0; i < info.vertexCount; i++) {
    const offset = (info.startVertex + i) * 4; // 每顶点 4 个通道
    colorAttr.array[offset + 3] = visible ? 1.0 : 0.0;
  }
  colorAttr.needsUpdate = true;
}

Shader 在渲染时丢弃 alpha = 0 的几何体。

选项 B:覆盖索引为 -1(无效)

ts 复制代码
function setInvisible(info: GeometryInfo) {
  for (let i = 0; i < info.indexCount; i++) {
    indexAttr.array[info.startIndex + i] = -1;
  }
  indexAttr.needsUpdate = true;
}

这种方法在很多 CAD 应用中效率更高,因为它避免了浪费 GPU 填充率。

7.3 高亮几何体

通过修改缓冲区中对应 Geometry 的颜色即可:

ts 复制代码
function highlight(info: GeometryInfo, highlightColor: THREE.Color) {
  info.highlighted = true;
  for (let i = 0; i < info.vertexCount; i++) {
    const offset = (info.startVertex + i) * 4;
    colorAttr.array[offset + 0] = highlightColor.r;
    colorAttr.array[offset + 1] = highlightColor.g;
    colorAttr.array[offset + 2] = highlightColor.b;
  }
  colorAttr.needsUpdate = true;
}

取消高亮时恢复原本的 info.color

7.4 渲染部分批次(Draw Range)

有时我们只想渲染批次中的一部分,比如只渲染圆弧,不渲染多段线。Three.js 提供 geometry.setDrawRange(start, count) 方法来告诉 GPU 渲染索引数组的一个子区间。

示例:

ts 复制代码
// 只渲染弧线部分
geometry.setDrawRange(arcInfo.startIndex, arcInfo.indexCount);

// 渲染整个批次
geometry.setDrawRange(0, totalIndexCount);

这个方法效率很高,因为不需要修改缓冲区数据 ------ 只改变 GPU 渲染所使用的索引范围。

8. 总结

在本文中,我们探讨了如何设计并实现一个 Batched Geometry,以便在 Web 上高性能地渲染 DWG/DXF 图纸。

我们首先从动机出发:为何合并几何体对性能至关重要------特别是因为绘制调用代价高昂、CPU → GPU 带宽成为瓶颈。接着逐步介绍了:

  • Batched Geometry 的结构:顶点/索引缓冲区 + 元数据数组
  • 添加几何体:分配、自动扩容
  • 更新几何体:在片段内就地修改或迁移
  • 移除几何体:惰性移除或压缩处理
  • 渲染过程:整批渲染或使用 Draw Range 渲染部分
  • 高亮与可见性控制:通过修改颜色、alpha 或索引值实现

以简单的圆弧 + 多段线示例,我们演示了随着 Geometry 的添加、更新、隐藏与渲染,元数据、顶点缓冲区和索引缓冲区是如何演化的。

关键结论:通过 Batched Geometry,我们可以将成千上万次的绘制调用压缩为极少数一次 ------ 这正是让基于 Web 的 CAD 浏览体验从 <1 FPS 提升到流畅的 60+ FPS 的核心所在。

如果你想深入了解实现细节,或亲自尝试这个开源项目,欢迎查看我的代码仓库:

相关推荐
前端老宋Running4 小时前
微信小程序的操作日志收集模块
前端
CAD老兵4 小时前
打造高性能二维图纸渲染引擎系列(三):高性能 CAD 文本渲染背后的隐藏工程
前端·webgl·three.js
CAD老兵4 小时前
打造高性能二维图纸渲染引擎系列(二):创建结构化和可扩展的渲染场景
前端·webgl·three.js
王木风4 小时前
1分钟理解什么是MySQL的Buffer Pool和LRU 算法?
前端·mysql
Jerry_Rod4 小时前
vue 项目如何使用 mqtt 通信
前端·vue.js
云中雾丽4 小时前
Flutter中路由配置的各种方案
前端
不一样的少年_4 小时前
女朋友炸了:刚打开的网页怎么又没了?我反手甩出一键恢复按钮!
前端·javascript·浏览器
Renounce4 小时前
【Android】让 Android 界面 “动” 起来:动画知识点大起底
前端
Asort4 小时前
JavaScript设计模式(十四)——命令模式:解耦请求发送者与接收者
前端·javascript·设计模式