splats.md
docs/splats.md · raw · all examples · site home
Headings: SplatsLayoutsChoosing a layoutRecordsSplats: the CPU sideGpuSplatReader: the shader sideGotchas
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
# Splats
A table stores Gaussian splats in one bundle, in one of three layouts. CPU code reads and writes them through `Splats`, and shaders through `GpuSplatReader` and `GpuSplats`. Both sides use the same bits, so a splat encoded in a worker decodes to the same values in a shader.
Code: `src/core/splats.ts` (`Splats`); `src/gpu/splatAccess.ts` (`GpuSplatReader`) and `splats.tsl.ts` (`GpuSplats`). How each field is encoded: [encoding.md](encoding.md).
```ts
// Ext splats by default, in data.bundles.extSplats
const data = await loadSplats(url);
// The splat bundle, whichever layout was loaded
const { bundle } = Splats.get(data);
// Splat 0, read into a new record
const splat = Splats.makeSplat();
Splats.load(bundle, 0, splat);
const { center, scales, quaternion, rgb, opacity } = splat;
console.log(center, scales, quaternion, rgb, opacity);
```
## Layouts
| Layout and bundle name | Definition | Size | Holds |
|---|---|---|---|
| `extSplats` (the default) | `Splats.EXT`: `extA`, `extB` (`uint32x4` each) | 32 B | a float32 center, opacity, HDR color, half-float log scales, and a rotation; PBR: a normal, roughness, and metallic |
| `covSplats` | `Splats.COV`: `covA`, `covB` | 32 B | like ext, but a covariance instead of scales and rotation; a normal, but no roughness or metallic |
| `packedSplats` | `Splats.PACKED`: `packed` (`uint32x4`) | 16 B | half the memory of ext: 8-bit color and opacity, a half-float center, 8-bit log scales, and an 8 + 8 + 8 bit rotation; no PBR |
A layout's name is also its standard bundle name, so ext splats are in `data.bundles.extSplats`. Lookups try the standard names first, then any bundle with a layout's channels.
Spherical harmonics (bands 1 to 4) are not part of a layout. They sit in an SH bundle beside the splat bundle (`extSh` by default, `packedSh`, or a palette's labels) and work with any layout. `Sh.find(data.bundles)` finds the bundle by its channel names, as `Splats.find` finds the splats ([sh.md](sh.md)).
The words of each layout (A and B are `uint32x4`):
```
ext (32 B)
A.xyz center, float32
A.w normal (Oct24, 0 = none) | opacity byte << 24 (128 = 1.0)
B.x rgb: three 8-bit magnitudes, three sign bits, a shared 5-bit exponent
B.y half(ln sx) | half(ln sy) << 16
B.z half(ln sz) | roughness unorm8 << 16 | metallic unorm8 << 24
B.w rotation, QuatOct32: a 10 + 10 bit octahedral axis
| a 12-bit angle << 20
cov (32 B)
A, B.x as ext
B.y half(ln xx) | half(ln yy) << 16
B.z half(ln zz) | half(c_xy) << 16
B.w half(c_xz) | half(c_yz) << 16 (c = sign x ln|correlation|)
packed (16 B)
x r, g, b over [rgbMin, rgbMax] and opacity, unorm8 each
y half(cx) | half(cy) << 16
z half(cz) | the rotation's 8 + 8 bit axis << 16
w three 8-bit log scales over [lnScaleMin, lnScaleMax] (0 = zero)
| the rotation's 8-bit angle << 24
```
`Splats.LOD_TREE` (`node`: `uint32x2`, the child count and the first child's index) defines a LoD tree with one node per splat, stored next to the splats in the bundle `lodTree`. `Splats.findLodTree(data)` returns it. Files that have a tree load it into that bundle ([files.md](files.md)).
## Choosing a layout
| Layout | Choose it for |
|---|---|
| `extSplats` | the default: full precision, HDR colors, and PBR fields for lighting |
| `covSplats` | data that comes as covariances (merged LoD nodes, some generators), and the `ewa` method, which reads covariances directly |
| `packedSplats` | large files and low-memory devices: half the memory and about 30% faster projection, with colors and scales limited to the encoding's ranges |
```ts
const layout = settings.lowMemory ? "packedSplats" : "extSplats";
const data = await loadSplats(url, { layout });
// Packed splats with room for HDR colors
const hdr = await loadSplats(url, {
layout: "packedSplats",
encoding: { rgbMin: -0.5, rgbMax: 1.5 },
});
```
**Packed encodings.** `PackedEncoding` (`rgbMin`, `rgbMax`, `lnScaleMin`, `lnScaleMax`) sets the ranges of the 8-bit codes. Every field is optional. Missing fields come from the source when it already holds packed splats (an RDX file, or `Splats.convert` from packed splats), and otherwise from `Splats.DEFAULT_PACKED_ENCODING`: colors 0 to 1, ln scales -12 to 9. The bundle keeps its encoding in `meta.encoding`; `Splats.packedEncodingOf(bundle)` returns it with the defaults filled in. Pass ranges when colors go outside 0 to 1 (HDR) or scales outside e^-12 to e^9.
## Records
```ts
// Every layout
interface Splat {
center: Vec3;
scales: Vec3;
quaternion: Quat;
rgb: Rgb;
opacity: number;
}
// Ext only
interface Pbr { normal: Vec3; roughness: number; metallic: number }
type ExtSplat = Splat & Pbr;
interface CovSplat {
center: Vec3;
cov: Cov;
rgb: Rgb;
opacity: number;
normal: Vec3;
}
```
- `Vec3` is `{ x, y, z }`, `Quat` is `{ x, y, z, w }`, `Rgb` is `{ r, g, b }`, and `Cov` is `{ xx, yy, zz, xy, xz, yz }`, so `THREE.Vector3`, `Quaternion`, and `Color` work directly.
- Opacity 1 is opaque; ext and cov store up to 255 / 128 (about 2), for merged LoD nodes.
- A normal of zero means none. Roughness is perceptual, 0 to 1.
- `Splats.makeSplat()`, `makePbr()`, `makeExtSplat()`, and `makeCovSplat()` make records: a unit splat at the origin, white and opaque, roughness 1, metallic 0, no normal.
## Splats: the CPU side
```ts
const splats = data.bundles.extSplats;
// Any layout (cov is diagonalized)
Splats.load(splats, i, splat);
// Any layout; keeps the PBR bits
Splats.store(splats, i, splat);
// Ext only
Splats.loadPbr(splats, i, pbr);
// Ext only; keeps the 3DGS bits
Splats.storePbr(splats, i, pbr);
// Any layout; reads only the center
Splats.loadCenter(splats, i, out);
// @ts-expect-error packed splats have no PBR
Splats.loadPbr(packed.bundles.packedSplats, i, pbr);
```
A bundle's type comes from its definition, so TypeScript accepts a call only when the layout stores that data. Code that works with any layout finds the bundle with `Splats.get`:
```ts
// Throws when there is no splat bundle (Splats.find returns undefined)
const { name, layout, bundle } = Splats.get(data);
Splats.load(bundle, i, splat);
if (Splats.isExt(bundle)) Splats.loadPbr(bundle, i, pbr);
```
For loops over every splat, `Splats.encoder(bundle)` and `Splats.decoder(bundle)` pick the layout's codec and look up its arrays once, before the loop. They return `(i, splat)` functions that write or read every field as an `ExtSplat`. Fields the layout lacks read as no normal, roughness 1, and metallic 0. The file readers and writers use them.
| Member | What it does |
|---|---|
| `EXT`, `COV`, `PACKED`, `LOD_TREE` | the bundle definitions |
| `LAYOUTS` | layout names to definitions |
| `DEFAULT_PACKED_ENCODING` | `{ rgbMin: 0, rgbMax: 1, lnScaleMin: -12, lnScaleMax: 9 }` |
| `makeSplat()`, `makePbr()`, `makeExtSplat()`, `makeCovSplat()` | new records |
| `isExt(bundle)`, `isCov(bundle)`, `isPacked(bundle)` | type guards by channel names and formats |
| `layoutOf(bundle)` | the layout of a bundle (`undefined` when it is none) |
| `findBundle(bundles, name?)` | `{ name, layout }` of the splat bundle among any bundles (of an `ArrayData` or a `GpuArrays`): `name` when given, else the standard names, else any bundle of a layout |
| `find(data, name?)`, `get(data, name?)` | `{ name, layout, bundle }` (`FoundSplats`); `get` throws when there is none |
| `findLodTree(data)` | the `lodTree` bundle, or `undefined` |
| `add(data, { layout?, encoding? })` | adds an empty splat bundle named after its layout (default `extSplats`) |
| `make(count, { layout?, encoding?, meta? })` | a new `SplatData<L>` of `count` empty splats |
| `convert(data, { layout, encoding? })` | the data in another layout: `data` itself when it already is, else a new table that shares the arrays of every other bundle |
| `load` / `store`, `loadPbr` / `storePbr`, `loadCenter` | one splat's fields, as above |
| `encoder(bundle)`, `decoder(bundle)` | per-splat functions for loops |
| `encodeExt` / `decodeExt`, `encodeCov` / `decodeCov`, `encodePacked` / `decodePacked` | every word of one splat of one layout, with the same bits (`ExtSplat`, `CovSplat`, `Splat`) |
| `toCov(splat, out)`, `fromCov(cov, out)` | scales plus rotation to a covariance, and back (an eigendecomposition) |
| `packedEncodingOf(bundle)` | a packed bundle's encoding, defaults filled in |
Types: `Splat`, `Pbr`, `ExtSplat`, `CovSplat`, `ExtSplats`, `CovSplats`, `PackedSplats` (typed bundles), `SplatBundle` (any of them), `SplatBundles`, `SplatLayout`, `SplatData<L>` (an `ArrayData` with layout `L`'s bundle), `SplatTarget` (`{ layout?, encoding? }`), `SplatReadOptions` (a target plus `progress`), `FoundSplats`, `BundleLike` (anything with named channels of formats), and `PackedEncoding`.
## GpuSplatReader: the shader side
```ts
// A GpuExtSplatReader on arrays.bundles.extSplats
const reader = GpuSplatReader.for("extSplats").bind(arrays);
// SplatNode: a TSL struct with the record's fields, plus index
const splat = reader.loadSplat(index);
// CovSplatNode: converted here; read directly from cov splats
const cov = reader.loadCovSplat(index);
// PbrNode (ext only)
const pbr = reader.loadPbr(index);
// A vec3; reads extA only
const center = reader.loadCenter(index);
// A bound reader for whichever layout the arrays hold
const any = GpuSplatReader.from(arrays);
if (any.layout === "extSplats") any.loadPbr(index);
```
- There are three readers: `GpuExtSplatReader`, `GpuCovSplatReader`, and `GpuPackedSplatReader` (which reads its encoding from the bundle's meta when bound). `AnyGpuSplatReader` is the union.
- Every reader has `loadSplat`, `loadCovSplat` (each converts from the form the layout stores), `loadCenter`, `loadRgb`, and `loadOpacity`; ext adds `loadPbr`. Each load fetches only the channels it needs, and reads zero past `count`.
- `bind(arrays, name?)` binds the bundle named after the reader's layout, or else any bundle of that layout; pass `name` for another bundle. Bind again after the arrays' count changes, or after they are resized or swapped.
- Splat structs carry the `index` they were loaded from, so effects can key per-splat randomness on it.
- To draw stored splats, wrap a reader in a generator with `SplatGenerator.fromSplats({ layout })`, or use `SplatCollection.arrays(arrays)`, which does the wrapping ([generators.md](generators.md)).
`GpuSplats` holds the struct constructors and the TSL codecs. This writer stores splats from a shader, baking a generator into arrays:
```ts
const writer = new GpuArrayWriter({
outputs: { extSplats: Splats.EXT },
write: (i, out) => {
const splat = generator.generateSplat(i);
const pbr = GpuSplats.Pbr(normal, roughness, metallic);
GpuSplats.storeExt(out.extSplats, splat, pbr);
},
});
arrays.write(writer, { at: 0, count });
```
| `GpuSplats` member | What it does |
|---|---|
| `Splat(center, scales, quaternion, rgb, opacity, index?)` | a `SplatNode` (fields left out are zero) |
| `CovSplat(center, xxyyzz, xyxzyz, rgb, opacity, normal, index?)` | a `CovSplatNode`: the covariance's diagonal and off-diagonal as two `vec3`s |
| `Pbr(normal, roughness, metallic)` | a `PbrNode` |
| `decodeExt(a, b)`, `decodeExtPbr(a, b)`, `encodeExt(splat, pbr)` | ext words (two `uvec4`) |
| `decodeCov(a, b)`, `encodeCov(cov)` | cov words |
| `decodePacked(p, encoding?)`, `encodePacked(splat, encoding?)` | packed words; `encoding` is a `PackedEncoding` or a TSL `vec4` |
| `storeExt(out, splat, pbr?)`, `storeCov(out, cov)`, `storePacked(out, splat, encoding?)` | fill a writer's outputs for that layout |
| `toCov(splat)`, `fromCov(cov)` | convert between the forms |
| `splatOf(generator, i)`, `covSplatOf(generator, i)` | a generator's splat in the asked form, converted when the generator lacks it |
| `withIndex(node, index)` | a copy of a splat struct with its `index` set |
| `normalOf(splat)` | the unit axis of the smallest scale, rotated: a flat splat's normal |
Types: `SplatNode`, `CovSplatNode`, and `PbrNode` (`StructNode<Fields, Brand>`): their `get()` accepts only their own field names, and the type checker rejects one kind of node where another is expected. More on writing TSL: [shaders.md](shaders.md).
## Gotchas
- `Splats.convert` returns the same object when no conversion is needed; do not assume a copy.
- Packed splats clamp colors and scales to their encoding's ranges. Check `encoding` for HDR colors or very small or large splats.
- GPU and CPU encodings give the same bits, except where float32 rounding on the GPU lands on the other side of a step from the CPU's float64: under 2% of ext and packed splats and about 7% of cov splats (correlations near 1) come out one code step off ([encoding.md](encoding.md#cpu-and-gpu)).