Skip to content

Hello, million triangles!

There’s a rite of passage in graphics programming: Drawing your first triangle.

This typically takes thousands of lines of code. Pick any tutorial for OpenGL, Vulkan, or raw WebGPU, and you’ll spend an afternoon setting pixel formats, allocating command pools, and wiring descriptor sets & binding groups before any colored pixel reaches the screen.

We’re going to do it in 5 lines of Rust.

To draw anything, anywhere, we need two things:

  • a surface to draw on,
  • and something that draws.

In FragmentColor those are a Target and a Renderer. Let’s create them:

let renderer = Renderer::new();
let target = renderer.create_target(canvas).await?;

The canvas is whatever your platform draws into: a <canvas> element on the web, an Arc<Window> from winit in Rust, a RenderCanvas in Python, a CAMetalLayer on iOS and a Surface on Android. In every case, the Renderer wraps it in a Target.

Now, what do we draw? A Shader: a small program that runs on the GPU, the graphics chip in your computer or phone. Drawing takes two stages. The vertex stage runs once per corner of a shape and decides where that corner lands on the screen. Every pixel inside the shape then gets one run of the fragment stage, which picks its color. FragmentColor takes its name from that second stage.

vs_main3 runsfs_main42 runs
The vertex stage runs at each corner. The fragment stage runs once for every pixel whose center lies inside the shape.

We write shaders in WGSL, the shading language of WebGPU. Here’s ours:

struct VOut { @builtin(position) pos: vec4<f32> };
@group(0) @binding(0) var<uniform> color: vec4<f32>;
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VOut {
// Three corners of an equilateral triangle, centered on the origin.
var corners = array<vec2<f32>, 3>(
vec2<f32>(-0.7, -0.404),
vec2<f32>( 0.7, -0.404),
vec2<f32>( 0.0, 0.808),
);
var out: VOut;
out.pos = vec4<f32>(corners[i], 0.0, 1.0);
return out;
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return color;
}

Keep that source in a string called TRIANGLE. Three more lines turn it into a picture:

let shader = Shader::new(TRIANGLE)?;
shader.set("color", __CLR_RUST__)?;
renderer.render(&shader, &target)?;

Congratulations! That’s your first triangle! Click the four numbers in the code to pick another color.

Behind those five lines of Rust, FragmentColor validates the WGSL and compiles it for your GPU. It builds a render pipeline, creates a buffer for color and copies your four numbers into it. Then it records the draw and submits it.

The shader, from the top:

  • var<uniform> color: vec4<f32> declares a uniform, a value the program sets from outside the shader. Its type, vec4<f32>, is four 32-bit floats, here red, green, blue and alpha. FragmentColor reads the declaration from the source and finds the uniform by its name, which is how set("color", ...) knows where the numbers go. The @group(0) @binding(0) in front is WGSL bookkeeping: no two bindings in one shader may share the same pair of numbers.
  • vs_main is the vertex stage. A shader with no geometry attached draws three vertices, so vs_main runs three times. Its argument i, marked @builtin(vertex_index), tells each run which corner to compute. The corners are in normalized device coordinates: x goes from -1 on the left edge to 1 on the right, y from -1 at the bottom to 1 at the top, whatever the target’s size in pixels.
  • VOut carries the result in pos, the field marked @builtin(position). After x and y come a depth for sorting overlapping shapes and a divisor for perspective. A flat drawing leaves them at 0.0 and 1.0.
  • fs_main, the fragment stage, returns color for every pixel the triangle covers. @location(0) on its result sends that color to the target.
xy1-11-1(-0.7, -0.404)i = 0(0.7, -0.404)i = 1(0, 0.808)i = 2
The corners vs_main returns, in normalized device coordinates. The square stretches to fill the target.

Our triangle covers about a fifth of the canvas, some 440,000 pixels at 1920 × 1080. The GPU works through thousands of them at once.

One call to render draws one frame. To animate, we update the uniform and render again, over and over. Each step of this tutorial splits its code into two functions. setup runs once and builds the shader. On every display refresh, frame receives time, the seconds since the animation started, and draws.

