~/satyajit

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

mdjsonmcp

2026-11-09 · 15 min · 3d · gaussian-splatting · quantization · webgpu

Why read this

Notabletop 60%

Reads the RAD converter, re-implements its SH k-means on a real 1.07M-splat scene and measures the size-vs-error trade, including when it loses.

  • Runs on a laptop CPU
  • Original analysis
  • Concrete numbers to act on

3D & spatialApache-2.0Practitioner tool

How this was scored
Is it new?
1 of 3: An incremental tweak
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
3 of 3: Open, permissive, runs on reader hardware with instructions
Will I understand it?
2 of 3: Mechanism from first principles with figures
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
1 of 3: Relevant for months
Does it affect many?
1 of 3: A specialist community
Only here?
2 of 3: A teardown or measurement few others did

Score 62 of 100, ranked 368 of 701 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

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.

WilliamLiu-1997/Gaussian-Splat-Lite@435be9b · snapshot 2026-11-10
tracked files
358
license
Apache-2.0
branch
main
tests
1 file
source
1.8 MB
commit date
2026-11-09
source by language
JavaScript1.2 MB(157)Rust392.9 kB(22)TypeScript177.3 kB(125)GLSL36.9 kB(4)CSS26.5 kB(1)HTML11.2 kB(1)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-11-10 at 435be9b — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

What Gaussian-Splat-Lite is

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 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.

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:

propertyencodingbytes
centerf32_lebytes, three float32 in byte planes12
alphar8, opacity over 0 to 21
rgbr8_delta, base colour minus the previous splat's3
scalesln_0r8, log scale over a percentile range3
orientationoct88r8, octahedral axis plus angle3
child_count, child_startu16 + u32, the LOD tree6
sh1, sh2, sh3s8, step 1/128, clipped to ±127/1289 + 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 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:

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/N28 + 4 + 45K/N bytes, where KK is the number of entries and NN the number of splats after LOD.

Put the post's scene in. NN = 60,000,000 and KK = 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 KK entries start as the SH of evenly spaced splats, taken after LOD and in the converter's spatial order. KK 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 ∥x∥2+∥c∥2−2x⋅c\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 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 scene is the Tanks and Temples train scene trained by the reference 3DGS code to 30,000 iterations, from the 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.

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:

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 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 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 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:

SOGRAD via gsl-build-lod
max entries65,536262,144
label16-bit, in a WebP imageu16 or u32, deflated
label on disk1.61 B/splat (bicycle, measured there)1.66 B (u16), 2.05 B (u32) on train, measured here
entry values8-bit indices into a 256-value codebooks8, a fixed 1/128 step
splat orderMorton, for WebP's predictorsthe converter's spatial order
containerzip of WebP images + meta.jsonJSON 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

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.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Gaussian-Splat-Lite's SH codebook: where a 55.6% smaller RAD file comes from", ai.satyajit.si, November 2026.

bibtex
@misc{ghana2026gaussiansplatlite,
  author = {Satyajit Ghana},
  title  = {Gaussian-Splat-Lite's SH codebook: where a 55.6% smaller RAD file comes from},
  url    = {https://ai.satyajit.si/articles/gaussian-splat-lite},
  year   = {2026}
}
share