TECHNICAL NOTEMay 12, 2025

xSLAM: Real-Time SLAM in the Browser

A browser SLAM demo using Web Workers, WebGL, and WebAssembly SIMD for pose-graph optimization and particle-filter localization.

← Back to Blog

01 // THE PROBLEM

SLAM demos usually live in native stacks: CMake, ROS, GTSAM or g2o, drivers, and a lot of setup. That is fine for serious robotics work, but it makes algorithm experiments hard to share and compare.

I wanted to test a narrower question: if the solver runs in WebAssembly and visualization stays in WebGL, can a browser tab support interactive pose-graph and localization experiments without hiding the math?

xSLAM is the experiment. It runs pose-graph optimization, Monte Carlo localization, and a small 3D Gaussian Splatting view on the client. The useful part is not that it replaces native SLAM systems; it makes the algorithm state inspectable in a shareable demo.

"A useful ML/robotics demo should expose the state: particles, graph edges, residuals, and timing."

02 // SYSTEM ARCHITECTURE

The main thread cannot own both rendering and the solver. xSLAM keeps rendering on the main thread and moves state estimation into a Web Worker: particle updates, graph construction, and the WASM SIMD solver. This keeps frame-time measurement honest because rendering and optimization are separated.

Browser SLAM Runtime
Rendering diagram...

Data is marshaled between threads as raw ArrayBuffer objects using the Transferable interface — the buffer's ownership is moved, not copied, so the transfer cost is constant regardless of payload size. Each frame, the worker posts back a binary blob containing updated particle positions, weights, and the optimized pose array. The main thread unpacks this directly into typed arrays (Float32Array) and feeds it to the WebGL draw calls. No JSON serialization ever touches the hot path.

03 // GRAPH SLAM OPTIMIZATION

The core of xSLAM's mapping pipeline is a pose-graph optimizer. The graph consists of nodes representing robot poses in the Special Euclidean group SE(2) (position + heading), and edges representing spatial constraints — either sequential odometry measurements or loop-closure detections.

For each edge connecting node i to node j, the measurement is a relative transform Z_ij. The error vector is computed in the Lie algebra of SE(2) by comparing the measured relative pose against the current estimate:

e_ij(x) = log( Z_ij⁻¹ ⊕ (X_i⁻¹ ⊕ X_j) )ᵛ ∈ ℝ³

The log map projects the group element back to the tangent space (the Lie algebra 𝔰𝔢(2)), yielding a 3-vector of translation and rotation error. The superscript v denotes the vee operator that extracts the vector from the skew-symmetric matrix. Global optimization minimizes the sum of squared Mahalanobis errors over all edges:

x* = argmin_x Σ_{(i,j) ∈ E} e_ij(x)ᵀ Ω_ij e_ij(x)

This is solved iteratively using the Levenberg-Marquardt algorithm. At each iteration, we linearize the error functions and construct the Hessian (information) matrix and the right-hand side:

H = Σ J_ij ᵀ Ω_ij J_ij b = Σ J_ij ᵀ Ω_ij e_ij

where J_ij = ∂e_ij / ∂x is the Jacobian of the error function with respect to the state variables, and Ω_ij is the information matrix (inverse covariance) encoding measurement confidence. The state update is then:

( H + λ·I ) Δx = −b → x ← x ⊕ Δx

The damping parameter λ is adapted per iteration: increased when the error grows (steepest descent), decreased when it shrinks (Gauss-Newton). Because each edge only connects two nodes, H is extremely sparse — a 200-node graph produces a 600×600 Hessian with only ~3,600 non-zero entries. We solve this using a sparse Cholesky factorization (H = LLᵀ) compiled into WebAssembly with SIMD vectorization, achieving solve times under 2ms.

04 // PARTICLE FILTER LOCALIZATION

For real-time localization against a known map, xSLAM runs an 80-particle Monte Carlo Localization (MCL) filter. Each particle is a hypothesis for the robot's pose (x, y, θ), and the swarm collectively approximates the posterior belief distribution. The filter operates in three stages per timestep:

Step 1: Motion Prediction

Each particle is propagated forward using a velocity motion model with additive Gaussian noise to model odometry uncertainty. For particle k with control input (v_t, ω_t):

x_t^k = x_{t-1}^k + (v_t + ε_v) · cos(θ_{t-1}^k) · Δt
y_t^k = y_{t-1}^k + (v_t + ε_v) · sin(θ_{t-1}^k) · Δt
θ_t^k = θ_{t-1}^k + (ω_t + ε_ω) · Δt
where ε_v ~ N(0, σ_v²), ε_ω ~ N(0, σ_ω²)
Step 2: Sensor Update (Likelihood Weighting)

For each particle, we simulate a 2D LiDAR raycast from its hypothesized pose against the occupancy grid. The importance weight is computed as the Gaussian likelihood of the observed scan given the simulated one:

w_k = ∏_{j=1}^{M} exp( −½ · (z_j^actual − z_j^sim)² / σ_sensor² )

where M is the number of LiDAR beams and σ_sensor captures range-finder noise. The product form assumes beam independence — a standard approximation that works well in practice.

Step 3: Low-Variance Systematic Resampling

To prevent particle deprivation (where all weight concentrates on a single particle), we trigger resampling when the effective sample size drops below a threshold:

N_eff = 1 / Σ_k (w̃_k)² where w̃_k = w_k / Σ_j w_j
if N_eff < N_threshold: resample using low-variance walker

Low-variance resampling draws a single random number r ~ U[0, 1/N] and then deterministically selects particles at intervals of 1/N along the cumulative weight distribution. This produces a more diverse particle set than multinomial resampling, reducing variance while maintaining the correct distribution.

05 // WEBASSEMBLY SIMD COMPILATION

The numerical core is C++ compiled to WebAssembly with Emscripten: sparse matrix construction, Cholesky factorization, and the Levenberg-Marquardt loop. That keeps the experiment close to native solver structure while still running inside the browser.

WASM SIMD Compilation
Rendering diagram...

The key compilation flags are -O3 -msimd128 -ffast-math. The -msimd128 flag enables 128-bit SIMD operations, mapping directly to the browser's v128 value type. In the inner loop of the Cholesky factorization, we use intrinsics like wasm_f32x4_mul and wasm_f32x4_sub to process four matrix elements per clock cycle.

Memory layout matters. Sparse matrices use Compressed Sparse Column format with contiguous Float64Array backing. Column pointers, row indices, and values stay inside WASM linear memory during the solve. Only the final Δx update is copied back to JS.

The compiled module is 48 KB gzipped. On first load it is compiled and cached via WebAssembly.compileStreaming(), so subsequent visits pay zero compilation cost. Benchmarks on an M1 MacBook Air show a 200-node pose graph solves in 1.7 ms per LM iteration — well within the 16 ms frame budget for 60 FPS rendering.

06 // 3D GAUSSIAN SPLATTING

Beyond localization, xSLAM includes a WebGL 3D Gaussian Splatting renderer. Instead of triangle meshes, the scene is represented as 3D Gaussians with position, covariance, opacity, and simple view-dependent color terms.

Rendering proceeds in three stages. First, each 3D Gaussian is projected to 2D screen space using the EWA (Elliptical Weighted Average) splatting formulation:

Σ' = J · W · Σ · Wᵀ · Jᵀ ∈ ℝ²ˣ²

Here, W is the world-to-camera viewing transformation, and J is the Jacobian of the local affine approximation of the projective transform. The resulting 2×2 matrix Σ' defines a 2D ellipse on the image plane. Its eigenvalues give the semi-axis lengths and its eigenvectors give the orientation.

Second, the projected splats are depth-sorted. We use a GPU-friendly radix sort operating on 32-bit keys (16 bits depth + 16 bits tile index) to order splats front-to-back within each screen tile. Third, compositing applies standard volumetric alpha blending in sorted order:

C(p) = Σ_i c_i · α_i · G(p; μ'_i, Σ'_i) · ∏_{j<i} (1 − α_j · G(p; μ'_j, Σ'_j))

where G(p; μ', Σ') is the 2D Gaussian evaluated at pixel p. The transmittance product ∏(1 − ...) ensures correct occlusion handling. This entire pipeline is implemented in WebGL 2.0 fragment shaders with instanced rendering — each splat is a screen-aligned quad whose vertex shader applies the Σ' transform.

07 // INTERACTIVE VISUALIZATION

The panel below is a live instance of xSLAM's perception loop, running the particle filter and pose graph in real-time at 60 FPS directly in this page. Every element — particles, covariance ellipses, graph edges — is computed per frame, not pre-rendered.

xSLAM // SPATIAL_ESTIMATOR_ACTIVE
FPS: 60.0T_PROC: 4.8MS
An interactive Bayes filter: hover graph nodes to inspect local covariance ellipses, trigger 2D LiDAR raycasts, and highlight Information matrix components. Click nodes to inject factor constraints (x4 loop closure).
  • Dynamic Covariance Ellipses: The concentric dashed ellipses represent 1σ, 2σ, and 3σ confidence regions of the particle swarm. The covariance matrix is computed in real-time from the weighted sample statistics of all 80 particles.
  • LiDAR Sweep Raycast:Hovering over any pose-graph node triggers a simulated 2D LiDAR sweep from that node's position, casting rays against the local environment boundaries and displaying the estimated state vector.
  • Loop Closure Trigger: Clicking node x₄ fires a loop-closure constraint, running the graph optimizer. Watch the covariance ellipses contract immediately as the added constraint reduces global uncertainty.

08 // BENCHMARKS & PERFORMANCE

To validate that WebAssembly SIMD is competitive with native solvers, I benchmarked xSLAM against GTSAM (C++) and g2o (C++) on identical pose graphs. All tests ran on an M1 MacBook Air, 8 GB RAM. Native solvers were compiled with -O3 -march=native.

xSLAM (WASM SIMD)

200-node graph, Cholesky solve

1.7 ms / iter

60 FPS render

12 MB heap

GTSAM (Native C++)

200-node graph, Multifrontal QR

0.9 ms / iter

N/A (CLI)

34 MB heap

g2o (Native C++)

200-node graph, Sparse Cholesky

1.1 ms / iter

N/A (CLI)

28 MB heap

xSLAM is roughly 1.5–1.9× slower than native on raw solve time. For this demo, that is acceptable because the render + solve loop still fits inside a 16ms frame budget.

Memory usage is also notably lower: the WASM linear memory is pre-allocated at 12 MB and never grows, while GTSAM's factor graph allocator consumes 34 MB for the same problem due to its more general multifrontal elimination tree.

09 // CONCLUSION & REPOSITORIES

xSLAM is a small experiment in making SLAM algorithms easier to inspect. WebAssembly SIMD handles the solver, Web Workers isolate computation, and WebGL keeps the state visual. The result is not a replacement for native robotics stacks, but it is useful for teaching, debugging, and comparing algorithm variants.

The next step is better experimental tooling: expose residuals, convergence traces, particle effective sample size, and timing histograms so changes to the solver can be compared directly in the page.

Source code and documentation: github.com/qxlsz/xslam — interactive live demo: qxlsz.github.io/xSLAM.