fn frame(
shader: &Shader,
renderer: &Renderer,
target: &impl Target,
time: f32,
) -> Result<(), FragmentColorError> {
let red = 0.5 + 0.45 * (time * 0.7).sin();
let green = 0.5 + 0.45 * (time * 0.5 + 1.7).cos();
let blue = 0.5 + 0.45 * (time * 0.9 + 3.1).sin();
shader.set("color", [red, green, blue, 1.0])?;
renderer.render(shader, target)?;
Ok(())
}

Three sine waves at different speeds push red, green and blue up and down. Press play:

Our corners describe a triangle with three equal sides. On a wide screen, the demo above draws it short and broad.

Normalized device coordinates run from -1 to 1 across the full width and, separately, across the full height. When the canvas is wider than it is tall, one unit along x spans more pixels than one unit along y. A phone held upright flips that: its canvas is taller than it is wide, which pulls the triangle into a thin spike.

The shader needs the canvas size to undo that stretch. A second uniform carries it:

struct VOut { @builtin(position) pos: vec4<f32> };
@group(0) @binding(0) var<uniform> color: vec4<f32>;
@group(0) @binding(1) var<uniform> resolution: vec2<f32>;
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-0.7, -0.404),
vec2<f32>( 0.7, -0.404),
vec2<f32>( 0.0, 0.808),
);
// Shrink the longer axis so one unit spans as many pixels on x as on y.
let aspect = resolution.x / resolution.y;
var pos = corners[i];
if (aspect > 1.0) { pos.x = pos.x / aspect; }
else { pos.y = pos.y * aspect; }
var out: VOut;
out.pos = vec4<f32>(pos, 0.0, 1.0);
return out;
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return color;
}

resolution holds the target’s width and height in pixels. The vertex stage divides one by the other to get the aspect ratio, then scales positions down along the longer axis to match the shorter one. One unit now covers the same number of pixels in both directions.

On the program side, frame reads the target’s size and sets it next to the color:

fn frame(
shader: &Shader,
renderer: &Renderer,
target: &impl Target,
time: f32,
) -> Result<(), FragmentColorError> {
let red = 0.5 + 0.45 * (time * 0.7).sin();
let green = 0.5 + 0.45 * (time * 0.5 + 1.7).cos();
let blue = 0.5 + 0.45 * (time * 0.9 + 3.1).sin();
shader.set("color", [red, green, blue, 1.0])?;
let size = target.size();
shader.set("resolution", [size.width as f32, size.height as f32])?;
renderer.render(shader, target)?;
Ok(())
}

Three equal sides, on any canvas. From here on, every shader that draws our triangle declares resolution. Each frame sets it before rendering.

The Shader constructor also takes a list. Each entry can be WGSL source, a path to a .wgsl file or the name of a function in FragmentColor’s shader library. FragmentColor joins the entries in order and compiles them as one program.

More than 200 small WGSL functions make up the library: easing curves, color spaces, hashes, noise, distance functions in 2D and 3D, lighting models and post-processing effects. Each name pairs a category with a function, such as noise/simplex2. Rust, Python, Swift and Kotlin ship the whole library inside the package and look names up offline. On the web, a name means a download, and a constructor can’t wait for one: JavaScript loads such a list with await Shader.fetch(...), while a list of plain WGSL still works with new Shader(...).

Here noise/simplex2 goes first in the list, ahead of our triangle’s source:

fn setup() -> Result<Shader, FragmentColorError> {
let shader = Shader::new(["noise/simplex2", TRIANGLE])?;
shader.set("color", [0.95, 0.30, 0.42, 1.0])?;
Ok(shader)
}

Our fragment stage can now call simplex2 as if we had written it ourselves. Three other things are new in this shader:

  • A third uniform, time. frame sets it on every refresh, along with color.
  • uv, a second field in VOut. For each corner, the vertex stage computes a pair of numbers between 0 and 1, a common way to label points on a surface. The GPU blends the three pairs across the triangle, giving each run of fs_main its own mix. The @location numbers name the slots that pass values from the vertex stage to the fragment stage. Each @location field of VOut takes a different number.
  • .rgb and .a, which pick channels out of a vector: color.rgb is the first three numbers of color, and color.a is the fourth.
struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@group(0) @binding(0) var<uniform> color: vec4<f32>;
@group(0) @binding(1) var<uniform> resolution: vec2<f32>;
@group(0) @binding(2) var<uniform> time: f32;
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-0.7, -0.404),
vec2<f32>( 0.7, -0.404),
vec2<f32>( 0.0, 0.808),
);
let aspect = resolution.x / resolution.y;
var pos = corners[i];
if (aspect > 1.0) { pos.x = pos.x / aspect; }
else { pos.y = pos.y * aspect; }
var out: VOut;
out.pos = vec4<f32>(pos, 0.0, 1.0);
// The corner before the aspect fix, moved from -1..1 into 0..1.
out.uv = (corners[i] + vec2<f32>(1.0)) * 0.5;
return out;
}
@fragment
fn fs_main(in: VOut) -> @location(0) vec4<f32> {
// simplex2 comes from noise/simplex2 and returns roughly -1..1.
let noise = simplex2(in.uv * 6.0 + vec2<f32>(time * 0.4));
let brightness = 0.55 + 0.45 * (noise * 0.5 + 0.5);
return vec4<f32>(color.rgb * brightness, color.a);
}
fn frame(
shader: &Shader,
renderer: &Renderer,
target: &impl Target,
time: f32,
) -> Result<(), FragmentColorError> {
let red = 0.5 + 0.45 * (time * 0.7).sin();
let green = 0.5 + 0.45 * (time * 0.5 + 1.7).cos();
let blue = 0.5 + 0.45 * (time * 0.9 + 3.1).sin();
shader.set("color", [red, green, blue, 1.0])?;
shader.set("time", time)?;
let size = target.size();
shader.set("resolution", [size.width as f32, size.height as f32])?;
renderer.render(shader, target)?;
Ok(())
}

Simplex noise gives a smooth, random-looking value for every point in the plane. The fragment stage samples it at each pixel’s uv and slides that point as time grows. Each value scales the color between 55% and full brightness. Soft patches of light drift across the triangle like sunlight through leaves on a windy day.

So far the corners have lived inside the shader. Most programs keep their geometry on the CPU and send it to the GPU. FragmentColor gives them two objects for that:

  • Vertex is one point. Its constructor takes the position, and set attaches any other attribute by name, such as a color, a normal or texture coordinates.
  • Mesh is a list of vertices.

We build three vertices, each with its own color, and attach the mesh to the shader:

fn setup() -> Result<Shader, FragmentColorError> {
let shader = Shader::new(GRADIENT)?;
let mesh = Mesh::new();
mesh.add_vertices([
Vertex::new([-0.7, -0.404, 0.0]).set("color", [0.95, 0.30, 0.42]),
Vertex::new([0.7, -0.404, 0.0]).set("color", [0.30, 0.85, 0.55]),
Vertex::new([0.0, 0.808, 0.0]).set("color", [0.30, 0.55, 0.95]),
]);
shader.add_mesh(&mesh)?;
Ok(shader)
}

Each position has a third number, z, for depth. Our flat triangle sets it to 0.0.

The vertex stage now declares two inputs, position at @location(0) and color at @location(1). On a vertex input, @location refers to an attribute slot. Attribute slots have a numbering of their own, apart from the one in VOut. When we call add_mesh, FragmentColor matches the vertices’ positions and color attributes to those inputs by name. If the mesh has nothing for an input the shader reads, the call fails with an error that names it.

struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec3<f32>,
};
@group(0) @binding(0) var<uniform> resolution: vec2<f32>;
@vertex
fn vs_main(
@location(0) position: vec3<f32>,
@location(1) color: vec3<f32>,
) -> VOut {
let aspect = resolution.x / resolution.y;
var pos = position.xy;
if (aspect > 1.0) { pos.x = pos.x / aspect; }
else { pos.y = pos.y * aspect; }
var out: VOut;
out.pos = vec4<f32>(pos, 0.0, 1.0);
out.color = color;
return out;
}
@fragment
fn fs_main(in: VOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}

position.xy keeps the first two numbers of position and drops z.

Pink, green and blue sit at the three corners. The GPU blends the vertex colors as it blends uv in step 3: a pixel near the pink corner comes out mostly pink, while one in the middle gets equal parts of all three. Our program only ever sets the corners.

