【WebGPU实时光追美少女】WebGPU基础与简单渲染器

少女dtysky

世界Skill

时刻2021.09.13

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

WebGPU作为Web平台的下一代图形API标准(当然不仅仅是图形),提供了类似于DX12、Metal、Vulkan对GPU深入控制的能力,在经过了数个版本的迭代后,其在当下时点(2021年9月)终于基本稳定,可见WebGPU标准,其使用的着色器语言wgsl也基本稳定,详见WebGPU Shading Language

WTF,就在我写这篇文章这一周(2021年9月第二周)标准又有更新,写完发现原来的代码跑不起来了......待我修一修。

在本文中,笔者将论述如何用WebGPU来实现一个简单的渲染器,并且目前只支持静态物体渲染,没有动画物理等等组件,这个渲染器的架构脱胎于笔者过去研究和实现过的几个渲染引擎,以求尽量简单,而非大而全,所以实现中可能有粗糙之处,但作为如何使用WebGPU的例子而言是绝对足够的。

概述

在设计一个渲染引擎,或者说渲染器的时候,我们最关心的问题毫无疑问是“渲染”,无论如何设计,首要考虑的都是“渲染是如何被驱动的”。一般来讲,目前通用的渲染驱动方案大致都是:

一帧开始 -> 组织提取渲染数据 -> 提交数据更改到GPU -> 设置渲染目标并清屏 -> 提交绘制指令 -> 一帧结束

对应于比较详细的工程概念,帧的调度可以视作是Web中的requestAnimationFrame,以每秒30、60或者120帧驱动;组织提取渲染数据可以分为两部分,一部分是渲染所需资源的管理,另一部分是“剔除”,即通过某种手段在整个场景中筛选出真正需要渲染的物体;而提交必要数据到GPU,则是指渲染过程中可能有一些数据会被更新,比如模型数据、Uniform等等,这时候就需要把这些数据更新上传到GPU;然后是设置渲染目标为画布或者主屏,并进行颜色、深度和模板缓冲区的清除;最后就是提交绘制指令,即进行实际的绘制。

为了实现以上整个驱动的过程,我们首先需要有一个用于渲染的环境,这个环境用于管理Canvas、Context以及一些全局的变量;其次是需要一些资源,这些资源管理着所有渲染所需要的数据;之后还需要一个场景结构和容器,来将这些渲染数据组装管理起来,方便剔除和绘制,与此同时还需要相机来提供观察场景的视角,需要灯光来让场景更加真实;最终还需要一种数据格式用于存储模型数据,还需要对应的加载器来它转换为我们需要的资源数据。

以下内容需要参考文首提到的项目源代码同步观看。

HObject

在真正开始讲述渲染部分的原理之前,要大概说一下整个工程的基本架构。这里我采用的是一个比较简单的继承式OO,对于工程中的每个类,都拥有统一的基类,并遵循统一的模式。这个基类叫做HObject(至于为何是HObject,那当然是因为青年H的原因咯):

export default class HObject {
  public static CLASS_NAME: string = 'HObject';
  public isHObject: boolean = true;
  public name: string;
  get id(): number;
  get hash(): string;
}

每个继承自HObject的类都需要提供static CLASS_NAMEisXXXX,并会通过CLASS_NAME自动生成idhash,还有可选的name方便Debug。

而有了isXXXX这种标示,还可以避免使用instanceof而是使用谓词判断类型,比如:

export default class RenderTexture extends HObject {
  public static IS(value: any): value is RenderTexture {
    return !!value.isRenderTexture;
  }

  public static CLASS_NAME: string = 'RenderTexture';
  public isRenderTexture: boolean = true;
}

便可以通过RenderTexture.IS(obj)来判断是否为obj是否为RenderTexture,无论是运行时还是编译期。

渲染环境

渲染环境在引擎中的实现是core/renderEnv,其实现比较简单,主要是通过Canvas初始化整个WebGPU的Context,同时管理全局Uniform和全局宏,Uniform和宏后面资源的死后会说到,这里先主要关注最重要的init方法实现:

public async init(canvas: HTMLCanvasElement) {
  if (!navigator.gpu) {
    throw new Error('WebGPU is not supported!');
  }

  const adapter = await navigator.gpu.requestAdapter();

  if (!adapter) {
    throw new Error('Require adapter failed!');
  }

  this._device = await adapter.requestDevice();

  if (!this._device) {
    throw new Error('Require device failed!');
  }

  this._canvas = canvas;
  this._ctx = (canvas.getContext('webgpu') as any || canvas.getContext('gpupresent')) as GPUCanvasContext;

  this._ctx.configure({
    device: this._device,
    format: this._swapChainFormat,
  });
}

这里可以看出WebGPU的Context的初始化方式,我们首先要检查navigator上是否有gpu实例,以及gpu.requestAdapter是否能够返回值,然后再看是否能够用adapter.requestDevice分配到设备,这个设备可以认为是图形硬件的一个抽象,其可以把实现和底层硬件隔离,有了这个中间层,便可以做各种兼容和适配。

有了device,还需要一个Context,这可以通过传入的canvas使用getContext('webgpu')获取,获取了Context后,便可以使用ctx.configure来配置SwapChain,注意这里使用到的format一般默认为'bgra8unorm'

场景和容器

根据一开始的论述,读者可能认为接下来就要讲资源的实现,接着才是场景和容器,但这种论述其实是反直觉的。对于了解一个渲染引擎的设计而言,自顶向下才是最优解,下面我会先从场景的组织讲起,然后是渲染的驱动,再到渲染和计算单元,最后才是各个资源的细节。

Node

虽然是自顶向下,但Node,即节点还是要放在最开始说的。由于本文不是3D渲染的科普贴,就不再去说MVP矩阵这种入门知识了。简单来讲,Node就是描述3D场景中某个物体位置信息的基础,其拥有平移pos、旋转quat、缩放scale三个属性,并拥有父级parent和子级children来进行级联,级联后即为树状结构的节点树,这也就是场景的基础:

节点树将会在每一帧驱动的时候通过深度优先搜索dfs来从根节点遍历每个节点,调用updateMatrix进行自顶向下的世界矩阵worldMat更新。成熟的渲染引擎都会在节点树刷新这一步做很多优化,比如脏检查,但本项目没有考虑这些,每帧全量刷新。

节点是非常重要的单元,其也是相机、灯光、渲染单元的基类。

Scene

有了Node,场景Scene就自然而然构成了。所有的渲染引擎基本都有场景的概念,只不过功能可能不同。而本引擎为了方便,直接把渲染驱动的能力给了它。所以本引擎的Scene主要有两个功能——管理场景,以及管理绘制流程。

对于场景的管理主要在于节点树,节点树管理的实现很简单,场景内部持有了一个rootNode,直接将自己新建的节点作为rootNode的子级即可将其加入场景。但场景管理不限于此,在每帧从rootNode自顶向下刷新整棵节点树的时候,我们还可以收集一些渲染必要的信息,比如当帧可供剔除的渲染单元列表meshes当帧需要的灯光列表lights

this._rootNode.updateSubTree(node => {
  if (Mesh.IS(node)) {
    this._meshes.push(node);
  } else if (Light.IS(node)) {
    this._lights.push(node);
  }
});

关于这二者的时候马上就会讲到。

那么现在有了场景,我们如何驱动渲染呢,渲染当然需要一些资源,但也需要流程将它们组织起来。在文章的一开始已经提到过一个渲染流程应该是如何的,那么其实直接将它们翻译成接口给Scene即可:

1.startFrame(deltaTime: number)

一帧开始,执行节点树刷新、收集信息,并设置全局的Uniform,比如u_lightInfosu_gameTime等。然后是最重要的:创建GPUCommandEncoder

this._command = renderEnv.device.createCommandEncoder();

GPUCommandEncoder顾名思义,是WebGPU中用于指令编码的管理器。一般每一帧都会重新创建一个,并在一帧结束时完成它的生命周期。这也是这一代图形API的标准设计,但其实类似思想早就存在于一些成熟的游戏引擎中,比如UE。

2.setRenderTarget(target: RenderTexture | null)

这个接口用于设置渲染目标,如果是RenderTexture资源,那么接下来执行的所有绘制指令都会会知道这个RT上,如果是null则会会知道主屏的缓冲区。

3.cullCamera(camera: Camera): Mesh[]

使用某个相机的camera.cull进行剔除,返回一个Mesh列表,并对列表按照z进行排序。

注意,在标准流程中,透明物体和非透明物体需要分类反着排序,透明物体由远及近画,非透明物体由近及远。并且往往还会加上renderQueue来加上一个可控的排序维度。

4.renderCamera(camera: Camera, meshes: Mesh[], clear: boolean = true)

使用某个相机进行渲染一批Mesh,直接代理到camera.render方法。

5.renderImages(meshes: ImageMesh[], clear: boolean = true)

渲染一批特殊的渲染单元ImageMesh,专用于处理图像效果。

6.computeUnits(units: ComputeUnit[])

对一批计算单元进行计算,专用于处理计算着色器。

7.endFrame()

结束一帧的绘制,主要是将当帧的commandEncoder送入GPUQueue并提交:

renderEnv.device.queue.submit([this._command.finish()]);

如此,this._command便结束了其生命周期,不能再被使用。

Camera

相机是观察整个场景的眼睛,其继承自Node,对渲染的主要贡献有以下几点:

1.剔除:

剔除分为很多种,相机的剔除是物体级别的可见性剔除,其利用相机位置和投影参数构造的视锥体和物体自身求交(出于性能考虑,往往使用包围盒或者包围球)来判定物体是否在可见范围内,并算出相机到物体的距离,来作为后续排序的依据。剔除的实现为camera.cull,但由于其不是教程重点,并且对于目前场景也没什么意义,所以没有实现。不过实现也很简单,读者可以自行查阅相关资料。

2.提供VP矩阵:

相机还需要为渲染过程提供VP矩阵,即视图矩阵ViewMatrix和投影矩阵ProjectionMatrix。前者和相机结点的WorldMatrix相关,后者则和相机的投影模式(透视或者正交)以及参数有关。这些参数将在updateMatrix方法更新了WorldMatrix后计算,计算后会立即设置到全局Uniform中。

当然理论上这种做法在场景中存在多个相机的时候会出问题,实际上应该有个专门的preRender之类的流程来处理,但为了方便这里就这么做吧。

3.天空盒:

除了计算VP矩阵外,在updateMatrix中还会计算一个skyVP,这个是用于天空盒渲染的。天空盒是一种特殊的Mesh,其不会随着相机的移动变换相对位置(只会相对旋转)。本引擎对天空盒的实现很简单,固定使用内置的几何体Geometry,为相机增加了skyboxMat这个材质属性来定制绘制过程,并提供了内置的天空盒材质。

4.渲染:

相机最重要的功能就是进行渲染实际的渲染驱动了,其render(cmd: GPUCommandEncoder, rt: RenderTexture, meshes: Mesh[], clear: boolean)方法就用于此。这个方法的实现并不复杂,直接贴代码:

const [r, g, b, a] = this.clearColor;
const {x, y, w, h} = this.viewport;
const {width, height, colorViews, depthStencilView} = rt;

const renderPassDescriptor: GPURenderPassDescriptor = {
  colorAttachments: colorViews.map(view => ({
    view,
    loadValue: clear ? { r, g, b, a } : 'load' as GPULoadOp,
    storeOp: this.colorOp
  })),
  depthStencilAttachment: depthStencilView && {
    view: depthStencilView,
    depthLoadValue: this.clearDepth,
    stencilLoadValue: this.clearStencil,
    depthStoreOp: this.depthOp,
    stencilStoreOp: this.stencilOp
  }
};

const pass = cmd.beginRenderPass(renderPassDescriptor);
pass.setViewport(x * width, y * height, w * width, h * height, 0, 1);
pass.setBindGroup(0, renderEnv.bindingGroup);

for (const mesh of meshes) {
  mesh.render(pass, rt);
}

if (this.drawSkybox && this._skyboxMesh) {
  this._skyboxMesh.render(pass, rt);
}

pass.endPass();

其中比较重要的是RenderPass的概念,在WebGPU中这可以认为是一次渲染的流程,创建其使用的GPURenderPassDescriptor对象是这个流程要绘制的目标和操作的描述符。可以看到其中主要是设置了颜色和深度/模板缓冲的view,关于这个将会在后面的RenderTexture中详细说到,这里就认为其就是画布即可;而loadValuestoreOp是决定如何清屏的,在这里这些参数都可以交由开发者决定,一般来讲是颜色值store操作。

在创建了一个RenderPass后,就可以设置viewportbindGroup,前者和OpenGL中的视口概念等同,后者可以认为就是UniformBlock,这个在后面会详细讲到,这里设置的是第0个UniformBlock,即全局UB。设置好后便是调用每个Mesh的render方法逐个渲染,最终渲染天空盒。渲染完毕后调用pass.endPass来结束这个RenderPass。

事实上关于ImageMeshComputeUnit的绘制处理也类似,后面会详细讲到。

Mesh

网格Mesh是用于渲染的基本单元,其构造为new Mesh(geometry, material)。其将存储着图元数据的几何体Geometry和决定了渲染方式的材质Material对应组装起来,加之每个对象各有的UniformBlock,在相机绘制的过程中,被调用进行渲染:

public render(pass: GPURenderPassEncoder, rt: RenderTexture) {
  const {_geometry, _material} = this;

  if (_material.version !== this._matVersion || !this._pipelines[rt.pipelineHash]) {
    this._createPipeline(rt);
    this._matVersion = _material.version;
  }

  this.setUniform('u_world', this._worldMat);
  this._bindingGroup = this._ubTemplate.getBindingGroup(this._uniformBlock, this._bindingGroup);

  _geometry.vertexes.forEach((vertex, index) => {
    pass.setVertexBuffer(index, vertex);
  });
  pass.setIndexBuffer(_geometry.indexes, _geometry.indexFormat);
  pass.setBindGroup(1, _material.bindingGroup);
  pass.setBindGroup(2, this._bindingGroup);
  pass.setPipeline(this._pipelines[rt.pipelineHash]);
  pass.drawIndexed(_geometry.count, 1, 0, 0, 0);
}

渲染的流程并不复杂,先不管下面章节会说到的资源的具体实现,从宏观的角度来看,这里主要的工作是设置顶点缓冲vertexBuffer,设置索引缓冲indexBuffer,分别设置物体(index=1)和材质(index=2)级别的Uniform,最后设置一个叫做pipeline的参数,全部设置完后调用drawIndexed来绘制这一批图元。

当然,这也支持GPU实例化,但不在本引擎的讨论范围内。

ImageMesh

图像渲染单元ImageMesh一般用于图像处理,其构造为new ImageMesh(material)。其内置了绘制一张图像的图元数据,直接用传入的Material进行绘制。所以其绘制流程也相对简单,不需要MVP矩阵,所以并不需要相机,也不需要结点,不需要深度缓冲,其他和camera.render几乎一致。

还要注意的是ImageMesh最终的绘制并不是用drawIndexed方法而是用pass.draw(6, 1, 0, 0)方法,因为其并不需要顶点数据,这个在着色器章节会详细论述。

ComputeUnit

WebGPU相对于WebGL最重大的进化之一就是支持计算着色器,计算单元ComputeUnit就用于支持它。和渲染单元不同,其专门用于使用Compute Shader进行计算,相比于渲染单元,其不需要顶点、渲染状态等等数据,所有数据都将视为用于计算的缓冲,所以其构造为new ComputeUnit(effect, groups, values, marcos),效果effect以及valuesmarcos参数也是构造Material的参数,所以ComputeUnit合并了一部分Material的功能。与此同时还加上了groups(类型为{x: number, y?: number, z?: number}),这里先不谈,让我们看看整个一个单元列表的计算是如何实现的:

// 在Scene.ts中
public computeUnits(units: ComputeUnit[]) {
  const pass = this._command.beginComputePass();
  pass.setBindGroup(0, renderEnv.bindingGroup);

  for (const unit of units) {
    unit.compute(pass);
  }

  pass.endPass();
}

// 在ComputeUnit.ts中
public compute(pass: GPUComputePassEncoder) {
  const {_material, _groups} = this;

  if (_material.version !== this._matVersion) {
    this._createPipeline();
    this._matVersion = _material.version;
  }

  pass.setPipeline(this._pipeline);
  pass.setBindGroup(1, _material.bindingGroup);
  pass.dispatch(_groups.x, _groups.y, _groups.z);
}

可以看到,和渲染流程的RenderPass不同,这里创建了一个ComputePass用于这批计算,并且同样需要设置BindingGroup0(可以认为是全局UniformBlock)。之后针对每个计算单元,都设置了pipeline、单元级别的UniformBlock,最后执行dispatch,后面的参数涉及到线程组的概念,和计算着色器也有关,这个后面会讲到。在计算完所有单元后,执行endPass来结束这个流程。

Light

除了相机和渲染单元之外,对于一个最简单的渲染器,灯光也是不可或缺的。本引擎的灯光设计比较简单,全部集中在Light这一个类的实现中。灯光主要属性有类型type、颜色color以及分类型的各种参数,类型一般有:

export enum ELightType {
  INVALID = 0,
  Area,
  Directional,
  Point,
  Spot
}

每种类型的光源都有不同参数,对于本引擎针对的路径追踪而言,比较重要的是面光源的参数:模式(矩形Rect或圆盘Disc)和尺寸宽高或半径。和相机一样,灯光本身也继承自结点,所以在更新矩阵的时候也要计算自己的灯光矩阵LightMatrix,并更新需要送往全局UniformBlock的信息:

public updateMatrix() {
  super.updateMatrix();

  this._ubInfo.set(this._color, 4);
  this._ubInfo.set(this._worldMat, 8);
  this._ubInfo.set(mat4.invert(new Float32Array(16), this._worldMat), 24);
}

在实际渲染中,renderEnv.startFrame中全局UB的u_lightInfos就是从这个ubInfo中获取信息更新的,目前一个物体一次绘制最多支持四个灯光。

资源

有了宏观的渲染管线和容器,就只剩具体的资源来填充它们了。在接下来的章节,我将论述我们熟悉的一些渲染资源抽象如何在WebGPU中实现。

Shader

首先必须要说的是着色器Shader,因为后续的所有资源最终都会在Shader中被应用,并且它们的部分设计和Shader也有着十分紧密的联系。着色器是什么我不再赘述,相信写过OpenGL或WebGL的读者都写过顶点着色器片段着色器,在WebGPU中它们同样存在,而比起WebGL,WebGPU还实现了计算着色器

WebGPU使用的着色器语言在一次又一次的变更后,终于定论为WGSL。其语法和glslhlsl都有较大差异,以本人的观点看比较像融合了metal和rust的很多部分,这一点也体现在编译阶段——WGSL的类型系统十分严格。

本文并非是一个WGPU或者WGSL的详细教程(否则至少得写一本书),下面就分别以三个/三种着色器的简单示例,来论述一下本文需要用到的一些WGSL的基本知识。

顶点着色器

//model.vert.wgsl
struct VertexOutput {
  [[builtin(position)]] Position: vec4<f32>;
  [[location(0)]] texcoord_0: vec2<f32>;
  [[location(1)]] normal: vec3<f32>;
  [[location(2)]] tangent: vec4<f32>;
  [[location(3)]] color_0: vec3<f32>;
  [[location(4)]] texcoord_1: vec2<f32>;
};

[[stage(vertex)]]
fn main(attrs: Attrs) -> VertexOutput {
  var output: VertexOutput;

  output.Position = global.u_vp * mesh.u_world * vec4<f32>(attrs.position, 1.);

  #if defined(USE_TEXCOORD_0)
    output.texcoord_0 = attrs.texcoord_0;
  #endif

  #if defined(USE_NORMAL)
    output.normal = attrs.normal;
  #endif

  #if defined(USE_TANGENT)
    output.tangent = attrs.tangent;
  #endif

  #if defined(USE_COLOR_0)
    output.color_0 = attrs.color_0;
  #endif

  #if defined(USE_TEXCOORD_1)
    output.texcoord_1 = attrs.texcoord_1;
  #endif

  return output;
}

这是一个典型的顶点着色器,首先从其入口main看起,[[stage(vertex)]]表明这是一个顶点着色器的入口,attrs: Attrs表明其输入是一个类型为Attrs的struct,-> VertexOutput则说明顶点着色器要传输给下一步插值的数据类型为VertexOutputAttrs在这里没有写,这是因为其相对动态,我将其生成集成到了整个渲染过程的设计中,这个在下面的Geometry章节会讲到。而VertexOutput的定义就在代码顶部,其是一个struct,里面的内容都是形如[[位置]] 名字: 类型的形式,唯一不同的是[[builtin(position)]] Position,因为位置信息是重心坐标插值的重要依据,所以需要特殊指明。

接下来main的函数体中,主要实现了顶点数据的计算和输出,其中包括大家熟悉的MVP变换output.Position = global.u_vp * mesh.u_world * vec4<f32>(attrs.position, 1.);,这里要注意var output: VertexOutput的定义中使用的是var,在WGSL中使用varlet区分变量是动态还是静态,静态变量不可变并且必须初始化,动态的可变。在结尾return output表明返回计算结果到下一个阶段。

注意这里出现了#if defined(USE_TEXCOORD_0)这样的写法,而我们也在前面提到过,但实际上WGSL目前并没有实现宏、或者在这里的功能层面为预处理器,详见Issue[wgsl Consider a preprocessor。这里其实是我自己通过正则实现的一个简易预处理器,除此之外我还是用webpack的loader机制实现了一个简单的require方法,来完成shader文件的分隔复用,这里不再赘述。

这里还有必要单独提一下ImageMesh使用的顶点着色器,在上面的章节中提到了其绘制并不依赖于顶点数据,其实是利用WGSL的模块作用域变量(MODULE SCOPE VARIABLE)实现的:

struct VertexOutput {
  [[builtin(position)]] position: vec4<f32>;
  [[location(0)]] uv: vec2<f32>;
};

var<private> pos: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
  vec2<f32>(-1.0, -1.0),
  vec2<f32>(1.0, -1.0),
  vec2<f32>(-1.0, 1.0),
  vec2<f32>(-1.0, 1.0),
  vec2<f32>(1.0, -1.0),
  vec2<f32>(1.0, 1.0)
);
var<private> uv: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
  vec2<f32>(0.0, 1.0),
  vec2<f32>(1.0, 1.0),
  vec2<f32>(0.0, 0.0),
  vec2<f32>(0.0, 0.0),
  vec2<f32>(1.0, 1.0),
  vec2<f32>(1.0, 0.0)
);

[[stage(vertex)]]
fn main([[builtin(vertex_index)]] VertexIndex : u32) -> VertexOutput {
  var output: VertexOutput;

  output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
  output.uv = uv[VertexIndex];

  #if defined(FLIP)
    output.uv.y = 1. - output.uv.y;
  #endif

  return output;
}

这里通过var<private>定义了两个模块作用域的变量数组posuv,可以被着色器函数索引内容,而此处函数入参[[builtin(vertex_index)]] VertexIndex : u32是当前正在绘制的顶点索引,用这个索引和两个数组即可完成输出。

模块作用域变量不止有private一个域,还有workgroup等,这里不再赘述。

片段着色器

struct VertexOutput {
  [[builtin(position)]] Position: vec4<f32>;
  [[location(0)]] texcoord_0: vec2<f32>;
  [[location(1)]] normal: vec3<f32>;
  [[location(2)]] tangent: vec4<f32>;
  [[location(3)]] color_0: vec3<f32>;
  [[location(4)]] texcoord_1: vec2<f32>;
};

[[stage(fragment)]]
fn main(vo: VertexOutput) -> [[location(0)]] vec4<f32> {
  return material.u_baseColorFactor * textureSample(u_baseColorTexture, u_sampler, vo.texcoord_0);
}

main函数上方的[[stage(fragment)]]表明这是一个片段着色器。这个着色器很简答也很典型,其将顶点着色器的输出VertexOutput重心插值后的结果作为输入,最终输出一个 [[location(0)]] vec4<f32>的结果,这个结果就是最终这个片元颜色。

有了结构,再看看函数体中做了什么。这里从一个叫material的变量中取出了u_baseColorFactor,并用textureSample方法和UVvo.texcoord_0,对名为u_baseColorTexture的贴图进行了采样,并且还用上了一个叫做u_sampler的变量。纹理采样我们熟悉,但这个materiau_baseColorTextureu_sampler又是什么呢?这就要涉及到WGSL的另一点:BindingGroup了

BindingGroup

这不是本文第一次出现BindingGroup这个词,在前面我们已经提到过无数次,并将其和UniformBlock相提并论,并在绘制流程中调用了pass.setBindGroup方法。那么这东西到底是什么呢?回想一下我们在OpenGL或者WebGL中如何在渲染过程中更新uniform的信息,一般是调用类似gl.uniform4vf这种借口,来将从shader反射信息拿到的location作为key,把值更新上去,如果是纹理则还需要线绑定纹理等等操作。就算是加上了缓存,每帧用于更新uniform的时间也是可观的,所以新一代图形API为了解决这个问题,就给出了BindingGroup或者类似的方案。

