WebGPU 编码与原理(3):一次完整代码的详细讲解

2023年4月6日,谷歌宣布在 Chrome 用户可在 113 Beta 版本中,启用全新的 WebGPU 图形 API,支持硬件图形加速。

本系列是学习记录,尚不能称之为教程(因此可能在代码的实现上、原理的阐述上等都可能存在不合适、不严谨或错误)。该系列希望通过代码撰写以及解读代码的含义,尝试阐述 WebGPU 中相关的图形学效果的实现方法、原理。如有遗漏、错误,还请指正与赐教。

一、一次完整 WebGPU 渲染代码的详细讲解

一、完整的 HTML 代码

DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebGPU 渲染title>
head>

<body>
    
    <canvas id="webgpu" width="500" height="500">canvas>
    <script type="module">
        // 顶点着色器、片元着色器代码
        // 顶点着色器代码
        const vertex = /* wgsl */ `
            @vertex
            fn main(@location(0) pos: vec3) -> @builtin(position) vec4 {
                return vec4(pos,1.0);
            }
            `

        // 片元着色器代码
        const fragment = /* wgsl */ `
            @fragment
            fn main() -> @location(0) vec4 {
                return vec4(1.0, 0.0, 0.0, 1.0);
            }
            `

        // 配置WebGPU上下文
        const adapter = await navigator.gpu.requestAdapter();
        const device = await adapter.requestDevice();
        const canvas = document.getElementById('webgpu');
        const context = canvas.getContext('webgpu');
        const format = navigator.gpu.getPreferredCanvasFormat();
        context.configure({
            device: device,
            format: format,
        });


        //创建顶点数据
        // 一个矩形拆分为两个三角形表示,三角形其中两个顶点坐标是重合的
        // 注意一个面的多个三角形,正反面要保持一致,要么都是正面,要么都是反面,或者说沿着某个方向看过去,要么都是顺时装,要么都是逆时针
        const vertexArray = new Float32Array([
            // 三角形1三个顶点坐标的x、y、z值
            -0.3, -0.5, 0.0,//顶点1坐标
            0.3, -0.5, 0.0,//顶点2坐标
            0.3, 0.5, 0.0,//顶点3坐标
            // 三角形2三个顶点坐标的x、y、z值
            -0.3, -0.5, 0.0,//顶点4坐标 与顶点1重合
            0.3, 0.5, 0.0,//顶点5坐标 与顶点3重合
            -0.3, 0.5, 0.0,//顶点6坐标
        ]);
        const vertexBuffer = device.createBuffer({// 创建顶点数据的缓冲区
            size: vertexArray.byteLength,
            usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
        });
        device.queue.writeBuffer(vertexBuffer, 0, vertexArray);// 顶点数据写入缓冲区


        // 渲染管线
        const pipeline = device.createRenderPipeline({
            layout: 'auto',
            vertex: {//顶点相关配置
                module: device.createShaderModule({ code: vertex }),
                entryPoint: "main",
                buffers: [//顶点缓冲区相关设置
                    {
                        arrayStride: 3 * 4, // 一个顶点数据占用的字节长度,该缓冲区一个顶点包含 xyz 三个分量,每个数字是 4 字节浮点数,3 * 4 字节长度
                        attributes: [{ // 顶点缓冲区属性
                            shaderLocation: 0,// GPU 显存上顶点缓冲区存储位置标记
                            format: "float32x3", // 格式: float 32 * 3 表示一个顶点数据包含 3 个 32 位浮点数
                            offset: 0 // arrayStride 表示每组顶点数据间隔字节数, offset 表示读取该组的偏差字节数,没特殊需要一般设置为 0
                        }]
                    }
                ]
            },
            fragment: {//片元相关配置
                module: device.createShaderModule({ code: fragment }),
                entryPoint: "main",
                targets: [{
                    format: format
                }]
            },
            primitive: {
                topology: "triangle-list",//绘制三角形
            }
        });

        // 命令编码器
        const commandEncoder = device.createCommandEncoder();
        // 渲染通道
        const renderPass = commandEncoder.beginRenderPass({
            // 给渲染通道指定颜色缓冲区,配置指定的缓冲区
            colorAttachments: [{
                // 指向用于Canvas画布的纹理视图对象(Canvas对应的颜色缓冲区)
        // 该渲染通道renderPass输出的像素数据会存储到Canvas画布对应的颜色缓冲区(纹理视图对象)
                view: context.getCurrentTexture().createView(),
                storeOp: 'store',
                clearValue: { r: 0.5, g: 0.5, b: 0.5, a: 1.0 }, //背景颜色
                loadOp: 'clear',
            }]
        });
        renderPass.setPipeline(pipeline);
        // 顶点缓冲区数据和渲染管线shaderLocation: 0表示存储位置关联起来
        renderPass.setVertexBuffer(0, vertexBuffer);
        renderPass.draw(6);// 绘制顶点数据
        // 渲染通道结束命令.end()
        renderPass.end();
        // 命令编码器.finish()创建命令缓冲区(生成GPU指令存入缓冲区)
        const commandBuffer = commandEncoder.finish();
        device.queue.submit([commandBuffer]);
    script>
body>

html>
二、代码分模块解析
一、两个三角形组合成矩形及其与 Shader 的关系

1、定义了一个 3 * 6 的顶点数据,且三个点(3 * 3)为一个三角形,每个三角形都是按照一个方向(这里都是逆时针)的顺序的。(-0.3,-0.5,0.0)-> (0.3,-0.5,0.0) -> (0.3,0.5,0.0) 和 (-0.3,-0.5,0.0) -> (0.3,0.5,0.0) -> (-0.3,0.5,0.0)

WebGPU 编码与原理(3):一次完整代码的详细讲解_第1张图片

2、renderPass.draw 方法 传的参数是 6,这个 6 就对应着顶点数据数组的顶点数量(18 除以 3,每个顶点用 3 个数字来表示,3 个数字为一组,总共 6 组,6 个顶点)。也就是顶点着色器 const vertex = ‘wgsl’ 的着色器代码会执行 6 次,会读取 vertexArray 的数组(因为顶点着色器的代码为 fn main(@location(0) pos: vec3),对应着 Float32Array 的 vertexArray ,读取时会将长度为 18 (6 * 3)的 vertexArray 数组,以 3 个数字为一组,如 (-0.3,-0.5,0.0) 作为着色器 fn main 函数的参数 vec3 传递进来,然后返回 vec4,此着色器代码将会执行 6 次,6 个顶点 )

WebGPU 编码与原理(3):一次完整代码的详细讲解_第2张图片

渲染管线中的 vertex 中设置的 buffers 中的 format: “float32×3” 即表示 3 个数字为一组。

一个 float32 位的浮点数 占用 4 个字节长度。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第3张图片

二、那 JS 中的 vertexArray 是如何从 CPU 到 GPU 上的呢?

将 vertexArray 这样的 JS 数据存储到 GPU 内存中(顶点缓冲区中)

  • 通过 device.createBuffer() 来创建顶点缓冲区,也就是在电脑显卡 GPU 的内存(显存)中开辟一片存储空间,用来存储顶点数据。这个开辟的存储空间就可以理解为顶点缓冲区
const vertexBuffer = device.createBuffer({// 创建顶点数据的缓冲区
            size: vertexArray.byteLength,
            usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
        });
  • 通过 vertexArray.byteLength 来定义该内存的数据字节长度(占用的大小)

  • 设置 usage 属性的值为 GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,|是JavaScript位运算符。

    GPUBufferUsage.VERTEX 表示用于该缓冲区是顶点缓冲区,就是存储顶点数据的缓冲区。

    GPUBufferUsage.COPY_DST 的 COPY 是复制英文单词,DST 是目的地单词 destination 的缩写,简单说该缓冲区可以写入顶点数据,作为复制顶点数据的目的地。

  • 把 CPU 上的 JS 数据写入到缓冲区(.writeBuffer (vertexBuffer, 0, vertexArray) 表示把 vertexArray 里面的顶点数据写入到 vertexBuffer 对应的 GPU 显存缓冲区中,参数2表示从 vertexArray 获取顶点数据的偏移量(单位字节),0 表示从 vertexArray 的数据开头读取数据。),实现 CPU 数据到 GPU 数据的转换。

device.queue.writeBuffer(vertexBuffer, 0, vertexArray);// 顶点数据写入缓冲区
三、那在 GPU 上的顶点数据是如何与 GPU上要执行的 Shader 是如何对应的呢?
  • 渲染管线 pipeline 中的 vertex 里的 buffers 里设置了 shaderLocation 为 0,即 GPU 显存上顶点缓冲区的标记 为 0,这个 0 和 渲染通道 renderPass.setVertexBuffer(0,vertexBuffer) 的 0 一样,两者对应,而这个 0 和 Shader 中的 @location(0) 也是对应的。即 renderPass.setVertexBuffer(0,vertexBuffer) 设置了标记为 0 的顶点缓冲区的数据值,而 Shader 中的 @location(0) 就是拿到这个标记为 0 的对应的顶点缓冲区的数据值。

    从而实现了 GPU 顶点数据与 Shader 要运行的代码的参数对应。

    渲染通道:渲染通道是一个渲染过程,包括灯光、阴影、高光等过程,并将结果累加到最终呈现的结果上。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第4张图片
WebGPU 编码与原理(3):一次完整代码的详细讲解_第5张图片

四、那顶点之间的连接方式是怎样的呢?

我们知道三个点可以连成一个三角形,那这个三角形是三角形的线,还是三角形的面呢?是首尾闭合还是不闭合呢?

  • 这就取决于渲染管线的 primitive 设置的属性了。

    如这里如果设置为 "line-strip"就表示绘制的是三角形的线,值为"triangle-list"就表示绘制的是三角形的面。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第6张图片

不同值的绘制结果:

WebGPU 编码与原理(3):一次完整代码的详细讲解_第7张图片

五、顶点数据已经在 GPU 上了,顶点的连接方式也已经确定了,那顶点数据表达的图形的颜色该怎么设置呢?
  • 通过片元着色器设置颜色,片元着色器和顶点着色器通过渲染管线一起定义。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第8张图片

六、那什么是渲染管线呢?

所谓渲染管线(render pipeline),也就是渲染流水线。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第9张图片

上图所示的就是真实生活中的流水线,就如左边图中的一样,有了流水线后,每一个步骤都专注于一个工序,生产效率便大大提高。

渲染管线也是同理,可以将刚才的概念延伸到计算机的图像渲染中。渲染管线的工作任务通常是由一个三维场景出发,最终渲染生成一张二维的图像,而这个工作一般是由 CPU 和 GPU 共同完成的,就如右边的图一样,CPU 如进货的卡车不断地将要处理的数据丢给 GPU,GPU 工厂调动一个个如工人一般的计算单元对这些数据进行简单的处理,最后组装出产品——图像。

而这里 WebGPU 渲染管线,可以用下图描述:

WebGPU 编码与原理(3):一次完整代码的详细讲解_第10张图片

更细节的说,以上的代码,可以对应到以下渲染管线的流程:

WebGPU 编码与原理(3):一次完整代码的详细讲解_第11张图片

所以看到,目前已经在渲染管线流程中走到了片元着色器的流程。

渲染管线的功能也就是完成从顶点数据到渲染到画布的每一个步骤、流程。渲染管线最终会输出像素数据(该像素数据会存储到颜色缓冲区中,类似顶点缓冲区用来存储顶点数据)。然后像素数据(图片)被绘制到 Canvas 画布上。