Step 4's triangle with each corner labeled by its color: 0.95, 0.30, 0.42 at the pink corner, 0.30, 0.85, 0.55 at the green one and 0.30, 0.55, 0.95 at the blue one. A dot in the middle reads 0.52, 0.57, 0.64, and a dot near the pink corner reads 0.69, 0.46, 0.55.
The colors fs_main receives. The middle pixel gets a third of each corner's color.

One triangle is a warm-up. Let’s draw 15,000.

The mesh from step 4 is all we need: the GPU repeats it 15,000 times, each copy with its own spot, phase and tint. Those copies are instances. An Instance holds the attributes of one copy, as a Vertex holds the attributes of one corner. Graphics programmers call small things drawn in large numbers particles. So does our code.

fn setup() -> Result<Shader, FragmentColorError> {
let shader = Shader::new(["easing/in_out_sine", PARTICLES])?;
let mesh = Mesh::new();
mesh.add_vertices([
Vertex::new([-0.7, -0.404, 0.0]).set("color", [0.95, 0.30, 0.42]),
Vertex::new([0.7, -0.404, 0.0]).set("color", [0.30, 0.85, 0.55]),
Vertex::new([0.0, 0.808, 0.0]).set("color", [0.30, 0.55, 0.95]),
]);
for _ in 0..COPIES {
let center = [fastrand::f32() * 2.0 - 1.0, fastrand::f32() * 2.0 - 1.0];
let phase = fastrand::f32() * TAU;
let tint = [
0.2 + fastrand::f32() * 0.8,
0.2 + fastrand::f32() * 0.8,
0.2 + fastrand::f32() * 0.8,
];
mesh.add_instance(
Instance::new()
.set("center", center)
.set("phase", phase)
.set("tint", tint),
);
}
shader.add_mesh(&mesh)?;
Ok(shader)
}

position and color change from corner to corner. center, phase and tint belong to an Instance: all three corners of a copy use the same ones.

struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec3<f32>,
};
@group(0) @binding(0) var<uniform> resolution: vec2<f32>;
@group(0) @binding(1) var<uniform> time: f32;
@vertex
fn vs_main(
@location(0) position: vec3<f32>, // per vertex
@location(1) color: vec3<f32>, // per vertex
@location(2) center: vec2<f32>, // per instance
@location(3) phase: f32, // per instance
@location(4) tint: vec3<f32>, // per instance
) -> VOut {
// Shrink the triangle and keep its shape, as in step 2.
let aspect = resolution.x / resolution.y;
var shape = position.xy * 0.03;
if (aspect > 1.0) { shape.x = shape.x / aspect; }
else { shape.y = shape.y * aspect; }
// Each copy loops around its center, offset by its own phase.
let wobble = vec2<f32>(
sin(time * 1.3 + phase),
cos(time * 0.9 + phase * 1.4),
) * 0.03;
// The library's in_out_sine flattens the wave near 0 and 1.
let wave = 0.5 + 0.5 * sin(time * 2.0 + phase);
let glow = 0.75 + 0.75 * in_out_sine(wave);
var out: VOut;
out.pos = vec4<f32>(center + wobble + shape, 0.0, 1.0);
out.color = color * tint * glow;
return out;
}
@fragment
fn fs_main(in: VOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}

Each copy traces a small curve around its center. phase gives every copy a head start along its sine waves, which spreads their peaks across time. The library’s easing/in_out_sine shapes the brightness. It flattens the wave at its top and bottom: each triangle holds its brightest and dimmest moments a little longer.

Mesh3 vertices× 15,000wobblecentertint
One mesh, drawn once per instance. All three corners of a copy share its center, phase and tint. Over time, wobble moves the copy along the dashed loop.

The program sends only two values per frame, time and resolution. With those and the instance data already on the GPU, the vertex stage places all 15,000 triangles:

fn frame(
shader: &Shader,
renderer: &Renderer,
target: &impl Target,
time: f32,
) -> Result<(), FragmentColorError> {
let size = target.size();
shader.set("resolution", [size.width as f32, size.height as f32])?;
shader.set("time", time)?;
renderer.render(shader, target)?;
Ok(())
}

A draw call is one request from the CPU to the GPU to draw something. render makes a single one here, covering 45,000 vertices every frame. One call per triangle would take 15,000.

