最近開源了高性能在線 DWG/DXF 查看器 CAD-Viewer (Gitlab 或 Github) 後,許多人問我如何在瀏覽器中實現高性能的渲染上百萬個實體的。一個典型的 DWG/DXF 檔案可能包含幾十萬甚至上百萬的幾何體,如線段、圓弧、填充圖案、符號、標註等,每一個在 WebGL 中都可能映射為單獨的幾何體。如果為每個幾何體都創建一次繪製調用(drawing call),渲染器將在達到互動幀率之前就被壓垮。
解決這個問題的關鍵在於 Batched Geometry Processing:將相似的原語合併到大的 GPU 緩衝區中,以盡可能少的繪製調用渲染它們。本文將講述我們如何在開源項目 CAD‑Viewer 中實現 Batched Geometry Processing,該實現受 Three.js 的 BatchedMesh
啟發。
本文將覆蓋以下內容:
注意:
在探索如何構建 Batched Geometry 前,理解為什麼 Batched Geometry 對高性能渲染 DWG/DXF 圖紙至關重要是非常必要的。
一個典型的 CAD 圖紙可能包含數十万个 Geometry:線、弧、圓、多段線、文字、填充圖案等等。如果我們將每個 Geometry 都作為單獨的 THREE.Mesh
或 THREE.Line
發送給 GPU,性能會崩潰 —— 並不是因為 GPU 無法繪製它們,而是因為 CPU → GPU 通信開銷成為瓶頸。
一個繪製調用意味著 CPU 告訴 GPU —— “用這些材質渲染這個幾何體”。每次調用都需要:
即使幾何體非常小(如一條線段),繪製調用的成本與大型網格基本相同。
如果有 100,000 個 Geometry,那麼就是每幀 100,000 次繪製調用。CPU 會被壓垮,幀率可能降至 < 1 FPS。
現代 GPU 擅長高速繪製數百萬個三角形,真正的瓶頸在於從 CPU 向 GPU 傳輸命令與數據的帶寬與延遲。
想像一下:
因此,如果你逐個發送幾何體給 GPU,GPU 大部分時間將處於等待狀態,等待 CPU 下個指令。
解決方案是 Batched Geometry:
舉例:
性能差異驚人:
這樣,Geometry 的單個開銷就變得很小,GPU 利用率大幅提升。這就是像 Three.js 的 BatchedMesh
和 cad‑viewer 中的 AcTrBatchedLine/Mesh/Point
在 Web CAD 查看器中至關重要的原因。
可以把一個 Batched Geometry 看成是一整塊大的頂點數組 + 索引數組。每個獨立的 Geometry 在數組中佔據連續的片段。在原始數組之上,我們維護一張表,描述每個 Geometry 的數據在何處。
頂點緩衝區 (Vertices Buffer)
┌──────────────┬─────────────────────┬─────────┐
│ Arc Vertices │ Polyline Vertices │ … │
└──────────────┴─────────────────────┴─────────┘
↑ ↑ ↑
┌──────────────┬─────────────────────┬─────────┐
│ Arc Indices │ Polyline Indices │ … │
└──────────────┴─────────────────────┴─────────┘
索引緩衝區 (Indices Buffer)
每個 我們用一個 BatchedGeometryInfo
記錄其信息:
interface BatchedGeometryInfo {
id: number; // 唯一 ID
vertexOffset: number; // 在頂點緩衝區的起始偏移 (頂點數偏移)
vertexCount: number; // 顆的個數
indexOffset: number; // 在索引緩衝區的起始偏移
indexCount: number; // 索引個數
visible: boolean; // 是否可見
}
我們把所有這些記錄存入一個數組:
const geometryInfos: BatchedGeometryInfo[] = [];
假設我們要合併兩個 Geometry:
Arc (圓弧,中心 (0,0),半徑 1,角度 0°–90°)
生成如下頂點(局部坐標):
V0 (1,0)
V1 (0.92,0.38)
V2 (0.71,0.71)
V3 (0.38,0.92)
V4 (0,1)
生成索引(GL.LINES):
(0,1), (1,2), (2,3), (3,4)
Polyline (矩形)頂點:
P0 (2,0)
P1 (3,0)
P2 (3,1)
P3 (2,1)
索引(GL.LINE_LOOP):
(0,1), (1,2), (2,3), (3,0)
假設緩衝區最初為空,加入兩者:
頂點緩衝區:[ Arc 的 V0–V4, Polyline 的 P0–P3 ]
索引緩衝區:[ Arc 的索引, Polyline 的索引(偏移調整後) ]
注意,Polyline 的索引從 5 開始,因為 Arc 占用了前 5 個頂點。
我們向 geometryInfos
推入:
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
描述了圓弧與多段線在緩衝區中的位置。
假設初始緩衝區容量較小:
完全適配,使用後剩餘
頂點槽只有 1 個空閒,不足以容納 4 個頂點
複製舊數據至新緩衝區
頂點緩衝區 (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),_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_ ]
修改後的緩衝區如下:
頂點緩衝區:[ Arc 頂點 …, Polyline 頂點 … ]
索引緩衝區:[ Arc 索引 …, Polyline 索引 … ]
元數據也在前面步驟中依次設置。
假設我們要修改多段線,其最初有 4 個頂點:
P0 (2,0), P1 (3,0), P2 (3,1), P3 (2,1)
例如將 P2 從 (3,1) 拖動至 (3,1.5):
更新前的頂點緩衝區:
[ ... 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), ... ]
新增一個點 P3’:
P0 (2,0)
P1 (3,0)
P2 (3,1)
P3 (2.5,1.5) // 新頂
P4 (2,1)
此時頂點數變為 5,原先片段只有空間容納 4 個頂點:
處理方式:
geometryInfos[polylineIndex] = {
...oldInfo,
vertexOffset: 9,
vertexCount: 5,
indexOffset: 16,
indexCount: 10
};
舊區域索引可覆蓋為 -1
(表示無效),這樣這些頂點雖然在緩衝區內,但不會被繪製。舊片段可在未來壓縮時回收。
假設我們要移除圓弧。
geometryInfos[arcIndex].visible
設為 false
優點:速度快
缺點:緩衝區仍佔用空間
arcInfo
這樣緩衝區保持緊湊,但代價是需要重寫所有受影響幾何體的索引與偏移信息。
在真實的 CAD 場景中,我們經常需要互動式地選擇、高亮或隱藏某些 Geometry。在批處理結構中不能像 Three.js 那樣單獨切換 Mesh.visible
,因為所有 Geometry 共用同一個緩衝區。我們通過元數據與 GPU 技巧實現這些功能。
在 GeometryInfo
中擴展:
interface GeometryInfo {
id: number;
startVertex: number;
vertexCount: number;
startIndex: number;
indexCount: number;
visible: boolean;
highlighted: boolean;
color: THREE.Color;
}
例如:
visible = true, highlighted = false
選項 A:把頂點 alpha 設為 0
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(無效)
function setInvisible(info: GeometryInfo) {
for (let i = 0; i < info.indexCount; i++) {
indexAttr.array[info.startIndex + i] = -1;
}
indexAttr.needsUpdate = true;
}
這種方法在很多 CAD 應用中效率更高,因為它避免了浪費 GPU 填充率。
通過修改緩衝區中對應 Geometry 的顏色即可:
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
。
有時我們只想渲染批次中的一部分,比如只渲染圓弧,不渲染多段線。Three.js 提供 geometry.setDrawRange(start, count)
方法來告訴 GPU 渲染索引數組的一個子區間。
示例:
// 只渲染弧線部分
geometry.setDrawRange(arcInfo.startIndex, arcInfo.indexCount);
// 渲染整個批次
geometry.setDrawRange(0, totalIndexCount);
這個方法效率很高,因為不需要修改緩衝區數據 —— 只改變 GPU 渲染所使用的索引範圍。
在本文中,我們探討了如何設計並實現一個 Batched Geometry,以便在 Web 上高性能地渲染 DWG/DXF 圖紙。
我們首先從動機出發:為何合併幾何體對性能至關重要——特別是因為繪製調用代價高昂、CPU → GPU 帶寬成為瓶頸。接著逐步介紹了:
以簡單的圓弧 + 多段線示例,我們演示了隨著 Geometry 的添加、更新、隱藏與渲染,元數據、頂點緩衝區和索引緩衝區是如何演化的。
關鍵結論:通過 Batched Geometry,我們可以將成千上萬次的繪製調用壓縮為極少數一次 —— 這正是讓基於 Web 的 CAD 瀏覽體驗從 <1 FPS 提升到流暢的 60+ FPS 的核心所在。
如果你想深入了解實現細節,或親自嘗試這個開源項目,歡迎查看我的程式碼倉庫: