webgl 系列 —— 繪製貓

2023-03-20 21:01:36

其他章節請看:

webgl 系列

繪製貓

上文我們瞭解瞭如何繪製漸變彩色三角形,明白了圖形裝配光柵化,以及片元著色器計算片元的顏色。

現在如果讓你繪製如下一隻貓。難道繪製很多三角形,然後指定它們的顏色?那樣簡直太難、太繁瑣了。

這時可以使用三維圖學中的紋理對映技術來解決這個問題。

紋理對映簡單來講就是將一張圖對映(貼)到一個幾何圖形的表面。

例如這樣:

本篇最後將實現如下效果:

漸變矩形

根據漸變三角形,我們很容易就可以繪製一個漸變矩形。就像這樣:

完整程式碼如下:

const VSHADER_SOURCE = `
attribute vec4 a_Position;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
  gl_Position = a_Position;
  v_uv = a_uv;
}
`
const FSHADER_SOURCE = `
precision mediump float;
varying vec2 v_uv;
void main() {
  gl_FragColor = vec4(v_uv, 0.0, 1.0);
}
`

function main() {
    const canvas = document.getElementById('webgl');
    const gl = canvas.getContext("webgl");
    if (!gl) {
        console.log('Failed to get the rendering context for WebGL');
        return;
    }

    if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
        console.log('Failed to intialize shaders.');
        return;
    }

    gl.clearColor(0.0, 0.5, 0.5, 1.0);


    // 幾何圖形的4個頂點的座標
    const positions = new Float32Array([
        // 左下角是第一個點,逆時針
        -0.5, -0.5,
        0.5, -0.5,
        0.5, 0.5,
        -0.5, 0.5,
    ])

    // 紋理的4個點的座標。通常稱為 uv(u類似x,v類似y) 座標
    const uvs = new Float32Array([
        // 左下角是第一個點,逆時針,與頂點座標保持對應
        0.0, 0.0,
        1.0, 0.0,
        1.0, 1.0,
        0.0, 1.0
    ])

    initVertexBuffers(gl, positions)

    initUvBuffers(gl, uvs)

    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);

}

function initVertexBuffers(gl, positions) {
    const vertexBuffer = gl.createBuffer();
    if (!vertexBuffer) {
        console.log('建立緩衝區物件失敗');
        return -1;
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

    gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);

    const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
    if (a_Position < 0) {
        console.log('Failed to get the storage location of a_Position');
        return -1;
    }

    gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);

    gl.enableVertexAttribArray(a_Position);
}

function initUvBuffers(gl, uvs) {
    const uvsBuffer = gl.createBuffer();
    if (!uvsBuffer) {
        console.log('建立 uvs 緩衝區物件失敗');
        return -1;
    }
    gl.bindBuffer(gl.ARRAY_BUFFER, uvsBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, uvs, gl.STATIC_DRAW);
    const a_uv = gl.getAttribLocation(gl.program, 'a_uv');
    if (a_uv < 0) {
        console.log('Failed to get the storage location of a_uv');
        return -1;
    }

    gl.vertexAttribPointer(a_uv, 2, gl.FLOAT, false, 0, 0);

    gl.enableVertexAttribArray(a_uv);
}

漸變矩形從左下角,逆時針,依次是黑、紅、黃、綠。與這段程式碼是匹配的:

// 幾何圖形的4個頂點的座標
const positions = new Float32Array([
    // 左下角是第一個點,逆時針
    -0.5, -0.5,
    0.5, -0.5,
    0.5, 0.5,
    -0.5, 0.5,
])

const uvs = new Float32Array([
    // 左下角是第一個點,逆時針,與頂點座標保持對應
    0.0, 0.0, // 黑
    1.0, 0.0, // 紅
    1.0, 1.0, // 黃
    0.0, 1.0, // 綠
])

這裡的 uvs 涉及紋理(貼圖)座標,是為貼圖做準備。

Tip: 接下來只需要把矩形中每個畫素的顏色換成紋理對應畫素的顏色即可。

紋理座標

對於貼圖,幾何圖形就得獲取紋理對應畫素的顏色,得有一個對映關係,否則獲取哪個畫素的顏色。座標對應關係如下:

紋理座標如下:

// 左下角,逆時針
0.0 0.0 // 左下角
1.0 0.0 // 右下角
1.0 1.0 // 右上角
0.0 1.0 // 左上角

漸變矩形我們所做的工作就是將紋理的範圍和幾何圖形對應上。

為了區分其他座標,這裡紋理座標不叫 (x, y),通常叫 (u, v) 或 (s, t)。

Tip:照片尺寸和紋理座標是沒有關係的。無論圖片多大,右下角都是(1.0, 0.0)。假如一張 1024*256 的圖片放入 256*256 的幾何圖形中,貼圖的寬度就會被壓縮。就像這樣:

繪製貓

效果

思路

  • 通過 new Image 定義圖片,圖片載入完成後建立紋理
  • 紋理的使用類似緩衝物件,有一系列規則
  • 在將紋理傳給片元著色器中定義的取樣器 u_Sampler(好像圖片的控制程式碼)
  • 最後通過 texture2D(u_Sampler, v_uv) 取得紋理畫素的顏色

完整程式碼

const VSHADER_SOURCE = `
attribute vec4 a_Position;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
  gl_Position = a_Position;
  v_uv = a_uv;
}
`
const FSHADER_SOURCE = `
precision mediump float;
// 定義一個取樣器。sampler2D 是一種資料型別,就像 vec2
uniform sampler2D u_Sampler;
varying vec2 v_uv;
void main() {
  // texture2D(sampler2D sampler, vec2 coord) - 著色器語言內建函數,從 sampler 指定的紋理上獲取 coord 指定的紋理座標處的畫素
  vec4 color = texture2D(u_Sampler, v_uv);
  gl_FragColor = color;
}
`

function main() {
    const canvas = document.getElementById('webgl');
    const gl = canvas.getContext("webgl");
    if (!gl) {
        console.log('Failed to get the rendering context for WebGL');
        return;
    }

    if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
        console.log('Failed to intialize shaders.');
        return;
    }

    gl.clearColor(0.0, 0.5, 0.5, 1.0);

    // 幾何圖形的4個頂點的座標
    const verticesOfPosition = new Float32Array([
        // 左下角是第一個點,逆時針
        -0.5, -0.5,
        0.5, -0.5,
        0.5, 0.5,
        -0.5, 0.5,
    ])

    // 紋理的4個點的座標
    const uvs = new Float32Array([
        // 左下角是第一個點,逆時針,與頂點座標保持對應
        0.0, 0.0,
        1.0, 0.0,
        1.0, 1.0,
        0.0, 1.0
    ])

    // 和漸變矩形相同
    initVertexBuffers(gl, verticesOfPosition)

    // 和漸變矩形相同
    initUvBuffers(gl, uvs)

    initTextures(gl)
}

// 初始化紋理。之所以為複數 s 是因為可以貼多張圖片。
function initTextures(gl) {
    // 定義圖片
    const img = new Image();
    // 請求 CORS 許可。解決圖片跨域問題
    img.crossOrigin = "";
    // The image element contains cross-origin data, and may not be loaded.
    img.src = "http://placekitten.com/256/256";

    img.onload = () => {
        // 建立紋理
        const texture = gl.createTexture();

        // 取得取樣器
        const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler');
        if (!u_Sampler) {
            console.log('Failed to get the storage location of u_Sampler');
            return false;
        }
        // pixelStorei - 影象預處理:圖片上下對稱翻轉座標軸 (圖片本身不變)
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        // 啟用紋理單元
        gl.activeTexture(gl.TEXTURE0);
        // 繫結紋理物件
        gl.bindTexture(gl.TEXTURE_2D, texture);
        // 設定紋理引數
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        // 紋理圖片分配給紋理物件
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
        // 將紋理單元傳給片元著色器
        gl.uniform1i(u_Sampler, 0);

        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
    }
}

