Skip to content

Gaussian Splats Beta

A splat capture is a scanned object or place, stored as hundreds of thousands of tiny coloured blobs rather than as a mesh. Place one in a scene like any other object. From a script you can reach the capture's per-splat data and drive it with a compute kernel, which is how reveals, dissolves and interactive effects are built.

Reaching a capture

representation.splat is the handle. It is null on any representation that is not a splat, and on a device that cannot render them, so a single check covers both:

javascript
// Attach to: On Experience Start
var entity = scene.findEntity({ name: 'Statue' });
await entity.waitUntilReady();

var splat = entity.representation.splat;
if (!splat) {
    console.log('No splat capture here');
    return;
}
console.log(splat.count + ' splats');

While a capture is still loading the handle exists but reports count === 0, so wait for the representation before reading anything from it.

What each splat carries

Five attributes, each a buffer you can bind to a kernel:

AttributePer splatNotes
position3 floatsCentre of the blob, in the capture's own space
scale3 floatsSize per axis, stored logarithmically
rotation4 floatsOrientation, stored w first
opacity1 floatStored pre-sigmoid, so 0 is half opaque
sphericalHarmonicsitemSize floatsView-dependent colour

Two of those storage formats matter as soon as you write a kernel:

  • Scale is a logarithm. Multiplying a splat's size means adding to its scale, so half size is scale - log(2), not scale * 0.5.
  • Opacity is a logit. Fade the alpha rather than the stored value, or every splat sits near half opaque for most of the ramp and then pops at the end.

splat.boundingBox gives the capture's extent, which is what radial and staggered effects key off. Use it rather than the representation's bounding box, which measures an internal proxy.

Animating a capture

Each attribute vends .read and .write tokens for a kernel's buffer maps, exactly as a texture does. A .write buffer arrives uninitialised, so an effect that reads the live buffer and adds to it drifts further from the original every frame and never lands back on the authored look. Copy the pristine attributes into your own buffers once, then compute every frame from that snapshot, and progress 1 writes the originals back exactly.

Note the types in the snapshot kernel: a float3 declaration aligns to 16 bytes and reads garbage against the 12-byte stride, so position and scale are packed_float3. The bounds guard matters too, since a thread past the end writes into memory the renderer is using.

javascript
// Attach to: On Experience Start
var threads = 64;
var groups = Math.ceil(splat.count / threads);

var authoredPos = Buffer.float32(splat.count * 3, 0);
var authoredScale = Buffer.float32(splat.count * 3, 0);
var authoredOpacity = Buffer.float32(splat.count, 0);

var snapshot = await Kernel.fromSource({
    source: `
        #include <metal_stdlib>
        using namespace metal;
        struct Uniforms { float count; };

        kernel void splatSnapshot(constant Uniforms& U                  [[buffer(0)]],
                                  device const packed_float3* srcPos    [[buffer(1)]],
                                  device const packed_float3* srcScale  [[buffer(2)]],
                                  device const float* srcOpacity        [[buffer(3)]],
                                  device packed_float3* dstPos          [[buffer(4)]],
                                  device packed_float3* dstScale        [[buffer(5)]],
                                  device float* dstOpacity              [[buffer(6)]],
                                  uint i [[thread_position_in_grid]]) {
            if (i >= (uint)U.count) { return; }
            dstPos[i] = srcPos[i];
            dstScale[i] = srcScale[i];
            dstOpacity[i] = srcOpacity[i];
        }
    `,
    functionName: 'splatSnapshot'
});

snapshot.run({
    uniforms: new Float32Array([splat.count]),
    inputBuffers: {
        srcPos: splat.position.read,
        srcScale: splat.scale.read,
        srcOpacity: splat.opacity.read
    },
    outputBuffers: {
        dstPos: authoredPos,
        dstScale: authoredScale,
        dstOpacity: authoredOpacity
    },
    threadGroups: [groups, 1, 1],
    threadsPerThreadgroup: [threads, 1, 1]
});

Your effect kernel then reads those buffers and writes splat.position.write and friends each frame. Fading uses the alpha rather than the stored value:

// Inside the effect kernel, with `progress` from a uniform
float origAlpha = 1.0 / (1.0 + exp(-origOpacity[i]));
float alpha = clamp(origAlpha * progress, 1e-6, 1.0 - 1e-6);
outOpacity[i] = log(alpha / (1.0 - alpha));

Written values stay put. Once a dispatch lands, the capture keeps those values with no further dispatches, so a one-shot reveal can stop when it finishes.

Known limitations

  • Displacement is clipped. The volume the renderer culls against is fixed when the capture loads and does not follow splats a kernel moves, so an effect that pushes splats outward meets a hard edge. Gather, swirl and shrink read well; explosions do not.
  • A scene holds up to eight captures. Past that they stop rendering, so budget them like video rather than like props.
  • Captures are placed in the editor, not from a script. There is no script-side factory for one yet, so find a placed capture with scene.findEntity(...) and drive it from there.
  • Colour is the expensive attribute. Changing it means rewriting every spherical-harmonic coefficient for every splat, because a .write buffer arrives uninitialised. Position, scale and opacity are far cheaper to animate.

Splat captures need iOS 27, iPadOS 27, macOS 27 or visionOS 27. Gate a portable script on the handle itself, since representation.splat is null wherever they are unavailable.