曲線擠出中最簡單的是〈旋轉擠出〉,因為從圓形路徑本身就可以提供切面轉動及位移等資訊,基本上,若曲線本身能提供足夠的資訊,例如曲線由數學公式描述,可由數學公式中計算得到切面轉動及位移等資訊,實作對應的擠出資訊就不會太麻煩。
然而,如果有任意一組點代表著一條路徑,而且提供了一個 2D 形狀,希望能照著路徑擠出 3D 物件,就要注意了,因為你只有點的資訊,例如,若有以下路徑:
問題來了,若其中一個點在切面上,切面該面向哪呢?你也許會說,最後擠出的 3D 物件,必須以該切面為橫切面,因此法向量方向要與線的方向相同,嗯?你指的是哪條線?紅色?還是綠色?
紅色那條是取前一個點與目前的點作為向量計算,綠色那點是取目前的點與下個點作為向量計算,或許就圖來看,紅色似乎比較合理,然而,第一個點的向量又要從何而來呢?反正,選綠色的話,最後一個點的向量又要怎麼計算呢?
記得!因為你只有點的資訊,沒有別的了,實際上無法完整提供切面轉動及位移等資訊,你只能決定一種大致上接近的策略,無論是哪種,都只是個權充用的方案,有時你腦袋中想要的,會大致符合該方案畫出來的 3D 物件,然而有時候不會符合,這並不是方案有錯,而是資訊本來就不足,程式實作採取的方案不對應你的需求罷了。
這邊先採用紅色作為切面的法向量,而第一個點的切面法向量,就將下個切面的法向量拿來用,那麼如何轉動切面?首先,2D 形狀是畫在 xy 平面,中心在原點:
接著,繞 x 軸轉動 -90 度,切面法向量為 y 軸方向:
然後繞 z 軸轉動 90 度,切面法向量為 x 軸方向:
如果路徑上有個如下圖的黑點,該黑點與路徑上前一個點構成的向量,如下圖紅箭頭,會作為切面的法向量,該向量若求得 (r, θ, φ)(對應下圖的橘箭頭):
那麼就將切面繞 z 軸轉動 φ,再繞 y 軸轉動 θ:
然後移動切面,令其中心置於該點,就完成了一個切面的計算:
這種實作方式相對來說比較簡單,在許多情況下,這種方式畫出來的 3D 物件大致上符合需求,不過,在某些條件下其實是會發生問題的,這之後的文件會看到,記得,會發生問題的原因在於,你只有提供了點的資訊!
無論如何,你必須能對點進行 3D 的旋轉與轉換計算,透過矩陣運算會比較方便,你可以自行實作,比較方便的是使用 glMatrix,或者是 p5.js 內建的矩陣運算 p5.Matrix,雖然沒有公開在 Reference,然而研究一下它的原始碼後,如果真的要使用 p5.Matrix 來對點進行運算話,可以先建立並組合矩陣,例如想先繞 x 軸轉動 -90 度,再繞 z 軸轉動 90 度,不過 p5.Matrix
旋轉時一律接受徑度,而且留意一下轉換順序,與使用 p5.js 時的 translate
、rotate
的順序是一樣的:
const m = new p5.Matrix();
// 指定徑度
m.rotateZ(HALF_PI);
m.rotateX(-HALF_PI);
有了矩陣物件後,對於一個點 [x, y, z]
,可以透過以下的 applyMatrixForPoint
來計算轉換後的點:
function applyMatrixForPoint(m, p) {
return [
m.mat4[0] * p[0] + m.mat4[4] * p[1] + m.mat4[8] * p[2] + m.mat4[12],
m.mat4[1] * p[0] + m.mat4[5] * p[1] + m.mat4[9] * p[2] + m.mat4[13],
m.mat4[2] * p[0] + m.mat4[6] * p[1] + m.mat4[10] * p[2] + m.mat4[14],
];
}
為了能得到 θ、φ,也來實作一個 thetaPhi
:
function thetaPhi(vt) {
const theta = _toRadians(vt.heading());
const phi = _toRadians(createVector(vt.x, vt.y, 0).angleBetween(vt));
return {theta, phi};
}
現在可以來實作 pathExtrude
了:
function pathExtrude(shape, path) {
const m = new p5.Matrix();
// 指定徑度
m.rotateZ(HALF_PI); // 繞 z 軸轉動 PI / 2
m.rotateX(-HALF_PI); // 繞 x 軸轉動 PI / 2
// 初始的面
const shape3D = shape.map(p => [p[0], p[1], 0])
.map(p => applyMatrixForPoint(m, p));
// 路徑上的點都轉換為 p5.Vector,便於向量計算
const vts = path.map(p => createVector(p[0], p[1], p[2]));
// 第一個面與第二個面的 theta、phi
const fst = thetaPhi(p5.Vector.sub(vts[0], vts[1]));
const angles = [fst, fst];
// 其他面的 theta、phi
for(let i = 1; i < vts.length - 1; i++) {
angles.push(thetaPhi(p5.Vector.sub(vts[i], vts[i + 1])));
}
sweep(
angles.map((as, i) => {
const {theta, phi} = as;
const m = new p5.Matrix();
m.translate(path[i]); // 位移
m.rotateZ(theta); // 旋轉
m.rotateY(phi); // 旋轉
return shape3D.map(p => applyMatrixForPoint(m, p));
})
);
}
底下是完整的範例:
接下來,就看你要不要在擠出過程加上扭轉、縮放等有的沒的了,這就自行嘗試了,最後來畫個 環面扭結(Torus knot)吧!