Tip: 為了方便演示,這裡通過 http://placekitten.com/256/256 返回一個指定尺寸貓(256*256)的圖片。需要解決圖片跨域問題,詳情請看這裡

影象 Y 軸反轉

pixelStorei - 用於影象預處理的函數

假如註釋 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); 圖片就會反過來。就像這樣:

原因是 canvas 座標中的 y 是向下,而紋理的 y(v) 是向上:

啟用紋理單元

webgl 通過紋理單元的機制同時使用多個紋理。每個紋理單元有個編號來管理一張紋理圖片。

根據硬體和瀏覽器對webgl的實現,webgl 至少支援8個紋理單元,有的更多。

內建變數 gl.TEXTURE0gl.TEXTURE1...gl.TEXTURE7 各表示一個紋理單元

activeTexture - 用來啟用指定的紋理單元。例如啟用一個紋理單元:

繫結紋理物件

gl.bindTexture(target, texture) - 指定紋理物件型別,將其繫結到紋理單元。就像這樣:

target 指紋理物件的型別(我們這裡就使用二維紋理):

  • gl.TEXTURE_2D: 二維紋理
  • gl.TEXTURE_CUBE_MAP: 立方體對映紋理

在 webgl 中不能直接操作紋理物件,必須將其繫結到紋理單元上,在通過紋理單元來操作。

圖片分配給紋理物件

執行完 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img) 後,圖片將分配給紋理物件。就像這樣:

這行程式碼引數很多,最主要的就是最後一個引數,即圖片。

Tip:texImage2D 語法如下:

gl.texImage2D(target, level, internalformat, format, type, HTMLImageElement? pixels):
    - target - gl.TEXTURE_2D `二維紋理` 或 gl.TEXTURE_CUBE_MAP 立方體對映紋理
    - level - 傳入 0(該引數為金字塔紋理準備,這裡不是)
    - internalformat - 影象的內部格式,這裡是 RBG
    - format - 紋理的資料格式,必須與 internalformat 相同
    - type - 紋理資料型別
    - HTMLImageElement - 圖片

紋理單元傳給片元著色器

前面已經將貼圖放入紋理物件,執行 gl.uniform1i(u_Sampler, 0) 就會將紋理單元傳給片元著色器。效果如下:

設定紋理引數

gl.texParameteri 用於設定紋理引數

語法:

gl.texParameterf(GLenum target, GLenum pname, GLfloat param)

target
    gl.TEXTURE_2D: 二維紋理。
    gl.TEXTURE_CUBE_MAP: 立方體紋理。

pname
    gl.TEXTURE_MAG_FILTER 紋理放大濾波器    gl.LINEAR (預設值), gl.NEAREST.
    gl.TEXTURE_MIN_FILTER 紋理縮小濾波器 
    gl.TEXTURE_WRAP_S     紋理座標水平填充  gl.REPEAT (預設值),gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT.
    gl.TEXTURE_WRAP_T     紋理座標垂直填充  gl.REPEAT (預設值),gl.CLAMP_TO_EDGE, gl.MIRRORED_REPEAT.

在繪製貓時我們進行了如下設定:

gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

我們的貼圖是二維的,所以選用 TEXTURE_2D。

TEXTURE_MAG_FILTER 放大紋理。例如將尺寸 1616 的圖片貼到 3232 的幾何圖形上,就得無中生有。無中生有,LINEAR 表示距離新畫素最近的4個畫素顏色的加權平均,比 NEAREST(最近的) 運算量大,但質量更好

只貼部分

需求:將圖片貼到幾何圖形左下角部分。

可以通過放大紋理座標。就像這樣:

修改程式碼如下:

// 將 1.0 統統變成 2.0,就好像圖片變小了一倍
const uvs = new Float32Array([
    0.0, 0.0,
    2.0, 0.0,
    2.0, 2.0,
    0.0, 2.0
])

效果確是這樣:

這是因為 TEXTURE_WRAP_S 和 TEXTURE_WRAP_T 預設值是 REPEAT。

增加如下程式碼:

// 水平方向 CLAMP_TO_EDGE 重複邊緣那條線的畫素
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
// 垂直方向 MIRRORED_REPEAT 反光鏡重複
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);

效果如下:

多幅紋理

這裡我們實現多幅紋理的效果。首先準備一張 256*256 的圖片,就像畫貓一樣,這裡先顯示第二張紋理:

const FSHADER_SOURCE = `
precision mediump float;
uniform sampler2D u_Sampler;
uniform sampler2D u_Sampler2;
varying vec2 v_uv;
void main() {
  vec4 color = texture2D(u_Sampler, v_uv);
  vec4 color2 = texture2D(u_Sampler2, v_uv);
  // 只顯示第二張貼圖
  gl_FragColor = color2;
}
`

function main() {
    // ...

    // 紋理的4個點的座標
    const uvs = new Float32Array([
        0.0, 0.0,
        1.0, 0.0,
        1.0, 1.0,
        0.0, 1.0
    ])
    // 不變
    initVertexBuffers(gl, verticesOfPosition)
    // 不變
    initUvBuffers(gl, uvs)
    // 不變
    initTextures(gl)

    initMaskTextures(gl)
}

// 初始化蒙版紋理
function initMaskTextures(gl) {
    const img = new Image();
    img.src = "./mask.png";

    img.onload = () => {
        const texture = gl.createTexture();

        const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler2');
        if (!u_Sampler) {
            console.log('Failed to get the storage location of u_Sampler');
            return false;
        }
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        // 第二個紋理單元
        gl.activeTexture(gl.TEXTURE1);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
        // 第二個紋理單元
        gl.uniform1i(u_Sampler, 1);

        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
    }
}

效果如下:

:假如將 mask.png 從 256256 改成 400400 ,圖片將不能顯示。因為WebGL限制了紋理的維度必須是2的整數次冪, 2 的冪有 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 等等。更多細節請看這裡

接下來顯示多幅紋理,主要涉及向量間的運算。修改如下程式碼:

// 左圖
// 向量相乘,(0,0,0) 是黑色,其他值和黑色相乘則是黑色,所中間還是黑色
gl_FragColor = color * color2;

// 右圖
// `(vec4(1, 1, 1, 2) - color2)` 相當於取反
gl_FragColor = color * (vec4(1, 1, 1, 2) - color2);

效果如下:

完整程式碼

const VSHADER_SOURCE = `
attribute vec4 a_Position;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
  gl_Position = a_Position;
  v_uv = a_uv;
}
`
const FSHADER_SOURCE = `
precision mediump float;
// 定義一個取樣器。sampler2D 是一種資料型別,就像 vec2
uniform sampler2D u_Sampler;
uniform sampler2D u_Sampler2;
varying vec2 v_uv;
void main() {
  // texture2D(sampler2D sampler, vec2 coord) - 著色器語言內建函數,從 sampler 指定的紋理上獲取 coord 指定的紋理座標處的畫素
  vec4 color = texture2D(u_Sampler, v_uv);
  vec4 color2 = texture2D(u_Sampler2, v_uv);
  gl_FragColor = color * color2;
}
`

function main() {
    const canvas = document.getElementById('webgl');
    const gl = canvas.getContext("webgl");
    if (!gl) {
        console.log('Failed to get the rendering context for WebGL');
        return;
    }

    if (!initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE)) {
        console.log('Failed to intialize shaders.');
        return;
    }

    gl.clearColor(0.0, 0.5, 0.5, 1.0);

    // 幾何圖形的4個頂點的座標
    const verticesOfPosition = new Float32Array([
        // 左下角是第一個點,逆時針
        -0.5, -0.5,
        0.5, -0.5,
        0.5, 0.5,
        -0.5, 0.5,
    ])

    // 紋理的4個點的座標
    const uvs = new Float32Array([
        // 左下角是第一個點,逆時針,與頂點座標保持對應
        0.0, 0.0,
        1.0, 0.0,
        1.0, 1.0,
        0.0, 1.0
    ])

    initVertexBuffers(gl, verticesOfPosition)

    initUvBuffers(gl, uvs)

    initTextures(gl)
    initMaskTextures(gl)

}

// 初始化紋理。之所以為複數 s 是因為可以貼多張圖片。
function initTextures(gl) {
    // 定義圖片
    const img = new Image();
    // 請求 CORS 許可。解決圖片跨域問題
    img.crossOrigin = "";
    // The image element contains cross-origin data, and may not be loaded.
    img.src = "http://placekitten.com/256/256";

    img.onload = () => {
        // 建立紋理
        const texture = gl.createTexture();

        // 取得取樣器
        const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler');
        if (!u_Sampler) {
            console.log('Failed to get the storage location of u_Sampler');
            return false;
        }
        // pixelStorei - 影象預處理:圖片上下對稱翻轉座標軸 (圖片本身不變)
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        // 啟用紋理單元
        gl.activeTexture(gl.TEXTURE0);
        // 繫結紋理物件
        gl.bindTexture(gl.TEXTURE_2D, texture);
        // 設定紋理引數
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
        // 紋理圖片分配給紋理物件
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
        // 將紋理單元傳給片元著色器
        gl.uniform1i(u_Sampler, 0);

        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
    }
}

// 初始化紋理。之所以為複數 s 是因為可以貼多張圖片。
function initMaskTextures(gl) {
    const img = new Image();
    img.src = "./mask.png";
    // img.src = "./mask400_400.png";

    img.onload = () => {
        // 建立紋理
        const texture = gl.createTexture();

        // 取得取樣器
        const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler2');
        if (!u_Sampler) {
            console.log('Failed to get the storage location of u_Sampler');
            return false;
        }
        // pixelStorei - 影象預處理:圖片上下對稱翻轉座標軸 (圖片本身不變)
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        // 啟用紋理單元
        gl.activeTexture(gl.TEXTURE1);
        // 繫結紋理物件
        gl.bindTexture(gl.TEXTURE_2D, texture);
        // 設定紋理引數
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        // 紋理圖片分配給紋理物件
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
        // 將紋理單元傳給片元著色器
        gl.uniform1i(u_Sampler, 1);

        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
    }
}

function initVertexBuffers(gl, positions) {
    const vertexBuffer = gl.createBuffer();
    if (!vertexBuffer) {
        console.log('建立緩衝區物件失敗');
        return -1;
    }

    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

    gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);

    const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
    if (a_Position < 0) {
        console.log('Failed to get the storage location of a_Position');
        return -1;
    }

    gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);

    gl.enableVertexAttribArray(a_Position);
}

function initUvBuffers(gl, uvs) {
    const uvsBuffer = gl.createBuffer();
    if (!uvsBuffer) {
        console.log('建立 uvs 緩衝區物件失敗');
        return -1;
    }
    gl.bindBuffer(gl.ARRAY_BUFFER, uvsBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, uvs, gl.STATIC_DRAW);
    const a_uv = gl.getAttribLocation(gl.program, 'a_uv');
    if (a_uv < 0) {
        console.log('Failed to get the storage location of a_uv');
        return -1;
    }

    gl.vertexAttribPointer(a_uv, 2, gl.FLOAT, false, 0, 0);

    gl.enableVertexAttribArray(a_uv);
}

其他章節請看:

webgl 系列