【WebGPU实时光追美少女】踩坑与调试心得

少女dtysky

世界Skill

时刻2021.10.05

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

在前面的七篇文章中,我从概论开始,论述了简单WebGPU渲染引擎的实现,并实现了一个支持BVH加速结构和BRDF光照模型的实时路径追踪渲染器。但由于WebGPU的API的实验性,目前相关标准仍然可能不断变更,而由于配套于WebGPU的调试工具还不存在,所以在不重编Chromium的情况下只能想一些朴素的方法来调试,这里就记录了一些调试心得。

教程基础部分到此完结,BSDF和BVH优化部分等有生之年吧。又在焦虑中做到了一次无偿的知识分享,算是以此再次纪念“互联网之子”亚伦·斯沃茨

关于API变更

目前WebGPU的API标准仍然可能变更,所以发现之前跑得好好的代码忽然就挂了也是正常的,当遇到这种问题后,我们一般需要去以下几个地方寻找解决方案:

Chromium WebGPU部分的Issue: https://bugs.chromium.org/p/dawn/issues/list

WebGPU最新标准:https://www.w3.org/TR/webgpu

着色语言WGSL最新标准: https://www.w3.org/TR/WGSL

同时有时候报错会不清晰,这时候可以直接看Chromium的WebGPU模块源码来分析:

Dawn: https://dawn.googlesource.com/dawn/+/HEAD/src/dawn_native

如何调试CS

对于有经验的开发者来说,相对而言渲染的部分比较好调试,实在不行可以使用朴素的Color Picker大法,毕竟有了数据什么都好说,并且对于路径追踪来讲渲染部分并不复杂,不需要怎么调试。

Compute Shader部分就不同了,在路径追踪实现中我大量使用了CS,每个部分都并不简单,而且环环相扣,出了问题十分难以排查,下面每一部我都遇到过问题:

  1. 射线生成。
  2. BVH求交。
  3. 三角形求交。
  4. 重心坐标插值。
  5. 重要性采样。
  6. BRDF着色计算。

比如在一开始,我遇到过这种情况:

当然对路径追踪十分熟悉的朋友,应该一眼就能看出这可能是射线生成的问题,但当时刚开始学习并没想到这个,况且就算想到了也需要有方法来确认和调试,为了调试,我使用SSBO构建了一条用来调试CS的流程。

SSBO

SSBO即Shader Storage Buffer Object,在前面的主流程中也使用过,用于存储合并过的场景数据。这种数据可以被CPU和GPU共享,不但可以用于从CPU向GPU传输数据,也可以将数据从GPU到CPU读回来。虽然这两个过程都比较慢,但对于调试而言已经足够。

在构建流程之前,需要先明确调试的步骤:

  1. 路径追踪过程始于屏幕空间的像素,所以以像素为入口调试。
  2. 构建一个屏幕大小的SSBO来存储CS计算过程中的信息,SSBO中的数据结构可以按情况定制。
  3. 可以用一个简单的策略,读回需要的GPU数据,在CPU端复刻算法进行的调试。

梳理清楚流程后,我编写了DebugInfo类以及各个Debug方法,比如debugRaydebugRayShadow,它们都在demo/debugCS.ts中。

DebugInfo

DebugInfo提供了给RayTracingManager中用于路径追踪的计算单元rtUnit注入调试信息的能力,这个调试信息通过一个SSBO作为Uniform传入,骑在Shader中的定义为:

struct DebugRay {
  preOrigin: vec4<f32>;
  preDir: vec4<f32>;
  origin: vec4<f32>;
  dir: vec4<f32>;
  nextOrigin: vec4<f32>;
  nextDir: vec4<f32>;
  normal: vec4<f32>;
};

