Post

2D SDF - Primeiros passos

Representando Formas com SDFs

Sabe-se que, para uma representação gráfica, é necessário introduzir vértices e índices no estágio de rasterização na pipeline para que os pixels sejam renderizados na tela. Isso é verdade até mesmo para objetos 2D em cena, pois utilizamos principalmente malhas poligonais para representar formas.

Embora as malhas sejam as mais fáceis de renderizar e as mais versáteis, existem outras maneiras de representar formas complexas em 2D e 3D. Uma forma frequentemente usada são os Signed Distance Fields (ou SDF).

A matemática, em sua essência, é capaz de espelhar a natureza com precisão ou simplificá-la de forma satisfatória, proporcionando uma liberdade artística. Nesse cenário, os modelos matemáticos, como os SDFs, se apresentam como instrumentos de grande valor - sem falar que é uma técnica bacana de se estudar!

Tudo o que abordarei aqui não é conhecimento novo, aprendi a maior parte lendo os artigos de Ronja e Inigo Quilez, que recomendo fortemente para obter mais entendimento técnico.

O foco dos meus posts é mostrar como os conteúdos deles podem ser entendidos para falantes de língua portuguesa, além de compartilhar algumas dicas extras que aprendi ao longo do caminho e despertar maiores interesses sobre o tema.

Visualize!

Já vamos começar visualizando o conceito de forma interativa. Isso nos permitirá ter uma compreensão intuitiva do que estamos prestes a explorar.

Mova com o mouse o círculo amarelo para visualizar a menor distância até a superfície.

Um conceito fundamental que está na base dessas funções é o cálculo da distância de qualquer ponto até a superfície mais próxima. Para cada ponto no espaço UV, calculamos a menor distância até a superfície de um objeto. Este cálculo de distância é a pedra angular para a compreensão das SDFs.

Ao analisar as formas dessas funções de distância, notamos que elas possuem um interior e um exterior bem definidos. Esta característica é a razão pela qual são chamadas de SDFs, pois há uma alteração de sinal que distingue claramente o interior do objeto de seu exterior.

Portanto, a notação SDF(p) que comumente representa a função de distância de um ponto p de tal modo que:

\[\textbf{SDF}(p) = \begin{cases} +d(p, O) & \text{se } p \text{ está fora de } O \\ -d(p, O) & \text{se } p \text{ está dentro de } O \\ 0 & \text{se } p \text{ está na superfície de } O \end{cases}\]

Onde:

  • p representa um ponto no espaço.
  • O representa um objeto geométrico no espaço.

Círculo

Entendemos então a forma mais básica na natureza: o Círculo! O círculo é uma figura geométrica simples por definição, dotada propriedades fascinantes para um entendimento inical acerca dos (SDFs).

dCircle

A distância entre um ponto e uma círculo é a distância do ponto ao centro do círculo menos o raio do círculo. Em termos matemáticos, se temos um círculo com centro em c e raio r, e queremos encontrar a distância de um ponto p até o círculo, a função SDF para o círculo é dada por:

\[\text{SDF}_{\text{círculo}}(p) = ||p - c|| - r\]

Nesta equação,

  • ||p - c|| representa a distância euclidiana entre o ponto p e o centro c.
  • r é o raio do círculo.

Visualizações

Visualizar o espaço de um objeto e a dinâmica entre ele e outros objetos pode ser significativamente facilitar o desenvolvimento de cenas e estruturas mais complexas.

Seno e Cosseno

Esta abordagem para visualizar o campo de distância utiliza funções \(sin\) e \(cos\) para criar variações de cor que não só destacam a forma do objeto, mas demonstram a distância. A abordagem de Inigo Quilez é valorizada por sua elegância e compactação, tornando-se uma das minhas favoritas para visualização.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void main()
{
  /** ... */
  float d = sdShape( uv, ... );

  // Coloração
  vec3 col = ( d > 0.0 ) ? POSITIVE_COLOR : NEGATIVE_COLOR;
  // Sombra delimitante
  col *= col *= 1.0 - exp( -6. * abs( d ) );
  // Visualização do campo
  col *= 0.8 + 0.2 * cos( 150.0 * d );
  // Borda do objeto
  col = mix( col, BORDER_COLOR, 1. - smoothstep( 0.0 , 0.01, abs( d ) ) );

  gl_FragColor = col;
}

Fract

O exemplo a seguir demonstra uma técnica mais avançada para visualizar o campo de distância de um objeto. Ele cria linhas principais e sublinhas dinâmicas que ajudam a destacar as variações e a estrutura do campo de distância.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void main()
{
  /** ... */

  // Coloração
  vec3 col = d < .0 ? NEGATIVE_BG : POSITIVE_BG;
  col *= 1.0 - exp(-10.0 * abs(d));

  {
    // Configurações das linhas
    float lineDistance = .2; // Distância entre as linhas principais
    float lineThickness = 0.005; // Espessura das linhas principais
    float subLineCount = 3.; // Número de sublinhas por linha principal
    float subLineThickness = lineThickness * 0.05; // Espessura das sublinhas

    float distanceChange = .01;

    // Cálculo das linhas principais
    float majorLineDistance = abs( fract(d / lineDistance + ( 0.5 * u_time * ( d / abs( d ) ) ) ) - 0.5 ) * lineDistance;
    float majorLines = smoothstep( lineThickness - distanceChange, lineThickness + distanceChange, majorLineDistance );

    // Cálculo das sublinhas
    float distanceBetweenSubLines = lineDistance / subLineCount;
    float subLineDistance = abs( fract(d / distanceBetweenSubLines + ( 0.5 * u_time * subLineCount * ( d / abs( d ) ) ) ) - 0.5 ) * distanceBetweenSubLines;
    float subLines = smoothstep( subLineThickness - distanceChange, subLineThickness + distanceChange, subLineDistance );

    // Aplicando as linhas e sublinhas à cor
    col *= majorLines * subLines;
  }

  // Borda do objeto
  col = mix( col, BORDER, 1.0 - smoothstep( 0.0, 0.01, abs( d ) ) );
  gl_FragColor = col;
}

Em prática

Independentemente da plataforma ou linguagem alvo da sua escolha, existem algumas diferenças nas dinâmicas e escritas. No entanto, um ponto em comum se destaca: a necessidade de uniformizar e remapear as coordenadas UV para uma visualização 2D simples. Isso é essencial para garantir que elas estejam centralizadas nas coordenadas UVs. Deste modo, facilita-se a manipulação do objeto de forma consistente.

Ao remapear as coordenadas para um intervalo equidistante de [-1 a +1], a partir do ponto central 0, você garante que qualquer transformação aplicada será simétrica e previsível. Isso é especialmente importante para operações de transformações, onde a simetria e a uniformidade das coordenadas garantem o resultado esperado.

