16/04/2024
En el mundo del desarrollo de videojuegos, la iluminación es uno de los pilares para crear atmósferas inmersivas y realistas. Una vez que tenemos luz en nuestra escena, el siguiente paso lógico es introducir su contraparte: las sombras. Las sombras no solo añaden profundidad y realismo, sino que también proporcionan al jugador información crucial sobre la posición de los objetos y su relación espacial. Una de las técnicas más conocidas y utilizadas para renderizar sombras es el Shadow Mapping.

Este método es un proceso de dos pasadas. En este artículo, nos sumergiremos de lleno en la primera y fundamental pasada: la generación de la imagen del mapa de sombras (o mapa de profundidad). Aunque los ejemplos de código se basarán en la API de Vulkan, los conceptos son universales y pueden ser aplicados fácilmente en otras APIs como OpenGL. ¡Preparemos el lienzo sobre el que pintaremos nuestras sombras!
- ¿En qué consiste el Shadow Mapping? Un Vistazo General
- El Lienzo de las Sombras: Creando la Imagen de Profundidad
- Poniéndonos en el Lugar de la Luz: Matrices de Vista y Proyección
- El Pipeline de Renderizado para el Mapa de Sombras
- ¡A Dibujar! El Command Buffer
- Verificando Nuestro Trabajo: Visualizando el Mapa de Sombras
¿En qué consiste el Shadow Mapping? Un Vistazo General
Antes de entrar en detalles técnicos, es vital entender el algoritmo a un alto nivel. Como mencionamos, se divide en dos fases principales:
- Primera Pasada (Generación del Mapa de Sombras): Renderizamos la escena completa, pero no desde el punto de vista de la cámara del jugador, sino desde la perspectiva de la fuente de luz. Durante este renderizado, no nos interesa el color de los objetos. Lo único que guardamos es la información de profundidad de cada fragmento. El resultado es una textura en escala de grises, conocida como mapa de sombras o mapa de profundidad, donde cada píxel almacena la distancia desde la luz hasta el objeto más cercano en esa dirección.
- Segunda Pasada (Renderizado de la Escena Final): Renderizamos la escena de forma normal, desde la perspectiva de la cámara. Sin embargo, para cada fragmento que dibujamos, realizamos un cálculo adicional. Transformamos su posición al espacio de la luz (como si lo estuviéramos viendo desde la luz) y comparamos su distancia a la luz con el valor que almacenamos en nuestro mapa de sombras. Si la distancia del fragmento actual es mayor que la guardada en el mapa, significa que hay otro objeto más cerca de la luz que lo está bloqueando. Por lo tanto, ese fragmento está en sombra.
Este artículo se centrará exclusivamente en la primera pasada, que es la base sobre la que se construye todo el efecto.
El Lienzo de las Sombras: Creando la Imagen de Profundidad
El mapa de sombras es, en esencia, una textura de profundidad. Necesitamos crear un recurso de imagen en Vulkan que pueda ser utilizado como un adjunto de profundidad en un framebuffer y, posteriormente, como una textura muestreable en un shader.
La configuración de esta imagen es clave. Típicamente, se crea una imagen 2D con un formato de alta precisión para la profundidad, como VK_FORMAT_D32_SFLOAT. La resolución de esta imagen (su ancho y alto) determinará la calidad de las sombras. Una mayor resolución produce sombras más nítidas y detalladas, pero a un mayor costo computacional. Es un equilibrio constante entre calidad y rendimiento.
Al crear la imagen en Vulkan, debemos especificar dos flags de uso cruciales:
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT: Indica que vamos a renderizar directamente sobre esta imagen como si fuera un buffer de profundidad.VK_IMAGE_USAGE_SAMPLED_BIT: Indica que, en la segunda pasada, leeremos de esta imagen en nuestros shaders como si fuera una textura normal.
Una vez creada la imagen, también necesitaremos una VkImageView que la envuelva para poder vincularla a un framebuffer durante el proceso de renderizado.
Poniéndonos en el Lugar de la Luz: Matrices de Vista y Proyección
Para renderizar la escena desde la perspectiva de la luz, necesitamos el equivalente a una cámara posicionada en el lugar de la luz. Esto se logra calculando un par de matrices: la matriz de Vista (View) y la matriz de Proyección (Projection) para la luz.
El tipo de matriz de proyección que usemos dependerá del tipo de luz que estemos simulando. Aquí hay una tabla comparativa simple:
| Tipo de Luz | Tipo de Proyección | Descripción |
|---|---|---|
| Foco (Spotlight) | Perspectiva | Es el caso más simple. La luz emite en un cono, similar a una linterna. Se usa una matriz de perspectiva estándar, como la de una cámara. |
| Puntual (Positional) | Perspectiva (Cúbica) | La luz emite en todas las direcciones. Se necesita renderizar la escena 6 veces en las 6 caras de un cubemap para capturar la profundidad en 360 grados. |
| Direccional (Directional) | Ortográfica | Simula una fuente de luz muy lejana (como el sol), donde los rayos son paralelos. La proyección ortográfica crea sombras uniformes sin distorsión de perspectiva. |
Para este ejemplo, nos centraremos en una luz de tipo foco (spotlight). Crearemos una matriz de proyección en perspectiva (usando glm::perspective, por ejemplo) y una matriz de vista que apunte en la dirección del cono de luz. Es crucial ajustar los planos cercano (near) y lejano (far) de la proyección de la luz para que se ajusten lo más posible a la escena. Esto ayuda a maximizar la precisión del buffer de profundidad y a reducir artefactos como el "shadow acne".
El Pipeline de Renderizado para el Mapa de Sombras
El pipeline gráfico para generar el mapa de sombras es significativamente más simple que el utilizado para renderizar la escena final. Estas son sus particularidades:
- Sin Color: No nos importa el color, por lo que el Render Pass solo necesita un adjunto (attachment): nuestra imagen de profundidad. No hay adjuntos de color.
- Shaders Mínimos: ¡No necesitamos un fragment shader! Como no estamos escribiendo colores, podemos omitirlo por completo, lo que ahorra una cantidad considerable de trabajo a la GPU. El vertex shader, por otro lado, es muy simple. Su única tarea es tomar la posición de un vértice del modelo y transformarla utilizando la matriz combinada de Vista-Proyección de la luz.
- Estado Simplificado: Solo necesitamos enviar el atributo de posición de los vértices al pipeline. No son necesarias las normales, coordenadas de textura, tangentes, etc. Esto reduce el ancho de banda de la memoria del vértice.
Vertex Shader de Ejemplo (GLSL)
#version 450 layout(set = 0, binding = 0) uniform LightSpaceUBO { mat4 lightViewProjection; } ubo; layout(set = 1, binding = 0) uniform ModelUBO { mat4 model; } modelUbo; layout(location = 0) in vec3 inPosition; void main() { // Transformar el vértice al espacio de la luz y proyectarlo gl_Position = ubo.lightViewProjection * modelUbo.model * vec4(inPosition, 1.0); }¡A Dibujar! El Command Buffer
El proceso de grabación del command buffer es similar a un renderizado normal, pero con nuestras configuraciones simplificadas:
- Iniciar el Render Pass: Comenzamos el render pass específico para el mapa de sombras, apuntando al framebuffer que contiene nuestra imagen de profundidad como único adjunto. Es importante establecer un valor de limpieza para la profundidad (normalmente 1.0, que representa el plano lejano).
- Establecer Viewport y Scissor: Configuramos el viewport y el scissor para que coincidan con las dimensiones de nuestra textura de mapa de sombras.
- Vincular el Pipeline: Vinculamos nuestro pipeline gráfico simplificado que no tiene fragment shader.
- Vincular Descriptores y Vértices: Vinculamos los descriptor sets que contienen la matriz de la luz y las matrices de los modelos, así como los buffers de vértices.
- Dibujar la Geometría: Emitimos los comandos de dibujo para todos los objetos de la escena que puedan proyectar sombras.
- Finalizar el Render Pass: Terminamos el render pass.
Una vez que el command buffer está grabado, lo enviamos a la cola de la GPU para su ejecución. Es fundamental usar mecanismos de sincronización (como semáforos) para garantizar que la generación del mapa de sombras se complete antes de que comience la segunda pasada que lo utilizará como textura.
Verificando Nuestro Trabajo: Visualizando el Mapa de Sombras
¿Cómo sabemos si hemos generado el mapa correctamente? Un paso de depuración esencial es visualizarlo. Para ello, podemos renderizar un quad que ocupe toda la pantalla (o una parte de ella) y aplicarle como textura el mapa de sombras que acabamos de crear.
Sin embargo, hay un detalle: los valores en un buffer de profundidad no se distribuyen linealmente. La precisión es mucho mayor cerca del plano cercano que del plano lejano. Si lo visualizamos directamente, es probable que veamos una imagen casi completamente blanca. Para hacerlo visible, podemos aplicar una simple fórmula en el fragment shader de depuración para "linearizar" o expandir el rango de valores y poder apreciar los detalles. Los tonos más oscuros representarán objetos más cercanos a la luz.
La imagen resultante debería ser una representación en escala de grises de la escena desde la perspectiva de la luz, un resultado directo de nuestra primera pasada.
Preguntas Frecuentes (FAQ)
- ¿Necesito un fragment shader para generar el mapa de sombras?
- No, y es una optimización clave. El objetivo es solo registrar la profundidad. La GPU puede realizar este proceso sin necesidad de ejecutar un fragment shader, ahorrando recursos computacionales.
- ¿Por qué mi mapa de sombras se ve casi todo blanco al visualizarlo?
- Esto se debe a la distribución no lineal de la profundidad en una proyección de perspectiva. Los valores están concentrados cerca del plano cercano. Para una visualización de depuración, necesitas aplicar una fórmula en un shader para expandir ese rango y hacerlo perceptible al ojo humano.
- ¿Tengo que regenerar el mapa de sombras en cada fotograma?
- Depende. Si la fuente de luz y todos los objetos que proyectan sombras en la escena son estáticos, puedes generar el mapa una sola vez y reutilizarlo. Sin embargo, si la luz se mueve o los objetos son dinámicos, deberás regenerar el mapa en cada fotograma para que las sombras se actualicen correctamente.
Conclusión
Hemos cubierto en detalle el primer y más importante paso de la técnica de Shadow Mapping: la creación del mapa de profundidad. Hemos aprendido a configurar los recursos necesarios, a pensar desde la perspectiva de la luz calculando sus matrices, a construir un pipeline de renderizado optimizado y a ejecutarlo. Este mapa de profundidad es la información fundamental que nos permitirá, en la segunda pasada, determinar qué partes de nuestra escena están iluminadas y cuáles están ocultas en la sombra, un tema que abordaremos en un futuro artículo.
Si quieres conocer otros artículos parecidos a Shadow Mapping: Creando el Mapa de Profundidad puedes visitar la categoría Juegos.
