La Biblia de Shaders en Unity. Una explicación lineal sobre shaders, desde principiante hasta avanzado. Mejora los gráficos de tus juegos con Unity y conviértete en un artista técnico profesional. (Primera Edición) Fabrizio Espíndola. Desarrollador de videojuegos & artista técnico. La Biblia de Shaders en Unity, versión 0.1.5b. Jettelly ® Todos los derechos reservados. www.jettelly.com DDI 2021-A-11866 ISBN 979-881-8401-82-9 Créditos. Autor. Fabrizio Espíndola. Diseño. Pablo Yeber. Revisón Técnica. Daniel Santalla. Corrección ortográfica y gramatical. Elena Miranda. Acerca del autor. Fabrizio Espíndola es un desarrollador de videojuegos chileno, especializado en Computer Graphics. Gran parte de su trayectoria la ha dedicado a desarrollar efectos visuales y arte técnico para distintos proyectos, entre los cuales podemos destacar: Star Wars - Galactic Defense, Dungeons and Dragons - Arena of War, Timenaus, Frozen 2, Nom Noms entre otros. Actualmente, se encuentra desarrollando algunos títulos independientes junto a su equipo en Jettelly. Su gran pasión por los videojuegos nace en 1994 tras la aparición de Donkey Kong Country (Super Nintendo); juego que despertó en él un profundo interés sobre las herramientas y técnicas asociadas a esta tecnología. A la fecha cuenta con más de diez años de experiencia en la industria y este libro contiene una parte del conocimiento que ha adquirido durante todo este tiempo. Años atrás, mientras estuvo en la universidad, fue instruido por su profesor Freddy Gacitúa, quien le comentó en una oportunidad: hace un aporte hacia los demás. “ “ Una persona se vuelve profesional cuando Tales palabras fueron muy significativas para él en su proceso de formación, y generaron la necesidad de contribuir con su conocimiento a la comunidad internacional de desarrolladores de videojuegos independientes. Jettelly nace oficialmente el día 3 de marzo del año 2018 bajo la firma de Pablo Yeber y Fabrizio Espíndola. Juntos y comprometidos, han desarrollado distintos proyectos entre los cuales, la Biblia de shaders en Unity, sería uno de los más relevantes debido a su naturaleza intelectual. Contenido. Prefacio. 10 Capítulo I | Introducción al lenguaje de programción de shaders. 1. Observaciones iniciales. 15 1.0.1. | Propiedades de un objeto poligonal. 15 1.0.2. | Vértices. 17 1.0.3. | Normales. 18 1.0.4. | Tangentes. 18 1.0.5. | Coordenadas UV. 19 1.0.6. | Color de los vértices. 20 1.0.7. | Arquitectura de un Render Pipeline. 21 1.0.8. | Etapa de aplicación. 22 1.0.9. | Fase de procesamiento de geometría. 23 1.1.0. | Etapa de rasterización. 25 1.1.1. | Etapa de procesamiento de un píxel. 26 1.1.2. | Tipo de Render Pipeline. 26 1.1.3. | Forward Rendering. 28 1.1.4. | Deferred Shading. 30 1.1.5. | ¿Qué Render Pipeline debo utilizar? 30 1.1.6. | Matrices y sistemas de coordenadas. 31 2. Shaders en Unity. 36 2.0.1. | ¿Qué es un shader? 36 2.0.2. | Introducción al lenguaje de programación. 37 2.0.3. | Tipos de shaders. 39 2.0.4. | Standard Surface Shader. 40 2.0.5. | Unlit Shader. 40 2.0.6. | Image Effect Shader. 40 2.0.7. | Compute Shader. 41 2.0.8. | Ray Tracing Shader. 41 3. Propiedades, comandos y funciones. 42 3.0.1. | Estructura de un Vertex-Fragment Shader. 42 3.0.2. | ShaderLab Shader. 46 3.0.3. | ShaderLab Properties. 47 3.0.4. | Propiedades para números y sliders. 49 3.0.5. | Propiedades para colores y vectores. 49 3.0.6. | Propiedades para texturas. 50 3.0.7. | Material Property Drawer. 53 3.0.8. | MPD Toggle. 54 3.0.9. | MPD KeywordEnum. 57 3.1.0. | MPD Enum. 59 3.1.1. | MPD PowerSlider e IntRange. 60 3.1.2. | MPD Space y Header. 62 3.1.3. | ShaderLab SubShader. 63 3.1.4. | SubShader Tags. 65 3.1.5. | Tag Queue. 66 3.1.6. | Tag Render Type. 69 3.1.7. | SubShader Blending. 74 3.1.8. | SubShader AlphaToMask. 79 3.1.9. | SubShader ColorMask. 80 3.2.0. | SubShader Culling y Depth Testing. 81 3.2.1. | ShaderLab Cull. 84 3.2.2. | ShaderLab ZWrite. 86 3.2.3. | ShaderLab ZTest. 87 3.2.4. | ShaderLab Stencil. 90 3.2.5. | ShaderLab Pass. 97 3.2.6. | CGPROGRAM / ENDCG. 98 3.2.7. | Tipos de datos. 100 3.2.8. | Cg / HLSL Pragmas. 105 3.2.9. | Cg / HLSL Include. 106 3.3.0. | Cg / HLSL Vertex Input y Vertex Output. 107 3.3.1. | Cg / HLSL variables y vectores de conexión. 111 3.3.2. | Cg / HLSL Vertex Shader Stage. 113 3.3.3. | Cg / HLSL Fragment Shader Stage. 115 3.3.4. | ShaderLab Fallback. 117 4. Implementación y otros conceptos. 119 4.0.1. | Analogía entre un shader y un material. 119 4.0.2. | Nuestro primer shader en Cg o HLSL. 119 4.0.3. | Agregando transparencia en Cg o HLSL. 122 4.0.4. | Estructura de una función en HLSL. 123 4.0.5. | Depurando un shader (Debugging). 126 4.0.6. | Agregando compatibilidad en URP. 130 4.0.7. | Funciones intrínsecas. 135 4.0.8. | Función Abs. 135 4.0.9. | Función Ceil. 140 4.1.0. | Función Clamp. 145 4.1.1. | Función Sin y Cos. 150 4.1.2. | Función Tan. 155 4.1.3. | Función Exp, Exp2 y Pow. 159 4.1.4. | Función Floor. 161 4.1.5. | Función Step y Smoothstep. 165 4.1.6. | Función Length. 169 4.1.7. | Función Frac. 173 4.1.8. | Función Lerp. 177 4.1.9. | Función Min y Max. 181 4.2.0. | Tiempo y animación. 182 Capítulo II | Iluminación, sombras y superficies. 5. Introducción al capítulo. 186 5.0.1. | Configurando inputs y outputs. 186 5.0.2. | Vectores. 191 5.0.3. | Producto Punto. 193 5.0.4. | Producto Cruz. 197 6. Superficie. 199 6.0.1. | Mapa de Normales. 199 6.0.2. | Compresión DXT. 205 6.0.3. | Matriz TBN. 210 7. Iluminación. 213 7.0.1. | Modelo de iluminación. 213 7.0.2. | Color de ambiente. 213 7.0.3. | Reflexión Difusa. 217 7.0.4. | Reflexión Especular. 226 7.0.5. | Reflexión Ambiental. 240 7.0.6. | Efecto Fresnel. 250 7.0.7. | Estructura de un Standard Surface shader. 259 7.0.8. | Standard Surface input y output. 261 8. Sombra. 263 8.0.1. | Shadow Mapping. 263 8.0.2. | Shadow Caster. 264 8.0.3. | Shadow Map Texture. 270 8.0.4. | Implementación de sombras. 274 8.0.5. | Optimización del Shadow Map en Built-in RP. 279 8.0.6. | Shadow Mapping en Universal RP. 282 9. Shader Graph. 289 9.0.1. | Introducción a Shader Graph. 289 9.0.2. | Iniciando en Shader Graph. 291 9.0.3. | Analizando su interfaz. 293 9.0.4. | Nuestro primer shader en Shader Graph. 295 9.0.5. | Graph Inspector. 303 9.0.6. | Nodos. 305 9.0.7. | Custom Functions. 306 Capítulo III | Compute Shader, Sphere Tracing y Ray Tracing. 10. Conceptos avanzados. 312 10.0.1. | Estructura de un Compute Shader. 313 10.0.2. | Nuestro primer Compute Shader. 317 10.0.3. | Coordenadas UV y textura. 332 10.0.4. | Buffers. 336 11. Sphere Tracing. 348 11.0.1. | Implementando funciones con Sphere Tracing. 350 11.0.2. | Proyectando una textura. 360 11.0.3. | Mínimo suavizado entre dos superficies. 366 12. Ray Tracing. 371 12.0.1. | Configurando Ray Tracing en HDRP. 372 12.0.2. | Utilizando Ray Tracing en nuestra escena. 379 Índice. 382 Agradecimientos especiales. 385 Prefacio. Uno de los mayores problemas que tenemos los desarrolladores de videojuegos al momento de iniciar nuestros estudios sobre shaders, independientemente del motor de rendering, es la poca información que existe en la web para principiantes. Seas un desarrollador independiente o uno enfocado en proyectos AAA, puede volverse un tanto complicado entrar en esta materia dado que el conocimiento necesario para desarrollar este tipo de programas es bastante técnico. No obstante esta dificultad, Unity ofrece una gran ventaja, ya que al ser multiplataforma, permite escribir el código de nuestros videojuegos sólo una vez y exportarlos a distintos dispositivos, incluyendo consolas y teléfonos inteligentes. Así mismo, una vez que comencemos nuestra aventura en el mundo de los shaders, escribiremos nuestro código una vez y el software se encargará de compilarlo para distintas plataformas (OpenGL, Metal, Vulkan, Direct3D, GLES 20, GLES 3x). La Biblia de Shaders en Unity ha sido creada para solucionar gran parte de los problemas que tenemos al iniciarnos en este mundo. Comenzaremos revisando la estructura de un shader en lenguaje Cg y HLSL para luego conocer: sus propiedades, comandos, funciones y sintaxis. ¿Sabías que en Unity existen tres tipos de Rendering Pipeline y cada uno de ellos tiene sus propias cualidades? A lo largo del libro precisaremos cada uno, verificando además la manera en que Unity procesa los gráficos para proyectar nuestros videojuegos en la pantalla del ordenador. 10 I. Temas que veremos en este libro. El libro estará dividido en tres capítulos, en los cuales se irán abordando los puntos en la medida que sean requeridos; de manera lineal. Todo el código incluido en el mismo ha sido examinado utilizando el editor de código Visual Studio Code y revisado en Unity para los distintos tipos de Render Pipeline. Capítulo I: Introducción al lenguaje de programación de shaders. Corresponde a los conocimientos previos que debemos tener antes de iniciarnos en la materia, tales como: la estructura de un shader en lenguaje ShaderLab, analogía entre las propiedades y variables de conexión, SubShader y comandos (ColorMask, Stencil, Blending, etc.) pases y estructura del lenguaje Cg y HLSL, estructura de una función, análisis del Vertex Input, análisis del Vertex Output, analogía entre una semántica y una primitiva, estructura del Vertex Shader Stage, estructura del Fragment Shader Stage, matrices y más. Este capítulo será el punto de partida para entender conceptos fundamentales sobre el funcionamiento de un shader en Unity. Capítulo II: Iluminación, sombras y superficies. En él se abordarán temas de alta relevancia, tales como: los mapas de Normales y su implementación, mapas de reflexión, análisis de iluminación y sombras, modelo básico de iluminación, análisis de superficies, funciones matemáticas, especularidad y luz ambiental. Además, revisaremos Shader Graph, su estructura, funciones en HLSL, nodos, propiedades y más. En este capítulo haremos que nuestro videojuego luzca profesional con conceptos simples de iluminación. Capítulo III: Compute Shader, Ray Tracing, and Sphere Tracing. Pondremos en práctica conceptos avanzados, tales como: estructura de un Compute Shader, variables buffer, Kernel, implementación de Sphere Tracing, superficies implícitas, formas y algoritmos, introducción a Ray Tracing, configuraciones y rendering de alta calidad. Nuestros estudios van a concluir en este capítulo y para ello indagaremos en programación GPGPU (general purpose GPU) utilizando shaders de tipo .compute, comprobaremos la técnica Sphere Tracing y utilizaremos Direct Ray Tracing (DXT) en HDRP. 11 II. Recomendaciones. Es fundamental trabajar con un editor de código que posea Intellisense en lenguaje de programación de gráficos; específicamente en Cg o HLSL. En Unity podemos encontrar a Visual Studio Code que en sí, es una versión más compacta de Visual Studio Community. En este editor podemos encontrar algunas extensiones que agregan intellisense tanto en C# como en ShaderLab y HLSL. Para aquellos que usarán Visual Studio Code como su editor de código, se recomienda la instalación de las siguientes extensiones: C# for Visual Studio Code (Microsoft), Shader language support for VS Code (Slevesque), ShaderLab VS Code (Amlovey), Unity Code Snippets (Kleber Silva). III. Para quién es este libro. Este libro ha sido escrito para desarrolladores Unity que buscan mejorar sus conocimientos en materia gráfica o bien crear efectos que luzcan profesionales. Se asume que el lector ya conoce, entiende y tiene acceso a la interfaz de Unity, por consiguiente no entraremos en detalles sobre esta. Si bien es cierto tener un conocimiento previo sobre lenguaje C# o C++, será de bastante ayuda para entender parte del contenido presentado en este libro, sin embargo, esto no es un requisito excluyente para el lector. Será fundamental tener una pequeña base de conocimiento en aritmética, álgebra y trigonometría para entender conceptos más avanzados. De todas maneras haremos revisión de las operaciones matemáticas y funciones para entender a fondo lo que estamos desarrollando. IV. Glosario. Dada su naturaleza, en este libro encontraremos frases y palabras que se distinguen del resto, las cuales podremos identificar con facilidad debido a que serán resaltadas para dar énfasis a una explicación o concepto. Así mismo, existirán bloques de código en lenguaje HLSL para ejemplificar algunas funciones. Para enfatizar, algunas palabras serán mostradas en negrita. Este ejercicio será empleado de igual manera para llamar la atención del lector sobre líneas de código. Otras definiciones técnicas presentarán mayúsculas al inicio de la palabra (p. ej., Vertex), mientras que aquellas de índole constante serán presentadas completamente en el mismo estilo (p. ej., RGBA). En la definición de funciones, podremos encontrar tanto “argumentos” definidos con el acrónimo “RG” (p. ej., N RG ) como “AX” para el caso de “coordenadas de espacio” (p. ej., Y 12 AX ). Asimismo, bloques de código que incluyen tres puntos ( … ), estos se refieren precisamente a variables o funciones incluidas por defecto dentro del código. V. Errata. Al momento de escribir este libro, hemos tomado todas las precauciones para asegurar la fidelidad de su contenido. Aún así, debemos recordar que somos seres humanos y es muy probable que algunos puntos no estén bien explicados o se haya errado en la corrección ortográfica o gramatical. Si encuentras un error conceptual, de código u otro, te agradeceríamos enviar un mensaje al correo contact@jettelly.com indicando en el asunto USB Errata, de esta manera estarás ayudando a otros lectores a reducir su nivel de frustración, mejorando cada versión del mismo en las siguientes actualizaciones. Asimismo, si consideras agregar alguna sección de interés, puedes enviarnos un correo de todas maneras y nosotros incluiremos esa información en futuras ediciones. VI. Assets y donaciones. Para reforzar el contenido, este libro cuenta con assets exclusivos que vienen incluidos en la descarga del mismo. Estos han sido desarrollados utilizando Unity 2020.3.21f1 y analizados tanto en Built-in como en Scriptable Render Pipeline. → learn.jettelly.com/usb-resources Todo el trabajo que ves en Jettelly ha sido desarrollado por sus propios integrantes, esto incluye: dibujos, diseños, videos, audio, tutoriales, y en sí, todo lo que ves en la marca. Jettelly es un estudio independiente de desarrollo de videojuegos y tu apoyo es fundamental para nosotros. Si deseas apoyarnos económicamente, puedes hacerlo directamente a través de nuestra cuenta de PayPal. → paypal.com/paypalme/jettelly VII. Piratería. Antes de copiar, reproducir o facilitar este material sin nuestro consentimiento, recuerda que Jettelly es un estudio independiente y autofinanciado, por lo que, cualquier práctica ilegal podría afectar nuestra integridad como equipo desarrollador. Este libro se encuentra patentado bajo derechos de autor y tomaremos protección de nuestras licencias seriamente. Si encuentras este libro en una plataforma distinta de Jettelly o detectas una copia ilegal, solicitamos comunicarse con nosotros al correo contact@jettelly.com (adjuntando el enlace si fuese necesario), de esta manera podremos buscar una solución. Muchas gracias de antemano. 13 Capítulo I Introducción al lenguaje de programación de shaders. Observaciones iniciales. Años atrás; cuando recién comenzaba mis estudios sobre shaders en Unity, fue muy difícil comprender gran parte del contenido que encontraba en los libros por diversos factores. Aún recuerdo aquel día de estudios, deseando entender el funcionamiento de la semántica POSITION[n]. “ “ No obstante, cuando logré dar con su definición, me encontré con el siguiente enunciado: Vertex position in object-space. En ese momento me pregunté, ¿qué es Vertex position en Object-Space? Ahí entendí que existía información previa que debía conocer antes de empezar la lectura sobre esta materia. En mi experiencia, he podido identificar al menos cuatro áreas fundamentales que facilitan la comprensión sobre shaders y su estructura, tales corresponden a: › Propiedades de un objeto poligonal. › Estructura de un Render Pipeline. › Matrices y sistemas de coordenadas. 1.0.1. Propiedades de un objeto poligonal. La palabra polígono proviene del Griego πολύγωνος (polúgōnos) y está compuesta por poly (muchos) y gnow (ángulos). Por definición, un polígono se refiere a una figura plana cerrada, delimitada por segmentos de recta. Propiedades de un objeto poligonal ⚫ ⚫ ⚫ (Fig. 1.0.1a) 15 Una primitiva es un objeto geométrico tridimensional formado por un conjunto de polígonos y es utilizado como objeto predefinido en distintos software de desarrollo. Dentro de Unity, Maya o Blender podemos encontrar distintas primitivas, las más comunes son: › Esferas. › Cajas. › Planos. › Cilindros. › Cápsulas. Todos estos objetos son distintos en forma, pero iguales en propiedades; todos poseen: › Vértices. › Tangentes. › Normales. › Coordenadas UV. › Color. Las cuales son almacenadas dentro de un tipo de dato llamado Mesh. En un shader podemos acceder a todas estas propiedades de manera independiente y almacenarlas en vectores. Esto es muy útil debido a que podemos modificar sus valores y así generar efectos interesantes. Para entender este concepto de mejor manera haremos una pequeña definición sobre las propiedades de un objeto poligonal. Propiedades de un objeto poligonal ⚫ ⚫ ⚫ (Fig. 1.0.1b) 16 1.0.2. Vértices. Los vértices de un objeto corresponden al conjunto de puntos que definen el área de una superficie, ya sea en un espacio bidimensional o tridimensional. Tanto en Maya como en Blender, los vértices son representados como los puntos de intersección de la malla de un objeto, similar a un conjunto de átomos (moléculas). Estos puntos se caracterizan por dos cosas principalmente: 1 Son hijos del componente Transform. 2 Poseen una posición definida según el centro del volumen total del objeto. ¿Qué quiere decir esto? Supongamos, en Maya 3D existen dos nodos por defecto asociados a un objeto, estos son conocidos como Transform y Shape. El nodo Transform, al igual que en Unity, define la posición, rotación y escala de un objeto en relación con el pivote del mismo. En cambio, el nodo Shape; hijo del nodo Transform, contiene los atributos de la geometría, es decir la posición de los vértices del objeto con relación al volumen del mismo. Dada esta característica, podemos trasladar, rotar o escalar al conjunto de vértices de un objeto, pero al mismo tiempo podríamos cambiar la posición de un vértice en específico. La semántica POSITION[n] en lenguaje HLSL, es precisamente aquella que da acceso a la posición de los vértices con relación al volumen del mismo, o sea, a la configuración que ha exportado el nodo Shape desde Maya. Vértices ⚫ ⚫ ⚫ (Fig. 1.0.2a) 17 1.0.3. Normales. Vamos a suponer que tenemos una hoja de papel completamente blanca y le pedimos a un amigo que dibuje sobre la cara frontal de la misma ¿Cómo podríamos determinar la cara frontal si ambos lados son iguales? Para esto existen las Normales. Una Normal corresponde a un vector perpendicular en la superficie de un polígono, el cual se utiliza para determinar la dirección u orientación de una cara o vértice. En Maya podemos visualizar a las Normales de un objeto seleccionando la propiedad Vertex Normals. Esta nos permite ver hacia donde apunta un vértice en el espacio y además determinar el nivel de dureza entre las distintas caras de un objeto. Normales ⚫ ⚫ ⚫ (Fig. 1.0.3a. Representación gráfica de las normales por cada vértice) 1.0.4. Tangentes. Una tangente es un vector de una unidad de longitud que sigue la superficie de la malla a lo largo de la dirección de la textura horizontal. “ “ De acuerdo a la documentación oficial en Unity: ¿Qué quiere decir el enunciado anterior? Prestaremos atención a la Figura 1.0.4a para entender su naturaleza. La Tangente es un vector normalizado que sigue la orientación de la coordenada U de los UV en cada cara de la geometría. Su función principal es generar un espacio denominado Tangent-Space. 18 Tangentes ⚫ ⚫ ⚫ (Fig. 1.0.4a. Por defecto, no podemos acceder a las Binormales en un shader, en cambio, tendremos que calcularla en relación con las Normales y Tangentes) Más adelante, en el capítulo II, sección 6.0.1, revisaremos esta propiedad en detalle e incluiremos a las Binormales para la implementación de un mapa de normales sobre nuestro objeto. 1.0.5. Coordenadas UV. Todos alguna vez hemos cambiado el skin de nuestro personaje favorito por uno más interesante. Las coordenadas UV están directamente relacionadas con este concepto, ya que nos permiten posicionar una textura bidimensional sobre la superficie de un objeto tridimensional. Estas coordenadas actúan como puntos de referencia, los cuales controlan qué texels en el mapa de textura corresponden a cada vértice en la malla. El proceso de posicionar vértices sobre coordenadas UV se denomina UV mapping y es un proceso mediante el cual se crean, editan y organizan los UV que aparecen como una representación aplanada, y bidimensional de la malla del objeto. Dentro de nuestro shader podremos acceder a esta propiedad, ya sea para posicionar una textura sobre nuestro objeto o para guardar información en ella. 19 Coordenadas UV ⚫ ⚫ ⚫ (Fig. 1.0.5a. Los vértices pueden ser acomodados de distinta manera dentro de un mapa de UV) El área de las coordenadas UV corresponde a un rango entre 0.0f y 1.0f, donde el primero corresponde al punto inicial, y el último, al punto final. Coordenadas UV ⚫ ⚫ ⚫ (Fig. 1.0.5b. Referencia gráfica de las coordenadas UV en un plano cartesiano) 1.0.6. Color de los vértices. Cuando exportamos un objeto desde un software 3D, este asigna un color al objeto para que pueda ser afectado, ya sea por la iluminación o bien, por la multiplicación de otro color. A tal color se le conoce como Vertex Color y corresponde a un color blanco por defecto, es decir que posee el valor 1.0f en sus canales RGBA. 20 1.0.7. Arquitectura de un Render Pipeline. En las versiones actuales de Unity existen tres tipos de Rendering Pipeline (RP), los cuales son: Built-in RP, Universal RP (llamado Lightweight en versiones anteriores) y High Definition RP. Cabe preguntarnos entonces, ¿Qué es un Render Pipeline? Para responder a esta interrogante primero debemos comprender el concepto de pipeline como tal. Un pipeline es un conjunto de varias etapas donde cada una de ellas realiza una operación de una tarea más grande. Entonces, ¿A qué se refiere rendering pipeline? Vamos a pensar en este concepto como el proceso completo que debe tomar un objeto poligonal para ser renderizado en la pantalla de nuestro ordenador; como si tal objeto estuviera viajando por las tuberías de Super Mario hasta llegar a su destino final. Arquitectura de un Render Pipeline ⚫ ⚫ ⚫ (Fig. 1.0.7a) Entonces, cada Render Pipeline tiene sus propias características y dependiendo del tipo que estemos utilizando: las propiedades de los materiales, fuentes de luz, texturas y en sí, todas las funciones que están ocurriendo de manera interna dentro del shader, afectarán la apariencia y optimización de los objetos en pantalla. Ahora bien, ¿Cómo ocurre este proceso? Para ello debemos hablar de su arquitectura básica. Unity está dividida en cuatro etapas generales, las cuales son: › Etapa de aplicación. › Fase de procesamiento de geometría. › Etapa de rasterización. › Procesamiento de píxel. 21 Cabe destacar que tales etapas corresponden al modelo básico de un Render Pipeline para motores de rendering en tiempo real. Cada una de las etapas mencionadas poseen subprocesos que iremos definiendo a continuación. Arquitectura de un Render Pipeline ⚫ ⚫ ⚫ (Fig. 1.0.7b. Render Pipeline lógico) 1.0.8. Etapa de Aplicación. La etapa de aplicación inicia en la CPU y es responsable de varias operaciones que ocurren dentro de una escena, por ejemplo: › Detección de colisiones. › Animaciones de textura. › Inputs del teclado. › Inputs del mouse y más. Su función es leer la información que se encuentra almacenada en la memoria para luego generar primitivas (triángulos, líneas, vértices). Al final de la etapa de aplicación, toda esta información es enviada a la fase de procesamiento de geometría para luego generar la transformación de los vértices a través de la multiplicación de matrices. 22 Etapa de Aplicación ⚫ ⚫ ⚫ (Fig. 1.0.8a. Posición local de los vértices de un Quad) 1.0.9. Fase de procesamiento de geometría. Las imágenes que vemos en nuestra pantalla del ordenador son solicitadas por la GPU a la CPU. Tal solicitud es realizada en dos pasos principalmente: 1 Se configura el estado del render, el cual corresponde al conjunto de etapas desde la fase de procesamiento de geometría hasta el procesamiento de un píxel. 2 Luego se dibuja el objeto en pantalla. La fase de procesamiento de la geometría ocurre en la GPU y es responsable del procesamiento de vértices de un objeto, la cual está dividida en cuatro subprocesos que corresponden a: › Vertex Shading. › Projection. › Clipping. › Screen mapping. 23 Fase de procesamiento de geometría ⚫ ⚫ ⚫ (Fig. 1.0.9a) Cuando las primitivas ya han sido ensambladas en la etapa de aplicación, el Vertex Shading, más bien conocido como Vertex Shader Stage, se encarga de dos tareas principalmente: 1 Calcular la posición de los vértices del objeto. 2 Transformar su posición a distintas coordenadas de espacio para que puedan ser proyectadas en la pantalla del ordenador. Además, dentro de este subproceso podemos seleccionar aquellas propiedades que deseamos pasar a las siguientes etapas, es decir Normales, Tangentes, coordenadas UV, etc. Como parte del proceso, ocurre la proyección y el clipping, los cuales varían según las propiedades de nuestra cámara en la escena; si está configurada en perspectiva u ortográfica (paralela). Cabe mencionar que todo el proceso de renderización ocurre sólo para aquellos elementos que se encuentran dentro del frustum de la cámara, también conocido como el View-Space. Para entender este proceso vamos a suponer que tenemos una esfera en nuestra escena, donde la mitad de la misma está fuera del frustum. Únicamente el área de la esfera que se encuentra dentro del frustum será proyectada y posteriormente clipeada en pantalla (clipping), mientras que todo aquello fuera de la vista será descartado en el proceso de rendering. 24 Geometry processing phase ⚫ ⚫ ⚫ (Fig. 1.0.9b) Una vez que tenemos nuestros objetos clipeados en la memoria, estos son enviados posteriormente al mapeo de pantalla. En esta etapa, aquellos objetos tridimensionales que se encuentran en nuestra escena, son transformados a coordenadas de pantalla 2D también conocidas como Screen o Windows coordinates. 1.1.0. Etapa de rasterización. La tercera etapa corresponde a la rasterización. En este punto los objetos poseen coordenadas de pantalla 2D, por lo cual, debemos buscar los píxeles que se encuentran en el área de proyección. El proceso de encontrar aquellos píxeles que bordean un objeto en pantalla se denomina Rasterización. Este proceso puede ser visto como un punto de sincronización entre los objetos de nuestra escena y los píxeles en pantalla. Para cada objeto, el Rasterizador realiza dos procesos: 1 Triangle Setup. 2 Triangle Traversal. Triangle Setup se encarga de generar la data que será enviada al Triangle Traversal posteriormente. El proceso incluye las ecuaciones para los bordes de un objeto en pantalla. Luego, el Triangle Traversal enumera los píxeles que están cubiertos por el área del objeto poligonal. De esta manera se genera un conjunto de píxeles el cual se denomina “fragmento”; de ahí la palabra Fragment Shader, la cual también es empleada para referirse a un píxel independiente. 25 1.1.1. Etapa de procesamiento de un píxel. Utilizando los valores interpolados de los procesos anteriores, esta última etapa inicia cuando los píxeles se encuentran listos para ser proyectados en pantalla. En este punto se ejecuta el Fragment Shader Stage conocido también como Píxel Shader Stage, el cual es responsable de la visibilidad de cada píxel. Su función es procesar el color final de un píxel para luego enviarlos al Color Buffer. Etapa de procesamiento de un píxel ⚫ ⚫ ⚫ (Fig. 1.1.1a. El área que cubre una geometría es transformada a píxeles en la pantalla) 1.1.2. Tipos de render Pipeline. Como ya sabemos, en Unity existen tres tipos de Render Pipeline. Por defecto podemos encontrar a Built-in que corresponde al motor más antiguo perteneciente al software. En cambio, Universal y High Definition pertenecen a un tipo de rendering denominado Scriptable Render Pipeline, el cual es más actualizado y ha sido pre-optimizado para un mejor desempeño gráfico. Tipos de render Pipeline ⚫ ⚫ ⚫ (Fig. 1.1.2a. Cuando creamos un nuevo proyecto en Unity podemos escoger entre estos tres motores de rendering. Nuestra selección va a depender de las necesidades del proyecto en curso) 26 Independiente del Rendering Pipeline que utilicemos, si deseamos generar una imagen en pantalla, tendremos que viajar a través del pipeline. Un pipeline puede tener distintas rutas de procesamiento conocidas como Render Paths; como si aquella tubería que ejemplificamos en la sección 1.0.7, tuviera más de un camino para llegar a su destino. Un Render Path corresponde a una serie de operaciones relacionadas con iluminación y sombreado de objetos. Este nos permite procesar gráficamente una escena iluminada (p. ej. una escena con una luz direccional y una esfera). Entre ellas podemos encontrar: › Forward Rendering. › Deferred Shading. › Legacy deferred. › Legacy vertex lit. Cada una de estas posee distintas capacidades y características de rendimiento. En Unity, el Rendering Path por defecto corresponde a Forward Rendering, en consecuencia, los tres tipos de Render Pipeline que vienen incluidos (Built-in, Universal y High Definition) iniciarán con su configuración. Esto se debe a que tiene mayor compatibilidad con tarjetas gráficas y además posee un límite para el cálculo de iluminación, haciendo su proceso más optimizado. Tipos de render Pipeline ⚫ ⚫ ⚫ (Fig. 1.1.2b. Para seleccionar una ruta de rendering en Built-in Render Pipeline, debemos ir a nuestra jerarquía, seleccionar la cámara principal y en la propiedad “Rendering Path” podemos cambiar la configuración según las necesidades de nuestro proyecto) 27 Para entender el concepto, vamos a suponer un objeto y una luz direccional en una escena. La interacción entre ambos se basa en dos conceptos fundamentales: 1 Características de la iluminación. 2 Características del material del objeto (shader). Tal interacción se denomina “modelo de iluminación”. El modelo básico de iluminación corresponde a la suma de tres propiedades distintas: › Color de ambiente. › Reflexión difusa. › Reflexión especular. El cálculo de iluminación se ejecuta dentro del shader y puede ser llevado a cabo por vértice o por fragmento. Cuando es calculado por vértice se denomina Per-Vertex Lighting y es realizado en el Vertex Shader Stage. De la misma manera, se denomina Per-Fragment o Per-Pixel Lighting cuando es calculado por fragmento en el Fragment Shader Stage. 1.1.3. Forward Rendering. Forward es el Rendering Path por defecto y soporta todas las características típicas de un material, incluye mapas de Normales, iluminación por cada pixel, sombras y más. Posee dos pases (SubShader Pass) distintos que podemos utilizar en nuestro shader desde código. El primero corresponde al pase base (Base Pass) y el segundo al pase adicional (Additional Pass). En el pase base podemos definir el Light Mode ForwardBase como tal, mientras que en el pase adicional podemos definir el Light Mode ForwardAdd para cálculos de iluminación adicional. Ambas son funciones características de un shader con cálculo de iluminación. El pase base tiene la capacidad de procesar luz direccional Per-Pixel. Si hay múltiples luces direccionales en la escena, la más brillante será quien tenga prioridad. Asimismo, puede procesar Light Probes, iluminación global e iluminación ambiental (sky light). Como su nombre lo dice, en el pase adicional podemos procesar luces adicionales (Point Light, Spotlight, Area Light) o también sombras que afecten al objeto, ¿Qué quiere decir esto? Si tenemos dos luces en la escena, nuestro objeto será influenciado únicamente por una de ellas, sin embargo, si tenemos definido un pase adicional para esta configuración, entonces será influenciado por ambas. 28 Un punto que debemos tomar en consideración es que cada pase iluminado va a generar un Draw Call independiente, ¿Qué significa esto? Por definición, un Draw Call es un llamado gráfico que se realiza en la GPU cada vez que deseamos dibujar un elemento en la pantalla de nuestro ordenador. Estos llamados son procesos que requieren una gran cantidad de cálculo, por lo que es necesario que se mantengan al mínimo posible, más aún si estamos trabajando en proyectos para dispositivos móviles. Para entender este concepto, vamos a suponer cuatro esferas y una luz direccional en nuestra escena. Cada esfera, por su naturaleza, genera un llamado a la GPU, es decir que cada una de ellas va a generar un Draw Call independiente por defecto. Asimismo, la luz direccional tiene influencia sobre todas las esferas que se encuentran en la escena, por lo tanto, va a generar un Draw Call adicional por cada una. Esto se debe principalmente a que un segundo pase ha sido incluido en el shader para calcular la proyección de sombras. En consecuencia, cuatro esferas, más una luz direccional generarán ocho llamados gráficos en total. Forward Rendering ⚫ ⚫ ⚫ (Fig. 1.1.3a. En la imagen anterior podemos apreciar el aumento de Draw Calls cuando existen fuentes lumínicas. En el cálculo se incluye el color de ambiente y la fuente de luz como objeto) Habiendo determinado el pase base, si agregamos un pase adicional en nuestro shader, sumaremos un nuevo Draw Call por cada objeto. En consecuencia, la carga gráfica aumentará con relación a la cantidad de los mismos. 29 1.1.4. Deferred Shading. Este Rendering Path garantiza que exista solo un pase de iluminación computando cada fuente lumínica en nuestra escena y solo en aquellos píxeles que son afectados por la misma, todo esto a través de la separación de geometría y la iluminación. Esto figura como una ventaja dado que podríamos generar una cantidad significativa de luces que afecten a distintos objetos, mejorando con ello la fidelidad de la imagen final, pero aumentando nominalmente el cálculo Per-Pixel en la GPU. Si bien Deferred Shading es superior a Forward cuando se trata de cálculo de múltiples fuentes de luz, trae consigo algunas restricciones, p. ej., según la documentación oficial de Unity, Deferred Shading requiere de una tarjeta gráfica con múltiples Render Targets, Shader Model 3.0 o superior, y soporte para Depth render texture. En dispositivos móviles, esta configuración funciona solo en aquellos que soportan al menos OpenGL ES 3.0. Otra consideración no menor sobre este Rendering Path es que únicamente se puede utilizar en proyectos con una cámara en perspectiva. Deferred Shading no posee soporte para proyección ortográfica. 1.1.5. ¿Qué Render Pipeline debo utilizar? Antiguamente, nada más existía Built-in RP, por lo que era muy fácil iniciar un proyecto ya sea en 2D o 3D. Sin embargo, actualmente debemos comenzar nuestro proyecto según las necesidades del mismo. Cabe preguntarnos entonces, ¿Cómo podemos determinar sus necesidades? Para responder a esta inquietud debemos considerar los siguientes factores: 1 Si vamos a desarrollar un videojuego para PC podemos usar cualquiera de los tres render pipeline que existen en Unity. Generalmente, un ordenador de escritorio tiene mayor capacidad de cálculo que un dispositivo móvil o incluso una consola. Entonces, si nuestro videojuego tiene como objetivo un dispositivo de alta gama, ¿Necesitamos que gráficamente luzca realista? En este caso, podríamos comenzar tanto en High Definition como en Built-in Render Pipeline. 2 Si nuestro videojuego debe lucir gráficamente en una definición media, podemos utilizar Universal o, al igual que en el caso anterior; Built-in Render Pipeline también. Ahora bien, ¿Por qué Built-in RP aparece como opción en ambos casos? 30 A diferencia de los anteriores, este rendering es mucho más flexible, por ende, es más técnico y no posee pre-optimización. High Definition RP ha sido pre-optimizado para generar gráficos de alta gama, así también Universal RP para gráficos de gama media. Otro factor importante al momento de escoger nuestro Render Pipeline son los shaders. Generalmente, tanto en High Definition como en Universal RP, los shaders son creados en Shader Graph, el cual es un paquete que trae en sí una interfaz que permite el desarrollo de shaders a través de nodos. Esto trae consigo un lado positivo y otro negativo. Por una parte, podemos producir shaders visualmente a través de nodos sin la necesidad de escribir código en HLSL. Sin embargo, si deseamos actualizar la versión de Unity a una superior en etapa de producción (p. ej. desde 2019 a 2022), es muy probable que los shaders dejen de compilar debido a que Shader Graph cuenta con versiones y actualizaciones independientes. La mejor manera de generar shaders en Unity es a través de lenguaje HLSL, ya que de esta manera podemos asegurar que nuestro programa compile en los distintos Render Pipeline y continúen funcionando independientemente de la actualización de Unity. Este concepto será discutido más adelante cuando revisaremos en detalle la estructura de un programa en HLSL. 1.1.6. Matrices y sistemas de coordenadas. Uno de los conceptos que veremos con frecuencia en la creación de shaders son las matrices. Una matriz es una lista de elementos numéricos que siguen ciertas reglas aritméticas y son utilizadas a menudo en Computer Graphics. En Unity las matrices representan una transformación espacial y entre ellas podemos encontrar: › UNITY_MATRIX_MVP. › UNITY_MATRIX_MV. › UNITY_MATRIX_V. › UNITY_MATRIX_P. › UNITY_MATRIX_VP. › UNITY_MATRIX_T_MV. › UNITY_MATRIX_IT_MV. › unity_ObjectToWorld. › unity_WorldToObject. 31 Todas corresponden a matrices de cuatro por cuatro dimensiones, es decir que cada una posee cuatro filas y cuatro columnas de valores numéricos. Su representación conceptual es la siguiente: UNITY_MATRIX ( Xx, Yx, Xz, Yz, Xy, Xt, Yy, Zx, Tx, Zy, Ty, Yt, Zt, Zz, ); Tz, Tw Como ejemplificamos anteriormente en la sección 1.0.2; cuando hablamos de vértices, un objeto poligonal posee dos nodos por defecto. En Maya estos nodos son conocidos como Transform y Shape, y ambos se encargan de calcular la posición de los vértices en un espacio denominado Object-Space, el cual define la posición de los vértices en relación con la posición del centro del mismo objeto. El valor final de cada vértice de un objeto es multiplicado por una matriz conocida como Model Matrix (UNITY_MATRIX_M), la cual nos permite modificar los valores de transformación, rotación y escala del mismo. Cada vez que rotamos, cambiamos de posición o escalamos nuestro objeto, el Model Matrix es actualizado. ¿Cómo ocurre este proceso? Para entenderlo vamos a suponer la transformación de un Cubo en nuestra escena. Iniciaremos tomando un vértice de nuestro Cubo que se encuentre en la posición 0.5 , -0.5 , -0.5 , 1.0 X Y Z W con respecto a su centro. Cabe mencionar que el canal “W” corresponde a un sistema de coordenadas denominado homogéneo, el cual nos permite manejar vectores y puntos de manera uniforme. En las transformaciones de matrices, la coordenada W puede ser igual a cero o a uno. Cuando n W es igual a 1, se refiere a un punto en el espacio, mientras que, cuando es igual a 0, se refiere a una dirección. Más adelante en este libro hablaremos de este sistema cuando multipliquemos vectores por matrices y viceversa. 32 Matrices y sistemas de coordenadas ⚫ ⚫ ⚫ (Fig. 1.1.6a. Identity matrix se refiere a los valores por defecto de una matriz) Uno de los elementos a considerar con respecto a las matrices es que una multiplicación se puede llevar a cabo siempre y cuando el número de columnas de la primera matriz sea igual al número de filas de la segunda. Como ya sabemos nuestro Model Matrix posee una dimensión de cuatro filas y cuatro columnas, y la posición de los vértices posee una dimensión de cuatro filas y una columna. Como la cantidad de columnas en el Model Matrix es igual a la cantidad de filas en la posición de los vértices, podemos multiplicar y el resultado será igual a una nueva matriz de cuatro filas y una columna, lo que definiría una nueva posición para los vértices. Este proceso de multiplicación ocurre para todos los vértices en nuestro objeto y tal proceso es llevado a cabo en el Vertex Shader Stage. Hasta este punto ya sabemos que Object-Space se refiere a la posición de un vértice según su propio centro, entonces, ¿Qué quiere decir World-Space, View-Space o Clip-Space? El concepto es básicamente el mismo. World-Space corresponde a la posición de un vértice según el centro del mundo; a la distancia entre el punto inicial de la cuadrícula en nuestra escena (0 , 0 , 0 , 1 ) y la posición de un vértice X en el objeto. Y Z W Si deseamos transformar una coordenada de espacio desde Object-Space a World-Space podemos utilizar la variable interna unity_ObjectToWorld. 33 Matrices y sistemas de coordenadas ⚫ ⚫ ⚫ (Fig. 1.1.6b) View-Space corresponde a la posición de un vértice de nuestro objeto con relación a la vista de la cámara. Si deseamos transformar una coordenada de espacio desde World-Space a View-Space podemos emplear la matriz UNITY_MATRIX_V. Matrices y sistemas de coordenadas ⚫ ⚫ ⚫ (Fig. 1.1.6c) Finalmente, Clip-Space, también conocido como Projection-Space, se refiere a la posición de un vértice de nuestro objeto con relación al frustum de la cámara. Así pues, el factor será afectado por el Near Clipping Plane, Far Clipping Plane y Field of View. Si deseamos transformar una coordenada de espacio desde View-Space a Clip-Space podemos utilizar la matriz UNITY_MATRIX_P. 34 Matrices y sistemas de coordenadas ⚫ ⚫ ⚫ (Fig. 1.1.6d) En general, se ha definido a nivel conceptual las distintas coordenadas de espacio, pero aún no se ha precisado en sí a qué se refieren las matrices de transformación. Por ejemplo, la variable interna UNITY_MATRIX_MVP se refiere a la multiplicación de tres matrices distintas. La letra M es igual a Model Matrix, V es igual a View Matrix y P es igual a Projection Matrix. Esta matriz se usa principalmente para transformar directamente los vértices de un objeto desde Object-Space a Clip-Space. Recordemos que nuestro objeto poligonal ha sido creado en un entorno tridimensional mientras que la pantalla de nuestro ordenador; en donde será proyectado, es bidimensional, por lo tanto, será necesario transformar nuestro objeto desde un espacio a otro. Este concepto será discutido más adelante en el libro, cuando utilicemos la función UnityObjectToClipPos( V RG ), incluida en nuestro shader, dentro del Vertex Shader Stage. 35 Shaders en Unity. 2.0.1. ¿Qué es un shader? Considerando el conocimiento previo, nos adentraremos en el tema de los shaders en Unity. Un shader es un pequeño programa con extensión .shader el cual podemos emplear para generar efectos interesantes en nuestros proyectos. En su interior posee cálculos matemáticos y listas de instrucciones que permiten el procesamiento de color para cada píxel dentro del área que cubre un objeto en la pantalla de nuestro ordenador. Dicho programa nos permite dibujar elementos mediante sistemas de coordenadas, basándonos en las propiedades de un objeto poligonal. Los shaders son ejecutados a través de la GPU debido a su arquitectura en paralelo, que consiste en miles de núcleos pequeños y eficaces, diseñados para resolver tareas de manera simultánea, mientras que la CPU ha sido diseñada para el procesamiento en serie secuencial. Cabe destacar que en Unity nos encontraremos con tres tipos de archivos asociados a shaders. Por una parte, existen aquellos con extensión .shader que son capaces de compilar en los distintos tipos de Render Pipeline. Asimismo, tenemos otros de extensión .shadergraph que sólo pueden compilar ya sea en Universal RP como en High Definition RP. Por otra parte, tenemos archivos con extensión .hlsl que permiten la creación de funciones personalizadas; generalmente utilizadas dentro de un tipo de nodo llamado Custom Function, el cual podemos encontrar en Shader Graph. Más adelante revisaremos estos archivos, incluyendo aquellos de extensión .cginc. Por ahora nos limitaremos a hacer la siguiente asociación: › Aquellos de extensión .cginc están relacionados con .shader y CGPROGRAM mayormente. › Mientras que .hlsl está vinculado a .shadergraph y HLSLPROGRAM. Es fundamental conocer esta analogía debido a que cada extensión cumple una función distinta y se emplean en contextos específicos. 36 ¿Qué es un shader? ⚫ ⚫ ⚫ (Fig. 2.0.1a. ícono de referencia para shaders en Unity) En Unity existen al menos cuatro tipos de estructuras definidas para generar shaders, entre las cuales podemos encontrar: › La combinación de Vertex Shader y Fragment Shader. › Surface Shader para cálculo de iluminación automática en Built-in RP. › Compute Shader para conceptos más avanzados. Cada una cuenta con propiedades y funciones previamente descritas que facilitan el proceso de compilación. De la misma manera, podremos definir nuestras operaciones con facilidad dado que el software agrega dichas estructuras de manera automática. 2.0.2. Introducción al lenguaje de programación. Antes de comenzar en la definición de código, debemos tomar en consideración que en Unity existen tres lenguajes de programación asociados al desarrollo de un shader, estos son: 1 HLSL (High Level Shader Language - Microsoft). 2 Cg (C for Graphics - NVIDIA) el cual sigue compilando dentro del programa, pero ya no es utilizado en versiones actuales del software. 3 ShaderLab (lenguaje declarativo - Unity) funciona como vínculo entre el programa y Unity. Iniciaremos nuestra aventura trabajando con lenguaje Cg y ShaderLab en Built-in RP para luego dar paso a la introducción de HLSL en Universal RP. 37 Cg es un lenguaje de programación de alto nivel, diseñado para compilar en la mayoría de las GPU. Este ha sido desarrollado por NVIDIA en colaboración con Microsoft y usa una sintaxis muy similar a HLSL. La razón de Cg en el funcionamiento de shader en Unity se debe a que, este puede compilar tanto HLSL como GLSL (OpenGL Shading Language), permitiendo la aceleración y optimización del proceso de creación de materiales para videojuegos. Cuando creamos un script de extensión “.shader”, nuestro código compila dentro de un campo llamado CGPROGRAM. Unity actualmente está trabajando en dar mayor soporte y compatibilidad entre Cg y HLSL. Por lo tanto, es muy probable que en un futuro cercano estos bloques sean reemplazados por HLSLPROGRAM y ENDHLSL, ya que HLSL es oficialmente el lenguaje de programación de shader en versiones actuales del software (versión 2019 en adelante). La mayoría de los shaders en Unity (exceptuando Shader Graph, Compute y Ray Tracing) están escritos dentro de un lenguaje declarativo llamado ShaderLab. Su sintaxis permite mostrar las propiedades de un shader directamente en el inspector del software, concediendo la manipulación de variables y vectores en tiempo real, facilitando el resultado que deseamos obtener. En ShaderLab podemos definir manualmente varias propiedades y comandos. Entre ellas se encuentra el bloque Fallback, el cual es compatible en los distintos tipos de Render Pipeline existentes. Fallback es un bloque de código fundamental en juegos multiplataforma. Permite compilar un shader distinto de aquel que ha generado un error, ¿Qué quiere decir esto? Básicamente, si el shader se rompe en su proceso de compilación, el Fallback retorna un shader distinto, y así, el hardware gráfico puede continuar su trabajo. Otro bloque que podemos encontrar en ShaderLab corresponde al SubShader, el cual permite declarar comandos y generar pases. Un shader puede contener más de un SubShader y más de un pase cuando es escrito en Cg/HLSL. Sin embargo, esta regla no se cumple en el caso de Scriptable RP, ya que puede contener sólo un pase por SubShader. 38 2.0.3. Tipos de shader. Para iniciar nuestro trabajo en la creación de shader primero debemos crear un nuevo proyecto en Unity. Si estás utilizando Unity Hub se recomienda originar un proyecto en cualquiera de las versiones más recientes del software, es decir 2019, 2020 o 2021. Es fundamental que nuestro proyecto sea un template 3D con Built-in RP para facilitar el entendimiento sobre el lenguaje de programación de gráficos. Una vez que el proyecto ha sido producido, debemos ir a nuestro Project Window (ctrl + 5 o cmd + 5), presionamos clic derecho, vamos a Create y seleccionamos la opción Shader. Tipos de shader ⚫ ⚫ ⚫ (Fig. 2.0.3a. Siguiendo la ruta Assets / Create / Shader, podemos lograr el mismo resultado) Como podemos ver, existen múltiples shaders, entre ellos podemos destacar: › Standard Surface Shader. › Unlit Shader. › Image Effect Shader. › Compute Shader. › Ray Tracing Shader. Es probable que la lista varíe según la versión de Unity que estamos utilizando. Asimismo, Shader Graph podría afectar la cantidad de shaders que aparecen en la lista si el proyecto se ha generado en Universal RP o High Definition RP. Por ahora no entraremos en detalles referidos a esta materia, ya que debemos conocer algunos conceptos previos a la creación de nuestro primer shader. Simplemente, nos limitaremos a trabajar con aquellos que vienen por defecto en Built-in RP. 39 2.0.4. Standard Surface Shader. Este tipo se caracteriza por su optimización en la escritura de código que interactúa con un modelo básico de iluminación, el cual funciona únicamente en Built-in RP. Si deseamos crear un shader que interactúe con la luz, tenemos dos opciones: 1 Utilizar un Unlit Shader y agregar funciones matemáticas que permitan el renderizado de iluminación en el material. 2 O bien, utilizar un Standard Surface Shader el cual posee un modelo básico de iluminación que incluye albedo, emisión y especularidad en algunos casos. 2.0.5. Unlit Shader. La palabra “Lit” hace referencia a un material que es afectado por la iluminación. “Unlit” es todo lo contrario. Unlit Shader se refiere a modelo básico de color y será la estructura base que usaremos generalmente para crear nuestros efectos. Este tipo de programa; ideal para hardwares de gama baja, no posee optimización en su código, por ende, podemos ver su estructura completa, facilitando su modificación según nuestras necesidades. Funciona tanto en Built-in como en Scriptable RP. 2.0.6. Image Effect Shader. Estructuralmente, es muy similar a un shader de tipo Unlit Shader. Sin embargo, los Image Effects son empleados principalmente en efectos de Post-Processing para Built-in RP, por ende requieren de la función OnRenderImage para su funcionamiento. Image Effect Shader ⚫ ⚫ ⚫ public Material mat; void OnRenderImage(RenderTexture src, RenderTexture dest) { Graphics.Blit(src, dest, mat); } 40 2.0.7. Compute Shader. Este tipo de programa se caracteriza por correr en la tarjeta gráfica, fuera del Render Pipeline normal, por tanto, estructuralmente es muy distinto a los shaders mencionados anteriormente. A diferencia de un shader común, su extensión es .compute y su lenguaje de programación es HLSL. Los Compute Shaders se utilizan en casos específicos para acelerar parte del procesamiento del juego. Más adelante, en el tercer capítulo, sección 10.0.2, profundizaremos más sobre este tipo de shader. 2.0.8. Ray Tracing Shader. Ray Tracing Shader es un tipo de programa experimental con extensión .raytrace. Permite el procesamiento de Ray Tracing en la GPU. Funciona únicamente en High Definition RP y posee algunas limitaciones técnicas. Si deseamos trabajar con DXR (DirectX Ray Tracing) debemos poseer al menos una tarjeta gráfica GTX 1080 o equivalente con soporte RTX, Windows 10 versión 1809+ y Unity 2019.3b1 en adelante. Este tipo de programa puede ser usado para reemplazar al shader de tipo .compute en el procesamiento de funciones definidas para ray-casting, p. ej., iluminación global, reflexiones, refracción o cáusticas. 41 Propiedades, comandos y funciones. 3.0.1. Estructura de un Vertex-Fragment Shader. Iniciaremos creando un shader de tipo Unlit Shader al cual llamaremos USB_simple_color en nuestro proyecto. Lo utilizaremos para analizar su estructura general, dejando de lado la inclusión de funciones personalizadas de momento. Tal proceso podemos llevarlo a cabo con facilidad siguiendo la explicación detallada en la sección 2.0.3, cuando hablamos sobre “tipos de shader”. Como ya sabemos, este tipo de shader es un modelo básico de color y no posee gran optimización en su código. Esto nos va a permitir analizar en profundidad las distintas propiedades y funciones. Cuando generamos un shader por primera vez, Unity agrega código por defecto para facilitar el proceso de compilación del mismo. Dentro del programa podemos encontrar bloques de código estructurados de tal manera que la GPU pueda interpretarlos. Si abrimos nuestro shader previamente creado, su estructura debería lucir de la siguiente manera: Estructura de un Vertex-Fragment Shader ⚫ ⚫ ⚫ Shader "Unlit/USB_simple_color" { Properties { _MainTex ("Texture", 2D) = "white" {} } SubShader { Tags {"RenderType"="Opaque"} LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag Continúa en la siguiente página. 42 // make fog work #pragma multi_compile_fog #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS(1) float4 vertex : SV_POSITION; }; sampler 2D _MainTex; float4 _MainTex; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o, o.vertex); return o; } fixed4 frag (v2f i) : SV_Target { // sample the texture fixed4 col = tex2D(_MainTex, i.uv); // apply fog UNITY_APPLY_FOG(i.fogCoord, col); return col; } Continúa en la siguiente página. 43 ENDCG } } } Probablemente, será difícil interpretar las distintas líneas de código asociadas al shader que acabamos de crear. No obstante, para comenzar nuestro estudio, pondremos atención a su estructura principal. Estructura de un Vertex-Fragment Shader ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { // propiedades en este bloque } SubShader { // configuración de SubShader en este bloque Pass { CGPROGRAM // programa Cg - HLSL en este bloque ENDCG } } Fallback "ExampleOtherShader" } (Tanto en Cg como en HLSL la estructura del shader es la misma, únicamente cambian los bloques de programa en Cg y HLSL. Ambos compilan en versiones actuales de Unity por compatibilidad) El shader inicia con el InspectorPath, el cual hace referencia al Inspector en la interfaz de Unity; donde seleccionamos nuestro shader para aplicarlo a un material, y un nombre (ShaderName) para identificarlo. Luego, continúan las propiedades (p. ej. texturas, vectores, colores, etc.), el SubShader y al final de todo está el Fallback, que es opcional. 44 Debemos recordar que no podemos aplicar un shader directamente a un objeto en nuestra escena, en cambio, tendremos que llevar a cabo el proceso a través de un material previamente creado. Nuestro shader USB_simple_color posee la ruta “Unlit” por defecto, es decir que desde la interfaz de Unity tendremos que realizar la siguiente acción: 1 Seleccionamos nuestro material. 2 Vamos al Inspector. 3 Buscamos la ruta “Unlit”. 4 y aplicamos el shader USB_simple_color al material. Un factor estructural que debemos considerar es que la GPU leerá el programa desde arriba hacia abajo, de manera lineal. Esto tiene directa relación con las funciones que deseemos incluir en el shader, ya que, si posicionamos tal función por debajo del bloque de código en donde será utilizada, la GPU no podrá leerla, generando así un error en el procesamiento del mismo. Si así fuera el caso, el Fallback asignará un shader distinto para que el hardware gráfico pueda seguir su proceso. Haremos el siguiente ejercicio para entender este concepto. Estructura de un Vertex-Fragment Shader ⚫ ⚫ ⚫ // 1 . declaramos nuestra función float4 ourFunction() { // tu operación aquí … } // 2. utilizamos nuestra función fixed4 frag (v2f i) : SV_Target { // acá se está utilizando la función float4 f = ourFunction(); return f; } Es posible que la sintaxis de las funciones anteriores no se entienda del todo. Estas han sido creadas sólo para conceptualizar la posición de una función respecto a otra. 45 En la sección 4.0.4 hablaremos en detalle sobre la estructura de una función. Por ahora, lo único importante es que en el ejemplo anterior su estructura está correcta, porque la función llamada ourFunction ha sido escrita por sobre el bloque de código en donde es empleada. La GPU primero leerá la función ourFunction y luego continuará con la etapa de fragmento llamada frag. Veamos un caso distinto del anterior. Estructura de un Vertex-Fragment Shader ⚫ ⚫ ⚫ // 2. utilizamos nuestra función fixed4 frag (v2f i) : SV_Target { // acá se está utilizando la función float4 f = ourFunction(); return f; } // 1 . declaramos nuestra función float4 ourFunction() { // tu operación aquí … } Por el contrario, esta estructura va a generar un error debido a que la misma función ha sido escrita por debajo del bloque de código en donde se está usando. 3.0.2. ShaderLab Shader. Gran parte de nuestros shaders Cg o HLSL iniciarán con la declaración del Shader, luego la ruta del mismo en el Inspector y finalmente el nombre que le asignaremos. Tanto las propiedades como el SubShader y el Fallback estarán escritos dentro del campo del Shader, en lenguaje declarativo ShaderLab. 46 ShaderLab Shader ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { // Escribir código ShaderLab aquí } En vista de que USB_simple_color incluye la ruta Unlit por defecto; si deseamos asignarlo a un material, tendríamos que ir al Inspector de Unity, buscar la ruta Unlit y luego seleccionar el shader. Tanto la ruta como el nombre del mismo se pueden cambiar según las necesidades y organización del proyecto. ShaderLab Shader ⚫ ⚫ ⚫ // valor por defecto Shader "Unlit/USB_simple_color" { // Escribir código ShaderLab aquí } // ruta customizada a USB (Unity Shader Bible) Shader "USB/USB_simple_color" { // Escribir código ShaderLab aquí … } 3.0.3. ShaderLab Properties. Las propiedades corresponden a una lista de parámetros que pueden ser manipulados desde el Inspector. Existen ocho propiedades distintas tanto en sus valores como en su utilidad. Tales propiedades las usaremos en lo que se refiere al shader que deseemos crear y modificar, ya sea de manera análoga o en tiempo de ejecución. 47 La sintaxis para declarar una propiedad es la siguiente: ShaderLab Properties ⚫ ⚫ ⚫ PropertyName ("display name", type) = defaultValue. PropertyName hace referencia al nombre de la propiedad, mientras que display name corresponde al nombre que proyectaremos en el Inspector como tal. Type indica el tipo de propiedad, es decir, color, vector, textura, etc. Y finalmente, como su nombre lo indica, defaultValue es el valor por defecto que asignaremos a la misma. ShaderLab Properties ⚫ ⚫ ⚫ (Fig. 3.0.3a) Si prestamos atención a las propiedades de nuestro shader creado anteriormente, notaremos que existe una propiedad de tipo textura que ha sido declarada dentro del campo. Esto lo podemos corroborar en la siguiente línea de código. ShaderLab Properties ⚫ ⚫ ⚫ Properties { _MainTex ("Texture", 2D) = "white" {} } Un factor a considerar es que cuando declaramos una propiedad, ésta queda abierta dentro del campo de las propiedades. Por lo tanto, debemos evitar el punto y coma ( ; ) al final de la línea de código, de otra manera, la GPU no podrá leer el programa. 48 3.0.4. Propiedades para números y sliders. Estos tipos de propiedades proporcionan valores numéricos a nuestro shader. Para ejemplificar, vamos a suponer que deseamos crear un shader con funciones de iluminación en donde 0.0f sea igual a 0 % de iluminación y 1.0f sea igual a 100 %. Para ello podríamos generar un rango entre ambos valores, y luego configurar el valor mínimo, máximo y valor por defecto. Si deseamos declarar números y sliders en nuestro shader, la sintaxis es la siguiente: Propiedades para números y sliders ⚫ ⚫ ⚫ // name ("display name", Range(min, max)) = defaultValue // name ("display name", Float) = defaultValue // name ("display name", Int) = defaultValue Shader "InspectorPath/ShaderName" { Properties { _Specular ("Specular", Range(0.0, 1.1)) = 0.3 _Factor ("Color Factor", Float) = 0.3 _Cid ("Color id", Int) = 2 } } En el ejemplo anterior declaramos tres propiedades: 1 Una de tipo rango flotante llamada _Specular. 2 Otra de tipo flotante escalar llamada _Factor. 3 Y finalmente una de tipo entero llamada _Cid. 3.0.5. Propiedades para colores y vectores. Continuando con la analogía anterior, vamos a suponer un shader que pueda cambiar de color en tiempo de ejecución. Para ello tendríamos que agregar una propiedad de color en donde podamos modificar los valores RGBA (Red, Green, Blue y Alpha) del mismo. 49 Para declarar colores y vectores en nuestro shader, la sintaxis es la siguiente: Propiedades para colores y vectores ⚫ ⚫ ⚫ // name ("display name", Color) = (R, G, B, A) // name ("display name", Vector) = (0, 0, 0, 1) Shader "InspectorPath/ShaderName" { Properties { _Color ("Tint", Color) = (1, 1, 1, 1) _VPos ("Vertex Position", Vector) = (0, 0, 0, 1) } } En el ejemplo se han declarado dos propiedades: 1 Una de tipo color RGBA llamada _Color. 2 Y otra de tipo vector llamada _VPos que incluye cuatro dimensiones. Podemos deducir con facilidad que el color por defecto de la propiedad _Color es igual a “blanco”, dado que 1.0f es el valor máximo de iluminación para un píxel (1 , 1 , 1 , 1 ), R y 0.0f es el mínimo. G B A 3.0.6. Propiedades para texturas. Si deseamos colocar una textura sobre un objeto cualquiera, p. ej., un personaje, tendríamos que declarar una textura 2D y luego implementarla a través de la función denominada tex2D( S , UV RG RG ), la cual nos pedirá dos parámetros por defecto: 1 La textura de tipo sampler2D. 2 Y las coordenadas UV del objeto. Una propiedad que utilizaremos con frecuencia en nuestros videojuegos son los Cube que en sí se refiere a un Cubemap. Este tipo de textura es bastante útil para generar mapas de reflexión, p. ej., reflejos en la armadura de nuestro personaje o para elementos metálicos en general. 50 Otro tipo de textura que podemos encontrar son aquellas de tipo 3D. Se utilizan con menor frecuencia que las anteriores dado que son volumétricas y poseen una coordenada más para su cálculo espacial. Para declarar texturas en nuestro shader, la sintaxis es la siguiente: Propiedades para texturas ⚫ ⚫ ⚫ // name ("display name", 2D) = "defaultColorTexture" // name ("display name", Cube) = "defaultColorTexture" // name ("display name", 3D) = "defaultColorTexture" Shader "InspectorPath/ShaderName" { Properties { _MainTex ("Texture", 2D) = "white" {} _Reflection ("Reflection", Cube) = "black" {} _3DTexture ("3D Texture", 3D) = "white" {} } } Al momento de declarar una propiedad es de gran importancia considerar que estará escrita en lenguaje declarativo ShaderLab, mientras que nuestro programa estará escrito ya sea en lenguaje Cg o HLSL. Al tratarse de dos lenguajes distintos tendremos que crear variables globales de conexión. Estas variables se declaran de manera global utilizando la palabra uniform. Sin embargo, este paso puede ser omitido dado que el programa, aún así, las reconoce como variables globales. Entonces, para conectar una propiedad en un “.shader”, primero debemos declarar la propiedad en ShaderLab, luego la variable global dentro del pase, empleando el mismo nombre en Cg o HLSL, y finalmente podemos utilizarla. 51 Propiedades para texturas ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { // declaramos las propiedades _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1, 1, 1, 1) } SubShader { Pass { CGPROGRAM … // inicializamos variables globales sampler2D _MainTex; float4 _Color; … half4 frag (v2f i) : SV_Target { // Utilizamos las variables half4 col = tex2D(_MainTex, i.uv); return col * _Color; } ENDCG } } } En el ejemplo anterior se han declarado dos propiedades: › _MainTex de tipo textura de dos dimensiones. › Y _Color para el color. 52 Luego se han creado dos variables de conexión dentro de nuestro CGPROGRAM, estas corresponden a: › sampler2D _MainTex › y float4 _Color. Es fundamental que, tanto las propiedades como las variables de conexión posean el mismo nombre, así el programa podrá reconocer su naturaleza. Más adelante, en la sección 3.2.7 detallaremos el funcionamiento de un sampler2D cuando hablemos sobre tipos de datos. 3.0.7. Material Property Drawer. Otro tipo de propiedades que podemos encontrar en ShaderLab son los drawers. Esta clase permite generar propiedades personalizadas en el Inspector, facilitando así la programación de condicionales en el shader. Por defecto, estas propiedades no vienen incluidas en nuestro shader, en cambio, tendremos que declararlas según nuestras necesidades. A la fecha existen siete drawers distintos, entre los cuales podemos encontrar de tipo: › Toggle. › Enum. › KeywordEnum. › PowerSlider. › IntRange. › Space. › Header. 53 Cada una de ellas cuenta con una función específica y se declara de manera independiente. Dada su particularidad, podemos generar múltiples estados dentro de nuestro programa, permitiendo la creación de efectos dinámicos sin la necesidad de cambiar materiales en tiempo de ejecución. Generalmente, utilizaremos estos drawers en conjunto con dos tipos de Shader Variant, estos se refieren a: 1 #pragma multi_compile 2 y #pragma shader_feature. Material Property Drawer ⚫ ⚫ ⚫ (Fig. 3.0.7a) 3.0.8. MPD Toggle. ShaderLab no posee soporte para propiedades de tipo booleana, en cambio, podemos encontrar el Drawer Toggle que cumple la misma función. Este drawer permite cambiar de un estado a otro empleando una condición dentro de nuestro shader. Para ejecutarlo, primero debemos agregar la palabra Toggle entre corchetes y luego declarar nuestra propiedad. Su valor por defecto debe ser de tipo entero, es decir, cero o uno, ¿Por qué razón? Porque cero simboliza apagado, y uno, simboliza encendido. 54 Su sintaxis es la siguiente: MDP Toggle ⚫ ⚫ ⚫ [Toggle] _PropertyName ("Display Name", Float) = 0 Un punto a considerar al momento de trabajar con este drawer es que, si deseamos implementarlo, tendremos que utilizar el #pragma shader_feature. Este pertenece a los Shader Variants y su funcionalidad es generar distintas condiciones según el estado en el que se encuentre (habilitado o deshabilitado). Para entender su implementación realizaremos la siguiente operación: MDP Toggle ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { _Color ("Color", Color) = (1, 1, 1, 1) // Declaramos drawer Toggle [Toggle] _Enable ("Enable ?", Float) = 0 } SubShader { Pass { CGPROGRAM … // declaramos pragma #pragma shader_feature _ENABLE_ON … float4 _Color; … half4 frag (v2f i) : SV_Target { Continúa en la siguiente página. 55 half4 col = tex2D(_MainTex, i.uv); // generamos condición #if _ENABLE_ON return col; #else return col * _Color; #endif } ENDCG } } } En el ejemplo anterior, declaramos una propiedad tipo Toggle llamada _Enable. Luego agregamos el Shader Variant (shader_feature) que se encuentra en el CGPROGRAM. Sin embargo, a diferencia de la propiedad en nuestro programa, el Toggle se ha declarado como _ENABLE_ON, ¿A qué se debe esto? Los Shader Variants son constantes, por ende se escriben en su totalidad con MAYÚSCULAS. En un caso distinto, si a nuestra propiedad la hubiésemos nombrado “_Change”, entonces debería ser agregada como “_CHANGE” en el Shader Variant. Siguiendo con la explicación, su continuación _ON corresponde al estado por defecto del Drawer, es decir, si la propiedad _Enable está activa, retornaremos el color de la textura como podemos apreciar en el Fragment Shader Stage, de otra manera, incluiremos la multiplicación de _Color a la misma. Cabe mencionar que el pragma shader_feature no posee la capacidad de compilar múltiples variantes para una aplicación, ¿Qué quiere decir esto? Unity no incluirá en el build final aquellas variantes que no estemos utilizando. En consecuencia, no podremos pasar de un estado a otro en tiempo de ejecución. Para ello será necesario utilizar el Drawer KeywordEnum que cuenta con el Shader Variant pragma multi_compile. 56 3.0.9. MPD KeywordEnum. A diferencia de un Toggle, este drawer permite configurar hasta nueve estados distintos, generando un menú estilo emergente en el Inspector. Para ejecutarlo debemos agregar la palabra KeywordEnum entre corchetes y luego enumerar el conjunto de estados que vamos a emplear. Su sintaxis es la siguiente: MDP KeywordEnum ⚫ ⚫ ⚫ [KeywordEnum(StateOff, State01, etc...)] _PropertyName ("Display name", Float) = 0 En la declaración de este drawer, podemos utilizar tanto el Shader Variant shader_feature como multi_compile. La elección va a depender de la cantidad de variantes que deseamos incluir en el build final. Como ya sabemos, shader_feature únicamente exportará la variante seleccionada desde el Inspector del material, en cambio, multi_compile exportará todas las variantes que se encuentran en el shader, independientemente si las usamos o no. Dada esta característica, multi_compile es ideal para exportar o compilar múltiples estados que cambiarán en tiempo de ejecución (p. ej. estado de estrella en Super Mario). Para entender su implementación realizaremos la siguiente operación: MDP KeywordEnum ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { // declaramos drawer Toggle [KeywordEnum(Off, Red, Blue)] _Options ("Color Options", Float) = 0 } Continúa en la siguiente página. 57 SubShader { Pass { CGPROGRAM … // declaramos pragma y condiciones #pragma multi_compile _OPTIONS_OFF _OPTIONS_RED _OPTIONS_BLUE … half4 frag (v2f i) : SV_Target { half4 col = tex2D(_MainTex, i.uv); // generamos condiciones #if _OPTIONS_OFF return col; #elif _OPTIONS_RED return col * float4(1, 0, 0, 1); #elif _OPTIONS_BLUE return col * float4(0, 0, 1, 1); #endif } ENDCG } } } En el ejemplo anterior, declaramos una propiedad tipo KeywordEnum llamada _Options y configuramos tres estados para ella (Off, Red y Blue). Luego agregamos el multi_compile que se encuentra en el CGPROGRAM, e inicializamos sus tres estados como constantes. MDP KeywordEnum ⚫ ⚫ ⚫ #pragma multi_compile _OPTIONS_OFF _OPTIONS_RED _OPTIONS_BLUE Finalmente, utilizando las condicionales, definimos los tres estados para nuestro shader que en sí corresponden a cambios de color para la textura principal. 58 3.1.0. MPD Enum. Este drawer posee algunas similitudes al KeywordEnum. Su diferencia radica en sus argumentos, dado que podemos definir más de un valor/id, y pasar tales propiedades a un comando en nuestro shader. De esta manera, podemos recorrer sus valores de manera dinámica desde el Inspector. Su sintaxis es la siguiente: MDP Enum ⚫ ⚫ ⚫ [Enum(valor, id_00, valor, id_01, etc … )] _PropertyName ("Display Name", Float) = 0 Los Enum no usan Shader Variant, por ende son declarados sobre un comando o función. Para entender su implementación realizaremos la siguiente operación: MPD Enum ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { // Declaramos drawer [Enum(Off, 0, Front, 1, Back, 2)] _Face ("Face Culling", Float) = 0 } SubShader { // Pasamos la propiedad al comando Cull [_Face] Pass { … } } } 59 Como se presenta en el ejemplo, se ha declarado una propiedad tipo Enum llamada _Face, y como argumento se han utilizado los valores/id: › Off, 0. › Front, 1. › Back, 2. Luego, se ha agregado esta propiedad al comando Cull que se encuentra en el SubShader, de esta manera podremos recorrer sus valores desde el Inspector y así modificar la cara que deseamos renderizar de nuestro objeto. Más adelante, en la sección 3.2.1 detallaremos el funcionamiento del comando Cull. 3.1.1. MPD PowerSlider e IntRange. Estos drawers son bastante útiles al momento de trabajar con rangos numéricos y precisión. Por una parte, tenemos al PowerSlider, el cual nos permite generar un slider no lineal con control de curva. Su sintaxis es la siguiente: MPD PowerSlider e IntRange ⚫ ⚫ ⚫ [PowerSlider(3.0)] _PropertyName ("Display name", Range (0.01, 1)) = 0.08 Por otra parte, tenemos al IntRange el cual, como su nombre lo menciona, agrega un rango numérico de valores enteros. Su sintaxis es la siguiente: MPD PowerSlider e IntRange ⚫ ⚫ ⚫ [IntRange] _PropertyName ("Display name", Range (0, 255)) = 100 Cabe mencionar que, si deseamos utilizar estas propiedades dentro de nuestro shader, habrá que declararlas dentro del CGPROGRAM de igual manera que una propiedad convencional. 60 Realizaremos la siguiente operación para entender su implementación: MPD PowerSlider e IntRange ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { // declaramos drawer [PowerSlider(3.0)] _Brightness ("Brightness", Range (0.01, 1)) = 0.08 [IntRange] _Samples ("Samples", Range (0, 255)) = 100 } SubShader { Pass { CGPROGRAM … // generamos variables de conexión float _Brightness; int _Samples; … ENDCG } } } En el ejemplo anterior: › Declaramos un PowerSlider llamado _Brightness. › Y un IntRange llamado _Samples. Finalmente, utilizando los mismos nombres, generamos nuestras variables globales dentro del CGPROGRAM. 61 3.1.2. MPD Space y Header. Estos drawers son bastante útiles en la organización de las propiedades que deseamos proyectar en el Inspector. Space proporciona un espacio determinado entre dos propiedades, mientras que Header se refiere a un encabezado. A continuación, agregaremos diez puntos de espacio entre dos propiedades mediante la implementación del Drawer Space. MPD Space y Header ⚫ ⚫ ⚫ _PropertyName01 ("Display name", Float ) = 0 // Agregamos espacio [Space(10)] _PropertyName02 ("Display name", Float ) = 0 Continuando con la misma analogía, agregaremos un encabezado utilizando el Drawer Header. MPD Space y Header ⚫ ⚫ ⚫ // Agregamos el encabezado [Header(Category name)] _PropertyName01 ("Display name", Float ) = 0 _PropertyName02 ("Display name", Float ) = 0 El Header es bastante útil al momento de generar categorías. En el ejemplo se ha añadido un pequeño encabezado antes de iniciar nuestras propiedades, el cual será visible únicamente desde el Inspector. 62 A continuación pondremos en práctica un caso real de implementación para entender ambos drawers. MPD Space y Header ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { [Header(Specular properties)] _Specularity ("Specularity", Range (0.01, 1)) = 0.08 _Brightness ("Brightness", Range (0.01, 1)) = 0.08 _SpecularColor ("Specular Color", Color) = (1, 1, 1 , 1) [Space(20)] [Header(Texture properties)] _MainTex ("Texture", 2D) = "white" {} } SubShader { … } } 3.1.3. ShaderLab SubShader. El segundo componente de un shader es el SubShader. Cada shader se compone de al menos un SubShader para la perfecta carga del programa. En caso de que exista más de uno, Unity tomará el más adecuado según las características de hardware, iniciando en el primero y finalizando en el último en la lista. Para entender el concepto, supondremos el siguiente caso: Un shader se ejecutará en un hardware que incluye Metal Graph API (iOS). Para esto, el programa tomará el primer SubShader que soporte la aplicación y lo ejecutará. En caso de que el SubShader no sea soportado, Unity intentará usar el componente Fallback mencionado en la sección 3.0.1 para que el hardware pueda continuar su tarea sin errores gráficos. 63 ShaderLab SubShader ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { // configuración del shader aquí } } Si prestamos atención a nuestro shader USB_simple_color, el SubShader aparecerá de la siguiente manera en sus valores por defecto: ShaderLab SubShader ⚫ ⚫ ⚫ Shader "USB/USB_simple_color" { Properties { … } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { … } } } 64 3.1.4. SubShader Tags. Los Tags son etiquetas que permiten indicar cómo y cuándo serán procesados nuestros shaders. Al igual que un Tag de un GameObject, estos pueden ser utilizados para reconocer la manera en la que un shader será renderizado o bien qué comportamiento gráfico tendrá un grupo de ellos. La sintaxis para todos los Tags es la siguiente: SubShader Tags ⚫ ⚫ ⚫ Tags { "TagName1"="TagValue1" "TagName2"="TagValue2" } Estos pueden ser escritos en dos campos distintos, ya sea dentro del SubShader o dentro del pase. Todo esto va a depender del resultado que deseamos obtener, p. ej., si escribimos un Tag dentro del SubShader, este afectará a todos los pases que se encuentren incluidos en el mismo, en cambio, si lo escribimos dentro del pase, únicamente afectará al pase seleccionado. Un Tag que emplearemos con frecuencia corresponde a Queue, el cual nos permite definir el aspecto de la superficie de nuestro objeto. Por defecto, todo volumen es definido como Geometry, es decir, que no posee transparencia. Si prestamos atención a nuestro shader USB_simple_color, nos encontraremos con la siguiente línea de código dentro del SubShader, el cual define una superficie opaca para el tipo de render. SubShader Tags ⚫ ⚫ ⚫ SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } LOD 100 Pass { … } } 65 3.1.5. Tag Queue. Por defecto, este Tag no aparece gráficamente como línea de código en el shader debido a que es compilado de manera automática en la GPU. Su función está directamente relacionada con el orden de procesamiento de objetos en cada material. Tag Queue ⚫ ⚫ ⚫ Tags { "Queue"="Geometry" } Este Tag guarda una estrecha relación entre la cámara y la GPU. Cada vez que posicionamos un objeto en nuestra escena, enviamos su información (vértices, normales, color, etc.) hacia las unidades de cómputo. En el caso del Game View es exactamente lo mismo, con la diferencia que la información enviada corresponde al objeto posicionado dentro del frustum de la cámara. Una vez que dicha información se encuentra localizada en la GPU, enviamos la data a la VRAM (Video Random Access Memory), también conocido como Frame Buffer, el cual se encargará de dibujar el objeto en la pantalla del ordenador. El proceso de dibujar un objeto se denomina Draw Call. Mientras más pases tenga un shader, más Draw Calls habrán en el procesamiento de la imagen. Un pase es equivalente a un Draw Call, por lo tanto, un shader multipase reflejará la misma cantidad de Draw Calls según cuantos pases posea. Ahora bien, ¿Cómo la GPU realiza tal proceso? Para ello debemos comprender el funcionamiento del algoritmo de pintado. Tal proceso toma como referencia el orden de los objetos en la escena, iniciando desde el más lejano a la cámara en su eje Z , y finalizando en el más cercano a la AX misma. Finalmente, los elementos son dibujados en pantalla siguiendo el mismo orden. Cabe destacar que Unity renderiza los objetos opacos en modo batch-optimized sorting, el cual minimiza los estados de transición, es decir, ejecución del shader, Vertex Buffer, texturas, cambios de Buffer, y más. En este caso, el Z-Buffer garantiza que el orden fuera de la profundidad (outof-depth-order) dibuje los objetos de manera apropiada, desde adelante hacia atrás, similar al comportamiento del algoritmo de pintado revertido, mientras que para objetos transparentes, se utiliza el algoritmo de pintado convencional. 66 Tag Queue ⚫ ⚫ ⚫ (Fig. 3.1.5a. Considerando ambos objetos transparentes, el triángulo se dibuja primero en la pantalla debido a que se encuentra más lejos de la cámara. Al final se dibuja el cuadrado y ambos generan dos Draw Calls) Cada material en Unity posee una cola de procesamiento llamada Render Queue la cual nos permite modificar el orden de procesamiento de los objetos en la GPU. Existen dos maneras de modificar el Render Queue: 1 A través de las propiedades del material en el inspector. 2 O mediante el Tag Queue. Si modificamos el valor de Queue en un shader, por defecto, el Render Queue del material al cual se le aplica el programa también será modificado. Queue cuenta con rangos numéricos que van desde 0 al 5000, en donde “cero” corresponde al elemento más lejano y “cinco mil” al más cercano a la cámara. Estos valores de orden cuentan con grupos predefinidos de los cuales podemos destacar: › Background. › Geometry. › AlphaTest. › Transparent. › Overlay. Tags { “Queue”=”Background” } desde 0 a 1499, valor por defecto 1000. Tags { “Queue”=”Geometry” } desde 1500 a 2399, valor por defecto 2000. Tags { “Queue”=”AlphaTest” } desde 2400 a 2699, valor por defecto 2450. Tags { “Queue”=”Transparent” } desde 2700 a 3599, valor por defecto 3000. Tags { “Queue”=”Overlay” } desde 3600 a 5000, valor por defecto 4000. 67 Background es utilizado principalmente para elementos que se encuentren muy lejos de la cámara (p. ej. skybox). Geometry es el valor por defecto en el Queue y es usado para los objetos opacos en la escena (p. ej. primitivas y objetos en general). AlphaTest es empleado en objetos semitransparentes que deben estar por delante de un objeto opaco, pero detrás de un objeto transparente (p. ej. un cristal, pasto o vegetación). Transparent es utilizado en elementos transparentes que deben estar por sobre otros. Finalmente, Overlay corresponde a aquellos elementos que están por sobre todo en la escena (p. ej. imágenes de UI). Tag Queue ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "Queue"="Geometry" } } } High Definition RP utiliza el Render Queue de una manera distinta a Built-in RP, ya que los materiales no exponen directamente esta propiedad en el Inspector, este, en cambio, introduce dos métodos de control los cuales son: 1 Orden por material. 2 Y orden por Renderer. En su conjunto, HDRP utiliza estos dos métodos de orden para el control del procesamiento de objetos. 68 3.1.6. Tag Render Type. Utilice el tag RenderType para sobreescribir el comportamiento de un shader. “ “ De acuerdo a la documentación oficial en Unity, ¿Qué quiere decir el enunciado anterior? Básicamente, con este Tag podemos pasar de un estado a otro en el SubShader, agregando un efecto sobre todo material que coincida en una configuración (Type) determinada. Para llevar a cabo su función necesitamos al menos dos shaders: 1 Uno de reemplazo (color o efecto que deseamos agregar en tiempo de ejecución) 2 Y otro a ser reemplazado (shader asignado al material) Su sintaxis es la siguiente: Tag Render Type ⚫ ⚫ ⚫ Tags { "RenderType"="type" } Al igual que el Tags Queue; RenderType posee distintos valores configurables que varían según la tarea que se está llevando a cabo. Entre ellos podemos encontrar. › Opaque. Default. › Transparent. › TransparentCutout. › Background. › Overlay. › TreeOpaque. › TreeTransparentCutout. › TreeBillboard. › Grass. › GrassBillboard. 69 Por defecto, el tipo Opaque se establece cada vez que creamos un nuevo shader. Así mismo, la mayoría de los Built-in shaders en Unity vienen asignados con este valor dado que no poseen configuración para transparencias. No obstante podemos cambiar libremente esta categoría; todo dependerá del efecto que deseamos aplicar sobre una coincidencia. Para entender el concepto a fondo haremos lo siguiente. En nuestro proyecto, 1 Nos aseguraremos de agregar algunos objetos 3D en la escena. 2 Generaremos un script C# al cual llamaremos USBReplacementController. 3 Luego originaremos un shader al cual llamaremos USB_replacement_shader. 4 Finalmente, agregaremos un material al cual llamaremos USB_replaced_mat. Utilizando Camera.SetReplacementShader, asignaremos un shader sobre el material USB_replaced_mat de manera dinámica. Para llevar a cabo la función, el shader del material deberá poseer un Tag RenderType igual al shader de reemplazo. Para ejemplificar, asignaremos el shader Mobile/Unlit a USB_replaced_mat. Este Built-in shader posee un Tag de tipo RenderType igual a Opaque, por consiguiente el shader USB_replacement_shader deberá coincidir el mismo RenderType para que la operación se lleve a cabo. Tag Render Type ⚫ ⚫ ⚫ (Fig. 3.1.6a. Se ha asignado el shader Unlit (Supports Lightmap) sobre el material USB_replaced_mat ) El script USBReplacementController debe ser asignado directamente a la cámara como un componente. Este controlador estará a cargo de reemplazar un shader por otro de la misma naturaleza, siempre y cuando posean la misma configuración en el RenderType. 70 Tag Render Type ⚫ ⚫ ⚫ using System.Collection; using System.Collections.Generic; using UnityEngine; [ExecuteInEditMode] public class USBReplacementController : MonoBehaviour { // shader de reemplazo public Shader m_replacementShader; private void OnEnable() { if(m_replacementShader != null) { // la cámara va a reemplazar todos los shaders en la // escena por aquel de reemplazo la configuración // del render type debe coincidir en ambos shaders GetComponent<Camera>().SetReplacementShader( m_replacementShader, "RenderType"); } } private void OnDisable() { // reseteamos al shader asignado GetComponent<Camera>().ResetReplacementShader(); } } Cabe mencionar que se ha definido la función [ExecuteInEditMode] sobre la clase. Esta propiedad nos va a permitir previsualizar cambios en modo de edición. Utilizaremos a USB_replacement_shader como shader de reemplazo. 71 Como ya sabemos, cada vez que creamos un nuevo shader, este viene configurado con su RenderType igual a Opaque. En consecuencia USB_replacement_shader podrá reemplazar el shader Unlit que hemos asignado al material previamente. Para previsualizar los cambios de manera evidente, iremos al Fragment Shader Stage de USB_replacement_shader y agregaremos un color rojo, el cual multiplicaremos por el color de salida. Tag Render Type ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // agregamos un color rojo fixed4 red = fixed4(1, 0, 0, 1); return col * red; } Debemos asegurarnos de incluir a USB_replacement_shader en la variable de reemplazo tipo Shader que se encuentra en el script USBReplacementController. Tag Render Type ⚫ ⚫ ⚫ (Fig. 3.1.6b. Se ha asignado el script USBReplacementController a la cámara) Y además, aquellos objetos que agregamos previamente en la escena, deben poseer el material USB_replaced_mat. 72 Tag Render Type ⚫ ⚫ ⚫ (Fig. 3.1.6c. Se ha asignado el material USB_replaced_mat a los objetos 3D; a un Quad, un cubo y una esfera) Dado que la clase USBReplacementController tiene incluida las funciones OnEnable y OnDisable, si activamos o desactivamos el script, podremos ver como el built-in shader Unlit es reemplazado por USB_replacement_shader en modo de edición, aplicando un color rojo en el rendering. Tag Render Type ⚫ ⚫ ⚫ (Fig. 3.1.6d. El built-in shader Unlit ha sido reemplazado por USB_replacement_shader en el rendering) 73 3.1.7. SubShader Blending. Blending es el proceso de mezclar dos píxeles en uno. Su comando es compatible tanto en Builtin RP como en Scriptable RP. El Blending ocurre en una etapa denominada Merging la cual permite combinar el color final de un píxel con el Frame Buffer. Esta etapa, que ocurre al final del Render Pipeline; después del Fragment Shader Stage, donde el Stencil-Buffer, Z-Buffer y Color Blending se ejecutan. Por defecto, la línea de código asociada al comando no viene incluida en el shader, ya que es una función opcional y es utilizada principalmente cuando trabajamos con objetos transparentes, o sea, con píxeles de poca opacidad. Su valor por defecto es Blend Off el cual da como resultado una superficie opaca. Sin embargo, podemos habilitar distintos tipos de mezcla y así generar efectos similares a aquellos que aparecen en Photoshop. Su sintaxis es la siguiente: SubShader Blending ⚫ ⚫ ⚫ Blend [SourceFactor] [DestinationFactor] Blend es un comando que requiere de dos valores llamados factores para su funcionamiento. Según una ecuación simple, será el color final que obtendremos en pantalla. De acuerdo a la documentación oficial en Unity, tal ecuación corresponde a la siguiente operación: B = SrcFactor * SrcValue [OP] DstFactor * DstValue. Para entender su funcionamiento debemos considerar lo siguiente: › Primero se realiza el Fragment Shader Stage. › Y luego, como proceso opcional; el Merging Stage. 74 SrcValue (Source Value), el cual se lleva a cabo en el Fragment Shader Stage, corresponde al output de color RGB que posee un píxel. DstValue (Destination Value) corresponde al color RGB que ha sido escrito en el Destination Buffer, mayormente conocido como Render Target (SV_Target). Cuando las opciones de Blending no están activas en nuestro shader, SrcValue sobrescribe a DstValue. Sin embargo, si activamos esta operación, ambos colores son mezclados para obtener un nuevo color, el cual sobrescribe a DstValue posteriormente. SrcFactor (Source Factor) al igual que DstFactor (Destination Factor) son vectores de tres dimensiones los cuales varían dependiendo de su configuración. Su función principal es modificar los valores del SrcValue y DstValue para lograr efectos interesantes. Algunos factores que podemos encontrar en la documentación de Unity son: › Off, deshabilita las opciones de Blending. › One, (1, 1, 1). › Zero, (0, 0, 0). › SrcColor, es igual a los valores RGB del SrcValue. › SrcAlpha, es igual al valor Alpha del SrcValue. › OneMinusSrcColor, 1 menos los valores RGB del SrcValue (1 - R, 1 - G, 1 - B). › OneMinusSrcAlpha, 1 menos el Alpha del SrcValue (1 - A, 1 - A, 1- A). › DstColor, es igual a los valores RGB del DstValue. › DstAlpha, es igual al valor Alpha del DstValue. › OneMinusDstColor, 1 menos los valores RGB del DstValue (1 - R, 1 - G, 1 - B). › OneMinusDstAlpha, 1 menos el Alpha del DstValue (1 - A, 1 - A, 1- A). Cabe mencionar que el Blending del canal Alpha se lleva a cabo de la misma manera con la que procesamos el color RGB de un píxel, pero se realiza en un proceso independiente debido a que no se utiliza con frecuencia. Así mismo, al no ejecutar este proceso, la escritura en el Render Target es optimizada. Render Target es una característica de las GPU la cual permite renderizar una escena en un buffer de memoria intermedia. Forward Rendering utiliza un Render Target por defecto, mientras que Deferred Shading emplea múltiples de ellos. 75 Tal proceso podemos entenderlo como “múltiples capas” de un fotograma de nuestra composición, almacenadas en buffers intermedios. › Render Target Diffuse. Capa o buffer de difusión. › Render Target Specular. Capa o buffer de especularidad. › Render Target Normals. Capa o buffer de normales. › Render Target Emission. Capa o buffer de emisión. › Render Target Depth. Capa o buffer de profundidad. Ejemplificando, vamos a suponer un píxel RGB con los valores de color 0.5 , 0.45 , 0.35 . Tal R G B tinte ha sido procesado por el Fragment Shader Stage, en consecuencia, corresponde al SrcValue. Continuamos multiplicando el resultado anterior por el SrcFactor One el cual es igual a 1 , 1 , 1 . Todo número multiplicado por “uno” da como resultado su mismo valor, de modo que, R G B el resultado entre el SrcFactor y el SrcValue es igual a su valor inicial. B = [0.5 , 0.45 , 0.35 ] [OP] DstFactor * DstValue. R G B El argumento OP se refiere a la operación que vamos a realizar. Por defecto, está configurada en Add, es decir, suma. B = [0.5 , 0.45 , 0.35 ] + DstFactor * DstValue. R G B Luego de obtener el valor de la primera operación, el DstValue es sobreescrito, por lo tanto, queda configurada con exactamente el mismo color 0.5 , 0.45 , 0.35 . R G B Continuamos multiplicando tal resultado por el DstFactor DstColor, que es igual al valor actual del DstFactor. DstFactor [0.5 , 0.45 , 0.35 ] * DstValue [0.5 , 0.45 , 0.35 ] = [0.25 , 0.20 , 0.12 ]. R G B R Finalmente, el color output para el píxel es. 76 G B R G B B = [0.5 , 0.45 , 0.35 ] + [0.25 , 0.20 , 0.12 ]. R G B B = [0.75 , 0.65 , 0.47 ] R G R G B B Podemos habilitar el Blending en nuestro shader utilizando el comando Blend, seguido del SrcFactor y luego el DstFactor. Su sintaxis es la siguiente: SubShader Blending ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha } } Será necesario agregar y modificar el Render Queue si deseamos utilizar Blending. Como ya sabemos, el valor por defecto del Tag Queue es igual a Geometry, es decir que que nuestro objeto lucirá opaco. Si deseamos que nuestro objeto luzca transparente, primero debemos cambiar el Queue a Transparent y luego agregar algún tipo de blending. Los tipos más comunes son los siguientes: › Blend SrcAlpha OneMinusSrcAlpha Blending transparente general. › Blend One One Blending color aditivo. › Blend OneMinusDstColor One Blending color aditivo suave. › Blend DstColor Zero Blending color multiplicativo. › Blend DstColor SrcColor Blending multiplicativo x2. › Blend SrcColor One Blending overlay. › Blend OneMinusSrcColor One Blending de luz suave. › Blend Zero OneMinusSrcColor Blending color negativo. 77 Una manera distinta de configurar nuestro Blending es mediante la dependencia UnityEngine. Rendering.BlendMode. Esta línea de código permite cambiar el Blending de un objeto desde el Inspector. Podemos configurarla inicializando el Toggle Enum en nuestras propiedades y declarando tanto el SrcFactor como el DstFactor posteriormente en el Blend. Su sintaxis es la siguiente: SubShader Blending ⚫ ⚫ ⚫ [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Source Factor", Float) = 1 [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Destination Factor", Float) = 1 SubShader Blending ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("SrcFactor", Float) = 1 [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("DstFactor", Float) = 1 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend [_SrcBlend] [_DstBlend] } } Las opciones de Blending pueden ser escritas dentro del campo del SubShader o del Pass. La posición va a depender de la cantidad de pases y resultado que deseamos obtener. 78 3.1.8. SubShader AlphaToMask. Existen algunos tipos de Blending que son muy fáciles de controlar, p. ej., Blend SrcAlpha OneMinusSrcAlpha, el cual agrega un efecto transparente con canal Alfa incluido. Sin embargo, hay ocasiones en donde el Blending no es capaz de generar transparencia para nuestro shader. En tal caso se utiliza la propiedad AlphaToMask, la cual aplica una máscara de cobertura sobre el canal Alpha, generando un efecto similar a “píxeles descartados”. A diferencia del Blending, una máscara de cobertura únicamente puede asignar los valores “uno” o “cero” para el canal Alpha, ¿Qué quiere decir esto? Mientras que el Blending tiene la capacidad de generar distintos niveles de transparencia; niveles que van desde el 0.0f hasta 1.0f, AlphaToMask sólo puede generar números enteros, sin decimales. Esto se traduce a un tipo de transparencia más dura, la cual va a funcionar en casos específicos, p. ej., es muy útil para vegetación en general como para crear efectos de portales espaciales. › AlphaToMask On › AlphaToMask Off Valor por defecto. Podemos activar este comando mediante su declaración en el campo del Subshader o directamente en el pase. Cuenta solamente con dos valores: “On y Off”, y se inicializa de la siguiente manera: SubShader AlphaToMask ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "RenderType"="Opaque" } AlphaToMask On } } Cabe destacar que a diferencia del Blending, en este caso no es necesario agregar Tags de transparencia ni otros comandos. Simplemente, agregamos AlphaToMask y automáticamente el cuarto canal de color “A” adquiere las cualidades de máscara de cobertura en nuestro programa. 79 3.1.9. SubShader ColorMask. Compatible tanto en Built-in RP como en Scriptable RP, este comando permite que nuestra GPU se limite a escribir un canal RGBA de color o varios de ellos al momento de representar una imagen. Cuando creamos un shader, por defecto la GPU escribe todos los canales correspondientes al color. No obstante, para algunos casos es posible que deseemos mostrar sólo alguno de ellos (p. ej. canal rojo o “R” de un efecto). › ColorMask R Nuestro objeto se verá de color rojo › ColorMask G Nuestro objeto se verá de color verde › ColorMask B Nuestro objeto se verá de color azul › ColorMask A Nuestro objeto será afectado por la transparencia. › ColorMask RG Podemos utilizar la mezcla de dos canales. Como ya sabemos la sigla RGBA hace referencia a Red, Green, Blue y Alpha, por lo tanto, si configuramos nuestra máscara con el valor “G” sólo mostraremos el canal verde como color output. Este comando de ShaderLab es muy simple de usar y podemos ocuparlo tanto en el SubShader como en el Pass. Su sintaxis es la siguiente: SubShader ColorMask ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "Queue"="Geometry" } ColorMask RGB } } 80 3.2.0. SubShader Culling y Depth Testing. Para poder entender ambos conceptos primero debemos conocer el funcionamiento del Z-Buffer (también conocido como Depth Buffer) y del Depth Testing. Antes de iniciar debemos considerar que los píxeles poseen valores de profundidad. Tales valores son almacenados en el Depth Buffer el cual determina si es que un objeto va en frente o detrás de otro en pantalla. Por otra parte, el Depth Testing es una condicional que determina si es que un píxel será actualizado o no en el Depth Buffer. Como ya sabemos, un píxel posee un valor asignado que se mide en color RGB el cual es almacenado en el Color Buffer. El Z-Buffer agrega un valor extra que mide la profundidad de un píxel en términos de distancia a la cámara sólo para aquellas superficies que se encuentran dentro del frustum de la misma, permitiendo que dos píxeles sean iguales en color, pero distintos en profundidad. Mientras más cercano se encuentre el objeto a la cámara, más bajo será el valor del Z-Buffer y los píxeles con valores de búfer más bajo sobrescriben a los píxeles con valores más altos. Para entender el concepto, supondremos una cámara y algunas primitivas en nuestra escena; todas posicionadas en el eje Z . Ahora bien, ¿Por qué en el eje Z AX medir la distancia entre la cámara y un objeto en el eje Z en X AX eY AX AX ? Tal resultado se obtiene al AX del espacio, mientras que los valores miden el desplazamiento horizontal y vertical en la pantalla. La palabra buffer hace referencia a un espacio de memoria en el que se almacenarán datos de manera temporal, por tanto, Z-Buffer corresponde a aquellos valores de profundidad entre los objetos de nuestra escena y la cámara, los cuales se asignan a cada píxel. Ejemplificaremos este concepto utilizando una pantalla de 36 píxeles totales. Para ello supondremos un Quad transparente de color azul en la escena. Dada su naturaleza, este ocupará desde el píxel 8 al 29, por ende, todos los píxeles entre esta área se encenderán y se pintarán de color azul. Asimismo, tal información será enviada tanto al Z-Buffer como al Color Buffer. 81 SubShader Culling y Depth Testing ⚫ ⚫ ⚫ (Fig. 3.2.0a. En el Z-Buffer se guardará la profundidad del objeto en la escena, y en el Color Buffer se guardará la información de color RGBA) Supondremos un nuevo Quad transparente en la escena, esta vez de color amarillo y nos aseguraremos que su posición sea más cercana a la cámara. Para diferenciarlo del anterior, haremos este Quad más pequeño, ocupando desde el píxel 15 al 29. Como podemos ver en la Figura 3.2.0b, tal área ya ha sido ocupada por la información del Quad inicial (azul), entonces, ¿Qué ocurre en el proceso? Por el hecho de que el Quad amarillo se encuentra a una distancia menor a la cámara, sobrescribe los valores tanto del Z-Buffer como del Color Buffer, generando que los píxeles entre esta área se enciendan; reemplazando al color anterior. 82 SubShader Culling y Depth Testing ⚫ ⚫ ⚫ (Fig. 3.2.0b) Podremos repetir este proceso dependiendo del objeto y su material. Como ya se ha mencionado, el algoritmo de pintado funcionará de manera convencional para materiales transparentes, mientras que para aquellos opacos, el proceso será inverso. Una manera de generar efectos visuales atractivos es a través de la modificación de los valores del Z-Buffer. Para ello hablaremos de tres opciones que están incluidas en Unity: › Cull. › ZWrite. › y ZTest. Al igual que los Tags, las opciones de Culling y Depth Testing pueden ser escritas en campos distintos: dentro del SubShader o del pase. La posición va a depender del resultado que deseamos obtener y a la cantidad de pases con la cual trabajaremos. Para entender este concepto, supondremos un shader para representar la superficie de un diamante. Para ello, vamos a necesitar dos pases: 1 Al primero, lo utilizaremos para el color de fondo del diamante. 2 Y al segundo, para el brillo de la superficie del mismo. 83 En este caso hipotético, dado que necesitamos dos pases que cumplen una función distinta, será necesario configurar las opciones del Culling dentro de cada pase, de manera independiente. 3.2.1. ShaderLab Cull. Esta propiedad, compatible tanto en Built-in RP como en Scriptable RP, controla que caras de un polígono serán removidas en el procesamiento de la profundidad del píxel, ¿Qué quiere decir esto? Recordemos que un objeto poligonal posee caras internas y caras externas. Por defecto, las caras externas son visibles (Cull Back), sin embargo, podemos activar las caras internas siguiendo el esquema que se muestra a continuación. › Cull Off Ambas caras del objeto son renderizadas. › Cull Back Las caras externas del objeto son renderizadas › Cull Front Las caras internas del objeto son renderizadas. Este comando posee tres valores, los cuales son: Back, Front y Off. Por defecto, cada shader ha sido configurado como Back, sin embargo, generalmente la línea de código asociada al Culling no es visible en el programa por motivos de optimización. Si deseamos modificar estas opciones, debemos agregar la palabra Cull seguido del modo que queremos aplicar. ShaderLab Cull ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { // Cull Off // Cull Front Cull Back } } También podemos configurar dinámicamente las opciones de Culling a través del Inspector, mediante la dependencia UnityEngine.Rendering.CullMode la cual se declara desde el Drawer Enum y se pasa como argumento en la función. 84 ShaderLab Cull ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 0 } SubShader { Cull [_Cull] } } Otra opción bastante útil ocurre por medio de la semántica SV_IsFrontFace, la cual nos permite proyectar distintos colores y texturas en ambas caras de un mesh. Para ello, simplemente debemos declarar una variable booleana y asignar tal semántica como argumento en el Fragment Shader Stage. ShaderLab Cull ⚫ ⚫ ⚫ fixed4 frag (v2f i, bool face : SV_IsFrontFace) : SV_Target { fixed4 colFront = tex2D(_FrontTexture, i.uv); fixed4 colBack = tex2D(_BackTexture, i.uv); return face ? colFront : colBack; } Cabe destacar que esta opción funciona únicamente cuando el comando Cull ha sido configurado en Off previamente. 85 3.2.2. ShaderLab ZWrite. Este comando controla la escritura de los píxeles de la superficie de un objeto en el Z-Buffer, o sea, permite ignorar o respetar la distancia de profundidad entre la cámara y un objeto. ZWrite posee únicamente dos valores los cuales son: On y Off, donde el primero corresponde a su valor por defecto. Generalmente, utilizaremos este comando cuando trabajemos con transparencias, p. ej., cuando activemos las opciones de Blending. › ZWrite Off Para transparencias. › ZWrite On Valor por defecto. ¿Por qué razón debemos desactivar el Z-Buffer cuando trabajamos con transparencias? Principalmente por la superposición de píxeles translúcidos (Z-fighting). Cuando trabajamos con objetos semitransparentes, es común que la GPU no sepa qué objeto va delante de otro, produciendo un efecto de superposición entre los píxeles cuando movemos la cámara en la escena. Para solucionar este problema, simplemente debemos desactivar el Z-Buffer haciendo el comando ZWrite igual a Off como muestra el siguiente ejemplo: ShaderLab ZWrite ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off } } El Z-fighting ocurre cuando tenemos dos o más objetos exactamente a la misma distancia respecto a la cámara, causando valores idénticos en el Z-Buffer. 86 ShaderLab ZWrite ⚫ ⚫ ⚫ (Fig. 3.2.2a) El conflicto se produce cuando se intenta procesar un píxel al final del rendering pipeline. Dado que el Z-Buffer no puede determinar qué elemento está detrás del otro, se producen líneas parpadeantes que cambian de forma según la posición de la cámara. 3.2.3. ShaderLab ZTest. ZTest controla el cómo se debe realizar el Depth Testing y es utilizado generalmente en shaders con múltiples pases, cuando deseamos generar diferencia de colores y profundidades. Esta propiedad posee siete valores distintos los cuales corresponden a una operación de comparación. › Less. › Greater. › LEqual. › GEqual. › Equal. › NotEqual. › Always. ZTest Less: (<) Dibuja los objetos por delante. Son descartados aquellos objetos que están a la misma distancia o detrás de aquel que posee el shader. ZTest Greater: (>) Dibuja al objeto que está detrás. No dibuja aquellos objetos que están a la misma distancia o delante de aquel que posee el shader. 87 ZTest LEqual: (≤) Valor por defecto. Dibuja los objetos que están por delante o a la misma distancia. No dibuja aquellos objetos que están por detrás de aquel que posee el shader. ZTest GEqual: (≥) Dibuja el objeto que está detrás o a la misma distancia. No dibuja aquellos objetos que están delante de aquel que posee el shader. ZTest Equal: (==) Dibuja los objetos que están a la misma distancia. No dibuja aquellos objetos que están delante o detrás de aquel que posee el shader. ZTest NotEqual: (!=) Dibuja al objeto que no está a la misma distancia. No dibuja a los objetos que están a la misma distancia de aquel que posee el shader. ZTest Always: Dibuja todos los píxeles independientemente de la distancia de un objeto respecto a la cámara. Para entender este comando, haremos el siguiente ejercicio. Supondremos dos objetos en nuestra escena; un cubo y una esfera. La esfera se encuentra en frente del cubo respecto a la cámara y la profundidad de los píxeles es la esperada. ShaderLab ZTest ⚫ ⚫ ⚫ (Fig. 3.2.3a) Si posicionamos la esfera por detrás del cubo nuevamente los valores de profundidad serán los esperados, ¿Por qué razón? Porque el Z-Buffer está almacenando la información de profundidad para cada píxel en la pantalla. Los valores de profundidad son calculados en relación con la cercanía de un objeto respecto a la cámara. 88 ShaderLab ZTest ⚫ ⚫ ⚫ (Fig. 3.2.3b) Ahora, ¿Qué pasaría si activamos ZTest Always? En este caso, el Depth Testing no sería realizado, por lo tanto, todos los píxeles figurarían con la misma profundidad en pantalla. ShaderLab ZTest ⚫ ⚫ ⚫ (Fig. 3.2.3c) Su sintaxis es la siguiente: ShaderLab ZTest ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" } ZTest LEqual } } 89 3.2.4. ShaderLab Stencil. “ Según la documentación oficial en Unity: El Stencil Buffer almacena un valor entero de ocho bits (0 a 255) para cada píxel en el Frame Buffer. Antes de ejecutar el Fragment Shader stage para un píxel determinado, la GPU puede comparar el valor actual en el Stencil Buffer con un valor de referencia determinado. GPU realiza la prueba de profundidad (Depth Test). Si el Stencil Test falla, la GPU omite el resto del procesamiento de ese píxel. Esto quiere decir que puede usar el Stencil Buffer como una máscara para indicar a la GPU qué píxeles dibujar y qué píxeles descartar. “ Este proceso se denomina Stencil Test. Si el Stencil Test pasa, la Iniciaremos considerando que el Stencil Buffer en sí, es una textura que debe ser creada, y para ello, se almacena un valor entero de 0 a 255 para cada píxel en el Frame Buffer. ShaderLab Stencil ⚫ ⚫ ⚫ (Fig. 3.2.4a) Como ya sabemos, cuando posicionamos objetos en nuestra escena, su información es enviada al Vertex Shader Stage (p. ej. posición de los vértices). Dentro de esta etapa, los atributos de nuestro objeto son transformados desde Object-Space a World-Space, luego a View-Space y finalmente a Clip-Space. Dicho proceso ocurre solo para aquellos objetos que se encuentran dentro del frustum de la cámara. 90 Cuando la información ha sido procesada correctamente, es enviada al Rasterizador el cual permite proyectar en píxeles las coordenadas de nuestros objetos en la escena. Sin embargo, antes de llegar a este punto, pasa por una etapa de procesamiento anterior llamada Culling y Depth testing. Ahí ocurren varios procesos que podemos manipular dentro de nuestro shader, entre los cuales podemos mencionar: › Cull. › ZWrite. › ZTest. › y Stencil. Básicamente, el funcionamiento del Stencil Buffer es activar el Stencil Test, el cual permite descartar fragmentos (píxeles) para que no sean procesados en el Fragment Shader Stage, generando así un efecto de máscara en nuestro shader. La función que realiza el Stencil Test posee la siguiente sintaxis: ShaderLab Stencil ⚫ ⚫ ⚫ if ( StencilRef & StencilReadMask [Comp] StencilBufferValue & StencilReadMask) { Accept Pixel. } else { Discard Pixel. } StencilRef es el valor que vamos a pasar al Stencil Buffer a modo de referencia, ¿Qué quiere decir esto? Recordemos que el Stencil Buffer es una textura que cubre el área en píxeles del objeto que la posee. El StencilRef funciona como un “id” que se asigna a todos los píxeles que se encuentran en el Stencil Buffer. 91 Para ejemplificar, vamos a hacer el valor del StencilRef igual a 2. ShaderLab Stencil ⚫ ⚫ ⚫ (Fig. 3.2.4b) En el ejemplo anterior, todos los píxeles que cubren el área de la cápsula han sido marcados con el valor del StencilRef, por lo tanto, ahora el Stencil Buffer es igual a dos. Posteriormente, para todos aquellos píxeles que poseen un valor de referencia, se crea una máscara (StencilReadMask) que por defecto posee el valor 255. ShaderLab Stencil ⚫ ⚫ ⚫ (Fig. 3.2.4c) 92 De este modo, la operación anterior queda de la siguiente manera. ShaderLab Stencil ⚫ ⚫ ⚫ if ( 2 & 255 [Comp] 2 & 255) { Accept Pixel. } else { Discard Pixel. } Comp es igual a una función de comparación la cual va a permitir el retorno de un valor verdadero o falso. Si el valor es verdadero el programa escribe el píxel en el Frame Buffer, de otra manera, el píxel es descartado. Dentro de las funciones de comparación, podemos encontrar los siguientes operadores predefinidos: › Comp Never La función siempre va a retornar falso. › Comp Less <. › Comp Equal ==. › Comp LEqual ≤. › Comp Greater >. › Comp NotEqual !=. › Comp GEqual ≥. › Comp Always La operación siempre va a retornar verdadero. Para utilizar el Stencil Buffer vamos a necesitar al menos dos shaders: Uno para la máscara y otro para el objeto enmascarado. A modo de ejemplo, supondremos tres objetos en nuestra escena: › Un Cubo. › Una Esfera. › Y un Quad. 93 La Esfera se encuentra dentro del Cubo, y el Quad queremos utilizarlo como “máscara” para ocultar el Cubo, y así, la esfera en su interior pueda verse. Continuaremos creando un shader al cual llamaremos USB_stencil_ref para entender el concepto. Su sintaxis será la siguiente: ShaderLab Stencil ⚫ ⚫ ⚫ SubShader { Tags { "Queue"="Geometry-1" } ZWrite Off ColorMask 0 Stencil { Ref 2 // StencilRef Comp Always Pass Replace } } ShaderLab Stencil ⚫ ⚫ ⚫ (Fig. 3.2.4d) Analicemos lo anteriormente señalado. Iniciamos configurando el Queue igual a Geometry menos uno. Geometry por defecto es igual a 2000, por lo tanto, Geometry menos 1 es igual a 1999, esto va a permitir que nuestro Quad (máscara) al cual le aplicaremos el shader sea procesado primero en el Z-Buffer. Sin embargo, como ya sabemos, Unity por defecto procesa 94 los objetos según su posición en la escena con respecto a la cámara y algoritmo de pintado, en consecuencia, si deseamos desactivar esta función debemos configurar la propiedad ZWrite en Off. Luego, configuramos ColorMask en cero para que los píxeles de la máscara sean descartados en el Frame Buffer y así luzca transparente. Hasta este punto nuestro Quad aún no funciona como máscara, por lo tanto, debemos agregar el comando Stencil para que funcione como tal. Ref 2 (StencilRef), nuestro valor de referencia es comparado en la GPU con el contenido actual del Stencil Buffer utilizando la operación de comparación definida. Comp Always se asegura de establecer un “dos” en el Stencil Buffer, considerando el área que abarca el Quad en pantalla. Finalmente, Pass Replace especifica que los valores actuales del Stencil Buffer sean reemplazados por los valores del StencilRef. A continuación daremos énfasis sobre el objeto que deseamos enmascarar. Para ello, nuevamente crearemos un shader, pero a este le llamaremos USB_stencil_value. Su sintaxis es la siguiente: ShaderLab Stencil ⚫ ⚫ ⚫ SubShader { Tags { "Queue"="Geometry" } Stencil { Ref 2 Comp NotEqual Pass Keep } … } 95 ShaderLab Stencil ⚫ ⚫ ⚫ (Fig. 3.2.4e) A diferencia de nuestro shader anterior, mantendremos el Z-Buffer activo para que sea renderizado en la GPU según su posición respecto a la cámara. Luego agregamos el comando Stencil para que nuestro objeto pueda ser enmascarado. Agregamos “Ref 2” nuevamente para vincular este shader con USB_stencil_ref. Luego, en la operación de comparación, asignamos Comp NotEqual. Esto va a permitir que el área del Cubo; que será expuesta alrededor del Quad, sea renderizada debido a que el Stencil Test pasa (no hay comparación o es verdadero). En cambio, para el área que cubre el Quad, al ser “igual” (Equal) entonces el Stencil Test no pasará y los píxeles serán descartados. Pass Keep se refiere a que el objeto mantiene el contenido actual del Stencil Buffer. En el caso que deseemos trabajar con más de una máscara, podemos pasar un número distinto de “2” a la propiedad Ref. 96 3.2.5. ShaderLab Pass. En esta oportunidad analizaremos los pases (Pass). Dentro de un shader pueden existir múltiples de ellos. Sin embargo, Unity agrega sólo “uno” dentro del campo del SubShader por defecto. Si prestamos atención a USB_simple_color, creado anteriormente, encontraremos el siguiente pase incluido. ShaderLab Pass ⚫ ⚫ ⚫ Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag // make fog work #pragma multi_compile_fog #include "UnityCG.cginc" struct appdata { … }; struct v2f { … }; sampler2D _MainTex; float4 _MainTex; v2f vert (appdata v) { … } fixed4 frag (v2f i) : SV_Target { … } } Un pase se refiere literalmente a un Render Pass. Para aquellos que han trabajado con rendering en software 3D, p. ej., Maya o Blender, este concepto les será más fácil de entender, ya que cuando se está procesando una composición, se pueden generar distintas capas o pases por separado. Cada Pass renderiza un objeto a la vez, es decir, si tenemos dos pases en nuestro shader, el objeto será renderizado dos veces en la GPU y el equivalente a eso serían dos Draw Calls. 97 Por esta razón debemos utilizar la menor cantidad de pases posibles, de otra manera se podría generar una carga gráfica significativa. ShaderLab Pass ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "RenderType"="Opaque" } Pass { // primer pase por defecto } Pass { // segundo pase adicional } } } 3.2.6. CGPROGRAM / ENDCG. Todas las secciones que revisamos anteriormente están escritas en el lenguaje declarativo ShaderLab. Nuestro desafío real en lenguaje de programación de gráficos comienza aquí con la declaración de CGPROGRAM o HLSLPROGRAM. Por defecto, podemos apreciar que Unity ha incluido la palabra CG a nuestro programa. Con “ respecto a esto, la documentación oficial (versión 2021.2) menciona: este lenguaje, sin embargo, las palabras aún siguen incluidas y el código aún sigue compilando por temas de compatibilidad. 98 “ Unity utilizó originalmente el lenguaje Cg, de ahí el nombre de algunas palabras y extensiones .cginc, pero el software ya no utiliza Como se mencionó anteriormente, es probable que en versiones futuras nuestro shader aparezca con la declaración de HLSLPROGRAM en vez de CGPROGRAM, ya que HLSL es actualmente el lenguaje de programación de gráficos oficial (de hecho Shader Graph está basado en el). De todas maneras podemos actualizar nuestro shader simplemente reemplazando la palabra CGPROGRAM por HLSLPROGRAM, y ENDCG por ENDHLSL, logrando así que nuestro programa compile tanto en Built-in RP como en Universal y High Definition RP. Si deseamos llevar a cabo la actualización del programa, tendremos que modificar su configuración y además incluir algunas rutas a nuestro shader, lo que hará más complejo nuestro entendimiento sobre la materia. CGPROGRAM / ENDCG ⚫ ⚫ ⚫ // versión CG Pass { CGPROGRAM … ENDCG } // versión HLSL Pass { HLSLPROGRAM … ENDHLSL } Todas las funciones necesarias para que nuestro shader compile serán escritas dentro del campo de CGPROGRAM y ENDCG. Aquellas funciones que están fuera de su campo serán tomadas por Unity como propiedades pertenecientes a ShaderLab. 99 CGPROGRAM / ENDCG ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM // todas nuestras funciones se escriben aquí ENDCG } } } 3.2.7. Tipos de datos. Antes de continuar definiendo las propiedades y funciones de nuestro shader, debemos hacer un alto en los tipos de datos debido a que existe una pequeña diferencia entre Cg y HLSL. Cuando creamos un shader en las versiones actuales de Unity, por defecto, podemos encontrar números de punto flotante que difieren en precisión, entre ellos figuran: › Float. › Half. › Y Fixed. Un shader escrito en Cg puede compilar perfectamente en estos tres tipos de precisión, sin embargo, HLSL no soporta el tipo de dato Fixed. En consecuencia, si trabajamos con este lenguaje tendremos que reemplazar todas sus variables y vectores ya sea por Half o Float. Float es un tipo de dato de alta precisión que cuenta con 32 bits y generalmente es utilizado en el cálculo de posiciones en World-Space, coordenadas UV o cálculos escalares que implican funciones complejas, como trigonometría o potenciación. 100 A su vez, Half es de media precisión, cuenta con 16 bits y generalmente es utilizado en el cálculo de vectores de baja magnitud, direcciones, posiciones en Object-Space y colores de alto rango dinámico. En el caso de Fixed, este es de baja precisión, cuenta con solo 11 bits y generalmente es utilizado en el cálculo de operaciones simples (p. ej. almacenamiento de colores básicos). Una pregunta que surge comúnmente en la utilización de vectores es, ¿Qué ocurriría si solamente utilizamos un tipo de dato flotante (Float) para todas nuestras variables? En la práctica esto es posible, sin embargo, debemos considerar que float es un tipo de dato de alta precisión, es decir que posee mayor cantidad de decimales, por ende, la GPU tardará más en calcularlo, aumentando tiempos y generando aumento de calor en el dispositivo. Es fundamental utilizar vectores o variables en el tipo de dato que se requiere, así podremos optimizar nuestro programa, disminuyendo la carga gráfica en la GPU. Otros tipos de datos muy utilizados que podemos encontrar en ambos lenguajes son: › Int. › Sampler2D. › Y SamplerCube. Sampler se refiere al estado de muestreo de una textura. Dentro de este tipo de dato podremos almacenar una textura y sus coordenadas UV. Generalmente, cuando deseamos trabajar con texturas en nuestro shader debemos utilizar el tipo de dato Texture2D para almacenar la textura y crear un SamplerState para muestrear. El tipo de dato Sampler permite almacenar tanto la textura como el estado de muestreo en una sola variable. Realizaremos la siguiente operación para entender el concepto. 101 Tipos de datos ⚫ ⚫ ⚫ // declaramos nuestra textura _MainTex como variable global Texture2D _MainTex; // declaramos el sampleo para _MainTex como variable global SamplerState sampler_MainTex; // vamos al fragment shader stage half4 frag(v2f i) : SV_Target { // dentro del vector col sampleamos la textura en las coordenadas UV. half4 col = _MainTex.Sample(sampler_MainTex, i.uv); // retornamos el color de la textura. return col; } El proceso anterior puede ser optimizado simplemente utilizando un Sampler2D. Tipos de datos ⚫ ⚫ ⚫ // declaramos nuestro sampler para _MainTex sampler2D _MainTex; // vamos al fragment shader stage half4 frag(v2f i) : SV_Target { // sampleamos la textura en las coordenadas UV utilizando la función. half4 col = tex2D(_MainTex, i.uv) // retornamos el color de la textura. return col; } Ambos ejemplos retornan exactamente el mismo valor, el cual corresponde a la textura que asignaremos posteriormente desde el Inspector de Unity. En nuestro programa podremos utilizar valores escalares, vectores y matrices, los cuales se diferencian por la cantidad de dimensiones que poseen. 102 Los valores escalares son aquellos que retornan un número real, ya sea entero o con decimales. Para declararlos en nuestro shader primero debemos agregar el tipo de dato, luego su nombre de identificación y finalmente inicializamos su valor. Su sintaxis es la siguiente: Tipos de datos ⚫ ⚫ ⚫ float name = n; // p. ej. float a = 0; half name = n; // p. ej. half b = 1.456; fixed name = n; // p. ej. fixed c = 2.5; Los vectores retornan un valor con más de una dimensión, p. ej. XYZW, y para declararlos debemos agregar el tipo de dato, luego la cantidad de dimensiones, luego su nombre identificador y finalmente inicializamos su valor. Su sintaxis es la siguiente: Tipos de datos ⚫ ⚫ ⚫ float2 name = n; // p. ej. float2 a = float2(0.5, 0.5); half3 name = n; // p. ej. half3 b = float3(0, 0, 0); fixed4 name = n; // p. ej. fixed4 c = float4(1, 1, 1, 1); Finalmente, las matrices cuentan con valores almacenados en columnas y filas, poseen más de una dimensión y son utilizadas principalmente para shearing, rotación y cambio de posición de los vértices. Al declarar una matriz en nuestro shader, debemos agregar el tipo de dato, luego la cantidad de dimensiones multiplicada por sí mismo, luego el nombre de la matriz, y finalmente inicializamos sus valores por defecto, considerando su identidad X , Y & Z . X 103 Y Z Su sintaxis es la siguiente: Tipos de datos ⚫ ⚫ ⚫ // tres filas y tres columnas float3x3 name = float3x3 ( 1, 0, 0, 0, 1, 0, 0, 0, 1 ); // dos filas y dos columnas half2x2 name = half2x2 ( 1, 0, 0, 1 ); // cuatro filas y cuatro columnas fixed4x4 name = fixed4x4 ( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 ); Si estás iniciando en el mundo de los shader, es muy posible que parte del contenido explicado en esta sección sea complejo de entender a primera vista. Más adelante en este libro revisaremos en detalle estos parámetros y pondremos en práctica su funcionamiento. 104 3.2.8. Cg / HLSL Pragmas. En nuestro shader, podemos encontrar al menos tres pragmas que vienen incluidos por defecto. Estas corresponden a directivas de procesador y están incluidas en nuestro programa Cg o HLSL. Su función es ayudar a nuestro programa en el reconocimiento y compilación de ciertas funciones, que de otra manera no podrían ser reconocidas como tal. El #pragma vertex vert permite que el Vertex Shader Stage llamado vert pueda compilar en la GPU como tal. Sin esta línea de código, la GPU no podría reconocer su naturaleza, en consecuencia, toda información de nuestro objeto, incluyendo la transformación a coordenadas de pantalla, quedaría inhabilitada. Si prestamos atención al pase que viene incluido en el shader USB_simple_color, encontraremos la siguiente línea de código relacionada con el concepto explicado previamente. Cg / HLSL Pragmas ⚫ ⚫ ⚫ // permite que la función "vert" compile como vertex shader #pragma vertex vert // función "vert" como vertex shader v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o, o.vertex); return o; } La directiva #pragma fragment frag cumple la misma función que el pragma vertex, con la diferencia que este permite que el Fragment Shader Stage denominado frag pueda compilar en el programa. 105 Cg / HLSL Pragmas ⚫ ⚫ ⚫ // permite que compile la función "frag" cómo fragment shader #pragma fragment frag fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); UNITY_APPLY_FOG(i.fogCoords, col); return col; } A diferencia de las directivas anteriores, el #pragma multi_compile_fog tiene una doble función. Por una parte, multi_compile se refiere a un Shader Variant la cual nos permite generar variantes con funcionalidades distintas dentro de nuestro shader. Por otro lado, la palabra _fog habilita las funcionalidades para fog desde la ventana Lighting en Unity, es decir que, si vamos a dicha pestaña, sección Environment / Other Setting, podremos activar o desactivar las opciones de fog para nuestro shader. 3.2.9. Cg / HLSL Include. La directiva .cginc (Cg include) contiene varios archivos que pueden ser utilizados en nuestro shader para traer variables predefinidas y funciones auxiliares. Si prestamos atención nuevamente a USB_simple_color, encontraremos las siguientes directivas que han sido declaradas dentro del pase: › UNITY_FOG_COORDS( T RG › UnityObjectToClipPos( V RG › TRANSFORM_TEX( T ). ). , ST ). › UNITY_TRANSFER_FOG( O ,C RG › UNITY_APPLY_FOG( I RG RG ,O RG RG ). RG ). Todas pertenecen a UnityCG.cginc. Si eliminamos esta directiva nuestro shader no podrá compilar dado que no tendrá una referencia a tales propiedades. 106 Otra función definida que podemos encontrar en UnityCG.cginc es UNITY_PI, matemáticamente equivalente a 3.14159265359f. Esta última no viene incluida en nuestro shader por defecto, ya que se usa solo en casos específicos, p. ej., al calcular un triángulo o esfera. Si deseemos revisar las variables y funciones que “.cginc” trae incluido, podemos seguir la siguiente ruta: Windows → {unity install path} / Data / CGIncludes / UnityCG.cginc Mac → / Applications / Unity / Unity.app / Contents / CGIncludes / UnityCG.cginc Además de las directivas incluidas en el software, nosotros podemos crear personalizadas utilizando la extensión .cginc. Para ello simplemente debemos generar un nuevo documento de texto, guardarlo con dicha extensión y luego comenzar a definir nuestras variables y funciones. 3.3.0. Cg / HLSL Vertex Input y Vertex Output. Un tipo de dato que emplearemos con frecuencia en la creación de nuestros shaders son los Struct. Para aquellos que conocen el lenguaje C, C# o C++, un Struct o estructura es una declaración de tipo de datos compuesto, que define una lista agrupada de varios elementos del mismo tipo, el cual permite acceder a diferentes variables a través de un único puntero. En nuestro programa, estos serán empleados para definir tanto los Inputs como Outputs en cada etapa del shader. Su sintaxis es la siguiente: Cg / HLSL Vertex Input y Vertex Output. ⚫ ⚫ ⚫ struct name { vector[n] name : SEMANTIC[n]; }; Dentro del campo del Struct declaramos aquellos vectores que utilizaremos posteriormente entre el Vertex Shader Stage y Fragment Shader Stage a modo de conexión. La semántica posee información que utilizaremos desde el objeto, es decir, coordenadas UV, vértices, normales, tangentes y color. 107 Por defecto, Unity agrega dos funciones de tipo “struct” los cuales son: › Appdata. › Y v2f. La estructura “appdata” (application data) corresponde al Vertex Input, es decir al lugar donde almacenaremos las propiedades del objeto, mientras que y “v2f” (vertex to fragment) hace referencia al Vertex Output, el cual permite pasar propiedades desde el Vertex Shader Stage al Fragment Shader Stage. Vamos a pensar en las semánticas como “propiedades de acceso” de un objeto. Una semántica es una cadena conectada a una entrada o salida del shader que transmite información sobre el uso previsto de un parámetro. “ “ Según la documentación oficial de Microsoft: ¿A qué se refiere el párrafo anterior? Vamos a ejemplificar utilizando la semántica POSITION[n]. En la sección 1.0.1, mencionamos las propiedades de una primitiva. Una semántica permite acceder de manera individual a tales propiedades, es decir, si declaramos un vector de cuatro dimensiones y le pasamos la semántica POSITION[n] entonces ese vector contendrá la posición de los vértices de la misma. Vamos a suponer el siguiente ejercicio: Cg / HLSL Vertex Input y Vertex Output. ⚫ ⚫ ⚫ struct appdata { float4 pos : POSITION; } Si prestamos atención a la semántica POSITION, notaremos que la posición de los vértices del objeto, están siendo almacenados en un vector de cuatro dimensiones llamado pos. Dado que el proceso se está llevando a cabo en el Vertex Input appdata, se asume que los vértices están en Object-Space. 108 Las semánticas más comunes que emplearemos durante el desarrollo de nuestros efectos, corresponden a : › POSITION[n]. › TEXCOORD[n]. › TANGENT[n]. › NORMAL[n]. › COLOR[n]. Cg / HLSL Vertex Input y Vertex Output. ⚫ ⚫ ⚫ struct vertexInput // (p. ej. appdata) { float4 vertPos : POSITION; float2 texCoord : TEXCOORD0; float3 normal : NORMAL0; float3 tangent : TANGENT0; float3 vertColor: COLOR0; }; struct vertexOutput // (p. ej. v2f) { float4 vertPos : SV_POSITION; float2 texCoord : TEXCOORD0; float3 tangentWorld : TEXCOORD1; float3 binormalWorld : TEXCOORD2; float3 normalWorld : TEXCOORD3; float3 vertColor: COLOR0; }; TEXCOORD[n] permite acceder a las coordenadas UV de nuestra primitiva y posee hasta cuatro dimensiones (XYZW). TANGENT[n] da acceso a las tangentes de nuestra primitiva. Si deseamos crear mapas de normales será necesario trabajar con esta semántica que posee hasta cuatro dimensiones (XYZW). 109 A través de NORMAL[n] podemos acceder a las normales de nuestra primitiva y posee hasta cuatro dimensiones (XYZW). Esta semántica será fundamental en la implementación de iluminación y mapas normales. Finalmente, COLOR[n] permite acceder al color de los vértices de nuestra primitiva y posee hasta cuatro dimensiones (RGBA). Generalmente, corresponde a un color blanco o gris por defecto. Para entender el concepto, vamos a prestar atención a las estructuras que han sido declaradas de manera automática dentro de USB_simple_color. Iniciaremos con Vertex Input. Cg / HLSL Vertex Input y Vertex Output. ⚫ ⚫ ⚫ struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; Por una parte, “vertex” posee la semántica POSITION, es decir que dentro del vector estamos almacenando la posición de los vértices del objeto en Object-Space. Estos vértices son transformados a Clip-Space (coordenadas de pantalla) posteriormente en el Vertex Shader Stage a través de la función UnityObjectToClipPos( V RG ). Por otra parte, el vector “uv” posee la semántica TEXCOORD0, la cual da acceso a las coordenadas UV. ¿Por qué el vector vertex posee cuatro dimensiones? Esto es debido a su coordenada “W” la cual es igual a “uno” en este caso, dado que XYZ marcan una posición en el espacio. Si prestamos atención tanto a v2f como a appdata, notaremos que en ambos casos se incluye la semántica POSITION, con la diferencia que, en el primero, tal semántica posee el prefijo SV el cual significa System Value. 110 Cg / HLSL Vertex Input y Vertex Output. ⚫ ⚫ ⚫ struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS(1) float4 vertex : SV_POSITION; }; Cabe destacar que estos vectores están siendo conectados en el Vertex Shader Stage de la siguiente manera: Cg / HLSL Vertex Input y Vertex Output. ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); … } En el ejemplo anterior, el puntero o.vertex es igual al Vertex Output, o sea, al vector “vertex” que ha sido declarado en la estructura v2f. En cambio, v.vertex es igual al Vertex Input, es decir, al vector “vertex” que fue declarado en la estructura appdata. Esta misma lógica se cumple en los vectores uv. 3.3.1. Cg / HLSL variables y vectores de conexión. Continuando con el shader USB_simple_color, podemos notar que dentro del campo del CGPROGRAM se encuentra una variable global de tipo sampler2D y un vector de cuatro dimensiones que hace referencia directa a la propiedad _MainTex. Cg / HLSL variables y vectores de conexión ⚫ ⚫ ⚫ sampler2D _MainTex; float4 _MainTex_ST; 111 Las variables globales son utilizadas para conectar los valores o parámetros de las propiedades con las variables o vectores internos de nuestro programa. En el caso de _MainTex, podremos asignar una textura desde el Inspector y utilizarla en el programa. Para entender el concepto de mejor manera, supondremos un shader con la capacidad de cambio de color desde el Inspector. En su desarrollo, tendríamos que ir a las propiedades, generar un parámetro de tipo Color y luego inicializar su variable de conexión dentro del campo CGPROGRAM. De esta manera se generaría un vínculo entre ellas. Cg / HLSL variables y vectores de conexión ⚫ ⚫ ⚫ Properties { // Primero declaramos la propiedad _Color ("Tint", Color) = (1, 1, 1 , 1) } … CGPROGRAM … // Luego declaramos la variable de conexión sampler2D _MainTex; float4 _Color; … ENDCG Generalmente, las variables globales en nuestro programa Cg o HLSL son declaradas con la palabra uniform, p. ej., uniform float4 _Color. Sin embargo, Unity omite este paso, ya que tal declaración está incluida internamente en el shader. 112 3.3.2. Cg / HLSL Vertex Shader Stage. Corresponde a una etapa programable del Render Pipeline, donde los vértices son transformados desde un espacio 3D a una proyección bidimensional en la pantalla. Su unidad de cálculo más pequeña corresponde a un vértice independiente. Dentro del shader USB_simple_color podremos encontrar una función llamada vert la cual corresponde al Vertex Shader Stage. Podemos concluir tal funcionalidad debido a la declaración del pragma vertex. Cg / HLSL Vertex Shader Stage ⚫ ⚫ ⚫ #pragma vertex vert … v2f vert (appdata v) { … } Antes de continuar, cabe recordar que nuestro shader USB_simple_color es de tipo Unlit, es decir que no posee luz. Por esta razón incluye una función para el Vertex Shader y otra para el Fragment Shader. En un caso contrario, únicamente en Built-in RP, Unity provee una manera rápida de escribir shaders en la forma de Surface Shader, que en sí mismo genera código Cg de manera automática, exclusivamente para materiales que son afectados por iluminación. Su naturaleza permite optimizar tiempos de desarrollo, incluyendo funciones y cálculos internos en el programa. Sin embargo, dada su estructura, no es ideal para la explicación de conceptos, es por ello que iniciamos creando un shader de tipo Unlit al comienzo de este libro; para entender en detalle el funcionamiento del mismo. Analizando la estructura del Vertex Shader Stage, podemos notar que su definición comienza con la palabra v2f la cual hace referencia a “vertex to fragment”. Tal nombre tiene sentido cuando comprendemos su proceso interno. Básicamente, v2f será utilizado posteriormente como argumento en el Fragment Shader Stage, de ahí el nombre. 113 Cg / HLSL Vertex Shader Stage ⚫ ⚫ ⚫ v2f vert (appdata v) { … } Dentro de su campo podemos transformar y conectar las variables que han sido declaradas tanto de appdata como en v2f. De hecho, podemos notar que el Vertex Output ha sido inicializado con la letra “o”, la cual hace referencia a todas las variables internas de v2f. Cg / HLSL Vertex Shader Stage ⚫ ⚫ ⚫ v2f o; o.vertex … o.uv … return o; Cabe recordar que los objetos en nuestra escena se encuentran dentro de un espacio “tridimensional”, mientras que la pantalla del ordenador es “bidimensional”. En consecuencia, será necesario transformar tales coordenadas. Por lo tanto, la primera operación que ocurre en el Vertex Shader Stage es la transformación de los vértices del objeto desde ObjectSpace a Clip-Space a través de la función UnityObjectToClipPos( V RG ), la cual multiplica la matriz del modelo actual (unity_ObjectToWorld) por el factor de la multiplicación entre la vista y la matriz de proyección (UNITY_MATRIX_VP). Cg / HLSL Vertex Shader Stage ⚫ ⚫ ⚫ UnityObjectToClipPos(float3 pos). { return mul( UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(pos , 1.0))); } Tal operación ha sido declarada en el archivo UnityShaderUtilities.cginc, el cual, a su vez, ha sido incluido como dependencia en UnityCG.cginc. 114 Cg / HLSL Vertex Shader Stage ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); } Un factor a considerar es la cantidad de dimensiones que deben contener las variables al momento de trabajar con inputs u outputs, p. ej., notaremos que en appdata, el vector float4 vertex tiene la misma cantidad de dimensiones que su contraparte en el Vertex Output. Si dos vectores difieren en su cantidad de dimensiones al momento de ser conectados, es posible que Unity arroje un error de transformación. Luego podemos encontrar la función TRANSFORM_TEX la cual pide dos argumentos: 1 El input de coordenadas UV del objeto (v.uv). 2 Y la textura que vamos a posicionar sobre esas coordenadas (_MainTex). Básicamente, cumple la función de controlar el “tiling” y “offset” en las coordenadas UV de la textura. Como última operación, tales valores son almacenados en el output de UV para ser utilizados en el Fragment Shader Stage posteriormente. 3.3.3. Cg / HLSL Fragment Shader Stage. Nuestra siguiente función y última en el pase corresponde al Fragment Shader Stage la cual aparece en el programa con el nombre frag por defecto. Podemos concluir su naturaleza dado que ha sido declarada como tal en pragma fragment. Cg / HLSL Fragment Shader Stage ⚫ ⚫ ⚫ #pragma fragment frag fixed4 frag (v2f i) : SV_Target { // funciones del fragment shader aquí } 115 La palabra “fragment” quiere decir fragmento. Esta hace referencia a un píxel en la pantalla; a un fragmento individual o a un grupo de ellos que en su conjunto cubren el área de un objeto. El Fragment Shader Stage se encarga de procesar cada píxel en la pantalla del ordenador en relación con el objeto que estamos visualizando. Analizando su estructura, podemos concluir que el retorno será igual a un vector de cuatro dimensiones debido al tipo de dato “fixed4”. Cabe destacar el lenguaje Cg en este caso dado que el vector únicamente va a compilar en Built-in RP. Si deseamos que el shader funcione en Scriptable RP tendremos que reemplazar fixed por “half” o “float”, manteniendo el número de dimensiones. Cg / HLSL Fragment Shader Stage ⚫ ⚫ ⚫ // lenguaje Cg fixed4 frag (v2f i) : SV_Target { … } // lenguaje HLSL half4 frag (v2f i) : SV_Target { … } A diferencia del Vertex Shader Stage, esta etapa posee un output llamado SV_Target el cual corresponde al valor de salida que será almacenado en el Render Target. En versiones anteriores de Direct3D (versión 9 hacia abajo) el output de color en el Fragment Shader aparecía con la semántica COLOR, sin embargo, en GPU modernas esta semántica es actualizada por SV_Target, la cual significa “system value target” y tiene la capacidad de aplicar efectos adicionales a la imagen antes de proyectarlos en la pantalla del ordenador. Por defecto, dentro del campo podemos encontrar un vector de cuatro dimensiones de tipo fixed llamado col, el cual hace referencia al color de la textura _MainTex que se está aplicando a través de la función tex2D( S RG , UV output de coordenadas UV. RG ), quien a su vez, recibe como argumento a la propiedad y el Tal vector posee cuatro dimensiones por dos razones principalmente: 1 Porque la función “frag” es un vector de cuatro dimensiones. 2 Porque la función “tex2D” retorna distintos valores para cada dimensiones RGBA. 116 Cg / HLSL Fragment Shader Stage ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // guardamos la textura dentro de col fixed4 col = tex2D(_MainTex, i.uv); // retornamos el color de la textura return col; } 3.3.4. ShaderLab Fallback. Es bastante común ver objetos de color magenta tras un error de compilación en la GPU. Fallback ayuda a evitar tales conflictos, posicionando un shader distinto de aquel que ha generado el error. Su sintaxis es la siguiente: ShaderLab Fallback ⚫ ⚫ ⚫ Fallback "shaderPath" Para ello, simplemente podemos declarar un nombre y ruta, así el software sabrá de dónde obtener un nuevo shader en caso de fallos en el hardware. Este comando puede ser omitido, dejando el espacio en blanco o declarando Fallback Off. De igual forma, es recomendable utilizar una ruta y nombre de shaders que hayan sido incluidos en el software, p. ej., Mobile/Diffuse. De esta manera podremos estar seguros de que el programa continuará su funcionamiento en caso de fallos. 117 ShaderLab Fallback ⚫ ⚫ ⚫ Shader "InspectorPath/ShaderName" { Properties { … } SubShader { … } Fallback "Mobile/Unlit" } En el ejemplo anterior, en caso de que el SubShader genere un error, el Fallback retornará un shader tipo Unlit perteneciente a la categoría Mobile. Si estamos desarrollando un juego multiplataforma, es recomendable declarar una ruta específica para el Fallback, ya que de esta manera podemos asegurar que nuestro programa funcione en la mayoría de los dispositivos. 118 Implementación y otros conceptos. 4.0.1. Analogía entre un shader y un material. “ Según su terminología, “ Los materiales son definiciones de cómo una superficie debería ser renderizada, incluyendo referencias a las texturas, tiling, offset, color y más. Las opciones de un material dependen de qué shader se está utilizando. ¿Cómo podemos traducir la definición anterior a un nivel práctico? Vamos a pensar en un material como “el contenedor de un shader”, es decir que, por una parte, tenemos al programa que está realizando los cálculos de superficie (shader), y por otra, tenemos al contenedor (material) que es capaz de leer los cálculos. Un material por sí solo no es capaz de ejecutar alguna operación. Si no posee un shader, en consecuencia, no sabrá cómo debe ser renderizado. Así mismo, un shader no puede ser aplicado a un objeto si no es a través de un material, por lo tanto, la analogía entre un material y un shader es de “previsualización gráfica de cálculos matemáticos”. 4.0.2. Nuestro primer shader en Cg o HLSL. Uno de los ejercicios más simples que podemos llevar a cabo en un shader es el cambio de color o tinte de una textura. Para tal explicación, continuaremos trabajando con USB_simple_color dado que, por defecto, posee una textura llamada _MainTex, la cual utilizaremos para cambiar su tinte de manera dinámica desde el Inspector. Cabe recordar que no podemos aplicar directamente un shader sobre un objeto en nuestra escena, en cambio, necesitaremos ayuda de un material. Se asume que el lector de este libro ya conoce el proceso de creación de materiales en el software, de todas maneras realizaremos una pequeña especificación para abordar el asunto. 119 Para generar un material; 1 Debemos ir a nuestro proyecto. 2 Presionar clic derecho sobre la carpeta en la que estamos trabajando. 3 Ir al menú Create y seleccionar Material. Dependiendo del Render Pipeline será la configuración por defecto que posea el material que acabamos de originar. Generalmente, Built-in RP trae consigo un shader de tipo Standard Surface el cual permite visualizar la dirección de iluminación, sombras y otros cálculos asociados. Para previsualizar los cambios que efectuaremos, vamos a navegar hasta la ventana Hierarchy en Unity, y crearemos un objeto 3D. Debemos asegurarnos de asignar el shader mencionado previamente al material, y luego, aplicar tal material sobre el objeto en la escena. De igual manera, será fundamental aplicar una textura sobre el material para llevar a cabo el ejercicio. Volvemos al shader y creamos una propiedad de tipo Color, como se muestra en el siguiente ejemplo: Nuestro primer shader en Cg o HLSL ⚫ ⚫ ⚫ Shader "USB/USB_simple_color" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Texture Color", Color) = (1, 1, 1, 1) } SubShader { … } } En el ejemplo, se ha declarado una propiedad de color llamada “Texture Color” la cual utilizaremos para modificar el tinte de “Texture”. Si guardamos y volvemos a Unity podremos apreciar que tal propiedad aparece en el Inspector, sin embargo, no ha sido inicializada dentro del CGPROGRAM, por ende no es completamente funcional. 120 A continuación debemos agregar la variable de conexión _Color para su utilización dentro del programa. Para ello, nos posicionamos “sobre” el Vertex Shader Stage; donde _MainTex ha sido declarada como sampler2D, y creamos la variable de la siguiente manera: Nuestro primer shader en Cg o HLSL ⚫ ⚫ ⚫ uniform sampler2D _Maintex; uniform float4 _MainTex_ST; uniform float4 _Color; // variable de conexión. Tras la declaración de la variable global en el CGPROGRAM o HLSLPROGRAM, podemos proceder a su utilización en cualquier de las funciones en nuestro shader, siempre considerando que la GPU leerá el programa desde arriba hacia abajo, de manera lineal. Para cambiar el tinte de _MainTex, iremos al Fragment Shader Stage y realizaremos el siguiente ejercicio: Nuestro primer shader en Cg o HLSL ⚫ ⚫ ⚫ // CGPROGRAM fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); return col * _Color; } // HLSLPROGRAM half4 frag (v2f i) : SV_Target { half4 col = tex2D(_MainTex, i.uv); return col * _Color; } Como podemos apreciar, se ha multiplicado la variable col por _Color, el cual, como resultado se consigue la modificación del tinte de _MainTex. Si guardamos y volvemos a Unity, podremos cambiar el tinte de la textura desde el Inspector en el material. 121 4.0.3. Agregando transparencia en Cg o HLSL. En la sección anterior, agregamos color RGBA a la configuración de USB_simple_color con la finalidad de cambiar el tinte de la textura _MainTex. Sin embargo, si modificamos el canal Alpha del color desde el Inspector, notaremos que no genera cambio alguno sobre la textura, ¿A qué se debe tal comportamiento? Tal como se ha mencionado anteriormente en la sección 3.1.7, será fundamental configurar el tipo de Blending, además del RenderType y Queue transparente. Agregando transparencia en Cg o HLSL ⚫ ⚫ ⚫ Shader "USB/USB_simple_color" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Texture Color", Color) = (1, 1, 1, 1) } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha LOD 100 Pass { … } } } Si guardamos nuestro shader y volvemos a Unity, podremos modificar la opacidad de la textura dado que está siendo afectada por el cuarto canal de color. 122 4.0.4. Estructura de una función en HLSL. Al igual que en C#, dentro del lenguaje HLSL podemos declarar funciones de tipo void o aquellas que retornan un valor. Dependiendo del tipo de función tendremos que utilizar “declaraciones”. Estas nos permiten determinar si un valor corresponde a un input (in), output (out), variable global (uniform) o un valor constante (const). Estructura de una función en HLSL ⚫ ⚫ ⚫ void functionName_precision (declaration type arg) { float value = 0; arg = value; } En la declaración de una función tipo void, iniciamos con su nomenclatura, luego el nombre de la función acompañada de la precisión y finalmente sus argumentos. Generalmente en los argumentos debemos definir si es que tales serán inputs u outputs, por ende cabe preguntarnos, ¿Cómo podemos saber si llevan declaración? Todo va a depender de las funciones que deseemos pasar como argumentos. Para entender el concepto, supondremos una función simple que sea capaz de calcular la iluminación en un objeto. Para ello, una de las propiedades necesarias a utilizar serán las Normales, ya que, dada su naturaleza, nos va a permitir determinar el ángulo entre la fuente lumínica y superficie del objeto. Por lo tanto, las Normales serían un input a calcular dentro de nuestra función. Estructura de una función en HLSL ⚫ ⚫ ⚫ void FakeLight_float (in float3 Normal, out float3 Out) { float[n] operation = Normal; Out = operation; } En una función de tipo void siempre debemos agregar la precisión, de otra manera no podrá ser compilada dentro de nuestro programa. Cabe mencionar que la función anterior no cumple funcionalidad alguna, no obstante la utilizaremos para entender los conceptos mencionados previamente. 123 FakeLight corresponde al nombre de la función como tal, y _float es su precisión. Esta última puede ser de tipo float o half, ya que, como sabemos, estos son los tipos de datos compatibles con el lenguaje HLSL. Luego en los argumentos podemos notar que las Normales del objeto han sido declaradas como input, puesto que posee la declaración “in”. Asimismo, existe un output denominado “out” el cual corresponde al valor final de la operación. A continuación supondremos la simulación de nuestra función FakeLight dentro del Fragment Shader Stage; como si realmente realizara una operación. Estructura de una función en HLSL ⚫ ⚫ ⚫ // creamos nuestra función void FakeLight_float (in float3 Normal, out float3 Out) { float[n] operation = Normal; Out = operation; } Estructura de una función en HLSL ⚫ ⚫ ⚫ half4 frag (v2f i) : SV_Target { // declaramos las normales. float3 n = i.normal; // declaramos el output. float3 col = 0; // pasamos ambos valores como argumentos. FakeLight_float (n, col); return float4(col.rgb, 1); } En el ejemplo anterior hay varias situaciones que están ocurriendo. Primero, ha sido declarada la función FakeLight sobre la función “frag” debido a que la GPU lee el código desde arriba hacia abajo. Luego, en el Fragment Shader Stage, se ha generado un vector de tres dimensiones llamado “n” y otro del mismo tamaño llamado “col”, esto se debe a que utilizaremos ambos vectores como 124 argumentos en la función FakeLight_float, la cual nos pide tanto un input como output de tipo vector de tres dimensiones. Entonces, el primer argumento corresponde al input de Normales del objeto y el segundo, al resultado de la operación que se está efectuando dentro de la función. El vector “col” ha sido inicializado en “cero” para todos sus canales, en consecuencia posee un color negro por defecto. Sin embargo, ya que ha sido incluido como output en la función FakeLight_float, hereda el resultado que se está llevando a cabo dentro de la misma. Finalmente, el Fragment Shader stage retorna un vector de cuatro dimensiones, en donde, los primeros tres valores corresponden al vector “col” en RGB y “uno” para A. ¿Por qué estamos retornando un vector de cuatro dimensiones? Esto se debe a que la función “frag” es de tipo half4, o sea, un vector de media precisión con dimensiones. Habiendo definido una función de tipo void, pasaremos a analizar la estructura de una función de retorno. En esencia son semejantes, con la diferencia que en el último no será necesario agregar la precisión de la función ni declarar un output como argumento. Ejemplificaremos utilizando la misma analogía a través de la función FakeLight, la cual, en esta oportunidad, retornará un valor. Estructura de una función en HLSL ⚫ ⚫ ⚫ // creamos nuestra función half3 FakeLight (float3 Normal) { float[n] operation = Normal; return operation; } Estructura de una función en HLSL ⚫ ⚫ ⚫ half4 frag (v2f i) : SV_Target { // declaramos las normales. float3 n = i.normal; float3 col = FakeLight_float (n); return float4(col.rgb, 1); } 125 En una función de retorno simplemente agregamos el argumento sin declaración dado que no lo requiere. Cabe destacar que el resultado de la función podemos asignarlo directamente a una variable o vector, de la misma manera que haríamos en C#. 4.0.5. Depurando un shader (Debugging). Es bastante común en C# utilizar la función Debuig.Log( O RG ) para depurar un programa. Tal operación permite imprimir un mensaje en la consola de Unity, facilitando el entendimiento sobre lo que estamos desarrollando. Sin embargo, dada su naturaleza, esta función u otra no está disponible en Cg o HLSL. Cabe preguntarnos entonces, ¿Cómo podemos depurar un shader? Para ello debemos considerar algunos factores, entre ellos, sus colores. En un shader existen tres colores importantes de los cuales debemos prestar atención, estos corresponden a: › Blanco (1 , 1 , 1 , 1 ). R G B A › Negro (0 , 0 , 0 , 1 ). R G B A › Magenta (1 , 0 , 1 , 1 ). R G B A El blanco representa un valor por defecto, mientras que el negro se refiere a un valor inicializado. Por otra parte, el magenta alude a un error gráfico, de hecho es muy común importar assets a Unity que lucen de color magenta en nuestra escena. Depurando un shader ⚫ ⚫ ⚫ (Fig. 4.0.5a) 126 Para abordar este concepto, vamos a recordar la declaración de Propiedades en ShaderLab. Iniciaremos generando una propiedad de color. Depurando un shader ⚫ ⚫ ⚫ _Color ("Tint", Color) = (1, 1, 1, 1) Si prestamos atención al ejemplo anterior, notaremos que tal propiedad ha sido inicializada en color “blanco”, esto lo podemos corroborar en el vector de cuatro dimensiones; al final de la operación. En consecuencia, el selector de color en el Inspector será inicializado con el mismo valor. Analicemos otra propiedad. Depurando un shader ⚫ ⚫ ⚫ _MainTex ("Texture", 2D) = "white"{} Al igual que en el caso anterior, _MainTex va a generar un selector en el Inspector, en este caso, de textura, pero, ¿Cuál será su color por defecto? Su color será blanco, lo podemos corroborar en la declaración “white” al final de la propiedad. De la misma manera, podríamos utilizar otros colores para inicializar nuestra propiedad, p. ej., “red”, “black” o “gray”. El color negro lo veremos con frecuencia en declaraciones de vectores y variables internas en nuestro código, de hecho es muy común inicializar vectores en “cero” para luego sumar alguna operación. Cabe mencionar que el color blanco representa el valor máximo de iluminación de un pixel en pantalla, mientras que el negro es igual al mínimo. Es fundamental considerar este factor, dado que algunas operaciones excederán tales valores máximos (x > 1) y mínimos (x < 0). En estos casos se produce saturación de color y para ello será necesario emplear algunas funciones como “clamp” el cual limita un valor entre dos números; un mínimo y un máximo. 127 ¿A qué se debe el color magenta en nuestros objetos? Como ya sabemos, este color representa un “error gráfico”. Básicamente, cuando la GPU no ha sido capaz de llevar a cabo las operaciones que se encuentran dentro del shader, arroja un color magenta por defecto. En Unity, las razones de este color se deben a dos factores principalmente: 1 Que el Render Pipeline no ha sido configurado. 2 O que el shader tiene un error de compilación en su código. Cuando importamos un asset al software, lo primero que debemos considerar es, ¿En cuál Render Pipeline estamos trabajando? Recordemos que en Unity existen tres tipos de Render Pipeline y cada uno posee una configuración distinta. Si importamos un asset a Universal RP que incluya un material con un shader de tipo Standard Surface, dada su configuración, aparecerá de color magenta, ya que, como sabemos, este tipo de shader es soportado únicamente en Built-in RP. Para solucionar este error gráfico tendremos que seleccionar el material, ir al Inspector y cambiar el tipo de shader por alguno que se encuentre dentro del menú “Shader Graphs”. Una vez que hemos identificado el tipo de Render Pipeline que estamos utilizando debemos proceder a la evaluación del shader. Si prestamos atención a la consola podremos encontrar su definición. Debemos considerar algunos factores en la solución de errores gráficos, entre ellos: › Término de una instrucción o función en HLSL y ShaderLab. La mayoría de los errores se generan por mala redacción o sintaxis, a continuación revisaremos uno bastante común. Depurando un shader ⚫ ⚫ ⚫ En el error anterior, el shader que se encuentra en el menú USB, llamado USB_simple_color, posee un error en la línea de código 60 y no puede compilar en Direct3D 11 (d3d11). 128 Ahora bien, ¿Es ese realmente el problema del shader? Para ello, vamos a analizar la línea de código que está generando el error. Depurando un shader ⚫ ⚫ ⚫ 58 fixed4 frag (v2f i) : SV_Target 59 { 60 fixed4 col = tex2D(_MainTex, i.uv); 61 return col // (;) 62 } Según la consola, el error se está generando en la línea de código número 60, pero como podemos ver, ahí no hay ningún problema. Cabe preguntarnos entonces, ¿Qué es lo que está ocurriendo? El error se debe a que olvidamos cerrar la operación en la línea de código número 61. Si prestamos atención al return nos daremos cuenta de que al vector col le falta un punto y coma. Dada esta característica, la GPU piensa que la operación continua, por eso no puede compilarla. Otro error que veremos con frecuencia es el siguiente: Depurando un shader ⚫ ⚫ ⚫ Depurando un shader ⚫ ⚫ ⚫ half4 frag (v2f i) : SV_Target { half4 col = tex2D(_MainTex, i.uv); half2 uv = 0; Analyzing Shader Graph col = uv; // no se puede convertir return col; } 129 En este caso, el error se ha generado debido a que estamos intentando convertir un vector de cuatro dimensiones en uno de dos dimensiones. El vector “col” posee los canales RGBA o XYZW, en cambio, el vector “uv”, al ser de dos dimensiones únicamente posee las combinaciones RG, RB, GB, etc. En consecuencia, no se puede convertir. 4.0.6. Agregando compatibilidad en URP. Hasta este punto, gran parte de las variables, funciones y vectores que hemos implementado, funcionan tanto para Cg como en HLSL, sin embargo, habrá algunos casos donde tendremos que agregar compatibilidad en URP para que nuestro shader pueda compilar. Si deseamos crear un shader vía HLSL en Universal RP, tendremos que agregar algunas dependencias para que la GPU pueda leer adecuadamente el Render Pipeline. Tales dependencias podemos encontrarlas en distintas rutas, de las cuales podemos mencionar: → Packages / Core RP Library / ShaderLibrary → Packages / Universal RP / ShaderLibrary El paquete Core RP Library es incluido de manera automática cuando instalamos Shader Graph en nuestro proyecto. Así mismo, el paquete Universal RP se incluye cuando seleccionamos a este Render Pipeline como nuestro motor de rendering. Ambos paquetes poseen archivos con extensión “.hlsl” los cuales son requeridos por nuestro programa para compilar shaders en HLSL. Para entender el concepto, vamos a duplicar el shader USB_simple_color y lo renombramos como USB_simple_color_URP. Cabe mencionar que el shader USB_simple_color (Cg), al ser un modelo básico de color, ya posee compatibilidad en Universal RP, sin embargo, vamos a generar una copia del mismo y lo modificaremos para revisar en detalle su implementación en HLSL. Iniciaremos declarando el Tags RenderPipeline en nuestro shader URP y lo haremos igual a UniversalRenderPipeline, de esta manera la GPU podrá deducir la compatibilidad con el Render Pipeline. 130 Agregando compatibilidad en URP ⚫ ⚫ ⚫ Tags { "RenderType"="Transparent" "Queue"="Transparent" "RenderPipeline"="UniversalRenderPipeline" } Como se ha mencionado anteriormente, Universal RP funciona con lenguaje HLSL, por lo tanto, habrá que reemplazar los bloques CGPROGRAM/ENDCG por HLSLPROGRAM y ENDHLSL respectivamente dentro del pase. Agregando compatibilidad en URP ⚫ ⚫ ⚫ Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag … ENDHLSL } Para su perfecta compilación y eficiencia, habrá que incluir la dependencia Core.hlsl, la cual contiene funciones, estructuras y otros que permiten el buen funcionamiento del programa en Universal RP. En sí, Core.hlsl reemplaza a UnityCg.cginc, por lo tanto, será necesario eliminar esta última de nuestro shader. 131 Agregando compatibilidad en URP ⚫ ⚫ ⚫ HLSLPROGRAM #pragma vertex vert #pragma fragment frag // #pragma multi_compile_fog // #include "UnityCg.cginc" #include "Package/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" … ENDHLSL A diferencia de UnityCg.cginc que viene incluido en la instalación de Unity como tal, Core. hlsl es agregado como paquete una vez instalamos Universal RP en nuestro proyecto, por lo tanto, habrá que escribir la ruta completa de su ubicación en el proyecto. Una vez que hemos reemplazamos tales dependencias, ocurrirán dos cosas principalmente: 1 La GPU no podrá compilar las coordenadas de fog (UNITY_FOG_COORDS, UNITY_TRANSFER_FOR y UNITY_APPLY_FOR) dado que han sido incluidas en UnityCg.cginc. 2 Tampoco podrá compilar la función UnityObjectToClipPos( V RG ) por el mismo motivo. En cambio, tendremos que utilizar la función TransformObjectToHClip( V RG ) la cual viene incluida en la dependencia SpaceTransforms.hlsl, que a su vez, ha sido incluida en Core.hlsl. TransformObjectToHClip( V UnityObjectToClipPos( V RG RG ) realiza la misma operación que la función ), o sea, transforma la posición de los vértices desde Object-Space a Clip-Space, sin embargo, la primera es más eficiente en su proceso de ejecución. 132 Agregando compatibilidad en URP ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; // o.vertex = UnityObjectToClipPos(v.vertex); o.vertex = TransformObjectToHClip(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } Si en este punto guardamos nuestro shader y volvemos a Unity, podremos ver que se ha generado un error en la consola del tipo unrecognized identifier, ¿Por qué razón? Recordemos que tanto el Fragment Shader Stage como el vector “col” son del tipo fixed4. Así lo podemos comprobar en la función. Agregando compatibilidad en URP ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // sample the texture fixed4 col = tex2D(_MainTex, i.uv); // apply fog // UNITY_APPLY_FOG(i.fogcoord, col); return col * _Color; } Agregando compatibilidad en URP ⚫ ⚫ ⚫ 133 Como ya sabemos Universal RP no puede compilar variables o vectores del tipo “fixed”, en cambio, tendremos que utilizar “half o float”. Por lo tanto, en este punto podemos hacer dos cosas: 1 Reemplazar manualmente las variables o vectores de tipo fixed por half. 2 O incluir la dependencia HLSLSupport.cginc que agrega macros de ayuda y definiciones multiplataforma para la compilación de shaders. Agregando compatibilidad en URP ⚫ ⚫ ⚫ HLSLPROGRAM #pragma vertex vert #pragma fragment frag // #pragma multi_compile_fog // #include "UnityCg.cginc" #include "HLSLSupport.cginc" #include "Package/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" … ENDHLSL Una vez incluida tal dependencia podremos trabajar con variables y vectores del tipo fixed. Ahora nuestro programa va a reconocer a este tipo de dato según su precisión y las va a reemplazar ya sea por half o float, de manera automática. 134 4.0.7. Funciones intrínsecas. Tanto en Cg como en HLSL podemos encontrar funciones intrínsecas que nos ayudarán en la programación de efectos. Tales funciones corresponden a operaciones matemáticas generales y se utilizan en casos específicos según el resultado que deseamos obtener. Entre las más comunes podemos encontrar: › Abs. › Exp. › Length. › Ceil. › Exp2. › Lerp. › Clamp. › Floor. › Min. › Cos. › Step. › Max. › Sin. › Smoothstep. › Pow. › Tan. › Frac. Las cuales definiremos a continuación. 4.0.8. Función Abs. Esta función se refiere al valor absoluto de un número y como argumento podemos pasar tanto un valor escalar como a un vector. Su sintaxis es la siguiente: Función Abs ⚫ ⚫ ⚫ // retorna el valor absoluto de n float abs(float n) { return max(-n, n); } float2 abs (float2 n); float3 abs (float3 n); float4 abs (float4 n); 135 Un valor absoluto siempre va a retornar a un número positivo. Su simbología matemática consta de dos barras laterales que enmarcan a un valor. |-3 | = 3 valor absoluto de -3 es igual a 3 |-5 | = 5 valor absoluto de -5 es igual a 5 |+6 | = 6 valor absoluto de 6 es igual a sí mismo En nuestro programa podríamos utilizar la función abs( N RG ) para múltiples efectos, entre ellos: recrear un caleidoscopio o generar una proyección triplanar. De hecho, para el primer caso podríamos realizar tal efecto mediante el cálculo del valor absoluto en las coordenadas UV, mientras que en la proyección triplanar, podríamos determinar el valor absoluto de las normales del mesh, y así generar proyecciones tanto en ejes positivos como negativos. Función Abs ⚫ ⚫ ⚫ (Fig. 4.0.8a. Como pudimos ver en la sección 1.0.5, las coordenadas UV comienzan en 0.0f y finalizan en 1.0f. En la figura anterior podemos apreciar el comportamiento de la coordenada U cuando restamos 0.5f. La textura ha sido configurada en clamp desde el Inspector) Si prestamos atención a la Figura 4.0.8a, notaremos que al restar 0.5f al valor inicial de la coordenada U, queda centrada en el Quad. Así mismo, el valor mínimo de la coordenada pasa a ser -0.5f. En el ejemplo, los texels se estiran debido a que la textura ha sido configurada en Clamp desde el Wrap Mode. En este contexto podríamos aplicar el valor absoluto para generar un efecto espejo. 136 Función Abs ⚫ ⚫ ⚫ (Fig. 4.0.8b. Valor absoluto de la coordenada U menos 0.5f) A continuación desarrollaremos el efecto de un caleidoscopio para entender el concepto a cabalidad. Iniciaremos creando un nuevo shader tipo Unlit al cual llamaremos USB_function_ABS. Luego, en sus Propiedades declararemos un rango numérico que utilizaremos posteriormente para rotar las coordenadas UV. Función Abs ⚫ ⚫ ⚫ Shader "USB/USB_function_ABS" { Properties { _MainTex ("Texture", 2D) = "white" {} // agregamos una propiedad para rotar los UV _Rotation ("Rotation", Range(0, 360)) = 0 } } Dado que una rotación completa posee 360 grados, _Rotation es igual a un rango entre 0 y 360. Continuamos con la variable global o de conexión para la propiedad. 137 Función Abs ⚫ ⚫ ⚫ Pass { CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; float _Rotation; … ENDCG } Para generar el efecto simplemente podemos calcular el valor absoluto tanto de la coordenada U como de V, y utilizar el resultado para el color de salida en el Fragment Shader Stage. Función Abs ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // calculamos el valor absoluto de U float u = abs(i.uv.x - 0.5); // calculamos el valor absoluto de V float v = abs(i.uv.y - 0.5); fixed col = tex2D(_MainTex, float2(u, v)); UNITY_APPLY_FOG(i.fogCoord, col); return col; } Dependiendo de la configuración de la textura que estamos asignando como _MainTex, pueden ocurrir dos cosas principalmente: 1 Si la textura ha sido configurada en Repeat, el área negativa de las coordenadas UV será llenada con la repetición de la misma. 2 En cambio, si la configuración de la textura corresponde a Clamp, los texels de la imagen serán estirados, de la misma manera que ocurre en la Figura 4.0.8a. 138 Independientemente de la configuración, el efecto espejo será evidente, ya que estamos utilizando la función “abs” para cada coordenada. Utilizando la función Unity_Rotate_Degrees_float perteneciente a Unity e incluida en Shader Graph, podemos rotar las coordenadas UV. Función Abs ⚫ ⚫ ⚫ void Unity_Rotate_Degrees_float ( float2 UV, float2 Center, float Rotation, out float2 Out ) { Rotation = Rotation * (UNITY_PI/180.0f); UV -= Center; float s = sin(Rotation); float c = cos(Rotation); float2x2 rMatrix = float2x2(c, -s, s, c); rMatrix *= 0.5; rMatrix += 0.5; rMatrix = rMatrix * 2 - 1; UV.xy = mul(UV.yx, rMatrix); UV += Center; Out = UV; } Dado que la función es de tipo “void” tendremos que inicializar algunas variables dentro del campo del Fragment Shader Stage, y luego pasarlas como argumento. 139 Función Abs ⚫ ⚫ ⚫ Unity_Rotate_Degrees_float() { … } fixed4 frag (v2f i) : SV_Target { float u = abs(i.uv.x - 0.5); float v = abs(i.uv.y - 0.5); // vinculamos la propiedad de rotación float rotation = _Rotation; // centramos el pivote de rotación float center = 0.5; // generamos nuevas coordenadas uv para la textura float2 uv = 0; Unity_Rotate_Degrees_float(float2(u,v), center, rotation, uv); fixed4 col = tex2D(_MainTex, uv); UNITY_APPLY_FOG(i.fogCoord, col); return col; } El primer argumento en la función Unity_Rotate_Degrees_float corresponde a las coordenadas UV que deseamos rotar, continua el centro de rotación o pivote, luego la cantidad de grados de rotación, y finalmente el output con los nuevos valores de coordenadas que utilizaremos para la textura. 4.0.9. Función Ceil. Ceil retorna el valor entero más pequeño no menor que su argumento. ¿Qué quiere decir esto? La función ceil( N RG “ “ Según la documentación oficial de NVIDIA, ) va a retornar un número entero, es decir sin decimales, que se encuentre cercano a su argumento, p. ej., si el número es igual a 0.5f, ceil va a retornar 1. 140 ceil (0.1) = 1 ceil (0.3) = 1 ceil (1.7) = 2 ceil (1.3) = 2 Todos los números que se encuentren entre 0.0f y 1.0f van a retornar “uno” dado que este último sería el valor entero más pequeño no menor que su argumento. Su sintaxis es la siguiente: Función Ceil ⚫ ⚫ ⚫ // retorna un valor entero float ceil(float n) { return -floor(-n); } float2 ceil (float2 n); float3 ceil (float3 n); float4 ceil (float4 n); Esta función es bastante útil para generar efectos de zoom o lupa en un videojuego. Para ello simplemente debemos calcular el valor de ceil( N RG ) tanto para la coordenada U como de V, multiplicar el resultado por 0.5f. Luego, generar una interpolación lineal entre los valores por defecto de las coordenadas UV y los valores resultantes de la función. Para entender el concepto a profundidad, haremos lo siguiente: Crearemos un nuevo shader tipo Unlit al cual llamaremos USB_function_CEIL, e iniciaremos declarando las nuevas coordenadas UV para la textura _MainTex, en el Fragment Shader Stage. 141 Función Ceil ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // declaramos la coordenada U float u = ceil(i.uv.x); /// declaramos la coordenada V float v = ceil(i.uv.y); // pasamos las coordenadas a la textura fixed4 col = tex2D(_MainTex, float2(u, v)); UNITY_APPLY_FOG(i.fogCoord, col); return col; } La operación que realizamos anteriormente va a retornar el texel que se encuentra en la posición 1.0 , 1.0 , ¿Por qué razón? Tanto la coordenada U como V comienzan en 0.0f y finalizan en 1.0f, por U V lo tanto, la función únicamente va a retornar el último texel que se encuentre en la textura y por consecuencia, se va a generar un efecto de zoom que irá desde el punto superior derecho de la textura hacia el punto inferior izquierdo de la misma. Función Ceil ⚫ ⚫ ⚫ (Fig. 4.0.9a. Ceil va a retornar el último texel que se encuentra en la textura; aquel que está en la posición 1.0 , 1.0 ) U V Para tal efecto, vamos a necesitar una propiedad que nos permita aumentar o disminuir el tamaño de la textura. Para ello, iremos a las Propiedades y declararemos un rango flotante al cual llamaremos _Zoom. 142 Función Ceil ⚫ ⚫ ⚫ Shader "USB/USB_function_CEIL" { Properties { _MainTex ("Texture", 2D) = "white" {} _Zoom ("Zoom", Range(0, 1)) = 0 } } En el rango, “cero” simboliza 0 % de zoom, mientras que “uno” es equivalente al 100 %. Luego declaramos la variable de conexión dentro del programa. Función Ceil ⚫ ⚫ ⚫ Pass { … sampler2D _MainTex; float4 _MainTex_ST; float _Zoom; … } Dado que nosotros necesitamos que el punto de zoom inicie en el centro de la textura, podemos modificar su posición multiplicando la operación por 0.5f de la siguiente manera: Función Ceil ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // declaramos la coordenada U float u = ceil(i.uv.x) * 0.5; // declaramos la coordenada V float v = ceil(i.uv.y) * 0.5; Continúa en la siguiente página. 143 // pasamos las coordenadas a la textura fixed4 col = tex2D(_MainTex, float2(u, v)); UNITY_APPLY_FOG(i.fogCoord, col); return col; } En este punto, la textura ya se está expandiendo desde su centro, sin embargo, aún no podemos apreciar el efecto de zoom dado que la función ceil( N RG ) sigue retornando “uno”, por ende, continuaremos viendo un solo color de relleno en el área del Quad. Lo que podemos hacer es generar una interpolación lineal entre los valores por defecto de las coordenadas UV y aquellos resultantes de la función ceil( N RG ). Función Ceil ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { float u = ceil(i.uv.x) * 0.5; float v = ceil(i.uv.y) * 0.5; float uLerp = lerp(u, i.uv.x, _Zoom); float vLerp = lerp(v, i.uv.y, _Zoom); // pasamos las coordenadas a la textura fixed4 col = tex2D(_MainTex, float2(uLerp , vLerp)); UNITY_APPLY_FOG(i.fogCoord, col); return col; } En el ejemplo anterior creamos dos variables llamadas uLerp y vLerp para las distintas coordenadas que estamos utilizando. En ellas se está realizando una interpolación lineal a través de la función “lerp” perteneciente al lenguaje Cg/HLSL. Además, se ha incluido la propiedad _Zoom, la cual posee un rango entre 0.0f y 1.0f. Si modificamos los valores de _Zoom desde el Inspector, podremos ver como la textura aumenta o disminuye su tamaño tomando como referencia el punto central de la misma. 144 Función Ceil ⚫ ⚫ ⚫ (Fig. 4.0.9b. Ceil va a retornar el texel que se encuentra en el centro de la textura) 4.1.0. Función Clamp. Esta función es muy útil cuando deseamos limitar el resultado de una operación. Por defecto, permite definir un valor dentro de un rango numérico, estableciendo un mínimo y un máximo. En la medida que desarrollemos funciones, nos encontraremos con algunas operaciones que dan como resultado un número menor a “cero” o mayor a “uno”, p. ej., en el cálculo del producto punto entre las normales del mesh y la dirección de iluminación, podemos obtener un rango entre -1.0f y 1.0f. Dado que un valor negativo generaría artefactos de color en el efecto final, con clamp( A ,X RG ,B RG RG ) podemos limitar y redefinir el rango. Su sintaxis es la siguiente: Función Clamp ⚫ ⚫ ⚫ float clamp (float a, float x, float b) { return max(a, min(x, b)); } float2 clamp (float2 a, float2 x, float2 b); float3 clamp (float3 a, float3 x, float3 b); float4 clamp (float4 a, float4 x, float4 b); 145 En la función anterior, el argumento A RG se refiere al valor mínimo de retorno, en cambio, B refiere al valor máximo de retorno dentro del rango. En cuanto al argumento X al valor que deseamos limitar según A RG RG yB RG será igual a B límite más alto para la operación. , corresponde , ¿qué quiere decir esto? Supondremos un rango RG es igual a 1.0f; si el argumento A máximo de retorno para X se RG establecido como mínimo y máximo, y un número variable para X Cuando X RG RG . RG es igual a 0.1f y B RG es igual a 0.9f, el valor , ¿por qué razón? Porque este último define el valor RG Función Clamp ⚫ ⚫ ⚫ (Fig. 4.1.0a) Así mismo ocurre en el caso contrario. Cuando X aA RG es igual a 0.0f, el valor de retorno será igual , esto debido a que este último define el valor mínimo en la función. RG Función Clamp ⚫ ⚫ ⚫ (Fig. 4.1.0b) 146 Para entender a profundidad el concepto haremos lo siguiente: Primero crearemos un nuevo shader tipo Unlit al cual llamaremos USB_function_CLAMP, y en sus Propiedades declararemos tres rangos flotantes; uno para cada argumento. Función Clamp ⚫ ⚫ ⚫ Shader "USB/USB_function_CLAMP" { Properties { _MainTex ("Texture", 2D) = "white" {} _Xvalue ("X", Range(0, 1)) = 0 _Avalue ("A", Range(0, 1)) = 0 _Bvalue ("B", Range(0, 1)) = 0 } } Luego declaramos las variables de globales o de conexión para conectarlas con el programa. Función Clamp ⚫ ⚫ ⚫ … Pass { … sampler2D _MainTex; float4 _MainTex_ST; float _Xvalue; float _Avalue; float _Bvalue; … } 147 El shader que acabamos de crear lo utilizaremos para aumentar o disminuir la gama de color de la textura principal, por lo tanto, en este punto podemos hacer dos cosas: 1 Generar una función simple dentro de nuestro programa que limite un valor dentro de un rango. 2 O utilizar la función intrínseca clamp que viene incluida en el lenguaje Cg/HLSL. Iniciaremos declarando una nueva función a la cual llamaremos ourClamp. Para ello, nos posicionamos entre el Vertex Shader y el Fragment Shader Stage. Función Clamp ⚫ ⚫ ⚫ v2f vert(appdata v) { … } float ourClamp(float a, float x, float b) { return max(a, min(x, b)); } fixed4 frag(v2f i) : SV_Target { … } Luego, en el Fragment Shader Stage crearemos una variable flotante a la cual llamaremos darkness y la haremos igual a nuestra nueva función. Como argumento pasaremos las propiedades que declaramos anteriormente, siguiendo el mismo orden de sintaxis. Función Clamp ⚫ ⚫ ⚫ float ourClamp(float a, float x, float b) { … } fixed4 frag(v2f i) : SV_Target { float darkness = ourClamp(_Avalue, _Xvalue, _Bvalue); fixed4 col = tex2D(_MainTex, i.uv); UNITY_APPLY_FOG(i.fogCoord, col); return col; } 148 La variable darkness es de tipo flotante-escalar, el cual significa que posee sólo una dimensión. Por lo tanto, si multiplicamos el “vector col” por la variable mencionada, serán afectados los canales RGBA de color. Función Clamp ⚫ ⚫ ⚫ float ourClamp(float a, float x, float b) { … } fixed4 frag(v2f i) : SV_Target { float darkness = ourClamp(_Avalue, _Xvalue, _Bvalue); fixed4 col = tex2D(_MainTex, i.uv) * darkness; UNITY_APPLY_FOG(i.fogCoord, col); return col; } La operación anterior puede ser simplificada a través de la función clamp incluida en el lenguaje que estamos aplicando. Función Clamp ⚫ ⚫ ⚫ // float ourClamp(float a, float x, float b) { … } fixed4 frag(v2f i) : SV_Target { float darkness = clamp(_Avalue, _Xvalue, _Bvalue); fixed4 col = tex2D(_MainTex, i.uv) * darkness; UNITY_APPLY_FOG(i.fogCoord, col); return col; } En conclusión, ahora podremos definir un máximo y mínimo de gama para el color de salida en el shader. 149 4.1.1. Función Sin y Cos. Estas funciones trigonométricas se refieren al seno y coseno de un ángulo, es decir: › La razón entre el cateto adyacente y la hipotenusa, en el caso de coseno. › Y a la razón entre el cateto opuesto y la hipotenusa, en el caso del seno. Su sintaxis es la siguiente: Función Sin y Cos ⚫ ⚫ ⚫ float cos (float n); float2 cos (float2 n); float3 cos (float3 n); float4 cos (float4 n); float sin (float n); float2 sin (float2 n); float3 sin (float3 n); float4 sin (float4 n); Tanto cos( N RG ) como sin( N RG ), pueden ser empleadas sobre valores escalares o vectores. Función Sin y Cos ⚫ ⚫ ⚫ (Fig. 4.1.1a. Representación gráfica de las funciones sobre un plano cartesiano. A la izquierda podemos ver sin( X ) y a la derecha cos( X )) 150 Estas funciones igualmente incluidas en el lenguaje Cg/HLSL son muy útiles en Computer Graphics, con ellas podemos generar múltiples figuras geométricas e incluso transformaciones de matrices. Un ejemplo práctico de implementación es la rotación de vértices en un objeto. Como ya sabemos, un vértice posee tres coordenadas de espacio (XYZ) consideradas como vectores. Dada su naturaleza, podemos transformar estos valores y general la ilusión de rotación desde una matriz. Supongamos la rotación de un vértice en un espacio bidimensional. Si aplicamos la función “sin” sobre su eje Y , obtendremos un movimiento ondulatorio que irá desde arriba hacia abajo. Si a AX ello le aplicamos la función “cos” sobre su eje X , reproduciremos un movimiento circular. AX Función Sin y Cos ⚫ ⚫ ⚫ (Fig. 4.1.1b. La rotación se produce principalmente por el desfase entre sin y cos) Para entender el concepto haremos lo siguiente: crearemos un nuevo shader tipo Unlit al cual llamaremos USB_function_SINCOS. Utilizaremos este shader para generar una pequeña animación de rotación en los vértices de un Cubo. Iniciaremos declarando un rango flotante el cual utilizaremos posteriormente en la operación para determinar la velocidad de rotación. 151 Función Sin y Cos ⚫ ⚫ ⚫ Shader "USB/USB_function_SINCOS" { Properties { _MainTex ("Texture", 2D) = "white" {} _Speed ("Rotation Speed", Range(0, 3)) = 1 } SubShader { … } } Necesitamos la ayuda de una matriz para rotar coordenadas; en la sección 3.2.7 pudimos revisar su estructura empleando matrices de distintas dimensiones. En esta oportunidad usaremos una matriz de tres por tres dimensiones para generar una rotación sobre nuestro objeto. Función Sin y Cos ⚫ ⚫ ⚫ Pass { … sampler2D _MainTex; float4 _MainTex_ST; float _Speed; // agregamos nuestra función de rotación float3 rotation(float3 vertex) { // creamos una matriz de 3x3 dimensiones float3x3 m = float3x3 ( 1, 0, 0, 0, 1, 0, 0, 0, 1 ); Continúa en la siguiente página. 152 // multiplicamos la matriz por el input de vértices return mul(m, vertex); } … } La función rotation retorna un vector de tres dimensiones. Por sí sola no realiza ninguna acción específica debido a que la matriz m posee solo sus valores de identidad. Sin embargo, podemos utilizarla posteriormente para transformar los vértices de un objeto en el Vertex Shader Stage. Iniciaremos seleccionado un eje de rotación, para eso prestaremos atención al siguiente diagrama. Función Sin y Cos ⚫ ⚫ ⚫ (Fig. 4.1.1c. Ejes de rotación en una matriz de 4x4) En la Figura anterior, cada una de las matrices representa un eje de transformación de rotaciones para un objeto. En esta oportunidad desarrollaremos el ejercicio empleando el RY-axis que se aprecia en la Figura 4.1.1c, por ende tendremos que agregar dos variables funciones trigonométricas sin( N flotantes RG en ) y cos( N RG 153 el ). método rotation, aplicando las Función Sin y Cos ⚫ ⚫ ⚫ float3 rotation(float3 vertex) { // agregamos las variables de rotation float c = cos(_Time.y * _Speed); float s = sin(_Time.y * _Speed); // creamos una matriz de 3x3 dimensiones float3x3 m = float3x3 ( c, 0, s, 0, 1, 0, -s, 0, c ); // multiplicamos la matriz por el input de vértices return mul(m, vertex); } Más adelante, en la sección 4.2.4 hablaremos de la propiedad _Time en detalle, por ahora solo tomaremos en consideración que esta variable agrega tiempo a la operación, muy similar al comportamiento de Time.timeSinceLevelLoad en C#. _Time va a influenciar los vértices del Cubo en nuestra escena, por ende comenzarán a moverse según su eje de rotación. Hasta este punto, la función rotation ya puede operar perfectamente. A continuación debemos implementarla en el Vertex Shader Stage. Para este fin debemos tomar en consideración la función UnityObjectToClipPos( V RG ) dado que; como se explicó en la sección 3.3.2, permite transformar los vértices de un objeto desde Object-Space a Clip-Space. Por consiguiente habrá que implementar la rotación de los vértices antes de transformar sus coordenadas en posición de pantalla. 154 Función Sin y Cos ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; float3 rotVertex = rotation(v.vertex); o.vertex = UnityObjectToClipPos(rotVertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } En el ejercicio anterior se ha declarado un nuevo vector de tres dimensiones llamado rotVertex. En él se ha almacenado el input de vértices del Mesh y su rotación en Object-Space. Posteriormente, se ha utilizado dicho vector como argumento en la función UnityObjectToClipPos( V RG ). Si volvemos a Unity y presionamos el botón Play, podremos ver la rotación de los vértices del Cubo en su conjunto. 4.1.2. Función Tan. Esta función trigonométrica se refieren a la tangente de un ángulo, o sea: › La razón entre el cateto opuesto y el cateto adyacente. Su sintaxis es la siguiente: Función Tan ⚫ ⚫ ⚫ float tan (float n); float2 tan (float2 n); float3 tan (float3 n); float4 tan (float4 n); 155 Función Tan ⚫ ⚫ ⚫ (Fig. 4.1.2a. Representación gráfica de la función tan( X ) sobre un plano cartesiano) Al igual que sin y cos; tan( N RG ) es muy útil en el cálculo de figuras geométricas y patrones de repetición. Un ejemplo práctico de implementación es la generación de una máscara procedural con forma de rejilla, la cual podemos utilizar para generar el efecto de proyección holográfica sobre un objeto. Para el fin, simplemente podemos calcular el valor absoluto de la tangente en alguna de las coordenadas UV, dentro del Fragment Shader Stage. Función Tan ⚫ ⚫ ⚫ (Fig. 4.1.2b) Ejemplificaremos generando un nuevo shader de tipo Unlit al cual llamaremos USB_function_ TAN. Iniciaremos declarando una Propiedad de color y un rango para aumentar o disminuir la cantidad de líneas que deseamos proyectar. 156 Función Tan ⚫ ⚫ ⚫ Shader "USB/USB_function_TAN" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1, 1, 1, 1) _Sections ("Sections", Range(2, 10)) = 10 } SubShader { … } } Cabe destacar que este efecto lo aplicaremos sobre objetos 3D incluidos en el software. La razón por la que se hace mención a este punto se debe a la operación que realizaremos sobre la coordenada V de los UV. Como podemos apreciar en la Figura 4.1.2b, tal efecto posee transparencia, por consiguiente tendremos que agregar opciones de Blending en el SubShader, así el color negro será reconocido como un canal Alpha en el mismo. Función Tan ⚫ ⚫ ⚫ SubShader { Tags {"RenderType"="Transparent" "Queue"="Transparent"} Blend SrcAlpha OneMinusSrcAlpha Pass { … } } Nos aseguraremos de declarar las variables globales y luego iremos al Fragment Shader Stage para agregar la funcionalidad que va a permitir proyectar las líneas horizontales en el objeto. 157 Función Tan ⚫ ⚫ ⚫ float4 _Color; float _Sections; fixed4 frag (v2f i) : SV_Target { float4 tanCol = abs(tan(i.uv.y * _Sections)); tanCol *= _Color; fixed4 col = tex2D(_MainTex, i.uv) * tanCol; return col; } En el ejemplo anterior se ha declarado un vector de cuatro dimensiones llamado tanCol, en él se ha guardado el resultado del valor absoluto para el cálculo de la tangente, del producto entre la coordenada V de los UV y la propiedad _Sections. Posteriormente, se ha guardado el factor entre la textura y el vector tanCol, en el vector de cuatro dimensiones denominado col. Por lo tanto, la textura que asignemos en la propiedad _MainTex se verá interlineada. Un pequeño detalle que podemos encontrar en la operación anterior es que la tangente de V retorna un rango numérico menor a 0.0f y mayor a 1.0f, por consiguiente, el color final se verá saturado en la pantalla de nuestro ordenador. Para solucionar el problema podemos hacer uso de la función clamp( A ,X RG ,B RG RG ), limitando los valores entre “cero y uno”. Función Tan ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { float4 tanCol = clamp(0, abs(tan(i.uv.y * _Sections)), 1); tanCol *= _Color; fixed4 col = tex2D(_MainTex, i.uv) * tanCol; return col; } Utilizando _Time podemos volver a generar movimiento, esta vez en el interlineado. Para ello simplemente debemos restar o sumar esta variable a la coordenada V en la operación. 158 Función Tan ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { float4 tanCol = clamp(0, abs(tan((i.uv.y - _Time.x) * _Sections)), 1); tanCol *= _Color; fixed4 col = tex2D(_MainTex, i.uv) * tanCol; return col; } 4.1.3. Función Exp, Exp2 y Pow. Estas funciones se caracterizan por emplear exponentes en sus operaciones, p. ej., la función exp( N RG ) retorna el exponencial de base-e en valores escalares y vectores, es decir 2.71828182846f elevado a un número. exp (2) = 7.3890560986f Es lo mismo que expresar 2.71828182846f 2 Su sintaxis es la siguiente: Función Exp, Exp2 y Pow ⚫ ⚫ ⚫ float exp (float n) { float e = 2.71828182846; float en = pow (e, n); return en; } float2 exp (float2 n); float3 exp (float3 n); float4 exp (float4 n); 159 Función Exp, Exp2 y Pow ⚫ ⚫ ⚫ (Fig. 4.1.3a. Representación gráfica de exp( X ) en un plano cartesiano) En cambio, exp2( N RG ) retorna el exponente de base-2 en valores de distintas dimensiones, es decir, dos elevado a un número. 3 exp2 (3) = 8 Es lo mismo expresar 2 exp2 (4) = 16 exp2 (5) = 32 Función Exp, Exp2 y Pow ⚫ ⚫ ⚫ float exp2 (float n); float2 exp2 (float2 n); float3 exp2 (float3 n); float4 exp2 (float4 n); Por otra parte, pow( X ,N RG RG pow (3, 2) = 9 ) posee dos argumentos: El número base y su exponente. 2 Es lo mismo expresar 3 pow (2, 2) = 4 pow (4, 2) = 16 160 Función Exp, Exp2 y Pow ⚫ ⚫ ⚫ float pow (float x, float n); float2 pow (float2 x, float2 n); float3 pow (float3 x, float3 n); float4 pow (float4 x, float4 n); La utilidad de estas funciones va a depender de la operación que estemos realizando. Sin embargo, son utilizadas generalmente en el cálculo de ruido, aumento de gama en el color de salida y patrones de repetición. 4.1.4. Función Floor. Esta función retorna un valor entero no mayor a su argumento, es decir, un número escalar o vector sin decimales, redondeado hacia el piso, p. ej., floor de 1.97f retorna “uno”, ¿Por qué razón? Esta función resta los decimales de un número al total del mismo. floor (1.56) = 1 Es lo mismo expresar 1.56f - 0.56f. floor (0.34) = 0 floor (2.99) = 2 Su sintaxis es la siguiente: Función Floor ⚫ ⚫ ⚫ float floor (float n) { float fn; fn = n - frac(n); return fn; } float2 floor (float2 n); float3 floor (float3 n); float4 floor (float4 n); 161 Función Floor ⚫ ⚫ ⚫ (Fig. 4.1.4a Representación gráfica de floor( X ) en un plano cartesiano) Contraria a la función ceil( N RG ), floor( N RG ) es bastante útil cuando deseamos crear efectos con bloques sólidos de color, p. ej., toon shader o patrones de repetición en general. Para ejemplificar su funcionalidad, vamos a entender el principio de implementación de un shader estilo toon. En tal sentido generaremos un nuevo shader tipo Unlit al cual llamaremos USB_functions_FLOOR. Iniciaremos agregando dos Propiedades en nuestro shader: una para generar múltiples divisiones y otra para aumentar la gama en el color de salida. Función Floor ⚫ ⚫ ⚫ Shader "USB/USB_function_FLOOR" { Properties { _MainTex ("Texture", 2D) = "white"{} [IntRange]_Sections ("Sections", Range (2, 10)) = 5 _Gamma ("Gamma", Range (0, 1)) = 0 } SubShader { … } } 162 Dado que las secciones se deben agregar de manera uniforme, se ha definido a [IntRange] para la variable _Sections. Al igual que en procesos anteriores, debemos asegurarnos de incluir las variables globales dentro del pase, así lograremos una comunicación directa entre ShaderLab y nuestro programa. Función Floor ⚫ ⚫ ⚫ Pass { CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; float _Sections; float _Gamma; … ENDCG } A continuación iremos al Fragment Shader Stage y declararemos una nueva variable, la cual se encargará de generar bloques de color sólido según la coordenada V de los UV. Función Floor ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { float fv = floor(i.uv.y); fixed4 col = tex2D(_MainTex, i.uv); return col; } En la operación anterior se ha declarado e inicializado la variable fv la cual posee sólo una dimensión. Su valor es igual al resultado de floor( V ), por ende, es igual a “cero.” Esto se debe a que las coordenadas UV inician en 0.0f y finalizan en 1.0f, y como ya sabemos, esta función retorna un número entero no mayor a su argumento. Podemos corroborar la operación asignando un vector de cuatro dimensiones como output, donde los tres primeros sean iguales al valor de fv. 163 Función Floor ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { float fv = floor(i.uv.y); // fixed4 col = tex2D(_MainTex, i.uv); // return col; return float4(fv.xxx, 1); } Función Floor ⚫ ⚫ ⚫ (Fig. 4.1.4b) Para agregar bloques de color sólido, simplemente podemos multiplicar la operación por una cierta cantidad de secciones y luego dividir por un valor decimal. En cuanto a la gama, podemos sumar la propiedad directamente en el color de salida. Función Floor ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { float fv = floor(i.uv.y * _Sections) * (_Sections/ 100.0); // fixed4 col = tex2D(_MainTex, i.uv); // return col; return float4(fv.xxx, 1) + _Gamma; } 164 Función Floor ⚫ ⚫ ⚫ (Fig. 4.1.4c. El material ha sido configurado con seis secciones y 0.1 7f de gama) Para un shader toon, el principio de implementación es el mismo, con la diferencia que utilizamos la iluminación global en el cálculo, en vez de la coordenada V de los UV. En el segundo capítulo, sección 7.0.3, revisaremos en detalle la implementación de iluminación personalizada en un shader cuando hablemos de reflexión difusa. 4.1.5. Función Step y Smoothstep. Step y smoothstep son funciones bastante similares entre sí, de hecho, ambas poseen un argumento denominado “edge” encargado de diferenciar el retorno entre dos valores. profundidad el funcionamiento de smoothstep( A más elaborada en comparación a la anterior. “ RG ,B RG RG ,E ,E RG RG ) para luego abordar en ), quien posee una estructura Según la documentación oficial en NVIDIA; Step puede retornar uno para cada componente de x mayor que o igual al edge. En el caso contrario retorna cero. 165 “ Iniciaremos nuestro estudio en la compresión de step( X Su sintaxis es la siguiente. Función Step y Smoothstep ⚫ ⚫ ⚫ float step (float edge, float x) { return edge >= x; } float2 step (float2 edge, float2 x); float3 step (float3 edge, float3 x); float4 step (float4 edge, float4 x); Ejemplificando el enunciado propuesto por NVIDIA, podemos realizar una operación simple en el Fragment Shader Stage para entender su funcionamiento. Función Step y Smoothstep ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // agregamos el borde de color float edge = 0.5; // agregamos el color de retorno en RGB fixed3 sstep = 0; sstep = step (edge, i.uv.y); // fixed4 col = tex2D (_MainTex, i.uv); return fixed4(sstep, 1); } Iniciamos declarando un vector de tres dimensiones llamado sstep. Como argumento se ha utilizado la coordenada V de los UV, y 0.5f como edge. Podemos apreciar su representación gráfica en la Figura 4.1.5a. Finalmente, hemos retornado sstep en RGB y “uno” para el canal Alpha. 166 Función Step y Smoothstep ⚫ ⚫ ⚫ (Fig. 4.1.5a. La función step empleada sobre algunas figuras primitivas) Cabe recordar que, tanto la coordenada U como V, empiezan en 0.0f y finalizan en 1.0f, por lo tanto, todos aquellos menores o iguales al E caso contrario (color negro). , retornarán “uno” (color blanco), y “cero” en el RG El comportamiento de la función smoothstep no es muy distinta de la anterior, su única diferencia radica en la generación de una interpolación lineal entre los valores de retorno. Su sintaxis es la siguiente. Función Step y Smoothstep ⚫ ⚫ ⚫ float smoothstep (float a, float b, float edge) { float t = saturate((edge - a) / (b - a)); return t * t * (3.0 - (2.0 * t)); } float2 smoothstep (float2 a, float2 b, float2 edge) float3 smoothstep (float3 a, float3 b, float3 edge) float4 smoothstep (float4 a, float4 b, float4 edge) Volviendo a la operación en el Fragment Shader Stage, podríamos agregar una nueva variable para determinar la cantidad de interpolación entre los valores de retorno. 167 Función Step y Smoothstep ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // agregamos el borde de color float edge = 0.5; // agregamos la cantidad de interpolación float smooth = 0.1; // agregamos el color de retorno en RGB fixed3 sstep = 0; // sstep = step (edge, i.uv.y); sstep = smoothstep((i.uv.y - smooth), (i.uv.y + smooth), edge); // fixed4 col = tex2D (_MainTex, i.uv); return fixed4(sstep, 1); } En el ejercicio anterior, podríamos modificar el valor de “smooth” entre 0.0f y 0.5f para obtener distintos niveles de interpolación. Función Step y Smoothstep ⚫ ⚫ ⚫ (Fig. 4.1.5b. Smooth igual a 0.1f) 168 4.1.6. Función Length. Como su título lo menciona, length( N RG ) se refiere a la magnitud que expresa la distancia entre dos puntos. Esta función es muy útil al momento de crear figuras geométricas, p. ej., podemos generar círculos o figuras poligonales con bordes redondeados con ella. Su sintaxis es la siguiente: Función Length ⚫ ⚫ ⚫ float length (float n) { return sqrt(dot(n,n)); } float length (float2 n); float length (float3 n); float length (float4 n); Como acostumbramos, crearemos un nuevo shader tipo Unlit al cual llamaremos USB_ function_LENGTH. En esta oportunidad emplearemos algunas funciones para representar un círculo en nuestro programa. Iniciaremos agregando algunas Propiedades que utilizaremos posteriormente para aumentar, centrar y suavizar el círculo. Función Length ⚫ ⚫ ⚫ Shader "USB/USB_function_LENGTH" { Properties { _MainTex ("Texture", 2D) = "white" {} _Radius ("Radius", Range(0.0, 0.5)) = 0.3 _Center ("Center", Range(0, 1)) = 0.5 _Smooth ("Smooth", Range(0.0, 0.5)) = 0.01 } SubShader { Continúa en la siguiente página. 169 … Pass { … float _Smooth; float _Radius; float _Center; … } } } Para crear un círculo, simplemente debemos calcular la magnitud de las coordenadas UV y restar el radio. Su representación sería igual a la siguiente: Función Length ⚫ ⚫ ⚫ float circle (float2 p, float radius) { // creamos el círculo float c = length(p) - radius; return c; } Sin embargo, como podemos apreciar en la Figura 4.1.6a, la operación anterior retorna un círculo difuminado que inicia en el punto 0.0f y termina en 1.0f, y el resultado que estamos buscando corresponde a un círculo centrado y más compacto. 170 Función Length ⚫ ⚫ ⚫ (Fig. 4.1.6a. El círculo se difumina en la medida que aumenta el valor de las coordenadas UV) Para ello podemos agregar un nuevo argumento en la función que nos permita centrar el círculo en el objeto, al cual le estamos aplicando el material. Función Length ⚫ ⚫ ⚫ float circle (float2 p, float center, float radius) { // el argumento "center" corresponde a un rango entre 0.0f y 1.0f float c = length(p - center) - radius; return c; } Función Length ⚫ ⚫ ⚫ (Fig. 4.1.6b. Center igual a 0.5f) Sin embargo, el círculo continuará difuminado. Si deseamos controlar la cantidad de difuminación podemos utilizar la función smoothstep que, como ya sabemos, genera una interpolación lineal entre dos valores. 171 Función Length ⚫ ⚫ ⚫ float circle (float2 p, float center, float radius, float smooth) { float c = length(p - center); return smoothstep(c - smooth, c + smooth, radius); } En la operación anterior se ha agregado un nuevo argumento llamado smooth el cual controlará la cantidad de difuminación. A continuación podemos aplicar esta función en el Fragment Shader Stage de la siguiente manera. Función Length ⚫ ⚫ ⚫ float circle (float2 p, float center, float radius, float smooth) { … } fixed4 frag (v2f i) : SV_Target { float c = circle (i.uv, _Center, _Radius, _Smooth); return float4(c.xxx, 1); } Como podemos apreciar, se ha creado una variable de una dimensión llamada c la cual se ha inicializado con los valores por defecto del círculo. Es fundamental prestar atención al output de color debido a que se está utilizando un mismo canal (R) para los tres de salida (c.xxx). Función Length ⚫ ⚫ ⚫ (Fig. 4.1.6c. Radius 0.3f, Center 0.5f y Smooth 0.023f) 172 4.1.7. Función Frac. Esta función retorna la fracción de un valor, es decir, sus valores decimales, p. ej., frac(.1.534.) retorna 0.534f, ¿Por qué razón? Esto se debe a la operación que se realiza en la función. frac (3.27) = 0.27f Es lo mismo expresar 3.27f - 3. frac (0.47) = 0.47f frac (1.0) = 0.0f Su sintaxis es la siguiente: Función Frac ⚫ ⚫ ⚫ float frac (float n) { return n - floor(n); } float2 frac (float2 n); float3 frac (float3 n); float4 frac (float4 n); El método frac( N RG ) es utilizado en múltiples operaciones. Puede ser empleada en el cálculo de ruido, patrones de repetición aleatorios y mucho más. Para entender el concepto haremos lo siguiente: 1 Crearemos un nuevo shader de tipo Unlit el cual llamaremos USB_function_FRAC. 2 Iniciaremos agregando una Propiedad a la cual llamaremos _Size y la usaremos posteriormente para aumentar o disminuir el tamaño de un círculo. 173 Función Frac ⚫ ⚫ ⚫ Shader "USB/USB_function_FRAC" { Properties { _MainTex ("Texture", 2D) = "white" {} _Size ("Size", Range(0.0, 0.5)) = 0.3 } SubShader { … Pass { … float _Size; … } } } La primera operación que realizaremos será multiplicar las coordenadas UV para obtener un patrón de repetición en el Fragment Shader Stage. Función Frac ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // aumentamos la cantidad de repeticiones de la textura i.uv *= 3; fixed4 col = tex2D(_MainTex, i.uv); return col; } 174 Si volvemos a Unity y asignamos una textura configurada en Clamp obtendremos un resultado similar a la Figura 4.1.7a. En ella podremos notar que los texels de los bordes en la imagen se estiran a lo largo de la misma. Función Frac ⚫ ⚫ ⚫ (Fig. 4.1.7a. La imagen de la izquierda presenta coordenadas UV por defecto, en cambio, las imágenes de la derecha presentan las mismas coordenadas multiplicadas por tres. El Wrap Mode corresponde a Clamp) En este caso, podríamos utilizar la función frac para retornar el valor fraccionario de las coordenadas UV y así generar un patrón de repetición definido. Función Frac ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // aumentamos la cantidad de repeticiones de la textura i.uv *= 3; float2 fuv = frac(i.uv); fixed4 col = tex2D(_MainTex, fuv); return col; } 175 Función Frac ⚫ ⚫ ⚫ (Fig. 4.1.7b) Cabe mencionar que no es muy útil realizar esta operación sobre una textura, ya que fácilmente la podemos configurar en modo Repeat desde el Inspector. Para un ejemplo más práctico, vamos a generar un patrón a lo largo de la textura utilizando un círculo. Función Frac ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // aumentamos la cantidad de repeticiones de la textura i.uv *= 3; float2 fuv = frac(i.uv); // generamos el círculo float circle = length(fuv - 0.5); // volteamos los colores y retornamos un valor entero float wCircle = floor(_Size / circle); // fixed4 col = tex2D(_MainTex, fuv); return float4(wCircle.xxx, 1); } Si prestamos atención al valor de retorno, notaremos que se está utilizando un mismo canal para los colores de salida en RGB (wCircle.xxx). Si modificamos el valor de la propiedad _Size podremos aumentar o disminuir el tamaño de los círculos. 176 Función Frac ⚫ ⚫ ⚫ (Fig. 4.1.7c. La Propiedad _Size ha sido configurada en 0.3f) 4.1.8. Función Lerp. Como su nombre lo indica, esta función permite realizar una interpolación lineal entre dos valores y es comúnmente empleada en transiciones de color, p. ej., podríamos utilizar lerp( A ,B RG ,N RG RG ) en alguno de nuestros personajes para pasar de una piel a otra a través de un desvanecimiento cruzado. Su sintaxis es la siguiente: Función Lerp ⚫ ⚫ ⚫ float lerp (float a, float b, float n) { return a + n * (b - a); } float2 lerp (float2 a, float2 b, float2 n); float3 lerp (float3 a, float3 b, float3 n); float4 lerp (float4 a, float4 b, float4 n); Como hemos hecho anteriormente, crearemos un pequeño shader de tipo Unlit al cual llamaremos USB_function_LERP para ejemplificar la función. Iniciaremos declarando dos Texturas que usaremos posteriormente como “pieles” en el efecto, más un rango numérico para efectuar el desvanecimiento cruzado. 177 Función Lerp ⚫ ⚫ ⚫ Shader "USB/USB_function_LERP" { Properties { _Skin01 ("Skin 01", 2D) = "white" {} _Skin02 ("Skin 02", 2D) = "white" {} _Lerp ("Lerp", Range(0, 1)) = 0.5 } SubShader { … Pass { … sampler2D _Skin01; float4 _Skin01_ST; sampler2D _Skin02; float4 _Skin02_ST; float _Lerp; … } } } Dado que emplearemos dos texturas para el efecto, será necesario la utilización de coordenadas UV en cada caso. Estas deberán ser declaradas tanto en el Vertex Input como Output por dos razones fundamentales: 1 Las texturas serán afectadas por el Tiling y Offset a través de la función TRANSFORM_TEX. 2 Las utilizaremos posteriormente en el Fragment Shader Stage. 178 Función Lerp ⚫ ⚫ ⚫ struct appdata { float4 vertex : POSITION; // creamos las coordenadas UV para cada piel 01 y 02 float2 uv_s01 : TEXCOORD0; float2 uv_s02 : TEXCOORD1; }; struct v2f { float4 vertex : SV_POSITION; // utilizaremos las coordenadas UV en el fragment shader stage float2 uv_s01 : TEXCOORD0; float2 uv_s02 : TEXCOORD1; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); // configuramos el tiling y offset para cada caso o.uv_s01 = TRANSFORM_TEX(v.uv_s01, _Skin01); o.uv_s02 = TRANSFORM_TEX(v.uv_s02, _Skin02); return o; } En el Fragment Shader Stage podemos declarar dos vectores de cuatro dimensiones y utilizar la función tex2D( S , UV RG RG ) en cada caso, para luego realizar una interpolación lineal entre ambos mediante la función lerp( A ,B RG ,N RG RG ). 179 Función Lerp ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // creamos un vector para cada piel fixed4 skin01 = tex2D(_Skin01, i.uv_s01); fixed4 skin02 = tex2D(_Skin02, i.uv_s02); // realizamos una interpolación lineal entre cada color fixed4 render = lerp(skin01, skin02, _Lerp); return render; } Si prestamos atención a la propiedad _Lerp, notaremos que su valor ha sido limitado entre 0.0f y 1.0f. Por defecto posee el valor 0.5f, por ende, si asignamos dos texturas distintas en cada propiedad de _Skin[n], el resultado será igual a una transparencia o desvanecimiento del 50 % en cada caso. Función Lerp ⚫ ⚫ ⚫ (Fig. 4.1.8a. Lerp entre dos texturas) 180 4.1.9. Función Min y Max. Por una parte, min se refiere al valor mínimo entre dos vectores o escalares, mientras que max es lo contrario. Estas funciones las utilizaremos frecuentemente en distintas operaciones, p. ej., podemos utilizar max( A RG ,B RG ) para calcular la difusión sobre un objeto, retornando el máximo entre “cero”, y el producto punto entre las normales del Mesh y la dirección de la luz. Su sintaxis es la siguiente: Función Min y Max ⚫ ⚫ ⚫ // si "a" es menor que "b", retorna "a", de otra manera retorna "b" float min (float a, float b) { float n = (a < b ? a : b); return n; } float2 min (float2 a, float2 b); float3 min (float3 a, float3 b); float4 min (float4 a, float4 b); Función Min y Max ⚫ ⚫ ⚫ // si "a" es mayor que "b", retorna "a", de otra manera retorna "b" float max (float a, float b) { float n = (a > b ? a : b); return n; } float2 max (float2 a, float2 b); float3 max (float3 a, float3 b); float4 max (float4 a, float4 b); Más adelante, en el capítulo II, sección 7.0.3, revisaremos esta función en detalle cuando hablemos de reflexión difusa. 181 4.2.0. Tiempo y animación. En Unity existen tres Built-in Shader Variables que utilizaremos con frecuencia en la animación de propiedades para nuestros efectos. Estas se refieren a: › _Time. › _SinTime. › y _CosTime. Tales variables se caracterizan por ser vectores de cuatro dimensiones, en donde cada una representa un nivel de velocidad, p. ej., _Time.y es igual al tiempo (en segundos) que transcurre desde que la escena o nivel ha sido cargado, similar al funcionamiento de la función Time.timeSinceLevelLoad; mientras que _Time.x corresponde al mismo valor, dividido por veinte. Su sintaxis es la siguiente: Tiempo y animación ⚫ ⚫ ⚫ // "t" time in seconds. _Time.x = t / 20; _Time.y = t; _Time.z = t * 2; _Time.w = t * 3; Las variables _CosTime y _SinTime poseen el mismo comportamiento dado que corresponden a funciones simplificadas de _Time, p. ej., _CosTime.w es igual a la operación cos( _Time.y ); ambas retornan en mismo resultado, así mismo para sin( _Time.y ). Su sintaxis es la siguiente: 182 Tiempo y animación ⚫ ⚫ ⚫ _SinTime.x = t / 8; _SinTime.y = t / 4; _SinTime.z = t / 3; _SinTime.w = t; // sin(_Time.y); _CosTime.x = t / 8; _CosTime.y = t / 4; _CosTime.z = t / 3; _CosTime.w = t; // cos(_Time.y); Profundizando el concepto, podríamos utilizar la función _Time para generar la animación de offset que aparece en los íconos de Super Mario. A tal efecto, tendríamos que agregar tiempo a la coordenada U de los UV, en el Fragment Shader Stage. Tiempo y animación ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // agregamos tiempo a la coordenada U i.uv.x += _Time.y; fixed4 col = tex2D(_MainTex, i.uv); return col; } Tiempo y animación ⚫ ⚫ ⚫ (Fig. 4.2.0a. Offset en U sobre un Quad) 183 O también podríamos generar una animación de rotación mediante _SinTime y _CosTime, agregando movimiento tanto en U como en V. Tiempo y animación ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // agregamos tiempo a la coordenada U i.uv.x += _SinTime.w; // agregamos tiempo a la coordenada V i.uv.y += _CosTime.w; fixed4 col = tex2D(_MainTex, i.uv); return col; } Tiempo y animación ⚫ ⚫ ⚫ (Fig. 4.2.0b. Rotación offset tanto en U como en V sobre un Quad. El tiling es igual a 4) 184 Capítulo II Iluminación, sombras y superficies. Introducción al capítulo. Uno de los conceptos más complejos en Computer Graphics es precisamente el cálculo de iluminación, sombras y superficies. Las operaciones que debemos realizar para obtener un buen resultado dependen de varias funciones o propiedades, que en la mayoría de los casos, son demasiado técnicas y requieren de un nivel alto de entendimiento matemático. Iniciaremos detallando la teoría detrás de cada concepto, para luego, dar paso a la implementación de funciones en lenguaje HLSL. Cabe mencionar que en Unity contamos con algunos programas predefinidos que facilitan el cálculo de iluminación sobre una superficie (p. ej. Standard Surface, Lit Shader Graph) sin embargo, será fundamental continuar nuestro estudio utilizando shaders de tipo Unlit, dado que al ser un modelo básico de color, nos permitirá implementar nuestras propias funciones, y así, obtener la comprensión necesaria para generar iluminación personalizada y sus derivados, ya sea en Built-in RP o Scriptable RP. 5.0.1. Configurando inputs y outputs. En el capítulo anterior; en la sección 3.3.0, pudimos entender la analogía entre la propiedad de un objeto poligonal y una semántica. Así mismo, pudimos ver como esta última es inicializada dentro de un struct. En esta sección detallaremos los pasos necesarios para configurar las normales de nuestro objeto y transformar sus coordenadas desde Object-Space a World-Space. Como ya se sabe, si deseamos trabajar con las normales de nuestro objeto entonces tendremos que almacenar la semántica NORMAL[n] en un vector de tres dimensiones. El primer paso sería incluir este valor en el Vertex Input como se muestra en el siguiente ejemplo. 186 Configurando inputs y outputs ⚫ ⚫ ⚫ struct appdata { … float3 normal : NORMAL; }; Recordemos que la cuarta dimensión de un vector corresponde a su componente W, el cual, en el caso de una Normal, su valor por defecto es “cero” dado que es una dirección en el espacio. Una vez que hemos declarado las normales de nuestro objeto como input, debemos formular la siguiente pregunta: ¿Vamos a necesitar pasar estos valores al Fragment Shader Stage? Si la respuesta es afirmativa, entonces tendremos que volver a declarar las normales, pero esta vez como Output. Esta analogía se aplica de igual manera a todos los Inputs y Outputs que utilicemos en nuestro programa. Configurando inputs y outputs ⚫ ⚫ ⚫ struct v2f { … float3 normal : TEXCOORD1; }; ¿Por qué razón hemos declarado las normales tanto en el Vertex Input como en el Vertex Output? Esto se debe a que tendremos que conectar ambas propiedades dentro del Vertex Shader Stage, para luego, pasarlas al Fragment Shader Stage. Cabe mencionar que, según la documentación oficial de HLSL, no existe la semántica NORMAL para el Fragment Shader Stage, por ende, tendremos que utilizar una semántica que pueda almacenar al menos tres coordenadas de espacio. Debido a esto se ha utilizado TEXCOORD1 en el ejemplo anterior. Tal semántica posee cuatro dimensiones (XYZW) y es ideal para trabajar con normales. Luego de haber declarado una propiedad tanto en el Vertex Input como Output, podemos ir al Vertex Shader Stage para conectarlas. 187 Configurando inputs y outputs ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; … // conectamos output con input o.normal = v.normal; … return o; } En el ejemplo anterior conectamos el output de normales del struct v2f con el input de Normales del struct appdata. Recordemos que v2f es utilizado como argumento en el Fragment Shader Stage, por lo tanto, podremos utilizar las normales del objeto como propiedad dentro de esta etapa. Para abordar el concepto crearemos una función a modo de ejemplo, la cual llamaremos unity_light y se encargará de calcular la iluminación sobre una superficie en el Fragment Shader Stage. En sí misma, esta función no va a realizar ninguna operación real, sin embargo, nos va a ayudar en el entendimiento de algunos factores que debemos considerar. Configurando inputs y outputs ⚫ ⚫ ⚫ void unity_light (in float3 normals, out float3 Out) { Out = [Op] (normals); } Según su declaración podemos observar que unity_light es una función vacía; que posee dos argumentos: Las Normales del objeto y el valor de salida. Cabe mencionar que todas las operaciones para el cálculo de iluminación, requieren a las Normales como una de sus variables, ¿Por qué razón? Porque sin estas, el programa no sabrá cómo la luz debe interactuar con la superficie del objeto. 188 Implementaremos la función unity_light en el Fragment Shader Stage. Configurando inputs y outputs ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // almacenamos las normales en un vector half3 normals = i.normal; // inicializamos nuestra luz en negro half3 light = 0; // inicializamos nuestra función y pasamos los vectores unity_light(normals, light); return float4(light.rgb, 1); } Si prestamos atención al ejercicio anterior, notaremos que el output de Normales (i.normal) se encuentra en Object-Space, ¿Cómo sabemos esto? Podemos determinarlo con facilidad debido a que no se ha generado ningún tipo de transformación de matriz hasta este punto. Las operaciones para el cálculo de iluminación deben estar en World-Space, ¿Por qué razón? Porque los valores de incidencia se encuentran en el mundo; dentro de una escena. Asimismo, los objetos poseen una posición según el centro de una cuadrícula, por lo tanto, tendremos que transformar las coordenadas de espacio de las Normales en el Fragment Shader Stage. Para ello, podemos realizar la siguiente operación: 189 Configurando inputs y outputs ⚫ ⚫ ⚫ // creamos una nueva función half3 normalWorld (half3 normal) { return normalize(mul(unity_ObjectToWorld, float4(normal, 0))).xyz; } half4 frag (v2f i) : SV_Target { // almacenamos las normales world-space en un vector float3 normals = normalWorld(i.normal); float3 light = 0; unity_light(normals, light); return float4(light.rgb, 1); } En el ejemplo anterior, la función normalWorld retorna la transformación de coordenadas de espacio para las Normales. En su proceso, utiliza la matriz unity_ObjectToWorld, la cual nos permite transformar coordenadas desde Object-Space a World-Space. Configurando inputs y outputs ⚫ ⚫ ⚫ (Fig. 5.0.1a) Considerando la función de transformación, ¿Por qué las Normales se encuentran dentro de un vector de cuatro dimensiones en la multiplicación de la función normalWorld? Como ya sabemos corresponden a una dirección en el espacio, es decir que su componente W debe ser igual a “cero”. unity_ObjectToWorld es una matriz de cuatro por cuatro dimensiones, por tanto, como resultado obtendremos una nueva dirección para las normales en sus cuatro canales XYZW. 190 En la transformación debemos asegurarnos de asignar el valor “cero” al componente W, debido a que, por reglas aritméticas, cualquier número multiplicado por cero, da cero como resultado. El proceso mencionado anteriormente también puede ser llevado a cabo en el Vertex Shader Stage. La operación es básicamente la misma, con la diferencia que, si lo hacemos en esta etapa, habrá un grado de optimización en el ejercicio debido a que las Normales serán calculadas por vértices y no por cantidad de píxeles en pantalla. Configurando inputs y outputs ⚫ ⚫ ⚫ v2f vert (appdata v) { … o.normal = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0))).xyz; … } 5.0.2. Vectores. Antes de iniciar la implementación de iluminación en nuestro programa, debemos comprender qué es un vector y cómo funciona en Computer Graphics. Un vector en sí puede interpretarse como una línea o flecha, que posee una magnitud y una dirección. Vectores ⚫ ⚫ ⚫ (Fig. 5.0.2a. EL vector de tres dimensiones representa la dirección, mientras que “m”, su magnitud) 191 En la Figura anterior, se ha utilizado a una cuadrícula como sistema de medida para determinar la magnitud de un vector. Estos valores pueden ser medidos tanto en magnitudes escalares como en magnitudes vectoriales. Las magnitudes escalares corresponden a un valor único; a un número de tipo valor-unidad, p. ej., [n] kilos, [n] horas, [n] grados, etc. En este caso “n”, el cual es una variable, representa a una magnitud escalar dado que contiene un valor único de una unidad específica. En cambio, las magnitudes vectoriales; además del valor-unidad, necesitan de una dirección y sentido en el espacio, p. ej., [n] km/h hacia el norte, [n] N de fuerza hacia abajo, etc. Debido a que las magnitudes vectoriales poseen dirección, podemos concluir que tienen un punto de inicio y un final, y además están comprendidas dentro de un sistema de coordenadas en dos o tres dimensiones. Las Normales de un objeto son magnitudes vectoriales, ¿Por qué razón? Porque tienen una longitud normalizada en la mayoría de los casos y una dirección en el espacio. Así mismo, las Tangentes y Binormales también lo son debido a que cuentan con propiedades semejantes a una Normal. Vectores ⚫ ⚫ ⚫ (Fig. 5.0.2b) Por su parte, los vértices son magnitudes escalares, ya que representan puntos de intersección en una geometría. Ahora, si intentamos calcular la distancia entre el centro del objeto y la posición de un vértice, en este caso sí estaríamos obteniendo una magnitud vectorial, puesto que, por definición, ahora nuestro valor-unidad tendría una dirección y sentido de espacio. 192 Vectores ⚫ ⚫ ⚫ (Fig. 5.0.2c) En Computer Graphics las magnitudes escalares son representadas como variables de una dimensión, p. ej., float, half, fixed. En cambio, las magnitudes vectoriales son representadas como variables de más de una dimensión, es decir, float2, half3, fixed4. 5.0.3. Producto Punto. La función dot( A ,B RG RG ) corresponde a una operación que utilizaremos con frecuencia en el cálculo de iluminación y reflexión, ya que permite determinar el ángulo entre dos vectores y retorna un valor escalar como output, es decir, una variable de una dimensión. Generalmente, el valor resultante será normalizado para asegurarnos que el rango de retorno se encuentre entre -1.0f y 1.0f. a · b = || a || || b || Cos θ (El “punto” (·) entre a y b se refiere al Producto Punto, mientras que el símbolo || x || se refiere a la magnitud de un vector.) En la función anterior: cuando el ángulo entre los vectores “a y b” sea igual a 0°, el Producto Punto retornará 1.0f. A su vez cuando el ángulo sea igual a 90°, retornará 0.0f. Por último, cuando el ángulo sea igual a 180°, el Producto Punto retornará -1.0f. 193 Su sintaxis es la siguiente: Producto Punto ⚫ ⚫ ⚫ float dot(float4 a, float4 b) { return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w; } ¿Cómo funciona esta operación? Para entenderla debemos calcular el Coseno del ángulo entre estos dos vectores. a·b Cos θ = || a || || b || Para entender la función anterior, supondremos dos vectores bidimensionales con los siguientes valores. › vector A (1, 1) › vector B (1, 1) Producto Punto ⚫ ⚫ ⚫ (Fig. 5.0.3a. El vector “A” corresponde a la dirección de una Normal, mientras que “B” se refiere a la dirección de la iluminación) 194 Ambos vectores son iguales tanto en dirección como en magnitud. Para calcular el Producto Punto entre estos dos vectores tendremos que multiplicar A por B , luego A por B y finalmente X sumar sus resultados. X Y Y A *B =1 x x y y A *B =1 1+1=2 En consecuencia, el Producto Punto entre A y B es igual a “dos”. Para ejemplificar, vamos a reemplazar estos valores en la función mencionada anteriormente. 2 Cos θ = || a || || b || Luego debemos calcular la magnitud tanto de A como de B. Para llevar a cabo esto debemos realizar la siguiente operación. 2 2 || v || = √ Vx + Vy Entonces, para el vector a. 2 2 || A || = √ 1x + 1y || A || = √ 2 Dado que el vector B es exactamente igual al vector A, podemos deducir que su valor es el mismo. 2 2 || B || = √ 1x + 1y || B || = √ 2 195 Si multiplicamos la magnitud de A por la magnitud de B, obtenemos el siguiente valor. 2 || A || * || B || = √ 2 * √ 2 = √ 2 * 2 = √ 2 = 2 || A || * || B || = 2 Como podemos ver el factor entre la magnitud de A por B es igual a dos. Nuevamente, vamos a reemplazar este valor en la función anterior para una mejor comprensión. 2 Cos θ = Cos θ = 1 θ = Cos 0 -1 2 0 (1) = 0 Cos (0 ) = 1 Entonces, el coseno de cero grados es igual a uno, por consiguiente, si llevamos a cabo la operación inicial (a · b = || a || || b || Cos θ) quedaría de la siguiente manera. A·B = 2*1=2 El resultado del Producto Punto entre A y B es igual a “dos”. Ahora, si normalizamos este valor, obtendremos una magnitud de uno. normalize(A · B) = 1 ¿Para qué sirve toda esta explicación? Cuando estemos implementando iluminación en nuestro shader tendremos que utilizar dos vectores: › Uno para el cálculo de la luz. › Y otro para el cálculo de las Normales. 196 Entonces el vector “A” podría representar a la iluminación global y el vector “B” a las Normales del objeto. En su implementación, el cálculo de iluminación se realizará por cada vértice en el objeto, así para aquellas Normales que se encuentren en la misma dirección de la iluminación, el producto punto retornará 1.0f, y para aquellas que se encuentren del lado opuesto, el valor resultante será igual a -1.0f. 5.0.4. Producto Cruz. La función cross( A ,B RG RG ) (también conocida como Producto Vectorial) corresponde a una operación que a diferencia del Producto Punto, retorna un vector de tres dimensiones que es perpendicular a sus argumentos. Su sintaxis es la siguiente: Producto Cruz ⚫ ⚫ ⚫ float3 cross(float3 a, float3 b) { return a.yzx * b.zxy - a.zxy * b.yzx; } Para entender el concepto, nuevamente vamos a tomar dos vectores: A y B, y los posicionaremos en el espacio de la siguiente manera. › vector A (1, 0, 0) › vector B (0, 1, 0) 197 Producto Cruz ⚫ ⚫ ⚫ (Fig. 5.0.4a) En la Figura anterior, el Producto Cruz genera un tercer vector denominado “C” con nuevas coordenadas de espacio. Para entender su funcionamiento vamos a prestar atención a la siguiente operación. || a × b || = || a || || b || Sin θ La magnitud del vector resultante estará relacionado con la función de “sin ”. Tomando en consideración los vectores A y B mencionados anteriormente, podemos calcular el Producto Cruz desde una matriz determinante entre ambos vectores. Vector C = X [(A * B ) - (A * B )] Y [(A * B ) - (A * B )] Z [(A * B ) - (A * B )] y z z y z x x z x y y x Reemplazando los valores obtenemos. Vector C = X [(0 * 0) - (0 * 1)] Y [(0 * 1) - (1 * 0)] Z [(1 * 1) - (0 * 0)] Vector C = X [(0 - 0)] Y [(0 - 0)] Z [(1 - 0)] Vector C = (0, 0, 1) En conclusión el vector resultante es perpendicular a sus argumentos. Más adelante utilizaremos la función cross( A un mapa de Normales. ,B RG 198 RG ) para calcular el valor de una Binormal en Surperficie. 6.0.1. Mapa de Normales. Un Mapa de Normales es una técnica que permite generar detalles sobre una superficie sin la necesidad de agregar mayor cantidad de vértices al objeto. Para realizar este proceso las Normales deben cambiar de dirección siguiendo algún marco de referencia. Para ello, podemos almacenar cada vértice dentro de una coordenada de espacio denominada Tangent-Space, la cual es utilizada para el cálculo de iluminación sobre la superficie de un objeto. Mapa de Normales ⚫ ⚫ ⚫ (Fig. 6.0.1a) Para generar nuestro Mapa de Normales será necesario emplear tres vectores normalizados, los cuales, en su conjunto, forman una matriz denominada TBN. › Tangentes. › Binormales. › Normales. En la sección 5.0.1 hablamos sobre cómo transformar las Normales a World-Space usando la matriz unity_ObjectToWorld. De manera similar, emplearemos la matriz TBN para pasar de un espacio a otro, y así, podremos transformar tanto la iluminación como nuestro Mapa de Normales, desde World-Space a Tangent-Space. 199 La representación gráfica de la matriz TBN es la siguiente: float4x4 TBN = float4x4 ( Tx, Ty, Tz, 0, Bx, By, Bz, 0, Nx, Ny, Nz, 0, 0, 0, 0, 0 ); En la matriz, la primera fila corresponde a los valores de las Tangentes; la segunda, los valores de las Binormales y en la tercera podemos encontrar las Normales. En su implementación, habrá que seguir el mismo orden mencionado anteriormente. Para ejemplificar estos conceptos, crearemos un nuevo shader tipo Unlit, al cual llamaremos USB_normal_map. Iniciaremos la implementación agregando una propiedad de textura en nuestro programa. Mapa de Normales ⚫ ⚫ ⚫ Shader "USB/USB_normal_map" { Properties { _MainTex ("Texture", 2D) = "white" {} _NormalMap ("Normal Map", 2D) = "white" {} } SubShader { … } } En el ejemplo anterior, se ha declarado una Propiedad de tipo Textura 2D llamada _NormalMap, la cual utilizaremos para aplicar nuestro Mapa de Normales desde el Inspector. A continuación agregaremos el Sampler de conexión correspondiente a la propiedad declarada. 200 Mapa de Normales ⚫ ⚫ ⚫ Pass { CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NormalMap; float4 _NormalMap_ST; … ENDCG } Ahora, nuestro Mapa de Normales podrá ser utilizado dentro del shader como textura. Por consiguiente, debemos comenzar con la declaración de la matriz TBN. Para ello, vamos a extraer las Normales y Tangentes de nuestro objeto, yendo al Vertex Input, e inicializando las semánticas NORMAL y TANGENT. Mapa de Normales ⚫ ⚫ ⚫ Pass { CGPROGRAM … struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; float4 tangent : TANGENT; }; … ENDCG } Un factor a considerar hasta este punto, es que, tanto Normales como Tangentes se encuentran en Object-Space y nosotros necesitamos que sean transformadas a World-Space antes de 201 ser convertidas a Tangent-Space, por lo tanto, tendremos que conectarlas en el Vertex Shader Stage, para luego, pasar el Vertex Output al Fragment Shader Stage. Para ello, debemos agregar las semánticas correspondientes a las Normales, Tangentes y Binormales en el struct v2f para su cálculo en World-Space. Mapa de Normales ⚫ ⚫ ⚫ Pass { CGPROGRAM … struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; float2 uv_normal : TEXCOORD1; float3 normal_world : TEXCOORD2; float4 tangent_world : TEXCOORD3; float3 binormal_world : TEXCOORD4; }; … ENDCG } Como se ha mencionado anteriormente, dentro del Vertex Output no se pueden utilizar las semánticas NORMAL o TANGENT, esto es debido a que no existen para este proceso según la documentación oficial de HLSL. En este caso tendremos que utilizar una semántica que pueda almacenar hasta cuatro dimensiones en cada una de sus coordenadas. Esta es la razón principal por la cual se ha utilizado la semántica TEXCOORD[n] en el ejemplo anterior. Ahora, si prestamos atención, notaremos que cada una de las propiedades posee una coordenada con un ID distinto, p. ej., uv_normal tiene asignado a TEXCOORD con su índice en [1] mientras que binormal_world posee el índice [4]. Es fundamental que los ID sean distintos en su valor debido a que, de otra manera, estaremos realizando operaciones sobre un sistema de coordenadas duplicado. Para transformar las propiedades desde Object-Space a World-Space, iremos a Vertex Shader Stage y realizaremos la siguiente operación: 202 Mapa de Normales ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); // agregamos tiling y offset al mapa de normales o.uv_normal = TRANSFORM_TEX(v.uv, _NormalMap); // transformarmos las normales a world-space o.normal_world = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0))); // transformarmos las tangentes a world-space o.tangent_world = normalize(mul(v.tangent, unity_WorldToObject)); // calculamos el cross product entre las normales y tangentes o.binormal_world = normalize(cross(o.normal_world, o.tangent_world) * v.tangent.w); return o; } En el ejemplo, se ha iniciado la operación agregando Tiling y Offset a las coordenadas UV del Mapa de Normales a través de la función TRANSFORM_TEX, la cual viene incluida en UnityCg. cginc. Luego, multiplicamos la matriz de cuatro dimensiones llamada unity_ObjectToWorld por el input de Normales para transformar sus coordenadas de espacio desde Object-Space a World-Space. El producto de la multiplicación se ha almacenado dentro del output de Normales llamado normal_world, el cual utilizaremos más tarde para el cálculo per-pixel en el Fragment Shader Stage. A continuación, multiplicamos las Tangentes por la matriz unity_WorldToObject para transformar sus coordenadas de espacio a World-Space de manera inversa, y finalmente calculamos un vector perpendicular entre las Normales y Tangentes, utilizando la función cross( A ,B RG RG ). Su operación da como resultado a las Binormales, por esa razón lo almacenamos dentro del output binormal_world. Es posible que en Direct3D 11 sea necesario inicializar el Vertex Output v2f en “cero” para llevar a cabo el cálculo de Normales en nuestro shader. Para corroborarlo, una advertencia aparecerá en la consola de Unity, y además, el shader marcará una pequeña advertencia relacionada con este punto. 203 Mapa de Normales ⚫ ⚫ ⚫ (Fig. 6.0.1b) En este caso, tendremos que utilizar el macro UNITY_INITIALIZE_OUTPUT dentro del Vertex Shader de la siguiente manera: Mapa de Normales ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; UNITY_INITIALIZE_OUTPUT (v2f, o); o.vertex = UnityObjectToClipPos(v.vertex); … } Hasta este punto, ya tenemos nuestros inputs y outputs conectados. A continuación debemos generar la matriz TBN para transformar las coordenadas del mapa de normales desde World-Space a Tangent-Space. Este proceso lo realizaremos en el Fragment Shader Stage. Un factor que debemos considerar al momento de leer nuestro Mapa de Normales es que las coordenadas XYZW están incrustadas dentro de los canales RGBA, los cuales tiene un rango entre 0.0f y 1.0f. Es fundamental entender este concepto dado que el Mapa de Normales posee un rango de color entre -1.0f y 1.0f. Por lo tanto, lo primero que debemos hacer es modificar el rango numérico utilizando la siguiente ecuación. 204 normal_map.rgb * 2 - 1; Para entender la operación anterior haremos el siguiente ejercicio: si multiplicamos un rango entre 0.0f y 1.0f, por “dos”, entonces el nuevo valor del rango estará entre 0.0f y 2.0f. 0.0f * 2 = 0.0f Mínimo de color 1.0f * 2 = 2.0f Máximo de color Si a la misma operación le restamos “uno”, entonces nuestro rango será modificado a un valor entre -1.0f y 1.0f. 0.0f - 1 = -1.0f Mínimo de color 2.0f - 1 = 1.0f Máximo de color Otro factor a considerar es que nuestro Mapa de Normales posee más peso que una textura común, por esta razón será necesario reducir su carga gráfica en la GPU a través de una compresión denominada DXT. 6.0.2. Compresión DXT. Los Mapas de Normales son muy útiles al momento de generar detalles en nuestros objetos, sin embargo, son muy pesados y producen una carga gráfica significativa en la GPU. Así mismo, si estamos trabajando en dispositivos móviles, es muy probable que su procesamiento genere sobrecalentamiento de la batería, lo que podría afectar directamente la experiencia de usuario. Por este motivo, será crucial comprimir estas texturas dentro de nuestro shader. La compresión DXT es una de las más utilizadas para reducir este tipo de imágenes. Cuando trabajamos con canales RGBA, cada píxel necesita 32 bits de información para ser almacenados en el Frame Buffer, sin embargo, DXT divide la textura en bloques de “cuatro por cuatro” píxeles, que luego se minimizan empleando únicamente sus canales AG. Esto permite que el mapa de Normales sea comprimido a 1⁄4 de resolución. 205 Para entender este concepto primero tendremos que calcular el Mapas de Normales y sus coordenadas UV en el Fragment Shader Stage. Para ello usaremos la función tex2D( S , UV RG RG ). Compresión DXT ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 normal_map = tex2D(_NormalMap, i.uv_normal); … } Al igual que el vector “col” en ejercicios pasados, se ha generado un vector de cuatro dimensiones llamado normal_map en esta oportunidad. En él se han almacenado el Mapas de Normales y sus coordenadas UV. Sus canales RGBA actualmente tienen un rango entre 0.0f y 1.0f los cuales modificaremos a continuación. Para ello crearemos una nueva función la cual llamaremos DXTCompression y la posicionamos sobre el Fragment Shader Stage. Compresión DXT ⚫ ⚫ ⚫ float3 DXTCompression (float4 normalMap) { #if defined (UNITY_NO_DXT5nm) return normalMap.rgb * 2 - 1; #else float3 normalCol; normalCol = float3 (normalMap.a * 2 - 1, normalMap.g * 2 - 1, 0); normalCol.b = sqrt(1 - (pow(normalCol.r, 2) + pow(normalCol.g, 2))); return normalCol; #endif } 206 En la función anterior están ocurriendo cuatro cosas principalmente: 1 La función retorna un vector de tres dimensiones según una condición. 2 Se ha definido UNITY_NO_DXT5nm, el cual corresponde a un Built-in shader defined y su función es compilar shaders para plataformas que no admiten compresión DXT5nm, es decir que los Mapas de Normales se codificaran en RGB en su lugar. 3 En la condición #else hemos generado la compresión DXT utilizando únicamente los canales Alpha y Green. Si prestamos atención, notaremos que hemos reemplazado el canal R por A, y luego se ha utilizado a G como segundo canal. 4 El tercer canal B en el vector ha sido descartado, pero luego se ha calculado de manera independiente empleando la siguiente función: sqrt(1 - (pow(normalCol.r, 2) + pow(normalCol.g, 2))) “ ¿Por qué estamos utilizando esta función? La respuesta se encuentra en el teorema de Pitágoras. es igual a la suma de los cuadrados de los catetos. Compresión DXT “ En todo triángulo rectángulo el cuadrado de la hipotenusa ⚫ ⚫ ⚫ (Fig. 6.0.2a) 207 Por consiguiente. 2 2 2 C =A +B Un vector en el espacio genera un triángulo rectángulo, así mismo, si deseamos calcular la magnitud de un vector de tres dimensiones, tendremos que hacerlo a través del teorema de Pitágoras. 2 || V || 2 2 2 =A +B +C Cuando transformamos las coordenadas de espacio para las Normales, Tangentes y Binormales, utilizamos la función normalize el cual devuelve un vector con una magnitud de “uno”. Así mismo, la magnitud del vector que utilizaremos para la coordenada B o Z; que es lo mismo, será igual a 1.0f, entonces la operación quedaría de la siguiente manera: 2 2 2 1 = X + Y + Z 2 2 2 O en su variación R + G + B que es lo mismo. Para la operación debemos calcular la coordenada B o Z, entonces debemos restar la suma de X e Y, a 1. 2 2 2 1 - (X + Y ) = Z Finalmente, por factorización, la operación anterior sería igual a: Z = √ 1 − (X2 + Y2) 208 Que en lenguaje Cg o HLSL se traduciría como: normalCol.b = sqrt(1 - (pow(normalCol.r, 2) + pow(normalCol.g, 2))) ¿Por qué estamos haciendo esto? Recordemos que nuestro Mapa de Normales posee hasta cuatro canales y en la compresión DXT estamos utilizando sólo dos de ellos. El tercer canal ha sido descartado en el vector normalCol, es decir que no utilizaremos los valores incluidos en el Mapa de Normales para su coordenada B. Ahora, de todas maneras debemos calcular esta coordenada, sino nuestra textura no podrá funcionar correctamente, por esta razón hemos llevado a cabo la operación anterior, en donde se ha calculado un nuevo vector normalizado basándonos en las coordenadas AG. A continuación debemos aplicar la compresión al Mapa de Normales, para lo cual iremos al Fragment Shader Stage nuevamente y pasaremos la textura como argumento en la función DXTCompression de la siguiente manera: Compresión DXT ⚫ ⚫ ⚫ float3 DXTCompression (float4 normalMap){ … } fixed4 frag (v2f i) : SV_Target { fixed4 normal_map = tex2D(_NormalMap, i.uv_normal); fixed3 normal_compressed = DXTCompression(normal_map); … } El ejercicio que acabamos de realizar es equivalente a la función UnpackNormal que viene incluida en UnityCg.cginc, esto quiere decir que podríamos reemplazar la función DXTCompression por esta última y el resultado sería el mismo. 209 Compresión DXT ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 normal_map = tex2D(_NormalMap, i.uv_normal); fixed3 normal_compressed = UnpackNormal(normal_map); … } 6.0.3. Matriz TBN. Como ya sabemos, la matriz TBN está compuesta por las Tangentes, Binormales y Normales de nuestro objeto. En la sección 6.0.1 vimos cómo transformar estas propiedades desde Object-Space a World-Space. Lo que haremos a continuación es crear esta nueva matriz para transformar nuestro Mapa de Normales a Tangent-Space. Para ello, iremos al Fragment Shader Stage y generamos la matriz siguiendo el mismo orden mencionado en las siglas TBN. Matriz TBN ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 normal_map = tex2D(_NormalMap, i.uv_normal); fixed4 normal_compressed = DXTCompression(normal_map); float3x3 TBN_matrix = float3x3 ( i.tangent_world.xyz, i.binormal_world, i.normal_world ); … } En el ejemplo anterior, hemos generado una matriz llamada TBN_matrix la cual posee tres por tres dimensiones. En ella, incluimos el output de Tangentes, el output de Binormales y las Normales; todas en World-Space. Un detalle que podemos apreciar es que, en el caso de las Tangentes, se han incluido sus coordenadas XYZ de manera explícita. Esto se debe a que fueron declaradas como un vector de cuatro dimensiones en el struct v2f, de otra manera, 210 si no especificamos la cantidad de dimensiones que deseamos utilizar, el programa asumirá que debe utilizarlas todas (XYZW), y por consecuencia, esto podría generar un error haciendo que nuestro shader no pueda compilar. Para concluir, lo único que debemos hacer es multiplicar nuestro Mapa de Normales por la matriz TBN y retornar el resultado de la operación. Matriz TBN ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 normal_map = tex2D(_NormalMap, i.uv_normal); fixed3 normal_compressed = DXTCompression(normal_map); float3x3 TBN_matrix = float3x3 ( i.tangent_world.xyz, i.binormal_world, i.normal_world ); fixed4 normal_color = normalize(mul(normal_compressed, TBN_matrix)); return fixed4 (normal_color, 1); } En el ejemplo se ha creado un vector de tres dimensiones llamado normal_color. Este vector posee el factor entre el Mapa de Normales y la matriz TBN. Finalmente, se ha retornado el color de las normales en RGB, y asignado el valor “uno” al canal A. Cabe mencionar que, cuando importamos un mapa de normales a Unity, por defecto, queda configurado en Texture Type Default dentro de nuestro proyecto. Antes de asignar el Mapa de Normales a nuestro material, debemos seleccionar la textura, ir al Inspector y configurarla como Texture Type Normal Map, de otra manera, es posible que nuestro programa no funcione correctamente. 211 Matriz TBN ⚫ ⚫ ⚫ (Fig. 6.0.3a) 212 Iluminación. 7.0.1. Modelo de iluminación. Un modelo de iluminación se refiere al resultado de la interacción entre la superficie de un objeto y una fuente lumínica. Por definición incluye, primero las propiedades de la fuente de luz, es decir, color, intensidad, y más, y luego las propiedades que hemos asignado al material. Dentro de un shader, la iluminación puede ser calculada por cada vértice o por cada píxel. Cuando es calculada por cada vértice, se denomina per-vertex lighting y la operación se realiza en el Vertex Shader Stage, en cambio, cuando es calculada por cada píxel, se denomina per-fragment o per-pixel lighting y su operación se realiza dentro del Fragment Shader Stage. En el capítulo anterior, sección 1.1.2, definimos conceptualmente la función de un Render Path, el cual corresponde a una serie de operaciones relacionadas con iluminación y sombreado de objetos. Asimismo, detallamos el funcionamiento de dos tipos de rendering, los cuales son Forward Rendering y Deferred Shading. En esta sección recrearemos un shader utilizando un modelo básico de iluminación, para ello hablaremos de Color de Ambiente, Reflexión Difusa y Reflexión Especular. 7.0.2. Color de Ambiente. Un valor por defecto que podemos encontrar en la vida real es la oscuridad como tal. Esto es un punto interesante dado que todos los objetos en su naturaleza son oscuros y la razón por la cual podemos percibir volúmenes, se debe precisamente a cómo la iluminación interactúa con las superficies y sus propiedades. A esto se debe la inicialización en “cero” de una propiedad lumínica, debido a que 0.0f es igual a “negro”, o sea, su valor por defecto. 213 Color de Ambiente ⚫ ⚫ ⚫ (Fig. 7.0.2a) El color de ambiente se refiere a la tonalidad de la iluminación que se ha generado por el rebote de múltiples fuentes de luz. En Computer Graphics, esta propiedad deriva de una técnica conocida como Global Illumination, que en sí corresponde a un algoritmo capaz de calcular iluminación indirecta para simular el fenómeno natural de rebote de la luz. En Unity 2020.3.21f1 podemos acceder con facilidad al Color de Ambiente desde el menú, › Window / Rendering / Lighting. Tal ruta despliega la ventana Lighting y en ella podemos encontrar las propiedades de iluminación global para nuestro proyecto. En su pestaña Environment se encuentran dos propiedades que utilizaremos en nuestro shader de manera directa, estas se refieren a: 1 Source. 2 Y Ambient Color. Para ejemplificar, crearemos un nuevo shader tipo Unlit al cual llamaremos USB_ambient_ color. En esta oportunidad centraremos nuestros estudios en la compresión de la variable interna UNITY_LIGHTMODEL_AMBIENT, la cual da acceso al Color de Ambiente. Iniciaremos declarando una Propiedad para aumentar o disminuir la cantidad de color ambiental en nuestro shader. Para ello, podemos utilizar un rango entre 0.0f y 1.0f, donde “cero” sea igual al 0 % de iluminación y “uno” al 100 %. 214 Color de Ambiente ⚫ ⚫ ⚫ Shader "USB/USB_ambient_color" { Properties { _MainTex ("Texture", 2D) = "white" {} _Ambient ("Ambient Color", Range(0, 1)) = 1 } SubShader { … Pass { CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; float _Ambient; … ENCG } } } La Propiedad _Ambient será quien controle la cantidad de color ambiental en el programa. Su valor de inicialización es igual a 1.0f, por lo tanto, iniciará afectando en su totalidad el color de la superficie del objeto en la escena. 215 Realizaremos el cálculo por cada píxel en pantalla, por lo tanto, iremos al Fragment Shader Stage y emplearemos la variable interna de la siguiente manera: Color de Ambiente ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 ambient_color = UNITY_LIGHTMODEL_AMBIENT * _Ambient; col.rgb += ambient_color; return col; } En el ejercicio anterior, se ha guardado el factor entre el color de ambiente y la propiedad _Ambient, en el vector ambient_color. Finalmente, se ha sumado el color de ambiente RGB a la textura “col”. Hasta este punto nuestro color de ambiente ya está funcionando. Ahora simplemente debemos ir a la ventana Lighting, pestaña Environment, y configurar la propiedad Source en Color, y luego seleccionar un color de ambiente desde la propiedad Ambient Color. Color de Ambiente ⚫ ⚫ ⚫ (Fig. 7.0.2b) Cabe mencionar que la propiedad Ambient Color corresponde a un color HDR (High Dynamic Range) por defecto, o sea; de alta precisión. Por esa razón el vector de tres dimensiones ambient_color fue declarado como vector de tipo “float3” en el ejemplo anterior. 216 7.0.3. Reflexión Difusa. Generalmente, una superficie puede ser definida por dos tipos de reflexión: › Mate. › O brillante. La Reflectancia Difusa obedece a una ley denominada Lambert’s cosine law por su creador Johann Heinrich Lambert, quien hace una analogía entre la iluminación y la superficie de un objeto, tomando en consideración la dirección de la fuente lumínica y la Normal de la superficie. Reflexión Difusa ⚫ ⚫ ⚫ (Fig. 7.0.3a) En Maya 3D podemos encontrar un material llamado Lambert el cual agrega Reflectancia Difusa por defecto. Este mismo concepto es aplicado en Blender, con la diferencia de que el material es llamado Diffuse, sin embargo, en ambos casos se realiza la misma operación y el resultado es bastante similar, con variaciones de acuerdo a la arquitectura del Render Pipeline en cada software. Según Johann Heinrich Lambert, para obtener una Difusión perfecta debemos llevar a cabo la siguiente operación: D=D R D max(0, N · L) L ¿Cómo se traduce esta ecuación a código? Para responder a esta pregunta primero debemos entender la analogía entre la dirección de la iluminación y la Normal de una superficie. 217 Supondremos una esfera y una luz direccional apuntando hacia ella, como se muestra en la Figura 7.0.3b. Reflexión Difusa ⚫ ⚫ ⚫ (Fig. 7.0.3b) La Difusión es calculada según el ángulo entre la Normal [ N ] de la superficie y la dirección de la iluminación [ L ], que de hecho, corresponde al producto punto entre estas dos propiedades. Sin embargo, hay otros cálculos a considerar dada la naturaleza de la reflectancia, estos se refieren a [ D e intensidad. “ R ] y [ D ] que en sí corresponden a la cantidad de reflexión en términos de color L En consecuencia, la ecuación anterior se puede traducir de la siguiente manera: color de la fuente lumínica [D ] por su intensidad [D ], y el máximo R L (max) entre cero [0] y el resultado del producto punto entre las normales de la superficie y la dirección de la iluminación [n · l]. “ La difusión [D] es igual a la multiplicación entre la reflexión de Para entender de mejor manera esta definición, crearemos un nuevo shader tipo Unlit, al cual llamaremos USB_diffuse_shading. Iniciaremos generando una función a la cual llamaremos LambertShading e incluiremos las Propiedades mencionadas anteriormente para su correcto funcionamiento. 218 Reflexión Difusa ⚫ ⚫ ⚫ Shader "USB/USB_diffuse_shading" { Properties { … } SubShader { Pass { CGPROGRAM … float3 LambertShading() { … } … ENDCG } } } Reflexión Difusa ⚫ ⚫ ⚫ // estructura interna de la función LambertShading float3 LambertShading ( float3 colorRefl, // Dr float lightInt, // Dl float3 normal, // n float3 lightDir // l ) { return colorRefl * lightInt * max(0, dot(normal, lightDir)); } 219 La función LambertShading retorna un vector de tres dimensiones para sus colores RGB. Como argumento se ha utilizado: › El color de la reflexión de la luz ( colorRefl ). › La intensidad de la luz ( lightInt ). › Las Normales de la superficie ( normal ). › Y la dirección de la iluminación ( lightDir ). Cabe mencionar que, tanto las Normales como la dirección de iluminación serán calculadas en World-Space, por ende, tendremos que llevar a cabo la operación de transformación en el Vertex Shader Stage. Para la iluminación, no será necesario generar algún tipo de transformación debido a que podemos utilizar la variable interna _WorldSpaceLightPos0 la cual se refiere a la dirección de la luz direccional en World-Space, incluida por defecto en Unity. Dado que la intensidad de la luz puede ser “cero o uno”, iniciaremos la implementación yendo las Propiedades, y declararemos un rango al cual llamaremos _LightInt. Reflexión Difusa ⚫ ⚫ ⚫ Shader "USB/USB_diffuse_shading" { Properties { _MainTex ("Texture", 2D) = "white" {} _LightInt ("Light Intensity", Range(0, 1)) = 1 } SubShader { … } } La Propiedad _LightInt será utilizada para aumentar o disminuir la intensidad de la luz en nuestra función LambertShading. Como parte del proceso, luego debemos declarar una variable interna para generar la conexión entre la Propiedad y nuestro programa. 220 Reflexión Difusa ⚫ ⚫ ⚫ Pass { CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; float _LightInt; … ENDCG } Hasta este punto _LightInt ya está configurada. A continuación debemos iniciar la implementación de la función LambertShading en el Fragment Shader Stage. Para ello, iremos a dicha etapa y declararemos un nuevo vector de tres dimensiones al cual llamaremos “diffuse”, y lo haremos igual al resultado de la función LambertShading. Reflexión Difusa ⚫ ⚫ ⚫ float3 LambertShading() { … } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // LambertShading(1, 2, 3, 4); half3 diffuse = LambertShading( 0, _LightInt, 0, 0); return col; } Como ya sabemos, la función LambertShading posee cuatro argumentos, los cuales son: 1 float3 colorRefl. 2 float lightInt. 3 float3 normal. 4 Y float3 lightDir. 221 En la implementación de la función, hemos inicializado tales argumentos en “cero” a excepción de “lightInt” que; dada su naturaleza, debe ser reemplazada por la propiedad _LightInt en la segunda casilla. Continuaremos con el color de reflexión, para ello podemos utilizar la variable interna _LightColor[n], la cual se refiere al color de la iluminación que tenemos en nuestra escena. Para utilizarla primero debemos declararla como variable uniforme dentro del CGPROGRAM de la siguiente manera: Reflexión Difusa ⚫ ⚫ ⚫ Pass { CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; float _LightInt; float4 _LightColor0; … ENDCG } Ahora podemos utilizarla como primer argumento en la función LambertShading. Para ello, declaramos un nuevo vector de tres dimensiones y utilizamos únicamente sus canales RGB. Reflexión Difusa ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); fixed3 colorRefl = _LightColor0.rgb; half3 diffuse = LambertShading( colorRefl, _LightInt, 0, 0); return col; } 222 Como ya hemos mencionado, WorldSpaceLightPos[n] se refiere a la dirección de iluminación en World-Space. A diferencia de _LightColor[n], no es necesario declarar tal variable como vector uniforme dado que ya ha sido inicializada previamente en UnityCG.cginc, así pues, podemos utilizarla directamente como argumento en nuestra función. Reflexión Difusa ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); fixed3 colorRefl = _LightColor0.rgb; half3 diffuse = LambertShading( colorRefl, _LightInt, 0, lightDir); return col; } En el ejemplo anterior, se ha declarado un vector de tres dimensiones llamado lightDir para guardar los valores de dirección de iluminación en sus coordenadas XYZ. Además, la función se ha normalizado para que el vector resultante tenga una magnitud de “uno”. Finalmente, se ha posicionado la dirección de iluminación como cuarto argumento en la función. Únicamente faltaría el tercer argumento que corresponde a las Normales del objeto en WorldSpace, para ello tendremos que ir tanto al Vertex Input como al Vertex Output e incluir la semántica. Reflexión Difusa ⚫ ⚫ ⚫ CGPROGRAM … // vertex input struct appdata { float4 vertex : POSITION; float2 UV : TEXCOORD0; float3 normal : NORMAL; Continúa en la siguiente página. 223 }; // vertex output struct v2f { float2 UV : TEXCOORD0; float4 vertex : SV_POSITION; float3 normal_world : TEXCOORD1; }; … ENDCG Recordemos que la razón por la cual estamos configurando las Normales tanto en el Vertex Input como Output se debe precisamente a que las utilizaremos en el Fragment Shader Stage, donde ha sido inicializada nuestra función LambertShading, sin embargo, la conexión entre ambas la realizaremos en el Vertex Shader Stage para optimizar el proceso de transformación. Reflexión Difusa ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.normal_world = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0))).xyz; return o; } En el vector normal_world se ha almacenado el factor normalizado entre la matriz unity_ObjectToWorld y el input de Normales del objeto en World-Space. Ahora podemos ir al Fragment Shader Stage, declarar un nuevo vector de tres dimensiones y pasar el output de Normales como tercer argumento en la función LambertShading. 224 Reflexión Difusa ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 normal = i.normal_world; float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); fixed3 colorRefl = _LightColor0.rgb; half3 diffuse = LambertShading(colorRefl, _LightInt, normal, lightDir); return col; } Como podemos ver, se ha declarado un nuevo vector de tres dimensiones llamado normal al cual hemos pasado las Normales del objeto. Luego, se ha utilizado tal vector como tercer argumento en la función LambertShading. Sólo faltaría multiplicar la difusión por el color RGB de la textura, y además; dado que nuestro shader está interactuando con una fuente de iluminación, tendremos que incluir el LightMode ForwardBase, así el Render Path quedará configurado. Reflexión Difusa ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 normal = i.normal_world; float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); fixed3 colorRefl = _LightColor0.rgb; half3 diffuse = LambertShading(colorRefl, _LightInt, normal, lightDir); // se incluye la difusión en la textura col.rgb *= diffuse; return col; } 225 Para finalizar iremos a los Tags y configuraremos el Render Path. Reflexión Difusa ⚫ ⚫ ⚫ SubShader { Tags { "RenderType"="Opaque" } Pass { Tags { "LightMode"="ForwardBase" } } } 7.0.4. Reflexión Especular. Uno de los modelos de reflectancia más comunes en Computer Graphics corresponde al modelo de Phong (Bui Tuong Phong), el cual agrega brillos especulares a una superficie según la posición de sus Normales. De hecho, en Maya 3D existe un material con este nombre y su función es precisamente generar superficies brillantes. Reflexión Especular ⚫ ⚫ ⚫ (Fig. 7.0.4a) Según su autor, si deseamos agregar reflectancia especular debemos llevar a cabo la siguiente operación: S=S A 2 S max(0, H · N) P 226 Si prestamos atención a esta ecuación notaremos que es muy similar a la función de Reflectancia Difusa. D=D R D max(0, N · L) Diffuse reflection. L La gran diferencia recae en el cálculo del vector [ H ] que corresponde a un vector medio denominado halfway. Esto nos va a permitir apreciar el brillo de la reflectancia cuando esté cerca de [ N ]; donde este último corresponde a las Normales de la superficie. Para entender el concepto, iniciaremos nuestro estudio ejemplificando la Reflectancia Especular, suponiendo que tenemos una superficie plana y una luz direccional apuntando hacia ella, como muestra la Figura 7.0.4b. Reflexión Especular ⚫ ⚫ ⚫ (Fig. 7.0.4b) Podemos deducir que la Reflectancia Especular tiene el mismo ángulo que la dirección de la luz. Esto representa un problema dado que si nuestro ojo/cámara no está en la misma dirección de la reflectancia, no podremos verla. Para solucionarlo podemos calcular un vector intermedio entre las Normales y la dirección de la luz, siguiendo la dirección de la vista. 227 Reflexión Especular ⚫ ⚫ ⚫ (Fig. 7.0.4c) El vector [ E ] corresponde a la “dirección de la vista”, mientras que el vector [ H ] es el valor halfway que hemos calculado entre la dirección de la luz y las normales de la superficie. Para determinar el valor del vector [ H ] podemos llevar a cabo la siguiente operación. H = L+E || L + E || Cabe mencionar que para nuestro programa vamos a utilizar vectores normalizados, es decir, de magnitud igual a “uno”, por consiguiente, la operación anterior puede ser reducida a la siguiente función: H = normalize( L + E ). En el cálculo de la reflectancia, habrá al menos tres variables que tendremos que agregar en nuestro código, estas corresponden a: 1 La dirección de la iluminación. 2 Las normales de la superficie. 3 Y el valor halfway que incluye a la dirección de la vista. Además, en caso de que deseemos agregar Mapas de Especularidad, será necesario calcular el color de reflectancia. 228 Iniciaremos un nuevo programa para revisar estos conceptos, para ello crearemos un shader tipo Unlit al cual llamaremos USB_specular_reflection. Cabe mencionar que muchas de las operaciones que realizaremos en esta sección son iguales en implementación a aquellas realizadas en USB_diffuse_shading, de modo que, podemos comenzar desde cero o continuar desde el shader que desarrollamos en la sección anterior. Dentro de nuestro programa, originamos una función llamada SpecularShading. Dentro de ella incluiremos las propiedades mencionadas anteriormente de la siguiente manera: Reflexión Especular ⚫ ⚫ ⚫ Shader "USB/USB_specular_reflection" { Properties { … } SubShader { Pass { CGPROGRAM … // declaramos la función en el programa float3 SpecularShading() { … } … ENDCG } } } Reflexión Especular ⚫ ⚫ ⚫ // estructura interna de la función SpecularShading float3 SpecularShading ( float3 colorRefl, // S a float specularInt, // S float3 normal, p // n Continúa en la siguiente página. 229 float3 lightDir, // l float3 viewDir, // e float specularPow // exponent ) { float3 h = normalize(lightDir + viewDir); // halfway return colorRefl * specularInt * pow(max(0, dot(normal, h)), specularPow); } En esta ocasión, se ha declarado una función llamada SpecularShading que retorna un vector de tres dimensiones para sus colores RGB. Dentro de sus argumentos podemos encontrar, 1 El color de reflectancia ( colorRefl ). 2 La intensidad de la especularidad ( specularInt ). 3 Las normales de la superficie ( normal ). 4 La dirección de la iluminación ( lightDir ). 5 La dirección de la vista ( viewDir ). 6 Y el exponente de especularidad ( specularPow ). De la misma manera que hicimos en el cálculo de la Reflectancia Difusa, tanto las Normales como la dirección de la vista e iluminación serán calculadas en World-Space, por ende, habrá que realizar algunas transformaciones en el Vertex Shader Stage. Iniciaremos configurando tres propiedades para nuestro shader, 1 Una propiedad de textura para el mapa especular. 2 Un rango entre 0.0f y 1.0f para la intensidad de reflectancia. 3 Y un nuevo rango entre 1 y 128 para el exponente de la especularidad. 230 Reflexión Especular ⚫ ⚫ ⚫ Shader "USB/USB_specular_reflection" { Properties { // mode "white" _MainTex ("Texture", 2D) = "white" {} // mode "black" _SpecularTex ("Specular Texture", 2D) = "black" {} _SpecularInt ("Specular Intensity", Range(0, 1)) = 1 _SpecularPow ("Specular Power", Range(1, 128)) = 64 } } A diferencia de la propiedad _MainTex; _SpecularTex posee un color negro por defecto. Esto podemos corroborarlo en la definición black que se encuentra al final de la declaración. Su funcionamiento es bastante simple: Si no asignamos una textura desde el Inspector, entonces el objeto se verá de color negro. Cabe mencionar que la especularidad se va a sumar a la textura principal, por lo tanto, para este caso el color negro no será visible gráficamente debido a que, como ya sabemos, el color negro es igual a “cero”, y 0.0f más 1.0f, es igual a “uno”. Reflexión Especular ⚫ ⚫ ⚫ (Fig. 7.0.4d) A continuación, debemos declarar las variables de conexión dentro de nuestro programa, para las tres Propiedades que hemos generado. 231 Reflexión Especular ⚫ ⚫ ⚫ Pass { CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; sampler2D _SpecularTex; // float4 _SpecularTex_ST; float _SpecularInt; float _SpecularPow; float4 _LightColor0; … ENDCG } ¿Por qué hemos descartado la variable _SpecularTex_ST en el ejemplo anterior? Como ya sabemos, las variables de conexión que terminan en el sufijo _ST agregan Tiling y Offset a su textura. En el caso de _SpecularTex no será necesario agregar este tipo de transformación debido a que, generalmente, las texturas o Mapas de Especularidad no las necesitan por su naturaleza constante. Otra variable de conexión que hemos generado es _LightColor[n]. Esta la utilizaremos para multiplicar el resultado de color de _SpecularTex, de esta manera, el color de la especularidad se verá afectado por el color de la fuente lumínica que tenemos en nuestra escena. Hasta este punto las propiedades ya son funcionales; ya podemos comenzar con la implementación de la función SpecularShading en el Fragment Shader Stage. Iniciaremos calculando el color de reflexión. 232 Reflexión Especular ⚫ ⚫ ⚫ float3 SpecularShading() { … } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); fixed3 colorRefl = _LightColor0.rgb; fixed3 specCol = tex2D(_SpecularTex, i.uv) * colorRefl; half3 specular = SpecularShading(specCol, 0, 0, 0, 0, 0); return col; } El primer argumento en la función SpecularShading corresponde al color de reflexión. Para ello, hemos multiplicado la textura _SpecularTex por el color de la iluminación, y el factor se ha almacenado dentro de un vector de tres dimensiones llamado specCol, el cual asignamos como primer argumento en la función. El segundo argumento corresponde a la intensidad de la especularidad, para ello, simplemente podemos asignar la propiedad _SpecularInt, que es un rango entre 0.0f y 1.0f. Reflexión Especular ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); fixed3 colorRefl = _LightColor0.rgb; fixed3 specCol = tex2D(_SpecularTex, i.uv) * colorRefl; half3 specular = SpecularShading(specCol, _SpecularInt, 0, 0, 0, 0); return col; } 233 El tercer argumento en la función se refiere a las Normales del objeto en World-Space. Para este fin, tendremos que agregar las semánticas correspondientes tanto en el Vertex Input como Output y luego transformar su espacio en el Vertex Shader Stage, de la misma manera que hicimos en el cálculo de Reflexión Difusa. Reflexión Especular ⚫ ⚫ ⚫ struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; Posteriormente debemos asignar las Normales en el Vertex Output. No obstante, debemos recordar que la semántica NORMAL no existe para este proceso, por lo tanto, habrá que utilizar a TEXCOORD[n] dado que esta posee hasta cuatro dimensiones. Reflexión Especular ⚫ ⚫ ⚫ struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 normal_world : TEXCOORD1; }; Antes de volver al Fragment Shader Stage pasaremos una última semántica en el Vertex Output. Tal será utilizada posteriormente en el cálculo de la dirección de la vista, a modo de punto de referencia. 234 Reflexión Especular ⚫ ⚫ ⚫ struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 normal_world : TEXCOORD1; float3 vertex_world : TEXCOORD2; }; Como podemos ver, se ha incluido una nueva propiedad llamada vertex_world, la cual se refiere a la posición de los vértices del objeto en World-Space. Continuaremos yendo al Vertex Shader Stage para transformar el espacio de estas coordenadas, sin embargo, a diferencia de los procesos señalados anteriormente, en esta oportunidad utilizaremos la función UnityObjectToWorldNormal( N RG Object-Space a World-Space. ) para transformar las Normales desde Reflexión Especular ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o, o.vertex); o.normal_world = UnityObjectToWorldNormal(v.normal); o.vertex_world = mul(unity_ObjectToWorld, v.vertex); return o; } UnityObjectToWorldNormal( N RG ) viene incluida en UnityCg.cginc y es equivalente a la multiplicación de la matriz unity_ObjectToWorld por el input de Normales del objeto, de manera inversa. A continuación podremos apreciar su estructura interna. 235 Reflexión Especular ⚫ ⚫ ⚫ inline float3 UnityObjectToWorldNormal(in float3 norm) { #ifdef UNITY_ASSUME_UNIFORM_SCALING return UnityObjectToWorldDir(norm); #else return normalize(mul(norm, (float3x3) unity_WorldToObject)); #endif } Un factor a considerar es que normal_world está normalizando la operación de transformación. Esto se debe a que las Normales son una dirección de espacio; un vector de tres dimensiones que retorna una magnitud de “uno” como máximo, mientras que vertex_world sigue siendo una posición de espacio, con la diferencia que ahora es calculada en World-Space. Continuaremos con la función SpecularShading. Reflexión Especular ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // implementamos las normales en world-space float3 normal = i.normal_world; fixed3 colorRefl = _LightColor0.rgb; fixed3 specCol = tex2D(_SpecularTex, i.uv) * colorRefl; half3 specular = SpecularShading(specCol, _SpecularInt, normal, 0, 0, 0); return col; } Como podemos ver, se ha creado un nuevo vector de tres dimensiones llamado normal. Este vector posee el output de Normales en World-Space, por esa razón se ha asignado como tercer argumento en la función. 236 Para la iluminación, no será necesario generar algún tipo de transformación debido a que podemos utilizar la variable interna _WorldSpaceLightPos[n], que se refiere a la dirección de la luz en World-Space. Reflexión Especular ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // calculamos la dirección de la luz float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 normal = i.normal_world; fixed3 colorRefl = _LightColor0.rgb; fixed3 specCol = tex2D(_SpecularTex, i.uv) * colorRefl half3 specular = SpecularShading(specCol, _SpecularInt, normal, lightDir, 0, 0); return col; } Hasta este punto solo faltaría calcular la dirección de la vista dado que el último argumento en la función SpecularShading corresponde al valor exponencial la cual aumenta o disminuye la cantidad de reflexión. Para calcular la dirección de la vista debemos restar los vértices del objeto en World-Space a la cámara en World-Space también. Unity posee una variable interna llamada _WorldSpaceCameraPos, la cual nos da acceso precisamente a la posición de la cámara que tenemos en la escena. 237 Reflexión Especular ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // calculamos la dirección de la luz float3 viewDir = normalize(_WorldSpaceCameraPos - i.vertex_world); float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float3 normal = i.normal_world; fixed3 colorRefl = _LightColor0.rgb; fixed3 specCol = tex2D(_SpecularTex, i.uv) * colorRefl; // pasamos la dirección de la vista a la función half3 specular = SpecularShading(specCol, _SpecularInt, normal, lightDir, viewDir, _SpecularPow); return col; } Finalmente, solo estaría faltando agregar la especularidad a la textura principal, para ello podemos realizar la siguiente operación: Reflexión Especular ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 viewDir = normalize(_WorldSpaceCameraPos - i.vertex_world); float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); half3 normal = i.normal_world; fixed3 colorRefl = _LightColor0.rgb; fixed3 specCol = tex2D(_SpecularTex, i.uv) * colorRefl; half3 specular = SpecularShading(specCol, _SpecularInt, normal, lightDir, viewDir, _SpecularPow); Continúa en la siguiente página. 238 // agregamos la especularidad a la textura col.rgb += specular; return col; } Recordemos que no podemos sumar un vector de cuatro dimensiones con uno de tres dimensiones, por esta razón, debemos asegurarnos de agregar únicamente los canales RGB de la Reflexión Especular a la textura principal. Dado que la reflexión es un pase de iluminación, una vez más, debemos ir a los Tags y configurar el Render Path de la misma manera que hicimos en la Reflexión Difusa. Reflexión Especular ⚫ ⚫ ⚫ Shader "USB/USB_specular_reflection" { Properties { … } SubShader { Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" } } } 239 7.0.5. Reflexión Ambiental. La Reflexión Ambiental ocurre de manera similar a la Reflexión Especular. Su diferencia radica en la cantidad de rayos de luz que afectan a una superficie, p. ej., en una escena genérica, la Reflexión Especular será generada únicamente por la fuente de luz principal, mientras que la Reflexión Ambiental será generada por cada uno de los rayos de luz que impactan sobre una superficie, incluyendo rebotes de todos los ángulos. Reflexión Ambiental ⚫ ⚫ ⚫ (Fig. 7.0.5a) Dada su naturaleza, calcular este tipo de reflexión en tiempo real es muy costoso para la GPU, en cambio, podemos utilizar una textura de tipo Cubemap. En la sección 3.0.6 del capítulo uno, mencionamos a la propiedad Cube la cual se refiere precisamente a este tipo de textura. En Unity podemos generar Cubemaps a través del componente Reflection Probe. Este objeto funciona similar a una cámara, que captura una vista esférica de su entorno en todas las direcciones, para luego, generar una textura de tipo Cube que podemos utilizar como mapa de reflexión. 240 Reflexión Ambiental ⚫ ⚫ ⚫ (Fig. 7.0.5b) Para ver en detalle su implementación, crearemos un nuevo shader tipo Unlit al cual llamaremos USB_cubemap_reflection. Iniciaremos declarando una nueva función que utilizaremos posteriormente para generar reflexión en el programa. Reflexión Ambiental ⚫ ⚫ ⚫ Shader "USB/USB_cubemap_reflection" { Properties { … } SubShader { Pass { CGPROGRAM … // agregamos la función en el programa float3 AmbientReflection () { … } … ENDCG } } } 241 Reflexión Ambiental ⚫ ⚫ ⚫ // estructura interna de la función AmbientReflection float3 AmbientReflection ( samplerCUBE colorRefl, float reflectionInt, half reflectionDet, float3 normal, float3 viewDir, float reflectionExp ) { float3 reflection_world = reflect(viewDir, normal); float4 cubemap = texCUBElod(colorRefl, float4(reflection_world, reflectionDet)); return reflectionInt * cubemap.rgb * (cubemap.a * reflectionExp); } El primer argumento de la función AmbientReflection corresponde a un sampler para una textura de tipo Cube, la cual supone la creación de una Propiedad de este tipo. Luego podemos encontrar a una variable llamada reflectionInt, la cual utilizaremos para modificar la intensidad de reflexión posteriormente, dentro de un rango entre “cero y uno”. El tercer argumento corresponde a una variable de media precisión llamada reflectionDet, esta la utilizaremos para aumentar o disminuir la densidad de texels que posee el samplerCUBE. Si prestamos atención, notaremos que reflectionDet ha sido incluida en el método texCUBElod( C RG ,S RG ) de la siguiente manera: Reflexión Ambiental ⚫ ⚫ ⚫ texCUBElod(colorRefl, float4(reflection_world, reflectionDet)); // float4 texCUBElod(samplerCUBE samp, float4 s) // s.xyz = coordenadas de reflexión // s.w = densidad de texels 242 El método texCUBElod posee dos argumentos por defecto: el primero se refiere al samplerCUBE que vamos a utilizar como textura, y el segundo corresponde a un vector de cuatro dimensiones que ha sido dividido en dos partes; los primeros tres canales corresponden a las coordenadas de reflexión en World-Space, mientras que el último corresponde al nivel de detalle de los texels del samplerCUBE. Al igual que en la Reflexión Especular, será necesario calcular tanto las Normales como la dirección de la vista en World-Space. Por esta razón, se han incluido tales vectores como argumentos en la función. Finalmente, como último argumento, podemos encontrar una variable llamada reflectionExp, la cual se refiere a la exposición de color del Mapa de Reflexión. Una función que utilizaremos con frecuencia en el cálculo de reflexión es reflect(.I ,N RG operación incluida tanto en Cg como en HLSL se compone de la siguiente manera: ). Esta RG. Reflexión Ambiental ⚫ ⚫ ⚫ float3 reflect (float3 i, float3 n) { return i - 2.0 * n * dot(n, i); } float3 reflection_world = reflect(viewDir, normal); Su primer argumento I que N RG RG se refiere al valor de incidencia, o sea, a la dirección de la vista, mientras son las Normales del objeto. Cabe mencionar que en la operación interna de la función “reflect”, el valor de incidencia está siendo calculado en dirección al punto de reflexión, lo cual va a generar que el Mapa de Reflexión se vea gráficamente volteado; como si estuviésemos viendo el reflejo a través de un lente cóncavo. Para solucionar esto tendremos que hacer negativo al valor de incidencia. Ahora que entendemos gran parte de la operación, iniciaremos su implementación en el Fragment Shader Stage. Para ello crearemos un vector de tres dimensiones y le pasaremos la función AmbientReflection de la siguiente manera: 243 Reflexión Ambiental ⚫ ⚫ ⚫ float3 AmbientReflection() { … } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); half3 reflection = AmbientReflection(0, 0, 0, 0, 0, 0); return col; } Como ya sabemos, el primer argumento en la función corresponde al color de reflexión de tipo Cube, por esta razón iremos a las Propiedades de nuestro shader y declararemos tanto la textura como aquellas variables de intensidad, detalle y exposición. Reflexión Ambiental ⚫ ⚫ ⚫ Shader "USB/USB_cubemap_reflection" { Properties { _MainTex ("Texture", 2D) = "white" {} _ReflectionTex ("Reflection Texture", Cube) = "white" {} _ReflectionInt ("Reflection Intensity", Range(0, 1)) = 1 _ReflectionMet ("Reflection Metallic", Range(0, 1)) = 0 _ReflectionDet ("Reflection Detail", Range(1, 9)) = 1 _ReflectionExp ("Reflection Exposure", Range(1, 3)) = 1 } SubShader { … } } Dentro de las Propiedades declaradas, podemos encontrar a _ReflectionMet la cual no ha sido incluida en la función AmbientReflection. Como su nombre lo menciona utilizaremos esta propiedad para controlar el brillo de la reflexión y así emular una superficie metálica. 244 Continuaremos generando las variables de conexión para estas propiedades. Reflexión Ambiental ⚫ ⚫ ⚫ CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; samplerCUBE _ReflectionTex; float _ReflectionInt; half _ReflectionDet; float _ReflectionExp; float _ReflectionMet; … ENDCG Dado que el muestreo de la textura ocurre dentro de la función AmbientReflection, podemos pasar directamente la propiedad _ReflectionTex como primer argumento en la declaración de la función. Reflexión Ambiental ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // agregamos la textura half3 reflection = AmbientReflection(_ReflectionTex, 0, 0, 0, 0, 0); return col; } Así mismo podemos agregar el segundo y tercer argumento a la función, ya que corresponden a reflectionInt y reflectionDet. 245 Reflexión Ambiental ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // agregamos la intensidad y detalle de la reflexión half3 reflection = AmbientReflection(_ReflectionTex, _ReflectionInt, _ReflectionDet, 0, 0, 0); return col; } El cuarto y quinto argumento corresponde a las Normales y a la dirección de la vista, ambas en World-Space. Para esto, tendremos que llevar a cabo exactamente la misma operación que realizamos en la Reflexión Especular, o sea, incluir a las Normales tanto en el Vertex Input como Output y luego declarar la dirección de la vista en el Vertex Output de igual manera. Reflexión Ambiental ⚫ ⚫ ⚫ // vertex input struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; // vertex output struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 normal_world : TEXCOORD1; float3 vertex_world : TEXCOORD2; }; 246 Ahora simplemente podemos transformar sus coordenadas de espacio desde Object-Space a World-Space en el Vertex Shader Stage. Reflexión Ambiental ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.normal_world = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0))).xyz; o.vertex_world = mul(unity_ObjectToWorld, v.vertex); return o; } Continuaremos con la función AmbientReflection, y para ello, crearemos un nuevo vector en el cual asignaremos las Normales, y además, llevaremos a cabo la operación para el cálculo de la dirección de la vista. Sin embargo, esta vez utilizaremos la función UnityWorldSpaceViewDir; incluida en UnityCg.cginc, la cual es equivalente a la resta entre la posición de la cámara y los vértices del objeto. Reflexión Ambiental ⚫ ⚫ ⚫ // función incluida en UnityCg.cginc inline float3 UnityWorldSpaceViewDir( in float3 worldPos) { return _WorldSpaceCameraPos.xyz - worldPos; } // nuestra función fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); half3 normal = i.normal_world; half3 viewDir = normalize(UnityWorldSpaceViewDir(i.vertex_world)); Continúa en la siguiente página. 247 // agregamos las normales y dirección de la vista half3 reflection = AmbientReflection(_ReflectionTex, _ReflectionInt, _ReflectionDet, normal, -viewDir, 0); return col; } Si prestamos atención, notaremos que el quinto argumento; correspondiente a la dirección de la vista, ha sido incluido en negativo, ¿A qué se debe esto? Básicamente, a la dirección del vector de incidencia. Hacer su valor negativo va a permitir que la reflexión funcione perfectamente para este caso. Reflexión Ambiental ⚫ ⚫ ⚫ (Fig. 7.0.5c) Como última operación faltaría agregar el sexto argumento correspondiente a la exposición de la reflexión, y además, sumar la reflexión total al color RGB de la textura principal. Reflexión Ambiental ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); half3 normal = i.normal_world; half3 viewDir = normalize(UnityWorldSpaceViewDir(i.vertex_world)); // agregamos la exposición half3 reflection = AmbientReflection(_ReflectionTex, _ReflectionInt, _ReflectionDet, normal, -viewDir, _ReflectionExp); Continúa en la siguiente página. 248 col.rgb *= reflection + _ReflectionMet; return col; } En el ejemplo anterior, multiplicamos el color de la textura en RGB por la reflexión, y luego sumamos la propiedad _ReflectionMet que corresponde a un rango entre 0.0f y 1.0f. Para entender la operación debemos prestar atención a la propiedad _ReflectionInt la que también es un rango. Dado que el vector “reflection” está multiplicando a “col.rgb”, el color resultante será el de una superficie metalizada. En este punto sumamos a _ReflectionMet para aclarar el color final de la superficie y así obtener variaciones de reflexión. Una manera distinta de llevar a cabo el proceso es a través del macro UNITY_SAMPLE_TEXCUBE. Esta asigna de manera automática la Reflexión Ambiental que se encuentre configurada en nuestra escena, es decir, que si tenemos configurado algún Skybox desde la ventana de iluminación (Lighting Window) entonces la reflexión será guardada como textura dentro de nuestro shader y podremos utilizarla de inmediato sin la necesidad de generar una textura Cubemap de manera independiente. Reflexión Ambiental ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); half3 normal = i.normal_world; half3 viewDir = normalize(UnityWorldSpaceViewDir(i.vertex_world)); half3 reflect_world = reflect(-viewDir, normal); // el proceso mencionado anteriormente es reemplazado por la // función UNITY_SAMPLE_TEXCUBE half3 reflectionData = UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, reflect_world ); half3 reflectionColor = DecodeHDR(reflectionData , unity_SpecCube0_HDR); col.rgb = reflectionColor; return col; } 249 La variable interna unity_SpecCube[n] contiene la data del objeto Reflection Probe que se encuentra por defecto en Unity. El macro de UNITY_SAMPLE_TEXCUBE reflexión para DecodeHDR( D ,I RG RG luego samplea decodificar los esta colores data en siguiendo HDR a las través coordenadas de la función ) la cual viene incluida en UnityCg.cginc. La operación descrita anteriormente agrega mayor facilidad al momento de la implementación de este tipo de reflexión, pero genera menor control en el resultado final. 7.0.6. Efecto Fresnel. El efecto Fresnel (por su creador Augustin Jean Fresnel), también conocido como efecto Rim, es un tipo de reflexión donde la cantidad de la misma es proporcional al ángulo de incidencia; al ángulo entre las Normales de un objeto y la dirección de la cámara. Efecto Fresnel ⚫ ⚫ ⚫ (Fig. 7.0.6a) Mientras más lejana se encuentre la superficie a la cámara, más Reflexión Fresnel habrá, dado que el ángulo entre el valor de incidencia y las Normales del objeto es mayor. 250 Efecto Fresnel ⚫ ⚫ ⚫ (Fig. 7.0.6b) La reflexión es nula cuando el ángulo entre el valor de incidencia y las Normales es igual a 0°. Esto debido a que ambos vectores son paralelos, en cambio, cuando el ángulo es igual a 90°, la reflexión es completa y los vectores son perpendiculares. Este factor es bastante interesante dado que, cuando la reflexión sea nula, nuestro programa tendrá que retornar un color negro. Por el contrario, cuando sea completa, tendrá que retornar a un color blanco, ¿Por qué razón? Porque estos corresponden a los valores máximos y mínimos de iluminación de un píxel. Para entender este concepto analizaremos la siguiente función proveniente del nodo Fresnel Effect en Shader Graph. Efecto Fresnel ⚫ ⚫ ⚫ void unity_FresnelEffect_float ( in float3 normal, in float3 viewDir, in float power, out float Out ) { Out = pow((1 - saturate(dot(normal, viewDir))), power); } 251 En su estructura están ocurriendo varias cosas que iremos detallando a lo largo de esta sección, por ahora, nos centraremos únicamente en la operación interna que ocurre como output. Dicha operación puede ser dividida en tres procesos: saturate(dot(normal, viewDir)) Tal operación determina el ángulo entre el vector de incidencia y las normales del objeto, y como resultado retorna un rango numérico entre 0.0f y 1.0f. Como ya sabemos, la función dot( A ,B RG RG ) va a retornar “uno, cero, o menos uno” según el ángulo entre sus argumentos. Dado que la operación de reflexión requiere únicamente de un valor entre 0.0f y 1.0f, se ha agregado la función intrínseca saturate( X RG valores entre este rango. Efecto Fresnel ), la cual limita los ⚫ ⚫ ⚫ // solo puede retornar "0" como mínimo y "1" como máximo float saturate (float x) { return max(0, min(1, x)); } // se puede modificar el rango mínimo y máximo float clamp (float x, float a, float b) { return max(a, min(b, x)); } “Saturate” cumple la misma función que “clamp”, con la diferencia que en este último podemos modificar el valor mínimo y máximo para generar el límite. Continuemos con la operación “1 - x”. (1 - x ) 252 Para entender su naturaleza debemos volver al ejercicio anterior. La función dot(A , B RG ) RG va a retornar 1.0f cuando el vector de dirección de la vista y las Normales sean paralelas, y apunten hacia la misma dirección. Esto figura un problema para nosotros, ya que, en este caso necesitamos que la operación retorne 0.0f, el cual, como ya se ha mencionado, equivale a un color negro. Efecto Fresnel ⚫ ⚫ ⚫ (Fig. 7.0.6c) La operación “1 - x” cumple la función de voltear el resultado de la siguiente manera. Efecto Fresnel ⚫ ⚫ ⚫ // si las normales y la dirección de la vista son paralelas en la misma // dirección. saturate(dot(float3(0, 1, 0), float3(0, 1, 0))) = 1 1 - 1 = 0 // si las normales y la dirección de la vista son perpendiculares saturate(dot(float3(0, 1, 0), float3(1, 0, 0))) = 0 1 - 0 = 1 Finalmente, en la función podemos encontrar la operación pow(X , N RG aumentar o disminuir el rango de la reflexión. ) la cual permite RG Para entender en detalle la operación Fresnel de Shader Graph, iniciaremos un nuevo shader tipo Unlit al que llamaremos USB_fresnel_effect. La primera acción que llevaremos a cabo será incluir esta función dentro de nuestro programa. 253 Efecto Fresnel ⚫ ⚫ ⚫ Shader "USB/USB_fresnel_effect" { Properties { … } SubShader { Pass { CGPROGRAM … void unity_FresnelEffect_float() { … } … ENDCG } } } Cabe destacar que la función unity_FresnelEffect_float es de tipo void. En la sección 4.0.4 del capítulo I, revisamos la diferencia entre la implementación de una función vacía y otra que retorna un valor. En este caso tendremos que declarar algunas variables y pasarlas como argumentos, según corresponda. Iniciaremos declarando la función en el Fragment Shader Stage. Efecto Fresnel ⚫ ⚫ ⚫ void unity_FresnelEffect_float() { … } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // inicializamos la función vacía unity_FresnelEffect_float(0, 0, 0, 0); return col; } 254 El primer argumento en la función corresponde a las Normales del objeto en World-Space, así pues, tendremos que ir a Vertex Input y utilizar la semántica NORMAL, para luego transformar sus coordenadas de espacio en el Vertex Shader Stage. Por el hecho que utilizaremos las Normales en el Fragment Shader Stage, será necesario declarar un vector en el Vertex Output, de esta manera podremos almacenar el resultado de la transformación. Efecto Fresnel ⚫ ⚫ ⚫ // vertex input struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; // vertex output struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; float3 normal_world : TEXCOORD1; float3 vertex_world : TEXCOORD2; }; Como podemos apreciar, se ha agregado el vector vertex_world en el Vertex Output, dado que vamos a calcular la dirección de la vista, y para ello será necesario utilizar esta variable. Si prestamos atención, notaremos que el proceso es exactamente el mismo que hemos realizado en secciones anteriores para el cálculo de reflexión. 255 Efecto Fresnel ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.normal_world = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0))).xyz; o.vertex_world = mul(unity_ObjectToWorld, v.vertex); return o; } Continuando con la implementación de la función, iremos al Fragment Shader Stage y declararemos dos vectores: 1 Uno para el cálculo de las normales. 2 Y otro para la dirección de la vista. Efecto Fresnel ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 normal = i.normal_world; float3 viewDir = normalize(_WorldSpaceCameraPos i.vertex_world); // asignamos las normales y dirección de la vista a la función unity_FresnelEffect_float(normal, viewDir, 0, 0); return col; } En el ejercicio, se ha declarado un vector de tres dimensiones llamado normal, utilizado para almacenar el valor del output de Normales en World-Space. Luego se ha declarado un nuevo vector llamado viewDir el cual contiene el cálculo para la dirección de la vista. Ambos vectores se han asignado como primer y segundo argumento en la función 256 unity_FresnelEffect_float, dado que son requeridos en su operación interna. Para el tercer argumento habrá que declarar una propiedad con un rango numérico, el cual utilizaremos para modificar el rango de reflexión. Efecto Fresnel ⚫ ⚫ ⚫ Shader "USB/USB_fresnel_effect" { Properties { _MainTex ("Texture", 2D) = "white" {} _FresnelPow ("Fresnel Power", Range(1, 5)) = 1 _FresnelInt ("Fresnel Intensity", Range(0, 1)) = 1 } SubShader { … } } Utilizaremos a _FresnelPow como tercer argumento en la función; como valor exponencial, mientras que _FresnelInt será empleada para aumentar o disminuir la cantidad de efecto en el objeto. Como ya sabemos, para ambas propiedades tendremos que declarar variables de conexión dentro de nuestro programa. Efecto Fresnel ⚫ ⚫ ⚫ CGPROGRAM … sampler2D _MainTex; float4 _MainTex_ST; float _FresnelPow; float _FresnelInt; … ENDCG Una vez realizado el proceso, la Propiedad se encontrará conectada a nuestro programa, en otras palabras, podremos modificar de manera dinámica el rango de reflexión desde el Inspector de Unity. Ahora podemos utilizar a _FresnelPow como tercer argumento en la función. 257 Efecto Fresnel ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 normal = i.normal_world; float3 viewDir = normalize(_WorldSpaceCameraPos - i.vertex_world); // agregamos el valor exponente en la función unity_FresnelEffect_float(normal, viewDir, _FresnelPow, 0); return col; } El cuarto argumento corresponde al valor de salida de la función; donde guardaremos el output de color. Para ello, simplemente podemos crear una variable flotante y agregarla en la función. Efecto Fresnel ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 normal = i.normal_world; float3 viewDir = normalize(_WorldSpaceCameraPos - i.vertex_world); // inicializamos el output de color en negro float fresnel = 0; // agregamos el output de color unity_FresnelEffect_float(normal, viewDir, _FresnelPow, fresnel); col += fresnel * _FresnelInt; return col; } En el ejemplo anterior se ha declarado una variable llamada fresnel, la cual ha sido inicializada en “cero”. Luego se ha incluido en la función como cuarto argumento (como output) es decir, dentro de esta variable se encuentra el resultado de la operación final que ocurre en la función unity_FresnelEffect_float. 258 Al final de la operación podemos notar que el resultado de la variable fresnel, por su intensidad, se ha sumado al color de la textura base denominada “col”. Esto va a agregar la reflexión al objeto en nuestra escena y además va a permitir modificar su intensidad. 7.0.7. Estructura de un Standard Surface shader. Antes de continuar definiendo algunas funciones, haremos un alto en la estructura de un Standard Surface shader. Este programa; distinto del tipo Unlit, se caracteriza por poseer una estructura simplificada, la cual viene configurada para interactuar con iluminación únicamente en Built-in RP. Las funciones de reflexión que vimos en secciones anteriores, vienen incluidas de manera interna en este programa, así pues, este shader por defecto posee: › Iluminación Global. › Difusión. › Reflectancia. › Y Fresnel. Si creamos un Standard Surface notaremos de inmediato que su estructura no cuenta con un pase definido como tal, sino que, el CGPROGRAM está escrito dentro del campo del SubShader. Para entender su funcionamiento debemos prestar atención a la función surf que sería equivalente al output de color de la superficie del objeto. Dentro de esta función podemos encontrar dos argumentos, los cuales son: 1 Input IN. 2 E inout SurfaceOutputStandard o. Estos se refieren a los Inputs y Outputs que utilizaremos en nuestro shader, y sus semánticas han sido predefinidas de manera interna en el código. 259 Structure of a Standard Surface ⚫ ⚫ ⚫ Shader "Custom/SurfaceShader" { Properties { … } SubShader { CGPROGRAM … #pragma surface surf Standard fullforwardshadows … void surf (Input IN, input SurfaceOutputStandard o) { … } ENDCG } } La razón por la cual podemos determinar que “surf” es la función de salida de color, se debe a que ha sido declarada como tal en el #pragma surface surf. Este proceso es similar a un VertexFragment Shader, con la diferencia que en este caso podemos declarar otras propiedades, p. ej., el modelo de iluminación (Standard) y otros parámetros opcionales (fullforwardshadows). Por defecto, este tipo de programa viene configurado con un modelo de iluminación tipo Standard el cual lleva consigo algunas propiedades predefinidas que podemos utilizar como output de color. Si prestamos atención nuevamente a la función surf en nuestro código, notaremos que sus propiedades definidas son de tipo SurfaceOutputStandard, es decir, que el output de color estará determinado por el modelo de iluminaición Standard. 260 Structure of a Standard Surface ⚫ ⚫ ⚫ #pragma surface surf Standard … struct SurfaceOutputStandard { fixed3 Albedo; fixed3 Normal; half3 Emission; half Metallic; half Smoothness; half Occlusion; fixed Alpha; }; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; } 7.0.8. Standard Surface Input y Output. Al igual que en un shader tipo Vertex-Fragment; en un Standard Surface podemos encontrar dos funciones de tipo “struct” por defecto, estas son: 1 Input. 2 SurfaceOutputStandard. Struct Input es distinto del Struct appdata el cual revisamos en el capítulo anterior, ¿Por qué razón? En appdata podemos definir las semánticas que utilizaremos de nuestro objeto a modo de entrada, en cambio, en Input podemos determinar las funciones predefinidas que usaremos en nuestro shader para el cálculo de iluminación, ¿Qué quiere decir esto?. 261 En appdata podemos emplear la semántica POSITION[n] para utilizar la posición de los vértices del Mesh en Object-Space, en cambio, en Input podemos obtener directamente la variable “viewDir” el cual, como ya sabemos, corresponde a la dirección de la vista en World-Space, y es utilizada para el cálculo de distintas funciones de iluminación. Standard Surface Input y Output ⚫ ⚫ ⚫ struct Input { float2 uv_MainTex; // TEXCOORD0 float3 viewDir; // dirección de la vista en world-space float4 color : COLOR; // color de los vértices float3 worldPos; // vértices en world-space float3 worldNormal; // normales en world-space }; Al igual que en el cálculo de reflexión en las secciones anteriores, el Input viewDir es exactamente igual a la función empleada para determinar la dirección de la vista en World-Space. Standard Surface Input y Output ⚫ ⚫ ⚫ viewDir = normalize(_WorldSpaceCameraPos - i.vertex_world); Así mismo ocurre con worldPos y worldNormal, que se refieren a la posición de los vértices y Normales en World-Space también. Standard Surface Input y Output ⚫ ⚫ ⚫ worldPos = mul(unity_ObjectToWorld, v.vertex); worldNormal = normalize(mul(unity_ObjectToWorld, float4(v.normal, 0)))xyz; En conclusión, podemos utilizar las mismas propiedades que en un Vertex-Fragment Shader, su diferencia recae únicamente sobre las variables internas. 262 Sombra. 8.0.1. Shadow Mapping. Esta técnica permite generar Shadow Maps en una escena. Su concepto es bastante simple: el área de luz y sombra es generada en relación con el frustum de iluminación que estamos utilizando, es decir, si la fuente de luz corresponde a una luz direccional, la proyección de la sombra será ortográfica, mientras que si la fuente corresponde a una luz de punto, entonces la proyección será renderizada en perspectiva. Tal cálculo se realiza comparando si un píxel es visible desde la fuente de luz; como si tal fuente fuera el punto de proyección. El concepto se ejemplifica en la Figura 8.0.1a. Shadow Mapping ⚫ ⚫ ⚫ (Fig. 8.0.1a) Cuando creamos una fuente de luz en nuestra escena, toda el área visible; según el punto de vista de la fuente de luz, será la zona iluminada, y toda el área fuera de la misma, será la zona de sombra. De acuerdo a esta lógica, podemos determinar que esto corresponde a una operación de comparación, pero, ¿Cómo funciona este proceso? Para ello debemos revisar dos conceptos: › Shadow Caster. › Y Shadow Map. 263 Por una parte, el Shadow Caster corresponde al área de proyección de una sombra, mientras que el Shadow Map es igual a la sombra proyectada sobre un objeto. Shadow Mapping ⚫ ⚫ ⚫ (Fig. 8.0.1b) Shadow Map es una textura, por ende posee coordenadas UV y es calculada en dos etapas: primero, la escena es renderizada según el punto de vista de la fuente lumínica. En este proceso se extrae la información de profundidad desde el Z-Buffer y luego se guarda como una textura en la memoria interna. En segundo lugar, la escena es dibujada en la GPU de manera usual según el punto de vista de la cámara. Es en este punto donde debemos calcular las coordenadas UV de la textura guardada en memoria para generar y aplicar las sombras al objeto con el que estamos trabajando. 8.0.2. Shadow Caster. Iniciaremos con la implementación de las sombras. Para ello crearemos un nuevo shader de tipo Unlit al cual llamaremos USB_shadow_map. En este proceso, vamos a necesitar dos pases: › Uno para proyectar sombras ( Shadow Caster ). › Y otro para recibirlas ( Shadow Map ). Por lo tanto, comenzaremos incluyendo un segundo pase en el programa, el cual se encargará de la proyección de las sombras. 264 Shadow Caster ⚫ ⚫ ⚫ Shader "USB/USB_shadow_map" { Properties { … } SubShader { Tags { "RenderType"="Opaque" } LOD 100 // pase para el shadow caster Pass { … } // pase de color por defecto Pass { … } } } El pase de color corresponde al Pass que viene incluido por defecto cada vez que generamos un shader, en cambio, el nuevo Pass se encargará de generar la proyección de las sombras, por lo tanto, será necesario declarar al pase de proyección como Shadow Caster. Shadow Caster ⚫ ⚫ ⚫ // pase para el shadow caster Pass { Name "Shadow Caster" Tags { "RenderType"="Opaque" "LightMode"="ShadowCaster" } … } El Shadow Caster Pass inicia con la declaración de su nombre (Name “Shadow Caster”) y continúa con el Tag LightMode; que en este caso, debe ser igual a ShadowCaster para que Unity pueda reconocer su naturaleza. 265 Nombrar a un Pass es de gran ayuda cuando deseamos utilizar su funcionalidad de manera dinámica en un shader. En secciones posteriores de este libro revisaremos en detalle al comando UsePass el cual está relacionado directamente con este concepto. Cabe mencionar que la propiedad Name sólo cumple la función de asignación de nombre en el shader y no posee injerencia sobre el proceso de cálculo de la proyección. La declaración del nombre es un paso que eventualmente puede ser omitido, sin embargo en esta oportunidad lo utilizaremos para diferenciar ambos pases. Por el hecho de que el Shadow Caster corresponde únicamente a una proyección de sombra, será necesario pasar la posición de los vértices al Vertex Shader Stage y retornar “cero” en el Fragment Shader Stage. Shadow Caster ⚫ ⚫ ⚫ // pase para el shadow caster Pass { Name "Shadow Caster" Tags { "RenderType"="Opaque" "LightMode"="ShadowCaster" } ZWrite On CGPROGRAM #pragma vertex vert #pragma fragment frag struct appdata { // necesitamos solo la posición de vértices como input float4 vertex : POSITION; }; Continúa en la siguiente página. 266 struct v2f { // necesitamos solo la posición de vértices como output float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { return 0; } ENDCG } Hasta este punto el Shadow Caster estaría funcionando correctamente, sin embargo, la configuración anterior no va a permitir ajustar los valores de proyección, dado que aún estos no han sido definidos. Cuando trabajamos con sombras podemos determinar los valores de intensidad, resolución, Bias, Normal Bias y Near Plane. Tales propiedades podemos encontrarlas en el componente de la iluminación. 267 Shadow Caster ⚫ ⚫ ⚫ (Fig. 8.0.2a) Definir cada propiedad en nuestro shader podría tomarnos una gran cantidad de tiempo, para ello podemos trabajar con los siguientes macros que vienen incluido en UnityCG.cginc: 1 V2F_SHADOW_CASTER. 2 TRANSFER_SHADOW_CASTER_NORMALOFFSET( O 3 SHADOW_CASTER_FRAGMENT( I RG RG ). ). V2F_SHADOW_CASTER contiene varias semánticas para el cálculo de sombras tanto en la posición de los vértices interpolados como en los Mapas de Normales, es decir, que este macro posee: › Un output para la posición de los vértices ( vertex : SV_POSITION ). › Un output para las normales ( normal_world : TEXCOORD1 ). › Un output para las tangentes ( tangent_world : TEXCOORD2 ). › Y un output para las binormales ( binormal_world : TEXCOORD3 ). TRANSFER_SHADOW_CASTER_NORMALOFFSET( O RG ) se encarga de transformar las coordenadas de la posición de los vértices a Clip-Space y además calcula su Normal Offset, el cual permite incluir sombras en Mapas de Normales. 268 Finalmente, SHADOW_CASTER_FRAGMENT( I RG proyección de las sombras. ) es quien realiza el output de color para la Para que Unity pueda compilar estos macros tendremos que asegurarnos de incluir tanto la directiva UnityCG.cginc, como el #pragma multi_compile_shadowcaster para el cálculo de sus variantes. Shadow Caster ⚫ ⚫ ⚫ // shadow caster Pass Pass { Name "Shadow Caster" Tags { "RenderType"="Opaque" "LightMode"="ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_shadowcaster #include "UnityCg.cginc" struct v2f { V2F_SHADOW_CASTER; }; v2f vert (appdata_full v) { v2f o; TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) return o; } Continúa en la siguiente página. 269 fixed4 frag (v2f i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG } Hasta este punto el pase Shadow Caster está listo, por ende, podemos continuar sobre el pase incluido para generar un Shadow Map. 8.0.3. Shadow Map texture. Continuando con el shader USB_shadow_map, en esta sección será necesario definir una textura para proyectar sombras sobre nuestro objeto. Para ello tendremos que incluir el LightMode en el pase de color y hacerlo igual a ForwardBase, así, Unity sabrá que este pase se verá afectado por la iluminación. Shadow Map texture ⚫ ⚫ ⚫ // pase de color por defecto Pass { Name "Shadow Map Texture" Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" } … } Dado que este tipo de sombras corresponde a una textura proyectada sobre coordenadas UV, tendremos que declarar una variable tipo sampler2D. Así mismo, vamos a incluir coordenadas para abordar la textura. Este proceso será llevado a cabo en el Fragment Shader Stage debido a que la proyección debe ser calculada per-pixel. 270 Shadow Map texture ⚫ ⚫ ⚫ // pase de color por defecto Pass { Name "Shadow Map Texture" Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" } CGPROGRAM … struct v2f { float2 uv : TEXCOORD0; UNITY_FOG_COORDS(1) float4 vertex : SV_POSITION; // declaramos las coordenadas UVs para el shadow map float4 shadowCoord : TEXCOORD1; }; sampler2D _MainTex; float4 _MainTex_ST; // declaramos un sampler para el shadow map sampler2D _ShadowMapTexture; … ENDCG } Cabe mencionar que la textura _ShadowMapTexture sólo existirá dentro del programa, por consecuencia, no debe ser declarada como Propiedad en las propiedades de nuestro shader, tampoco vamos a pasar ninguna textura de manera dinámica desde el Inspector, en cambio, vamos a generar una proyección la cual funcionará como textura. Entonces cabe preguntarnos, ¿Cómo generamos una proyección de textura en nuestro shader? Para ello debemos entender como funciona la matriz de proyección 271 UNITY_MATRIX_P. Esta matriz nos permite pasar desde View-Space a Clip-Space la cual genera el clipping de los objetos en pantalla. Dada su naturaleza, posee una cuarta coordenada de la que ya hemos hablado anteriormente, esta se refiere a W, la cual; en este caso, define las coordenadas homogéneas que permiten dicha proyección. Orthographic projection space W = - ((Z FAR +Z ) / (Z NEAR FAR -Z )) NEAR Perspective projection space W = - ( 2 (Z FAR Z ) / (Z NEAR FAR -Z )) NEAR Como mencionamos en la sección 1.1.6, la matriz UNITY_MATRIX_P define la posición de un vértice de nuestro objeto en relación con el frustum de la cámara. El resultado de esta operación, la cual es llevada a cabo dentro de la función UnityObjectToClipPos( V RG ), genera unas coordenadas de espacio denominadas Normalized Device Coordinates (NDC). Este tipo de coordenadas poseen un rango entre -1.0f y 1.0f, y son generadas dividiendo los ejes XYZ por el componente W de la siguiente manera: projection.X projection.Y projection.Z projection.W projection.W projection.W Shadow Map texture ⚫ ⚫ ⚫ (Fig. 8.0.3a) 272 Es fundamental entender este proceso dado que, para posicionar la textura de sombra en nuestro shader, tendremos que utilizar la función “tex2D”. Tal función, como ya sabemos, nos pide dos argumentos: El primero se refiere a la textura en sí misma; a la textura _ShadowMapTexture que declaramos anteriormente, y el segundo se refiere a las coordenadas UV de la textura, sin embargo, en el caso de una proyección, tendremos que transformar desde normalized device coordinates a coordenadas UV, ¿Cómo hacemos esto? Para ello debemos tomar en consideración que las coordenadas UV tienen un rango entre 0.0f y 1.0f, mientras que NDC; como ya mencionamos, tiene un rango entre menos -1.0f y 1.0f Entonces, para transformar desde NDC a UV tendremos que realizar la siguiente operación: NDC = [-1, 1] + 1 NDC = [0, 2] / 2 NDC = [0, 1] Esta operación se puede resumir en la siguiente ecuación. ( NDC + 1 ) 2 Ahora, ¿Cuál es el valor de NDC? Como mencionamos anteriormente, sus coordenadas son generadas dividiendo los ejes XYZ por su componente W, como se muestra en la siguiente ecuación. NDC.x = NDC.y = projection.X projection.W projection.Y projection.W La razón por la que se ha mencionado este concepto se debe a que, en Cg o HLSL, los valores de las coordenadas UV son accedidas desde los componentes o coordenadas XY, ¿Qué nos quiere decir esto? Supongamos, en el input float2 uv : TEXCOORD0, podremos acceder a la coordenada U desde el componente uv.x, así mismo, para la coordenada V el cual sería uv.y. No obstante, 273 para proyectar sombras habrá que transformar las coordenadas desde Normalized Device Coordinates a coordenadas UV. Por lo tanto, volviendo a la operación anterior, las coordenadas UV obtendrían el siguiente valor: U = ( NDC.x + 1) * 0.5 NDX.w V = ( NDC.y + 1) * 0.5 NDX.w 8.0.4. Implementación de sombras. Ahora que entendemos el proceso de transformación de coordenadas, volveremos a nuestro shader USB_shadow_map para incluir una función a la cual llamaremos NDCToUV. Esta se encargará de transformar desde Normalized Device Coordinates a coordenadas UV. Cabe destacar que la función mencionada previamente corresponde a una explicación simplificada de la función ComputeScreenPos( P UnityCG.cginc. RG ), la cual ha sido incluida en la dependencia Tal función la ocuparemos en el Vertex Shader Stage, por esta razón habrá que declararla por sobre tal etapa. Implementación de sombras ⚫ ⚫ ⚫ // declaramos a NDCToUV sobre el vertex shader stage float4 NDCToUV(float4 clipPos) { float4 o = clipPos * 0.5; o.xy = float2(o.x, o.y) + o.w; o.zw = clipPos.zw; return o; } // vertex shader stage v2f vert(appdata v) { … } 274 En el ejemplo anterior se ha declarado la función NDCToUV que es tipo float4, es decir, un vector de cuatro dimensiones. Como argumento, se ha utilizado un nuevo vector de cuatro dimensiones llamado clipPos que se refiere al Output de posición de los vértices; al resultado que retorna desde la función UnityObjectToClipPos( V RG ). Luego, en la función, se ha traducido a código la operación matemática mencionada en la sección anterior. A pesar de ello, la operación no está completa debido a que existen algunos factores que no se están considerando para su implementación. Estos se refieren a: › La plataforma en donde compilamos nuestro código. › Y al half-texel offset. Es necesario indicar que existe una pequeña diferencia de coordenadas entre OpenGL y Direct3D. En este último, sus coordenadas UV inician en la parte superior izquierda, mientras que en OpenGL tales comienzan en la parte inferior. Implementación de sombras ⚫ ⚫ ⚫ (Fig. 8.0.4a) Tal factor genera un problema al momento de implementar una función en nuestro shader, dado que el resultado podría variar dependiendo de la plataforma. Para solucionar este problema, Unity provee una variable interna llamada _ProjectionParams la cual nos ayudará a corregir la diferencia de coordenadas. _ProjectionParams es un vector de cuatro dimensiones con distintos valores, p. ej., _ProjectionParams.x puede ser “uno o menos uno” dependiendo de si la plataforma posee 275 una matriz de transformación volteada; o sea, Direct3D. _ProjectionParams.y posee los valores de la cámara para el Z NEAR , _ProjectionParams.z posee los valores para el Z y _ProjectionParams.w tiene la operación 1/ Z FAR , FAR. En este caso utilizaremos _ProjectionParams.x dado que sólo necesitamos voltear la coordenada V dependiendo de su punto de inicio. Si prestamos atención a la Figura 8.0.4a , notaremos que la coordenada U mantiene su posición independientemente de la plataforma en la que estamos compilando, sin embargo, la coordenada V es aquella que cambia. Habiendo mencionado el concepto anterior, _ProjectionParams.x será igual a 1.0f cuando el shader sea compilado en OpenGL, o será -1.0f si es compilado en Direct3D. Tomando en consideración este factor, nuestra operación quedaría de la siguiente manera. Implementación de sombras ⚫ ⚫ ⚫ float4 NDCToUV(float4 clipPos) { float4 o = clipPos * 0.5; o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w; o.zw = clipPos.zw; return o; } Como podemos ver, se ha multiplicado la coordenada V de los UV, por la variable _ProjectionParams.x, de esta manera podremos voltear la matriz en caso de que nuestro shader sea compilado en Direct3D y ahora simplemente faltaría incorporar el Half-Texel Offset. En Unity podemos encontrar un macro llamado UNITY_HALF_TEXEL_OFFSET el cual funciona en plataformas que necesitan ajustes de desplazamiento en el mapeo, desde texturas a píxeles. Para generar el desplazamiento vamos a ocupar la variable interna _ScreenParams, la cual; al igual que _ProjectionParams, es un vector de cuatro dimensiones que posee un valor distinto en cada una de sus coordenadas. 276 Los valores que vamos a utilizar serán _ScreenParams.zw dado que, › Z es igual a 1.0f + 1.0f / width. › Mientras que W es igual a 1.0f + 1.0f / height. Tomando en consideración el Half-Texel Offset, nuestra función quedaría de la siguiente manera. Implementación de sombras ⚫ ⚫ ⚫ float4 NDCToUV(float4 clipPos) { float4 o = clipPos * 0.5; #if defined(UNITY_HALF_TEXEL_OFFSET ) o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w * _ScreenParams.zw; #else o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w; #endif o.zw = clipPos.zw; return o; } Hasta este punto las coordenadas UV para las sombras ya están funcionando perfectamente. A continuación debemos declararlas en el Vertex Shader Stage, para ello, haremos el Output shadowCoord igual a la función NDCToUV, y como argumento pasaremos el Output de vértices. Implementación de sombras ⚫ ⚫ ⚫ float4 NDCToUV() { … } v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.shadowCoord = NDCToUV(o.vertex); return 0; } 277 En este punto, shadowCoord posee las coordenadas de proyección y podemos utilizarlas como coordenadas UV para el _ShadowMapTexture. Como ya sabemos, la función “tex2D” pide dos argumentos: la textura y sus coordenadas UV. Podemos utilizar esta función para generar la sombra en el Fragment Shader Stage. Implementación de sombras ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // creamos las coordenadas UV para la sombra float2 uv_shadow = i.shadowMCoord.xy / i.shadowCoord.w; // guardamos la textura de sombra en la variable shadow fixed shadow = tex2D(_ShadowMapTexture, i.shadowCoord).a; col.rgb *= shadow; return col; } En el ejemplo anteriormente presentado, hemos creado una variable de una dimensión llamada shadow, la cual guarda los valores de muestreo para el _ShadowMapTexture. Una diferencia que podemos encontrar entre el vector “col” y la variable “shadow” es que esta última posee sólo una dimensión, ¿Por qué razón?. Debemos recordar que una textura de sombra únicamente posee los colores blanco y negro, por lo tanto, dentro de la variable shadow vamos a almacenar solamente el canal A dado que este nos entrega un rango desde 0.0f a 1.0f. 278 8.0.5. Optimización del Shadow Map en Built-in RP. El proceso de implementación de sombras que vimos anteriormente para el pase de color, puede ser optimizado a través de la utilización de macros que vienen incluidos en Unity, estos se refieren a: 1 SHADOW_COORDS( N 2 TRANSFER_SHADOW( O 3 SHADOW_ATTENUATION( O RG ). RG ). RG ). Será fundamental incluir el archivo AutoLight.cginc en nuestro programa, y el #pragma multi_ compile_fwdbase para el correcto funcionamiento de estos macros. Este último se encargará de compilar todas las variantes de lightmaps y sombras producidas por luces direccionales para el pase ForwardBase. Optimización del Shadow Map en Built-in RP ⚫ ⚫ ⚫ // pase de color por defecto Pass { Name "Shadow Map Texture" Tags { "LightMode"="ForwardBase" } CGPROGRAM … #pragma multi_compile_fwdbase nolightmap nodirlightmap nodynlightmap novertexlight #include "UnityCG.cginc" #include "AutoLight.cginc" … ENDCG } 279 Aquellas variables que han sido definidas luego del #pragma corresponden a parámetros opcionales que podemos definir para agregar o quitar alguna funcionalidad en el comportamiento de las sombras. › nolightmap. › nodirlightmap. › nodynlightmap. › novertexlight. Cabe destacar que si utilizamos macros para la implementación del Shadow Map, tendremos que aplicar algunas definiciones predeterminadas en aquellas semánticas que empleemos como Input-Output, de otra manera, nuestro código podría generar un error. Optimización del Shadow Map en Built-in RP ⚫ ⚫ ⚫ struct appdata { float4 vertex : POSITION; // float2 uv : TEXCOORD0; float2 texcoord : TEXCOORD0; }; Por defecto, nuestro código incluye al vector llamado uv con su semántica TEXCOORD[n] para el Vertex Input. En cambio, si utilizamos los macros será fundamental reemplazar la definición uv por texcoord, dado que, de otra manera, la operación interna no podrá leer las coordenadas UV para la generación del Shadow Map. Ocurre lo mismo en el Vertex Output. El vector vertex debe ser renombrado como pos para que el proceso interno pueda escribir las coordenadas UV. Además, tendremos que incluir el macro SHADOW_COORDS( N Shader Stage. RG ), el cual incluye a las coordenadas UV que pasaremos al Fragment 280 Optimización del Shadow Map en Built-in RP ⚫ ⚫ ⚫ struct v2f { float2 uv : TEXCOORD0; // posicionamos la data de sombra en TEXCOORD1 SHADOW_COORDS(1) // sin punto y coma (;) // float4 vertex : SV_POSITION; float4 pos : SV_POSITION; … }; Luego de haber declarado tanto los Inputs como los Outputs en el pase “Shadow Map Texture”, podemos sincronizar los valores en el Vertex Shader Stage. Optimización del Shadow Map en Built-in RP ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.texcoord; // transferimos las coordenadas UV de las sombras al fragment shader TRANSFER_SHADOW(o) // sin punto y coma (;) return o; } El macro TRANSFER_SHADOW( O RG ) es igual a la operación NDCToUV que realizamos en la sección anterior. Básicamente, está calculando las coordenadas UV para la textura de sombra. En este punto podemos transferir las coordenadas al Fragment Shader Stage. 281 Optimización del Shadow Map en Built-in RP ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // utilizamos las sombras fixed shadow = SHADOW_ATTENUATION(i); col.rgb *= shadow; return col; } Finalmente, SHADOW_ATTENUATION( ORG ) contiene la textura y su proyección. Dada su naturaleza, podemos guardar el resultado de esta función en un vector de una dimensión ya que solo se estará ocupando un canal en la proyección de la textura. 8.0.6. Shadow Mapping en Universal RP. El proceso que llevamos a cabo previamente es el más óptimo para el funcionamiento en Unity, sin embargo, su implementación puede variar dependiendo del Render Pipeline que estemos utilizando, p. ej., el proceso para la implementación Shadow Map que se detalló en las secciones anteriores, funciona únicamente en Built-in RP. Si deseamos generar sombras en Universal RP tendremos que hacer uso de la dependencia Lighting.hlsl, dado que este paquete incluye definiciones para el cálculo de coordenadas. Iniciaremos configurando el Render Pipeline a Universal RP de manera análoga. Para ello debemos incluir un Render Pipeline Asset en la casilla Scriptable Render Pipeline Settings, del menú Graphics. 282 Shadow Mapping en Universal RP ⚫ ⚫ ⚫ (Fig. 8.0.6a) Una vez realizado el proceso, crearemos un nuevo shader de tipo Unlit al cual llamaremos USB_ shadow_map_URP. Este shader lo utilizaremos para implementar tanto un Shadow Map como el Shadow Caster funcional en el motor de rendering. Además, revisaremos las dependencias necesarias para que el programa pueda compilar. Debido a su naturaleza, este shader será opaco y va a funcionar en Universal RP, por lo tanto, habrá que definir los valores de rendering en el programa. Shadow Mapping en Universal RP ⚫ ⚫ ⚫ Shader "USB/USB_shadow_map_URP" { Properties { … } SubShader { Tags { "RenderType"="Opaque" // agregamos el tipo de rendering pipeline "RenderPipeline"="UniversalRenderPipeline" } … } } 283 Como ya sabemos, las sombras son producidas en dos pases: uno para la proyección de sombras y otro para su textura. Por defecto, Unity agrega sólo un pase el cual podríamos utilizar para el Shadow Map. En Universal RP existe un shader llamado Lit, el cual ha sido incluido en la categoría Universal Render Pipeline. En su interior, podemos encontrar un pase llamado ShadowCaster, encargado del cálculo de coordenadas para la proyección de sombras. En Unity podemos incluir pases de manera dinámica utilizando el comando UsePass, ¿Qué quiere decir esto? Por una parte, podríamos ir al shader Lit, copiar el pase y pegarlo en nuestro shader, o simplemente podemos incluir la ruta del pase y hacer un llamado directamente a su función. Shadow Mapping en Universal RP ⚫ ⚫ ⚫ SubShader { Tags { "RenderType"="Opaque" // agregamos el tipo de rendering pipeline "RenderPipeline"="UniversalRenderPipeline" } // pase agregado por defecto Pass { … } // pase para el shadow caster UsePass "Universal Render Pipeline/Lit/ShadowCaster" } El comando UsePass se utiliza cuando deseamos incluir funcionalidades de un pase que se encuentra en un shader distinto del que estamos programando. Considerando el ejemplo anterior: el pase llamado ShadowCaster; que se encuentra en el shader Lit; en la ruta Universal Render Pipeline, será quien contenga los cálculos para el Shadow Caster en nuestro programa. 284 Shadow Mapping en Universal RP ⚫ ⚫ ⚫ // shader incluido por defecto en Unity distinto del que estamos programando Shader "Universal Render Pipeline/Lit" { Properties { … } SubShader { Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } … } } } Dado que se ha incluido tal ruta dentro del programa, será necesario definir al pase por defecto como aquel que va a procesar el Shadow Map, es decir, habrá que incluir el LightMode para Universal RP. Shadow Mapping en Universal RP ⚫ ⚫ ⚫ // pase agregado por defecto Pass { Tags { "LightMode"="UniversalForward" } HLSLPROGRAM … ENDHLSL } La propiedad UniversalForward funciona de manera similar a ForwardBase, su diferencia recae en la evaluación de todas las contribuciones de luz en el mismo pase. 285 Una vez definido el LightMode, debemos agregar algunas dependencias que serán de gran ayuda en la implementación de la textura. Shadow Mapping en Universal RP ⚫ ⚫ ⚫ HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOW #include "HLSLSupport.cginc" #include "Packages/com.unity.render-pipelines.universal/ ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ ShaderLibrary/Lighting.hlsl" … ENDHLSL Las dependencias Core.hlsl y Lighting.hlsl poseen varias funciones que utilizaremos en el cálculo, entre las cuales podemos indicar: la función GetVertexPositionInputs, la cual pertenece a una subdependencia denominada ShaderVariablesFunctions.hlsl, y GetShadowCoord que ha sido incluida en la subdependencia Shadows.hlsl. Debido a que implementaremos una textura, necesitaremos de un vector que pueda almacenar sus coordenadas UV, para ello podemos crear un vector de cuatro dimensiones en el Vertex Output. Shadow Mapping en Universal RP ⚫ ⚫ ⚫ struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float4 shadowCoord : TEXCOORD1; }; 286 Como se detalla en la sección 8.0.4, el vector shadowCoord va a almacenar el resultado de la transformación de vértices desde NDC a coordenadas UV. En Universal RP, será necesario utilizar la función GetShadowCoord( V de tipo VertexPositionInputs como argumento. RG ) que nos pide un objeto Shadow Mapping en Universal RP ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = TransformObjectToHClip(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); // puedes encontrar a VertexPositionInputs en Core.hlsl // puedes encontrar a GetVertexPositionInputs en // ShaderVariablesFunctions.hlsl VertexPositionInputs vertexInput = GetVertexPositionInputs(v.vertex.xyz); // puedes encontrar a GetShadowCoord en Shadows.hlsl o.shadowCoord = GetShadowCoord(vertexInput); return o; } En este punto, el vector “shadowCoord” ya posee las coordenadas para la generación de las sombras, ahora simplemente debemos pasarlo como argumento en la función GetMainLight( S RG ) que en sí posee el cálculo para la dirección de iluminación, atenuación, y color de la luz. Esta función ha sido incluida en la dependencia Lighting.hlsl. 287 Shadow Mapping en Universal RP ⚫ ⚫ ⚫ Light GetMainLight() { Light light; light.direction = _MainLightPosition.xyz; light.distanceAttenuation = unity_LightData.z; light.shadowAttenuation = 1.0; light.color = _MainLightColor.rgb; return light; } Light GetMainLight(float4 shadowCoord) { Light light = GetMainLight(); light.shadowAttenuation = MainLightRealtimeShadow(shadowCoord); return light; } GetMainLight( S RG ) posee hasta tres variaciones, en su tercera, podemos incluir el ShadowMask en caso de que lo necesitemos. A continuación, simplemente debemos crear un vector para almacenar la atenuación de sombras y multiplicar el color RGB de la textura por esta. Shadow Mapping en Universal RP ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { // puedes encontrar la función GetMainLight en Lighting.hlsl Light light = GetMainLight(i.shadowCoord); float3 shadow = light.shadowAttenuation; fixed4 col = tex2D(_MainTex, i.uv); col.rgb *= shadow; return col; } 288 Shader Graph. 9.0.1. Introducción a Shader Graph. Hasta este punto de nuestro libro, hemos revisado gran parte de la estructura de un Render Pipeline, así como también, entendido el funcionamiento de un shader en Unity. A continuación daremos paso a la introducción de Shader Graph, ya que su estructura y analogía se basan en gran medida en el conocimiento adquirido previamente. Shader Graph es un paquete que agrega soporte a una herramienta visual de edición de nodos. Su interfaz puede ser utilizada por artistas y desarrolladores para crear shaders personalizados a través de nodos en lugar de tener que escribir código. Aun así, podemos encontrar una alta compatibilidad con el lenguaje HLSL mediante su nodo Custom Función, el cual permite generar funciones específicas dentro del programa. Actualmente, Shader Graph se encuentra disponible para dos modalidades de rendering, estas se refiere a: › High Definition RP. › Y Universal RP. Introducción a Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.1a) Existe más de una consideración que debemos tomar al momento de trabajar con Shader Graph, y es que, las versiones desarrolladas en Unity 2018 corresponden a versiones BETA y no 289 reciben soporte, en cambio, las versiones desarrolladas posteriormente, ya son compatibles activamente y sí reciben soporte. Otra consideración; es muy probable que aquellos shaders creados en la interfaz no compilen correctamente en distintas versiones de la misma. Esto se debe a que, por cada actualización, se agregan nuevas características, afectando directamente el conjunto de nodos con el que estamos trabajando, más aún si se trata de funciones personalizadas. Entonces, ¿Es Shader Graph una buena herramienta para desarrollar shaders? La respuesta es sí, más aún para artistas. Para aquellos que han trabajado con software 3D como Maya o Blender; Shader Graph les será de mucha utilidad, ya que utiliza un sistema de nodos muy similar a Hypershader o Shader Editor, permitiendo un desarrollo más intuitivo. Introducción a Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.1b) Antes de introducirnos en esta materia, debemos considerar que la interfaz de Shader Graph posee algunas variaciones funcionales según su versión, p. ej. Al momento de escribir este libro, su versión más actualizada corresponde a 12.0.0. Si creamos un nodo dentro de esta versión podremos notar que el Vertex y Fragment shader Stage aparecen separados y funcionan de manera independiente. En cambio, si vamos a su versión 8.3.1, ambas etapas están fusionadas dentro de un nodo llamado Master, el cual se refiere al output de color final del shader. Como se ha mencionado anteriormente, es muy posible que los shaders generados en esta interfaz no compilen en distintas versiones de la misma, de hecho, si originamos un shader en la 290 versión 8.3.1 y actualizamos a la versión 12.0.0, probablemente existan cambios funcionales que impidan su proceso de compilación. 9.0.2. Iniciando en Shader Graph. Shader Graph puede ser incluido de dos maneras dentro de nuestro proyecto: 1 Desde la configuración por defecto; cuando iniciamos un proyecto, ya sea en Universal RP o High Definition RP. 2 O también de manera análoga, instalando el paquete desde el Package manager en Unity, el cual se encuentra en la ruta Window / Package Manager. Si iniciamos un proyecto en Universal RP o High Definition RP, tal paquete vendrá incluido por defecto y podremos utilizarlo de inmediato, en consecuencia, desde el menú Assets / Create / Shaders, podremos encontrar una nueva sección de shaders que funcionan de manera exclusiva para estos tipos de rendering. Iniciando en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.2a) ¿Qué ocurre si hemos creado un proyecto en Built-in RP y deseamos actualizarlo, ya sea a Universal RP o High Definition RP? Desde Built-in RP podemos actualizar a Universal RP fácilmente. Para ello, debemos seguir el segundo paso, descrito previamente en la inclusión de Shader Graph. Así mismo, será fundamental incluir el paquete Universal RP el cual agrega soporte a este tipo de Render Pipeline. 291 Cabe destacar que no se puede actualizar un proyecto desde Built-in RP a High Definition RP. De la misma manera, tampoco es permitido actualizar desde Universal RP a este último. High Definition RP es un Render Pipeline para videojuegos de alta gama y únicamente se puede iniciar como proyecto desde el Hub de Unity. Una vez instalados ambos paquetes, tendremos que crear un tipo de objeto llamado Pipeline Asset y otro denominado Forward Renderer Data desde el menú Assets / Create / Rendering. Generalmente, cuando generamos un Pipeline Asset, Unity origina un objeto de tipo Forward Renderer Data por defecto. Tal archivo funciona como Render Path para Universal RP. Teniendo estos archivos, debemos ir a la ventana Project Settings y en su submenú Graphics debemos asignar el objeto Pipeline Asset en la casilla Scriptable Render Pipeline Asset, así Unity sabrá que el Render Pipeline ahora corresponde a la configuración de Universal RP. Para finalizar, debemos ir a Quality, que se encuentra dentro de los Project Setting y asignar nuevamente el Pipeline Asset en la casilla de Rendering, de esta manera, todas las propiedades asociadas al rendering podremos modificarlas desde el mismo Render Pipeline Asset. Para generar un shader en Universal o High Definition RP, el proceso es exactamente el mismo que en Built-in RP, la única diferencia es que los shaders pertenecientes a Shader Graph están en una categoría exclusiva que se agrega de manera automática al momento de instalar el paquete. Iniciando en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.2b) Es necesario considerar la posibilidad de que el proceso y los pasos mencionados anteriormente cambien según la versión de Unity, sin embargo, su analogía de creación e instalación son la misma. 292 9.0.3. Analizando su interfaz. Como ya sabemos, la interfaz de Shader Graph tiene algunos cambios estructurales según la versión que estemos utilizando. En esta sección detallaremos su actualización número 10.4.0 que corresponde al paquete incluido en Unity 2020.3.1f1. Ahora; independientemente de su referencia, la analogía entre las distintas versiones será la misma, por ende, si has llegado hasta este punto del libro podrás entender a grandes rasgos su interfaz y sus distintas propiedades. Para iniciar en la interfaz de Shader Graph debemos haber creado previamente un shader perteneciente a Universal o High Definition RP según corresponda. Una vez que tenemos un shader en nuestro Proyecto, podemos abrir el programa haciendo doble clic sobre él o presionando el botón Open Shader Editor que se encuentra en el Inspector. Analizando su interfaz ⚫ ⚫ ⚫ (Fig. 9.0.3a) En su interfaz podremos encontrar cuatro botones principales que definen funcionalidades de la misma, estos corresponden a: › Save Asset. › Blackboard. › Graph Inspector. › Y Main Preview. 293 Analizando su interfaz ⚫ ⚫ ⚫ (Fig. 9.0.3b) La interfaz de Shader Graph luce como en la Figura 9.0.3b. El botón Save Asset permite guardar la configuración interna del shader; funciona de igual manera que el comando Ctrl+S para Windows o Cmd+S en el caso de un Mac. El Blackboard es la analogía directa de las Propiedades en ShaderLab. Como ya sabemos, si deseamos crear una propiedad en un shader vía código, debemos agregarlas dentro de las Propiedades en lenguaje declarativo. En Shader Graph es prácticamente lo mismo a diferencia que aquellas propiedades son agregadas a través del botón Plus que se encuentra en la parte superior del menú. Graph Inspector es un pequeño panel que facilita la modificación de nodos y configuración general del shader. Este cuenta con dos pestañas las cuales son: Node Settings y Graph Setting; ambas con distintas funcionalidades. Por su parte, Node Settings permite nombrar propiedades, referencias, valores por defecto, modo, precisión y otros. En cambio, Graph Settings define la configuración general que tendrá nuestro shader, p. ej., si nuestro shader será transparente, aditivo u opaco. Finalmente, Main Preview activa o desactiva la vista previa de la configuración de nodos, ideal para previsualizar el efecto que estamos desarrollando. Una función interesante que posee el panel de vista previa es que permite importar objetos personalizados. Para llevar a cabo el proceso, simplemente debemos hacer clic derecho sobre el área de previsualización y seleccionar Custom Mesh. 294 Analizando su interfaz ⚫ ⚫ ⚫ (Fig. 9.0.3c) 9.0.4. Nuestro primer shader en Shader Graph. En esta oportunidad trabajaremos con Universal RP para testar nuestros shaders. Iniciaremos nuestro trabajo yendo a la ventana de Proyecto en Unity, y crearemos un shader tipo Unlit Shader Graph, el cual se encuentra en la siguiente ruta: › Create / Shader / Universal Render Pipeline / Unlit Shader Graph. Cabe mencionar que esta ruta será visible únicamente si tenemos el paquete de Shader Graph incluido en nuestro proyecto. A continuación, recrearemos en Shader Graph aquel shader USB_simple_color que iniciamos en la sección 3.0.1. Para ello, nombraremos a este nuevo programa USB_simple_color_SG y haremos doble clic sobre él para iniciarlo. A simple vista, la primera diferencia que podemos notar entre ambos programas, es que el primero posee una extensión .shader; mientras que en el último, su extensión es .shadergraph. Así mismo, el ícono de presentación en cada caso posee una variación gráfica que se utiliza para diferenciar su naturaleza. Cabe señalar que, al igual que en programas anteriores, en Shader Graph también podemos modificar la ruta de acceso en el Inspector. 295 Por defecto, todos los shaders que creemos con esta interfaz serán guardados en la ruta Shader Graphs, sin embargo, si deseamos modificar su destino, podemos hacerlo directamente desde el acceso a la ruta que se encuentra en el Blackboard, debajo del nombre del shader. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4a) La operación anterior es equivalente a cambiar la ruta de un programa con extensión .shader de manera análoga. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ Shader "Shader Graphs/USB_simple_color_SG" { Properties { … } SubShader { … } } 296 Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4b) Como pudimos ver en secciones anteriores, cada vez que creamos un shader tipo Unlit, Unity agrega una propiedad por defecto que corresponde a una textura llamada _MainTex. Shader Graph no posee esta cualidad. Cuando iniciamos la interfaz, el Blackboard; donde se agregan las propiedades, viene vacío por defecto, por ende, tendremos que agregar cada propiedad al shader, de manera independiente presionando el botón Plus. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4c) 297 Antes de iniciar, haremos una pequeña introducción al Vertex-Fragment Shader Stage para entender su funcionamiento en esta interfaz. Como podemos ver, en el Vertex Shader Stage existen tres inputs definidos los cuales son: › Position(3). › Normal(3). › Tangent(3). Al igual que en un shader Cg o HLSL, tales inputs se refieren a las semánticas que podemos utilizar en el Vertex Input, o sea, › Position(3) es igual a POSITION[n]. › Normal(3) es igual a NORMAL[n]. › Y Tangent(3) es igual a TANGENT[n]. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4d) El valor que precede a tales inputs se refiere a la cantidad de dimensiones que posee ese vector, por lo tanto, Position(3) es igual a Position.xyz en Object-Space. ¿Por qué razón la cantidad de dimensiones en Shader Graph es igual a tres, pero en Cg o HLSL puede llegar hasta cuatro? Recordemos que la cuarta dimensión de un vector corresponde a su componente W, el cual; en la mayoría de los casos, puede ser “uno o cero”. Cuando W es igual a “uno” significa que el vector corresponde a una posición en el espacio; a un punto. En cambio, cuando es igual a “cero” entonces el vector corresponde a una dirección en el espacio. 298 Considerando la explicación anterior, podemos concluir que, si el input Position tuviera cuatro coordenadas, entonces estas serían XYZ1, en cambio, para las Normales sería XYZ0, así mismo para las Tangentes que también corresponden a una dirección en el espacio. Es fundamental entender esta analogía dado que la mayoría de las funciones, aplicaciones y propiedades en Shader Graph, se basa en la estructura de un .shader en HLSL. Para iniciar nuestro programa, lo primero que haremos es ir al Blackboard y crearemos dos propiedades: 1 Una de tipo Color a la que llamaremos _Color. 2 Y otra de tipo Texture2D que llamaremos _MainTex. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4e) Una pregunta que surge con frecuencia es, ¿Cuál es el color por defecto de nuestra propiedad _Color o incluso de _MainTex? Cuando declaramos una propiedad en ShaderLab podemos definir su tonalidad en el valor de la misma, sin embargo, en Shader Graph es distinto. Por defecto, nuestra propiedad _Color es de color negro, esto podemos corroborarlo yendo al Graph Inspector; pestaña Node Settings. Si prestamos atención a la propiedad Default notaremos que está configurada como color negro. 299 Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4f) De todas maneras podemos seleccionar un color distinto presionando sobre la barra de tonalidad (barra negra) y cambiando de color. Para _MainTex es exactamente lo mismo, dentro del Graph Inspector; pestaña Node Settings, podemos seleccionar el Mode de la misma, el cual es igual a white por defecto. Para generar comunicación entre las propiedades ShaderLab y nuestro programa, debemos crear variables de conexión dentro del campo del CGPROGRAM. En Shader Graph este proceso es distinto. Lo que debemos hacer es arrastrar las propiedades que tenemos en el Blackboard al área de nodos. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4g) 300 Como pudimos ver en la sección 3.2.7, del capítulo I, para que una textura sea proyectada sobre un objeto debemos generar un sample. Para ello existe el nodo Sample Texture 2D el cual cumple la función de tex2D( S llevar a cabo dos acciones: 1 RG , UV RG ). Si deseamos trabajar con este nodo podemos Presionar la tecla “espacio” sobre el área de nodos y escribir el nombre del nodo que estamos buscando; que en este caso sería Sample Texture 2D. 2 O hacer clic derecho, seleccionar la opción Create Node y buscar el nodo con el que deseamos trabajar. Para que la textura tipo Texture2D funcione en conjunto con el nodo Sample Texture 2D, debemos conectar el output de la propiedad _MainTex con el input tipo Texture( T2 en el Sample Texture 2D. RG ) que viene incluido Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4h) Esta operación es equivalente a crear un vector de cuatro dimensiones y pasar tanto la textura como el input de coordenadas UV a ella. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ float4 col = tex2D(_MainTex, i.uv); Si deseamos cambiar el tinte de la textura, debemos multiplicar el output RGBA, del Sample Texture 2D con el output de la propiedad _Color. De esta manera, cuando el color sea negro, la textura será negra, y cuando el color sea blanco, la textura será de su color por defecto. 301 Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4i) Para multiplicar ambos nodos, simplemente debemos traer el nodo Multiply y pasar ambos valores como input. Finalmente, el output de color (factor) de la operación anterior debemos conectarla con el Base Color que se encuentra en el Fragment Shader Stage. Una vez realizada la operación, debemos guardar nuestro shader presionando el botón Save Asset que se encuentra en la parte superior izquierda de la interfaz de Shader Graph. Nuestro primer shader en Shader Graph ⚫ ⚫ ⚫ (Fig. 9.0.4j) Un aspecto que debemos tener en consideración es que el Base Color es un input que corresponde al color final RGB de nuestro shader y forma parte de las operaciones que están ocurriendo dentro del Fragment Shader Stage. En consecuencia, el output de color 302 final que incluya alpha y blending, sería igual a la semántica SV_Target que podemos encontrar en un .shader. 9.0.5. Graph Inspector. “ De acuerdo a la documentación oficial en Unity; utilizar al Graph Inspector para editar atributos y valores. “ El Graph Inspector nos permite interactuar con cualquier elemento seleccionable y ajustes en Shader Graph. Podemos Dependiendo de la versión de Shader Graph, este panel puede tener algunas variaciones estéticas. Su versión 10.6.0 luce como en la Figura 9.0.5a. Para sintetizar: este panel; el cual ha sido dividido en dos secciones llamadas Node y Graph, posee aquellas propiedades configurables que permiten modificar el output de color. Entre ellas podemos encontrar opciones de Blending, Cull y Alpha Clip. Asimismo, podemos modificar las propiedades de los nodos en nuestra configuración. Graph Inspector ⚫ ⚫ ⚫ (Fig. 9.0.5a) 303 Por una parte, Graph Settings posee las propiedades generales del nodo, es decir, aquellos comandos que utilizamos previamente para definir el comportamiento de los píxeles en pantalla, p. ej., el comando Blend [SourceFactor] [DestinationFactor] se habilita una vez que definimos el tipo de “superficie” en el shader. Asimismo ocurre para las opciones de Cull, la cual podemos habilitar a través de la casilla Two Sides. Por otra parte, Node Settings contiene los atributos de aquellos elementos seleccionados, p. ej., en el nodo Custom Function, aparecen las opciones de precisión y previsualización, además de aquellos inputs y outputs configurables según la operación que estamos llevando a cabo. En cambio, para los restantes, solamente aparecen las opciones de precisión y previsualización. Este mismo comportamiento se ve reflejado en las propiedades que hemos agregado al Blackboard. Como podemos ver en la Figura 9.0.5b, en la pestaña aparecen aquellos atributos que agregamos manualmente en la escritura de un shader. Graph Inspector ⚫ ⚫ ⚫ (Fig. 9.0.5b) El atributo de precisión se refiere al cálculo en decimales de un nodo en la GPU. Por defecto viene configurada la opción “inherit”, sin embargo, podemos cambiarla a “single” (float) que contiene seis decimales de precisión, o a “half” que posee únicamente tres decimales. 304 9.0.6. Nodos. En el primer capítulo, pudimos revisar las operaciones que realizan algunas funciones intrínsecas, entre ellas: clamp( A RG ,X RG ,B RG ), abs( N RG ), ceil( N RG ), exp( N RG ) y muchas otras. Los nodos en Shader Graph son la representación gráfica de estas funciones, por ende cumplen las mismas funcionalidades, p. ej., según la documentación oficial de Unity, El siguiente código de ejemplo representa un posible resultado de este nodo. Nodos “ “ el nodo Clamp está articulado de la siguiente manera: ⚫ ⚫ ⚫ void Unity_Clamp_float4(float4 In, float4 Min, float4 Max, out float4 Out) { Out = clamp(In, Min, Max); } Como podemos ver en el ejemplo anterior, la función Unity_Clamp_float4 de tipo “void” posee tres inputs correspondientes a vectores de cuatro dimensiones. Entre ellos podemos encontrar a In, Min y Max. Estos mismos valores podemos verlos gráficamente en la construcción del nodo, como se aprecia en la Figura 9.0.6a. En tanto al output llamado Out, este simplemente posee la operación que se desea llevar a cabo. Nodos ⚫ ⚫ ⚫ (Fig. 9.0.6a) 305 Dado que Shader Graph se basa en lenguaje HLSL, podemos utilizar estas mismas funciones dentro de un shader con extensión .shader, siguiendo los puntos mencionados en la sección 4.0.4. Nodos ⚫ ⚫ ⚫ void Unity_Clamp_float4(float4 In, float4 Min, float4 Max, out float4 Out) { … } half4 frag (v2f i) : SV_Target { float4 c = 0; Unity_Clamp_float4(_Value, 0, 1, c); return c; } 9.0.7. Custom Functions. Es fundamental conocer conceptos básicos sobre Computer Graphics y saber escribir funciones en HLSL para trabajar con este nodo. Como su nombre lo indica, Custom Functions permite generar nuestras propias funciones y trabajar con ellas en Shader Graph. Es muy útil cuando deseamos emplear iluminación personalizada, funciones complejas u optimizar operaciones muy largas. Dada su estructura, podemos trabajar de dos maneras con este nodo: Desde archivos con extensión “.hlsl” (type file) o escribiendo funciones directamente en su cuerpo (type string), no obstante, es recomendable utilizar la primera opción debido a que podemos reutilizar los archivos posteriormente en otros proyectos. 306 Custom Functions ⚫ ⚫ ⚫ (Fig. 9.0.7a) Por defecto, Custom Functions no posee input ni output alguno. Inicialmente, debemos ir al Graph Inspector y agregar las propiedades necesarias para la función que emplearemos. Es posible que este proceso sea distinto dependiendo de la versión de Unity, p. ej., dado que la versión 2019.3 del software no posee Graph Inspector, debemos presionar el botón de configuración (botón de engranaje) que se encuentra en la parte superior derecha del nodo para realizar la misma acción. Analizaremos la implementación de una función simple para entender el concepto. Custom Functions ⚫ ⚫ ⚫ void Add_float(in float A, in float B, out float Out) { Out = A + B; } En el ejercicio anterior, la función Add simplemente retorna un valor escalar (Out) que es igual a la suma de dos números. El primer input que podemos encontrar corresponde a la variable de tipo flotante A RG , y el segundo es B RG . Por lo tanto, en nuestro nodo Custom Function, deberíamos agregar dos inputs y un output correspondiente a valores escalares. 307 Custom Functions ⚫ ⚫ ⚫ (Fig. 9.0.7b) Se cumple la misma analogía para vectores y otros tipos de datos, es decir, si A RG fuese un vector de tres dimensiones, entonces habría que agregar tal vector como input en la configuración del nodo. A continuación recrearemos el comportamiento de la variable _WorldSpaceLightPos que mencionamos inicialmente en la sección 7.0.3, cuando hablamos de Reflexión Difusa, la cual utilizaremos para el mismo fin. Iniciaremos creando un nuevo shader de tipo Unlit Shader Graph al cual llamaremos USB_custom_function. La primera tarea que llevaremos a cabo será traer un nodo Custom Function al área de nodos. Como se ha mencionado anteriormente, vamos a necesitar un script con extensión .hlsl. Para generarlo podemos simplemente crear un archivo con extensión “.txt” y reemplazar a “.hlsl”, o crear un script directamente desde el editor con el cual estemos trabajando en Unity. Llamaremos CustomLight al archivo .hlsl e iniciaremos agregando nuestra función de la siguiente manera: 308 Custom Functions ⚫ ⚫ ⚫ // CustomLight.hlsl void CustomLight_float(out half3 direction) { #ifdef SHADERGRAPH_PREVIEW direction = half3(0, 1, 0); #else #if defined(UNIVERSAL_LIGHTING_INCLUDED) Light mainLight = GetMainLight(); direction = mainLight.direction; #endif #endif } En el bloque de código anterior podemos deducir que: si la previsualización en Shader Graph está habilitada (SHADERGRAPH_PREVIEW), entonces proyectaremos iluminación en noventa grados, sobre el eje Y . De otra manera, si se ha definido Universal RP, entonces el output AX “direction” será igual a la dirección de la luz principal, es decir, a la luz direccional que tenemos en la escena. A diferencia del caso anterior, la función CustomLight no posee “inputs”, por lo tanto, solo habrá que agregar un vector de tres dimensiones como “output” posteriormente en la configuración del nodo. Por otra parte, cabe destacar que tal output es de tipo “half3”. En consecuencia, la precisión del nodo será igual a un valor de 16-bits debido a que, por defecto, está configurada como inherit. A continuación debemos asegurarnos de arrastrar o seleccionar el archivo .hlsl en la casilla Source que se encuentra en el Graph Inspector, ventana Node Settings. En la casilla Name debemos utilizar el mismo nombre de la función, es decir, CustomLight, de otra manera podría generar errores de compilación. 309 Custom Functions ⚫ ⚫ ⚫ (Fig. 9.0.7c. El nodo Custom Function luce de color verde debido a los valores del vector direction) Dado que la variable incluida _WorldSpaceLightPos posee la posición de la luz, podemos utilizar a nuestro nodo para replicar el mismo comportamiento, de hecho, la operación mainLight.direction es igual a la variable incluida _MainLightPosition. Esto lo podemos corroborar yendo al archivo Lighting.hlsl que se encuentra en nuestro proyecto. Custom Functions ⚫ ⚫ ⚫ (Fig. 9.0.7d. D = max(0, N · L)) Como podemos ver en la Figura 9.0.7d, siguiendo el esquema de difusión que vimos en la sección 7.0.3, podemos generar el mismo comportamiento a través de nodos en Shader Graph. 310 Capítulo III Compute Shader, Ray Tracing y Sphere Tracing. Conceptos avanzados. En los capítulos anteriores concentramos nuestro estudio en la comprensión de tres tipos de etapas programables: Vertex, Fragment y Surface Standard, de los cuales revisamos sus propiedades, tipos de datos, estructura, funciones, entre otros temas. En este capítulo profundizaremos en conceptos mucho más avanzados y para ello indagaremos en aquellos shaders de tipo .compute. Asimismo, pondremos en práctica dos técnicas de renderizado que requieren un alto nivel de cómputo y entendimiento matemático: Ray Tracing y Sphere Tracing. Compute Shader se refiere a un tipo de programa que permite implementar algoritmos de datos directamente en las unidades de cómputo (graphic card), generando efectos de alta calidad mediante procesos en paralelo. Dada su capacidad, es bastante útil para programación GPGPU (general purpose GPU) el cual se refiere a la utilización de funciones para el procesamiento de aplicaciones que no necesariamente sean de índole gráfica, p. ej., cálculo de millones de vértices o múltiples instancias de un objeto. Una característica fundamental del Compute Shader es su Render Pipeline. Este posee su propio pipeline gráfico el cual no es parte de aquellos mencionados en capítulos anteriores. Sin embargo, debido a que este shader es parte de la API Direct3D, permite enlazar el output directamente sobre el Render Pipeline lógico, mencionado en la sección 1.0.7. Ray Tracing y Sphere Tracing se refieren a un conjunto de técnicas o algoritmos para renderizado de iluminación y materialización física. Funcionan trazando un rayo; una línea en nuestra escena para determinar la distancia de un objeto según la posición de la cámara. Dicha tarea se realiza a través de tres partes principales: › Generación del rayo (punto de inicio). › Intersección del rayo (punto donde golpea al objeto). › Shading (iluminación, sombra y superficie). En su implementación, tales algoritmos aumentan la calidad gráfica de nuestro proyecto, generando un acabado muy pulido y realista; asimismo generan una carga significativa en la GPU, lo que hace incompatible su aplicación en dispositivos de gama media o baja, p. ej., dispositivos móviles. 312 Un factor a considerar en este capítulo es la utilización de High Definition RP para la implementación de funciones y algoritmos en shaders de tipo .raytrace. Cabe destacar que las funciones de DXR poseen limitaciones técnicas, por lo tanto, es recomendable que el lector posea un ordenador de gama alta con las siguientes características técnicas para un buen desempeño gráfico: › Windows 10, versión 1809+. › DirectX 12. › NVIDIA serie 20+ (2060, 2070, 2080 y sus variantes TI). Ray Tracing funciona también en tarjetas NVIDIA, generación Turing y Pascal (GTX 1060+), sin embargo, su desempeño es limitado en comparación con aquellas mencionadas anteriormente. 10.0.1. Estructura de un Compute Shader. Hasta este punto, hemos centrado nuestro estudio en la comprensión de shaders tipo Unlit y Surface, los cuales poseen una estructura bastante similar entre sí, de hecho, ambos se ejecutan dentro del campo de ShaderLab el cual, como ya sabemos, es un lenguaje declarativo que permite la comunicación entre el programa y Unity. Sin embargo, existe otro tipo de shader llamado Compute, el cual tiene una estructura semejante a aquellos mencionados anteriormente, pero no incluye Built-in shader variables que faciliten su programación. Iniciaremos esta sección creando un Compute Shader, para ello: 1 Iremos a nuestra carpeta de Proyecto en Unity. 2 Presionamos clic derecho. 3 Y seleccionamos Create / Shaders / Compute Shader. 4 Lo llamaremos USB_simple_color_CS. Más adelante trabajaremos con este shader para entender la estructura básica en la implementación de color, coordenadas UV y textura, por ende, no será muy atractivo a nivel funcional, no obstante servirá para ilustrar la sintaxis detrás del programa. 313 Una vez abierto, obtendremos una estructura igual a la siguiente: Estructura de un Compute Shader ⚫ ⚫ ⚫ // Each Kernel tells which function to compile; // you can have many Kernels #pragma kernel CSMain // Create a RenderTexture with enableRandomWrite // and set it with cs.SetTexture RWTexture2D <float4> Result; [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { // TODO: insert actual code here Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0) } En el ejemplo, podemos ver una estructura básica de color que Unity agrega por defecto en el programa. En ella podemos encontrar los siguientes componentes: › El Kernel de nuestra función CSMain. › Una textura 2D para escritura y lectura llamada Result. › El número de hilos que utilizaremos para procesar cada texel de la textura (numthreads). › Una función llamada CSMain que incluye una semántica como argumento y un output de color RGBA. Tal estructura posee semejanzas a un Vertex-Fragment Shader. Esto se debe a que ambos programas están escritos en lenguaje HLSL, por lo tanto, para entender su estructura básica, haremos una analogía utilizando el Kernel CSMain. Como se ha mencionado en el capítulo I, sección 3.3.2, el Vertex Shader Stage es configurado como tal cuando se determina el pragma asociado a su función, esto quiere decir que, p. ej., la función vert debe ser declarada como vertex en el pragma para que la GPU pueda reconocer su naturaleza dentro del Render Pipeline. 314 Estructura de un Compute Shader ⚫ ⚫ ⚫ // declaramos la función vert como vertex shader stage #pragma vertex vert // inicializamos la función vert v2f vert(appdata v) { … } El mismo comportamiento podemos encontrarlo en el Fragment Shader Stage. Si deseamos que la función por defecto llamada frag compile como Fragment Shader, habrá que declararla en su respectivo pragma. Estructura de un Compute Shader ⚫ ⚫ ⚫ // declaramos la función frag como fragment shader stage #pragma fragment frag // inicializamos la función frag fixed4 frag (v2f i) : SV_Target { … } Un Compute Shader no es la excepción, si deseamos que la función CSMain pueda ser enviada a las unidades de cómputo (unidad física donde se realiza el cálculo), entonces habrá que definirla como kernel en el pragma. Estructura de un Compute Shader ⚫ ⚫ ⚫ // declaramos la función CSMain como kernel #pragma Kernel CSMain // inicializamos la función CSMain [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { … } La unidad más pequeña que puede procesar un Compute Shader corresponde a un hilo independiente y el atributo [numthreads( X ,Y RG ,Z RG RG )] tiene directa relación con esto. Los hilos son aquellos que ejecutan el cómputo de la operación que deseamos llevar a cabo, p. ej., en el caso de una textura, estos se encargan de procesar cada texels que posea la imagen. 315 Por defecto, nuestro programa posee un grupo de 64 hilos, ¿Cómo podemos determinar esto? Básicamente multiplicando los valores en X ,Y RG numthreads. RG yZ RG que han sido incluidos en el atributo › numthreads (x, y, z). › 8 * 8 * 1 = 64 hilos por cada grupo. Los valores anteriores se pueden traducir como: › Ocho columnas de hilos en el eje X. › Ocho filas de hilos en el eje Y. › Un conjunto de hilos en el eje Z. Al trabajar con hilos, el hardware divide los grupos en sub-bloques llamados Warps. El total de hilos por grupo debe ser múltiplo del tamaño del Warps(32 hilos por grupo en tarjetas NVIDIA) o múltiplo del tamaño del Wavefront (64 hilos por grupo en tarjetas ATI). Unity define ocho hilos tanto en X RG como en Y RG precisamente para asegurar que el programa corra en ambas tarjetas: NVIDIA y ATI. Más adelante en este capítulo revisaremos este y otros atributos en detalle dado que existen algunas semánticas asociadas a los hilos con los que operaremos. Por ahora continuaremos definiendo la estructura interna de nuestro programa. La variable RWTexture2D llamada Result se refiere a una textura bidimensional RGBA con la capacidad de lectura y escritura (RW). Esta característica permite enviar datos desde la CPU a la GPU, procesar en paralelo y luego traerla de vuelta. Si deseamos escribir una variable que sólo tenga la capacidad de escritura, entonces habría que declararla sin el prefijo RW, p. ej., Texture2D. Ahora, ¿Cómo podemos determinar la necesidad de una variable en nuestro programa? Para ello tendremos que poner en práctica la implementación de funciones en el Compute Shader. Un Vertex-Fragment Shader requiere del lenguaje declarativo ShaderLab para la comunicación entre Unity y el programa CGPROGRAM o HLSLPROGRAM. Analógicamente, un shader tipo .compute requiere de un script C# para la misma función. Cada vez que trabajemos con Compute Shaders habrá que crear y asociar un script C# en nuestra escena. En este último declaramos las variables globales y buffers que conectaremos posteriormente con programa HLSL. 316 Una función que veremos de manera recurrente a lo largo de este capítulo es Dispatch, quien es la encargada de ejecutar el Kernel CSMain como tal, lanzando una determinada cantidad de grupos de hilos en sus dimensiones XYZ. Existen otros conceptos asociados al funcionamiento de este tipo de shaders de los que hablaremos más adelante en este libro. 10.0.2. Nuestro primer Compute Shader. Continuando con USB_simple_color_CS, vamos a necesitar un objeto 3D para la asignación de color, texturas y coordenadas. Para este ejercicio agregaremos un Quad a nuestra escena y nos aseguraremos de que quede centrado, con su posición y rotación en “cero”, en la cuadrícula. En nuestro proyecto, crearemos un script C# que llamaremos USBSimpleColorController.cs. Este lo utilizaremos como controlador para el Compute Shader. Dado que vamos a escribir una textura sobre el material del objeto, tendremos que asignar el script directamente al objeto 3D. Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2a. Se ha asignado el script USBSimpleColorController al Quad que tenemos en la escena) 317 Una vez abierto el programa obtendremos la siguiente estructura: Nuestro primer Compute Shader ⚫ ⚫ ⚫ using System.Collections; using System.Collections.Generic; using UnityEngine; public class USBSimpleColorController : MonoBehaviour { // Start is called before the first frame update. void Start() { } // Update is called once per frame void Update() { } } Podemos observar que corresponde a la estructura por defecto de un script C#. Incluye funciones de inicio y actualización cuadro a cuadro para facilitar el entendimiento. Iniciaremos agregando una variable global pública a nuestro programa para conectar el Compute Shader. La llamaremos m_shader. Nuestro primer Compute Shader ⚫ ⚫ ⚫ public class USBSimpleColorController : MonoBehaviour { public ComputeShader m_shader; … } 318 Asumiendo que escribiremos una textura sobre el material del Quad, vamos a necesitar dimensiones tanto para el ancho como para el alto de la misma. Los tamaños más comunes para texturas son valores en potencias de dos, p. ej., 128, 256, 512, 1024, etc. Por esta razón, declaramos una variable pública de tipo RenderTexture para la textura y además un valor entero para sus dimensiones. Utilizaremos 256 tanto para el ancho como para el alto. Nuestro primer Compute Shader ⚫ ⚫ ⚫ public class USBSimpleColorController : MonoBehaviour { public ComputeShader m_shader; public RenderTexture m_mainTex; int m_texSize = 256; … } En el ejemplo anterior, se ha declarado una textura llamada m_mainTex y se ha definido su dimensión en la variable m_texSize. Su naturaleza está asociada a la propiedad _MainTex que empleamos frecuentemente en la definición de propiedades para shader tipo .shader. Esto sugiere la escritura de la variable m_mainTex sobre _MainTex posteriormente para la visualización de color. Ahora únicamente faltaría definir una variable que permita escribir la textura sobre el material del Quad. Nuestro primer Compute Shader ⚫ ⚫ ⚫ public class USBSimpleColorController : MonoBehaviour { public ComputeShader m_shader; public RenderTexture m_mainTex; int m_texSize = 256; Renderer m_rend; … } La variable m_rend la emplearemos posteriormente para almacenar el componente Renderer del material asociado al Quad, mientras que la variable m_mainTex será usada para escribir los colores que vamos a generar en el Compute Shader. 319 Antes de continuar con la explicación, nos aseguraremos de salvar el código que hemos agregado y volveremos al inspector de Unity para asignar el Compute Shader en su respectiva variable. Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2b. Se ha asignado el Compute Shader en la variable m_shader desde el Inspector de Unity) Volvemos a nuestro script e inicializamos la textura en el método Start. Para ello utilizaremos la variable m_texSize tanto para el alto como para el ancho de la misma. Nuestro primer Compute Shader ⚫ ⚫ ⚫ public class USBSimpleColorController : MonoBehaviour { public ComputeShader m_shader; public RenderTexture m_mainTex; int m_texSize = 256; Renderer m_rend; void Start () { // inicializamos la textura. m_mainTex = new RenderTexture(m_texSize , m_texSize, 0, RenderTextureFormat.ARGB32); } … } 320 El constructor de la clase RenderTexture posee hasta siete argumentos, sin embargo únicamente necesitamos cuatro de ellos para el correcto funcionamiento de la textura. Los primeros dos argumentos corresponden al ancho y alto de la textura, continúa el Depth Buffer y finalmente se establece la configuración de la misma (RGBA de 32 bits). Ahora simplemente debemos habilitar las opciones de escritura aleatoria y crear la textura como tal. Nuestro primer Compute Shader ⚫ ⚫ ⚫ public class USBSimpleColorController : MonoBehaviour { public ComputeShader m_shader; public RenderTexture m_mainTex; int m_texSize = 256; Renderer m_rend; void Start () { m_mainTex= new RenderTexture(m_texSize , m_texSize, 0, RenderTextureFormat.ARGB32); // habilitamos la escritura aleatoria m_mainTex.enableRandomWrite = true; // creamos la textura como tal m_mainTex.Create(); } … } 321 Dado que los grupos de hilos no tienen la capacidad de sincronizarse entre ellos, no podemos determinar qué texel se va a escribir primero sobre la textura. Por esa razón debemos utilizar la función enableRandomWrite antes de crearla. Finalmente, la función Create genera la textura como tal, de hecho, según la documentación oficial en Unity podemos encontrar el siguiente texto. llamando la función Create la textura es creada por adelantado. Nuestro primer Compute Shader “ “ El constructor RenderTexture no crea la textura realmente, por defecto, la textura se genera la primera vez que se activa. ⚫ ⚫ ⚫ (Fig. 10.0.2c. Los Compute Shaders pueden escribir los texels de manera arbitraria sobre una textura) El proceso que realizaremos a continuación permitirá la comunicación entre el script C# y el Compute Shader. Iniciaremos guardando el componente Renderer (propio del material del Quad) en la variable rend que creamos previamente. 322 Nuestro primer Compute Shader ⚫ ⚫ ⚫ void Start () { m_mainTex= new RenderTexture(m_texSize , m_texSize, 0, RenderTextureFormat.ARGB32); m_mainTex.enableRandomWrite = true; m_mainTex.Create(); // obtenemos el componente renderer del material m_rend = GetComponent<Renderer>(); // hacemos el objeto visible m_rend.enabled = true; } Hasta este punto tenemos la textura creada, pero esta no posee ningún color en específico, por lo tanto, debemos enviarla al Compute Shader, asignarle un color o diseño y luego reasignarla al material que está utilizando el Quad para que sea visible desde la escena. Para ello podemos utilizar la función ComputeShader.SetTexture. Nuestro primer Compute Shader ⚫ ⚫ ⚫ void Start () { m_mainTex= new RenderTexture(m_texSize , m_texSize, 0, RenderTextureFormat.ARGB32); m_mainTex.enableRandomWrite = true; m_mainTex.Create(); m_rend = GetComponent<Renderer>(); m_rend.enabled = true; // enviamos la textura al Compute Shader. m_shader.SetTexture(0, "Result", m_mainTex); } El primer argumento en la función SetTexture( K que estamos utilizando en el Compute Shader. ,S RG 323 ,T RG RG ) corresponde al índice del Kernel Nuestro primer Compute Shader ⚫ ⚫ ⚫ // Each kernel tells which function to compile; // you can have many kernels #pragma kernel CSMain // Create a RenderTexture with enableRandomWrite and set it // with cs.SetTexture RWTexture2D <float4> Result; [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { … } El shader USB_simple_color_CS posee sólo “uno”, llamado CSMain, el cual ha sido agregado por defecto. Este Kernel ocupa el índice “cero” dado que es el único que tenemos en el programa. Un Compute Shader puede tener múltiples Kernel y a cada uno posee un id, asignado de manera automática. Nuestro primer Compute Shader ⚫ ⚫ ⚫ #pragma kernel CSMain // id 0 #pragma kernel CSFunction01 // id 1 #pragma kernel CSFunction02 // id 2 El segundo argumento en la función corresponde al nombre de la variable buffer en el Compute Shader. Por defecto se llama Result y es una textura 2D RGBA que tiene la capacidad de lectura y escritura. Nuestro primer Compute Shader ⚫ ⚫ ⚫ RWTexture2D <float4> Result; Finalmente, el tercer argumento en la función corresponde a la textura que vamos a escribir sobre la variable buffer, en nuestro caso, se llama m_mainTex. Esta última será procesada en el Compute Shader dentro del método CSMain, esto lo podemos corroborar por la operación que se está realizando en la función. 324 Nuestro primer Compute Shader ⚫ ⚫ ⚫ [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { // TODO: insert actual code here Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0); } De momento no profundizaremos sobre la operación que está ocurriendo en el método CSMain, por ahora continuaremos con la implementación de la textura sobre el material que posee actualmente el Quad en nuestra escena. Para ello volvemos al script USBSimpleColorController y pasamos la textura m_mainTex sobre la propiedad _MainTex utilizando la función SetTexture del material. Nuestro primer Compute Shader ⚫ ⚫ ⚫ void Start () { m_mainTex= new RenderTexture(m_texSize , m_texSize, 0, RenderTextureFormat.ARGB32); m_mainTex.enableRandomWrite = true; m_mainTex.Create(); m_rend = GetComponent<Renderer>(); m_rend.enabled = true; m_shader.SetTexture(0, "Result", m_mainTex); // enviamos la textura al material del Quad. m_rend.material.SetTexture("_MainTex", m_mainTex); } Por defecto, cada shader en Unity posee la propiedad _MainTex, por lo tanto, podemos asumir que el material del Quad la tiene también. 325 Hasta este punto el proceso está casi listo, únicamente faltaría generar los grupos de hilos que procesarán cada texel de la textura que estamos creando, para ello debemos llamar a la función Dispatch. Nuestro primer Compute Shader ⚫ ⚫ ⚫ void Start () { m_mainTex= new RenderTexture(m_texSize , m_texSize, 0, RenderTextureFormat.ARGB32); m_mainTex.enableRandomWrite = true; m_mainTex.Create(); m_rend = GetComponent<Renderer>(); m_rend.enabled = true; m_shader.SetTexture(0, "Result", m_mainTex); m_rend.material.SetTexture("_MainTex", m_mainTex); // generamos los grupos de hilos para procesar la textura m_shader.Dispatch(0, m_texSize/8, m_texSize/8, 1); } El primer argumento en la función se refiere al Kernel; como únicamente estamos utilizando la función CSMain, tendremos que situar el valor “cero” en él. Los tres argumentos siguientes corresponden a la cuadrícula (grupos de hilos) que vamos a generar para el procesamiento de los texels de la textura. El primer valor corresponde a la cantidad de columnas que tendrá la cuadrícula, luego las filas y finalmente la cantidad de dimensiones. Como ya sabemos, m_texSize es igual a 256, por lo tanto, si dividimos ese valor en 8, obtendremos una cuadrícula de 32 x 32 x 1. 326 Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2d. Cada bloque en la cuadrícula representa un grupo de hilos) En la programación de GPU, el número de hilos deseados para la ejecución es dividido en una cuadrícula denominada thread group. Un grupo de hilos es ejecutado en cada unidad de cómputo independiente. La operación de sincronización de hilos puede ocurrir sólo para aquellos que se encuentran dentro de un mismo grupo, generando métodos de programación en paralelo más eficaces. Distintos grupos de hilos no se pueden sincronizar, de hecho, nosotros no tenemos control sobre el orden en que serán procesados, por esta razón, tales grupos pueden ser enviados a distintas unidades de cómputo. Cada uno de los bloques generados en la cuadrícula corresponde a un grupo de hilos. Ahora, ¿Cuántos hilos tiene cada grupo? Su valor está determinado por el atributo numthreads que se encuentra en la parte superior de la función CSMain. Nuestro primer Compute Shader ⚫ ⚫ ⚫ [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { … } Como ya sabemos, para calcular la cantidad de hilos dentro de un grupo, simplemente debemos multiplicar la cantidad de columnas, por la cantidad de filas, por las dimensiones (8 * 8 * 1). Para nuestra configuración obtenemos el número 64 hilos por cada grupo. 327 Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2e. La semántica SV_GroupID corresponde al índice de un grupo sé que ejecutará en el Compute Shader, numthreads se refiere al total de hilos que tendremos por cada grupo, y SV_GroupThreadID se refiere al identificador de cada hilo por separado)) Es fundamental ahondar en este proceso para entender la semántica SV_DispatchThreadID, que se encuentra como argumento en el método CSMain. Esta corresponde a la suma del número de hilos por cada grupo que estamos utilizando, más el índice de cada hilo. SV_DispatchThreadID = ([(SV_GroupID) * (numthreads)] + (SV_GroupThreadID)) Volviendo a nuestro programa USBSimpleColorController.cs, si guardamos, volvemos a Unity y presionamos el botón “play”, podremos ver que una textura se ha generado y asignado de manera dinámica al Quad. 328 Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2f. La textura corresponde a la representación gráfica del fractal de Sierpinski) La textura que vemos en la figura anterior se está generando dentro de la función CSMain y su proceso de creación es bastante simple. Nuestro primer Compute Shader ⚫ ⚫ ⚫ [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { // TODO: insert actual code here Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0); } Para entender, debemos prestar atención a los argumentos de la función CSMain. La semántica SV_DispatchThreadID representa los índices de aquellos hilos combinados y grupos de hilos ejecutados en el Compute Shader, esto quiere decir que la identificación de cada hilo está siendo almacenada en la variable id de tipo uint3 (unsigned integer). A diferencia de una variable entera; las de tipo uint sólo poseen números positivos, iniciando en “cero”. Esto hace sentido dado que los índices de cada grupo de hilos comienzan en 0 , 0 , 0 de ahí el tipo de dato uint3. X Y Z Nuestro primer Compute Shader ⚫ ⚫ ⚫ (uint3 id : SV_DispatchThreadID) 329 Los texels de la textura Result están siendo procesados por cada uno de los hilos que se encuentran en la variable id. Dado que es un vector de cuatro dimensiones RGBA, podemos retornar un color sólido, p. ej., un color verde. Nuestro primer Compute Shader ⚫ ⚫ ⚫ // retornamos un color verde Result[id.xy] = float4(0, 1, 0, 1); Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2g) Un factor a considerar es la cantidad de hilos que vamos a utilizar en nuestro Compute Shader, dado que está relacionado directamente con el resultado final que obtendremos. Anteriormente en la función Dispatch configuramos el ancho y alto de la textura dividido en “ocho” para generar una cuadrícula de 32 x 32 x 1 grupos de hilos. ¿Por qué utilizamos tal número como divisor en la operación? Esto tiene relación con el conjunto de hilos que están configurados en el componente numthreads. Realicemos la operación matemática para entender el concepto: La textura que estamos creando tiene un ancho y alto igual a 256. Al dividirla en 8 da como resultado 32. Su representación gráfica sería igual a “ocho” filas o columnas de “treinta y dos” texels cada una sobre la textura. 330 Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2h) Para que el resultado sea nuevamente igual a 265, debemos multiplicar por 8; y es precisamente este número aquel que está configurado como total de hilos tanto en X RG como en Y RG en el atributo numthreads. De hecho, si cambiamos la cantidad de número de hilos a “cuatro” [numthreads(4, 4, 1)], notaremos que sólo se renderiza ¼ de la textura en el Quad. Nuestro primer Compute Shader ⚫ ⚫ ⚫ (Fig. 10.0.2i. Color (1, 0, 0, 1)) Tal factor ocurre al multiplicar 32 por 4, como resultado da 128 que es precisamente textura que estamos generando. 331 ¼ de la 10.0.3. Coordenadas UV y textura. En la sección anterior definimos una textura y sus dimensiones a través de las variables m_mainTex y m_texSize respectivamente. Sin embargo, el resultado final corresponde a la representación gráfica del triángulo de Sierpinski. Tomando en consideración el script que hemos escrito hasta este punto, en esta sección asignaremos una textura desde el Inspector y para ello tendremos que definir coordenadas UV. Iniciaremos declarando una nueva textura pública de tipo Texture la cual llamaremos m_tex en nuestro script C#. Coordenadas UV y textura ⚫ ⚫ ⚫ public class USBSimpleColorController : MonoBehaviour { public ComputeShader m_shader; public Texture m_tex; RenderTexture m_mainTex; int m_texSize = 256; Renderer m_rend; … } Dado que la data corresponde a una textura, posteriormente tendremos que declarar una variable de tipo Texture2D y otra de tipo SamplerState en el Compute Shader. Coordenadas UV y textura ⚫ ⚫ ⚫ RWTexture2D<float4> Result; Texture2D<float4> ColTex; SamplerState sampler_ColTex; … 332 Como podemos ver en el ejemplo anterior, la declaración para una textura posee la misma analogía que aquellas vistas en secciones anteriores. De la misma manera, vamos a necesitar coordenadas UV para posicionar la textura sobre el Quad que estamos utilizando. Para ello, podemos utilizar la función GetDimensions( W RG ,H RG ) dentro del Kernel CSMain. Coordenadas UV y textura ⚫ ⚫ ⚫ RWTexture2D<float4> Result; Texture2D<float4> ColTex; SamplerState sampler_ColTex; [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { uint width; uint height; Result.GetDimensions(width, height); … } La función GetDimensions( W ,H RG RG ) es de tipo “void”, por lo tanto, estamos guardados las dimensiones de la variable Result en width y height; dimensiones que corresponden al valor que asignamos en la variable “m_texSize” desde USBSimpleColorController. Coordenadas UV y textura ⚫ ⚫ ⚫ void GetDimensions(out uint width, out uint height); 333 A continuación podremos determinar los valores de las coordenadas UV de la siguiente manera: Coordenadas UV y textura ⚫ ⚫ ⚫ [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { uint width; uint height; Result.GetDimensions(width, height); float2 uv = float2(id.xy / float2(width, height)); … } Al igual que width y height, la variable id también es un valor entero, y las coordenadas UV corresponden a un rango que va desde 0.0f hasta 1.0f, por esa razón se han declarado como variables de tipo float en el ejemplo anterior. Cabe destacar que tal operación tiene un aspecto técnico que debemos evaluar según el Wrap Mode de la textura que estamos asignando al Quad. La operación anterior funciona perfectamente para aquellas texturas que han sido declaradas en modo Clamp. En caso de que la textura posea un modo de repetición, habrá que agregar 0.5f a la variable id, de otra manera, parte de los bordes se verán reflejados en la proyección de la misma. Coordenadas UV y textura ⚫ ⚫ ⚫ // si la textura ha sido configurada como Wrap Mode = Clamp float2 uv = float2(id.xy / float2(width, height)); // si la textura ha sido configurada como Wrap Mode = Repeat float2 uv = float2((id.xy + float2(0.5, 0.5)) / float2(width, height)); A continuación podemos utilizar la función SampleLevel( S , UV RG textura en el Kernel. 334 , LOD RG RG ) para determinar la Coordenadas UV y textura ⚫ ⚫ ⚫ [numthreads(8, 8, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { uint width; uint height; Result.GetDimensions(width, height); float2 uv = float2(id.xy / float2(width, height)); float4 col = ColTex.SampleLevel(sampler_ColTex, uv, 0); Result[id.xy] = col; } Tal función puede retornar un valor escalar o vector multidimensional. En el ejercicio anterior se ha utilizado para guardar los valores de muestreo RGBA en el vector “col”. Coordenadas UV y textura ⚫ ⚫ ⚫ Object.SampleLevel(in SamplerState s, in float2 uv, in int LOD); Para concluir, debemos volver al script USBSimpleColorController y enviar la textura al Compute Shader a través de la función SetTexture, de la misma manera que hicimos con la variable m_mainTex. Coordenadas UV y textura ⚫ ⚫ ⚫ void Start() { … m_shader.SetTexture(0, "Result", m_mainTex); m_shader.SetTexture(0, "ColTex", m_tex); m_rend.material.SetTexture("_MainTex", m_mainTex); … } 335 Si hasta el momento todo ha ido bien, podremos ver la textura que hemos asignado en el campo m_tex proyectada en nuestro Quad. Coordenadas UV y textura ⚫ ⚫ ⚫ (Fig. 10.0.3a. La textura ha sido configurada en modo Clamp) 10.0.4. Buffers. Existen algunos casos donde será necesario el procesamiento de múltiples datos de manera simultánea, p. ej., desarrollo de partículas, post-processing, funciones de Ray Tracing, simulaciones y más. Estos se caracterizan por la amplia carga gráfica que generan en las unidades de cómputo. Sin embargo, a nuestro beneficio, existen dos tipos de datos asociados que podemos utilizar en nuestro programa para acelerar la lectura y escritura de valores en el buffer de memoria, estos corresponden a: › ComputeBuffer. › Y StructuredBuffer. Como su nombre lo menciona, ComputeBuffer, corresponde a un buffer, el cual podemos crear y llenar con una lista de valores desde nuestro script C#. Un StructuredBuffer, esencialmente es lo mismo, con la diferencia que es declarado en el Compute Shader. 336 Buffers ⚫ ⚫ ⚫ // -------------------- C# struct Properties { Vector3 vertices; Vector3 normals; Vector4 tangents; } Properties[] m_meshProp; ComputeBuffer m_meshBuffer; // -------------------- Compute Shader struct Properties { float3 vertices; float3 normals; float4 tangents; }; StructuredBuffer<Properties> meshProp; Para entender su implementación, crearemos un nuevo Compute Shader en nuestro proyecto, al cual llamaremos USB_compute_buffer. De la misma manera, crearemos un nuevo script C# al cual denominaremos USBComputeBuffer. Anteriormente, en la sección 4.1.6, creamos un método simple llamado circle el cual utilizamos para reproducir un círculo en un Quad. En esta sección realizaremos el mismo ejercicio con la diferencia que utilizaremos los scripts creados previamente para tal efecto. Habiendo estudiado parte de la integración de un Compute Shader en Unity, podemos deducir que USBComputeBuffer se encargará de configurar información a través de las funciones SetFloat( S RG ,N RG ) y SetTexture( K RG ,S RG ,T RG ). De la misma manera; utilizando la función ComputeBuffer.SetBuffer, configuraremos data, la cual enviaremos posteriormente a la lista predefinida de valores en el Compute Shader. 337 Iniciaremos declarando las variables públicas, asociadas a la función “circle” detallada, en la sección 4.1.6. Buffers ⚫ ⚫ ⚫ using System.Collections; using System.Collections.Generics; using UnityEngine; public class USBComputeBuffer : MonoBehaviour { public ComputeShader m_shader; [Range(0.0f, 0.5f)] public float m_radius = 0.5f; [Range(0.0f, 1.0f)] public float m_center = 0.5f; [Range(0.0f, 0.5f)] public float m_smooth = 0.01f; public Color m_mainColor = new Color(); private RenderTexture m_mainTex; private int m_texSize = 128; private Renderer m_rend; … } Si prestamos atención al ejemplo anterior, notaremos que se han definido las mismas propiedades (m_radius, m_center y m_smooth) que utilizamos anteriormente para la generación de un círculo. Además, se ha creado una propiedad de color para la misma. Tales variables podemos enviarlas de manera individual al Compute Shader mediante la función ComputeShader.SetFloat, o también, crear un buffer que contenga la lista completa de valores que deseamos asignar. Para el ejercicio, declaramos una estructura y un buffer asociado a la lista de valores que utilizaremos en el shader. 338 Buffers public class USBComputeBuffer : MonoBehaviour { public ComputeShader m_shader; [Range(0.0f, 0.5f)] public float m_radius = 0.5f; [Range(0.0f, 1.0f)] public float m_center = 0.5f; [Range(0.0f, 0.5f)] public float m_smooth = 0.01f; public Color m_mainColor = new Color(); private RenderTexture m_mainTex; private int m_texSize = 128; private Renderer m_rend; //declaramos un struct con la lista de valores struct Circle { public float radius; public float center; public float smooth; } // declaramos una lista de tipo Circle para acceder a cada variable Circle[] m_circle; // declaramos un buffer de tipo ComputeBuffer ComputeBuffer m_buffer; … } Dentro del Struct Circle se han declarado las variables que utilizaremos posteriormente en el ComputeShader; a través de m_circle podremos acceder a cada instancia individual. Dada la naturaleza del ejercicio, podemos deducir que los valores de las variables globales serán asignadas a aquellas definidas en el Struct. En este punto entra en juego el ComputeBuffer dado que, una vez que se ha llenado la lista con valores, debemos copiar la data al buffer y finalmente pasarla al shader. 339 Iniciaremos creando la textura antes de realizar tal proceso. Para ello, declaramos un nuevo método al cual llamaremos CreateShaderTex. Este contendrá el algoritmo descrito en la sección 10.0.2 para la definición de la misma. Buffers ⚫ ⚫ ⚫ void Start() { CreateShaderTex(); } void CreateShaderTex() { // primero creamos la textura m_mainTex = new RenderTexture(m_texSize, m_texSize, 0, RenderTextureFormat.ARGB32); m_mainTex.enableRandomWrite = true; m_mainTex.Create(); // luego accedemos al mesh renderer m_rend = GetComponent<Renderer>(); m_rend.enabled = true; } A continuación, declaramos una nueva función, la cual emplearemos en el método Update únicamente a modo de estudio. Buffers ⚫ ⚫ ⚫ void Update() { SetShaderTex(); } void SetShaderTex() { // escribir el código aquí … } 340 Antes de continuar, iremos a USB_compute_buffer dado que configuraremos su estructura antes de traer la data desde USBComputeBuffer. Comenzaremos agregando la función “circle” para luego definir sus valores en el Kernel CSMain. Buffers ⚫ ⚫ ⚫ #pragma kernel CSMain RWTexture2D<float4> Result; // declaramos la función float CircleShape (float2 p, float center, float radius, float smooth) { float c = length(p - center); return smoothstep(c - smooth, c + smooth, radius); } [numthreads(128, 1, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { uint width; uint height; Result.GetDimensions(width, height); float2 uv = float2((id.xy + 0.5) / float2(width, height)); // inicializamos los valores en cero float c = CircleShape(uv, 0, 0, 0); Result[id.xy] = float4(c, c, c, 1); } En el ejercicio anterior, se ha definido el método CircleShape que es igual en naturaleza a la función circle. Dentro del Kernel CSMain, se ha inicializado tal función con sus valores en “cero”. En consecuencia, el output corresponde a un color negro por defecto. 341 Cabe destacar que el número de hilos para la operación es igual a 128 en X , esto debido a dos RG factores principalmente: › El tamaño de la textura de la variable m_texSize es igual a 128. › Solo necesitaremos una dimensión para recorrer la lista “m_circle” definida previamente. A continuación vamos a definir el buffer que contendrá las variables necesarias para el correcto funcionamiento del método CircleShape. Buffers ⚫ ⚫ ⚫ #pragma kernel CSMain RWTexture2D<float4> Result; float4 MainColor; // declaramos la lista de valores struct Circle { float radius; float center; float smooth; }; // declaramos el buffer StructuredBuffer<Circle> CircleBuffer; // declaramos la función float CircleShape (float2 p, float center, float radius, float smooth) { float c = length(p - center); return smoothstep(c - smooth, c + smooth, radius); } [numthreads(128, 1, 1)] void CSMain (uint3 id : SV_DispatchThreadID) { Continúa en la siguiente página. 342 uint width; uint height; Result.GetDimensions(width, height); float2 uv = float2((id,xy + 0.5) / float2(width, height)); // accedemos a los valores de la lista float center = CircleBuffer[id.x].center; float radius = CircleBuffer[id.x].radius; float smooth = CircleBuffer[id.x].smooth; // inicializamos los valores float c = CircleShape(uv, center, radius, smooth); Result[id.xy] = float4(c, c, c, 1); } Al igual que en USBComputeBuffer, se ha precisado una lista de escalares dentro de un Struct llamado Circle. Tales variables coinciden en cantidad como en tipos de datos con aquellos declarados en el script C#. Posteriormente, se ha creado un StructuredBuffer llamado CircleBuffer. Este se encargará de almacenar los valores que enviemos desde USBComputeBuffer. Únicamente faltaría completar la operación de la función SetShaderTex y enviar la data al Compute Shader. Para ello debemos regresar a USBComputeBuffer. Buffers ⚫ ⚫ ⚫ void SetShaderTex() { uint threadGroupSizeX; m_shader.GetKernelThreadGroupSizes(0, out threadGroupSizeX, out _, out _); int size = (int)threadGroupSizeX; m_circle = new Circle[size]; … } 343 En el ejemplo, se ha iniciado el ejercicio declarando una variable de tipo unsigned integer llamada threadGroupSizeX. Esto se debe a la función de tipo Void llamada GetKernelThreadGroupSizes, la cual toma el grupo de hilos que se han configurado en el Kernel, es decir, que la variable mencionada anteriormente recibirá el valor 128. Buffers ⚫ ⚫ ⚫ // compute shader [numthreads(128, 1, 1)] // C# GetKernelThreadGroupSizes(kernel, 128, 1, 1); Finalmente, se ha agregado tal valor a la lista m_circle el cual utilizaremos como data para el buffer. A continuación, asignaremos las variables públicas a aquellas declaradas dentro de la lista. Para ello, simplemente podemos inicializar un bucle y pasar los valores a cada variable por separado. Buffers ⚫ ⚫ ⚫ void SetShaderTex() { uint threadGroupSizeX; m_shader.GetKernelThreadGroupSizes(0, out threadGroupSizeX, out _, out _); int size = (int)threadGroupSizeX; m_circle = new Circle[size]; for(int i = 0; i < size; i++) { Circle circle = m_circle[i]; circle.radius = m_radius; circle.center = m_center; circle.smooth = m_smooth; m_circle[i] = circle; } … } 344 Una vez que la información se encuentra almacenada en la lista, podemos declarar un nuevo ComputeBuffer, configurar la información en él y luego enviar la data al Compute Shader. Buffers ⚫ ⚫ ⚫ for(int i = 0; i < size; i++) { Circle circle = m_circle[i]; circle.radius = m_radius; circle.center = m_center; circle.smooth = m_smooth; m_circle[i] = circle; } int stride = 12; m_buffer = new ComputeBuffer(m_circle, stride, ComputeBufferType.Default); m_buffer.SetData(m_circle); m_shader.SetBuffer(0, "CircleBuffer", m_buffer); … Por defecto, el constructor del ComputeBuffer contiene tres argumentos: › El número de elementos en el buffer. › El tamaño de los elementos. › Y el tipo de buffer que estamos creando. Como podemos ver en el ejemplo anterior, se han utilizado los datos guardados en la variable “m_circle” como primer argumento. El segundo (stride) es igual a la cantidad de dimensiones de aquellos escalares que estamos pasando, por la cantidad de bytes de la variable flotante. 345 Buffers ⚫ ⚫ ⚫ struct Circle { float radius; // 1 dimensión float - 4 bytes float center; // 1 dimensión float - 4 bytes float smooth; // 1 dimensión float - 4 bytes }; int stride = (1 + 1 + 1) * 4 El tipo de buffer que estamos utilizando en el tercer argumento corresponde al StructuredBuffer de tipo Circle que declaramos anteriormente en el Compute Shader. Buffers ⚫ ⚫ ⚫ StructuredBuffer<Circle> CircleBuffer; Luego de pasar la data al buffer a través de la función SetData, enviamos la información al StructuredBuffer CircleBuffer mediante la función SetBuffer. Finalmente, debemos configurar la textura y pasar el color m_mainColor al vector de cuatro dimensiones MainColor que se encuentra en el Compute Shader. Al final del proceso; cuando el buffer ya no será utilizado, podemos llamar a la función Release o Dispose la cual libera manualmente el buffer. 346 Buffers ⚫ ⚫ ⚫ void SetShaderTex() { … int stride = 12; m_buffer = new ComputeBuffer(m_circle, stride, ComputeBufferType.Default); m_buffer.SetData(m_circle); m_shader.SetBuffer(0, "CircleBuffer", m_buffer); m_shader.SetTexture(0, "Result", m_mainTex); m_shader.SetVector("MainColor", m_mainColor); m_rend.material.SetTexture("_MainTex", m_mainTex); m_shader.Dispatch(0, m_texSize, m_texSize, 1); m_buffer.Release(); } 347 Sphere Tracing. Sphere Tracing es una técnica para la renderización de superficies implícitas que utilizan distancia geométrica. “ “ De acuerdo a la publicación en The Visual Computer (1995) por John C. Hart; ¿A qué se refiere el enunciado anterior? Antes de entrar en detalles, abordaremos algunos puntos fundamentales para el buen entendimiento de los conceptos que se relacionan a su función. Sphere Tracing, Sphere Casting o Ray Marching, se refieren a un mismo concepto. Básicamente, es el proceso de “marchar” a lo largo de un rayo, el cual está dividido por puntos en el espacio. Este método se utiliza a menudo para el renderizado de volúmenes, donde no hay una superficie específica, en cambio, tendremos que encontrar la intersección entre el rayo y una superficie definida por una ecuación de “distancia implícita”. Sphere Tracing ⚫ ⚫ ⚫ (Fig. 11.0.0a. Sphere Casting sobre tres esferas rojas) En cálculo diferencial, podemos encontrar ecuaciones algebraicas y trascendentales, explícitas e implícitas, p. ej., la siguiente ecuación implícita nos permite generar una esfera en tres dimensiones: 348 Todas sus variables forman parte de la ecuación X² + Y² + Z² - 1 = 0 Sphere Tracing ⚫ ⚫ ⚫ (Fig. 11.0.0b. Con “Z” igual a 0.0f) Que es lo mismo decir, || X || - 1 = 0 Por tanto, Sphere Tracing ⚫ ⚫ ⚫ float sphereSDF(float3 p, float radius) { float sphere = length(p) - radius; return sphere; } Una superficie implícita es definida por una función que, dado un punto en el espacio, indica si tal punto está dentro o fuera de la superficie. 349 Para lograr el objetivo, un rayo viaja desde la cámara a través de un píxel hasta golpear una superficie. Este concepto se denomina Ray Casting, el cual es el proceso de encontrar el objeto más cercano a lo largo del rayo, de ahí el nombre “sphere casting”. Como ya sabemos, un rayo o línea está compuesto por dos puntos en el espacio: uno de partida y otro final. En esta técnica, el punto inicial está representado por la posición tridimensional de la cámara, y el punto final corresponde a la intersección de la superficie que estamos golpeando. Para determinar el punto final del rayo habrá que tomar en consideración la forma de la superficie que estamos generando. En este contexto entran en juego aquellas funciones de tipo SDF (Signed Distance Functions) las cuales, toman un punto como input y retornan la distancia más corta entre tal punto y la superficie de una figura. Si el valor de retorno es positivo, el rayo continúa su camino, mientras que si el mismo valor es igual a cero, entonces este ha colisionado con una superficie. Sphere Tracing ⚫ ⚫ ⚫ (Fig. 11.0.0c El punto “p0” representa la posición de la cámara, mientras que “p1” representa el punto de colisión del rayo) 11.0.1. Implementando funciones con Sphere Tracing. Será necesario definir al menos dos funciones en nuestro shader para el correcto funcionamiento de esta técnica. Para ello debemos considerar que: 1 Una función SDF para determinar el tipo de superficie. 2 Otra función para el cálculo del Sphere Casting. 350 En Unity, iniciaremos creando un nuevo .shader de tipo Unlit al que llamaremos USB_SDF_fruit. Para entender este concepto, desarrollaremos un efecto al cual denominaremos “fruta en rodajas”. Básicamente, vamos a dividir una esfera en dos partes para mostrar el interior de la misma. Cabe destacar que este efecto lo aplicaremos sobre una esfera primitiva, idealmente aquella que viene incluida en los objetos 3D, en Unity, ¿Por qué razón? Tal esfera posee su pivote en el centro de su masa, y además tiene una circunferencia igual a uno. Por tanto, cabe perfectamente dentro de un bloque de la cuadrícula en nuestra escena. Comenzaremos declarando una nueva Propiedad en nuestro shader, la cual se hará cargo de definir el borde o división en el efecto. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ Shader "USB/USB_SDF_fruit" { Properties { _Maintex ("Texture", 2D) = "white" {} _Edge ("Edge", Range(-0.5, 0.5)) = 0.0 } SubShader { Pass { … float _Edge; … } } } Si prestamos atención a la propiedad _Edge definida previamente, notaremos que su rango corresponde a un valor entre -0.5f y 0.5f. Esto se debe al volumen o escala de la esfera con la que estamos trabajando. 351 Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ (Fig. 11.0.1a) Para dividir la esfera emplearemos dos operaciones principalmente: descartar los píxeles que se encuentren sobre el _Edge y proyectar un plano en el centro de la misma. Descartar los píxeles nos va a permitir optimizar el efecto y visualizar el plano que vamos a generar posteriormente. Continuamos declarando una función de tipo SDF para el cálculo del plano. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ Pass { … // declaramos la función para el plano float planeSDF(float3 ray_position) { // restamos el edge a la posición del rayo en Y para aumentar // o disminuir la posición del plano float plane = ray_position.y - _Edge; return plane; } … } Dado que el _Edge está restando a la posición en Y AX del plano, nuestro objeto tridimensional, gráficamente tendrá la capacidad de subir o bajar en el espacio según el valor de la Propiedad. Su implementación podremos verla más adelante en esta sección. Por ahora, 352 vamos a definir algunas constantes que utilizaremos en el cálculo de Sphere Casting; determinando la superficie del plano. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ float planeSDF(float3 ray_position) { … } // máximo de pasos para determinar la intersección de una superficie #define MAX_MARCHIG_STEPS 50 // distancia máxima para encontrar la intersección de la superficie #define MAX_DISTANCE 10.0 // distancia de la superficie. #define SURFACE_DISTANCE 0.001 La directiva #define permite declarar un identificador el que podemos utilizar como constante global. Los valores asociados a cada macro se han determinado según su funcionalidad, p. ej., MAX_DISTANCE es igual a diez metros o diez bloques de la cuadrícula en la escena, mientras que MAX_MARCHING_STEPS se refiere a la cantidad de pasos que necesitaremos para encontrar la intersección del plano. Continuamos declarando una función para llevar a cabo el Sphere Casting. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ float planeSDF(float3 ray_position) { … } #define MAX_MARCHIG_STEPS 50 #define MAX_DISTANCE 10.0 #define SURFACE_DISTANCE 0.001 float sphereCasting(float3 ray_origin, float3 ray_direction) { float distance_origin = 0; for(int i = 0; i < MAX_MARCHIG_STEPS; i++) { float3 ray_position = ray_origin + ray_direction * distance_origin; Continúa en la siguiente página. 353 float distance_scene = planeSDF(ray_position); distance_origin += distance_scene; if(distance_scene < SURFACE_DISTANCE || distance_origin > MAX_MARCHIG_STEPS); break; } return distance_origin; } A simple vista, la operación que se está llevando a cabo en el ejemplo anterior parece difícil de comprender, sin embargo, no es del todo compleja. Inicialmente, debemos prestar atención a sus argumentos. El vector ray_origin corresponde al punto inicial del rayo, es decir, a la posición de la cámara en World-Space, mientras que ray_direction es igual a la posición de los vértices del mesh, en otras palabras, a la posición de la esfera con la que estamos trabajando, ¿Por qué razón? Dado que vamos a generar una división en la primitiva según un borde, necesitaremos que la posición del plano SDF sea igual a la posición del objeto 3D. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ (Fig. 11.0.1b) Como se ha mencionado anteriormente, será necesario descartar los píxeles de la esfera que se encuentren sobre el plano. Para realizar tal evaluación, necesitaremos la posición en 354 Object-Space de los vértices en el eje Y AX de la esfera. Utilizando la declaración discard podemos descartar aquellos píxeles que se encuentran sobre el borde, como se muestra en la siguiente operación. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ if (vertexPosition.y > _Edge) discard; Para el ejercicio será necesario declarar un nuevo vector de tres dimensiones en el Vertex Output, ¿Por qué razón? Dada su naturaleza, los píxeles únicamente pueden ser descartados en el Fragment Shader Stage. Por lo tanto, tendremos que llevar los valores desde el vertex shader al Fragment Shader Stage. Crearemos un nuevo vector al cual llamaremos “hitPos”. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; float3 hitPos : TEXCOORD1; }; Tal vector tendrá una doble funcionalidad en nuestro efecto. Por una parte, lo utilizaremos para definir la posición de los vértices del Mesh; y por otra, para calcular la posición espacial del plano, de tal modo que ambas superficies estén ubicadas en el mismo punto. 355 Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); // asignamos la posición de los vértices en object-space o.hitPos = v.vertex; return o; } Finalmente, podemos descartar los píxeles que se encuentren sobre la propiedad _Edge. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // descartamos los píxeles que se encuentren sobre el _Edge if (i.hitPos > _Edge) discard; return col; } Si hemos ejecutado de manera correcta lo anteriormente señalado en Unity, podremos apreciar un comportamiento similar al de la Figura 11.0.1c. La Propiedad _Edge va a modificar de manera dinámica el borde de píxeles descartados. 356 Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ (Fig. 11.0.1c. _Edge igual a 0.251) Cabe destacar que nuestro efecto es “opaco”, o sea, que no posee transparencia. Aquellos píxeles descartados no serán ejecutados, por ende no serán enviados al color de salida. Como pudimos observar en la Figura 11.0.1b, el origen del rayo está dado por la posición de la cámara, mientras que la dirección del rayo la podemos calcular siguiendo la posición de los vértices en el mismo espacio. La declaración de estas variables podemos llevarla a cabo con facilidad en el Fragment Shader Stage utilizando la variable _WorldSpaceCameraPos, y luego, pasando los vértices a la función como muestra el siguiente ejemplo: Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); // transformamos la cámara local-space float3 ray_origin = mul(unity_WorldToObject, float4( _WorldSpaceCameraPos, 1)); // calculamos la dirección del rayo float3 ray_direction = normalize(i.hitPos - ray_origin); Continúa en la siguiente página. 357 // pasamos los valores a la función float t = sphereCasting(ray_origin, ray_direction); // calculamos el punto espacial del plano float3 p = ray_origin + ray_direction * t; if (i.hitPos > _Edge) discard; return col; } Observando el ejemplo anterior, se ha guardado la distancia del plano respecto a la cámara en la variable “t”, y luego se ha guardado cada punto del mismo en la variable “p”. Será necesario proyectar nuestro plano SDF sobre la cara frontal de la esfera. En consecuencia, tendremos que desactivar el Culling desde el SubShader. Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ SubShader { … // proyectamos ambas caras de la esfera Cull Off … Pass { … } } Luego, a través de la semántica SV_isFrontFace, podemos proyectar los píxeles de la esfera, en su cara trasera; y al plano, en la cara frontal de la misma. 358 Implementando funciones con Sphere Tracing ⚫ ⚫ ⚫ fixed4 frag (v2f i, bool face : SV_isFrontFace) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); float3 ray_origin = mul(unity_WorldToObject, float4( _WorldSpaceCameraPos, 1)); float3 ray_direction = normalize(i.hitPos - ray_origin); float t = sphereCasting(ray_origin, ray_direction); float3 p = ray_origin + ray_direction * t; if (i.hitPos > _Edge) discard; return face ? col : float4(p, 1); } Si volvemos a la escena, podremos determinar la posición en el eje Y la propiedad _Edge, como lo muestra la Figura 11.0.1d. Implementando funciones con Sphere Tracing AX del plano SDF utilizando ⚫ ⚫ ⚫ (Fig. 11.0.1d) 359 11.0.2. Proyectando una textura. Continuando con nuestro shader USB_SDF_fruit, en esta oportunidad proyectaremos una textura sobre el plano SDF que hemos generado anteriormente. Para ello, iniciaremos agregando algunas propiedades que utilizaremos posteriormente en el efecto. Proyectando una textura ⚫ ⚫ ⚫ Shader "USB/USB_SDF_fruit" { Properties { _Maintex ("Texture", 2D) = "white" {} // textura para el plano _PlaneTex ("Plane Texture", 2D) = "white" {} // color del borde de la proyección _CircleCol ("Circle Color", Color) = (1, 1, 1, 1) // radio del borde de la proyección _CircleRad ("Circle Radius", Range(0.0, 0.5)) = 0.45 _Edge ("Edge", Range(-0.5, 0.5)) = 0.0 } SubShader { Pass { … sampler2D _MainTex; sampler2D _PlaneTex; float4 _MainTex_ST; float4 _CircleCol; float _CircleRad; float _Edge; … } } } 360 Para este caso, si deseamos proyectar una textura sobre el plano SDF habrá que considerar la posición y dirección de la cara trasera del mismo, ¿Por qué razón? Cabe recordar que el plano está apuntando hacia el eje Y dirección contraria. AX positivo y por ende la textura deberá estar orientada en la Proyectando una textura ⚫ ⚫ ⚫ (Fig. 11.0.2a) Para ello, podemos calcular coordenadas UV dentro del área máxima de Sphere Casting, utilizando el punto “p.xz”. Proyectando una textura ⚫ ⚫ ⚫ fixed4 frag (v2f i, bool face : SV_isFrontFace) : SV_Target { … float t = sphereCasting(ray_origin, ray_direction); if(t < MAX_DISTANCE) { float3 p = ray_origin + ray_direction * t; float2 uv_p = p.xz; } Continúa en la siguiente página. 361 if (i.hitPos > _Edge) discard; return face ? col : float4(p, 1); } Ahora podremos utilizar estas coordenadas en la función tex2D(S manera que se ha realizado a lo largo del libro. RG , UV Proyectando una textura RG ) de la misma ⚫ ⚫ ⚫ fixed4 frag (v2f i, bool face : SV_isFrontFace) : SV_Target { … float t = sphereCasting(ray_origin, ray_direction); float4 planeCol = 0; if(t < MAX_DISTANCE) { float3 p = ray_origin + ray_direction * t; float2 uv_p = p.xz; planeCol = tex2D(_PlaneTex, uv_p); } if (i.hitPos > _Edge) discard; return face ? col : planeCol); } En el ejercicio anterior se ha declarado un nuevo vector de cuatro dimensiones llamado “planeCol”. En este vector se ha guardado la proyección de la textura para el plano SDF, orientada en el eje Y AX Si todo ha ido bien, en nuestra escena podremos ver tanto a la esfera como al plano con texturas. 362 Proyectando una textura ⚫ ⚫ ⚫ (Fig. 11.0.2b) Cabe destacar que el punto inicial de la coordenada “uv_p” se encuentra en el centro de la cuadrícula 0 , 0 , 0 por lo tanto, será necesario restar 0.5f para centrar la proyección sobre el plano. X Y Z Proyectando una textura ⚫ ⚫ ⚫ … planeCol = tex2D(_PlaneTex, uv_p - 0.5); … Si modificamos el valor de la propiedad _Edge desde el material Inspector, podremos notar que el efecto está funcionando. Sin embargo, será necesario buscar una operación que nos permita mantener el tamaño de la proyección sobre el plano cuando _Edge sea igual a 0.0f, y disminuir su tamaño cuando la misma se encuentre en el rango, entre 0.5f y -0.5f. A continuación, se ha realizado la siguiente operación que resuelve de manera simple el ejercicio: 2 2 (-abs( x )) + (-abs( x ) - 1) 363 Por tanto, Proyectando una textura ⚫ ⚫ ⚫ … if(t < MAX_DISTANCE) { float3 p = ray_origin + ray_direction * t; float2 uv_p = p.xz; float l = pow(-abs(_Edge), 2) + pow(-abs(_Edge) - 1, 2); planeCol = tex2D(_PlaneTex, (uv_p(1 - abs(pow(_Edge * l, 2)))) - 0.5); } … Si modificamos nuevamente el valor de la propiedad _Edge desde el material inspector, podremos apreciar que la proyección de la textura ahora decrece conforme al volumen de la esfera. Proyectando una textura ⚫ ⚫ ⚫ (Fig. 11.0.2c. En la izquierda, _Edge es igual a -0.246, mientras que en la derecha, es igual a 0.282) Podemos estilizar el efecto proyectando un círculo en el plano, que siga la circunferencia de la esfera. Para ello podemos llevar a cabo la siguiente operación. 364 Proyectando una textura ⚫ ⚫ ⚫ … float4 planeCol = 0; float4 circleCol = 0; if(t < MAX_DISTANCE) { float3 p = ray_origin + ray_direction * t; float2 uv_p = p.xz; float l = pow(-abs(_Edge), 2) + pow(-abs(_Edge) - 1, 2); // generamos un círculo siguiendo las coordenadas UV del plano float c = length(uv_p); // aplicamos el mismo esquema al radio del círculo // de esta manera podemos modificar el tamaño del mismo circleCol = (smoothstep(c - 0.01, c + 0.01, _CircleRad abs(pow(_Edge * (1 * 0.5), 2)))); planeCol = tex2D(_PlaneTex, (uv_p(1 - abs(pow(_Edge * l, 2)))) - 0.5); // eliminamos los bordes de la textura planeCol *= circleCol; // agregamos el círculo y aplicamos color al mismo planeCol += (1 - circleCol) * _CircleCol; } … En el ejercicio anterior se ha generado un círculo siguiendo la proyección de las coordenadas UV del plano, y se ha guardado en la variable “c”. Luego, se ha utilizado el mismo esquema matemático para aumentar o disminuir el radio del círculo. Por último, considerando que tal círculo posee únicamente color blanco y negro, se han eliminado los bordes de la proyección de la textura. La razón de esto se debe a la suma de estos valores al final de la operación. 365 El radio del círculo podremos modificarlo de manera dinámica a través de la propiedad _CircleRad, desde el material Inspector. Proyectando una textura ⚫ ⚫ ⚫ (Fig. 11.0.2d) 11.0.3. Mínimo suavizado entre dos superficies. Es muy común la utilización de operadores (unión, intersección, diferencia) para la generación de objetos elaborados al trabajar con Sphere Tracing, p. ej., si deseamos crear una cruz en nuestro shader, podríamos emplear seis cubos, unirlos y simular su forma, como se muestra en la Figura 11.0.3a. Esta técnica se denomina constructive solid geometry (CSG) y consiste en crear cuerpos complejos a partir de estructuras primitivas, es decir, cubos, cilindros, esferas, etc. Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ (Fig. 11.0.3a) 366 Uno de los operadores más utilizados en esta técnica corresponde a la unión entre dos superficies. Para calcularlo, simplemente podemos utilizar la función “min” detallada en la sección 4.1.9. Sin embargo, dada su naturaleza, el resultado va a generar líneas afiladas entre ambas superficies de distancia implícita, manteniendo la forma general que se está desarrollando. Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ (Fig. 11.0.3b. Unión entre dos círculos) Si deseamos mezclar ambas superficies, podemos utilizar la solución de Íñigo Quilez, quien propone una función llamada polynomial smooth minimum, el cual utiliza una interpolación lineal para aproximar el mínimo entre A específica. RG y B , donde cada una se refiere a una forma RG Su sintaxis es la siguiente: Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ float smin (float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return lerp(b, a, k) - k * h * (1.0 - h); } Para entender su implementación, iniciaremos creando un nuevo shader de tipo Unlit al cual llamaremos USB_function_SMIN. Básicamente, realizaremos una unión suavizada entre dos círculos, la cual proyectaremos sobre un Quad en nuestra escena. 367 Comenzaremos definiendo algunas propiedades. Estas las utilizaremos posteriormente en el desarrollo del efecto. Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ Shader "USB/USB_function_SMIN" { Properties { _Position ("Circle Position", Range(0, 1)) = 0.5 _Smooth ("Circle Smooth", Range(0.0, 0.1)) = 0.01 _k ("K", Range(0.0. 0.5)) = 0.1 } SubShader { Pass { … float _Position; float _Smooth; float _K; … } } } La Propiedad _Position está directamente relacionada con el cambio de posición de “uno” de los círculos. Como se ha mencionado anteriormente, crearemos dos círculos. Uno de ellos se mantendrá estático, mientras que el segundo, lo moveremos de un lado a otro para apreciar el funcionamiento de la función smin( A ,B RG ,K RG RG ). Por su parte, _Smooth, lo utilizaremos para suavizar los bordes del conjunto en sí, mientras que _K será empleado en el cálculo de la interpolación entre ambos círculos. 368 Para el ejercicio, declaramos una función que permita generar un círculo. Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ float circle (float2 p, float r) { float d = length(p) - r; return d; } Como podemos observar, la función anterior tiene la misma estructura que hemos visto previamente. Utilizando la función “smin” podremos calcular el mínimo suavizado entre dos círculos, en el Fragment Shader Stage. Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ float circle (float2 p, float r) { … } float smin (float a, float b, float k) { … } fixed4 frag (v2d i) : SV_Target { float a = circle(i.uv, 0.5); float b = circle(i.uv - _Position, 0.2); float s = smin(a, b, _K); return float4(s.xxx, 1); } En el ejercicio anterior creamos dos variables escalares llamadas “a y b”. Estas las utilizamos en la función “smin” la cual retorna la unión suavizada entre ambas formas. 369 Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ (Fig. 11.0.3c. Unión suavizada según “k”) Incorporando la función “smoothstep” en la operación, podemos generar bordes suavizados para la composición en general. Mínimo suavizado entre dos superficies fixed4 frag (v2d i) : ⚫ ⚫ ⚫ SV_Target { float a = circle(i.uv, 0.5); float b = circle(i.uv - _Position, 0.2); float s = smin(a, b, _K); float render = smoothstep(s - _Smooth, s + _Smooth, 0.0); return float4(render.xxx, 1); } Mínimo suavizado entre dos superficies ⚫ ⚫ ⚫ (Fig. 11.0.3d) 370 Ray Tracing. Hasta este punto, hemos revisado una parte importante del conocimiento necesario para el desarrollo de shaders en Unity, tanto en Built-In como en Universal RP. Sin embargo, en esta última sección, centraremos nuestro estudio en la comprensión de High Definition RP y su configuración de Ray Tracing. Comenzaremos formulando la siguiente pregunta, ¿Qué es Ray Tracing? Para entender el concepto debemos prestar atención al comportamiento de la iluminación en el mundo físico. Ray Tracing; al igual que Sphere Tracing, utiliza la técnica del Ray Casting. No obstante, en este caso particular se concentra en obtener contribuciones de iluminación desde objetos reflexivos o refractivos en tiempo real, es decir, que podemos lograr una composición más realista (en cada cuadro) mediante el envío de rayos a través de cada píxel en nuestra pantalla, los cuales colisionan y rebotan con cada objeto en nuestra escena. Ray Tracing ⚫ ⚫ ⚫ (Fig. 12.0.0a) Una composición realista se puede llevar a cabo mediante la comprensión de las siguientes características. › Iluminación global. › Reflexiones. › Refracciones. › Oclusión ambiental. › Sombras. 371 Previamente a Ray Tracing, tales propiedades eran calculadas en el software a través de Lightmaps los cuales podemos conseguir desde el panel Window / Rendering / Lighting. Sin embargo, dada la naturaleza de esta técnica, los elementos en la escena debían permanecer estáticos, inhabilitando la posibilidad de cálculos en tiempo real. 12.0.1. Configurando Ray Tracing en HDRP. Comenzaremos esta sección utilizando un template por defecto, incluido en Unity Hub versión 3.0.0-beta.6. Tal template luce de la siguiente manera. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1a) Como se mencionó al comienzo de este capítulo, será necesario la utilización de High Definition RP para la realización de los ejercicios. Podemos corroborar su configuración yendo al menú Window / Package Manager, y asegurándonos que sé encuentre instalado el paquete High Definition RP en nuestro proyecto, como se muestra en la imagen 12.0.1b. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1b) 372 High Definition RP se caracteriza por su rendering de calidad y compatibilidad con plataformas de alta gama, es decir, PC, PlayStation 4 o Xbox One en adelante. Así mismo, soporta DirectX 11 y versiones posteriores, y Shader Model 5.0 el cual introduce la utilización de Compute Shaders para aceleración de gráficos. En la apertura de nuestra escena, es muy común que algunas texturas luzcan como en la Figura 12.0.1c. Esto se debe a un error de configuración en los Lightmaps que se generaron al momento de crear el proyecto. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1c. Las murallas poseen errores en sus lightmaps) Para solucionar este problema debemos prestar atención a la configuración de los objetos. Si seleccionamos un objeto cualquiera, p. ej., FR_SectionA_01_LOD0 (muralla), notaremos que ha sido marcada como “estática” desde el Inspector de Unity. Entonces, debemos ir al menú Window / Rendering / Lighting, y realizar la siguiente operación: 1 Presionamos el dropdown perteneciente al botón Generate Lighting, y seleccionaremos Clear Baked Data. Al realizar esta acción, todos los Lightmaps serán eliminados, lo cual va a permitir visualizar la iluminación por defecto. 2 A continuación debemos presionar el botón Generate Lighting. El proceso puede tardar algunos minutos dependiendo de la capacidad de nuestro ordenador, sin embargo, al terminar el proceso, las texturas e iluminación se podrán apreciar de manera correcta. 373 Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1d. Los Lightmaps han sido corregidos) Cabe destacar en este punto que la iluminación global y otras propiedades como la oclusión ambiental, están siendo “quemadas” sobre cada textura. Por lo tanto, si cambiamos la posición de un objeto, sus propiedades lumínicas mantendrán su forma y no serán recalculadas. La única manera de realizar este proceso en tiempo real es a través de la activación de Ray Tracing en nuestro proyecto. Para ello, habrá que tomar en consideración varias configuraciones para el mismo, incluyendo DirectX 12 (DX12). El proceso completo puede ser resumido en tres pasos principalmente; 1 Render Pipeline Asset. 2 Project Settings. 3 DirectX 12. Iniciaremos yendo al menú Window / Panels / Project Settings, y prestaremos atención a las siguientes categorías: › Quality. › Graphics. › HDRP Default Settings. 374 Cabe destacar que Unity crea una configuración de Rendering distinta para cada nivel de calidad en nuestro proyecto, p. ej., nuestro proyecto posee “tres niveles” de calidad por defecto, las cuales podemos encontrar en la pestaña Quality. › High Quality. › Medium Quality. › Low Quality. Cada uno de estos niveles posee un HD Render Pipeline Asset distinto, por ende, si deseamos trabajar con Ray Tracing, tendremos que habilitar sus opciones desde el Asset previamente configurado; en este caso en particular, Medium Quality. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1e. Configuración para Medium Quality) Luego de haber seleccionado el HD Render Pipeline Asset desde nuestro proyecto, debemos prestar atención tanto al menú Rendering, como a Lighting, que se encuentran en el Inspector de Unity. Iniciando en el dropdown del primero, tendremos que activar la opción Realtime Ray Tracing (Preview), como se muestra en la Figura 12.0.1f. Si nuestro proyecto está utilizando una configuración de DirectX distinta a su versión 12, nos Actualmente, Ray Tracing solo es compatible con DX12. 375 “ “ aparecerá el siguiente mensaje: Dado que Ray Tracing no funciona en versiones inferiores a DirectX 12, tendremos que cambiar su configuración en nuestro proyecto más adelante. Por ahora continuaremos habilitando algunas opciones que van a permitir realizar cálculos de iluminación global y otras características. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1f) A continuación, iremos al menú Lighting y habilitaremos las siguientes opciones: › Screen Space Ambient Occlusion. › Screen Space Global Illumination. › Screen Space Reflection. › Screen Space Shadows. Luego de Clip-Space continua Screen-Space, la cual se refiere a la transformación de coordenadas entre -1.0f a 1.0f, a coordenadas de pantalla. Por lo tanto, podemos deducir que a mayor resolución, mayor será el cálculo de Ray Casting, por ende, más potencia necesitaremos en la GPU. Screen Space Ambient Occlusion (SSAO) corresponde a un efecto de imagen capaz de reproducir una aproximación de oclusión ambiental en tiempo real. Screen Space Global Illumination (SSGI) nos va a permitir calcular el rebote de la iluminación en tiempo real, generando una representación lumínica más precisa de la composición en nuestra escena. Screen Space Reflection calcula reflexiones en tiempo real. 376 La misma analogía se cumple en Screen Space Shadows, la cual mejora la proyección de sombras en nuestro proyecto. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1g. Se han habilitado las distintas opciones desde el drop down Lighting) Hasta este punto, Ray Tracing y sus propiedades ya se encuentran habilitadas en el Render Pipeline Asset. A continuación, debemos configurar nuestro proyecto para que tales propiedades puedan realizar sus operaciones. Nuevamente, iremos a Window / Panels / Project Settings, menú HDRP Default Settings, y nos aseguramos de tener activa la opción Ray Tracing desde el dropdown Rendering, en el Frame Settings. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1h. Habilitamos Ray Tracing para las cámaras en nuestra escena) 377 Entonces, desde el menú Lighting debemos habilitar exactamente las mismas opciones que activamos en el Render Pipeline Asset, es decir, Screen Space Shadows, Screen Space Reflection, Screen Space Global Illumination y Screen Space Ambient Occlusion. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1i) De momento, Ray Tracing ya está configurado en nuestro proyecto. Sin embargo, debemos cambiar nuestra configuración de DirectX dado que, como se ha mencionado anteriormente, esta técnica únicamente funciona en su versión 12, y nuestro proyecto, por defecto, ha sido configurado en DX11. Esto lo podemos corroborar en la ventana de Unity, sobre la barra de herramientas, como se muestra en la Figura Fig 12.0.1j. Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1j) Para ello, debemos ir al menú Window / Render Pipeline / HD Render Pipeline Wizard. Desde la pestaña DirectX Raytracing (HDRP + DXT), debemos presionar el botón Fix All. 378 Configurando Ray Tracing en HDRP ⚫ ⚫ ⚫ (Fig. 12.0.1k. Pestaña DirectX Raytracing) Es posible que el software solicite reiniciar una vez que el proceso haya terminado. Al cargar nuevamente nuestro proyecto, Unity aparecerá con el tag <DX12> en la parte superior de la interfaz. Si regresamos a la ventana Render Pipeline Wizard, notaremos que todas las propiedades aparecen marcadas en color verde, lo cual significa que Ray Tracing está habilitado para ellas. 12.0.2. Utilizando Ray Tracing en nuestra escena. Iniciaremos creando una nueva escena en nuestro proyecto. Para el ejercicio, utilizaremos un template incluido en nuestro proyecto, llamado Basic Outdoors (HDRP), el cual se caracteriza por poseer los siguientes objetos por defecto: › Una cámara (Main Camera). › Una luz direccional (Sun). › Un cielo (Sky and Fog Volume). Cabe destacar que utilizaremos una habitación y una esfera para la ejemplificación del ejercicio. Tales objetos de tipo .fbx podremos encontrarlos en el paquete adjunto a este libro, en su respectiva sección. Comenzaremos generando dos materiales en nuestro proyecto, uno para cada elemento. Llamaremos “mat_room” aquel material para la habitación, mientras que el material para la 379 esfera lo nombraremos “mat_sphere”. Nos aseguraremos de asignar el shader HDRP / Lit a ambos materiales. Antes de comenzar, asignaremos cada material a su respectivo objeto. Previamente, habilitamos la propiedad Screen Space Reflection desde el Render Pipeline Asset, por lo tanto, si aumentamos el valor de las propiedades Metallic y Smoothness en alguno de los materiales, podremos visualizar reflexiones en tiempo real, como muestra la Figura 12.0.2a. Sin embargo, tal reflexión depende del ángulo de vista de la cámara, en consecuencia genera artefactos gráficos. Utilizando Ray Tracing en nuestra escena ⚫ ⚫ ⚫ (Fig. 12.0.2a. Material mat_room, Metallic igual a 0.5f, Smoothness igual a 1.0f) Si deseamos activar las reflexiones a través de Ray Tracing, debemos realizar los siguientes pasos: 1 Seleccionamos el objeto Sky and Fog Volume. 2 Vamos a su componente Volume. 3 Presionamos el botón Add Override. 4 Seleccionamos el menú Lighting / Screen Space Reflection. Para el ejercicio, activaremos todas las propiedades del Override. Sin embargo, podremos apreciar grandes cambios gráficos una vez que habilitemos Ray Tracing (Preview) debido a que las reflexiones comenzarán a ser calculadas en tiempo real. 380 Utilizando Ray Tracing en nuestra escena ⚫ ⚫ ⚫ (Fig. 12.0.2b) Modificando el valor del parámetro Bounce Count podemos aumentar o disminuir la cantidad de rebotes para los rayos de reflexión. Se puede utilizar la misma analogía para configurar la oclusión ambiental e iluminación global. Para ello, presionamos nuevamente el botón Add Override y seleccionamos el menú Lighting / Screen Space Global Illumination o Ambient Occlusion. El proceso es semejante en el caso de las sombras. Para ello, debemos ir a luz global en nuestra escena (Sun), seleccionar el menú Shadows y habilitar Screen Space Shadows. Debemos asegurarnos de activar la propiedad Ray Traced Shadows (Preview) para que surta efecto. Una vez realizado este proceso, podemos ir al menú Shape y modificar el diámetro angular de acuerdo a las necesidades de nuestro proyecto. 381 Índice. A. E. Agregando transparencia en Cg o HLSL. (122) Etapa de rasterización. (25) Agregando compatibilidad en URP. (130) Etapa de procesamiento de un píxel. (26) Analizando Shader Graph. (293) Estructura de un shader. (42) Analogía entre un shader y un material. (119) Etapa de aplicación. (22) Estructura de una función en HLSL. (123) B. Estructura de un Standard Surface shader. (259) Built-in Render Pipeline. (26) Efecto Fresnel. (250) C. F. Color de los vértices. (20) Forward Rendering. (28) Clip-Space Coordinates. (34) Fragment Shader Stage. (115) CGPROGRAM / ENDCG. (98) Funciones intrínsecas. (135) Cg / HLSL Pragmas. (105) Función Abs. (135) Cg / HLSL Include. (106) Función Ceil. (140) Cg / HLSL Vertex input y Vertex Output. (107) Función Clamp. (145) Cg / HLSL Variables y Vectores de Conexión. (111) Función Sin y Cos. (150) Cg / HLSL Vertex Shader Stage. (113) Función Tan. (155) Cg / HLSL Fragment Shader Stage. (115) Función Exp, Exp2 y Pow. (159) Configurando inputs y outputs. (186) Función Floor. (161) Compresión DXT. (205) Función Step y Smoothstep. (165) Color de ambiente. (213) Función Length. (169) Custom Functions. (306) Función Frac. (173) Compute Shader. (312) Función Lerp. (177) Coordenadas UV y textura, Compute Shader. (332) Función Min y Max. (181) Compute Buffers. (336) Función Smin. (366) Coordenadas UV. (19) Constructive solid geometry. (366) Configurando Ray Tracing en HDRP. (372) D. Deferred Shading. (30) Debugging. (126) Fase de procesamiento de la geometría. (23) G. Graph Inspector. (303) H. High Definition Render Pipeline. (26) HLSL. (37) 382 I. Proyectando una Textura con Sphere Tracing. (360) Image Effect Shader. (40) Q. Introducción al lenguaje de programación. (37) Implementación de sombras. (274) ¿Qué Render Pipeline debo utilizar? (30) Iniciando en Shader Graph. (291) ¿Qué es un shader? (36) Implementando funciones, Sphere Tracing. (350) M. R. Render Pipeline. (21) Matrices y sistemas de coordenadas. (31) Ray Tracing Shader. (41) Material Property Drawer. (53) Reflexión Difusa. (217) MPD Toggle. (54) Reflexión Especular. (226) MPD KeywordEnum. (57) Reflexión Ambiental. (240) MPD Enum. (59) Render Pipeline Asset. (282) MPD PowerSlider e IntRange. (60) Ray Tracing. (371) MPD Space y Header. (62) S. Mapa de normales. (199) Matriz TBN. (210) Shader. (36) Modelo de iluminación. (213) Standard Surface Shader. (40) Mínimo suavizado entre dos superficies. (366) ShaderLab Shader. (46) ShaderLab Properties. (47) N. ShaderLab SubShader. (63) Normales. (18) SubShader Tags. (65) Nuestro primer shader en Cg o HLSL. (119) SubShader Blending. (74) Nuestro primer shader en Shader Graph. (295) SubShader AlphaToMask. (79) Nodos. (305) SubShader ColorMask. (80) Nuestro primer Compute Shader. (317) SubShader Culling y Depth Testing. (81) ShaderLab Cull. (84) O. ShaderLab ZWrite. (86) Object-Space. (32) ShaderLab ZTest. (87) Optimización del shadow map en Built-in RP. (279) ShaderLab Stencil. (90) P. ShaderLab Pass. (97) ShaderLab Fallback. (117) Propiedades de un objeto poligonal. (15) Standard Surface input y output (261) Propiedades para números y sliders. (49) Sombra. (263) Propiedades para colores y vectores. (49) Shadow Mapping. (263) Propiedades para texturas. (50) Shadow Caster. (264) Producto Punto. (193) Shadow Map Texture. (270) Producto Cruz. (197) Shadow Mapping en Universal RP. (282) 383 Shader Graph. (289) Sphere Tracing. (348) Signed Distance Functions. (350) Screen-Space. (376) Screen Space Global Illumination. (376) Screen Space Reflections. (376) Screen Space Shadows. (377) Screen Space Ambient Occlusion. (376) T. Tangentes. (18) Tipo de Render Pipeline. (26) Tipos de shaders. (39) Tag Queue. (66) Tag Render Type. (69) Tipos de datos. (100) Tiempo y animación. (182) U. Unlit Shader. (40) Universal Render Pipeline (26). V. Vértices. (17) Vertex Shader Stage. (113) Vectores. (191) View-Space. (34) W. World-Space. (33) 384 Agradecimientos Especiales. Taneli Nyyssönen; David Jesús Ville Salazar; Carlos Aldair Roman Balbuena; Sergio Mireles Zamorano; Jhoseman Cesar Maraza Ytomacedo; Luis Fernando Salcido Infante; José Pizarro Rocco; Nicholas Hutchind; Luis Bazan Bravo; Elsie Ng; Lewis Hackett; Sarang Borude; Jonathan Sanchez; Иван Востриков; Alberto Pérez-Bermejo Galilea; Stephen Liu; Михаил Хаджинов; Zach Hilbert; Jake Manfre; Carlos Castro; Orlando Javier Orozco Guzmán; Furrholic; Alejandro Ruiz Ferrer; Éric Le Maître; Dimas Alcalde; Mika Juhani Makkonen; Giuseppe Graziano Softwareentwicklung; Xury Greer; Luca Palmili; Timothy Nedvyga; Coelet Swart; David Tamayo; Pedro Martins; Vaclav Vancura; Rene Melendez; Cesar Daniel Cerino Susano; Marcin Skupień; Ryan Bridge; Victor Celis Padrón; Josef Rogovsky; Jay Edry; Angel Daniel Vanches Segura; Mikail Miller; Insaneety Gaming; Ruilan Berg Pereira; PointNine; GameDevHQ Inc; David Muñoz López; Andrea Vollendorf; Djamschid Arefi; Srikanth Siddhu; Broken Glass LLC; Ben Vanhaelst; Anthony Davis; Asim Ullah; John Schulz; Rubén Luna de San Macario; Nathalie Barbosa Vásquez; Lukas Aue; Arnis Vaivars; Joel MacFadyen; Rebecca L Dilella; 志炜 马; Nhân Nguyễn; Eduardas Klenauskis; Stylianos Petrakis; 承晏 蔡; Ivan Paulo Guazzelli Machado; Daniel García Fernández; Victor Pan; Amit Netanel; Daniel Ponce; Thibaut Chergui; Tuanminh Vu; Craig Herndon; Jordan Huot-Roberge; Dragonhill LLC; Yuan Chiu; Sergey Ladychin; 庆 常; Patrick Pilmeyer; Ignacio María Muñoz Márquez; Tapani Heikkinen; Александр Максимович; Sebastian Cruz Dussan; Dustin Hoye; Matthias Rich; Cory Bujnowicz; Josue Ortigoza; Skydsgaard Translations; Marc Cacho; Geovane Pereira; Brainstorm Games LLC; César Iván Gallo Flores; Mark Mainardi; Hello Labs; Ricardo Costa Maginador; Yannick Vanhoutte; Peter Winston; Orlando Batista da Silva Lando; Brandon Brown. MKS Soluciones; Jdui; Duca Stefan Stefan; Daniel Kavanaugh; Dominik Gygax; Erick Breto; Ángel Paredes Zambrano; Derek Westbrook; Gabriele Lange; Christopher Manasse; Timothy Fehr; Taylor Bazhaw; Nathaniel Shirey; AuKtagon; Matteo Lo Piccolo; Jonathan Smith; Rowan Goswell; De Blasio Corporation; Chaya Jagroep Jagroep; Nikhil Sinha; Dana Frenklach; Wilmer Lin; Roman Lembersky; Carlos Daniel Ahuactzin Parra; 江哉 湊; Tesseraction Ltd; Nasrul Nasir; Alexandre Caila; Sime Tadic; Michael Dunkley; Blue Robot Creations; Juan Medina; Anne Postma; Adrian Higareda; Ignacio Gajardo; Edward Fernandez Silva; Juan Camilo Alcaraz Cartagena; Gabriela Mylonas; Juan Castillo; Sarah Sturm; Fernando Labarta; Alan Pereira; Tom Nemec; Angel Ordoñez Sanchez; Sabina Kurgunayeva; Kayden Tang Tang; Kluge Strategic Inc; Joshua Byron; Afif Faris; Jonathan Morales-Rocha; Ángel David García Gómez; 용근 류; Shawn Sarwar; inMotion VR B.V; Joakim Lundkvist; Valerio Bellia; BetaJester Ltd; Bryan Pierce; Marco Tieghi; Dominique Maier; Carl Emil Hattestad; Eric Rico; Ossama Obeid; Liam Walsh; Quentin Chalivat; Carlos Melo; Michel Bartz; Lidia Arzenton; Abandon Ship LLC; Paola 385 González Olea; Kasper Røgen; Jozef Bátrna; Luke Blaker; Noah Gude; Peter Britton; Timo Sikinger; Михаил Екимов; Kevin Toet; Patrick Geoghegan; Radosław Polasik; Gerard Belenguer; Roberto Chiovenda; Justen Chong; Michael Stein; Brett Beers; Abraham Armas Cordero; Jae-Hyeok Hong; Juan Sabater Sanjaume; Piotr Wardyński; Kerry Leonard; Tsukada Takumi; Crisley Maihana; Ben Kahlert; Parag Ponkshe; Giyong Park; Ryan Kann; Samuel Porter; Jacob Bind; Andrew Fitzpatrick. Daniel Holmes; Anura Rajapaksa; Giorgi Tsaava; Pau Elias Soriano; Robert Hoole; SinisterUX; Psypher Games LLP; Patricia Kelley; Алексей Черендаков; Esko Evtyukov; Fabricio Henrique; Zach Jaquays; Jad Deeb; Michael Hein; Sourav Chatterjee; Djordje Ungar; House of How Games LLC; Alexandre Rene; Daniel López; Thibaut Hunckler; Sunny Valley Studio; 裕貴 新井; Marcos Rebollo; François Therasse; Jimmy Brown III; Rupert Morris; Zach Williamson; Gage Kilmer; Adam Lomax; Ian Winter; Adrian Galeazzi; Tan Jen; Kayla Slifer; Simon Kandah; Alexei Tristan Menardo; Gorilla Gonzales Studios; Fred Mastropasqua; Bradley White; Johnathan Pardue; Matan Poreh; Curtis Weekusk-White; Wei Zhang; Davide Jones; Luis Reynaldo Alves; Diego Peña; Virtuos Vietnam; Robert Krakower; Owen Duckett; Muhammad Azman; Metacious; Mike Curtis; Freelance; Tammy Martin; Piotr Rudnicki; Adam Anh Doan Kim Caramés; Micael Brito de Jesus; 이 미주; Juan Restrepo; Eran Mani; Mario Fatati; Lucas Keven Simoes dos Santos Sales; Jeremias Meister; Scott Allison; Christopher Goy; Le Canh; Leonardo Oropeza; Implosive Games; Victor Mahecha; Deyanira Llanes; Omer Avci; Marcos Silva; Stanislav Kirdey; Camila González; Cesar Mory Jorahua; Juan Diego Vázquez Moreno; Unknown Worlds Entertainment Inc; Daniel Garcia; Michał Lęcznar; David Nieves Trujillo; 友太 本山; Carsten Flöth; Ángel Siendones Sillero; Mikko McMenamin; Richard Le; Ericke Maciel; Максим Хламов; Alessandro Salvati; Marc Jensen; Ariel Nuńez Bodden; Khang Nguyen; Pepijn van der Linden; Alexander Horvat; Carlos Alberto Montiel Zavala; Christopher O’Shea; Useful Slug; Chris Rosati; Sean Loughran; Suresh Venkataramana; Benjamin Radcliffe; José Manuel Vera Menárguez. Charlie Darraud; 健斗 神田; Jonathan Rodriguez Ruiz; Jose Vicente Fernandez Pardo; Ishan Prakash; Ari Hoopes; Jarvis Hill; Ekipa2 d.o.o; Marcin Iwanowski; Fredy Espinosa; Étienne Loignon; Datorien Anderson; Thomas Brown; Sebastián Procek; Shahar Butz; Tomás Quiroz; Frayed Pixel Limited; Mayank Ghanshala; TwinRayj Studios; One Wheel Studio; Luca Naselli; Pablo José de Andrés Martín; Максим Голубенко; Nikola Garabandić; Ferdinando Spagnolo; Unreality3D; Péter Nagyidai; Winfried Schwan; VRFX Realtime Studio GmbH; Dana Würzburg; 陳芸軒; Juan Manuel Ramon Vigo; Eric White; Yu Gao; David Bermudez Lopez; Berlingeri Gilles; Derek Yiok Teik Lau; Robin Hinderiks; Dennis Koch; Grant Nelson; Marcel Marti; Daniel Corujeira Ortega; Charlotte Delannoy; Casey Mooney; Yorai Omer; Eric Manahan; Anastasiya Tolkachyova; Wei Tang; NieddaWorks; Colin Mongabure; ;בוקלו יירדנאShuxing Li; Shiri 386 Blumenthal; Kevin Choo Fun Young; Slufter; Nikolai McNeely; Kevin Roberto Gomez Peralta; Diego Esedin; Andrew Bowen; Matthew Spencer; Susan Cho; Bright Future GmbH; Sander De Pauw; Alen Brkicic; Guillaume Cauvet; Michael Aviles; Fabio Schegg; Michael Center; Jean-Baptiste Sarrazin; Rolandas Cinevskis; 学贤 张; Brian Smith; Nathan Buckley; Julien Rochefort Delsalle; Coldharbour Media; Ali Taher; Wade Lewis; Wei Zeng; Vesselin Handjiev; Lukasz Lampka; Groove Jones; Stephen Eisenmann; Kaochoy Saetern; Christopher Kline; Darya Luchaninova; Akash Castelino; Daniel Radu; Théo Monnom; Javier Molla Garcia; Aykut Yildirim; Bernhard Esperester; Nathan Sheppard; Remi Kroll; Катерина Колесникова; Alexander Sachuk; Cesar Ramirez Cervantes; Christian Miller; James Rossiter; Moritz Großfurtner; Ludwig Broman. Christian Hertwig; Ben Boniface; Jans Margevics; William Barteck; Nils Hammerich; Oskar Kogut; Darko Nikolic; 043 Imagine; Jose Torres; Digitalpro; Юрий Ануфриев; Ludibyte Games; Hamish Dickson; Timothy Neville; William Beard; Евгений Шишкин; 広希 奈 良; Alessio Landi; Niklas Weber; James Macgill; Jessamyn Dahmen; Justin Herrick; Raymond Micheau; Troy Patterson; Sandor Fejer; Anthony Massingham; Ryousuke Nakai; Kazumi Mitarai; Samuel Furr jr; Katsuya Taniguchi; Arthur Del; Matthew Burgess; Anil Ayaz; Neomorph Studio SRL; David Moscoso; KoriinArt; Rodrigo Abreu; Antonio Ripa; Victor Lalo; Gold Gnome; Vitai Tamás Egyéni Vállalkozó; Low Zhe Ming Walter; Jordan Dubreuil; Jun Kyung Kim; Adrian Orcik; Edwin Morizet; Wojciech Sajdak; Roberto Bernous; Wilson Ortiz Morales; Mario Tudon; Marc Segura Molina; Malik Abu Aune; Miguel Grunfeldt; Pitchayah Chiothian; Iván Godoy Rojas; Juan Mauricio Ochoa Castillo; Alina Sommer; Omar El Halabi; 재영 최; Ryan Salam; Ben Guiden; Kevin Hagen; Ninquiet; Kevin Willis; Carlos Gerardo Enríquez Valerio; Valerie Nunez; Peter Cowen; Chang Hoon Oh; Pavel Shutau; Mattias Gyllerup; T K; Yuri Kovtunovych; Jason Peterson; Nicole Wade; Starloop SL; Raul Torres Gonzalez; Adrián Rangel Suárez; Zaibatsu Interactive Inc; Arnaud Jopart; Valdeir Antonio Nascimento Santos; Juan Carlos Romero; Joss Gitlin; Christian Santoni; Du Yoon; Adrian Koretski; Juan Antonio López Rodríguez; Dongyeon Kim; Flashosophy; Colter Wehmeier; Wendeline Aerts; Joan Sierra Patiño; Michael Ha; Test Out Web Design; Eddy May; Josh Lowes; Do Minh Triet; Matthew Anderson; Durmus Ali Collu; Emma Barnes; Christer Bjoerk. Angie Rojas Mendez; Adriel Almirol; Jacob Fletcher; Krzysztof Bziuk; Simon Borg; Diego Paniagua Morales; Alan Berfield; Adam Warkentin; Jonathon Stone; Chèze Chèze; Senem Gokce Ogultekin; Louise Crouch; Koi koi; Victor Carvalho Estrella; Bruno Fernando Pita Sassioto Silveira de Figueiredo de Figueiredo; Oleksandr Kokoshyn; Danny Darwiche; The Life Forge; Natus; Patrick Schnorbus; Raúl Vera Ortega; Daniel Izacar Memije Fábrego; Kevin Babin; Diego Millan; Jean-Bernard Géron; David Alonso Alapont; Andrea Osorio; Manuel Obertlik; Juan Alvarez Mesa; Brad Johnson; Ricardo Díaz; Alejandro Azpitarte; Wayne Moodie; Brock Williams; Ernesto Salvador Solares Guerrero; Abdullah 387 Al Zeer; Mathis Schmidtke; Juan Carlos Horta; Ilham Effendi; Aaron Prideaux; Syed Salahuddin; Damian Griffin; David Roume; Mal Duffin; Armonte Williams; Daiya Shinobu; Michael Joyce; Danik Tomyn; Syama Mishra; Marine Le Bornge; Daniel Ilett; Omar Espinosa; Pollywog Games; Paulo Sergio Fernandes; Ahmed Gado; Gilberto Alexandre dos Santos; Ato Ishimoto; Alexander Grinkevich; Victor Cavagnac; Andoni Torres; Kailun Cai; Wilko Willame; Christopher Johnson; Jefferson Perez; 1998; Iván Gallego Muñoz; Sergio Labbe Grandon; Arnold Wittenberg; Dominic Butler; Steve Barr; Misumi Yuki; Dawid Ochryniak; Yingdi Fu; Doge Corp; Sofía Lozano Valdés; 健斗 中島; Thomas Tassi Joergensen; Christopher Munguia; Lim Siang; Rolf Vidstid; Desarius Games di Dario Visaggio; David Vivas Estevao; Ciria Quispe; Nathan Clark; Marco Di Timoteo; Jan Neuber; Angel Aristides Zuniga; James Meade; Iniciativas Digitales; Marvin Gewiss; Yulia Trukhan; Paulo Lara Carreño; Daniel Sierra; Andrés Cortés Dávalos; Raul Bustamante Morales; Aldo Eduardo Fuentes Millan; Raimundo Gallino; Martin Perez Villabrille; José Francisco Torreblanca Nava; Julio Ortiz Acosta. Jesus Popocatl Lara; Juan Manuel Hernandez Hernandez; Pookzzz3d Ninja; Данила Поляков; Juan Antonio Pascual Albarranch; Tahiche Maria Mena; Silvia Acevedo; Orlando Almario; Alfonso Varela Giménez; Ekaitz Segurola Elosua; Andrea Polanco; Murughavell Allagarsamy; Marcos Antonio Vilca; Javier Maldonado Díaz; Massimo Di Cesare; Paul Pinto Camacho; Hector Ortiz Muniz; Roberto Delgado Sánchez; Ahmed Elwardy; Omar Akkari; Guillermo Meléndez Morales; Daniel Garcia Daniel Garcia; Jorge Vecino Labajo; Juan Francisco Matheu García; Bastian Oñate; Thiago Carneiro; Eduardo Noé; John Estrada; Luis Garvi Zarco; Adalberto Perdomo Abreu; Miguel Cano Santana; Casey Hallis; Ignacio Alcaino; Juan Díaz de Jesús; Miguel Muñoz Ortega Terrazas; Vita Skruibyte; Daniel Sørensen Sørensen; Sebastian Manriquez; Patil Aslanian; Ersagun Kuruca; Ismael Salvado Fernandez; 永 恒 朱; Oscar Yair Núñez Hernández; Nguyễn Đại; Fraser Hutchison; Mario Pinto Hermosell; Pedro Afonso de Aviz; Lee Wayne; 有成 胡; Matthew Berenty; Yimeng Chen; A Brunton; George Katsaros; Mark Kieran; Ryan Collins; Igor Dantsev; Ayoub Khourbach; Damian Osikovsky; Ross Furmidge; David Lozano Sánchez; Nguyen Cuong; Kyle Harrison; Trixtaro - Desarrollo de Software; Jordan Totten; Paul Moore; 현근 곽; Peter Law; Francisco Javier Lucas Martínez; Павел Плеханов; Andrea Zilio; Juan Mozo Osorio; Quentin Julien; Alexandre Calabuig Langa; Keith Mottram; Reinier Goijvaerts; Dilpreet Singh Natt; Patricia Sipes; Renan Dresch Martins; Sungjin Bae; Stephen Selwood; Unije Apps; Rosario Ranieri; Scott McCulloch; Jordan Fye; Wenpu Ng; Alexander Grunert; Jason Tu; Nikita Kotter; Nicole Cox; 一樹 西脇; Angéline Guignard; Bartosz Bielecki; Baptiste Valle; 順一 馬場; Lleïr Valerià Diego Gutierrez; Damian Turnbull; Ian Brenneman; Nicolas Acevedo Suzarte; 亮人 西尾; Anastasija Grigorjeva. 388 Manoj Jeyaram; 尼玛 胡; Jelle Husson; Karsten Westra; Judie Thai; Emmanuel Castro Flores; Eduardo Roa M; Daniel Fairgrieve; Steven Hurst; Michal Pawlowski; Robert Southgate; Jeffrey Scheidelaar; Daniel Turner; Денис Смольников; Shayam Thomas; Raül Pla Ruiz; John Bulseco; Hongbum Kim; Roberto Margotta; Eran Eshkol; Maciej Miarecki; Alexandre Abreu; Kelvin Put; Alexander Mutuc; Valdream; Yifat Shaik; Ramon Ausio Mateu; Jose Ignacio Ferrer Vera; Burak Soylu; Thierry Berger; Alan Sorio; Jamie Niman; Adrian Impedovo; Jaeyoung Choi; Chara Sottou; Alessia Marra; Arno Poppe; Jose Aristizabal; Juan Lucas Arruda Maciel; Shounak Mandal; Daniel Gomez Atienza; Eddy Margueron; José Javier Serrano Solís; Mitchell Theriault; Alexander Isom; YuChen Ou; Venkatesh Muskam; Enmar Ortega; Katie McCarthy; Juan Martinez Lopez; Samuel Moreno Luque; Cody Scott; Elliot Padfield; Wilson Rivera; Ian Butterfield; Juan Casal; Jason Brock; Santiago Viso Cervera; Camilo Angel Grimaldo Arreguin; Samuel Swift-Glasman; John Rantala; Adrian Ślusarek; Phuoc Vu; Faisal; Jamie Hyland; José Ignacio Alonso Kuri; Eric Kalpin; Vasile Sebastian Mihali; Jonatas Santos; Daniel De Oliveira; Damian Smyth; Jouni Sarvanko; Giuseppe Modarelli; Juliusz Wojnicz; Ellitsa Ilieva; Angel German Pavon Cabrera; Guilherme Schüler; Simon Sääf malm; Logan Lewis; Mikel Gonzalez Alabau; Bethany Dixon; Daniel Fischer; Lewis Nicholson; Armando Soto; Lloyd Vincent; Michael Jonathan Magaña Dominguez; 修逸 谷; Julio Alejandro Quiroz Astorga; Michael Donnelly; Brendan Polley; Steafan Collins; Harry Emmanuel; Ryan Trowbridge; Alex Pritchard; Zhouming Tang; Manuel Galindez; Here’s Joe, LLC; Jeremy Bonnaud; Christopher Medina; Daum Park. Chanael Godefroid; Tushar Purang; Donnovan Feuillastre; Christopher Coyle; Aakash Shah; Aboud Malki Malki; Matthew Kinahan; Александр Джанашвили; 崇 億 張; Adeline Kinsama; Chi Hung Wu; Clara Rodríguez Palacios; Cristian Peñas Laplaceta; Neel Rajeshkumar Mevada; Brian Heinrich; Glaswyll Entertainment LLC; Anil Mohan; Zdravko Nikolovski; Arda Hamamcioglu; Nick Ward; Designer Hacks; เจนสิทธิ์ วงศ์วรจรรย์; Tu Ho Le Thanh; Erik Niese-Petersen; Emil Bachvarov; Christian Van Houten; Sweet Cheeks; Michał Szynal; Michael Hayter; Genevieve St-Michel; Fouad AlSabeh; Alejandro Ruiz; Jacob Rouse; Jeremedia; Under Galaxie; Elodie Marine Solange Saito; Shawn Beck; Ming Hau Loh; Marvin Saignat; Albert Marcelus; Kayra Kupcu; Long Nguyễn; Madeleine Kay; Zbigniew Zelga; Morgane Paulmier; Grant Blair; 승범 이; Ciro Continisio; Diego Gutiérrez Rondán; Antonella Vannucci; Jose Argenis Jimenez Gonzalez; Jason Holland Holland; Alejandro Endo; 岩田 和己; Jordi Porras Estupiñá; Giulio Piana; Mishel Delgado; Kaliba Games & Technologies Inc; Ryan Miller; 国云 刘; Aesthezel; Sharatbabu Achary; Gigantic Teknoloji A.S; Andreas Tsimpanogiannis; Diwakar Singh; Steven Burgess; A Kim Arrate; Austin Eathorne; Caleb Greer; Antonio Rafael Ruano Rodriguez; Florian Geslin; Benjamin Thomas Harbakk; Xu Guo; Jonathan Kelly; Ronnie Denney; Aaron Stewart; Steven Cardenas; Александр Беспалов; Thomas Dik; Aristoteles Dominguez Gonzalez; Florent Lagrede; Kathy Huynh; Bryan Link; Alex Murphy; Johannes Peter; Yulia Yudintseva; Simon Gemmel; Christoph Weinreich; Cybernate PTY LTD; 389 Leonardo Marques; Jonathan Ludwig; Shin Tsukada; Eric Farmer; Виктор Григорьев; Kushal Timsina; Jalexa Hernandez Baena; Trung Nguyen Tran; Hibbert IT Solutions Ltd; Konrad Jastrzebski; Alexandre Bianchi. Caio Marchi Gomes do Amaral; Matt Mccormack; Yuichi Matsuoka; Andriy Matviychuk; Lieven Van den Audenaeren; Ediber Reyes; จุฑาวัชร บุญมาก; Renáta Ivony; Dmytro Derybas; Joshua Villarreal; Clement Brard; Sofia Caponnetto; Maksim Ambrazhei; Seongho Lee; Jeff Minnear; Heath Sargent; Diego Sánchez Ramírez; Christopher Page; Jean de Oliveira; Adriano Romano; Gerson Cardenas; Catgames; Reder Joubert; Trevor Ings; Jonathan Jenkins; Hugo Bombail; Ivan Sazanov; Michael Daubert; Jérôme Cremoux; Yefri Avella Molano; Luis Angel Meza Salinas; Mauricio Vargas; Olie Swinton; Ayoub Ourahma; Yaraslau Sidarkevich; Kristófer Knutsen; Garrett McMichael; Ayoub Ourahma; James Smith; Marc Alloza Ayxendri; Miquel Postigo Llabres; Илья Загайнов; Cameron Millar; Hamza Rangoonia; Zach Holt; Holly Wolf Newlands; 信裕 石黒; Dai Zhen; Matthew Insley; Henrique Sousa; Yusue Chen; Antti Hietaniemi; Anton Sasinovich; Mischa Wasmuth; Wang Yong-Gang; Carl Hughes; Liyuan Qi; Jorge Mula Ferrer; Joshua Prosser; Aaron Scott; Pierre Tane; Ramil Limarest Roosileht; Patrick Yeung; Juan Manuel Altamirano Argudo; Hugo Alvarado; Jonas Sulzer; Nicole Folliott; Donghwan Kim; Raees Rahim; Cole Andress; Orlando Si-Kae Fang; Pablo Guinot Gironda; Matthew Tan; Peetsj; Nikita Pohutsa; Erik Minarini; Alexander Eisenhart; Sergi Herrero Collada; Guido Meo; Showy Lee; Fabian Schweizer; Shane Nilsson; Hernan; Hugues Vincey; Abdul Brown; Faris Alshehri; Ji Hyun Ahn; Daniel Rondán; Jeff Registre; Jose Javier Delgado Cuder; Rocio Campo; Arthur Gros Coumantaros Aulicino; Michael Hoen; Michael Trainor; David Hooper; Takefum I Kaido; Александр Кравцов; Александр Басюк; Carlos Ivan Cordoba Quintana; Shadow Storm Limited. 390 “Jettelly te desea éxito en tu carrera profesional”. 391