wandres.dev
EL CANVAS · Contexto, formato y configuración

configure: los siete campos del descriptor del canvas

Cada miembro de GPUCanvasConfiguration explicado, qué desbloquea usage, para qué sirve viewFormats, y qué hace colorSpace con los colores que escribe tu shader.

⏱ 18 min

configure() recibe un objeto de siete campos, de los que casi todo el mundo usa dos. Los otros cinco existen porque resuelven problemas concretos que aparecen en cuanto la aplicación deja de ser una demo: leer píxeles del canvas, aplicar corrección gamma sin pagarla dos veces, trabajar en un espacio de color amplio, o componer el resultado con el resto de la página.

🎯 Al terminar esta lección sabrás
  • Enumerar los siete campos de GPUCanvasConfiguration y su valor por defecto.
  • Elegir los flags de usage según lo que vayas a hacer con la textura del frame.
  • Explicar para qué sirve viewFormats y su relación con sRGB.
  • Reconfigurar el contexto de forma segura y saber cuándo hace falta.

Los siete campos

context.configure({
  device,                                          // requerido
  format: navigator.gpu.getPreferredCanvasFormat(), // requerido
  usage: GPUTextureUsage.RENDER_ATTACHMENT,        // por defecto
  viewFormats: [],                                 // por defecto
  colorSpace: 'srgb',                              // por defecto
  toneMapping: { mode: 'standard' },               // por defecto
  alphaMode: 'opaque',                             // por defecto
});

device es obligatorio y es la asociación que hace útil al contexto. Si el dispositivo se pierde, esta configuración deja de valer y hay que rehacerla con el nuevo.

format es obligatorio y determina el formato de la textura que devuelve getCurrentTexture(). Tiene su propia lección porque la respuesta correcta casi siempre es navigator.gpu.getPreferredCanvasFormat() y el porqué no es evidente.

usage son los flags de uso de esa textura. Por defecto solo RENDER_ATTACHMENT, que es lo mínimo para poder dibujar en ella. Se amplía cuando quieres hacer algo más, y merece su propio apartado.

viewFormats es la lista de formatos alternativos con los que podrás crear vistas de la textura. También tiene su apartado.

colorSpace admite 'srgb' —el valor por defecto— y 'display-p3'. Determina cómo interpreta el compositor los valores que escribe tu shader al llevarlos a la pantalla. Con 'display-p3', los mismos números representan colores más saturados en pantallas capaces, y es el camino para gama amplia.

toneMapping es un objeto con un campo mode, que admite 'standard' y 'extended'. Controla qué pasa con los valores fuera del rango de 0 a 1 al presentar: 'standard' los recorta, 'extended' permite que lleguen a la pantalla si esta admite alto rango dinámico. La especificación indica que las implementaciones que no lo soporten no deberían exponer el miembro, así que se detecta con getConfiguration().

alphaMode admite 'opaque' —por defecto— y 'premultiplied', y controla cómo se compone el canvas con lo que hay debajo. Tiene su propia lección.

usage: lo que puedes hacer con la textura del frame

Por defecto, la textura del contexto solo sirve como destino de dibujado. Si quieres más, hay que declararlo, y hay tres casos que aparecen de verdad.

Copiar el resultado, para una captura de pantalla o para un post-proceso posterior. Necesita COPY_SRC:

context.configure({
  device,
  format,
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});

Escribir en ella desde un compute shader, sin pasar por el rasterizador. Necesita STORAGE_BINDING, y aquí hay un matiz importante: el formato preferido del canvas es con frecuencia bgra8unorm, y usar ese formato como textura de almacenamiento requiere la feature bgra8unorm-storage. Sin ella, la combinación falla en validación.

const puedeStorage = device.features.has('bgra8unorm-storage');
context.configure({
  device,
  format,
  usage: puedeStorage
    ? GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.STORAGE_BINDING
    : GPUTextureUsage.RENDER_ATTACHMENT,
});

Copiar algo dentro de ella desde otra textura, en lugar de dibujarla. Necesita COPY_DST.

La regla general es no pedir más de lo que usas. Declarar usos extra puede obligar a la implementación a elegir una disposición de memoria menos eficiente o a desactivar compresión interna del framebuffer, y eso se paga en ancho de banda en cada fotograma.

viewFormats y el problema de sRGB

Este campo confunde hasta que se entiende qué problema resuelve, y entonces resulta obvio.

Los formatos de textura vienen en dos sabores: bgra8unorm y bgra8unorm-srgb, rgba8unorm y rgba8unorm-srgb. Los mismos bytes; lo que cambia es la conversión automática. Al escribir en un formato -srgb, el hardware convierte de lineal a sRGB; al leer, convierte de sRGB a lineal. Esa conversión es gratis porque la hace hardware de función fija, y es la forma correcta de trabajar: iluminación en espacio lineal, almacenamiento en sRGB.

