Unity Interactive Shaders de Mapa

imagem

Este tutorial é sobre mapas interativos e como criá-los no Unity usando sombreadores.

Esse efeito pode servir de base para técnicas mais complexas, como projeções holográficas ou até mesmo uma mesa de areia do filme "Pantera Negra".

Uma inspiração para este tutorial é um tweet de Baran Kahyaoglu , que mostra um exemplo do que ele está criando para o Mapbox .



A cena (excluindo o mapa) foi tirada da demonstração da Nave Espacial Unity Visual Effect Graph (veja abaixo), que pode ser baixada aqui .


Parte 1. Desvio de vértice


Anatomia do efeito


A primeira coisa que você pode notar imediatamente é que os mapas geográficos são planos : se forem usados ​​como texturas, eles não têm a tridimensionalidade que um modelo 3D real da área do mapa correspondente teria.

Você pode aplicar esta solução: crie um modelo 3D da área necessária no jogo e aplique uma textura do mapa a ele. Isso ajudará a resolver o problema, mas leva muito tempo e não permitirá perceber o efeito da "rolagem" do vídeo Baran Kahyaoglu.

Obviamente, uma abordagem mais técnica é a melhor. Felizmente, os shaders podem ser usados ​​para alterar a geometria de um modelo 3D. Com a ajuda deles, você pode transformar qualquer avião em vales e montanhas da região de que precisamos.

Neste tutorial, usamos um mapa de Chillot , Chilli, famoso por suas colinas características. A imagem abaixo mostra a textura da região plotada em uma malha redonda.


Embora vejamos colinas e montanhas, elas ainda são completamente planas. Isso destrói a ilusão de realismo.

Normas de extrusão


O primeiro passo para usar shaders para alterar a geometria é uma técnica chamada extrusão normal . Ela precisa de um modificador de vértice : uma função que pode manipular vértices individuais de um modelo 3D.

A maneira como o modificador de vértice é usado depende do tipo de sombreador usado. Neste tutorial, alteraremos o Surface Shader padrão - um dos tipos de shaders que você pode criar no Unity.

Existem várias maneiras de manipular os vértices de um modelo 3D. Um dos primeiros métodos descritos na maioria dos tutoriais de vertex shader é a extrusão de normais . Consiste em empurrar cada vértice para fora ( extrudar ), o que dá ao modelo 3D uma aparência mais inchada. "Fora" significa que cada vértice se move na direção do normal.


Para superfícies lisas, isso funciona muito bem, mas em modelos com más conexões de vértices, esse método pode criar artefatos estranhos. Esse efeito é bem explicado em um dos meus primeiros tutoriais: Uma introdução suave aos sombreadores , onde mostrei como extrudar e intrometer um modelo 3D.


Adicionar normais extrudados a um shader de superfície é muito fácil. Cada sombreador de superfície possui uma #pragma , usada para transmitir informações e comandos adicionais. Um desses comandos é o vert , o que significa que a função vert será usada para processar cada vértice do modelo 3D.

O shader editado é o seguinte:

 #pragma surface surf Standard fullforwardshadows addshadow vertex:vert ... float _Amount; ... void vert(inout appdata_base v) { v.vertex.xyz += v.normal * _Amount; } 

Como estamos alterando a posição dos vértices, também precisamos usar o addshadow se quisermos que o modelo addshadow corretamente sombras sobre si mesmo.

O que é appdata_base?
Como você pode ver, adicionamos uma função do modificador de vértices ( vert ), que assume como parâmetro uma estrutura chamada appdata_base . Essa estrutura armazena informações sobre cada vértice individual do modelo 3D. Ele contém não apenas a posição do vértice ( v.vertex ), mas também outros campos, por exemplo , a direção normal ( v.normal ) e as informações de textura associadas ao vértice ( v.texcoord ).

Em alguns casos, isso não é suficiente e podemos precisar de outras propriedades, como cor do vértice ( v.color ) e direção da tangente ( v.tangent ). Os modificadores de vértice podem ser especificados usando uma variedade de outras estruturas de appdata_tan , incluindo appdata_tan e appdata_full , que fornecem mais informações ao custo da appdata_full de baixo desempenho. Você pode ler mais sobre appdata (e suas variantes) no wiki do Unity3D .

