WebGPU Rendering: Part 3 Depth Testing
Introduction
I have been reading through this online book on WebGPU. In this series of articles, I will be going through this book and implementing the lessons in a more structured typescript class approach and eventually we will build three types of WebGPU renderers: Gaussian Splatting, Ray tracing and Rasterization.
In this article we will address depth testing. In our renderer, we are trying to display 3D content on a 2D screen, an issue that arises from this projection is the conflict of what to show if 2 or more 3D points map to the same 2D screen coordinate. In the image on the left, we just show whatever the last vertex value was — which is why we can see the back faces (green)– in this article we will introduce depth testing to our render pipeline allowing us to encode a sense of back to front ordering of the vertices during rasterization and fragment shading.
The following link is the commit in my Github repo that matches the code we will go over.
Shader
We will use a simplified shader for this example, it just takes in uniforms for transformation and camera projection and then a single vertex buffer for the position. The coloring is done based on if we are looking at the front face (blue) or back face (green).
@group(0) @binding(0)
var<uniform> transform: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> projection: mat4x4<f32>;
struct VertexOutput {
@builtin(position) position: vec4<f32>
};
@vertex
fn vs_main(
@location(0) inPos: vec3<f32>
) -> VertexOutput {
var out: VertexOutput;
out.position = projection * transform * vec4<f32>(inPos, 1.0);
return out;
}
// Fragment shader
@fragment
fn fs_main(in: VertexOutput, @builtin(front_facing) face: bool) -> @location(0) vec4<f32> {
if (face) {
return vec4<f32>(0.0, 0.0, 1.0 ,1.0);
}
else {
return vec4<f32>(0.0, 1.0, 0.0 ,1.0);
}
}
Depth Stencil
To add considerations for depth into our render, we will need two things: a depth stencil state which we will pass in from the application to our pipeline and a depth texture which we will create and add into our render target.
Passing in the State
Our application will call a render function which takes in a GPUDepthStencilState
const depthStencilState: GPUDepthStencilState = {
depthWriteEnabled: true,
depthCompare: 'less' as GPUCompareFunction,
format: 'depth24plus-stencil8' as GPUTextureFormat,
}
This will make its way into our pipeline to help configure it. This basically says that depth writing is enabled and we should write the object with a lower value (i.e. closer to the camera) to our result.
Create Depth Texture
We will create a function called createDepthTexture which will make a texture to render depth info to
private _createDepthTexture(): GPUTexture {
const depthTextureDesc: GPUTextureDescriptor = {
size: { width: this._canvas.width, height: this._canvas.height },
dimension: '2d',
format: 'depth24plus-stencil8',
usage: GPUTextureUsage.RENDER_ATTACHMENT
};
const depthTexture = this._device.createTexture(depthTextureDesc);
return depthTexture;
}
Use Depth Texture in Render Target
Our createRenderTarget function will now take in an optional value for the depth texture and update our render pass to use it if it is present.
private _createRenderTarget(depthTexture?: GPUTexture): GPURenderPassDescriptor {
const colorTexture = this._context.getCurrentTexture();
const colorTextureView = colorTexture.createView();
const colorAttachment: GPURenderPassColorAttachment = {
view: colorTextureView,
clearValue: { r: 1, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [colorAttachment]
}
if (depthTexture) {
renderPassDescriptor.depthStencilAttachment = {
view: depthTexture.createView(),
depthClearValue: 1,
depthLoadOp: 'clear',
depthStoreOp: 'store',
stencilClearValue: 0,
stencilLoadOp: 'clear',
stencilStoreOp: 'store'
}
}
return renderPassDescriptor;
}
This function is augmented to add the depthStencilAttachment property to our render pass descriptor when appropriate. Our starting clear value for our depth is 1 (since all objects in projection space are from 0–1 depth). We can ignore the stencil arguments for now.
Render
Finally, we can use these new functions and parameters to build out our depth render.
public render_depth_testing(shaderCode: string, vertexCount: number, instanceCount: number, vertices: Float32Array, transformationMatrix: Float32Array, projectionMatrix: Float32Array, primitiveState: GPUPrimitiveState, depthStencilState: GPUDepthStencilState) {
const depthTexture = this._createDepthTexture();
const transformationMatrixBuffer = this._createGPUBuffer(transformationMatrix, GPUBufferUsage.UNIFORM);
const projectionMatrixBuffer = this._createGPUBuffer(projectionMatrix, GPUBufferUsage.UNIFORM);
const transformationMatrixBindGroupInput: IBindGroupInput = {
type: "buffer",
buffer: transformationMatrixBuffer,
}
const projectionMatrixBindGroupInput: IBindGroupInput = {
type: "buffer",
buffer: projectionMatrixBuffer,
}
const { bindGroupLayout: uniformBindGroupLayout, bindGroup: uniformBindGroup } = this._createUniformBindGroup([transformationMatrixBindGroupInput, projectionMatrixBindGroupInput]);
const { buffer: positionBuffer, layout: positionBufferLayout } = this._createSingleAttributeVertexBuffer(vertices, { format: "float32x3", offset: 0, shaderLocation: 0 }, 3 * Float32Array.BYTES_PER_ELEMENT);
const commandEncoder = this._device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass(this._createRenderTarget(depthTexture));
passEncoder.setViewport(0, 0, this._canvas.width, this._canvas.height, 0, 1);
passEncoder.setPipeline(this._createPipeline(this._createShaderModule(shaderCode), [positionBufferLayout], [uniformBindGroupLayout], primitiveState, depthStencilState));
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.setBindGroup(0, uniformBindGroup);
passEncoder.draw(vertexCount, instanceCount);
passEncoder.end();
this._device.queue.submit([commandEncoder.finish()]);
}
The major differences to note here are our call to createDepthTexture to set up our texture which we will render depth passes to, passing the depth texture to our createRenderTarget function and us passing the configuration of depthStencilState to our createPipeline function.
Conclusion
In this article, we learned how to add depth into our renderer, it was as simple as adding some configuration to our pipeline and creating a depth texture that we can render to.