勢力的交集(一)


透過 Worley 雜訊可以畫出 Voronoi 圖形,只不過代價是昂貴的運算,因為使用了全部的像素來運算,透過 CPU 來做這類運算相當吃力,因而 Worley 雜訊通常是應用 GPU 著色器的撰寫上。

可以透過幾何運算來求得 Voronoi 圖形,最易於理解的是半平面交集(Half-plane intersection),因為就是從勢力範圍的觀點來求 Voronoi 圖形。

例如,若一開始有兩個點,兩個點的中垂線畫出了兩者的勢力範圍:

勢力的交集(一)

現在將藍色點與另一個空間中的點也畫出勢力範圍,那麼藍色點可以擁有的勢力範圍,就是前一個勢力範圍與目前勢力範圍的交集:

勢力的交集(一)

重複相同步驟,藍色點與更多的點求得勢力範圍,並取得勢力範圍交集,直到全部的點都處理過為止。例如,有五個點的話,最後藍色點求得的勢力範圍就是下圖的紅色區域:

勢力的交集(一)

對於另外四個點,也可以重複以上的步驟,在每個點的勢力範圍交集都求得後,就會得到 Voronoi 圖形:

勢力的交集(一)

若要透過程式來實現半平面交集,只要有個夠大的正方形就可以了,為了方便,可以定義一個 polygonSquare 函式,可以指定正方形邊長大小,傳回正方形逆時針頂點的清單,頂點都使用 p5.Vector 來表示,這是因為透過向量,後續計算上會很方便:

function polygonSquare(size) {
    const halfS = size / 2;
    return [
      createVector(halfS, -halfS),
      createVector(halfS, halfS),
      createVector(-halfS, halfS),
      createVector(-halfS, -halfS)
   ];
}

那麼有兩個正方形的話,如何求得其交集呢?多邊形交集其實是個複雜的議題,幸好的是,正方形是凸多邊形,凸多邊形的交集在計算上簡單多了,先看看下圖:

勢力的交集(一)

求得另一方在自身範圍內的頂點,以及邊的交點,將這些點收集起來逆時針排序就可以了,因此有三個子任務必須完成:

  • 自身範圍內的頂點
  • 邊的交點
  • 點的逆時針排序

自身範圍內的頂點,雖然可以使用〈像素多邊形〉中 inShape 的實現原理,不過凸多邊形要做這種判斷,有更簡單的方式,如果點在凸多邊形內,那麼該點與凸多邊形的每個頂點相連而成的向量,若逆時針每兩個向量進行叉積(cross),那麼叉積後的向量方向都會相同,因此可以實作出:

function inConvex(convexPoints, p) {
    const preCp = p5.Vector.cross(
                     p5.Vector.sub(convexPoints[convexPoints.length - 1], p),
                     p5.Vector.sub(convexPoints[0], p)
                  ).z;
    for(let i = 0; i < convexPoints.length - 1; i++) {
        const cp = p5.Vector.cross(
                       p5.Vector.sub(convexPoints[i], p),
                       p5.Vector.sub(convexPoints[i + 1], p)
                   ).z;
        if(preCp * cp <= 0) {
            return false;
        }
    }

    return true;
}

邊的交點就是兩線的交集,若線的資料結構定義為 {p1: v1, p2: v2},其中 v1v2 是座標,然而以向量表示,有了線的點資料,就可以求斜率,然後取得線的方程式表示,兩條方程式求聯立方程式,就可以求交點:

function onLine(line, p, epsilon = 1e-5) {
    const v1 = p5.Vector.sub(line.p1, p);
    const v2 = p5.Vector.sub(line.p2, p);
    return p5.Vector.cross(v1, v2).mag() <= epsilon && p5.Vector.dot(v1, v2) <= epsilon;
}

function intersectionLines(line1, line2, epsilon = 1e-5) {
    const a = p5.Vector.sub(line1.p2, line1.p1);
    const b = p5.Vector.sub(line2.p2, line2.p1);

    const cp = p5.Vector.cross(a, b).z;

    if(abs(cp) >= epsilon) { // 不共線、不平行
        const s = p5.Vector.sub(line2.p1, line1.p1);
        const p = p5.Vector.add(line1.p1, p5.Vector.mult(a, p5.Vector.cross(s, b).z / cp));

        // 交點是否在邊上
        if(onLine(line1, p, epsilon) && onLine(line2, p, epsilon)) {
            return p;
        }
    }

    return null;
}

若兩線共線或平行,兩線的向量叉積大小會是 0,因為浮點數會有計算上的誤差,這邊使用了 epsilon 來設定大於該值,就不視為共線或平行了。

解聯立方程式得到的交點,可能是在邊的延伸上,然而我們要的是邊與邊的交點,因此必須進一步判斷交點是否在邊上,同樣地,如果點在邊上,那麼該點與邊的兩個頂點構成的向量,叉積會是 0,因為浮點數會有計算上的誤差,這邊使用了 epsilon,接著進一步判斷了點積(dot)的結果(也就是投影量)小於 epsilon,來判斷點是否在邊上。

接著就是針對收集到的頂點進行排序,這很簡單,可先找出多邊形的中點,這是因為中點的求得與頂點的順序沒有關係:

function convexCenterPoint(convexPoints) {
    let x = 0;
    let y = 0;
    for(let i = 0; i < convexPoints.length; i++) {
        x += convexPoints[i].x;
        y += convexPoints[i].y;
    }
    return createVector(x / convexPoints.length, y / convexPoints.length);
}

然後用中點來求得它與每個每個頂點的夾角,根據夾角進行排序:

function convexCtClk(convexPoints) {
    const centerP = convexCenterPoint(convexPoints);
    return convexPoints.sort((p1, p2) => {
                           return atan2(p1.y - centerP.y, p1.x - centerP.x) - 
                                  atan2(p2.y - centerP.y, p2.x - centerP.x);
                        });
}

完成這三個子任務後,就可以來求凸多邊形交集了:

// 找出凸多邊形的邊與線的交點
function intersectionConvexLine(convexPoints, line, epsilon = 1e-10) {
    const pts = [];
    for(let i = convexPoints.length - 1, j = 0; j < convexPoints.length; i = j++) {
        const p = intersectionLines(line, {p1: convexPoints[i], p2: convexPoints[j]}, epsilon);
        if(p !== null && pts.every(pt => !pt.equals(p))) {
            pts.push(p);
        }
    }
    return pts;
}

// 凸多邊形交集
function convexIntersection(convexPoints1, convexPoints2, epsilon = 1e-10) {
    // 邊的交點
    const points = [];
    for(let i = convexPoints1.length - 1, j = 0; j < convexPoints1.length; i = j++) {
        const pts = intersectionConvexLine(convexPoints2, {p1: convexPoints1[i], p2: convexPoints1[j]}, epsilon);
        for(let k = 0; k < pts.length; k++) {
            points.push(pts[k]);
        }
    }

    return convexCtClk( // 逆時針排列
       // 接上凸多邊形內的頂點
       points.concat(convexPoints1.filter(p => inConvex(convexPoints2, p)))
             .concat(convexPoints2.filter(p => inConvex(convexPoints1, p)))
    );
}

這篇文件就先完成到凸多邊形交集,為了做個展示,來個可平移、轉動各頂點的函式:

function polygonTranslate(polygonPoints, x, y) {
    return polygonPoints.map(p => createVector(p.x + x, p.y + y));
}

function polygonRotate(polygonPoints, angle) {
    return polygonPoints.map(p => p.copy().rotate(angle));
}

底下的範例可利用方才的函式,畫出兩個正方形的交集,其中一個正方形可以用滑鼠控制位置,按左、右鍵轉動: