路徑擠出(三)


在〈路徑擠出(二)〉中談到了,若獨立地處理每個切面,在某些路徑可能會造成斷裂狀,想避免這種情況,可以令切面與切面間有前後關係。

這比較像是將一開始的 2D 形狀看成是個環,路徑在環中,環循著路徑不斷地前進,當路徑變化時,你的環也會做出必要的旋轉,從離散的時間點來看,旋轉後的環是有前後關係的。

那麼程式面上該如何做到這種關係呢?方式之一是,若前一個切面的法向量為下圖紅色,下個切面法向量與下圖綠色:

路徑擠出(三)

那麼令紅色向量轉動到與綠色向量方向一致,而轉動紅色向量的時候,切面也要轉動,令紅色向量始終是切面的法向量,因為轉動的角度與綠色向量有關,切面的轉動也就有了前後關係。

那麼該怎麼轉動紅色向量轉動到與綠色向量方向一致呢?紅色向量與綠色向量會構成一個平面,計算出這個平面上兩個向量的夾角,並以該平面的法向量來轉動:

路徑擠出(三)

而因為紅色向量始終是切面的法向量,紅色向量轉的度數,就是切面上各點轉的度數,只不過問題來了,上圖的黑色向量不會是 x、y 或 z 軸,而是任意的一個軸,如何繞著指定的軸轉動呢?這是軸角(Axis–angle)轉動表示,可以用〈四元數旋轉矩陣〉來計算,而 p5.Matrixrotate 方法就提供了實作。

至於兩個向量間的夾角,可以用以下函式計算:

function angleBetween(vt1, vt2) {
    return _toRadians(acos(p5.Vector.dot(vt1, vt2) / (vt1.mag() * vt2.mag())));
}

由於切面與切面之間有前後關係,這代表著用來旋轉、移動切面的矩陣,也會有前後關係,要注意的是,p5.Matrix 的矩陣計算,在撰寫順序上與 p5.js 的矩陣套用是相同的,因此計算矩陣時,撰寫順序是先 translate,之後旋轉的累計,底下的 transformMatrices 是以路徑及各切面的法向量來計算各個轉換矩陣:

function transformMatrices(path, nVts) {
    const matrices = path.map(p => {
        const m = new p5.Matrix(); 
        m.translate(p);
        return m;
    });

    // 第 0 個切面法向量的 theta、phi
    const angles = thetaPhi(nVts[0]);

    for(let i = 0; i < matrices.length; i++) {
        const m = matrices[i];
        // 角軸旋轉累積
        for(let j = i; j > 1; j--) {
            const vt1 = nVts[j - 1];
            const vt2 = nVts[j];

            const axis = p5.Vector.cross(vt1, vt2);
            const a = angleBetween(vt1, vt2);
            m.rotate(a, axis.x, axis.y, axis.z);
        }
        m.rotateZ(angles.theta);
        m.rotateY(angles.phi);
    }  

    return matrices;
}

有了轉換矩陣,就可以重新實作 pathExtrude

function pathExtrude(shape, path) {
    const m = new p5.Matrix();
    m.rotateZ(HALF_PI)
    m.rotateX(-HALF_PI);

    const shape3D = shape.map(p => [p[0], p[1], 0])
                         .map(p => applyMatrixForPoint(m, p));

    const vts = path.map(p => createVector(p[0], p[1], p[2]));

    const fstNvt = p5.Vector.sub(vts[0], vts[1]);

    const nVts = [fstNvt, fstNvt]; 
    for(let i = 1; i < vts.length - 1; i++) {
        nVts.push(p5.Vector.sub(vts[i], vts[i + 1]))
    }

    const matrices = transformMatrices(path, nVts);
    const sections = transformMatrices(path, nVts).map(
        m => shape3D.map(p => applyMatrixForPoint(m, p))
    );

    sweep(sections);
}

這麼一來,〈路徑擠出(二)〉中的問題,在這邊就能獲得解決了:

看來這個版本比較好嗎?不一定,這只是一種猜想切面如何翻轉的方式,猜想的結果可能不是你要的,來看看這個環面紐結有什麼問題:

放大其中一部份來看:

路徑擠出(三)

第一個切面與最後一個切面顯然差距很大?在〈路徑擠出(一)〉中的版本,卻沒有這個問題?

再來看〈路徑擠出(二)〉中的螺旋,在這邊的實作中會有什麼問題:

看得出來嗎?將兩張圖擺一起:

路徑擠出(三)

左邊的切面顯然比較一致,右邊的切面有沿著路徑扭轉的情況,沒有哪個對哪個錯,只是哪個會是你腦袋中想要的圖形。

路徑擠出本來就是在只提供點,資訊不足的情況下嘗試腦補的功能,除了這邊談到的兩種方式外,也許你也可以發現其他的方式,當然,有數學公式、能提供切面翻轉詳細資訊,撰寫出專用的擠出功能,才能繪製出精確的結果。