splat2glb
v0.1.0
Published
Convert a phone Gaussian-splat scan into a low-poly, flat-shaded GLB small enough to ship on a web page. No dependencies.
Downloads
25
Maintainers
Readme
splat2glb
A phone Gaussian-splat scan goes in. A low-poly, flat-shaded GLB comes out — a few hundred to a few thousand triangles, in flat colour groups, small enough to ship on a web page. No dependencies, in Node or in a browser.
| off the phone | hand-cleaned | converted |
|---|---|---|
|
|
|
|
| 30,555 splats, 663KB .spz | 28,057 splats | 3,000 triangles, 30KB GLB |
Same object, same camera, same lights.
The middle panel is a manual step, and it is not optional. A phone scanner leaves low-confidence haze in the air around its subject — visible in the left frame as the drift below the pig — and haze bracketed as geometry is a model wearing a fur coat. Erasing it by hand took out 8% of the splats and 17mm of the bounding box: 219mm on the longest side before, 202mm after, against a pig that really is 200mm. Almost everything removed was air. This tool converts; it does not clean. Use SuperSplat for that.
And cleaning does not fix everything, which --check will tell you before you waste a conversion on it. This capture has a genuine hollow in the pig's back where the orbit was thin, and it survives the clean: 43mm of relief before, 39mm after, both flagged. Erasing cannot add geometry the capture never saw. The only fix is to reshoot that arc.
The numbers
A rubber pig off a shelf, scanned with a phone in about a minute.
| | | what moved it |
|---|---|---|
| Input | 30,555 splats, 663KB .spz | Scaniverse, handheld, one orbit |
| Output | 3,000 triangles, 30KB | the budget the gallery ships at is 8–20KB |
| Surface fit | 2.1mm on a 20cm object | outlines made of measured points rather than dilated raster rims, from 2.6mm |
| Bounding box | 3–6mm short | same change, from 13–15mm short |
| Worst dent | 13.1mm | a second hand-clean of the cloud, from 40.5mm |
| p90 error | 5.0mm | same, from 7.4mm |
| Conversion | 0.6s | single-threaded, on a laptop |
Be honest about what that last column of accuracy means: a phone splat of a small object puts its surface somewhere inside a band several millimetres thick, and everything below that band is invention. The model comes out soft. That is the capture, not the budget, and no triangle count invents an edge the scan never saw.
On a synthetic scan, where the answer is known in advance, npm test reports what matters more than any of the above:
ok clean: valid GLB, 2 primitive(s) 76.7 x 114.1 x 179.9 mm
ok fuzzy: valid GLB, 2 primitive(s) 81.5 x 118.7 x 183.4 mm
ok 4mm of fuzz moves the box less than 10mm drift 4.8 / 4.6 / 3.5 mm4mm of fuzz displaced along the surface normal is roughly what a phone capture of a small object looks like. It moves the reconstructed bounding box by under 5mm. If it moved it by much more, the pipeline would be reading the noise instead of the shape, and no amount of looking at a rendered pig would tell you.
Why this exists
Surveyed 2026-08-18, and the honest answer to "hasn't someone done this" is "partly, and none of it fits".
The best reconstructors — SuGaR, 2DGS, Gaussian Opacity Fields — produce far better geometry than this does, and are unusable here for a reason that has nothing to do with their licences: they are training-time methods. They want the original photographs and the camera poses, and they recover the surface as part of fitting the scene. A finished .spz off a phone is the wrong input to them, and for most people holding a scan it is the only input they have.
The tools that do take a standalone splat file agree on an approach, and it is the one bracket() argues against: build a density field, threshold it, march cubes over it, accept the blob. Polyvia3D does that free in a browser and says plainly that the result is soft. 3DGS-to-PC does it at the command line and its own authors call the meshing "quite naive, which can lead to noisy results". Open3D or MeshLab with screened Poisson gets there too, given surface normals you have to estimate yourself first.
splat-transform is worth naming because it looks like this tool and is not: its GLB is splat data in a KHR_gaussian_splatting container, and it reconstructs no surface at all.
What none of them produce is a few hundred to a few thousand triangles, flat-shaded, in flat colour groups, at 8–30KB. They hand back either a dense marching-cubes surface or a splat file, and the distance from there to a model that can stand next to sixty low-poly props is a decimation stage, a colour-clustering stage and a writer. That distance is what this is.
Quick start
Needs Node 20+. Nothing to install.
git clone https://github.com/jaycer/splat2glb && cd splat2glb
npm test # builds a synthetic scan and converts it, no scan of your own requiredThen, on a real capture — after cleaning the haze off it, which is a separate job and a manual one:
node splat2glb.mjs --in ~/Downloads/scan.spz --out model.glb --up +x --tris 800Reads a binary PLY or an SPZ, and they are interchangeable. Prefer the SPZ: exported both ways, the same Scaniverse capture came back as the same 30,555 points in the same order, positions identical to the bit once a Y/Z flip and a 0.13mm recentring are undone — and the SPZ was 11.4x smaller, 663,845 bytes against 7,579,170. The PLY is that file decompressed, with its float32 precision as decoration. An SPZ is decoded into the PLY's own frame on the way in, so --up means the same thing whichever file a scanner handed over.
Grade a capture before converting it, which is free and takes a second:
node splat2glb.mjs --in scan.spz --checkThe route
splats -> cull -> density grid -> bracket into a solid -> surface nets
-> Taubin smoothing -> quadric decimation -> colour -> GLBThe arguments
The headers in splat2glb.mjs and lib/build.js carry these in full, next to the code that acts on them. The short versions:
A splat cloud is a hollow shell with pinholes all over it. Columns through one read ...###......####..., so the obvious move — threshold it, flood air in from outside, call the rest inside — gets air into the middle through the first hole it finds and hands back a crumpled bag. No closing radius fixes it: measured, the holes run from one cell to eight, and sealing the big ones swells the model by more than the model is worth. Reading each ray's entry and exit off accumulated density instead does not care about holes at all.
Silhouettes beat cleverness on porous data. Draw round the object from 480 directions and keep only what falls inside every outline. This beat the four cleverer things tried first for one reason: a splat scan is porous, and "is this neighbourhood flat", "does this ray enter and leave", "is this cell dense enough" and "does the sheet touch here" are all questions a hole can answer wrongly. Filling an outline is not, because the holes are all inside the silhouette, so filling from the border inward makes them stop existing.
The outline is a ring of measured points, not the rim of a dilated raster. A vertex that is a measurement cannot be in the wrong place, so nothing is fattened and nothing has to be shrunk back by a guess. This is the change that took fit from 2.6mm to 2.1mm and the bounding box from 13–15mm short to 3–6mm.
Intersecting silhouettes is a minimum, and a minimum over noisy estimates drifts downward forever. Proof: tracing the same 240 directions from both ends adds no information about the shape and removes 3.2% of the volume — more than adding 240 new directions does. --consensus forgives the worst 1% and stops it, but only once there are enough directions that a real concavity has more objectors than the budget forgives.
Surface nets rather than marching cubes. Quad-regular, near-uniform output is a far better mesh to hand a decimator than marching cubes' slivers, and it is eighty lines instead of a 256-entry table.
Triangles rather than voxels, because of where the budget goes. An animal is one smooth barrel and four small legs, and a quadric decimator spends almost nothing on the flank and keeps the legs. At the voxel resolution that costs the same bytes, the legs are three cubes.
And the fixture is not decoration. This tool has exactly one dangerous failure mode: it always returns a plausible lump. Run it on a real scan and you get a soft blobby animal whether the pipeline is right or wrong, and there is no way to tell those apart by looking, because a real capture has no ground truth — so every bug reads as "the scan was mediocre". The bracket step was rewritten three times against a real pig, each version judged by eye, before a fixture showed the pipeline had been correct for two of them and the scan was simply that soft. tools/make-splat-fixture.mjs writes a synthetic scan of a known shape; tools/selftest.mjs is that fixture wired to assertions instead of to an eyeball.
The two settings that carry the risk
Neither can be guessed from the file, and everything else has a defensible default.
--up, because a phone has no idea which way up a pig is. Check the axis by looking, not by reasoning about the bounding box. One capture here was converted with --up -x for most of its life on the strength of its box coming out 20.0 x 12.2 x 11.2 cm, which is the right shape for a standing pig and says nothing whatever about which end is up — a pig on its back measures the same. It was on its back the whole time. Six renders on a floor grid, one per candidate axis, settles it in about a minute and cannot be argued with.
--crop, because a splat trainer leaves low-confidence haze in the air around its subject, and haze bracketed as geometry is a model wearing a fur coat. There are two ways to cut it and they fail differently: --crop is a box, exact, but it will slice through the object where the haze overlaps it; --min-lum drops dark splats, cannot cut the shape, but only works when the subject is paler than the room and eats the subject's own shadowed parts along with the haze. The pig took the crop — measured, the brightness cull came back a third smaller because it had thinned his belly and undersides away.
Look at the scan before running this, and read both off what you see.
Options
Run node splat2glb.mjs with no arguments for the full list. The ones worth knowing:
| flag | | default |
|---|---|---|
| --up ±x\|±y\|±z | which scan axis points up out of the object | +y |
| --crop x0,y0,z0,x1,y1,z1 | keep only this box, in the scan's units | none |
| --tris N | triangle budget after decimation | 800 |
| --grid N | longest axis of the density grid, in cells | 64 |
| --colors N | flat colour groups to cluster into | 1 |
| --min-opacity F | drop splats fainter than this | 0.3 |
| --hull N | trace the outline from N directions, keep what is inside all | off |
| --consensus F | share of views a cell must be inside, 0.5–1 | 1 (strict) |
| --smooth N | Taubin smoothing passes | 12 |
| --check | grade the capture and stop, converting nothing | |
| --report | print the density distribution and the elapsed time | |
As a library
The pipeline touches no filesystem, no DOM, no three.js and no process.argv. It takes typed arrays and numbers and returns typed arrays and numbers, which is what lets a Node tool and a browser page import the same copy — and they should, because an approximation of the pipeline in a preview answers a different question from the pipeline.
import { buildFromCloud } from 'splat2glb';
const mesh = await buildFromCloud(
{ pos, rgb, opacity, radius, n }, // a decoded cloud
{ up: '+x', tris: 800, colors: 1 },
);Individual stages (cull, density, silhouetteHull, surfaceNets, smooth, decimate, colorize, orient) are exported too, because the interesting part of a pipeline is which stage you swap out.
lib/wrap.js, and what it is for
A second surface method, implemented and not wired to the CLI: a skin that starts outside everything and is pulled in until it lies on the points. A vacuum former, formally a shrink-wrap over an active surface, cousin to CGAL's alpha wrapping. What it has over the volumetric bracket is the thing a real vacuum former has — the sheet is a membrane, and it resists being stretched. That single property is what bridges a hole: the bracket collapses into an unobserved patch because nothing holds it out, whereas a membrane spans it, the way plastic bridges a gap in a mould instead of pouring through it. It is used from a browser editor rather than from here. Read its header before reaching for it.
What is not new here
None of the parts, and none of them should be claimed: visual hull is Laurentini 1994, surface nets is Gibson 1998, Taubin smoothing is Taubin 1995, quadric error metrics is Garland & Heckbert 1997, and wrap.js is a cousin of CGAL's alpha wrapping. What is here is the combination, the output target, and the measurements.
For cleaning the haze off a scan by hand before converting it, use SuperSplat (PlayCanvas, MIT, in the browser, GPU brush and lasso selection with unlimited undo). It is the standard tool for that and it is better at it than anything in this repo.
Licence
MIT. See LICENSE.