BindingGroup本质上可以看做是一个uniform的集合,和其他资源一样,在WebGPU最后创建一个BindingGroup也需要一个描述符:

device.createBindGroup(descriptor: {layout: GPUBindGroupLayout; entries: Iterable<GPUBindGroupEntry>}): GPUBindGroup;

描述符需要一个layout和一个entries,前者给出了结构,后者给出了实际的。以一个简单的构造为例:

const visibility = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
const bindingGroup = renderEnv.device.createBindGroup({
  layout: device.createBindGroupLayout({entries: [
    {
      binding: 0, visibility,
      buffer: {type: 'uniform' as GPUBufferBindingType},
    },
    {
      binding: 1, visibility,
      texture: {
        sampleType: 'rgba8unorm',
        viewDimension: '2d'
      },
    },
    {
      binding: 2, visibility,
      sampler: {type: 'filtering'}
    },
    {
      binding: 3,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {type: 'storage'}
    },
  ]}),
  entries: [
    {
      binding: 0,
      resource: {buffer: gpuBuffer}
    },
    {
      binding: 1,
      resource: textureView
    },
    {
      binding: 2,
      resource: sampler
    },
    {
      binding: 3,
      resource: {buffer}
    }
  ]
});

这里构建的BindingGroup涵盖了几种值,分别是Uniform、Texture、Sampler和Storage,可见每种方式的描述定义都不一样,但它们都有共同之处——需要制定binding(可以认为是这个Group下的子地址)和visibility(在哪种着色器可见)。而这里构建的BindingGroup最终会设置到Pass中指定地址给Shader使用,在Shader中我们同样需要定义它们的结构:

[[block]] struct UniformsMaterial {
 [[align(16)]] u_baseColorFactor: vec4<f32>;
};
[[group(2), binding(0)]] var<uniform> material: UniformsMaterial;
[[group(2), binding(1)]] var u_baseColorTextures: texture_2d<f32>;
[[group(2), binding(2)]] var u_sampler: sampler;

struct Debug {
  origin: vec4<f32>;
  dir: vec4<f32>;
};
[[block]] struct DebugInfo {
  rays: array<DebugRay>;
};[[group(2), binding(3)]] var<storage, read_write> u_debugInfo: DebugInfo;

这里将一个BindingGroup分为了几个部分,所有的向量、矩阵等uniform类型的数据被打包在UniformsMaterial这个struct中,texture、sampler和storage类型的数据则需要分离出来,并且都有各自的定义形式,这个就是为何我们上面要通过materia.u_baseColorFactor去取uniform数据,而直接用u_texture来取纹理数据。

还需要注意的是各种前缀,group(2)表明这是绑定的第2个BindingGroup,这和前面说过的全局、单元、材质的UniformBlock是绑定在不同级别的一致。事实上前面也给出过其他两个级别的例子,在顶点着色器中使用到的global.u_vp * mesh.u_world就是在全局0和单元1级别的。而后面的binding(x)则需要和ts中的声明一致。同时还要注意的是uniform类型的struct的align(16),这是为了强制每一项16字节对齐,同时可能使用的还是stride这样的修饰。

由于BindingGroup的创建和Shader描述耦合十分高,其中还有字节对齐之类的问题,为了方便使用、减少冗余代码,在引擎中我将BindingGroup的构造、Shader对应的struct的定义生成、Uniform管理都深度融合了,将在下面的UBTemplate论述。

计算着色器

计算着色器和前两种都不同,其不属于渲染管线的一部分,利用的是GPU的通用计算能力,所以也没有顶点输入、像素输出之类的要求,下面是一个比较糙的对图像卷积滤波的例子:

let c_radius: i32 = ${RADIUS};
let c_windowSize: i32 = ${WINDOW_SIZE};

[[stage(compute), workgroup_size(c_windowSize, c_windowSize, 1)]]
fn main(
  [[builtin(workgroup_id)]] workGroupID : vec3<u32>,
  [[builtin(local_invocation_id)]] localInvocationID : vec3<u32>
) {
  let size: vec2<i32> = textureDimensions(u_input, 0);
  let windowSize: vec2<i32> = vec2<i32>(c_windowSize, c_windowSize);
  let groupOffset: vec2<i32> = vec2<i32>(workGroupID.xy) * windowSize;
  let baseIndex: vec2<i32> = groupOffset + vec2<i32>(localInvocationID.xy);
  let baseUV: vec2<f32> = vec2<f32>(baseIndex) / vec2<f32>(size);

  var weightsSum: f32 = 0.;
  var res: vec4<f32> = vec4<f32>(0., 0., 0., 1.);
  for (var r: i32 = -c_radius; r <= c_radius; r = r + 1) {
    for (var c: i32 = -c_radius; c <= c_radius; c = c + 1) {
      let iuv: vec2<i32> = baseIndex + vec2<i32>(r, c);

      if (any(iuv < vec2<i32>(0)) || any(iuv >= size)) {
        continue;
      }

      let weightIndex: i32 = (r + c_radius) * c_windowSize + (c + c_radius);
      let weight: f32 = material.u_kernel[weightIndex / 4][weightIndex % 4];
      weightsSum = weightsSum + weight;
      res = res + weight * textureLoad(u_input, iuv, 0);
    }
  }
  res = res / f32(weightsSum);

  textureStore(u_output, baseIndex, res);
}

main函数的修饰stage(compute)表明这是一个计算着色器,除此之外后面还有workgroup_size(c_windowSize, c_windowSize, 1),读者应该注意到了这里有三个维度的参数,还记得我们前面在论述ComputeUint时提到的groupSize以及pass.dispatch(x, y, z)的三个参数吗?它们毫无疑问是有关联的,这就是线程组的概念。

这里不再赘述卷积滤波的原理,只需要知道它每次要对一个N X N窗口内的像素做处理,所以这里定义了一个窗口大小windowSize、也就定义了一个二维的(第三个维度为1)、大小为size = windowSize X windowSize的线程组,这表明一个线程组有这么大数量的线程,如果我们要处理纹理数据、同时每个线程处理一个像素,那这么一个线程组可以处理size个数量的像素,那么如果我们要处理一张width x height大小的图片,就应当将groupSize设置(width / windowSize, height / windowSize, 1)

