Embora as malhas sejam a maneira mais simples e versátil de renderizar, existem outras opções para representar formas em 2D e 3D. Um método comumente usado são os campos de distância assinada (SDF). Os campos de distância assinados fornecem um traçado de raio menos dispendioso, permitem que diferentes formas fluam entre si e economizam em texturas de baixa resolução para imagens de alta qualidade.
Começaremos gerando o sinal dos campos de distância usando funções em duas dimensões, mas depois continuaremos a gerá-los em 3D. Usarei as coordenadas do espaço mundial para termos a menor dependência possível de dimensionamento e coordenadas UV; portanto, se você não entender como ele funciona, estude este
tutorial em uma sobreposição plana , o que explica o que acontece.
Preparação da fundação
Vamos jogar fora temporariamente as propriedades do sombreador de sobreposição plana de base, porque, por enquanto, cuidaremos da base técnica. Em seguida, escrevemos a posição do vértice no mundo diretamente na estrutura do fragmento, e não o converteremos primeiro em UV. No último estágio de preparação, escreveremos uma nova função que calcula a cena e retorna a distância para a superfície mais próxima. Então chamamos as funções e usamos o resultado como uma cor.
Shader "Tutorial/034_2D_SDF_Basics"{ SubShader{
Escreverei todas as funções para os campos de distância assinados em um arquivo separado, para que possamos usá-los repetidamente. Para fazer isso, vou criar um novo arquivo. Não adicionaremos nenhum mal a ele, depois o definiremos e concluiremos a proteção condicional de inclusão, verificando primeiro se a variável de pré-processador está definida. Se ainda não estiver definido, nós o definimos e concluímos a construção condicional if após as funções que queremos incluir. A vantagem disso é que, se adicionarmos o arquivo duas vezes (por exemplo, se adicionarmos dois arquivos diferentes, cada um com as funções necessárias e os dois adicionarem o mesmo arquivo), isso quebrará o sombreador. Se você tiver certeza de que isso nunca acontecerá, não poderá executar esta verificação.
Se o arquivo de inclusão estiver localizado na mesma pasta que o shader principal, podemos simplesmente incluí-lo usando a construção pragma.
Portanto, veremos apenas uma superfície preta na superfície renderizada, pronta para exibir a distância com um sinal nela.
Círculo
A função mais simples do campo de distância sinalizada é a função de círculo. A função receberá apenas a posição da amostra e o raio do círculo. Começamos obtendo o comprimento do vetor de posição da amostra. Portanto, obtemos um ponto na posição (0, 0), que é semelhante a um círculo com um raio de 0.
float circle(float2 samplePosition, float radius){ return length(samplePosition); }
Em seguida, você pode chamar a função de círculo na função de cena e retornar a distância que ela retorna.
float scene(float2 position) { float sceneDistance = circle(position, 2); return sceneDistance; }
Em seguida, adicionamos o raio aos cálculos. Um aspecto importante das funções de distância assinada é que, quando estamos dentro do objeto, obtemos uma distância negativa da superfície (é isso que a palavra assinado significa no campo de distância assinada da expressão). Para aumentar o círculo para um raio, subtraímos o raio do comprimento. Assim, a superfície, que está em toda parte onde a função retorna 0, se move para fora. O que está em duas unidades da distância da superfície para um círculo com tamanho 0, é apenas uma unidade de um círculo com raio de 1 e uma unidade dentro do círculo (o valor é -1) para um círculo com raio de 3;
float circle(float2 samplePosition, float radius){ return length(samplePosition) - radius; }
Agora, a única coisa que não podemos fazer é mover o círculo do centro. Para corrigir isso, você pode adicionar um novo argumento à função círculo para calcular a distância entre a posição da amostra e o centro do círculo e subtrair o raio desse valor para definir um círculo. Ou, você pode redefinir a origem movendo o espaço do ponto de amostra e, em seguida, obter um círculo nesse espaço. A segunda opção parece muito mais complicada, mas como mover objetos é uma operação que queremos usar para todas as figuras, é muito mais universal e, portanto, explicarei.
Movendo
"Transformação do espaço de um ponto" - soa muito pior do que realmente é. Isso significa que passamos o ponto para a função, e a função o altera para que ainda possamos usá-lo no futuro. No caso de uma transferência, simplesmente subtraímos o deslocamento do ponto. A posição é subtraída quando queremos mover as formas na direção positiva, porque as formas que renderizamos no espaço se movem na direção oposta à movimentação do espaço.
Por exemplo, se queremos desenhar uma esfera na posição
(3, 4)
, precisamos alterar o espaço para que
(3, 4)
transforme em
(0, 0)
, e para isso precisamos subtrair
(3, 4)
. Agora, se desenharmos uma esfera em torno de um
novo ponto de origem, será um ponto
antigo (3, 4)
.
float scene(float2 position) { float2 circlePosition = translate(position, float2(3, 2)); float sceneDistance = circle(circlePosition, 2); return sceneDistance; }
Retângulo
Outra forma simples é um retângulo. Para começar, consideramos os componentes separadamente. Primeiro, obtemos a distância do centro, assumindo o valor absoluto. Então, da mesma forma que um círculo, subtraímos metade do tamanho (que basicamente se assemelha ao raio de um retângulo). Para apenas mostrar como serão os resultados, retornaremos apenas um componente por enquanto.
float rectangle(float2 samplePosition, float2 halfSize){ float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize; return componentWiseEdgeDistance.x; }
Agora podemos obter uma versão barata do retângulo simplesmente retornando o maior componente 2. Isso funciona em muitos casos, mas não corretamente, porque não exibe a distância correta nos cantos.
Os valores corretos para o retângulo fora da figura podem ser obtidos, primeiro levando o máximo entre as distâncias até as arestas e 0, e depois o comprimento.
Se não limitarmos a distância abaixo de 0, simplesmente calculamos a distância até os cantos (onde as distâncias das arestas são
(0, 0)
), mas as coordenadas entre os cantos não cairão abaixo de 0, portanto, toda a aresta será usada. A desvantagem disso é que 0 é usado como a distância da borda para todo o interior da figura.
Para corrigir a distância 0 para toda a parte interna, é necessário gerar a distância interna, simplesmente usando a fórmula de retângulo barato (obtendo o valor máximo do componente xey) e garantindo que nunca exceda 0, levando o valor mínimo para 0. Em seguida, adicionamos a distância externa, que nunca é menor que 0, e a distância interna, que nunca excede 0, e obtemos a função de distância finalizada.
float rectangle(float2 samplePosition, float2 halfSize){ float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize; float outsideDistance = length(max(componentWiseEdgeDistance, 0)); float insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0); return outsideDistance + insideDistance; }
Como já gravamos a função de transferência de forma universal, agora também podemos usá-la para mover seu centro para qualquer lugar.
float scene(float2 position) { float2 circlePosition = translate(position, float2(1, 0)); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Turn
Girar formas é semelhante a movimento. Antes de calcular a distância da figura, giramos as coordenadas na direção oposta. Para simplificar o máximo possível a compreensão das rotações, multiplicamos a rotação por 2 * pi para obter o ângulo em radianos. Assim, passamos uma rotação para a função, em que 0,25 é um quarto de volta, 0,5 é meia volta e 1 é uma volta completa (você pode realizar conversões de maneira diferente se lhe parecer mais natural). Também invertemos a rotação, porque precisamos girar a posição na direção oposta à rotação da figura pelo mesmo motivo que ao mover.
Para calcular as coordenadas giradas, primeiro calculamos o seno e o cosseno com base no ângulo. O Hlsl possui uma função sincos que calcula esses dois valores mais rapidamente do que quando calculados separadamente.
Ao construir um novo vetor para o componente x, pegamos o componente original x multiplicado pelo cosseno e o componente y multiplicado pelo seno. Isso pode ser facilmente lembrado se você lembrar que o cosseno de 0 é 1 e, quando girado por 0, queremos que o componente x do novo vetor seja exatamente o mesmo de antes (ou seja, multiplique por 1). O componente y, que anteriormente apontou para cima, não contribuiu para o componente x, gira para a direita e seus valores começam em 0, inicialmente ficando maiores, ou seja, seu movimento é completamente descrito por um seno.
Para o componente y do novo vetor, multiplicamos o cosseno pelo componente y do vetor antigo e subtraímos o seno multiplicado pelo componente antigo x. Para entender por que subtraímos, em vez de adicionar o seno, multiplicado pelo componente x, é melhor imaginar como o vetor
(1, 0)
muda quando girado no sentido horário. O componente y do resultado começa em 0 e depois se torna menor que 0. Esse é o oposto de como o seno se comporta, então trocamos de sinal.
float2 rotate(float2 samplePosition, float rotation){ const float PI = 3.14159; float angle = rotation * PI * 2 * -1; float sine, cosine; sincos(angle, sine, cosine); return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x); }
Agora que escrevemos o método de rotação, podemos usá-lo em combinação com a transferência para mover e girar a figura.
float scene(float2 position) { float2 circlePosition = position; circlePosition = rotate(circlePosition, _Time.y); circlePosition = translate(circlePosition, float2(2, 0)); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Nesse caso, primeiro giramos o objeto em torno do centro de toda a cena, para que a rotação também afete a transferência. Para girar uma figura em relação ao seu próprio centro, primeiro é necessário movê-la e depois girá-la. Devido a essa ordem alterada no momento da rotação, o centro da figura se tornará o centro do sistema de coordenadas.
float scene(float2 position) { float2 circlePosition = position; circlePosition = translate(circlePosition, float2(2, 0)); circlePosition = rotate(circlePosition, _Time.y); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Dimensionamento
A escala funciona de maneira semelhante a outras maneiras de transformar formas. Dividimos as coordenadas por escala, renderizando a figura no espaço com uma escala reduzida e, no sistema de coordenadas base, elas se tornam maiores.
float2 scale(float2 samplePosition, float scale){ return samplePosition / scale; }
float scene(float2 position) { float2 circlePosition = position; circlePosition = translate(circlePosition, float2(0, 0)); circlePosition = rotate(circlePosition, .125); float pulseScale = 1 + 0.5*sin(_Time.y * 3.14); circlePosition = scale(circlePosition, pulseScale); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Embora isso execute o dimensionamento corretamente, a distância também é dimensionada. A principal vantagem do campo de distância assinada é que sempre sabemos a distância da superfície mais próxima, mas diminuir o zoom destrói completamente essa propriedade. Isso pode ser facilmente corrigido multiplicando o campo de distância obtido a partir da função de distância do sinal (no nosso caso, o
rectangle
) pela escala. Pela mesma razão, não podemos facilmente escalar desigualmente (com escalas diferentes para os eixos x e y).
float scene(float2 position) { float2 circlePosition = position; circlePosition = translate(circlePosition, float2(0, 0)); circlePosition = rotate(circlePosition, .125); float pulseScale = 1 + 0.5*sin(_Time.y * 3.14); circlePosition = scale(circlePosition, pulseScale); float sceneDistance = rectangle(circlePosition, float2(1, 2)) * pulseScale; return sceneDistance; }
Visualização
Os campos de distância assinados podem ser usados para várias coisas, como criar sombras, renderizar cenas 3D, física e renderizar texto. Mas ainda não queremos aprofundar a complexidade, portanto, explicarei apenas duas técnicas de visualização. A primeira é uma forma clara com antialiasing, a segunda é a renderização de linhas, dependendo da distância.
Limpar formulário
Este método é semelhante ao que é frequentemente usado ao renderizar texto, pois cria um formulário claro. Se queremos gerar um campo de distância não a partir de uma função, mas lê-lo a partir de uma textura, isso nos permite usar texturas com uma resolução muito menor do que o habitual e obter bons resultados. O TextMesh Pro usa essa técnica para renderizar texto.
Para aplicar essa técnica, aproveitamos o fato de os dados nos campos de distância serem assinados e conhecemos o ponto de corte. Começamos calculando a distância que o campo de distância muda para o próximo pixel. Esse deve ser o mesmo valor que o comprimento da alteração de coordenadas, mas é mais fácil e confiável calcular a distância com um sinal.
Depois de receber a mudança de distância, podemos fazer um
passo suave da metade da mudança de distância para menos / mais metade da mudança de distância. Isso fará um recorte simples em torno de 0, mas com suavização. Então você pode usar esse valor suavizado para qualquer valor binário que precisarmos. Neste exemplo, alterarei o sombreador para um sombreador de transparência e o utilizarei para o canal alfa. Eu passo suavemente de um valor positivo para um negativo porque queremos que o valor negativo do campo de distância seja visível. Se você não entende bem como a renderização em transparência funciona aqui, recomendo a leitura do
meu tutorial sobre renderização em transparência.
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); float distanceChange = fwidth(dist) * 0.5; float antialiasedCutoff = smoothstep(distanceChange, -distanceChange, dist); fixed4 col = fixed4(_Color, antialiasedCutoff); return col; }
Linhas de elevação
Outra técnica comum para visualizar campos de distância é exibir distâncias como linhas. Em nossa implementação, adicionarei algumas linhas grossas e algumas finas entre elas. Também pintarei o interior e o exterior da figura em cores diferentes, para que você possa ver onde está o objeto.
Começaremos exibindo a diferença entre o interior e o exterior da figura. Como as cores podem ser personalizadas no material, adicionaremos novas propriedades, bem como variáveis de sombreamento para as cores internas e externas da figura.
Properties{ _InsideColor("Inside Color", Color) = (.5, 0, 0, 1) _OutsideColor("Outside Color", Color) = (0, .5, 0, 1) }
Em seguida, no shader de fragmento, verificamos onde o pixel está localizado, que renderizamos comparando a distância com o sinal com 0 usando a função
step
. Usamos essa variável para interpolar da cor interna para a externa e renderizá-la na tela.
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist)); return col; }
Para renderizar linhas, primeiro precisamos especificar com que freqüência renderizamos as linhas e qual a espessura delas, definindo as propriedades e as variáveis de sombreamento correspondentes.
Então, para renderizar as linhas, começaremos calculando a mudança na distância para que possamos usá-la mais tarde para suavizar. Também já o dividimos por 2, porque mais tarde adicionamos metade e subtraímos metade para cobrir a distância de mudança de 1 pixel.
float distanceChange = fwidth(dist) * 0.5;
Então tomamos a distância e a transformamos para que ele tenha o mesmo comportamento em pontos de repetição. Para fazer isso, primeiro dividimos pela distância entre as linhas, enquanto não obteremos números completos a cada primeiro passo, mas números completos apenas com base na distância que definimos.
Então adicionamos 0,5 ao número, pegamos a parte fracionária e subtraímos 0,5 novamente. A parte fracionária e a subtração são necessárias aqui para que a linha passe por zero no padrão de repetição. Adicionamos 0,5 para obter a parte fracionária, a fim de neutralizar subtrações adicionais de 0,5 - o deslocamento levará ao fato de que os valores nos quais o gráfico é 0 estão em 0, 1, 2 etc., e não em 0,5, 1,5, etc.
Os últimos passos para converter o valor - pegamos o valor absoluto e multiplicamos novamente pela distância entre as linhas. O valor absoluto torna as áreas antes e depois dos pontos da linha iguais, o que facilita a criação de recortes para as linhas. A última operação, na qual multiplicamos novamente o valor pela distância entre as linhas, é necessária para neutralizar a divisão no início da equação, graças a ela, a alteração no valor é novamente a mesma do início, e a alteração calculada anteriormente na distância ainda está correta.
float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance;
Agora que calculamos a distância das linhas com base na distância da figura, podemos desenhar as linhas. Fazemos um passo suave da espessura da linha menos metade da alteração na distância para espessura da linha mais metade da mudança na distância e usamos a distância da linha calculada como um valor para comparação. Depois de calcular esse valor, multiplicamos por cor para criar linhas pretas (você também pode ler uma cor diferente se precisar de linhas coloridas).
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist)); float distanceChange = fwidth(dist) * 0.5; float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance; float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance); return col * majorLines; }
Implementamos linhas finas entre as grossas da mesma maneira - adicionamos uma propriedade que determina quantas linhas finas devem existir entre as grossas e depois fazemos o que fizemos com as grossas, mas, devido à distância entre as linhas finas, dividimos a distância entre as grossas pelo número de linhas finas entre elas. eles. Também
IntRange
o número de linhas finas
IntRange
, graças a isso, podemos apenas atribuir valores inteiros e não obter linhas finas que não
IntRange
grossas. Depois de calcular as linhas finas, nós as multiplicamos por cor da mesma maneira que as grossas.
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist)); float distanceChange = fwidth(dist) * 0.5; float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance; float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance); float distanceBetweenSubLines = _LineDistance / _SubLines; float subLineDistance = abs(frac(dist / distanceBetweenSubLines + 0.5) - 0.5) * distanceBetweenSubLines; float subLines = smoothstep(_SubLineThickness - distanceChange, _SubLineThickness + distanceChange, subLineDistance); return col * majorLines * subLines; }
Código fonte
Recursos SDF 2D
#ifndef SDF_2D #define SDF_2D float2 rotate(float2 samplePosition, float rotation){ const float PI = 3.14159; float angle = rotation * PI * 2 * -1; float sine, cosine; sincos(angle, sine, cosine); return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x); } float2 translate(float2 samplePosition, float2 offset){
Exemplo de círculo
Shader "Tutorial/034_2D_SDF_Basics/Rectangle"{ SubShader{
Exemplo de retângulo
Shader "Tutorial/034_2D_SDF_Basics/Rectangle"{ SubShader{
Cutoff
Shader "Tutorial/034_2D_SDF_Basics/Cutoff"{ Properties{ _Color("Color", Color) = (1,1,1,1) } SubShader{ Tags{ "RenderType"="Transparent" "Queue"="Transparent"} Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Pass{ CGPROGRAM #include "UnityCG.cginc" #include "2D_SDF.cginc" #pragma vertex vert #pragma fragment frag struct appdata{ float4 vertex : POSITION; }; struct v2f{ float4 position : SV_POSITION; float4 worldPos : TEXCOORD0; }; fixed3 _Color; v2f vert(appdata v){ v2f o;
Linhas de distância
Shader "Tutorial/034_2D_SDF_Basics/DistanceLines"{ Properties{ _InsideColor("Inside Color", Color) = (.5, 0, 0, 1) _OutsideColor("Outside Color", Color) = (0, .5, 0, 1) _LineDistance("Mayor Line Distance", Range(0, 2)) = 1 _LineThickness("Mayor Line Thickness", Range(0, 0.1)) = 0.05 [IntRange]_SubLines("Lines between major lines", Range(1, 10)) = 4 _SubLineThickness("Thickness of inbetween lines", Range(0, 0.05)) = 0.01 } SubShader{
Espero ter conseguido explicar o básico dos campos de distância com um sinal e você já está aguardando alguns novos tutoriais nos quais falarei sobre outras maneiras de usá-los.