最近开源了高性能在线 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
- 高亮与可见性控制
注意:
- CAD-Viewer 的代码仓库迁移到 https://github.com/mlightcad/cad-viewer 了
1. 为什么合并 Geometry 可以大幅度提升渲染性能?
在探索如何构建 Batched Geometry 前,理解为什么 Batched Geometry 对高性能渲染 DWG/DXF 图纸至关重要是非常必要的。
一个典型的 CAD 图纸可能包含数十万个 Geometry:线、弧、圆、多段线、文字、填充图案等等。如果我们将每个 Geometry 都作为单独的 THREE.Mesh
或 THREE.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 的 BatchedMesh
和 cad‑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 的核心所在。
如果你想深入了解实现细节,或亲自尝试这个开源项目,欢迎查看我的代码仓库:
- 👉 Github:github.com/mlightcad/c...
- 👉 Gitlab:gitlab.com/mlightcad/c...