Un poco de uno de los lenguajes de shaders que usa Unity (HLSL), para programar efectos visuales. La idea es mostrar la parte de código, ya que en muchos lugares se muestra cómo hacer efectos uniendo nodos, pero raras veces muestran el código.
Introducción
ShaderLab es un lenguaje declarativo, como lo define la documentación de Unity, que sirve para escribir shaders. Este lenguaje nos permite crear variables que se puedan exponer en el editor, de forma que podamos modificarlas fácilmente. Además, nos permite escribir un bloque de código en HLSL/CG que es un lenguaje de shaders desarrollado por Microsoft, bastante similar en muchas cosas a C (está básicamente basado en C). Como los bloques de código que vamos a escribir van a estar, por así decirlo, en 2 lenguajes (uno que es específico de ShaderLab y otro que es CG), vamos a explicar cada una de las partes como si esta mezcla fuera una sola cosa. Voy a tratar de dividirlo en los bloques principales, e ir armando de a poco la estructura básica. La idea es que sepan cómo escribir un shader arrancando desde un archivo en blanco.
Programas usados
El software que voy a usar para este tutorial es:
- Unity3D 2020.1.0a17. Podría ser cualquier versión de 2018 para arriba. Uso esta para mantenerme actualizado nada más.
- Rider 2019.2.3. Como editor de código (cualquiera sirve).
Creando los primeros archivos
Para arrancar, tenemos que crear algunos archivos en Unity, para poder ir probando nuestro shader. Si están en este tutorial, asumo que ya están familiarizados con Unity y su interfaz. Así que para empezar vamos a crear dos archivos:
- Un material: Click derecho sobre la pestaña “Project” => “Create” => “Material”.
- Un shader: Click derecho sobre la pestaña “Project” => “Create” => “Shader” => “Unlit Shader”.
ACLARACIÓN:
Ahora arrastramos el shader directamente al material que creamos. Esto va a hacer que el material lo contenga, como si fuera un componente dentro de un GameObject. A continuación, abrimos el archivo de shader que acabamos de crear y BORRAMOS TODO LO QUE TENGA ADENTRO. De esta forma lo dejamos listo para arrancar desde cero. Cabe aclarar que al hacer esto, el shader no va a hacer nada y muy probablemente Unity tire errores en el archivo. Pero no importa, ya que ahora vamos a escribir de nuevo gran parte de ese código, pero explicado paso a paso.
Bloques de código principales
Shader
El primer bloque de código a definir va a ser el más sencillo, y es el inicial. Sería algo así como el equivalente a “namespace” en algunos lenguajes como C#. Es el espacio desde el cual nuestro shader va a ser accesible.
Shader "MyShadersTutorials/01/MyFirstShader" { }
Properties
Este bloque de código define desde dónde vamos a seleccionar este shader, cuando tengamos que elegir uno para nuestro material. No es necesario que sea la ubicación del archivo, pero si debería terminar con el nombre de nuestro shader (que tampoco es necesario que coincida con el nombre del archivo). La única finalidad que tiene es indicar la dirección virtual del archivo. La siguiente parte que vamos a agregar no es estrictamente código del shader, sino que pertenece más a la parte del editor, de Unity en este caso. En este bloque vamos a poner todas las propiedades/variables que va a exponer nuestro shader.
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } }
En este bloque vamos a poner cada una de las propiedades que queremos poder editar desde el inspector de Unity. Esto no es como declarar variables en el lenguaje de shaders, sino que es como escribirlas en un lenguaje específico que Unity entiende para que las muestre de la misma manera que lo haría con las variables de los scripts tradicionales. Supongo que en parte también se hace porque el shader se va a ejecutar de formas muy diferentes a como lo haría un script convencional, y podría traer ciertas complicaciones, pero sólo es una suposición. La “fórmula” para la declaración es la siguiente:
_NombreInterno ("Nombre en el inspector", TipoDeDato) = ValorPredeterminado
El guión bajo es puramente nomenclatura pero hay veces en los que se debería llamar de una manera específica porque Unity lo entiende de esa manera. Ya lo vamos a ver más adelante.
Dejo a continuación cómo declarar dichas propiedades:
_MyTexture ("My Texture", 2D) = white{} //Textura. _MyInteger ("My Integer", Int) = 0 //Entero simple. _MyFloat ("My Float", Float) = 1.0 //Flotante. _MyColor ("My Color", Color) = (0, 0, 0, 0) //Color RGBA
SubShader
Vamos a ir viendo más formas de declarar propiedades, pero por ahora estas son suficientes.
Ahora empecemos un poco con la parte de programación que corresponde más al shader. Unity nos permite agregar un campo en el cual vamos a escribir el código correspondiente a un shader. Ese campo se llama “SubShader“, y se declara de la siguiente forma:
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { } }
Pass
Como ven es un bloque aparte. A partir de este bloque va a “funcionar” nuestro shader. Acá vamos a empezar a escribir el programa en CG, con alguna especie de “magia” agregada. Digo “magia” porque en realidad el archivo “.shader” de Unity es una mezcla de muchas cosas, y dependiendo de en dónde y cómo lo escribas, hace una cosa u otra.
Igualmente, cada engine interpreta el código de shaders de maneras diferentes. Incluso algunos programas y/o páginas que permiten compilar en CG/HLSL lo hacen de maneras diferentes. En algunos casos, tienen una sección donde mediante ventanas y configuraciones agregan “variables de editor” y otra donde directamente escriben el código de shader en CG.
Parece un lindo quilombo todo, pero una vez que le agarran la lógica todo empieza a tener sentido de alguna manera.
El bloque que le sigue es el bloque “Pass“. Este bloque representa una pasada del shader, como si fuera una pasada de pintura sobre un objeto. Ustedes pueden darle a un objeto (en la vida real), todas las capas de pintura que quieran, para realizar distintas terminaciones. Acá es lo mismo. Cada bloque “Pass” que hagamos representa una pasada, un trabajo sobre nuestro objeto, el cual le vamos a realizar según el efecto que queramos lograr. En muchísimos casos van a ver que con una pasada está más que bien. Pero algunos casos van a precisar más de una. Por ejemplo, cuando hay una sombra se procesa dicha sombra en una pasada aparte, como vamos a ver en otro momento.
El bloque “Pass” se declara dentro del bloque “SubShader“:
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { } } }
Y con esto Unity ya debería poder compilar el shader. Lógicamente no va a hacer nada, pero no debería tirar ningún error. En la siguiente página, vamos a escribir un poco más de código y vamos a pintar los primeros pixeles.
Vertex & Fragment shader
Son dos funciones que se ejecutan en momentos diferentes. La primera se ejecuta al momento de procesar cada uno de los vértices de un objeto, y la segunda por cada pixel que se va a pintar del objeto. Por supuesto, la cantidad de veces que se ejecuta cada uno depende de la cantidad de vértices y de la cantidad de pixeles que se vayan a pintar respectivamente. Dependiendo de la posición del objeto entre muchas otras cosas más, el engine sabe la cantidad de veces que debe ejecutar cada función.
Ambas funciones son básicamente como los eventos de Unity como “Start” o “Update“, que también se ejecutan en momentos específicos, con la diferencia de que el tipo de dato que reciben y devuelven se puede modificar, junto con todos los valores que tiene dentro. La idea de estas funciones es que primero se procesen todos los vértices y luego tomemos esa información procesada y se la pasemos a la función que se encarga de los pixeles.
Para esto vamos a escribir a continuación un bloque de código largo, pero vamos a explicar también cada parte de dicho código.
Entendiendo el funcionamiento
CGPROGRAM
Cada una de estas dos funciones recibe una “CAJA” con datos, los procesa y el sistema los traslada al siguiente paso o función. Como dije antes, el VERTEX se procesa primero. Este VERTEX recibe los datos necesarios (al ejecutarse se pasan automáticamente), y puede hacer lo que quiera con estos datos. A su vez, DEBE devolver otro grupo de datos, que va a recibir el siguiente paso, o sea, la función FRAGMENT. Al mismo tiempo, la función FRAGMENT recibe esos datos, los procesa (en realidad lo hacemos nosotros), y DEBE devolver un color. Este color es el resultado del proceso de pintado. Es el cómo quedaría el color final de nuestro objeto.
Para empezar con el proceso, dentro de nuestro bloque “PASS” vamos a escribir nuestro primer bloque en CG.
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM //Cuerpo de nuestro código... ENDCG } } }
Raro, no? Es como un bloque de código completamente nuevo. Pero dentro de esas CGPROGRAM y ENDCG vamos a escribir en “lenguaje de shaders”. Este lenguaje es el mismo que va a aceptar cualquier compilador de shaders. Algunos engines quizás hasta nos den dos archivos diferentes para escribir nuestras dos funciones de VERTEX y FRAGMENT, pero las mismas cosas que escribamos acá, me refiero al formato, van a ser las mismas que podamos escribir en esos engines.
Por supuesto, Unity nos da funciones para que podamos utilizar dentro del código en CG para poder hacer las cosas mucho más fáciles.
Vamos a escribir un poco más de código. Como ya dijimos, tenemos dos funciones y debemos empezar a crearlas.
Vertex y Fragment
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM void VertexFunction() { } void FragmentFunction() { } ENDCG } } }
Hasta acá vemos que las funciones se declaran como en la mayoría de los lenguajes de programación más conocidos. “void” significa que no devuelve nada, y entre los paréntesis podemos poner parámetros que cada función puede recibir.
ACLARACIÓN:
Los nombres de ambas funciones son completamente opcionales. Pueden ponerle el nombre que ustedes quieran. Puede ser con mayúsculas, con minúsculas, como ustedes gusten. Los nombres más comunes, que van a encontrar probablemente cuando busquen información son “vertex()” y “frag()” (tal cual están escritos).
struct
Para continuar, recordemos que ambas funciones deben RECIBIR un dato y DEVOLVER otro. Ambos tipos datos que vamos a recibir y devolver los tenemos que crear. Ambos serán estructuras (tipo de dato “struct“). Vamos a crearlas.
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM struct VertexData { }; struct FragmentData { }; void VertexFunction() { } void FragmentFunction() { } ENDCG } } }
Con esto creamos dos estructuras, una para cada función. Lo siguiente sería respetar el flujo que dijimos más arriba. Vamos a hacerles a continuación las modificaciones a las funciones para que respeten dicho flujo.
ACLARACIÓN IMPORTANTE:
Puesto que no hay muchos “BUENOS” compiladores de shader, pongan especial importancia a detalles como el ” ; ” que está al final de la declaración de ambas estructuras. En varios lenguajes ese punto y coma no va, así que es fácil confundirse.
Para agregarlo, hacemos la siguiente modificación:
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM struct VertexData { }; struct FragmentData { }; FragmentData VertexFunction(VertexData input) { FragmentData output; return output; } float4 FragmentFunction(FragmentData input) { return float4(1, 1, 1, 1); } ENDCG } } }
float4
Entonces… Qué es ese “float4” que devuelve nuestra función FRAGMENT? Este tipo de dato es básicamente un vector de 4 valores. Contiene x, y, z, w (sí sí, la “w” al final). Es la manera de muchos lenguajes de programación gráfica de representar 4 valores. Además, tiene 4 variables llamadas r, g, b, a (red, green, blue, alpha). Estas variables tienen EXACTAMENTE los mismos valores que las 4 anteriores. Básicamente está hecho de esa manera para que dependiendo de para qué lo usen, sea mucho más claro de leer. Además, también van a encontrar que este tipo de dato también tiene variables como “xyz”, “xy”, “rgb”, “rg”, etc. Estas variables nos devuelven distintos tipos de vectores. Por ejemplo, “xy” nos devolvería un float2 con esos dos valores. Más adelante vamos profundizando un poco en este tema.
Utilización de la instrucción “#pragma”
Lo que nos queda ahora para poder hacer al menos una prueba, es decirle al sistema cuáles son nuestras funciones VERTEX y FRAGMENT. Anteriormente habíamos dicho que podían tener cualquier nombre, y es verdad. Pero tenemos que indicarle al sistema a qué funciones llamar para cada acción. Para esto usamos la instrucción “#pragma“.
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction struct VertexData { }; struct FragmentData { }; FragmentData VertexFunction(VertexData input) { FragmentData output; return output; } float4 FragmentFunction(FragmentData input) { return float4(1, 1, 1, 1); } ENDCG } } }
De esta manera, se va a llamar a cada función indicada arriba, y automáticamente se le va a pasar a cada una la estructura especificada. Luego de esto, guarden y vuelvan a Unity. Van a ver que les tira un error un tanto particular.
Shader error in 'MyShadersTutorials/01/MyFirstShader': 'FragmentFunction': function return value missing semantics at line 34 (on d3d11) Compiling Fragment program Platform defines: UNITY_NO_DXT5nm UNITY_ENABLE_REFLECTION_BUFFERS UNITY_NO_CUBEMAP_ARRAY UNITY_NO_SCREENSPACE_SHADOWS UNITY_PBS_USE_BRDF2 SHADER_API_DESKTOP UNITY_HARDWARE_TIER3 UNITY_COLORSPACE_GAMMA UNITY_LIGHTMAP_DLDR_ENCODING Disabled keywords: UNITY_NO_RGBM UNITY_USE_NATIVE_HDR UNITY_FRAMEBUFFER_FETCH_AVAILABLE UNITY_ENABLE_NATIVE_SHADOW_LOOKUPS UNITY_METAL_SHADOWS_USE_POINT_FILTERING UNITY_USE_DITHER_MASK_FOR_ALPHABLENDED_SHADOWS UNITY_PBS_USE_BRDF1 UNITY_PBS_USE_BRDF3 UNITY_NO_FULL_STANDARD_SHADER UNITY_SPECCUBE_BOX_PROJECTION UNITY_SPECCUBE_BLENDING UNITY_ENABLE_DETAIL_NORMALMAP SHADER_API_MOBILE UNITY_HARDWARE_TIER1 UNITY_HARDWARE_TIER2 UNITY_LIGHT_PROBE_PROXY_VOLUME UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS UNITY_LIGHTMAP_RGBM_ENCODING UNITY_LIGHTMAP_FULL_HDR UNITY_VIRTUAL_TEXTURING
Este error es un tanto bastante particular y algo que quizás les pueda traer algunos dolores de cabeza. Particularmente la parte que dice “missing semantics“.
Como habíamos dicho, al ejecutarse, el shader llama a cada función, VERTEX y FRAGMENT. Ahora… NECESITA saber para qué va a usar cada una de las variables que ustedes declaren, y algunos datos que devuelven. Por ejemplo, la función FRAGMENT devuelve un “float4“, que representa el color que tiene que pintar, pero esto el sistema no lo sabe. Por ende, no sabe qué hacer con ese color, y como es una función que se llama automáticamente, necesitamos que al hacerlo sepa para qué usar ese valor. Para esto se usa, lo que en este lenguaje se llaman “SEMANTICS”. Básicamente es un texto que indica para qué vas a usar un dato en particular. Cada uno de esos textos manifiesta tu intención con esa variable. Entonces el sistema sabe para qué pretendes usarla. Por supuesto, cada SEMANTIC tiene su uso y significado.
Para más información ENTREN ACÁ.
La SEMANTIC que debemos usar para que Unity sepa que queremos usar el valor devuelto por FRAGMENT para el color final de nuestro objeto se llama “SV_TARGET”.
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction struct VertexData { }; struct FragmentData { }; FragmentData VertexFunction(VertexData input) { FragmentData output; return output; } float4 FragmentFunction(FragmentData input) : SV_TARGET { return float4(1, 1, 1, 1); } ENDCG } } }
NOTA INTERESANTE:
Cuando vean que una SEMANTIC empieza con “SV” significa que ese valor es interno. Significa “System Value”. Y por lo general se usa para que Unity (o el framework que estén usando), asigne este valor en algún lugar clave. Como el color final, por ejemplo.
Si guardan y vuelven a Unity, van a ver que esta vez pasa algo. Quizás no es el resultado esperado, pero pasa algo.
Bien… De a poco empieza a hacer “algo”. No importa si no es del todo lo que querémos, pero estar libre de errores es un buen avance. Ahora viene la pregunta del millón: “Por qué garrrrrlopa se ve transparente?!?!“. Y la respuesta es… Que nos falta algo. Ese algo es decirle DÓNDE pintar el color que le dijimos en el FRAGMENT.
Esta es otra de las cosas para las que nos sirven los SEMANTICS. Básicamente, para estas 3:
- Decirle QUÉ HACER con un valor determinado, o sea, para qué usarlo.
- Decirle QUE ME DE UN VALOR que represente ALGO del sistema.
- Declarar una variable y decirle QUE REPRESENTE ALGO.
La tercera es en la que me voy a centrar para resolver este pequeño problemita. La idea de esa última es que podamos crear una variable y decirle “vos vas a representar ESTO”. Por ejemplo, puedo crear una variable y decirle que represente la posición de un vértice. De este modo, yo podría hacer cálculos con esa variable, asignarle valores, y eso se vería reflejado en el resultado final. Si yo le digo que represente la posición de un vértice, y luego le sumo algo a ese valor, vería al vértice desplazarse. Esto es uno de los usos claves.
Entonces… Vamos a crear una variable para representar la posición de un vértice (de hecho, la posición DE CADA vértice), y luego vamos a ir pasando esta variable a lo largo de las distintas faces (VERTEX y FRAGMENT). Para eso, creamos la variable de la siguiente forma:
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction struct VertexData { float4 vertexPosition : POSITION; }; struct FragmentData { }; FragmentData VertexFunction(VertexData input) { FragmentData output; return output; } float4 FragmentFunction(FragmentData input) : SV_TARGET { return float4(1, 1, 1, 1); } ENDCG } } }
La SEMANTIC llamada POSITION indica que esa variable va a representar el valor de CADA vértice. O sea, que cada vez que se llame al VERTEX y se le pase como parámetro la estructura que definimos, esa variable va a tener el valor del vértice que se esté procesando. Se usa específicamente para trabajar dentro del VERTEX.
ACLARACIÓN:
El tipo de dato es “float4“… ¿Por qué? Si bien la posición del vértice podría representarse con un “float3“, se usa un “float4” para representar el “sentido” del vector de posición.
Lo siguiente que vamos a hacer es pasar esta información al FRAGMENT, que la va a utilizar para saber en qué punto del espacio pintar cada pixel. Para esto, necesitamos saber aunque sea un poco qué es un UV.
UV Mapping
La definición pseudotécnica sería algo así como el proceso de representar una imagen 2D sobre un objeto 3D. Vamos a ver una imagen:
Imaginen que esa imagen es una textura. Los valores que ven en las esquinas son básicamente las coordenadas de cada una de las esquinas en porcentajes. 0 = 0% y 1 = 100%. Cada uno de esos valores representaría un “float2” con sus coordenadas. No importa el tamaño de la textura ni las proporciones: cada punta siempre representa los mismos valores. O sea que… Si yo quiero obtener el pixel del centro, estaría en la coordenada (0.5, 0.5).
De esta forma, el engine puede “vincular” cada extremo de la textura a un vértice en particular, y así calcular qué valor le corresponde de la textura a cada parte de nuestro objeto 2D.
En este ejemplo, se está asociando cada vértice de los 4 de adelante del cubo, a uno de los extremos de la textura.
De esta forma también el diseño de nuestra textura se adapta a cada parte del objeto.
Incluso si el objeto sufriera deformaciones, gracias a esto el engine puede reubicar cada una de las partes de la textura.
Quizás se note más con un patrón un poco más a lo largo de la textura, como el siguiente:
Para esto necesitamos pasar la información del vértice al FRAGMENT. Dependiendo de cómo se la pasemos, va a ser cómo la va a dibujar. No necesariamente se la tenemos que pasar de la forma que ejemplifiqué. Podríamos pasarsela de una manera completamente diferente para probar diferentes tipos de efectos (que les dejo a ustedes probar).
Vamos ahora a pasarle un valor y ver qué pasa:
Primer pintado de pixeles
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction struct VertexData { float4 vertexPosition : POSITION; }; struct FragmentData { float4 vertexPosition : SV_POSITION; }; FragmentData VertexFunction(VertexData input) { FragmentData output; output.vertexPosition = input.vertexPosition; return output; } float4 FragmentFunction(FragmentData input) : SV_TARGET { return float4(1, 1, 1, 1); } ENDCG } } }
Bueno… Logramos más avances. Ahora el objeto se pinta. De un color plano (blanco en este caso), y con una forma un poco extraña, pero se pinta. Prueben cambiar el color, para que vean que el color es el que pusieron en el FRAGMENT realmente.
La pregunta que queda ahora es… ¿Por qué se pinta… Así? Bueno… La respuesta es simple: le dijimos al FRAGMENT cuál era la posición del vértice, y el fragment pintó la textura en ese lugar. Peeeeeeeero… La posición que le dijimos al asignarle la misma que nos llegó a nosotros en el VERTEX es la posición relativa al objeto. Esto significa, que las posiciones de cada vértice que nos llegaron al VERTEX están en el espacio del objeto (object space). O sea, vendría a ser como cuando ustedes ponen un objeto dentro de otro en Unity, y quieren obtener la posición de dicho objeto: No importa para dónde muevan el padre, el hijo estará siempre en la misma posición con relación a su padre.
Fijense que lo mismo pasa con el shader: no importa que lo roten, o que incluso muevan la cámara, el shader se pinta exactamente de la misma manera, SIEMPRE.
A menos que…
Noten que desaparece cuando hago el suficiente zoom como para que el objeto salga de pantalla. Lo mismo pasa si muevo el objeto fuera de la cámara:
Esto es porque la cámara ya no necesita procesarlo… Si no lo ve. ;).
Noten que lo que está mal es la posición de los vértices que pasamos al FRAGMENT, puesto que si seleccionan el cubo, aún se puede ver la forma de cubo. Esto significa que la posición de sus vértices está bien. Lo que está mal es la posición que interpreta el FRAGMENT, que es el que pinta el espacio de pantalla. Lo que quiero decir es que si agarran unas coordenadas que están en el espacio del objeto y las pasamos a otro que no tiene nada que ver, es evidente que se va a dibujar en cualquier lado.
Varias veces vamos a tener que cambiar entre un espacio y otro en cada tipo de shader que hagamos. Por suerte Unity nos brinda funciones para hacer eso más fácil.
Distintos tipos de espacios
Hay varios espacios por los que se pasa para transformar desde el espacio en sí mismo del objeto que se va a pintar, hasta el espacio final dentro de nuestra pantalla. Voy a ejemplificar cómo es el proceso para pasar de un espacio al otro.
Lo primero es el espacio en el cual se mueve el objeto. Teniendo como origen su centro, que será el (0, 0) sin importar en qué posición del mundo o de otra cosa en el cual esté. Esto se llama “Object-Space“.
Desde este punto, el sistema puede convertir a coordenadas de mundo, o lo que se llama “World-Space“. De hecho, nosotros también podemos hacerlo con una simple cuenta matemática:
float4 worldVertex = mul(unity_ObjectToWorld, input.vertexPosition);
- Donde “mul” es un método para multiplicar (aunque también podrían usar el operador *).
- “unity_ObjectToWorld” es una variable ya definida para convertir espacios.
- “input” es la variable que contenía el acceso a nuestra estructura creada.
- “vertexPosition” es la variable que habíamos creado para guardar la posición del vértice.
Luego, el sistema pasa a algo llamado “Camera-Space“. Esto es básicamente obtener la posición del objeto con respecto a la posición de la cámara. En este caso, la posición en X del objeto es de -10 porque está a 10 unidades adelante de la cámara de la cámara.
El siguiente espacio es “Projection-Space“, y hace referencia a la posición del objeto, con respecto a “min clip plane” y el “max clip plane” (frustum), de la cámara. Esto aplica también a muchas cosas como el FOV (Field Of View), de la cámara.
El que le sigue a este es el “Clip-Space” que hace referencia a la coordenada UV que obtenemos para pintar en una posición específica. Unity tiene una función para convertir desde “Object-Space” a “Clip-Space” que pasa por todo el proceso anterior, llamada “UnityObjectToClipPos“.
Por último, aunque voy a nombrarlo muy por arriba, está el “Normalized Device Coordinate Space“, que básicamente es el espacio final de la pantalla. Si alguna vez trabajaron con 2D en Unity, van a ver que la parte del centro de la cámara es (0, 0), y las puntas son (-X, Y), (X, Y), (X, -Y) y (-X, -Y). Este último espacio, hace referencia a este espacio, donde X es el valor a lo ancho desde el centro, e Y es el valor a lo alto desde el centro. Este espacio de coordenadas finales van de -1 a 1, o sea que si las puntas horizontales de la cámara son -5 y 5, el sistema, en esta instancia, va a convertir ESAS coordandas a -1 y 1 respectivamente. Y cuando tengamos a un objeto en la posición 2.5, estará en la posición 0.5 en este sistema de coordenadas.
Transformar SPACE-POSITION a WORLD-POSITION
La idea ahora es que transformemos las posiciones de los vértices a las coordenadas de pantalla que le corresponden. Para eso, podemos asignarle las coordenadas correctas al tipo de dato que vamos a pasar hacia el FRAGMENT.
Unity cuenta con una función que, dadas las coordenadas en object-space las convierte a las equivalentes a la posición con respecto de cámara.
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction struct VertexData { float4 vertexPosition : POSITION; }; struct FragmentData { float4 vertexPosition : SV_POSITION; }; FragmentData VertexFunction(VertexData input) { FragmentData output; output.vertexPosition = UnityObjectToClipPos(input.vertexPosition); return output; } float4 FragmentFunction(FragmentData input) : SV_TARGET { return float4(1, 1, 1, 1); } ENDCG } } }
Noten que ahora se pinta correctamente, con forma de cubo. Prueben también cambiarle el color, para asegurarse que todo funciona correctamente. Y ahora sí, ya tenemos nuestro primer pintado “bien hecho”!
Lo próximo que vamos a hacer es pintar con algún patrón y colores diferentes, para darle un poco más de forma al shader. Para eso, vamos a necesitar un dato que todavía no tenemos del shader, y es el dato que nos dice sobre qué pixel va a pintar.
Coordenadas UV
Teniendo en cuenta la explicación de UV Mapping que dije más atrás, las coordenadas UV representarían una posición dentro de ese UV. Estas coordenadas van desde 0 a 1 en cada eje (X e Y). Lo que cambia, dependiendo del sistema sobre el cual se ejecute el shader, es el origen del pintado.
Por ejemplo, DirectX y OpenGL:
Esto es más que nada un dato a tener en cuenta cuando trabajan en algún engine que quizás contemple esto. Unity en este caso lo contempla y en las distintas plataformas no deberían notar ningún cambio (es mayoritariamente una de las ideas principales de un engine multiplataforma). Además, no hay un orden definido en el cual se pintan los pixeles, de hecho, muchos se pintan en simultáneo, y el hardware podría empezar dicho pintado por cualquier posición.
Pero, al margen de eso, tenemos una forma de obtener cuál es la posición DE LA TEXTURA que se va a pintar. De estar forma, podríamos hacer diversos efectos como pintar más brillo donde la textura es más blanca, pintar otra textura en determinados colores de la textura, o incluso pintar texturas diferentes dependiendo de qué color sea el pixel que se vaya a pintar.
Para obtener las coordenadas UV, las tenemos que pasar desde el VERTEX hacia el FRAGMENT, de la siguiente manera:
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { } SubShader { Pass { CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction struct VertexData { float4 vertexPosition : POSITION; float2 uv : TEXCOORD0; }; struct FragmentData { float4 vertexPosition : SV_POSITION; float2 uv : TEXCOORD0; }; FragmentData VertexFunction(VertexData input) { FragmentData output; output.vertexPosition = UnityObjectToClipPos(input.vertexPosition); output.uv = input.uv; return output; } float4 FragmentFunction(FragmentData input) : SV_TARGET { float4 finalColor; if(input.uv.x < 0.5 && input.uv.y < 0.5) { finalColor = float4(0, 0, 0, 1); }else { finalColor = float4(1, 1, 1, 1); } return finalColor; } ENDCG } } }
Aproveché la ocasión para escribir además un pequeño ejemplo de funcionamiento. Noten que en las dos estructuras agregué la misma variable con la SEMANTIC llamada “TEXCOORD0”. Esta significa que de la lista de texturas que tiene para pintar referentes a este shader, va a elegir la primera. Para entender un poco mejor esto, imaginen que hay en algún lugar del sistema un array con texturas, y ustedes le piden la que está en el índice 0. El número que va al final es LITERALMENTE el número de índice de ese array. O sea que, si ustedes escriben TEXCOORD1, les va a dar la segunda (o sea, la que está en el índice 1).
Esto significa que si yo quiero que mi shader tenga 2 texturas, una va a ser TEXCOORD0 y la otra TEXCOORD1. Por lo general, a menos que la vayamos a modificar, podríamos usar una sola de estas variables, si todas se pintan bajo las mismas coordenadas.
Volviendo un poco al ejemplo que puse en la función FRAGMENT, traté de que sea algo sencillo y fácil de leer. Básicamente, pregunto si las coordenadas que el sistema está por pintar son menores a 0.5 pinta de negro, sino de blanco. O sea que desde las coordenadas 0.0 a 0.5 va a pintar de negro, y el resto va a ser blanca. Tengan en cuenta que no importa en qué orden mande a pintar cada pixel, ya que si cumple la condición lo va a pintar, sino no.
Hasta acá, antes de seguir, intenten hacer diferentes formas, como una cruz, ya sea de una punta a la otra, o desde los medios. Intenten algunas formas, y vean qué sale.
Usando las propiedades agregadas
Por último, y para concluir un poco este tutorial, vamos a usar un poco algunas propiedades. Por ejemplo, vamos a agregar un color al cuadrado que dibujamos y otro al resto del espacio.
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { _MainColor("Main Color", Color) = (0, 0, 0, 1) _SecondaryColor("Secondary Color", Color) = (1, 1, 1, 1) } SubShader { Pass { CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction float4 _MainColor; float4 _SecondaryColor; struct VertexData { float4 vertexPosition : POSITION; float2 uv : TEXCOORD0; }; struct FragmentData { float4 vertexPosition : SV_POSITION; float2 uv : TEXCOORD0; }; FragmentData VertexFunction(VertexData input) { FragmentData output; output.vertexPosition = UnityObjectToClipPos(input.vertexPosition); output.uv = input.uv; float4 f = mul(unity_ObjectToWorld, input.vertexPosition); return output; } float4 FragmentFunction(FragmentData input) : SV_TARGET { float4 finalColor; if(input.uv.x < 0.5 && input.uv.y < 0.5) { finalColor = _MainColor; }else { finalColor = _SecondaryColor; } return finalColor; } ENDCG } } }
Algo fundamental a tener en cuenta es que CADA VEZ que agreguen una propiedad en el bloque “Properties” deben también agregar su equivalente dentro del CGPROGRAM. En el caso de los colores que yo agregué, el equivalente es “float4“. Esto lo que hace es crear la “relación” directa entre esas propiedades y las variables de nuestro shader.
Transparencias
El tema de la transparencia es un poco más complejo pero voy a pasarles un dato como para que puedan, al menos, empezar a aplicar algo de transparencia, ya que seguramente habrán notado que si le quieren bajar el alpha el shader lo ignora completamente. Esto lo hace porque no está dentro de los shaders transparentes. Para ponerlo dentro de los shaders transparentes, tenemos que agregar lo siguiente:
Shader "MyShadersTutorials/01/MyFirstShader" { Properties { _MainColor("Main Color", Color) = (0, 0, 0, 1) _SecondaryColor("Secondary Color", Color) = (1, 1, 1, 1) } SubShader { Pass { Blend SrcAlpha OneMinusSrcAlpha CGPROGRAM #pragma vertex VertexFunction #pragma fragment FragmentFunction float4 _MainColor; float4 _SecondaryColor; struct VertexData { float4 vertexPosition : POSITION; float2 uv : TEXCOORD0; }; struct FragmentData { float4 vertexPosition : SV_POSITION; float2 uv : TEXCOORD0; }; FragmentData VertexFunction(VertexData input) { FragmentData output; output.vertexPosition = UnityObjectToClipPos(input.vertexPosition); output.uv = input.uv; float4 f = mul(unity_ObjectToWorld, input.vertexPosition); return output; } float4 FragmentFunction(FragmentData input) : SV_TARGET { float4 finalColor; if(input.uv.x < 0.5 && input.uv.y < 0.5) { finalColor = _MainColor; }else { finalColor = _SecondaryColor; } return finalColor; } ENDCG } } }
Esto básicamente le dice a nuestro shader cómo debe “fusionarse” con el entorno. Pero como dije, es un tema un poco más complejo, así que lo voy a dejar para otro tutorial. Mientras tanto, para más información, pueden visitar esta página: