wandres.dev
ONTOLOGÍA · El mapa de WebGPU

El modelo de objetos completo: quién crea a quién y cuánto vive

El grafo entero de objetos de WebGPU, las cuatro familias en que se agrupan, qué es inmutable, qué es de un solo uso y qué se destruye a mano.

⏱ 19 min

WebGPU tiene alrededor de veinticinco tipos de objeto y todos cuelgan, directa o indirectamente, de uno solo. Aprenderlos de uno en uno según aparecen en un tutorial produce la sensación de que la API es un saco de cosas sin relación. Vistos como un grafo con reglas de creación y reglas de vida, resulta que hay cuatro familias, cada una con un comportamiento uniforme, y que casi todo lo que puede salir mal es una violación de esas reglas.

🎯 Al terminar esta lección sabrás
  • Dibujar de memoria el grafo de creación de objetos de WebGPU.
  • Clasificar cada objeto en una de las cuatro familias y deducir su comportamiento.
  • Distinguir qué objetos son inmutables, cuáles son de un solo uso y cuáles hay que destruir.
  • Predecir qué falla cuando se usa un objeto fuera de su ventana de validez.

El grafo

Todo empieza en navigator.gpu, el único punto de entrada, y desemboca en la cola. Entre medias, el grafo tiene esta forma:

flowchart TB
nav[navigator gpu] --> ad[GPUAdapter]
ad --> dev[GPUDevice]
dev --> q[GPUQueue una sola]
dev --> buf[GPUBuffer]
dev --> tex[GPUTexture]
dev --> sam[GPUSampler]
dev --> sm[GPUShaderModule]
dev --> bgl[GPUBindGroupLayout]
dev --> pl[GPUPipelineLayout]
dev --> bg[GPUBindGroup]
dev --> rp[GPURenderPipeline]
dev --> cp[GPUComputePipeline]
dev --> enc[GPUCommandEncoder]
tex --> tv[GPUTextureView]
buf --> bg
tv --> bg
sam --> bg
bgl --> bg
bgl --> pl
sm --> rp
sm --> cp
pl --> rp
pl --> cp
enc --> pass[GPURenderPassEncoder o GPUComputePassEncoder]
rp --> pass
cp --> pass
bg --> pass
enc --> cb[GPUCommandBuffer]
cb --> q
canvas[GPUCanvasContext] --> frame[Textura del frame]
frame --> tv
style nav fill:#cba6f7,color:#11111b
style ad fill:#cba6f7,color:#11111b
style dev fill:#a6e3a1,color:#11111b
style q fill:#fab387,color:#11111b
style buf fill:#94e2d5,color:#11111b
style tex fill:#94e2d5,color:#11111b
style sam fill:#94e2d5,color:#11111b
style tv fill:#94e2d5,color:#11111b
style sm fill:#89b4fa,color:#11111b
style bgl fill:#89b4fa,color:#11111b
style pl fill:#89b4fa,color:#11111b
style bg fill:#89b4fa,color:#11111b
style rp fill:#89b4fa,color:#11111b
style cp fill:#89b4fa,color:#11111b
style enc fill:#f9e2af,color:#11111b
style pass fill:#f9e2af,color:#11111b
style cb fill:#f9e2af,color:#11111b
style canvas fill:#f38ba8,color:#11111b
style frame fill:#f38ba8,color:#11111b

Dos hechos del diagrama merecen atención inmediata. El primero es que el dispositivo es la fábrica de casi todo: salvo las vistas de textura, que las crea la textura, y los pases, que los crea el encoder, cada objeto nace de un método device.createAlgo(descriptor). Si buscas cómo crear algo y no lo encuentras, míralo en GPUDevice. El segundo es que hay exactamente un sumidero: queue.submit(). Todo lo demás es preparación.

Los colores marcan las cuatro familias, y esa clasificación es la que de verdad hay que memorizar.

Las cuatro familias

Familia uno: los singletons de arranque. GPUAdapter y GPUDevice. Se obtienen con promesas, se obtienen una vez y viven todo el programa. El adaptador representa una implementación concreta sobre una GPU concreta y solo sirve para consultar capacidades y pedir un dispositivo; una vez usado para pedir un dispositivo queda consumido y no puede dar otro. El dispositivo es tu conexión lógica: todos los objetos que crea pertenecen a él y no se pueden mezclar con los de otro dispositivo. GPUQueue es un tercer singleton, accesible como device.queue, que no se crea sino que ya existe.

Familia dos: los recursos. GPUBuffer, GPUTexture, GPUSampler, y GPUTextureView como derivada de la textura. Son memoria. Se crean con un descriptor que fija tamaño, formato y usos permitidos, y esos parámetros son inmutables: no puedes redimensionar un búfer ni cambiarle el usage. El contenido sí cambia. Búferes y texturas ocupan memoria de vídeo de verdad y son los únicos objetos con un destroy() que conviene llamar; los demás los recoge el recolector de basura sin drama.

Familia tres: las descripciones de estado. GPUShaderModule, GPUBindGroupLayout, GPUPipelineLayout, GPUBindGroup, GPURenderPipeline y GPUComputePipeline. Son inmutables en su totalidad, incluido el contenido: un pipeline creado es un objeto congelado que describe una configuración completa y validada. Crearlos es caro —hay compilación de shaders detrás— y usarlos es barato. La consecuencia de diseño es clara: se crean fuera del bucle de render y se reutilizan. Un GPUBindGroup es la excepción parcial: es inmutable como objeto, pero apunta a recursos cuyo contenido sí puede cambiar, y por eso puedes actualizar un búfer uniforme sin recrear el bind group.

Familia cuatro: los efímeros. GPUCommandEncoder, GPURenderPassEncoder, GPUComputePassEncoder, GPURenderBundleEncoder y GPUCommandBuffer. Viven un fotograma o menos. Un encoder se crea, se le graban comandos, se cierra con finish() y muere; el GPUCommandBuffer resultante se envía una vez y muere. Son los únicos objetos de la API con una máquina de estados interna, y prácticamente todos los errores de «invalid state» que verás al empezar vienen de esta familia: llamar a algo después de end(), olvidar end() antes de finish(), o reutilizar un command buffer ya enviado.

// Familias 1 a 3: una vez, al arrancar.
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

const modulo = device.createShaderModule({ label: 'principal', code: wgsl });
const pipeline = device.createRenderPipeline({ /* ... */ });
const uniformes = device.createBuffer({
  size: 64,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
const grupo = device.createBindGroup({ /* ... */ });

// Familia 4: cada fotograma, desechable.
function frame() {
  const encoder = device.createCommandEncoder();
  const pase = encoder.beginRenderPass({ /* ... */ });
  pase.setPipeline(pipeline);
  pase.setBindGroup(0, grupo);
  pase.draw(3);
  pase.end();
  device.queue.submit([encoder.finish()]);
  requestAnimationFrame(frame);
}

Ese esqueleto —tres bloques arriba que no se repiten, un bloque abajo que se repite— es la forma canónica de cualquier programa de WebGPU, desde el triángulo hasta un motor completo.

Etiquetas, validez y muerte

Cada descriptor acepta label, una cadena arbitraria. No cuesta nada y cambia por completo la experiencia de depuración: los mensajes de validación citan la etiqueta del objeto implicado, y sin ella recibes «Buffer is invalid» sin más pistas. Ponla siempre, desde el primer día.

WebGPU tiene un concepto que no existe en otras APIs web: un objeto puede estar vivo y ser inválido. Cuando la creación falla la validación, createBuffer no lanza una excepción ni devuelve null: devuelve un GPUBuffer marcado como inválido y emite un error por el canal de errores. Ese objeto se puede pasar por ahí, y el fallo aparece más tarde, cuando alguien intenta usarlo. Es un diseño deliberado —permite que la API sea síncrona sin obligar a comprobar cada retorno— y es la razón de que los errores de WebGPU aparezcan a veces lejos de su causa.

La destrucción tiene tres formas. buffer.destroy() y texture.destroy() liberan la memoria de inmediato; cualquier uso posterior es un error de validación. device.destroy() invalida el dispositivo entero y resuelve la promesa device.lost con motivo "destroyed". Y para todo lo demás no hay destrucción explícita: el recolector de basura de JavaScript se encarga cuando ya no hay referencias.

El grafo es de creación, no de propiedad, y confundirlos es una fuga de memoria

Una lectura razonable del diagrama es «el pipeline contiene el shader module, así que si guardo el pipeline puedo soltar el módulo». Razonable y equivocada en su consecuencia práctica. Las flechas describen qué hace falta para crear qué, no qué mantiene vivo a qué desde el punto de vista de la memoria de vídeo.

La regla real es incómoda: la memoria de vídeo no la libera el recolector de basura de forma predecible. Un GPUTexture de 4096 por 4096 en RGBA de 8 bits son 64 MiB de VRAM, y esos 64 MiB siguen ocupados desde que sueltas la última referencia hasta que al recolector le apetece pasar, que puede ser mucho después o puede ser nunca si hay una referencia colgando en un cierre. En una aplicación que recrea sus render targets al redimensionar la ventana, eso se convierte en un consumo que sube de forma monótona hasta que el navegador pierde el dispositivo, y el síntoma —«se cae al cabo de un rato mientras redimensiono»— no apunta a la causa.

El hábito que lo evita es explícito y aburrido: si un búfer o una textura tiene un tamaño no trivial, llama a destroy() en cuanto dejes de necesitarlo, y trata el redimensionado como un ciclo destruir-crear, nunca como un crear-y-olvidar. Los objetos de la familia tres puedes soltarlos sin ceremonia; los de la familia dos, no.

Con el grafo claro, lo que queda es el orden de aprendizaje: qué parte de todo esto hay que dominar primero y cuál puede esperar. Eso está en el mapa del territorio.