🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

打造高性能二维圖紙渲染引擎系列(一):Batched Geometry 助你輕鬆渲染百萬實體

最近開源了高性能在線 DWG/DXF 查看器 CAD-Viewer (GitlabGithub) 後,許多人問我如何在瀏覽器中實現高性能的渲染上百萬個實體的。一個典型的 DWG/DXF 檔案可能包含幾十萬甚至上百萬的幾何體,如線段、圓弧、填充圖案、符號、標註等,每一個在 WebGL 中都可能映射為單獨的幾何體。如果為每個幾何體都創建一次繪製調用(drawing call),渲染器將在達到互動幀率之前就被壓垮。

解決這個問題的關鍵在於 Batched Geometry Processing:將相似的原語合併到大的 GPU 緩衝區中,以盡可能少的繪製調用渲染它們。本文將講述我們如何在開源項目 CAD‑Viewer 中實現 Batched Geometry Processing,該實現受 Three.js 的 BatchedMesh 啟發。

dwg-viewer.jpg

本文將覆蓋以下內容:

  • 為什麼合併幾何體可以提升性能
  • Batched Geometry 的結構
  • 添加、更新和移除子幾何體
  • 渲染整個 Batch 或部分 Batch
  • 高亮與可見性控制

注意:

1. 為什麼合併幾何體可以大幅度提升渲染性能?

在探索如何構建 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 的數據在何處。

頂點緩衝區 (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[] = [];

3. 添加 Geometry

假設我們要合併兩個 Geometry:

  • 圓弧(用 8 條線段近似)
  • 多段線(定義為一個矩形)

步驟 1 — 將 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)

步驟 2 — 放入共享緩衝區

假設緩衝區最初為空,加入兩者:

頂點緩衝區:[ Arc 的 V0–V4, Polyline 的 P0–P3 ]
索引緩衝區:[ Arc 的索引, Polyline 的索引(偏移調整後) ]

注意,Polyline 的索引從 5 開始,因為 Arc 占用了前 5 個頂點。

步驟 3 — 記錄元數據

我們向 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 描述了圓弧與多段線在緩衝區中的位置。

4. 緩衝區自動擴容

假設初始緩衝區容量較小:

  • 頂點緩衝區有 6 個槽
  • 索引緩衝區有 12 個槽

步驟 1 — 添加圓弧 (5 頂點, 8 索引)

完全適配,使用後剩餘

步驟 2 — 嘗試加入多段線 (4 頂點, 8 索引)

頂點槽只有 1 個空閒,不足以容納 4 個頂點

步驟 3 — 擴容

  • 頂點緩衝區擴大為 12 槽
  • 索引緩衝區擴大為 24 槽

複製舊數據至新緩衝區

頂點緩衝區 (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 — 插入多段線

修改後的緩衝區如下:

頂點緩衝區:[ Arc 頂點 …, Polyline 頂點 … ]
索引緩衝區:[ Arc 索引 …, Polyline 索引 … ]

元數據也在前面步驟中依次設置。

5. 更新幾何體

假設我們要修改多段線,其最初有 4 個頂點:

P0 (2,0), P1 (3,0), P2 (3,1), P3 (2,1)

情況 A — 頂點數不變

例如將 P2 從 (3,1) 拖動至 (3,1.5):

  • 頂點仍然是 4 個
  • 我們可以直接在原始緩衝區片段內就地覆蓋
  • 元數據不需修改
更新前的頂點緩衝區:  
[ ... 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’:

P0 (2,0)
P1 (3,0)
P2 (3,1)
P3 (2.5,1.5)   // 新頂
P4 (2,1)

此時頂點數變為 5,原先片段只有空間容納 4 個頂點:

處理方式:

  • 在緩衝區末尾分配新的片段(例如 [9–13])
  • 將新頂點複製過去
  • 更新元數據:
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 中擴展:

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

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 填充率。

7.3 高亮幾何體

通過修改緩衝區中對應 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

7.4 渲染部分批次(Draw Range)

有時我們只想渲染批次中的一部分,比如只渲染圓弧,不渲染多段線。Three.js 提供 geometry.setDrawRange(start, count) 方法來告訴 GPU 渲染索引數組的一個子區間。

示例:

// 只渲染弧線部分
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 的核心所在。

如果你想深入了解實現細節,或親自嘗試這個開源項目,歡迎查看我的程式碼倉庫:


原文出處:https://juejin.cn/post/7561442148370989107


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝26   💬9   ❤️7
656
🥈
我愛JS
📝4   💬13   ❤️7
284
🥉
御魂
💬1  
4
#4
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付