WebGPU 入門(mén):繪制一個(gè)三角形
大家好,我是前端西瓜哥。
今天我們來(lái)入門(mén) WebGPU,來(lái)寫(xiě)一個(gè)圖形版本的 Hello World,即繪制一個(gè)三角形。
WebGPU 是什么?
WebGPU 是一個(gè)正在開(kāi)發(fā)中的潛在 Web 標(biāo)準(zhǔn)和 JavaScript API,目標(biāo)是提供 “現(xiàn)代化的 3D 圖形和計(jì)算能力”。
簡(jiǎn)單來(lái)說(shuō),WebGPU 提供一個(gè)更現(xiàn)代的 Web 上的圖形渲染標(biāo)準(zhǔn)。
WebGPU 的出現(xiàn)就是為了取代 WebGL 的,因?yàn)楹笳叩?API 實(shí)在有些過(guò)時(shí),無(wú)法利用好現(xiàn)代 GPU 的一些高級(jí)特性,本身的 API 設(shè)計(jì)也較難使用。
相比 WebGL,WebGPU 有更好的性能表現(xiàn),API 更底層更靈活,并支持更高級(jí)的現(xiàn)代特性,比如計(jì)算著色器。
毫無(wú)疑問(wèn),WebGPU 是前端圖形渲染的未來(lái),值得去學(xué)習(xí)一下。
像是以性能著稱(chēng)的前端圖形庫(kù) PixiJS,也開(kāi)始進(jìn)行支持 WebGPU 的工作,并在最近發(fā)布了預(yù)覽版本,聲稱(chēng)性能將是 WebGL 的 2.5 倍。
不過(guò)目前 WebGPU 還不夠成熟,仍有許多工作要做,且只有少數(shù)瀏覽器的最新版本直接支持或通過(guò)設(shè)置開(kāi)啟。
即使之后所有瀏覽器都支持了,舊版本瀏覽器還是不支持的,離大范圍使用還有相當(dāng)長(zhǎng)的一段路要走。
只能說(shuō)未來(lái)可期。
但生產(chǎn)中,我們可以做一個(gè)回退機(jī)制:如果瀏覽器支持 WebGPU,我們用 WebGPU 去渲染,如果不支持就回滾到 WebGL。
只要在底層渲染方案上封裝一層渲染器 renderer,就像 PixiJS 現(xiàn)在做的事情一樣,個(gè)人還是比較期待它在性能上的提升的。
繪制三角形
OK,我們開(kāi)始用 WebGPU 繪制一個(gè)三角形。
確保你的瀏覽器支持 WebGPU,建議用 Chrome,并更新到最新版本。
這里我們創(chuàng)建一個(gè)寬高各為 300 的 canvas 元素,用于繪制圖形。
<canvas width="300" height="300"></canvas>
初始化 WebGPU 相關(guān)的一些對(duì)象。
adapter 和 device
創(chuàng)建一個(gè)適配器對(duì)象 adapter,適配器是一個(gè) GPU 物理硬件設(shè)備的抽象。
const adapter = await navigator.gpu.requestAdapter();
requestAdapter() 方法會(huì)查看系統(tǒng)上所有可用的 GPU 設(shè)備,并選擇其中合適的適配器。該方法可以傳一些參數(shù),去按條件匹配。比如 { powerPreference: 'low-power' } 表示優(yōu)先使用低能耗的 GPU。
此外,這個(gè)方法返回的是一個(gè) Promise,即它是 異步的,需要用 await 的方式去等待異步的結(jié)果。
然后基于 adapter,調(diào)用 requestDevice 方法拿到設(shè)備對(duì)象 device。
device 可以理解為 adapter 的一個(gè)會(huì)話。做個(gè)比喻的話 adapter 是一個(gè)公司,device 是一個(gè)具體干活的人。
const device = await adapter.requestDevice();
requestDevice() 方法也可以傳入配置項(xiàng),去開(kāi)啟一些高級(jí)特性,或是指定一些硬件限制,比如最大紋理尺寸。
配置 canvas
類(lèi)似 canvas 2d 和 webgl,我們需要通過(guò) canvas 元素拿到上下文。
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('webgpu');
接著是調(diào)用 ctx.configure() 方法配置剛剛聲明的 device 對(duì)象和像素格式。
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
// 給上下文配置 device 對(duì)象和
ctx.configure({
device,
format: canvasFormat,
});
navigator.gpu.getPreferredCanvasFormat() 會(huì)返回當(dāng)前環(huán)境合適的像素格式的字符串標(biāo)識(shí),通常是 'bgra8unorm',表示用 8 位無(wú)符號(hào)整數(shù)來(lái)表示藍(lán)色、綠色、紅色和透明度四個(gè)分量。
設(shè)置背景色
創(chuàng)建命令編碼器 GPUCommandEncoder 實(shí)例,它用于編碼需要提交給 GPU 的命令。
const encoder = device.createCommandEncoder();
開(kāi)啟一個(gè)新的渲染通道(Render Pass),這里清空顏色緩沖區(qū)時(shí)填充了一個(gè)淺藍(lán)色背景。
和 WebGL 一樣,使用 RGBA 的格式,每個(gè)分量為 0 到 1 的范圍,比如 { r: 1, g: 0, b: 0, a: 1 } 表示紅色,或者你可以用數(shù)組的形式 [1, 0, 0, 1]。
const pass = encoder.beginRenderPass({
// 顏色附件,一個(gè)用于存儲(chǔ)渲染輸出顏色數(shù)據(jù)的紋理
colorAttachments: [
{
// 要渲染到的目標(biāo)
view: ctx.getCurrentTexture().createView(),
// 渲染前清空顏色緩沖區(qū)
loadOp: 'clear',
// 清除顏色為淺藍(lán)色,不設(shè)置會(huì)默認(rèn)使用黑色
clearValue: { r: 0.6, g: 0.8, b: 0.9, a: 1 },
// 渲染結(jié)果會(huì)被保留在紋理中,后序好繪制到 canvas 上
storeOp: 'store',
},
],
});
我們先不繪制三角形,看看背景的渲染效果,為此我們提前執(zhí)行下面代碼:
// 這里是繪制三角形的代碼,之后會(huì)實(shí)現(xiàn)
pass.end(); // 完成指令隊(duì)列的記錄
const commandBuffer = encoder.finish(); // 結(jié)束編碼
device.queue.submit([commandBuffer]); // 提交給 GPU 命令隊(duì)列
遠(yuǎn)峰藍(lán)。
創(chuàng)建緩沖區(qū)
先說(shuō)說(shuō) WebGPU 的坐標(biāo)系,它和 WebGL 一樣,原點(diǎn)在畫(huà)布中心,x 軸向右,y 軸向上,取值范圍都是 -1 到 1。
聲明頂點(diǎn)數(shù)據(jù)。這些頂點(diǎn)為組成三角形的三個(gè)坐標(biāo)。
const vertices = new Float32Array([
-0.5, -0.5,
0.5, -0.5,
0.5, 0.5,
]);
然后創(chuàng)建頂點(diǎn)緩沖區(qū):
const vertexBuffer = device.createBuffer({
// 標(biāo)識(shí),字符串隨意寫(xiě),報(bào)錯(cuò)時(shí)會(huì)通過(guò)它定位
label: 'Triangle Vertices',
// 緩沖區(qū)大小,這里是 24 字節(jié)。6 個(gè) 4 字節(jié)(即 32 位)的浮點(diǎn)數(shù)
size: vertices.byteLength,
// 標(biāo)識(shí)緩沖區(qū)用途(1)用于頂點(diǎn)著色器(2)可以從 CPU 復(fù)制數(shù)據(jù)到緩沖區(qū)
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
label 方便我們定位錯(cuò)誤位置:
接著是將頂點(diǎn)數(shù)據(jù)復(fù)制到緩沖區(qū):
device.queue.writeBuffer(vertexBuffer, /* bufferOffset */ 0, vertices);
參數(shù) bufferOffset 表示緩沖區(qū)偏移多少字節(jié)數(shù)的位置寫(xiě)入數(shù)據(jù)。
讀取方式
設(shè)置緩沖區(qū)的讀取方式。
const vertexBufferLayout = {
// 每組讀 8 個(gè)字節(jié)。一個(gè)坐標(biāo)為兩個(gè)浮點(diǎn)數(shù)(2 * 4字節(jié))
arrayStride: 2 * 4,
attributes: [
{
// 指定數(shù)據(jù)格式,這樣 WebGPU 才知道該如何解析,格式為 2 個(gè) 32位浮點(diǎn)數(shù)
format: 'float32x2',
offset: 0, // 從每組的第一個(gè)數(shù)字開(kāi)始
shaderLocation: 0, // 頂點(diǎn)著色器中的位置
},
],
};
attributes 是一個(gè)數(shù)組,這里我們只有頂點(diǎn)要讀,所以只有一個(gè)數(shù)組元素。如果引入了顏色值并和頂點(diǎn)放在一起,我們就要多聲明一個(gè)數(shù)組元素,并將 offset 指定到顏色的位置。
這個(gè)對(duì)象此時(shí)還沒(méi)用到,后面設(shè)置渲染流水線時(shí)會(huì)用到。
著色器
聲明 WebGPU 的著色器,創(chuàng)建著色器模塊(GPUShaderModule)。
WebGPU 使用特有的 WGSL 著色器語(yǔ)言,頂點(diǎn)著色器和片元著色器可以寫(xiě)在一起的。
// 創(chuàng)建著色器模塊
const vertexShaderModule = device.createShaderModule({
label: 'Vertex Shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`,
});
頂點(diǎn)著色器函數(shù)。
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
- @vertex:裝飾器,表示頂點(diǎn)著色器主函數(shù)。
- @location(0):緩沖區(qū)讀取方式設(shè)置的 shaderLocation,這里拿到了兩個(gè)浮點(diǎn)數(shù)。
- vec2f:兩個(gè)浮點(diǎn)數(shù)的向量,同理,vec4f 為 4 浮點(diǎn)數(shù)的向量。
- -> @builtin(position):表示函數(shù)的返回值會(huì)被設(shè)置為內(nèi)置的頂點(diǎn)位置變量。WebGPU 是利用函數(shù)的返回值配合修飾符的方式進(jìn)行內(nèi)部變量賦值的。
片元著色器。
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // 紅色
}
- @fragment 表示片元著色器主函數(shù)。
- -> @location(0) 表示將返回的顏色輸出到位置為 0 的顏色附件上,簡(jiǎn)單來(lái)說(shuō),就是給對(duì)應(yīng)點(diǎn)設(shè)置為對(duì)應(yīng)顏色。
渲染流水線
創(chuàng)建渲染流水線,也就是把之前的設(shè)置組合起來(lái),用哪個(gè)著色器的哪個(gè)函數(shù)作為入口、如何讀取緩沖區(qū)等。
const pipeline = device.createRenderPipeline({
label: 'pipeline', // 標(biāo)識(shí),定位錯(cuò)誤用
layout: 'auto', // 自動(dòng)流水線布局
vertex: {
module: vertexShaderModule, // 著色器模塊
entryPoint: 'vertexMain', // 入口函數(shù)為 vertexMain
buffers: [vertexBufferLayout], // 讀取緩沖區(qū)的方式
},
fragment: {
module: vertexShaderModule,
entryPoint: 'fragmentMain',
targets: [
{
format: canvasFormat, // 輸出到 canvas 畫(huà)布上
},
],
},
});
將渲染流水線設(shè)置到 pass 上。
pass.setPipeline(pipeline);
將緩沖區(qū)綁定到管線的第一個(gè)頂點(diǎn)緩沖槽(slot)。
pass.setVertexBuffer(0, vertexBuffer);
繪制圖元,這里要設(shè)置繪制幾組,一組是兩個(gè)點(diǎn),所以要處以 2。
pass.draw(vertices.length / 2);
然后就是前面講過(guò)的收尾代碼。
pass.end(); // 完成指令隊(duì)列的記錄
const commandBuffer = encoder.finish(); // 結(jié)束編碼
device.queue.submit([commandBuffer]); // 提交給 GPU 命令隊(duì)列
至此,一個(gè)三角形就畫(huà)好了。
繪制結(jié)果
完整代碼
線上 demo 演示:
https://codesandbox.io/s/lg4w27?file=/src/index.mjs。
完整代碼:
const render = async () => {
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('webgpu');
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
ctx.configure({
device,
format: canvasFormat,
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: ctx.getCurrentTexture().createView(),
loadOp: 'clear',
clearValue: { r: 0.6, g: 0.8, b: 0.9, a: 1 },
storeOp: 'store',
},
],
});
// 創(chuàng)建頂點(diǎn)數(shù)據(jù)
// prettier-ignore
const vertices = new Float32Array([
-0.5, -0.5,
0.5, -0.5,
0.5, 0.5,
]);
// 緩沖區(qū)
const vertexBuffer = device.createBuffer({
// 標(biāo)識(shí),字符串隨意寫(xiě),報(bào)錯(cuò)時(shí)會(huì)通過(guò)它定位,
label: 'Triangle Vertices',
// 緩沖區(qū)大小,這里是 24 字節(jié)。6 個(gè) 4 字節(jié)(即 32 位)的浮點(diǎn)數(shù)
size: vertices.byteLength,
// 標(biāo)識(shí)緩沖區(qū)用途(1)用于頂點(diǎn)著色器(2)可以從 CPU 復(fù)制數(shù)據(jù)到緩沖區(qū)
// eslint-disable-next-line no-undef
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
// 將頂點(diǎn)數(shù)據(jù)復(fù)制到緩沖區(qū)
device.queue.writeBuffer(vertexBuffer, /* bufferOffset */ 0, vertices);
// GPU 應(yīng)該如何讀取緩沖區(qū)中的數(shù)據(jù)
const vertexBufferLayout = {
arrayStride: 2 * 4, // 每一組的字節(jié)數(shù),每組有兩個(gè)數(shù)字(2 * 4字節(jié))
attributes: [
{
format: 'float32x2', // 每個(gè)數(shù)字是32位浮點(diǎn)數(shù)
offset: 0, // 從每組的第一個(gè)數(shù)字開(kāi)始
shaderLocation: 0, // 頂點(diǎn)著色器中的位置
},
],
};
// 著色器用的是 WGSL 著色器語(yǔ)言
const vertexShaderModule = device.createShaderModule({
label: 'Vertex Shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`,
});
// 渲染流水線
const pipeline = device.createRenderPipeline({
label: 'pipeline',
layout: 'auto',
vertex: {
module: vertexShaderModule,
entryPoint: 'vertexMain',
buffers: [vertexBufferLayout],
},
fragment: {
module: vertexShaderModule,
entryPoint: 'fragmentMain',
targets: [
{
format: canvasFormat,
},
],
},
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
};
render();
結(jié)尾
本文講解了如何用 WebGPU 繪制一個(gè)三角形??梢钥吹剿?WebGL 的邏輯有很多共同之處的,都要?jiǎng)?chuàng)建緩沖區(qū)、著色器、定義讀取方式。