【WebGPU实时光追美少女】踩坑与调试心得
本文首发于 我的博客 ,请规范转载
本系列文章设计的所有代码均已开源,Github仓库在这里: dtysky/webgpu-renderer 。
在前面的七篇文章中,我从概论开始,论述了简单WebGPU渲染引擎的实现,并实现了一个支持BVH加速结构和BRDF光照模型的实时路径追踪渲染器。但由于WebGPU的API的实验性,目前相关标准仍然可能不断变更,而由于配套于WebGPU的调试工具还不存在,所以在不重编Chromium的情况下只能想一些朴素的方法来调试,这里就记录了一些调试心得。
教程基础部分到此完结,BSDF和BVH优化部分等有生之年吧。又在焦虑中做到了一次无偿的知识分享,算是以此再次纪念 “互联网之子”亚伦·斯沃茨 。
关于API变更
目前WebGPU的API标准仍然可能变更,所以发现之前跑得好好的代码忽然就挂了也是正常的,当遇到这种问题后,我们一般需要去以下几个地方寻找解决方案:
Chromium WebGPU部分的Issue: https:// bugs.chromium.org/p/daw n/issues/list
WebGPU最新标准: https://www. w3.org/TR/webgpu
着色语言WGSL最新标准: https://www. w3.org/TR/WGSL
同时有时候报错会不清晰,这时候可以直接看Chromium的WebGPU模块源码来分析:
Dawn: https:// dawn.googlesource.com/d awn/+/HEAD/src/dawn_native
如何调试CS
对于有经验的开发者来说,相对而言渲染的部分比较好调试,实在不行可以使用朴素的 Color Picker大法 ,毕竟有了数据什么都好说,并且对于路径追踪来讲渲染部分并不复杂,不需要怎么调试。
但 Compute Shader 部分就不同了,在路径追踪实现中我大量使用了CS,每个部分都并不简单,而且环环相扣,出了问题十分难以排查,下面每一部我都遇到过问题:
- 射线生成。
- BVH求交。
- 三角形求交。
- 重心坐标插值。
- 重要性采样。
- BRDF着色计算。
比如在一开始,我遇到过这种情况:

当然对路径追踪十分熟悉的朋友,应该一眼就能看出这可能是射线生成的问题,但当时刚开始学习并没想到这个,况且就算想到了也需要有方法来确认和调试,为了调试,我使用
SSBO
构建了一条用来调试CS的流程。
SSBO
SSBO即
Shader Storage Buffer Object
,在前面的主流程中也使用过,用于存储合并过的场景数据。这种数据可以被CPU和GPU共享,不但可以用于从CPU向GPU传输数据,也可以将数据从GPU到CPU读回来。虽然这两个过程都比较慢,但对于调试而言已经足够。
在构建流程之前,需要先明确调试的步骤:
- 路径追踪过程始于屏幕空间的像素,所以以像素为入口调试。
- 构建一个屏幕大小的SSBO来存储CS计算过程中的信息,SSBO中的数据结构可以按情况定制。
- 可以用一个简单的策略,读回需要的GPU数据,在CPU端复刻算法进行的调试。
梳理清楚流程后,我编写了
DebugInfo
类以及各个Debug方法,比如
debugRay
、
debugRayShadow
,它们都在
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好的信息中的
origin
和
dir
作为参数进行了CPU端的
hitTest
,
hitTest
的实现对于此章节无关紧要,不再赘述。
合理借助外部工具
在实际的调试过程中,往往即便我们有了数据和调试函数,但像是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}}]`);