WebGPU Rendering: Part 12 Stencil Buffer
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 talk about the stencil buffer which is an advanced feature in graphics rendering that allows for per-pixel control over whether or not a fragment (pixel) is drawn. It works by allowing us to outline an area on the screen where subsequent render passes can draw content.
The following link is the commit in my Github repo that matches the code we will go over.
Stencil Buffer
We will be building a portal to another world using the stencil buffer. We will achieve this by first doing a render pass for our stencil buffer to mark a plane as the stencil area. Then, we will draw our Plane, Teapot and Frame in a new render pass, the objects will have their pipelines configured to read from the stencil buffer before drawing. The Teapot and Plane will only draw content within fragments marked by the stencil, while the Frame will ignore the stencil and draw no matter what, giving us a framed portal to another world.
Let’s start by talking about our stencil shader which we will use to outline our “portal” space.
@group(0) @binding(0)
var<uniform> modelView: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> projection: mat4x4<f32>;
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>
}
@vertex
fn vs_main(
@location(0) inPos: vec3<f32>
) -> VertexOutput {
var out: VertexOutput;
var world_loc:vec4<f32> = modelView * vec4<f32>(inPos, 1.0);
out.clip_position = projection * world_loc;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
Our shader will just take in some vertex positions for the stencil area — we will be using an OBJ to provide the stencil shape. Since we do not want to render any color and purely are using this shader to populate the stencil buffer, we return no visible color from our fragment shader.
We will encapsulate the Stencil object in its own class.
export class Stencil {
private _pipeline: GPURenderPipeline;
private _positionBuffer: GPUBuffer;
private _uniformBindGroup: GPUBindGroup;
private _indexBuffer?: GPUBuffer;
private _indexSize?: number;
public static async init(device: GPUDevice, modelViewMatrixUniformBuffer: GPUBuffer, projectionMatrixUnifromBuffer: GPUBuffer, shaderCode: string): Promise<Stencil> {
const shaderModule = device.createShaderModule({ code: shaderCode });
const objResponse = await fetch("./objs/stencil.obj");
const objBlob = await objResponse.blob();
const objText = await objBlob.text();
const objDataExtractor = new ObjDataExtractor(objText);
const positions = objDataExtractor.vertexPositions;
const positionBuffer = createGPUBuffer(device, positions, GPUBufferUsage.VERTEX);
const indices = objDataExtractor.indices;
const indexBuffer = createGPUBuffer(device, indices, GPUBufferUsage.INDEX);
const indexSize = indices.length;
const unifromBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 1,
visibility: GPUShaderStage.VERTEX,
buffer: {}
}
]
});
const uniformBindGroup = device.createBindGroup({
layout: unifromBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: modelViewMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUnifromBuffer
}
}
]
});
const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0,
offset: 0,
format: 'float32x3'
}
const positionBufferLayout: GPUVertexBufferLayout = {
attributes: [positionAttribDesc],
arrayStride: 3 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex'
}
const pipelineLayoutDesc: GPUPipelineLayoutDescriptor = { bindGroupLayouts: [unifromBindGroupLayout] };
const pipelineLayout = device.createPipelineLayout(pipelineLayoutDesc);
const colorState: GPUColorTargetState = {
format: 'bgra8unorm'
};
const pipelineDesc: GPURenderPipelineDescriptor = {
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-list',
frontFace: 'ccw',
cullMode: 'none'
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus-stencil8',
stencilFront: {
compare: "always",
passOp: "replace",
},
stencilBack: {
compare: "always",
passOp: "replace",
}
}
};
const pipeline = device.createRenderPipeline(pipelineDesc);
return new Stencil(pipeline, positionBuffer, uniformBindGroup, indexBuffer, indexSize);
}
public encodeRenderPass(renderPassEncoder: GPURenderPassEncoder) {
renderPassEncoder.setPipeline(this._pipeline);
renderPassEncoder.setVertexBuffer(0, this._positionBuffer);
renderPassEncoder.setBindGroup(0, this._uniformBindGroup);
renderPassEncoder.setIndexBuffer(this._indexBuffer!, 'uint16');
renderPassEncoder.drawIndexed(this._indexSize!);
}
private constructor(pipeline: GPURenderPipeline, positionBuffer: GPUBuffer, uniformBindGroup: GPUBindGroup, indexBuffer: GPUBuffer, indexSize: number) {
this._pipeline = pipeline;
this._positionBuffer = positionBuffer;
this._uniformBindGroup = uniformBindGroup;
this._indexBuffer = indexBuffer;
this._indexSize = indexSize;
}
}
This Stencil class will load in the appropriate OBJ and use the data in it to provide values for the vertex attributes we need in our stencil shader. We then build a pipeline to be used when rendering this stencil.
The most important piece to note is the depthStencil part of the render pipeline descriptor. stencil front and stencil back operate on front and back faces of triangles respectively. The compare and passOp values state that the pass op is always executed no matter what is in the stencil buffer and the value that is in the stencil buffer is replaced by the newly seen triangle’s data.
When we want to create the stencil outline in our render pipeline, we will call encodeRenderPass which will set the pipeline to use the one we created for stencil writing and then draw out the OBJ for the stencil area.
OBJ Models
Now, we will look at how our OBJ models which actually appear on screen will be rendered. We will have two OBJs in the portal world (Plane and Teapot) and one in the outside world — Frame.
This shader is the same one we have seen multiple times for rendering OBJs.
@group(0) @binding(0)
var<uniform> modelView: mat4x4<f32>;
@group(0) @binding(1)
var<uniform> projection: mat4x4<f32>;
@group(0) @binding(2)
var<uniform> normalMatrix: mat4x4<f32>;
@group(0) @binding(3)
var<uniform> lightDirection: vec3<f32>;
@group(0) @binding(4)
var<uniform> viewDirection: vec3<f32>;
@group(0) @binding(5)
var<uniform> ambientColor:vec4<f32>;
@group(0) @binding(6)
var<uniform> diffuseColor:vec4<f32>;
@group(0) @binding(7)
var<uniform> specularColor:vec4<f32>;
@group(0) @binding(8)
var<uniform> shininess:f32;
@group(0) @binding(9)
var<uniform> offset:vec3<f32>;
const diffuseConstant:f32 = 1.0;
const specularConstant:f32 = 1.0;
const ambientConstant: f32 = 1.0;
fn specular(lightDir:vec3<f32>, viewDir:vec3<f32>, normal:vec3<f32>, specularColor:vec3<f32>,
shininess:f32) -> vec3<f32> {
let reflectDir:vec3<f32> = reflect(-lightDir, normal);
let specDot:f32 = max(dot(reflectDir, viewDir), 0.0);
return pow(specDot, shininess) * specularColor;
}
fn diffuse(lightDir:vec3<f32>, normal:vec3<f32>, diffuseColor:vec3<f32>) -> vec3<f32>{
return max(dot(lightDir, normal), 0.0) * diffuseColor;
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) viewDir: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) lightDir: vec3<f32>,
@location(3) wldLoc: vec3<f32>,
@location(4) lightLoc: vec3<f32>,
@location(5) inPos: vec3<f32>
};
@vertex
fn vs_main(
@location(0) inPos: vec3<f32>,
@location(1) inNormal: vec3<f32>
) -> VertexOutput {
var out: VertexOutput;
out.viewDir = normalize((normalMatrix * vec4<f32>(-viewDirection, 0.0)).xyz);
out.lightDir = normalize((normalMatrix * vec4<f32>(-lightDirection, 0.0)).xyz);
out.normal = normalize(normalMatrix * vec4<f32>(inNormal, 0.0)).xyz;
var wldLoc:vec4<f32> = modelView * vec4<f32>(inPos + offset, 1.0);
out.clip_position = projection * wldLoc;
return out;
}
@fragment
fn fs_main(in: VertexOutput, @builtin(front_facing) face: bool) -> @location(0) vec4<f32> {
var lightDir:vec3<f32> = normalize(in.lightDir);
var n:vec3<f32> = normalize(in.normal);
var viewDir: vec3<f32> = in.viewDir;
if (face) {
var radiance:vec3<f32> = ambientColor.rgb * ambientConstant +
diffuse(-lightDir, n, diffuseColor.rgb)* diffuseConstant +
specular(-lightDir, viewDir, n, specularColor.rgb, shininess) * specularConstant;
return vec4<f32>(radiance,1.0);
}
return vec4<f32>( 0.0,0.0,0.0,1.0);
}
It will take in some lighting, vertex data and some colors and use the Phong shading model to color the fragments.
Plane
The Plane is the first thing we will draw in the portal world.
export class Plane {
private _pipeline: GPURenderPipeline;
private _positionBuffer: GPUBuffer;
private _normalBuffer: GPUBuffer;
private _uniformBindGroup: GPUBindGroup;
public static async init(device: GPUDevice, modelViewMatrixUniformBuffer: GPUBuffer,
projectionMatrixUnifromBuffer: GPUBuffer, normalMatrixUniformBuffer: GPUBuffer,
viewDirectionUniformBuffer: GPUBuffer, lightDirectionUniformBuffer: GPUBuffer, shaderCode: string): Promise<Plane> {
const shaderModule = device.createShaderModule({ code: shaderCode });
const positions = new Float32Array([
-100, -100, 0,
100, -100, 0,
-100, 100, 0,
100, 100, 0
]);
const normals = new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]);
const positionBuffer = createGPUBuffer(device, positions, GPUBufferUsage.VERTEX);
const normalBuffer = createGPUBuffer(device, normals, GPUBufferUsage.VERTEX);
const unifromBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 1,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 2,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 3,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 4,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 5,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 6,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 7,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 8,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 9,
visibility: GPUShaderStage.VERTEX,
buffer: {}
}
]
});
const ambientUniformBuffer = createGPUBuffer(device, new Float32Array([0.15, 0.10, 0.10, 1.0]), GPUBufferUsage.UNIFORM);
const diffuseUniformBuffer = createGPUBuffer(device, new Float32Array([0.55, 0.55, 0.95, 1.0]), GPUBufferUsage.UNIFORM);
const specularUniformBuffer = createGPUBuffer(device, new Float32Array([0.0, 0.0, 0.0, 1.0]), GPUBufferUsage.UNIFORM);
const shininessUniformBuffer = createGPUBuffer(device, new Float32Array([0.0]), GPUBufferUsage.UNIFORM);
const offsetUniformBuffer = createGPUBuffer(device, new Float32Array([0.0, 0.0, 0.0]), GPUBufferUsage.UNIFORM);
const uniformBindGroup = device.createBindGroup({
layout: unifromBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: modelViewMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUnifromBuffer
}
},
{
binding: 2,
resource: {
buffer: normalMatrixUniformBuffer
}
},
{
binding: 3,
resource: {
buffer: lightDirectionUniformBuffer
}
},
{
binding: 4,
resource: {
buffer: viewDirectionUniformBuffer
}
},
{
binding: 5,
resource: {
buffer: ambientUniformBuffer
}
},
{
binding: 6,
resource: {
buffer: diffuseUniformBuffer
}
},
{
binding: 7,
resource: {
buffer: specularUniformBuffer
}
},
{
binding: 8,
resource: {
buffer: shininessUniformBuffer
}
},
{
binding: 9,
resource: {
buffer: offsetUniformBuffer
}
}
]
});
const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0,
offset: 0,
format: 'float32x3'
}
const positionBufferLayout: GPUVertexBufferLayout = {
attributes: [positionAttribDesc],
arrayStride: 3 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex'
}
const normalAttribDesc: GPUVertexAttribute = {
shaderLocation: 1,
offset: 0,
format: 'float32x3'
}
const normalBufferLayout: GPUVertexBufferLayout = {
attributes: [normalAttribDesc],
arrayStride: 3 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex'
}
const pipelineLayoutDesc: GPUPipelineLayoutDescriptor = { bindGroupLayouts: [unifromBindGroupLayout] };
const pipelineLayout = device.createPipelineLayout(pipelineLayoutDesc);
const colorState: GPUColorTargetState = {
format: 'bgra8unorm'
}
const pipelineDesc: GPURenderPipelineDescriptor = {
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayout, normalBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-strip',
frontFace: 'ccw',
cullMode: 'none'
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus-stencil8',
stencilFront: {
compare: "less",
passOp: "keep"
},
stencilBack: {
compare: "less",
passOp: "keep"
}
}
}
const pipeline = device.createRenderPipeline(pipelineDesc);
return new Plane(pipeline, positionBuffer, normalBuffer, uniformBindGroup);
}
private constructor(pipeline: GPURenderPipeline, positionBuffer: GPUBuffer, normalBuffer: GPUBuffer, uniformBindGroup: GPUBindGroup) {
this._pipeline = pipeline;
this._positionBuffer = positionBuffer;
this._normalBuffer = normalBuffer;
this._uniformBindGroup = uniformBindGroup;
}
public encodeRenderPass(renderPassEncoder: GPURenderPassEncoder) {
renderPassEncoder.setPipeline(this._pipeline);
renderPassEncoder.setBindGroup(0, this._uniformBindGroup);
renderPassEncoder.setVertexBuffer(0, this._positionBuffer);
renderPassEncoder.setVertexBuffer(1, this._normalBuffer);
renderPassEncoder.draw(4, 1);
}
}
We create the necessary vertex attributes for a plane within the actual class — i.e. no loading an OBJ for this simple geometry. Things like lighting direction and projection matrices will be passed in from our outer rendering system.
Again, the important thing to note here is the values we provided in the stencilFront and stencilBack sections. This time we are saying we will only draw this object if it is less than the value from the stencil we have drawn. We will set the stencil render pass’s stencil values to 0xFF while everything else will receive 0x0. Therefore we will only draw this plane in the section marked by our stencil render pass — since 0x0 < 0xFF (stencil area) but 0x0 == 0x0 (everywhere else).
The keep section just means that we will keep whatever is in the stencil buffer currently and not let our new object overwrite it.
Teapot
Our Teapot will be set up exactly like our plane — except our vertex data will be loaded from an obj.
export class Teapot {
private _pipeline: GPURenderPipeline;
private _positionBuffer: GPUBuffer;
private _normalBuffer: GPUBuffer;
private _uniformBindGroup: GPUBindGroup;
private _indexBuffer?: GPUBuffer;
private _indexSize?: number;
public static async init(device: GPUDevice, modelViewMatrixUniformBuffer: GPUBuffer,
projectionMatrixUnifromBuffer: GPUBuffer, normalMatrixUniformBuffer: GPUBuffer,
viewDirectionUniformBuffer: GPUBuffer, lightDirectionUniformBuffer: GPUBuffer, shaderCode: string): Promise<Teapot> {
const shaderModule = device.createShaderModule({ code: shaderCode });
const objResponse = await fetch("./objs/teapot.obj");
const objBlob = await objResponse.blob();
const objText = await objBlob.text();
const objDataExtractor = new ObjDataExtractor(objText);
const positions = objDataExtractor.vertexPositions;
const positionBuffer = createGPUBuffer(device, positions, GPUBufferUsage.VERTEX);
const normals = objDataExtractor.normals;
const normalBuffer = createGPUBuffer(device, normals, GPUBufferUsage.VERTEX);
const indices = objDataExtractor.indices;
const indexBuffer = createGPUBuffer(device, indices, GPUBufferUsage.INDEX);
const indexSize = indices.length;
const unifromBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 1,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 2,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 3,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 4,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 5,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 6,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 7,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 8,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 9,
visibility: GPUShaderStage.VERTEX,
buffer: {}
}
]
});
const ambientUniformBuffer = createGPUBuffer(device, new Float32Array([0.15, 0.10, 0.10, 1.0]), GPUBufferUsage.UNIFORM);
const diffuseUniformBuffer = createGPUBuffer(device, new Float32Array([0.55, 0.55, 0.55, 1.0]), GPUBufferUsage.UNIFORM);
const specularUniformBuffer = createGPUBuffer(device, new Float32Array([1.0, 1.0, 1.0, 1.0]), GPUBufferUsage.UNIFORM);
const shininessUniformBuffer = createGPUBuffer(device, new Float32Array([20.0]), GPUBufferUsage.UNIFORM);
const offsetUniformBuffer = createGPUBuffer(device, new Float32Array([-10.0, 0.0, 0.0]), GPUBufferUsage.UNIFORM);
const uniformBindGroup = device.createBindGroup({
layout: unifromBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: modelViewMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUnifromBuffer
}
},
{
binding: 2,
resource: {
buffer: normalMatrixUniformBuffer
}
},
{
binding: 3,
resource: {
buffer: lightDirectionUniformBuffer
}
},
{
binding: 4,
resource: {
buffer: viewDirectionUniformBuffer
}
},
{
binding: 5,
resource: {
buffer: ambientUniformBuffer
}
},
{
binding: 6,
resource: {
buffer: diffuseUniformBuffer
}
},
{
binding: 7,
resource: {
buffer: specularUniformBuffer
}
},
{
binding: 8,
resource: {
buffer: shininessUniformBuffer
}
},
{
binding: 9,
resource: {
buffer: offsetUniformBuffer
}
}
]
});
const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0,
offset: 0,
format: 'float32x3'
}
const positionBufferLayout: GPUVertexBufferLayout = {
attributes: [positionAttribDesc],
arrayStride: 3 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex'
}
const normalAttribDesc: GPUVertexAttribute = {
shaderLocation: 1,
offset: 0,
format: 'float32x3'
}
const normalBufferLayout: GPUVertexBufferLayout = {
attributes: [normalAttribDesc],
arrayStride: 3 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex'
}
const pipelineLayoutDesc: GPUPipelineLayoutDescriptor = { bindGroupLayouts: [unifromBindGroupLayout] };
const pipelineLayout = device.createPipelineLayout(pipelineLayoutDesc);
const colorState: GPUColorTargetState = {
format: 'bgra8unorm'
};
const pipelineDesc: GPURenderPipelineDescriptor = {
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayout, normalBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-list',
frontFace: 'ccw',
cullMode: 'none'
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus-stencil8',
stencilFront: {
compare: "less",
passOp: "keep",
},
stencilBack: {
compare: "less",
passOp: "keep",
}
}
};
const pipeline = device.createRenderPipeline(pipelineDesc);
return new Teapot(pipeline, positionBuffer, normalBuffer, uniformBindGroup, indexBuffer, indexSize);
}
public encodeRenderPass(renderPassEncoder: GPURenderPassEncoder) {
renderPassEncoder.setPipeline(this._pipeline);
renderPassEncoder.setVertexBuffer(0, this._positionBuffer);
renderPassEncoder.setVertexBuffer(1, this._normalBuffer);
renderPassEncoder.setBindGroup(0, this._uniformBindGroup);
renderPassEncoder.setIndexBuffer(this._indexBuffer!, 'uint16');
renderPassEncoder.drawIndexed(this._indexSize!);
}
private constructor(pipeline: GPURenderPipeline, positionBuffer: GPUBuffer, normalBuffer: GPUBuffer, uniformBindGroup: GPUBindGroup, indexBuffer: GPUBuffer, indexSize: number) {
this._pipeline = pipeline;
this._positionBuffer = positionBuffer;
this._normalBuffer = normalBuffer;
this._uniformBindGroup = uniformBindGroup;
this._indexBuffer = indexBuffer;
this._indexSize = indexSize;
}
}
We use the same stencil configuration as the plane to only paint this teapot when it is within the stenciled area we will create in our first render pass.
Frame
Our Frame is slightly different than the previous two objs. We will still load in an obj model and all the necessary uniforms. However, our stencil values will be different.
export class Frame {
private _pipeline: GPURenderPipeline;
private _positionBuffer: GPUBuffer;
private _normalBuffer: GPUBuffer;
private _uniformBindGroup: GPUBindGroup;
private _indexBuffer?: GPUBuffer;
private _indexSize?: number;
public static async init(device: GPUDevice, modelViewMatrixUniformBuffer: GPUBuffer,
projectionMatrixUnifromBuffer: GPUBuffer, normalMatrixUniformBuffer: GPUBuffer,
viewDirectionUniformBuffer: GPUBuffer, lightDirectionUniformBuffer: GPUBuffer, shaderCode: string): Promise<Frame> {
const shaderModule = device.createShaderModule({ code: shaderCode });
const objResponse = await fetch("./objs/frame.obj");
const objBlob = await objResponse.blob();
const objText = await objBlob.text();
const objDataExtractor = new ObjDataExtractor(objText);
const positions = objDataExtractor.vertexPositions;
const positionBuffer = createGPUBuffer(device, positions, GPUBufferUsage.VERTEX);
const normals = objDataExtractor.normals;
const normalBuffer = createGPUBuffer(device, normals, GPUBufferUsage.VERTEX);
const indices = objDataExtractor.indices;
const indexBuffer = createGPUBuffer(device, indices, GPUBufferUsage.INDEX);
const indexSize = indices.length;
const unifromBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 1,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 2,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 3,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 4,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 5,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 6,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 7,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 8,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 9,
visibility: GPUShaderStage.VERTEX,
buffer: {}
}
]
});
const ambientUniformBuffer = createGPUBuffer(device, new Float32Array([0.15, 0.10, 0.10, 1.0]), GPUBufferUsage.UNIFORM);
const diffuseUniformBuffer = createGPUBuffer(device, new Float32Array([0.55, 0.55, 0.55, 1.0]), GPUBufferUsage.UNIFORM);
const specularUniformBuffer = createGPUBuffer(device, new Float32Array([1.0, 1.0, 1.0, 1.0]), GPUBufferUsage.UNIFORM);
const shininessUniformBuffer = createGPUBuffer(device, new Float32Array([20.0]), GPUBufferUsage.UNIFORM);
const offsetUniformBuffer = createGPUBuffer(device, new Float32Array([0.0, 0.0, 0.0]), GPUBufferUsage.UNIFORM);
const uniformBindGroup = device.createBindGroup({
layout: unifromBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: modelViewMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUnifromBuffer
}
},
{
binding: 2,
resource: {
buffer: normalMatrixUniformBuffer
}
},
{
binding: 3,
resource: {
buffer: lightDirectionUniformBuffer
}
},
{
binding: 4,
resource: {
buffer: viewDirectionUniformBuffer
}
},
{
binding: 5,
resource: {
buffer: ambientUniformBuffer
}
},
{
binding: 6,
resource: {
buffer: diffuseUniformBuffer
}
},
{
binding: 7,
resource: {
buffer: specularUniformBuffer
}
},
{
binding: 8,
resource: {
buffer: shininessUniformBuffer
}
},
{
binding: 9,
resource: {
buffer: offsetUniformBuffer
}
}
]
});
const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0,
offset: 0,
format: 'float32x3'
}
const positionBufferLayout: GPUVertexBufferLayout = {
attributes: [positionAttribDesc],
arrayStride: 3 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex'
}
const normalAttribDesc: GPUVertexAttribute = {
shaderLocation: 1,
offset: 0,
format: 'float32x3'
}
const normalBufferLayout: GPUVertexBufferLayout = {
attributes: [normalAttribDesc],
arrayStride: 3 * Float32Array.BYTES_PER_ELEMENT,
stepMode: 'vertex'
}
const pipelineLayoutDesc: GPUPipelineLayoutDescriptor = { bindGroupLayouts: [unifromBindGroupLayout] };
const pipelineLayout = device.createPipelineLayout(pipelineLayoutDesc);
const colorState: GPUColorTargetState = {
format: 'bgra8unorm'
};
const pipelineDesc: GPURenderPipelineDescriptor = {
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayout, normalBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-list',
frontFace: 'ccw',
cullMode: 'none'
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus-stencil8',
stencilFront: {
compare: "always",
passOp: "keep",
},
stencilBack: {
compare: "always",
passOp: "keep",
}
}
};
const pipeline = device.createRenderPipeline(pipelineDesc);
return new Frame(pipeline, positionBuffer, normalBuffer, uniformBindGroup, indexBuffer, indexSize);
}
public encodeRenderPass(renderPassEncoder: GPURenderPassEncoder) {
renderPassEncoder.setPipeline(this._pipeline);
renderPassEncoder.setVertexBuffer(0, this._positionBuffer);
renderPassEncoder.setVertexBuffer(1, this._normalBuffer);
renderPassEncoder.setBindGroup(0, this._uniformBindGroup);
renderPassEncoder.setIndexBuffer(this._indexBuffer!, 'uint16');
renderPassEncoder.drawIndexed(this._indexSize!);
}
private constructor(pipeline: GPURenderPipeline, positionBuffer: GPUBuffer, normalBuffer: GPUBuffer, uniformBindGroup: GPUBindGroup, indexBuffer: GPUBuffer, indexSize: number) {
this._pipeline = pipeline;
this._positionBuffer = positionBuffer;
this._normalBuffer = normalBuffer;
this._uniformBindGroup = uniformBindGroup;
this._indexBuffer = indexBuffer;
this._indexSize = indexSize;
}
}
We want to paint this Frame outside of the portal world so we always paint the object regardless of what the stencil buffer says.
Rendering
Our render setup will now pull all of these steps together. We will do this in two render passes. The first pass will populate our stencil buffer with the area flagged as our portal. The next render pass will paint our objects to the screen using the stencil buffer to determine if they should paint their fragments or not for given pixels.
const renderDepthStencilExample = async () => {
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter!.requestDevice();
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const context = canvas.getContext("webgpu");
const canvasConfig: GPUCanvasConfiguration = {
device: device!,
format: navigator.gpu.getPreferredCanvasFormat() as GPUTextureFormat,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
alphaMode: "opaque",
}
context!.configure(canvasConfig);
let angle = 0.0;
const arcball = new Arcball(6.0);
const modelViewMatrix = arcball.getMatrices();
const modelViewMatrixUniformBuffer = createGPUBuffer(device!, new Float32Array(modelViewMatrix), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
const viewDir = glMatrix.vec3.fromValues(-10.0, -10.0, -10.0);
const viewDirectionUniformBuffer = createGPUBuffer(device!, new Float32Array(viewDir), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
const lightDirectionBuffer = createGPUBuffer(device!, new Float32Array(viewDir), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
const modelViewMatrixInverse = glMatrix.mat4.invert(glMatrix.mat4.create(), modelViewMatrix)!;
const normalMatrix = glMatrix.mat4.transpose(glMatrix.mat4.create(), modelViewMatrixInverse);
const normalMatrixUniformBuffer = createGPUBuffer(device!, new Float32Array(normalMatrix), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
const projectionMatrix = glMatrix.mat4.perspective(glMatrix.mat4.create(), 1.4, canvas.width / canvas.height, 0.1, 1000.0);
const projectionMatrixUnifromBuffer = createGPUBuffer(device!, new Float32Array(projectionMatrix), GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
const stencil = await Stencil.init(device!, modelViewMatrixUniformBuffer, projectionMatrixUnifromBuffer, stencilWgsl);
const plane = await Plane.init(device!, modelViewMatrixUniformBuffer, projectionMatrixUnifromBuffer, normalMatrixUniformBuffer, viewDirectionUniformBuffer, lightDirectionBuffer, objModelWgsl);
const frame = await Frame.init(device!, modelViewMatrixUniformBuffer, projectionMatrixUnifromBuffer, normalMatrixUniformBuffer, viewDirectionUniformBuffer, lightDirectionBuffer, objModelWgsl);
const teapot = await Teapot.init(device!, modelViewMatrixUniformBuffer, projectionMatrixUnifromBuffer, normalMatrixUniformBuffer, viewDirectionUniformBuffer, lightDirectionBuffer, objModelWgsl);
let depthTexture: GPUTexture | null = null;
let depthStencilAttachmentOne: GPURenderPassDepthStencilAttachment | undefined = undefined;
let depthStencilAttachmentTwo: GPURenderPassDepthStencilAttachment | undefined = undefined;
async function render() {
const devicePixelRatio = window.devicePixelRatio || 1;
const currentCanvasHeight = canvas.clientHeight * devicePixelRatio;
const currentCanvasWidth = canvas.clientWidth * devicePixelRatio;
let projectionMatrixUniformBufferUpdate = null;
// update projection and depth textures if canvas changes
if (currentCanvasWidth != canvas.width || currentCanvasHeight != canvas.height) {
canvas.width = currentCanvasWidth;
canvas.height = currentCanvasHeight;
if (depthTexture != null) {
depthTexture.destroy();
}
depthTexture = device!.createTexture({
size: [canvas.width, canvas.height, 1],
dimension: "2d",
format: "depth24plus-stencil8",
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
});
const depthTextureView = depthTexture.createView();
depthStencilAttachmentOne = {
view: depthTextureView,
depthClearValue: 1,
depthLoadOp: 'clear',
depthStoreOp: 'store',
stencilClearValue: 0,
stencilLoadOp: "clear",
stencilStoreOp: "store",
stencilReadOnly: false
};
depthStencilAttachmentTwo = {
view: depthTextureView,
depthClearValue: 1,
depthLoadOp: 'clear',
depthStoreOp: 'store',
stencilClearValue: 0,
stencilReadOnly: true
};
const projectionMatrix = glMatrix.mat4.perspective(glMatrix.mat4.create(), 1.4, canvas.width / canvas.height, 0.1, 1000.0);
projectionMatrixUniformBufferUpdate = createGPUBuffer(device!, new Float32Array(projectionMatrix), GPUBufferUsage.COPY_SRC);
}
// handle arcball movement
const modelViewMatrix = arcball.getMatrices();
const modelViewMatrixUniformBufferUpdate = createGPUBuffer(device!, new Float32Array(modelViewMatrix), GPUBufferUsage.COPY_SRC);
const modelViewMatrixInverse = glMatrix.mat4.invert(glMatrix.mat4.create(), modelViewMatrix)!;
const normalMatrix = glMatrix.mat4.transpose(glMatrix.mat4.create(), modelViewMatrixInverse);
const normalMatrixUniformBufferUpdate = createGPUBuffer(device!, new Float32Array(normalMatrix), GPUBufferUsage.COPY_SRC);
const viewDir = glMatrix.vec3.fromValues(-arcball.forward[0], -arcball.forward[1], -arcball.forward[2]);
const viewDirectionUniformBufferUpdate = createGPUBuffer(device!, new Float32Array(viewDir), GPUBufferUsage.COPY_SRC);
const lightDir = glMatrix.vec3.fromValues(Math.cos(angle) * 8.0, Math.sin(angle) * 8.0, 10);
const lightDirectionBufferUpdate = createGPUBuffer(device!, new Float32Array(lightDir), GPUBufferUsage.COPY_SRC);
const colorTexture = context!.getCurrentTexture();
const colorTextureView = colorTexture.createView();
let colorAttachmentOne: GPURenderPassColorAttachment = {
view: colorTextureView,
clearValue: { r: 1, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store'
};
let colorAttachmentTwo: GPURenderPassColorAttachment = {
view: colorTextureView,
clearValue: { r: 0, g: 0, b: 1, a: 1 },
loadOp: 'load',
storeOp: 'store'
};
const renderPassDesc: GPURenderPassDescriptor = {
colorAttachments: [colorAttachmentOne],
depthStencilAttachment: depthStencilAttachmentOne
};
const renderPassDesc2: GPURenderPassDescriptor = {
colorAttachments: [colorAttachmentTwo],
depthStencilAttachment: depthStencilAttachmentTwo
};
const commandEncoder = device!.createCommandEncoder();
if (projectionMatrixUniformBufferUpdate != null) {
commandEncoder.copyBufferToBuffer(projectionMatrixUniformBufferUpdate, 0, projectionMatrixUnifromBuffer, 0, 16 * Float32Array.BYTES_PER_ELEMENT);
}
commandEncoder.copyBufferToBuffer(modelViewMatrixUniformBufferUpdate, 0, modelViewMatrixUniformBuffer, 0, 16 * Float32Array.BYTES_PER_ELEMENT);
commandEncoder.copyBufferToBuffer(normalMatrixUniformBufferUpdate, 0, normalMatrixUniformBuffer, 0, 16 * Float32Array.BYTES_PER_ELEMENT);
commandEncoder.copyBufferToBuffer(viewDirectionUniformBufferUpdate, 0, viewDirectionUniformBuffer, 0, 3 * Float32Array.BYTES_PER_ELEMENT);
commandEncoder.copyBufferToBuffer(lightDirectionBufferUpdate, 0, lightDirectionBuffer, 0, 3 * Float32Array.BYTES_PER_ELEMENT);
const passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setStencilReference(0xFF);
stencil.encodeRenderPass(passEncoder);
passEncoder.end();
const passEncoderTwo = commandEncoder.beginRenderPass(renderPassDesc2);
passEncoderTwo.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoderTwo.setStencilReference(0x0);
plane.encodeRenderPass(passEncoderTwo);
teapot.encodeRenderPass(passEncoderTwo);
frame.encodeRenderPass(passEncoderTwo);
passEncoderTwo.end();
device!.queue.submit([commandEncoder.finish()]);
await device!.queue.onSubmittedWorkDone();
if (projectionMatrixUniformBufferUpdate != null) {
projectionMatrixUniformBufferUpdate.destroy();
}
modelViewMatrixUniformBufferUpdate.destroy();
normalMatrixUniformBufferUpdate.destroy();
viewDirectionUniformBufferUpdate.destroy();
lightDirectionBufferUpdate.destroy();
angle += 0.01;
requestAnimationFrame(render);
}
new Controls(canvas, arcball, render);
requestAnimationFrame(render);
}
We create two depthstencil attachments, one for writing to the stencil buffer and one for reading from it. We also create two color attachments the first of which will clear the current texture on the screen and the second of which will load the texture created by our first pass (to get the stencil data we wrote).
Our first Render Pass will set our stencil reference to 0XFF and draw our portal area with the help of our stencil object. Our second render pass will set the stencil reference to 0x0 and draw the objects. Our Plane and Teapot will only be drawn within the stencil because our stencil compare argument was “less”. The frame will draw even if it is outside the stenciled fragments because our stencil compare argument was “always”.
Conclusion
In this article we learned what the stencil buffer is and how it can be used to mark an area with a stencil value in initial render passes. These stenciled areas can be used in subsequent draw calls to decide if an item should be drawn on a fragment or not. This allows us to build cool effects like viewport masking, mirrors and portals.