【WebGPU实时光追美少女】场景数据组织与合并
世界Skill
本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer。
本文将在上一篇文章WebGPU基础与简单渲染器的基础上,论述针对路径追踪,应当如何组织场景数据,这涉及到PBR材质定义、glTF资源的导出和加载、场景的构建、以及最重要的场景合并等内容。
PBR材质
这篇文章以PBR材质开始,为什么要以材质开始?因为如上一篇文章所言,材质(以及效果)决定了物体的渲染方式,也可以从某种程度上认为是决定了使用光照模型(或者说是光照模型决定了材质和效果),这个对整个场景数据和渲染流程的组织有决定性的影响。为了搞清楚为何要如此组织数据和渲染流程,我们必须要先了解使用到的材质。
PBR即“基于物理的渲染”是工业界的说法,其可以认为是基于相对物理正确的BRDF、BSDF等模型,根据不同的场合,抽象了一些参数供美术人员调节,并加上一些技术(比如用于实时渲染的预计算IBL、SH技术等等)来达到相对物理真实的效果。不过这里我们不讨论背后实现的理论细节,这些细节将在后续的章节详细论述,本文只需要了解这些参数和工作流即可。
事实上,在光栅化渲染流程如果单纯使用PBR材质进行直接光照,而不使用IBL/SH进行间接光照,相对于传统的光照模型,并不能带来多大的提升,所以Baking是十分重要的。
根源上来讲,PBR材质只是一个统称,其有很多变种,这些变种一般被称为工作流(Workflow)。同时这些变种也有不同的定义和实现,比如著名的迪士尼的Disney Principled BRDF。而本引擎的定义则基于后续要提到的glTF资源中的标准定义,支持了两种最常见的工作流——金属(MetallicRoughness)和高光(SpecularGlossiness)工作流。
Metallic Roughness
金属工作流,是指主要通过金属度和粗糙度来控制渲染效果,这实际上模拟的是自然界的导体,也有一些其他参数来共同决定最终效果:
- Metallic:金属度,由单通道贴图
metallicMap
和系数metallicFactor
相乘得到。 - Roughness:粗糙度,由单通道贴图
roughnessMap
和系数roughnessFactor
相乘得到。 - BaseColor:反照率,有的引擎也称为
Albedo
,由RGBA贴图baseColorMap
和颜色系数baseColorFactor
相乘得到。 - Occlusion:环境光遮蔽,由单通道贴图
occlusionMap
和系数occlusionFactor
相乘得到。 - Normal:法线贴图,一般是切线空间的法线贴图
normalMap
和系数normalScale
计算得到。 - Emission:自发光,由RGBA贴图
emissiveMap
和颜色系数emissiveFactor
相乘得到。
在实际使用中,metallicMap
、roughnessMap
和occlusionMap
一般会合并成一张RGB贴图。
Specular Glossiness
除了金属度和粗糙度,高光工作流的大部分参数和金属工作流都是通用的,但高光和光泽度是独有的,也是控制渲染的最主要因素:
- Specular:高光,由RGB贴图
specularMap
和颜色系数specularFactor
相乘得到。 - Glossiness:光泽度,由单通道贴图
glossinessMap
和系数glossinessFactor
相乘得到。
这里要注意除了BaseColor和Emissive一般是SRGB空间的,其他都是线性空间的,并且在实际计算时都是转到线性空间计算的,这也是物理正确的要求。
glTF资源
有了材质的定义,接下来的问题就是如何将它们应用到物体上了,这又引出了更多的问题——我们该如何组织整个场景?又如何去存储它们、如何去生成它们?答案很简单——使用glTF格式。
glTF是KhronosGroup近年来提出的一种标准模型格式,从某种意义上你可以认为它是3D场景序列化的一种标准解决方案。其方案非常简单明确,基本由一个存储节点、曲面、材质、纹理、动画等等索引信息的json格式的.gltf文件,同时还提供了一个二进制.bin文件用于存储实际的顶点、动画信息等,而这个二进制文件中的数据和GL所需格式基本一致,这大幅降低加载延迟、提升了首屏速度。更多详细的信息可以看这里——Sein.js glTF和实例化。
读者可能会疑惑为何这里会提及另一个引擎,因为这个引擎是我还在支付宝供职时研发的(当然已开源),同时还为其开发了配套的Unity扩展——SeinjsUnityToolkit,而本引擎也可以直接使用这个工具导出的资源,也算是物尽其用吧,但相比于工具支持的所有特性,本引擎只用到了需要的部分:
- 贴图。
- 2D和Cube纹理。
- PBR材质,主要是glTF官方内置的金属工作流,以及通过
KHR_materials_pbrSpecularGlossiness
扩展实现的高光工作流。 - 图元和Mesh,直接对应引擎中的
Geometry
和Mesh
。 - 相机,支持正交和透视。
- 灯光,在路径追踪中主要用到了
rect
和disc
两种面光源。 - 结点,用于构建节点树。
整个加载器的代码的实现在resource/GlTFLoader内
,实际使用中,只需要调用load
方法,之后将返回的结果中的根节点rootNode
添加给场景的根节点即可:
const model = this._model = await H.resource.load({type: 'gltf', name: 'scene.gltf', src: MODEL_SRC});
if (model.cameras.length) {
this._camera = model.cameras[0];
}
_scene.rootNode.addChild(model.rootNode);
场景合并
加载完资源并将其添加到场景后,我们有了一整颗节点树,节点树上也有了不同的Mesh
、Light
、Camera
,而Mesh
则拥有Geometry
和Material
,Material
全部是PBR材质,并使用UniformBlock将需要的参数管理了起来。如果是传统的光栅化流程,我们可以直接开始渲染——Camera剔除出需要的Mesh,将lightInfo、VP矩阵等写入全局UB,之后分别绘制每个Mesh.....但对于路径追踪流程,却不能这么做,原因很简单——信息不足。
上面这张图生动地描述了两个流程的差异(当然第二个实际上是光线追踪流程,不过用于描述差距还是足够了,把射线反过来就OK)。
光栅化流程实际上是将场景中的每个三角面投影到近裁剪面上,然后通过算法将矢量的三角形栅格化为像素(片元),之后经过重心坐标插值、像素着色、各种测试、混合等等决定屏幕空间某些像素最后的颜色值,这实际上是反直觉的,并且只能计算直接光照。
而路径追踪则不同,其出发点并非三角形,而是屏幕空间的像素,我们通过相机坐标->像素对应世界空间坐标生成一条世界空间的射线,然后让其和场景中所有三角面求交,找到最近的相交点后再通过反射/折射计算光路,最终可以通过N次弹射,最终收集完整个场景对此像素的贡献。除了部分场景(比如焦散),单向的路径追踪可以比较准确地计算出全局光照的结果。
那么这些和场景合并又有什么关系呢?当然有——路径追踪需要在每个射线求交的过程中拥有整个场景所有三角面的信息,除此之外,由于交点可能存在于任意三角面上,所以也需要能够即时获取到三角面对应的材质信息。
有经验的读者可能已经联想到了,这个特征其实和工业界讨论许久的GPU Driven有些相似。
GPU Driven Rendering Pipeline
GPU Driven Rendering指的是一类渲染技术,其根本目的是为了充分利用GPU的计算能力,使用Bindless Resource、Virtual Texture、Indirect Draw等等技术,尽可能减少渲染过程中CPU和GPU之间数据传输、调用的cost,还可以充分控制利用cache,来提升性能。这里最主要的就是上一篇文章前面提到过剔除、资源绑定、绘制等等。此技术的细节比较复杂,需要很多工程上的工作,有兴趣的读者可以自行搜索。对于本文我们只需要关心它和路径追踪相关的内容,也就是——资源绑定部分。
资源绑定在WebGPU中,实质上就是上篇文章提到pass.setIndexBuffer
、pass.setVertexBuffer
、pass.setBindingGroup
等等方法,CPU通过这些方法告诉GPU我们接下来的绘制指令将会使用哪些顶点数据、Uniform数据......之后进行渲染。但因为硬件结构和软件设计,资源绑定操作是有开销的,一部分是指令本身、另一部分就是GPU中的计算单元获取这些资源对应Buffer的机制,那么如何尽量减少这些操作便是一个可以考虑的问题。
这里我们可以回忆一下Instanced Drawing,即GPU实例化的机制,这个机制它提供一个一次性绘制一批相同图元、相同渲染状态,但可以有不同位置、颜色等等的Mesh的方法。无论是使用InstanceBuffer
,还是使用instanceId
+ const buffer
/texture array
的机制,本质上都是将这些Mesh中不同的Uniform(比如worldMatrix、color)转换成可以按照实例索引的数据,然后按照实例的维度去索引。这实际上是以在CPU对这些数据打包为代价,降低了实际渲染时绑定和绘制指令的大幅降低(当然,绑定这个操作很久之前就已经可以通过VAO来降低,在新的技术里也有Bindless Resource来降低)。
那么在这个基础上进一步考虑,我们是否可以将图元、某些渲染状态、大量Uniform都不一致的Mesh一批渲染完成呢?当然可以——这就是合批(Batch)技术。在目前常见的光栅化渲染管线(比如ForwardBase)中,合批一般指将相同渲染状态、只有少数Uniform不同的Mesh进行合并的技术,一般在简单的图元、UI上比较常用,其原理是Mesh的某些顶点数据和对应的uniform预先在CPU处理好(比如position和worldMatrix相乘得到worldPos),并将这些Mesh图元数据合并,被合批的uniform统一,便可以实现一次DrawCall绘制大量Mesh。
GPU实例化和合批在目前的游戏中都有大量应用,而GPU Driven的资源部分可以认为是基于它们和一些新技术,做得更加彻底、做了更多优化、也更为复杂。本项目用于路径追踪的部分实际上也可以认为结合了这两者,但毕竟和光栅化流程面对的问题不完全相同,所以做法也不完全一致。
顶点合并
整个路径追踪场景数据管理相关的代码都在
extension/RayTracingManager
中。
首先要做的就是顶点数据vertexData
的合并,这个流程和传统的合批流程基本一致,将位置position
和法线normal
转换到世界空间,其他的直接拼接即可。如果只是为了合并,其实也可以不转换,然后在下面的流程将worldMatrix
合并,通过索引后续计算也行,但由于构建BVH必须要使用世界空间的坐标,这里也就必须要合并,至于法线也只是顺带算了。在合并顶点数据的过程中,还需要同时构建索引数据indexData
:
protected _buildAttributeBuffers = (meshes: Mesh[]) => {
const {_materials} = this;
let indexCount: number = 0;
let vertexCount: number = 0;
meshes.forEach(mesh => {
vertexCount += mesh.geometry.vertexCount;
indexCount += mesh.geometry.count;
});
const {value: indexes} = this._indexInfo = {
value: new Uint32Array(indexCount)
};
const {position, texcoord_0, normal, meshMatIndex} = this._attributesInfo = {
position: {
// alignment of vec3 is 16bytes!
value: new Float32Array(vertexCount * 4),
length: 4,
format: 'float32x3'
},
texcoord_0: {
value: new Float32Array(vertexCount * 2),
length: 2,
format: 'float32x2'
},
normal: {
// alignment of vec3 is 16bytes!
value: new Float32Array(vertexCount * 4),
length: 4,
format: 'float32x3'
},
meshMatIndex: {
value: new Uint32Array(vertexCount * 2),
length: 2,
format: 'uint32x2'
}
};
let attrOffset: number = 0;
let indexOffset: number = 0;
for (let meshIndex = 0; meshIndex < meshes.length; meshIndex += 1) {
const mesh = meshes[meshIndex];
const {worldMat} = mesh;
const quat = mat4.getRotation(new Float32Array(4), worldMat) as Float32Array;
const {geometry, material} = mesh;
const {indexData, vertexInfo, vertexCount, count} = geometry;
if (material.effect.name !== 'rPBR') {
throw new Error('Only support Effect rPBR!');
}
let materialIndex = _materials.indexOf(material);
if (materialIndex < 0) {
_materials.push(material);
materialIndex = _materials.length - 1;
}
indexData.forEach((value: number, index: number) => {
indexes[index + indexOffset] = value + attrOffset;
});
if (!vertexInfo.normal) {
geometry.calculateNormals();
}
for (let index = 0; index < vertexCount; index += 1) {
this._copyAttribute(vertexInfo.position, position, attrOffset, index, worldMat);
this._copyAttribute(vertexInfo.texcoord_0, texcoord_0, attrOffset, index);
this._copyAttribute(vertexInfo.normal, normal, attrOffset, index, quat, true);
meshMatIndex.value.set([meshIndex, materialIndex], (attrOffset + index) * meshMatIndex.length);
}
indexOffset += count;
attrOffset += vertexCount;
}
}
有读者应该注意到了打包的除了传统的那些顶点数据,还有meshMatIndex
,这其实是记录了当前顶点属于哪个Mesh(meshIndex),拥有哪个材质(matIndex)。这是因为光有点点数据并不足以支撑整个渲染流程,所以需要通过某个顶点获取到对应的材质。于是我借鉴上面说到的GPU实例化思想——通过材质ID来索引,这也就引出了“材质合并”。
材质合并
所谓材质合并,其实是将材质中的UniformBlock进行合并(Mesh级别的UB目前只有worldMatrix
,已经在上一步合过了),来准备给每个顶点的matIndex
索引出渲染需要的参数。前面提到过我们使用的都是PBR材质,所以合并的也就是这些参数:
protected _buildCommonUniforms = (materials: Material[]) => {
const matId2TexturesId = new Int32Array(materials.length * 4).fill(-1);
const baseColorFactors = new Float32Array(materials.length * 4).fill(1);
const metallicRoughnessFactorNormalScaleMaterialTypes = new Float32Array(materials.length * 4).fill(1);
const specularGlossinessFactors = new Float32Array(materials.length * 4).fill(1);
const baseColorTextures: Texture[] = [];
const normalTextures: Texture[] = [];
const metalRoughOrSpecGlossTextures: Texture[] = [];
materials.forEach((mat, index) => {
const useGlass = mat.marcos['USE_GLASS'];
const useSpecGloss = mat.marcos['USE_SPEC_GLOSS'];
const baseColorFactor = mat.getUniform('u_baseColorFactor') as Float32Array;
const metallicFactor = mat.getUniform('u_metallicFactor') as Float32Array;
const roughnessFactor = mat.getUniform('u_roughnessFactor') as Float32Array;
const specularFactor = mat.getUniform('u_specularFactor') as Float32Array;
const glossinessFactor = mat.getUniform('u_glossinessFactor') as Float32Array;
const normalScale = mat.getUniform('u_normalTextureScale') as Float32Array;
const baseColorTexture = mat.getUniform('u_baseColorTexture') as Texture;
const normalTexture = mat.getUniform('u_normalTexture') as Texture;
const metallicRoughnessTexture = mat.getUniform('u_metallicRoughnessTexture') as Texture;
const specularGlossinessTexture = mat.getUniform('u_specularGlossinessTexture') as Texture;
const mid = index * 4;
baseColorTexture !== buildinTextures.empty && this._setTextures(mid, baseColorTextures, baseColorTexture, matId2TexturesId);
normalTexture !== buildinTextures.empty && this._setTextures(mid + 1, normalTextures, normalTexture, matId2TexturesId);
baseColorFactor && baseColorFactors.set(baseColorFactor, index * 4);
normalScale !== undefined && metallicRoughnessFactorNormalScaleMaterialTypes.set(normalScale.slice(0, 1), index * 4 + 2);
if (useSpecGloss) {
specularFactor !== undefined && specularGlossinessFactors.set(specularFactor.slice(0, 3), index * 4);
glossinessFactor !== undefined && specularGlossinessFactors.set(glossinessFactor.slice(0, 1), index * 4 + 3);
specularGlossinessTexture !== buildinTextures.empty && this._setTextures(mid + 2, metalRoughOrSpecGlossTextures, specularGlossinessTexture, matId2TexturesId);
metallicRoughnessFactorNormalScaleMaterialTypes.set([useGlass ? EPBRMaterialType.GLASS_SPEC_GLOSS : EPBRMaterialType.SPEC_GLOSS], index * 4 + 3);
} else {
metallicFactor !== undefined && metallicRoughnessFactorNormalScaleMaterialTypes.set(metallicFactor.slice(0, 1), index * 4);
roughnessFactor !== undefined && metallicRoughnessFactorNormalScaleMaterialTypes.set(roughnessFactor.slice(0, 1), index * 4 + 1);
metallicRoughnessTexture !== buildinTextures.empty && this._setTextures(mid + 2, metalRoughOrSpecGlossTextures, metallicRoughnessTexture, matId2TexturesId);
metallicRoughnessFactorNormalScaleMaterialTypes.set([useGlass ? EPBRMaterialType.GLASS_METAL_ROUGH : EPBRMaterialType.METAL_ROUGH], index * 4 + 3);
}
});
this._commonUniforms = {
matId2TexturesId,
baseColorFactors,
metallicRoughnessFactorNormalScaleMaterialTypes,
specularGlossinessFactors,
baseColorTextures: this._generateTextureArray(baseColorTextures),
normalTextures: this._generateTextureArray(normalTextures),
metalRoughOrSpecGlossTextures: this._generateTextureArray(metalRoughOrSpecGlossTextures)
};
}
可以看到合并的逻辑也比较简单,唯一注意的是合并的规则——都是尽量凑齐16字节对齐的。同时除了材质本身固有的基本参数,还有materialType
和matId2TexturesId
。materialType
用于表示材质的类型,分为不透明金属、透明金属、不透明高光、透明高光,不同材质对应的光照计算流程不同。
matId2TexturesId
记录的是该材质到纹理id的索引。通过顶点找到材质需要索引,那么通过材质找到需要用到的纹理也需要索引,这是由于不同材质可能复用同一张纹理资源,所以需要同样对纹理进行合并。
纹理合并
纹理合并即将不同的纹理合并成一个可以索引的集合,其并没有看起来那么简单。目前工业界比较成熟和推荐的方案是使用Bindless Texture,但WebGPU并不支持,另外妥协的方案有两个:
- 大纹理:创建一张很大的纹理,通过UV映射将需要的纹理分配在大纹理的一个个子区域,存下offset和size来进行采样。
- ArrayTexture:创建数组纹理,直接在shader中通过索引采样。
二者各有各的缺点,大纹理的缺点主要是容量有限,对于很多最大支持2048 x 2048
的平台很容易超,并且即便是支持不同大小的纹理,也容易产生空间的浪费,当然最主要的还是容量问题。ArrayTexture主要就是要求纹理尺寸必须一致了,但胜在使用方便不太需要担心容量超过。本项目使用的是ArrayTexture:
protected _generateTextureArray(textures: Texture[]): Texture {
let width: number = 0;
let height: number = 0;
textures.forEach(tex => {
width = Math.max(width, tex.width);
height = Math.max(height, tex.height);
});
const images = textures.map(tex => {
if (tex.width === width && tex.height === height) {
return tex.source as TTextureSource;
}
if (!(tex.source instanceof ImageBitmap)) {
throw new Error('Can only resize image bitmap!');
}
if (!RayTracingManager.RESIZE_CANVAS) {
RayTracingManager.RESIZE_CANVAS = document.createElement('canvas');
RayTracingManager.RESIZE_CANVAS.width = 2048;
RayTracingManager.RESIZE_CANVAS.height = 2048;
RayTracingManager.RESIZE_CTX = RayTracingManager.RESIZE_CANVAS.getContext('2d');
}
const ctx = RayTracingManager.RESIZE_CTX;
ctx.drawImage(tex.source as ImageBitmap, 0, 0, width, height);
return ctx.getImageData(0, 0, width, height).data.buffer;
})
return new Texture(
width, height,
images,
textures[0].format
);
}
至此,整个场景合并部分完成。