Como os valores são retornados da vert?
A função superior não tem valor de retorno. Se você está familiarizado com a linguagem C #, deve saber que estruturas são passadas por valor, ou seja, quando v.vertex muda v.vertex isso afeta apenas a cópia de v , cujo escopo é limitado pelo corpo da função.

No entanto, v também v declarado como inout , o que significa que é usado para entrada e saída. Qualquer alteração que você fizer altera a própria variável, que passamos para vert . As palavras-chave inout e out frequentemente usadas em computação gráfica e podem ser correlacionadas aproximadamente com ref e out em C #.

Extrudando normais com texturas


O código que usamos acima funciona corretamente, mas está longe do efeito que queremos alcançar. O motivo é que não queremos extrudar todos os vértices na mesma quantidade. Queremos que a superfície do modelo 3D corresponda aos vales e montanhas da região geográfica correspondente. Primeiro, precisamos de alguma forma armazenar e recuperar informações sobre quanto cada ponto no mapa é gerado. Queremos que a extrusão seja influenciada pela textura na qual as alturas da paisagem são codificadas. Essas texturas são freqüentemente chamadas de mapas de altura , mas também são chamados de mapas de profundidade , dependendo do contexto. Tendo recebido informações sobre as alturas, poderemos modificar a extrusão do avião com base no mapa de altura. Como mostrado no diagrama, isso nos permitirá controlar o aumento e o abaixamento de áreas.


É muito simples encontrar uma imagem de satélite da área geográfica em que você está interessado e um mapa de elevação associado. Abaixo está o mapa de satélite de Marte (acima) e o mapa de altitude (abaixo) que foram usados ​​neste tutorial:



Falei detalhadamente sobre o conceito de mapa de profundidade em outra série de tutoriais chamados "fotos 3D do Facebook por dentro: shaders de paralaxe" [ tradução para Habré].

Neste tutorial, assumiremos que o mapa de altura é armazenado como uma imagem em escala de cinza, onde preto e branco correspondem a alturas mais baixas e mais altas. Também precisamos que esses valores sejam dimensionados linearmente , ou seja, a diferença de cor, por exemplo, em 0.1 corresponde a uma diferença de altura entre 0 e 0.1 ou entre 0.9 e 1.0 . Para mapas de profundidade, isso nem sempre é verdade, porque muitos deles armazenam informações de profundidade em uma escala logarítmica .

Para amostrar uma textura, são necessários dois elementos de informação: a própria textura e as coordenadas UV do ponto que queremos amostrar. O último pode ser acessado através do campo texcoord , armazenado na estrutura appdata_base . Essa é a coordenada UV associada ao vértice atual que está sendo processado. A amostragem de textura em uma função de superfície é feita usando tex2D , no entanto, quando estamos em uma , é necessário tex2Dlod .

No snippet de código abaixo, uma textura chamada _HeightMap usada para modificar o valor de extrusão executado para cada vértice:

 sampler2D _HeightMap; ... void vert(inout appdata_base v) { fixed height = tex2Dlod(_HeightMap, float4(v.texcoord.xy, 0, 0)).r; vertex.xyz += v.normal * height * _Amount; } 

Por que o tex2D não pode ser usado como uma função de vértice?
Se você observar o código que o Unity gera para o Shader de superfície padrão, você perceberá que ele já contém um exemplo de como provar texturas. Em particular, ele mostra a textura principal (chamada _MainTex ) em uma função de superfície (chamada surf ) usando a função tex2D integrada.

De fato, o tex2D usado para amostrar pixels de uma textura, independentemente do que está armazenado nela, cor ou altura. No entanto, você pode perceber que o tex2D não pode ser usado em uma função de vértice.

O motivo é que o tex2D não apenas lê pixels da textura. Ela também decide qual versão da textura usar, dependendo da distância da câmera. Essa técnica é chamada de mipmapping : permite que você tenha versões menores de uma única textura que podem ser usadas automaticamente em diferentes distâncias.

Na função de superfície, o shader já sabe qual textura MIP usar. Essas informações ainda podem não estar disponíveis na função de vértice e, portanto, o tex2D não pode ser usado com total confiança. Em contraste, a função tex2Dlod pode receber dois parâmetros adicionais, que neste tutorial podem ter um valor zero.

O resultado é claramente visível nas imagens abaixo.



Nesse caso, uma pequena simplificação pode ser feita. O código que analisamos anteriormente pode funcionar com qualquer geometria. No entanto, podemos assumir que a superfície é absolutamente plana. De fato, realmente queremos aplicar esse efeito ao avião.

Portanto, você pode remover v.normal e substituí-lo por float3(0, 1, 0) :

 void vert(inout appdata_base v) { float3 normal = float3(0, 1, 0); fixed height = tex2Dlod(_HeightMap, float4(v.texcoord.xy, 0, 0)).r; vertex.xyz += normal * height * _Amount; } 

Poderíamos fazer isso porque todas as coordenadas no appdata_base são armazenadas no espaço do modelo , ou seja, são definidas em relação ao centro e à orientação do modelo 3D. Transição, rotação e escala com transformação no Unity alteram a posição, rotação e escala do objeto, mas não afetam o modelo 3D original.

Parte 2. Efeito de rolagem


Tudo o que fizemos acima funciona muito bem. Antes de prosseguir, extrairemos o código necessário para calcular a nova altura do vértice em uma função getVertex separada:

 float4 getVertex(float4 vertex, float2 texcoord) { float3 normal = float3(0, 1, 0); fixed height = tex2Dlod(_HeightMap, float4(texcoord, 0, 0)).r; vertex.xyz += normal * height * _Amount; return vertex; } 

Então toda a função vert terá a forma:

 void vert(inout appdata_base v) { vertex = getVertex(v.vertex, v.texcoord.xy); } 

Fizemos isso porque abaixo precisamos calcular a altura de vários pontos. Devido ao fato de que essa funcionalidade estará em sua própria função separada, o código se tornará muito mais simples.

Cálculo de coordenadas UV


No entanto, isso nos leva a outro problema. A função getVertex depende não apenas da posição do vértice atual (v.vertex), mas também de suas coordenadas UV ( v.texcoord ).

Quando queremos calcular o deslocamento da altura do vértice que a função vert está processando atualmente, os dois elementos de dados estão disponíveis na estrutura appdata_base . No entanto, o que acontece se precisarmos provar a posição de um ponto vizinho? Nesse caso, podemos conhecer a posição xyz no espaço do modelo , mas não temos acesso às suas coordenadas UV.

Isso significa que o sistema existente é capaz de calcular o deslocamento da altura apenas para o vértice atual. Essa restrição não nos permitirá seguir em frente, por isso precisamos encontrar uma solução.

A maneira mais fácil é encontrar uma maneira de calcular as coordenadas UV de um objeto 3D, sabendo a posição de seu vértice. Essa é uma tarefa muito difícil, e existem várias técnicas para resolvê-la (uma das mais populares é a projeção triplanar ). Mas neste caso em particular, não precisamos combinar UV com geometria. Se assumirmos que o sombreador sempre será aplicado à malha plana, a tarefa se tornará trivial.

Podemos calcular as coordenadas UV (imagem inferior) a partir das posições dos vértices (imagem superior) devido ao fato de que ambos são sobrepostos linearmente em uma malha plana.



Isso significa que, para resolver nosso problema, precisamos transformar os componentes XZ da posição do vértice nas coordenadas UV correspondentes.


Este procedimento é chamado de interpolação linear . É discutido em detalhes no meu site (por exemplo: Os segredos da interpolação de cores ).

Na maioria dos casos, os valores UV estão na faixa de 0antes 1; as coordenadas de cada vértice, por outro lado, são potencialmente ilimitadas. Do ponto de vista da matemática, para a conversão de XZ em UV, precisamos apenas de seus valores-limite:

  • Xmin, Xmax
  • Zmin, Zmax
  • Umin, Umax
  • Vmin, Vmax