七、那渲染管线是如何创建以及与画布关联的呢(将像素数据绘制到画布上)?
  • 1、通过获取到的 GPU 设备对象 device 的 .createCommandEncoder 方法创建一个命令编码器,命令编码器能够控制渲染管线渲染输出像素数据。
    所有的顶点缓冲区、渲染管线、着色器配置都是不会执行的,如果想要在GPU上执行,还需要配置 GPU 命令编码器对象实现,然后通过命令编码器的 .beginRenderPass 方法创建渲染通道。

  • 2、由于 GPU 可以是有自己内存的独立显卡,所以可以通过所谓的“指令缓冲”或者“指令队列”来控制它。

    指令队列,是一块内存(显示内存),编码了 GPU 待执行的指令。编码与 GPU 本身紧密相关,由显卡驱动负责创建。WebGPU 暴露了一个 “CommandEncoder” API 来对接这个术语。

  • 3、WebGPU 使用现代图形 API 的思想,将所有 GPU 该做的操作、需要信息事先编码至一个叫“CommandBuffer(指令缓冲)”的容器上,最后统一由 CPU 提交至 GPU,GPU 拿到开始执行。

    编码指令缓冲的对象叫做 GPUCommandEncoder,即指令编码器,它最大的作用就是创建两种通道编码器(commandEncoder.begin[Render/Compute]Pass()),以及发出提交动作(commandEncoder.finish()),最终生成这一帧所需的所有指令。

  • 4、管线对象更多的是扮演一个“执行者”,它代表的是某个单一计算过程的全部行为,而且是发生在 GPU 上。

    而对于 PassEncoder,也就是通道编码器,它拥有一系列 setXXX 方法,它的角色更多的是“调度者”。

    通道编码器在结束编码后,整个被编码的过程就代表了一个 Pass(通道)的计算流程。

        // 命令编码器
        const commandEncoder = device.createCommandEncoder();
        // 渲染通道
        const renderPass = commandEncoder.beginRenderPass({
            // 给渲染通道指定颜色缓冲区,配置指定的缓冲区
            colorAttachments: [{
                // 指向用于Canvas画布的纹理视图对象(Canvas对应的颜色缓冲区)
        // 该渲染通道renderPass输出的像素数据会存储到Canvas画布对应的颜色缓冲区(纹理视图对象)
                view: context.getCurrentTexture().createView(),
                storeOp: 'store',//像素数据写入颜色缓冲区
                clearValue: { r: 0.5, g: 0.5, b: 0.5, a: 1.0 }, //背景颜色
                loadOp: 'clear',
            }]
        });
八、那绘制的操作是通过哪些命令来确定的呢?
// 设置该渲染通道控制渲染管线        
renderPass.setPipeline(pipeline);
// 顶点缓冲区数据和渲染管线shaderLocation: 0表示存储位置关联起来
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.draw(6);// 绘制顶点数据
// 渲染通道结束命令.end()
renderPass.end();
// 命令编码器.finish()创建命令缓冲区(生成GPU指令存入缓冲区)
const commandBuffer = commandEncoder.finish();
device.queue.submit([commandBuffer]);
  • 1、通过指令编码器创建的渲染通道来设定当前的一条渲染管线。

什么是渲染通道呢?

本质上就是为了解决一件复杂问题而采用**分而治之**算法在图形渲染领域的一种应用。

打个盖高楼的比方,盖一栋摩天大楼是已经复杂的事情,现实中我们看到的过程从大方向上看至少可以拆成:打地基+盖大楼主体+后期装修,而不会把这盖楼(砌砖)和装修(粉刷、门窗等)的事情在单次操作中同时干了,那样不仅效率极其低下而且肯定把事情搞砸。

GPU 呈现一幅精美的画面也是同样的逻辑,开发者通常把这件事情拆成几个部分,例如先画所有不透明的物体、再画带Mask的不透明物体、再画透明的物体,可能还有后期特效的绘制,而具体怎么拆,就跟你盖楼一样,聪明的人有更聪明的拆法。而这些每个单独的画面部分的绘制工作,GPU都干完后,你就可以提交到屏幕显示了。而**一个Render Pass 就是你拆出来的这些单独的画面绘制工作中的一个,**它包含你提供的原材料(模型、贴图、shader、其他属性设置等等)和操作过程(绘制指令),最终会提交给GPU执行,而本次GPU执行的结果会写入某个 Render Target。


多通道渲染技术:一个物体我们需要多次渲染,每个渲染过程的结果会被累加到最终的呈现结果上。

这些渲染过程一般是:

highlights pass、Global illumination pass、Light Pass、Reflection Pass, Shadow Pass.  Pre-Z、  Post Effect 、

为什么会有多个pass:

多pass是为了实现一个pass实现不了的效果。

pass之间是相互依赖的,后边的pass会用到前面的pass数据(深度、几何信息),最后的pass出来的数据才是帧缓冲中的数据。

一系列drawcall的集合, 这类DrawCall通常有着类似的属性,并且按照一定的顺序执行,绘制结果通常保存在一张RT中,作为输入供后边使用。

一次FBO bind  所有绘制指令  unbind FBO

类似画一幅画 有  草稿构图阶段,明暗调整阶段,上色阶段,以及最后的精修阶段。


WebGPU 编码与原理(3):一次完整代码的详细讲解_第12张图片

  • 2、渲染通道将渲染管线和顶点缓冲区数据关联
// 设置该渲染通道控制渲染管线        
renderPass.setPipeline(pipeline);
// 顶点缓冲区数据和渲染管线shaderLocation: 0表示存储位置关联起来
renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.draw(6);// 绘制顶点数据
// 渲染通道结束命令.end()
renderPass.end();
// 命令编码器.finish()创建命令缓冲区(生成GPU指令存入缓冲区)
const commandBuffer = commandEncoder.finish();
device.queue.submit([commandBuffer]);
  • 3、draw 命令用于绘制顶点数据
  • 4、end 命令结束渲染通道命令

通过GPU命令编码器对象 commandEncoder 可以根据需要创建多个渲染通道,每个通道都可以控制自己对应的的渲染管线输出图像。不过这里案例比较简单,只是创建一个渲染通道而已。

  • 5、.finish 创建命令缓冲区(生成 GPU 指令存入缓冲区)
  • 6、在 device.queue.submit([commandBuffer]); 方法之前,WebGPU 相关命令并不会并硬件真正执行。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第13张图片

最终通过以上步骤,就实现了渲染管线的完整流程:从 CPU 到 GPU,从数据 到 图形。

九、补充1:WebGPU 坐标系

WebGPU 编码与原理(3):一次完整代码的详细讲解_第14张图片
WebGPU 编码与原理(3):一次完整代码的详细讲解_第15张图片

WebGPU坐标系在Canvas画布上的坐标原点是Canvas画布的中间位置,x轴水平向y轴竖直向,x和y的坐标范围都是[-1,1],WebGPU坐标系z轴与Canvas画布垂直,朝向屏幕,z坐标的范围是[0,1]。

十、补充2:WebGPU 渲染规则

在x、y、z轴上各取一个点创建一个等边三角形。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第16张图片

为了更好的理解,假设在WebGPU的3D空间中,存在一束平行光线,沿着z轴照射到 XOY 平面上,这时候 3D 空间中的三角形会在 XOY 平面上产生投影,就像生活中,人在太阳光下,会地面上产生投影。

这时候,z 轴上的任何顶点,投影后,其实都在坐标原点,这样上面一个等边三角形,三个点投影后,就是两个点在 x 和 y 轴,z 轴上的点投影到坐标原点,这样三个点连接起来,渲染的投影结果就是一个直接三角形。

WebGPU 编码与原理(3):一次完整代码的详细讲解_第17张图片

参考:

渲染管线概述

WebGPU入门(三):命令编码器、渲染通道及最终绘制

WebGPU 计算管线、计算着色器(通用计算)入门案例:2D 物理模拟

7. 渲染命令(至此完成第一个案例)

4. WebGPU 存储缓冲区 (WebGPU Storage Buffers)

多通道渲染 RenderPass

理解Vulkan渲染通道(RenderPass)

浅谈GPU的Web化—WebGPU

计算机图形里面的RenderingPass(渲染通道)是什么意思?

你可能感兴趣的:(WebGPU,3d,前端)