RAD (SparkJS / World Labs) → 3D Tiles — format notes & why it isn't "streamed"
Notes distilled from the Spark team + WilliamLiu (3DGS-PLY-3DTiles-Converter) on GitHub, captured to
Notes distilled from the Spark team + WilliamLiu (3DGS-PLY-3DTiles-Converter) on GitHub, captured to
explain how rad-to-3dtiles works and why RAD is served via /convert (preprocess+cache) rather than
the live /stream range adapters.
Sources:
- sparkjsdev/spark#372 — "Expose JS API lod tree walker" (replies from
mrxz, Spark collaborator) - WilliamLiu-1997/3DGS-PLY-3DTiles-Converter#25 — RAD opacity & orientation
What RAD actually is
- RAD's LoD structure is a per-splat LoD tree, not a spatial tile tree. One hierarchy node is essentially one renderable splat; its children are the finer splats that simplify into it (WilliamLiu: "a per-splat LOD system, rather than a chunk/tile-level LOD system like 3D Tiles").
- Chunks ≠ tiles. RAD chunks are storage/streaming units: 64K splats ordered by
featureSize(coarsest first), then grouped. The chunker does build an octree to spatially co-locate splats, but that spatial partition is not persisted — only used during construction (mrxz). A chunk can contain splats from multiple LoD-tree levels, and those shouldn't be rendered together. - Consequence: recomputing chunk bounds offline reconstructs the chunking octree, not the LoD
tree. To make real tiles you'd extract the leaf-most LoD-tree splats per chunk (direct children
are never split across chunks), which requires the LoD tree — only available in
spark-lib(wasm), not persisted in the.radfile.
RAD /stream adapter (centers-only tree + on-demand encode)
A 3D-Tiles tileset needs per-tile spatial bounds to emit the tree. RAD persists neither tile
bounds nor a spatial tile partition, so — unlike COPC/Potree/SOG — we can't build the tree from
metadata alone. But the full /convert is overkill: the tree only needs each chunk's AABB, which
comes from the center column alone. So /stream (see packages/tile-server/rad-live.js) does:
- tileset.json — range-fetch the header, then range-fetch every chunk in parallel and decode,
per chunk, the
centercolumn (→ AABB) and thechild_count/child_startcolumns (→ LoD-tree topology) in a single combined pass (chunkBoundsAndTopology). No SPZ encode, no gzip. - tile/
<i>.glb — range-fetch that one chunk and full-decode + SPZ-v2 encode it on demand (~200 ms / 65k-splat chunk), reusing the sharedencodeChunkGlb.
This defers the expensive per-tile decode+encode+gzip (which /convert runs for the whole dataset up
front) to on-demand. Bounds match the default (unfiltered) /convert output (outlier filtering is
opt-in/off by default).
Cost of the cold tileset build (important for huge clouds). RAD has no top-level spatial index,
so the tree build must touch every chunk — both the topology (child columns) and the bounds (center
column) live per-chunk. That's inherent, not a wart: a 100 M-splat .rad (~1500+ chunks) requires
reading + decompressing every chunk's center+child columns before the first tile appears. The adapter
mitigates this with (a) HTTP range requests (never buffers the whole multi-GB file; falls back to a
single full download only if the host ignores Range), (b) a single parallel pass (one decode per
chunk, bounded concurrency) instead of two sequential passes, and (c) per-URL caching of the result.
But the first load of a very large .rad is still bound by reading all chunk centers — for clouds
that big, /convert (preprocess + cache once) is the better mode, or persist a bounds/topology
sidecar. (This is why the original notes said RAD doesn't stream like COPC/Potree: those persist bounds;
RAD does not.)
Caveat: only monolithic .rad files stream (streaming-manifest .rad has no embedded chunk
data). And the LoD-tree-vs-chunking-octree subtlety above still applies — a chunk may mix LoD levels;
the adapter renders whole chunks, same as /convert. For a fully faithful per-LoD-leaf tiling you still
need the offline spark-lib path. /convert (preprocess+cache) remains available and produces
identical tiles; /stream just trades up-front conversion for on-demand latency.
The Spark team's own recommendation for a faithful converter is an offline process written against
spark-lib (or by adjusting the build-lod command to emit tile data directly), so the spatial
partition and LoD-tree membership are guaranteed.
Current rad-to-3dtiles decode status (these are handled)
- Opacity (the "spiky/faint splats" bug): fixed. RAD stores quantisation under
splatEncoding(per-chunk, mirrored from the root header), NOTencoding. WhensplatEncoding.lodOpacity: true, the real opacity isstored/255 * 2(SparkJSunpackSplat). The converter now readssplatEncodingand applies the ×2, so coarse splats blend correctly instead of showing needles through. Seepackages/rad-to-3dtiles/src/index.js(lodOpacity). - Orientation: decoded as octahedral
(u,v)+ angle (oct88r8) → quaternion (decodeOrientationToQuat). - Scales: log-quantised
ln_0r8,exp(lnMin + (v-1)/254·(lnMax-lnMin))with ranges sourced fromsplatEncoding.lnScaleMin/Max. - Noise/outlier filtering: optional opacity-floor and scale-outlier caps (off by default) to drop haze/giant-anisotropic "floating noise" splats that a baked HLOD would otherwise always render.
- Note: WilliamLiu is drafting an
EXT_splat_opacityglTF extension so 3D-Tiles GLB can carry RAD's extended opacity range (up to ~1000) faithfully; until then the ×2 LoD-opacity decode is the pragmatic match.
Known residual gap
Because chunks mix LoD-tree levels, the current chunk-as-tile mapping can only approximate RAD's
refinement, and the geometric error is therefore approximate. A faithful "v2" would be an offline,
LoD-tree-aware extraction (leaf-most per chunk) built against spark-lib. Deferred — out of scope for
a pure-JS .rad reader. The current converter is a good, renderable approximation.