2023年4月6日,谷歌宣布在 Chrome 用户可在 113 Beta 版本中,启用全新的 WebGPU 图形 API,支持硬件图形加速。
本系列是学习记录,尚不能称之为教程(因此可能在代码的实现上、原理的阐述上等都可能存在不合适、不严谨或错误)。该系列希望通过代码撰写以及解读代码的含义,尝试阐述 WebGPU 中相关的图形学效果的实现方法、原理。如有遗漏、错误,还请指正与赐教。
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>
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)
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 个顶点 )
渲染管线中的 vertex 中设置的 buffers 中的 format: “float32×3” 即表示 3 个数字为一组。
一个 float32 位的浮点数 占用 4 个字节长度。
将 vertexArray 这样的 JS 数据存储到 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);// 顶点数据写入缓冲区
渲染管线 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 要运行的代码的参数对应。
渲染通道:渲染通道是一个渲染过程,包括灯光、阴影、高光等过程,并将结果累加到最终呈现的结果上。
我们知道三个点可以连成一个三角形,那这个三角形是三角形的线,还是三角形的面呢?是首尾闭合还是不闭合呢?
这就取决于渲染管线的 primitive 设置的属性了。
如这里如果设置为 "line-strip"就表示绘制的是三角形的线,值为"triangle-list"就表示绘制的是三角形的面。
不同值的绘制结果:
所谓渲染管线(render pipeline),也就是渲染流水线。
上图所示的就是真实生活中的流水线,就如左边图中的一样,有了流水线后,每一个步骤都专注于一个工序,生产效率便大大提高。
渲染管线也是同理,可以将刚才的概念延伸到计算机的图像渲染中。渲染管线的工作任务通常是由一个三维场景出发,最终渲染生成一张二维的图像,而这个工作一般是由 CPU 和 GPU 共同完成的,就如右边的图一样,CPU 如进货的卡车不断地将要处理的数据丢给 GPU,GPU 工厂调动一个个如工人一般的计算单元对这些数据进行简单的处理,最后组装出产品——图像。
而这里 WebGPU 渲染管线,可以用下图描述:
更细节的说,以上的代码,可以对应到以下渲染管线的流程:
所以看到,目前已经在渲染管线流程中走到了片元着色器的流程。
渲染管线的功能也就是完成从顶点数据到渲染到画布的每一个步骤、流程。渲染管线最终会输出像素数据(该像素数据会存储到颜色缓冲区中,类似顶点缓冲区用来存储顶点数据)。然后像素数据(图片)被绘制到 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]);
什么是渲染通道呢?
本质上就是为了解决一件复杂问题而采用**分而治之**算法在图形渲染领域的一种应用。
打个盖高楼的比方,盖一栋摩天大楼是已经复杂的事情,现实中我们看到的过程从大方向上看至少可以拆成:打地基+盖大楼主体+后期装修,而不会把这盖楼(砌砖)和装修(粉刷、门窗等)的事情在单次操作中同时干了,那样不仅效率极其低下而且肯定把事情搞砸。
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
类似画一幅画 有 草稿构图阶段,明暗调整阶段,上色阶段,以及最后的精修阶段。
// 设置该渲染通道控制渲染管线
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]);
通过GPU命令编码器对象 commandEncoder 可以根据需要创建多个渲染通道,每个通道都可以控制自己对应的的渲染管线输出图像。不过这里案例比较简单,只是创建一个渲染通道而已。
最终通过以上步骤,就实现了渲染管线的完整流程:从 CPU 到 GPU,从数据 到 图形。
WebGPU坐标系在Canvas画布上的坐标原点是Canvas画布的中间位置,x轴水平向右,y轴竖直向上,x和y的坐标范围都是[-1,1],WebGPU坐标系z轴与Canvas画布垂直,朝向屏幕,z坐标的范围是[0,1]。
在x、y、z轴上各取一个点创建一个等边三角形。
为了更好的理解,假设在WebGPU的3D空间中,存在一束平行光线,沿着z轴照射到 XOY 平面上,这时候 3D 空间中的三角形会在 XOY 平面上产生投影,就像生活中,人在太阳光下,会地面上产生投影。
这时候,z 轴上的任何顶点,投影后,其实都在坐标原点,这样上面一个等边三角形,三个点投影后,就是两个点在 x 和 y 轴,z 轴上的点投影到坐标原点,这样三个点连接起来,渲染的投影结果就是一个直接三角形。
参考:
渲染管线概述
WebGPU入门(三):命令编码器、渲染通道及最终绘制
WebGPU 计算管线、计算着色器(通用计算)入门案例:2D 物理模拟
7. 渲染命令(至此完成第一个案例)
4. WebGPU 存储缓冲区 (WebGPU Storage Buffers)
多通道渲染 RenderPass
理解Vulkan渲染通道(RenderPass)
浅谈GPU的Web化—WebGPU
计算机图形里面的RenderingPass(渲染通道)是什么意思?