graphicswebgpu

WebGPU: OBJ Loading and Perspective Camera

I have a small blog post sitting in my drafts I thought I would finally finish. I haven’t seen many practical examples how to get started with WebGPU and I believe it’s a great API and has a great future ahead.

In this post I will show how to:

If you haven’t used WebGPU before but have some beginner to intermediate experience with GPU APIs, this blog post might be for you.

Let’s get started!

OBJ parsing

OBJ is a simple text format for storing 3D models and particularly easy to parse.

bash
v 0.723607 -0.447220 0.525725
# ...
vn 0.6363 -0.4776 0.6058
# ...
vt 0.159092 0.118096
# ...
f 1/1/1 20/38/2 19/36/3

This is how a simple *.obj parser looks. It assumes triangulated faces with positive vertex, UV and normal indices (v/vt/vn).

Obj.ts
import { Vec2 } from "./math/Vec2";
import { Vec3 } from "./math/Vec3";

export type FaceVertex = {
  vertexIndex: number;
  uvIndex?: number;
  normalIndex?: number;
};

export type Face = {
  vertices: FaceVertex[];
};

export type ObjData = {
  vertices: Vec3[];
  uvs: Vec2[];
  normals: Vec3[];
  faces: Face[];
};

export function parseObjFile(objFileContents: string): ObjData {
  const vertices: Vec3[] = [];
  const uvs: Vec2[] = [];
  const normals: Vec3[] = [];
  const faces: Face[] = [];

  const lines = objFileContents.split("\n");
  for (const line of lines) {
    const t = line.trim().split(/\s+/);
    if (t[0] === "v") {
      vertices.push(new Vec3(parseFloat(t[1]), parseFloat(t[2]), parseFloat(t[3])));
    } else if (t[0] === "vt") {
      uvs.push(new Vec2(parseFloat(t[1]), parseFloat(t[2])));
    } else if (t[0] === "vn") {
      normals.push(new Vec3(parseFloat(t[1]), parseFloat(t[2]), parseFloat(t[3])));
    } else if (t[0] === "f") {
      const face: FaceVertex[] = [];
      // We are parsing the rest of the line that started with `f`.
      for (let i = 1; i < t.length; i++) {
        const v = t[i].split("/");
        const vertexIndex = parseInt(v[0]) - 1;
        const uvIndex = v[1] ? parseInt(v[1]) - 1 : undefined;
        const normalIndex = v[2] ? parseInt(v[2]) - 1 : undefined;
        face.push({ vertexIndex, uvIndex, normalIndex });
      }
      faces.push({ vertices: face });
    }
  }

  return { vertices, uvs, normals, faces };
}

Uploading data to GPU

If you are coming to WebGPU with OpenGL background, there will be some similarities. We have to create a shader module. We have to create buffers. But the rest is quite different.

The biggest unit of state is pipeline. Pipeline is basically a combination of shader, depth buffer write/read settings, color blending, and couple other things.

Shader defines compatible bind group layouts. During rendering we select which bind groups should be used. Bind groups contain information such as buffers (for example for storing matrices) or textures.

You can see the code in index.ts in the editor:

Loading editor

This is everything we needed to get some triangles to show up on the screen!

Adding perspective – 3D camera

Next we will add a 3D camera. We will need to store a view-projection matrix in a uniform buffer. For that we need a buffer and our first bind group. Bind group layout can be explicitly declared or created automatically. We will do the latter.

In index.ts:

ts
const uniformBuffer = device.createBuffer({
  size: 4 * 16,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

// ...

const bindGroup = device.createBindGroup({
  layout: pipeline.getBindGroupLayout(0),
  entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
});

Inside render():

ts
passEncoder.setBindGroup(0, bindGroup);

Updated shader:

wgsl
struct VSOut {
  @builtin(position) Position: vec4f,
  @location(0) normal: vec3f,
  @location(1) uv: vec2f,
};

struct Uniforms {
  mvp: mat4x4f,
}

@group(0) @binding(0) var<uniform> uni: Uniforms;

@vertex
fn main(
  @location(0) inPosition: vec3f,
  @location(1) inNormal: vec3f,
  @location(2) inUV: vec2f,
) -> VSOut {
    var vsOut: VSOut;
    vsOut.Position = uni.mvp * vec4f(inPosition, 1);
    vsOut.normal = inNormal;
    vsOut.uv = inUV;
    return vsOut;
}
Loading editor

Now we are looking at the icosahedron from a distance and with proper perspective!

Interactive camera

The next step is to make the camera interactive. I will create a new Camera.ts module for that. Target (what we want to look at) and the distance from it will be the source of truth. Based on that I will calculate camera position.

Camera.ts
import { clamp } from "./utils";
import { Mat4 } from "./math/Mat4";
import { Vec3 } from "./math/Vec3";

export class Camera {
  target = new Vec3(0, 0, 0);
  distance = 10;

  scrollDirection = 0;

  wheelTimeout: number | null = null;

  private lastX: number = 0;
  private lastY: number = 0;
  private isDragging: boolean = false;

  constructor(
    public pitch: number,
    public yaw: number,
  ) {
    document.addEventListener("mousedown", this.handleMouseDown);
    document.addEventListener("mousemove", this.handleMouseMove);
    document.addEventListener("mouseup", this.handleMouseUp);
    document.addEventListener("wheel", this.handleMouseWheel);
  }

  handleMouseDown = (event: MouseEvent) => {
    this.isDragging = true;
    this.lastX = event.clientX;
    this.lastY = event.clientY;
  };

  handleMouseMove = (event: MouseEvent) => {
    if (!this.isDragging) return;

    const dx = event.clientX - this.lastX;
    const dy = event.clientY - this.lastY;

    this.lastX = event.clientX;
    this.lastY = event.clientY;

    this.pitch -= dy * 0.01;
    this.yaw += dx * 0.01;
  };

  handleMouseUp = (event: MouseEvent) => {
    this.isDragging = false;
  };

  handleMouseWheel = (event: WheelEvent) => {
    const scaleFactor = 0.04;
    this.distance += event.deltaY * scaleFactor;
    this.distance = clamp(this.distance, 2, 50);
  };

  getPosition(): Vec3 {
    return new Vec3(
      Math.cos(this.pitch) * Math.cos(this.yaw),
      Math.sin(this.pitch),
      Math.cos(this.pitch) * Math.sin(this.yaw),
    )
      .scale(this.distance)
      .add(this.target);
  }

  getView(): Mat4 {
    const position = this.getPosition();
    return Mat4.lookAt(position, this.target, new Vec3(0, 1, 0));
  }
}

Shading

Final step is to add basic simulation of real lighting model. As I mentioned, classic Phong will do a great job here. For that we will add a couple of new uniform values:

More uniform values:

diff
struct Uniforms {
  mvp: mat4x4f,
+  model: mat4x4f,
+  cameraPosition: vec3f,
+  lightPosition: vec3f,
+  lightColor: vec3f,
}

Now an important thing to know about how WebGPU approaches buffers. In WGSL, vec2f has 8-byte alignment, while vec3f has 16-byte alignment but takes 12 bytes. In this struct, the two matrices start at offsets 0 and 64, then the three vectors at 128, 144 and 160. We add four bytes after each vector, making the total size 176 bytes.

diff
const uniformBuffer = device.createBuffer({
-  size: 4 * 16,
+  size: 2 * 4 * 16 + 3 * 4 * 4, // 2 * mat4x4f + 3 padded vec3f
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

Here’s the padding with 0s in practice:

diff
const mvp = model.multiply(view).multiply(projection);

-queue.writeBuffer(uniformBuffer, 0, new Float32Array(mvp.data));
+queue.writeBuffer(uniformBuffer, 0, new Float32Array([
+  ...mvp.data,
+  ...model.data,
+  cameraPosition.x,
+  cameraPosition.y,
+  cameraPosition.z,
+  0,
+  lightPosition.x,
+  lightPosition.y,
+  lightPosition.z,
+  0,
+  lightColor.x,
+  lightColor.y,
+  lightColor.z,
+  0,
+]));
queue.submit([commandEncoder.finish()]);

The lighting calculations use world-space positions and normals. Add model to the vertex shader’s uniform struct as well and transform both outputs:

wgsl
vsOut.fragmentPosition = (uni.model * vec4f(inPosition, 1)).xyz;
vsOut.normal = normalize((uni.model * vec4f(inNormal, 0)).xyz);

This works for the rotation and uniform scale used here.

And finally the shading in fragment shader:

wgsl
let ambientStrength = 0.1;
let ambient = uni.lightColor * ambientStrength;

let norm = normalize(normal);
let lightDir = normalize(uni.lightPosition - fragmentPosition);
let diff = max(dot(norm, lightDir), 0.0);
let diffuse = uni.lightColor * diff;

let specularStrength = 0.5;
let viewDir = normalize(uni.cameraPosition - fragmentPosition);
let reflectDir = reflect(-lightDir, norm);
let spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
let specular = specularStrength * spec * uni.lightColor;

let objectColor = vec3f(1, 0.5, 0.31);
let result = (ambient + diffuse + specular) * objectColor;

return vec4f(result, 1);

Result

Drag to rotate, scroll to zoom. Keep in mind that it is the camera rotating around the object, but due to lack of background, optical illusion suggests otherwise.

Loading editor

Hope this article was useful! If you are curious how to implement much more modern and realistic lighting model, see my PBR in WebGPU: Implementation Details blog post.