Pular para o conteúdo
Fundamento · Campos de distânciaIntermediário

Campos de distância com sinal em 3D

Esfera, caixa, toro e cápsula como funções de distância de um ponto 3D, e a normal da superfície a partir do gradiente do campo.

Como funciona

Tudo dos campos de distância em 2D vale em 3D: uma função recebe um ponto, agora um vec3, e devolve a distância até a superfície mais próxima, negativa por dentro. As booleanas, o mínimo suave, a repetição e o arredondamento funcionam sem mudança. O que o 3D acrescenta é um jeito de ver o campo, o raymarching (avançar ao longo de um raio saindo da câmera pela distância que o campo informa), e a necessidade de uma normal da superfície para iluminá-lo. Este fundamento trata das formas e da normal; o raymarching em si é um conceito de renderização mais adiante na ordem.

As formas básicas

A maioria estende diretamente a versão 2D:

float sdSphere(vec3 p, float r) {
    return length(p) - r;
}

// b: metade do tamanho em cada eixo
float sdBox(vec3 p, vec3 b) {
    vec3 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}

// um segmento de a até b, engrossado por r
float sdCapsule(vec3 p, vec3 a, vec3 b, float r) {
    vec3 pa = p - a;
    vec3 ba = b - a;
    float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
    return length(pa - ba * h) - r;
}

// n: normal unitária; o plano fica à distância h ao longo dela
float sdPlane(vec3 p, vec3 n, float h) {
    return dot(p, n) - h;
}

A esfera, a caixa e a cápsula são o círculo, a caixa e o segmento com um componente a mais. O plano é um produto escalar: a distância com sinal de um ponto até um plano que passa pela origem é o comprimento da projeção dele sobre a normal unitária.

Montando formas a partir das 2D

Algumas formas 3D são uma distância 2D aplicada num plano. Um toro é um círculo de raio big no plano xz, varrido por um tubo de raio small. No semiplano que contém o eixo y e o ponto, o ponto fica em (length(p.xz) - big, p.y) em relação à linha central do tubo, e o toro ali é um círculo 2D:

float sdTorus(vec3 p, float big, float small) {
    vec2 q = vec2(length(p.xz) - big, p.y);
    return length(q) - small;
}

O mesmo movimento, vec2(length(p.xz), p.y) passado a qualquer função de distância 2D, gira uma forma 2D em torno do eixo y: uma caixa 2D dá um cilindro, uma caixa 2D arredondada dá um cilindro arredondado. A extrusão faz o caminho contrário: avalie uma distância 2D em p.xy e faça a interseção com uma placa de meia espessura t ao longo de z, o que transforma uma forma plana numa moeda ou numa letra com profundidade.

A normal a partir do gradiente

Para iluminar uma superfície, você precisa da normal dela. Um campo de distância a entrega de graça: o campo cresce mais rápido no sentido que se afasta da superfície em linha reta, então o gradiente dele aponta ao longo da normal. Com uma distância exata, o comprimento do gradiente é 1; para qualquer campo, normalizá-lo dá a direção.

O gradiente pode ser calculado avaliando o campo em pontos vizinhos, um par por eixo:

float scene(vec3 p) {
    return min(sdSphere(p - vec3(-0.6, 0.0, 0.0), 0.5),
               sdTorus(p - vec3(0.6, 0.0, 0.0), 0.4, 0.12));
}

vec3 calcNormal(vec3 p) {
    const vec2 e = vec2(0.001, 0.0); // um passo pequeno, em unidades da cena
    return normalize(vec3(
        scene(p + e.xyy) - scene(p - e.xyy),
        scene(p + e.yxy) - scene(p - e.yxy),
        scene(p + e.yyx) - scene(p - e.yyx)));
}

Os swizzles e.xyy, e.yxy e e.yyx são passos pequenos ao longo de x, y e z. Cada linha é uma diferença central: o quanto a distância muda num passo minúsculo naquele eixo. O tamanho do passo é um meio-termo: grande demais e os detalhes pequenos borram, pequeno demais e o erro de ponto flutuante deixa a normal ruidosa. 0.001 serve para cenas com cerca de uma unidade de largura; ajuste com a escala da cena, ou com a distância até a câmera.

Isso custa seis avaliações da cena inteira por pixel. Uma otimização comum amostra quatro pontos nos vértices de um tetraedro, o que dá a mesma direção com duas avaliações a menos; o artigo sobre normais nas fontes faz a dedução.

Vendo um campo 3D sem raymarching

Antes de escrever um raymarcher, você pode inspecionar um campo 3D fatiando-o: avalie-o num plano e desenhe o resultado 2D. Com uma coordenada de pixel 2D p:

float d = scene(vec3(p, sin(time) * 0.5)); // uma fatia que se move ao longo de z
vec3 color = d < 0.0 ? vec3(0.9, 0.5, 0.2) : vec3(0.2, 0.5, 0.9);
color *= 0.8 + 0.2 * cos(60.0 * d);         // curvas de nível a cada ~0.1 unidade
// a própria superfície
color = mix(color, vec3(1.0), 1.0 - smoothstep(0.0, 0.01, abs(d)));

Curvas de nível igualmente espaçadas indicam que o campo é uma distância real. Onde elas se afastam, o campo subestima a distância, e o raymarcher vai precisar de mais passos ali; onde elas se apertam, ele superestima, e o raymarcher vai atravessar a superfície.

Leitura complementar