在Nextjs使用WebGPU

本文详细介绍了如何在Next.js项目中集成WebGPU,包括安装依赖、配置环境、编写并使用WGSLshader,以及创建和绘制三角形的过程。作者还分享了使用Uniform和Storagebuffer的不同方法以及它们的性能差异。
摘要由CSDN通过智能技术生成

在博客搭建起来之前,暂用CSDN记录一下WebGPU的学习过程

Mark的一些有用的资源

WebGPU

  1. W3C官方文档
  2. 中文翻译

WGSL语言

  1. WGSL官方文档
  2. WGSL中文翻译
  3. Google的WGSL tour

Demo

  1. 官方Demo

Tutorial

  1. webgpufundamentals

使用Nextjs以及配置基础环境

  1. 首先安装nextjs脚手架,注意需要使用typescript,CSS按需是否使用TailWind
    新建nextjs项目

  2. 安装WebGPU需要用到的依赖npm install @webgpu/types

  3. 配置项目,因为webgpu需要用到WGSL去写shader,因此需要将wgsl文件转换为纯文本引入,这里使用ts-shader-loader,需要安装 npm install ts-shader-loader

  4. 之后在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;
    
  5. 在src目录先创建wgsl.d.ts文件,增加对于.wgsl文件类型的描述,将其全部转换为string类型导入

    declare module '*.wgsl'
    {
    	const value: string;
    	export default value;
    }
    
  6. 在src目录下创建webgpu.d.ts文件,告诉TypeScript编译器需要包含一个额外的类型定义文件(笔者对于nextjs基本一窍不通,所以不知道为什么再tsconfig里面typeRoot引入vscode还是会找不到类型,因此就这么搞了)

    /// <reference types="@webgpu/types" />
    
  7. 到这里环境就配置好了,之后就可以画三角形了

绘制三角形

  1. 首先初始化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 };
    }
    
  2. 创建写一个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中定义好的输出,及就是

    
    fn fs_main((0) Color: vec4<f32>) -> (0) vec4<f32> {
        return Color;
    }
    // 其实这里也可以写成,这样做会更好理解一些,
    
    fn fs_main(fsInput: Fragment) -> (0) vec4<f32> {
        return fsInput.Color;
    }
    

    对于 Inter-stage 变量,它们也是通过 location 索引进行连接,因此这里直接使用@location(0)也是正确的。
    关于这里的@builtin,则代表了内置变量,WGSL中的builtin参考文档在不同的地方builtin的含义不尽相同。在顶点着色器中,@builtin(position) 是 GPU 绘制三角形/线/点所需的输出。在片段着色器中,@builtin(position) 是一个输入。它是片段着色器当前被要求计算颜色的像素坐标。
    那么在顶点着色器vertexIndex 是 u32 类型,即是32 位无符号整数,对于非索引绘制,第一个顶点的索引等于绘制的 firstVertex 参数,无论是直接提供还是间接提供。 绘制实例中每增加一个顶点,索引就会增加 1。,在之后我们只要通过draw函数绘制一次,该索引会自动+1

  3. 创建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()])
    }
    
  4. 导出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>
        );
    }
    
  5. 之后再nextjs的页面中加载component即可,就可以绘制出如图所示三角形

使用useState获取GPU相关属性

  1. 由于是初学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;
    }
    
  2. 之后在每次使用时,只需要设置一下context的config,就可以直接使用device以及context进行绘制。

使用Uniform绘制多个三角形

Write Buffer的几种方式

  1. 见链接
  2. 我们可以使用@group(0) @binding(0) var<uniform>设置需要bind的uniform的变量,之后在代码中创建buffer之后就可以使用passEncoder.setBindGroup(0, bindGroup);去绑定。

对于绘制多个三角形的绘制命令

  1. 注意,我们写入一系列绘制命令后再进行提交,而不是提交一个命令就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()])
    

一些关键代码

  1. 首先是创建buffer的代码,注意usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,这里设置GPUBufferUsage.UNIFORM即可使用uniform
    for (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的区别

  1. uniform buffer在典型的使用情况下速度更快
  2. storage buffer(64 Kib)比uniform buffer(128Mib)更小
  3. storage buffer可读写,而uniform buffer只读

主要的修改

  1. 首先是shader中使用array去存储要输入的值
     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>;
    
    以及在vertex的入口,使用@builtin(instance_index)去在每次绘制时更新索引
    @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;
    }
    
    最后绘制时,直接使用,即可在一个drawcall中绘制kNumObjects个三角形
    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); 
    
    由24*3*2个三角形组成的圆形
  • 13
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值