Mudar para a próxima WebGPU significa mais do que apenas trocar APIs gráficas. É também um passo em direção ao futuro dos gráficos da web. Mas essa migração será melhor com preparação e compreensão – e este artigo irá prepará-lo.
Olá a todos, meu nome é Dmitrii Ivashchenko e sou engenheiro de software na MY.GAMES. Neste artigo, discutiremos as diferenças entre o WebGL e o futuro WebGPU e explicaremos como preparar seu projeto para a migração.
Linha do tempo de WebGL e WebGPU
O estado atual da WebGPU e o que está por vir
Diferenças conceituais de alto nível
Inicialização
• WebGL: o modelo de contexto
• WebGPU: o modelo do dispositivo
Programas e pipelines
• WebGL: Programa
• WebGPU: pipeline
Uniformes
• Uniformes em WebGL 1
• Uniformes em WebGL 2
• Uniformes em WebGPU
Sombreadores
• Linguagem de Shader: GLSL vs WGSL
• Comparação de tipos de dados
• Estruturas
• Declarações de Função
• Funções integradas
• Conversão de Shader
Diferenças na Convenção
Texturas
• Espaço da janela de visualização
• Espaços de clipe
Dicas e truques da WebGPU
• Minimize o número de pipelines usados.
• Crie pipelines com antecedência
• Usar RenderBundles
Resumo
WebGL , como muitas outras tecnologias da web, tem raízes que remontam a um passado bastante distante. Para entender a dinâmica e a motivação por trás da mudança em direção ao WebGPU, é útil primeiro dar uma rápida olhada na história do desenvolvimento do WebGL:
Nos últimos anos, tem havido um aumento de interesse em novas APIs gráficas que fornecem aos desenvolvedores mais controle e flexibilidade:
Hoje, o WebGPU está disponível em diversas plataformas, como Windows, Mac e ChromeOS, por meio dos navegadores Google Chrome e Microsoft Edge, começando com a versão 113. O suporte para Linux e Android é esperado em um futuro próximo.
Aqui estão alguns dos motores que já suportam (ou oferecem suporte experimental) para WebGPU:
Considerando isto, a transição para WebGPU ou pelo menos a preparação de projetos para tal transição parece ser um passo oportuno no futuro próximo.
Vamos diminuir o zoom e dar uma olhada em algumas das diferenças conceituais de alto nível entre WebGL e WebGPU, começando com a inicialização.
Ao começar a trabalhar com APIs gráficas, um dos primeiros passos é inicializar o objeto principal para interação. Este processo difere entre WebGL e WebGPU, com algumas peculiaridades para ambos os sistemas.
No WebGL, esse objeto é conhecido como “contexto”, que representa essencialmente uma interface para desenhar em um elemento de tela HTML5. Obter este contexto é bastante simples:
const gl = canvas.getContext('webgl');
O contexto do WebGL está, na verdade, vinculado a uma tela específica. Isso significa que se você precisar renderizar em diversas telas, precisará de diversos contextos.
WebGPU introduz um novo conceito chamado “dispositivo”. Este dispositivo representa uma abstração de GPU com a qual você interagirá. O processo de inicialização é um pouco mais complexo que no WebGL, mas oferece mais flexibilidade:
const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); const context = canvas.getContext('webgpu'); context.configure({ device, format: 'bgra8unorm', });
Uma das vantagens desse modelo é que um dispositivo pode renderizar em várias telas ou até mesmo em nenhuma. Isto proporciona flexibilidade adicional; por exemplo, um dispositivo pode controlar a renderização em múltiplas janelas ou contextos.
WebGL e WebGPU representam abordagens diferentes para gerenciar e organizar o pipeline gráfico.
No WebGL, o foco principal está no programa shader. O programa combina shaders de vértices e fragmentos, definindo como os vértices devem ser transformados e como cada pixel deve ser colorido.
const program = gl.createProgram(); gl.attachShader(program, vertShader); gl.attachShader(program, fragShader); gl.bindAttribLocation(program, 'position', 0); gl.linkProgram(program);
Passos para criar um programa em WebGL:
Este processo permite um controle gráfico flexível, mas também pode ser complexo e propenso a erros, especialmente para projetos grandes e complexos.
WebGPU introduz o conceito de "pipeline" em vez de um programa separado. Este pipeline combina não apenas shaders, mas também outras informações, que no WebGL são estabelecidas como estados. Portanto, criar um pipeline no WebGPU parece mais complexo:
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, }], }, });
Etapas para criar um pipeline no WebGPU:
Enquanto o WebGL separa cada aspecto da renderização, o WebGPU tenta encapsular mais aspectos em um único objeto, tornando o sistema mais modular e flexível. Em vez de gerenciar shaders e estados de renderização separadamente, como é feito no WebGL, o WebGPU combina tudo em um objeto de pipeline. Isso torna o processo mais previsível e menos sujeito a erros:
Variáveis uniformes fornecem dados constantes que estão disponíveis para todas as instâncias de sombreador.
No WebGL básico, temos a capacidade de definir variáveis uniform
diretamente por meio de chamadas de 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 é simples, mas requer múltiplas chamadas de API para cada variável uniform
.
Com a chegada do WebGL 2, agora temos a capacidade de agrupar variáveis uniform
em buffers. Embora você ainda possa usar sombreadores uniformes separados, uma opção melhor é agrupar uniformes diferentes em uma estrutura maior usando buffers uniformes. Em seguida, você envia todos esses dados uniformes para a GPU de uma vez, semelhante a como você pode carregar um buffer de vértice no WebGL 1. Isso tem várias vantagens de desempenho, como reduzir chamadas de API e estar mais próximo de como as GPUs modernas funcionam.
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 um buffer grande e uniforme no WebGL 2, você pode usar uma chamada de API especial conhecida como bindBufferRange
. No WebGPU, existe algo semelhante chamado deslocamentos de buffer uniformes dinâmicos, onde você pode passar uma lista de deslocamentos ao chamar a API setBindGroup
.
WebGPU nos oferece um método ainda melhor. Neste contexto, as variáveis uniform
individuais não são mais suportadas e o trabalho é feito exclusivamente atravé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 });
As GPUs modernas preferem que os dados sejam carregados em um bloco grande, em vez de muitos blocos pequenos. Em vez de recriar e religar buffers pequenos a cada vez, considere criar um buffer grande e usar diferentes partes dele para diferentes chamadas de desenho. Essa abordagem pode aumentar significativamente o desempenho.
WebGL é mais imperativo, redefinindo o estado global a cada chamada e se esforçando para ser o mais simples possível. Já o WebGPU pretende ser mais orientado a objetos e focado na reutilização de recursos, o que leva à eficiência.
A transição do WebGL para o WebGPU pode parecer difícil devido às diferenças nos métodos. No entanto, começar com uma transição para WebGL 2 como uma etapa intermediária pode simplificar sua vida.
A migração do WebGL para o WebGPU requer mudanças não apenas na API, mas também nos shaders. A especificação WGSL foi projetada para tornar essa transição suave e intuitiva, ao mesmo tempo que mantém a eficiência e o desempenho das GPUs modernas.
WGSL foi projetado para ser uma ponte entre WebGPU e APIs gráficas nativas. Comparado ao GLSL, o WGSL parece um pouco mais detalhado, mas a estrutura permanece familiar.
Aqui está um exemplo de shader para 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); }
A tabela abaixo mostra uma comparação dos tipos de dados básicos e matriciais em GLSL e WGSL:
A transição de GLSL para WGSL demonstra o desejo de uma digitação mais rigorosa e definição explícita de tamanhos de dados, o que pode melhorar a legibilidade do código e reduzir a probabilidade de erros.
A sintaxe para declarar estruturas também mudou:
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, };
A introdução de sintaxe explícita para declaração de campos em estruturas WGSL enfatiza o desejo de maior clareza e simplifica a compreensão das estruturas de dados em shaders.
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); }
A alteração da sintaxe das funções no WGSL reflete a unificação da abordagem às declarações e valores de retorno, tornando o código mais consistente e previsível.
No WGSL, muitas funções GLSL integradas foram renomeadas ou substituídas. Por exemplo:
Renomear funções integradas no WGSL não apenas simplifica seus nomes, mas também as torna mais intuitivas, o que pode facilitar o processo de transição para desenvolvedores familiarizados com outras APIs gráficas.
Para quem está planejando converter seus projetos de WebGL para WebGPU, é importante saber que existem ferramentas para conversão automática de GLSL para WGSL, como **[Naga](https://github.com/gfx-rs/naga /)**, que é uma biblioteca Rust para converter GLSL em WGSL. Pode até funcionar diretamente no seu navegador com a ajuda do WebAssembly.
Aqui estão os endpoints suportados pelo Naga:
Após a migração, você poderá encontrar uma surpresa na forma de imagens invertidas. Aqueles que já portaram aplicativos de OpenGL para Direct3D (ou vice-versa) já enfrentaram esse problema clássico.
No contexto do OpenGL e WebGL, as texturas geralmente são carregadas de forma que o pixel inicial corresponda ao canto inferior esquerdo. No entanto, na prática, muitos desenvolvedores carregam imagens começando no canto superior esquerdo, o que leva ao erro de imagem invertida. No entanto, este erro pode ser compensado por outros fatores, acabando por nivelar o problema.
Ao contrário do OpenGL, sistemas como Direct3D e Metal tradicionalmente usam o canto superior esquerdo como ponto de partida para texturas. Considerando que esta abordagem parece ser a mais intuitiva para muitos desenvolvedores, os criadores do WebGPU decidiram seguir esta prática.
Se o seu código WebGL selecionar pixels do buffer de quadros, esteja preparado para o fato de que o WebGPU usa um sistema de coordenadas diferente. Pode ser necessário aplicar uma operação simples "y = 1,0 - y" para corrigir as coordenadas.
Quando um desenvolvedor enfrenta um problema em que os objetos são cortados ou desaparecem antes do esperado, isso geralmente está relacionado a diferenças no domínio de profundidade. Há uma diferença entre WebGL e WebGPU na forma como eles definem a faixa de profundidade do clip space. Enquanto o WebGL usa um intervalo de -1 a 1, o WebGPU usa um intervalo de 0 a 1, semelhante a outras APIs gráficas, como Direct3D, Metal e Vulkan. Esta decisão foi tomada devido a diversas vantagens de usar um intervalo de 0 a 1 que foram identificadas ao trabalhar com outras APIs gráficas.
A principal responsabilidade por transformar as posições do seu modelo em clip space é da matriz de projeção. A maneira mais simples de adaptar seu código é garantir que a saída da matriz de projeção resulte na faixa de 0 a 1. Para quem usa bibliotecas como gl-matrix, existe uma solução simples: em vez de usar a função perspective
, você pode usar perspectiveZO
; funções semelhantes estão disponíveis para outras operações matriciais.
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, …); }
No entanto, às vezes você pode ter uma matriz de projeção existente e não pode alterar sua origem. Neste caso, para transformá-lo em um intervalo de 0 a 1, você pode pré-multiplicar sua matriz de projeção por outra matriz que corrija o intervalo de profundidade.
Agora, vamos discutir algumas dicas e truques para trabalhar com WebGPU.
Quanto mais pipelines você usar, mais alternância de estado terá e menos desempenho; isso pode não ser trivial, dependendo de onde vêm seus ativos.
Criar um pipeline e usá-lo imediatamente pode funcionar, mas não é recomendado. Em vez disso, crie funções que retornem imediatamente e comecem a trabalhar em um thread diferente. Quando você usa o pipeline, a fila de execução precisa aguardar a conclusão das criações pendentes do pipeline. Isso pode resultar em problemas significativos de desempenho. Para evitar isso, reserve algum tempo entre a criação do pipeline e a primeira utilização dele.
Ou, melhor ainda, use as variantes create*PipelineAsync
! A promessa é resolvida quando o pipeline está pronto para uso, sem qualquer paralisação.
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()]); });
Os pacotes de renderização são passagens de renderização pré-gravadas, parciais e reutilizáveis. Eles podem conter a maioria dos comandos de renderização (exceto coisas como configurar a janela de visualização) e podem ser "repetidos" como parte de uma passagem de renderização real posteriormente.
const renderPass = encoder.beginRenderPass(descriptor); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.executeBundles([renderBundle]); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.end();
Os pacotes de renderização podem ser executados junto com comandos regulares de passagem de renderização. O estado de passagem de renderização é redefinido para os padrões antes e depois de cada execução do pacote configurável. Isso é feito principalmente para reduzir a sobrecarga do JavaScript no desenho. O desempenho da GPU permanece o mesmo, independentemente da abordagem.
A transição para WebGPU significa mais do que apenas trocar APIs gráficas. É também um passo em direção ao futuro dos gráficos da web, combinando recursos e práticas bem-sucedidas de várias APIs gráficas. Esta migração requer uma compreensão profunda das mudanças técnicas e filosóficas, mas os benefícios são significativos.