Pasar a la próxima WebGPU significa más que simplemente cambiar las API de gráficos. También es un paso hacia el futuro de los gráficos web. Pero esta migración resultará mejor con preparación y comprensión, y este artículo lo preparará.
Hola a todos, mi nombre es Dmitrii Ivashchenko y soy ingeniero de software en MY.GAMES. En este artículo, analizaremos las diferencias entre WebGL y la próxima WebGPU, y explicaremos cómo preparar su proyecto para la migración.
Cronología de WebGL y WebGPU
El estado actual de WebGPU y lo que está por venir
Diferencias conceptuales de alto nivel
Inicialización
• WebGL: el modelo de contexto
• WebGPU: el modelo de dispositivo
Programas y canalizaciones
• WebGL: Programa
• WebGPU: canalización
Uniformes
• Uniformes en WebGL 1
• Uniformes en WebGL 2
• Uniformes en WebGPU
Sombreadores
• Lenguaje de sombreado: GLSL frente a WGSL
• Comparación de tipos de datos
• Estructuras
• Declaraciones de funciones
• Funciones integradas
• Conversión de sombreador
Diferencias de convenciones
Texturas
• Espacio de ventana gráfica
• Recortar espacios
Consejos y trucos de WebGPU
• Minimizar el número de tuberías que utiliza.
• Cree canales por adelantado
• Utilice paquetes de renderizado
Resumen
WebGL , como muchas otras tecnologías web, tiene raíces que se remontan a un pasado bastante lejano. Para comprender la dinámica y la motivación detrás del cambio hacia WebGPU, es útil echar primero un vistazo rápido a la historia del desarrollo de WebGL:
En los últimos años, ha habido un gran interés en nuevas API de gráficos que brinden a los desarrolladores más control y flexibilidad:
Hoy en día, WebGPU está disponible en múltiples plataformas como Windows, Mac y ChromeOS a través de los navegadores Google Chrome y Microsoft Edge, a partir de la versión 113. Se espera compatibilidad con Linux y Android en un futuro próximo.
Estos son algunos de los motores que ya admiten (u ofrecen soporte experimental) para WebGPU:
Teniendo esto en cuenta, la transición a WebGPU o al menos preparar proyectos para dicha transición parece ser un paso oportuno en el futuro cercano.
Alejémonos y echemos un vistazo a algunas de las diferencias conceptuales de alto nivel entre WebGL y WebGPU, comenzando con la inicialización.
Al comenzar a trabajar con API de gráficos, uno de los primeros pasos es inicializar el objeto principal para la interacción. Este proceso difiere entre WebGL y WebGPU, con algunas peculiaridades para ambos sistemas.
En WebGL, este objeto se conoce como "contexto", que esencialmente representa una interfaz para dibujar en un elemento de lienzo HTML5. Obtener este contexto es bastante simple:
const gl = canvas.getContext('webgl');
El contexto de WebGL en realidad está vinculado a un lienzo específico. Esto significa que si necesita renderizar en varios lienzos, necesitará varios contextos.
WebGPU introduce un nuevo concepto llamado "dispositivo". Este dispositivo representa una abstracción de GPU con la que interactuarás. El proceso de inicialización es un poco más complejo que en WebGL, pero proporciona más flexibilidad:
const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); const context = canvas.getContext('webgpu'); context.configure({ device, format: 'bgra8unorm', });
Una de las ventajas de este modelo es que un dispositivo puede renderizar en varios lienzos o incluso en ninguno. Esto proporciona flexibilidad adicional; por ejemplo, un dispositivo puede controlar la representación en múltiples ventanas o contextos.
WebGL y WebGPU representan diferentes enfoques para gestionar y organizar el proceso de gráficos.
En WebGL, el foco principal está en el programa de sombreado. El programa combina sombreadores de vértices y fragmentos, definiendo cómo se deben transformar los vértices y cómo se debe colorear cada píxel.
const program = gl.createProgram(); gl.attachShader(program, vertShader); gl.attachShader(program, fragShader); gl.bindAttribLocation(program, 'position', 0); gl.linkProgram(program);
Pasos para crear un programa en WebGL:
Este proceso permite un control de gráficos flexible, pero también puede ser complejo y propenso a errores, especialmente para proyectos grandes y complejos.
WebGPU introduce el concepto de "canalización" en lugar de un programa independiente. Este canal combina no solo sombreadores sino también otra información, que en WebGL se establece como estados. Entonces, crear una canalización en WebGPU parece más complejo:
const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: shaderModule, entryPoint: 'vertexMain', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }], }, fragment: { module: shaderModule, entryPoint: 'fragmentMain', targets: [{ format, }], }, });
Pasos para crear una canalización en WebGPU:
Mientras WebGL separa cada aspecto del renderizado, WebGPU intenta encapsular más aspectos en un solo objeto, haciendo que el sistema sea más modular y flexible. En lugar de administrar sombreadores y estados de renderizado por separado, como se hace en WebGL, WebGPU combina todo en un solo objeto de canalización. Esto hace que el proceso sea más predecible y menos propenso a errores:
Las variables uniformes proporcionan datos constantes que están disponibles para todas las instancias de sombreado.
En WebGL básico, tenemos la capacidad de establecer variables uniform
directamente mediante llamadas API.
GLSL :
uniform vec3 u_LightPos; uniform vec3 u_LightDir; uniform vec3 u_LightColor;
JavaScript :
const location = gl.getUniformLocation(p, "u_LightPos"); gl.uniform3fv(location, [100, 300, 500]);
Este método es simple, pero requiere múltiples llamadas API para cada variable uniform
.
Con la llegada de WebGL 2, ahora tenemos la capacidad de agrupar variables uniform
en buffers. Aunque todavía puedes usar sombreadores de uniformes separados, una mejor opción es agrupar diferentes uniformes en una estructura más grande usando buffers uniformes. Luego, envía todos estos datos uniformes a la GPU a la vez, de manera similar a cómo puede cargar un búfer de vértices en WebGL 1. Esto tiene varias ventajas de rendimiento, como reducir las llamadas API y estar más cerca de cómo funcionan las GPU modernas.
GLSL :
layout(std140) uniform ub_Params { vec4 u_LightPos; vec4 u_LightDir; vec4 u_LightColor; };
JavaScript :
gl.bindBufferBase(gl.UNIFORM_BUFFER, 1, gl.createBuffer());
Para vincular subconjuntos de un búfer uniforme grande en WebGL 2, puede utilizar una llamada API especial conocida como bindBufferRange
. En WebGPU, hay algo similar llamado compensaciones de búfer uniformes dinámicas donde puede pasar una lista de compensaciones al llamar a la API setBindGroup
.
WebGPU nos ofrece un método aún mejor. En este contexto ya no se soportan variables uniform
individuales y se trabaja exclusivamente a través de buffers uniform
.
WGSL :
[[block]] struct Params { u_LightPos : vec4<f32>; u_LightColor : vec4<f32>; u_LightDirection : vec4<f32>; }; [[group(0), binding(0)]] var<uniform> ub_Params : Params;
JavaScript :
const buffer = device.createBuffer({ usage: GPUBufferUsage.UNIFORM, size: 8 });
Las GPU modernas prefieren que los datos se carguen en un bloque grande, en lugar de muchos bloques pequeños. En lugar de recrear y volver a vincular búferes pequeños cada vez, considere crear un búfer grande y usar diferentes partes del mismo para diferentes llamadas de sorteo. Este enfoque puede aumentar significativamente el rendimiento.
WebGL es más imperativo, restablece el estado global con cada llamada y se esfuerza por ser lo más simple posible. WebGPU, por otro lado, apunta a estar más orientado a objetos y centrado en la reutilización de recursos, lo que conduce a la eficiencia.
La transición de WebGL a WebGPU puede parecer difícil debido a las diferencias en los métodos. Sin embargo, comenzar con una transición a WebGL 2 como paso intermedio puede simplificarle la vida.
Migrar de WebGL a WebGPU requiere cambios no solo en la API, sino también en los sombreadores. La especificación WGSL está diseñada para hacer que esta transición sea fluida e intuitiva, manteniendo al mismo tiempo la eficiencia y el rendimiento de las GPU modernas.
WGSL está diseñado para ser un puente entre WebGPU y las API de gráficos nativos. En comparación con GLSL, WGSL parece un poco más detallado, pero la estructura sigue siendo familiar.
Aquí hay un ejemplo de sombreador de textura:
GLSL :
sampler2D myTexture; varying vec2 vTexCoord; void main() { return texture(myTexture, vTexCoord); }
WGSL :
[[group(0), binding(0)]] var mySampler: sampler; [[group(0), binding(1)]] var myTexture: texture_2d<f32>; [[stage(fragment)]] fn main([[location(0)]] vTexCoord: vec2<f32>) -> [[location(0)]] vec4<f32> { return textureSample(myTexture, mySampler, vTexCoord); }
La siguiente tabla muestra una comparación de los tipos de datos básicos y matriciales en GLSL y WGSL:
La transición de GLSL a WGSL demuestra el deseo de una escritura más estricta y una definición explícita de los tamaños de datos, lo que puede mejorar la legibilidad del código y reducir la probabilidad de errores.
La sintaxis para declarar estructuras también ha cambiado:
GLSL:
struct Light { vec3 position; vec4 color; float attenuation; vec3 direction; float innerAngle; float angle; float range; };
WGSL:
struct Light { position: vec3<f32>, color: vec4<f32>, attenuation: f32, direction: vec3<f32>, innerAngle: f32, angle: f32, range: f32, };
La introducción de una sintaxis explícita para declarar campos en estructuras WGSL enfatiza el deseo de una mayor claridad y simplifica la comprensión de las estructuras de datos en los sombreadores.
GLSL :
float saturate(float x) { return clamp(x, 0.0, 1.0); }
WGSL :
fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }
Cambiar la sintaxis de las funciones en WGSL refleja la unificación del enfoque de las declaraciones y los valores de retorno, lo que hace que el código sea más consistente y predecible.
En WGSL, se ha cambiado el nombre o se han reemplazado muchas funciones GLSL integradas. Por ejemplo:
Cambiar el nombre de las funciones integradas en WGSL no solo simplifica sus nombres, sino que también las hace más intuitivas, lo que puede facilitar el proceso de transición para los desarrolladores familiarizados con otras API de gráficos.
Para aquellos que planean convertir sus proyectos de WebGL a WebGPU, es importante saber que existen herramientas para convertir automáticamente GLSL a WGSL, como **[Naga](https://github.com/gfx-rs/naga /)**, que es una biblioteca de Rust para convertir GLSL a WGSL. Incluso puede funcionar directamente en su navegador con la ayuda de WebAssembly.
Estos son los puntos finales compatibles con Naga:
Después de la migración, es posible que te encuentres con una sorpresa en forma de imágenes invertidas. Aquellos que alguna vez han portado aplicaciones de OpenGL a Direct3D (o viceversa) ya se han enfrentado a este problema clásico.
En el contexto de OpenGL y WebGL, las texturas generalmente se cargan de tal manera que el píxel inicial corresponde a la esquina inferior izquierda. Sin embargo, en la práctica, muchos desarrolladores cargan imágenes comenzando desde la esquina superior izquierda, lo que provoca el error de imagen invertida. Sin embargo, este error puede compensarse con otros factores y, en última instancia, solucionar el problema.
A diferencia de OpenGL, sistemas como Direct3D y Metal tradicionalmente utilizan la esquina superior izquierda como punto de partida para las texturas. Considerando que este enfoque parece ser el más intuitivo para muchos desarrolladores, los creadores de WebGPU decidieron seguir esta práctica.
Si su código WebGL selecciona píxeles del búfer de fotogramas, prepárese para el hecho de que WebGPU utiliza un sistema de coordenadas diferente. Es posible que deba aplicar una operación simple "y = 1,0 - y" para corregir las coordenadas.
Cuando un desarrollador se enfrenta a un problema en el que los objetos se recortan o desaparecen antes de lo esperado, esto suele estar relacionado con diferencias en el dominio de profundidad. Existe una diferencia entre WebGL y WebGPU en cómo definen el rango de profundidad del espacio del clip. Mientras que WebGL usa un rango de -1 a 1, WebGPU usa un rango de 0 a 1, similar a otras API de gráficos como Direct3D, Metal y Vulkan. Esta decisión se tomó debido a varias ventajas de utilizar un rango de 0 a 1 que se identificaron al trabajar con otras API de gráficos.
La principal responsabilidad de transformar las posiciones de su modelo en espacio de clip recae en la matriz de proyección. La forma más sencilla de adaptar su código es asegurarse de que su matriz de proyección genere resultados en el rango de 0 a 1. Para aquellos que usan bibliotecas como gl-matrix, existe una solución simple: en lugar de usar la función perspective
, puede usar perspectiveZO
; Hay funciones similares disponibles para otras operaciones matriciales.
if (webGPU) { // Creates a matrix for a symetric perspective-view frustum // using left-handed coordinates mat4.perspectiveZO(out, Math.PI / 4, ...); } else { // Creates a matrix for a symetric perspective-view frustum // based on the default handedness and default near // and far clip planes definition. mat4.perspective(out, Math.PI / 4, …); }
Sin embargo, a veces es posible que tenga una matriz de proyección existente y no pueda cambiar su fuente. En este caso, para transformarlo en un rango de 0 a 1, puedes multiplicar previamente tu matriz de proyección por otra matriz que corrija el rango de profundidad.
Ahora, analicemos algunos consejos y trucos para trabajar con WebGPU.
Cuantas más canalizaciones utilice, más cambios de estado tendrá y menor rendimiento; Esto puede no ser trivial, dependiendo de dónde provengan sus activos.
Crear una canalización y usarla inmediatamente puede funcionar, pero no se recomienda. En su lugar, cree funciones que regresen inmediatamente y comiencen a trabajar en un hilo diferente. Cuando utiliza la canalización, la cola de ejecución debe esperar a que finalicen las creaciones de canalizaciones pendientes. Esto puede provocar importantes problemas de rendimiento. Para evitar esto, asegúrese de dejar algo de tiempo entre la creación de la canalización y su uso por primera vez.
O, mejor aún, ¡use las variantes create*PipelineAsync
! La promesa se resuelve cuando el oleoducto esté listo para su uso, sin ningún tipo de estancamiento.
device.createComputePipelineAsync({ compute: { module: shaderModule, entryPoint: 'computeMain' } }).then((pipeline) => { const commandEncoder = device.createCommandEncoder(); const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(pipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.dispatchWorkgroups(128); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); });
Los paquetes de renderizado son pases de renderizado reutilizables, parciales y pregrabados. Pueden contener la mayoría de los comandos de renderizado (excepto cosas como configurar la ventana gráfica) y pueden "reproducirse" como parte de una pasada de renderizado real más adelante.
const renderPass = encoder.beginRenderPass(descriptor); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.executeBundles([renderBundle]); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.end();
Los paquetes de renderizado se pueden ejecutar junto con los comandos de paso de renderizado habituales. El estado de paso de renderizado se restablece a los valores predeterminados antes y después de cada ejecución del paquete. Esto se hace principalmente para reducir la sobrecarga de JavaScript del dibujo. El rendimiento de la GPU sigue siendo el mismo independientemente del enfoque.
La transición a WebGPU significa más que simplemente cambiar las API de gráficos. También es un paso hacia el futuro de los gráficos web, ya que combina características y prácticas exitosas de varias API de gráficos. Esta migración requiere una comprensión profunda de los cambios técnicos y filosóficos, pero los beneficios son significativos.