路徑擠出(一)


曲線擠出中最簡單的是〈旋轉擠出〉,因為從圓形路徑本身就可以提供切面轉動及位移等資訊,基本上,若曲線本身能提供足夠的資訊,例如曲線由數學公式描述,可由數學公式中計算得到切面轉動及位移等資訊,實作對應的擠出資訊就不會太麻煩。

然而,如果有任意一組點代表著一條路徑,而且提供了一個 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 時的 translaterotate 的順序是一樣的:

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)吧!