自訂 3D 物件


除了 box 之外,p5.js 內建的 3D 物件還有 planespherecylinderconeellipsoidtorus 等,如果需要其他的 3D 物件,可以透過 beginShapevertexendShape 等自行建立。例如,p5.js 的 box 不能指定位置,底下定義一個可指定位置的立方體:

可以看到,建立的方式是透過 beginShapevertexendShape 指定頂點,p5.js 會自行處理深度問題,在指定頂點時,一個方式是列出全部頂點,然後透過頂點索引來處理,指定頂點時,p5.js 並沒有明確規範順序,然而為了日後對於面的各種計算時方便,順序建議一致,例如都是逆時針或都是順時針,我的習慣是採用逆時針(不過 p5.js 的 beginShape 文件中都是採用順時針)。

只要是共面的頂點,都可以透過這個方式來建構,然而對於比較複雜的 3D 物件,要採用三角形來建構,除了採取頂點索引方式,若你的形狀允許,也可以採取無索引頂點方式。

例如正四面體的四個面都是正三角,在定義頂點時,拿出三角函數是一個方式,不過,其實正四面體可以連接正立方體的四個頂點來畫出來:

自訂 3D 物件

對正四面體來說,可以採用頂點索引或無索引頂點,對於後者,因為正四面體可以有共用邊,將之展開的話就可以清楚看出:

自訂 3D 物件

也就是在實作時,只要依序且循環地走訪頂點四次就可以了,是正四面體的實作範例:

無索引頂點不是每種 3D 物件時都適用,然而有時就是懶,可以只頂點,可否自行找出索引陣列嗎?其實方才的四面體就是一個例子,無索引頂點其實只是說,你不用自行列出索引,而是由程式計算索引,例如,可以建立一個接受頂點與三角面索引的 polyhedron

function polyhedron(points, faces) {
    faces.forEach(vi => {
        const [x0, y0, z0] = points[vi[0]];
        const [x1, y1, z1] = points[vi[1]];
        const [x2, y2, z2] = points[vi[2]];
        beginShape();
        vertex(x0, y0, z0);
        vertex(x1, y1, z1);
        vertex(x2, y2, z2);
        endShape(CLOSE);
    });
}

然後方才的四面體範例可以重構為:

要能自動生成頂點索引,必須能為 3D 物件類型定義生成方式,四面體是一個例子,另一個例子是凸面體,它可以藉由 3D 的凸包(hull)演算來求頂點索引。

3D 的凸包(hull)演算方式之一是遞增法,先將隨意給定的點依 x、y、z 排序,然後求出一個四面體作為初始的凸面體:

// 求得一個四面體
function fstTetrahedron(points) {
    const vts = [0];
    vts.push(tV1(points, vts));
    vts.push(tV2(points, vts));
    vts.push(tV3(points, vts));

    const [v0, v1, v2, v3] = vts;
    // 面的法向量
    const n = p5.Vector.cross(
                  p5.Vector.sub(points[v1], points[v0]), 
                  p5.Vector.sub(points[v2], points[v0])
              );
    // 邊的向量
    const e = p5.Vector.sub(points[v3], points[v0]);

    return {
        vts, 
        faces : p5.Vector.dot(n, e) > 0 ? [
            [v1, v0, v2],
            [v0, v1, v3],
            [v1, v2, v3],
            [v2, v0, v3]
        ] : [
            [v0, v1, v2],
            [v1, v0, v3],
            [v2, v1, v3],
            [v0, v2, v3]
        ]
    };
}

function tV1(points, vts) {
    const v0 = vts[0];
    for(let v1 = 1; v1 < points.length; v1++) {
        if(p5.Vector.sub(points[v1], points[v0]).mag() !== 0) {
            return v1;
        }
    }
    throw new Error('指定的頂點全共點');
}

