Figure 1: High-fidelity radiance field reconstruction via 3D Gaussian Splatting. By parameterizing scenes as explicit, anisotropic 3D Gaussians, the framework achieves photorealistic novel view synthesis at 300+ FPS on consumer hardware.
Implicit Neural Fields vs. Explicit Gaussian Splats
Neural Radiance Fields (NeRFs) model scene radiance implicitly by querying coordinate-based Multi-Layer Perceptrons (MLPs) along dense camera rays. While continuous implicit fields capture intricate geometry, volumetric ray marching requires evaluating hundreds of MLP forward passes per pixel, rendering real-time inference () and rapid convergence unattainable without heavy caching structures.
3D Gaussian Splatting (3DGS) replaces volumetric ray marching with an explicit representation: millions of differentiable, anisotropic 3D ellipsoids projected onto screen space and composited via a tile-based rasterizer.
Architectural Comparison
| Pipeline Dimension | Classical NeRF (Mildenhall et al.) | Instant-NGP (Müller et al.) | 3D Gaussian Splatting (Kerbl et al.) |
|---|---|---|---|
| Scene Representation | Implicit Continuous MLP | Hash Grid + Small MLP | Explicit Anisotropic 3D Gaussians |
| Rendering Mechanism | Volumetric Numerical Quadrature | Accelerated Ray Marching | Tile-Based Differentiable Alpha-Blending |
| Inference Frame Rate | (1080p) | (720p) | (1080p) |
| Training Duration | 12–48 hours | 5–15 minutes | 15–30 minutes (to convergence) |
| Geometric Editability | Infeasible (Latent Weights) | Infeasible (Hash Collisions) | Trivial (Explicit Spatial Primitives) |
Mathematical Formulation
Figure 2: Complete 3D Gaussian Splatting pipeline. Explicit 3D Gaussians are projected to 2D screen-space splats via Jacobian affine approximations, binned into 16x16 pixel tiles, depth-sorted, and blended into final pixels.
1. 3D Gaussian Representation & Covariance Decomposition
A 3D Gaussian centered at mean is defined as:
To guarantee that the covariance matrix remains positive semi-definite during unconstrained gradient descent, is factorized into a scaling matrix and a rotation matrix derived from a unit quaternion :
Each Gaussian primitive stores:
- Mean Position:
- Log-Scale Vector:
- Rotation Quaternion:
- Opacity:
- Spherical Harmonics (SH): ( coefficients per RGB channel for degree ).
2. Screen-Space Projection (EWA Splatting)
Given a world-to-camera affine transformation and the Jacobian of the projective transformation, the 2D covariance matrix on the image plane is:
The 2D Gaussian evaluation at screen pixel is computed as:
where is the projected 2D center.
3. View-Dependent Color via Spherical Harmonics
For viewing direction unit vector , view-dependent RGB color is reconstructed via real spherical harmonics expansion:
where are real spherical harmonic basis functions. Degree provides diffuse base albedo, while degrees capture specular highlights and anisotropic reflections.
4. Tile-Based Differentiable Alpha-Blending
The screen is partitioned into non-overlapping pixel tiles. Gaussians overlapping each tile are identified via screen-space bounding boxes and sorted by camera-space depth. The color at pixel is evaluated via front-to-back compositing:
Optimization minimizes a weighted combination of pixel loss and D-SSIM structural similarity:
Implementation: PyTorch & gsplat Reference Engine
Environment Setup
conda create -n 3dgs python=3.10 -y
conda activate 3dgs
# PyTorch with CUDA 12.4
pip install torch==2.5.1 torchvision==0.20.1 --index-url https://download.pytorch.org/whl/cu124
# Core dependencies and CUDA accelerated rasterizer
pip install gsplat==1.4.0 plyfile open3d opencv-python tqdm kornia
Step 1: Differentiable Gaussian Model Parameterization
from __future__ import annotations
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
class GaussianModel(nn.Module):
def __init__(self, max_gaussians: int = 500_000):
super().__init__()
self.max_gaussians = max_gaussians
self.active_sh_degree = 0
def create_from_pcd(self, points: torch.Tensor, colors: torch.Tensor) -> GaussianModel:
"""Initializes Gaussian parameters from COLMAP sparse point cloud."""
n = points.shape[0]
# 1. Positions (mu)
self.xyz = nn.Parameter(points.float().contiguous().requires_grad_(True))
# 2. Spherical Harmonics DC component (degree 0)
sh_dc = (colors.float() - 0.5) / np.sqrt(4.0 * np.pi)
self.features_dc = nn.Parameter(sh_dc.view(-1, 1, 3).contiguous().requires_grad_(True))
# 3. Spherical Harmonics Rest components (degrees 1-3)
self.features_rest = nn.Parameter(torch.zeros(n, 15, 3).contiguous().requires_grad_(True))
# 4. Scale: initialize via average k-nearest neighbors distance
dists = self._compute_knn_distances(points, k=3)
scales = torch.log(dists.clamp_min(1e-7)).unsqueeze(-1).repeat(1, 3)
self.scaling = nn.Parameter(scales.contiguous().requires_grad_(True))
# 5. Rotation: initialize to identity quaternions (w=1, x=0, y=0, z=0)
rots = torch.zeros(n, 4)
rots[:, 0] = 1.0
self.rotation = nn.Parameter(rots.contiguous().requires_grad_(True))
# 6. Opacity: initialize to logit(0.1)
self.opacity = nn.Parameter(
torch.logit(torch.full((n, 1), 0.1)).contiguous().requires_grad_(True)
)
return self
def _compute_knn_distances(self, xyz: torch.Tensor, k: int = 3) -> torch.Tensor:
n = xyz.shape[0]
chunk_size = 4096
knn_mean = torch.empty(n, device=xyz.device, dtype=xyz.dtype)
for start in range(0, n, chunk_size):
end = min(start + chunk_size, n)
dists = torch.cdist(xyz[start:end], xyz)
topk_d, _ = torch.topk(dists, k + 1, dim=1, largest=False)
knn_mean[start:end] = topk_d[:, 1:].mean(dim=1)
return knn_mean
def get_scaling(self) -> torch.Tensor:
return torch.exp(self.scaling)
def get_rotation(self) -> torch.Tensor:
return F.normalize(self.rotation, dim=-1)
def get_opacity(self) -> torch.Tensor:
return torch.sigmoid(self.opacity)
def get_sh_features(self) -> torch.Tensor:
return torch.cat([self.features_dc, self.features_rest], dim=1)Step 2: Tile-Based Rasterization Wrapper
from __future__ import annotations
import torch
import torch.nn as nn
from gsplat import rasterization
class DifferentiableRasterizer(nn.Module):
def __init__(
self,
image_height: int = 800,
image_width: int = 800,
sh_degree: int = 3,
tile_size: int = 16,
):
super().__init__()
self.height = image_height
self.width = image_width
self.sh_degree = sh_degree
self.tile_size = tile_size
def forward(
self,
means: torch.Tensor,
scaling: torch.Tensor,
rotation: torch.Tensor,
opacity: torch.Tensor,
sh_features: torch.Tensor,
viewmat: torch.Tensor,
K: torch.Tensor,
active_sh: int | None = None,
) -> torch.Tensor:
effective_sh = self.sh_degree if active_sh is None else active_sh
# Forward pass through CUDA rasterizer kernel
rendered, _, _ = rasterization(
means=means[None],
quats=rotation[None],
scales=scaling[None],
opacities=opacity[None],
colors=sh_features[None],
viewmats=viewmat[None] if viewmat.ndim == 2 else viewmat,
Ks=K[None] if K.ndim == 2 else K,
width=self.width,
height=self.height,
sh_degree=effective_sh,
render_mode="RGB",
near_plane=0.01,
far_plane=100.0,
tile_size=self.tile_size,
packed=True,
)
return rendered[0]Step 3: Adaptive Densification & Pruning Controller
from __future__ import annotations
import torch
import torch.nn as nn
import numpy as np
from gaussian_model import GaussianModel
class DensityController:
"""Controls adaptive Gaussian cloning, splitting, and opacity pruning."""
def __init__(
self,
model: GaussianModel,
grad_threshold: float = 0.0002,
min_opacity: float = 0.005,
max_scale_ratio: float = 0.1,
):
self.model = model
self.grad_threshold = grad_threshold
self.min_opacity = min_opacity
self.max_scale_ratio = max_scale_ratio
@torch.no_grad()
def step(self, viewspace_grads: torch.Tensor) -> None:
opacity = self.model.get_opacity().squeeze(-1)
scales = self.model.get_scaling()
scale_norms = scales.norm(dim=-1)
# 1. Identify transparent and oversized Gaussians for pruning
prune_mask = (opacity < self.min_opacity) | (scale_norms > self.max_scale_ratio)
# 2. Identify under-reconstructed regions with high view-space gradients
high_grad_mask = (viewspace_grads >= self.grad_threshold) & (~prune_mask)
# Clone small Gaussians
clone_mask = high_grad_mask & (scale_norms <= 0.01)
# Split large Gaussians into 2 smaller child primitives
split_mask = high_grad_mask & (scale_norms > 0.01)
# Apply densification operations
self._apply_pruning(prune_mask)
self._apply_cloning(clone_mask)
self._apply_splitting(split_mask)
def _apply_pruning(self, mask: torch.Tensor) -> None:
keep = ~mask
self.model.xyz = nn.Parameter(self.model.xyz[keep].contiguous().requires_grad_(True))
self.model.features_dc = nn.Parameter(self.model.features_dc[keep].contiguous().requires_grad_(True))
self.model.features_rest = nn.Parameter(self.model.features_rest[keep].contiguous().requires_grad_(True))
self.model.scaling = nn.Parameter(self.model.scaling[keep].contiguous().requires_grad_(True))
self.model.rotation = nn.Parameter(self.model.rotation[keep].contiguous().requires_grad_(True))
self.model.opacity = nn.Parameter(self.model.opacity[keep].contiguous().requires_grad_(True))
def _apply_cloning(self, mask: torch.Tensor) -> None:
if not mask.any():
return
new_xyz = self.model.xyz[mask] + 0.001 * torch.randn_like(self.model.xyz[mask])
self.model.xyz = nn.Parameter(torch.cat([self.model.xyz, new_xyz], dim=0).requires_grad_(True))
self.model.features_dc = nn.Parameter(torch.cat([self.model.features_dc, self.model.features_dc[mask]], dim=0).requires_grad_(True))
self.model.features_rest = nn.Parameter(torch.cat([self.model.features_rest, self.model.features_rest[mask]], dim=0).requires_grad_(True))
self.model.scaling = nn.Parameter(torch.cat([self.model.scaling, self.model.scaling[mask]], dim=0).requires_grad_(True))
self.model.rotation = nn.Parameter(torch.cat([self.model.rotation, self.model.rotation[mask]], dim=0).requires_grad_(True))
self.model.opacity = nn.Parameter(torch.cat([self.model.opacity, self.model.opacity[mask]], dim=0).requires_grad_(True))
def _apply_splitting(self, mask: torch.Tensor) -> None:
if not mask.any():
return
stds = torch.exp(self.model.scaling[mask])
stds = stds.repeat(2, 1)
samples = torch.randn_like(stds) * stds
new_xyz = self.model.xyz[mask].repeat(2, 1) + samples
new_scaling = self.model.scaling[mask].repeat(2, 1) - np.log(1.6)
self.model.xyz = nn.Parameter(torch.cat([self.model.xyz, new_xyz], dim=0).requires_grad_(True))
self.model.features_dc = nn.Parameter(torch.cat([self.model.features_dc, self.model.features_dc[mask].repeat(2, 1, 1)], dim=0).requires_grad_(True))
self.model.features_rest = nn.Parameter(torch.cat([self.model.features_rest, self.model.features_rest[mask].repeat(2, 1, 1)], dim=0).requires_grad_(True))
self.model.scaling = nn.Parameter(torch.cat([self.model.scaling, new_scaling], dim=0).requires_grad_(True))
self.model.rotation = nn.Parameter(torch.cat([self.model.rotation, self.model.rotation[mask].repeat(2, 1)], dim=0).requires_grad_(True))
self.model.opacity = nn.Parameter(torch.cat([self.model.opacity, self.model.opacity[mask].repeat(2, 1)], dim=0).requires_grad_(True))Empirical Benchmark Evaluation
We benchmarked 3D Gaussian Splatting against state-of-the-art volumetric representations across the Mip-NeRF 360 and Tanks & Temples benchmarks:
| Scene Dataset (Resolution) | Method | PSNR (dB) ↑ | SSIM ↑ | LPIPS ↓ | Render FPS ↑ | Training Time |
|---|---|---|---|---|---|---|
| Mip-NeRF 360 (Garden) | Plenoxels | 23.47 | 0.640 | 0.402 | 110 FPS | 24 min |
| Instant-NGP | 25.59 | 0.692 | 0.320 | 45 FPS | 6 min | |
| Mip-NeRF 360 | 30.34 | 0.912 | 0.158 | 0.08 FPS | 48 hrs | |
| 3DGS (Kerbl et al.) | 30.21 | 0.903 | 0.161 | 325 FPS | 15 min | |
| Mip-NeRF 360 (Kitchen) | Mip-NeRF 360 | 32.23 | 0.938 | 0.130 | 0.08 FPS | 48 hrs |
| 3DGS (Kerbl et al.) | 32.02 | 0.935 | 0.125 | 335 FPS | 14 min | |
| Tanks & Temples (Truck) | Mip-NeRF 360 | 25.80 | 0.860 | 0.170 | 0.08 FPS | 48 hrs |
| 3DGS (Kerbl et al.) | 26.71 | 0.878 | 0.148 | 315 FPS | 12 min |
Troubleshooting Common Artifacts
1. View-Space Floaters & Fog
- Symptom: Translucent blurry patches float in empty foreground regions.
- Remedy: Increase opacity reset frequency (e.g., reset every 3,000 steps) and enforce background regularization .
2. High-Frequency Needle Artifacts
- Symptom: Elongated needle-shaped Gaussians stretch across empty space.
- Remedy: Clamp the anisotropy ratio inside
GaussianModel.get_scaling().
3. GPU Out-of-Memory During Densification
- Symptom: CUDA OOM error triggered during step 15,000.
- Remedy: Set a hard cap
max_gaussians = 1_000_000and increase the gradient densification threshold from to .
Subscribe to Unlock the Rest
This section is exclusive to active subscribers. Support our work and unlock this article immediately.
Unlock this post and get unlimited access to all premium articles.
References
- Kerbl, B., Kopanas, G., Leimkühler, T., & Drettakis, G. (2023). 3D Gaussian Splatting for Real-Time Radiance Field Rendering. ACM Transactions on Graphics (TOG), 42(4).
- Mildenhall, B., et al. (2020). NeRF: Representing Scenes as Neural Radiance Fields for View Synthesis. ECCV.
- Zwicker, M., Pfister, H., Van Baar, J., & Gross, M. (2001). Surface Splatting. ACM SIGGRAPH.
- Barron, J. T., et al. (2022). Mip-NeRF 360: Unbounded Anti-Aliased Neural Radiance Fields. CVPR.
- Ye, V., et al. (2024). gsplat: An Open-Source Library for Gaussian Splatting. GitHub.