WebGPU is a new graphics API for the web that follows the architecture of modern computer graphics APIs such as Vulkan, DirectX 12, and Metal. This shift in paradigm for web graphics APIs allows users to take advantage of the same benefits native graphics APIs bring, faster applications thanks to the ability to keep the GPU busy with work, less graphics driver specific bugs, and the potential for new features should they be implemented in the future either by vendor extensions or in the specification itself.

WebGPU is arguably the most complex out of all rendering APIs on the web, though this cost is offset by both the increase in performance and guarantee of future support that the API provides. This post aims to demystify the API, making it easier to piece together how to write web apps that use it.

⚠️ Note: The WebGPU’s specification is still a work in progress, so the following is subject to change. This blog post is based on their API as of June 6th 2021, let me know either here or on Twitter if anything’s changed and I’ll update it right away.

I’ve prepared a Github repo with everything you need to get started. We’ll walk through writing a WebGPU Hello Triangle application in TypeScript.

Check out my other post on WebGL for writing graphics applications on with a more mature and supported web graphics API.


First install:

Then type the following in any terminal your such as VS Code’s Integrated Terminal.

# 🐑 Clone the repo
git clone https://github.com/alaingalvan/webgpu-seed
# 💿 go inside the folder
cd webgpu-seed
# 🔨 Start building the project
npm start

Refer to this blog post on designing web libraries and apps for more details on Node.js, packages, etc.

Project Layout

As your project becomes more complex, you’ll want to separate files and organize your application to something more akin to a game or renderer, check out this post on game engine architecture and this one on real time renderer architecture for more details.

├─ 📂 node_modules/   # 👶 Dependencies
│ ├─ 📁 gl-matrix # ➕ Linear Algebra
│ └─ 📁 ... # 🕚 Other Dependencies (TypeScript, Webpack, etc.)
├─ 📂 src/ # 🌟 Source Files
│ ├─ 📄 renderer.ts # 🔺 Triangle Renderer
│ └─ 📄 main.ts # 🏁 Application Main
├─ 📄 .gitignore # 👁️ Ignore certain files in git repo
├─ 📄 package.json # 📦 Node Package File
├─ 📄 license.md # ⚖️ Your License (Unlicense)
└─ 📃readme.md # 📖 Read Me!


  • gl-matrix — A JavaScript library that allows users to write glsl like JavaScript code, with types for vectors, matrices, etc. While not in use in this sample, it's incredibly useful for programming more advanced topics such as camera matrices.
  • TypeScript — JavaScript with types, makes it significantly easier to program web apps with instant autocomplete and type checking.
  • Webpack — A JavaScript compilation tool to build minified outputs and test our apps faster.


In this application we will need to do the following:

  1. Initialize the API — Check if navigator.gpu exists, and if it does, request a GPUAdapter, then request a GPUDevice, and get that device's default GPUQueue.
  2. Setup Frame Backings — Create a GPUSwapchain to receive a GPUTexture output for the current frame, as well as any other attachments you might need (such as a depth-stencil texture, etc.). Create GPUTextureViews for those textures.
  3. Initialize Resources — Create your Vertex and Index GPUBuffers, pre-compile your Shaders to SPIR-V and load your SPIR-V shader binary as GPUShaderModules, create your GPURenderPipeline by describing every stage of a Graphics or Compute pipeline. Finally, build your GPUCommandEncoder with what what render passes you intend to run, then a GPURenderPassEncoder with all the draw calls you intend to execute for that render pass.
  4. Render — Submit your GPUCommandEncoder by calling .finish(), and submitting that to your GPUQueue. Refresh the swapchain by calling requestAnimationFrame.
  5. Destroy — Destroy any data structures after you’re done using the API.

The following will explain snippets from that can be found in the Github repo, with certain parts omitted, and member variables (this.memberVariable) declared inline without the this. prefix so their type is easier to see and the examples here can work on their own.

Initialize API

Entry Point

To access the WebGPU API, you need to see if there exists a gpu object in the global navigator.