que são mostrados abaixo:


Esses valores variam dependendo da malha usada. No plano Unity, as coordenadas UV estão na faixa de 0antes 1, e as coordenadas dos vértices estão no intervalo de 5antes +5.

As equações para converter XZ em UV são:

(1)
imagem


Como eles são exibidos?
Se você não está familiarizado com o conceito de interpolação linear, essas equações podem parecer bastante intimidadoras.

No entanto, eles são exibidos de maneira bastante simples. Vejamos apenas um exemplo. U. Temos dois intervalos: um tem valores de Xminantes Xmaxoutro de Uminantes Umax. Dados recebidos para a coordenada Xé a coordenada do vértice atual que está sendo processado e a saída será a coordenada Uusado para provar a textura.

Precisamos manter as propriedades da proporcionalidade entre Xe seu intervalo, e Ue seu intervalo. Por exemplo, se Ximporta 25% do seu intervalo então Utambém importará 25% de seu intervalo.

Tudo isso é mostrado no diagrama a seguir:


A partir disso, podemos deduzir que a proporção composta do segmento vermelho em relação ao rosa deve ser a mesma que a proporção entre o segmento azul e o azul:

2)

Agora podemos transformar a equação mostrada acima para obter U:


e essa equação tem exatamente a mesma forma mostrada acima (1).

Essas equações podem ser implementadas no código da seguinte maneira:

 float2 _VertexMin; float2 _VertexMax; float2 _UVMin; float2 _UVMax; float2 vertexToUV(float4 vertex) { return (vertex.xz - _VertexMin) / (_VertexMax - _VertexMin) * (_UVMax - _UVMin) + _UVMin; } 

Agora podemos chamar a função getVertex sem precisar passar o v.texcoord :

 float4 getVertex(float4 vertex) { float3 normal = float3(0, 1, 0); float2 texcoord = vertexToUV(vertex); fixed height = tex2Dlod(_HeightMap, float4(texcoord, 0, 0)).r; vertex.xyz += normal * height * _Amount; return vertex; } 

Então toda a função vert assume a forma:

 void vert(inout appdata_base v) { v.vertex = getVertex(v.vertex); } 

Efeito de rolagem


Graças ao código que escrevemos, o mapa inteiro é exibido na malha. Se queremos melhorar a exibição, precisamos fazer alterações.

Vamos formalizar um pouco mais o código. Em primeiro lugar, talvez seja necessário aumentar o zoom em uma parte separada do mapa, em vez de analisá-lo inteiro.


Essa área pode ser definida por dois valores: seu tamanho ( _CropSize ) e localização no mapa ( _CropOffset ), medido no espaço do vértice (de _VertexMin a _VertexMax ).

 // Cropping float2 _CropSize; float2 _CropOffset; 

Após receber esses dois valores, podemos novamente usar a interpolação linear para que o getVertex chamado não para a posição atual da parte superior do modelo 3D, mas para o ponto escalado e transferido.


