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.
Who owns what.
@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).
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.
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
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.Four methods, and a justification for every rejection.
The eight-stage lifecycle is over-specified. This is the minimum that actually works.
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;
}| Stage | Verdict |
|---|---|
| create + initialize | Merged. Two-phase construction exists to defer async work; ours is synchronous because the device is ready before any component mounts. |
| prepare | Rejected. 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. |
| update | Kept. The props boundary, and where the no-pipelines-outside-create rule is enforced. |
| compute + render | Merged into plan(). Separating them forces a component to know the scheduler’s ordering; returning both lets the scheduler order globally. |
| postRender | Rejected. Its uses are readback and profiling — readback is off the hot path by policy, profiling is the runtime’s job. |
| dispose | Kept, 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.Not a scene graph. Not a frame graph. A pass list.
| Scene graph | Frame graph | Flat pass list — ours | |
|---|---|---|---|
| Models | Hierarchical transforms, materials, culling | Passes as nodes, resources as edges; auto barriers and aliasing | An ordered list of compute + render passes per component |
| Cost | One JS object per drawable — fatal at 1M spans | Resource lifetime analysis, aliasing, pass culling | ~200 lines |
| What it would buy us | Nothing — 2D data surfaces have a viewport, not a hierarchy | Aliasing across ~3 transient targets, and barriers WebGPU already handles | Explicitness, 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.
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;
}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.
An adapter, not the runtime.
// 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.