---
title: Draws
description: draw(gpu, opts) renders geometry with custom vertex buffers — you write the vertex stage, geometry(gpu, ...) supplies the buffers.
---

# Draws



A [`Draw`](/docs/reference/vgpu/draw#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](/docs/concepts/effects).

## 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.

```ts
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](/docs/concepts/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](/docs/concepts/effects) and [Passes](/docs/concepts/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 `GPUBuffer`s and vertex layouts. See the [reference](/docs/reference/vgpu/draw#geometrylike).

## 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:

```ts
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](/examples/instanced-rendering) drives a 125k-cube lattice from a single instance stream.


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)