wandres.dev
POR QUÉ WEBGPU · Los límites de WebGL

La validación en cada llamada: qué comprueba WebGL antes de dibujar

Qué tiene que verificar una implementación de WebGL en cada draw call, por qué no puede evitarlo, y cómo WebGPU mueve ese trabajo al momento de crear los objetos.

⏱ 17 min

Un navegador no puede pasarle tus comandos al controlador gráfico tal cual. Tiene que comprobar que son coherentes, porque un comando incoherente en una API nativa es comportamiento indefinido y en un navegador eso significa una vulnerabilidad. La pregunta interesante no es si hay validación, que la hay en las dos APIs, sino cuándo se paga: en WebGL, en cada llamada de dibujo, para siempre; en WebGPU, una vez al crear el objeto.

🎯 Al terminar esta lección sabrás
  • Enumerar qué comprueba una implementación de WebGL antes de aceptar un dibujo.
  • Explicar por qué esas comprobaciones no se pueden cachear en el modelo de WebGL.
  • Describir el desplazamiento de la validación al momento de creación en WebGPU.
  • Deducir qué queda por validar en tiempo de dibujo en WebGPU y por qué es poco.

La lista de comprobación de un draw call

Cuando llamas a gl.drawArrays, la implementación no puede asumir nada. Tiene que verificar, entre otras cosas:

Que hay un programa activo y que enlazó correctamente. Que cada atributo de vértice que el programa declara tiene un búfer enlazado y habilitado, y que ese búfer es lo bastante grande para el número de vértices pedido con el desplazamiento y el paso configurados. Que los tipos de los atributos son compatibles con lo que declara el shader. Que cada uniforme de tipo muestreador apunta a una unidad de textura que tiene una textura enlazada, que esa textura es del tipo correcto —2D, cúbica, array—, que está completa (todos los niveles de mipmap presentes y consistentes si el filtrado los usa), y que su formato es filtrable si el sampler pide filtrado lineal. Que ninguna textura enlazada para lectura está a la vez adjunta al framebuffer activo para escritura. Que el framebuffer activo está completo. Que los formatos de las salidas del shader son compatibles con los adjuntos del framebuffer. Que el estado de mezcla es válido para esos formatos. Que las dimensiones pedidas no exceden los límites de la implementación.

Son decenas de comprobaciones, algunas de ellas sobre estructuras de datos que hay que recorrer. Y hay que hacerlas todas, cada vez, porque en el modelo de WebGL cualquier llamada intermedia puede haber invalidado cualquier suposición previa. Enlazar una textura distinta cambia el resultado de la comprobación de completitud. Cambiar el programa cambia la lista de atributos requeridos. Borrar un búfer cambia el tamaño disponible.

La implementación puede cachear parte del trabajo con invalidación fina —y las implementaciones buenas lo hacen— pero el techo es duro: el estado es mutable y global, así que la caché hay que invalidarla desde muchos sitios, y el coste de mantener esa invalidación correcta se acerca al de comprobar.

ℹ️
Y esto ocurre en otro proceso

En un navegador moderno, el código de la página y el que habla con el controlador gráfico viven en procesos distintos por seguridad. Las llamadas de WebGL se serializan en un búfer de comandos y se envían al proceso de GPU, que las deserializa, las valida y las traduce. La validación no es solo trabajo de CPU: es trabajo de CPU en otro proceso, con serialización de por medio. Por eso la métrica que domina el rendimiento de WebGL no es el número de triángulos sino el número de llamadas.

El coste, en orden de magnitud

Conviene tener una intuición numérica aunque las cifras varíen mucho por máquina y navegador.

Una llamada de dibujo en WebGL cuesta, en el hilo principal, del orden de decenas de microsegundos cuando el estado cambia entre llamadas, y bastante menos cuando no cambia nada. A dieciséis milisegundos de presupuesto por fotograma, eso pone el techo práctico en el orden de uno o dos millares de llamadas de dibujo por fotograma antes de que la CPU sea el cuello de botella, y bastante menos en móvil. Un motor bien escrito gasta un esfuerzo enorme en no llegar ahí: agrupa objetos por material, fusiona geometrías, ordena para minimizar cambios de estado y usa instanciación siempre que puede.

