WebGPU Rendering: Part 17 Basic glTF Rasterizer

Matthew MacFarquhar
14 min readJan 30, 2025

--

Introduction

In these next couple articles, we will use the foundations of WebGPU programming we have learned to build a glb loader. A lot of these upcoming articles will be based on a combination of the 0 to glTF article series and this webgpu+glTF case study. For my version, I will use typescript instead of the original javascript and focus more on encapsulating logic within classes — like we were doing in our previous articles.

First, lets talk about what glTF is, previously we had been working with OBJs which are nice and easy to get started with for a 3D viewer. However, when you want to really get into serving your 3D data in an efficient and structured manner, glTF is king.

GLTF organizes 3D assets into a structured format where meshes define geometry, materials specify appearance using textures linked to images, and nodes form a scene graph to position and transform objects.

The state of the viewer in this article can be found here.

What we will build

For our first step we are just going to worry about a single mesh — disregarding any node structuring and textures. This will be enough to load up the Avocado in the thumbnail which we color using surface normals.

To get this model onto our screen, we will need a couple of classes.

  • GLTFBuffer — will hold the entire glb data
  • GLTFBufferView — will point to subsections of the buffer and handle uploading them to the GPU
  • GLTFAccessor — will point to bufferviews and hold some info about how we should read the buffer views for our accessor’s purpose
  • GLTFPrimitive — will handle the actual drawing logic for our model, its vertex attributes will point to different accessors
  • GLTFMesh — will hold a bunch of primitives and will be our application’s interface touch point.

We will go over how we load data into these objects, then we will talk about how the rendering is done, and — finally — we will go over how these steps come together in our viewer app.

Loading GLB Model

We will encapsulate the loading of our model inside a function called uploadGLB

export async function uploadGLB(buffer: ArrayBuffer, device: GPUDevice) {
const header = new Uint32Array(buffer, 0, 5);

if (header[0] != 0x46546C67) {
throw Error("Invalid GLB magic");
}
if (header[1] != 2) {
throw Error("Unsupported glb version (only glTF 2.0 is supported)")
}
if (header[4] != 0x4E4F534A) {
throw Error("Invalid glB: The first chunk of the glB file should be JSON");
}

const jsonContentLength = header[3];
const jsonChunk = JSON.parse(new TextDecoder("utf-8").decode(new Uint8Array(buffer, 20, jsonContentLength)));
const binaryHeader = new Uint32Array(buffer, 20 + jsonContentLength, 2);
if (binaryHeader[1] != 0x004E4942) {
throw Error("Invalid glB: The second chunk of the glB file should be binary");
}
const binaryContentLength = binaryHeader[0];
const binaryChunk = new GLTFBuffer(buffer, 20 + jsonContentLength + 8, binaryContentLength);

const bufferViews: GLTFBufferView[] = loadBufferViews(jsonChunk, binaryChunk)
const accessors: GLTFAccessor[] = loadAccessors(jsonChunk, bufferViews);
const meshes: GLTFMesh[] = loadMesh(jsonChunk, accessors);

bufferViews.forEach((bufferView: GLTFBufferView) => {
if (bufferView.needsUpload) {
bufferView.upload(device);
}
})

return meshes[0];
}

This function does some initial header validation, and then extracts the JSON part of the glTF and the binary part.

After this, most of the actual loading logic is delegated to each of the individual GLTF classes. We load up the buffer with the binary data, then set up the buffer views using the buffer, then set up the accessors using the buffer views and finally build up the mesh and its primitives using the accessors.

GLTFBuffer

Our Buffer is very simple and just holds the binary data.

export class GLTFBuffer {
buffer: Uint8Array;
constructor(buffer: ArrayBuffer, offset: number, size: number) {
this.buffer = new Uint8Array(buffer, offset, size);
}
}

GLTFBufferView

Our BufferView will read our JSON data for each bufferView which look like

{
"buffer": 0,
"byteOffset": 3248,
"byteLength": 4872
}

We access some properties from the JSON and then construct a new GLTFBufferview. We will return the list of our constructed BufferViews back to the loader for the next stage.

function alignTo(val: number, align: number) {
return Math.ceil(val / align) * align;
}

export function loadBufferViews(jsonChunk: any, binaryChunk: GLTFBuffer) {
const bufferViews: GLTFBufferView[] = [];
for (const bufferView of jsonChunk.bufferViews) {
let byteLength = bufferView["byteLength"] as number;
let byteStride = 0;
if ("byteStride" in bufferView) {
byteStride = bufferView["byteStride"] as number;
}
let byteOffset = 0;
if ("byteOffset" in bufferView) {
byteOffset = bufferView["byteOffset"] as number;
}
bufferViews.push(new GLTFBufferView(binaryChunk, byteLength, byteOffset, byteStride));
}

return bufferViews;
}

export class GLTFBufferView {
byteLength: number;
byteStride: number;
view: Uint8Array;
needsUpload: boolean;
gpuBuffer?: GPUBuffer;
usage: GPUBufferUsageFlags;

constructor(buffer: GLTFBuffer, byteLength: number, byteOffset: number, byteStride: number) {
this.byteLength = byteLength;
this.byteStride = byteStride;

this.view = buffer.buffer.subarray(byteOffset, byteOffset + this.byteLength);

this.needsUpload = false;
this.gpuBuffer = undefined;
this.usage = 0;
}

addUsage(usage: GPUBufferUsageFlags) {
this.usage = this.usage | usage;
}

upload(device: GPUDevice) {
const buf = device.createBuffer({
size: alignTo(this.view.byteLength, 4),
usage: this.usage,
mappedAtCreation: true
});

new Uint8Array(buf.getMappedRange()).set(this.view);
buf.unmap();
this.gpuBuffer = buf;
this.needsUpload = false;
}
}

BuffersViews need to be uploaded to the GPU, they contain a usage flag which encodes how they will be used in the GPU and an upload function which will use a provided GPU device to map its data to a GPU Buffer.

GLTFAccessor

Now that we have our bufferViews, we can construct our accessors to point to them properly.

{
"bufferView": 2,
"componentType": 5126,
"count": 406,
"type": "VEC4"
}

Accessors contain a type to say what kind of container this accessor reads and a componentType to tell us what each entry of the container is (i.e. float, int, double, etc…). It will also tell us the number of these to read from the Buffer View.

Our Accessor could also allow us to specify byteOffsets and strides. This would allow multiple accessors to point to the same Buffer View and get their needed data by reading it in a different way.

function gltfTypeSize(componentType: GLTFComponentType, type: GLTFType) {
let componentSize = 0;
switch (componentType) {
case GLTFComponentType.BYTE:
componentSize = 1;
break;
case GLTFComponentType.UNSIGNED_BYTE:
componentSize = 1;
break;
case GLTFComponentType.SHORT:
componentSize = 2;
break;
case GLTFComponentType.UNSIGNED_SHORT:
componentSize = 2;
break;
case GLTFComponentType.INT:
componentSize = 4;
break;
case GLTFComponentType.UNSIGNED_INT:
componentSize = 4;
break;
case GLTFComponentType.FLOAT:
componentSize = 4;
break;
case GLTFComponentType.DOUBLE:
componentSize = 8;
break;
default:
throw Error("Unrecognized GLTF Component Type?");
}
return gltfTypeNumComponents(type) * componentSize;
}

function gltfTypeNumComponents(type: GLTFType) {
switch (type) {
case GLTFType.SCALAR:
return 1;
case GLTFType.VEC2:
return 2;
case GLTFType.VEC3:
return 3;
case GLTFType.VEC4:
case GLTFType.MAT2:
return 4;
case GLTFType.MAT3:
return 9;
case GLTFType.MAT4:
return 16;
default:
throw Error(`Invalid glTF Type ${type}`);
}
}

function gltfVertexType(componentType: GLTFComponentType, type: GLTFType) {
let typeStr = null;
switch (componentType) {
case GLTFComponentType.BYTE:
typeStr = "sint8";
break;
case GLTFComponentType.UNSIGNED_BYTE:
typeStr = "uint8";
break;
case GLTFComponentType.SHORT:
typeStr = "sint16";
break;
case GLTFComponentType.UNSIGNED_SHORT:
typeStr = "uint16";
break;
case GLTFComponentType.INT:
typeStr = "int32";
break;
case GLTFComponentType.UNSIGNED_INT:
typeStr = "uint32";
break;
case GLTFComponentType.FLOAT:
typeStr = "float32";
break;
default:
throw Error(`Unrecognized or unsupported glTF type ${componentType}`);
}

switch (gltfTypeNumComponents(type)) {
case 1:
return typeStr;
case 2:
return typeStr + "x2";
case 3:
return typeStr + "x3";
case 4:
return typeStr + "x4";
default:
throw Error(`Invalid number of components for gltfType: ${type}`);
}
}

function parseGltfType(type: string) {
switch (type) {
case "SCALAR":
return GLTFType.SCALAR;
case "VEC2":
return GLTFType.VEC2;
case "VEC3":
return GLTFType.VEC3;
case "VEC4":
return GLTFType.VEC4;
case "MAT2":
return GLTFType.MAT2;
case "MAT3":
return GLTFType.MAT3;
case "MAT4":
return GLTFType.MAT4;
default:
throw Error(`Unhandled glTF Type ${type}`);
}
}

export function loadAccessors(jsonChunk: any, bufferViews: GLTFBufferView[]) {
const accessors: GLTFAccessor[] = [];
for (const accessor of jsonChunk.accessors) {
const viewID = accessor["bufferView"];
const count = accessor["count"] as number;
const componentType = accessor["componentType"] as GLTFComponentType;
const gltfType = parseGltfType(accessor["type"]);
const byteOffset = "byteOffset" in accessor ? accessor["byteOffset"] as number : 0;

accessors.push(new GLTFAccessor(bufferViews[viewID], count, componentType, gltfType, byteOffset))
}

return accessors;
}

export class GLTFAccessor {
count: number;
componentType: GLTFComponentType;
gltfType: GLTFType;
view: GLTFBufferView;
byteOffset: number;

constructor(view: GLTFBufferView, count: number, componentType: GLTFComponentType, gltfType: GLTFType, byteOffset: number) {
this.count = count;
this.componentType = componentType;
this.gltfType = gltfType;
this.view = view;
this.byteOffset = byteOffset;
}

get byteStride() {
const elementSize = gltfTypeSize(this.componentType, this.gltfType);
return Math.max(elementSize, this.view.byteStride);
}

get byteLength() {
return this.count * this.byteStride;
}

get elementType() {
return gltfVertexType(this.componentType, this.gltfType);
}
}

Since accessors are just metadata pointing to the buffer views, we don’t need any functionality in the actual class except for some helpful getters. We return the list of accessors so our Mesh and primitives can use them in the next step.

Mesh

Loading our Mesh is pretty boring, it is more so just a container for our primitives.

{
"primitives": [
{
"attributes": {
"TEXCOORD_0": 0,
"NORMAL": 1,
"TANGENT": 2,
"POSITION": 3
},
"indices": 4,
"material": 0
}
],
"name": "Avocado"
}

Inside a mesh JSON, we have a list of primitives and a name.

When we load a mesh, we load all of its primitives and pass them into the mesh constructor.

export function loadMesh(jsonChunk: any, accessors: GLTFAccessor[]) {
const meshes: GLTFMesh[] = [];
for (const meshJson of jsonChunk.meshes) {
const meshPrimitives: GLTFPrimitive[] = loadPrimitives(jsonChunk, meshJson, accessors);

const mesh = new GLTFMesh(meshJson["name"], meshPrimitives);
meshes.push(mesh);
}

return meshes;
}

export class GLTFMesh {
name: string;
primitives: GLTFPrimitive[];

constructor(name: string, primitives: GLTFPrimitive[]) {
this.name = name;
this.primitives = primitives;
}

buildRenderPipeline(device: GPUDevice, shaderModule: GPUShaderModule, colorFormat: GPUTextureFormat,
depthFormat: GPUTextureFormat, uniformsBGLayout: GPUBindGroupLayout) {
for (const primitive of this.primitives) {
primitive.buildRenderPipeline(device, shaderModule, colorFormat, depthFormat, uniformsBGLayout);
}

}

render(renderPassEncoder: GPURenderPassEncoder, viewParamBindGroup: GPUBindGroup) {
for (const primitive of this.primitives) {
primitive.render(renderPassEncoder, viewParamBindGroup);
}
}
}

There is some interesting stuff going into the render process — we will come back to that shortly — but for now lets move on to the primitive loading.

GLTFPrimitive

Our primitive is where everything comes to a head, it will use everything we have built in the previous steps to actually create the object which will handle rendering.

{
"attributes": {
"TEXCOORD_0": 0,
"NORMAL": 1,
"TANGENT": 2,
"POSITION": 3
},
"indices": 4,
"material": 0 //IGNORE FOR NOW
}

All of the numbers in the primitive — except for material — are the indices into our GLTFAccessor array. Now, it is just a matter of grabbing and linking them to our primitive.

Primitives can have a topology mode (which defaults to TRIANGLE LIST. if not provided). We will then grab the accessors for the indices, texcoords, normals and positions. The only one that is actually required is the positions.

Every different configuration of topology and vertex attributes needs a separate shader code and render pipeline. So if we are missing some of these vertex attributes in one primitive and not another, we would need two pipelines and two shader codes. Since I don’t want to do that, I fake the missing attributes and we will add some logic in our shader to ignore them if they are the faked value later on. Aside from that faking logic, we are just assigning properties for the primitive based on the JSON.

export function loadPrimitives(jsonChunk: any, meshJson: any, accessors: GLTFAccessor[]) {
const meshPrimitives = [];
for (const meshPrimitive of meshJson.primitives) {
const topology = meshPrimitive["mode"] || GLTFRenderMode.TRIANGLES;

let indices = null;
if (jsonChunk["accessors"][meshPrimitive["indices"]] !== undefined) {
indices = accessors[meshPrimitive["indices"]];
}

let positions = null;
let texcoords = null;
let normals = null;
for (const attribute of Object.keys(meshPrimitive["attributes"])) {
const accessor = accessors[meshPrimitive["attributes"][attribute]];
if (attribute === "POSITION") {
positions = accessor;
} else if (attribute === "TEXCOORD_0") {
texcoords = accessor;
} else if (attribute === "NORMAL") {
normals = accessor;
}
}

if (positions == null) {
throw new Error("No positions found");
}

if (texcoords == null) {
const fakeTexCoordBufferByteLength = Float32Array.BYTES_PER_ELEMENT * 2 * positions.count;
const buffer = new ArrayBuffer(fakeTexCoordBufferByteLength);
const fakeBufferView = new GLTFBufferView(new GLTFBuffer(buffer, 0, fakeTexCoordBufferByteLength), fakeTexCoordBufferByteLength, 0, 2 * Float32Array.BYTES_PER_ELEMENT);
fakeBufferView.addUsage(GPUBufferUsage.VERTEX);
texcoords = new GLTFAccessor(fakeBufferView, positions.count, GLTFComponentType.FLOAT, GLTFType.VEC2, 0);
}

if (normals == null) {
const fakeNormalBufferByteLength = Float32Array.BYTES_PER_ELEMENT * 3 * positions.count;
const buffer = new ArrayBuffer(fakeNormalBufferByteLength);
const fakeBufferView = new GLTFBufferView(new GLTFBuffer(buffer, 0, fakeNormalBufferByteLength), fakeNormalBufferByteLength, 0, 3 * Float32Array.BYTES_PER_ELEMENT);
fakeBufferView.addUsage(GPUBufferUsage.VERTEX);
normals = new GLTFAccessor(fakeBufferView, positions.count, GLTFComponentType.FLOAT, GLTFType.VEC3, 0);
}

meshPrimitives.push(new GLTFPrimitive(positions, indices || undefined, texcoords, normals, topology));
}

return meshPrimitives;
}

export class GLTFPrimitive {
positions: GLTFAccessor;
indices?: GLTFAccessor;
texcoords: GLTFAccessor;
normals: GLTFAccessor;
topology: GLTFRenderMode;
renderPipeline?: GPURenderPipeline;

constructor(positions: GLTFAccessor, indices: GLTFAccessor | undefined, texcoords: GLTFAccessor, normals: GLTFAccessor, topology: GLTFRenderMode) {
this.positions = positions;
this.texcoords = texcoords;
this.normals = normals;
this.indices = indices;
this.topology = topology;
this.renderPipeline = undefined;

this.positions.view.needsUpload = true;
this.positions.view.addUsage(GPUBufferUsage.VERTEX);

if (this.texcoords) {
this.texcoords.view.needsUpload = true;
this.texcoords.view.addUsage(GPUBufferUsage.VERTEX);
}

if (this.normals) {
this.normals.view.needsUpload = true;
this.normals.view.addUsage(GPUBufferUsage.VERTEX);
}

if (this.indices) {
this.indices.view.needsUpload = true;
this.indices.view.addUsage(GPUBufferUsage.INDEX);
}
}

buildRenderPipeline(device: GPUDevice, shaderModule: GPUShaderModule, colorFormat: GPUTextureFormat,
depthFormat: GPUTextureFormat, uniformsBGLayout: GPUBindGroupLayout) {
const vertexBuffers: GPUVertexBufferLayout[] = [
{
arrayStride: this.positions.byteStride,
attributes:[
{
format: this.positions.elementType as GPUVertexFormat,
offset: 0,
shaderLocation: 0
}
]
}
];

const primitive = this.topology == GLTFRenderMode.TRIANGLE_STRIP ? {topology: "triangle-strip" as GPUPrimitiveTopology, stripIndexFormat: this.indices!.elementType as GPUIndexFormat} : {topology: "triangle-list" as GPUPrimitiveTopology};

this.renderPipeline = getPipelineForArgs(vertexBuffers, primitive, colorFormat, depthFormat, uniformsBGLayout, device, shaderModule)

}

render(renderPassEncoder: GPURenderPassEncoder, viewParamBindGroup: GPUBindGroup) {
renderPassEncoder.setPipeline(this.renderPipeline!);
renderPassEncoder.setBindGroup(0, viewParamBindGroup);

renderPassEncoder.setVertexBuffer(0,
this.positions.view.gpuBuffer,
this.positions.byteOffset,
this.positions.byteLength
);

if (this.indices) {
renderPassEncoder.setIndexBuffer(this.indices.view.gpuBuffer!, this.indices.elementType as GPUIndexFormat, this.indices.byteOffset, this.indices.byteLength);
renderPassEncoder.drawIndexed(this.indices.count);
} else {
renderPassEncoder.draw(this.positions.count);
}
}
}

Like in the Mesh there is some very interesting rendering code in the GLTFPrimitive, but in the constructor all we really do is set the properties for the indices and vertex attributes and then mark them as needing to be uploaded and give them their usages.

Rendering Process

Now that we have loaded in all our data, we can talk about rendering. Our render process will be called on each mesh which will then proxy the command to its primitives. To render, we will first build a render pipeline and then — in our render loop — call render and pass in the renderPassEncoder.

Shader

Before we jump in, let’s take a quick look at the shader, it is quite simple.

alias float4 = vec4<f32>;
alias float3 = vec3<f32>;

struct VertexInput {
@location(0) position: float3,
};

struct VertexOutput {
@builtin(position) position: float4,
@location(0) world_pos: float3,
};

struct ViewParams {
view_proj: mat4x4<f32>,
};

@group(0) @binding(0)
var<uniform> view_params: ViewParams;

@vertex
fn vertex_main(vert: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.position = view_params.view_proj * float4(vert.position, 1.0);
out.world_pos = vert.position.xyz;
return out;
}

@fragment
fn fragment_main(in: VertexOutput) -> @location(0) float4 {
let dx = dpdx(in.world_pos);
let dy = dpdy(in.world_pos);
let n = normalize(cross(dx, dy));
return float4((n + 1.0) * 0.5, 1.0);
}

We load in position data and some uniforms for our camera view and then build out the final projected position for painting the vertices and the untransformed position which we will use for our surface normals.

In our fragment shader we just color using the surface normals.

Building renderPipeline

Now let’s talk about how the render pipeline is set up.

buildRenderPipeline(device: GPUDevice, shaderModule: GPUShaderModule, colorFormat: GPUTextureFormat,
depthFormat: GPUTextureFormat, uniformsBGLayout: GPUBindGroupLayout) {
for (const primitive of this.primitives) {
primitive.buildRenderPipeline(device, shaderModule, colorFormat, depthFormat, uniformsBGLayout);
}
}

This function within the GLTFMesh is called and just proxies the setup to each of its primitives.

buildRenderPipeline(device: GPUDevice, shaderModule: GPUShaderModule, colorFormat: GPUTextureFormat,
depthFormat: GPUTextureFormat, uniformsBGLayout: GPUBindGroupLayout) {
const vertexBuffers: GPUVertexBufferLayout[] = [
{
arrayStride: this.positions.byteStride,
attributes:[
{
format: this.positions.elementType as GPUVertexFormat,
offset: 0,
shaderLocation: 0
}
]
}
];

const primitive = this.topology == GLTFRenderMode.TRIANGLE_STRIP ? {topology: "triangle-strip" as GPUPrimitiveTopology, stripIndexFormat: this.indices!.elementType as GPUIndexFormat} : {topology: "triangle-list" as GPUPrimitiveTopology};

this.renderPipeline = getPipelineForArgs(vertexBuffers, primitive, colorFormat, depthFormat, uniformsBGLayout, device, shaderModule)

}

Our primitive will build up a list of its vertex attributes (just positions for now) and will create an object defining how its topology is laid out. These values — along with some outer context about the uniform bind groups, and texture formats for the screen — are passed into a function called getPipelineForArgs to build and return the render pipeline we will use for the primitive.

Switching pipelines is expensive so if we can re-use pipelines, we will have a much better time rendering our objects. However, pipelines need to have the same vertex attributes and primitive topology so we cannot just blindly use one pipeline for all our primitives. We create a map keyed by those values so we can re-use a pipeline only if it is valid for our primitive.

const pipelineGPUData = new Map();
let numPipelines = 0;

export function getPipelineForArgs(vertexBufferLayouts: GPUVertexBufferLayout[], primitive: GPUPrimitiveState, colorFormat: GPUTextureFormat, depthFormat: GPUTextureFormat,
uniformsBGLayout: GPUBindGroupLayout, device: GPUDevice, shaderModule: GPUShaderModule) {

const key = JSON.stringify({vertexBufferLayouts, primitive});
let pipeline = pipelineGPUData.get(key);
if (pipeline) {
return pipeline;
}

numPipelines++;
console.log(`Pipeline #${numPipelines}`);

const layout = device.createPipelineLayout({
bindGroupLayouts: [uniformsBGLayout]
});

pipeline = device.createRenderPipeline({
vertex: {
entryPoint: "vertex_main",
module: shaderModule,
buffers: vertexBufferLayouts
},
fragment: {
module: shaderModule,
entryPoint: "fragment_main",
targets: [
{
format: colorFormat,
}
],
},
primitive: {
...primitive,
cullMode:"back",
},
depthStencil: {
format: depthFormat,
depthWriteEnabled: true,
depthCompare: "less"
},
layout: layout
});

pipelineGPUData.set(key, pipeline);

return pipeline;
}

If there is already an acceptable pipeline, we can re-use it instead of creating a brand new one. If there is not a pipeline we can use, we will build one and add it to the map.

Rendering

Now, we can actually render our meshes.

render(renderPassEncoder: GPURenderPassEncoder, viewParamBindGroup: GPUBindGroup) {
for (const primitive of this.primitives) {
primitive.render(renderPassEncoder, viewParamBindGroup);
}
}

Like building the render pipeline, the call to render comes in through the mesh and gets propagated to our primitives.

render(renderPassEncoder: GPURenderPassEncoder, viewParamBindGroup: GPUBindGroup) {
renderPassEncoder.setPipeline(this.renderPipeline!);
renderPassEncoder.setBindGroup(0, viewParamBindGroup);

renderPassEncoder.setVertexBuffer(0,
this.positions.view.gpuBuffer,
this.positions.byteOffset,
this.positions.byteLength
);

if (this.indices) {
renderPassEncoder.setIndexBuffer(this.indices.view.gpuBuffer!, this.indices.elementType as GPUIndexFormat, this.indices.byteOffset, this.indices.byteLength);
renderPassEncoder.drawIndexed(this.indices.count);
} else {
renderPassEncoder.draw(this.positions.count);
}
}

When we render a primitive, we set the render pipeline to our primitive’s render pipeline and then set bind group 0 to the one provided — for our camera position — and set the vertex buffer at slot 0 to our positions for the primitive.

If our primitive used indices, we set them in our pipeline and draw using drawIndexed. Otherwise, we just draw.

Application

Now we can run our application. The structure and drawing is all contained within our glTF classes, so our application only needs to set up the GPU, load the data up and call the mesh render function.

const App = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);

useEffect(() => {
initWebGPU();
}, []);

const initWebGPU = async () => {
//aquire and configure the GPU
if (navigator.gpu === undefined) {
alert('WebGPU is not supported');
return;
}

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
alert('Failed to create GPU device');
return;
}

const canvas = canvasRef.current;
if (!canvas) {
alert('Failed to get canvas');
return;
}
const context = canvas.getContext('webgpu');
if (!context) {
alert('Failed to get webgpu context');
return;
}

// configure canvas, deth texture and some uniforms
context?.configure({
device: device,
format: navigator.gpu.getPreferredCanvasFormat(),
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});

const depthTexture = device?.createTexture({
size: [canvas.width, canvas.height, 1],
format: 'depth24plus-stencil8',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
});

const viewParamBindGroupLayout = device?.createBindGroupLayout({
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {type: 'uniform'},
}],
});
const viewParamBuffer = device?.createBuffer({
size: 16 * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const viewParamBindGroup = device?.createBindGroup({
layout: viewParamBindGroupLayout,
entries: [{
binding: 0,
resource: {buffer: viewParamBuffer},
}],
});

const mesh = await fetch("./Avocado.glb")
.then(res => res.arrayBuffer())
.then(buffer => uploadGLB(buffer, device));

const shaderModule = await device?.createShaderModule({
code: gltfShader,
});

mesh.buildRenderPipeline(device, shaderModule, navigator.gpu.getPreferredCanvasFormat(), 'depth24plus-stencil8', viewParamBindGroupLayout);

const camera = new ArcballCamera([0, 0, 0.3], [0, 0, 0], [0, 1, 0], 0.5, [
canvas.width,
canvas.height,
]);

const projection = glMatrix.mat4.perspective(
glMatrix.mat4.create(),
(50 * Math.PI) / 180.0,
canvas.width / canvas.height,
0.1,
1000
);

let projView = glMatrix.mat4.create();
const controller = new Controller();

controller.mousemove = function (prev: any, cur: any, event: { buttons: number; }) {
if (event.buttons == 1) {
camera.rotate(prev, cur);
} else if (event.buttons == 2) {
camera.pan([cur[0] - prev[0], prev[1] - cur[1]])
}
}
controller.wheel = function (amount: number) {
camera.zoom(amount * 0.5);
}
controller.registerForCanvas(canvas);

const renderPassDesc = {
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp:"clear" as GPULoadOp,
loadValue: [0.3, 0.3, 0.3, 1],
storeOp: "store" as GPUStoreOp
}],
depthStencilAttachment: {
view: depthTexture.createView(),
depthLoadOp: "clear" as GPULoadOp,
depthClearValue: 1.0,
depthStoreOp: "store" as GPUStoreOp,
stencilLoadOp: "clear" as GPULoadOp,
stencilClearValue: 0,
stencilStoreOp: "store" as GPUStoreOp
}
}

const frame = function() {
const viewParamUpdateBuffer = device.createBuffer({
size: 16 * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.COPY_SRC,
mappedAtCreation: true
});
projView = glMatrix.mat4.mul(projView, projection, camera.camera);
const map = new Float32Array(viewParamUpdateBuffer.getMappedRange());
map.set(projView);
viewParamUpdateBuffer.unmap();

renderPassDesc.colorAttachments[0].view = context.getCurrentTexture().createView();

const commandEncoder = device.createCommandEncoder();
commandEncoder.copyBufferToBuffer(viewParamUpdateBuffer, 0, viewParamBuffer, 0, 16 * Float32Array.BYTES_PER_ELEMENT);
const renderPass = commandEncoder.beginRenderPass(renderPassDesc);
mesh.render(renderPass, viewParamBindGroup);
renderPass.end();

device.queue.submit([commandEncoder.finish()]);
viewParamUpdateBuffer.destroy();
requestAnimationFrame(frame);
}

requestAnimationFrame(frame);
}

return (
<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh'}}>
<canvas width="800" height="600" style={{border: '1px solid black'}} ref={canvasRef}></canvas>
</div>
)
}

export default App;

Our viewer logic is contained inside of initWebGPU which runs when the component first mounts.

We configure and set up the render context and create our depth texture. Then, we build out a uniform bind group for our camera params, load up our glb and build the render pipeline.

To manage interactions, we will use two external modules — ArcBallCamera and ez_canvas_controller — their functionality is very similar to what we did ourselves in part 9. They will handle updating the view param buffer.

In our frame function — which is called on each requestAnimationFrame — we will update our view based on what our controls say, then we set up our renderPass and ask the mesh to render itself to the screen.

Conclusion

In this article, we learned about glTF and used our knowledge of WebGPU to build a simple viewer to load in and render a single mesh from a glb file. By encapsulating the logic inside of our GLTF classes, we have made our application logic quite simple and have made our model loading module easily extendable to use all the features glTF can offer.

--

--

Matthew MacFarquhar
Matthew MacFarquhar

Written by Matthew MacFarquhar

I am a software engineer working for Amazon living in SF/NYC.

No responses yet