WebGPU Rendering: Part 16 Animations

Matthew MacFarquhar
10 min readJan 29, 2025

--

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 Animations. Animations are enabled using a list of bones. The bones form a skeleton which is a hierarchical structure where each bone (or joint) is connected in a parent-child relationship, allowing transformations to propagate down the chain. The bone influence matrix determines how much each bone affects the vertices of a mesh, blending multiple bone transformations smoothly for realistic deformations. By using a tree-like hierarchy, moving a parent bone affects all its children, enabling complex animations like character limb movement with minimal data input.

The following link is the commit in my Github repo that matches the code we will go over.

Animations

Let’s look at how the bones impact our OBJ shader. You will notice the only additional uniform we take in is the boneTransforms, which will allow us to pass in 16 bones. We also have some boneWeights which vary based on the vertex and are thus provided as vertex attributes in the vertex stage in our shader.

@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>;// = vec4<f32>(0.15, 0.10, 0.10, 1.0);
@group(0) @binding(6)
var<uniform> diffuseColor:vec4<f32>;// = vec4<f32>(0.55, 0.55, 0.55, 1.0);
@group(0) @binding(7)
var<uniform> specularColor:vec4<f32>;// = vec4<f32>(1.0, 1.0, 1.0, 1.0);

@group(0) @binding(8)
var<uniform> shininess:f32;// = 20.0;

@group(1) @binding(0)
var<uniform> boneTransforms: array<mat4x4<f32>, 16>;

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>,
@location(2) boneWeight0: vec4<f32>,
@location(3) boneWeight1: vec4<f32>,
@location(4) boneWeight2: vec4<f32>,
@location(5) boneWeight3: vec4<f32>,
) -> VertexOutput {
var out: VertexOutput;
var totalTransform:mat4x4<f32> = mat4x4<f32>(
0.0,0.0,0.0,0.0,
0.0,0.0,0.0,0.0,
0.0,0.0,0.0,0.0,
0.0,0.0,0.0,0.0
);

totalTransform += boneTransforms[0] * boneWeight0[0];
totalTransform += boneTransforms[1] * boneWeight0[1];
totalTransform += boneTransforms[2]* boneWeight0[2];
totalTransform += boneTransforms[3] * boneWeight0[3];
totalTransform += boneTransforms[4] * boneWeight1[0];
totalTransform += boneTransforms[5] * boneWeight1[1];
totalTransform += boneTransforms[6] * boneWeight1[2];
totalTransform += boneTransforms[7] * boneWeight1[3];
totalTransform += boneTransforms[8] * boneWeight2[0];
totalTransform += boneTransforms[9] * boneWeight2[1];
totalTransform += boneTransforms[10]* boneWeight2[2];
totalTransform += boneTransforms[11] * boneWeight2[3];
totalTransform += boneTransforms[12] * boneWeight3[0];

out.viewDir = normalize((normalMatrix * vec4<f32>(-viewDirection, 0.0)).xyz);
out.lightDir = normalize((normalMatrix * vec4<f32>(-lightDirection, 0.0)).xyz);
out.normal = normalize(normalMatrix * totalTransform * vec4<f32>(inNormal, 0.0)).xyz;

var wldLoc:vec4<f32> = modelView *totalTransform *vec4(inPos,1.0);
out.clip_position = projection * wldLoc;
out.wldLoc = wldLoc.xyz / wldLoc.w;
out.inPos = (totalTransform *vec4(inPos,1.0)).xyz;
var lightLoc:vec4<f32> = modelView * vec4<f32>(lightDirection, 1.0);
out.lightLoc = lightLoc.xyz / lightLoc.w;

return out;
}

@fragment
fn fs_main(in: VertexOutput, @builtin(front_facing) face: bool) -> @location(0) vec4<f32> {
var lightLoc:vec3<f32> = in.lightLoc;
var lightDir:vec3<f32> = normalize(in.lightDir);
var n:vec3<f32> = normalize(in.normal);
var viewDir: vec3<f32> = in.viewDir;

if (face) {
var wldLoc2light:vec3<f32> = in.wldLoc-lightLoc;
var align:f32 = dot( normalize(wldLoc2light),lightDir);

if (align > 0.9) {
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);
}

For each Vertex, we take in the bone transforms and the bone weights and build out a totalTransform matrix. This matrix is then applied to our input vertex’s position and passed into the fragment shader.

All the work in dealing with animations is just around moving vertices, therefore our fragment shader remains untouched.

Animated OBJ

Our RunningCube class will be very similar to our previous OBJ classes, the main differences are that we now need to build a pipeline to use bone weights and bone transforms.

The weights are quite easy to add and will just be loaded in from our OBJ and set using our recursive assignBoneWeightsToVertices function (which traverses down our skeleton and returns a single array of weights). Each Vertex will have 16 associated weights — one for each bone.

export class RunningCube {
private _pipeline: GPURenderPipeline;
private _positionBuffer: GPUBuffer;
private _boneWeightBuffer: GPUBuffer;
private _uniformBindGroup: GPUBindGroup;
private _uniformBindGroupBone: GPUBindGroup;
private _boneTransformsUniformBuffer: GPUBuffer;
private _objBody: any;
private _indexBuffer?: GPUBuffer;
private _indexSize?: number;

public get objBody() {
return this._objBody;
}

public get boneTransformsUniformBuffer() {
return this._boneTransformsUniformBuffer;
}

public static async init(device: GPUDevice, modelViewMatrixUniformBuffer: GPUBuffer,
projectionMatrixUniformBuffer: GPUBuffer, normalMatrixUniformBuffer: GPUBuffer,
viewDirectionUniformBuffer: GPUBuffer, lightDirectionUniformBuffer: GPUBuffer, shaderCode: string): Promise<RunningCube> {
const shaderModule = device.createShaderModule({ code: shaderCode });

const objResponse = await fetch("./data/cuberun.json");
const objBody = await objResponse.json();

const boneWeights = new Float32Array(objBody.vert.length * 16 / 3);
for (const bone of objBody.skeleton) {
RunningCube.assignBoneWeightsToVertices(bone, boneWeights);
}

const boneTransforms = RunningCube.updateAnimation(0, objBody);

const boneTransformsUniformBuffer: GPUBuffer = createGPUBuffer(device, boneTransforms, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
const boneWeightBuffer: GPUBuffer = createGPUBuffer(device, boneWeights, GPUBufferUsage.VERTEX);
const positionBuffer: GPUBuffer = createGPUBuffer(device, new Float32Array(objBody.vert), GPUBufferUsage.VERTEX);

const indexSize = objBody.indices.length;
const indexBuffer: GPUBuffer = createGPUBuffer(device, new Uint16Array(objBody.indices), GPUBufferUsage.INDEX);

const unifromBindGroupLayoutBone: GPUBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
}
]
});
const uniformBindGroupBone: GPUBindGroup = device.createBindGroup({
layout: unifromBindGroupLayoutBone,
entries: [
{
binding: 0,
resource: {
buffer: boneTransformsUniformBuffer
}
}
]
});
uniformBindGroupBone.label = "uniformBindGroupBone";

const uniformBindGroupLayout: GPUBindGroupLayout = 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: {}
}
]
});

const ambientUniformBuffer = createGPUBuffer(device, new Float32Array([0.25, 0.25, 0.25, 1.0]), GPUBufferUsage.UNIFORM);
const diffuseUniformBuffer = createGPUBuffer(device, new Float32Array([0.75, 0.75, 0.95, 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 uniformBindGroup: GPUBindGroup = device.createBindGroup({
layout: uniformBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: modelViewMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUniformBuffer
}
},
{
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
}
}
]
});
uniformBindGroup.label = "uniformBindGroup";

const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0,
offset: 0,
format: 'float32x3'
};

const positionBufferLayoutDesc: GPUVertexBufferLayout = {
attributes: [positionAttribDesc],
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
stepMode: 'vertex'
};

const normalAttribDesc: GPUVertexAttribute = {
shaderLocation: 1,
offset: Float32Array.BYTES_PER_ELEMENT * 3,
format: 'float32x3'
};

const normalBufferLayoutDesc: GPUVertexBufferLayout = {
attributes: [normalAttribDesc],
arrayStride: Float32Array.BYTES_PER_ELEMENT * 6,
stepMode: 'vertex'
};
const boneWeight0AttribDesc: GPUVertexAttribute = {
shaderLocation: 2,
offset: 0,
format: 'float32x4'
};

const boneWeight1AttribDesc: GPUVertexAttribute = {
shaderLocation: 3,
offset: Float32Array.BYTES_PER_ELEMENT * 4,
format: 'float32x4'
};

const boneWeight2AttribDesc: GPUVertexAttribute = {
shaderLocation: 4,
offset: Float32Array.BYTES_PER_ELEMENT * 8,
format: 'float32x4'
};

