WebGL 與 WebGPU比對[6] - 紋理


圖形編程中的紋理,是一個很大的話題,涉及到的知識面非常多,有硬件的,也有軟件的,有實時渲染技術,也有標準的實現等非常多可以討論的。

受制於個人學識淺薄,本文只能淺表性地列舉 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_Sgl.TEXTURE_WRAP_Tgl.TEXTURE_MIN_FILTERgl.TEXTURE_MAG_FILTER,這四個採樣參數的值分別是 gl.CLAMP_TO_EDGEgl.CLAMP_TO_EDGEgl.NEARESTgl.NEAREST,具體含義就不細說了,我認爲這方面的資料還是蠻多的。

1.2. 紋理數據寫入與拷貝

首先,是紋理數據的寫入。

使用 gl.texImage2D() 方法將內存中的數據寫入至紋理中,流向是 CPU → GPU

gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)

這個函數有非常多種重載,可以自行查閱 MDN 或 WebGL 有關規範。

上述函數調用傳遞的 imageImage 類型的,也即 HTMLImageElement;其它的重載可以使用的數據來源還可以是:

  • ArrayBufferViewUint8ArrayUint16ArrayUint32ArrayFloat32Array
  • 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 主題的可以翻翻我不久之前的文章。

紋理與渲染緩衝,即 WebGLTextureWebGLRenderbuffer,其實最大的區別就是紋理允許再次通過 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 將紋理分成 GPUTextureGPUTextureView 兩種對象。

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,需要注意 mipmapTexturedestTextureusage,複製源需要有 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 對應 samplersampler_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,順帶一提,這個工具並未完全穩定,請考慮各種風險。

注意一點,這個 textureDescriptormipLevelCount 是有一個 算法 的,它必須小於等於根據紋理維度、紋理尺寸計算的 最大限制值。這裏紋理維度是 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 圖像格式 jpgpng 更快,它本身也比 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 對紋理的二級細化,提供 GPUTextureGPUTextureView 兩級 API,發文時還未見到官方規範解釋這兩個 API,猜測前者專注於數據的 IO,後者則提供紋理數據的一層視圖(根據參數具象化紋理數據的某一方面)。

很遺憾,發文時我還沒深入瞭解過存儲型紋理,以後介紹 GPGPU 時再說吧。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章