一起學 WebGL:繪制圖片
大家好,我是前端西瓜哥。之前講解了如何用 WebGL 繪制紅色三角形,今天西瓜哥帶大家來學習如何將圖片繪制到畫布上的技術:紋理映射(texture mapping)。
紋理映射會根據紋理圖像,將光柵化后的每個片元(像素點)設置對應顏色值。這些像素也稱為 紋素(texels, texture elements)。
紋理坐標
紋理圖像的坐標系統是二維的,為和世界坐標的 x、y 區分,WebGL 對應使用 s、t 來表示。
目前紋理坐標更常用的命名是 uv。因為歷史原因,WebGL 還是用的 st。
和世界坐標系類似,寬高使用的是一個比例值,即真實像素位置除以寬高后得到的比例。
著色器
const vertexShaderSrc = `
attribute vec4 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
gl_Position = a_Position;
v_TexCoord = a_TexCoord;
}
`;
const fragmentShaderSrc = `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
void main() {
gl_FragColor = texture2D(u_Sampler, v_TexCoord);
}
`;
相比繪制單色三角形,我們在頂點著色器加了 a_TexCoord,記錄頂點對應的紋理坐標,因為紋理坐標只有兩個維度,所以用的 vec2 屬性。
并命名一個 v_TexCoord 的 varying 變量,用于將 a_TexCoord 的值傳遞給片元著色器。
片元著色器,聲明了一個接收同名 v_TexCoord 變量 接收傳過來的紋理坐標。
u_Sampler 是 sampler2D 類型,是一個二維紋理采樣器,指定著色器提取顏色的紋理對象。texture2D(u_Sampler, v_TexCoord) 表示從 u_Sampler 紋理采樣器中的某個位置中取出顏色。
傳入頂點數據
將頂點位置和紋理位置對應好,放在一個緩沖區中,并設置讀取規則。
先讀第一個點的位置,然后是第一個點對應的紋理坐標。然后第二個點...
// 頂點坐標,紋理坐標
const verticesTexCoords = new Float32Array([
// 左上點。左邊兩個是頂點,右邊兩個是紋理
-0.5, 0.5, 0.0, 1.0,
// 左下
-0.5, -0.5, 0.0, 0.0,
// 右上
0.5, 0.5, 1.0, 1.0,
// 右下
0.5, -0.5, 1.0, 0.0,
]);
const FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;
// 創建緩存對象
const verticesTexBuffer = gl.createBuffer();
// 綁定緩存對象到上下文
gl.bindBuffer(gl.ARRAY_BUFFER, verticesTexBuffer);
// 向緩存區寫入數據
gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);
// 獲取 a_Position 變量地址
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
// 將緩沖區對象分配給 a_Position 變量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
// 允許訪問緩存區
gl.enableVertexAttribArray(a_Position);
// 傳入紋理坐標位置信息
const a_TexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2);
gl.enableVertexAttribArray(a_TexCoord);
紋理對象與圖片綁定
/***** 紋理對象 *****/
const texture = gl.createTexture(); // 創建紋理對象
const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler'); // 獲取 u_Sampler 地址
const img = new Image();
img.onload = () => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 翻轉紋理圖像的 y 軸
gl.activeTexture(gl.TEXTURE0); // 開啟 0 號紋理單元
gl.bindTexture(gl.TEXTURE_2D, texture); // 將我們的紋理對象綁定到 gl.TEXTURE_2D,類似綁定緩沖區對象
// 配置紋理參數
// 這里表示在 “繪制范圍小于紋理尺寸” 時,使用 “加權平均” 算法縮小
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// 將紋理圖像分配給紋理對象
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
// 使用 0 號紋理單元
gl.uniform1i(u_Sampler, 0);
/****** 繪制 ******/
// 清空畫布,并指定顏色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// 繪制矩形,這里提供了 4 個點
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
img.src = './fe_watermelon.jpg';
創建紋理對象,然后在圖片加載完之后配置到紋理對象上。
WebGL 下有多個紋理紋理單元(比如 gl.TEXTURE0、gl.TEXTURE5 之類),至少有 8 個。這些單元可以保存多個我們創建好的紋理圖片,在需要的時候進行切換。
激活一個紋理單元:
gl.activeTexture(gl.TEXTURE0);
激活后,我們用 gl.TEXTURE_2D 來訪問這個紋理圖像,進行紋理綁定和參數配置。
這里我們需要反轉紋理圖像的 y 軸線,因為圖片和紋理坐標系不一樣。我實在是蚌不住了。
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 翻轉紋理圖像的 y 軸
gl.texImage2D 用于將紋理圖像分配給紋理對象。
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
gl.RGB 表示紋素的格式為 RGB,此外還有 gl.RGBA、gl.LUMINANCE(流明) 等。gl.UNSIGNED_BYTE 表示紋理的數據類型。
將紋理單元綁定到 u_Sampler 變量上。
gl.uniform1i(u_Sampler, 0);
最后就是調用 el.drawArrays 方法進行繪制。
圖片的注意事項
關于圖片,有幾點需要注意。
首先是 圖片不要跨域,因為安全限制,Canvas 是不能將跨域的圖片繪制上去的,會報錯。
然后是 圖片的尺寸需要是 2 的冪次方,比如 16、32、64、128、256、512。
尺寸不對的圖片需要留白補全到 2 的冪次方,然后在設置紋理坐標時指定對應真正寬高比例。
完整源碼
/** @type {HTMLCanvasElement} */
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
const vertexShaderSrc = `
attribute vec4 a_Position;
attribute vec2 a_TexCoord;
varying vec2 v_TexCoord;
void main() {
gl_Position = a_Position;
v_TexCoord = a_TexCoord;
}
`;
const fragmentShaderSrc = `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
void main() {
gl_FragColor = texture2D(u_Sampler, v_TexCoord);
}
`;
/**** 渲染器生成處理 ****/
// 創建頂點渲染器
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSrc);
gl.compileShader(vertexShader);
// 創建片元渲染器
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSrc);
gl.compileShader(fragmentShader);
// 程序對象
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
gl.program = program;
// 頂點坐標,紋理坐標
const verticesTexCoords = new Float32Array([
// 左上點。左邊兩個是頂點,右邊兩個是紋理
-0.5, 0.5, 0.0, 1.0,
// 左下
-0.5, -0.5, 0.0, 0.0,
// 右上
0.5, 0.5, 1.0, 1.0,
// 右下
0.5, -0.5, 1.0, 0.0,
]);
const FSIZE = verticesTexCoords.BYTES_PER_ELEMENT;
// 創建緩存對象
const verticesTexBuffer = gl.createBuffer();
// 綁定緩存對象到上下文
gl.bindBuffer(gl.ARRAY_BUFFER, verticesTexBuffer);
// 向緩存區寫入數據
gl.bufferData(gl.ARRAY_BUFFER, verticesTexCoords, gl.STATIC_DRAW);
// 獲取 a_Position 變量地址
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
// 將緩沖區對象分配給 a_Position 變量
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 4, 0);
// 允許訪問緩存區
gl.enableVertexAttribArray(a_Position);
// 傳入紋理坐標位置信息
const a_TexCoord = gl.getAttribLocation(gl.program, 'a_TexCoord');
gl.vertexAttribPointer(a_TexCoord, 2, gl.FLOAT, false, FSIZE * 4, FSIZE * 2);
gl.enableVertexAttribArray(a_TexCoord);
/***** 紋理對象 *****/
const texture = gl.createTexture(); // 創建紋理對象
const u_Sampler = gl.getUniformLocation(gl.program, 'u_Sampler'); // 獲取 u_Sampler 地址
// 記載圖片
const img = new Image();
img.onload = () => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); // 翻轉紋理圖像的 y 軸
gl.activeTexture(gl.TEXTURE0); // 開啟 0 號紋理單元
gl.bindTexture(gl.TEXTURE_2D, texture); // 將我們的材質對象綁定上去
// 配置紋理參數
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// 配置紋理圖像
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
// 綁定 0 號紋理單元
gl.uniform1i(u_Sampler, 0);
/****** 繪制 ******/
// 清空畫布,并指定顏色
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// 繪制矩形,這里提供了 4 個點
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
img.src = './fe_watermelon.jpg';
繪制結果:
填充方式
補充一下設置圖片的幾種方式。
gl.texParameteri(target, pname, param);
上面表示,在 pname 場景下,使用 param 策略。比如
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
表示在 “繪制范圍小于紋理尺寸”(gl.TEXTURE_MIN_FILTER) 場景下,使用 “加權平均”(gl.LINEAR) 算法進行縮小。
參數 pname 有下面幾個幾個值可選擇:
- gl.TEXTURE_MAG_FILTER:紋理放大。對應場景為紋理尺寸小于要繪制區域,需要將紋理放大。默認值為 gl.LINEAR
- gl.TEXTURE_MIN_FILTER:紋理縮小。默認值為 gl.NEAREST_MIPMAP_LINEAR。
- gl.TEXTURE_WRAP_S:紋理水平填充。默認為 gl.REPEAT。
- gl.TEXTURE_WRAP_T:紋理垂直填充。默認為 gl.REPEAT。
參數 param 的一些可選值:
- gl.LINEAR:使用 “加權平均” 縮放;
- gl.NEAREST:使用 “曼哈頓距離” 縮放;
- gl.REPEAT:重復平鋪;
- gl.MIRRORED_REPEAT:鏡像重復平鋪:
- gl.CLAMP_TO_EDGE:使用紋理圖像的邊緣進行延伸填充;
看個實例,將繪制區域設置為圖片的 2.5 倍,并設置填充方式。
// 頂點坐標,紋理坐標
const verticesTexCoords = new Float32Array([
// 左上點。左邊兩個是頂點,右邊兩個是紋理
-0.5, 0.5, 0.0, 2.5,
// 左下
-0.5, -0.5, 0.0, 0.0,
// 右上
0.5, 0.5, 2.5, 2.5,
// 右下
0.5, -0.5, 2.5, 0.0,
]);
// ...
// 配置紋理參數
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_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.CLAMP_TO_EDGE);
得到了一個很有意思的結果: