# Gaussian-Splat-Lite's SH codebook: where a 55.6% smaller RAD file comes from

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.satyajit.si/articles/gaussian-splat-lite
> date: 2026-11-09
> tags: 3d, gaussian-splatting, quantization, webgpu

William Liu posted this on 2 November:

> SH codebooks are now supported in the Node.js 3DGS → RAD converter in
> Gaussian-Splat-Lite, my #Threejs/WebGPU 3DGS renderer! On a 60M-splat scene:
> 55.6% smaller files. Full conversion, including SH clustering: 11m vs Spark's
> 11h 20m (~58× faster)

Two claims, then: a file that is a bit more than half gone, and a conversion
that is about sixty times quicker than the converter it descends from. Neither
comes with the scene, so I cannot reproduce either number as stated. What I can
do is read the converter, work out where a number like 55.6% has to come from,
and measure the same mechanism on a real scene I can download. That is what
this article is.

The short version: the 55.6% falls straight out of the byte layout. A splat in
a RAD chunk costs 73 bytes before compression with its spherical harmonics
stored per splat, and 32 with a codebook label, which is 55.9% smaller
(reasoned, from the code). On a real 1,074,761-splat scene, my re-implementation
cuts the SH from 29.63 to 9.48 bytes a splat and the file by 42.7%, at a
weighted colour error of 2.43 eight-bit levels (measured). On a 53,671-splat
scene the same switch makes the file 5.3% *larger* (measured), which the docs
warn about and the arithmetic explains.

<RepoCard repo="WilliamLiu-1997/Gaussian-Splat-Lite" />

## What Gaussian-Splat-Lite is

[Gaussian-Splat-Lite](https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite)
is a three.js renderer for 3D Gaussian Splatting that runs on WebGPU or falls
back to WebGL2, sorts on the GPU, and streams levels of detail for RAD, SOG and
LCC2 scenes (reported, its README). It is Apache-2.0, published on npm as
`gaussian-splat-lite` (version 1.7.0, released 6 November), and its `NOTICE`
credits code derived from World Labs' [Spark](https://github.com/sparkjsdev/spark)
and PlayCanvas's SuperSplat, both MIT (read, the repo at commit `435be9b`).

The piece this article is about is not the renderer but the command-line tool
it ships, `gsl-build-lod`. It takes a `.ply` or `.spz` splat file and writes a
`.rad` file: Spark's level-of-detail format, a tree of splats where each
interior node stands in for its children at a distance, cut into chunks a
viewer can fetch with HTTP Range requests as the camera moves.

```sh
npx --package gaussian-splat-lite gsl-build-lod scene.ply          # SH codebook, 10 passes
npx --package gaussian-splat-lite gsl-build-lod scene.ply --no-cluster-sh
```

The converter is about 6,000 lines of plain JavaScript under `bin/rad/` plus
two prebuilt WebAssembly modules: a decoder for the input formats and
`cluster.wasm`, the nearest-entry kernel the codebook uses (counted, the
1.7.0 npm tarball). No native build is needed, which is the point of a
Node.js converter.

## What one splat costs in a RAD file

A RAD file is a JSON header followed by chunks of 65,536 splats. Inside a
chunk, every property is laid out column by column (all the first bytes, then
all the second bytes) and compressed on its own with raw deflate at level 6
(read, `bin/rad/rad.js`). The columns, and what each costs a splat before
deflate:

| property | encoding | bytes |
|---|---|---|
| `center` | `f32_lebytes`, three float32 in byte planes | 12 |
| `alpha` | `r8`, opacity over 0 to 2 | 1 |
| `rgb` | `r8_delta`, base colour minus the previous splat's | 3 |
| `scales` | `ln_0r8`, log scale over a percentile range | 3 |
| `orientation` | `oct88r8`, octahedral axis plus angle | 3 |
| `child_count`, `child_start` | `u16` + `u32`, the LOD tree | 6 |
| `sh1`, `sh2`, `sh3` | `s8`, step 1/128, clipped to ±127/128 | 9 + 15 + 21 |

That is 28 bytes of geometry and base colour and 45 bytes of view-dependent
colour: the spherical-harmonic (SH) coefficients of degrees 1 to 3, which let a
splat change colour with the viewing angle. The SH are 62% of the raw splat
(reasoned, 45 of 73). Any format that wants to shrink a splat scene has to go
after them first; the [SOG article](/articles/sog-splat-format) found the same
thing in a float PLY, where they are 180 of 248 bytes.

The codebook replaces those 45 bytes with a label. Toggle it below and pick a
scene size:

<RadRecord />

With a codebook, a splat stores `sh_label`: a `u16` when the codebook has
65,536 entries or fewer, a `u32` above that. The table itself, one row of 45
signed bytes per entry, rides in the first chunk and is shared by the whole
file (read, `encodeChunk` and `encodeCodes`). So a splat costs
$28 + 4 + 45K/N$ bytes, where $K$ is the number of entries and $N$ the number
of splats after LOD.

Put the post's scene in. $N$ = 60,000,000 and $K$ = 262,144 make the table
0.20 bytes a splat, and the splat 32.20 bytes against 73: **55.9% smaller
before deflate** (reasoned). The post reports 55.6% for the finished file.
Those agree to within the slack you would expect from deflate treating the two
layouts a little differently and from a LOD tree whose interior nodes I am not
counting separately. The headline number is the layout, not a surprise.

## How the codebook is built

The clustering is in `bin/rad/cluster.js`, about 800 lines, and its header
comment says what it is: weighted k-means "as Spark does". Reading it, the
procedure is:

1. **Seed.** The $K$ entries start as the SH of evenly spaced splats, taken
   after LOD and in the converter's spatial order. $K$ is
   `min(splats, 262144)`.
2. **Weigh.** Each splat counts by area times opacity, the same weight the LOD
   merge uses. A large opaque splat pulls its entry harder than a speck.
3. **Train on a sample.** Training reads 10% of the splats, or 64 per entry,
   whichever is more, drawn in proportion to weight. Below that many splats it
   trains on all of them. Only the final assignment reads every splat.
4. **Search a few groups, not every entry.** With at least 16,384 entries, the
   entries are themselves clustered into 512 groups. A splat compares itself
   only against the entries of its 4 nearest groups while training and its 16
   nearest in the final pass.
5. **Refine** for 10 passes by default (`--cluster-sh=<n>` changes it). Each
   pass moves every entry to the weighted mean of its splats; an entry nobody
   picked stays where it was.
6. **Store** each entry as 45 signed bytes and each splat's nearest entry as
   its label.

Points are taken relative to the mean of the seeds before the distance
expansion $\lVert x\rVert^2 + \lVert c\rVert^2 - 2x\cdot c$, so the float32
arithmetic rounds with the spread of the coefficients rather than their offset
from zero; the inner loop is a WebAssembly kernel over blocks of 8 entries,
spread over as many worker threads as `--memory-budget` leaves room for (read).

The comments carry the author's own measurements, which I could not rerun:
training on 10% instead of 5% brought renders of two test scenes "5–14%
closer" to uncompressed SH, a 111-million-splat scene clustered in "about
185 s", and the grouped search "gave 99% of splats their nearest entry"
(reported, `cluster.js`).

## Where a 58x speed-up can come from

The post compares a full conversion against Spark's at 11 minutes to 11 hours
20 minutes. I have not run Spark's converter, and cannot see the machine or
settings either was timed on, so that number stays reported. Note that
11 hours 20 against 11 minutes is closer to 62x than the ~58× in the post;
the 11 minutes is presumably rounded.

What the code does show is three choices that each cut the work by a large
factor, which is how you get an order of magnitude or two without a cleverer
algorithm (reasoned):

- **The sample.** On a 60M-splat scene, training reads
  `max(262144 × 64, 10%)` = 16.8M splats per pass, about 28% of them, and only
  the last pass reads all 60M.
- **The groups.** Comparing a splat against 4 of 512 groups touches about
  1/128 of the entries if the groups were even. They are not: in my
  re-implementation on the train scene, the largest of the 512 groups held
  16,024 of 262,144 entries and the median 422 (measured), so a splat that
  lands near the big group pays for it.
- **The kernel.** WebAssembly over blocked float32 on every core, rather than
  a scalar loop.

The algorithm is the same weighted k-means; the search is not exhaustive. That
trade is the usual one in vector quantization, and it is also why the result
is an approximation of k-means rather than k-means: a splat whose true nearest
entry sits in its fifth-nearest group gets the second-best one.

## What I ran

I wanted to run `gsl-build-lod` itself on a public scene with and without the
codebook. I installed version 1.7.0 from npm (the tarball ships the prebuilt
`cluster.wasm`, so no Rust was needed), but the sandbox's permission layer
refused to execute the downloaded converter. So I re-implemented the parts
that decide size and SH error, in about 200 lines of numpy, from the code
above:

- the SH stored as half floats, as the converter keeps them;
- weights as area times opacity, with the converter's area formula;
- seeds at evenly spaced splats after a Morton sort (my stand-in for the
  converter's spatial order), 10 weighted refinement passes, 512 groups with 4
  probes while training and 16 in the final pass for $K \ge$ 16,384;
- entries and per-splat coefficients as `s8` with the 1/128 step, labels as
  `u16` or `u32`, deflated per 65,536-splat column exactly as `encodeChunk`
  does;
- the other columns encoded the same way for leaf splats only: I did not build
  the LOD tree, so there are no interior splats and the tree columns are
  zeros.

The scene is the Tanks and Temples *train* scene trained by the reference 3DGS
code to 30,000 iterations, from the
[Voxel51/gaussian_splatting](https://huggingface.co/datasets/Voxel51/gaussian_splatting)
dataset: a 266,542,260-byte PLY with **1,074,761** splats and full degree-3 SH.
The error metric evaluates SH degrees 1 to 3 for 200,000 random splats at 4
random view directions each, and reports the largest per-channel change in
eight-bit levels. It is per-splat colour, not a rendered image: overlapping
splats blend, so on screen an error is diluted by its neighbours.

Two baselines bracket the result. Per-splat `s8` coefficients, what
`--no-cluster-sh` writes, cost 29.63 bytes a splat after deflate, from 45 raw,
with a mean error of 0.77 levels. Dropping the SH entirely (`--max-sh 0`)
costs nothing and leaves a mean error of 16.57 levels, with 37.0% of samples
off by more than 16 levels (all measured). This scene's view-dependent colour
is real; throwing it away shows.

<CodebookSweep />

At the converter's cap of 262,144 entries, the SH fall from 29.63 to 9.48
bytes a splat (7,990,544 bytes of table, 2,200,040 of labels) and the mean
error rises from 0.77 to 5.45 levels. Weighted by area times opacity, the
error is 2.43 levels, and 1.2% of weighted samples are off by more than 16
(all measured). The two error columns differ by more than a factor of two for
the reason you would hope: k-means spends its entries on the splats it
weighs most, so large opaque splats get good entries and tiny faint ones get
whatever is nearby. Faint specks are also the splats you see least.

For the whole leaf-only file, 50,722,776 bytes become 29,064,528: **42.7%
smaller** (measured). Smaller codebooks keep shrinking it: at 65,536 entries
the SH take 3.55 bytes a splat and the file is 55.3% smaller, with the
weighted error at 4.12 levels.

Two details in those numbers are worth having. The `u32` label for 262,144
entries costs 2.05 bytes a splat after deflate, against 4 raw, because only
18 of its 32 bits ever change; the `u16` label at 65,536 entries costs 1.66
(measured). And a table entry deflates only from 45 bytes to about 30.5, since
cluster centres are, by construction, unlike one another (measured, 30.48 and
30.99 bytes per entry at the two sizes).

### Why my 42.7% is not the post's 55.6%

At 1,074,761 splats and 262,144 entries, the table is still 7.43 bytes a
splat, 78% of the codebook's cost (reasoned, from the measured bytes). At
60M splats it would be 0.13. The saving grows with the scene because the table
does not:

<ScaleCurve />

Feed my measured deflate rates into the 60M case and the model says 58.2%
smaller, against the 55.9% the raw layout gives and the 55.6% reported
(reasoned). I would not lean on the second decimal of any of these: the
"other" columns of a real file include interior LOD splats and a different
spatial order than my Morton sort.

The flip side is the honest one, and the
[docs](https://github.com/WilliamLiu-1997/Gaussian-Splat-Lite/blob/main/docs/BuildRad.md)
say it plainly: a model that "fit[s] entirely in the codebook" stores one entry
per splat plus a label, "so the file may grow". I measured it on a
53,671-splat plush scene from the
[solaaaa/sample-gaussian-splats](https://huggingface.co/datasets/solaaaa/sample-gaussian-splats)
dataset: the SH grow from 892,672 to 989,760 bytes and the file by 5.3%
(measured). Below 262,144 splats the codebook is a per-splat copy with an
index stapled on. If your scenes are that small, pass `--no-cluster-sh`, or
keep the default only for the streaming path below.

### What the error means, and what it does not

My per-splat number overstates what you would see in a render, since splats
blend, and it understates what a much larger scene would get from the same
cap: on the train scene there are about 4 splats per entry; at 60M there would
be about 229 (reasoned). More splats sharing an entry is exactly where the
size comes from, and it is also where the error comes from. The author's own
render tests in `cluster.js` are the better guide there, and the defaults are
tuned on them (reported). If you have a scene with strong reflections, convert
it both ways and look; the docs say highlights "can lose some detail".

## The other half: memory at run time

The file is not the only place SH live. Version 1.7.0 of the renderer keeps
one shared codebook per RAD or SOG model on the GPU instead of expanding it
into per-splat coefficients, and the changelog reports that this cuts "CPU and
GPU memory used by Splat attributes" by up to 25%, 43.75% and 62.5% for SH1,
SH2 and SH3 (reported, `CHANGELOG.md`). For a streamed scene that is the more
useful half: a fixed memory budget holds more splats, so more detail loads
before the scheduler stops. I did not measure this; I have no GPU in the
sandbox.

## Against SOG, concretely

The [SOG article](/articles/sog-splat-format) took apart PlayCanvas's format on
a 5,000,000-splat bicycle scene. SOG did the same thing to the SH first: a
palette of up to 65,536 centroids and a 16-bit label per splat, stored as
lossless WebP images. Set the two side by side:

| | SOG | RAD via gsl-build-lod |
|---|---|---|
| max entries | 65,536 | 262,144 |
| label | 16-bit, in a WebP image | `u16` or `u32`, deflated |
| label on disk | 1.61 B/splat (bicycle, measured there) | 1.66 B (`u16`), 2.05 B (`u32`) on train, measured here |
| entry values | 8-bit indices into a 256-value codebook | `s8`, a fixed 1/128 step |
| splat order | Morton, for WebP's predictors | the converter's spatial order |
| container | zip of WebP images + `meta.json` | JSON header + deflated chunks, LOD tree, Range streaming |

The labels cost almost the same on disk in both, 1.61 and 1.66 bytes a splat,
which is what you would expect: a palette index carries real per-splat
entropy, and neither WebP nor deflate can predict it away. The differences are
elsewhere. RAD allows four times the entries, and on the train scene going from
65,536 to 262,144 entries took the weighted error from 4.12 to 2.43 levels for
5.93 more bytes a splat (measured); at 60M splats that extra would be about
0.5 bytes, mostly the wider label (reasoned). SOG's centroid values go through a
learned 256-value codebook, which can put its levels where the coefficients
actually are; RAD's fixed 1/128 grid is simpler and wastes levels on the
tails. And RAD keeps 12 lossless bytes of float32 position plus a LOD tree,
which is why a RAD file is not trying to be the smallest file. It is trying to
be the file a viewer can stream a 60M-splat scene from.

So the codebook does not make RAD a SOG. It takes the one idea that did most of
SOG's work on the SH and applies it to a format built for streaming large
scenes, with a bigger palette because the scenes are bigger.

## What this costs, and when to use it

- **Use the default on large scenes.** Above a few million splats after LOD,
  the table is noise and the file roughly halves; the measured slope on the
  train scene says the saving keeps climbing past 262,144 entries.
- **Turn it off on small ones.** Below 262,144 splats it makes the file
  bigger; at the 53,671-splat plush, by 5.3%.
- **Look at shiny scenes both ways.** Every splat gets its nearest entry's
  view-dependent colour, and positions, shapes, base colours and LOD are
  unchanged (reported, the docs). The error lands on reflections and
  highlights, and on the small faint splats first.
- **Budget memory and time.** Clustering runs inside `--memory-budget`
  (default 4 GiB) and uses as many cores as that leaves room for. My numpy
  stand-in took 443 s for 262,144 entries on 2 threads; that says nothing
  about the converter's own speed, only that the method is tractable even
  done badly.

What I did not do: run the converter itself (refused by the sandbox), build a
LOD tree, render anything, or run Spark. Every size and error above comes from
my re-implementation of the parts of `gsl-build-lod` that decide them, on two
public scenes, and every claim about the converter's internals comes from
reading `bin/rad/` at commit `435be9b`.