Código relevante:

 void vert(inout appdata_base v) { float2 croppedMin = _CropOffset; float2 croppedMax = croppedMin + _CropSize; // v.vertex.xz: [_VertexMin, _VertexMax] // cropped.xz : [croppedMin, croppedMax] float4 cropped = v.vertex; cropped.xz = (v.vertex.xz - _VertexMin) / (_VertexMax - _VertexMin) * (croppedMax - croppedMin) + croppedMin; v.vertex.y = getVertex(cropped); } 

Se quisermos rolar, será suficiente atualizar _CropOffset através do script. Devido a isso, a área de truncamento se moverá, rolando a paisagem.

 public class MoveMap : MonoBehaviour { public Material Material; public Vector2 Speed; public Vector2 Offset; private int CropOffsetID; void Start () { CropOffsetID = Shader.PropertyToID("_CropOffset"); } void Update () { Material.SetVector(CropOffsetID, Speed * Time.time + Offset); } } 

Para que isso funcione, é muito importante definir o Modo de quebra automática de todas as texturas como Repetir . Se isso não for feito, não conseguiremos fazer o loop da textura.

Para o efeito zoom / zoom, basta alterar _CropSize .

Parte 3. Sombreamento do terreno


Sombreamento plano


Todo o código que escrevemos funciona, mas tem um problema sério. Sombrear o modelo é algo estranho. A superfície é adequadamente curva, mas reage à luz como se fosse plana.

Isso é visto claramente nas imagens abaixo. A imagem superior mostra um shader existente; o fundo mostra como ele realmente funciona.



Resolver esse problema pode ser um grande desafio. Mas primeiro, precisamos descobrir qual é o erro.

A operação de extrusão normal alterou a geometria geral do plano que usamos inicialmente. No entanto, o Unity mudou apenas a posição dos vértices, mas não suas direções normais. A direção do vértice normal , como o nome indica, é um vetor de comprimento unitário ( direção ) indicando perpendicular à superfície. As normais são necessárias porque elas desempenham um papel importante no sombreamento de um modelo 3D. Eles são usados ​​por todos os shaders de superfície para calcular como a luz deve ser refletida em cada triângulo do modelo 3D. Normalmente, isso é necessário para melhorar a tridimensionalidade do modelo, por exemplo, faz com que a luz ricocheteie em uma superfície plana, assim como em uma superfície curva. Esse truque é frequentemente usado para fazer com que as superfícies com baixo poli pareçam mais suaves do que realmente são (veja abaixo).


No entanto, no nosso caso, acontece o contrário. A geometria é curva e suave, mas como todas as normais são direcionadas para cima, a luz é refletida no modelo como se fosse plana (veja abaixo):


Você pode ler mais sobre o papel das normais no sombreamento de objetos no artigo Mapeamento normal (mapeamento de resposta) , onde cilindros idênticos parecem muito diferentes, apesar do mesmo modelo 3D, devido a diferentes métodos de cálculo de normais de vértices (veja abaixo).



Infelizmente, nem o Unity nem o idioma para criar shaders têm uma solução integrada para recalcular automaticamente os normais. Isso significa que você precisa alterá-las manualmente, dependendo da geometria local do modelo 3D.

Cálculo normal


A única maneira de corrigir o problema de sombreamento é calcular manualmente as normais com base na geometria da superfície. Uma tarefa semelhante foi discutida em um post do Vertex Displacement - Melting Shader Part 1 , onde foi usado para simular o derretimento de modelos 3D em Cone Wars .

Embora o código finalizado tenha que funcionar em coordenadas 3D, vamos restringir a tarefa a apenas duas dimensões por enquanto. Imagine que você precisa calcular a direção do normal correspondente ao ponto na curva 2D (a grande seta azul no diagrama abaixo).


Do ponto de vista geométrico, a direção do normal (grande seta azul) é um vetor perpendicular à tangente que passa pelo ponto de interesse para nós (uma fina linha azul). A tangente pode ser representada como uma linha localizada na curvatura do modelo. Um vetor tangente é um vetor unitário que se encontra em uma tangente.

Isso significa que, para calcular o normal, é necessário executar duas etapas: primeiro, encontre a linha tangente ao ponto desejado; depois calcule o vetor perpendicular a ele (que será a direção necessária do normal ).

Cálculo tangente


Para obter o normal, primeiro precisamos calcular a tangente . Pode ser aproximada amostrando um ponto próximo e usando-o para construir uma linha perto do vértice. Quanto menor a linha, mais preciso é o valor.

São necessárias três etapas:

  • Etapa 1. Mova uma pequena quantidade em uma superfície plana
  • Etapa 2. Calcule a altura do novo ponto.
  • Etapa 3. Use a altura do ponto atual para calcular a tangente

Tudo isso pode ser visto na imagem abaixo:


Para que isso funcione, precisamos calcular as alturas de dois pontos, não um. Felizmente, já sabemos como fazer isso. Na parte anterior do tutorial, criamos uma função que mostra a altura de uma paisagem com base em um ponto de malha. Nós o chamamos de getVertex .

Podemos pegar o novo valor do vértice no ponto atual e depois em outros dois. Um será para a tangente, o outro para a tangente em dois pontos. Com a ajuda deles, conseguimos o normal. Se a malha original usada para criar o efeito for plana (e, no nosso caso, é), não precisamos acessar v.normal e podemos usar float3(0, 0, 1) para tangente e tangente a dois pontos, respectivamente float3(0, 0, 1) e float3(1, 0, 0) . Se quiséssemos fazer o mesmo, mas, por exemplo, para uma esfera, seria muito mais difícil encontrar dois pontos adequados para calcular a tangente e a tangente em dois pontos.

Arte vetorial


Tendo obtido os vetores tangentes e tangentes adequados para dois pontos, podemos calcular o normal usando uma operação chamada produto vetorial . Existem muitas definições e explicações sobre um trabalho vetorial e o que ele faz.

Um produto vetorial recebe dois vetores e retorna um novo. Se dois vetores iniciais foram unitários (seu comprimento é igual a um) e estão localizados em um ângulo de 90, o vetor resultante será localizado em 90 graus em relação a ambos.

A princípio, isso pode ser confuso, mas graficamente pode ser representado da seguinte forma: o produto vetorial de dois eixos cria um terceiro. Isso é X vezesY=Zmas tambem X vezesZ=Ye assim por diante.

Se dermos um passo suficientemente pequeno (no código, isso é offset ), os vetores da tangente e da tangente a dois pontos estarão em um ângulo de 90 graus.Juntamente com o vetor normal, eles formam três eixos perpendiculares orientados ao longo da superfície do modelo.

Sabendo disso, podemos escrever todo o código necessário para calcular e atualizar o vetor normal.

 void vert(inout appdata_base v) { float3 bitangent = float3(1, 0, 0); float3 tangent = float3(0, 0, 1); float offset = 0.01; float4 vertexBitangent = getVertex(v.vertex + float4(bitangent * offset, 0) ); float4 vertex = getVertex(v.vertex); float4 vertexTangent = getVertex(v.vertex + float4(tangent * offset, 0) ); float3 newBitangent = (vertexBitangent - vertex).xyz; float3 newTangent = (vertexTangent - vertex).xyz; v.normal = cross(newTangent, newBitangent); v.vertex.y = vertex.y; } 

Juntando tudo


Agora que tudo está funcionando, podemos retornar o efeito de rolagem.

 void vert(inout appdata_base v) { // v.vertex.xz: [_VertexMin, _VertexMax] // cropped.xz : [croppedMin, croppedMax] float2 croppedMin = _CropOffset; float2 croppedMax = croppedMin + _CropSize; float4 cropped = v.vertex; cropped.xz = (v.vertex.xz - _VertexMin) / (_VertexMax - _VertexMin) * (croppedMax - croppedMin) + croppedMin; float3 bitangent = float3(1, 0, 0); float3 normal = float3(0, 1, 0); float3 tangent = float3(0, 0, 1); float offset = 0.01; float4 vertexBitangent = getVertex(cropped + float4(bitangent * offset, 0) ); float4 vertex = getVertex(cropped); float4 vertexTangent = getVertex(cropped + float4(tangent * offset, 0) ); float3 newBitangent = (vertexBitangent - vertex).xyz; float3 newTangent = (vertexTangent - vertex).xyz; v.normal = cross(newTangent, newBitangent); v.vertex.y = vertex.y; v.texcoord = float4(vertexToUV(cropped), 0,0); } 

E com isso nosso efeito está finalmente concluído.


Para onde ir a seguir


Este tutorial pode se tornar a base de efeitos mais complexos, por exemplo, projeções holográficas ou até mesmo uma cópia da mesa de areia do filme "Pantera Negra".


Pacote Unity


O pacote completo deste tutorial pode ser baixado no Patreon , pois contém todos os recursos necessários para executar o efeito descrito.

Source: https://habr.com/ru/post/pt462153/


All Articles