Este tutorial trata sobre
mapas interactivos y cómo crearlos en Unity usando sombreadores.
Este efecto puede servir como base para técnicas más complejas, como las proyecciones holográficas o incluso una mesa de arena de la película "Black Panther".
Una inspiración para este tutorial es el tweet publicado por
Baran Kahyaoglu , que muestra un ejemplo de lo que está creando para
Mapbox .
La escena (excluyendo el mapa) se tomó de la demostración de la Nave espacial del gráfico de efectos visuales de Unity (ver más abajo), que se puede descargar
aquí .
Parte 1. Compensación de vértices
Anatomía del efecto
Lo primero que puede notar de inmediato es que los mapas geográficos son
planos : si se usan como texturas, carecen de la tridimensionalidad que tendría un modelo 3D real del área del mapa correspondiente.
Puedes aplicar esta solución: crea un modelo 3D del área que se necesita en el juego y luego aplica una textura del mapa. Esto ayudará a resolver el problema, pero lleva mucho tiempo y no permitirá darse cuenta del efecto de "desplazamiento" del video Baran Kahyaoglu.
Obviamente, un enfoque más técnico es el mejor. Afortunadamente, los sombreadores se pueden usar para cambiar la geometría de un modelo 3D. Con su ayuda, puede convertir cualquier avión en valles y montañas de la región que necesitamos.
En este tutorial usamos un mapa
de Chillot , Chilli, famoso por sus colinas características. La imagen a continuación muestra la textura de la región trazada en una malla redonda.
Aunque vemos colinas y montañas, todavía son completamente planas. Esto destruye la ilusión del realismo.
Extrusión de normales
El primer paso para usar sombreadores para cambiar la geometría es una técnica llamada
extrusión normal . Necesita
un modificador de vértice : una función que pueda manipular vértices individuales de un modelo 3D.
La forma en que se usa el modificador de vértice depende del tipo de sombreador utilizado. En este tutorial, cambiaremos el
sombreador estándar de superficie , uno de los tipos de sombreadores que puede crear en Unity.
Hay muchas formas de manipular los vértices de un modelo 3D. Uno de los primeros métodos descritos en la mayoría de los tutoriales de sombreado de vértices es
extrudir normales . Consiste en empujar cada vértice hacia afuera (
extrudir ), lo que le da al modelo 3D un aspecto más hinchado. "Afuera" significa que cada vértice se mueve a lo largo de la dirección de lo normal.
Para superficies lisas, esto funciona muy bien, pero en modelos con malas conexiones de vértices, este método puede crear artefactos extraños. Este efecto está bien explicado en uno de mis primeros tutoriales:
Una introducción suave a los sombreadores , donde mostré cómo
extruir e
introducir un modelo 3D.
Agregar normales extruidas a un sombreador de superficie es muy fácil. Cada sombreador de superficie tiene una
#pragma
, que se utiliza para transmitir información y comandos adicionales. Uno de estos comandos es
vert
, lo que significa que la función
vert
se usará para procesar cada vértice del modelo 3D.
El sombreador editado es el siguiente:
#pragma surface surf Standard fullforwardshadows addshadow vertex:vert ... float _Amount; ... void vert(inout appdata_base v) { v.vertex.xyz += v.normal * _Amount; }
Debido a que estamos cambiando la posición de los vértices, también necesitamos usar el
addshadow
si queremos que el modelo
addshadow
sombras correctamente sobre sí mismo.
¿Qué es appdata_base?Como puede ver, hemos agregado una función de modificador de vértices (
vert
), que toma como parámetro una
estructura llamada
appdata_base
. Esta estructura almacena información sobre cada vértice individual del modelo 3D. Contiene no solo
la posición del vértice (
v.vertex
), sino también otros campos, por ejemplo
, la dirección normal (
v.normal
) y la
información de textura asociada con el vértice (
v.texcoord
).
En algunos casos, esto no es suficiente y es posible que necesitemos otras propiedades, como el
color del vértice (
v.color
) y la
dirección tangente (
v.tangent
). Los modificadores de vértices se pueden especificar utilizando una variedad de otras estructuras de
appdata_tan
, incluidas
appdata_tan
y
appdata_full
, que proporcionan más información a costa de una
appdata_full
de bajo rendimiento. Puede leer más sobre
appdata
(y sus variantes) en la
wiki de Unity3D .
¿Cómo se devuelven los valores de vert?La función superior no tiene valor de retorno. Si está familiarizado con el lenguaje C #, debe saber que las estructuras se pasan por valor, es decir, cuando v.vertex
cambia v.vertex
esto afecta solo a la copia de v
, cuyo alcance está limitado por el cuerpo de la función.
Sin embargo, v
también se declara como inout
, lo que significa que se usa tanto para entrada como para salida. Cualquier cambio que realice cambia la variable en sí, que pasamos a vert
. Las palabras clave inout
y out
usan con mucha frecuencia en gráficos de computadora, y se pueden correlacionar aproximadamente con ref
y out
en C #.
Extruyendo normales con texturas
El código que utilizamos anteriormente funciona correctamente, pero está lejos del efecto que queremos lograr. La razón es que no queremos extruir todos los vértices en la misma cantidad. Queremos que la superficie del modelo 3D coincida con los valles y montañas de la región geográfica correspondiente. Primero, de alguna manera necesitamos almacenar y recuperar información sobre cuánto se eleva cada punto en el mapa. Queremos que la extrusión se vea influenciada por la textura en la que se codifican las alturas del paisaje. Tales texturas a menudo se llaman
mapas de altura , pero a menudo también se llaman
mapas de profundidad , según el contexto. Habiendo recibido información sobre las alturas, podremos modificar la extrusión del plano en función del mapa de altura. Como se muestra en el diagrama, esto nos permitirá controlar la subida y bajada de áreas.
Es bastante sencillo encontrar una imagen satelital del área geográfica que le interesa y un mapa de elevación asociado. A continuación se muestra el mapa satelital de Marte (arriba) y el mapa de altitud (abajo) que se utilizaron en este tutorial:
Hablé en detalle sobre el concepto del mapa de profundidad en otra serie de tutoriales llamados
"fotos 3D de Facebook desde adentro: sombreadores de paralaje" [
traducción al Habré].
En este tutorial, asumiremos que el mapa de altura se almacena como una imagen en escala de grises, donde el blanco y el negro corresponden a alturas cada vez más altas. También necesitamos estos valores para escalar
linealmente , es decir, la diferencia de color, por ejemplo, en
0.1
corresponde a una diferencia de altura entre
0
y
0.1
o entre
0.9
y
1.0
. Para los mapas de profundidad, esto no siempre es cierto, porque muchos de ellos almacenan información de profundidad en una
escala logarítmica .
Para muestrear una textura, se necesitan dos elementos de información: la textura misma y las
coordenadas UV del punto que queremos muestrear. Se puede acceder a este último a través del campo
texcoord
, almacenado en la estructura
appdata_base
. Esta es la coordenada UV asociada con el vértice actual que se está procesando. El muestreo de textura en una
función de superficie se realiza usando
tex2D
, sin embargo, cuando estamos en una
, se requiere
tex2Dlod
.
En el fragmento de código a continuación,
_HeightMap
usa una textura llamada
_HeightMap
para modificar el valor de extrusión realizado 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 qué no se puede usar tex2D como una función de vértice?
Si observa el código que Unity genera para Standard Surface Shader, notará que ya contiene un ejemplo de cómo muestrear texturas. En particular, muestra la
textura principal (llamada
_MainTex
) en una
función de superficie (llamada
surf
) utilizando la función incorporada
tex2D
.
Y, de hecho,
tex2D
utiliza para muestrear píxeles de una textura, independientemente de lo que esté almacenado en ella, el color o la altura. Sin embargo, puede notar que
tex2D
no se puede usar en una función de vértice.
La razón es que
tex2D
no solo lee píxeles de la textura. Ella también decide qué versión de la textura usar, dependiendo de la distancia a la cámara. Esta técnica se llama
mipmapping : le permite tener versiones más pequeñas de una sola textura que pueden usarse automáticamente a diferentes distancias.
En la función de superficie, el sombreador ya sabe qué
textura MIP usar. Es posible que esta información aún no esté disponible en la función de vértice y, por
tex2D
tanto,
tex2D
no se puede utilizar con plena confianza. En contraste, la función
tex2Dlod
puede pasar dos parámetros adicionales, que en este tutorial pueden tener un valor cero.
El resultado es claramente visible en las imágenes a continuación.
En este caso, se puede hacer una ligera simplificación. El código que revisamos anteriormente puede funcionar con cualquier geometría. Sin embargo, podemos suponer que la superficie es absolutamente plana. De hecho, realmente queremos aplicar este efecto al avión.
Por lo tanto, puede eliminar
v.normal
y reemplazarlo con
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; }
Podríamos hacer esto porque todas las coordenadas en
appdata_base
se almacenan en
el espacio modelo , es decir, se establecen en relación con el centro y la orientación del modelo 3D. La transición, rotación y escala con
transformación en Unity cambian la posición, rotación y escala del objeto, pero no afectan el modelo 3D original.
Parte 2. Efecto de desplazamiento
Todo lo que hicimos arriba funciona bastante bien. Antes de continuar,
getVertex
el código necesario para calcular la nueva altura del vértice en una función
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; }
Entonces toda la función
vert
tendrá la forma:
void vert(inout appdata_base v) { vertex = getVertex(v.vertex, v.texcoord.xy); }
Hicimos esto porque a continuación necesitamos calcular la altura de varios puntos. Debido al hecho de que esta funcionalidad estará en su propia función separada, el código será mucho más simple.
Cálculo de coordenadas UV
Sin embargo, esto nos lleva a otro problema. La función
getVertex
depende no solo de la posición del vértice actual (v.vertex), sino también de sus coordenadas UV (
v.texcoord
).
Cuando deseamos calcular el desplazamiento de altura de vértice que la función
vert
está procesando actualmente, ambos elementos de datos están disponibles en la estructura
appdata_base
. Sin embargo, ¿qué sucede si necesitamos muestrear la posición de un punto vecino? En este caso, podemos conocer la posición xyz en
el espacio modelo , pero no tenemos acceso a sus coordenadas UV.
Esto significa que el sistema existente puede calcular el desplazamiento de altura solo para el vértice actual. Tal restricción no nos permitirá seguir adelante, por lo que debemos encontrar una solución.
La forma más fácil es encontrar una manera de calcular las coordenadas UV de un objeto 3D, conociendo la posición de su vértice. Esta es una tarea muy difícil, y existen varias técnicas para resolverla (una de las más populares es la
proyección triplanar ). Pero en este caso particular, no necesitamos unir los rayos UV con la geometría. Si suponemos que el sombreador siempre se aplicará a la malla plana, la tarea se vuelve trivial.
Podemos calcular
las coordenadas UV (imagen inferior) a partir de las
posiciones de los vértices (imagen superior) debido al hecho de que ambas se superponen linealmente en una malla plana.
Esto significa que para resolver nuestro problema, necesitamos transformar los
componentes XZ de la posición del vértice en las
coordenadas UV correspondientes.
Este procedimiento se llama
interpolación lineal . Se discute en detalle en mi sitio web (por ejemplo:
The Secrets Of Color Interpolation ).
En la mayoría de los casos, los valores UV están en el rango de
0 antes
1 ; las coordenadas de cada vértice, en contraste, son potencialmente ilimitadas. Desde el punto de vista de las matemáticas, para la conversión de XZ a UV, solo necesitamos sus valores límite:
- Xmin , Xmax
- Zmin , Zmax
- Umin , Umax
- Vmin , Vmax
que se muestran a continuación:
Estos valores varían según la malla utilizada. En el plano de Unity, las
coordenadas UV están en el rango de
0 antes
1 , y las
coordenadas de los vértices están en el rango de
−5 antes
+5 .
Las ecuaciones para convertir XZ a UV son:
(1)

