wandres.dev
TEXTURAS I · Crear, subir y muestrear

createTexture: el descriptor completo, campo a campo

Los siete campos del descriptor de una textura, las reglas de validación que los relacionan entre sí, el cálculo del número máximo de mips, y para qué sirve viewFormats.

⏱ 20 min

Una textura en WebGPU no es una imagen: es una región de memoria con un formato, una disposición y un conjunto declarado de usos permitidos. La declaración de usos es lo que más sorprende a quien viene de WebGL, porque obliga a saber de antemano qué vas a hacer con ella, y porque pedir de más tiene un coste real en memoria y en velocidad de acceso.

🎯 Al terminar esta lección sabrás
  • Escribir un descriptor de textura completo con los siete campos y sus valores por defecto.
  • Elegir los flags de usage mínimos para un caso dado y justificar cada uno.
  • Calcular el número máximo de niveles de mip para un tamaño dado.
  • Explicar para qué sirve viewFormats y qué coste tiene declararlo.

Los siete campos

const textura = device.createTexture({
  label: 'albedo-madera',
  size: [1024, 1024, 1],       // o { width, height, depthOrArrayLayers }
  format: 'rgba8unorm',        // obligatorio
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
  mipLevelCount: 11,           // por defecto 1
  sampleCount: 1,              // por defecto 1
  dimension: '2d',             // por defecto '2d'
  viewFormats: [],             // por defecto []
});

size acepta un array o un objeto. El width es obligatorio; height y depthOrArrayLayers valen 1 si se omiten. Ese tercer valor tiene dos significados según dimension: con '2d' es el número de capas de array, con '3d' es la profundidad del volumen. Es el mismo campo con dos semánticas, y confundirlas produce texturas que ocupan lo que esperas y se muestrean como no esperas.

format no tiene valor por defecto. Decide el tamaño en memoria, qué operaciones se permiten y cómo el shader ve los datos. La tabla de formatos tiene su propia lección porque las capacidades de cada uno no son adivinables.

dimension vale '1d', '2d' o '3d'. Con '1d', height y depthOrArrayLayers tienen que ser 1, sampleCount tiene que ser 1 y el formato no puede ser comprimido ni de profundidad. Con '3d' valen restricciones parecidas: sampleCount 1 y ni comprimido ni de profundidad. No existe una dimensión '2d-array': un array de texturas 2D es dimension: '2d' con depthOrArrayLayers mayor que 1, y la distinción se hace en la vista.

sampleCount solo admite 1 o 4. Nada más. Con 4, la textura es multimuestreada y arrastra cuatro restricciones: mipLevelCount tiene que ser 1, depthOrArrayLayers tiene que ser 1, usage tiene que incluir RENDER_ATTACHMENT y no puede incluir STORAGE_BINDING. Una textura MSAA no se muestrea con un sampler normal: se resuelve a otra textura o se lee con textureLoad sobre texture_multisampled_2d.

Los flags de uso

usage es la máscara que declara todo lo que vas a poder hacer con la textura. Los seis valores son:

Flag Hex Qué permite
COPY_SRC 0x01 Ser origen de una copia
COPY_DST 0x02 Ser destino de una copia o de writeTexture
TEXTURE_BINDING 0x04 Ser muestreada desde un shader
STORAGE_BINDING 0x08 Ser escrita desde un shader como storage texture
RENDER_ATTACHMENT 0x10 Ser el destino de un render pass
TRANSIENT_ATTACHMENT 0x20 Attachment que solo vive dentro de un pass

La regla es pedir el mínimo, y no por purismo. Cada flag restringe cómo puede la implementación colocar la textura en memoria. Una textura que solo es RENDER_ATTACHMENT puede usar una disposición comprimida específica del hardware; añadirle TEXTURE_BINDING puede forzar una disposición más general, o insertar una descompresión al cambiar de uso. En GPUs móviles con arquitectura de tiles la diferencia es notable.

TRANSIENT_ATTACHMENT es el caso extremo y merece conocerse: declara que la textura solo se usa dentro de un render pass y nunca se lee después. Su usage tiene que ser exactamente TRANSIENT_ATTACHMENT | RENDER_ATTACHMENT, con dimension: '2d', mipLevelCount: 1 y una sola capa. A cambio, la implementación puede mantener el contenido en la memoria del tile y no reservar VRAM en absoluto. Para un depth buffer que solo sirve para el test de profundidad y nunca se muestrea, es memoria gratis.

Las combinaciones típicas, para no dudar:

// Textura de material cargada desde una imagen.
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST

// La misma, si vas a generar mipmaps renderizando a ella.
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
     | GPUTextureUsage.RENDER_ATTACHMENT

// Destino de copyExternalImageToTexture: exige COPY_DST y RENDER_ATTACHMENT.
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
     | GPUTextureUsage.RENDER_ATTACHMENT

// Depth buffer que solo sirve para el test.
usage: GPUTextureUsage.RENDER_ATTACHMENT

// Render target intermedio que después se lee.
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING

// Salida de un compute shader que luego se muestrea.
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING
⚠️
copyExternalImageToTexture exige RENDER_ATTACHMENT

Es la validación que más desconcierta: para subir una imagen con queue.copyExternalImageToTexture(), la textura destino necesita COPY_DST y RENDER_ATTACHMENT, aunque nunca vayas a renderizar a ella. El motivo es que la implementación puede resolver la conversión de color y el volteo vertical con un pass de render interno. Con queue.writeTexture() basta COPY_DST.

mipLevelCount y su máximo

El número máximo de niveles de mip es el número de veces que puedes dividir la dimensión mayor por dos hasta llegar a 1, más uno:

function mipsMaximos(ancho, alto, profundidad = 1) {
  return 1 + Math.floor(Math.log2(Math.max(ancho, alto, profundidad)));
}
mipsMaximos(1024, 1024);  // 11: 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1
mipsMaximos(1024, 256);   // 11: la dimensión mayor manda
mipsMaximos(300, 200);    // 9

Para texturas '3d' la profundidad cuenta en el máximo; para '2d' con capas de array, no: todas las capas comparten la cadena de mips y las capas no se reducen.

Pedir mipLevelCount mayor que el máximo es un error de validación. Pedir menos es legítimo: puedes tener una textura de 1024 con solo cuatro niveles, y el sampler la muestreará hasta el nivel 3 con lodMaxClamp implícito.

Declarar los mips reserva la memoria, no los genera. Una textura con 11 niveles ocupa aproximadamente un 33 % más que una con uno solo, y los niveles del 1 al 10 salen a cero hasta que alguien los rellene. Generarlos es trabajo tuyo, y es el tema de la lección del nivel 22.

viewFormats

viewFormats es un array de formatos adicionales con los que se podrá crear una vista de esta textura, además del propio format. Los formatos tienen que ser compatibles, que en la práctica significa que solo cambia el aspecto sRGB: rgba8unorm y rgba8unorm-srgb son compatibles entre sí, igual que bgra8unorm y bgra8unorm-srgb.

const t = device.createTexture({
  size: [1024, 1024],
  format: 'rgba8unorm',
  viewFormats: ['rgba8unorm-srgb'],
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
       | GPUTextureUsage.COPY_DST,
});

const vistaLineal = t.createView();                             // rgba8unorm
const vistaSRGB   = t.createView({ format: 'rgba8unorm-srgb' }); // conversión al leer

Sirve exactamente para un caso: escribir datos lineales y leerlos con la conversión sRGB aplicada por el hardware, o al revés. Es lo que permite hacer post-proceso en espacio lineal sobre una textura que se presenta en sRGB, sin duplicar memoria.

Tiene un coste que la especificación menciona y conviene tener presente: declarar viewFormats puede impedir que la implementación use ciertas compresiones internas, porque la textura tiene que ser legible de dos formas. Si no lo necesitas, déjalo vacío.

El error de rendimiento más caro se comete en esta línea

Hay un patrón que aparece en todos los proyectos que crecen: alguien necesita depurar y añade COPY_SRC a todas las texturas para poder volcarlas; alguien más añade TEXTURE_BINDING a los depth buffers por si acaso; y al final todas las texturas del renderer tienen los seis flags. El código funciona idénticamente y el renderer va un 15 % más lento en móvil sin que nadie sepa por qué. La razón es la disposición interna: una textura con muchos usos declarados se coloca en un formato general que el hardware puede leer de todas las maneras, renunciando a las compresiones de framebuffer y a las disposiciones en mosaico que aceleran el acceso. El diagnóstico es incómodo porque no aparece en ningún perfil: no hay una llamada lenta, todo es un poco más lento. La disciplina que lo evita es tratar usage como un permiso y no como una comodidad, y tener una variante de depuración que añada COPY_SRC solo cuando la depuración esté activa.

⚔️ Reto práctico

Crea la misma textura de 2048×2048 con rgba8unorm dos veces: una con RENDER_ATTACHMENT a secas y otra con los seis flags. Renderiza mil quads a cada una en un bucle y compara con timestamp queries. En una GPU de escritorio la diferencia puede ser cero; en una integrada o en un móvil, sospecha si no la ves.