function tV2(points, vts) {
    const [v0, v1] = vts;
    for(let v2 = v1 + 1; v2 < points.length; v2++) {
        // 兩個向量的法向量長度
        const nL = p5.Vector.cross(
                       p5.Vector.sub(points[v1], points[v0]), 
                       p5.Vector.sub(points[v2], points[v0])
                   ).mag();
        if(nL !== 0) {
            return v2;
        }
    }
    throw new Error('指定的頂點全共線');
}

function tV3(points, vts) {
    const [v0, v1, v2] = vts;
    // 面的法向量
    const n = p5.Vector.cross(
                    p5.Vector.sub(points[v1], points[v0]), 
                    p5.Vector.sub(points[v2], points[v0])
                );
    for(let v3 = v2 + 1; v3 < points.length; v3++) {
        // 邊的向量
        const e = p5.Vector.sub(points[v3], points[v0]);
        if(p5.Vector.dot(n, e) !== 0) {
            return v3;
        }
    }
    throw new Error('指定的頂點全共面');
}

後續每次增加一個點,看看既有凸面體的每個面若與新增的點結合為多面體的話,會是該多面體的凸面、凹面或共面(點在同一個面),這可以用 faceType 來計算:

function faceType(pts, p, face) {
    const p0 = pts[face[0]];
    const p1 = pts[face[1]];
    const p2 = pts[face[2]];
    // 面的法向量
    const n = p5.Vector.cross(p5.Vector.sub(p1, p0), p5.Vector.sub(p2, p0));
    // 點 p 與面上一個頂點構成的向量
    const d = p5.Vector.dot(p5.Vector.sub(p0, p), n);
    // 標示是凸面、凹面或共面的邊
    return d > 0 ? 1 :   // 凸面
           d < 0 ? -1 :  // 凹面
           0;            // 共面
}

若是凸面或共面加以保留,接著點要與保留的面連結成為多面體,連結的方式是找到凸、凹面的邊,連結邊的頂點,為此,使用了一個 edges 來標示邊是凸面、凹面或共面的邊:

function hull(points) {
    // 排序並建立 p5.Vector,便於利用向量運算
    const pts = points.sort(byXyz)
                      .map(p => createVector(p[0], p[1], p[2]));

    // 用來標示邊是在凸面、凹面或共面上
    const edges = [];
    for(let i = 0; i < pts.length; i++) {
        edges.push([]);
    }

    let {vts, faces} = fstTetrahedron(pts);
    for(let i = 0; i < pts.length; i++) {
        if(!vts.includes(i)) {
            const types = faces.map(face => faceType(pts, pts[i], face)); // 面的類型
            // 標示是凸面、凹面或共面的邊
            for(let j = 0; j< faces.length; j++) {
                edges[faces[j][0]][faces[j][1]] = types[j];
                edges[faces[j][1]][faces[j][2]] = types[j];
                edges[faces[j][2]][faces[j][0]] = types[j];     
            }

            faces = nextFaces(i, faces, types, edges);
        }
    }

    polyhedron(points, faces);
}

function byXyz(p1, p2) {
    const dx = p1[0] - p2[0];
    if(dx === 0) {
        const dy = p1[1] - p2[1];
        if(dy === 0) {
            return p1[2] - p2[2];
        }
        return dy;
    }
    return dx;
}

function nextFaces(i, currentFaces, types, edges) {
    // 保留凸面、共面
    const faces = currentFaces.filter((_, j) => types[j] >= 0);  
    // i 點要與保留的面連結成為多面體
    for(let j = 0; j < currentFaces.length; j++) {
        let [v0, v1, v2] = currentFaces[j];
        // 若是凸面、凹面的共用邊,與點 i 建立新面
        if(edges[v0][v1] < 0 && edges[v0][v1] != edges[v1][v0]) {
            faces.push([v0, v1, i]);
        }
        if(edges[v1][v2] < 0 && edges[v1][v2] != edges[v2][v1]) {
            faces.push([v1, v2, i]);
        }
        if(edges[v2][v0] < 0 && edges[v2][v0] != edges[v0][v2]) {
            faces.push([v2, v0, i]);
        }
    }
    return faces;
}

底下是完整的範例,其中隨意設立了多面體的頂點: