Skip to content

Add zstd compression for RAD property blobs - #433

Open
xohm wants to merge 1 commit into
sparkjsdev:mainfrom
xohm:feat/rad-zstd
Open

xohm wants to merge 1 commit into
sparkjsdev:mainfrom
xohm:feat/rad-zstd

Conversation

@xohm

@xohm xohm commented Sep 16, 2026

Copy link
Copy Markdown

Why

RAD compresses each property blob independently and reads the codec per blob from its compression field. Today the only codec is gz (raw DEFLATE).

Third-band SH is what makes a RAD file heavy, and DEFLATE is most of the reason. Summing the blobs of an 18.3M-splat SH3 scan:

gz-6 zstd-9
non-SH blobs (centre, alpha, rgb, scales, orientation, tree) 293 MB 291 MB ~0
SH blobs (sh1+sh2+sh3) 358 MB 180 MB −50%
whole file 651 MB 471 MB −27.7%

Per splat: 16.0 bytes of everything else, against 19.6 bytes of SH with DEFLATE or 9.8 with zstd — SH3 costs 2.2× the scene today, 1.6× with zstd. The 151.6M-splat scene in your streaming example carries maxSh 0, no SH at all.

The size win depends on how much DEFLATE left on the table, not on scene size — chunks are 65,536 splats whatever the scene, and even inside this one the per-chunk win runs from −7% to −31%. A second SH3 scene whose sh3 blob already deflated to 36% of raw (61% above) gained only 2.8%; zstd landed at the same size on both. An SH-0 file gains nothing.

Decode is the steadier win. Whole files through this branch's built spark_rs wasm, with an uncompressed transcode as the dequant-only baseline:

scene raw dequant only gz zstd whole decode
A, 18.3M splats 1335 MB 5.9 s 10.7 s 6.5 s −39%
B, 0.5M splats 37.5 MB 164 ms 275 ms 201 ms −27%

Codec alone: scene B inflates at 340 MB/s against zstd's 1,013 (3.0×); scene A about 280 against roughly 1.5 GB/s (the baseline pushes twice the bytes into the decoder, so this flatters both a little, zstd more). Scene B's file is only 2.8% smaller and still decodes 27% faster.

End-to-end, gz file against zstd file in the same Chrome page, scene A, 25 Mbit/s with 20 ms latency, camera inside the scene: 481 → 353 MB fetched and 93.3 → 69.9 s to full detail (−25.1%). At that bandwidth the time is the bytes, so the gain tracks file size; on a fast link or a local file the decode share grows, and with it the gain. Without a browser in the way, our native C++ RAD reader runs zlib at 302 MB/s against libzstd's 1565 MB/s on the same 1335 MB.

Where it does not help

SH-0 files, and clustered ones: --cluster-sh (#293) attacks the same bytes and often wins bigger — on scene B it takes the file 15.44 → 11.02 MB alone, and zstd then adds 0.5%. Clustering is opt-in, so default files still carry raw SH; on a clustered file this mostly buys decode speed.

What

  • RadChunkPropertyCompression::Zstd beside Gz, decoded per property through libzstd (the zstd crate), which compiles to wasm32 and runs there.
  • build-lod --zstd [--zstd-level=N], default 9 (faster to encode than gz-6; 19 buys the most offline).
  • gz arm untouched, existing files unaffected, one line added to the build-lod docs.

Cost

libzstd adds 244 KB to spark_rs_bg.wasm (1.60 → 1.85 MB) and 228 KB to the built module, and the build needs a clang that can target wasm32 (zstd-sys ships a wasm shim; build_rust_wasm.sh builds unchanged on Ubuntu's clang 18, on Windows it means LLVM on the PATH). Consumers of the published dist/ need no toolchain. With #418 it could sit behind a feature flag; say the word and I will move it.

Pure-Rust decoders were tried first: ruzstd is correct but ~4× slower in wasm; zrip 0.5.1 silently corrupts valid frames on wasm32 while passing on x86_64.

Compatibility

A .rad written with --zstd is not readable by older Spark versions (unknown compression variant). Existing gz files keep working in old and new builds.

Verified

Two round-trip tests for zstd frames with and without a content size; a golden test decoding one source encoded as gz and as zstd to an identical splat hash; and in headless Chromium a zstd .rad and its gz twin streaming through paged: true to the same splat count.

Open question

#332 adds zstd for SPZ v4 on the JS side. Since #374 moved decoding into Rust, that path needs a Rust-side zstd too — this one could serve both.

RAD reads the codec per property from its `compression` field, so a file can
carry a mix. Today the only codec is `gz` (raw DEFLATE).

Third-band SH is what makes a RAD file heavy, and DEFLATE is most of the
reason. On an 18.3M-splat SH3 scan the non-SH blobs are 293 MB and the SH is
358 MB on top; zstd takes the SH to 180 MB and leaves the rest alone, so the
file goes 651 -> 471 MB. Per splat that is 16.0 bytes of everything-else
against 19.6 bytes of SH with DEFLATE or 9.8 with zstd. The 151.6M-splat
scene in Spark's own streaming example carries maxSh 0 and no SH at all.

How much DEFLATE leaves on the table varies, so the file-level win does too:
a second SH3 scene gains only 2.8%, because DEFLATE already reached 36% of
raw there. zstd lands within 0.5% of the same size on both. An SH-0 file
gains nothing.

Decode is the steadier win. Whole files through this branch's own wasm, with
an uncompressed transcode as the dequant-only baseline: the 18.3M-splat scene
decodes in 10.7 s as gz and 6.5 s as zstd (dequant alone 5.9 s); the small
scene 275 ms against 201 ms (164 ms). The codec alone runs about 340 MB/s for
gz against 1013 for zstd on the small scene, and roughly 280 against 1500 on
the big one. In a native C++ RAD reader the same swap measures zlib 302 MB/s
against libzstd 1565 MB/s.

It also overlaps with --cluster-sh, which attacks the same bytes: on the
small scene clustering alone takes the file 15.44 -> 11.02 MB and zstd then
adds 0.5%.

This adds `zstd` on both sides: decode through libzstd (the `zstd` crate,
which compiles to wasm32 and runs there), and `build-lod --zstd
[--zstd-level=N]`, default 9. The `gz` arm is untouched, old files are
unaffected, and two round-trip tests cover the frames zstd writes with and
without a content size in the header.

The cost is the wasm: libzstd adds 244 KB to spark_rs_bg.wasm (1.60 -> 1.85
MB), 228 KB to the built module, and the BUILD needs a clang that can target
wasm32 - consumers of the published dist need no toolchain.
@xohm

xohm commented Sep 17, 2026

Copy link
Copy Markdown
Author

Addendum: --cluster-sh on a large scene

The description undersold --cluster-sh (in since 2.0): I had only measured it on the small scene. Same 18.5M-node SH3 scan, written four ways in this format by our native builder (same leaves, clustering in --cluster-sh's layout, gz 6 / zstd 12), whole-file decode through this branch's wasm, median of 3:

size wasm decode
plain, gz 624 MB 15.8 s
plain, zstd 456 MB (−27 %) 12.2 s (−23 %)
clustered, gz 316 MB (−49 %) 7.7 s (−51 %)
clustered, zstd 317 MB (−49 %) 6.5 s (−59 %)

Clustering is the bigger size win, and on a clustered file zstd adds no bytes: what remains compresses the same under both codecs, so such files can stay gz for older readers. zstd still cuts 16 % off their decode; together it is 2.4x faster than plain gz at half the size.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant