Subido por felikkdvak

.

Anuncio
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 [email protected] 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 [email protected]
(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
Descargar