Phase 04 - Lesson 22

3D Gaussian Splatting do zero

Uma cena é uma nuvem de milhões de Gaussianas 3D. Cada uma tem posição, orientação, escala, opacidade e uma cor que depende da direção de visualização. Rasterize-as, faça backprop através da rasterização, pronto.

Tipo: Build Linguagens: Python Pré-requisitos: Fase 4 Lição 13 (Visão 3D e NeRF), Fase 1 Lição 12 (Operações com Tensores), Fase 4 Lição 10 (Noções de Diffusion, opcional) Tempo: ~90 minutos

Objetivos de aprendizado

  • Explicar por que o 3D Gaussian Splatting substituiu o NeRF como padrão de produção para reconstrução 3D fotorrealista em 2026
  • Enunciar os seis parâmetros por Gaussiana (posição, quaternion de rotação, escala, opacidade, cor por harmônicos esféricos, feature opcional) e quantos floats cada um contribui
  • Implementar do zero um rasterizador de Gaussian splatting 2D usando composição alpha, e então mostrar como o caso 3D se projeta para o mesmo loop
  • Usar nerfstudio, gsplat ou SuperSplat para reconstruir uma cena a partir de 20-50 fotos e exportar para a extensão glTF KHR_gaussian_splatting ou o schema OpenUSD 26.03 UsdVolParticleField3DGaussianSplat

O problema

Um NeRF armazena uma cena como os pesos de uma MLP. Cada pixel renderizado são centenas de consultas à MLP ao longo de um raio. O treinamento leva horas, a renderização leva segundos, e os pesos não podem ser editados — se você quiser mover uma cadeira dentro de uma cena, precisa retreinar.

O 3D Gaussian Splatting (Kerbl, Kopanas, Leimkühler, Drettakis, SIGGRAPH 2023) substituiu tudo isso. Uma cena é um conjunto explícito de Gaussianas 3D. A renderização é rasterização na GPU a 100+ fps. O treinamento leva minutos. A edição é direta: translade um subconjunto de Gaussianas e você moveu a cadeira. Até 2026 o Khronos Group ratificou uma extensão glTF para Gaussian splats, o OpenUSD 26.03 traz um schema de Gaussian splat, a Zillow e a Apartments.com renderizam imóveis com elas, e a maioria dos novos artigos de pesquisa sobre reconstrução 3D são variantes da ideia central do 3DGS.

O modelo mental é simples, mas a matemática tem partes móveis suficientes para que a maioria das introduções comece pela rasterização e pule as projeções e os harmônicos esféricos. Esta lição constrói a coisa toda — uma versão 2D primeiro, depois a extensão 3D.

O conceito

O que uma Gaussiana carrega

Uma Gaussiana 3D é um blob paramétrico no espaço com estes 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

Rotação + escala constroem uma covariância 3x3: Sigma = R S S^T R^T. Essa é a forma da Gaussiana em 3D. Os harmônicos esféricos permitem que a cor mude com a direção de visualização — reflexos especulares, brilho sutil, glow dependente da vista — sem armazenar texturas por vista. Com grau 3 de SH você obtém 16 coeficientes por canal de cor, 48 floats por Gaussiana só para a cor.

Uma cena tipicamente tem 1-5 milhões de Gaussianas. Cada uma armazena cerca de 60 floats (3 + 4 + 3 + 1 + 48 + diversos). Isso dá 240 MB para uma cena de cinco milhões de Gaussianas — muito menor que a nuvem de pontos equivalente com textura por ponto, e uma ordem de magnitude menor que os pesos da MLP de um NeRF re-renderizados em alta resolução.

Rasterização, não ray marching

flowchart LR
    SCENE["Milhões de Gaussianas 3D<br/>(posição, rotação, escala,<br/>opacidade, cor SH)"] --> PROJ["Projetar para 2D<br/>(extrínsecos + intrínsecos da câmera)"]
    PROJ --> TILES["Atribuir a tiles<br/>(16x16 no espaço de tela)"]
    TILES --> SORT["Ordenar por profundidade<br/>por tile"]
    SORT --> ALPHA["Compor alpha<br/>de frente para trás"]
    ALPHA --> PIX["Cor do pixel"]

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

Cinco passos, todos amigáveis para GPU. Sem consulta à MLP por pixel. Uma única RTX 3080 Ti renderiza 6 milhões de splats a 147 fps.

O passo de projeção

