乐趣区

关于webgl:WebGPU-的几个最佳实践

来自 2022 WebGL & WebGPU Meetup 的 幻灯片

1 在能用的中央都用 label 属性

WebGPU 中的每个对象都有 label 属性,不论你是创立它的时候通过传递 descriptor 的 label 属性也好,亦或者是创立实现后间接拜访其 label 属性也好。这个属性相似于一个 id,它能让对象更便于调试和察看,写它简直不须要什么老本考量,然而调试的时候会十分、十分爽。

const projectionMatrixBuffer = gpuDevice.createBuffer({
  label: 'Projection Matrix Buffer',
  size: 12 * Float32Array.BYTES_PER_ELEMENT, // 成心设的 12,实际上矩阵应该要 16
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
})
const projectionMatrixArray = new Float32Array(16)

gpuDevice.queue.writeBuffer(projectionMatrixBuffer, 0, projectionMatrixArray)

下面代码成心写错的矩阵所用 GPUBuffer 的大小,在谬误校验的时候就会带上 label 信息了:

// 控制台输入
Write range (bufferOffset: 0, size: 64) does not fit in [Buffer "Projection Matrix Buffer"] size (48).

2 应用调试组

指令缓冲(CommandBuffer)容许你增删调试组,调试组其实就是一组字符串,它批示的是哪局部代码在执行。谬误校验的时候,报错音讯会显示调用堆栈:

// --- 第一个调试点:标记以后帧 ---
commandEncoder.pushDebugGroup('Frame ${frameIndex}');
  // --- 第一个子调试点:标记灯光的更新 ---
  commandEncoder.pushDebugGroup('Clustered Light Compute Pass');
        // 譬如,在这里更新光源
    updateClusteredLights(commandEncoder);
  commandEncoder.popDebugGroup();
  // --- 完结第一个子调试点 ---
  // --- 第二个子调试点:标记渲染通道开始 ---
  commandEncoder.pushDebugGroup('Main Render Pass');
    // 触发绘制
    renderScene(commandEncoder);
  commandEncoder.popDebugGroup();
  // --- 完结第二个子调试点
commandEncoder.popDebugGroup();
// --- 完结第一个调试点 ---

这样,如果有报错音讯,就会提醒:

// 控制台输入
Binding sizes are too small for bind group [BindGroup] at index 0

Debug group stack:
> "Main Render Pass"
> "Frame 234"

3 从 Blob 中载入纹理图像

应用 Blob 创立的 ImageBitmaps 能够获得最佳的 JPG/PNG 纹理解码性能。

/**
 * 依据纹理图片门路异步创立纹理对象,并将纹理数据拷贝至对象中
 * @param {GPUDevice} gpuDevice 设施对象
 * @param {string} url 纹理图片门路
 */
async function createTextureFromImageUrl(gpuDevice, url) {const blob = await fetch(url).then((r) => r.blob())
  const source = await createImageBitmap(blob)
  
  const textureDescriptor = {label: `Image Texture ${url}`,
    size: {
      width: source.width,
      height: source.height,
    },
    format: 'rgba8unorm',
    usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
  }
  const texture = gpuDevice.createTexture(textureDescriptor)
  gpuDevice.queue.copyExternalImageToTexture({ source},
    {texture},
    textureDescriptor.size,
  )
  
  return texture
}

更举荐应用压缩格局的纹理资源

能用就用。

WebGPU 反对至多 3 种压缩纹理类型:

  • texture-compression-bc
  • texture-compression-etc2
  • texture-compression-astc

反对多少是取决于硬件能力的,依据官网的探讨(Github Issue 2083),全平台都要反对 BC 格局(又名 DXT、S3TC),或者 ETC2、ASTC 压缩格局,以保障你能够用纹理压缩能力。

强烈推荐应用超压缩纹理格局(例如 Basis Universal),益处是能够忽视设施,它都能转换到设施反对的格局上,这样就防止筹备两种格局的纹理了。

原作者写了个库,用于在 WebGL 和 WebGPU 种加载压缩纹理,参考 Github toji/web-texture-tool

WebGL 对压缩纹理的反对不太好,当初 WebGPU 原生就反对,所以尽可能用吧!

4 应用 glTF 解决库 gltf-transform

这是一个开源库,你能够在 GitHub 上找到它,它提供了命令行工具。

譬如,你能够应用它来压缩 glb 种的纹理:

> gltf-transform etc1s paddle.glb paddle2.glb
paddle.glb (11.92 MB) → paddle2.glb (1.73 MB)

做到了视觉无损,然而从 Blender 导出的这个模型的体积能小很多。原模型的纹理是 5 张 2048 x 2048 的 PNG 图。

这库除了压缩纹理,还能缩放纹理,重采样,给几何数据附加 Google Draco 压缩等诸多性能。最终优化下来,glb 的体积只是原来的 5% 不到。

> gltf-transform resize paddle.glb paddle2.glb --width 1024 --height 1024
> gltf-transform etc1s paddle2.glb paddle2.glb
> gltf-transform resample paddle2.glb paddle2.glb
> gltf-transform dedup paddle2.glb paddle2.glb
> gltf-transform draco paddle2.glb paddle2.glb

  paddle.glb (11.92 MB) → paddle2.glb (596.46 KB)

5 缓冲数据上载

WebGPU 中有很多种形式将数据传入缓冲,writeBuffer() 办法不肯定是谬误用法。当你在 wasm 中调用 WebGPU 时,你应该优先思考 writeBuffer() 这个 API,这样就防止了额定的缓冲复制操作。

const projectionMatrixBuffer = gpuDevice.createBuffer({
  label: 'Projection Matrix Buffer',
  size: 16 * Float32Array.BYTES_PER_ELEMENT,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

// 当投影矩阵扭转时(例如 window 扭转了大小)function updateProjectionMatrixBuffer(projectionMatrix) {const projectionMatrixArray = projectionMatrix.getAsFloat32Array();
  gpuDevice.queue.writeBuffer(projectionMatrixBuffer, 0, projectionMatrixArray);
}

原作者指出,创立 buffer 时设 mappedAtCreation 并不是必须的,有时候创立时不映射也是能够的,譬如对 glTF 中无关的缓冲加载。

6 举荐异步创立 pipeline

如果你不是马上就要渲染管线或者计算管线,尽量用 createRenderPipelineAsynccreateComputePipelineAsync 这俩 API 来代替同步创立。

同步创立 pipeline,有可能会在底层去把管线的无关资源进行编译,这会中断 GPU 无关的步骤。

而对于异步创立,pipeline 没筹备好就不会 resolve Promise,也就是说能够优先让 GPU 以后在干的事件先做完,再去折腾我所须要的管线。

上面看看比照代码:

// 同步创立计算管线
const computePipeline = gpuDevice.createComputePipeline({/* ... */})

computePass.setPipeline(computePipeline)
computePass.dispatch(32, 32) // 此时触发调度,着色器可能在编译,会卡 

再看看异步创立的代码:

// 异步创立计算管线
const asyncComputePipeline = await gpuDevice.createComputePipelineAsync({/* ... */})

computePass.setPipeline(asyncComputePipeline)
computePass.dispatch(32, 32) // 这个时候着色器早已编译好,没有卡顿,棒棒哒 

7 慎用隐式管线布局

隐式管线布局,尤其是独立的计算管线,或者对写 js 的时候很爽,然而这么做会带来俩潜在问题:

  • 中断共享资源绑定组
  • 更新着色器时产生点奇怪的事件

如果你的状况特地简略,能够应用隐式管线布局,然而能用显式创立管线布局就显式创立。

上面就是所谓的隐式管线布局的创立形式,先创立的管线对象,而后调用管线的 getBindGroupLayout() API 推断着色器代码中所需的管线布局对象。

const computePipeline = await gpuDevice.createComputePipelineAsync({
  // 不传递布局对象
  compute: {
    module: computeModule,
    entryPoint: 'computeMain'
  }
})

const computeBindGroup = gpuDevice.createBindGroup({
  // 获取隐式管线布局对象
  layout: computePipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: {buffer: storageBuffer},
  }]
})

7 共享资源绑定组与绑定组布局对象

如果在渲染 / 计算过程中,有一些数值是不会变然而频繁要用的,这种状况你能够创立一个简略一点的资源绑定组布局,可用于任意一个应用了同一号绑定组的管线对象上。

首先,创立资源绑定组及其布局:

// 创立一个相机 UBO 的资源绑定组布局及其绑定组本体
const cameraBindGroupLayout = device.createBindGroupLayout({
  label: `Camera uniforms BindGroupLayout`,
  entries: [{
    binding: 0,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
    buffer: {},}]
})

const cameraBindGroup = gpu.device.createBindGroup({
  label: `Camera uniforms BindGroup`,
  layout: cameraBindGroupLayout,
  entries: [{
    binding: 0,
    resource: {buffer: cameraUniformsBuffer,},
  }],
})

随后,创立两条渲染管线,留神到这两条管线都用到了两个资源绑定组,有区别的中央就是用的材质资源绑定组是不一样的,共用了相机资源绑定组:

const renderPipelineA = gpuDevice.createRenderPipeline({
  label: `Render Pipeline A`,
  layout: gpuDevice.createPipelineLayout([cameraBindGroupLayout, materialBindGroupLayoutA]),
  /* Etc... */
});

const renderPipelineB = gpuDevice.createRenderPipeline({
  label: `Render Pipeline B`,
  layout: gpuDevice.createPipelineLayout([cameraBindGroupLayout, materialBindGroupLayoutB]),
  /* Etc... */
});

最初,在渲染循环的每一帧中,你只需设置一次相机的资源绑定组,以缩小 CPU ~ GPU 的数据传递:

const renderPass = commandEncoder.beginRenderPass({/* ... */});

// 只设定一次相机的资源绑定组
renderPass.setBindGroup(0, cameraBindGroup);

for (const pipeline of activePipelines) {renderPass.setPipeline(pipeline.gpuRenderPipeline)
  for (const material of pipeline.materials) {
      // 而对于管线中的材质资源绑定组,就别离设置了
    renderPass.setBindGroup(1, material.gpuBindGroup)
    
    // 此处设置 VBO 并收回绘制指令,略
    for (const mesh of material.meshes) {renderPass.setVertexBuffer(0, mesh.gpuVertexBuffer)
      renderPass.draw(mesh.drawCount)
    }
  }
}

renderPass.endPass()

原作附带信息

  • 作者:Brandon Jones,推特 @Tojiro
  • 原幻灯片:https://docs.google.com/prese…
  • 更多额定浏览:https://toji.github.io/webgpu…
  • 一个很棒的原生 WebGPU 教程(英文):https://alain.xyz/blog/raw-we…
  • 对于纹理的比照细节:https://toji.github.io/webgpu…
  • 对于缓冲上载的细节:https://toji.github.io/webgpu…
退出移动版