Phase 04 - Lesson 22

3D Gaussian Splatting desde cero

Una escena es una nube de millones de Gaussianas 3D. Cada una tiene posición, orientación, escala, opacidad y un color que depende de la dirección de visualización. Rasterízalas, haz backprop a través de la rasterización, listo.

Tipo: Build Lenguajes: Python Prerrequisitos: Fase 4 Lección 13 (Visión 3D y NeRF), Fase 1 Lección 12 (Operaciones con Tensores), Fase 4 Lección 10 (Nociones de Diffusion, opcional) Tiempo: ~90 minutos

Objetivos de aprendizaje

  • Explicar por qué el 3D Gaussian Splatting reemplazó al NeRF como el estándar de producción para la reconstrucción 3D fotorrealista en 2026
  • Enunciar los seis parámetros por Gaussiana (posición, cuaternión de rotación, escala, opacidad, color por armónicos esféricos, feature opcional) y cuántos floats aporta cada uno
  • Implementar desde cero un rasterizador de Gaussian splatting 2D usando composición alpha, y luego mostrar cómo el caso 3D se proyecta al mismo bucle
  • Usar nerfstudio, gsplat o SuperSplat para reconstruir una escena a partir de 20-50 fotos y exportar a la extensión glTF KHR_gaussian_splatting o al schema OpenUSD 26.03 UsdVolParticleField3DGaussianSplat

El problema

Un NeRF almacena una escena como los pesos de una MLP. Cada pixel renderizado son cientos de consultas a la MLP a lo largo de un rayo. El entrenamiento toma horas, el renderizado toma segundos, y los pesos no se pueden editar — si quieres mover una silla dentro de una escena, tienes que reentrenar.

El 3D Gaussian Splatting (Kerbl, Kopanas, Leimkühler, Drettakis, SIGGRAPH 2023) reemplazó todo eso. Una escena es un conjunto explícito de Gaussianas 3D. El renderizado es rasterización en GPU a 100+ fps. El entrenamiento toma minutos. La edición es directa: traslada un subconjunto de Gaussianas y habrás movido la silla. Para 2026 el Khronos Group ratificó una extensión glTF para Gaussian splats, OpenUSD 26.03 trae un schema de Gaussian splat, Zillow y Apartments.com renderizan bienes raíces con ellas, y la mayoría de los nuevos artículos de investigación sobre reconstrucción 3D son variantes de la idea central del 3DGS.

El modelo mental es simple, pero las matemáticas tienen suficientes partes móviles como para que la mayoría de las introducciones empiecen por la rasterización y se salten las proyecciones y los armónicos esféricos. Esta lección construye todo el asunto — una versión 2D primero, luego la extensión 3D.

El concepto

Qué lleva una Gaussiana

Una Gaussiana 3D es un blob paramétrico en el espacio con estos atributos:

position         mu         (3,)    centre in world coordinates
rotation         q          (4,)    unit quaternion encoding orientation
scale            s          (3,)    log-scales per axis (exponentiated at render time)
opacity          alpha      (1,)    post-sigmoid opacity [0, 1]
SH coefficients  c_lm       (3 * (L+1)^2,)   view-dependent colour

Rotación + escala construyen una covarianza 3x3: Sigma = R S S^T R^T. Esa es la forma de la Gaussiana en 3D. Los armónicos esféricos permiten que el color cambie con la dirección de visualización — reflejos especulares, brillo sutil, glow dependiente de la vista — sin almacenar texturas por vista. Con grado 3 de SH obtienes 16 coeficientes por canal de color, 48 floats por Gaussiana solo para el color.

Una escena suele tener 1-5 millones de Gaussianas. Cada una almacena alrededor de 60 floats (3 + 4 + 3 + 1 + 48 + varios). Eso da 240 MB para una escena de cinco millones de Gaussianas — mucho más pequeña que la nube de puntos equivalente con textura por punto, y un orden de magnitud más pequeña que los pesos de la MLP de un NeRF re-renderizados a alta resolución.

Rasterización, no ray marching

flowchart LR
    SCENE["Millones de Gaussianas 3D<br/>(posición, rotación, escala,<br/>opacidad, color SH)"] --> PROJ["Proyectar a 2D<br/>(extrínsecos + intrínsecos de la cámara)"]
    PROJ --> TILES["Asignar a tiles<br/>(16x16 en espacio de pantalla)"]
    TILES --> SORT["Ordenar por profundidad<br/>por tile"]
    SORT --> ALPHA["Componer alpha<br/>de adelante hacia atrás"]
    ALPHA --> PIX["Color del pixel"]

    style SCENE fill:#dbeafe,stroke:#2563eb
    style ALPHA fill:#fef3c7,stroke:#d97706
    style PIX fill:#dcfce7,stroke:#16a34a

Cinco pasos, todos amigables para GPU. Sin consulta a la MLP por pixel. Una sola RTX 3080 Ti renderiza 6 millones de splats a 147 fps.

El paso de proyección

La Gaussiana 3D en la posición de mundo mu con covarianza 3D Sigma proyecta a una Gaussiana 2D en la posición de pantalla mu' con covarianza 2D Sigma':

mu' = project(mu)
Sigma' = J W Sigma W^T J^T          (2 x 2)

W = viewing transform (rotation + translation of camera)
J = Jacobian of the perspective projection at mu'

La huella (footprint) de la Gaussiana 2D es una elipse cuyos ejes son los autovectores de Sigma'. Cada pixel dentro de esa elipse recibe la contribución de la Gaussiana, ponderada por exp(-0.5 * (p - mu')^T Sigma'^-1 (p - mu')).

La regla de composición alpha

Para un pixel, las Gaussianas que lo cubren se ordenan de atrás hacia adelante (o equivalentemente de adelante hacia atrás con la fórmula invertida). El color se compone con la misma ecuación de todo rasterizador semitransparente desde los años 1980:

C_pixel = sum_i alpha_i * T_i * c_i

T_i = prod_{j < i} (1 - alpha_j)       transmittance up to i
alpha_i = opacity_i * exp(-0.5 * d^T Sigma'^-1 d)   local contribution
c_i = eval_SH(SH_i, view_direction)    view-dependent colour

Esta es la misma ecuación del renderizado volumétrico del NeRF, solo que sobre un conjunto explícito y disperso de Gaussianas en vez de muestras densas a lo largo de un rayo. Esa identidad es la razón de que la calidad renderizada iguale al NeRF — ambos están integrando la misma ecuación del campo de radiancia.

Por qué esto es diferenciable

Cada paso — proyección, asignación a tiles, composición alpha, evaluación de SH — es diferenciable con respecto a los parámetros de las Gaussianas. Dada una imagen ground-truth, calcula la loss de pixel renderizado, haz backprop a través del rasterizador, actualiza todos los (mu, q, s, alpha, c_lm) por descenso de gradiente. A lo largo de ~30.000 iteraciones las Gaussianas encuentran sus posiciones, escalas y colores correctos.

Densificación y poda

Un conjunto fijo de Gaussianas no puede cubrir una escena compleja. El entrenamiento incluye dos mecanismos adaptativos:

  • Clonar una Gaussiana en su posición actual cuando la magnitud del gradiente es alta pero su escala es pequeña — la reconstrucción necesita más detalle aquí.
  • Dividir una Gaussiana de gran escala en dos más pequeñas cuando su gradiente es alto — una Gaussiana grande es demasiado suave para ajustar la región.
  • Podar Gaussianas cuya opacidad cae por debajo de un umbral — no están contribuyendo.

La densificación corre cada N iteraciones. Una escena suele crecer de ~100k Gaussianas iniciales (sembradas a partir de puntos de SfM) a 1-5M al final del entrenamiento.

Armónicos esféricos en un párrafo

El color dependiente de la vista es una función c(direction) en la esfera unitaria. Los armónicos esféricos son la base de Fourier de la esfera. Trunca en el grado L y obtienes (L+1)^2 funciones de base por canal. Evaluar el color para una nueva vista es un producto punto entre los coeficientes de SH aprendidos y la base evaluada en la dirección de visualización. Grado 0 = un coeficiente = color constante. Grado 3 = 16 coeficientes = suficiente para capturar sombreado Lambertiano, especular y reflexión moderada. Los artículos de SD Gaussian Splatting usan grado 3 por defecto.

El stack de producción de 2026

1. Capture         smartphone / DJI drone / handheld scanner
2. SfM / MVS       COLMAP or GLOMAP derives camera poses + sparse points
3. Train 3DGS      nerfstudio / gsplat / inria official / PostShot (~10-30 min on RTX 4090)
4. Edit            SuperSplat / SplatForge (clean floaters, segment)
5. Export          .ply -> glTF KHR_gaussian_splatting or .usd (OpenUSD 26.03)
6. View            Cesium / Unreal / Babylon.js / Three.js / Vision Pro

Variantes 4D y generativas

  • 4D Gaussian Splatting — las Gaussianas son funciones del tiempo; se usa para video volumétrico (Superman 2026, "Helicopter" de A$AP Rocky).
  • Splats generativos — modelos text-to-splat (Marble de World Labs) que alucinan escenas enteras.
  • 3D Gaussian Unscented Transform — la variante del NuRec de NVIDIA para simulación de conducción autónoma.

Constrúyelo

Paso 1: Una Gaussiana 2D

Primero construimos un rasterizador 2D. El caso 3D se reduce a él tras la proyección.

import torch
import torch.nn as nn
import torch.nn.functional as F


def eval_2d_gaussian(means, covs, points):
    """
    means:  (G, 2)      centres
    covs:   (G, 2, 2)   covariance matrices
    points: (H, W, 2)   pixel coordinates
    returns: (G, H, W)  density at every pixel for every Gaussian
    """
    G = means.size(0)
    H, W, _ = points.shape
    flat = points.view(-1, 2)
    inv = torch.linalg.inv(covs)
    diff = flat[None, :, :] - means[:, None, :]
    d = torch.einsum("gpi,gij,gpj->gp", diff, inv, diff)
    density = torch.exp(-0.5 * d)
    return density.view(G, H, W)

einsum hace la forma cuadrática diff^T Sigma^-1 diff para cada par (Gaussiana, pixel).

Paso 2: Rasterizador de splatting 2D

Composición alpha de adelante hacia atrás. La profundidad en 2D no tiene sentido, así que usamos un escalar aprendido por Gaussiana para el orden.

def rasterise_2d(means, covs, colours, opacities, depths, image_size):
    """
    means:     (G, 2)
    covs:      (G, 2, 2)
    colours:   (G, 3)
    opacities: (G,)     in [0, 1]
    depths:    (G,)     per-Gaussian scalar used for ordering
    image_size: (H, W)
    returns:   (H, W, 3) rendered image
    """
    H, W = image_size
    yy, xx = torch.meshgrid(
        torch.arange(H, dtype=torch.float32, device=means.device),
        torch.arange(W, dtype=torch.float32, device=means.device),
        indexing="ij",
    )
    points = torch.stack([xx, yy], dim=-1)

    densities = eval_2d_gaussian(means, covs, points)
    alphas = opacities[:, None, None] * densities
    alphas = alphas.clamp(0.0, 0.99)

    order = torch.argsort(depths)
    alphas = alphas[order]
    colours_sorted = colours[order]

    T = torch.ones(H, W, device=means.device)
    out = torch.zeros(H, W, 3, device=means.device)
    for i in range(means.size(0)):
        a = alphas[i]
        out += (T * a)[..., None] * colours_sorted[i][None, None, :]
        T = T * (1.0 - a)
    return out

No es rápido — una implementación real usa kernels CUDA basados en tiles — pero son exactamente las matemáticas correctas y totalmente diferenciable.

Paso 3: Una escena de splats 2D entrenable

class Splats2D(nn.Module):
    def __init__(self, num_splats=128, image_size=64, seed=0):
        super().__init__()
        g = torch.Generator().manual_seed(seed)
        H, W = image_size, image_size
        self.means = nn.Parameter(torch.rand(num_splats, 2, generator=g) * torch.tensor([W, H]))
        self.log_scale = nn.Parameter(torch.ones(num_splats, 2) * math.log(2.0))
        self.rot = nn.Parameter(torch.zeros(num_splats))  # single angle in 2D
        self.colour_logits = nn.Parameter(torch.randn(num_splats, 3, generator=g) * 0.5)
        self.opacity_logit = nn.Parameter(torch.zeros(num_splats))
        self.depth = nn.Parameter(torch.rand(num_splats, generator=g))

    def covs(self):
        s = torch.exp(self.log_scale)
        c, si = torch.cos(self.rot), torch.sin(self.rot)
        R = torch.stack([
            torch.stack([c, -si], dim=-1),
            torch.stack([si, c], dim=-1),
        ], dim=-2)
        S = torch.diag_embed(s ** 2)
        return R @ S @ R.transpose(-1, -2)

    def forward(self, image_size):
        covs = self.covs()
        colours = torch.sigmoid(self.colour_logits)
        opacities = torch.sigmoid(self.opacity_logit)
        return rasterise_2d(self.means, covs, colours, opacities, self.depth, image_size)

log_scale, opacity_logit y colour_logits son todos parámetros sin restricción mapeados por la activación correcta en el momento del renderizado. Ese es el patrón estándar de toda implementación de 3DGS.

Paso 4: Ajustar Gaussianas 2D a una imagen objetivo

import math
import numpy as np

def make_target(size=64):
    yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
    img = np.zeros((size, size, 3), dtype=np.float32)
    # Red circle
    mask = (xx - 20) ** 2 + (yy - 20) ** 2 < 10 ** 2
    img[mask] = [1.0, 0.2, 0.2]
    # Blue square
    mask = (np.abs(xx - 45) < 8) & (np.abs(yy - 40) < 8)
    img[mask] = [0.2, 0.3, 1.0]
    return torch.from_numpy(img)


target = make_target(64)
model = Splats2D(num_splats=64, image_size=64)
opt = torch.optim.Adam(model.parameters(), lr=0.05)

for step in range(200):
    pred = model((64, 64))
    loss = F.mse_loss(pred, target)
    opt.zero_grad(); loss.backward(); opt.step()
    if step % 40 == 0:
        print(f"step {step:3d}  mse {loss.item():.4f}")

A lo largo de 200 pasos las 64 Gaussianas se acomodan en las dos formas. Esa es toda la idea — descenso de gradiente sobre primitivas geométricas explícitas.

Paso 5: De 2D a 3D

La extensión 3D mantiene el mismo bucle. Las adiciones:

  1. La rotación por Gaussiana es un cuaternión en vez de un único ángulo.
  2. La covarianza es R S S^T R^T con R construido a partir del cuaternión y S = diag(exp(log_scale)).
  3. La proyección (mu, Sigma) -> (mu', Sigma') usa los extrínsecos de la cámara y el Jacobiano de la proyección perspectiva en mu.
  4. El color pasa a ser una expansión en armónicos esféricos; evalúala en la dirección de visualización.
  5. El ordenamiento por profundidad viene de la z real del espacio de cámara en vez de un escalar aprendido.

Toda implementación de producción (gsplat, inria/gaussian-splatting, nerfstudio) hace exactamente esto en la GPU con kernels CUDA basados en tiles.

Paso 6: Evaluación de armónicos esféricos

La base de SH hasta grado 3 tiene 16 términos por canal. Evaluación:

def eval_sh_degree_3(sh_coeffs, dirs):
    """
    sh_coeffs: (..., 16, 3)   last dim is RGB channels
    dirs:      (..., 3)       unit vectors
    returns:   (..., 3)
    """
    C0 = 0.282094791773878
    C1 = 0.488602511902920
    C2 = [1.092548430592079, 1.092548430592079,
          0.315391565252520, 1.092548430592079,
          0.546274215296039]
    x, y, z = dirs[..., 0], dirs[..., 1], dirs[..., 2]
    x2, y2, z2 = x * x, y * y, z * z
    xy, yz, xz = x * y, y * z, x * z

    result = C0 * sh_coeffs[..., 0, :]
    result = result - C1 * y[..., None] * sh_coeffs[..., 1, :]
    result = result + C1 * z[..., None] * sh_coeffs[..., 2, :]
    result = result - C1 * x[..., None] * sh_coeffs[..., 3, :]

    result = result + C2[0] * xy[..., None] * sh_coeffs[..., 4, :]
    result = result + C2[1] * yz[..., None] * sh_coeffs[..., 5, :]
    result = result + C2[2] * (2.0 * z2 - x2 - y2)[..., None] * sh_coeffs[..., 6, :]
    result = result + C2[3] * xz[..., None] * sh_coeffs[..., 7, :]
    result = result + C2[4] * (x2 - y2)[..., None] * sh_coeffs[..., 8, :]

    # degree 3 terms omitted here for brevity; full 16-coefficient version in the code file
    return result

Los sh_coeffs aprendidos almacenan el "color en cada dirección" para esa Gaussiana. En el momento del renderizado evalúas con respecto a la dirección de vista actual y obtienes un RGB de 3 componentes.

Úsalo

Para trabajo real de 3DGS, usa gsplat (Meta) o nerfstudio:

pip install nerfstudio gsplat
ns-download-data example
ns-train splatfacto --data path/to/data

splatfacto es el entrenador de 3DGS de nerfstudio. La ejecución toma de 10 a 30 minutos en una RTX 4090 para una escena típica.

Opciones de exportación que importan en 2026:

  • .ply — nube cruda de Gaussianas (portátil, archivo más grande).
  • .splat — formato cuantizado de PlayCanvas / SuperSplat.
  • glTF KHR_gaussian_splatting — estándar Khronos, portátil entre visualizadores (RC de febrero de 2026).
  • OpenUSD UsdVolParticleField3DGaussianSplat — nativo de USD, para los pipelines de NVIDIA Omniverse y Vision Pro.

Para escenas 4D / dinámicas, 4DGS y Deformable-3DGS extienden la misma maquinaria con medias y opacidades variables en el tiempo.

Entrégalo

Esta lección produce:

  • outputs/prompt-3dgs-capture-planner.md — un prompt que planifica una sesión de captura (número de fotos, trayectoria de cámara, iluminación) para un tipo de escena dado.
  • outputs/skill-3dgs-export-router.md — una skill que elige el formato de exportación correcto (.ply / .splat / glTF / USD) según el visualizador o engine de destino.

Ejercicios

  1. (Fácil) Ejecuta el entrenador de splats 2D de arriba en una imagen sintética diferente. Varía num_splats en [16, 64, 256] y grafica MSE vs paso para cada uno. Identifica el punto de rendimientos decrecientes.
  2. (Medio) Extiende el rasterizador 2D para soportar colores RGB por Gaussiana que dependan de un "ángulo de vista" escalar a través de un armónico de grado 2. Entrena en un par de imágenes objetivo y verifica que el modelo reconstruye ambas.
  3. (Difícil) Clona nerfstudio y entrena splatfacto en una captura de 20 fotos de cualquier escena que tengas (escritorio, planta, rostro, habitación). Exporta a glTF KHR_gaussian_splatting y ábrelo en un visualizador (Three.js GaussianSplats3D, SuperSplat, Babylon.js V9). Reporta el tiempo de entrenamiento, el número de Gaussianas y los fps renderizados.

Términos clave

Término Lo que dice la gente Lo que realmente significa
3DGS "Gaussian splats" Representación explícita de escena como millones de Gaussianas 3D con posición, rotación, escala, opacidad y color SH por Gaussiana
Covarianza "Forma de la Gaussiana" Sigma = R S S^T R^T; orientación y escala anisotrópica de una Gaussiana
Composición alpha "Blend de atrás hacia adelante" La misma ecuación del renderizado volumétrico del NeRF, ahora sobre un conjunto disperso explícito
Densificación "Clonar y dividir" Adición adaptativa de nuevas Gaussianas donde la reconstrucción está subajustada
Poda "Borrar baja opacidad" Eliminar Gaussianas que colapsaron a opacidad casi nula durante el entrenamiento
Armónicos esféricos "Color dependiente de la vista" Base de Fourier en la esfera; almacena el color como función de la dirección de visualización
Splatfacto "3DGS de nerfstudio" El camino más fácil para entrenar 3DGS en 2026
KHR_gaussian_splatting "Estándar glTF" Extensión Khronos de 2026 que hace al 3DGS portátil entre visualizadores y engines

Lecturas adicionales

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).