SplatRs: The Week the Gap Closed

SplatRs is a 3D Gaussian Splatting trainer and renderer written in Rust — GPU compute in WGSL via wgpu, running on a single Mac Studio. The benchmark discipline: every quality number is measured on held-out views of the Tanks & Temples “train” scene, on the exact 38-view split nerfstudio’s splatfacto uses, so the two systems are directly comparable.

A week ago the honest gap to splatfacto was 6.45 dB. Today it’s 5.30 — and the week taught us more than the delta suggests.

Left: SplatRs rendering a held-out camera view it was never trained on. Right: the actual photo.

Left: our engine rendering a camera view it was never trained on, 30k iterations, 1.5 hours. Right: the photo.

One plot to redirect a roadmap

We’d assumed the gap was mostly capacity — splatfacto trains ~300k gaussians, our GPU budget capped us near 60–150k. So we measured it: quality vs gaussian count for both systems on the same test set.

PSNR vs gaussian count for SplatRs and splatfacto — the curves touch at ~150k, then splatfacto gains +6 dB at constant count

The curves touch at ~150k. Then splatfacto adds +6 dB at essentially constant count, through pure optimization. Measured capacity yield: under 0.8 dB per count doubling. The gap was recipe, not capacity. Everything else this week followed from this plot.

Recipe, part 1: opacity floors

Our reset/prune opacity thresholds were 20× lower than splatfacto’s — 90%+ of our population sat below visible opacity, unprunable, memorizing training views. Raising the pair 5× flipped the late training phase from decaying to climbing: +1.07 dB, with half the gaussians. The dose-response curve peaked cleanly — 20× overshoots, because aggressive culling eats useful dim mass.

Recipe, part 2: the loss split

Re-testing L1+DSSIM (an old refutation from a buggier era) produced a puzzle: PSNR got worse, but renders looked unmistakably better and LPIPS agreed. Our harness was PSNR-blind — so we made SSIM a first-class eval column and batch-scored LPIPS.

Then the settle-phase decay under DSSIM turned out to be our own anisotropy pruner: tuned for L2, it was culling exactly the long skinny gaussians DSSIM uses to hug edges, ~1,000 per pass, in a cull-regrow treadmill. One flag (needle prune off) and DSSIM won every metric simultaneously.

Same data, same budget, same schedule — the only difference between left and middle is the loss function: 15.76 vs 16.91 dB

Same data, same budget, same schedule. The only difference between left and middle is the loss function: 15.76 → 16.91 dB, and perceptual error (LPIPS) nearly halved, 0.556 → 0.240.

Throughput: the tile rasterizer

With the recipe settled, quality experiments were bottlenecked on a naive O(pixels × gaussians) rasterizer: 12.5 hours per 30k-iteration run. We built a tile-binned rasterizer in WGSL — project, count, emit, sort, range, blend — each stage behind a flag and gated bit-exact against the naive renderer as an oracle.

Forward up to 25.7× faster, backward up to 36.5×, and the only acceptable training-equivalence number: 0.0000 dB divergence over 500 iterations. The same showcase run now takes 1.5 hours and scored slightly higher. Every experiment is now a lunch break instead of an overnight.

Training evolution: the same held-out view, one frame every 1,500 iterations, as the scene learns itself

The bugs you only find by looking

While assembling a report we noticed magenta smudges in a saved training target. The original photo was clean. The culprit: the JPEG decoder our image library is version-locked to corrupts the chroma plane in patches — ~0.2% of every image’s pixels, in every training and eval image, for weeks. Three other decoders read the same files perfectly.

Left: what we trained on, with magenta smudges. Right: what the file actually contains.

One loader swap later, training data is clean. Weeks of A/B verdicts survived — both arms saw identical corruption — but it’s a lesson in actually looking at your data.

Full-resolution training then found two more: our GPU device was silently created with a 128 MiB buffer-binding cap (the hardware supports 4 GiB), and fixing that exposed a compute dispatch that needed 65,536 workgroups against a hard API limit of 65,535. Both fixed, both now guarded by pre-flight checks; the GPU pipeline then ran 30k full-res iterations at 400k gaussians without a fault.

Negative results are results

Absgrad densification — the reference’s own detail-growth heuristic — lost its A/B here by 0.45 dB. Reset-schedule levers refused to stack. And the first healthy full-res run produced fog: the standard periodic opacity reset, harmless at half resolution, is a 17× cut to a DSSIM-trained population that full-res optimization rates can never recover from.

Sharp at iteration 3,000, fog by iteration 30,000 — the same run, nine opacity resets later

Sharp at iteration 3,000; fog by 30,000. The telemetry pinned the mechanism in an evening, and the fix arm was running the same night. Negative results get recorded so they never get re-run.

What’s next

Full-resolution training with a survivable reset schedule — the model literally cannot learn detail it’s never seen:

Left: the photo. Middle: the half-res version training actually consumes. Right: what the model paints — a faithful copy of the mush.

Then re-measuring the capacity/recipe split with clean data.

5.3 dB to go. We know where it lives.