Drawing points in WebGPU

June 25, 2026

Now that you're here, we're going to learn how to render a bunch of points in WebGPU. To do that, we need to build a simple app.

First, we're going to create an interface to load our vertices from JSON. Why JSON? Honestly, I'm not sure — everything is JSON nowadays. Here it is:

export interface Vertex {
    x: number;
    y: number;
    z: number;
}

Next, let's create a model class that will hold a reference to the vertex array we're going to load from JSON. We'll name it Geometry. The vertices will be uploaded to a GPU buffer the first time our model is exposed to a renderer, so let's add a field for the vertex buffer as well:

export class Geometry {
    vertices: Vertex[];
    vertexBuffer: GPUBuffer | null = null;

    constructor(vertices: Vertex[]) {
        this.vertices = vertices;
    }
}

Now that we have the models for storing vertex data, let's write some shaders:

@vertex
fn vs_main(@location(0) position: vec3) -> @builtin(position) vec4
{
    return vec4(position, 1);
}

@fragment
fn fs_main() -> @location(0) vec4
{
    return vec4(1, 1, 1, 1);
}

The shader code is pretty much self-explanatory. Our vertices are already in NDC space, so the vertex shader does nothing but accept and pass them through. The fragment shader paints them white.

We also need a couple of functions to load geometry and shader code. Since we're going with OOP, let's put them in a dedicated class called AssetLoader:

export class AssetLoader {
    static async loadGeometry(url: string): Promise {
        const response = await fetch(AssetLoader.resolvePath(url));
        const vertices: Vertex[] = await response.json();
        return new Geometry(vertices);
    }

    static async loadShader(url: string): Promise {
        const file = await fetch(AssetLoader.resolvePath(url));
        const text = await file.text();
        return text;
    }

    private static resolvePath(url: string): string {
        const result = new URL(url, import.meta.url).href;
        return result;
    }
}

The private helper method resolvePath ensures that JavaScript can locate assets via relative paths, even when the script is loaded from an HTML file in a different directory.

Now it's time to implement the most important component of our app — the renderer:

export class Renderer {
    private static readonly NUM_COORDS_PER_VERTEX = 3;
    private static readonly VERTEX_SIZE = Renderer.NUM_COORDS_PER_VERTEX * Float32Array.BYTES_PER_ELEMENT;

    private shaderCode: string;
    private canvas: HTMLCanvasElement;
    private context!: GPUCanvasContext;
    private device!: GPUDevice;
    private textureFormat!: GPUTextureFormat;
    private pipeline!: GPURenderPipeline;

    private onInitSuccessful: () => void;
    private onDeviceLost: (info: GPUDeviceLostInfo) => void;

    static async create(
        canvas: HTMLCanvasElement,
        onInitSuccessful: () => void,
        onDeviceLost: (info: GPUDeviceLostInfo) => void) {

        const shaderCode = await AssetLoader.loadShader("shaders/shaders.wgsl");
        const renderer = new Renderer(canvas, shaderCode, onInitSuccessful, onDeviceLost);

        await renderer.init();
        return renderer;
    }
    ...
}

Since we need to call asynchronous methods during setup, we can't initialize the renderer in its constructor. Instead, we expose a static create method:

static async create(
    canvas: HTMLCanvasElement,
    onInitSuccessful: () => void,
    onDeviceLost: (info: GPUDeviceLostInfo) => void) {

    const shaderCode = await AssetLoader.loadShader("shaders/shaders.wgsl");
    const renderer = new Renderer(canvas, shaderCode, onInitSuccessful, onDeviceLost);

    await renderer.init();
    return renderer;
}

Inside the method we asynchronously load the shader code, pass it along with the other parameters to a newly created renderer instance, and then call the asynchronous init method.

Besides the shader code, we pass a canvas to extract the rendering context from and to monitor its dimensions for resizing, plus two callbacks — one for successful initialization and one for device loss.

private async init() {
    if (!navigator.gpu) {
        throw new Error("WebGPU is not supported.");
    }

    const context = this.canvas.getContext('webgpu');
    if (!context) {
        throw new Error("Failed to acquire WebGPU context.");
    }
    this.context = context;

    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) {
        throw new Error("Failed to request GPU adapter.")
    }

    this.device = await adapter.requestDevice();
    this.device.lost.then(async (info) => {
        this.onDeviceLost(info);
        try {
            await this.init();
        } catch (error) {
            console.error("Failed to reinitialize the renderer after device loss:", error);
        }
    });

    const observer = new ResizeObserver(() => this.resize());
    observer.observe(this.canvas);

    this.textureFormat = navigator.gpu.getPreferredCanvasFormat();
    this.context.configure({
        device: this.device,
        format: this.textureFormat,
    });

    const shaderModule = this.createShaderModule(this.shaderCode);
    this.pipeline = this.createPipeline(shaderModule, this.textureFormat);

    this.onInitSuccessful();
}

We start initialization by checking the navigator.gpu property — if it exists, WebGPU is supported.

if (!navigator.gpu) {
    throw new Error("WebGPU is not supported.");
}

The next step is to acquire a WebGPU context from the canvas, the same way we used to do it with WebGL.

const context = this.canvas.getContext('webgpu');
if (!context) {
    throw new Error("Failed to acquire WebGPU context.");
}

Once we have the context, we request a GPUAdapter from navigator.gpu. A GPUAdapter represents a physical device — think of it as your graphics card.

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
    throw new Error("Failed to request GPU adapter.")
}

From the GPUAdapter we request a logical device. Since a logical device can be lost unexpectedly, we attach a handler to the device.lost promise. The handler notifies the outside world via the onDeviceLost callback and attempts to reinitialize the renderer.

this.device = await adapter.requestDevice();
this.device.lost.then(async (info) => {
    this.onDeviceLost(info);
    try {
        await this.init();
    } catch (error) {
        console.error("Failed to reinitialize the renderer after device loss:", error);
    }
});

We need to monitor the canvas dimensions, so we create a ResizeObserver and pass it a callback that invokes our resize method.

const observer = new ResizeObserver(() => this.resize());
observer.observe(this.canvas);

Now let's take a closer look at what the resize method does:

private resize() {
    const dpr = window.devicePixelRatio || 1;
    const width = Math.round(this.canvas.clientWidth * dpr);
    const height = Math.round(this.canvas.clientHeight * dpr);

    if (this.canvas.width === width && this.canvas.height === height) {
        return;
    }

    this.canvas.width = width;
    this.canvas.height = height;

    this.context.configure({
        device: this.device,
        format: this.textureFormat,
    });
}

DPR (device pixel ratio) is the ratio between the physical pixels on your monitor and the logical pixels (CSS pixels) used to define the dimensions of HTML elements. In other words: DPR = PhysicalPixels / LogicalPixels.

The width and height properties define canvas dimensions in physical pixels. To set them correctly, we multiply the canvas's logical dimensions (clientWidth, clientHeight) by the DPR and round to the nearest integer:

const dpr = window.devicePixelRatio || 1;
const width = Math.round(this.canvas.clientWidth * dpr);
const height = Math.round(this.canvas.clientHeight * dpr);

We only apply new dimensions if they actually changed, because resizing a canvas is a relatively expensive operation:

if (this.canvas.width === width && this.canvas.height === height) {
    return;
}

this.canvas.width = width;
this.canvas.height = height;

We need to reconfigure the context because resizing the canvas invalidates its swap chain:

this.context.configure({
    device: this.device,
    format: this.textureFormat,
});

Back in init, we configure the canvas rendering context by providing it with the logical device that will be used for rendering and the optimal texture format obtained from navigator.gpu.getPreferredCanvasFormat():

this.textureFormat = navigator.gpu.getPreferredCanvasFormat();
this.context.configure({
    device: this.device,
    format: this.textureFormat,
});

Now it's time to compile our shader:

const shaderModule = this.createShaderModule(this.shaderCode);

Here is the method itself. It simply calls createShaderModule on the logical device:

private createShaderModule(code: string): GPUShaderModule {
    return this.device.createShaderModule({ code: code });
}

Let's dissect the createPipeline method:

private createPipeline(shaderModule: GPUShaderModule, textureFormat: GPUTextureFormat): GPURenderPipeline {
    const pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [] });
    const vertexBufferLayout: GPUVertexBufferLayout = {
        arrayStride: Renderer.VERTEX_SIZE,
        stepMode: "vertex",
        attributes: [
            {
                shaderLocation: 0,
                offset: 0,
                format: "float32x3",
            }
        ]
    };

    return this.device.createRenderPipeline({
        vertex: {
            module: shaderModule,
            entryPoint: "vs_main",
            buffers: [vertexBufferLayout],
        },
        fragment: {
            module: shaderModule,
            entryPoint: "fs_main",
            targets: [{ format: textureFormat }],
        },
        primitive: { topology: "point-list" },
        layout: pipelineLayout,
    });
}

To create a pipeline, we first need a pipeline layout:

const pipelineLayout = this.device.createPipelineLayout({ bindGroupLayouts: [] });

We also need a layout for our vertex buffer:

const vertexBufferLayout: GPUVertexBufferLayout = {
    arrayStride: Renderer.VERTEX_SIZE,
    stepMode: "vertex",
    attributes: [
        {
            shaderLocation: 0,
            offset: 0,
            format: "float32x3",
        }
    ]
};

The layout consists of the following properties:

We have a single attribute. Let's go over its properties:

With everything in place, we can now create the pipeline:

return this.device.createRenderPipeline({
    vertex: {
        module: shaderModule,
        entryPoint: "vs_main",
        buffers: [vertexBufferLayout],
    },
    fragment: {
        module: shaderModule,
        entryPoint: "fs_main",
        targets: [{ format: textureFormat }],
    },
    primitive: { topology: "point-list" },
    layout: pipelineLayout,
});

Let's go over each property of the pipeline descriptor:

Now let's take a closer look at the rendering method:

renderGeometry(geometry: Geometry) {
    if(geometry.vertexBuffer === null) {
        this.uploadGeometry(geometry);
    }
    this.device.queue.submit([this.createCommandBuffer(geometry.vertexBuffer!)]);
}

Before rendering we check whether vertexBuffer is null. A null value means this is the first call and the vertices haven't been uploaded to the GPU yet. In that case we call uploadGeometry:

uploadGeometry(geometry: Geometry) {
    geometry.vertexBuffer = this.createVertexBuffer(geometry.vertices);
}

uploadGeometry does one thing: it calls createVertexBuffer with the vertices from the geometry instance and assigns the result to geometry.vertexBuffer.

private createVertexBuffer(vertices: Vertex[]): GPUBuffer {
    const vertexBuffer = this.device.createBuffer({
        size: vertices.length * Renderer.VERTEX_SIZE,
        usage: GPUBufferUsage.VERTEX,
        mappedAtCreation: true,
    });

    const vertexBufferPtr = new Float32Array(vertexBuffer.getMappedRange());
    for (let i = 0; i < vertices.length; ++i) {
        vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 0] = vertices[i].x;
        vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 1] = vertices[i].y;
        vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 2] = vertices[i].z;
    }

    vertexBuffer.unmap();
    return vertexBuffer;
}

Inside createVertexBuffer, we allocate the GPU buffer:

const vertexBuffer = this.device.createBuffer({
    size: vertices.length * Renderer.VERTEX_SIZE,
    usage: GPUBufferUsage.VERTEX,
    mappedAtCreation: true,
});

createBuffer accepts a GPUBufferDescriptor object. We're interested in three of its properties:

Since the newly created buffer is mapped, we obtain a pointer to its memory:

const vertexBufferPtr = new Float32Array(vertexBuffer.getMappedRange());

With the pointer in hand, we fill the buffer with vertex data:

for (let i = 0; i < vertices.length; ++i) {
    vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 0] = vertices[i].x;
    vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 1] = vertices[i].y;
    vertexBufferPtr[i * Renderer.NUM_COORDS_PER_VERTEX + 2] = vertices[i].z;
}

Once the data is written, we unmap the buffer:

vertexBuffer.unmap();

With the vertices uploaded to the GPU buffer, we need to create a command buffer:

private createCommandBuffer(vertexBuffer: GPUBuffer): GPUCommandBuffer {
    const commandEncoder = this.device.createCommandEncoder();
    const texture = this.context.getCurrentTexture();
    const view = texture.createView();
    const renderPass = commandEncoder.beginRenderPass({
        colorAttachments: [
            {
                view: view,
                clearValue: { r: 0, g: 0, b: 0, a: 0 },
                loadOp: "clear",
                storeOp: "store",
            }
        ],
    });

    renderPass.setVertexBuffer(0, vertexBuffer);
    renderPass.setPipeline(this.pipeline);
    renderPass.draw(vertexBuffer.size / Renderer.VERTEX_SIZE);
    renderPass.end();

    return commandEncoder.finish();
}

We start by creating a command encoder:

const commandEncoder = this.device.createCommandEncoder();

Then we get a reference to the canvas texture (the frame texture):

const texture = this.context.getCurrentTexture();

And create a view into it:

const view = texture.createView();

Then we begin the render pass:

const renderPass = commandEncoder.beginRenderPass({
    colorAttachments: [
        {
            view: view,
            clearValue: { r: 0, g: 0, b: 0, a: 0 },
            loadOp: "clear",
            storeOp: "store",
        }
    ]
});

beginRenderPass accepts a GPURenderPassDescriptor. Right now we only need its colorAttachments property — a list of textures the render pass will write pixels into. We don't pass the texture directly; instead we use the view created from it.

Inside the render pass, we bind the vertex buffer and the pipeline:

renderPass.setVertexBuffer(0, vertexBuffer);
renderPass.setPipeline(this.pipeline);

And issue the draw call:

renderPass.draw(vertexBuffer.size / Renderer.VERTEX_SIZE);

Then we end the render pass:

renderPass.end();

And finalize the command buffer:

return commandEncoder.finish();

Once we have the command buffer, we submit it to the device queue for asynchronous execution by the GPU:

this.device.queue.submit([this.createCommandBuffer(geometry.vertexBuffer!)]);

We have all the components. Let's put them together in the App class:

export class App {
    private frameRequestId!: number;;
    private renderer!: Renderer;
    private geometry!: Geometry;

    async Run(canvas: HTMLCanvasElement) {
        this.geometry = await AssetLoader.loadGeometry("assets/models/points.json");

        try {
            this.renderer = await Renderer.create(
                canvas,
                () => this.start(),
                (info) => this.onDeviceLost(info)
            );
        } catch (error) {
            console.error("Failed to create renderer:", error);
        }
    }

    private onDeviceLost(info: GPUDeviceLostInfo) {
        console.warn(`Device Lost: ${info.message}`);
        this.stop();
        this.geometry.vertexBuffer = null;
    }

    private frame = (time: number) => {
        try {
            this.renderer.renderGeometry(this.geometry);
        } catch (error) {
            console.error("Failed to render geometry:", error);
        }
        this.frameRequestId = requestAnimationFrame(this.frame);
    };

    private start() {
        this.frameRequestId = requestAnimationFrame(this.frame);
    }

    private stop() {
        cancelAnimationFrame(this.frameRequestId);
    }
}

The Run method loads geometry from JSON and instantiates the renderer:

async Run(canvas: HTMLCanvasElement) {
    this.geometry = await AssetLoader.loadGeometry("assets/models/points.json");

    try {
        this.renderer = await Renderer.create(
            canvas,
            () => this.start(),
            (info) => this.onDeviceLost(info)
        );
    } catch (error) {
        console.error("Failed to create renderer:", error);
    }
}

The device loss handler stops the render loop and resets the invalidated vertex buffer to null:

private onDeviceLost(info: GPUDeviceLostInfo) {
    console.warn(`Device Lost: ${info.message}`);
    this.stop();
    this.geometry.vertexBuffer = null;
}

The frame function renders the geometry and schedules its next execution:

private frame = (time: number) => {
    try {
        this.renderer.renderGeometry(this.geometry);
    } catch (error) {
        console.error("Failed to render geometry:", error);
    }
    this.frameRequestId = requestAnimationFrame(this.frame);
};

And a pair of methods to start and stop the loop:

private start() {
    this.frameRequestId = requestAnimationFrame(this.frame);
}

private stop() {
    cancelAnimationFrame(this.frameRequestId);
}

Finally, the entry point:

async function main() {
    const canvas = document.querySelector("canvas")!;
    const app = new App();
    await app.Run(canvas);
}

await main();

And here's what we just built:

The source code is available here.