¿Cómo se muestran?Si no está familiarizado con el concepto de interpolación lineal, estas ecuaciones pueden parecer bastante intimidantes.
Sin embargo, se muestran de manera bastante simple. Veamos solo un ejemplo.
U . Tenemos dos intervalos: uno tiene valores de
Xmin antes
Xmax otro de
Umin antes
Umax . Datos entrantes para la coordenada
X es la coordenada del vértice actual que se está procesando, y la salida será la coordenada
U usado para probar la textura.
Necesitamos mantener las propiedades de proporcionalidad entre
X y su intervalo, y
U y su intervalo Por ejemplo, si
X importa el 25% de su intervalo entonces
U También importará el 25% de su intervalo.
Todo esto se muestra en el siguiente diagrama:
De esto podemos deducir que la proporción formada por el segmento rojo con respecto al rosa debería ser la misma que la proporción entre el segmento azul y el azul:
(2)
Ahora podemos transformar la ecuación que se muestra arriba para obtener
U :
y esta ecuación tiene exactamente la misma forma que se muestra arriba (1).
Estas ecuaciones se pueden implementar en código de la siguiente manera:
float2 _VertexMin; float2 _VertexMax; float2 _UVMin; float2 _UVMax; float2 vertexToUV(float4 vertex) { return (vertex.xz - _VertexMin) / (_VertexMax - _VertexMin) * (_UVMax - _UVMin) + _UVMin; }
Ahora podemos llamar a la función
getVertex
sin tener que pasar
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; }
Entonces toda la función
vert
toma la forma:
void vert(inout appdata_base v) { v.vertex = getVertex(v.vertex); }
Efecto de desplazamiento
Gracias al código que escribimos, todo el mapa se muestra en la malla. Si queremos mejorar la visualización, entonces necesitamos hacer cambios.
Formalicemos el código un poco más. En primer lugar, es posible que debamos acercarnos a una parte separada del mapa, en lugar de mirarlo por completo.
Esta área se puede definir mediante dos valores: su tamaño (
_CropSize
) y su ubicación en el mapa (
_CropOffset
), medido en el
espacio de vértices (desde
_VertexMin
a
_VertexMax
).
Una vez recibidos estos dos valores, podemos usar una vez más la interpolación lineal para que
getVertex
llame a la posición actual de la parte superior del modelo 3D, sino al punto escalado y transferido.
Código relevante:
void vert(inout appdata_base v) { float2 croppedMin = _CropOffset; float2 croppedMax = croppedMin + _CropSize;
Si queremos desplazarnos, será suficiente actualizar
_CropOffset
través del script. Debido a esto, el área de truncamiento se moverá, desplazándose realmente por el paisaje.
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 esto funcione, es muy importante configurar el
modo de ajuste
de todas las texturas para que se
repita . Si esto no se hace, no podremos repetir la textura.
Para el efecto de zoom / zoom, basta con cambiar
_CropSize
.
Parte 3. Sombreado del terreno
Sombreado plano
Todo el código que escribimos funciona, pero tiene un problema grave. Sombrear el modelo es de alguna manera extraño. La superficie está bien curvada, pero reacciona a la luz como si fuera plana.
Esto se ve muy claramente en las imágenes a continuación. La imagen superior muestra un sombreador existente; la parte inferior muestra cómo funciona realmente.
Resolver este problema puede ser un gran desafío. Pero primero, tenemos que descubrir cuál es el error.
La operación de extrusión normal cambió la geometría general del plano que usamos inicialmente. Sin embargo, Unity solo cambió la posición de los vértices, pero no sus direcciones normales.
La dirección del vértice
normal , como su nombre lo indica, es un vector de longitud unitaria (
dirección ) que indica perpendicular a la superficie.
Las normales son necesarias porque juegan un papel importante en el sombreado de un modelo 3D. Todos los sombreadores de superficie los usan para calcular cómo se debe reflejar la luz de cada triángulo del modelo 3D. Por lo general, esto es necesario para mejorar la tridimensionalidad del modelo, por ejemplo, hace que la luz rebote en una superficie plana al igual que rebotaría en una superficie curva. Este truco se usa a menudo para hacer que las superficies de baja poli se vean más lisas de lo que realmente son (ver más abajo)
Sin embargo, en nuestro caso sucede lo contrario. La geometría es curva y suave, pero como todas las normales están dirigidas hacia arriba, la luz se refleja desde el modelo como si fuera plana (ver más abajo):
Puede leer más sobre el papel de las normales en el sombreado de objetos en el artículo sobre
Mapeo normal (mapeo de relieve) , donde los cilindros idénticos se ven muy diferentes, a pesar del mismo modelo 3D, debido a los diferentes métodos de cálculo de las normales de vértices (ver más abajo).
Desafortunadamente, ni Unity ni el lenguaje para crear sombreadores tienen una solución integrada para recalcular automáticamente las normales. Esto significa que debe cambiarlos manualmente según la geometría local del modelo 3D.
Cálculo normal
La única forma de solucionar el problema de sombreado es calcular manualmente las normales en función de la geometría de la superficie. Una tarea similar fue discutida en una publicación de
Vertex Displacement - Melting Shader Part 1 , donde se usó para simular la fusión de modelos 3D en
Cone Wars .
Aunque el código terminado tendrá que funcionar en coordenadas 3D, limitemos la tarea a solo dos dimensiones por ahora. Imagine que necesita calcular la
dirección de la normalidad correspondiente al punto en la curva 2D (la gran flecha azul en el diagrama a continuación).
Desde un punto de vista geométrico, la
dirección de la normal (flecha azul grande) es un vector perpendicular a la
tangente que pasa por el punto de interés para nosotros (una delgada línea azul).
La tangente se puede representar como una línea ubicada en la curvatura del modelo.
Un vector tangente es un
vector unitario que se encuentra en una tangente.
Esto significa que para calcular lo normal, debe seguir dos pasos: primero, encuentre la línea
tangente al punto deseado; luego calcule el vector perpendicular a él (que será la
dirección necesaria
de lo normal ).
Cálculo tangente
Para obtener lo
normal, primero necesitamos calcular la
tangente . Se puede aproximar muestreando un punto cercano y usándolo para construir una línea cerca del vértice. Cuanto más pequeña es la línea, más preciso es el valor.
Se requieren tres pasos:
- Etapa 1. Mueva una pequeña cantidad sobre una superficie plana
- Paso 2. Calcule la altura del nuevo punto.
- Paso 3. Usa la altura del punto actual para calcular la tangente
Todo esto se puede ver en la imagen a continuación:
Para que esto funcione, necesitamos calcular las alturas de dos puntos, no uno. Afortunadamente, ya sabemos cómo hacer esto. En la parte anterior del tutorial, creamos una función que muestrea la altura de un paisaje en función de un punto de malla. Lo llamamos
getVertex
.
Podemos tomar el nuevo valor de vértice en el punto actual, y luego en otros dos. Uno será para la tangente, el otro para la tangente en dos puntos. Con su ayuda, obtenemos lo normal. Si la malla original utilizada para crear el efecto es plana (y en nuestro caso lo es), entonces no necesitamos acceso a
v.normal
y solo podemos usar
float3(0, 0, 1)
para tangente y tangente a dos puntos, respectivamente
float3(0, 0, 1)
y
float3(1, 0, 0)
. Si quisiéramos hacer lo mismo, pero, por ejemplo, para una esfera, sería mucho más difícil encontrar dos puntos adecuados para calcular la tangente y la tangente a dos puntos.
Ilustraciones vectoriales
Habiendo obtenido los vectores tangentes y tangentes adecuados en dos puntos, podemos calcular la normalidad usando una operación llamada
producto vectorial . Hay muchas definiciones y explicaciones de un trabajo vectorial y de lo que hace.
Un producto vectorial recibe dos vectores y devuelve uno nuevo. Si dos vectores iniciales fueran unidades (su longitud es igual a la unidad), y se ubican en un ángulo de 90, entonces el vector resultante se ubicará a 90 grados con respecto a ambos.
Al principio, esto puede ser confuso, pero gráficamente se puede representar de la siguiente manera: el producto vectorial de dos ejes crea un tercero. Eso es
X vecesY=Z pero tambien
X vecesZ=Y Y así sucesivamente.
Si damos un paso suficientemente pequeño (en el código, esto es
offset
), entonces los vectores de la tangente y la tangente a dos puntos estarán en un ángulo de 90 grados.
Junto con el vector normal, forman tres ejes perpendiculares orientados a lo largo de la superficie del modelo.Sabiendo esto, podemos escribir todo el código necesario para calcular y actualizar el vector 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; }
Poniendo todo junto
Ahora que todo funciona, podemos devolver el efecto de desplazamiento. void vert(inout appdata_base v) {
Y en esto nuestro efecto finalmente se completa.A donde ir ahora
Este tutorial puede convertirse en la base de efectos más complejos, por ejemplo, proyecciones holográficas o incluso una copia de la mesa de arena de la película "Black Panther".Paquete de unidad
El paquete completo para este tutorial se puede descargar en Patreon , contiene todos los recursos necesarios para reproducir el efecto descrito.