Sombreadores de mapas interactivos de Unity

imagen

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)
imagen


¿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 ).

 // Cropping float2 _CropSize; float2 _CropOffset; 

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; // 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); } 

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) { // 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); } 

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.

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


All Articles