Elegir formato y tamaño de un render target
Qué formatos se pueden renderizar y cuáles además se pueden filtrar o mezclar, cómo se muestrea una textura de profundidad, y la aritmética de memoria que decide a qué resolución puedes permitirte trabajar.
Un render target tiene dos parámetros y los dos se eligen mal por defecto. El formato se copia del ejemplo más cercano y acaba siendo de ocho bits en un pipeline que produce valores mayores que uno, con el consiguiente recorte silencioso. El tamaño se hereda del canvas sin pensar, y en una pantalla de alta densidad eso multiplica por cuatro el trabajo de fragmento antes de haber escrito una línea de shader. Ambas decisiones son fáciles de tomar bien si tienes delante la tabla de qué puede hacer cada formato y la cuenta de cuánta memoria cuesta cada opción.
- Distinguir formato renderizable, filtrable y mezclable, y qué features amplían cada categoría.
- Elegir el formato de un target intermedio según el rango de valores que va a contener.
- Muestrear una textura de profundidad con el tipo de binding y de sampler correctos.
- Calcular la memoria de una cadena de targets y decidir la resolución de render.
Renderizable no es lo mismo que filtrable
Tres capacidades distintas, que mucha gente trata como una sola:
Renderizable es poder ser el destino de un attachment de color. Lo son los formatos de 8 bits normalizados sin signo y sus variantes sRGB, los de enteros, los de 16 y 32 bits en coma flotante, y rgb10a2unorm. No lo son los formatos comprimidos ni los normalizados con signo como rgba8snorm.
Filtrable es poder muestrearse con un sampler que interpole. Los formatos de coma flotante de 32 bits por canal —r32float, rg32float, rgba32float— no son filtrables en el núcleo de WebGPU: se leen con textureLoad o con un sampler declarado como 'non-filtering', y para filtrarlos hace falta la feature float32-filterable. Es la sorpresa más frecuente al montar un búfer de acumulación.
Mezclable es poder llevar un bloque blend. Los formatos de enteros no lo son nunca, y los de 32 bits en coma flotante necesitan la feature float32-blendable.
Con eso, la elección de formato de un target intermedio se reduce a cuatro casos:
| Contenido | Formato | Por qué |
|---|---|---|
| Color final ya mapeado al rango cero-uno | rgba8unorm-srgb o el preferido del canvas |
4 bytes, la curva sRGB reparte bien los bits |
| Color con rango dinámico alto | rgba16float |
8 bytes, filtrable y mezclable, admite valores mayores que uno y negativos |
| Color HDR con presupuesto ajustado | rgb10a2unorm |
4 bytes, diez bits por canal matan el banding, pero no admite valores fuera del rango |
| Datos, no color | rgba16float, rg16float o formatos de enteros |
según precisión; los enteros para identificadores y máscaras |
El error clásico es guardar iluminación lineal en rgba8unorm sin sufijo sRGB. Ocho bits repartidos linealmente dejan escalones perfectamente visibles en las zonas oscuras, que es donde el ojo tiene más resolución. Si el destino intermedio es de ocho bits, que sea -srgb; si el contenido puede superar el uno, que sea de coma flotante. No hay tercera opción que salga bien.
Muestrear la profundidad
Leer después la textura de profundidad que produjo un pass es la base de las sombras, la niebla volumétrica, las partículas suavizadas, la oclusión ambiental y la reconstrucción de posición en espacio de mundo. Tiene tres particularidades que no comparte ninguna otra textura.
La primera es el uso: además de RENDER_ATTACHMENT, la textura necesita TEXTURE_BINDING, y eso hay que decidirlo al crearla.
La segunda es el tipo de binding. En WGSL se declara como texture_depth_2d, no como texture_2d, y devuelve un f32 escalar en lugar de un vector.
La tercera es el sampler. Una textura de profundidad no se puede muestrear con un sampler de filtrado normal. Las dos opciones válidas son un sampler declarado como 'non-filtering', para leer el valor crudo, o un sampler de comparación, que devuelve directamente el resultado del test contra un valor de referencia y aprovecha el filtrado por hardware para dar el porcentaje de muestras que pasan.
@group(1) @binding(0) var mapaSombra : texture_depth_2d;
@group(1) @binding(1) var compSampler : sampler_comparison;
fn sombra(posLuz: vec3f) -> f32 {
// Devuelve la fraccion de las muestras vecinas que pasan la comparacion,
// interpolada por hardware: filtrado percentage-closer de cuatro muestras
// al precio de una lectura.
return textureSampleCompare(mapaSombra, compSampler, posLuz.xy, posLuz.z);
}
Ese textureSampleCompare es la razón por la que existe el tipo de sampler de comparación, y hace en una instrucción lo que en un shader ingenuo son cuatro lecturas y tres interpolaciones a mano. Recuerda que si tu renderizador usa profundidad invertida, la comparación del sampler también hay que invertirla, tal y como se explica en reversed-z y la precisión.
El tamaño y la aritmética
La memoria de un target es exactamente ancho por alto por bytes por píxel por número de muestras. La cuenta no tiene sorpresas, pero sus resultados sí:
| Target | Resolución | Formato | Memoria |
|---|---|---|---|
| Color HDR | 1920 por 1080 | rgba16float |
16,6 MB |
| Color HDR con 4 muestras | 1920 por 1080 | rgba16float |
66,4 MB |
| Color HDR | 3840 por 2160 | rgba16float |
66,4 MB |
| Color HDR con 4 muestras | 3840 por 2160 | rgba16float |
265 MB |
Ese último número es la razón por la que el multimuestreo a resolución nativa en pantallas 4K no siempre es una opción, y también la razón por la que conviene desacoplar la resolución de render de la del canvas.
El desacople se hace en dos capas. El canvas HTML tiene un tamaño en píxeles CSS y un tamaño en píxeles de dispositivo, y la relación entre ambos es devicePixelRatio, que en un móvil moderno vale 3 y en un portátil de retina vale 2. Renderizar a la resolución de dispositivo completa en un móvil significa sombrear nueve veces más píxeles que a resolución CSS. La solución estándar es introducir una escala de render propia:
// Resolucion de presentacion, acotada por el limite del dispositivo.
const escala = 0.75; // parametro de calidad expuesto al usuario
const dpr = Math.min(window.devicePixelRatio, 2);
const maxDim = device.limits.maxTextureDimension2D;
const ancho = Math.min(Math.round(canvas.clientWidth * dpr * escala), maxDim);
const alto = Math.min(Math.round(canvas.clientHeight * dpr * escala), maxDim);
if (canvas.width !== ancho || canvas.height !== alto) {
canvas.width = ancho;
canvas.height = alto;
recrearTargets(ancho, alto); // todos, o beginRenderPass fallara
}
Dos detalles de ese fragmento que se olvidan. El acotado por maxTextureDimension2D importa porque el límite garantizado es de 8192 y una ventana de escritorio arrastrada a dos monitores 4K puede superarlo. Y hay que recrear todos los targets a la vez, no solo el que has tocado, porque la validación exige que todos los attachments de un pass compartan dimensiones.
La escala de render se aplica casi siempre como un único multiplicador global, y esa es una forma muy tosca de gastar el presupuesto. Los efectos de un fotograma tienen frecuencias espaciales radicalmente distintas y la resolución que necesita cada uno es proporcional a su frecuencia, no a la de la pantalla. El bloom es un desenfoque enorme por definición: calcularlo a un cuarto de la resolución en cada eje —un dieciseisavo del área— es indistinguible del resultado a resolución completa y cuesta el 6% del sombreado. La oclusión ambiental es información de baja frecuencia con bordes que hay que respetar: a media resolución con un filtrado bilateral al subir es el estándar de la industria desde hace quince años. Las partículas de humo, que son manchas suaves y además el peor caso de sobredibujado, van a media resolución en todos los motores que cuidan el rendimiento en móvil. La transparencia acumulada de weighted blended OIT tolera media resolución sin problema. Y en cambio la geometría opaca, el texto de la interfaz y cualquier cosa con bordes duros o detalle de alta frecuencia necesitan la resolución completa, porque ahí sí se ve. La consecuencia de diseño es que la cadena de targets de un renderizador serio no es un conjunto de texturas del mismo tamaño, es una pirámide, y que el ajuste de calidad que ofreces al usuario no debería ser un único deslizador que escala todo por igual sino una serie de decisiones por efecto. La comprobación que lo demuestra es incómoda de lo barata que es: coge el efecto más caro de tu perfil, divide su resolución por dos, y mira la imagen al lado de la anterior. Si no distingues cuál es cuál —y en tres de cada cuatro efectos de pantalla completa no vas a distinguirlo— acabas de recuperar el 75% de su coste.
Instrumenta tu renderizador para que imprima la memoria total de sus render targets al recrearlos, sumando ancho por alto por bytes por muestras de cada uno. La cifra suele sorprender: es habitual descubrir que la cadena de post-proceso ocupa más que todas las texturas del arte junto. Con ese número delante, las decisiones de formato y de resolución de esta lección se toman solas.