wandres.dev
RENDER TARGETS · Dibujar a textura

Renderizar a textura: la misma máquina apuntando a otro sitio

Qué cambia exactamente al dibujar fuera de pantalla, las tres coherencias que exige la validación entre textura, vista y pipeline, y por qué la textura del canvas es un caso particular y no el caso general.

⏱ 17 min

Casi todo lo interesante que hace un renderizador moderno ocurre fuera de la pantalla. Los mapas de sombras, el bloom, el desenfoque, los reflejos, la oclusión ambiental, el antialiasing temporal y cualquier arquitectura diferida son pasadas que escriben en texturas que nadie ve directamente. La buena noticia es que dibujar a una textura no requiere ninguna API nueva: es el mismo render pass con otro view en el attachment. La menos buena es que hay tres coherencias que la validación comprueba y una cuarta que no comprueba nadie.

🎯 Al terminar esta lección sabrás
  • Crear una textura utilizable como destino de render y como recurso muestreado.
  • Enumerar las coherencias que exige la validación entre textura, vista, pipeline y pass.
  • Dirigir un pass a un nivel de mipmap o a una capa concreta mediante la vista.
  • Explicar en qué se diferencia la textura del canvas de cualquier otra textura de destino.

La textura de destino

Una textura sirve de destino de render si tiene el uso RENDER_ATTACHMENT y un formato renderizable. Si además vas a leerla después desde un shader, cosa que ocurre en el 95% de los casos, necesita también TEXTURE_BINDING. Y si vas a copiarla a un búfer para leerla en la CPU, COPY_SRC.

const objetivo = device.createTexture({
  label: 'escena hdr',
  size: [ancho, alto],
  format: 'rgba16float',
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});

const pass = encoder.beginRenderPass({
  label: 'escena',
  colorAttachments: [{
    view: objetivo.createView(),
    clearValue: { r: 0, g: 0, b: 0, a: 1 },
    loadOp: 'clear',
    storeOp: 'store',       // aqui si: lo vamos a leer despues
  }],
  depthStencilAttachment: {
    view: profundidad.createView(),
    depthClearValue: 1.0,
    depthLoadOp: 'clear',
    depthStoreOp: 'discard',
  },
});

Declarar usos de más no es gratis. Algunos backends eligen una disposición de memoria distinta —comprimida, con metadatos de compresión sin pérdida— según los usos declarados, y añadir TEXTURE_BINDING o STORAGE_BINDING a una textura que solo va a ser destino puede desactivar esa compresión. La regla es pedir exactamente lo que vas a usar.

Las tres coherencias

La validación de WebGPU comprueba tres correspondencias, y las tres producen errores claros en el momento correcto, no artefactos.

El formato. El format declarado en el elemento correspondiente de targets del pipeline tiene que ser idéntico al formato de la vista del attachment. No hay conversión implícita: un pipeline escrito para bgra8unorm no dibuja en una textura rgba16float. Esto obliga a algo que sorprende al venir de WebGL: necesitas un pipeline por combinación de formatos de destino. El mismo material dibujado al canvas y a un target HDR son dos pipelines, y esa es una de las razones por las que las variantes de pipeline se multiplican en un motor real.

El número de muestras. El count del bloque multisample del pipeline tiene que coincidir con el sampleCount de la textura de todos los attachments del pass.

El tamaño. Todos los attachments de un pass —color y profundidad— tienen que tener exactamente las mismas dimensiones de render. No hay recorte automático ni escalado: si tu textura de profundidad se quedó con el tamaño anterior tras un redimensionado de la ventana, beginRenderPass falla.

Y la cuarta, la que nadie comprueba: el espacio de color en el que escribes. Si el destino es un formato -srgb, el hardware codifica al escribir y decodifica al leer, así que tu shader trabaja en lineal y todo cuadra. Si el destino es rgba8unorm a secas y escribes valores lineales, estás guardando lineal en ocho bits, que es exactamente donde se ve el banding en las zonas oscuras. Nadie te avisará; solo verás escalones en un degradado y tardarás en atribuirlos a esto.

La vista decide a qué subrecurso apuntas

