rgsplat async
High-performance 3D Gaussian Splatting from video — Rust 2021 · GPU + CPU fallback · Tokio async · Rayon parallel
This crate implements the complete pipeline described in 3D Gaussian Splatting for Real-Time Radiance
Field Rendering (Kerbl et al., SIGGRAPH 2023). It takes a video as input and produces a trained
.ply point cloud of 3D Gaussians that can be rendered in real time.
What it does
Design principles
| Principle | How |
|---|---|
| Never block the async executor | All CPU-heavy work runs in tokio::task::spawn_blocking. Subprocesses (ffmpeg,
COLMAP) use tokio::process::Command. |
| GPU-optional | GPU support is a Cargo feature (--features gpu). Falls back to CPU
automatically if no adapter found. |
| Memory efficiency | Gaussian3D is #[repr(C)] and implements bytemuck::Pod
— zero-copy GPU uploads. Large files use memmap2. |
| Correctness first | Quaternions are renormalized every Adam step. Covariance is always positive semi-definite (RS·RSᵀ construction). |
| Parallel by default | Rayon par_iter over Gaussians for projection, gradient update, densification.
Tile rasterizer uses zero-contention tile decomposition. |
Key numbers
Installation
Prerequisites and build configurations
Quick install (one command)
Installs Rust, ffmpeg, COLMAP, and builds rgsplat:
Linux / macOS
bash <(curl -fsSL https://raw.githubusercontent.com/mfaeezshabbir/rgsplat/main/scripts/install.sh)
Windows (PowerShell as Admin)
powershell -ExecutionPolicy Bypass -File https://raw.githubusercontent.com/mfaeezshabbir/rgsplat/main/scripts/install.ps1
Or clone and run locally:
git clone https://github.com/mfaeezshabbir/rgsplat.git cd rgsplat bash scripts/install.sh # Linux / macOS powershell -File scripts/install.ps1 # Windows
System requirements
| Dependency | Required for | Install |
|---|---|---|
Rust ≥ 1.75 |
Everything | rustup update stable |
ffmpeg |
Frame extraction | apt install ffmpeg / brew install ffmpeg |
colmap |
Structure from Motion | colmap.github.io/install |
OpenBLAS |
ndarray-linalg | apt install libopenblas-dev |
| Vulkan / Metal / DX12 driver | GPU mode only | OS-specific GPU driver |
Python ≥ 3.9 + maturin |
Python bindings | pip install maturin |
Build variants
# CPU-only (default, no GPU driver needed) cargo build --release # With GPU support (wgpu / WGSL compute shaders) cargo build --release --features gpu # With ffmpeg bindings (optional, alternative to subprocess approach) cargo build --release --features video # Full build: GPU + video + Python FFI cargo build --release --features full # Python wheel (requires maturin) maturin build --release --features python
Add as library dependency
[dependencies]
rgsplat = { git = "https://github.com/mfaeezshabbir/rgsplat", features = ["gpu"] }
On Linux with NVIDIA GPUs, the Vulkan backend gives the best compute performance. On macOS, wgpu uses Metal automatically — no additional setup needed.
Quick start
From video to trained Gaussians in one call
CLI
# Basic run (CPU mode) rgsplat --input my_scene.mp4 --output ./output # GPU mode, 2fps extraction, 15k iterations rgsplat --input my_scene.mp4 --output ./output --gpu --fps 2 --iters 15000 # White background, checkpoint every 1k iters, skip SfM rgsplat --input ./frames --output ./out --bg 1,1,1 --save-interval 1000 --skip-sfm # View all flags rgsplat --help
All CLI flags
| Flag | Default | Description |
|---|---|---|
-i, --input |
required | Input video (.mp4) or directory of pre-extracted frames |
-o, --output |
required | Output directory for frames, COLMAP workspace, checkpoints, and final PLY |
--gpu |
false |
Use GPU compute path (requires --features gpu) |
--fps |
2.0 |
Frames per second to extract from video |
--iters |
30000 |
Number of training iterations |
--save-interval |
5000 |
Save a checkpoint PLY every N iterations |
--bg |
0,0,0 |
Background color as R,G,B floats in [0,1] (e.g. 1,1,1 for white) |
--skip-sfm |
false |
Skip ffmpeg extraction and COLMAP; load existing workspace from --output |
Rust API
use rgsplat::pipeline::{Pipeline, PipelineConfig}; #[tokio::main] async fn main() -> anyhow::Result<()> { let cfg = PipelineConfig::from_video( "scene.mp4", "./output", ); let pipeline = Pipeline::new(cfg).await?; let output_ply = pipeline.run().await?; println!("Done: {:?}", output_ply); Ok(()) }
Customise config
let mut cfg = PipelineConfig::from_video("scene.mp4", "./out"); cfg.extract_fps = 3.0; // frames per second to extract cfg.use_gpu = true; // enable GPU if available cfg.train.num_iterations = 15_000; cfg.train.max_gaussians = 1_000_000; cfg.bg_color = [1.0, 1.0, 1.0]; // white background cfg.save_interval = 2000; // checkpoint every 2k iters
Expected output layout
output/
├── frames/ # Extracted PNG frames
│ ├── frame_000001.png
│ └── ...
├── colmap/ # COLMAP workspace
│ ├── colmap.db
│ └── sparse/0/
│ ├── cameras.bin
│ ├── images.bin
│ └── points3D.bin
├── ckpt_005000.ply # Periodic checkpoints
├── ckpt_010000.ply
└── output.ply # Final trained splat
Pipeline stages
What happens inside Pipeline::run()
Stage 1 — Frame extraction
Calls ffmpeg as an async subprocess. Non-blocking: the Tokio runtime handles other tasks
while ffmpeg runs.
sfm::extract_frames(&video_path, &frames_dir, fps).await?
| Parameter | Default | Notes |
|---|---|---|
fps |
2.0 | 2 frames/sec balances coverage vs COLMAP cost. Increase for fast-moving scenes. |
| Output format | PNG (-qscale:v 1) | Highest quality; lossless SIFT features. |
Stage 2 — Structure from Motion (COLMAP)
Runs three COLMAP passes in sequence, all as async subprocesses:
| Pass | COLMAP command | What it does |
|---|---|---|
| 1 | feature_extractor |
SIFT keypoint detection on every frame |
| 2 | exhaustive_matcher |
Match features between all frame pairs |
| 3 | mapper |
Incremental SfM → camera poses + sparse 3D points |
COLMAP is the main bottleneck. For a 100-frame sequence, expect 5–15 minutes on CPU. Use
--gpu to enable GPU SIFT and GPU matching — roughly 5× faster.
Stage 3 — Gaussian initialisation
Parses COLMAP binary files (points3D.bin, cameras.bin) and converts each SfM
point into a Gaussian3D:
| Parameter | Initial value | Derivation |
|---|---|---|
| Position | SfM xyz | Direct copy |
| Color (SH DC) | SfM RGB | (linear(r) - 0.5) × SH_C0_INV |
| Scale (isotropic) | mean NN distance | K=3 nearest neighbours via spatial sort |
| Rotation | identity quaternion | [1, 0, 0, 0] |
| Opacity | logit(0.1) ≈ −2.2 | 10% opacity, optimised during training |
Stage 4 — Training loop
30 000 iterations (default). Each iteration:
Pick random camera → Render (GPU or CPU) → Compute L1+D-SSIM loss
→ Backward pass (per-Gaussian gradients) → Adam step → [Densify/Prune every 100 iters]
Key events during training:
| Iteration range | Event |
|---|---|
| 500 – 15 000 | Adaptive densification window (clone + split) |
| Every 3 000 | Opacity reset — forces re-learning of which Gaussians are real |
| Every 5 000 | Checkpoint save to ckpt_NNNNNN.ply (async, non-blocking) |
Stage 5 — Export
Saves a PLY point cloud where each Gaussian is a point with custom properties (position, rotation, scale, opacity, 48 SH coefficients). Compatible with standard Gaussian Splatting viewers.
Module map
Every source file and what it owns
Gaussian3D struct,
Camera, sigmoid, logit, softplus. No I/O, no
async, no allocations beyond struct construction.SH_C0,
SH_C1, SH_C2[5], SH_C3[7]. eval_sh_deg3 takes 48
coefficients and a view direction, returns RGB.Framebuffer (RGBA f32 linear).--features gpu. gpu_available() probes the system
without panicking.points3D.bin, cameras.bin), Gaussian initialisation from SfM points
including nearest-neighbour scale estimation.AdamState), adam_step, adaptive
densification (densify_and_prune: split + clone + prune), opacity reset.
TrainConfig with all hyper-parameters.Pipeline struct. Owns PipelineConfig and
optional GpuContext. Pipeline::run() sequences all 5 stages. All heavy
work dispatched with spawn_blocking.@compute fn project
(Gaussian → SplatInfo2D, 256 threads/workgroup) and @compute fn rasterize (tile raster,
16×16 threads/workgroup).Memory model
How data is stored and moved through the pipeline
Gaussian3D memory layout
Gaussian3D is #[repr(C)] with explicit field ordering to minimise padding and
allow zero-copy GPU uploads via bytemuck::Pod.
| Field | Type | Bytes | Purpose |
|---|---|---|---|
position |
[f32; 3] | 12 | World-space centre μ |
opacity_logit |
f32 | 4 | Pre-sigmoid opacity (avoids gradient vanishing) |
rotation |
[f32; 4] | 16 | Unit quaternion [w,x,y,z] |
log_scale |
[f32; 3] | 12 | Log scale — exp gives positive scale |
| (padding) | — | 4 | Align to 16B for GPU vec4 alignment |
sh_coeffs |
[f32; 48] | 192 | 16 SH coefficients × 3 RGB channels |
| Total | 240B | ||
Allocation strategy
| Data | Strategy | Why |
|---|---|---|
| Gaussian array | Vec<Gaussian3D> |
Contiguous — good cache locality, SIMD-friendly, direct GPU upload with
bytemuck::cast_slice |
| Adam state (m1/m2) | Vec<f32> flat arrays |
Indexed as [gaussian_idx * 59 + param_idx] — avoids pointer indirection |
| Framebuffer | Vec<f32> RGBA |
Single allocation, passed to GPU or tile rasterizer |
| Large binary files (COLMAP) | memmap2::Mmap |
OS-managed paging — only touched pages loaded into RAM |
| Tile pixel buffers | Stack ([f32; 16×16×4]) |
Per-rayon-thread, no heap allocation in hot loop |
GPU buffer flow
Vec<Gaussian3D>
→ bytemuck::cast_slice (zero-copy view)
→ wgpu STORAGE buffer (COPY_DST | COPY_SRC)
→ compute shader: project pass
→ SplatInfo2D STORAGE buffer
→ radix sort (depth keys)
→ compute shader: rasterize pass
→ texture_storage_2d (rgba8unorm)
→ readback buffer (MAP_READ)
→ Vec<u8> on CPU
GPU buffer uploads use DeviceExt::create_buffer_init — one memcpy from CPU
slice to VRAM, no intermediate staging buffer allocation needed in application code.
Concurrency model
How the pipeline stays non-blocking end-to-end
Thread roles
| Thread pool | Used for | Size |
|---|---|---|
| Tokio async executor | Orchestration, subprocess I/O, GPU submission futures | Tokio default (num CPUs) |
| Tokio blocking pool | CPU-heavy Rust code: projection, Adam step, densification, parsing | 512 (Tokio default max) |
| Rayon thread pool | Data parallelism within a blocking task: par_iter over Gaussians, tile
rasterizer |
num CPUs |
Key async boundaries
// This blocks the thread for ~minutes — must be spawn_blocking let (gaussians, cameras) = tokio::task::spawn_blocking(move || { load_sfm_output(&workspace) }).await??; // Render runs in blocking pool, GPU future awaits in async context let rendered = task::spawn_blocking(move || { cpu::rasterizer::rasterize(&gaussians, &camera, bg) }).await?; // Checkpoint saves don't block the training loop task::spawn(async move { task::spawn_blocking(move || save_ply(&g, &path)).await });
Tile rasterizer zero-contention design
The screen is divided into 16×16 tiles. Each tile writes to a disjoint range of the output pixel buffer.
Rayon iterates over tiles in parallel with no mutexes needed — a single unsafe pointer
write is used with a safety invariant that's trivially verified (each pixel belongs to exactly one
tile).
The unsafe tile write is the only unsafe block in the codebase. It replaces what would
otherwise be per-pixel Mutex contention with a guaranteed-disjoint memory partition.
Gaussian primitives
The mathematical object at the core of 3DGS
The 3D Gaussian function
Each scene primitive is a 3D Gaussian density function centred at μ with covariance Σ:
Covariance factorisation
Σ is parameterised as the product of a rotation matrix R and a diagonal scale matrix S, ensuring Σ is always positive semi-definite:
In code, R is stored as a unit quaternion and S as log-scale (so optimisation can take unconstrained steps):
// Decode scale: always positive s = [log_scale[0].exp(), log_scale[1].exp(), log_scale[2].exp()] // Decode opacity: always in (0, 1) α = sigmoid(opacity_logit) = 1 / (1 + exp(−opacity_logit))
Why logit/log parameterisation?
Adam takes unconstrained gradient steps. If we stored opacity directly as a float in (0,1), a gradient
step could push it outside the valid range. Storing the logit (inverse sigmoid) means any real number is
valid, and we decode to (0,1) at evaluation time. Same for scale — storing log-scale means any real
number decodes to a positive scale via exp.
Trainable parameters per Gaussian
| Parameter | Raw storage | Decoded form | Count |
|---|---|---|---|
| Position μ | f32[3] | World xyz | 3 |
| Rotation | f32[4] quaternion | 3×3 rotation matrix R | 4 |
| Scale | f32[3] log | exp → positive scale | 3 |
| Opacity | f32 logit | sigmoid → (0,1) | 1 |
| SH coefficients | f32[48] | 16 coeffs × 3 channels | 48 |
| Total | 59 | ||
Covariance projection (EWA)
Mapping 3D Gaussian ellipsoids to 2D screen splats
The EWA splatting formula
Based on Zwicker et al. 2001. The 2D screen covariance is:
where W is the view rotation (upper-left 3×3 of the view matrix) and J is the affine approximation Jacobian of the perspective projection at the Gaussian centre:
[ 0 fy/tz −fy·ty/tz² ]
[ 0 0 0 ]
Conic representation
For rasterization, we store the inverse 2×2 covariance (the conic) to avoid a matrix inverse at every pixel:
[ −b a ] [ b c ]
Gaussian evaluation at pixel (dx, dy) from the centre:
alpha = σ(opacity_logit) · exp(power)
Low-pass anti-aliasing filter
A 0.3-pixel constant is added to the diagonal of Σ′₂D before inversion. This prevents Gaussian splats from becoming sub-pixel (infinitely sharp) and causing aliasing:
sigma_2d[(0,0)] += 0.3; sigma_2d[(1,1)] += 0.3;
Screen-space AABB
The tight bounding box of the Gaussian splat (n_sigma = 3, covering 99.7% of density) is computed from the eigenvalues of the 2×2 covariance:
mid = (a + c) / 2
disc = sqrt(((a−c)/2)² + b²)
λ₁ = mid + disc (largest eigenvalue)
λ₂ = mid − disc
r = n_sigma × sqrt(max(λ₁, λ₂))
Spherical harmonics
View-dependent colour via degree-3 real SH basis
What SH gives us
Each Gaussian can emit different colour depending on the viewing direction — mimicking specular highlights, subsurface scattering, and non-Lambertian surfaces. The colour for view direction d is:
Coefficient layout
48 floats per Gaussian: 16 SH coefficients × 3 RGB channels, stored channel-major:
sh_coeffs[0..16] = Red channel (l=0 to l=3)
sh_coeffs[16..32] = Green channel
sh_coeffs[32..48] = Blue channel
Degree 0 (DC term)
The degree-0 term gives a view-independent base colour:
Constants
| Degree | Count | Constant(s) |
|---|---|---|
| l=0 | 1 | SH_C0 = 0.28209479 |
| l=1 | 3 | SH_C1 = 0.48860251 |
| l=2 | 5 | SH_C2[0..5] |
| l=3 | 7 | SH_C3[0..7] |
Learning rate
Higher-degree SH coefficients use a 20× smaller learning rate than the DC term, preventing high-frequency colour noise from appearing early in training:
lr_sh_dc = 0.0025 lr_sh_rest = lr_sh_dc / 20.0 = 0.000125
Camera & projection
Pinhole camera model, NDC mapping, frustum culling
Pinhole camera model
| Symbol | Field | Meaning |
|---|---|---|
| fx, fy | Camera.fx / fy |
Focal lengths in pixels |
| cx, cy | Camera.cx / cy |
Principal point (image centre) |
| W, H | Camera.width / height |
Image resolution in pixels |
| R, t | Camera.c2w upper 3×3, col 3 |
Camera-to-world rotation + translation |
| k1, k2, k3 | Camera.distortion |
Radial distortion (currently unused in rasterizer) |
Perspective projection
World point P → screen pixel (sx, sy):
sx = fx · p_cam.x / p_cam.z + cx
sy = fy · p_cam.y / p_cam.z + cy
NDC projection matrix
OpenCV-convention pinhole camera to OpenGL NDC (for wgpu):
[ 0 −2fy/H (H−2cy)/H 0 ]
[ 0 0 −(f+n)/(f−n) −2fn/(f−n) ]
[ 0 0 −1 0 ]
Frustum culling
Six planes extracted from the MVP matrix (Gribb-Hartmann method). Sphere test using Gaussian position and bounding radius keeps only visible Gaussians. Reduces GPU/CPU work proportionally to scene complexity.
Gaussian3D
src/math/mod.rs · #[repr(C)] ·
impl Pod + Zeroable
Fields
sigmoid(opacity_logit)[log_scale[i].exp()]Methods
// Constructor: identity rotation, small scale, low opacity fn new(position: [f32; 3]) -> Gaussian3D // Decode opacity from logit space → (0, 1) fn opacity(&self) -> f32 // Decode scale from log space → (0, ∞) fn scale(&self) -> [f32; 3] // Decode rotation quaternion as nalgebra UnitQuaternion fn rotation_quat(&self) -> UnitQuaternion<f32> // Build 3×3 world-space covariance Σ = R·S·Sᵀ·Rᵀ fn covariance_3d(&self) -> Matrix3<f32> // Project 3D covariance to 2D screen space fn projected_covariance_2d(&self, view: &Matrix4<f32>, focal: (f32,f32)) -> Matrix3<f32> // Get base (view-independent) colour from DC SH coefficient fn base_color(&self) -> [f32; 3]
Camera
src/math/mod.rs · Pinhole camera model
Fields
Methods
// World-to-camera matrix (inverse of c2w) fn view_matrix(&self) -> Matrix4<f32> // Perspective projection matrix for NDC mapping fn projection_matrix(&self, near: f32, far: f32) -> Matrix4<f32> // (fx, fy) focal length pair fn focal(&self) -> (f32, f32)
CPU rasterizer
src/cpu/rasterizer.rs · Tile-based, Rayon-parallel CPU
rasterize()
pub fn rasterize(
gaussians: &[Gaussian3D],
camera: &Camera,
bg_color: [f32; 3],
) -> Framebuffer
Main entry point. Must be called from tokio::task::spawn_blocking — it blocks the calling
thread.
Algorithm
| Step | Work | Parallelism |
|---|---|---|
| 1. Project | Each Gaussian → SplatInfo2D (screen pos, conic, colour, AABB) | Rayon par_iter |
| 2. Sort | Back-to-front by depth (unstable sort) | Single-threaded |
| 3. Tile assign | Each splat's tile-space AABB → tile lists | Single-threaded |
| 4. Rasterize | Per tile: alpha-composite front-to-back with early termination at T < 0.0001 | Rayon par_iter over tiles |
Framebuffer
pub struct Framebuffer { pub width: usize, pub height: usize, pub data: Vec<f32>, // RGBA linear, row-major } // Convert linear → sRGB u8 for PNG export fn to_u8_srgb(&self) -> Vec<u8>
Constants
| Constant | Value | Purpose |
|---|---|---|
TILE_SIZE |
16 | 16×16 pixel tiles — matches GPU warp width |
ALPHA_THRESHOLD |
1/255 | Skip sub-visible Gaussians |
TRANSMITTANCE_STOP |
0.0001 | Early ray termination — pixel is full |
GPU context
src/gpu/context.rs · wgpu device management GPU
Requires --features gpu. Compiles to a stub that returns an error at runtime if the
feature is not enabled.
GpuContext::new()
pub async fn new() -> Result<GpuContext>
Requests a high-performance adapter (discrete GPU preferred). Logs the adapter name and backend. Returns error if no compatible GPU is found — callers should fall back to CPU.
Buffer helpers
// Upload Vec data → STORAGE | COPY_DST | COPY_SRC buffer fn upload_buffer<T: Pod>(&self, data: &[T], label: &str) -> Buffer // Allocate empty output buffer fn output_buffer(&self, size_bytes: u64, label: &str) -> Buffer // Staging buffer for GPU→CPU readback fn readback_buffer(&self, size_bytes: u64) -> Buffer // Submit command encoder and poll until complete async fn submit_and_wait(&self, encoder: CommandEncoder) // Read GPU buffer back to CPU Vec async fn readback<T: Pod + Clone>(&self, buf: &Buffer, count: usize) -> Vec<T>
gpu_available()
pub async fn gpu_available() -> bool
Safe probe — does not panic if no GPU exists. Use this before calling GpuContext::new() if
you want to suppress the error log on headless machines.
Optimizer
src/pipeline/optimizer.rs · Adam + densification
adam_step()
pub fn adam_step( gaussians: &mut [Gaussian3D], grads: &[GaussianGrad], state: &mut AdamState, cfg: &TrainConfig, )
Updates all Gaussian parameters using the Adam algorithm with bias correction. Quaternions are renormalised after every step. Parallelised over Gaussians with Rayon.
AdamState
pub struct AdamState { pub m1: Vec<f32>, // first moment, shape [N × 59] pub m2: Vec<f32>, // second moment pub grad_accum: Vec<f32>, // accumulated screen gradient norm pub grad_denom: Vec<u32>, // count for average pub t: usize, // iteration (for bias correction) }
densify_and_prune()
pub fn densify_and_prune( gaussians: &mut Vec<Gaussian3D>, state: &mut AdamState, cfg: &TrainConfig, scene_extent: f32, )
| Action | Condition | Effect |
|---|---|---|
| Clone | High 2D gradient AND small scale | Add a copy near the original — fills under-reconstructed regions |
| Split | High 2D gradient AND large scale | Replace with 2 smaller Gaussians along the major axis |
| Prune (opacity) | α < prune_opacity_thresh | Remove transparent Gaussians |
| Prune (size) | max_scale > 0.1 × scene_extent | Remove outlier-sized Gaussians |
GaussianGrad
pub struct GaussianGrad { pub d_position: [f32; 3], pub d_rotation: [f32; 4], pub d_log_scale: [f32; 3], pub d_opacity_logit: f32, pub d_sh: [f32; 48], pub screen_grad_norm: f32, // for densification threshold }
SfM pipeline
src/pipeline/sfm.rs · Frame extraction, COLMAP, point cloud parsing
extract_frames()
pub async fn extract_frames(
video_path: &Path,
output_dir: &Path,
fps: f32,
) -> Result<Vec<PathBuf>>
Spawns ffmpeg as a tokio::process::Command. Non-blocking.
run_colmap()
pub async fn run_colmap(
image_dir: &Path,
workspace_dir: &Path,
use_gpu: bool,
) -> Result<()>
Runs three COLMAP commands in sequence. Each is awaited independently — if one fails, the pipeline surfaces the error immediately.
parse_points3d_bin()
pub fn parse_points3d_bin(data: &[u8]) -> Result<Vec<SfMPoint>>
Parses COLMAP's binary points3D.bin format. Track data (image observations) is skipped.
Returns SfMPoint with xyz, RGB, reprojection error, and track length.
gaussians_from_sfm()
pub fn gaussians_from_sfm(points: &[SfMPoint]) -> Vec<Gaussian3D>
Converts SfM point cloud to initial Gaussians. Computes per-point initial scale from K=3 nearest-neighbour distances using a spatial sort for O(n log n) complexity.
splat.wgsl
shaders/splat.wgsl · WGSL compute shader · Two-pass GPU pipeline GPU
Entry points
| Function | Workgroup size | One thread per |
|---|---|---|
@compute fn project |
256 | 1 Gaussian |
@compute fn rasterize |
16 × 16 | 1 pixel in a tile |
Bindings — project pass (group 0)
| Binding | Type | Purpose |
|---|---|---|
| 0 | array<Gaussian3D> read |
Input Gaussians |
| 1 | array<SplatInfo2D> read_write |
Output projected splats |
| 2 | CameraUniforms uniform |
View matrix, focal, resolution |
| 3 | array<u32> read_write |
Depth sort keys (bit-flipped float for radix sort) |
Bindings — rasterize pass (group 1)
| Binding | Type | Purpose |
|---|---|---|
| 0 | array<SplatInfo2D> read |
Sorted splats from pass 1 |
| 1 | array<vec2<u32>> read |
Tile ranges (start, end) after sort |
| 2 | texture_storage_2d<rgba8unorm, write> |
Output framebuffer |
Depth key encoding
Camera-space depth (positive float) is encoded as a sortable u32 by bit-flipping. This allows a standard ascending radix sort to produce back-to-front order:
// In WGSL depth_keys[idx] = ~bitcast<u32>(tz); // flip all bits → back-to-front order
TrainConfig
All hyper-parameters with defaults from the 3DGS paper
Cargo features
Build-time feature flags
| Feature | Activates | Additional dep |
|---|---|---|
cpu (default) |
CPU rasterizer, all math, SfM pipeline | none |
gpu |
wgpu context, WGSL compute shaders, GPU rasterizer | wgpu 0.19 |
video |
ffmpeg-next bindings (alternative to subprocess) | ffmpeg-next 6.0 |
python |
PyO3 FFI module (rgsplat.pyd / .so) |
pyo3 0.20 |
full |
All of the above | all |
Release profile
| Setting | Value | Effect |
|---|---|---|
opt-level |
3 | Maximum LLVM optimisation |
lto |
fat | Cross-crate link-time optimisation — removes dead code across deps |
codegen-units |
1 | Single LTO unit — slower compile, fastest runtime |
panic |
abort | No unwinding overhead |
strip |
true | Strip debug symbols from binary |
GPU vs CPU guide
Choosing the right compute backend for your environment
Decision matrix
| Scenario | Recommended |
|---|---|
| Training speed (interactive feedback) | GPU always |
| Headless CI / render farm node | CPU (no GPU driver requirement) |
| macOS Apple Silicon | GPU (wgpu Metal backend, very fast) |
| Low-memory device (<4GB VRAM) | CPU (avoids VRAM OOM on large scenes) |
| Production server (no display) | CPU with Rayon (predictable) |
Automatic fallback
let pipeline = Pipeline::new(cfg).await?; // If GPU init fails, Pipeline logs a warning and continues with CPU. // No code change needed.
Manual override
let available = gpu::gpu_available().await; cfg.use_gpu = available && std::env::var("GSPLAT_NO_GPU").is_err();
Performance expectations
| Backend | 1M Gaussian render | Training iteration |
|---|---|---|
| GPU (RTX 3080) | ~3ms | ~80ms |
| CPU (16-core Ryzen) | ~180ms | ~400ms |
| CPU (4-core laptop) | ~600ms | ~1500ms |
Python bindings
Using the pipeline from Python via PyO3 / maturin
Build the wheel
pip install maturin
maturin build --release --features python
pip install target/wheels/rgsplat-*.whl
Usage
import rgsplat as gs import asyncio async def main(): cfg = gs.PipelineConfig("scene.mp4", "./output") cfg.use_gpu = True cfg.train.num_iterations = 15000 pipeline = await gs.Pipeline.new(cfg) output_path = await pipeline.run() print(f"Done: {output_path}") asyncio.run(main())
The Python bindings expose an async interface via PyO3's pyo3-asyncio integration. The
Tokio runtime is created once and shared across all pipeline calls in the process.
Load and inspect Gaussians
gaussians = gs.load_ply("output/output.ply") print(f"Count: {len(gaussians)}") print(f"First position: {gaussians[0].position}") print(f"First opacity: {gaussians[0].opacity():.3f}")
Performance tuning
Tips for faster training and lower memory use
Reduce scene size
| Config | Effect | Trade-off |
|---|---|---|
max_gaussians = 500_000 |
50% less VRAM/RAM, 50% faster iterations | Lower detail ceiling |
extract_fps = 1.0 |
Fewer frames → faster COLMAP | May miss fast-moving objects |
densify_until_iter = 8000 |
Shorter densification window | Fewer Gaussians in final model |
Speed up COLMAP
# Restrict to sequential matching for large frame sets rgsplat --input scene.mp4 --output out --colmap-matcher sequential # Use GPU SIFT (3-5x faster than CPU SIFT) rgsplat --input scene.mp4 --output out --gpu
Profile with Tokio Console
RUSTFLAGS="--cfg tokio_unstable" cargo build --release --features full
RUST_LOG=debug ./target/release/rgsplat --input scene.mp4 --output out
Install tokio-console to visualise async task execution and identify blocking tasks that
should be moved to spawn_blocking.
Memory usage estimate
// Rough formula:
// RAM = N × (240B Gaussian + 2 × 59 × 4B Adam state)
// For 1M Gaussians: 1M × (240 + 472) ≈ 680MB RAM
// GPU VRAM: same for buffers + framebuffer (W×H×16B)
Adam state is the largest allocation: N × 59 × 2 × 4 bytes (m1 + m2). For 3M Gaussians
this is ~1.4GB. If RAM is tight, reduce max_gaussians or switch to a half-precision
Adam variant.