Experimenting with Bindless Textures in WebGPU
When WebGPU first shipped back in 2023, one of the biggest gaps that it had relative to its native counterparts was that
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
When WebGPU first shipped back in 2023, one of the biggest gaps that it had relative to its native counterparts was that it didn’t support bindless resources. Happily, WebGPU is on the cusp of supporting bindless texture sampling, with other resource types coming after that! You can try it out today in your own pages, and see a live example of it in action at emojigraffiti.com.
Typically when developing with WebGPU you choose which resources (textures, buffers, samplers) can be accessed by the current render pipeline by placing them into Bind Groups. The bindings must be carefully coordinated between the shader, a Bind Group Layout, and the Bind Groups themselves. I wrote much more on the subject in my Bind Group best practices article if you want to know more.
The way this works in many renderers is that you’ll have some bind groups that are shared between multiple objects or an entire scene containing common data like camera uniforms or environment maps, and then one bind group per mesh or material that just contains the textures or matricies used by that object. Unfortunately creating and setting bind groups has some overhead, and while effective use of instancing and other data-packing tricks can reduce the total number of bindings needed, it’s still something that needs to be handled carefully by any efficient renderer.
A (very) simplified snippet of a renderer using a bind groups would look something like this:
// // "Bindful" renderer //
// Setup const meshShader = device.createShaderModule({ label: 'Mesh', code: ` struct Camera { projection: mat4x4f, view: mat4x4f, }; @group(0) @binding(0) var<uniform> camera: Camera; @group(0) @binding(1) var environmentCube: texture_cube<f32>;
struct Mesh { model: mat4x4f, baseColor: vec4f, }; @group(1) @binding(0) var<uniform> mesh: Mesh; @group(1) @binding(1) var colorTexture: texture_2d<f32>; @group(1) @binding(2) var metallicRoughnessTexture: texture_2d<f32>; @group(1) @binding(3) var meshSampler: sampler;
// ... Vertex entry point omitted
@fragment fn fragMain(@location(1) uv: vec2f, @location(2) normal: vec3f) -> vec4f { let color = mesh.baseColor * textureSample(colorTexture, meshSampler, uv); let metalRough = textureSample(metallicRoughnessTexture, meshSampler, uv); let env = textureSample(environmentCube, meshSampler, normal);
return pbrShade(color, metalRough, env); } ` });
// ... pipeline and bind group layout ommitted
const frameBindings = device.createBindGroup({ label: 'Frame', layout: frameBindGroupLayout, entries: [{ binding: 0, resource: cameraUniforBuffer, }, { binding: 1, resource: environmentCubeMap, }] });
for (const mesh of meshes) { mesh.meshBindings = device.createBindGroup({ label: 'Mesh', layout: frameBindGroupLayout, entries: [{ binding: 0, resource: mesh.uniforBuffer, }, { binding: 1, resource: mesh.colorTexture, }, { binding: 2, resource: mesh.metallicRoughnessTexture, }, { binding: 3, resource: defaultSampler, }] }); }
// Render Loop const pass = encoder.beginRenderPass({ // ...snip });
pass.setBindGroup(0, frameBindings); pass.setPipeline(meshPipeline); for (const mesh of meshes) { pass.setBindGroup(1, mesh.meshBindings); pass.setVertexBuffer(0, mesh.vertexBuffer); pass.setIndexBuffer(mesh.indexBuffer, 'uint32'); pass.drawIndexed(mesh.indexCount); }
pass.end();
Using Bindless
WebGPU’s upcoming bindless texturing model, on the other hand, allows you to throw most or all of the textures needed by an entire scene into one big Resource Table and access any of them via an index in your shaders. That index still get passed to the shader through a traditional means, like a bind group or immediates, but it allows for a much more dynamic usage of the textures in the shaders themselves. You can get an idea of how it works by looking at this re-write of the above snippet to use bindless texture sampling:
// // Bindless renderer //
// Setup const meshShader = device.createShaderModule({ label: 'Mesh', code: ` struct Camera { projection: mat4x4f, view: mat4x4f, environmentMap: u32, }; @group(0) @binding(0) var<uniform> camera: Camera;
struct Mesh { model: mat4x4f, baseColor: vec4f, color: u32, metallicRoughness: u32, sampler: u32, }; @group(1) @binding(0) var<uniform> mesh: Mesh;
// ... Vertex entry point omitted
@fragment fn fragMain(@location(1) uv: vec2f, @location(2) normal: vec3f) -> vec4f { // New: Get the resources from the resource table let colorTexture = getResource<texture_2d<f32>>(mesh.color); let metallicRoughnessTexture = getResource<texture_2d<f32>>(mesh.metallicRoughness); let environmentCube = getResource<texture_cube<f32>(camera.environmentMap); let meshSampler = getResource<sampler>(mesh.metallicRoughness);
let color = mesh.baseColor * textureSample(colorTexture, meshSampler, uv); let metalRough = textureSample(metallicRoughnessTexture, meshSampler, uv); let env = textureSample(environmentCube, meshSampler, normal);
return pbrShade(color, metalRough, env); } ` });
// ... pipeline and bind group layout ommitted
function SetInBuffer(uniformBuffer, offset, u32Value) { // Pseudocode that sets the u32Value at offset in uniformBuffer. // Details on how are unimportant for this code snippet. }
const resourceTable = device.createResourceTable({ size: 1024 // Number of resources, not bytes. Something big enough for your scene. });
const envMapIdx = resourceTable.insert(environmentCube); SetInBuffer(cameraUniforBuffer, 128, envMapIdx);
const frameBindings = device.createBindGroup({ label: 'Frame', layout: frameBindGroupLayout, entries: [{ binding: 0, resource: cameraUniforBuffer, }] });
for (const mesh of meshes) { const colorIdx = resourceTable.insert(mesh.colorTexture); const metalRoughIdx = resourceTable.insert(mesh.metallicRoughnessTexture); const samplerIdx = resourceTable.insert(defaultSampler); SetInBuffer(mesh.uniforBuffer, 64, colorIdx); SetInBuffer(mesh.uniforBuffer, 68, metalRoughIdx); SetInBuffer(mesh.uniforBuffer, 72, samplerIdx);
mesh.meshBindings = device.createBindGroup({ label: 'Mesh', layout: frameBindGroupLayout, entries: [{ binding: 0, resource: mesh.uniforBuffer, }] }); }
// Render Loop const pass = encoder.beginRenderPass({ // ...snip resourceTable: resourceTable, });
pass.setBindGroup(0, frameBindings); pass.setPipeline(meshPipeline); for (const mesh of meshes) { pass.setBindGroup(1, mesh.meshBindings); pass.setVertexBuffer(0, mesh.vertexBuffer); pass.setIndexBuffer(mesh.indexBuffer, 'uint32'); pass.drawIndexed(mesh.indexCount); }
pass.end();
There’s a few things to take note of here. The most obivous is that we can now using a single GPUResourceTable for all of the textures, regardless of where they are used. Instead of binding the textures directly as part of a bind group when we want to use them we instead pass the index for the texture that we received when inserting it. It’s important to know that you can mix and match: Textures can still be passed as part of a bind group, even when a Resource Table is being used.
The next thing to note is that, at least in this example, it didn’t actually reduce the number of bind groups of the frequency of binding. That may lead you to wonder what the benefit is? There’s a few answers to that! One is that depending on the structure of your rendering algorithm using bindless texture sampling may allow you to get rid of some bind groups! (Probably not all of all of them, but that’s OK!) If all your instance data is stored in the frameBindings, for example, you could get away with not using any bindings per-mesh and instead reference the instance data with either immediates or changing the firstInstance value in the draw call. The other benefit is that using bindless textures unlocks a class of algorithms that either wasn’t possible or was quite awkward to implement with the traditional binding model. Think of ray tracing, where a single ray may hit any number of meshes in the scene and it’s nearly impossible to predict in advance. That implies that you need to have access to the textures for any mesh at any time, which is what bindless texture sampling delivers.
In case it’s not obvious: This is not a comprehensive overview of how GPUResourceTables work. There are, for example, other ways to manage the resources in the table besides insert() that allow more flexibility while requiring more care on the part of the developer. See the Bindless proposal document for full details.
Bindless texture sampling is available for testing in Google Chrome on Windows, Linux, and Android. MacOS support will be coming soon. Since it is still considered experimental, it requires that you enable the “Unsafe WebGPU Support” flag in Chrome’s about:flags page. Please note that this is for development only and you generally should not do your day-to-day browsing with that flag enabled.
To demonstrate a scenario where bindless texture sampling enables or improves a renderer’s ability to achieve the effects it wants, I created emojigraffiti.com. It places you in a simple “gallery” environment with a spray can that can draw any emoji you want (and a few extras) onto the walls, giving you a canvas to decorate as you see fit!
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。