Cubemaps: seis caras, un vector de dirección
Cómo se construye un cubemap en WebGPU a partir de seis capas, el orden de las caras que fija la especificación, el muestreo por dirección, y cómo renderizar a las seis caras.
Un cubemap no es un tipo de textura distinto: es un array de seis capas cuadradas con una vista que le dice al hardware que las interprete como las caras de un cubo. A partir de ahí, el muestreo cambia de naturaleza: en vez de dos coordenadas normalizadas se pasa un vector de dirección, y el hardware decide la cara y las coordenadas dentro de ella. Es el mecanismo que sostiene los cielos, los reflejos y las sombras omnidireccionales.
- Construir un cubemap a partir de seis imágenes con el orden de caras correcto.
- Muestrear con
texture_cubeusando un vector de dirección. - Renderizar a las seis caras con seis vistas de una capa.
- Conocer el convenio de orientación de cada cara y qué implica al generar contenido.
Construirlo
La textura es dimension: '2d' con exactamente 6 capas, cuadrada. La vista con dimension: 'cube' es lo que la convierte en cubemap.
const LADO = 1024;
const cubo = device.createTexture({
label: 'cielo',
size: [LADO, LADO, 6],
format: 'rgba8unorm-srgb',
mipLevelCount: 11,
dimension: '2d',
usage: GPUTextureUsage.TEXTURE_BINDING
| GPUTextureUsage.COPY_DST
| GPUTextureUsage.RENDER_ATTACHMENT,
});
El orden de las capas lo fija la especificación y no es negociable: +X, -X, +Y, -Y, +Z, -Z. Es el mismo orden de D3D y de OpenGL, así que los assets de cubemap que encuentres por ahí suelen venir ordenados así, con nombres del tipo px, nx, py, ny, pz, nz.
const nombres = ['px', 'nx', 'py', 'ny', 'pz', 'nz'];
for (let cara = 0; cara < 6; cara++) {
const respuesta = await fetch(`/cielo/${nombres[cara]}.png`);
const bitmap = await createImageBitmap(await respuesta.blob());
device.queue.copyExternalImageToTexture(
{ source: bitmap },
{ texture: cubo, origin: [0, 0, cara] },
[LADO, LADO, 1],
);
bitmap.close();
}
Y la vista:
const vistaCubo = cubo.createView({ dimension: 'cube' }); // arrayLayerCount = 6
La validación exige que la textura sea '2d', que el ancho sea igual al alto, y que arrayLayerCount sea exactamente 6. Con dimension: 'cube-array' la restricción pasa a ser múltiplo de 6, y el shader usa texture_cube_array con un índice de cubo adicional.
Muestrear por dirección
En el bind group layout, la entrada lleva viewDimension: 'cube'; en WGSL, el tipo es texture_cube<f32> y la coordenada es un vec3<f32>:
@group(0) @binding(0) var cielo : texture_cube<f32>;
@group(0) @binding(1) var smp : sampler;
@fragment
fn fs(entrada : Interpolado) -> @location(0) vec4<f32> {
// La dirección no hace falta normalizarla: solo importa su orientación.
return textureSample(cielo, smp, entrada.direccion);
}
El vector no necesita ser unitario. El hardware toma la componente de mayor valor absoluto para elegir la cara y divide las otras dos por ella para obtener las coordenadas dentro de esa cara. Eso implica que escalar el vector no cambia nada, y que el vector nulo produce un resultado indefinido.
El muestreo entre caras es sin costura: el hardware filtra correctamente en los bordes tomando texels de la cara adyacente. Esa es una capacidad del hardware de texturas que no puedes reproducir con seis texturas separadas, y es la principal razón de usar un cubemap en vez de un array de seis capas.
Los dos usos canónicos, con dos direcciones distintas:
// Skybox: la dirección es la del rayo de cámara al fragmento.
let dirCielo = normalize(entrada.posMundo - camara.posicion);
// Reflejo especular: la dirección es la reflejada sobre la normal.
let vista = normalize(entrada.posMundo - camara.posicion);
let dirReflejo = reflect(vista, normalize(entrada.normal));
let reflejo = textureSample(cielo, smp, dirReflejo);
Para el skybox hay además un truco de vertex shader que evita dibujar un cubo: un triángulo de pantalla completa con la profundidad fijada al plano lejano y la dirección reconstruida desde la matriz inversa de vista-proyección. Dibuja el cielo con tres vértices y un solo draw, y con depthCompare: 'less-equal' se dibuja al final aprovechando el depth test para descartar los píxeles ya cubiertos.
Las caras de un cubemap tienen orientaciones específicas heredadas de Direct3D, donde el sistema es levógiro. Es la razón por la que las caras +Y y -Y de muchos cubemaps aparecen giradas si las montas a mano, y por la que casi todo el mundo acaba probando las seis combinaciones hasta que el cielo encaja. Si generas el cubemap tú renderizando, las matrices de vista de cada cara tienen que respetar ese mismo convenio; si lo cargas de un archivo generado por una herramienta estándar, ya viene bien.
Renderizar a las seis caras
Generar un cubemap en tiempo de ejecución —una sonda de reflexión, un mapa de sombras de una luz puntual— es renderizar seis veces con seis cámaras a 90 grados de campo de visión. Cada destino es una vista de una capa:
const vistasCara = Array.from({ length: 6 }, (_, cara) => cubo.createView({
dimension: '2d',
baseArrayLayer: cara, arrayLayerCount: 1,
baseMipLevel: 0, mipLevelCount: 1,
}));
// Direcciones de mirada y vectores "arriba" de cada cara, en el orden de la spec.
const MIRADAS = [
[ 1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1],
];
const ARRIBAS = [
[0, -1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1], [0, -1, 0], [0, -1, 0],
];
function renderizarSonda(encoder, centro) {
for (let cara = 0; cara < 6; cara++) {
actualizarCamara(centro, MIRADAS[cara], ARRIBAS[cara], Math.PI / 2);
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: vistasCara[cara], loadOp: 'clear', storeOp: 'store',
clearValue: [0, 0, 0, 1],
}],
depthStencilAttachment: {
view: depthCara, depthLoadOp: 'clear', depthStoreOp: 'discard',
depthClearValue: 1.0,
},
});
dibujarEscena(pass);
pass.end();
}
}
Seis passes, seis matrices, el mismo depth buffer reutilizado con depthStoreOp: 'discard' porque no hace falta conservarlo entre caras. Un campo de visión de exactamente 90 grados y una relación de aspecto de 1 son obligatorios para que las caras encajen.
Después hay que generar los mipmaps del cubemap si vas a usarlo para reflejos con rugosidad. El generador de mipmaps funciona capa a capa, aunque con el defecto que el reto de esa lección apunta: reduce cada cara por separado y no toma texels de las caras vecinas, así que las costuras de los niveles bajos no son perfectas.
Cuando un cubemap sirve de mapa de entorno para reflejos, sus niveles de mip no deberían contener la imagen reducida sino la imagen convolucionada con el lóbulo especular correspondiente a cada rugosidad. Es lo que se llama prefiltrado del mapa de entorno, y es la base del IBL moderno: el nivel 0 guarda el reflejo perfecto, el nivel 3 el reflejo de una superficie medio rugosa, el último el difuso casi uniforme. La diferencia frente a un mipmap normal es enorme visualmente: un reflejo borroso hecho con mipmaps de caja parece una foto desenfocada, mientras que uno prefiltrado con la BRDF correcta parece metal. Y la diferencia de implementación es solo el fragment shader del generador: en vez de una muestra bilineal, un muestreo por importancia de la distribución GGX con el rugosidad correspondiente al nivel. La estructura del generador de mipmaps que ya tienes —un render pass por nivel, vistas aisladas— es exactamente la misma; solo cambia el shader. Es un ejemplo perfecto de por qué merece la pena entender bien la infraestructura antes de aprenderse las técnicas que se montan encima.