El attachment no recibe una textura sino una vista, y ahí está toda la flexibilidad. Una vista de attachment tiene que resolver a un único subrecurso: un nivel de mipmap y una capa de array. Eso significa que para dibujar en el nivel 2 de la cadena de mipmaps hay que pedirlo explícitamente:

// Dibujar directamente en un nivel concreto de mipmap.
const vistaNivel2 = objetivo.createView({
  baseMipLevel: 2,
  mipLevelCount: 1,
});

// Dibujar en una cara concreta de un cubemap, o en una capa de un array.
const vistaCara = cubo.createView({
  dimension: '2d',
  baseArrayLayer: 3,
  arrayLayerCount: 1,
});

Ese mecanismo es el que sostiene tres técnicas de uso diario. La cadena de reducción del bloom, que dibuja sucesivamente en niveles cada vez más pequeños de la misma textura. La generación de un cubemap dinámico, con seis passes que apuntan a seis capas. Y los mapas de sombras en atlas o en array de texturas, con un pass por luz apuntando a su capa. En los tres casos el recurso es uno solo y lo que cambia entre passes es la vista.

Para texturas tridimensionales existe además depthSlice en el propio attachment de color, que selecciona el corte de profundidad al que se escribe sin necesidad de crear una vista distinta.

El pipeline depende del formato del destino, y de ahí sale la explosión combinatoria que hunde el tiempo de arranque de los motores

Que el formato del target forme parte del estado inmutable del pipeline parece un detalle burocrático de la validación previa, y es la decisión de diseño con más consecuencias prácticas de todo este nivel. En WebGL podías dibujar el mismo material en cualquier framebuffer porque el estado era global y se resolvía al vincular; en WebGPU, cada combinación de formatos de destino es un objeto distinto que hay que crear y compilar. Multiplica: si tienes ochenta materiales y los dibujas al target HDR de la escena, al mapa de sombras que solo tiene profundidad, al pase de identificadores en r32uint para la selección y al canvas para el modo de depuración, tienes hasta trescientos veinte pipelines. Compilar cada uno cuesta entre unos milisegundos y varias décimas de segundo según el backend, y ese coste aparece la primera vez que se usa cada uno, es decir, en forma de tirones durante los primeros segundos de la aplicación, justo donde más se notan. Hay tres defensas y conviene montarlas desde el principio. La primera es reducir la variedad de formatos por decreto: un único formato HDR para toda la escena, un único formato de profundidad, y resistirse a la tentación de que cada efecto tenga el suyo. La segunda es createRenderPipelineAsync(), que compila fuera del hilo crítico y devuelve una promesa; el patrón correcto es lanzar todas las compilaciones durante la pantalla de carga y esperarlas antes de dibujar el primer fotograma, no crear pipelines dentro del bucle. Y la tercera, la que casi nadie hace y más devuelve: una función de caché con clave textual que componga material, formatos y estado, de modo que dos rutas del código que necesiten el mismo pipeline compartan el objeto en vez de crear dos. Sin esa caché, el número de pipelines de un motor crece de forma silenciosa hasta que alguien mide y descubre que hay seiscientos, de los cuales cuarenta se usan.

El canvas no es especial, salvo en una cosa

La textura del canvas se obtiene con context.getCurrentTexture() y es una textura normal: se le pide una vista, se pone en un attachment y se dibuja. Su uso por defecto es RENDER_ATTACHMENT, y si quieres además copiarla o muestrearla tienes que declararlo en configure(), en el campo usage.

La diferencia está en el ciclo de vida. La textura que devuelve getCurrentTexture() solo es válida durante el fotograma actual: pertenece a la cadena de intercambio y el navegador la recicla al presentar. Guardar la vista en una variable de módulo y reutilizarla al fotograma siguiente es el error clásico, y el síntoma es un pass que falla o que dibuja en una textura que ya nadie mira. La llamada es barata; hazla cada fotograma.

De ahí sale también un patrón que conviene interiorizar: el canvas es el último eslabón, no el primero. Un renderizador serio dibuja la escena en un target HDR propio, encadena ahí sus efectos, y en la última pasada mapea el rango dinámico y escribe al canvas. Esa última pasada es normalmente la única que toca la textura de la cadena de intercambio, y tenerla aislada hace trivial añadir después una etapa más, cambiar el formato interno o renderizar a resolución distinta de la de presentación.