A Gaussiana 3D na posição de mundo mu com covariância 3D Sigma projeta para uma Gaussiana 2D na posição de tela mu' com covariância 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'

A pegada (footprint) da Gaussiana 2D é uma elipse cujos eixos são os autovetores de Sigma'. Todo pixel dentro dessa elipse recebe a contribuição da Gaussiana, ponderada por exp(-0.5 * (p - mu')^T Sigma'^-1 (p - mu')).

A regra de composição alpha

Para um pixel, as Gaussianas que o cobrem são ordenadas de trás para frente (ou equivalentemente de frente para trás com a fórmula invertida). A cor é composta com a mesma equação de todo rasterizador semitransparente desde os anos 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 é a mesma equação da renderização volumétrica do NeRF, apenas sobre um conjunto explícito e esparso de Gaussianas em vez de amostras densas ao longo de um raio. Essa identidade é o motivo de a qualidade renderizada igualar o NeRF — ambos estão integrando a mesma equação do campo de radiância.

Por que isso é diferenciável

Cada passo — projeção, atribuição a tiles, composição alpha, avaliação de SH — é diferenciável em relação aos parâmetros das Gaussianas. Dada uma imagem ground-truth, calcule a loss de pixel renderizado, faça backprop através do rasterizador, atualize todos os (mu, q, s, alpha, c_lm) por gradiente descendente. Ao longo de ~30.000 iterações as Gaussianas encontram suas posições, escalas e cores corretas.

Densificação e poda

Um conjunto fixo de Gaussianas não consegue cobrir uma cena complexa. O treinamento inclui dois mecanismos adaptativos:

  • Clonar uma Gaussiana em sua posição atual quando a magnitude do gradiente é alta mas sua escala é pequena — a reconstrução precisa de mais detalhe aqui.
  • Dividir uma Gaussiana de grande escala em duas menores quando o gradiente é alto — uma Gaussiana grande é suave demais para ajustar a região.
  • Podar Gaussianas cuja opacidade cai abaixo de um limiar — elas não estão contribuindo.

A densificação roda a cada N iterações. Uma cena tipicamente cresce de ~100k Gaussianas iniciais (semeadas a partir de pontos de SfM) para 1-5M ao final do treinamento.

Harmônicos esféricos em um parágrafo

A cor dependente da vista é uma função c(direction) na esfera unitária. Os harmônicos esféricos são a base de Fourier da esfera. Trunque no grau L e você obtém (L+1)^2 funções de base por canal. Avaliar a cor para uma nova vista é um produto escalar entre os coeficientes de SH aprendidos e a base avaliada na direção de visualização. Grau 0 = um coeficiente = cor constante. Grau 3 = 16 coeficientes = suficiente para capturar sombreamento Lambertiano, especular e reflexão moderada. Os artigos de SD Gaussian Splatting usam grau 3 por padrão.

A stack de produção 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 e generativas

  • 4D Gaussian Splatting — as Gaussianas são funções do tempo; usado para vídeo volumétrico (Superman 2026, "Helicopter" do A$AP Rocky).
  • Splats generativos — modelos text-to-splat (Marble da World Labs) que alucinam cenas inteiras.
  • 3D Gaussian Unscented Transform — a variante do NuRec da NVIDIA para simulação de direção autônoma.

Construa

Passo 1: Uma Gaussiana 2D

Primeiro construímos um rasterizador 2D. O caso 3D se reduz a ele após a projeção.

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 faz a forma quadrática diff^T Sigma^-1 diff para cada par (Gaussiana, pixel).

Passo 2: Rasterizador de splatting 2D

Composição alpha de frente para trás. A profundidade em 2D não tem sentido, então usamos um escalar aprendido por Gaussiana para a ordem.

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

Não é rápido — uma implementação real usa kernels CUDA baseados em tiles — mas é exatamente a matemática correta e totalmente diferenciável.

Passo 3: Uma cena de splats 2D treinável

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 e colour_logits são todos parâmetros irrestritos mapeados pela ativação correta no momento da renderização. Esse é o padrão padrão de toda implementação de 3DGS.

Passo 4: Ajustar Gaussianas 2D a uma imagem-alvo

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}")

Ao longo de 200 passos as 64 Gaussianas se acomodam nas duas formas. Essa é a ideia inteira — gradiente descendente sobre primitivas geométricas explícitas.

Passo 5: De 2D para 3D

A extensão 3D mantém o mesmo loop. As adições:

  1. A rotação por Gaussiana é um quaternion em vez de um único ângulo.
  2. A covariância é R S S^T R^T com R construído a partir do quaternion e S = diag(exp(log_scale)).
  3. A projeção (mu, Sigma) -> (mu', Sigma') usa os extrínsecos da câmera e o Jacobiano da projeção perspectiva em mu.
  4. A cor passa a ser uma expansão em harmônicos esféricos; avalie-a na direção de visualização.
  5. A ordenação por profundidade vem do z real do espaço de câmera em vez de um escalar aprendido.

Toda implementação de produção (gsplat, inria/gaussian-splatting, nerfstudio) faz exatamente isso na GPU com kernels CUDA baseados em tiles.

Passo 6: Avaliação de harmônicos esféricos

A base de SH até grau 3 tem 16 termos por canal. Avaliação:

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

Os sh_coeffs aprendidos armazenam a "cor em cada direção" para aquela Gaussiana. No momento da renderização você avalia em relação à direção de vista atual e obtém um RGB de 3 componentes.

Use

Para trabalho real de 3DGS, use gsplat (Meta) ou nerfstudio:

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

splatfacto é o treinador de 3DGS do nerfstudio. A execução leva de 10 a 30 minutos em uma RTX 4090 para uma cena típica.

Opções de exportação que importam em 2026:

  • .ply — nuvem bruta de Gaussianas (portátil, maior arquivo).
  • .splat — formato quantizado do PlayCanvas / SuperSplat.
  • glTF KHR_gaussian_splatting — padrão Khronos, portátil entre visualizadores (RC de fevereiro de 2026).
  • OpenUSD UsdVolParticleField3DGaussianSplat — nativo de USD, para os pipelines do NVIDIA Omniverse e do Vision Pro.

Para cenas 4D / dinâmicas, 4DGS e Deformable-3DGS estendem a mesma maquinaria com médias e opacidades variáveis no tempo.

Entregue

Esta lição produz:

  • outputs/prompt-3dgs-capture-planner.md — um prompt que planeja uma sessão de captura (número de fotos, trajetória da câmera, iluminação) para um dado tipo de cena.
  • outputs/skill-3dgs-export-router.md — uma skill que escolhe o formato de exportação correto (.ply / .splat / glTF / USD) dado o visualizador ou engine de destino.

Exercícios

  1. (Fácil) Rode o treinador de splats 2D acima em uma imagem sintética diferente. Varie num_splats em [16, 64, 256] e plote MSE vs passo para cada um. Identifique o ponto de retornos decrescentes.
  2. (Médio) Estenda o rasterizador 2D para suportar cores RGB por Gaussiana que dependem de um "ângulo de vista" escalar através de um harmônico de grau 2. Treine em um par de imagens-alvo e verifique que o modelo reconstrói ambas.
  3. (Difícil) Clone o nerfstudio e treine o splatfacto em uma captura de 20 fotos de qualquer cena que você tenha (mesa, planta, rosto, sala). Exporte para glTF KHR_gaussian_splatting e abra em um visualizador (Three.js GaussianSplats3D, SuperSplat, Babylon.js V9). Relate o tempo de treinamento, o número de Gaussianas e os fps renderizados.

Termos-chave

Termo O que as pessoas dizem O que realmente significa
3DGS "Gaussian splats" Representação explícita de cena como milhões de Gaussianas 3D com posição, rotação, escala, opacidade e cor SH por Gaussiana
Covariância "Forma da Gaussiana" Sigma = R S S^T R^T; orientação e escala anisotrópica de uma Gaussiana
Composição alpha "Blend de trás para frente" A mesma equação da renderização volumétrica do NeRF, agora sobre um conjunto esparso explícito
Densificação "Clonar e dividir" Adição adaptativa de novas Gaussianas onde a reconstrução está subajustada
Poda "Deletar baixa opacidade" Remover Gaussianas que colapsaram para opacidade quase nula durante o treinamento
Harmônicos esféricos "Cor dependente da vista" Base de Fourier na esfera; armazena a cor como função da direção de visualização
Splatfacto "3DGS do nerfstudio" O caminho mais fácil para treinar 3DGS em 2026
KHR_gaussian_splatting "Padrão glTF" Extensão Khronos de 2026 que torna o 3DGS portátil entre visualizadores e engines

Leitura adicional

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