For a change of pace, let’s make it look like film. We render the particles into a texture first, then draw that texture to the canvas through a second shader, which grades the picture with four functions from the library:

  • postfx/chromatic_offsets returns three slightly different spots to read red, green and blue from, like a cheap lens that splits colors near its edges.
  • color/tonemap_filmic compresses the highlights toward white along a curve from Jim Hejl and Richard Burgess-Dawson. The shader halves the brightness before the curve so the colors don’t wash out.
  • postfx/vignette darkens the corners.
  • postfx/film_grain adds a new layer of noise on every frame.

Two more objects hand the picture from the first shader to the second:

  • Texture is an image stored on the GPU. A TextureTarget keeps what we draw into it as a Texture that another shader can read.
  • Pass groups shaders and draws them into one target. render can take a bare Shader, as in the steps before. A Pass adds a target of its own, a clear color and an order among passes. Its name labels the pass in GPU errors and debuggers.
async fn setup(renderer: &Renderer) -> Result<State, FragmentColorError> {
let particles = Shader::new(["easing/in_out_sine", PARTICLES])?;
let mesh = Mesh::new();
mesh.add_vertices([
Vertex::new([-0.7, -0.404, 0.0]).set("color", [0.95, 0.30, 0.42]),
Vertex::new([0.7, -0.404, 0.0]).set("color", [0.30, 0.85, 0.55]),
Vertex::new([0.0, 0.808, 0.0]).set("color", [0.30, 0.55, 0.95]),
]);
for _ in 0..COPIES {
let center = [fastrand::f32() * 2.0 - 1.0, fastrand::f32() * 2.0 - 1.0];
let phase = fastrand::f32() * TAU;
let tint = [
0.2 + fastrand::f32() * 0.8,
0.2 + fastrand::f32() * 0.8,
0.2 + fastrand::f32() * 0.8,
];
mesh.add_instance(
Instance::new()
.set("center", center)
.set("phase", phase)
.set("tint", tint),
);
}
particles.add_mesh(&mesh)?;
// The particle pass draws into this texture for `postfx` to read.
let scene = renderer.create_texture_target([1024, 1024]).await?;
let postfx = Shader::new([
"postfx/chromatic_offsets",
"color/tonemap_filmic",
"postfx/vignette",
"postfx/film_grain",
POSTFX,
])?;
postfx.set("scene", &scene.texture())?;
let particle_pass = Pass::from_shader("particles", &particles);
particle_pass.set_target(&scene)?;
particle_pass.set_clear_color([0.04, 0.05, 0.08, 1.0])?;
let output = Pass::from_shader("postfx", &postfx);
output.require(&particle_pass)?;
Ok(State {
particles,
postfx,
output,
})
}

set_target points the particle pass at the texture. Each frame, the pass fills it with the blue-black from set_clear_color before the particles draw. With no target set, output draws to the canvas that render receives. Calling require on output runs the particle pass first.

particlesscenepostfxtargetPass1024 × 1024Passcanvasdraws intois read bydraws intorequire
output is the postfx pass. Its require runs the particle pass first, which fills scene for postfx to read.

Now frame renders only output:

fn frame(
state: &State,
renderer: &Renderer,
target: &impl Target,
time: f32,
) -> Result<(), FragmentColorError> {
let size = target.size();
state
.particles
.set("resolution", [size.width as f32, size.height as f32])?;
state.particles.set("time", time)?;
state.postfx.set("time", time)?;
renderer.render(&state.output, target)?;
Ok(())
}

Even though the particles draw into the texture, their shader still gets the canvas’s size. output stretches the 1024 × 1024 texture to fit. Squeezing each triangle by that same aspect ratio beforehand undoes the distortion: the particles keep their shape.

The post-processing shader draws one triangle twice as wide and twice as tall as the canvas. Then the GPU discards everything past the edges and runs fs_main once for each pixel that remains:

struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@group(0) @binding(0) var scene: texture_2d<f32>;
@group(0) @binding(1) var scene_sampler: sampler;
@group(0) @binding(2) var<uniform> time: f32;
@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VOut {
// One triangle large enough to cover the whole target.
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0),
);
var uvs = array<vec2<f32>, 3>(
vec2<f32>(0.0, 1.0), vec2<f32>(2.0, 1.0), vec2<f32>(0.0, -1.0),
);
var out: VOut;
out.pos = vec4<f32>(corners[i], 0.0, 1.0);
out.uv = uvs[i];
return out;
}
@fragment
fn fs_main(in: VOut) -> @location(0) vec4<f32> {
// Sample each channel at its own offset.
let offsets = chromatic_offsets(in.uv, 0.006);
let red = textureSample(scene, scene_sampler, offsets[0]).r;
let green = textureSample(scene, scene_sampler, offsets[1]).g;
let blue = textureSample(scene, scene_sampler, offsets[2]).b;
// Halve the exposure, then compress the highlights.
let graded = tonemap_filmic(vec3<f32>(red, green, blue) * 0.5);
let edges = vignette(in.uv, 0.55, 0.40);
let grain = film_grain(in.uv, time) * 0.05;
return vec4<f32>(graded * edges + grain, 1.0);
}

scene is the particle texture. To read its color at a uv position, textureSample uses scene_sampler, which blends neighboring pixels. FragmentColor builds that sampler from the texture’s own settings. Because textures count their rows from the top, this uv runs from 0 at the top edge to 1 at the bottom.

targetuv (0, 0)uv (1, 1)(-1, -1)uv (0, 1)(3, -1)uv (2, 1)(-1, 3)uv (0, -1)(1, 1)discarded
The full-screen triangle. Its long side runs through the target's corner. The GPU discards everything outside the target.

The particle shader is the one from step 5, unchanged. Only its target is new.

Step 5 builds every Instance on the CPU with its own set calls, then uploads them all. Fifteen thousand copies make a short loop. A million would make a long one. The GPU can invent the numbers itself.

fn setup() -> Result<Shader, FragmentColorError> {
let shader = Shader::new(GALAXY)?;
let mesh = Mesh::new();
mesh.add_vertices([[-0.7, -0.404], [0.7, -0.404], [0.0, 0.808]]);
mesh.set_instance_count(COPIES);
shader.add_mesh(&mesh)?;
Ok(shader)
}

A vertex that has only a position can go in as a bare list of numbers. Two numbers per vertex are enough for a flat shape. set_instance_count asks for a million copies of the mesh without a single Instance. The shader tells them apart by @builtin(instance_index), the copy’s number from 0 to 999,999:

struct VOut {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec3<f32>,
};
@group(0) @binding(0) var<uniform> resolution: vec2<f32>;
@group(0) @binding(1) var<uniform> time: f32;
// PCG hash: mixes the bits of a number so the result looks random.
fn hash(value: u32) -> u32 {
let state = value * 747796405u + 2891336453u;
let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
return (word >> 22u) ^ word;
}
// The same seed always gives the same number between 0 and 1.
fn random(seed: u32) -> f32 {
return f32(hash(seed)) / 4294967295.0;
}
@vertex
fn vs_main(
@location(0) position: vec2<f32>,
@builtin(instance_index) index: u32,
) -> VOut {
// Four random numbers per copy, taken from its index.
let radius = random(index * 4u);
let arm = f32(hash(index * 4u + 1u) % 3u) * 2.094;
let spread = (random(index * 4u + 2u) - 0.5) * (1.2 - radius);
let brightness = 0.4 + 0.6 * random(index * 4u + 3u);
// Three spiral arms, turning slowly as one disk.
let angle = arm + radius * 5.0 + spread - time * 0.15;
let center = vec2<f32>(cos(angle), sin(angle)) * radius * 0.95;
// Keep the disk round on a canvas of any shape.
let fit = min(resolution.x, resolution.y) / resolution;
// Measured in pixels, each triangle is about four wide.
let shape = position * 6.0 / resolution;
var out: VOut;
out.pos = vec4<f32>(center * fit + shape, 0.0, 1.0);
let core = vec3<f32>(1.0, 0.8, 0.55);
let rim = vec3<f32>(0.35, 0.55, 1.0);
out.color = mix(core, rim, radius) * brightness;
return out;
}
@fragment
fn fs_main(in: VOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}

hash scrambles a number, and random maps the result to a value between 0 and 1. Because an index always hashes to the same values, each triangle keeps its spot from one frame to the next. Four values per copy pick a distance from the center, one of three arms, a sideways scatter and a brightness, each from its own seed: the index times four, plus 0 to 3. The angle grows with the distance, which curls each arm into a spiral. time rotates the whole disk.

instance_index = 10random(40)hash(41) % 3random(42)random(43)radius = 0.66arm = 0.00spread = -0.12brightness = 0.84
Copy 10 from its four seeds to its spot. The dots are the first 1,200 copies at time 0.

Three pieces of WGSL need explaining:

  • u32 is a 32-bit whole number that is never negative. A u after a literal, as in 4u, makes it one.
  • Angles are in radians, where a full turn is 6.283. 2.094 is a third of a turn, one arm’s share.
  • mix(core, rim, radius) blends from core to rim as radius goes from 0 to 1.

Two lines replace step 2’s aspect fix. fit divides the shorter side by each side of the canvas: 1 for the short axis, less for the long one. The second line, position * 6.0 / resolution, sizes the triangle in pixels instead. With two units across the canvas, one pixel is 2.0 / resolution units wide. Each triangle comes out about four pixels across.

fn frame(
shader: &Shader,
renderer: &Renderer,
target: &impl Target,
time: f32,
) -> Result<(), FragmentColorError> {
let size = target.size();
shader.set("resolution", [size.width as f32, size.height as f32])?;
shader.set("time", time)?;
renderer.render(shader, target)?;
Ok(())
}

setup does the same work for ten triangles as for a million: three vertices and a count. The GPU still prepares every triangle when it draws.

A display shows a new picture on every refresh. The time between two refreshes is the frame budget: 1000 milliseconds divided by the refresh rate, about 16.7 ms at 60 Hz and 8.3 ms at 120 Hz. Your display settings list the rate. In a browser, the gap between requestAnimationFrame callbacks tells you the same thing. Paste this into the console:

const stamps = [];
await new Promise((done) => requestAnimationFrame(function tick(now) {
stamps.push(now);
if (now - stamps[0] < 1000) requestAnimationFrame(tick);
else done();
}));
const gap = (stamps.at(-1) - stamps[0]) / (stamps.length - 1);
console.log(`${Math.round(1000 / gap)} frames a second, ${gap.toFixed(1)} ms apart`);

With nothing playing, it prints your refresh rate. Run it again while the demo above plays to see how often the page manages a new frame. Once the work stops fitting in the budget, the gap widens to two refreshes or more.

A timestamp around renderer.render measures only the CPU side. render records the work and submits it to the GPU, which finishes later. To time the GPU from Rust, render a batch of frames, wait for the last one by reading its pixels back, and divide the elapsed time by how many there were. host.rs does that whenever you set FC_TUTORIAL_FRAMES: FC_TUTORIAL_FRAMES=120 ./fc example tutorial_01_hello_triangle_instance_index renders 120 of them offscreen at 1280 × 720 and prints the average. A browser’s performance panel shows the same work on its GPU track.

Each GPU’s crossover point is the count where the frame time grows past your budget. To find yours, change COPIES at the top of the step’s file and measure again: with the command above in Rust, or with the console snippet while your copy of example.js plays.

The demo draws a million triangles. Your GPU decides whether all of them fit in your budget.

Twice the triangles take about twice as long to draw. Shrinking each one to a tenth of its width cuts the frame time by less than half. At four pixels wide, the count sets the length of a frame.

Seven steps, eight objects. Here’s what each one does:

  • Renderer draws. Every frame ends in a call to render.
  • Target receives the picture: a window or canvas in every step, plus a texture in step 6.
  • Shader is the program the GPU runs. It gets a uniform in step 1 and library functions in steps 3, 5 and 6.
  • Vertex and Mesh (step 4) move the geometry out of the shader and into our program.
  • Instance gives one triangle 15,000 copies in step 5. Step 7 drops it for a count and an index.
  • Pass chains shaders: two render passes with a Texture between them in step 6.

Next, Drawing with distance stays inside one fragment shader and draws circles, boxes and stars out of nothing but arithmetic. In A particle system, a compute shader moves eight million particles with no triangle for the GPU to prepare.