const boneWeight3AttribDesc: GPUVertexAttribute = {
shaderLocation: 5,
offset: Float32Array.BYTES_PER_ELEMENT * 12,
format: 'float32x4'
};

const boneWeightBufferLayoutDesc: GPUVertexBufferLayout = {
attributes: [boneWeight0AttribDesc, boneWeight1AttribDesc, boneWeight2AttribDesc, boneWeight3AttribDesc],
arrayStride: Float32Array.BYTES_PER_ELEMENT * 16,
stepMode: 'vertex'
};

const layout = device.createPipelineLayout(
{bindGroupLayouts: [uniformBindGroupLayout, unifromBindGroupLayoutBone]}
);
const colorState: GPUColorTargetState = {
format: "bgra8unorm"
};

const pipelineDesc: GPURenderPipelineDescriptor = {
layout: layout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayoutDesc, normalBufferLayoutDesc, boneWeightBufferLayoutDesc]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: "triangle-list",
frontFace: "ccw",
cullMode: "none"
},
depthStencil: {
depthWriteEnabled: true,
depthCompare: "less",
format: "depth32float"
}
};

const pipeline: GPURenderPipeline = device.createRenderPipeline(pipelineDesc);

return new RunningCube(pipeline, positionBuffer, boneWeightBuffer, boneTransformsUniformBuffer, uniformBindGroup, uniformBindGroupBone, indexBuffer, indexSize, objBody);
}

public encodeRenderPass(renderPassEncoder: GPURenderPassEncoder) {
renderPassEncoder.setPipeline(this._pipeline);
renderPassEncoder.setBindGroup(0, this._uniformBindGroup);
renderPassEncoder.setBindGroup(1, this._uniformBindGroupBone);
renderPassEncoder.setVertexBuffer(0, this._positionBuffer);
renderPassEncoder.setVertexBuffer(1, this._positionBuffer);
renderPassEncoder.setVertexBuffer(2, this._boneWeightBuffer);
renderPassEncoder.setIndexBuffer(this._indexBuffer!, 'uint16');
renderPassEncoder.drawIndexed(this._indexSize!);
}

public static updateAnimation(time: number, objBody: any) {
const boneTransforms: Float32Array = new Float32Array(16*16);

for (const bone of objBody.skeleton) {
RunningCube.deriveBoneTransformHelper(time, bone, glMatrix.mat4.identity(glMatrix.mat4.create()), boneTransforms);
}

return boneTransforms;
}

private static interpolateVertexAttributes(time: number, vertexAttributes: any[], interpolate: (attr1: any, attr2: any, t: number) => any) {
const moddedTime = (time * 1000) % vertexAttributes[vertexAttributes.length - 1].time;
let startIndex = 0;
while (startIndex < vertexAttributes.length - 1 && moddedTime > vertexAttributes[startIndex + 1].time) {
startIndex++;
}


const endIndex = (startIndex + 1) % vertexAttributes.length;

if (startIndex == vertexAttributes.length - 1) {
return interpolate(vertexAttributes[0], vertexAttributes[0], 0.5);
}

const endTime = vertexAttributes[endIndex].time;
const startTime = vertexAttributes[startIndex].time;

const factor = (moddedTime - startTime) / (endTime - startTime);
return interpolate(vertexAttributes[startIndex], vertexAttributes[endIndex], factor);
}

private static deriveBoneTransformHelper(time: number, bone: any, parentTransform: glMatrix.mat4, boneTransforms: Float32Array) {
if (bone.id !== undefined) {
const offsetMatrix = glMatrix.mat4.fromValues(
bone.offsetMatrix[0],
bone.offsetMatrix[4],
bone.offsetMatrix[8],
bone.offsetMatrix[12],
bone.offsetMatrix[1],
bone.offsetMatrix[5],
bone.offsetMatrix[9],
bone.offsetMatrix[13],
bone.offsetMatrix[2],
bone.offsetMatrix[6],
bone.offsetMatrix[10],
bone.offsetMatrix[14],
bone.offsetMatrix[3],
bone.offsetMatrix[7],
bone.offsetMatrix[11],
bone.offsetMatrix[15]);

if (bone.ani !== undefined) {
const interpolatedPosition = RunningCube.interpolateVertexAttributes(time, bone.ani.pos, (pos1, pos2, factor) => {
return glMatrix.vec3.lerp(glMatrix.vec3.create(), glMatrix.vec3.fromValues(pos1.pos[0], pos1.pos[1], pos1.pos[2]),
glMatrix.vec3.fromValues(pos2.pos[0], pos2.pos[1], pos2.pos[2]), factor);
});
const translationMatrix = glMatrix.mat4.fromTranslation(glMatrix.mat4.create(), interpolatedPosition);

const interpolatedQuat = RunningCube.interpolateVertexAttributes(time, bone.ani.rot, (quat1, quat2, factor) => {
return glMatrix.quat.lerp(glMatrix.quat.create(),
glMatrix.quat.fromValues(quat1.q[1], quat1.q[2], quat1.q[3], quat1.q[0]),
glMatrix.quat.fromValues(quat2.q[1], quat2.q[2], quat2.q[3], quat2.q[0]),
factor
);
});
const rotationMatrix = glMatrix.mat4.fromQuat(glMatrix.mat4.create(), interpolatedQuat);

const interpolatedScale = RunningCube.interpolateVertexAttributes(time, bone.ani.scal, (scal1, scal2, factor) => {
return glMatrix.vec3.lerp(glMatrix.vec3.create(), glMatrix.vec3.fromValues(scal1.pos[0], scal1.pos[1], scal1.pos[2]),
glMatrix.vec3.fromValues(scal2.pos[0], scal2.pos[1], scal2.pos[2]), factor);
});
const scalingMatrix = glMatrix.mat4.fromScaling(glMatrix.mat4.create(), interpolatedScale);

const rotation_x_scale = glMatrix.mat4.multiply(glMatrix.mat4.create(), rotationMatrix, scalingMatrix);
const locationTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(), translationMatrix, rotation_x_scale);
const globalTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(), parentTransform, locationTransformation);
const finalBoneTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(), globalTransformation, offsetMatrix);

boneTransforms.set(finalBoneTransformation, bone.id * 16);

for (const boneChild of bone.children) {
RunningCube.deriveBoneTransformHelper(time, boneChild, globalTransformation, boneTransforms);
}
} else {
const nodeTransform = glMatrix.mat4.fromValues(bone.nodeTransform[0],
bone.nodeTransform[4],
bone.nodeTransform[8],
bone.nodeTransform[12],
bone.nodeTransform[1],
bone.nodeTransform[5],
bone.nodeTransform[9],
bone.nodeTransform[13],
bone.nodeTransform[2],
bone.nodeTransform[6],
bone.nodeTransform[10],
bone.nodeTransform[14],
bone.nodeTransform[3],
bone.nodeTransform[7],
bone.nodeTransform[11],
bone.nodeTransform[15]);

const globalBoneTransform = glMatrix.mat4.multiply(glMatrix.mat4.create(), parentTransform, nodeTransform);
const finalBoneTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(), globalBoneTransform, offsetMatrix);
boneTransforms.set(finalBoneTransformation, bone.id * 16);

for (const boneChild of bone.children) {
RunningCube.deriveBoneTransformHelper(time, boneChild, globalBoneTransform, boneTransforms);
}
}
} else {
const nodeTransform = glMatrix.mat4.fromValues(bone.nodeTransform[0],
bone.nodeTransform[4],
bone.nodeTransform[8],
bone.nodeTransform[12],
bone.nodeTransform[1],
bone.nodeTransform[5],
bone.nodeTransform[9],
bone.nodeTransform[13],
bone.nodeTransform[2],
bone.nodeTransform[6],
bone.nodeTransform[10],
bone.nodeTransform[14],
bone.nodeTransform[3],
bone.nodeTransform[7],
bone.nodeTransform[11],
bone.nodeTransform[15]);

const globalTransformation = glMatrix.mat4.multiply(glMatrix.mat4.create(), parentTransform, nodeTransform);
for (const boneChild of bone.children) {
RunningCube.deriveBoneTransformHelper(time, boneChild, globalTransformation, boneTransforms);
}
}
}

private static assignBoneWeightsToVertices(bone: any, boneWeights: Float32Array) {
if (bone.weights) {
for (let i = 0; i < bone.weights.length; i++) {
const {id, w} = bone.weights[i];
boneWeights[id * 16 + bone.id] = w;
}
}

if (bone.children) {
for (const childBone of bone.children) {
RunningCube.assignBoneWeightsToVertices(childBone, boneWeights);
}
}
}

private constructor(pipeline: GPURenderPipeline, positionBuffer: GPUBuffer, boneWeightBuffer: GPUBuffer, boneTransformsUniformBuffer: GPUBuffer,
uniformBindGroup: GPUBindGroup, uniformBindGroupBone: GPUBindGroup, indexBuffer: GPUBuffer, indexSize: number, objBody: any) {
this._pipeline = pipeline;
this._positionBuffer = positionBuffer;
this._boneWeightBuffer = boneWeightBuffer;
this._boneTransformsUniformBuffer = boneTransformsUniformBuffer;
this._uniformBindGroup = uniformBindGroup;
this._uniformBindGroupBone = uniformBindGroupBone;
this._indexBuffer = indexBuffer;
this._indexSize = indexSize;
this._objBody = objBody;
}
}

To update the bone transformation matrices, we need to interpolate over the animated bones’ rotations, scales and positions. These transformations are propagated through the skeleton hierarchy, so parent bone animations impact the child bones’ transformations.

Render

Dealing with the complicated transformations and interpolations in the Running Cube class means our rendering code only needs to keep track of the current scene time and ask the Running Cube to update itself using RunningCube.updateAnimation(elapsedTime, runningCube.objBody).

const renderRunningCubeExample = 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);

const arcball = new Arcball(15.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 runningCube = await RunningCube.init(device!, modelViewMatrixUniformBuffer, projectionMatrixUnifromBuffer, normalMatrixUniformBuffer, viewDirectionUniformBuffer, lightDirectionBuffer, animatedObjModelWgsl);

let depthTexture: GPUTexture | null = null;
let depthStencilAttachment: GPURenderPassDepthStencilAttachment | undefined = undefined;
let startTime: number | undefined = undefined;

async function render(timestamp: number) {
if (!startTime) {
startTime = timestamp;
}

const elapsedTime = (timestamp - startTime) / 1000.0;
const devicePixelRatio = window.devicePixelRatio || 1;
let currentCanvasWidth = canvas.clientWidth * devicePixelRatio;
let currentCanvasHeight = canvas.clientHeight * devicePixelRatio;

let projectionMatrixUniformBufferUpdate = null;
const boneTransforms = RunningCube.updateAnimation(elapsedTime, runningCube.objBody);
const boneTransformsUniformBufferUpdate = createGPUBuffer(device!, new Float32Array(boneTransforms), GPUBufferUsage.COPY_SRC);

if (depthTexture == null || currentCanvasWidth != canvas.width || currentCanvasHeight != canvas.height) {
canvas.width = currentCanvasWidth;
canvas.height = currentCanvasHeight;

if (depthTexture !== null) {
depthTexture.destroy();
}

const depthTextureDesc: GPUTextureDescriptor = {
size: [canvas.width, canvas.height, 1],
dimension: '2d',
format: 'depth32float',
usage: GPUTextureUsage.RENDER_ATTACHMENT
};

depthTexture = device!.createTexture(depthTextureDesc);
depthStencilAttachment = {
view: depthTexture.createView(),
depthClearValue: 1,
depthLoadOp: 'clear',
depthStoreOp: 'store'
};

let 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);
}

const modelViewMatrix = arcball.getMatrices();
const modelViewMatrixUniformBufferUpdate = createGPUBuffer(device!, new Float32Array(modelViewMatrix), GPUBufferUsage.COPY_SRC);

const colorTexture = 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 renderPassDesc: GPURenderPassDescriptor = {
colorAttachments: [colorAttachment],
depthStencilAttachment: depthStencilAttachment
};

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(boneTransformsUniformBufferUpdate, 0, runningCube.boneTransformsUniformBuffer, 0, 16 * 16 * Float32Array.BYTES_PER_ELEMENT);

const passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
runningCube.encodeRenderPass(passEncoder);
passEncoder.end();

device!.queue.submit([commandEncoder.finish()]);
await device!.queue.onSubmittedWorkDone();

if (projectionMatrixUniformBufferUpdate) {
projectionMatrixUniformBufferUpdate.destroy();
}
modelViewMatrixUniformBufferUpdate.destroy();
boneTransformsUniformBufferUpdate.destroy();
requestAnimationFrame(render);
}

new Controls(canvas, arcball, render);
requestAnimationFrame(render);
}

Everything else is pretty basic, our render pipeline will start a render pass and delegate drawing the animated cube to the runningCube class itself.

Conclusion

In this article we saw how animations are achieved by embedding a skeleton structure in our model. The skeleton has weights to encode the influence each bone has on the model vertices and is structured in a hierarchical fashion so that parent bone movements impact child bone transforms. We encapsulated the complicated bone transformation logic within the RunnignCube class so our render pipeline only needed to keep track of a time value and tell the cube to update its own bones based on the time.

--

--

Matthew MacFarquhar
Matthew MacFarquhar

Written by Matthew MacFarquhar

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

No responses yet