【WebGPU实时光追美少女】WebGPU基础与简单渲染器
世界Skill
本系列文章设计的所有代码均已开源,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
(至于为何是H
Object,那当然是因为青年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_NAME
、isXXXX
,并会通过CLASS_NAME
自动生成id
和hash
,还有可选的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_lightInfos
、u_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
中详细说到,这里就认为其就是画布即可;而loadValue
和storeOp
是决定如何清屏的,在这里这些参数都可以交由开发者决定,一般来讲是颜色值和store
操作。
在创建了一个RenderPass后,就可以设置viewport
和bindGroup
,前者和OpenGL中的视口概念等同,后者可以认为就是UniformBlock,这个在后面会详细讲到,这里设置的是第0个UniformBlock,即全局UB。设置好后便是调用每个Mesh的render
方法逐个渲染,最终渲染天空盒。渲染完毕后调用pass.endPass
来结束这个RenderPass。
事实上关于ImageMesh
和ComputeUnit
的绘制处理也类似,后面会详细讲到。
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
以及values
、marcos
参数也是构造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。其语法和glsl
与hlsl
都有较大差异,以本人的观点看比较像融合了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
则说明顶点着色器要传输给下一步插值的数据类型为VertexOutput
。Attrs
在这里没有写,这是因为其相对动态,我将其生成集成到了整个渲染过程的设计中,这个在下面的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中使用var
和let
区分变量是动态还是静态,静态变量不可变并且必须初始化,动态的可变。在结尾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>
定义了两个模块作用域的变量数组pos
和uv
,可以被着色器函数索引内容,而此处函数入参[[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
的变量。纹理采样我们熟悉,但这个materia
、u_baseColorTexture
、u_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_id
、local_invocation_id
,这其实就是线程组的偏移和线程组内线程的偏移,通过它们我们就可以得到真正的线程偏移,也可以作为纹理采样的依据。能够采样纹理,就可以进行计算,最后将计算的结果通过textureStore
写回输出纹理即可,输出纹理是一个write
类型的storageTexture
。
着色器中的
${WINDOW_SIZE}
这种也属于我自己实现的简陋的宏的一部分。
Geometry
几何体Geometry
本质上就是顶点数据和索引数据的集合。在OpenGL中,我们往往会自己组织一个结构来描述顶点数据的存储构造,在WebGPU中,API标准即将这个结构定死了,其为GPUVertexBufferLayout[]
。这个结构的描述加上vertexBuffer
、indexBuffer
即构成了整个渲染单元的几何信息:
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需要的VertexLayout
和GPUBuffer
,同时还生成了其他必要的数据。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的过程中还会生成别的重要数据:
- 一张宏的表
_marcos
,所有用到的顶点属性都会以USE_XXX
的形式存在,然后作用在Shader中,比如上面顶点着色器示例那样。 - 顶点相关的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;
};
}
具体的实现不贴了,就是根据colors
和depthStencil
中的参数去创建不同的GPUTexture
,然后使用每个color
的name
建表索引,之后创建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部分的是global
、mesh
还是material
。而前者就相对复杂了,其主要是四个部分:
- 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
设置值的时候也会自动按照这个对齐规则来设置。 - textures:纹理部分,和前面给出的创建
BindingGroup
时使用基本一致,但注意这里有参数storageAccess
和storageFormat
,用于生成storageTexture
的定义,一般用于给CS提供可写入的RenderTexture。 - samplers:采样器部分,和前面给出的创建
BindingGroup
时使用完全一致。 - storages:SSBO部分,这是一种可以在CPU和GPU共享的特殊Buffer,其可以存储相对大量的数据,并可以在GPU的CS中写入和读取、也可以在CPU中写入和读取。在本项目中我一般用于调试CS。
uniforms和storage都提供了
customStruct
或customType
参数来让用户自定义结构,而非默认生成,提供了自由度。
每次创建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
(用于计算)的区分,通用的部分是:
- UBTemplate构造参数
uniformDesc
:指定这个Effect提供的默认UniformBlock结构来创建UBTemplate。 - 宏对象
marcos
:Effect能够支持的宏特性列表,后续生成Shader的时候会使用。 - 渲染状态
renderState
:Effect提供的默认渲染状态,这里只定义了几个我用到过的,实际上还有不少。
Effect的功能并不多,其最重要的是对外暴露了createDefaultUniformBlock
和getShader
两个方法,以供最后的渲染使用。前者将会在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的创建是分别实现在Mesh
、ImageMesh
、ComputeUint
中的,也就是前面在论述这三者时提到的_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定义来生成最终的vs
和fs
,然后使用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和工作流
本来这里想顺便说说资源和工作流部分的,但篇幅已经太长了,就放在下一个章节讲吧。