How Splat State Tracking Works in Supersplat: CPU, GPU, and History Layers

Supersplat manages splat state tracking through a three-layer architecture combining a CPU-side Uint8Array with bit flags, a GPU-side R8 texture for shader access, and edit-history operations for undo/redo support.

The open-source Supersplat application (playcanvas/supersplat) handles interactive editing of Gaussian splats by maintaining synchronized state across the CPU-GPU boundary. Understanding how splat state tracking is implemented reveals why the editor can selectively transform, lock, or delete individual splats in real-time while preserving full undo capabilities.

Understanding the Three-Layer State Architecture

Supersplat implements splat state tracking across three tightly-coupled layers:

  • CPU state array: A Uint8Array where each byte holds three bit-flags (selected = 1, locked = 2, deleted = 4)
  • GPU state texture: A 1-pixel-wide R8 texture that mirrors the CPU array each frame
  • Edit-history operations: Transform operations that store old/new state for undo/redo functionality

This architecture ensures that UI interactions, shader rendering, and history management all reference the same authoritative state.

CPU State Storage: The Uint8Array Bit-Flags

The authoritative source for splat state lives in a CPU-side typed array defined by the State enum in src/splat-state.ts:

// src/splat-state.ts
enum State {
    selected = 1,
    locked   = 2,
    deleted  = 4
}
export { State };

When a Splat instance initializes, it ensures the vertex element contains a state property backed by this array. In src/splat.ts, the constructor adds the storage channel if missing:

// src/splat.ts – constructor (excerpt)
if (!this.splatData.getProp('state')) {
    this.splatData.getElement('vertex').properties.push({
        type: 'uchar',
        name: 'state',
        storage: new Uint8Array(this.splatData.numSplats),
        byteSize: 1
    });
}

The resulting Uint8Array contains one byte per splat, with bits 0, 1, and 2 representing selected, locked, and deleted states respectively.

GPU Synchronization: The State Texture Pipeline

To make state accessible to shaders, src/splat.ts allocates an R8 texture in the constructor:

this.stateTexture = createTexture('splatState', PIXELFORMAT_R8);

The updateState() method synchronizes CPU data to GPU memory and recomputes visibility statistics:

// src/splat.ts – updateState()
const state = this.splatData.getProp('state') as Uint8Array;
const data  = this.stateTexture.lock();
data.set(state);
this.stateTexture.unlock();

// count selected/locked/deleted
for (let i = 0; i < state.length; ++i) {
    const s = state[i];
    if (s & State.deleted) { numDeleted++; }
    else if (s & State.locked) { numLocked++; }
    else if (s & State.selected) { numSelected++; }
}

After unlocking, the texture is bound to the material via material.setParameter('splatState', this.stateTexture), making it available to vertex and fragment shaders.

Shader Implementation: Reading State in the Vertex Shader

The vertex shader in src/shaders/splat-shader.ts samples the state texture to determine per-splat visibility and selection status:

// src/shaders/splat-shader.ts – vertex shader (excerpt)
uint vertexState = uint(texelFetch(splatState, splat.uv, 0).r * 255.0 + 0.5) & 7u;

// discard deleted splats
#if !PICK_PASS
if ((vertexState & 4u) != 0u) {
    gl_Position = discardVec;
    return;
}
#endif

// pass selected / locked flags to fragment shader
texCoord_flags = vec4(
    corner.uv,
    (vertexState & 1u) != 0u ? 1.0 : 0.0,   // selected
    (vertexState & 2u) != 0u ? 1.0 : 0.0    // locked
);

The fragment shader uses these flags to apply visual tinting, rendering selected splats with highlight colors and locked splats with desaturated or warning colors.

UI and Transform Integration

Filtering in the Data Panel

The UI layer in src/ui/data-panel.ts queries the state array to determine which splats are editable or visible. The data panel uses filter functions that check against the State enum values:

// src/ui/data-panel.ts – example of filtering
valueFunc: i => ((state[i] === 0 || state[i] === State.selected) ? func(i) : undefined),
selectedFunc: i => state[i] === State.selected,

When users toggle selection through the interface, the corresponding byte in the Uint8Array updates immediately, triggering updateState() to refresh the GPU texture.

Transform Handler Selection Logic

The SplatsTransformHandler in src/splats-transform-handler.ts respects state flags when allocating transform operations. Only splats marked as State.selected receive palette entries for interactive manipulation:

// src/splats-transform-handler.ts – start()
for (let i = 0; i < state.length; ++i) {
    if (state[i] === State.selected) {
        const oldIdx = indices[i];
        // allocate a new palette index …
        indices[i] = newIdx;
    }
}

This ensures that locked or deleted splats remain immutable during bulk transform operations, while selected splats participate in moves, rotations, and scaling.

Undo/Redo and Edit History

State changes integrate with the edit history system through operation objects. When transformations complete, SplatsTransformOp and PlacePivotOp instances capture the previous and current state values. These operations bundle into a MultiOp and fire via events.fire('edit.add', …), preserving the entire state array, texture references, and transform palette.

On undo, the system restores the previous Uint8Array values and calls updateState() to resynchronize the GPU texture, reverting both internal logic and visual representation simultaneously.

Practical Code Examples

Toggling a Splat's Selection

To programmatically toggle the selected state of a specific splat:

import { State } from './splat-state';
import { Splat } from './splat';

// Assume `splat` is an existing Splat instance
function toggleSelection(splatId: number) {
    const state = splat.splatData.getProp('state') as Uint8Array;
    // Flip the selected flag (bit 1)
    state[splatId] ^= State.selected;
    // Push the change to GPU and refresh UI
    splat.updateState(State.selected);
}

Deleting Selected Splats

To mark all currently selected splats as deleted:

function deleteSelection(splat: Splat) {
    const state = splat.splatData.getProp('state') as Uint8Array;
    for (let i = 0; i < state.length; ++i) {
        if (state[i] & State.selected) {
            state[i] |= State.deleted;   // mark as deleted
        }
    }
    // Re-compute sorting & bounds after deletion
    splat.updateState(State.deleted);
}

Summary

  • Bit-packed storage: Supersplat uses a Uint8Array with bit flags (selected=1, locked=2, deleted=4) defined in src/splat-state.ts for memory-efficient CPU-side state tracking.
  • GPU mirroring: The updateState() method in src/splat.ts synchronizes the CPU array to an R8 texture, enabling shader access to selection and visibility states.
  • Shader-driven rendering: The vertex shader in src/shaders/splat-shader.ts discards deleted splats and passes selection flags to the fragment shader for visual feedback.
  • Selective transformation: src/splats-transform-handler.ts filters operations by checking state[i] === State.selected, ensuring only appropriate splats receive transform operations.
  • History integration: Edit operations preserve state arrays in SplatsTransformOp instances, making undo/redo operations atomic and reversible across the entire application state.

Frequently Asked Questions

What data structure does Supersplat use for splat state tracking?

Supersplat stores splat state in a Uint8Array where each byte represents oneGaussian splat. The byte uses bit flags to track three states simultaneously: bit 0 for selected (value 1), bit 1 for locked (value 2), and bit 2 for deleted (value 4). This compact representation allows the application to store state for millions of splats with minimal memory overhead.

How does the GPU access splat state information?

The GPU accesses state through a dedicated R8 texture created in src/splat.ts. The updateState() method copies the CPU-side Uint8Array into this texture each frame, which shaders then sample using texelFetch(). The vertex shader converts the normalized float value back to integer flags by multiplying by 255.0 and applying a bitmask of 7 (binary 111) to isolate the three relevant bits.

Can locked splats be transformed or deleted?

No. The transform handler in src/splats-transform-handler.ts explicitly checks for state[i] === State.selected before allocating transform palette entries, preventing locked splats from participating in move, rotate, or scale operations. Similarly, UI panels filter out locked splats from selection operations. However, locked splats can still be rendered and viewed unless they are also marked as deleted.

How does Supersplat handle undo/redo for state changes?

State changes are preserved through operation objects like SplatsTransformOp and PlacePivotOp that store both the old and new state values. When a user completes an action, these operations bundle into a MultiOp and push onto the edit history stack. Undo operations restore the previous Uint8Array values and trigger updateState() to resynchronize the GPU texture, ensuring visual and logical consistency.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →