Skip to content

THREEjs 局部網格細分

Billy

需求

要在一個模型上畫出更多細節,就需要細分現有的網格。

直接細分模型所有網格並不實際,代價是三角形數量爆炸,記憶體爆炸還有畫面卡頓。所以需要的是局部網格細分:只在使用者選中的區域增加三角形,其他地方維持原樣。

前情提要

網格細分算法主要參考開源專案Sculptgl 專案鏈接算法位置

Sculptgl不是用THREEjs實現,THREEjs處理拓撲(hashPoint、hashEdge、建立鄰居關係)的部分,可參考我之前分享的文章 THREEjs BufferGeometry網格擴散

說明原理

整個流程可以拆成四個步驟:找出選中三角形、標記需要分割的三角形、沿最長邊中點分割、修補T-junction裂縫。

1. 找出使用者選中區域的三角形

根據使用者畫刷的位置和大小,用BVH找出被包含的三角形,作為這次要處理的集合。可參考我之前分享的文章Three-mesh-bvh shapecast函式

2. 標記需要分割的三角形

對於每個使用者選中的三角形,量測它三條邊的長度,若最長邊大於細分精度edgeMax2),則標記為需要分割,並記錄下是哪一條邊。

這裡所有長度都用平方距離(distanceToSquared)來比較,避免開根號的開銷。回傳值 1 / 2 / 3 代表最長邊是哪一條,0 代表不需要分割。

findSplit = (triangleIndex, checkInsideSphere) => {
    const { v1, v2, v3 } = this.findSplitTempData;
    const { bigIndexAttr, bigPosAttr } = this;

    const index = triangleIndex * 3;
    const a = bigIndexAttr.array[index];
    const b = bigIndexAttr.array[index + 1];
    const c = bigIndexAttr.array[index + 2];
    v1.fromBufferAttribute(bigPosAttr, a);
    v2.fromBufferAttribute(bigPosAttr, b);
    v3.fromBufferAttribute(bigPosAttr, c);

    // 只處理真的落在筆刷球體內的三角形
    if (checkInsideSphere) {
        let triangleInsideSphereArray = false;
        for (const center of this.centerArray) {
            if (triangleInsideSphere(center, this.radius2, v1, v2, v3)) {
                triangleInsideSphereArray = true;
                break;
            }
        }
        if (!triangleInsideSphereArray) return 0;
    }

    // 找出最長邊,若超過細分精度才標記
    const length1 = v1.distanceToSquared(v2);
    const length2 = v2.distanceToSquared(v3);
    const length3 = v1.distanceToSquared(v3);
    if (length1 > length2 && length1 > length3) return length1 > this.edgeMax2 ? 1 : 0;
    else if (length2 > length3) return length2 > this.edgeMax2 ? 2 : 0;
    else return length3 > this.edgeMax2 ? 3 : 0;
}

3. 沿最長邊的中點分割

對於每個被標記的三角形,沿著最長邊的中點分割成兩個三角形,並把這條被分割的邊記錄起來(存進 verticesMap),key 是邊的 hash,value 是新插入的中點索引。

記錄的用意是:相鄰的三角形共用這條邊,後面修補 T-junction 時就能查到「這條邊已經有中點了」,直接重用同一個頂點,而不會在同一條邊上產生兩個不同的點。

中點位置除了取兩端點的平均,還會依兩端法線的夾角往外推一點(curvatureStrength),讓細分後的表面更圓滑而不是維持平面。

halfEdgeSplit = (triIdx, iv1, iv2, iv3) => {
    const { /* temp vectors... */ } = this.halfEdgeSplitTempData;
    const { bigIndexAttr, bigPosAttr, bigNorAttr, bigColAttr, verticesMap, localPointMap } = this;

    v1.fromBufferAttribute(bigPosAttr, iv1);
    v2.fromBufferAttribute(bigPosAttr, iv2);
    v3.fromBufferAttribute(bigPosAttr, iv3);

    const key1 = hashPoint(v1.x, v1.y, v1.z);
    const key2 = hashPoint(v2.x, v2.y, v2.z);
    const key = hashEdge(key1, key2);

    // 同一條邊只建立一次中點,相鄰三角形共用
    let midIndex = verticesMap.get(key);
    if (midIndex === undefined) {
        midIndex = bigPosAttr.usedCount;
        verticesMap.set(key, midIndex);

        // 中點法線、顏色取兩端平均
        n1.fromBufferAttribute(bigNorAttr, iv1);
        n2.fromBufferAttribute(bigNorAttr, iv2);
        nMid.addVectors(n1, n2).normalize();
        bigNorAttr.setXYZ(midIndex, nMid.x, nMid.y, nMid.z);
        bigNorAttr.usedCount++;

        c1.fromBufferAttribute(bigColAttr, iv1);
        c2.fromBufferAttribute(bigColAttr, iv2);
        cMid.addVectors(c1, c2).multiplyScalar(0.5);
        bigColAttr.setXYZ(midIndex, cMid.x, cMid.y, cMid.z);
        bigColAttr.usedCount++;

        // 依兩端法線夾角,把中點往外推一點讓表面更圓滑
        const dot = THREE.MathUtils.clamp(n1.dot(n2), -1, 1);
        const angle = Math.acos(dot);
        vEdge.subVectors(v1, v2);
        let offset = angle * this.curvatureStrength * vEdge.length();
        norPlus.addVectors(n1, n2);
        if (norPlus.length() > 0) offset /= norPlus.length();
        norDiff.subVectors(n1, n2);
        if (vEdge.dot(norDiff) < 0) offset = -offset;

        vMid.addVectors(v1, v2).multiplyScalar(0.5);
        vMid.add(norPlus.multiplyScalar(offset));
        bigPosAttr.setXYZ(midIndex, vMid.x, vMid.y, vMid.z);
        bigPosAttr.usedCount++;
    }

    // 原三角形改成 (iv1, mid, iv3),再新增一個 (mid, iv2, iv3)
    const triPosIdx = triIdx * 3;
    bigIndexAttr.array[triPosIdx] = iv1;
    bigIndexAttr.array[triPosIdx + 1] = midIndex;
    bigIndexAttr.array[triPosIdx + 2] = iv3;

    const newTriPosIdx = bigIndexAttr.usedCount;
    bigIndexAttr.array[newTriPosIdx] = midIndex;
    bigIndexAttr.array[newTriPosIdx + 1] = iv2;
    bigIndexAttr.array[newTriPosIdx + 2] = iv3;
    bigIndexAttr.usedCount += 3;

    // ... 同步更新 localPointMap / divideMap
}

4. 修補 T-junction

只分割選中的三角形會留下問題:被分割的邊上多了一個中點,但邊另一側的鄰居三角形沒有跟著分割,於是這個中點懸在邊上,形成 T-junction,渲染時會出現裂縫。

修補方式是:擴張到選中三角形外圍的鄰居,對於每個三角形,只要它的某條邊verticesMap 裡已經有中點,就沿那個既有中點補上一刀,讓中點被兩側的三角形共用,裂縫就消除了。

一個三角形可能有不止一條邊被標記,所以這裡會反覆處理直到沒有新的待分割三角形為止;當多條邊都被標記時,會優先選擇連接點數較少的邊分割,避免局部過度密集。

