El Feedback Visual
En el capítulo anterior logramos que los enemigos murieran y cayeran. Funciona, pero no hay feedback cuando reciben daño. Para que un juego se sienta profesional, necesitamos Feedback Visual inmediato. Esto es lo que en la industria se llama “Game Juice”.
La herramienta más potente para lograr este efecto visual son los Shaders.
1. ¿Qué es un Shader?
Un Shader es un pequeño programa que no se ejecuta en la CPU, sino en la GPU (la tarjeta gráfica).
- CPU: Ejecuta tu código GDScript. Procesa la lógica del juego (movimiento, colisiones, puntuación).
- GPU: Dibuja los píxeles en pantalla. Es un procesador especializado en operaciones gráficas masivas.
Cuando escribes un Shader, estás dando instrucciones directas a la GPU sobre cómo pintar cada píxel.
2. Arquitectura: Núcleos y Paralelismo
¿Por qué usar la GPU en lugar de la CPU para efectos visuales?
CPU: Pocos Núcleos, Muy Potentes
Una CPU moderna como el Intel Core i7-14700K tiene:
- 20 núcleos físicos (8 de alto rendimiento + 12 de eficiencia).
- 28 hilos (threads) gracias a Hyper-Threading.
Cada núcleo puede ejecutar instrucciones complejas y diferentes entre sí. Pero incluso con 28 hilos, si necesitas procesar 10.000 píxeles, la CPU tendría que repartirlos entre esos hilos y ejecutarlos en lotes secuenciales.
GPU: Miles de Núcleos, Muy Simples
Una GPU moderna como la NVIDIA RTX 4080 tiene:
- 9.728 núcleos CUDA.
- Cada núcleo es mucho más simple que uno de CPU.
- Pero pueden ejecutar la misma instrucción en miles de datos diferentes a la vez.
Esta arquitectura se llama SIMD (Single Instruction, Multiple Data): una sola instrucción aplicada a muchos datos en paralelo.
El Ejemplo Concreto
Si tu sprite del enemigo mide 100x100 píxeles, son 10.000 píxeles en total.
| Procesador | Hilos Paralelos | Operaciones por Lote |
|---|---|---|
| CPU (i7-14700K) | ~28 | 10.000 ÷ 28 = ~357 lotes |
| GPU (RTX 4080) | ~9.728+ | 10.000 ÷ 9.728 = ~1 lote |
La GPU puede procesar todos los píxeles prácticamente de una sola pasada. Por eso los Shaders son tan rápidos para efectos visuales.
3. GLSL: El Lenguaje de la GPU
Los Shaders en Godot se escriben en un lenguaje llamado GLSL (OpenGL Shading Language), similar a C. No te asustes. Vamos a explicar cada concepto antes de usarlo.
shader_type
Todo shader de Godot empieza con una declaración de tipo:
shader_type canvas_item;shader_type canvas_item;¿Qué significa?
shader_type canvas_item: Este shader es para elementos 2D (sprites, nodos de canvas).- Existen otros tipos como
spatial(3D) oparticles, pero en este curso usaremoscanvas_item.
fragment()
Es la función principal del shader. Se ejecuta una vez por cada píxel del sprite.
void fragment() {
// Este código se ejecuta para CADA píxel
// Tu trabajo aquí: calcular el color final del píxel
}void fragment() {
// Este código se ejecuta para CADA píxel
// Tu trabajo aquí: calcular el color final del píxel
}Importante: Si tu sprite tiene 10.000 píxeles, esta función se ejecuta 10.000 veces en paralelo.
COLOR
Es la variable de salida obligatoria. Es donde guardas el color final que tendrá el píxel.
void fragment() {
COLOR = vec4(1.0, 0.0, 0.0, 1.0); // Pinta TODO el sprite de rojo sólido
}void fragment() {
COLOR = vec4(1.0, 0.0, 0.0, 1.0); // Pinta TODO el sprite de rojo sólido
}vec4
Un color en GLSL se representa con 4 números del 0.0 al 1.0:
| Componente | Significado | Rango |
|---|---|---|
| R (Red) | Cantidad de rojo | 0.0 - 1.0 |
| G (Green) | Cantidad de verde | 0.0 - 1.0 |
| B (Blue) | Cantidad de azul | 0.0 - 1.0 |
| A (Alpha) | Transparencia | 0.0 (invisible) - 1.0 (sólido) |
Ejemplos:
vec4(1.0, 1.0, 1.0, 1.0)= Blanco opacovec4(0.0, 0.0, 0.0, 1.0)= Negro opacovec4(1.0, 0.0, 0.0, 0.5)= Rojo semitransparente
TEXTURE
Es una variable especial que referencia la imagen del sprite (la textura que cargaste en Sprite2D).
No puedes usarla directamente como color. Necesitas “leerla” con la función texture().
UV
Son las coordenadas normalizadas de cada píxel dentro del sprite.
- Van de
(0.0, 0.0)(esquina superior izquierda) a(1.0, 1.0)(esquina inferior derecha). - Cada hilo de ejecución (cada píxel) tiene su propio valor de
UV. - El hilo del píxel central tendría
UV = (0.5, 0.5).
Nota técnica: UV es a los shaders lo que self.position es a los scripts. Cada hilo sabe su propia coordenada.
texture()
Es una función que lee el color de un píxel de una imagen dada una coordenada.
vec4 color = texture(TEXTURE, UV);vec4 color = texture(TEXTURE, UV);Desglose:
TEXTURE: La imagen del sprite.UV: La coordenada de este píxel concreto.- Resultado: Devuelve el
vec4(color RGBA) del píxel en esa posición de la imagen.
Con esto, cada uno de los 10.000 hilos lee su propio color de la imagen original, reconstruyendo el sprite completo.
4. La Misión: Hit Flash
Queremos un efecto donde, al recibir daño, el enemigo parpadee en blanco puro durante una fracción de segundo.
Preparando el Material
- Selecciona el
Sprite2Ddel enemigo. - En el Inspector, busca
CanvasItem > Material. - Haz clic en el desplegable y selecciona
New ShaderMaterial. - Haz clic en el nuevo material y en
Shader, seleccionaNew Shader. - Crea la carpeta
shadersen tu proyecto y guarda el archivo comoshaders/hit_flash.gdshader.
Fase 1: Estructura Vacía
Empezamos con la estructura mínima de un shader 2D:
shader_type canvas_item;
void fragment() {
// Por ahora, no hacemos nada
// El sprite se verá normal
}shader_type canvas_item;
void fragment() {
// Por ahora, no hacemos nada
// El sprite se verá normal
}Si pruebas ahora, el sprite se verá invisible porque no hemos asignado nada a COLOR.
Fase 2: Recuperar el Color Original
Para que el sprite se vea normal, debemos leer su textura y asignarla a COLOR:
shader_type canvas_item;
void fragment() {
// Leemos el color original del sprite en esta coordenada (UV)
vec4 color_original = texture(TEXTURE, UV);
// Asignamos ese color como salida
COLOR = color_original;
}shader_type canvas_item;
void fragment() {
// Leemos el color original del sprite en esta coordenada (UV)
vec4 color_original = texture(TEXTURE, UV);
// Asignamos ese color como salida
COLOR = color_original;
}Ahora el sprite se ve exactamente igual que antes del shader. Hemos “reconstruido” la imagen original.
Fase 3: Añadir la Variable de Control (Uniform)
Necesitamos una forma de activar/desactivar el flash desde el código GDScript.
Para eso usamos una variable uniform:
shader_type canvas_item;
// Variable GLOBAL: la misma para todos los píxeles
// La controlaremos desde GDScript
uniform bool flash_active = false;
void fragment() {
vec4 color_original = texture(TEXTURE, UV);
COLOR = color_original;
}shader_type canvas_item;
// Variable GLOBAL: la misma para todos los píxeles
// La controlaremos desde GDScript
uniform bool flash_active = false;
void fragment() {
vec4 color_original = texture(TEXTURE, UV);
COLOR = color_original;
}¿Por qué uniform?
- Es una variable cuyo valor viene de fuera del shader (desde GDScript/CPU).
- Todos los 10.000 hilos reciben el mismo valor. Por eso se llama “uniforme”.
- A diferencia de
UV(que es diferente para cada hilo),flash_activeserátrueofalsepara TODOS a la vez.
Fase 4: La Lógica del Flash
Ahora añadimos la lógica condicional. Si flash_active es true, pintamos blanco. Si no, pintamos normal:
shader_type canvas_item;
uniform bool flash_active = false;
void fragment() {
vec4 color_original = texture(TEXTURE, UV);
if (flash_active) {
// Si está activo, pintamos BLANCO
// Usamos la transparencia original (color_original.a) para no pintar píxeles invisibles
COLOR = vec4(1.0, 1.0, 1.0, color_original.a);
} else {
// Si no, pintamos el color normal
COLOR = color_original;
}
}shader_type canvas_item;
uniform bool flash_active = false;
void fragment() {
vec4 color_original = texture(TEXTURE, UV);
if (flash_active) {
// Si está activo, pintamos BLANCO
// Usamos la transparencia original (color_original.a) para no pintar píxeles invisibles
COLOR = vec4(1.0, 1.0, 1.0, color_original.a);
} else {
// Si no, pintamos el color normal
COLOR = color_original;
}
}¿Por qué color_original.a?
Si usáramos vec4(1.0, 1.0, 1.0, 1.0), pintaríamos un cuadrado blanco sólido (incluyendo las áreas transparentes del sprite).
Al preservar la transparencia original (.a), solo se vuelven blancos los píxeles que ya eran visibles.
5. Conectando CPU y GPU
El shader está listo, pero no hace nada porque flash_active siempre es false.
Ahora volvemos a GDScript (enemy_plane.gd) para activarlo cuando recibamos daño.
Fase 1: Acceder al Material
Primero necesitamos crear la función hit_flash(), creamos una variable con la referencia de Sprite2D y otra con la referencia del material.
func hit_flash():
# 1. Obtenemos el nodo Sprite2D
var sprite = $Sprite2D
# 2. Accedemos a su material y lo tratamos como ShaderMaterial
var shader_material = sprite.materialfunc hit_flash():
# 1. Obtenemos el nodo Sprite2D
var sprite = $Sprite2D
# 2. Accedemos a su material y lo tratamos como ShaderMaterial
var shader_material = sprite.materialsprite.material devuelve un tipo genérico Material. Para acceder a set_shader_parameter(), necesitamos que GDScript sepa que es un ShaderMaterial.
La solución es usar if shader_material is ShaderMaterial. Esto mata dos pájaros de un tiro, luego lo explicaré:
Fase 2: Enviar el Dato a la GPU
Usamos set_shader_parameter() para cambiar el valor del uniform:
func hit_flash():
var sprite = $Sprite2D
var shader_material = sprite.material
if shader_material is ShaderMaterial:
# Activamos el flash (enviamos 'true' a la GPU)
shader_material.set_shader_parameter("flash_active", true)func hit_flash():
var sprite = $Sprite2D
var shader_material = sprite.material
if shader_material is ShaderMaterial:
# Activamos el flash (enviamos 'true' a la GPU)
shader_material.set_shader_parameter("flash_active", true)¿Cómo funciona if shader_material is ShaderMaterial?
Esto se llama type narrowing (estrechamiento de tipo):
- Verifica en tiempo de ejecución si
shader_materiales realmente unShaderMaterial. - Dentro del bloque
if, GDScript sabe queshader_materiales unShaderMaterial, habilitando el autocompletado deset_shader_parameter().
Se puede asignar el tipo de otro modo usando as ShaderMaterial, pero si el tipo no coincide, devolverá null y podrías tener errores al intentar usar el resultado. Con is, evitamos ese riesgo.
¿Qué hace set_shader_parameter?
"flash_active"es el nombre exacto de la variableuniformen el shader.truees el valor que queremos asignar.- Al llamar esto, los 10.000 hilos de la GPU reciben
truey pintan blanco.
Fase 3: Apagar el Flash
Si dejamos el flash activo para siempre, el enemigo se queda blanco. Necesitamos apagarlo después de un instante:
func hit_flash():
var sprite = $Sprite2D
var shader_material = sprite.material
# Verificamos que el material sea un ShaderMaterial
if shader_material is ShaderMaterial:
# Activar flash
shader_material.set_shader_parameter("flash_active", true)
# Esperar 0.1 segundos (100 milisegundos)
await get_tree().create_timer(0.1).timeout
# Desactivar flash
shader_material.set_shader_parameter("flash_active", false)func hit_flash():
var sprite = $Sprite2D
var shader_material = sprite.material
# Verificamos que el material sea un ShaderMaterial
if shader_material is ShaderMaterial:
# Activar flash
shader_material.set_shader_parameter("flash_active", true)
# Esperar 0.1 segundos (100 milisegundos)
await get_tree().create_timer(0.1).timeout
# Desactivar flash
shader_material.set_shader_parameter("flash_active", false)¿Qué hace await?
await es una palabra clave que pausa la ejecución de esta función específica hasta que ocurra algo (en este caso, que pase el tiempo del timer).
Importante: await NO congela el juego. El resto del motor sigue funcionando:
- El enemigo sigue moviéndose.
- Otras balas siguen volando.
- El jugador puede disparar.
Solo ESTA función (hit_flash) está “dormida” esperando. Cuando el timer termina, la función se “despierta” y continúa desde donde se quedó (desactivar el flash).
Es como poner un recordatorio en tu móvil: “En 0.1 segundos, apaga el flash”. Mientras tanto, sigues con tu vida.
Fase 4: Llamar al Flash al Recibir Daño
Finalmente, integramos la función en _on_area_entered:
func _on_area_entered(area):
if is_dying:
return
hp -= 1
area.queue_free()
hit_flash() # Nuevo: activar el feedback visual
if hp <= 0:
die()func _on_area_entered(area):
if is_dying:
return
hp -= 1
area.queue_free()
hit_flash() # Nuevo: activar el feedback visual
if hp <= 0:
die()6. Script Completo del Shader
shader_type canvas_item;
uniform bool flash_active = false;
void fragment() {
vec4 color_original = texture(TEXTURE, UV);
if (flash_active) {
COLOR = vec4(1.0, 1.0, 1.0, color_original.a);
} else {
COLOR = color_original;
}
}shader_type canvas_item;
uniform bool flash_active = false;
void fragment() {
vec4 color_original = texture(TEXTURE, UV);
if (flash_active) {
COLOR = vec4(1.0, 1.0, 1.0, color_original.a);
} else {
COLOR = color_original;
}
}Repasemos lo aprendido
- Shader: Programa que se ejecuta en la GPU para modificar píxeles.
- CPU vs GPU: Serial (una tarea a la vez) vs Paralelo (miles simultáneas).
shader_type canvas_item: Declaración para shaders 2D.fragment(): Función que se ejecuta una vez por cada píxel.COLOR: Variable de salida que define el color final del píxel.vec4: Estructura de 4 floats para colores (RGBA).TEXTURE: Referencia a la imagen del sprite.UV: Coordenadas normalizadas (0-1) de cada píxel.texture(): Función para leer un color de una imagen.uniform: Variable global controlada desde la CPU.set_shader_parameter(): Función de GDScript para enviar datos a la GPU.
En el próximo capítulo, aprenderemos a dar vida a nuestros enemigos usando Trigonometría para crear patrones de movimiento ondulantes y circulares.