Writing Custom Renderers#

When the built-in renderers don’t fit your data, you can subclass Renderer to create your own. A custom renderer needs three things:

  • ``get_shader_code()`` — return a WGSL shader string

  • ``get_bounding_box()`` — return the spatial extent for camera auto-fit

  • ``n_vertices`` — set the number of vertices in the draw call

Minimal custom renderer#

The simplest renderer: a hardcoded color triangle. The shader uses #import camera to get the cameraMapPoint function, which transforms world-space positions into clip space.

[1]:
from webgpu.renderer import Renderer
from webgpu.jupyter import Draw

shader_code = """
#import camera

struct FragmentInput {
    @builtin(position) p: vec4<f32>,
    @location(0) color: vec4<f32>,
};

@vertex
fn vertex_main(@builtin(vertex_index) vi: u32) -> FragmentInput {
    var pos = array<vec3f, 3>(
        vec3f( 0.0,  0.5, 0.0),
        vec3f(-0.5, -0.5, 0.0),
        vec3f( 0.5, -0.5, 0.0)
    );
    var col = array<vec4f, 3>(
        vec4f(1.0, 0.2, 0.2, 1.0),
        vec4f(0.2, 1.0, 0.2, 1.0),
        vec4f(0.2, 0.2, 1.0, 1.0)
    );
    return FragmentInput(cameraMapPoint(pos[vi]), col[vi]);
}

@fragment
fn fragment_main(input: FragmentInput) -> @location(0) vec4f {
    return input.color;
}
"""

class ColorTriangle(Renderer):
    def __init__(self):
        super().__init__()
        self.n_vertices = 3

    def get_bounding_box(self):
        return ((-0.5, -0.5, 0), (0.5, 0.5, 0))

    def get_shader_code(self):
        return shader_code

Draw(ColorTriangle())
▶ Click to interact
[1]:

Data via buffer bindings#

For data-driven renderers, pass GPU buffers using BufferBinding. Use buffer_from_array to upload NumPy arrays to the GPU, then declare matching var<storage> bindings in your shader with @group(0) @binding(N). Binding numbers must be >= 100 (below 100 is reserved for the framework).

This example renders a colored quad (two triangles, 6 vertices) from storage buffers:

[2]:
import numpy as np
from webgpu.renderer import Renderer
from webgpu.utils import BufferBinding, buffer_from_array
from webgpu.jupyter import Draw

shader = """
#import camera

@group(0) @binding(101) var<storage> vertices: array<f32>;
@group(0) @binding(102) var<storage> colors: array<f32>;

struct FragmentInput {
    @builtin(position) p: vec4<f32>,
    @location(0) color: vec4<f32>,
};

@vertex
fn vertex_main(@builtin(vertex_index) vi: u32) -> FragmentInput {
    let pos = vec3f(vertices[3u*vi], vertices[3u*vi+1u], vertices[3u*vi+2u]);
    let col = vec4f(colors[4u*vi], colors[4u*vi+1u], colors[4u*vi+2u], colors[4u*vi+3u]);
    return FragmentInput(cameraMapPoint(pos), col);
}

@fragment
fn fragment_main(input: FragmentInput) -> @location(0) vec4f {
    return input.color;
}
"""

class QuadRenderer(Renderer):
    def __init__(self):
        super().__init__()
        self.n_vertices = 6

    def update(self, options):
        self.verts = buffer_from_array(np.array([
            -0.5, -0.5, 0,   0.5, -0.5, 0,   0.5, 0.5, 0,
            -0.5, -0.5, 0,   0.5,  0.5, 0,  -0.5, 0.5, 0,
        ], dtype=np.float32))
        self.cols = buffer_from_array(np.array([
            1,0,0,1,  0,1,0,1,  0,0,1,1,
            1,0,0,1,  0,0,1,1,  1,1,0,1,
        ], dtype=np.float32))

    def get_bounding_box(self):
        return ((-0.5, -0.5, 0), (0.5, 0.5, 0))

    def get_shader_code(self):
        return shader

    def get_bindings(self):
        return [BufferBinding(101, self.verts), BufferBinding(102, self.cols)]

Draw(QuadRenderer())
▶ Click to interact
[2]:

Key methods reference#

Method / Attribute

Description

get_shader_code()

Return a WGSL source string. Use #import camera for 3D camera support, #import lighting for lighting.

get_bindings()

Return a list of BufferBinding, UniformBinding, etc. Binding numbers must be >= 100 (below 100 is reserved for the framework).

get_bounding_box()

Return ((min_x, min_y, min_z), (max_x, max_y, max_z)) for camera auto-fit.

update(options)

Called when data changes. Create or update GPU buffers here.

n_vertices

Number of vertices per draw call.

n_instances

Number of instances per draw call.

set_needs_update()

Mark the renderer for re-upload on the next frame.

Combining with built-in renderers#

Custom renderers can be used alongside built-in renderers in the same scene. Just pass a list of renderers to Draw:

[3]:
from webgpu.shapes import generate_cylinder, ShapeRenderer

quad = QuadRenderer()
cylinder = ShapeRenderer(
    generate_cylinder(n=16, radius=0.05, height=0.5),
    positions=[[0, 0, 0.1]],
    directions=[[0, 0, 1]],
    colors=[[1, 0.5, 0, 1]],
)
Draw([quad, cylinder])
▶ Click to interact
[3]: