wandres.dev
TEXTURAS I · Crear, subir y muestrear

Subir datos: writeTexture y copyExternalImageToTexture

Los dos caminos para llenar una textura desde la CPU, el cálculo de bytesPerRow y rowsPerImage, la regla de los 256 bytes que solo aplica a uno de ellos, y cómo cargar una imagen bien.

⏱ 19 min

Hay dos formas de meter datos en una textura desde JavaScript, y eligen distinto cuando el dato es una imagen decodificada por el navegador o cuando es un array de bytes que has calculado tú. Elegir mal no falla: hace una copia de más, o te obliga a un cálculo de padding que la otra API no exige. La diferencia entre ambas está en un detalle de alineación que la especificación pone en un solo sitio y casi nadie encuentra.

🎯 Al terminar esta lección sabrás
  • Subir un array de bytes con queue.writeTexture() calculando bien bytesPerRow.
  • Subir una imagen o un vídeo con queue.copyExternalImageToTexture() y sus opciones.
  • Distinguir dónde aplica la alineación de 256 bytes y dónde no.
  • Cargar una imagen desde red a una textura con el camino más corto.

writeTexture: bytes desde la CPU

La firma es writeTexture(destination, data, dataLayout, size).

const ancho = 4, alto = 4;
const pixeles = new Uint8Array(ancho * alto * 4);
for (let i = 0; i < ancho * alto; i++) {
  pixeles.set([255, (i * 16) & 255, 0, 255], i * 4);
}

device.queue.writeTexture(
  { texture, mipLevel: 0, origin: [0, 0, 0], aspect: 'all' },
  pixeles,
  { offset: 0, bytesPerRow: ancho * 4, rowsPerImage: alto },
  [ancho, alto, 1],
);

destination describe dónde escribir: la textura, el nivel de mip (por defecto 0), el origen dentro de ese nivel (por defecto [0, 0, 0]) y el aspecto ('all', 'depth-only' o 'stencil-only', por defecto 'all'). La textura necesita GPUTextureUsage.COPY_DST y sampleCount igual a 1.

data es un ArrayBuffer, un TypedArray o un DataView.

dataLayout describe cómo están dispuestos los bytes de origen. offset es dónde empiezan dentro de data. bytesPerRow es la distancia en bytes entre el inicio de una fila de bloques y el inicio de la siguiente; es obligatorio si copias más de una fila. rowsPerImage es el número de filas de bloques por imagen, obligatorio si copias más de una imagen —más de una capa o más de un slice—.

size es la extensión de la región a escribir.

ℹ️
writeTexture no exige alineación de 256 bytes

La regla de que bytesPerRow tiene que ser múltiplo de 256 se repite mucho y es falsa para writeTexture. Aplica a copyBufferToTexture y a copyTextureToBuffer, que son los métodos del command encoder que copian entre un GPUBuffer y una textura. queue.writeTexture es una función de conveniencia que deja al agente de usuario elegir el camino, y no impone esa alineación. Si estás rellenando manualmente filas con padding a 256 para usar writeTexture, estás haciendo trabajo de más.

Las filas de bloques y los formatos comprimidos

El nombre exacto de la unidad es fila de bloques, no fila de píxeles, y la distinción importa con formatos comprimidos. Un formato como bc7-rgba-unorm tiene bloques de 4×4 texels que ocupan 16 bytes. Para una textura de 256×256 en ese formato:

const bloquesX = 256 / 4;        // 64 bloques por fila
const bloquesY = 256 / 4;        // 64 filas de bloques
const bytesPorBloque = 16;

device.queue.writeTexture(
  { texture: comprimida },
  datosBC7,
  { bytesPerRow: bloquesX * bytesPorBloque,   // 1024
    rowsPerImage: bloquesY },                  // 64
  [256, 256, 1],
);

El size sigue expresándose en texels, no en bloques. Y el origin de un copiado tiene que ser múltiplo del tamaño de bloque en cada eje: no puedes escribir un rectángulo que empiece en el texel 3 de una textura BC.

Para formatos no comprimidos el bloque es de 1×1 texel, así que la fila de bloques coincide con la fila de píxeles y el cálculo es el habitual: bytesPerRow = ancho × bytesPorTexel.

copyExternalImageToTexture: imágenes y vídeo

La segunda vía toma un origen que el navegador ya tiene decodificado y lo copia sin pasar por JavaScript. La firma es copyExternalImageToTexture(source, destination, copySize).

const bitmap = await createImageBitmap(blob);

device.queue.copyExternalImageToTexture(
  { source: bitmap, origin: [0, 0], flipY: false },
  { texture, mipLevel: 0, origin: [0, 0, 0],
    colorSpace: 'srgb', premultipliedAlpha: false },
  [bitmap.width, bitmap.height],
);

El source admite HTMLCanvasElement, HTMLImageElement, HTMLVideoElement, ImageBitmap, ImageData, OffscreenCanvas y VideoFrame. El contenido se captura en el instante exacto de la llamada.

Del lado del destino hay dos opciones que no existen en writeTexture y que resuelven problemas reales. colorSpace ('srgb' por defecto, o 'display-p3') indica el espacio de color con el que codificar los datos en la textura. premultipliedAlpha (false por defecto) premultiplica los canales RGB por el alfa durante la copia, que es lo que quieres si vas a componer con blending premultiplicado.

Y en el origen, flipY invierte verticalmente la imagen durante la copia. Es la respuesta al eterno problema del origen de coordenadas: las imágenes de la web tienen el origen arriba a la izquierda y muchas convenciones de textura lo ponen abajo.

La textura destino tiene requisitos estrictos: COPY_DST y RENDER_ATTACHMENT en el usage, dimension: '2d', sampleCount 1, y un formato de la lista de formatos renderizables que la especificación enumera: r8unorm, r16float, r32float, rg8unorm, rg16float, rg32float, rgba8unorm, rgba8unorm-srgb, bgra8unorm, bgra8unorm-srgb, rgb10a2unorm, rgba16float y rgba32float.

Si el origen es de otro dominio sin CORS, la llamada lanza un SecurityError.

Cargar una imagen bien

El camino más corto desde una URL hasta una textura muestreable, sin pasos intermedios innecesarios:

async function cargarTextura(device, url, { srgb = true, mips = true } = {}) {
  const respuesta = await fetch(url);
  const blob = await respuesta.blob();
  const bitmap = await createImageBitmap(blob, { colorSpaceConversion: 'none' });

  const niveles = mips ? 1 + Math.floor(Math.log2(Math.max(bitmap.width, bitmap.height))) : 1;

  const textura = device.createTexture({
    label: url,
    size: [bitmap.width, bitmap.height, 1],
    format: srgb ? 'rgba8unorm-srgb' : 'rgba8unorm',
    mipLevelCount: niveles,
    usage: GPUTextureUsage.TEXTURE_BINDING
         | GPUTextureUsage.COPY_DST
         | GPUTextureUsage.RENDER_ATTACHMENT,
  });

  device.queue.copyExternalImageToTexture(
    { source: bitmap, flipY: false },
    { texture: textura },
    [bitmap.width, bitmap.height],
  );

  bitmap.close();          // libera la memoria de decodificación de inmediato
  return textura;
}

Cuatro decisiones merecen comentario. createImageBitmap sobre un Blob decodifica fuera del hilo principal, a diferencia de crear un Image y esperar a su decode(), que puede bloquear. colorSpaceConversion: 'none' evita que el navegador convierta los píxeles a sRGB antes de dártelos, lo cual duplicaría la conversión si además usas un formato -srgb. El formato -srgb hace que el hardware linealice al muestrear, que es lo correcto para texturas de color y no para mapas de normales o de rugosidad. Y bitmap.close() libera la copia decodificada, que para una textura de 4K son 64 MiB que si no se quedan esperando al recolector.

El formato srgb es una decisión de corrección, no de estética

La confusión entre rgba8unorm y rgba8unorm-srgb produce escenas que se ven casi bien, y por eso sobrevive tanto tiempo en los proyectos. La regla exacta es esta: una textura contiene color si sus valores representan luz que el ojo percibe, y contiene datos si sus valores representan cualquier otra cosa. El albedo es color y va en -srgb, porque el archivo PNG está codificado en sRGB y el shader necesita valores lineales para multiplicar por la iluminación. Un mapa de normales es datos: sus componentes son direcciones, y aplicarles la curva sRGB los tuerce. Un mapa de rugosidad, de oclusión o de altura es datos. Un mapa de emisión es color. El síntoma de equivocarse en el albedo es una escena que parece lavada o demasiado oscura según el sentido del error; el de equivocarse en un mapa de normales es un relieve que parece plano en las zonas suaves y exagerado en los bordes. Ninguno de los dos produce error, y ninguno de los dos se arregla ajustando la exposición.