在博客搭建起来之前,暂用CSDN记录一下WebGPU的学习过程
Mark的一些有用的资源
WebGPU
WGSL语言
Demo
Tutorial
使用Nextjs以及配置基础环境
-
首先安装nextjs脚手架,注意需要使用typescript,CSS按需是否使用TailWind
-
安装WebGPU需要用到的依赖
npm install @webgpu/types
-
配置项目,因为webgpu需要用到WGSL去写shader,因此需要将wgsl文件转换为纯文本引入,这里使用ts-shader-loader,需要安装
npm install ts-shader-loader
-
之后在next.config.(m)js中进行配置
/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: false, webpack: (config) => { // shaders loader config.module.rules.push({ test: /\.(wgsl|glsl|vs|fs)$/, loader: 'ts-shader-loader' }) return config }, } export default nextConfig;
-
在src目录先创建wgsl.d.ts文件,增加对于.wgsl文件类型的描述,将其全部转换为string类型导入
declare module '*.wgsl' { const value: string; export default value; }
-
在src目录下创建webgpu.d.ts文件,告诉TypeScript编译器需要包含一个额外的类型定义文件(笔者对于nextjs基本一窍不通,所以不知道为什么再tsconfig里面typeRoot引入vscode还是会找不到类型,因此就这么搞了)
/// <reference types="@webgpu/types" />
-
到这里环境就配置好了,之后就可以画三角形了
绘制三角形
-
首先初始化GPU,这里对代码就不解释了
async function initWebGPU(canvas: HTMLCanvasElement) { const adapter = await navigator.gpu.requestAdapter( { powerPreference: "high-performance" } ); const device = await adapter?.requestDevice() as GPUDevice; const context = canvas.getContext("webgpu") as GPUCanvasContext; const format = navigator.gpu.getPreferredCanvasFormat(); const width = canvas.clientWidth; const height = canvas.clientHeight; canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D)); canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D)); console.log(canvas.width, canvas.height) const size = { width: canvas.width, height: canvas.height } context.configure({ device: device!, format: format, alphaMode: "opaque" }); return { device, context, format, size }; }
-
创建写一个shader去定义vertex以及fragment,有种rust的美感,记得引入该shader,
import shader from "./shaders.wgsl"
struct Fragment { @builtin(position) Position : vec4<f32>, @location(0) Color : vec4<f32> }; @vertex fn vs_main(@builtin(vertex_index) v_id: u32) -> Fragment { var positions = array<vec2<f32>, 3> ( vec2<f32>( 0.0, 0.5), vec2<f32>(-0.5, -0.5), vec2<f32>( 0.5, -0.5) ); var colors = array<vec3<f32>, 3> ( vec3<f32>(1.0, 0.0, 0.0), vec3<f32>(0.0, 1.0, 0.0), vec3<f32>(0.0, 0.0, 1.0) ); var output : Fragment; output.Position = vec4<f32>(positions[v_id], 0.0, 1.0); output.Color = vec4<f32>(colors[v_id], 1.0); return output; } @fragment fn fs_main(@location(0) Color: vec4<f32>) -> @location(0) vec4<f32> { return Color; }
这里稍微解释一下代码,图形学的常识告诉我们当顶点着色器输出 3 个位置时,一个三角形就会被光栅化。顶点着色器可以在每个位置输出额外的值,默认情况下,这些值将在 3 个点之间进行插值。我们这里使用了一种叫做Inter-stage变量的方式,沟通vertex shder的以及fragment shader(其实就是把vertex shader中的变量传递到frag shader中去)。声明一个结构体struct。这是在顶点着色器和片段着色器之间增加 Inter-stage 变量的一种简便方法。
struct Fragment { (position) Position : vec4<f32>, (0) Color : vec4<f32> };
之后我们在vertex shader中返回该类型的结构体
vs_main(@builtin(vertex_index) v_id: u32) -> Fragment
然后我们可以在frag shader中“接住”在vertex shader中定义好的输出,及就是fs_main((0) Color: vec4<f32>) -> (0) vec4<f32> { return Color; } // 其实这里也可以写成,这样做会更好理解一些, fn fs_main(fsInput: Fragment) -> (0) vec4<f32> { return fsInput.Color; }
fn对于 Inter-stage 变量,它们也是通过 location 索引进行连接,因此这里直接使用
@location(0)
也是正确的。
关于这里的@builtin,则代表了内置变量,WGSL中的builtin参考文档在不同的地方builtin的含义不尽相同。在顶点着色器中,@builtin(position)
是 GPU 绘制三角形/线/点所需的输出。在片段着色器中,@builtin(position)
是一个输入。它是片段着色器当前被要求计算颜色的像素坐标。
那么在顶点着色器vertexIndex 是 u32 类型,即是32 位无符号整数,对于非索引绘制,第一个顶点的索引等于绘制的 firstVertex 参数,无论是直接提供还是间接提供。 绘制实例中每增加一个顶点,索引就会增加 1。,在之后我们只要通过draw函数绘制一次,该索引会自动+1 -
创建pipeline以及绘制,感觉这里的解释对初学者很友好链接
async function initPipeline(device: GPUDevice, format: GPUTextureFormat): Promise<GPURenderPipeline> { const descriptor: GPURenderPipelineDescriptor = { layout: 'auto', vertex: { module: device.createShaderModule({ code: shader }), entryPoint: 'vs_main' }, primitive: { topology: 'triangle-list' }, fragment: { module: device.createShaderModule({ code: shader }), entryPoint: 'fs_main', targets: [ { format: format } ] } } return await device.createRenderPipelineAsync(descriptor) } function draw(device: GPUDevice, context: GPUCanvasContext, pipeline: GPURenderPipeline) { const commandEncoder = device.createCommandEncoder() const view = context.getCurrentTexture().createView() const renderPassDescriptor: GPURenderPassDescriptor = { colorAttachments: [ { view: view, clearValue: { r: 0.5, g: 0.0, b: 0.25, a: 1.0 }, loadOp: 'clear' as GPULoadOp, storeOp: 'store' as GPUStoreOp } ] } const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor) passEncoder.setPipeline(pipeline) passEncoder.draw(3) passEncoder.end() device.queue.submit([commandEncoder.finish()]) }
-
导出component,这里需要注意的是注意改变canvas大小,具体参考了这篇文章,其中提到了如何保证canvas的显示尺寸以及canvas本身所包含的像素数量匹配,否则会存在模糊情况
export default function HelloWebGPU() { const canvasRef = useRef<HTMLCanvasElement>(null); useEffect(() => { if (canvasRef.current) { const canvas = canvasRef.current; let device: GPUDevice; let context: GPUCanvasContext; let pipeline: GPURenderPipeline; let format: GPUTextureFormat; let size: { width: number, height: number }; const setupAndDraw = async () => { if (canvas) { ({ device, context, format, size } = await initWebGPU(canvas)); if (device && context) { pipeline = await initPipeline(device, format); draw(device, context, pipeline); } } } // 改变窗口时更改canvas const handleResize = () => { if (canvas && device) { const width = canvas.clientWidth; const height = canvas.clientHeight; canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D)); canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D)); draw(device, context, pipeline); } }; window.addEventListener('resize', handleResize); setupAndDraw(); return () => { window.removeEventListener('resize', handleResize); }; } }, []); return ( <div className="w-full h-full"> <canvas ref={canvasRef} className="w-full h-full"></canvas> </div> ); }
-
之后再nextjs的页面中加载component即可,就可以绘制出如图所示三角形
使用useState获取GPU相关属性
- 由于是初学react,对于hooks并不是非常熟悉,这里代码使用了这篇文章的几个hook函数,现在使用
useWebGPU
即可返回所需要的一些属性const useWebGPU: (canvas: HTMLCanvasElement | null | undefined) => { canvas: HTMLCanvasElement | null | undefined; context: GPUCanvasContext | undefined; format: GPUTextureFormat; adapter: GPUAdapter | undefined; device: GPUDevice | undefined; }
- 之后在每次使用时,只需要设置一下context的config,就可以直接使用device以及context进行绘制。
使用Uniform绘制多个三角形
Write Buffer的几种方式
- 见链接
- 我们可以使用
@group(0) @binding(0) var<uniform>
设置需要bind的uniform的变量,之后在代码中创建buffer之后就可以使用passEncoder.setBindGroup(0, bindGroup);
去绑定。
对于绘制多个三角形的绘制命令
- 注意,我们写入一系列绘制命令后再进行提交,而不是提交一个命令就submit一次,实际上这些命令被写入到了命令缓冲区,当绘制函数退出时,我们会按照一定顺序发出这些命令
for (const { scale, bindGroup, uniformBuffer, uniformValues } of objectInfos) { uniformValues.set([scale / aspect, scale], kScaleOffset); device.queue.writeBuffer(uniformBuffer, 0, uniformValues); passEncoder.setBindGroup(0, bindGroup); passEncoder.draw(3); } passEncoder.end() device.queue.submit([commandEncoder.finish()])
一些关键代码
- 首先是创建buffer的代码,注意
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
这里设置GPUBufferUsage.UNIFORM
即可使用uniformfor (let i = 0; i < kNumObjects; ++i) { const uniformBuffer = device.createBuffer({ label: `uniforms for obj: ${i}`, size: uniformBufferSize, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); // 创建一个Float32Array来存储uniform数据,长度为uniformBufferSize / 4 const uniformValues = new Float32Array(uniformBufferSize / 4); // 设置颜色 uniformValues.set([rand(), rand(), rand(), 1], kColorOffset); uniformValues.set([rand(-0.9, 0.9), rand(-0.9, 0.9)], kOffsetOffset); // 创建一个绑定组,并将缓冲区绑定到我们在着色器中设置的上 const bindGroup = device.createBindGroup({ label: `bind group for obj: ${i}`, layout: pipeline.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: uniformBuffer } }, ], }); objectInfos.push({ scale: rand(0.1, 0.5), uniformBuffer, uniformValues, bindGroup, }); }
使用Storage buffer在一个draw call中绘制多个三角形
Uniform buffer与Storage buffer的区别
- uniform buffer在典型的使用情况下速度更快
- storage buffer(64 Kib)比uniform buffer(128Mib)更小
- storage buffer可读写,而uniform buffer只读
主要的修改
- 首先是shader中使用array去存储要输入的值
以及在vertex的入口,使用struct OurStruct { color: vec4f, offset: vec2f, }; struct OtherStruct { scale: vec2f, }; struct VSOutput { @builtin(position) position: vec4f, @location(0) color: vec4f, }; //这里使用array @group(0) @binding(0) var<storage, read> ourStructs: array<OurStruct>; @group(0) @binding(1) var<storage, read> otherStructs: array<OtherStruct>;
@builtin(instance_index)
去在每次绘制时更新索引
最后绘制时,直接使用,即可在一个drawcall中绘制kNumObjects个三角形@vertex fn vs_main( @builtin(vertex_index) v_id: u32, @builtin(instance_index) i_id: u32 ) -> VSOutput { var positions = array<vec2<f32>, 3> ( vec2<f32>( 0.0, 0.5), vec2<f32>(-0.5, -0.5), vec2<f32>( 0.5, -0.5) ); let otherStruct = otherStructs[i_id]; let ourStruct = ourStructs[i_id]; var output: VSOutput; output.position = vec4f(positions[v_id] * otherStruct.scale + ourStruct.offset, 0.0, 1.0); output.color = ourStruct.color; return output; }
objectInfos.forEach(({ scale }, ndx) => { const offset = ndx * (changingUnitSize / 4); storageValues.set([scale / aspect, scale], offset + kScaleOffset) }); device.queue.writeBuffer(changingStorageBuffer, 0, storageValues); passEncoder.setBindGroup(0, bindGroup); passEncoder.draw(3,kNumObjects);