Lo revelador es que esa cifra no depende de la GPU. Puedes poner la tarjeta más cara del mercado y el techo sigue donde estaba, porque el cuello está en el trabajo de validación y comunicación del lado de la CPU. Es la definición de estar limitado por el controlador, y es la razón principal por la que existió esta generación de APIs.

Dónde pone WebGPU la validación

WebGPU parte el trabajo en dos y mueve casi todo al momento de creación.

Al crear un GPURenderPipeline se valida todo lo que en WebGL se validaba en cada dibujo: que los shaders compilan, que las salidas del vertex shader encajan con las entradas del fragment shader, que los formatos de los atributos son compatibles con lo que declara el shader, que los formatos de los targets de color son válidos y coherentes con el estado de mezcla, que el formato de profundidad y estencil es consistente, que el número de muestras es soportado, que la disposición de recursos que el shader espera coincide con el GPUPipelineLayout. Todo eso se comprueba una vez, se guarda en un objeto inmutable, y no se vuelve a mirar.

Al crear un GPUBindGroup se valida que cada recurso es del tipo que el layout declara, que los búferes tienen el usage correcto, que los tamaños y desplazamientos respetan las alineaciones, que las vistas de textura tienen la dimensión y el formato esperados. Una vez creado, el bind group es una tupla de referencias verificada.

Al crear un GPURenderPassEncoder se valida que los adjuntos son coherentes entre sí: mismo tamaño, mismo número de muestras, formatos válidos, operaciones de carga y almacenamiento con sentido.

Lo que queda para el momento del dibujo es una lista corta y barata: que el pipeline enlazado es compatible con el layout de los bind groups enlazados, que los bind groups requeridos están puestos, que los búferes de vértices requeridos están puestos y son lo bastante grandes, y que ningún recurso está a la vez en lectura y escritura dentro del mismo pase. Comprobaciones sobre estructuras pequeñas y ya normalizadas.

// Todo esto es coste de creacion, una vez en la vida del programa.
const pipeline = device.createRenderPipeline({
  label: 'malla opaca',
  layout: device.createPipelineLayout({ bindGroupLayouts: [layoutEscena, layoutMaterial] }),
  vertex: { module, entryPoint: 'vs', buffers: [disposicionDeVertices] },
  fragment: { module, entryPoint: 'fs', targets: [{ format }] },
  primitive: { topology: 'triangle-list', cullMode: 'back' },
  depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
});

// Y esto es lo unico que se paga por objeto y por fotograma.
pase.setPipeline(pipeline);
pase.setBindGroup(1, materialA);
pase.draw(cuenta);

El efecto sobre el diseño de la aplicación es directo: crear pipelines es caro y crearlos durante el fotograma produce parones visibles, porque detrás hay una compilación de shaders que puede tardar milisegundos o decenas de milisegundos. La regla que se deriva es crear todos los pipelines en el arranque o en un momento de carga, nunca de forma perezosa la primera vez que un objeto aparece en cámara.

El compilador de shaders no está donde crees, y por eso el primer fotograma se atasca

createRenderPipeline retorna rápido. Eso engaña a mucha gente, porque la compilación de verdad —la traducción de WGSL al código máquina de esa GPU concreta, que es la parte cara— puede ocurrir de forma diferida: al crear el pipeline en un hilo de fondo, o incluso en el primer uso real dentro de un pase. La API tiene una versión asíncrona explícita, createRenderPipelineAsync, que devuelve una promesa que no se resuelve hasta que el pipeline está listo para usarse sin bloquear.

De ahí el patrón que separa una demo de un producto: crea los pipelines con la variante asíncrona durante la pantalla de carga y espera a todas las promesas antes de empezar a dibujar. Si los creas con la variante síncrona y dibujas inmediatamente, el coste no desaparece: se traslada al primer fotograma en que ese pipeline se usa, que suele ser justo cuando el usuario empieza a interactuar, y produce un parón de cientos de milisegundos que nadie sabe atribuir.

El corolario incómodo es que el número de pipelines es un presupuesto. Cada combinación de shader, formato de vértices, estado de mezcla y formato de salida es un pipeline distinto, y una arquitectura de materiales descuidada genera cientos sin querer. Contarlos y ponerles un techo desde el principio es más barato que descubrir a mitad de proyecto que el arranque tarda ocho segundos.

Validar menos por dibujo es la mitad de la ganancia. La otra mitad está en cuánto trabajo de CPU queda después de quitar la validación, y eso se ve en el coste de CPU por fotograma.