// 🏭 Entry to WebGPU
const entry: GPU = navigator.gpu;
if (!entry) {
throw new Error('WebGPU is not supported on this browser.')


An Adapter describes the physical properties of a given GPU, such as its name, extensions, and device limits.

// ✋ Declare adapter handle
let adapter: GPUAdapter = null;
// 🙏 Inside an async function...// 🔌 Physical Device Adapter
adapter = await entry.requestAdapter();


A Device is how you access the core of the WebGPU API, and will allow you to create the data structures you’ll need.

// ✋ Declare device handle
let device: GPUDevice = null;
// 🙏 Inside an async function...// 💻 Logical Device
device = await adapter.requestDevice();


A Queue allows you to send work asynchronously to the GPU. As of the writing of this post, you can only access a defaultQueue from a given GPUDevice.

// ✋ Declare queue handle
let queue: GPUQueue = null;

// 📦 Queue
queue = device.queue;

Frame Backings


In order to see what you’re drawing, you’ll need an HTMLCanvasElement and to create a Swapchain from that canvas.

// ✋ Declare Swapchain handle
let swapchain: GPUSwapchain = null;

const context: GPUCanvasContext = canvas.getContext('gpupresent') as any;

// ⛓️ Create Swapchain
const swapChainDesc: GPUSwapChainDescriptor = {
device: device,
format: 'bgra8unorm',
usage: GPUTextureUsage.OUTPUT_ATTACHMENT | GPUTextureUsage.COPY_SRC
swapchain = context.configureSwapChain(swapChainDesc);

Frame Buffer Attachments

When executing different passes of your rendering system, you’ll need output textures to write to, be it depth textures for depth testing or shadows, or attachments for various aspects of a deferred renderer such as view space normals, PBR reflectivity/roughness, etc.

Frame buffers attachments are references to texture views, which you’ll see later when we write our rendering logic.

// ✋ Declare attachment handles
let depthTexture: GPUTexture = null;
let depthTextureView: GPUTextureView = null;
// 🤔 Create Depth Backing
const depthTextureDesc: GPUTextureDescriptor = {
size: [canvas.width, canvas.height, 1],
mipLevelCount: 1,
sampleCount: 1,
dimension: '2d',
format: 'depth24plus-stencil8',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
depthTexture = device.createTexture(depthTextureDesc);
depthTextureView = depthTexture.createView();
// ✋ Declare swapchain image handles
let colorTexture: GPUTexture = null;
let colorTextureView: GPUTextureView = null;
colorTexture = swapchain.getCurrentTexture();
colorTextureView = colorTexture.createView();

Initialize Resources


A Buffer is an array of data, such as a mesh’s positional data, color data, index data, etc. When rendering triangles with a raster based graphics pipeline, you’ll need 1 or more buffers of vertex data (commonly referred to as Vertex Buffer Objects or VBOs), and 1 buffer of the indices that correspond with each triangle vertex that you intend to draw (otherwise known as an Index Buffer Object or IBO).

// 📈 Position Vertex Buffer Data
const positions = new Float32Array([
1.0, -1.0, 0.0,
-1.0, -1.0, 0.0,
0.0, 1.0, 0.0
// 🎨 Color Vertex Buffer Data
const colors = new Float32Array([
1.0, 0.0, 0.0, // 🔴
0.0, 1.0, 0.0, // 🟢
0.0, 0.0, 1.0 // 🔵
// 📇 Index Buffer Data
const indices = new Uint16Array([ 0, 1, 2 ]);
// ✋ Declare buffer handles
let positionBuffer: GPUBuffer = null;
let colorBuffer: GPUBuffer = null;
let indexBuffer: GPUBuffer = null;
// 👋 Helper function for creating GPUBuffer(s) out of Typed Arrays
let createBuffer = (arr: Float32Array | Uint16Array, usage: number) => {
// 📏 Align to 4 bytes (thanks @chrimsonite)
let desc = { size: ((arr.byteLength + 3) & ~3), usage, mappedAtCreation: true };
let buffer = device.createBuffer(desc);
const writeArray =
arr instanceof Uint16Array ? new Uint16Array(buffer.getMappedRange()) : new Float32Array(buffer.getMappedRange());
return buffer;
positionBuffer = createBuffer(positions, GPUBufferUsage.VERTEX);
colorBuffer = createBuffer(colors, GPUBufferUsage.VERTEX);
indexBuffer = createBuffer(indices, GPUBufferUsage.INDEX);

Compiling Shaders

In this example, the following was used as our vertex shader source:

#version 450layout (location = 0) in vec3 inPos;
layout (location = 1) in vec3 inColor;
layout (location = 0) out vec3 outColor;void main()
outColor = inColor;
gl_Position = vec4(inPos.xyz, 1.0);

In this example, the following was used as our fragment shader source:

#version 450// Varying
layout (location = 0) in vec3 inColor;
// Return Output
layout (location = 0) out vec4 outFragColor;
void main()
outFragColor = vec4(inColor, 1.0);

With glslang installed and accessible in your terminal’s PATH, run the following:

glslangValidator -V triangle.vert -o triangle.vert.spv
glslangValidator -V triangle.frag -o triangle.frag.spv

Shader Modules

Shader Modules are pre-compiled shader binaries that execute on the GPU when executing a given pipeline.

As shaders need to be pre-compiled to be used in WebGPU, you’ll need to use a tool to compile your shaders to SPIR-V. I’m currently working on a new version of CrossShader that will allow for this, but there’s certainly room for usability improvements such as WebPack shader loaders, etc.

// ✋ Declare shader module handles
let vertModule: GPUShaderModule = null;
let fragModule: GPUShaderModule = null;
// 👋 Helper function for creating GPUShaderModule(s) out of SPIR-V files
let loadShader = (shaderPath: string) =>
fetch(new Request(shaderPath), { method: 'GET', mode: 'cors' }).then((res) =>
res.arrayBuffer().then((arr) => new Uint32Array(arr))
// 🙏 inside an async function...
// ⚠️ Note: You could include these binaries as variables in your javascript source.
const vsmDesc: any = { code: await loadShader('triangle.vert.spv') };
vertModule = device.createShaderModule(vsmDesc);
const fsmDesc: any = { code: await loadShader('triangle.frag.spv') };
fragModule = device.createShaderModule(fsmDesc);

Uniform Buffer

You’ll often times need to feed data directly to your shader modules, and to do this you’ll need to specify a uniform. In order to create a Uniform Buffer in your shader, declare the following prior to your main function:

// 🕸️ In your Vertex Shader
layout (set = 0, binding = 0) uniform UBO
mat4 modelViewProj;
vec4 primaryColor;
vec4 accentColor;
// ❗ Then in your Vertex Shader's main file, replace the second to last line with:
gl_Position = modelViewProj * vec4(inPos, 1.0);

Then in your JavaScript code, create a Uniform Buffer as you would with an index/vertex buffer.

You’ll want to use a library like gl-matrix in order to better manage linear algebra calculations such as matrix multiplication.

// 👔 Uniform Data
const uniformData = new Float32Array([
// ♟️ ModelViewProjection Matrix
1.0, 0.0, 0.0, 0.0
0.0, 1.0, 0.0, 0.0
0.0, 0.0, 1.0, 0.0
0.0, 0.0, 0.0, 1.0
// 🔴 Primary Color
0.9, 0.1, 0.3, 1.0
// 🟣 Accent Color
0.8, 0.2, 0.8, 1.0
// ✋ Declare buffer handles
let uniformBuffer: GPUBuffer = null;
uniformBuffer = createBuffer(uniformData, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);

Pipeline Layout

Once you have a uniform, you can create a Pipeline Layout to describe where that uniform will be when executing a Graphics Pipeline.

// ✋ Declare handles
let uniformBindGroupLayout: GPUBindGroupLayout = null;
let uniformBindGroup: GPUBindGroup = null;
let layout: GPUPipelineLayout = null;
// 📁 Bind Group Layout
uniformBindGroupLayout = device.createBindGroupLayout({
bindings: [{
binding: 0,
visibility: GPUShaderStage.VERTEX,
type: "uniform-buffer"
// 🗄️ Bind Group
// ✍ This would be used when encoding commands
uniformBindGroup = device.createBindGroup({
layout: uniformBindGroupLayout,
bindings: [{
binding: 0,
resource: {
buffer: uniformBuffer
// 🗂️ Pipeline Layout
// 👩‍🔧 this would be used as a member of a GPUPipelineDescriptor
layout = device.createPipelineLayout({bindGroupLayouts: [uniformBindGroupLayout]});

Graphics Pipeline

A Graphics Pipeline describes all the data that’s to be fed into the execution of a raster based graphics pipeline. This includes:

  • 🔣 Input Assembly — What does each vertex look like? Which attributes are where, and how do they align in memory?
  • 🖍️ Shader Modules — What shader modules will you be using when executing this graphics pipeline?
  • ✏️ Depth/Stencil State — Should you perform depth testing? If so, what function should you use to test depth?
  • 🍥 Blend State — How should colors be blended between the previously written color and current one?
  • 🔺 Rasterization — How does the rasterizer behave when executing this graphics pipeline? Does it cull faces? Which direction should the face be culled?
  • 💾 Uniform Data — What kind of uniform data should your shaders expect? In WebGPU this is done by describing a Pipeline Layout.
// ✋ Declare pipeline handle
let pipeline: GPURenderPipeline = null;
// ⚗️ Graphics Pipeline// 🔣 Input Assembly
const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0, // [[attribute(0)]]
offset: 0,
format: 'float32x3'
const colorAttribDesc: GPUVertexAttribute = {
shaderLocation: 1, // [[attribute(1)]]
offset: 0,
format: 'float32x3'
const positionBufferDesc: GPUVertexBufferLayout = {
attributes: [positionAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
const colorBufferDesc: GPUVertexBufferLayout = {
attributes: [colorAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
// 🌑 Depth
const depthStencil: GPUDepthStencilState = {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus-stencil8'
// 🦄 Uniform Data
const pipelineLayoutDesc = { bindGroupLayouts: [] };
const layout = device.createPipelineLayout(pipelineLayoutDesc);
// 🎭 Shader Stages
const vertex: GPUVertexState = {
module: vertModule,
entryPoint: 'main',
buffers: [positionBufferDesc, colorBufferDesc]
// 🌀 Color/Blend State
const colorState: GPUColorTargetState = {
format: 'bgra8unorm',
blend: {
alpha: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
color: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
writeMask: GPUColorWrite.ALL
const fragment: GPUFragmentState = {
module: fragModule,
entryPoint: 'main',
targets: [colorState],
// 🟨 Rasterization
const primitive: GPUPrimitiveState = {
frontFace: 'cw',
cullMode: 'none', topology: 'triangle-list'
const pipelineDesc: GPURenderPipelineDescriptorNew = {
pipeline = device.createRenderPipeline(pipelineDesc);

Command Encoder

CCommand Encoders encode all the draw commands you intend to execute in groups of Render Pass Encoders. Once you’ve finished encoding commands, you’ll receive a Command Buffer that you could submit to your queue.

In that sense a command buffer is analogous to a callback that executes draw functions on the GPU once it’s submitted to the queue.

// ✋ Declare command handles
let commandEncoder: GPUCommandEncoder = null;
let passEncoder: GPURenderPassEncoder = null;
// ✍️ Write commands to send to the GPU
function encodeCommands() {
let colorAttachment: GPURenderPassColorAttachment = {
view: colorTextureView,
loadValue: { r: 0, g: 0, b: 0, a: 1 },
storeOp: 'store'
const depthAttachment: GPURenderPassDepthStencilAttachment = {
view: depthTextureView,
depthLoadValue: 1,
depthStoreOp: 'store',
stencilLoadValue: 'load',
stencilStoreOp: 'store'
const renderPassDesc: GPURenderPassDescriptor = {
colorAttachments: [colorAttachment],
depthStencilAttachment: depthAttachment
commandEncoder = device.createCommandEncoder(); // 🖌️ Encode drawing commands
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setScissorRect(0, 0, canvas.width, canvas.height);
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.setVertexBuffer(1, colorBuffer);
passEncoder.setIndexBuffer(indexBuffer, 'uint16');
passEncoder.drawIndexed(3, 1, 0, 0, 0);


Rendering in WebGPU is a simple matter of updating any uniforms you intend to update, getting the next attachments from your swapchain, submitting your command encoders to be executed, and using the requestAnimationFrame callback to do all of that again.

let render = () => {
// ⏭ Acquire next image from swapchain
colorTexture = swapchain.getCurrentTexture();
colorTextureView = colorTexture.createView();
// 📦 Write and submit commands to queue
// ➿ Refresh canvas


WebGPU might be more difficult than other graphics APIs, but it’s an API that more closely aligns with the design of modern graphics cards, and as a result, should not only result in faster applications, but also applications that should last longer.

There were a few things I didn’t cover in this post as they would have been a beyond the scope of this post, such as:

  • Matrices (for camera calculations)
  • A detailed overview of every possible state of a graphics pipeline
  • Compute pipelines
  • Loading textures
  • Ray tracing

Additional Resources

Here’s a few articles/projects for WebGPU in no particular order:

There’s also a number of open source projects including:

The specification for WebGPU and WebGPU Shading Language is also worth taking a look:

You can find all the source for this post in the GitHub Repo here.

https://Alain.xyz | Graphics Software Engineer @ Marmoset.co. Guest lecturer talking about 🛆 Computer Graphics, ✍ tech author.