【WebGPU实时光追美少女】管线组织与GBuffer

少女dtysky

世界Skill

时刻2021.09.17

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

上一篇文章场景数据组织与合并,我们讨论了要使用的材质、以及如何组织好场景数据,并对场景数据进行了合并。现在我们可以认为有了一个很大的Geometry,其中存储着整个场景所有的图元信息,还有合并好的UniformTexture,以便在渲染过程中查找到物体上某一点对应的材质数据。那么下一步,就是如何利用这些数据进行渲染了。

路径追踪管线

路径追踪管线和传统的光栅化管线是完全不同的,目前一般有两种方案来实现。在设计整个管线之前,我们要先回顾并深入一下路径追踪的原理。

追踪过程

还是这张图,但这次我们要更深入一些。首先从左侧的相机看起,路径追踪起始于相机发出的若干世界空间的射线,这些射线进入场景后:

  1. 首先进行相交测试,从结果上来看,我们需要的是第一个和射线相交的三角面,换言之是离相机最近的相交三角面
  2. 获取相交点的材质数据,进行着色。
  3. 生成下一条/多条射线。
  4. 对每一条射线进行相交测试、着色。
  5. 循环过程直到满足结束条件,累计结果。

通过对这几步的抽象,我们已经可以给出一个不同于传统光栅化流程的全新管线实现,事实上这就是新一代图形API提供的光线追踪管线(XXX Ray tracing pipeline)的实现,比如M$的DXR。不同于光栅化的VS -> Rasterization -> FS管线,这个管线被抽象为下图的形式:

在RTX硬件的支持下,这个管线的性能是非常不错的,也可以和光栅化管线同时存在、相互协作。但显然现在WebGPU目前是不支持的,所以我们只能寻求另一种方案——CS + GBuffer的混合方案。

CS和GBuffer混合方案

说是CS(计算着色器)和GBuffer(几何缓冲对象)的混合方案,实际上只有CS也可以。让我们考虑一下,CS使用通用逻辑单元进行计算,理论上可以完成任何渲染需要的计算,甚至是光栅化也可以,只不过在大部分情况下比较慢(但事实上已经有这样的应用了)。我们现在已经有了整个场景需要用于渲染的的所有数据,那么将它们直接传给CS,理论上当然可以完成路径追踪的计算,只不过比起专用硬件慢了一些而已。

既然如此,我们又为何需要GBuffer呢?这不是画蛇添足?其实很简单——为了性能,虽然CS比起硬件专用加速方案慢,但能快一点总归是好的,而且这在原理上也是行得通的。

让我们再回忆下光栅化的过程,光栅化最重要的分为两步——投影和栅格化,投影是将3D空间的三角面变换到近裁剪平面的三角形,栅格化则是将近裁剪平面的矢量三角形变换为一个个像素(片元);再对比下路径追踪流程——根据相机投影模式,从近裁剪平面的像素发射一条条射线,找到射线相交的最近三角面上的点。

相信读者已经可以觉察到了——从结果上来讲,光栅化,实际上就是路径追踪的第一步,也就是求取某个像素发出的射线和场景中三角面的第一个交点。而光栅化由于硬件的高度优化,速度是非常快的。而除了速度优势,GBuffer在后面要提到的降噪方面也有用处。

所以总结来看,到此我们的渲染管线至少有两步——

  1. 通过光栅化流程渲染GBuffer。
  2. 通过CS进行后续的路径追踪流程。

BVH

在上面的绘制流程我们提到了第一步是求得射线和场景中最近的三角面的交点,但并没有提到怎么做。最简单的方法当然是遍历整个场景所有三角面,但考虑到射线数量以及三角面的数量,这显然是不现实的,所以需要一个结构来进行加速,加速结构的分类会在后面讲到,这里只给结论——我最后使用了BVH,这也是业界一般的做法。

由于在相交测试就会使用到BVH,所以它的构建应当先于整个CS流程。但概论中已经说了,本项目目前只针对静态场景,不涉及到Mesh的增减,所以BVH只需要在资源加载、场景组织完成后做一次即可。

降噪和色调映射

最后还需要的流程就是降噪和色调映射了。由于我们要做的是实时路径追踪,并且由于原理(涉及到积分),每次弹射出复数射线进行积分显然是不现实的,所以只能时间换空间——利用随机采样的原理,每帧渲染出一个带有大量噪点的结果,最后进行时域滤波,再辅以空间滤波,得到相对可以接受的最终结果。这整个滤波过程就是降噪

同时路径追踪渲染出的结果最终基本都是高动态范围(HDR)的,为了让显示器能够正确显示,我们最后还要进行一个通用操作色调映射(Tone mapping),将图像亮度压缩到LDR范围,并尽可能保证细节。

下图表现出了降噪前和降噪+色调映射后的结果对比:

总结

至此,可以总结出整个路径追踪管线的组成:

  1. 构建BVH。
  2. 通过光栅化流程渲染GBuffer。
  3. 通过CS进行后续的路径追踪流程。
  4. 降噪。
  5. 色调映射。

GBuffer渲染

讲完了管线的组成,就让我们以GBuffer为开端,顺便实战一下之前文章论述的这个WebGPU渲染器吧。

构建Mesh

GBuffer的渲染走的是正常的光栅化流程,所以需要构建一个Mesh,这个Mesh需要的Geometry和Material可以直接使用上一篇文章合并过的场景数据:

protected _buildGBufferMesh = () => {
  const { _attributesInfo, _indexInfo, _commonUniforms } = this;

  const geometry = new Geometry(
    Object.keys(_attributesInfo).map((name, index) => {
      const { value, length, format } = (_attributesInfo[name] as any) as IBVHAttributeValue;

      return {
        layout: {
          arrayStride: length * 4,
          attributes: [{
            name, offset: 0, format, shaderLocation: index
          }]
        },
        data: value,
        usage: GPUBufferUsage.STORAGE
      }
    }),
    _indexInfo.value,
    _indexInfo.value.length
  );

  const material = new Material(buildinEffects.rRTGBuffer, {
    u_matId2TexturesId: _commonUniforms.matId2TexturesId,
    u_baseColorFactors: _commonUniforms.baseColorFactors,
    u_metallicRoughnessFactorNormalScaleMaterialTypes: _commonUniforms.metallicRoughnessFactorNormalScaleMaterialTypes,
    u_specularGlossinessFactors: _commonUniforms.specularGlossinessFactors,
    u_baseColorTextures: _commonUniforms.baseColorTextures,
    u_normalTextures: _commonUniforms.normalTextures,
    u_metalRoughOrSpecGlossTextures: _commonUniforms.metalRoughOrSpecGlossTextures
  });

  this._gBufferMesh = new Mesh(geometry, material);
}

可见这里构建的Geometry没有使用交错形式,而是使用了多个Buffer,每个Buffer对应一个Attribute。而Material的UniformBlock直接使用了场景合并过的各个Uniform和Texture,同时使用了buildinEffects.rRTGBuffer这个Effect。

Effect

buildinEffects是引擎内置的一些Effect,rRTGBuffer如其名是用于渲染GBuffer的,前缀的r表明是用于Render的。对于一个Effect,最重要的就是它的Shader。

首先是顶点着色器:

struct VertexOutput {
  [[builtin(position)]] position: vec4<f32>;
  [[location(0)]] wPosition: vec4<f32>;
  [[location(1)]] texcoord_0: vec2<f32>;
  [[location(2)]] normal: vec3<f32>;
  [[location(3)]] meshMatIndex: vec2<u32>;
};

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

  let wPosition: vec4<f32> = vec4<f32>(attrs.position, 1.);

  output.position = global.u_vp * wPosition;
  output.wPosition = wPosition;
  output.texcoord_0 = attrs.texcoord_0;
  output.normal = attrs.normal;
  output.meshMatIndex.x = attrs.meshMatIndex.x;
  output.meshMatIndex.y = attrs.meshMatIndex.y;

  return output;
}

可见比较简单,由于直接使用了合并过的顶点数据,所以不需要Model -> World变换,输出给光栅器重点处理的position只需要乘以VP矩阵即可,剩下要用于插值的数据有世界坐标、UV和法线这三个f32向量,以及meshMatIndex这个存储顶点材质索引的u32向量。

之后是片段着色器:

struct VertexOutput {
  [[builtin(position)]] position: vec4<f32>;
  [[location(0)]] wPosition: vec4<f32>;
  [[location(1)]] texcoord_0: vec2<f32>;
  [[location(2)]] normal: vec3<f32>;
  [[location(3)]] meshMatIndex: vec2<u32>;
};

struct FragmentOutput {
  [[location(0)]] positionMetalOrSpec: vec4<f32>;
  [[location(1)]] baseColorRoughOrGloss: vec4<f32>;
  [[location(2)]] normalGlass: vec4<f32>;
  [[location(3)]] meshIndexMatIndexMatType: vec4<u32>;
};

fn getRoughness(factor: f32, textureId: i32, uv: vec2<f32>) -> f32 {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).g;
}

fn getMetallic(factor: f32, textureId: i32, uv: vec2<f32>) -> f32 {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).b;
}

fn getSpecular(factor: vec3<f32>, textureId: i32, uv: vec2<f32>) -> vec3<f32> {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).rgb;
}

fn getGlossiness(factor: f32, textureId: i32, uv: vec2<f32>) -> f32 {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).a;
}

fn getBaseColor(factor: vec4<f32>, textureId: i32, uv: vec2<f32>) -> vec4<f32> {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_baseColorTextures, u_sampler, uv, textureId, 0.);
}

fn getFaceNormal(position: vec3<f32>) -> vec3<f32> {
  return normalize(cross(dpdy(position), dpdx(position)));
}

fn getNormal(
  vNormal: vec3<f32>, position: vec3<f32>, faceNormal: vec3<f32>,
  textureId: i32, uv: vec2<f32>, normalScale: f32
) -> vec3<f32> {
  var normal: vec3<f32> = normalize(vNormal);
  normal = normal * sign(dot(normal, faceNormal));

  if (textureId == -1) {
    return normal;
  }

  // http://www.thetenthplanet.de/archives/1180
  let dp1: vec3<f32> = dpdx(position);
  let dp2: vec3<f32> = dpdy(position);
  let duv1: vec2<f32> = dpdx(uv);
  let duv2: vec2<f32> = dpdy(uv);
  let dp2perp: vec3<f32> = cross(dp2, normal);
  let dp1perp: vec3<f32> = cross(normal, dp1);
  var dpdu: vec3<f32> = dp2perp * duv1.x + dp1perp * duv2.x;
  var dpdv: vec3<f32> = dp2perp * duv1.y + dp1perp * duv2.y;
  let invmax: f32 = inverseSqrt(max(dot(dpdu, dpdu), dot(dpdv, dpdv)));
  dpdu = dpdu * invmax;
  dpdv = dpdv * invmax;
  let tbn: mat3x3<f32> = mat3x3<f32>(dpdu, dpdv, normal);
  var tNormal: vec3<f32> = 2. * textureSample(u_normalTextures, u_sampler, uv, textureId).xyz - 1.;
  tNormal = tNormal * vec3<f32>(normalScale, normalScale, 1.);

  return normalize(tbn * tNormal);
}


[[stage(fragment)]]
fn main(vo: VertexOutput) -> FragmentOutput {
  var fo: FragmentOutput;

  let meshId: u32 = vo.meshMatIndex[0];
  let matId: u32 = vo.meshMatIndex[1];
  let metallicRoughnessFactorNormalScaleMaterialType: vec4<f32> = material.u_metallicRoughnessFactorNormalScaleMaterialTypes[matId];
  let specularGlossinessFactor: vec4<f32> = material.u_specularGlossinessFactors[matId];
  let textureIds: vec4<i32> = material.u_matId2TexturesId[matId];
  let matType = u32(metallicRoughnessFactorNormalScaleMaterialType[3]);
  let isSpecGloss: bool = isMatSpecGloss(matType);

  fo.positionMetalOrSpec = vec4<f32>(vo.wPosition.xyz, 0.);

  let baseColor: vec4<f32> = getBaseColor(material.u_baseColorFactors[matId], textureIds[0], vo.texcoord_0);
  fo.baseColorRoughOrGloss = vec4<f32>(baseColor.rgb, 0.);

  if (isSpecGloss) {
    fo.positionMetalOrSpec.w = getSpecular(specularGlossinessFactor.xyz, textureIds[2], vo.texcoord_0).r;
    fo.baseColorRoughOrGloss.w = getGlossiness(specularGlossinessFactor[3], textureIds[2], vo.texcoord_0);
  } else {
    fo.positionMetalOrSpec.w = getMetallic(metallicRoughnessFactorNormalScaleMaterialType[0], textureIds[2], vo.texcoord_0);
    fo.baseColorRoughOrGloss.w = getRoughness(metallicRoughnessFactorNormalScaleMaterialType[1], textureIds[2], vo.texcoord_0);
  }

  let faceNormal: vec3<f32> = getFaceNormal(vo.wPosition.xyz);
  fo.normalGlass = vec4<f32>(
    getNormal(vo.normal, vo.wPosition.xyz, faceNormal, textureIds[1], vo.texcoord_0, metallicRoughnessFactorNormalScaleMaterialType[2]),
    baseColor.a
  );

  fo.meshIndexMatIndexMatType = vec4<u32>(meshId, matId, matType, 2u);

  return fo;
}

可见这个比较顶点处理就复杂多了,但其实也不难理解。从宏观角度来看,片元处理的目的就是将需要的数据写入到输出的目标纹理中,这里我们可以回忆一下在只文章WebGPU基础与简单渲染器中讲过的RenderTexture,那时候提到了它支持多渲染目标MRT,其实就是主要用在了GBuffer中。要使用MRT,我们首先需要在CPU创建一个RenderTexture(下一节会讲),然后在FS中按照顺序定义好FragmentOutput作为它的输出,即可完成CPU和GPU两端的映射。

在实际的Shader编程中,我们可以认为FS可以输出到不同的纹理,本FS就输出到了以下几个纹理中:

  1. positionMetalOrSpec:存储世界空间的坐标,以及金属工作流下的metallic或者高光工作流下的specular,注意specular本来有三个通道,但这里只保留一个,这是出于节省空间,而且实际场合中其实一个通道也不是不行(逃。
  2. baseColorRoughOrGloss:存储baseColor,以及金属工作流下的roughness或者高光工作流下的glossiness
  3. normalGlass:存储世界空间的顶点法线normal,以及透射材质的折射率的倒数glass,之所以存倒数,是因为实际应用中折射率基本都大于1,而调整的时候0~1比较好调。
  4. meshIndexMatIndexMatType:存储顶点索引、材质索引、材质类型,注意这是一个u8向量,意味着整个场景支持的Mesh和材质数量最多256。

而其中的大部分计算其实都是索引,比如通过vo.meshMatIndex[1]拿到这个片元对应的材质id,然后通过material.u_matId2TexturesId[matId]获取到几种纹理的id数组,再通过textureIds[0]获取到对应baseColor纹理的id,最后用这个id和uv去用textureSampleLevel(u_baseColorTextures, u_sampler, uv, textureId, 0.)采样数组纹理,获得最终的baseColor值。

在这些最终输出到纹理的数据的计算中,有几个需要特别说明:

法线计算

和其他数据不同,这里法线的计算比较复杂,因为法线本身就有其复杂性,每个着色的片元都有自己的顶点法线、面法线和可能的法线贴图采样值:

  1. 顶点法线:顶点数据中的法线插值而来,如果没有法线贴图,就是最终的法线矢量的
  2. 面法线:顶点所在三角面的法线,一般用于和顶点法线计算求取法线矢量最终的方向
  3. 法线贴图:由于物体的表面会有复杂的凹凸不平,全部用几何信息描述开销过高,而使用贴图来存储法线信息能有效降低开销,一般会构建一个切线空间,将实际的顶点法线换算到其中,在渲染时还原。为何要使用切线空间读者可以自行查找,不再赘述。

面法线的一般计算方法是利用三角面两个边向量的叉乘,在片元着色器中无法直接获取边向量的信息,但有Derivative functions,可以用于近似计算,具体实现为上面的getFaceNormal函数,这里我们计算了世界空间坐标对屏幕空间坐标的导数,最终近似求得世界空间的面法线

而对于最终法线的求取,如果没有使用法线贴图,那么直接用已有的世界空间顶点法线和面法线点乘求得方向,返回即可。但如果有法线贴图,就需要涉及到切线空间的反变换了,如果使用传统的算法,还需要提供预计算的顶点切线数据Tangent,最终利用顶点法线、切线构造TBN矩阵,但由于懒得生成,这里就使用了这篇文章介绍的方法,其本质上是还原了切线的预计算过程。当然这也是近似,毕竟无法拿到真正的三角面的三个顶点数据。

meshIndexMatIndexMatType的alpha通道

读者可能刚才已经疑惑了为什么这个纹理的alpha通道要给个2,这其实是处于后续运算的考虑。在实际的渲染中,并非每个像素都会被三角面覆盖,所以一些像素是没有值的,对应于路径追踪流程,就是这个像素射出的射线和场景物体没有任何交点。至于为何是2而不是1,因为alpha会被相机clear为1,设计而已。

渲染和展示

现在我们有了GBuffer需要的Mesh,接下来只需要创建用于MRT的RenderTexture,并将它们关联起来:

this._gBufferRT = new H.RenderTexture({
  width: renderEnv.width,
  height: renderEnv.height,
  colors: [
    {name: 'positionMetalOrSpec', format: 'rgba16float'},
    {name: 'baseColorRoughOrGloss', format: 'rgba16float'},
    {name: 'normalGlass', format: 'rgba16float'},
    {name: 'meshIndexMatIndexMatType', format: 'rgba8uint'}
  ],
  depthStencil: {needStencil: false}
});

const {material} = this._gBufferDebugMesh = new H.ImageMesh(new H.Material(H.buildinEffects.iRTGShow));
material.setUniform('u_gbPositionMetalOrSpec', this._gBufferRT, 'positionMetalOrSpec');
material.setUniform('u_gbBaseColorRoughOrGloss', this._gBufferRT, 'baseColorRoughOrGloss');
material.setUniform('u_gbNormalGlass', this._gBufferRT, 'normalGlass');
material.setUniform('u_gbMeshIndexMatIndexMatType', this._gBufferRT, 'meshIndexMatIndexMatType');

然后渲染就OK:

this._scene.setRenderTarget(this._gBufferRT);
this._scene.renderCamera(this._camera, [this._rtManager.gBufferMesh]);

最后我用一个ImageMesh将最终的结果显示了出来,如图:

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