Skip to content
gpu-components
Architecture

One device, a scheduler, and four primitives.

The design is deliberately small. Four of the nine subsystems a runtime like this usually grows are already vgpu's, and building them again would be duplication with a version number on it.

Layers

Who owns what.

Applicationyour code
@gpu-components/reactours · npm
components/gpu/timeline/*yours · copied
@gpu-components/coreours · npm
vgpuupstream
WebGPUbrowser

@gpu-components/core is framework-free and must compile with React uninstalled — enforced in CI by a lint rule, not by good intentions. @gpu-components/react is an adapter with three hooks and no GPU state. Components are not a package at all: they are source copied into your repo.

Cut from the original sketch, with reasons: renderer (vgpu's draw/effect are the renderer), runtime (merged into core), shaders (split into a shared WGSL package and per-component copied shaders), and components (registry source instead).

Shared context

One device, many canvases, one frame.

This is idiomatic vgpu rather than a workaround — its own docs show multi-canvas rendering from a single context, and a canvas may host exactly one live Surface, which maps cleanly onto one component per canvas.

core/scheduler.ts
frameLoop(runtime.gpu, (f) => {
  // one shared uniform write for the whole page
  runtime.globals.set({ time, dpr });

  const plans = components
    .filter(cmp => cmp.dirty || cmp.animating)
    .map(cmp => cmp.plan(frameCtx));

  // all compute before all render — across components
  for (const p of plans)
    for (const pass of p.computePasses) pass.dispatch();

  for (const p of plans)
    for (const pass of p.renderPasses)
      f.pass({ target: pass.target, timer: profiler.span(pass.name) },
             (enc) => pass.encode(enc));
});

What the scheduler buys

  • One command buffer and one submit per page tick, at any component count
  • Global ordering — a binning pass cannot land after a consumer's draw
  • Clean components contribute nothing; a static page costs no GPU work
  • A profiler span per pass, so "which component blew the budget" is answerable
One real hazard. vgpu throws VGPU-FRAME-REENTRANT if frame() is called inside another frame or inside a surface resize callback — and the immediate fire on subscription counts. Resize handlers set state for the next frame; they never render inline.
Component model

Four methods, and a justification for every rejection.

The eight-stage lifecycle is over-specified. This is the minimum that actually works.

core/component.ts
interface GpuComponent<Props> {
  /* Allocate stable resources. Once. The ONLY place
     pipelines are created. */
  create(ctx: ComponentContext): void;

  /* Props changed. May write/resize buffers.
     Must NOT create pipelines. Marks dirty. */
  update(props: Props): void;

  /* Contribute passes to the shared frame.
     Pure: no allocation, no submit. */
  plan(frame: FrameContext): RenderPlan;

  /* Idempotent — StrictMode will call it twice. */
  dispose(): void;

  // optional
  hitTest?(x: number, y: number): HitResult | null;
  describe?(): SemanticModel;
  onContextRestored?(): void;
}
StageVerdict
create + initializeMerged. Two-phase construction exists to defer async work; ours is synchronous because the device is ready before any component mounts.
prepareRejected. Its job is CPU culling and sorting. Our culling is GPU-side and our sorting happened once at ingest — it would be empty on every component.
updateKept. The props boundary, and where the no-pipelines-outside-create rule is enforced.
compute + renderMerged into plan(). Separating them forces a component to know the scheduler’s ordering; returning both lets the scheduler order globally.
postRenderRejected. Its uses are readback and profiling — readback is off the hot path by policy, profiling is the runtime’s job.
disposeKept, and must be idempotent.
plan() is declarative on purpose. It returns a description of passes rather than encoding them, which means the scheduler can order globally, skip clean components, attach profiler spans uniformly — and a test can assert "this component contributes two render passes and one dispatch" without a GPU anywhere in sight.
Rendering

Not a scene graph. Not a frame graph. A pass list.

Scene graphFrame graphFlat pass list — ours
ModelsHierarchical transforms, materials, cullingPasses as nodes, resources as edges; auto barriers and aliasingAn ordered list of compute + render passes per component
CostOne JS object per drawable — fatal at 1M spansResource lifetime analysis, aliasing, pass culling~200 lines
What it would buy usNothing — 2D data surfaces have a viewport, not a hierarchyAliasing across ~3 transient targets, and barriers WebGPU already handlesExplicitness, and a trivially debuggable frame

A scene graph is categorically wrong here: the data lives in typed arrays and storage buffers, not a node tree, and materialising one JS node per span is exactly the mistake that makes display-list renderers fail at this scale.

The upgrade path stays open for free, because a pass declares its inputs and outputs even though v1 never reads them. If we ever hit twenty passes with real aliasing pressure, the scheduler grows a topological sort and nothing else changes.

core/plan.ts
interface RenderPass {
  name: string;
  target: Target | 'surface';
  reads?: ResourceRef[]; // declared, unused in v1
  writes?: ResourceRef[]; // → future auto-ordering
  clear?: ClearColor | false;
  scissor?: Rect;
  encode(pass: FramePass): void;
}
Primitives

Four, and a fifth requires an RFC.

This boundary is what stops the project becoming an accidental rewrite of a general 2D vector renderer.

InstancedQuadLayer

The workhorse. Per-instance attributes in a storage buffer, one draw for millions of quads, zero vertex buffers.

LineLayer

Instanced quads expanded to screen-space thick lines in the vertex stage — axis rules, connectors, edges.

RasterLayer

A texture drawn through a full-screen effect with a colormap. LOD density fields and heatmaps.

LabelLayer

v1: DOM overlay, which doubles as the accessibility layer. v2: glyph atlas, when a component needs more than ~400 labels.

React

An adapter, not the runtime.

react/hooks.ts
// three hooks. That is the entire surface.
useGpu(): GpuRuntime | null
useGpuCanvas(opts): { ref, surface, size }
useGpuComponent<P>(factory, props): MountHandle

// rejected for v1, and why:
//   useGPUBuffer / useGPUTexture / useGPUShader
//   → they make React’s lifecycle the GPU resource
//     lifecycle, which is the coupling we are avoiding
  • GPU resources are created in create() and only there. Never during React render — a concurrent render may never commit.
  • Render state lives in refs and GPU buffers, never React state. A pan gesture must cause zero React re-renders; it writes a uniform and marks dirty.
  • React state is for semantics — selected ids, visible labels, a11y focus — updated at most once per frame, and only when it actually changed.
  • StrictMode double-invocation is the leak vector. Idempotent dispose, plus a dev registry that counts live GPU objects and warns when a remount increases the count. Tested, not hoped.