Spirula Studio Standalone Viewer
A dependency-free, client-side WebGL2 viewer for 3D Gaussian Splats, meshes, and MVS datasets (COLMAP / Nerfstudio / Metashape). It runs entirely in the browser (no server) and can be hosted as a static site (e.g. GitHub Pages). Performance-critical parsing, depth sorting, and statistics run in C++ compiled to WebAssembly (built with CMake + Emscripten); rendering is WebGL2.
The viewer's own runtime code is independent from the rest of
the trainer — nothing here imports from the training code at runtime. The
one deliberate exception is at build time: the WASM module compiles the
trainer's dataset parsers (src/data/parsers/*Parser.cpp)
in place — referenced by relative path, not copied — so COLMAP/Nerfstudio/
Metashape parsing has exactly one implementation in the repo. Those files are
plain C++17 with no CUDA dependency (see csrc/CameraModel.h).
Features
- 3D Gaussian Splatting (
.ply, INRIA and Spirula Studio layouts, including very large files — binary PLY is parsed streaming through a small chunk buffer, non-position attributes are stored as half floats, rest SH coefficients are 8-bit quantized (Gaussian-wise scale,RGB8_SNORMtextures — half the VRAM and heap of f16, visually lossless), and the SH texture / mesh index buffers are split into chunks below per-resource GPU limits; tested with a 6.4 GB, 39M-splat SH2 scan (~3 GB VRAM). Splats are Morton-reordered at load so the depth-sorted draw order touches attribute textures cache-coherently (several-fold frame-rate gain on multi-10M-splat models); the async sort worker keeps only an xyz copy. Deep-linked?model=URLs stream straight into the parser (no Blob buffering, so multi-GB hosted models load). If the GPU runs out of memory on the SH textures, the viewer drops one SH degree at a time and retries instead of failing.- Primitive select: 3DGS, Mip (antialiased), 3DGUT (unscented transform projection; fragments evaluate the 3D Gaussian along per-pixel rays, "eval3d"). 3DGS/Mip use analytic projection Jacobians with the conventional out-of-bounds Jacobian clipping.
- Color gamut (Rec.709 / DCI-P3 / Rec.2020 / AdobeRGB / ACEScg /
ACES2065-1) and a linear-color toggle, matching the training pipeline's
rgb_to_srgbconventions. - Spherical harmonics up to degree 4, exposure.
- Depth sorting matches the training code's
get_sorting_depth: planar for perspective/orthographic, distance for equirectangular, and a smooth |z|/radial blend for fisheye — content behind the camera composites correctly in >180° views. Sorting runs asynchronously in a Web Worker that hosts its own instance of the WASM module (native counting sort over a positions copy), so looking around never blocks the render loop; results apply latest-wins when ready. - GPU-friendly: attributes live in
TEXTURE_2D_ARRAYs laid out so arbitrarily large models render even under a smallMAX_TEXTURE_SIZE(tiles into array layers).
- Meshes (
.ply,.obj,.gltf,.glb) as produced by the meshing code: vertex colors or a base-color texture atlas, shading toggles (shaded / unshaded, flat / interpolated normals, color on/off) with a view-following headlight so the surface reads from every angle. GLTF/GLB are parsed in JS; drop the model together with its external.bin/.mtl/ image files for textured.gltf/.obj. - Orbit / trackball / first-person / free-fly navigation (mouse, touch, keyboard, gamepad — matched to the training viewer), Y-up ↔ Z-up toggle (switching keeps the current view), and camera models perspective / orthographic / fisheye (equidistant) / fisheye (equisolid) / equirectangular 360° — all with analytic Jacobians / sigma-point projection; mesh triangles crossing a projection discontinuity (the equirect seam, the fisheye backward point) are discarded, and fragments outside a fisheye image circle are clipped.
- A rasterized, depth-tested axes + grid overlay in the model's native frame (power-of-10 cells that adapt to the zoom level; the line patch follows the orbit target while staying on the global lattice) and a configurable background.
- Statistics (splat count / vertices / edges / faces) and on-demand parameter histograms (opacity, scale, effective rank, RGB, anisotropy for splats; edge length, triangle area, coordinates for meshes), computed in WASM and cached.
- MVS datasets — drag & drop a COLMAP reconstruction
(
cameras/images/points3D, binary.binor text.txt), a Nerfstudiotransforms.json(+ point cloud.ply), or a Metashape camera export (.xml+.ply, optional.psx) — as a folder or as individual files:- Renders the seed point cloud and camera frustums with the true
per-camera projection: the image border is discretized and unprojected
through the camera model (pinhole / fisheye / equisolid / equirectangular)
including OpenCV distortion (Newton undistort,
k1–k4 p1 p2 s1 s2 b1 b2), so a fisheye camera's frustum visibly bulges. Wide cameras get an image-aligned wire dome (fisheye) or a lat/long wire globe (equirectangular) instead of a lone border ring. Point size and frustum size are adjustable; either layer can be hidden. - A compact info panel: source format, image / point counts, camera groups, per-model image counts.
- Component picker when the dataset has several reconstructions
(
sparse/0,sparse/1, multiple Metashape<component>chunks, …). - Hover anywhere on a camera frustum (ray-picked, not just its apex) to see its intrinsics (model, fx/fy/cx/cy, distortion); double-click a camera to view the scene from it (average focal, cx/cy/distortion omitted); double-click the point cloud to recenter. Picking is depth-ordered: whatever is visually in front at the cursor — frustum or point cloud — wins.
- Degrades gracefully: dropping only
sparse/, only atransforms.json, only a Metashape.xml, or only a point-cloud.plyshows whatever is available (images are never required, or read at all — only the metadata files are parsed). Load failures and partial loads pop a visible error toast (no need to open the console).
- Renders the seed point cloud and camera frustums with the true
per-camera projection: the image border is discretized and unprojected
through the camera model (pinhole / fisheye / equisolid / equirectangular)
including OpenCV distortion (Newton undistort,
- Double-click the viewport to center the view on the point under the cursor (MeshLab-style: the point becomes the orbit pivot and slides onto the optical axis). Splats use a one-pixel GPU depth pass; meshes use a WASM raycast; datasets pick the nearest point along the view ray. Works with every camera model.
- One model at a time — dropping another replaces it and frees the previous GPU buffers. Replacing keeps the current viewpoint (the camera is only fitted for the first model; refresh the page to start over).
Build
Requires the Emscripten SDK on PATH (emcc, emcmake) and CMake ≥ 3.16.
source ~/emsdk/emsdk_env.sh # activate emsdk
./build.sh # emcmake cmake + cmake --build
This produces js/ssv_wasm.js and js/ssv_wasm.wasm (committed so the site is
directly hostable without a build step).
Run
Serve the directory over HTTP (ES modules + WASM require it — file:// will not
work):
python3 -m http.server -d . # then open http://localhost:8000/
Drag a model or a dataset folder onto the canvas, or click to browse (the
picker takes multiple files; folder drops walk the directory tree). You can
also deep-link a hosted model: index.html?model=<url> (external
.bin/.mtl/image siblings are fetched automatically).
Test
test/run.sh generates synthetic COLMAP / Nerfstudio / Metashape datasets and
drives the viewer end-to-end in headless Chrome (needs google-chrome and
node ≥ 20):
test/run.sh # all cases
test/run.sh metashape # one case; see test/test_ds.html for the list
Layout
index.html UI + panel
css/style.css theme (adapted from the training viewer)
js/
main.js app wiring, input, render loop, sorting
renderer.js WebGL2 renderer (splat HDR pass, mesh, dataset, grid lines, tonemap)
shaders.js GLSL (splat / tonemap / mesh / points / frustum+grid lines)
camera.js camera + navigation modes
dataset.js dataset load orchestration + frustum geometry (undistort)
sortworker.js async depth-sort worker (own WASM instance)
wasm.js WASM bridge + streaming loader + GLTF/GLB (JS) + MEMFS mount
colors.js gamut matrices
linalg.js vec/quat/mat helpers
histogram.js canvas bar chart
ssv_wasm.{js,wasm} built WASM module
src/viewer.cpp parsers (PLY/OBJ), depth sort, histograms
src/dataset_bridge.cpp dataset C ABI over MEMFS (drives the trainer's parsers)
test/ end-to-end harness (headless Chrome) + data generators
CMakeLists.txt Emscripten build (also compiles ../src/data/ parsers)
Frustum lines use the same seam handling as meshes: fragments whose interpolated camera-space position re-projects far from the rasterized position (a segment wrapped across the equirect ±180° seam or the fisheye backward point) are discarded, and fisheye display models clip to the image circle. Double-clicking a dataset camera switches the display projection to that camera's model (pinhole → perspective, fisheye → equidistant, equisolid, equirectangular) with the matching field of view.