fillTriangles = (triArray) => {
    const { bigIndexAttr, bigPosAttr, verticesMap, localPointMap } = this;
    const indexArray = bigIndexAttr.array;

    const newTriArray = [];
    for (let i = 0; i < triArray.length; i++) {
        const triIndex = triArray[i];
        const index = triIndex * 3;
        const iv1 = indexArray[index];
        const iv2 = indexArray[index + 1];
        const iv3 = indexArray[index + 2];

        const key1 = pointIndex2pointHash(bigPosAttr, iv1);
        const key2 = pointIndex2pointHash(bigPosAttr, iv2);
        const key3 = pointIndex2pointHash(bigPosAttr, iv3);

        // 查三條邊有沒有已存在的中點
        const val1 = verticesMap.get(hashEdge(key1, key2));
        const val2 = verticesMap.get(hashEdge(key2, key3));
        const val3 = verticesMap.get(hashEdge(key1, key3));

        // 多條邊被標記時,挑連接三角形較少的邊優先分割
        const num1 = localPointMap.get(key1).length;
        const num2 = localPointMap.get(key2).length;
        const num3 = localPointMap.get(key3).length;

        let split = 0;
        if (val1) {
            if (val2) {
                if (val3) {
                    if (num1 < num2 && num1 < num3) split = 2;
                    else if (num2 < num3) split = 3;
                    else split = 1;
                } else if (num1 < num3) split = 2;
                else split = 1;
            } else if (val3 && num2 < num3) split = 3;
            else split = 1;
        } else if (val2) {
            if (val3 && num2 < num1) split = 3;
            else split = 2;
        } else if (val3) split = 3;

        // 沿著「已存在的中點」補一刀,不再建立新點
        if (split === 1) this.fillTriangle(triIndex, iv1, iv2, iv3, val1);
        else if (split === 2) this.fillTriangle(triIndex, iv2, iv3, iv1, val2);
        else if (split === 3) this.fillTriangle(triIndex, iv3, iv1, iv2, val3);
        else continue;

        newTriArray.push(triIndex, bigIndexAttr.usedCount / 3 - 1);
    }

    return newTriArray;
}

THREEjs 實現上的細節

處理拓撲

BufferGeometry 本身查不到「一個頂點周圍有哪些三角形、一條邊被哪兩個三角形共用」這類拓撲關係,必須自己建立。

作法是用 hashPoint 為每個頂點座標算出唯一 hash,用 hashEdge 為一條邊(兩個頂點 hash)算出唯一 hash,再建一個 Map 記錄每個點連到哪些三角形(buildLocalPointMap)。這部分的觀念和 THREEjs BufferGeometry網格擴散 一樣,差別只在這裡只對局部範圍建表,省時省記憶體。

const buildLocalPointMap = (geometry, triIndexSet) => {
    const indexArray = geometry.index.array;
    const posArray = geometry.getAttribute('position').array;

    const pointMap = new Map();
    for (const triIndex of triIndexSet) {
        const triPointIndex = triIndex * 3;
        for (let offset = 0; offset < 3; offset++) {
            const vecIndex = indexArray[triPointIndex + offset] * 3;
            const key = hashPoint(posArray[vecIndex], posArray[vecIndex + 1], posArray[vecIndex + 2]);

            const pointData = pointMap.get(key);
            if (pointData === undefined) pointMap.set(key, [triIndex]);
            else pointData.push(triIndex);
        }
    }

    return pointMap;
}

動態記憶體

細分過程中會不斷新增三角形與頂點,如果每加一個就重新配置一次 BufferAttribute,搬移成本會非常高。

作法是預先配置一塊比目前需求大上一倍的緩衝區(bigIndexAttrbigPosAttr 等),並用一個 usedCount 標記目前實際用到哪裡。新增資料時只往後寫並遞增 usedCount;只有當緩衝區不夠、或閒置太多時才重新配置。最後再依 usedCount 把實際用到的部分切出來組成新的 geometry。

reAllocate = (length) => {
    // ...取得原始 index / position / normal / color attribute

    const indexAttr = this.bigIndexAttr ? this.bigIndexAttr : oriIndexAttr;
    const triLen = indexAttr.usedCount + length;
    // 不夠用,或配置過大(超過需求 4 倍)時才重新配置成 2 倍緩衝
    if (!this.bigIndexAttr || this.bigIndexAttr.count < triLen || this.bigIndexAttr.count > triLen * 4) {
        const bigIndexAttr = new THREE.Uint32BufferAttribute(triLen * 2, 1, indexAttr.normalized);
        bigIndexAttr.set(indexAttr.array);
        bigIndexAttr.usedCount = indexAttr.usedCount;
        this.bigIndexAttr = bigIndexAttr;
    }

    // position / normal / color 同樣的方式預配置 2 倍
    // ...
}

快速更新 BVH

筆刷需要連續地刷,每刷一次三角形數量都在變,若每次都重建一棵 BVH 會嚴重拖慢互動。比較好的作法是局部地更新既有的 boundsTree,這部分牽涉的細節較多,會另外寫一篇專門分享。

Anterior
用 llama.cpp 串起本地 AI Agent:Hermes、OpenHands、n8n 與 SearXNG
下列的
JS宣告物件、陣列技巧