Draws

A Draw renders geometry with custom vertex buffers: you write both the vertex and the fragment stage, and a geometry supplies the buffers. If you want to render a full-screen shader instead, use an Effect.

Draw a geometry

geometry(gpu, geometry) turns geometry from vgpu/scene into vertex and index buffers. Your vertex shader declares the attributes it consumes — @location(0) position, @location(1) normal — and the geometry feeds them.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { init, draw, geometry, target } from "vgpu";
 
const gpu = await init();
 
// ---cut---
import { box, orbit, perspectiveCamera } from "vgpu/scene";
 
const shader = `
  struct Camera { viewProjection: mat4x4f }
  struct Model { model: mat4x4f }
  @group(0) @binding(0) var<uniform> camera: Camera;
  @group(0) @binding(1) var<uniform> model: Model;
 
  struct VertexOut { @builtin(position) position: vec4f, @location(0) normal: vec3f }
 
  @vertex fn vs_main(@location(0) position: vec3f, @location(1) normal: vec3f) -> VertexOut {
    var out: VertexOut;
    out.position = camera.viewProjection * model.model * vec4f(position, 1.0);
    out.normal = normal;
    return out;
  }
 
  @fragment fn fs_main(@location(0) normal: vec3f) -> @location(0) vec4f {
    let light = max(dot(normalize(normal), normalize(vec3f(1.0, 1.0, 1.0))), 0.15);
    return vec4f(vec3f(0.2, 0.5, 1.0) * light, 1.0);
  }
`;
 
const colorTarget = target(gpu, { size: [1280, 720], depth: true });
const camera = perspectiveCamera({ fov: 45, aspect: 16 / 9, position: [2, 2, 3], target: [0, 0, 0] });
 
const cube = draw(gpu, { shader, geometry: geometry(gpu, box({ size: 1 })) });
cube.set({
  camera: { viewProjection: camera.viewProjection },
  model: { model: orbit(0) },
});
 
cube.draw(colorTarget);

Everything works like the rest of vgpu: bindings are reflected from the WGSL, set() writes uniforms by name, and the draw renders one-shot into any target. Pipelines are compiled per target format and cached, so the same Draw can render into different targets. See Compilation to pre-warm each signature before the first draw.

Three details specific to geometry:

  • 3D needs a depth buffer, and surfaces don't have one — render into a target(gpu, { depth: true }) and composite it to the canvas. Effects and Passes show how. Deep scenes fight z-fighting with reversed-Z: depth: { compare: "greater" } on the draw, clearDepth: 0 on the pass.
  • A closed geometry like this box never shows its back faces — add cull: "back" to the draw and skip roughly half the fragment work.
  • GeometryLike is an open interface: geometry(gpu) builds one from vgpu/scene geometry, but you can also pass your own GPUBuffers and vertex layouts. See the reference.

No geometry? You spawn triangles

Leave geometry out and the draw runs with no buffers at all: vertices defaults to 3, so every instance is one triangle whose corners you position from @builtin(vertex_index). Combined with instances, that spawns a particle system from nothing:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { init, draw, surface } from "vgpu";
 
const gpu = await init();
const canvas = document.querySelector("canvas")!;
const canvasSurface = surface(gpu, canvas);
 
// ---cut---
const smokeShader = `
  struct Params { time: f32 }
  @group(0) @binding(0) var<uniform> params: Params;
 
  struct Out { @builtin(position) position: vec4f, @location(0) fade: f32 }
 
  @vertex fn vs_main(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
    var corners = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(0.0, 1.5));
    let seed = fract(sin(f32(i) * 12.9898) * 43758.5453);
    let life = fract(seed + params.time * 0.05);          // 0 -> 1, then respawn
    let center = vec2f(seed * 2.0 - 1.0, life * 2.2 - 1.1); // drifts upward
    let size = 0.01 + life * 0.04;                          // grows as it rises
 
    var out: Out;
    out.position = vec4f(center + corners[v] * size, 0.0, 1.0);
    out.fade = 1.0 - life;
    return out;
  }
 
  @fragment fn fs_main(@location(0) fade: f32) -> @location(0) vec4f {
    return vec4f(vec3f(0.35) * fade, 1.0); // dims into the dark background
  }
`;
 
const smoke = draw(gpu, { shader: smokeShader, instances: 10_000 });
 
smoke.set({ params: { time: 2.5 } }); // drive with clock(gpu).time in a frame loop
smoke.draw(canvasSurface);

One draw call, 10,000 smoke puffs, zero buffers — each particle derives its position, size, and fade from instance_index and time. Counts can also change per call: smoke.draw({ target: surface, instances: 500 }).

See it live: the instanced rendering example drives a 125k-cube lattice from a single instance stream.