[[block]] struct DebugInfo {
  rays: array<DebugRay>;
};`

其对应的Interface和具体构造为:

interface IDebugPixel {
  preOrigin: Float32Array;
  preDir: Float32Array;
  origin: Float32Array;
  dir: Float32Array;
  nextOrigin: Float32Array;
  nextDir: Float32Array;
  normal: Float32Array;
}

export class DebugInfo {
  protected _cpu: Float32Array;
  protected _gpu: GPUBuffer;
  protected _view: GPUBuffer;
  protected _size: number;
  protected _rtManager: H.RayTracingManager

  // 构造SSBO并作为Uniform注入给计算单元
  public setup(rtManager: H.RayTracingManager) {
    const {renderEnv} = H;
    const size = this._size = 4 * 7;

    this._cpu = new Float32Array(size * renderEnv.width * renderEnv.height);
    this._gpu = H.createGPUBuffer(this._cpu, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
    this._view = H.createGPUBufferBySize(size * renderEnv.width * renderEnv.height * 4, GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST);

    this._rtManager = rtManager;
    this._rtManager.rtUnit.setUniform('u_debugInfo', this._cpu, this._gpu)
  }

  // 拷贝SSBO,具体原因详见下面
  public run(scene: H.Scene) {
    scene.copyBuffer(this._gpu, this._view, this._cpu.byteLength);
  }

  // 从GPU读回数据,并指定需要解析的区域
  public async showDebugInfo(
    point1: [number, number],
    len: [number, number],
    step: [number, number]
  ): Promise<{
    rays: IDebugPixel[],
    mesh: H.Mesh
  }> {
    await this._view.mapAsync(GPUMapMode.READ);
    const data = new Float32Array(this._view.getMappedRange());
    const rays = this._decodeDebugInfo(data, point1, len, step);
  }

// 将读回的数据进行解析,变成可理解的结构
  protected _decodeDebugInfo(
    view: Float32Array,
    point1: [number, number],
    len: [number, number],
    step: [number, number]
  ) {
    const res: IDebugPixel[] = [];
    const logs = [];

    for (let y = point1[1]; y < point1[1] + len[1] * step[1]; y += step[1]) {
      for (let x = point1[0]; x <  point1[0] + len[0] * step[0]; x += step[0]) {
        const index = y * H.renderEnv.width + x;
        const offset = index * this._size;
        res.push({
          preOrigin: view.slice(offset, offset + 4),
          preDir: view.slice(offset + 4, offset + 8),
          origin: view.slice(offset + 8, offset + 12),
          dir: view.slice(offset + 12, offset + 16),
          nextOrigin: view.slice(offset + 16, offset + 20),
          nextDir: view.slice(offset + 20, offset + 24),
          normal: view.slice(offset + 24, offset + 28)
        } as IDebugPixel);
      }
    }

    return res;
  }
}

在这段代码中,需要注意的是我创建了两个GPUBuffer,一个设置给了uniform,另一个则用于读取,而期间做了一次拷贝scene.copyBuffer,这个拷贝的实现为:

this._command.copyBufferToBuffer(src, 0, dst, 0, size);

这是由于在WebGPU标准中,对GPUBuffer的使用有所限制,STORAGE不能和MAP_READ共存,也就是说一个Buffer不能又可以被CPU读又可以被GPU读,所以必须要先将其拷贝到另一个GPUBuffer中,再进行读取。

在GPU中填充数据

在流程组织完毕后,便可以在GPU中对SSBO进行填充,这个可以根据不同场景有不同的设置,比如这里是:

var hited: f32 = 0.;
if (hit.hit) {
  hited = 1.;
}
ray = startRay;
hit = gBInfo;
light = calcLight(ray, hit, baseUV, 0, false, false, debugIndex);
u_debugInfo.rays[debugIndex].origin = vec4<f32>(ray.origin, hit.sign);
u_debugInfo.rays[debugIndex].dir = vec4<f32>(ray.dir, f32(hit.matType));
ray = light.next;
hit = hitTest(ray);
light = calcLight(ray, hit, baseUV, 1, false, false, debugIndex);
u_debugInfo.rays[debugIndex].nextOrigin = vec4<f32>(ray.origin, hit.sign);
u_debugInfo.rays[debugIndex].nextDir = vec4<f32>(ray.dir, f32(hit.matType));
u_debugInfo.rays[debugIndex].normal = vec4<f32>(hit.normal, hit.glass);

我将初始射线的参数和在CS中求得的下一条射线参数记录了下来,以待调试。

在CPU调试指定数据

有了GPU的数据,就可以通过上面的showDebugInfo函数,来通过指定的像素位置和步长,来decode一定窗口大小的数据,只要有一个时机来触发即可:

H.renderEnv.canvas.addEventListener('mouseup', async (e) => {
  const {clientX, clientY} = e;
  const {rays, mesh} = await this._rtDebugInfo.showDebugInfo([clientX, clientY], [10, 10], [4, 2]);
  console.log(rays);
  rays.forEach(ray => debugRay(ray, this._rtManager.bvh, this._rtManager.gBufferMesh.geometry.getValues('position').cpu as Float32Array));
});

这段代码将调试过程和鼠标点击事件关联了起来,注意到最后一句我对每个像素的数据都执行了debugRay操作,这其实就是在CPU内实现了和GPU一样的算法,比如光源采样、BVH求交和三角形求交等等。由于是JS代码,所以很容易进行调试,并且也可以和GPU中存下的结果直接进行对比,虽然也很麻烦,但至少有一个调试的方法了,比如debugRay

export function debugRay(rayInfo: {origin: Float32Array, dir: Float32Array}, bvh: H.BVH, positions: Float32Array) {
  const ray: Ray = {
    origin: rayInfo.origin,
    dir: rayInfo.dir,
    invDir: new Float32Array(3)
  };
  H.math.vec3.div(ray.invDir, new Float32Array([1, 1, 1]), ray.dir);

  console.log('ray info', rayInfo);
  console.log('ray', ray);

  const fragInfo = hitTest(bvh, ray, positions);
  console.log(fragInfo);
}

这里可以看到此调试方法将上面decode好的信息中的origindir作为参数进行了CPU端的hitTesthitTest的实现对于此章节无关紧要,不再赘述。

合理借助外部工具

在实际的调试过程中,往往即便我们有了数据和调试函数,但像是BVH求交、三角形求交这种过程的计算比较复杂,数值上又很难分析出结果,这时候就需要外部工具的帮助了。设想如果有个工具能够将三角形、射线这些都直观得在三维空间内简洁地绘制出来,调试难度会大幅降低。

而这个工具确实存在,而且是免费在线的——Wolfram Cloud,可以认为是Mathematica的在线版,功能其实已经够用了。

对于本项目,用它调试最核心的就是生成各种绘制指令,我在CPU端的调试代码中插入了各种生成绘制指令的代码,比如:

// 绘制点
plotS.push(`Graphics3D[{Red, PointSize[0.1], Point[{${rsiPoints[1].join(',')}}]}]`);

// 绘制射线
plotS.push(`ParametricPlot3D[{${ray.dir[0]}t + ${ray.origin[0]}, ${ray.dir[1]}t + ${ray.origin[1]}, ${ray.dir[2]}t + ${ray.origin[2]}}, {t, 0, ${maxT}}]`);

// 绘制三角形
plotS.push(`Graphics3D[Triangle[{{${p0.join(',')}}, {${p1.join(',')}}, {${p2.join(',')}}}]]`);

// 绘制长方体
plotS.push(`Graphics3D[Cuboid[{${node.max.join(',')}}, {${node.min.join(',')}}]]`);

有了这些指令,最后我们就可以画出类似下面的图形:

结语

当然除了调试BVH求交这种问题,像是光照计算等等也都可以通过这种方式输出数据来观察和调试,在开发过程中我也是这么做的。

一开始计划的时候本篇应该还是有挺多内容,但现在回想起来很多踩的坑已经在前面的文章都论述过了,比如16字节对齐GPUBuffer的Usage射线原点偏移等等。

那么就到此为止吧,祝大家国庆快乐。

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