El problema es que la textura del canvas tiene un formato fijado en configure(), y a veces necesitas verla de las dos formas. Un pase de render que quiere la conversión automática necesita una vista -srgb; un compute shader que escribe valores crudos, o un pase de interfaz que trabaja directamente en el espacio de la pantalla, la quiere sin.

viewFormats declara qué formatos alternativos vas a usar al crear vistas:

context.configure({
  device,
  format: 'bgra8unorm',
  viewFormats: ['bgra8unorm-srgb'],
});

// Vista con el formato de la configuracion: valores crudos.
const vistaCruda = context.getCurrentTexture().createView();

// Vista con conversion automatica a sRGB al escribir.
const vistaSrgb = context.getCurrentTexture().createView({
  format: 'bgra8unorm-srgb',
});

Solo se permiten formatos compatibles, es decir, que difieran únicamente en el sufijo -srgb. No puedes ver una textura de 8 bits como si fuera de 16.

Y hay un motivo por el que esto se declara en lugar de permitirse siempre: algunas GPUs comprimen internamente el contenido del framebuffer, y esa compresión puede depender del formato. Declarar de antemano que la textura se verá de dos formas permite a la implementación elegir una configuración que sirva para ambas, o desactivar la optimización si no es posible. Si se permitiera crear cualquier vista en cualquier momento, habría que desactivarla siempre.

💡
La regla simple para sRGB en el canvas

Si tu pipeline de render trabaja en espacio lineal —que es lo correcto para cualquier cosa con iluminación— configura el canvas con el formato preferido y crea las vistas con el formato -srgb correspondiente, declarándolo en viewFormats. El hardware hace la conversión final gratis y tus colores no salen lavados. Si en cambio tu contenido ya está en espacio de pantalla —interfaz, texto, imágenes que no vas a iluminar— usa el formato sin sufijo y no conviertas nada.

Reconfigurar

configure() se puede llamar varias veces sobre el mismo contexto. Cada llamada reemplaza la configuración anterior y expira la textura actual, lo cual es exactamente lo que quieres en los tres casos en que hace falta.

Al redimensionar el canvas. En realidad no es obligatorio reconfigurar tras cambiar canvas.width o canvas.height: la especificación indica que redimensionar el canvas también expira la textura actual, y la siguiente llamada a getCurrentTexture() devuelve una del tamaño nuevo. Pero sí hay que redimensionar las texturas propias —el búfer de profundidad, los objetivos intermedios— y ese es el trabajo real.

Tras perder el dispositivo. La configuración apunta al dispositivo viejo. Hay que reconfigurar con el nuevo.

Al cambiar de espacio de color o de modo alfa en tiempo de ejecución, por ejemplo si el usuario alterna entre un modo estándar y uno de gama amplia.

Lo que sí hay que llamar es unconfigure() cuando la vista se desmonta y el canvas va a seguir en el DOM sin usarse. Libera la cadena de imágenes, que en un canvas grande son varios megabytes de memoria de vídeo por búfer.

El formato del canvas y el formato del pipeline son el mismo dato en dos sitios, y sincronizarlos a mano es una bomba de relojería

Aquí está uno de los errores más frustrantes de WebGPU, porque el mensaje de validación es claro y aun así la gente pierde horas: el format de cada objetivo de color del pipeline tiene que coincidir exactamente con el formato de la vista que se adjunta al pase. Si configuras el canvas en bgra8unorm y creas el pipeline con targets: [{ format: 'rgba8unorm' }], el pase falla.

Ocurre por una razón muy concreta: getPreferredCanvasFormat() devuelve bgra8unorm en unas plataformas y rgba8unorm en otras. En tu Mac te da uno; en el Windows de un compañero, el otro. El código que escribe el formato a mano en el descriptor del pipeline funciona en tu equipo y falla en el suyo, y el informe llega como «no compila el pipeline» sin más contexto.

La disciplina que lo elimina es no escribir el formato dos veces. Una constante calculada al arrancar, usada en la configuración del canvas y en todos los descriptores de pipeline que dibujen a él:

const FORMATO_CANVAS = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format: FORMATO_CANVAS });
const pipeline = device.createRenderPipeline({
  // ...
  fragment: { module, entryPoint: 'fs', targets: [{ format: FORMATO_CANVAS }] },
});

Y la generalización, que vale para todo el proyecto: cada formato de textura del render debe existir en una sola constante. El del canvas, el de profundidad, el de cada objetivo intermedio. En cuanto tienes tres pases encadenados, el número de sitios donde un formato tiene que coincidir con otro crece rápido, y es un tipo de error que la validación detecta pero que la depuración no localiza, porque el mensaje te dice que dos cosas no coinciden y no cuál de las dos está mal.

Queda por explicar de dónde sale ese formato preferido y por qué existe la función: el formato preferido del canvas.