需求
上一篇 THREEjs 局部網格細分 裡,每次畫刷只會細分局部區域。但細分完成後,BufferGeometry 的頂點、索引和三角形數量都改變了,原本的 BVH 已經不能直接使用。
最簡單的作法是丟掉舊 geometry,再對新 geometry 呼叫一次 computeBoundsTree()。但畫刷會連續觸發,模型也可能有幾十萬個三角形;每畫一下就重建整棵 BVH,局部細分省下來的時間又會在這裡花掉。
所以這次要解決兩個問題:
- 如何把細分期間使用的暫存 BufferAttribute,快速整理成新的 geometry。
- 如何讓既有 BVH 繼續對應新的三角形,而不用重新建樹。
問題的核心不是頂點,而是三角形順序
細分時新增 position、normal、color 並不複雜,只要把新頂點追加到 attribute 後面即可。真正麻煩的是 index。
BVH 的葉節點不會逐一保存自己包含哪些三角形,而是記錄一段連續範圍。例如:
offset = 5
count = 4
代表這個葉節點負責第 5 ~ 8 個三角形。
假設原本的三角形 2 被細分,新產生的 11、12、13 直接追加到 index 尾端。從幾何上看沒有問題,但原三角形和它的細分結果會散落在陣列兩端,BVH 葉節點便無法再用一組 offset + count 表示它們。
解法是反過來整理 index:每個舊三角形細分出的所有結果,都連續插回舊三角形原本的位置。
如圖所示,原來的順序是:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
三角形 2 和 5 細分後,新的順序改成:
[0, 1, 11, 12, 13, 3, 4, 15, 16, 17, 6, 7, 8, 9]
這樣同一個舊三角形產生的結果仍然是一段連續資料。舊 BVH 中任何一段連續三角形,更新後也仍然連續,只是範圍變長了。
記錄每個新三角形來自哪裡
要把細分結果放回正確位置,細分過程中就要記錄每個三角形的來源。
假設三角形 2 第一次分成 11、12,之後 12 又繼續分成兩個。後來新增的三角形仍然全部以 2 作為來源,而不是以 12 作為來源。
概念上可以理解成一張來源表:
來源 2 → [11, 12, 13]
來源 5 → [15, 16, 17]
實際細分時比較方便保存「新三角形 → 最初來源」,完成後再反轉成上面的分組。這樣不論一個區域遞迴細分多少次,最後都能把所有結果收回同一組。
收回暫存 BufferAttribute
上一篇提過,細分時不會每增加一個頂點就重新配置 BufferAttribute,而是先準備較大的緩衝區,並另外記錄實際用了多少資料。
updateGeometry 的第一步,就是依實際使用量截取 position、normal、color:
有效資料 = 暫存陣列[0 ... 實際使用量 × itemSize]
position、normal、color 的 itemSize 都是 3,所以實際使用頂點數要乘三。index 本身記錄的已經是索引元素數,不需再乘三。
截取後再用這些大小剛好的 TypedArray 建立新的 BufferAttribute。這次複製只會在整輪細分結束後執行一次,避免每新增資料就重新配置,也不會把預留但沒使用的容量上傳到 GPU。
一次掃描重建 index
把細分結果依來源三角形分組,再按照來源索引由小到大排序後,就可以由左至右重建 index。
過程會同時維護兩個位置:
- 舊 index 已處理到哪個三角形。
- 新 index 下一個三角形要寫到哪裡。
每遇到一個被細分的三角形,就做三件事:
- 把它前面沒有修改的連續區段整批複製到新 index。
- 不再複製原三角形,改為寫入它的全部細分結果。
- 記錄這個舊三角形在新 index 裡對應的第一個和最後一個三角形。
用偽代碼表示:
依來源索引排序所有細分組
for 每個細分組:
批量複製前一段沒有修改的三角形
寫入這一組的所有細分結果
記錄「舊三角形 → 新範圍」
批量複製最後一段沒有修改的三角形
沒有修改的區段可以用 TypedArray 的 subarray() 配合 set() 整批搬移,不需要在 JavaScript 中逐個複製三個索引。
建立新舊三角形映射
重建 index 的同時,還需要為每個舊三角形記錄一組 [first, last]:
舊三角形 0 → [0, 0]
舊三角形 1 → [1, 1]
舊三角形 2 → [2, 4]
舊三角形 3 → [5, 5]
舊三角形 4 → [6, 6]
舊三角形 5 → [7, 9]
沒有細分的三角形只對應一個新三角形,所以 first 和 last 相同。三角形 2 被分成三個,因此對應 [2, 4];前面增加的三角形會令後方索引位移,所以舊三角形 5 變成 [7, 9]。
這張映射表是 BufferGeometry 和舊 BVH 之間的橋樑。
更新 BVH 葉節點
上圖左邊的葉節點原本管理三角形 0 ~ 2。因為三角形 2 細分成 11、12、13,重排後這個葉節點應改為管理新 index 的 0 ~ 4。
另一個葉節點原本管理 5 ~ 8。三角形 5 細分後,它的新範圍則變成 7 ~ 12。
更新一個葉節點時,只要查兩次映射:
新起點 = 舊範圍第一個三角形的 first
新終點 = 舊範圍最後一個三角形的 last
新數量 = 新終點 - 新起點 + 1
因為前面刻意維持三角形的分組與順序,所以舊葉節點所包含的全部細分結果仍然連續。BVH 的 root、左右子樹和分割關係都不用改,只需遍歷樹並更新每個葉節點的起點與數量。
換上新的 geometry
所有資料準備完成後,便可以建立新的 BufferGeometry:
- 設定重排後的 index。
- 設定收回正式大小的 position、normal、color。
- 把更新後的 boundsTree 移交給新 geometry。
- 更新 boundsTree 內部指向的 geometry。
- 將 mesh 換成新 geometry,再 dispose 舊 geometry。
第三、四步很容易漏掉。只把 boundsTree 指派給新 geometry 還不夠;若樹的內部仍指向舊 geometry,後續 raycast 仍可能讀到舊的 position 和 index。
dispose 舊 geometry 則是為了釋放舊 attribute 對應的 GPU 資源,不會影響已經移交給新 geometry 的 boundsTree。
為甚麼這樣比較快
完整重建 BVH 需要重新計算所有三角形的空間資料,再決定每一層如何切分。這個方案保留原本的空間分割,只做三件線性的工作:
- 整理細分後的 index。
- 建立舊、新三角形索引映射。
- 遍歷既有 BVH,更新葉節點範圍。
重建 index 的時間複雜度是 O(舊三角形數 + 新三角形數),更新 BVH 則是 O(BVH節點數)。局部細分通常只會增加少量三角形,而且新三角形仍位於原三角形附近,因此沿用原樹結構通常比每次重建划算得多。
這個算法的核心並不是少複製幾個 BufferAttribute,而是:刻意維持新舊三角形的連續對應,讓原本的 BVH 結構仍然成立。
使用限制
這個優化依賴幾個前提:
- 每個舊三角形產生的新三角形,必須連續放在舊位置;若任意打亂順序,葉節點便不能只靠起點和數量表示。
- 範例只處理 position、normal、color。若 geometry 還有 uv、tangent、skin、morph attribute 或 groups,也要同步處理。
- 直接修改 packed BVH 資料會依賴
three-mesh-bvh的內部格式,升級套件版本時要重新確認。 - 這裡只更新葉節點包含的三角形範圍,沒有更新 bounding box。新頂點必須仍落在舊節點的 bounds 內;若細分會把表面推出原範圍,就要額外 refit 受影響節點、預留 bounds 餘量,或在必要時重建 BVH,否則 raycast 可能漏掉跑到 bounds 外的三角形。
它不是適用於所有 geometry 修改的通用捷徑,而是利用「局部細分不會大幅改變空間分布」這個條件,省掉最昂貴的整棵 BVH 重建。