wandres.dev
TEXTURAS I · Crear, subir y muestrear

createView: por qué el shader nunca ve una textura

Los ocho campos de un descriptor de vista, las reglas que deciden sus valores por defecto, y los cuatro usos donde una vista hace algo que la textura sola no puede.

⏱ 17 min

Ni un bind group ni un render pass aceptan una GPUTexture: los dos aceptan una GPUTextureView. La indirección parece burocracia hasta que descubres que la vista es lo único capaz de decir esta cara del cubemap, este nivel de mip, solo el aspecto de profundidad o interpreta estos bytes como sRGB. La textura es la memoria; la vista es la interpretación, y una textura puede tener muchas a la vez.

🎯 Al terminar esta lección sabrás
  • Escribir un descriptor de vista con sus ocho campos y conocer sus valores por defecto.
  • Predecir la dimension que WebGPU elegirá cuando la omitas.
  • Aislar un nivel de mip o una capa concreta para usarla como render target.
  • Usar aspect y format para reinterpretar los mismos bytes de dos maneras.

Los campos y sus valores por defecto

const vista = textura.createView({
  label: 'cara +X',
  format: 'rgba8unorm',    // por defecto, el de la textura
  dimension: '2d',         // por defecto, deducido (ver abajo)
  aspect: 'all',           // 'all' | 'depth-only' | 'stencil-only'
  baseMipLevel: 0,
  mipLevelCount: 1,        // por defecto, todos desde baseMipLevel
  baseArrayLayer: 0,
  arrayLayerCount: 1,      // por defecto, depende de dimension
  usage: 0,                // por defecto, todos los usos de la textura
});

Los valores por defecto no son uniformes, y ahí está la mayoría de las sorpresas.

dimension se deduce así: si la textura es '1d', la vista es '1d'; si es '3d', la vista es '3d'; si es '2d' y depthOrArrayLayers vale 1, la vista es '2d'; si es '2d' y depthOrArrayLayers es mayor que 1, la vista es '2d-array'. Esa última regla es la que rompe los bind groups: una textura de dos capas produce por defecto una vista '2d-array' que no encaja en un layout que pide '2d'.

arrayLayerCount también depende de dimension: es 1 para '1d', '2d' y '3d'; es 6 para 'cube'; y es depthOrArrayLayers - baseArrayLayer para '2d-array' y 'cube-array'.

mipLevelCount por defecto es mipLevelCount de la textura - baseMipLevel, es decir, todos los que quedan.

format por defecto es el de la textura, salvo si aspect es 'depth-only' o 'stencil-only' sobre un formato combinado, en cuyo caso es el formato específico de ese aspecto. Cualquier otro valor tiene que estar en el viewFormats de la textura.

usage por defecto es 0, que significa todos los usos de la textura. Se puede restringir a un subconjunto, y de hecho hay que hacerlo cuando el formato de la vista no soporta todos los usos de la textura original: si la textura es RENDER_ATTACHMENT | STORAGE_BINDING y creas una vista con un formato que no admite storage, el valor por defecto falla y tienes que declarar el uso explícitamente.

Las reglas de validación por dimensión

Cada valor de dimension impone condiciones que conviene tener a mano:

dimension La textura debe ser arrayLayerCount
'1d' '1d' 1
'2d' '2d' 1
'2d-array' '2d' cualquiera
'cube' '2d' con ancho igual a alto exactamente 6
'cube-array' '2d' con ancho igual a alto múltiplo de 6
'3d' '3d' 1

Y siempre: baseMipLevel + mipLevelCount no puede pasar del mipLevelCount de la textura, y baseArrayLayer + arrayLayerCount no puede pasar de su depthOrArrayLayers.

⚠️
La vista por defecto de una textura de dos capas no es 2d

Es el error de validación más común con arrays. Si creas una textura con size: [512, 512, 2] y la atas con textura.createView() a una entrada viewDimension: '2d', falla. La vista por defecto es '2d-array'. Para atar una sola capa hay que pedirla: createView({ dimension: '2d', baseArrayLayer: 1, arrayLayerCount: 1 }).

Los cuatro usos que justifican la indirección

Aislar un nivel de mip para renderizar a él. Un render pass escribe a un nivel concreto, y la única forma de nombrarlo es una vista que contenga solo ese nivel. Es la base del generador de mipmaps:

const destino = textura.createView({
  baseMipLevel: nivel,
  mipLevelCount: 1,
  dimension: '2d',
});
const pass = encoder.beginRenderPass({
  colorAttachments: [{ view: destino, loadOp: 'clear', storeOp: 'store' }],
});

Aislar una capa de un array. Renderizar las seis caras de un cubemap, o las cuatro cascadas de un mapa de sombras, es renderizar a seis o cuatro vistas de una capa cada una de la misma textura:

const caras = Array.from({ length: 6 }, (_, i) => textura.createView({
  dimension: '2d', baseArrayLayer: i, arrayLayerCount: 1, mipLevelCount: 1,
}));

Elegir el aspecto de una textura de profundidad y stencil. Un formato como depth24plus-stencil8 tiene dos aspectos en la misma memoria. Un shader que muestrea la profundidad necesita una vista 'depth-only'; uno que lee el stencil, una 'stencil-only'. Como attachment de un render pass, en cambio, se usa la vista con aspect: 'all'.

Reinterpretar el formato. Con viewFormats declarado en la textura, dos vistas pueden ver los mismos bytes como lineales o como sRGB:

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

const escribir = t.createView();                             // lineal, para el pass
const leer     = t.createView({ format: 'rgba8unorm-srgb' }); // sRGB, para muestrear

El swizzle, si el dispositivo lo permite

La especificación añadió un campo swizzle a la vista, disponible cuando el dispositivo expone la feature texture-component-swizzle. Es una cadena de cuatro caracteres que reordena o fija los canales que el shader ve:

const device = await adapter.requestDevice({
  requiredFeatures: adapter.features.has('texture-component-swizzle')
    ? ['texture-component-swizzle'] : [],
});

// Una textura de un canal vista como escala de grises opaca.
const gris = t.createView({ swizzle: 'rrr1' });

Cada posición corresponde a un canal de salida —rojo, verde, azul, alfa— y cada carácter puede ser r, g, b, a, 0 o 1. Sirve para reutilizar un shader con texturas de distinta disposición de canales sin compilar variantes, y para corregir ordenaciones de canal de formatos importados sin tocar los datos. Si la feature no está habilitada, el campo se ignora en silencio, así que hay que comprobarlo antes de depender de él.

Crear vistas en el bucle de dibujado es un coste que nadie mide

createView() parece gratis porque no copia memoria, y por eso aparece escrito dentro del bucle de dibujado en más proyectos de los que debería: un textura.createView() en línea dentro de un createBindGroup, dentro de un bucle, dentro de un frame. No lo es. Cada llamada crea un objeto de JavaScript, atraviesa el binding hacia el código nativo, valida el descriptor completo contra la textura y crea el objeto interno correspondiente, que en Vulkan es un VkImageView de verdad con su reserva de memoria del driver. Doscientas llamadas por frame es un cuarto de millón al minuto, con su presión de recolección y su ocupación en el caché del driver. La regla es la misma que con los bind groups: la vista es un objeto de larga vida, se crea junto a la textura y se guarda al lado. La única excepción legítima es context.getCurrentTexture().createView() en el frame, porque la textura del canvas cambia en cada frame y no hay alternativa.