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
antes
; 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:
- ,
- ,
- ,
- ,
que são mostrados abaixo:
Esses valores variam dependendo da malha usada. No plano Unity, as
coordenadas UV estão na faixa de
antes
, e as
coordenadas dos vértices estão no intervalo de
antes
.
As equações para converter XZ em UV são:
(1)
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.
. Temos dois intervalos: um tem valores de
antes
outro de
antes
. Dados recebidos para a coordenada
é a coordenada do vértice atual que está sendo processado e a saída será a coordenada
usado para provar a textura.
Precisamos manter as propriedades da proporcionalidade entre
e seu intervalo, e
e seu intervalo. Por exemplo, se
importa 25% do seu intervalo então
també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
:
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
).
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;
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 é
mas tambem
e 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) {
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.