El G-buffer: qué guardar y, sobre todo, qué no
Diseñar un G-buffer en WebGPU: reconstruir la posición desde la profundidad, codificar normales en dos canales, y por qué el límite real es maxColorAttachmentBytesPerSample.
El deferred shading nace de una observación sencilla: si el problema del forward es que la geometría y las luces se multiplican, sepáralos. Una pasada resuelve la geometría y escribe en varias texturas todo lo que hace falta para sombrear un punto; otra pasada, ya en espacio de pantalla, lee esas texturas e ilumina. El coste deja de ser el producto y pasa a ser la suma. El G-buffer es el contrato entre las dos pasadas, y diseñarlo bien es casi todo el trabajo: cada byte que metes ahí se escribe y se vuelve a leer una vez por píxel y por fotograma.
- Reconstruir la posición en el mundo desde el buffer de profundidad y la matriz inversa de proyección-vista.
- Codificar y decodificar normales en dos canales con proyección octaédrica en WGSL.
- Escribir a múltiples render targets con
@location(n)y calcular el coste en bytes por muestra. - Diseñar un G-buffer que quepa en el límite por defecto de
maxColorAttachmentBytesPerSampley estimar su ancho de banda.
Desacoplar la geometría del sombreado
La pasada de geometría no ilumina nada. Rasteriza la escena una sola vez y, por cada fragmento que gana el test de profundidad, escribe en varias texturas los atributos de superficie: color base, normal, rugosidad, metalicidad, lo que tu modelo de sombreado necesite. Al terminar, el buffer de profundidad contiene la superficie visible y las texturas contienen sus propiedades. La geometría ha desaparecido del problema.
La segunda pasada es un triángulo o un quad a pantalla completa, o directamente un compute shader, que por cada píxel lee esas texturas, reconstruye el punto y evalúa todas las luces. La diferencia con el bucle del forward es que ahora cada píxel se sombrea exactamente una vez, sin importar cuántos triángulos había apilados detrás. El overdraw de sombreado es cero por construcción, no por un prepass.
El G-buffer no es una receta fija. Es una decisión de diseño con una restricción muy concreta: cada byte cuesta dos veces, una al escribirlo y otra al leerlo. La pregunta correcta no es “qué me gustaría tener disponible en el sombreado” sino “qué es lo mínimo que no puedo derivar de otra cosa”. Y la primera víctima de esa pregunta es la posición.
Reconstruir la posición en vez de guardarla
La tentación es escribir la posición en el mundo como un target más. Son tres flotantes, 12 bytes por píxel, y como no existe un formato de tres canales de 32 bits en WebGPU acabarías pagando rgba32float, es decir 16 bytes con un canal tirado. Es el error más caro que se puede cometer en un G-buffer, porque esa información ya la tienes: está en el buffer de profundidad, que vas a escribir de todas formas.
Deshacer la proyección es álgebra directa. En WebGPU el volumen de recorte tiene x e y en el rango de menos uno a uno con la y hacia arriba, y la z de cero a uno. Con las coordenadas del píxel y el valor de profundidad tienes el punto en coordenadas normalizadas de dispositivo; multiplicar por la inversa de la matriz de proyección-vista y dividir por w te devuelve el punto en el mundo:
@group(0) @binding(0) var gProfundidad: texture_depth_2d;
@group(0) @binding(1) var<uniform> camara: Camara; // contiene invViewProj: mat4x4f
fn posicionMundo(coord: vec2u, tam: vec2f) -> vec3f {
let z = textureLoad(gProfundidad, coord, 0); // en WebGPU va de 0 a 1
let uv = (vec2f(coord) + 0.5) / tam; // centro del texel
// uv.y crece hacia abajo en pantalla y hacia arriba en NDC: hay que invertirla
let ndc = vec4f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, z, 1.0);
let p = camara.invViewProj * ndc;
return p.xyz / p.w;
}
textureLoad sobre una texture_depth_2d devuelve un f32 directamente, sin sampler y sin filtrado, que es justo lo que quieres: no tiene ningún sentido interpolar profundidad entre dos píxeles que pueden estar en superficies distintas. El tercer argumento es el nivel de mip, y aquí siempre es cero.
Dos detalles prácticos. El primero es que la textura de profundidad tiene que crearse con usage que incluya RENDER_ATTACHMENT y TEXTURE_BINDING, y el binding se declara con sampleType: 'depth' en el bind group layout si no usas layout: 'auto'. El segundo es el formato: usa depth32float en lugar de depth24plus si vas a reconstruir posiciones. depth24plus deja la precisión real a discreción de la implementación —el nombre dice “24 o más”—, y aunque en la práctica suele ser lo que quieres, si vas a hacer aritmética sobre esos valores prefieres saber exactamente qué tienes.
El ahorro son 12 bytes por píxel de información, 16 de attachment real, y encima el resultado es más preciso. Guardar la posición interpolada en rgba16float para ahorrar espacio da unos tres dígitos decimales útiles, que es basura más allá de unas decenas de metros del origen; guardarla en rgba32float es correcto pero paga los 16 bytes. Reconstruirla desde la profundidad te da el punto que el rasterizador usó de verdad, con la precisión completa del buffer, y a coste cero de attachment. Hay un tercer beneficio que no es obvio: el buffer de profundidad no cuenta para el límite de bytes por muestra de los color attachments, así que la profundidad es literalmente presupuesto gratis.
Si tu sombreado trabaja en espacio de vista en lugar de en el mundo —lo cual es buena idea, porque mantiene las magnitudes pequeñas y estables—, usa invProj en vez de invViewProj y te ahorras además el problema de precisión de las coordenadas grandes.
Empaquetar: normales en dos canales y elegir formato
La normal es el segundo candidato a adelgazar. Es un vector unitario, así que tiene dos grados de libertad, no tres: guardar tres componentes es guardar información redundante. La codificación ingenua —tirar la z y reconstruirla con sqrt(1 - x² - y²)— falla porque pierde el signo, y en espacio de vista casi todas las normales visibles apuntan hacia la cámara pero casi no es todas: las siluetas y las superficies con mapa de normales agresivo se salen.
La solución que usa todo el mundo desde hace una década es la proyección octaédrica: proyectar la esfera sobre un octaedro, desplegarlo en un cuadrado, y guardar las dos coordenadas del cuadrado. La distribución del error es notablemente uniforme y, con ocho bits por canal, es mejor que guardar las tres componentes crudas en rgba8unorm —y encima gasta un canal menos.
fn signoNoCero(v: vec2f) -> vec2f {
return select(vec2f(-1.0), vec2f(1.0), v >= vec2f(0.0));
}
fn plegar(v: vec2f) -> vec2f {
return (1.0 - abs(v.yx)) * signoNoCero(v);
}
fn codificarOct(n: vec3f) -> vec2f {
var p = n.xy * (1.0 / (abs(n.x) + abs(n.y) + abs(n.z)));
if (n.z < 0.0) { p = plegar(p); }
return p; // queda en el rango de -1 a 1
}
fn decodificarOct(e: vec2f) -> vec3f {
var n = vec3f(e.x, e.y, 1.0 - abs(e.x) - abs(e.y));
if (n.z < 0.0) { n = vec3f(plegar(n.xy), n.z); }
return normalize(n);
}
Tres cosas de este código merecen comentario. select(f, t, cond) en WGSL devuelve f cuando la condición es falsa y t cuando es verdadera, y con un vector de booleanos actúa componente a componente: es el equivalente vectorizado del operador ternario. La reconstrucción de n se hace creando un vec3f nuevo en vez de asignar a un swizzle porque WGSL no permite asignar a swizzles de varias componentes: n.x = 1.0 es legal, n.xy = vec2f(...) no compila. Y la salida se deja en el rango de menos uno a uno, sin el habitual * 0.5 + 0.5, porque el destino que vamos a usar es rg16float, que es un formato con signo; si guardaras en rg8unorm sí tendrías que remapear.
Sobre formatos, el criterio es corto. rgba8unorm para todo lo que sea un factor entre cero y uno: albedo, rugosidad, metalicidad, oclusión. Usa la variante rgba8unorm-srgb para el albedo, que hace la conversión de gamma en hardware al escribir y al leer sin coste; ojo a que en un formato srgb el canal alfa es siempre lineal, así que si metes la oclusión ahí no sufre la conversión, que es exactamente lo que quieres. rgba16float cuando el valor puede salirse de cero a uno: emisivo, cualquier cosa en rango dinámico alto, velocidades. Y rgba32float prácticamente nunca en un G-buffer: 16 bytes por píxel es medio presupuesto entero.
Merece la pena mencionar rg11b10ufloat porque tiene una pinta ideal para color en rango alto —cuatro bytes en vez de ocho, sin canal alfa, sin signo— pero solo es renderizable si el dispositivo expone la feature rg11b10ufloat-renderable. Sin ella puedes leerlo, no escribirlo como attachment. Compruébalo con adapter.features.has('rg11b10ufloat-renderable') y pídelo en requiredFeatures al crear el dispositivo, con un camino alternativo a rgba16float si no está.
Los múltiples render targets y el límite de 32 bytes
Escribir a varios targets desde un fragment shader es declarar una struct de salida con un @location(n) por attachment:
struct SalidaGBuffer {
@location(0) albedoOclusion: vec4f, // rgba8unorm-srgb
@location(1) normal: vec2f, // rg16float
@location(2) material: vec4f, // rgba8unorm
@location(3) emisivo: vec4f, // rgba16float
};
@fragment
fn fs_gbuffer(e: Varyings) -> SalidaGBuffer {
var s: SalidaGBuffer;
s.albedoOclusion = vec4f(albedoDeTextura(e.uv), oclusion(e.uv));
s.normal = codificarOct(normalize(e.normalVista));
s.material = vec4f(rugosidad, metalicidad, f32(idMaterial) / 255.0, 0.0);
s.emisivo = vec4f(emisivo, 0.0);
return s;
}
Del lado de JavaScript, targets es un array paralelo a los @location, y el pase de render lleva un colorAttachments con la misma cardinalidad:
const gbufferPipeline = device.createRenderPipeline({
layout: pipelineLayout,
vertex: { module, entryPoint: 'vs', buffers: [layoutVertices] },
fragment: {
module,
entryPoint: 'fs_gbuffer',
targets: [
{ format: 'rgba8unorm-srgb' }, // 4 bytes
{ format: 'rg16float' }, // 4 bytes
{ format: 'rgba8unorm' }, // 4 bytes
{ format: 'rgba16float' }, // 8 bytes
],
},
depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' },
});
Aquí es donde aparece el límite que de verdad decide el diseño. maxColorAttachments vale 8 por defecto, y eso rara vez estorba. El que estorba es maxColorAttachmentBytesPerSample, que vale 32 por defecto: la suma de bytes por muestra de todos los color attachments de un pipeline no puede pasar de 32. Ese número no es un capricho de la especificación; refleja lo que las GPU por tiles tienen de memoria on-chip por píxel. Cuantos más bytes por muestra pidas, menos píxeles caben en un tile y peor va el hardware.
Hay un matiz de la regla que pilla a todo el mundo una vez: el total no es una suma simple, se calcula recorriendo los attachments en orden, alineando el acumulador a la alineación de cada formato antes de sumar su coste. Los formatos de 8 y 16 bits tienen alineaciones pequeñas y los de 32 bits alineación de cuatro, así que reordenar los targets puede cambiar si cabes o no. Si te sale un error de validación por bytes por muestra y estás justo en el borde, prueba a poner los formatos anchos primero. Ocho targets de rgba8unorm suman exactamente 32 y son la configuración más ancha que permite el límite por defecto.
Este es el G-buffer del ejemplo, con su coste:
| Attachment | Formato | Bytes por muestra | Contenido |
|---|---|---|---|
@location(0) |
rgba8unorm-srgb |
4 | Albedo RGB, oclusión en alfa |
@location(1) |
rg16float |
4 | Normal octaédrica en espacio de vista |
@location(2) |
rgba8unorm |
4 | Rugosidad, metalicidad, id de material, libre |
@location(3) |
rgba16float |
8 | Emisivo RGB, canal libre |
| — | depth32float |
4 | Profundidad. No cuenta en el límite |
Veinte bytes de color de los 32 disponibles. Queda margen para un target de velocidad en rg16float si vas a usar antialiasing temporal, o para subir la normal a rgba16float si necesitas también la normal geométrica además de la del mapa.
Y ahora la cuenta que decide si esta arquitectura te sirve. El tráfico es el G-buffer escrito una vez y leído una vez, más la profundidad escrita y leída:
| Resolución | Píxeles | Escritura | Lectura | Total por fotograma | A 60 fps |
|---|---|---|---|---|---|
| 1920x1080 | 2 073 600 | 49,8 MB | 49,8 MB | 99,5 MB | 5,97 GB/s |
| 3840x2160 | 8 294 400 | 199,1 MB | 199,1 MB | 398,1 MB | 23,9 GB/s |
Compáralo con el medio gigabyte por segundo de una pasada forward simple. Son doce veces más a 1080p y casi cincuenta a 4K, y ese tráfico existe antes de haber evaluado la primera luz. En una tarjeta de escritorio con 400 o 500 GB/s de ancho de banda es asumible. En un portátil con gráficos integrados, o en un móvil, no lo es.
maxColorAttachmentBytesPerSample se puede subir: es un límite de clase “máximo”, así que consultas adapter.limits.maxColorAttachmentBytesPerSample y, si el adaptador ofrece más, lo pides en requiredLimits al llamar a requestDevice. En muchas GPU de escritorio verás 64 o 128. La tentación es evidente y el error también, porque lo que estás tocando no es una cifra administrativa: es la memoria on-chip por píxel de la arquitectura por tiles. Una GPU de móvil divide el framebuffer en tiles y mantiene el color y la profundidad de cada tile en SRAM pegada a las unidades de sombreado, con una capacidad fija del orden de decenas de kilobytes. Ese presupuesto se reparte entre los bytes por píxel y el número de píxeles del tile: pedir 64 bytes por muestra en lugar de 32 significa tiles de la mitad de píxeles, el doble de tiles, el doble de vinculación de geometría por tile, y una caída de rendimiento que no se parece en nada a un 2x lineal. Los 32 bytes por defecto son el máximo común denominador que la especificación garantiza en todas partes, y están puestos ahí precisamente para que un G-buffer escrito contra el límite por defecto funcione en un teléfono. La otra cara de la moneda es igual de dura: si tu G-buffer no cabe en 32 bytes, lo que estás descubriendo no es que necesites más límite, es que tu diseño no es viable en la mitad de los dispositivos. Antes de subir el límite, gasta el esfuerzo en el otro lado —octaédrica en vez de tres canales, id de material de un byte en vez de un target entero de parámetros, reconstruir en vez de guardar— porque cada byte que te ahorras ahí te lo ahorras dos veces por píxel y por fotograma.