不同平台上允许的最大线程数量有限制,但一般都应当是16 x 16以上。

除此之外,可以看到计算着色器确实没有顶点像素之类的概念,但它有输入workgroup_idlocal_invocation_id,这其实就是线程组的偏移线程组内线程的偏移,通过它们我们就可以得到真正的线程偏移,也可以作为纹理采样的依据。能够采样纹理,就可以进行计算,最后将计算的结果通过textureStore写回输出纹理即可,输出纹理是一个write类型的storageTexture

着色器中的${WINDOW_SIZE}这种也属于我自己实现的简陋的宏的一部分。

Geometry

几何体Geometry本质上就是顶点数据和索引数据的集合。在OpenGL中,我们往往会自己组织一个结构来描述顶点数据的存储构造,在WebGPU中,API标准即将这个结构定死了,其为GPUVertexBufferLayout[]。这个结构的描述加上vertexBufferindexBuffer即构成了整个渲染单元的几何信息:

constructor(
  protected _vertexes: {
    layout: {
      attributes: (GPUVertexAttribute & {name: string})[],
      arrayStride: number
    },
    data: TTypedArray,
    usage?: number
  }[],
  protected _indexData: Uint16Array | Uint32Array,
  public count: number,
  protected _boundingBox?: IBoundingBox
) {
  this._iBuffer = createGPUBuffer(_indexData, GPUBufferUsage.INDEX);
    this._vBuffers = new Array(_vertexes.length);
    this._vLayouts = new Array(_vertexes.length);
    this._indexFormat = _indexData instanceof Uint16Array ? 'uint16' : 'uint32';
    this._vInfo = {};
    this._marcos = {};
    this._attributesDef = 'struct Attrs {\n';

    _vertexes.forEach(({layout, data, usage}, index) => {
      const vBuffer = createGPUBuffer(data, GPUBufferUsage.VERTEX | (usage | 0));

      layout.attributes.forEach((attr) => {
        this._marcos[`USE_${attr.name.toUpperCase()}`] = true;
        this._attributesDef += `  [[location(${attr.shaderLocation})]] ${attr.name}: ${this._convertFormat(attr.format)};\n`;
        this._vInfo[attr.name.toLowerCase()] = {
          data, index,
          offset: attr.offset / 4, stride: layout.arrayStride / 4, length: this._getLength(attr.format)
        };
      });

      this._vBuffers[index] = vBuffer;
      this._vLayouts[index] = layout;

      this._vertexCount = data.byteLength / layout.arrayStride;
    });

    this._attributesDef += '};\n\n';
}

这是Geometry的构造方法,可以看到其主要是将多个vertexBuffer以及对应的layout、一个indexBuffer、顶点个数count处理,最终生成WebGPU需要的VertexLayoutGPUBuffer,同时还生成了其他必要的数据。VertexLayout这是描述无须赘述,这里主要需要注意GPUBuffer和所谓其他数据的生成。

GPUBuffer

首先是GPUBuffer,其在WebGPU中很常见,除了纹理之外所有在CPU和GPU传输的数据都是GPUBuffer。在历经数次迭代后,我们可以用一段简短的代码来创建它:

export function createGPUBuffer(array: TTypedArray, usage: GPUBufferUsageFlags) {
  const size = array.byteLength + (4 - array.byteLength % 4);
  const buffer = renderEnv.device.createBuffer({
    size,
    usage: usage | GPUBufferUsage.COPY_DST,
    mappedAtCreation: true
  });

  const view = new (array.constructor as {new(buffer: ArrayBuffer): TTypedArray})(buffer.getMappedRange(0, size));
  view.set(array, 0);

  buffer.unmap();

  return buffer;
}

这段代码通过传入的TypedArray创建一个同尺寸(但需要字节对齐)的GPUBuffer,并将其数据的值在初始化的时候拷贝给它。

宏和Shader

在创建Geometry的过程中还会生成别的重要数据:

  1. 一张宏的表_marcos,所有用到的顶点属性都会以USE_XXX的形式存在,然后作用在Shader中,比如上面顶点着色器示例那样。
  2. 顶点相关的Shader类型定义数据_attributesDef,自动组装出需要的拼接到Shader头部的字符串。

Texture

前面提到了BindingGroup的各种类型,其中有一大类就是纹理Texture,而纹理在引擎实现中又可以分为2D纹理和Cube纹理。

2D纹理

纹理在WebGPU中的创建很简单:

this._gpuTexture = renderEnv.device.createTexture({
  label: this.hash,
  size: {width: this._width, height: this._height, depthOrArrayLayers: this._arrayCount},
  format: _format || 'rgba8unorm',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
});

if (isTextureSourceArray(_src)) {
  _src.forEach((src, index) => this._load(src, index));
  this._gpuTextureView = this._gpuTexture.createView({dimension: '2d-array', arrayLayerCount: this._arrayCount});
} else {
  this._load(_src);
  this._gpuTextureView = this._gpuTexture.createView();
}

首先我们需要用device.createTexture来创建一个纹理,这里需要注意的参数是size中的depthOrArrayLayers,其在类型为2d-array的时候是数组的数量。创建了纹理后用_load方法来加载并上传纹理数据,最终调用createView方法创建view并缓存。而对于_load的实现,则根据来源是Buffer或图像有不同的做法:

_loadImg(img: ImageBitmap, layer: number) {
  renderEnv.device.queue.copyExternalImageToTexture(
    {source: img},
    {texture: this._gpuTexture, origin: this._isArray ? {x: 0, y: 0, z: layer} : undefined},
    {width: this._width, height: this._height, depthOrArrayLayers: 1}
  );
}

_loadBuffer(buffer: ArrayBuffer, layer: number) {
  renderEnv.device.queue.writeTexture(
    {texture: this._gpuTexture, origin: this._isArray ? {x: 0, y: 0, z: layer} : undefined},
    buffer as ArrayBuffer,
    {bytesPerRow: this._width * 4},
    {width: this._width, height: this._height, depthOrArrayLayers: 1}
  );
}

通过图像提供纹理数据,主要使用device.queue.copyExternalImageToTexture方法,但要求传入的是一个ImageBitMap,这个可以使用以下代码生成:

const img = document.createElement('img');
img.src = src;
await img.decode();
const bitmap = await createImageBitmap(img);

而使用Buffer的话,则直接用device.queue.writeTexture即可。

Cube纹理

Cube纹理和2D纹理大差不差,区别在于其初始化的时候depthOrArrayLayers为6,并且需要在初始化的时候提交六张纹理,并且在origin参数中的z为1~6。

RenderTexture

说完Texture,顺便可以直接说说可渲染纹理RenderTexture,顾名思义,这是一种可以用于用于绘制或写入数据的纹理。和通常的RenderTexture的设计一样,为了和MRT(多渲染目标)技术相容,本引擎的RenderTexture也设计为多个colorTexture和一个depthTexture的形式,来看看它的构造参数:

export interface IRenderTextureOptions {
  width: number;
  height: number;
  forCompute?: boolean;
  colors: {
    name?: string,
    format?: GPUTextureFormat
  }[];
  depthStencil?: {
    format?: GPUTextureFormat;
    needStencil?: boolean;
  };
}

具体的实现不贴了,就是根据colorsdepthStencil中的参数去创建不同的GPUTexture,然后使用每个colorname建表索引,之后创建view缓存。这里有个特别的参数forCompute(是否用于计算着色器),主要决定了创建GPUTexture时的usage,如果是则需要开启GPUTextureUsage.STORAGE_BINDING

创建好的RenderTexture可以和Texture直接一样直接用于渲染来源数据,但其最重要的功能是用于绘制,关于如何绘制,已经在前面的Camera章节说过了,将其按照顺序设置到GPURenderPassDescriptor即可。

UBTemplate

UBTemplate,可以认为是前面提了无数次的UniformBlock的模板,当然到这里读者应该察觉到了——这个UniformBlock远不止管理了Uniform,也管理了纹理、采样器、SSBO等等,只不过出于习惯这么称呼。而这也可以认为是整个引擎最复杂的一部分,因为它不仅涉及到了这些数据的创建和更新,还涉及到了Shader相关定义的生成。而在WebGPU和WGSL规范中,尤其是Uniform部分的规范又非常繁琐,比如各种字节对齐(align、stride),在方便使用的前提下,UBTemplate需要自动抹平这些复杂性,对外暴露足够简单的构造和接口,当然这是有代价的——会造成部分的内存浪费。

将UBTemplate所做的全部工作列出来实在会篇幅过长,而且必要性不大,这里就说一些核心的地方,剩下的读者自己去看代码即可。首先让我们看一下它的构造参数:

constructor(
  protected _uniformDesc: IUniformsDescriptor,
  protected _groupId: EUBGroup,
  protected _visibility?: number,
);

export enum EUBGroup {
  Global = 0,
  Material = 1,
  Mesh = 2
}

export type TUniformValue = TUniformTypedArray | Texture | CubeTexture | GPUSamplerDescriptor | RenderTexture;

export interface IUniformsDescriptor {
  uniforms: {
    name: string,
    type: 'number' | 'vec2' | 'vec3' | 'vec4' | 'mat2x2' | 'mat3x3' | 'mat4x4',
    format?: 'f32' | 'u32' | 'i32',
    size?: number,
    customType?: {name: string, code: string, len: number},
    defaultValue: TUniformTypedArray
  }[],
  textures?: {
    name: string,
    format?: GPUTextureSampleType,
    defaultValue: Texture | CubeTexture,
    storageAccess?: GPUStorageTextureAccess,
    storageFormat?: GPUTextureFormat
  }[],
  samplers?: {
    name: string,
    defaultValue: GPUSamplerDescriptor
  }[],
  storages?: {
    name: string,
    type: 'number' | 'vec2' | 'vec3' | 'vec4',
    format?: 'f32' | 'u32' | 'i32',
    customStruct?: {name: string, code: string},
    writable?: boolean,
    defaultValue: TUniformTypedArray,
    gpuValue?: GPUBuffer
  }[]
}

可见主要是_uniformDesc_groupId,后者很简单,决定UB将用于哪个级别,这决定生成的Shader定义中Uniform部分的是globalmesh还是material。而前者就相对复杂了,其主要是四个部分:

  1. uniforms:Uniform部分,这一部分的数据一般都是细粒度的向量、矩阵等,支持数组,在实际生成最后,会将他们都打包成一个大的Buffer,这个Buffer是严格16字节对齐的,这是什么意思呢?比如一个Vector3数组,将会被生成为[[align(16)]] ${name}: [[stride(16)]] array<vec3<f32>, 4>;。也就是说虽然这个数据只占用4 x 3 x 4个字节的空间即可描述,但这里强制使其占用4 x 4 x 4的空间,在每个vec3元素后都填了一位。在使用setUniform设置值的时候也会自动按照这个对齐规则来设置。
  2. textures:纹理部分,和前面给出的创建BindingGroup时使用基本一致,但注意这里有参数storageAccessstorageFormat,用于生成storageTexture的定义,一般用于给CS提供可写入的RenderTexture。
  3. samplers:采样器部分,和前面给出的创建BindingGroup时使用完全一致。
  4. storages:SSBO部分,这是一种可以在CPU和GPU共享的特殊Buffer,其可以存储相对大量的数据,并可以在GPU的CS中写入和读取、也可以在CPU中写入和读取。在本项目中我一般用于调试CS。

uniforms和storage都提供了customStructcustomType参数来让用户自定义结构,而非默认生成,提供了自由度。

每次创建UBTemplate的时候,实际上都只是生成了BindingGroup中的layout部分和CPU端的默认值等,于此同时还生成了Shader中对应的Uniform相关的定义字符串。而在后续实际渲染中,我们将使用createUniformBlock方法实际创建UB时,返回的是:

export interface IUniformBlock {
  isBufferDirty: boolean;
  isDirty: boolean;
  layout: GPUBindGroupLayout;
  entries: GPUBindGroupEntry[];
  cpuBuffer: Uint32Array;
  gpuBuffer: GPUBuffer;
  values: {
    [name: string]: {
      value: TUniformValue,
      gpuValue: GPUBuffer | GPUSampler | GPUTextureView
    }
  };
}

后续便可以使用setUniform(ub: IUniformBlock, name: string, value: TUniformValue, rtSubNameOrGPUBuffer?: string | GPUBuffer);方法和getUniform(ub: IUniformBlock, name: string)给用到UBTemplate的对象提供修改和获取的支持。还记得前面说到的几个UniformBlock吗?其实就是用UBTemplate生成的,其中renderEnv管理了全局的UniformBlock,Mesh/ImageMesh/ComputeUint管理了单元级别的UniformBlock,而即将说到的Material管理了材质级别的UniformBlock,它们被设置到不同的地址,共同完成渲染。

在最终,也就是后续会提到的创建BindingGroup的那一步,使用的是getBindingGroup方法,在上面说到的几类对象上都有bindingGroup访问器代理到这里:

public getBindingGroup(ub: IUniformBlock, preGroup: GPUBindGroup) {
  if (ub.isBufferDirty) {
    renderEnv.device.queue.writeBuffer(
      ub.gpuBuffer as GPUBuffer,
      0,
      ub.cpuBuffer
    );

    ub.isBufferDirty = false;
  }

  if (ub.isDirty) {
    preGroup = renderEnv.device.createBindGroup({
      layout: ub.layout,
      entries: ub.entries
    });
    ub.isDirty = false;
  }

  return preGroup;
}

可见这里主要做了两件事,第一件事是检查脏位更新Uniform部分的数据,第二则是检查并更新group缓存返回。

Effect

效果Effect可以认为是对Shader、UniformBlock、渲染状态和宏的一个管理器,也可以认为是一个模板,以供后面的Material实例化,其构造参数为:

export interface IRenderStates {
  cullMode?: GPUCullMode;
  primitiveType?: GPUPrimitiveTopology;
  blendColor?: GPUBlendComponent;
  blendAlpha?: GPUBlendComponent;
  depthCompare?: GPUCompareFunction;
}

export interface IEffectOptionsRender {
  vs: string;
  fs: string;
  uniformDesc: IUniformsDescriptor;
  marcos?: {[key: string]: number | boolean};
  renderState?: IRenderStates;
}
export interface IEffectOptionsCompute {
  cs: string;
  uniformDesc: IUniformsDescriptor;
  marcos?: {[key: string]: number | boolean};
}
export type TEffectOptions = IEffectOptionsRender | IEffectOptionsCompute;

除去vs/fs(用于渲染)和cs(用于计算)的区分,通用的部分是:

  1. UBTemplate构造参数uniformDesc:指定这个Effect提供的默认UniformBlock结构来创建UBTemplate。
  2. 宏对象marcos:Effect能够支持的宏特性列表,后续生成Shader的时候会使用。
  3. 渲染状态renderState:Effect提供的默认渲染状态,这里只定义了几个我用到过的,实际上还有不少。

Effect的功能并不多,其最重要的是对外暴露了createDefaultUniformBlockgetShader两个方法,以供最后的渲染使用。前者将会在Material中用到,后者则会在最后的Pipeline中用到。

Material

材质Material可以看做是实例化后的Effect,其构造如下:

constructor(
  protected _effect: Effect,
  values?: {[name: string]: TUniformValue},
  marcos?: {[key: string]: number | boolean},
  renderStates?: IRenderStates
) {
  super();

  this._uniformBlock = _effect.createDefaultUniformBlock();

  if (values) {
    Object.keys(values).forEach(name => this.setUniform(name, values[name]));
  }

  this._marcos = marcos || {};
  this._renderStates = renderStates || {};
}

可见其实很简单,就是利用拥有的Effect构建了一个材质级别的UniformBlock并设置初始值,然后提供了宏marcos和渲染状态renderState,后续也可以修改和获取这些宏和渲染状态。需要注意的是,Material还提供了version(number)类型,来记录版本,以便于后续Pipeline的更新。

Pipeline

讲了这么多,基本所有的要素都极其了,终于到了整个流程的最后一步——管线Pipeline。Pipeline的创建是分别实现在MeshImageMeshComputeUint中的,也就是前面在论述这三者时提到的_createPipeline方法,如果读者还记得,其实这里就利用了上面说到的material.version来判断版本做缓存。这是因为和BindingGroup一样,创建Pipeline的开销并不低。

在Mesh中,创建的实现为:

const {device} = renderEnv;
const {_geometry, _material, _ubTemplate} = this;

this._bindingGroup = this._ubTemplate.getBindingGroup(this._uniformBlock, this._bindingGroup);
const marcos = Object.assign({}, _geometry.marcos, _material.marcos);
const {vs, fs} = _material.effect.getShader(marcos, _geometry.attributesDef, renderEnv.shaderPrefix, _ubTemplate.shaderPrefix);

this._pipelines[rt.pipelineHash] = device.createRenderPipeline({
  layout: device.createPipelineLayout({bindGroupLayouts: [
    renderEnv.uniformLayout,
    _material.effect.uniformLayout,
    _ubTemplate.uniformLayout
  ]}),

  vertex: {
    module: vs,
    entryPoint: "main",
    buffers: _geometry.vertexLayouts
  },

  fragment: {
    module: fs,
    targets: rt.colorFormats.map(format => ({
      format,
      blend: _material.blendColor ? {
        color: _material.blendColor,
        alpha: _material.blendAlpha
      } : undefined
    })),
    entryPoint: "main"
  },

  primitive: {
    topology: _material.primitiveType,
    cullMode: _material.cullMode
  },

  depthStencil: rt.depthStencilFormat && {
    format: rt.depthStencilFormat,
    depthWriteEnabled: true,
    depthCompare: _material.depthCompare
  }
});

可见这里将之前所有部分基本都串起来了,首先合并两个级别的宏,通过他们和Geometry的顶点信息Shader定义、三个级别的UniformBlock的Shader定义来生成最终的vsfs,然后使用device.createRenderPipeline方法将这一切都组装起来,生成最终的Pipeline。

ImageMesh和上面基本一致,只不过省去了不需要的vertex部分,而ComputeUint由于没有顶点、片元,也没有渲染状态和渲染目标,更为简单:

 protected _createPipeline() {
  const {device} = renderEnv;
  const {_material} = this;

  const marcos = Object.assign({}, _material.marcos);
  const {cs} = _material.effect.getShader(marcos, '', renderEnv.shaderPrefix, '');

  this._pipeline = device.createComputePipeline({
    layout: device.createPipelineLayout({bindGroupLayouts: [
      renderEnv.uniformLayout,
      _material.effect.uniformLayout
    ]}),

    compute: {
      module: cs,
      entryPoint: "main"
    }
  });
}

至此,整个渲染引擎部分就此结束。

glTF和工作流

本来这里想顺便说说资源和工作流部分的,但篇幅已经太长了,就放在下一个章节讲吧。

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。