WebGL 与 WebGPU比对[5] - 渲染计算的过程

时间:2022-02-25 03:17:06

前两篇文章介绍了 WebGL 和 WebGPU 是如何准备顶点和数字型 Uniform 数据的(纹理留到下一篇),当渲染所需的原材料准备完成后,就要进入逻辑组装的过程。

WebGL 在这方面通过指定“WebGLProgram”,最终触发“drawArrays”或“drawElements”来启动渲染/计算。全局状态为特征的 WebGL 显然做多步骤渲染来说会麻烦一些,WebGPU 改善了渲染计算过程的接口设计,允许开发者组装更复杂的渲染、计算流程。

以所有的“draw”函数调用为分界线,调用后,就认为 CPU 端的任务已经完成,开始移交准备好的渲染、计算原材料(数据与着色器程序)至 GPU,进而运行起渲染管线,直至输出到帧缓冲/Canvas,我称 draw 这个行为是“一个通道”。

WebGPU 的出现,除了渲染的功能,还出现了通用计算功能,draw 也有了兄弟概念:dispatch(调度),下文会对比介绍。

1. WebGL

1.1. 使用 WebGLProgram 表示一个计算过程

WebGL 的整个渲染管线(虽然没有管线 API)中,能介入编程的就两处:顶点着色阶段片元着色阶段,分别使用顶点着色器和片元着色器完成渲染过程的定制。

很多书或入门教程都会说,顶点着色器和片元着色器是成对出现的,而能管理这两个着色器的上层容器对象,就叫做程序对象(接口 WebGLProgram)。

const vertexShader = gl.createShader(gl.VERTEX_SHADER) // WebGLShader
gl.shaderSource(vertexShader, vertexShaderSource)
gl.compileShader(vertexShader) const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) // WebGLShader
gl.shaderSource(fragmentShader, fragmentShaderSource)
gl.compileShader(fragmentShader) const program = gl.createProgram() // WebGLProgram
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)

其实,真正的渲染管线是有很多步骤的,顶点着色和片元着色只是比较有代表性:

  • 顶点着色器大多数时候负责取色、图形变换
  • 片元着色大多数时候负责计算并输出屏幕空间的片元颜色

既然 WebGL 只能定制这两个阶段,又因为这俩 WebGLShader 是被程序对象(WebGLProgram)管理的,所以,一个程序对象所代表的那个“管线”,通常用于执行一个通道的计算。

在复杂的 Web 三维开发中,一个通道还不足以将想要的一帧画面渲染完成,这个时候要切换着色器程序,再进行 drawArrays/drawElements,绘制下一个通道,这样组合多个通道的绘制结果,就能在一个 requestAnimationFrame 中完成想要的渲染。

1.2. WebGL 没有通道 API

上文提及,在一帧的渲染过程中,有可能需要多个通道共同完成渲染。最后一次 gl.drawXXX 的调用会使用一个绘制到目标帧缓冲的 WebGLProgram,这么说可能很抽象,不妨考虑这样一帧的渲染过程:

  • 渲染法线、漫反射信息到 FBO1 中;
  • 渲染光照信息到 FBO2 中;
  • 使用 FBO1 和 FBO2,把最后结果渲染到 Canvas 上。

每一步都需要自己的 WebGLProgram,而且每一步都要全局切换各种 Buffer、Texture、Uniform 的绑定,这样就需要一个封装对象来完成这些状态的切换,可惜的是 WebGL 并没有这种对象,大多数时候是第三方库使用类似的类完成的。

因此,如果你不用第三方库(ThreeJS等),那么你就要考虑设计自己的通道类来管理通道了。

当然,随着现代 GPU 的特性挖掘,一个通道不一定是为了绘制一张“画”,因为有通用计算技术的出现,所以我更乐意称一个通道为“一个计算集合,由一系列计算过程有逻辑地构成”。在 WebGPU 也就是下面要介绍的内容中会提及计算通道,那个就是为通用计算准备的。

2. WebGPU

2.1. 使用 Pipeline 组装管线中各个阶段

在 WebGPU 中,一个计算过程的任务就交由“管线”完成,也就是我们在各种资料里见得到的“可编程管线”的具象化 API;在 WebGPU 中,可编程管线有两类:

  • 渲染管线,GPURenderPipeline
  • 计算管线,GPUComputePipeline

管线对象在创建时,会传递一个参数对象,用不同的状态属性配置不同的管线阶段。

回顾,WebGL 是使用 gl.attachShader() 方法配置两个 WebGLShader 附着到程序对象上的。

对渲染管线来说,除了可以配置顶点着色器、片元着色器之外,还允许使用其它的状态来配置管线中的其它状态:

  • 使用 GPUPrimitiveState 对象设置 primitive 状态,配置图元的装配阶段和光栅化阶段;
  • 使用 GPUDepthStencilState 对象设置 depthStencil 状态,配置深度、模板测试以及光栅化阶段;
  • 使用 GPUMultisampleState 对象设置 multisample 状态,配置光栅化阶段中的多重采样。

具体内容需要参考 WebGPU 标准的文档。下面举个例子:

const renderPipeline = device.createRenderPipeline({
// --- 布局 ---
layout: pipelineLayout, // --- 五大状态用于配置渲染管线的各个阶段
vertex: {
module: device.createShaderModule({ /* 顶点着色器参数 */ }),
// ...
},
fragment: {
module: device.createShaderModule({ /* 片元着色器参数 */ }),
// ...
},
primitive: { /* 设置图元状态 */ },
depthStencil: { /* 设置深度模板状态 */ },
multisample: { /* 设置多重采样状态 */ }
})

然后再看一个异步创建计算管线的例子:

const computePipeline = await device.createComputePipelineAsync({
// --- 布局 ---
layout: pipelineLayout, // --- 计算管线只需配置计算状态 ---
compute: {
module: device.createShaderModule({ /* 计算着色器参数 */ }),
// ...
}
})

读者可自行比对 WebGL 中 WebGLProgram + WebGLShader 的组合。

题外话,我在我的另一文还提到过,管线还具备了 WebGL 中的 VAO 的作用,感兴趣的可以找找看看。管线的片元状态还承担了 MRT 的信息。

2.2. 使用 PassEncoder 调度管线内的行为

由上一小节可知,管线对象收集了对应管线各个阶段所需的参数。这说明了管线是一个具备行为的过程。

光有武林秘籍,没有人练,武功是体现不出来的。

所以,PassEncoder(通道编码器)就起了这么一个作用,它负责记录 GPU 计算一个通道的前后逻辑,可以对其设置管线、顶点相关的缓冲对象、资源绑定组,最后触发计算。

计算通道编码器(GPUComputePassEncoder)的触发动作是调用 dispatch() 方法,这个方法译作“调度”;渲染通道编码器(GPURenderPassEncoder)的触发动作是它的各个 “draw” 方法,即触发绘制。

这个时候就体现出面向对象编程的威力了,你可以将一个通道内的行为(即管线)、数据(即资源绑定组和各种缓冲对象)分别创建,独立于通道编码器之外,这样,面对不同的通道计算时,就可以按需选用不同的管线和数据,进而甚至可以实现管线或者资源的共用。

通道编码器这一小节没有示例代码,示例代码在下一小节。

2.3. 使用 CommandEncoder 编码多个通道

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

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

话不多说,这里直接借用 austin-eng 的例子 ShadowMapping(阴影映射)

// 创建指令编码器
const commandEncoder = device.createCommandEncoder() {
// 阴影通道的编码过程
const shadowPass = commandEncoder.beginRenderPass(shadowPassDescriptor) // 使用阴影渲染管线
shadowPass.setPipeline(shadowPipeline)
shadowPass.setBindGroup(0, sceneBindGroupForShadow)
shadowPass.setBindGroup(1, modelBindGroup)
shadowPass.setVertexBuffer(0, vertexBuffer)
shadowPass.setIndexBuffer(indexBuffer, 'uint16')
shadowPass.drawIndexed(indexCount)
shadowPass.endPass()
}
{
// 渲染通道常规操作
const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor); // 使用常规渲染管线
renderPass.setPipeline(pipeline)
renderPass.setBindGroup(0, sceneBindGroupForRender)
renderPass.setBindGroup(1, modelBindGroup)
renderPass.setVertexBuffer(0, vertexBuffer)
renderPass.setIndexBuffer(indexBuffer, 'uint16')
renderPass.drawIndexed(indexCount)
renderPass.endPass()
}
device.queue.submit([commandEncoder.finish()]);

为了完成三维物体的阴影渲染,在阴影映射有关的技术中一般会把阴影信息使用一个通道先绘制出来,然后把阴影信息传给下一个通道进而完成阴影的效果。

在上面的代码中,就使用了两个 RenderPassEncoder 进行阴影的先后步骤渲染。它们在 draw 之前就可以设置不同的渲染材料,包括代表行为的管线,以及代表资源的绑定组、各类缓冲等。

2.4. PassEncoder 和 Pipeline 的关系

WebGPU 中的 Pipeline 被划分成了多个阶段,其中有三个阶段是可编程的,其它的阶段是可配置的。管线由于在三个可编程阶段拥有了着色器模块,所以管线对象更多的是扮演一个“执行者”,它代表的是某个单一计算过程的全部行为,而且是发生在 GPU 上。

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

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

3. 总结

多个时间很短的画面,就构成了动态的渲染结果。这每一个画面,叫做帧。而每一帧,在实时渲染技术中用多个“通道”,通过图形学或实时渲染知识有逻辑地组装在一起共同完成。

通道由行为和数据构成。

行为由着色器程序实现,也就是“你想在这一个通道做什么计算”,在 WebGL 中使用 WebGLProgram 附着两个着色器,而在 WebGPU 中使用 GPURenderPipeline/GPUComputePipeline 装配管线的各个阶段状态。

而数据,则希望读者去看我写的 Uniform 和 顶点缓冲文章了。

每一帧,在 WebGL 代码中,其实就是不断切换 WebGLProgram,绑定不同数据,最后发出 draw 动作完成;在 WebGPU 代码中,就是创建指令编码器、开始通道编码、结束通道编码、结束指令编码,最后提交指令缓冲完成。

WebGPU 把 WebGLProgramWebGLShader 的行为职能抽离到 GPU[Render/Compute]PipelineGPUShaderModule 中去了,这样就可以在帧运算中独立出行为对象。