wandres.dev
TEXTURAS II · Mipmaps, arrays y cubemaps

Texture arrays: muchas texturas en un solo binding

Cómo se crea un array de texturas 2D, la diferencia con una textura 3D, el índice de capa en WGSL, y por qué un array vence a un atlas cuando todas las capas tienen el mismo tamaño.

⏱ 17 min

Un array de texturas es una sola GPUTexture con varias capas del mismo tamaño y formato, que el shader indexa con un entero. Resuelve el mismo problema que un atlas —muchas imágenes en un binding— sin ninguno de sus dos defectos: no hay sangrado entre vecinos al filtrar y cada capa tiene su propia cadena de mips completa. La restricción es que todas las capas tienen que medir lo mismo.

🎯 Al terminar esta lección sabrás
  • Crear un array de texturas y distinguirlo de una textura 3D en el descriptor.
  • Subir datos a una capa concreta y crear las vistas correspondientes.
  • Muestrear con texture_2d_array pasando el índice de capa.
  • Comparar array y atlas en filtrado, mipmaps y flexibilidad.

Crear y llenar

Un array de texturas se declara con dimension: '2d' y depthOrArrayLayers mayor que 1. No existe un dimension: '2d-array': la distinción entre array y volumen la hace dimension, y '2d' con varias capas es siempre un array.

const CAPAS = 8;
const arrayTexturas = device.createTexture({
  label: 'materiales del terreno',
  size: [1024, 1024, CAPAS],
  format: 'rgba8unorm-srgb',
  mipLevelCount: 11,
  dimension: '2d',
  usage: GPUTextureUsage.TEXTURE_BINDING
       | GPUTextureUsage.COPY_DST
       | GPUTextureUsage.RENDER_ATTACHMENT,
});

El límite es maxTextureArrayLayers, con un mínimo garantizado de 256 capas. Las capas comparten formato, tamaño y número de niveles de mip: no hay forma de tener una capa de 512 y otra de 1024.

Para llenar una capa concreta, el tercer componente del origin de destino es el índice de capa:

for (let capa = 0; capa < CAPAS; capa++) {
  const bitmap = await createImageBitmap(imagenes[capa]);
  device.queue.copyExternalImageToTexture(
    { source: bitmap },
    { texture: arrayTexturas, origin: [0, 0, capa] },
    [1024, 1024, 1],
  );
  bitmap.close();
}

Con writeTexture es igual: el origin lleva la capa en z, y si subes varias capas de una vez, rowsPerImage pasa a ser obligatorio porque separa las imágenes dentro de los datos de origen.

Muestrear desde WGSL

El tipo es texture_2d_array<f32>, y la función de muestreo lleva un parámetro extra con el índice de capa:

@group(1) @binding(0) var capas : texture_2d_array<f32>;
@group(1) @binding(1) var smp   : sampler;

@fragment
fn fs(entrada : Interpolado) -> @location(0) vec4<f32> {
  // El índice de capa es un entero, no una coordenada normalizada.
  let indice = i32(entrada.material);
  return textureSample(capas, smp, entrada.uv, indice);
}

El índice no se interpola: no hay filtrado entre capas. Es la diferencia fundamental con una textura 3D, donde la tercera coordenada sí se filtra. Si necesitas mezclar dos capas, muestreas las dos y mezclas tú:

let a = textureSample(capas, smp, entrada.uv, indiceA);
let b = textureSample(capas, smp, entrada.uv, indiceB);
let color = mix(a, b, factor);

Del lado de la API, la entrada del bind group layout lleva viewDimension: '2d-array', y la vista se crea con la misma dimensión, que además es la que se deduce por defecto para una textura de varias capas:

{ binding: 0, visibility: GPUShaderStage.FRAGMENT,
  texture: { sampleType: 'float', viewDimension: '2d-array' } }

// La vista por defecto ya es '2d-array' para esta textura.
const vista = arrayTexturas.createView();
⚠️
El índice de capa tiene que ser uniforme para las derivadas

textureSample en el fragment shader calcula el nivel de mip a partir de las derivadas de las coordenadas, y eso exige que la llamada ocurra bajo control de flujo uniforme. Si el índice de capa varía entre fragmentos vecinos del mismo quad de 2×2 —lo cual es normal en un terreno con varios materiales—, sigue funcionando porque las derivadas se toman de uv, no del índice. Pero si envuelves el textureSample en un if cuya condición varía por fragmento, el resultado del LOD es indeterminado. La salida es textureSampleLevel con el nivel calculado a mano, o mover el muestreo fuera de la rama.

Vistas de capas

Una vista puede exponer todas las capas, un subconjunto o una sola. Es lo que permite renderizar a una capa concreta:

// Todas las capas, para muestrear.
const todas = arrayTexturas.createView({ dimension: '2d-array' });

// Una sola capa como textura 2D normal, para renderizar a ella.
const capa3 = arrayTexturas.createView({
  dimension: '2d', baseArrayLayer: 3, arrayLayerCount: 1, mipLevelCount: 1,
});

// Un subrango: las capas 2 a 5 como array de cuatro.
const rango = arrayTexturas.createView({
  dimension: '2d-array', baseArrayLayer: 2, arrayLayerCount: 4,
});

El subrango es más útil de lo que parece: permite tener un array grande de sombras y darle a cada luz una vista de sus cuatro cascadas, con índices de capa que empiezan en 0 desde el punto de vista del shader.

Array o atlas

El atlas —empaquetar muchas imágenes en una textura grande y dar a cada una su rectángulo de coordenadas— resuelve el mismo problema y tiene tres desventajas concretas.

Sangrado al filtrar. En los bordes de cada sub-imagen, el filtro bilineal toma texels de la vecina. Se mitiga con un margen de píxeles duplicados alrededor de cada región, que consume espacio y hay que generar.

Mipmaps rotos. En los niveles bajos del atlas, cada sub-imagen ocupa pocos texels y el filtrado mezcla imágenes distintas. La única solución es limitar el número de niveles, y con eso se pierde el antialiasing en la distancia.

No se puede envolver. Con un atlas, addressMode: 'repeat' envuelve sobre el atlas entero, no sobre cada sub-imagen. Una textura de material que se repite en un suelo no puede estar en un atlas sin repetir la UV a mano en el shader, con un fract que además rompe las derivadas y produce una costura visible.

El array no tiene ninguno de los tres: cada capa está aislada, tiene su cadena de mips completa y responde al modo de dirección de forma independiente.

La ventaja del atlas es la única que le queda, y es real: admite tamaños distintos. Si tu escena tiene texturas de 256, 512 y 2048, un array te obliga a escalarlas todas al mismo tamaño y a desperdiciar memoria en las pequeñas. La estrategia habitual en motores es tener un array por tamaño: uno de 256 con veinte capas, otro de 1024 con cuarenta, otro de 2048 con diez.

Los texture arrays son el primer paso hacia el bindless que WebGPU todavía no tiene

La motivación de fondo para usar arrays no es el filtrado sino los bind groups. Con texturas sueltas, cada material necesita su bind group porque cada uno tiene sus texturas. Con un array, todos los materiales que comparten el array comparten el bind group, y el material se selecciona con un entero que puede venir de un vertex attribute o de un storage buffer indexado por instance_index. Es decir: el array de texturas es lo que permite eliminar el grupo de material del bucle de dibujado, y con él la mayoría de los setBindGroup. Las APIs nativas modernas llevan esto hasta el final con los recursos bindless, donde un shader indexa un array de descriptores de miles de texturas arbitrarias, de distintos tamaños y formatos. WebGPU no lo expone todavía; los binding_array están en el grupo de trabajo. Mientras tanto, un array de texturas por tamaño y formato es la aproximación disponible, y la arquitectura que construyas sobre ella es la que servirá el día que llegue el bindless de verdad.