Parte: 21 — Arquitectura avanzada de motores y rendering · Fuente: Akenine-Möller et al., «Real-Time Rendering» (color y tone mapping) · Estándares ITU-R BT.709 y BT.2100 ⏱️ Duración estimada: 115 min · Nivel: Avanzado
Entender por qué los colores de tu juego se ven distintos en cada pantalla, y qué hacer al respecto. Es un tema que la mayoría de desarrolladores evita hasta que un jugador reporta que "todo está lavado" en su monitor nuevo, y entonces descubre que el problema lleva ahí desde el principio.
Vas a estudiar la cadena completa: flujo lineal (por qué no se puede sumar luz en sRGB), espacios de color (qué colores puede representar una pantalla), HDR (qué cambia cuando el brillo llega a 1.000 nits en vez de 100), tone mapping (cómo se comprime un rango enorme en lo que la pantalla puede mostrar) y exposición (cómo se decide qué es "blanco"). Y verás por qué hacerlo bien no es una cuestión estética sino de corrección: con un flujo mal montado, la iluminación de tu juego está literalmente mal calculada.
Al finalizar, el alumno podrá:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | Luz vs percepción | La raíz de todo el asunto. |
| 2 | Gamma y sRGB | Por qué las texturas no están en lineal. |
| 3 | Flujo lineal | Sin él, la iluminación está mal calculada. |
| 4 | Espacios de color | Qué colores puede mostrar una pantalla. |
| 5 | Rango dinámico | De 100 nits a 1.000: qué cambia. |
| 6 | Brillo absoluto | En HDR, un valor significa nits reales. |
| 7 | Tone mapping | Comprimir el rango de la escena al de la pantalla. |
| 8 | Exposición | Decidir qué es blanco en cada momento. |
| 9 | Curva EOTF (PQ, HLG) | Cómo se codifica el HDR. |
| 10 | Calibración del jugador | Cada pantalla es distinta; hay que preguntarle. |
Godot 4.x con WorldEnvironment (tonemap, exposición, glow), texturas con y sin sRGB en sus ajustes de importación, y un monitor HDR si lo tienes (no es imprescindible: casi todo el contenido se verifica en SDR). Trabajaremos en res://color/. La documentación de entorno y post-procesado de Godot cubre los ajustes; aquí se explica qué hacen y por qué.
Dos luces, cada una de intensidad 0,5. ¿Cuánta luz hay donde se suman?
EN LINEAL (correcto):
0,5 + 0,5 = 1,0 → el doble de luz. Físicamente correcto.
EN sRGB (incorrecto):
El valor 0,5 en sRGB es ~0,214 de luz real.
0,5 + 0,5 = 1,0 en sRGB = 1,0 de luz real
Pero debería ser 0,214 + 0,214 = 0,428 de luz → valor sRGB ~0,70
Error: se muestra 1,0 (blanco puro) donde debería haber 0,70.
Resultado: las zonas de luces solapadas se queman, las penumbras se ven mal
y el 50 % de gris no está donde debería. Y todo el balance de iluminación de
la escena está calculado sobre valores incorrectos.
extends SceneTree # color/demo_lineal.gd
const GAMMA := 2.2
static func srgb_a_lineal(c: float) -> float:
# La curva sRGB real tiene un tramo lineal cerca del negro; la
# aproximación por gamma 2.2 basta para entender el efecto.
return pow(c, GAMMA) if c > 0.0 else 0.0
static func lineal_a_srgb(c: float) -> float:
return pow(c, 1.0 / GAMMA) if c > 0.0 else 0.0
func _init() -> void:
print("== Sumar dos luces de 0,5 ==")
var correcto := lineal_a_srgb(srgb_a_lineal(0.5) + srgb_a_lineal(0.5))
print(" en lineal (correcto): %.3f en sRGB" % correcto)
print(" en sRGB (incorrecto): %.3f en sRGB" % minf(1.0, 0.5 + 0.5))
print(" error: %.1f%%" % (abs(1.0 - correcto) / correcto * 100.0))
quit()
┌─ TEXTURAS ────────────────────────────────────────────────────────┐
│ Albedo, emisión → guardadas en sRGB → SE CONVIERTEN a lineal │
│ Normal, rough, → guardadas en lineal → NO se convierten │
│ metallic, AO, height, máscaras │
└───────────────────────────────┬───────────────────────────────────┘
▼
┌─ ILUMINACIÓN Y SHADING ───────────────────────────────────────────┐
│ TODO en lineal, en punto flotante (16 bits por canal) │
│ Los valores pueden superar 1,0: el sol es ~100, una bombilla ~5 │
└───────────────────────────────┬───────────────────────────────────┘
▼
┌─ POST-PROCESADO ──────────────────────────────────────────────────┐
│ Bloom, desenfoque, GI: en LINEAL, porque suman luz │
└───────────────────────────────┬───────────────────────────────────┘
▼
┌─ EXPOSICIÓN ──────────────────────────────────────────────────────┐
│ Escalar la escena: decidir qué valor será "blanco" │
└───────────────────────────────┬───────────────────────────────────┘
▼
┌─ TONE MAPPING ────────────────────────────────────────────────────┐
│ Comprimir [0, ∞) al rango de la pantalla. Decisión ARTÍSTICA. │
└───────────────────────────────┬───────────────────────────────────┘
▼
┌─ CODIFICACIÓN DE SALIDA ──────────────────────────────────────────┐
│ SDR: convertir a sRGB. HDR: aplicar PQ o HLG con su gamut. │
└───────────────────────────────┬───────────────────────────────────┘
▼
┌─ UI ──────────────────────────────────────────────────────────────┐
│ Se compone AL FINAL, ya en el espacio de salida │
└───────────────────────────────────────────────────────────────────┘
Los dos puntos donde casi todo el mundo se equivoca: marcar como sRGB una textura de datos (una normal map convertida es una normal map rota) y aplicar tone mapping a la UI (que hace el blanco del HUD gris y los colores lavados).
| Error | Síntoma visible | Arreglo |
|---|---|---|
| Texturas de color sin marcar sRGB | Todo se ve oscuro y con contraste excesivo | Marcar sRGB en la importación |
| Normal map marcada como sRGB | Iluminación de superficie sutilmente mal, difícil de ver | Desmarcar sRGB en mapas de datos |
| Doble corrección gamma | Todo lavado, blancuzco | Corregir una sola vez, al final |
| Sin corrección | Todo oscuro y muy contrastado | Aplicarla al final |
| Bloom en sRGB | Halos débiles y de color raro | Post-procesado en lineal |
| Búfer de 8 bits en lineal | Banding en las sombras | Punto flotante de 16 bits |
| UI dentro del tone mapping | HUD lavado, blancos grises | Componer la UI después |
| Colores elegidos a ojo en el editor | Se ven distintos al ejecutar | Elegirlos con el flujo ya montado |
SDR (Rec.709 + sRGB):
Blanco de referencia: ~100 nits
Rango: ~100:1 útil
Bits por canal: 8
Gamut: Rec.709
HDR10 (Rec.2020 + PQ):
Blanco de referencia: ~203 nits (papel blanco)
Máximo: 1.000-10.000 nits
Rango: ~10.000:1
Bits por canal: 10
Gamut: Rec.2020 (mucho mayor)
Lo que esto significa en el juego:
- El sol puede ser DE VERDAD deslumbrante, no un parche blanco.
- Un reflejo especular brilla sin quemar toda la imagen.
- Las sombras conservan detalle con luces brillantes en la misma escena.
- Los colores saturados (neones, fuego) no se recortan.
Y el detalle que lo complica: en HDR con PQ, un valor significa nits absolutos. Un blanco de UI a 1.000 nits en una habitación a oscuras es literalmente cegador. Por eso la UI en HDR se limita al blanco de referencia y no al máximo.
// Reinhard: el más simple. Comprime todo pero desatura las luces, y el
// resultado tiende a verse lavado.
vec3 reinhard(vec3 c) {
return c / (1.0 + c);
}
// Reinhard extendido: respeta el blanco hasta un valor dado.
vec3 reinhard_ext(vec3 c, float blanco) {
return (c * (1.0 + c / (blanco * blanco))) / (1.0 + c);
}
// ACES (aproximación de Narkowicz): contraste cinematográfico, luces que
// tienden a naranja al saturarse. Es el estándar de facto en juegos.
vec3 aces(vec3 c) {
const float a = 2.51, b = 0.03, cc = 2.43, d = 0.59, e = 0.14;
return clamp((c * (a * c + b)) / (c * (cc * c + d) + e), 0.0, 1.0);
}
// Filmic (Uncharted 2, Hable): control fino de sombras, medios y luces.
vec3 filmic(vec3 x) {
const float A = 0.15, B = 0.50, C = 0.10, D = 0.20, E = 0.02, F = 0.30;
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
}
| Operador | Carácter | Luces | Cuándo |
|---|---|---|---|
| Ninguno (clamp) | Duro | Se queman de golpe | Nunca en 3D |
| Reinhard | Suave, lavado | Desaturadas | Rápido y poco más |
| Reinhard extendido | Controlable | Mejor que el simple | Cuando quieras un blanco concreto |
| Filmic (Hable) | Contrastado | Buen rolloff | Estética cinematográfica |
| ACES | Cinematográfico | Tienden a cálido | El más usado en juegos |
| AgX | Neutro, moderno | Conserva el tono al saturar | Cuando el color debe mantenerse fiel |
La elección es artística, no técnica: ACES da un look de cine que puede no encajar con un juego de colores planos, y AgX conserva mejor los tonos saturados. Godot 4 ofrece Linear, Reinhard, Filmic, ACES y AgX en WorldEnvironment.
class_name ExposicionAutomatica
extends RefCounted
# Imita el ojo: al pasar de una cueva a la luz, deslumbra un momento y luego
# se adapta. Sin adaptación temporal, el cambio es un salto brusco y molesto.
var velocidad_oscurecer := 2.5 # adaptarse a MÁS luz es rápido
var velocidad_aclarar := 0.8 # a MENOS luz, lento (como el ojo real)
var exposicion_min := 0.05
var exposicion_max := 8.0
var clave := 0.18 # gris medio: el objetivo de exposición
var _actual := 1.0
func actualizar(luminancia_media: float, delta: float) -> float:
# La media se calcula en LOG: la percepción del brillo es logarítmica, y
# una media aritmética la dominan cuatro píxeles muy brillantes.
var objetivo := clampf(clave / maxf(luminancia_media, 0.0001),
exposicion_min, exposicion_max)
var vel := velocidad_oscurecer if objetivo < _actual else velocidad_aclarar
# Interpolación exponencial: independiente del framerate.
_actual = lerpf(_actual, objetivo, 1.0 - exp(-vel * delta))
return _actual
static func luminancia(c: Color) -> float:
# Coeficientes Rec.709: el verde aporta la mayor parte del brillo percibido.
return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
// El histograma de luminancia, que es como se hace en producción: más
// robusto que la media porque permite ignorar el 20 % más oscuro y el 5 %
// más brillante, que es donde están el cielo y las fuentes de luz directas.
shared uint histograma[256];
void main() {
float lum = dot(texture(escena, uv).rgb, vec3(0.2126, 0.7152, 0.0722));
// El bin en escala LOG: reparte los bins de forma perceptualmente útil.
uint bin = uint(clamp((log2(lum) + 10.0) / 16.0, 0.0, 1.0) * 255.0);
atomicAdd(histograma[bin], 1u);
}
┌─ Calibración de imagen ───────────────────────────────────────────┐
│ │
│ Ajusta el BRILLO hasta que apenas distingas el logo de la │
│ izquierda, y el de la derecha no se vea en absoluto. │
│ │
│ ▓▓▓▓ ░░░░ │
│ (apenas visible) (invisible) │
│ │
│ Brillo: [ ────────●──────── ] │
│ │
│ ── Solo en pantallas HDR ────────────────────────────────────── │
│ Brillo máximo de tu pantalla: [ 1000 ] nits │
│ Sube hasta que el cuadro deje de ganar brillo. │
│ Blanco de la interfaz: [ 203 ] nits │
│ Que el HUD sea cómodo de leer, no deslumbrante. │
└────────────────────────────────────────────────────────────────────┘
Los tres ajustes que hay que ofrecer, y por qué:
| Ajuste | Qué resuelve | Por qué no se puede automatizar |
|---|---|---|
| Brillo / gamma | Ver en sombras según la luz de la habitación | Depende del entorno físico del jugador |
| Brillo máximo (HDR) | Que las luces no se recorten ni queden apagadas | Cada panel HDR tiene un máximo distinto |
| Blanco de UI (HDR) | Que el HUD no deslumbre en escenas oscuras | Preferencia personal y del panel |
extends SceneTree
func _init() -> void:
var hechas := 0; var fallos := 0
var check := func(ok: bool, que: String):
hechas += 1
if not ok: fallos += 1; printerr(" FALLA ", que)
# 1) Ida y vuelta sRGB ↔ lineal.
for v in [0.0, 0.05, 0.2, 0.5, 0.8, 1.0]:
var ida := srgb_a_lineal(v)
check.call(abs(lineal_a_srgb(ida) - v) < 0.001,
"sRGB↔lineal es reversible en %.2f" % v)
# 2) Sumar en lineal da un resultado distinto (y correcto).
var suma_lineal := lineal_a_srgb(srgb_a_lineal(0.5) * 2.0)
check.call(abs(suma_lineal - 1.0) > 0.05,
"sumar en lineal difiere de sumar en sRGB")
# 3) Los operadores de tone mapping son monótonos: más luz, más brillo.
for op in ["reinhard", "aces", "filmic"]:
var monotono := true
var previo := -1.0
for i in 100:
var v := _tonemap(op, float(i) * 0.2)
if v < previo: monotono = false
previo = v
check.call(monotono, "%s es monótono creciente" % op)
# 4) Y acotados en [0, 1].
for op in ["reinhard", "aces", "filmic"]:
var acotado := _tonemap(op, 1000.0) <= 1.001 and _tonemap(op, 0.0) >= -0.001
check.call(acotado, "%s acota la salida en [0,1]" % op)
# 5) La luminancia usa los coeficientes correctos.
check.call(abs(ExposicionAutomatica.luminancia(Color(0, 1, 0)) - 0.7152) < 0.001,
"el verde aporta el 71,52 %% de la luminancia")
# 6) La adaptación converge y no oscila.
var e := ExposicionAutomatica.new()
var v := 1.0
for i in 300:
v = e.actualizar(0.5, 0.016)
check.call(abs(v - e.objetivo_para(0.5)) < 0.01, "la exposición converge")
# 7) Adaptarse a más luz es más rápido que a menos.
var e2 := ExposicionAutomatica.new()
var frames_oscurecer := _frames_hasta_converger(e2, 0.05, 2.0)
var e3 := ExposicionAutomatica.new()
var frames_aclarar := _frames_hasta_converger(e3, 2.0, 0.05)
check.call(frames_oscurecer < frames_aclarar,
"adaptarse a más luz es más rápido, como el ojo")
print("== %d comprobaciones, %d fallos ==" % [hechas, fallos])
quit(1 if fallos > 0 else 0)
Implementa una demostración de gestión de color con: conversiones sRGB↔lineal verificables, comparativa de al menos cuatro operadores de tone mapping, exposición automática con histograma y adaptación asimétrica, UI compuesta fuera del tone mapping, y pantalla de calibración con brillo y (si hay HDR) brillo máximo y blanco de UI.
Criterio de aceptación: una prueba headless con al menos 15 aserciones demuestra que: (a) las conversiones sRGB↔lineal son reversibles con error menor de 0,001 en al menos seis valores; (b) sumar dos valores de 0,5 en lineal da un resultado distinto del que da sumarlos en sRGB, con el error calculado; (c) los cuatro operadores de tone mapping son monótonos crecientes y acotan la salida en [0,1] para entradas de 0 a 1.000; (d) la luminancia usa los coeficientes Rec.709 exactos; (e) la exposición automática converge y no oscila tras 300 frames; (f) la adaptación a más luz tarda menos frames que a menos luz; (g) la UI no pasa por el tone mapping, comprobable porque un blanco puro de UI sale como blanco puro; (h) todas las texturas de datos del proyecto (normal, roughness, metallic) están importadas sin sRGB, comprobable con un script que revise los .import.
| Síntoma / mensaje | Causa y cómo arreglar |
|---|---|
| Todo se ve lavado y sin contraste | Doble corrección gamma. Corrige una sola vez, al final. |
| Todo se ve oscuro y muy contrastado | Falta la corrección, o las texturas de color no están marcadas sRGB. |
| La iluminación de superficies se ve rara sin saber por qué | Una normal map marcada como sRGB. Desmárcala. |
| El HUD se ve gris en vez de blanco | La UI pasa por el tone mapping. Componla después. |
| Banding en los degradados del cielo | Búfer de 8 bits. Usa punto flotante de 16 y añade dithering. |
| Los colores del editor no coinciden con el juego | Se eligieron sin el flujo montado. Configura primero, colorea después. |
| En HDR el HUD deslumbra | La UI se manda al brillo máximo. Limítala al blanco de referencia. |
| El bloom se ve débil y de color raro | Post-procesado en sRGB. Todo el post que suma luz va en lineal. |
| La exposición automática marea | Adaptación demasiado rápida o simétrica. Asimétrica y más lenta al aclarar. |
❓ ¿Godot ya hace esto por mí? En buena parte sí: el renderizador trabaja en lineal, aplica tone mapping y convierte a sRGB al final. Lo que tú tienes que hacer bien es marcar correctamente las texturas (color sí, datos no), elegir el tone mapping con criterio, decidir la exposición y sacar la UI del pipeline de tone mapping. Ahí es donde se rompe la mayoría de los proyectos.
❓ ¿Merece la pena soportar HDR? Si tu juego tiene contrastes fuertes (interiores oscuros con exteriores luminosos, fuego, neones), la diferencia es notable para quien tenga la pantalla. El coste principal no es técnico sino de validación: hay que probar en pantallas HDR reales, que se comportan de forma muy distinta entre sí. Un plan razonable es hacer bien el flujo lineal —que beneficia a todo el mundo— y añadir HDR después.
❓ ¿Qué tone mapping elijo? ACES si quieres un look cinematográfico estándar y no quieres pensarlo más. AgX si tus colores saturados importan (neones, magia, arte estilizado) y no quieres que viren al saturarse. Filmic si necesitas control fino. Y pruébalo con tu arte: lo que funciona en una escena realista puede arruinar una estilizada.
❓ ¿Por qué la exposición automática es tan difícil de ajustar? Porque compite con la intención artística: si un pasillo está diseñado para sentirse oscuro y la exposición lo aclara, has perdido la escena. Soluciones habituales: acotar el rango de exposición por zona, ignorar el cielo en el histograma y dar control al diseñador para fijarla en momentos concretos.
❓ ¿Cómo pruebo el color sin ojo entrenado? Con números, como en el paso 8: reversibilidad de las conversiones, monotonía de los operadores, coeficientes correctos. Y con imágenes de referencia: renderiza una carta de color conocida y comprueba que los valores salen donde deben. Lo que no se puede automatizar es si se ve bien, y para eso están las revisiones con el equipo de arte.
Clase 346 - Upscaling y resolución dinámica