Eis uma animação em Glsl que linearmente interpola entre as coordenadas ‘originais’ e as remapeadas: ( ou visualize-a na plataforma Shadertoy ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
uniform vec2 u_resolution;

vec2
centralizeUV( in vec2 uv )
{
    // Escala as UVs para o intervalo [0, 2]
    uv *= 2.;

    // Remapeia o centro das UVs de (1,1) para (0,0)
    uv -= u_resolution.xy;

    // Normaliza as UVs para o aspecto da tela, fixando [-1, 1]
    uv /= u_resolution.x;

    return uv;
}

Agora que uniformizamos e centramos as coordenadas UV, podemos desenhar a figura sem problemas. Use o editor abaixo para fazer alterações ao longo do post.

Descomente a linha 20 do código acima para ver a coordenada espacial.

Operações

Atuando como o motor principal na manipulação e modelagem de formas complexas, as operações transformam objetos primitivos e estáticos em estruturas complexas e dinâmicas.

A criação de novas configurações geométricas e a manipulação de sua disposição espacial são elementos cruciais para criação de uma cena viva e dinâmica. Isso é feito transformando os valores da posição que serão alimentados nos SDFs.

Transformações

As transformações modificam a posição, orientação ou escala de uma forma. Elas são essenciais para criação de cenas dinâmicas e animações, pois com elas conseguimos interpolar, e outras coisas.

Translate

\[\begin{bmatrix} P_x \\ P_y \end{bmatrix} - \begin{bmatrix} x \\ y \end{bmatrix} = \begin{bmatrix} P_x - x \\ P_y - y \end{bmatrix}\]
1
2
3
4
5
vec2
opTranslate( in vec2 p, in vec2 d )
{
    return p - d;
}

O conceito de transladar é bem simples: subtrair da posição para deslocar o objeto. Isso pode parecer contra-intuitivo, pois normalmente associamos adicionar a mover para direita ou para cima, e subtrair a mover para esquerda ou para baixo. No entanto, neste contexto, estamos manipulando o espaço ao redor do objeto, e não o objeto em si.

Para ajudar na fixação, imagine que você está segurando uma câmera apontada para um desenho em uma folha de papel (o nosso espaço 2D). Agora, se você quiser mover o objeto para direita na foto, você na verdade move a câmera para esquerda. Se você quiser movê-lo para cima na foto, mova a câmera para baixo.

Rotate

\[\left[\begin{array}{ccc} \cos \emptyset & -\sin \emptyset \\ \sin \emptyset & \cos \emptyset \end{array}\right] \cdot\left[\begin{array}{l} x \\ y \end{array}\right]=\left[\begin{array}{c} x . \cos \emptyset-y \cdot \sin \emptyset \\ x . \sin \emptyset+y \cdot \cos \emptyset \end{array}\right]\]
1
2
3
4
5
6
7
8
9
10
vec2
opRotate( in vec2 p, in float a )
{
    float s = sin( a );
    float c = cos( a );

    mat2 rot = mat2( c, -s, s, c );

    return p * rot;
}

Caso haja interesse em se profundar, há o artigo Matriz de rotação - Wikipédia que pode ajudar a elucidar tanto o porquê quanto o como em outras aplicações. Além disso, há um excelente vídeo da magnífica Freya Holmér.

Outra coisa a mencionar é que se você estiver usando as funções Rotate e Translate, a ordem em que você as usa dá resultados diferentes. Se você quer que a forma sempre gire em seu próprio eixo/centro, a função Rotate deve ser usada depois do Translate.

À esquerda, Translate seguido de Rotate. À direita, Rotate seguido de Translate.

Scale

\[\left[\begin{array}{lcc} P_x & 0 \\ 0 & P_y \end{array}\right] \cdot\left[\begin{array}{l} x \\ y \end{array}\right]=\left[\begin{array}{l} P_x \cdot x \\ P_y \cdot y \end{array}\right]\]
1
2
3
4
5
6
mat2
opScale(vec2 _scale)
{
    return mat2(_scale.x,0.0,
                0.0,_scale.y);
}

Combinações

Union

\[\begin{equation} \displaylines{ SDF_1 \cup SDF_2 \\ \min \left(SDF_1, SDF_2\right) } \end{equation}\]
1
2
3
4
5
float
merge( in float shape1, in float shape2 )
{
    return min( shape1, shape2 );
}

Intersect

\[\begin{equation} \displaylines{ SDF_1 \cap SDF_2 \\ \max \left(SDF_1, SDF_2\right) } \end{equation}\]
1
2
3
4
5
float
intersect( in float shape1, in float shape2 )
{
    return max( shape1, shape2 );
}

Subtract

\[\begin{equation} \displaylines{ SDF_1 - SDF_2 \\ \max \left(-SDF_1, SDF_2\right) } \end{equation}\]
1
2
3
4
5
float
opSubtraction( in float a, in float b )
{
    return max( -a, b );
}

XOR

\[\begin{equation} \displaylines{ SDF_1 \oplus SDF_2 \\ \max(\min(SDF_1, SDF_2), -\max(SDF_1, SDF_2)) } \end{equation}\]
1
2
3
4
5
float
opXor( in float a, in float b )
{
    return max( min( a, b ), -max( a, b) );
}

Smooth

Union

Smooth union permite combinar dois campos de distância de forma contínua e suave, eliminando arestas e transições bruscas.

1
2
3
4
5
float opSmoothUnion( float d1, float d2, float k )
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h);
}

Essa função suaviza a transição entre dois campos de distância, d1 e d2, controlada pelo parâmetro k. Esta técnica é essencial para criar superfícies suaves e contínuas.

Existem, é claro, outras operações smooth. Veja-as em ação:

Outras formas primitivas

Agora que você já está familiarizado com a forma básica do círculo e alguns operadores, é importante expandir esse conhecimento para outras formas primitivas.

Em SDF, formas primitivas e operações são usadas para construir novas formas de maneira eficiente e flexível. Por exemplo, além de círculos, você pode criar retângulos, esferas, cones, cilindros, entre outros. Combinando essas formas primitivas com operações booleanas como união, interseção e diferença, é possível construir formas mais complexas e detalhadas.

Sobre SDF, é fundamental prezar pela otimização. Algorítimos ineficientes podem resultar em um desempenho significativamente reduzido, especialmente em aplicações gráficas complexas e em tempo real. Lembre-se que a computação das formas é realizada para cada fragmento.

Segmento de linha

$$ \require{enclose} \enclose{updiagonalstrike}{ \begin{gathered} \text{SDF}_{\text{linha}}(p) = \\ \begin{cases}\left|p_y\right| & se -a \leq x \leq a \\ d(p, \text { endpoint }) & se -a<x<a\end{cases} \end{gathered} } $$

A dedução de segmentos de linha é um exemplo claro de como formas primitivas e operações podem ser combinadas para construir novas formas. Aqui, nesta simplificação, a função SDF para o segmento de linha combina uma condição para os pontos dentro do intervalo definido ( \(−a ≤ x ≤a\) ) e uma condição para os pontos fora desse intervalo (distância até o endpoint mais próximo), demonstrando a flexibilidade e eficiência da abordagem SDF, por mais que o código abaixo seja ~ingênuo~.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Distância de um ponto P até um segmento de
// reta de comprimento 2a centrado na origem
float
sdfSegment( in vec2 P, in float a )
{
    // Se Px estiver dentro dos limites [-a, a]
    if ( -a <= P.x && P.x <= a )
    {
        return abs( P.y );
    }

    // Distância até a extremidade mais próxima
    vec2 endpoint1 = vec2( -a, 0. );
    vec2 endpoint2 = vec2(  a, 0. );
    float distToEndpoint1 = length( P - endpoint1 );
    float distToEndpoint2 = length( P - endpoint2 );

    // Determinar a Distância mínima
    return min( distToEndpoint1, distToEndpoint2 );
}

Entretanto, temos aqui uma solução bem mais interessante para um segmento de linha qualquer. Ela é compacta, versátil, flexível e otimizada. E o legal é que ela é toda baseada em derivação:

1
2
3
4
5
6
7
float sdSegment( in vec2 p, in vec2 a, in vec2 b )
{
    vec2 ba = b - a;
    vec2 pa = p - a;
    float h = clamp( dot( pa, ba ) / dot( ba, ba ), 0.0, 1.0 );
    return length( pa - h * ba );
}

Para melhor entendimento desta derivação, consulte o vídeo The SDF of a Line Segment do mestre Inigo Quilez.

Triângulo Equilátero

$$ \require{enclose} \enclose{updiagonalstrike}{ \begin{gathered} \text{SDF}_{\text{tri.eq.}}(p) = \\ \begin{cases}|p_y| & se -\frac{a}{\sqrt{3}} \leq p_x \leq \frac{a}{\sqrt{3}} \\ \text{dist}(P, \text{vértice}) & \text{caso contrário} \end{cases} \end{gathered} } $$
1
2
3
4
5
6
7
8
9
float sdEquilateralTriangle( in vec2 p, in float r )
{
    const float k = sqrt(3.0);
    p.x = abs(p.x) - r;
    p.y = p.y + r/k;
    if( p.x+k*p.y>0.0 ) p = vec2(p.x-k*p.y,-k*p.x-p.y)/2.0;
    p.x -= clamp( p.x, -2.0*r, 0.0 );
    return -length(p)*sign(p.y);
}

Quadrado

$$ \require{enclose} \enclose{updiagonalstrike}{ \begin{gathered} \text{SDF}_{\text{quad}}(p) = \\ \begin{cases} \max(|p_x|, |p_y|) & se -a \leq p_x, p_y \leq a \\ \text{dist}(P, \text{borda}) & se \text{caso contrário} \end{cases} \end{gathered} } $$
1
2
3
4
5
float sdBox( in vec2 p, in vec2 b )
{
    vec2 d = abs( p ) - b;
    return length( max( d, 0.0 ) ) + min( max( d.x, d.y ), 0.0 );
}

Para mais formas, visite Inigo Quilez - 2D distance functions

Conclusão

Se você ainda não percebeu, sou um grande entusiasta dos campos de distância, e espero que agora você também veja o poder dos SDFs! Caso ainda esteja cético, não se preocupe—mais posts estão a caminho, abordando técnicas ainda mais avançadas e em 3D!

E se as palavras não forem suficientes para convencê-lo, talvez as imagens hipnotizantes que acompanham esses posts façam o truque. Elas são tão fascinantes que você pode se encontrar perdido nelas por horas… Mas não se preocupe, não é um efeito colateral permanente… eu acho…

Bom, sei que este post foi longo, mas agradeço de coração por você ter lido tudo! E se você se perdeu nas imagens, bem, pelo menos espero que tenha sido uma viagem divertida!

Vida longa e próspera 🖖

This post is licensed under CC BY 4.0 by the author.