beam-gpu is the WebGPU-native rewrite of Beam: a tiny, terse, teachable graphics
library. It keeps Beam's "make data, then draw it" aesthetic while honestly exposing
WebGPU's real model. You can write a triangle in a dozen lines, then drop one level
into raw WebGPU without relearning anything — every Beam verb maps 1:1 to a real
WebGPU call, and every handle exposes its raw object.
WebGPU's structure is device → pipeline + bind groups → encoder → render pass → draw.
Beam keeps every one of those visible, but hides the two pieces that are pure ceremony
(command-encoder lifecycle and queue.submit). The result is five nouns and
two drawing verbs:
| Beam noun | What it wraps | Old Beam ancestor |
|---|---|---|
Beam (device) |
GPUAdapter + GPUDevice + canvas context |
new Beam(canvas) |
Pipeline |
GPURenderPipeline + derived layouts |
beam.shader(template) |
| resources | persistent GPUBuffer/GPUTexture/sampler |
beam.resource(Type, ...) |
Bindings |
the data you hand one draw (→ GPUBindGroup) |
the ...resources spread |
Pass / Target |
a GPURenderPassEncoder / RTT attachment |
beam.clear / beam.target |
Two verbs: frame(cb) opens the per-frame command encoder, runs your draws, and
submits — and draw(pipeline, bindings) records one draw.
The terse everyday read mirrors the old gallery, one-for-one:
beam.frame(() =>
beam.clear([0, 0, 0, 1]).draw(tri, { verts, index, uniforms })
)WebGPU forces: createCommandEncoder() → beginRenderPass() → ...record... →
pass.end() → encoder.finish() → queue.submit(). Two of those — encoder
create/finish and queue.submit — are pure ceremony with one sensible shape per frame.
Beam removes ONLY those, inside frame(cb): "a frame is a function." The render pass
stays a first-class concept: the default screen pass is what beam.clear().draw(...)
records into, and an offscreen pass is an explicit Target. We deliberately do not
pretend passes don't exist — that would teach WebGPU wrong.
WebGPU binds by group + binding index, not by name or order. So a draw's data is a single keyed object, generic over the pipeline schema so the call site is fully type-checked:
beam.draw(pipe, { verts, index, uniforms, textures, samplers, instances })- Happy path: you never name a bind group.
draw(pipe, bindings)builds (and caches by resource identity) the bind groups from the keyedbindings, against layouts derived from the pipeline schema. - Power path:
pipe.group(i)returns a typedBindLayout;beam.bind(layout, {...})makes a reusable namedBindGroupyou pass viabindings.groups.
await Beam.gpu(canvas, config?) (alias Beam.create) acquires adapter + device, reads
navigator.gpu.getPreferredCanvasFormat(), configures the canvas context. Escape
hatches: beam.device, beam.adapter, beam.ctx, beam.format, beam.canvas.
The schema drives three outputs from one declaration: TS types (Pipeline<V,U,T,S>),
GPUVertexBufferLayout (strides/offsets/formats from vertex key order == @location
order), and explicit GPUBindGroupLayouts (uniforms → @group(0), textures+samplers →
@group(1)). Never layout: 'auto' (cross-pipeline incompatibility footgun) — we
build layouts from the schema so bind groups are reusable across pipelines sharing a
schema. WGSL is hand-authored, never rewritten. Fixed-function presets: primitive,
cull, depth, blend, targets, samples, constants, vsEntry/fsEntry.
Every resource is a persistent GPU object, mutable via .set(...) returning this.
beam.verts(schema, state?)→Verts<V>— oneGPUBufferper attribute (non-interleaved).beam.index(state)→Index— auto-selects uint16/uint32;.count/.offset.beam.uniforms(schema, state?)→Uniforms<U>— one std140-packed UBO;.set(key,val)/.set(obj); dotted keys for nested structs.beam.texture(src?, opts?)→Textureandbeam.cube(faces?, opts?)→Texture.beam.sampler(opts?)→Sampler— immutable (replace, don't mutate).
See the type surface (§ below) and the original design notes. beam.target(opts) is a
render pass with a sampleable color (and optional sampleable depth) texture; it .draws
identically to beam. beam.frame(cb) encodes one frame; beam.loop(cb) runs a rAF
loop and returns stop().
-
Vertex buffers are non-interleaved, no instanced attributes. One
GPUBufferper attribute (stride = attribute size, offset 0), matching old Beam's per-keyVertexBuffers. None of the 24 gallery examples need interleaved or per-instance step-mode attributes (old Beam had no instancing; multi-object scenes use multiple draws).instancesstays a draw count only. -
mat3packs as 3×vec4(48 bytes),mat2as 2×vec2(16 bytes),mat4as 64 bytes. The schema→offset computer padsmat3columns to 16..setformat3expands a 9-float input to the 12-float padded layout. A dev-mode assertion compares computed UBO size to schema and warns on std140 mismatch. (Examples should prefer passingmat3normal matrices asmat4where convenient to dodge the trap.) -
One UBO per
uniformsresource. Multi-object scenes use oneuniformsresource per object. Because all draws in aframesubmit together,queue.writeBuffercalls between draws all land before execution — sequential draws sharing one UBO would all read the last written value. So an N-object scene (multi-balls, material-balls, shadow grid) allocates Nuniformsresources (or per-object model-matrix uniforms). This is WebGPU-correct and a key teaching point, documented in the guide. No storage-buffer / dynamic-offset path in the locked core surface (reachable viabeam.device). -
Bind groups are cached by resource identity. Auto-binding memoizes the
GPUBindGroupper (pipeline-group, resource-set). Mutating a resource's contents via.setreuses the same buffer/bind group; allocating a new resource makes a new group. Combined with (3), per-object resources give correct per-object values. -
MSAA:
target.color/ the resolved texture is single-sample and sampleable. Withsamples: 4, Beam allocates an internal multisample color texture and setsresolveTargetto the single-sample texture (swapchain view for the screen, the sampleable texture for a target).samples: 1is the fully-exercised default. -
Default loadOp is clear-to-black.
beam.clear()with no args clears to[0, 0, 0, 1](depth 1). If a surface is drawn in a frame without aclear()call, its first pass that frame usesloadOp: 'clear'to[0,0,0,1]; subsequent draws to the same open passload(accumulate). Explicitclear(color)overrides the color. -
HiDPI is opt-in.
Beam.gpu(canvas, { hidpi: true })(andresize()) multiply bydevicePixelRatio; default off — sizing is the user's, as in the hello-world.
WGSL is hand-authored and never rewritten. Beam computes vertex + bind group layouts from the schema; you follow ONE fixed convention.
- One module, two entry points
vs/fs(overridevsEntry/fsEntry). - Vertex attributes →
@location, in schema key order. Type map:f32→f32/float32,vec2→vec2f/float32x2,vec3→vec3f/float32x3,vec4→vec4f/float32x4,i32→i32/sint32,u32→u32/uint32. One buffer per attribute. - Uniforms →
@group(0) @binding(0), one UBO, std140. Declare a WGSLstructwhose fields mirror the schema order. Align: scalar 4,vec28,vec3/vec416,mat216 (2×vec2 padded),mat348 (3×vec4),mat464. Put a scalar after avec3to fill its trailing pad. Nested structs via dotted.set('dirLight.direction', v). Uniform type map:f32→f32,vec2→vec2f,vec3→vec3f,vec4→vec4f,mat2→mat2x2f,mat3→mat3x3f,mat4→mat4x4f,i32→i32,u32→u32. - Textures then samplers →
@group(1). Textures get@binding(0..T-1)(intextureskey order), samplers@binding(T..T+S-1)(insamplerskey order). Type map:tex2d→texture_2d<f32>,texCube→texture_cube<f32>,texDepth→texture_depth_2d;sampler→sampler,samplerCompare→sampler_comparison. - Examples: one
.wgslper pipeline imported with?raw;Uniformsstruct first, then bindings, then@vertex, then@fragment; comment each binding with its schema key.
- No
layout: 'auto'; no WGSL codegen; no positional...resources; no scattered per-uniform setters; no mutable samplers; no compute/indirect in the terse surface (reachable viabeam.device); no.end()/.submit()on the happy path.