Paper · 2024-03-26 · cs.CV, cs.GR
2D Gaussian Splatting for Geometrically Accurate Radiance Fields
2DGS replaces the 3D Gaussian blobs of 3D Gaussian Splatting with flat, oriented 2D Gaussian disks, rendered by exact ray-splat intersection and trained with depth-distortion and normal-consistency losses. Meshes come from TSDF fusion of rendered depth. Author-reported DTU Chamfer distance is 0.80 vs 1.96 for 3DGS, with about 11 minutes of training per scene.
Gaussian splattingMulti-view reconstructionNeural radiance field (NeRF) and variants3d-reconstructionnovel-view-synthesisdifferentiable-renderingsurface-reconstruction
arXivPDFCodeMarkdown15 min read
AI-compiled by claude-opus-5-5 on 2026-10-18, not yet reviewed by a human. Every claim cites its source; check it when a detail matters.
In plain words
3D Gaussian Splatting rebuilds a scene from photos as millions of small, fuzzy, coloured blobs that can be drawn very fast. The pictures look great, but the blobs have no surface: each one is a soft 3D cloud, and where a camera ray "hits" it depends on the viewing angle. Extracting a clean 3D mesh from such a cloud gives noisy, bumpy geometry.
2D Gaussian Splatting flattens every blob into a thin elliptical disk, like a sticker floating in space. A disk has a clear front, back and normal direction, and a camera ray hits it at exactly one point no matter where the camera stands. So the depth you render from one view agrees with the depth from another view.
Rendering the disks is done by intersecting each pixel's ray with the disk plane, not by the usual approximate projection, so the result stays correct in perspective. Two extra training penalties push the disks to bunch up along each ray and to lie flat against the surface implied by the rendered depth. After training, depth maps from all cameras are fused into a mesh.
The result keeps most of 3D Gaussian Splatting's image quality and speed, while producing far cleaner surfaces than it on the DTU benchmark, at a fraction of the training time of neural implicit-surface methods.
TL;DR
2DGS represents a scene as oriented planar 2D Gaussians (surfels) with a center, two tangent vectors and two scales, so each primitive has a well-defined normal §4.1. Pixels are evaluated by an explicit ray-splat intersection computed from two transformed homogeneous planes, with an object-space low-pass filter for edge-on splats §4.2. Training adds a depth distortion loss and a normal consistency loss to the 3DGS photometric loss §5. Meshes come from TSDF fusion of rendered median depth maps §6.1. Author-reported DTU Chamfer distance 0.80 (3DGS 1.96, SuGaR 1.33) at 10.9 min training, while Mip-NeRF 360 rendering quality is slightly below 3DGS tab. 1 tab. 4.
Problem
3DGS models scenes with volumetric 3D Gaussians and renders them with an affine (local linear) approximation of the projection §3. For surface reconstruction this has four problems the paper names §3: volumetric blobs conflict with thin surfaces; 3DGS has no native surface normal; the plane at which a 3D Gaussian is evaluated changes with viewpoint, so geometry is not multi-view consistent fig. 2; and the affine projection is only accurate near each Gaussian's center. Meshes extracted from 3DGS are therefore noisy fig. 5. Neural SDF methods (NeuS, VolSDF) give good surfaces but take more than 12 hours per DTU scene tab. 1. Assumptions: posed multi-view RGB images plus a sparse SfM point cloud for initialisation §4.1 §5; surfaces are assumed fully opaque §7.
Method
Primitive §4.1. Each splat has center \mathbf{p}_k, two orthogonal unit tangent vectors \mathbf{t}_u, \mathbf{t}_v, scales (s_u, s_v), opacity \alpha and spherical-harmonics colour. The normal is \mathbf{t}_w = \mathbf{t}_u \times \mathbf{t}_v; \mathbf{R} = [\mathbf{t}_u, \mathbf{t}_v, \mathbf{t}_w] and \mathbf{S} is a 3\times3 diagonal matrix whose last entry is zero. A point on the splat plane is
P(u,v) = \mathbf{p}_k + s_u \mathbf{t}_u u + s_v \mathbf{t}_v v = \mathbf{H}(u,v,1,1)^\top eq. 4
\mathbf{H} = \begin{bmatrix} s_u \mathbf{t}_u & s_v \mathbf{t}_v & \mathbf{0} & \mathbf{p}_k \\ 0 & 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} \mathbf{R}\mathbf{S} & \mathbf{p}_k \\ \mathbf{0} & 1 \end{bmatrix} eq. 5
and the Gaussian value in local coordinates is
\mathcal{G}(\mathbf{u}) = \exp\left(-\frac{u^2+v^2}{2}\right) eq. 6
In code, the rotation is stored as an unnormalised quaternion (normalised on
read), scales are stored as log values and passed through exp, opacity
through a sigmoid gaussian_model.py.
Ray-splat intersection §4.2. With \mathbf{W} the 4×4 world-to-screen transform, a screen point is \mathbf{x} = (xz, yz, z, z)^\top = \mathbf{W}\mathbf{H}(u,v,1,1)^\top eq. 7. Inverting \mathbf{W}\mathbf{H} is unstable when a splat is seen edge-on, so instead the pixel ray is written as the intersection of the x-plane \mathbf{h}_x = (-1,0,0,x)^\top and y-plane \mathbf{h}_y = (0,-1,0,y)^\top, and both planes are moved into the splat's uv frame. Planes transform with the inverse transpose, so no inverse is needed §4.2:
\mathbf{h}_u = (\mathbf{W}\mathbf{H})^\top \mathbf{h}_x \qquad \mathbf{h}_v = (\mathbf{W}\mathbf{H})^\top \mathbf{h}_y eq. 8
The intersection point satisfies \mathbf{h}_u \cdot (u,v,1,1)^\top = \mathbf{h}_v \cdot (u,v,1,1)^\top = 0 eq. 9, giving (superscripts index the 4 plane components; the third components are always zero) eq. 10:
u(\mathbf{x}) = \frac{h_u^2 h_v^4 - h_u^4 h_v^2}{h_u^1 h_v^2 - h_u^2 h_v^1} \qquad v(\mathbf{x}) = \frac{h_u^4 h_v^1 - h_u^1 h_v^4}{h_u^1 h_v^2 - h_u^2 h_v^1} eq. 10
Depth z of the intersection then follows from eq. 7 §4.2.
Low-pass filter for degenerate splats §4.2. An edge-on splat collapses to a line and can miss every pixel center. The value is lower-bounded by a screen-space Gaussian around the projected center \mathbf{c}:
\hat{\mathcal{G}}(\mathbf{x}) = \max\left\{ \mathcal{G}(\mathbf{u}(\mathbf{x})), \mathcal{G}\left(\frac{\mathbf{x}-\mathbf{c}}{\sigma}\right) \right\} eq. 11
with \sigma = \sqrt{2}/2 §4.2.
Rasterization §4.2. As in 3DGS: per-splat screen bounding box, sort by center depth, bin into tiles, then front-to-back alpha blending, stopping when accumulated opacity saturates:
\mathbf{c}(\mathbf{x}) = \sum_{i=1} \mathbf{c}_i\,\alpha_i\,\hat{\mathcal{G}}_i(\mathbf{u}(\mathbf{x})) \prod_{j=1}^{i-1} \left(1 - \alpha_j\,\hat{\mathcal{G}}_j(\mathbf{u}(\mathbf{x}))\right) eq. 12
The renderer additionally outputs depth, normal and distortion maps §6.1.
Depth distortion loss §5. With blending weight \omega_i = \alpha_i \hat{\mathcal{G}}_i(\mathbf{u}(\mathbf{x})) \prod_{j<i}(1-\alpha_j\hat{\mathcal{G}}_j(\mathbf{u}(\mathbf{x}))) and intersection depth z_i:
\mathcal{L}_d = \sum_{i,j} \omega_i \omega_j |z_i - z_j| eq. 13
Unlike Mip-NeRF 360, gradients flow into z_i itself, pulling splats together along the ray §5. The implementation actually used is a squared version on NDC-mapped depth m = \mathrm{NDC}(z) with near 0.2 and far 1000, computed in one front-to-back pass app. A:
\mathcal{L} = \sum_{i=0}^{N-1} \sum_{j=0}^{i-1} \omega_i \omega_j (m_i - m_j)^2 = \sum_{i=0}^{N-1} \omega_i \left(m_i^2 A_{i-1} + D^2_{i-1} - 2 m_i D_{i-1}\right) app. A
where A_i = \sum_{j\le i}\omega_j, D_i = \sum_{j\le i}\omega_j m_j, D^2_i = \sum_{j\le i}\omega_j m_j^2 app. A.
Normal consistency loss §5. The surface is taken at the median intersection point \mathbf{p}_s where accumulated opacity reaches 0.5. The splat normal \mathbf{n}_i, flipped to face the camera, is aligned with the normal \mathbf{N} of the depth map:
\mathcal{L}_n = \sum_i \omega_i (1 - \mathbf{n}_i^\top \mathbf{N}) eq. 14
\mathbf{N}(x,y) = \frac{\nabla_x \mathbf{p}_s \times \nabla_y \mathbf{p}_s}{|\nabla_x \mathbf{p}_s \times \nabla_y \mathbf{p}_s|} eq. 15
with the gradients taken by finite differences of neighbouring depth points §5. The code uses central differences two pixels apart and leaves border pixels at zero point_utils.py.
Total loss §5:
\mathcal{L} = \mathcal{L}_c + \alpha \mathcal{L}_d + \beta \mathcal{L}_n eq. 16
\mathcal{L}_c is the 3DGS RGB loss (\mathcal{L}_1 plus D-SSIM). (This \alpha is a loss weight, not the splat opacity.)
Depths app. B. Mean depth z_\text{mean} = \sum_i \omega_i z_i / (\sum_i \omega_i + \epsilon); median depth z_\text{median} = \max\{z_i \mid T_i > 0.5\}, where T_i is the transmittance before splat i. If a ray never reaches 0.5 accumulated alpha, the last Gaussian is used.
Densification §6.1. 3DGS adaptive control, but the densification gradient is the gradient of the 3D center \mathbf{p}_k projected to screen space (2DGS has no 2D projected center in its backward pass). In code, a split samples new centers inside the splat's tangent plane (zero standard deviation along the normal) and divides scales by 1.6 for N=2 children gaussian_model.py.
Mesh extraction §6.1. Render (median) depth for all training views, fuse with Open3D TSDF, voxel size 0.004, truncation 0.02. Median depth beats expected depth and SPSR on splat centers tab. 5.
# 2DGS training step (one random training view)
# Input: camera, splats {center, quat, log_scale(2), opacity, sh}, gt_image
# Output: scalar loss
def render_pixel(pixel_x, pixel_y, sorted_splats, camera):
# pixel ray as two homogeneous planes [§4.2]
plane_x = (-1, 0, 0, pixel_x)
plane_y = (0, -1, 0, pixel_y)
transmittance = 1.0
outputs = zeros()
# splats sorted by center depth, front to back [§4.2]
for splat in sorted_splats:
# splat-to-screen matrix W H, no inversion [eq. 5] [eq. 8]
world_to_screen_splat = camera.full_proj @ splat.H
plane_u = transpose(world_to_screen_splat) @ plane_x
plane_v = transpose(world_to_screen_splat) @ plane_y
# closed-form intersection in uv space [eq. 10]
u, v = solve_two_planes(plane_u, plane_v)
g_surface = exp(-(u * u + v * v) / 2)
# screen-space low-pass, sigma = sqrt(2)/2 [eq. 11]
offset = (pixel_x, pixel_y) - project(splat.center)
g_filter = exp(-squared_norm(offset) / (2 * sigma * sigma))
g = max(g_surface, g_filter)
alpha = splat.opacity * g
weight = alpha * transmittance
# depth of the ray-splat hit point [eq. 7]
depth = intersection_depth(world_to_screen_splat, u, v)
# one-pass distortion with running sums A, D, D2 [app. A]
ndc = ndc_depth(depth, near=0.2, far=1000)
outputs.distortion += weight * (
ndc * ndc * outputs.acc_w
+ outputs.acc_wm2
- 2 * ndc * outputs.acc_wm)
outputs.acc_w += weight
outputs.acc_wm += weight * ndc
outputs.acc_wm2 += weight * ndc * ndc
# median depth: last hit while T > 0.5 [app. B]
if transmittance > 0.5:
outputs.median_depth = depth
# normal faces the camera [§5]
normal = facing_camera(splat.t_w, camera)
outputs.color += weight * splat.sh_color(camera)
outputs.normal += weight * normal
outputs.depth_sum += weight * depth
transmittance *= 1 - alpha
# stop once accumulated opacity saturates [§4.2]
if transmittance is saturated:
break
return outputs
def training_step(camera, splats, gt_image, alpha_dist, beta_normal):
maps = render_all_pixels(camera, splats)
# L1 + D-SSIM from 3DGS [§5]
loss_color = rgb_loss(maps.color, gt_image)
# depth-map normal by finite differences [eq. 15]
points = unproject(camera, maps.median_depth)
depth_normal = normalize(cross(grad_x(points), grad_y(points)))
# weighted normal agreement [eq. 14]
loss_normal = mean(maps.acc_w - dot(maps.normal, depth_normal))
loss_dist = mean(maps.distortion)
# total objective [eq. 16]
return loss_color + alpha_dist * loss_dist + beta_normal * loss_normalHyperparameters
| Name | Value | Where | Notes |
|---|---|---|---|
| depth distortion weight α | 1000 bounded, 100 unbounded scenes | §5 | code default 0, see Paper vs code [code: arguments/init.py#L86] |
| normal consistency weight β | 0.05 (all scenes) | §5 [code: arguments/init.py#L87] | |
| low-pass filter σ | √2/2 pixels | §4.2 | |
| distortion NDC near / far | 0.2 / 1000 | app. A | rasterizer uses far 100, see Paper vs code |
| densification gradient threshold | 0.0002 | §6.1 [code: arguments/init.py#L94] | on projected 3D-center gradient |
| opacity pruning threshold | 0.05 | §6.1 [code: arguments/init.py#L88] | paper: "every 3000 step"; code prunes at every densification step |
| opacity reset interval | 3000 (code) | [code: arguments/init.py#L91] | not stated in paper |
| densification interval / from / until | 100 / 500 / 15000 (code) | [code: arguments/init.py#L90-L93] | not stated in paper |
| iterations | 30k (15k variant also reported) | tab. 1 [code: arguments/init.py#L75] | ablations use 15k §6.3 |
| RGB loss D-SSIM weight | 0.2 (code) | [code: arguments/init.py#L85] | paper defers to 3DGS |
| learning rates | position 1.6e-4 → 1.6e-6 (× scene extent, exponential), features 2.5e-3 (SH rest /20), opacity 0.05, scaling 5e-3, rotation 1e-3 (code) | [code: arguments/init.py#L76-L83] gaussian_model.py | not stated in paper; Adam eps 1e-15 |
| SH degree | 3, raised by one every 1000 iters (code) | [code: arguments/init.py#L49] train.py | not stated in paper |
| initial opacity / rotation | 0.1 / random quaternion (code) | gaussian_model.py | not stated in paper |
| initial scale | from nearest-neighbour distance of SfM points, same for both axes (code) | gaussian_model.py | not stated in paper |
| regularizer start | distortion after iter 3000, normal after iter 7000 (code) | train.py | not stated in paper |
| TSDF voxel size / truncation | 0.004 / 0.02 | §6.1 | scripts use truncation 0.016, see Paper vs code |
| DTU input resolution | 800×600 (downsampled from 1600×1200) | §6.2 dtu_eval.py | -r 2 |
| hardware | single RTX 3090 | §6.1 |
Results
Author-reported. DTU: 15 scenes, 800×600, COLMAP sparse init, Chamfer distance (lower is better) §6.2 tab. 1. Times are per scene on one RTX 3090.
| Method | Mean CD ↓ | Time |
|---|---|---|
| NeRF | 1.49 | > 12 h |
| VolSDF | 0.86 | > 12 h |
| NeuS | 0.84 | > 12 h |
| 3DGS | 1.96 | 11.2 min |
| SuGaR | 1.33 | ~1 h |
| 2DGS-15k | 0.83 | 5.5 min |
| 2DGS-30k | 0.80 | 10.9 min |
Per-scene DTU Chamfer distance tab. 1:
| Scan | 24 | 37 | 40 | 55 | 63 | 65 | 69 | 83 | 97 | 105 | 106 | 110 | 114 | 118 | 122 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| NeRF | 1.90 | 1.60 | 1.85 | 0.58 | 2.28 | 1.27 | 1.47 | 1.67 | 2.05 | 1.07 | 0.88 | 2.53 | 1.06 | 1.15 | 0.96 |
| VolSDF | 1.14 | 1.26 | 0.81 | 0.49 | 1.25 | 0.70 | 0.72 | 1.29 | 1.18 | 0.70 | 0.66 | 1.08 | 0.42 | 0.61 | 0.55 |
| NeuS | 1.00 | 1.37 | 0.93 | 0.43 | 1.10 | 0.65 | 0.57 | 1.48 | 1.09 | 0.83 | 0.52 | 1.20 | 0.35 | 0.49 | 0.54 |
| 3DGS | 2.14 | 1.53 | 2.08 | 1.68 | 3.49 | 2.21 | 1.43 | 2.07 | 2.22 | 1.75 | 1.79 | 2.55 | 1.53 | 1.52 | 1.50 |
| SuGaR | 1.47 | 1.33 | 1.13 | 0.61 | 2.25 | 1.71 | 1.15 | 1.63 | 1.62 | 1.07 | 0.79 | 2.45 | 0.98 | 0.88 | 0.79 |
| 2DGS-15k | 0.48 | 0.92 | 0.42 | 0.40 | 1.04 | 0.83 | 0.83 | 1.36 | 1.27 | 0.76 | 0.72 | 1.63 | 0.40 | 0.76 | 0.60 |
| 2DGS-30k | 0.48 | 0.91 | 0.39 | 0.39 | 1.01 | 0.83 | 0.81 | 1.36 | 1.27 | 0.76 | 0.70 | 1.40 | 0.40 | 0.76 | 0.52 |
DTU efficiency tab. 3 (PSNR on training views):
| Method | CD ↓ | PSNR ↑ | Time ↓ | Storage (MB) ↓ |
|---|---|---|---|---|
| 3DGS | 1.96 | 35.76 | 11.2 min | 113 |
| SuGaR | 1.33 | 34.57 | ~1 h | 1247 |
| 2DGS-15k | 0.83 | 33.42 | 5.5 min | 52 |
| 2DGS-30k | 0.80 | 34.52 | 10.9 min | 52 |
Tanks and Temples, F1 score (higher is better) tab. 2:
| Scene | NeuS | Geo-Neus | Neuralangelo | SuGaR | 3DGS | 2DGS |
|---|---|---|---|---|---|---|
| Barn | 0.29 | 0.33 | 0.70 | 0.14 | 0.13 | 0.41 |
| Caterpillar | 0.29 | 0.26 | 0.36 | 0.16 | 0.08 | 0.23 |
| Courthouse | 0.17 | 0.12 | 0.28 | 0.08 | 0.09 | 0.16 |
| Ignatius | 0.83 | 0.72 | 0.89 | 0.33 | 0.04 | 0.51 |
| Meetingroom | 0.24 | 0.20 | 0.32 | 0.15 | 0.01 | 0.17 |
| Truck | 0.45 | 0.45 | 0.48 | 0.26 | 0.19 | 0.45 |
| Mean | 0.38 | 0.35 | 0.50 | 0.19 | 0.09 | 0.32 |
| Time | > 24 h | > 24 h | > 24 h | > 1 h | 14.3 min | 15.5 min |
Mip-NeRF 360 novel view synthesis, 30k iterations for 3DGS, SuGaR and 2DGS; other baselines copied from their papers tab. 4:
| Method | Outdoor PSNR ↑ | SSIM ↑ | LPIPS ↓ | Indoor PSNR ↑ | SSIM ↑ | LPIPS ↓ |
|---|---|---|---|---|---|---|
| NeRF | 21.46 | 0.458 | 0.515 | 26.84 | 0.790 | 0.370 |
| Deep Blending | 21.54 | 0.524 | 0.364 | 26.40 | 0.844 | 0.261 |
| Instant NGP | 22.90 | 0.566 | 0.371 | 29.15 | 0.880 | 0.216 |
| MERF | 23.19 | 0.616 | 0.343 | 27.80 | 0.855 | 0.271 |
| BakedSDF | 22.47 | 0.585 | 0.349 | 27.06 | 0.836 | 0.258 |
| MipNeRF360 | 24.47 | 0.691 | 0.283 | 31.72 | 0.917 | 0.180 |
| Mobile-NeRF | 21.95 | 0.470 | 0.470 | - | - | - |
| SuGaR | 22.93 | 0.629 | 0.356 | 29.43 | 0.906 | 0.225 |
| 3DGS | 24.64 | 0.731 | 0.234 | 30.41 | 0.920 | 0.189 |
| 2DGS | 24.34 | 0.717 | 0.246 | 30.40 | 0.916 | 0.195 |
Ablations on DTU, 15k iterations tab. 5:
| Variant | Accuracy ↓ | Completion ↓ | Average ↓ |
|---|---|---|---|
| A. w/o normal consistency | 1.35 | 1.13 | 1.24 |
| B. w/o depth distortion | 0.89 | 0.87 | 0.88 |
| C. expected depth instead of median | 0.88 | 1.01 | 0.94 |
| D. SPSR instead of TSDF | 1.25 | 0.89 | 1.07 |
| E. Full model | 0.79 | 0.86 | 0.83 |
Additional baselines on DTU, 30k iterations tab. 6:
| Variant | Accuracy ↓ | Completion ↓ | Average ↓ |
|---|---|---|---|
| SuGaR | 1.48 | 1.17 | 1.33 |
| SuGaR + TSDF | 2.47 | 1.90 | 2.18 |
| 3DGS + SPSR (center) | 2.05 | 1.25 | 1.65 |
| 3DGS + TSDF (mean) | 1.93 | 1.99 | 1.96 |
| 2DGS + SPSR (center) | 1.25 | 0.89 | 1.07 |
| 2DGS (affine approx) + TSDF (mean) | 0.96 | 1.20 | 1.08 |
| 2DGS (our rasterizer) + TSDF (mean) | 0.79 | 0.98 | 0.88 |
| 2DGS (our rasterizer) + TSDF (median) | 0.78 | 0.83 | 0.80 |
The affine-approximation baseline is 3DGS rasterization with one scale fixed to 1e-6 app. C.
NeRF-Synthetic PSNR, no regularizers tab. 7:
| Method | Mic | Chair | Ship | Materials | Lego | Drums | Ficus | Hotdog | Mean |
|---|---|---|---|---|---|---|---|---|---|
| Plenoxels | 33.26 | 33.98 | 29.62 | 29.14 | 34.10 | 25.35 | 31.83 | 36.81 | 31.76 |
| INGP-Base | 36.22 | 35.00 | 31.10 | 29.78 | 36.39 | 26.02 | 33.51 | 37.40 | 33.18 |
| Mip-NeRF | 36.51 | 35.14 | 30.41 | 30.71 | 35.70 | 25.48 | 33.29 | 37.48 | 33.09 |
| 3DGS | 35.36 | 35.83 | 30.80 | 30.00 | 35.78 | 26.15 | 34.87 | 37.72 | 33.32 |
| 2DGS | 35.09 | 35.05 | 30.60 | 29.74 | 35.10 | 26.05 | 35.57 | 37.36 | 33.07 |
Tanks and Temples PSNR tab. 8:
| Method | Barn | Caterpillar | Courthouse | Ignatius | Meetingroom | Truck | Mean |
|---|---|---|---|---|---|---|---|
| SuGaR | 28.63 | 23.27 | 23.33 | 20.72 | 25.47 | 24.40 | 24.16 |
| 3DGS | 27.99 | 24.82 | 23.33 | 23.95 | 26.89 | 25.01 | 25.33 |
| 2DGS | 28.79 | 24.23 | 23.51 | 23.82 | 26.15 | 26.85 | 25.56 |
Mip-NeRF 360 per scene tab. 9:
| Metric | Method | bicycle | flowers | garden | stump | treehill | room | counter | kitchen | bonsai | mean |
|---|---|---|---|---|---|---|---|---|---|---|---|
| PSNR ↑ | SuGaR | 23.34 | 19.54 | 25.40 | 25.07 | 21.30 | 29.97 | 27.56 | 29.41 | 30.77 | 25.82 |
| PSNR ↑ | 3DGS | 25.24 | 21.52 | 27.41 | 26.55 | 22.49 | 30.63 | 28.70 | 30.32 | 31.98 | 27.20 |
| PSNR ↑ | 2DGS | 24.87 | 21.15 | 26.95 | 26.47 | 22.27 | 31.06 | 28.55 | 30.50 | 31.52 | 27.03 |
| SSIM ↑ | SuGaR | 0.634 | 0.499 | 0.762 | 0.705 | 0.546 | 0.904 | 0.885 | 0.902 | 0.933 | 0.752 |
| SSIM ↑ | 3DGS | 0.771 | 0.605 | 0.868 | 0.775 | 0.638 | 0.914 | 0.905 | 0.922 | 0.938 | 0.815 |
| SSIM ↑ | 2DGS | 0.752 | 0.588 | 0.852 | 0.765 | 0.627 | 0.912 | 0.900 | 0.919 | 0.933 | 0.805 |
| LPIPS ↓ | SuGaR | 0.354 | 0.407 | 0.240 | 0.325 | 0.452 | 0.259 | 0.244 | 0.178 | 0.220 | 0.298 |
| LPIPS ↓ | 3DGS | 0.205 | 0.336 | 0.103 | 0.210 | 0.317 | 0.220 | 0.204 | 0.129 | 0.205 | 0.214 |
| LPIPS ↓ | 2DGS | 0.218 | 0.346 | 0.115 | 0.222 | 0.329 | 0.223 | 0.208 | 0.133 | 0.214 | 0.223 |
Paper vs code
Checked at code_commit. The CUDA rasterizer lives in the submodule
hbb1/diff-surfel-rasterization, pinned at commit e0ed0207; it was read on
GitHub rather than cloned, so its findings below name files but carry no
line citations.
- Distortion weight α. Paper: 1000 bounded, 100 unbounded §5. Code default is 0, so distortion is off unless passed [code: arguments/init.py#L86]. The DTU script passes 1000 dtu_eval.py. The Tanks and Temples script prints 100 (or 10 for Meetingroom and Courthouse) but does not pass it to the command it runs, so those runs train with 0 tnt_eval.py. The Mip-NeRF 360 script passes no value either m360_eval.py.
- Distortion form and NDC far plane. Eq. 13 uses |z_i - z_j| §5;
app. A and the rasterizer use squared differences of NDC depth. The paper
gives near/far 0.2/1000 app. A; the rasterizer's
cuda_rasterizer/auxiliary.hsetsnear_n = 0.2,far_n = 100.0. - Regularizer warm-up. Not in the paper. Code enables distortion after iteration 3000 and normal consistency after iteration 7000 train.py.
- Normal loss reduction. Eq. 14 sums \omega_i(1-\mathbf{n}_i^\top \mathbf{N}) per ray. Code renders the weighted normal sum \sum_i \omega_i \mathbf{n}_i, multiplies the depth normal by the detached accumulated alpha, and averages one minus their dot product over all pixels [code: gaussian_renderer/init.py#L141-L147] train.py. So the code computes 1 - A\sum_i \omega_i \mathbf{n}_i^\top \mathbf{N} with A = \sum_i \omega_i, while eq. 14 gives A - \sum_i \omega_i \mathbf{n}_i^\top \mathbf{N}. The two agree only where A = 1; on partly transparent pixels the code scales the normal term by A.
- Depth used for the normal target. Paper: median point §5. Code mixes
expected and median depth by
depth_ratio, default 0 (expected depth) [code: arguments/init.py#L69] [code: gaussian_renderer/init.py#L137-L144]. The DTU and TnT scripts set 1.0 (median); Mip-NeRF 360 uses the default dtu_eval.py m360_eval.py. The README recommends expected depth for unbounded scenes to reduce disk aliasing. - TSDF truncation. Paper: voxel 0.004, truncation 0.02 §6.1. Scripts use voxel 0.004, truncation 0.016, depth cut-off 3.0 for DTU and the four object-centric TnT scenes, and voxel 0.006, truncation 0.024, cut-off 4.5 for Meetingroom and Courthouse dtu_eval.py tnt_eval.py. Only the function default matches the paper mesh_utils.py. The depth cut-off is not stated in the paper.
- Opacity pruning. Paper: below 0.05 "every 3000 step" §6.1. Code prunes below 0.05 at every densification call (every 100 iterations from 500 to 15000), and also prunes splats larger than 20 px on screen or 0.1× scene extent after the first opacity reset; opacities are reset every 3000 iterations train.py gaussian_model.py.
- Median depth. App. B defines the maximum z_i with T_i > 0.5. The
rasterizer's
forward.cukeeps the depth of the last splat processed while T > 0.5, in center-depth sort order, which is not necessarily the largest intersection depth. - Rasterizer details not in the paper (from
forward.cu/auxiliary.hof the submodule): per-splat alpha clamped to 0.99, splats with alpha < 1/255 skipped, termination when transmittance would drop below 1e-4, 3σ bounding radius, normals flipped toward the camera, back-face culling flag enabled. - Mip-NeRF 360 resolution. Script trains outdoor scenes on
images_4and indoor scenes onimages_2m360_eval.py; the paper does not state the resolution.
Pitfalls
- Do not reproduce TnT or Mip-NeRF 360 with the official scripts and expect
the paper's α. As shipped they train with zero distortion weight; pass
--lambda_distyourself. Which α produced tab. 2 and tab. 4 is therefore uncertain. - Two different distortion losses. Eq. 13 (L1 on raw depth) is not what is implemented (squared NDC depth, app. A). Use the app. A form and the NDC mapping if you want the reported numbers; also note the near/far mismatch (paper 1000, code 100). The NDC mapping down-weights distant splats, so the far plane changes the effective weight.
- The normal loss needs median or expected depth chosen per scene type. Median depth gives better DTU meshes tab. 5 but the authors' code defaults to expected depth for unbounded scenes; the paper does not mention this switch.
- Warm-up matters. Enabling the normal loss from iteration 0 on random initial orientations is not what the code does; it waits until 7000.
- Ray-splat intersection is a division. Eq. 10's denominator h_u^1 h_v^2 - h_u^2 h_v^1 goes to zero for edge-on splats; the low-pass max in eq. 11 is what keeps such splats visible and differentiable. Do not drop it.
- Densification signal is approximate. 2DGS has no 2D-center gradient; the 3D center gradient projected to screen is used instead §6.1. In code this is a dummy zero tensor whose gradient the rasterizer fills [code: gaussian_renderer/init.py#L26-L31].
- Splits stay in-plane. New centers are sampled only along the tangent directions gaussian_model.py; sampling in 3D like 3DGS would scatter points off the surface.
- Mesh extraction needs a depth cut-off. Bounded TSDF fusion needs a scene-dependent maximum depth (3.0 or 4.5 in scripts); the paper does not state it. Unbounded scenes need the separate contraction-based extractor in the code mesh_utils.py.
- Camera model. The README warns the code supports only ideal pinhole cameras with the principal point at the image center.
- Known limits §7: semi-transparent surfaces (glass) fail because full opacity is assumed; densification favours texture-rich over geometry-rich regions; the regularizers trade image quality for geometry and can over-smooth. Holes can appear in very bright regions fig. 12.
- Image quality is slightly below 3DGS on Mip-NeRF 360 (outdoor PSNR 24.34 vs 24.64) tab. 4 and on NeRF-Synthetic (33.07 vs 33.32) tab. 7.
- License. The official code is under the Inria Gaussian-Splatting license, non-commercial research use only; reimplement rather than copy.
Lineage
- Builds on: 3D Gaussian Splatting (Kerbl et al., arXiv:2308.04079): same tile rasterizer, SH colour, adaptive density control and RGB loss; primitives flattened to 2D and projection replaced by ray-splat intersection §3 §4.
- Builds on: surface splatting and perspective-accurate splatting (Zwicker et al.), hardware ray-splat intersection (Weyrich et al.), object-space low-pass filter (Botsch et al.) §4.2; distortion loss from Mip-NeRF 360 (arXiv:2111.12077) §5.
- Compared against: NeuS, VolSDF, NeRF, 3DGS, SuGaR (arXiv:2311.12775), Geo-Neus, Neuralangelo tab. 1 tab. 2.
Source and license
Adapted from “2D Gaussian Splatting for Geometrically Accurate Radiance Fields” by Binbin Huang, Zehao Yu, Anpei Chen, Andreas Geiger, Shenghua Gao (arXiv:2403.17888), used under CC BY 4.0. Changes: condensed and restructured for implementation, with tables, equations and algorithms taken from the paper and its code, each cited to its source. Not affiliated with or endorsed by the authors. Code citations refer to https://github.com/hbb1/2d-gaussian-splatting at commit f3e3b9fa67bb (Gaussian-Splatting License (Inria/MPII, non-commercial)). Any quoted code is shown under that license.