除了 box
之外,p5.js 內建的 3D 物件還有 plane
、sphere
、cylinder
、cone
、ellipsoid
、torus
等,如果需要其他的 3D 物件,可以透過 beginShape
、vertex
、endShape
等自行建立。例如,p5.js 的 box
不能指定位置,底下定義一個可指定位置的立方體:
可以看到,建立的方式是透過 beginShape
、vertex
、endShape
指定頂點,p5.js 會自行處理深度問題,在指定頂點時,一個方式是列出全部頂點,然後透過頂點索引來處理,指定頂點時,p5.js 並沒有明確規範順序,然而為了日後對於面的各種計算時方便,順序建議一致,例如都是逆時針或都是順時針,我的習慣是採用逆時針(不過 p5.js 的 beginShape
文件中都是採用順時針)。
只要是共面的頂點,都可以透過這個方式來建構,然而對於比較複雜的 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;
}
底下是完整的範例,其中隨意設立了多面體的頂點: