Skip to main content

GPUScene

Overview

GPUScene owns or borrows a flat, fixed-capacity GPU database for renderable objects. Each record has a stable application ID, bounds, transform, draw-group reference, geometry reference, and indirect-command slot. The same storage can therefore connect visibility, spatial queries, picking, and later GPU draw generation without rebuilding a CPU-selected draw list each frame.

The class is deliberately a storage contract, not a scene graph. A game-engine hierarchy, a table, a simulation, or a map tile system may all populate the records, but none becomes the canonical source model. This separation makes the expensive GPU path reusable while applications retain their own hierarchy, lifecycle, and presentation policy.

GitHub

Concepts

Stable identity is separate from record position

id is the durable application identity used by picking and selection. A record's array position is only its current storage slot. Keeping those concepts separate is essential once later update and compaction workflows move records: references can continue to name an object even when its physical location changes. 0xffffffff is reserved as the invalid reference and cannot be an object ID.

Initial records occupy a dense active prefix. flags marks those records active, while the state buffer publishes count, activeCount, and overflow as separate uint32 graph views. After a removal, count is the physical high-water mark and may include holes; activeCount is the exact number of live records. Consumers therefore combine the prefix with flags until compaction makes the two counts equal again.

Pre-populated borrowed records may declare an explicit activeCount below their physical recordCount. Newly allocated empty storage cannot claim a positive record count, and bounds or transform values must remain finite after conversion to the stored float32 representation.

Mutation is transactional and measurable

mutate() accepts removals, patches, insertions, and optional compaction as one transaction. The complete transaction is validated before CPU metadata or GPU buffers change. Removals run first, patches retain the same stable IDs, and insertions reuse the lowest available slots. IDs may appear in only one operation within a transaction, which keeps replacement and ordering semantics unambiguous.

Fixed capacity is a normal result, not an implicit reallocation. Insertions fill the available slots in input order, overflowCount reports the uninserted suffix, and the state buffer's overflow value becomes one. Every result also reports writeCount and uploadedByteLength, so an application can decide when fragmented small writes cost more than compaction.

compact() and mutate({compact: true}) retain active records in prior slot order and return every {id, from, to} move. Stable IDs and group, geometry, and command references remain unchanged. Compaction uploads at most the former active prefix and clears its unused suffix; a dense no-op compaction uploads no record bytes.

These operations use explicit WebGPU queue writes. They do not submit command buffers or read GPU data back. This is the CPU-authored update path for scene graphs and streaming producers; later GPU-authored draw selection consumes the same resident records without requiring CPU filtering.

The embedded scene-graph explorer makes that boundary visible: an application-owned tree is flattened once, group checkboxes update GPU visibility policy, and clicking selects a stable application object ID rather than its temporary scene slot. Moving or removing the selection uses mutate() and displays the exact upload cost while the previously compiled draw and renderer-group workflows remain unchanged.

One fixed record connects several workflows

Every record is 128 bytes:

Byte offsetFieldPurpose
0object ID and flagsStable identity and participation state
8group and geometry IDsRenderer-owned resource references
16command slotDestination for later indirect-command generation
32minimum boundsfloat32x4; XYZ contains the caller-defined minimum
48maximum boundsfloat32x4; XYZ contains the caller-defined maximum
64transformFour column-major float32x4 columns

The padding is intentional. Every vector field is 16-byte aligned for storage-buffer access, and the interleaved layout can be imported into GPUCommandGraph as typed, strided field views without repacking. Bounds are useful to frustum and spatial filtering; stable IDs feed picking and selection; group, geometry, and command references let a renderer attach its own resources without putting pipelines or materials into the core scene record.

The bounds' coordinate space is caller policy. Applications may upload world-space bounds ready for queries, or object-space bounds that a later workflow transforms; the storage layer does not apply a transform or silently reinterpret them.

References do not imply ownership

groupId, geometryId, and commandSlot default to GPU_SCENE_INVALID_REFERENCE. They are application-defined keys, not handles owned or destroyed by GPUScene. In particular, a command slot names where a later workflow may write; this tranche does not allocate or encode indirect draw commands. That explicit boundary avoids coupling scene storage to one renderer or table package.

Capacity and ownership are visible

Capacity is fixed when the scene is constructed. The stats property reports record and state allocation sizes before submission, and getRecordByteOffset() provides validated slot addressing. The default constructor owns its two buffers. Supplying buffers borrows them unless ownsBuffers: true is explicit. An ownership object such as {records: false, state: true} may adopt the buffers independently, which lets a table adapter borrow record storage while owning its small state block. Borrowed buffers must have storage, copy-source, and copy-destination usage and enough bytes for the declared capacity.

This first contract never grows storage implicitly. A caller can size for a known maximum or create a replacement scene deliberately when capacity changes, keeping allocation and lifetime costs visible.

CPU-authored mutation requires known initial records. A scene created from opaque, pre-populated borrowed buffers exposes mutable === false: its storage and graph views remain usable, but mutate() and compact() reject because the class cannot safely infer stable IDs or holes without readback. Supplying matching records when adopting buffers keeps mutation available.

Graph integration remains submission-neutral

importToGraph() imports the record and state buffers and returns typed field views. It does not add passes, compile the graph, create a command encoder, or submit work. Algorithms can consume only the views they need, and the application's existing frame graph remains responsible for ordering and submission.

Usage

const scene = new GPUScene(device, {
capacity: 10_000,
records: [
{
id: 42,
bounds: {minimum: [-1, -1, -1], maximum: [1, 1, 1]},
geometryId: 7,
commandSlot: 0
}
]
});

const graph = new GPUCommandGraph(device);
const sceneView = scene.importToGraph(graph);

const mutation = scene.mutate({
update: [{id: 42, geometryId: 8}],
insert: [{id: 43, bounds: {minimum: [2, -1, -1], maximum: [4, 1, 1]}}]
});

if (mutation.overflowCount > 0) {
// Create a deliberately larger replacement scene at an application-defined boundary.
}

// Compose sceneView.boundsMinimum, sceneView.boundsMaximum, and
// sceneView.objectIds with visibility, spatial, picking, or draw workflows.

Constructor

new GPUScene(device: Device, props: GPUSceneProps)

Important properties include capacity, optional initial records, optional borrowed buffers, and boolean or per-buffer ownsBuffers. When borrowed buffers are already populated, recordCount declares their active prefix.

Methods

importToGraph(graph, id?)

Returns record/state buffer handles plus typed graph views for every public field.

getRecordByteOffset(recordIndex)

Returns the byte offset of a validated capacity slot.

getRecordIndex(id)

Returns the current physical slot for a stable ID when CPU record metadata is known.

mutate(mutation)

Atomically validates and applies insert, update, remove, and optional compact operations. The result identifies affected IDs and moves and reports overflow and exact queue-write cost.

compact()

Stable-compacts active records and returns the same mutation result shape.

destroy()

Destroys owned buffers and leaves borrowed buffers untouched. Calling it repeatedly is safe.

Current scope

GPUScene now implements the Phase 6.1a storage and 6.1b CPU-authored mutation contracts. The scene adapters add explicit CPU-hierarchy and zero-copy preserved-table boundaries without changing this core storage model. GPUSceneDrawGeneration publishes active, visible rows into deterministic indirect-command slots, while GPUSceneResourceGroups classifies those commands into explicit renderer-owned binding windows. GPU-authored record mutation and visibility policy remain graph workflows rather than hidden behavior inside the storage owner.