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.
- license
- Apache-2.0
- branch
- main
- tests
- 1 file
- source
- 1.8 MB
- commit date
- 2026-11-09
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-shThe 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 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
bytes, where is the number of entries and the number
of splats after LOD.
Put the post's scene in. = 60,000,000 and = 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:
- Seed. The entries start as the SH of evenly spaced splats, taken
after LOD and in the converter's spatial order. is
min(splats, 262144). - 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.
- 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.
- 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.
- 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. - 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 , 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 16,384;
- entries and per-splat coefficients as
s8with the 1/128 step, labels asu16oru32, deflated per 65,536-splat column exactly asencodeChunkdoes; - 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 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:
| 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.