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

1
Frame extraction
ffmpeg · async
→
2
SfM
COLMAP · async
→
3
Init Gaussians
Rayon · parallel
→
4
Training loop
Adam · GPU/CPU
→
5
Export
.ply · .splat

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

192B
per Gaussian3D
59
trainable params/Gaussian
16×16
rasterizer tile size
3M
max Gaussians default

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

math
src/math/mod.rs · src/math/projection.rs · src/math/sh.rs
Core mathematical types and functions. Gaussian3D struct, Camera, sigmoid, logit, softplus. No I/O, no async, no allocations beyond struct construction.
math::projection
src/math/projection.rs
EWA covariance projection (3D → 2D), perspective matrix construction, Gribb-Hartmann frustum plane extraction, sphere-frustum culling, Gaussian screen-space AABB.
math::sh
src/math/sh.rs
Degree-3 real spherical harmonics evaluation. SH constants SH_C0, SH_C1, SH_C2[5], SH_C3[7]. eval_sh_deg3 takes 48 coefficients and a view direction, returns RGB.
cpu::rasterizer
src/cpu/rasterizer.rs
Tile-based alpha-compositing Gaussian rasterizer. Projects all Gaussians, sorts by depth, decomposes screen into 16×16 tiles, assigns Gaussians to tiles, Rayon-parallel raster per tile. Returns a Framebuffer (RGBA f32 linear).
gpu
src/gpu/mod.rs · src/gpu/context.rs
wgpu device/queue initialisation, buffer upload helpers, async GPU readback. Feature-gated behind --features gpu. gpu_available() probes the system without panicking.
pipeline::sfm
src/pipeline/sfm.rs
Frame extraction (ffmpeg), COLMAP invocation, COLMAP binary parser (points3D.bin, cameras.bin), Gaussian initialisation from SfM points including nearest-neighbour scale estimation.
pipeline::optimizer
src/pipeline/optimizer.rs
Adam optimiser state (AdamState), adam_step, adaptive densification (densify_and_prune: split + clone + prune), opacity reset. TrainConfig with all hyper-parameters.
pipeline (orchestrator)
src/pipeline/mod.rs
Top-level Pipeline struct. Owns PipelineConfig and optional GpuContext. Pipeline::run() sequences all 5 stages. All heavy work dispatched with spawn_blocking.
shaders/splat.wgsl
shaders/splat.wgsl
WGSL compute shader with two entry points: @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 Σ:

G(x) = exp(−½ (x − μ)ᵀ Σ⁻¹ (x − μ))

Covariance factorisation

Σ is parameterised as the product of a rotation matrix R and a diagonal scale matrix S, ensuring Σ is always positive semi-definite:

Σ = R · S · Sᵀ · Rᵀ = (RS)(RS)ᵀ

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:

Σ′₂D = J · W · Σ₃D · Wᵀ · Jᵀ

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:

J = [ fx/tz 0 −fx·tx/tz² ]
[ 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:

Σ⁻¹ = 1/det · [ c −b ] where Σ = [ a b ]
[ −b a ] [ b c ]

Gaussian evaluation at pixel (dx, dy) from the centre:

power = −½ (a·dx² + 2b·dx·dy + c·dy²)
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:

c(d) = Σ_{l=0}^{3} Σ_{m=−l}^{l} k_{lm} · Y_{lm}(d)

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:

c_dc = 0.5 + SH_C0 · k₀₀, SH_C0 = 1 / (2√π) ≈ 0.2821

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):

p_cam = view · [P, 1]ᵀ
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):

[ 2fx/W 0 (W−2cx)/W 0 ]
[ 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

position[f32; 3]World-space centre μ. Directly optimised by Adam.
opacity_logitf32Pre-sigmoid opacity. Decode: sigmoid(opacity_logit)
rotation[f32; 4]Unit quaternion [w, x, y, z]. Renormalised every Adam step.
log_scale[f32; 3]Log scale per axis. Decode: [log_scale[i].exp()]
sh_coeffs[f32; 48]16 SH coefficients × 3 RGB channels. Channel-major layout.

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

width, heightu32Image resolution in pixels
fx, fyf32Focal lengths in pixels
cx, cyf32Principal point — typically (width/2, height/2)
c2w[[f32;4];4]Camera-to-world 4×4 homogeneous transform
distortion[f32; 3]Radial distortion coefficients [k1, k2, k3]. Currently parsed but not applied in rasterizer.

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

num_iterations
Default: 30_000
Total training steps. 15k gives decent results; 30k is the paper default.
lr_position
Default: 0.00016
Adam learning rate for Gaussian centres. Very small — position is sensitive.
lr_rotation
Default: 0.001
Learning rate for quaternion components.
lr_scale
Default: 0.005
Learning rate for log-scale axes.
lr_opacity
Default: 0.05
Largest LR — opacity converges fast.
lr_sh_dc
Default: 0.0025
Learning rate for degree-0 SH (view-independent colour).
lr_sh_rest
Default: 0.000125
20× smaller than lr_sh_dc. Prevents early high-frequency colour noise.
beta1
Default: 0.9
Adam first moment decay. Standard value.
beta2
Default: 0.999
Adam second moment decay.
eps
Default: 1e-8
Adam denominator stability term.
densify_from_iter
Default: 500
Start densification after this many iterations (let positions settle first).
densify_until_iter
Default: 15_000
Stop densification — Gaussians should be well-placed by now.
densify_grad_threshold
Default: 0.0002
Average 2D screen gradient norm threshold to trigger clone/split.
opacity_reset_iter
Default: 3000
Reset all opacities to 0.01 every N iters — forces re-learning, kills floaters.
prune_opacity_thresh
Default: 0.005
Remove Gaussians with opacity below this after each densification.
max_gaussians
Default: 3_000_000
Hard cap on total Gaussians. Densification stops when this is reached.

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.