# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

`pex-gpu` is a WebGPU-only GPU wrapper in the [PEX ecosystem](https://github.com/pex-gl/), written in TypeScript. Shaders are WGSL. It is a modern successor to WebGL [pex-context](https://github.com/pex-gl/pex-context).

## Commands

[snowdev](https://github.com/dmnsgn/snowdev/) globally installed workflow (see its `README.md` for conventions):

- `npm run dev` — dev server + ESM dependency install; compiles `src/*.ts` → JS in `lib/`, declarations in `types/`.
- `npm run build` — lint, format, run TypeScript (emit `lib/` + `types/`), update README API section between the `<!-- api-start -->`/`<!-- api-end -->` markers.

Claude doesn't need to run any snowdev command when writing code: the source is manually and visually checked via `examples/` (each demonstrates all the different pex-gpu features; served by `index.html` gallery).

- `npx tsc --noEmit` — quick typecheck (strict mode, `exactOptionalPropertyTypes`).
- No browser test runner; reflection/packing logic is pure and testable in Node (polyfill `globalThis.GPUShaderStage`).

## Architecture

### Package entries

`src/` is flat (no subdirectories). Two package entries — a DX-only split (leaner `import * as gpu` namespace); both share the same underlying modules, so state singletons and tree-shaking are unaffected:

- `src/index.ts` → `"pex-gpu"` — the public API, export order commands layer → core layer → shared types.
- `src/internals.ts` → `"pex-gpu/internals"` — the guts for advanced users: `PipelineCache`, `parseWGSL`/`mergeReflections`/`packStruct` + the reflection types, commands-layer state (`commandsState`/`frameState`/`UniformAllocator`, layout/bind-group/vertex-layout builders), debug hooks (`debugCounters`/`debugLog`/...), byte math (`alignTo`, `bytesPerTexel`, `paddedBytesPerRow`, `fullMipLevelCount`, `textureByteSize`), block-compressed format helpers (`blockInfo`, `isCompressedFormat`, `texelCopyLayout`, `physicalExtent`), cross-copy state (`shared`, `SCHEMA`, `objectId`).

Declarative commands (`submit.ts`, `define.ts`, `state.ts`, `bind-groups.ts`, `vertex-layout.ts`) are built **only on the public API** of the resource/reflection/pipeline code below them (dogfooding rule: if a command can't be built that way, the lower-level API is wrong). The one crossing in the other direction is `frame` in `context.ts`, which touches `commandsState` directly to reset/flush the per-frame uniform allocator.

`src/types.ts` holds all public interfaces: shared resource types (`GpuContext`, `GpuBuffer`, `GpuTexture`, reflection types) and the command descriptor types (`RenderCommand`, `RenderPassDescriptor`, `RenderPipeline`, ...).

### Context & frame lifecycle

`context.ts` — async `createContext` (adapter + device + configured canvas; missing `requiredFeatures` are skipped with a warning instead of rejecting; `depth`/`stencil`/`depthStencilFormat` options resolve `ctx.depthStencilFormat` for the managed canvas depth/stencil; `preserveDrawingBuffer` restores WebGL-era canvas persistence — WebGPU canvas textures carry no such guarantee, so it's faked with a persistent backing texture that `FrameInfo.canvasTexture`/`canvasView` point at instead of the literal swapchain texture, blitted into the real swapchain texture once per frame after the callback returns), `resize`, the `frame` loop.

A single `frame` loop manages per-frame submit state (encoder tracking, uniform ring reset/flush) inline, but the ring allocator is lazy — created at the first submit that packs uniforms — so code that never submits uniforms pays nothing for it.

`flush(ctx)` submits the open segment so far and continues it in a new encoder (same canvas texture, uniform ring not reset). It isolates work from failures: a validation error fails only its own command buffer, so whatever was flushed before it survives. Not allowed inside a pass scope; it finishes any previously obtained encoder.

### Resource factories

Thin helpers over raw WebGPU; users drive real `GPUCommandEncoder`s. Resource factories return plain objects wrapping GPU handles, with idempotent `Symbol.dispose`:

- `buffer.ts` — `createBuffer`/`updateBuffer`: `usage` is a preset name (`BUFFER_USAGE_PRESETS`) or raw flags, index data normalizes to uint16/uint32, sizes are 4-byte aligned, `size` is a minimum when `data` is present.
- `texture.ts` — `createTexture` (size/format/layer count inferred from image data; `mipmap: true` allocates **and** generates the full chain while `mipLevelCount` is the raw WebGPU count and `mipLevels` uploads a pre-computed chain — the only route for compressed formats, which can never be rendered into; `usage` **replaces** the `TEXTURE_BINDING | COPY_DST | RENDER_ATTACHMENT` default; `depth` is an ergonomic alias falling back to `depthOrArrayLayers`), `updateTexture`, `copyExternalImage`, `generateMipmaps`.
- `sampler.ts` — `createSampler` returns a raw `GPUSampler` (samplers own no GPU memory, need no dispose); `filter`/`addressMode` shorthands fan out to the per-axis fields.

### Reflection & packing — the crux

- `src/reflection.ts` is a hand-written WGSL parser (regex-based, not an AST): struct layouts, `@group/@binding` declarations classified into `BindingKind`s, vertex inputs from the `@vertex` entry point, entry-point names. Results cached by source string.
- Struct layouts follow WGSL **uniform address space** rules: vec3 aligns to 16, mat3 columns stride 16, array strides round to 16, nested structs align to 16.
- `mergeReflections` unifies separate vertex/fragment sources (OR-ing stage visibility; `read_write` storage excludes VERTEX).
- `src/pack.ts` writes JS values at reflected offsets — mat3 column padding lives here and is the classic silent-corruption spot; keep its tests green.

### Pipeline caching & execution

- `pipeline-cache.ts` — `PipelineCache`: shader modules + pipelines in nested maps keyed by source strings + serialized state (never hashed, so cache hits don't walk large WGSL); WGSL diagnostics captured per module via error scope + `getCompilationInfo`, reported with source-line context, identical across browsers.
- `submit.ts` — `submit(ctx, { pass, pipeline, attributes, uniforms })`; real `GPURenderPipeline`s are created lazily at first submit (vertex layout + target formats known then) and cached.
- `define.ts` — `defineCommand`/`definePass`/`definePipeline`: typed identity helpers, zero runtime work, so plain-JS authors get autocomplete.
- `state.ts` / `bind-groups.ts` / `vertex-layout.ts` — per-context state (WeakMap-keyed), layout/bind-group caching, vertex layout resolution.

How a declarative draw executes (`src/submit.ts`):

1. `beginPass` builds a `GPURenderPassDescriptor` from the plain `pass` object (canvas is the default target; `resolveTarget: "canvas"` for MSAA; canvas-targeting passes — including passless commands — implicitly attach the context-managed, auto-resizing depth/stencil texture whose format is `ctx.depthStencilFormat`, resolved from the `depth`/`stencil`/`depthStencilFormat` createContext options (depth on by default — the WebGL-style "default framebuffer"; it auto-clears at first use each frame, depth 1/stencil 0, and loads on later passes); formats/sampleCount recorded for pipeline creation. Depth/stencil ops are aspect-gated, so `stencil8` and depth-only formats stay valid).
2. Shader reflection → vertex layouts (`vertex-layout.ts`, one slot per attribute), explicit bind group layouts + pipeline layout (`bind-groups.ts`, cached per context — explicit rather than `layout: "auto"` so bind groups are shareable and uniform buffers can use **dynamic offsets**). Reflection + pipeline layout are resolved **once per pipeline object** (`CachedPipelines` in a per-context `WeakMap`; shader sources on a pipeline object are treated as immutable), along with the per-stage sources. Both the commands-layer `RenderPipeline` and the core-layer `RenderPipelineSource` take the same exclusive shader union (`RenderPipelineShaders`): a combined `shader` or a `vertex` with an optional `fragment`. `resolveShaders` (`pipeline-cache.ts`) unfolds either form, and `getRenderPipeline` normalizes **before** keying, so `shader` and the equivalent `vertex`/`fragment` pair share reflection, shader modules and cached GPU pipelines. A `vertex` with no `fragment` stays a fragment-less, color-target-less pipeline (shadow maps, depth prepasses).
3. Steady-state draws hit a per-pipeline-object variant map keyed by a cheap signature (pass formats/sampleCount from `ActivePass.key` + vertex layout key + mutable pipeline state); only a miss builds the full descriptor and goes through `ctx.pipelineCache.getRenderPipeline(...)`.
4. Uniform structs are packed into a per-frame **ring allocator** (`state.ts`, 256-byte aligned slices, single `writeBuffer` flush per frame, growth retires the old buffer safely mid-frame); bind groups are cached keyed by layout + resource ids and rebound with per-draw dynamic offsets.
5. Pass scope form `submit(ctx, cmd, () => ...)` keeps the pass open for nested submits — render passes (render-to-texture pattern) and compute passes alike (nested compute submits dispatch into the same compute pass; the scoped compute command always dispatches first — there is no pass-only compute form; `pass` on a compute command is an options-only descriptor: label + timestampWrites); array form batches variants in one pass (render draws or compute dispatches — compute pass usage scopes are per dispatch, so batched ping-pong is legal); nested passes throw, as does mixing render and compute inside one scope.
6. A pipeline object whose reflection throws, or whose GPU pipeline fails validation (`PipelineCache.hasFailed`, known asynchronously from the error scope), lands in `failedPipelines` and its later submits are skipped: the error is reported once, and an invalid pipeline would otherwise invalidate the frame's whole command buffer. `pipelineFailed(ctx, pipeline)` exposes it so a caller can swap in a fallback, which has to be a new pipeline object.

Per-context commands state lives in a `WeakMap` (`commandsState`) — nothing is stamped on the context object.

### Readback & queries

- `readback.ts` — async `readTexture` (handles the 256-byte `bytesPerRow` padding) / `readBuffer`.
- `query.ts` — `createTimestampQuery(ctx, { count, buffering, label })`: a query set plus a ring of `buffering` readback buffers (default 1), a partial `resolve(encoder, count?)` that arms one and reports false when none is free, a macrotask-deferred `read()` of the armed one, and a `dispose()` that defers destruction while a read is mapping. The manual route — the caller attaches `timestampWrites` themselves; the automatic one is `debug.ts`'s pass timing, built on this same object rather than a second copy of the query-set/readback/lifetime logic. That import is what makes `debug.ts` ↔ `query.ts` a cycle (see below).

### Render bundles

`bundle.ts` — `createRenderBundle(ctx, formats, record)`; `RenderBundleFormats` is a plain `GPURenderBundleEncoderDescriptor`.

### Debug & introspection

`debug.ts` — always-on per-device pressure counters snapshotted by `debugStats(ctx)` (resource alive/created/disposed/bytes for buffers/textures/querySets/samplers/renderBundles, cache misses vs hits, per-frame passes/draws/dispatches), plus `resources`: every live resource listed with its label, usage flags and — for textures — format, dimensions, mip/sample counts and estimated bytes. No `byFormat`/`byUsage` aggregates exist by design; the list is grouped in userland (`Object.groupBy`, with `usageNames(usage, "buffer" | "texture")` from `pex-gpu/internals` for readable flags). Entries are plain data, never the GPU handle: holding the handle would pin an undisposed-but-unreachable resource, turning a recoverable leak into a permanent one. They are **frozen at creation and shared with every snapshot**, not deep-copied into it — WebGPU has no resize or reformat, so nothing in an entry can go stale (`updateBuffer`/`updateTexture` write in place), and `structuredClone` of the list dominated `debugStats` (530µs vs 2µs at 1000 resources) while leaving `untrackResource`'s `bytes` open to mutation by whoever held a snapshot. Only the counters are cloned. The arrays themselves are fresh each call, so sorting or filtering a snapshot is safe. Samplers and render bundles have no `destroy()`, so their entries are dropped by a `FinalizationRegistry` (holding the device weakly) instead of a `dispose()` that never comes. Opt-in `debug(ctx, { log, groups })`: `"resources"` logs GPU object creation/cache misses/allocator growth (quiet in steady state), `"commands"` adds the full submit stream, `groups` wraps frames and labeled commands in pushDebugGroup for GPU profilers. Counters are keyed by `GPUDevice` (not context) so device-holding internals can report. The module was a dependency leaf until pass timing moved in: it now imports `query.ts`, which imports it back. The cycle is inert — neither module reads across it at evaluation time — but it is the reason to keep top-level code in both to `shared()` registrations and plain constants.

An access is recorded by whichever layer served it: the commands layer books the hit when its per-pipeline-object or per-variant cache answers a draw, so `shaderReflections`/`renderPipelines` describe what draws did rather than only the requests that got past the front line (which are, by construction, the misses). `PipelineCache` still records whenever it is consulted, so driving it directly still counts. Cache counters carry `ms` alongside `created`/`hits`: the wall time the miss path spent inside the factory, so "is the hitch reflection, module compilation or pipeline creation?" is answerable without a profiler. Only the miss path is measured (steady state has none, so it costs nothing), and a pipeline's `ms` excludes the reflection and module work it triggered (`PipelineCache.#nestedTime`, a delta of the monotonic lifetime counters across the factory call) so the seven numbers stay disjoint and additive. WGSL reflection is counted through `reflectWGSL(device, source)` — `parseWGSL` plus cache accounting — and through `reflectStages(device, vertex, fragment?)`, one access per pipeline object rather than per source, so the merge (not cached, runs per pipeline) lands in the same number as the parses it joins. `recordCacheAccess(device, key, ms)` takes a single duration where null is the hit, since "how long it took" only exists on the miss path. `parseWGSL` stays device-free so reflection still runs outside a WebGPU host, and `PipelineCache` deliberately uses it bare: its per-stage lookups are for entry points and overrides, not reflection requests, and recording them buried each real miss under two internal hits (a combined `shader` pinned the hit rate at exactly 2/3, whatever the app did).

**GPU timing** (same file, `─── GPU timing ───`) — opt-in behind `debug(ctx, { timing: true })`, surfaced as `debugStats(ctx).timings`. It owns one `TimestampQuery` (`buffering: 3` — two frames may be in flight and a third is mapping, so no frame ever waits) and adds only what is specific to timing passes: `beginPass`/`beginComputePass` claim a begin/end query pair from it (a caller's own `timestampWrites` wins the descriptor's single slot and that pass reports `ms: null`), `endFrame` resolves the claimed prefix and reads it after the submit. Growth is a new query — a set can't be resized, and the replaced one self-destructs once its last readback lands, which is what `dispose()`-while-mapping buys. Keyed by `GpuContext`, not device, since every call site has one — hence `timingStates` alongside the device-keyed `states` in the same module. Results lag two to three frames, which is why they are a sibling of `DebugStats.frame` and not a field in it: those counters reset synchronously at `beginFrame`, and a lagging value inside a per-frame struct reads as current. `DebugTimings.frameIndex` names the frame they actually describe. The same readback carries `latency` (submit → `onSubmittedWorkDone`, frame latency rather than GPU busy time) and `quantized`, set when every timing is a multiple of 100µs — Chrome's default bucket, disabled with `chrome://flags/#enable-webgpu-developer-features`. Needs `optionalFeatures: ["timestamp-query"]` at `createContext`; without it `debug` warns and leaves timing off.

Every GPU allocation in the package is routed through `trackResource`/`untrackResource`, including the ones users never create themselves — the ring allocator's buffers and its retired generations (`state.ts`), the timestamp query set and its resolve/read buffers (`query.ts`, pass timing included), and the readback staging buffers (`readback.ts`) — so `lifetime.buffers.bytes` is the device's buffer footprint rather than just the user's.

### Cross-copy state — `shared.ts`

Every module-scope cache is per-copy, so two copies of pex-gpu on one page each
build their own: a context created by one is unknown to the other, and the
second silently allocates a duplicate ring allocator and canvas depth texture
that the first one's `dispose()` can't reach. `shared.ts` holds them in a
registry on `globalThis` under `Symbol.for("pex-gpu")` instead. It is a leaf —
it imports nothing — and every module-scope cache in the package goes through
it:

```js
const states = shared("commandsState", () => new WeakMap());
```

Two layers, because sharing is only safe between copies that agree on the shape
of what's shared:

- The **root is frozen forever**: `nextId` and `objectIds`, a counter and an
  object-to-number WeakMap, which have no shape to disagree on. Ids sit above
  the schema partition because resources cross copies whatever their version
  (`isGpuBuffer`/`isGpuTexture` are structural), and two id spaces both starting
  at 1 would collide in the bind group cache — `bind-groups.ts` mixes buffer
  ids, texture ids and `objectId`s in one key space. One counter serves all
  three. Never rename or repurpose a root field.
- `schemas` partitions the structured state by `SCHEMA`. Contexts are branded
  with their creating copy's schema (non-enumerable, under
  `Symbol.for("pex-gpu.schema")`); `assertSchema` throws on the first crossing,
  checked only on the cache-miss path so steady-state submits pay nothing.

What each case does: copies at the same schema share everything and fully
interoperate, whatever their package versions. Copies at different schemas stay
isolated and throw a named error the moment a context crosses — not a fix,
because a copy cannot read a struct whose layout it wasn't compiled against, but
a deterministic failure instead of a silent leak. A mix partitions into islands
by schema. Resources cross safely in every case, thanks to the shared id space.

A second copy warns once on load: sharing works, but the page still ships the
package twice.

### Byte math

`utils.ts` — `alignTo`, `bytesPerTexel`, `paddedBytesPerRow`, `fullMipLevelCount`. `texture.ts` adds `textureByteSize`, which estimates a texture's footprint from either a `GpuTexture` or a bare descriptor (so callers can budget allocations before creating anything).

## Coding Style

### Conventions

- Tree-shaking is a hard requirement: free functions, no god object, `sideEffects: false`. Don't add methods to `GpuContext`.
- Raw WebGPU strings everywhere; never reintroduce enum wrapper objects.
- Field names follow WebGPU wherever a 1:1 concept exists (`label`, `colorAttachments`, `depthStencilAttachment`, `vertex`/`fragment` sources, `depthWriteEnabled`, `instanceCount`, `firstInstance`, `timestampWrites`); pex-only names are reserved for concepts WebGPU lacks (`attributes`, `uniforms`, `count`/`first` unifying vertex/index draws, `dispatch`, the `clearValue`/`depthClearValue` shorthands, `shader` for a single source declaring both stages — WebGPU has no combined-source concept, its module-level WGSL string is `code`). `depthWriteEnabled` doubles as the depth switch: it defaults to false, and setting it true defaults `depthCompare` to "less-equal" instead of "always".
- Option interfaces **derive from WebGPU descriptor types** via `extends`/`Pick`/`Partial`/`Omit` (`CreateContextOptions` from `GPUCanvasConfiguration`/`GPUDeviceDescriptor`, `CreateTextureOptions` from `GPUTextureDescriptor`/`GPUExtent3DDict`/the copy infos, `CreateSamplerOptions` extends the whole `GPUSamplerDescriptor`, `RenderBundleFormats` = `GPURenderBundleEncoderDescriptor`). Hand-declare a field only where semantics genuinely diverge, and put the divergence in its doc comment — e.g. `requiredFeatures` warn-skips instead of rejecting, buffer `size` is a 4-aligned minimum, buffer `usage` unions preset names in.
- **Bump `SCHEMA` in `shared.ts`** in the same commit as any change to a shape reachable from the registry — its six values (`ContextState`, `CommandsState`, `DebugState`, `TimingState`, `MipmapPipelines`, `ShaderReflection`) and everything they hold, transitively: `RingAllocator`, `FrameState`/`ActivePass`, the cached-pipeline records, and `ShaderReflection` down to `StructLayout`. It is the declaration that copies sharing that number agree on those layouts. A stale number fails silently, not loudly — an added `CommandsState` field the other copy never flushes, or a `StructLayout` offset convention the other copy packs against. It is not the package version and moves far more slowly, which is what lets two compatible releases interoperate.
- Module-scope caches go through `shared(name, factory)` (`shared.ts`), never a bare `new Map()`/`new WeakMap()`, so multiple loaded copies of pex-gpu find one another. Resource ids come from `nextId()`/`objectId()` — one id space for buffers, textures and unkeyed objects alike.
- Every `device.createBuffer`/`createTexture`/`createQuerySet`/`createSampler`/`createRenderBundleEncoder` call in the package pairs with `trackResource` (or `trackCollectable` for the two without `destroy()`) and its teardown with `untrackResource` — including internal allocations users never see. Skipping it silently under-reports GPU memory in `debugStats`, which is the one number people open it for.
- Get-or-create caches use `Map`/`WeakMap` `getOrInsertComputed` (the Stage 3 upsert proposal — shipped in target browsers); counters/logs on the miss path live inside the factory callback.
- `GpuBuffer`/`GpuTexture` are plain objects with `.buffer`/`.texture` GPU handles — discriminate `GpuBuffer` vs raw `GPUBuffer` with `"id" in value` (both have `.buffer`-adjacent shapes).
- WebGPU quirks encoded in the code: `writeBuffer` executes at submit order (hence the ring allocator, not per-draw buffer reuse); buffers pending `mapAsync` invalidate submits that use them (hence the microtask deferral in `query.ts` `read()`); viewport origin is **top-left** (no Y flip vs WebGL); clip-space depth is **[0, 1]** — projection matrices must be the ZO variants (`mat4.perspectiveZO`/`orthoZO` from pex-math), not the WebGL [-1, 1] ones.
- Examples are plain JS using the namespace import — `import * as gpu from "pex-gpu"` and `gpu.createContext(...)` calls — the encouraged consumption style (one import line, whole API behind `gpu.` autocomplete; equivalent for tree-shaking since bundlers resolve static namespace accesses to named bindings). Internals stay named: `import { parseWGSL } from "pex-gpu/internals"`. The bare `"pex-gpu"` specifier is a self-import: tsserver resolves it through package.json `exports` to `types/index.d.ts` for autocomplete; in the browser the `snowdev.importMap` entry in package.json maps it to `../lib/index.js`; other deps (`pex-math`, `pex-cam`, `primitive-geometry`, ...) are import-mapped too. Each new feature needs an example and an entry in `examples/index.js`.
- Public API needs TSDoc comments (snowdev regenerates the README API section — don't hand-edit between the markers or `lib/`).

### Comment style (JS/TS)

- Write comments that explain *why*, not *what* — the code should already say what it does. No journey-style comments, only add notable quirks or not handled edge cases.
- Use JSDoc (/** */) for exported functions, classes, and public APIs; TypeScript signatures already document param/return types, so skip @param/@returns tags
- No comments restating obvious code (e.g. `// increment i` above `i++`)
- Use `// TODO(name): reason` for known follow-ups, never bare TODOs
- Prefer self-documenting names over comments where possible
