圖形編程中的紋理,是一個很大的話題,涉及到的知識面非常多,有硬件的,也有軟件的,有實時渲染技術,也有標準的實現等非常多可以討論的。
受制於個人學識淺薄,本文只能淺表性地列舉 WebGL 和 WebGPU 中它們創建、數據傳遞和着色器中大致的用法,格式差異,順便撈一撈壓縮紋理的資料。
1. WebGL 中的紋理
1.1. 創建二維紋理與設置採樣參數
創建紋理對象 texture
,並將其綁定:
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
此時這個對象只是一個空的 WebGLTexture
,還沒有發生數據傳遞。
WebGL 沒有采樣器 API,紋理採樣參數的設置是通過調用 gl.texParameteri()
方法完成的:
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)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
採樣參數是 gl.TEXTURE_WRAP_S
、gl.TEXTURE_WRAP_T
、gl.TEXTURE_MIN_FILTER
、gl.TEXTURE_MAG_FILTER
,這四個採樣參數的值分別是 gl.CLAMP_TO_EDGE
、gl.CLAMP_TO_EDGE
、gl.NEAREST
、gl.NEAREST
,具體含義就不細說了,我認爲這方面的資料還是蠻多的。
1.2. 紋理數據寫入與拷貝
首先,是紋理數據的寫入。
使用 gl.texImage2D()
方法將內存中的數據寫入至紋理中,流向是 CPU → GPU
:
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
這個函數有非常多種重載,可以自行查閱 MDN 或 WebGL 有關規範。
上述函數調用傳遞的 image
是 Image
類型的,也即 HTMLImageElement
;其它的重載可以使用的數據來源還可以是:
ArrayBufferView
:Uint8Array
、Uint16Array
、Uint32Array
、Float32Array
ImageData
HTMLImageElement
/HTMLCanvasElement
/HTMLVideoElement
ImageBitmap
不同數據來源有對應的數據寫入方法。
其次,是紋理的拷貝。
WebGL 2.0 使用 gl.blitFramebuffer()
方法,以幀緩衝對象爲媒介,拷貝附着在兩類附件上的關聯紋理對象。
下面爲拷貝 renderableFramebuffer 的顏色附件的簡單示例代碼:
const renderableFramebuffer = gl.createFramebuffer();
const colorFramebuffer = gl.createFramebuffer();
// ... 一系列綁定和設置 ...
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, renderableFramebuffer);
gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, colorFramebuffer);
// ... 執行繪製 ...
gl.blitFramebuffer(
0, 0, FRAMEBUFFER_SIZE.x, FRAMEBUFFER_SIZE.y,
0, 0, FRAMEBUFFER_SIZE.x, FRAMEBUFFER_SIZE.y,
gl.COLOR_BUFFER_BIT, gl.NEAREST
);
WebGL 2.0 允許將 FBO 額外綁定到可讀幀緩衝(gl.READ_FRAMEBUFFER
)或繪製幀緩衝(gl.DRAW_FRAMEBUFFER
),WebGL 1.0 只能綁定至單個幀緩衝 gl.FRAMEBUFFER
.
WebGL 1.0 沒那麼便利,就只能自己封裝比較麻煩一點的做法了,提供如下思路:
- 把目標紋理附着到一個 FBO 上,利用一個
WebGLProgram
把源紋理通過着色器渲染進 FBO - 把源紋理附着到一個 FBO 上,利用
gl.copyTexImage2D()
或gl.copyTexSubImage2D()
方法拷貝到目標紋理 - 把源紋理附着到一個 FBO 上或直接繪製到 canvas 上,使用
gl.readPixels()
讀取渲染結果,然後使用gl.texImage2D()
將像素數據寫入目標紋理(這個方法看起來很蠢,雖然技術上行得通)
1.3. 着色器中的紋理
如何在片元着色器代碼中對紋理進行採樣,獲取該頂點對應的紋理顏色呢?
很簡單,獲取頂點着色器發送過來的插值後的片元紋理座標 v_texCoord
,然後對紋理對象進行採樣即可。
uniform sampler2D u_textureSampler;
varying vec2 v_texCoord;
void main() {
gl_FragColor = texture2D(u_textureSampler, v_texCoord);
}
關於如何通過 uniform 傳遞紋理到着色器中,還請查閱我之前發過的 Uniform 一文。
1.4. 紋理對象 vs 渲染緩衝對象
很多國內外的文章有介紹這兩個東西,它們通常出現在離屏渲染容器 - 幀緩衝對象的關聯附件上。
感興趣 FBO / RBO 主題的可以翻翻我不久之前的文章。
紋理與渲染緩衝,即 WebGLTexture
和 WebGLRenderbuffer
,其實最大的區別就是紋理允許再次通過 uniform 的形式傳給下一個渲染通道的着色器,進行紋理採樣。有資料說這兩個是存在性能差異的,但是我認爲那點差異還不如認真設計好架構。
- 如果你使用 MRT(無論是通過擴展還是直接使用 WebGL 2.0)技術,建議優先選擇渲染緩衝對象,但是其實用哪個都無所謂;
- 如果你要使用 WebGL 2.0 的 MSAA,那你得用渲染緩衝;
- 如果你要把 draw 的結果再次傳遞給下一個渲染通道,那麼你得用紋理對象;
- 對於讀像素,用哪個都無所謂,看你用的是 WebGL 1.0 還是 WebGL 2.0,都有對應的方法。
1.5. 立方體六面紋理
這東西雖然是給立方體的六個面貼圖用的“特殊”紋理,但是非常合適做環境貼圖,對應的數據傳遞函數、着色器採樣函數都略有不同。
// 注意第一個參數,既然有 6 面,就有六個值,這裏是 X 軸正方向的面
gl.texImage2D(
gl.TEXTURE_CUBE_MAP_POSITIVE_X,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
imagePositiveX)
// 爲立方體紋理創建 Mipmap
gl.generateMipmap(gl.TEXTURE_CUBE_MAP)
// 設置採樣參數
gl.texParameteri(
gl.TEXTURE_CUBE_MAP,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR)
在着色器中:
// 頂點着色器
attribute vec4 a_position;
uniform mat4 u_vpMatrix;
varying vec3 v_normal;
void main() {
gl_Position = u_vpMatrix * a_position;
// 因爲位置是以幾何中心爲原點的,可以用頂點座標作爲法向量
v_normal = normalize(a_position.xyz);
}
// 片元着色器
precision mediump float; // 從頂點着色器傳入
varying vec3 v_normal; // 紋理
uniform samplerCube u_texture;
void main() {
gl_FragColor = textureCube(u_texture, normalize(v_normal));
}
這方面資料其實也不少,網上搜索可以輕易找到。
1.6. WebGL 2.0 的變化
WebGL 2.0 增加了若干內容,資料可以在 WebGL2Fundamentals 找到,這裏簡單列舉。
- 在着色器中使用
textureSize()
函數獲取紋理大小 - 在着色器中使用
texelFetch()
直接獲取指定座標的紋素 - 支持了更多紋理格式
- 支持了 3D 紋理(而不是立方體六面紋理)
- 支持紋理數組(每個元素都是一個單獨的紋理)
- 支持長寬大小是非 2 次冪的紋理
- 支持若干壓縮紋理格式
- 支持深度紋理(WebGL 1.0 要調用擴展才能用)
- 加入
WebGLSampler
對象的支持 - ...
除此之外,GLSL 升級到 300 後,原來的 texture2D()
和 textureCube()
紋理採樣函數全部改爲了 texture()
函數,詳見文末參考資料的遷移文章。
1.7. Mipmapping 技術
裁剪空間裏的頂點構成的形狀,其實是近大遠小的,這點沒什麼問題。對於遠處的物體,透視投影變換完成後會比較小,這就沒必要對這個“小”的部分使用“大”的部分一樣清晰的紋理了。
Mipmap 能解決這個問題,幸運的是,WebGL 只需簡單的方法調用就可以創建 Mipmap,無需操心太多。
gl.generateMipmap(gl.TEXTURE_2D)
在參考資料中,你可以在 《WebGL紋理詳解之三:紋理尺寸與Mipmapping》一文中見到不錯的解釋,還可以看到 gl.texImage2D()
的第二個參數 level
的具體用法。
2. WebGPU 中的紋理
WebGPU 將紋理分成 GPUTexture
與 GPUTextureView
兩種對象。
2.1. GPUTexture 的創建
調用 device.createTexture()
即可創建一個紋理對象,你可以通過傳參指定它的用途、格式、維度等屬性。它扮演的更多時候是一個數據容器,也就是紋素的容器。
// 普通貼圖
const texture = device.createTexture({
size: [512, 512, 1],
format: 'rgba8unorm',
usage: GPUTextureUsage.TEXTURE_BINDING
| GPUTextureUsage.COPY_DST
| GPUTextureUsage.RENDER_ATTACHMENT,
})
// 深度紋理
const depthTexture = device.createTexture({
size: [800, 600],
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
})
// 從 canvas 中獲取紋理
const gpuContext = canvas.getContext('webgpu')
const canvasTexture = gpuContext.getCurrentTexture()
上面介紹了三種創建紋理的方式,前兩種類似,格式和用途略有不同;最後一個是來自 Canvas 的。
注意一點,有一些紋理格式並不是默認就支持的。如果需要特定格式,有可能還要在請求設備對象時,附上功能列表(
requiredFeatures
)
2.2. 紋理數據寫入與拷貝
知道創建紋理對象,還要知道如何往其中寫入來自 JavaScript 運行時的圖像資源。
首先,介紹紋理數據寫入。
有兩個手段可以向紋理對象寫入數據:
- 使用
ImageBitmap
API(globalThis.createImageBitmap()
) - 使用解碼後的 RGBA 數組
對於第一種,使用隊列對象的 copyExternalImageToTexture()
方法,配合瀏覽器自帶的 API,在隊列時間軸上完成外部數據拷入紋理對象:
const diffuseTexture = device.createTexture({ /* ... */ })
/** 方法一 藉助 HTMLImageElement 解碼 **/
const img = document.createElement('img')
img.src = require('/assets/diffuse.png')
await img.decode()
const imageBitmap = await createImageBitmap(img)
/** 方法一 **/
/** 方法二 使用 Blob **/
const blob = await fetch(url).then((r) => r.blob())
const imageBitmap = await createImageBitmap(blob)
/** 方法二 **/
device.queue.copyExternalImageToTexture(
{ source: imageBitmap },
{ texture: diffuseTexture },
[imageBitmap.width, imageBitmap.height]
)
上述例子提供了兩種思路,第一種藉助瀏覽器的 img 元素,也即 Image
來完成圖像的網絡請求、解碼;第二種藉助 Blob
API;隨後,使用 Image(HTMLImageElement)
/Blob
對象創建一個 ImageBitmap
,並進入隊列中完成數據拷貝。
對於第二種,使用隊列對象的 writeTexture()
方法,在隊列時間軸上完成外部數據拷入紋理對象:
const imgRGBAUint8Array = await fetchAndParseImageToRGBATypedArray('/assets/diffuse.png')
const arrayBuffer = imgRGBAUint8Array.buffer
device.queue.writeTexture(
{
bytePerRow: 4 * 512, // 每行多少字節
rowsPerImage: 512 // 這個圖像有多少行
},
arrayBuffer,
{ texture: diffuseTexture },
[512, 512, 1]
)
第二種方法相對來說比較消耗性能,因爲需要瀏覽器 API(例如藉助 canvas 繪圖再取數據)或其它手段(如 wasm 等)解碼圖像二進制至 RGBA 數組,不太適合每幀操作。
其次,介紹紋理拷貝。
與 WebGL 需要使用 FBO 或重新渲染不同,WebGPU 原生就在指令編碼器上提供了紋理複製操作有關的 API:使用 commandEncoder.copyTextureToTexture()
可以完成紋理之間的拷貝,使用 commandEncoder.copyBufferToTexture()
、commandEncoder.copyTextureToBuffer()
可以在緩衝對象和紋理對象之間的拷貝(以便讀取紋素數據)。
以紋理間的拷貝爲例:
commandEncoder.copyTextureToTexture({
texture: mipmapTexture,
mipLevel: 4,
}, {
texture: destTexture,
mipLevel: 5,
}, [512, 512, 1])
這個例子將 Mipmap 紋理的第 4 級拷貝至目標紋理對象的第 5 級,紋理的大小是 512 × 512
,需要注意 mipmapTexture
和 destTexture
的 usage
,複製源需要有 GPUTextureUsage.COPY_SRC
,複製目標要有 GPUTextureUsage.COPY_DST
.
既然發生在指令編碼器上,那就意味着操作紋理時,與普通的渲染通道、計算通道是平級的 —— 換句話說,拷貝紋理的行爲,必須在渲染通道之前或之後進行。
2.3. 紋理視圖
因官方文檔在我寫這篇文章前,都沒有給出紋理視圖對象的描述,所以下面的描述是我根據 WebGPU 中關於紋理方面的 API 猜測的。
當 CPU 需要使用紋理時,譬如進行紋理數據的寫入,或者紋理對象之間的拷貝,會直接在隊列上進行,而且傳參給的就是 GPUTexture
本身;而 GPU 需要使用紋理時,例如資源綁定組綁定一個紋理,或者渲染通道的附件需要使用容器時,通常傳參給的是 GPUTextureView
;所以,我猜測:
- 紋理對象適用於 CPU 側操作
- 紋理視圖對象爲 GPU 提供操作真正紋理數據的一個窗口
創建紋理視圖其實很簡單,它通過調用紋理對象本身的 createView()
方法創建:
const view = texture.createView()
// 在渲染通道的顏色附件中
const renderPassDescriptor = {
colorAttachments: [
{
view: canvasTexture.createView(),
// ...
}
]
}
紋理視圖對象是可以傳遞參數對象的,類型是 GPUTextureViewDescriptor
,當然這個參數對象是可選的。這個參數對象可以更具體描述紋理視圖。
譬如,立方體紋理創建視圖時,需要明確指定其維度(dimension
)參數等參數:
const cubeTextureView = cubeTexture.createView({
dimension: 'cube',
arrayLayerCount: 6,
})
2.4. 着色器中的紋理與採樣器
與 WebGL 使用的閹割版 GLSL 相比,WGSL 提供的類型就多多了。
WebGL 1.0 中的採樣參數與 WebGL 2.0 姍姍來遲的 WebGLSampler
類型,在 WebGPU 和 WGSL 中統一爲具體的變量類型,即 WebGPU 對應 GPUSampler
,WGSL 對應 sampler
和 sampler_comparision
類型。
WGSL 中的紋理類型有十幾種,紋理類型與紋理視圖的 dimension
參數是緊密相關的,參考 WebGPU Spec - TextureView Creation
而紋理相關的函數也跟隨着增多了許多,且各有用途,有最常規的紋理採樣函數 textureSample
,讀取單個紋素的 textureLoad
函數,獲取紋理尺寸的 textureDimensions
(等價於 WebGL 2.0 的 textureSize
),向存儲型紋理寫紋素的 textureStore
等,每個函數又有若干種重載。
最基本的用法,使用二維 f32 紋理對象、採樣器、紋理座標進行採樣:
@group(0) @binding(1) var mySampler: sampler;
@group(0) @binding(2) var myTexture: texture_2d<f32>;
@stage(fragment)
fn main(@location(0) fragUV: vec2<f32>) -> @location(0) vec4<f32> {
return textureSample(myTexture, mySampler, fragUV);
}
2.5. WebGPU 中的 Mipmapping
鑑於紋理技術本身的複雜性,官方在 GitHub issue 386 中關於自動生成 Mipmap 的 API 有激烈的討論,目前傾向於不實現,把 Mipmap 的生成實現交給社區。
WebGPU 保留了 Mipmap 的支持,但是沒有像 WebGL 一樣提供簡便的 gl.generateMipmap(gl.TEXTURE_2D)
調用方法一鍵生成,需要自己對紋理的每一個層生成。
幸運的是,WebGPU 社區的 Toji 大佬編寫了一個工具來生成紋理的 Mipmap:web-texture-tool/src/webgpu-mipmap-generator.js,原理就是開闢一個新的指令編碼器,使用一條特定的渲染管線離屏計算每一級 mipmap,最終寫入一個紋理對象並返回。若源紋理具備渲染附件的用途(GPUTextureUsage.RENDER_ATTACHMENT
),那麼就在源紋理上生成,否則會使用 commandEncoder.copyTextureToTexture()
方法把工具類內部創建的臨時 mipmap 紋理對象拷貝到源紋理對象。
目前只能對 "2d"
類型的紋理起作用,這個類的簡單用法如下:
import { WebGPUMipmapGenerator } from 'web-texture-tool/webgpu-mipmap-generator.js'
/* -- 常規創建紋理 -- */
const textureDescriptor = { /**/ }
const srcTexture = device.createTexture(textureDescriptor)
/* -- 爲紋理創建 mipmap -- */
const mipmapGenerator = new WebGPUMipmapGenerator(device)
mipmapGenerator.generateMipmap(srcTexture, textureDescriptor)
// ...
generateMipmap()
方法執行後,將在 2d 紋理的每個 layer
創建完成每一層 Mipmap,順帶一提,這個工具並未完全穩定,請考慮各種風險。
注意一點,這個 textureDescriptor
的 mipLevelCount
是有一個 算法 的,它必須小於等於根據紋理維度、紋理尺寸計算的 最大限制值。這裏紋理維度是 2d 類型,最大尺寸是 64,那麼容易算得最大 mipLevel 是 Math.floor(Math.log2(64)) + 1 = 7
.
const textureDescriptor = {
// ...
mipLevelCount: 7, // 創建紋理時,允許人爲指定 mipmap 有多少級,但是不超最大限制
size: {
width: 64,
height: 64,
depthOrArrayLayers: 1
},
dimension: "2d"
}
擴展閱讀:ThreeJS 關於 WebGPU 這項議程,參考了 Toji 的工具,集成到
WebGPUTextureUtils
類,有關討論見 ThreeJS pull 20284 WebGPUTextures: Add support for mipmap computation.
3. 紋理壓縮編碼算法
涉及到壓縮紋理格式我更是隻能“紙上談兵”,這一段僅作爲個人知識淺表性的記錄,道阻且長...
這一小節其實與 WebGL、WebGPU 的接口並無太大關係,紋理壓縮算法,或者說壓縮紋理格式,是另外的一門技術,WebGL 和 WebGPU 在底層實現上做了支持。
簡單的說,壓縮紋理格式是一種“時間+空間換空間”的產物,需要提前生成,常見的封裝文件格式有 ktx2
等(就好比 h264/5
於 .mp4
)。它有效地節約了 GPU 顯存,並且解壓速度比傳統的 Web 圖像格式 jpg
、png
更快,它本身也比 jpg/png
的文件體積要小一些。
不過很遺憾的是,諸多壓縮編碼算法在各個軟硬件廠商的實現都不太一樣,沒法像 jpg/png
一樣廣泛、普遍使用。
爲了兼容性,通常會針對不同平臺生成不同的壓縮紋理備用,也就是所謂的“時間+空間換解壓時間+顯存空間”。
WebGL 1.0 只能使用 2D 紋理,WebGL 2.0 支持使用 3D 紋理,而且對壓縮紋理的使用,是需要藉助擴展項來完成的。例如:
const ext = (
gl.getExtension('WEBGL_compressed_texture_s3tc') ||
gl.getExtension('MOZ_WEBGL_compressed_texture_s3tc') ||
gl.getExtension('WEBKIT_WEBGL_compressed_texture_s3tc')
)
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.compressedTexImage2D(gl.TEXTURE_2D, 0, ext.COMPRESSED_RGBA_S3TC_DXT5_EXT, 512, 512, 0, textureData)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
這個示例代碼展示了在 WebGL 1.0 通過 compressedTexImage2D()
方法使用了一個 S3TC_DXT5
壓縮編碼的紋理數據 textureData
.
具體的 WebGL 1/2 壓縮擴展和用法參考 MDN - compressedTexImage[23]D()
對於 WebGPU,它支持三類壓縮格式:
- texture-compression-bc
- texture-compression-etc2
- texture-compression-astc
請求設備對象時傳入 requiredFeatures
即可請求所需壓縮紋理格式:
// 以 astc 格式爲例 -- 需要在適配器上判斷是否支持此格式
const requiredFeatures = []
if (gpuAdapter.features.has('texture-compression-astc')) {
requiredFeatures.push('texture-compression-astc')
}
const device = await adapter.requestDevice({
requiredFeatures
})
當適配器支持時即可請求。這樣,astc
族壓縮紋理格式就全部可用了:
const compressedTextureASTC = device.createTexture({
// ...
format: "astc-10x6-unorm-srgb"
})
三大類型的壓縮紋理格式支持列表參考 WebGPU Spec - Feature Index: 24.4, 24.5, 24.6
幸運的是,Toji 的庫 toji/web-texture-tool 也爲紋理的加載寫了兩種 Loader,用於 WebGL 和 WebGPU 中紋理數據的生成,支持壓縮格式。
紋理壓縮算法(格式)簡單記憶規則:
ETC1/2 - Android
DXT/S3TC - Windows
PVRTC - Apple
ASTC - Will Be The Future
詳細的資料在文末的參考資料裏了。
4. 總結
關於 Mipmap、級聯紋理、壓縮格式等進階知識,我覺得已經超出了這兩個 API 比對的範圍,況且個人理解尚不深,就不關公面前舞大刀了。
這篇與上篇相隔時間較長,我在學習的過程中補充了很多欠缺的知識,爲了嚴謹和準確性也查閱了不少的例子、啃了不少的源碼。
簡而言之,WebGPU 把 WebGL 1/2 兩代的紋理接口進行了科學統一,並且出廠自帶壓縮紋理格式的支持(當然,還是看具體平臺的,需要按需選取)。
其中最讓我感興趣的就是 WebGPU 對紋理的二級細化,提供 GPUTexture
和 GPUTextureView
兩級 API,發文時還未見到官方規範解釋這兩個 API,猜測前者專注於數據的 IO,後者則提供紋理數據的一層視圖(根據參數具象化紋理數據的某一方面)。
很遺憾,發文時我還沒深入瞭解過存儲型紋理,以後介紹 GPGPU 時再說吧。
參考資料
- WebGLFundamentals - CubeMaps
- WebGLFundamentals - WebGL 圖像處理
- 郭隆邦 - ThreeJS 環境貼圖
- 掘金 - WebGL學習之紋理盒
- 掘金 - WebGL2系列之多采樣渲染緩衝對象
- CSDN - Three.js 使用設置envMap環境貼圖創建反光效果
- CSDN - WebGL着色器GLSL ES內置函數
- 知乎 - WebGL 紋理詳解
- WebGL2Fundamentals - WebGL2 紋理
- WebGL2Fundamentals - WebGL2 有什麼新內容
- WebGL2Fundamentals - 遷移WebGL1到WebGL2
- WebGL 紋理
- WebGL紋理詳解之一:紋理的基本使用
- WebGL紋理詳解之三:紋理尺寸與Mipmapping
- MDN - WEBGL_draw_buffers
- GithubIO - WebGL 紋理詳解
- 知乎 - 你所需要了解的幾種紋理壓縮格式原理