Canvas原生API(純CPU)計算並渲染三維圖

隨遇丿而安發表於2022-01-06

Canvas原生API(純CPU)計算並渲染三維圖

前端工程師學圖形學:Games101 第三次作業

利用Canvas畫三維中的三角形並使用超取樣實現抗鋸齒

最終完成功能

  1. Canvas 原生API實現三角形柵格化演算法
  2. 實現 z-buffer 判斷三角形先後關係
  3. 使用 super-sampling 處理 Anti-aliasing,也就是超取樣實現抗鋸齒

第三次作業1

展示2

1 整體分析

本次實驗中,首先需要進行矩陣變換,將初始傳入的三角形經過變換後到規範立方體內,這需要進行三種變換。設一個點的座標變換為(x, y, z) -> (x', y', z')

\[\begin{bmatrix} x' \\ y' \\ z' \\ 1 \end{bmatrix} = M_{presp} \times M_{view} \times M_{model} \times \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} \]

每個矩陣的求解在之前的部落格中都有講解圖形學 旋轉與投影矩陣—2 - 知乎 (zhihu.com),這是其中一篇可供參考,因此,投影矩陣,檢視矩陣和模型矩陣這裡不再求解。現狀,變換矩陣已經知道,現狀需要將轉換後的矩陣進行光柵化,在光柵化時,需要遍歷螢幕上的每個畫素點進行判斷,該點是否在三角形內,如果在,則渲染,由於本文采用的 Canvas 進行渲染,因此需要對 Canvas 上的每個畫素點進行判斷。

光柵化完成後,可得到一個充滿顏色的三角形,如果渲染多個三角形,會產生覆蓋現象,這個時候就需要判斷深度,因此我們需要維護一個深度緩衝的陣列,這個陣列的大小為 canvas 的 width*height。當渲染後面的三角形時,首先判斷該畫素的當前深度是否小於預渲染畫素的深度,如果小於,則渲染,否則,不進行處理。

上述完成後,會得到一些一個帶鋸齒的三角形,為了解決鋸齒問題,這裡進行了超取樣,即讓一個畫素點平分為 9 塊正方形區域,看九塊區域有多少在三角形內,佔比情況,憑佔比量設定該畫素的顏色,最終完成抗鋸齒的功能。

總結,完成該實驗的步驟如下

  1. 矩陣變換,投影,檢視,模型變換
  2. 光柵化,使用 Canvas 原生 Api 畫顏色
  3. 抗鋸齒,超取樣實現,將一個畫素點分為 9 個正方形

2 程式碼分析

第一步:矩陣變換函式

// 變換函式
function getFinalPosition(position){
    const finalPosition = new THREE.Vector4().set(
        position.x,
        position.y,
        position.z,
        1
    ).applyMatrix4(perspMatrix).applyMatrix4(viewMatrix);
    finalPosition.set(
        finalPosition.x/finalPosition.w,
        finalPosition.y/finalPosition.w,
        finalPosition.z/finalPosition.w,
        1
    )
    return finalPosition;
}

輸入三角形的座標即可得到轉換後的最終座標,為了簡單使用,這裡沒有使用到模型矩陣,僅僅用到了投影矩陣和檢視矩陣。經過轉換後,三角形座標 x,y,z 都被規範到 [-1, 1] 之間了

第二步:將畫素座標轉換為螢幕座標

螢幕空間內,畫素是從 (0, 0) 到 (width-1, height-1),渲染的範圍為 (0, 0) 到 (width, height),width 和 height 是 Canvas DOM 的寬和高。注意:畫素是一個一個方塊,如下圖所示。

2_1畫素

由此可得,規範立方體到螢幕空間的座標變換矩陣為

\[M_{viewport} = \begin{bmatrix} \frac{width}{2} & 0 & 0 & \frac{width}{2} \\ 0 & \frac{height}{2} & 0 & \frac{height}{2} \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \]

程式碼如下

// 轉換成螢幕座標
function transScreen(positions, width, height){
    const MViewPort = new THREE.Matrix4();
    MViewPort.set(
        width/2, 0, 0, width/2,
        0, -height/2, 0, height/2,
        0, 0, 1, 0,
        0, 0, 0, 1
    )
    positions.forEach(vec => vec.applyMatrix4(MViewPort));
}

第三步:光柵化

光柵化:遍歷相應畫素點,判斷該點是否在三角形內,在的話才繼續處理,遍歷範圍是包圍三角形最大的矩形盒。

矩形盒

求得三個頂點的寬高的最大最小值即可求出這個包圍盒,分別為 minX, minY, maxX, maxY,開始遍歷判斷

const ctx = canvas.getContext('2d');
...
// 1. 遍歷包圍盒的每個畫素
for (let i = Math.round(minX); i < maxX; i++) {
        for (let j = Math.round(minY); j < maxY; j++) {
            ...
            // 2. 判斷畫素是否在三角形內
            if((count=getInner(point1, point2, point3, pixel))!==0){
                ...
                // 3. 建立顏色
                switch (type) {
                    case 1:
                        data[0] = redColor;
                        data[1] = greenColor;
                        data[2] = blueColor;
                        data[3] = 透明度;
                        break;
                    case 2:
                        data[0] = redColor;
                        data[1] = greenColor;
                        data[2] = blueColor;
                        data[3] = 透明度;
                        break;
                }
				// 4. 賦予相應畫素點顏色
                ctx.putImageData(myImageData, i, j);
            }
        }
    }

執行上述程式後,Canvas 繪畫單個三角形的工作便完成了。

我們需要維護一個深度陣列,用來儲存當前畫素的深度

const z_buffer = []
for (let i = 0; i < height; i++) {
    const arr = [];
    z_buffer.push(arr)
    for (let j = 0; j < width; j++) {
        arr.push(-Number.MAX_VALUE)
    }
}

設定每個數字為無窮遠,代表後續的每個三角形都比其畫素點近,如果點在三角形內,判斷當前深度並進行賦值

// getZ 表示獲取欲渲染畫素點的深度,z_buffer 儲存當前畫素點深度
const z = getZ(i+0.5, j+0.5);
if(z<z_buffer[j][i]){
    // console.log('success');
    continue;
}
z_buffer[j][i] = z;

第四步:抗鋸齒

將一個畫素點分為 9 份相同大小的正方形,判斷有多少份正方形在三角形內,最後憑佔比賦予顏色

// 獲得一個畫素點分成 9 份正方形後,在三角形內的個數
function getInner(point1, point2, point3, pixel){
    let extend = {x:0, y:0, index: 0}
    for (let i = 1/6; i < 1; i+=1/3) {
        for (let j = 1/6; j < 1; j+=1/3) {
            extend.x = i;
            extend.y = j;
            if(isInner(point1, point2, point3, pixel, extend)) extend.index++;
        }
    }
      
    // 判斷當前正方形是否在三角形內
    function isInner(point1, point2, point3, pixel, extend){
        pixel.x += extend.x;
        pixel.y += extend.y;

        const ab = new THREE.Vector3().subVectors(point2, point1);
        const bx = new THREE.Vector3().subVectors(pixel, point2);
        const direct1 = new THREE.Vector3().crossVectors(ab, bx);

        const bc = new THREE.Vector3().subVectors(point3, point2);
        const cx = new THREE.Vector3().subVectors(pixel, point3);
        const direct2 = new THREE.Vector3().crossVectors(bc, cx);

        const ca = new THREE.Vector3().subVectors(point1, point3);
        const ax = new THREE.Vector3().subVectors(pixel, point1);
        const direct3 = new THREE.Vector3().crossVectors(ca, ax);

        const f1 = direct1.dot(direct2);
        const f2 = direct2.dot(direct3);

        return Math.sign(f1) === 1 && Math.sign(f2) === 1;
    }

    return extend.index;
}

將畫素點平均分成九份後,每份都為正方形,找出正方形中心,判斷該中心是否在正方形內,最後總結出在三角形內的正方形個數,最後賦予顏色.

count=getInner(point1, point2, point3, pixel);
const rat = count/9;
switch (type) {
    case 1:
        data[0] = 255 * rat + oriData[0] * (1-rat);
        data[1] = oriData[1] * (1-rat);
        data[2] = oriData[2] * (1-rat);
        data[3] = 255 * rat + oriData[3] * (1-rat);
        break;
    case 2:
        data[0] = oriData[0] * (1-rat);
        data[1] =255 * rat + oriData[1] * (1-rat);
        data[2] =255 * rat + oriData[2] * (1-rat);
        data[3] =255 * rat + oriData[3] * (1-rat);
        break;
}

3. 總結

使用 Canvas 原生 API 實現三維圖形的光柵化,能夠加強我們都圖形學座標轉換的整體印象,能使我們瞭解基本原理,對我們理解遊戲等三維引擎的底層原理有很大的幫助。

我這完整程式碼沒有整理,不太好看,就不放出來了,需要原始碼交流的可以私聊,如果覺得有用,可以點個贊哦。

相關文章