Blog de tecnologia
4 de fevereiro de 2025

Engines de jogo e stuttering de shaders: a solução da Unreal Engine para o problema

JogosPipeline State Objects

Olá a todos! Somos Kenzo ter Elst, Daniele Vettorel, Allan Bentham e Mihnea Balta, alguns dos engenheiros que trabalharam no sistema de pré-cache do PSO da Unreal Engine. 

Recentemente, tem havido uma série de discussões na comunidade Epic sobre stuttering de shaders e seu impacto nos projetos de desenvolvedores de jogos. 

Hoje, vamos entender por que esse fenômeno ocorre, explicar como o pré-cache do PSO pode ajudar a resolver o problema e explorar algumas práticas recomendadas de desenvolvimento que ajudarão a minimizar o stuttering de shaders. Também vamos compartilhar nossos planos futuros para o sistema de pré-cache do PSO. 

Se você se interessou pelo assunto, não perca nossa transmissão ao vivo do Inside Unreal nesta quinta-feira, 6 de fevereiro, na Twitch ou no YouTube, às 16h (horário de Brasília).


Contexto

O stuttering na compilação de shaders ocorre quando uma engine de renderização descobre que precisa compilar um novo shader imediatamente antes de usá-lo para desenhar algo, fazendo com que a execução seja interrompida enquanto aguarda o driver concluir a compilação. Para entender por que isso pode acontecer, precisamos analisar mais de perto como os shaders são compilados em códigos que são executados na GPU.

Shaders são programas que são executados na GPU para realizar as diversas etapas do processo de renderização de imagens 3D, como transformação, deformação, sombreamento, iluminação, pós-processamento e assim por diante. Geralmente, eles são escritos numa linguagem de alto nível, como o HLSL, e precisam ser compilados em código de máquina para que a GPU possa executá-los. Esse processo é semelhante ao das CPUs, onde o código escrito numa linguagem de alto nível, como o C++, é processado por um compilador para gerar instruções para uma determinada arquitetura, como x64, ARM etc. 

No entanto, há uma diferença crucial: cada plataforma (PC, Mac, Android etc.) geralmente tem um ou dois conjuntos de instruções de CPU, mas muitas GPUs diferentes, com conjuntos de instruções completamente distintos. Um executável compilado há 10 anos para PCs x64 será executado em chips produzidos hoje pela AMD e Intel, pois ambos os fabricantes usam o mesmo conjunto de instruções e oferecem fortes garantias de compatibilidade com versões anteriores. Por outro lado, um binário de GPU compilado para a AMD não funcionará na NVIDIA ou vice-versa, e os conjuntos de instruções podem até mudar entre diferentes gerações de hardware do mesmo fabricante.

Portanto, embora seja viável compilar programas de CPU diretamente em código de máquina executável e distribuí-los, uma abordagem diferente deve ser usada para programas de GPU. O código de shader de alto nível é compilado numa representação intermediária, ou bytecode, que usa um conjunto de instruções abstrato definido pela API 3D: DXBC para Direct3D 11, DXIL para Direct3D 12, SPIR-V para Vulkan etc. 

Os jogos incluem esses arquivos binários de bytecode para que tenham uma única biblioteca de shaders, em vez de uma para cada possível arquitetura de GPU. Em tempo de execução, o driver converte o bytecode em código executável para a GPU instalada na máquina. Essa abordagem ocasionalmente também é usada para programas de CPU, como no código-fonte Java, que é compilado em bytecode para que o mesmo binário possa ser executado em todas as plataformas que possuam um ambiente Java, independentemente da CPU.

Quando esse sistema foi introduzido, os jogos tinham shaders relativamente simples e em pequena quantidade, e a conversão de bytecode para código executável era simples, com um custo insignificante no tempo de execução. Conforme as GPUs se tornaram mais poderosas, a quantidade de código de shader aumentou significativamente. Além disso, os drivers começaram a realizar transformações mais avançadas para gerar um código de máquina mais eficiente, o que fez com que o custo da compilação em tempo de execução se tornasse um problema. A situação chegou a um ponto crítico no Direct3D 11, levando as APIs modernas, como Direct3D 12 e Vulkan, a resolver o problema com a introdução do conceito de Estado dos Objetos do Pipeline (PSOs).

Estado dos Objetos do Pipeline

A renderização de um objeto geralmente envolve vários shaders (por exemplo, um shader de vértice e um shader de pixel trabalhando juntos), além de diversas outras configurações da GPU, como modo de seleção, modo de mesclagem, modos de comparação de profundidade e estêncil etc. Juntos, esses elementos definem a configuração, ou estado, do pipeline da GPU. 

As APIs gráficas mais antigas, como Direct3D 11 e OpenGL, permitem alterar partes do estado individualmente e em momentos arbitrários, o que significa que o driver só vê a configuração completa quando o jogo emite uma solicitação de desenho. Algumas configurações influenciam o código de shader executável, então, há casos em que o driver só pode começar a compilar os shaders quando o comando de renderização é processado. Isso pode levar dezenas de milissegundos ou mais para um único comando de desenho, resultando em quadros muito longos quando um shader é usado pela primeira vez, um fenômeno conhecido pela maioria dos jogadores como travamentos ou stuttering.

As APIs modernas exigem que os desenvolvedores agrupem todos os shaders e configurações que serão usados para uma solicitação de desenho num Estado do Objeto do Pipeline e o definam como uma única unidade. Fundamentalmente, os PSOs podem ser construídos a qualquer momento, então, em teoria, as engines podem criar tudo o que precisam com tempo suficiente (por exemplo, durante o carregamento), de modo que a compilação tenha tempo para ser concluída antes da renderização.

Teoria vs. prática

A Unreal Engine possui um potente sistema de criação de materiais que é usado por artistas para criar mundos visualmente detalhados e imersivos, e muitos jogos contêm milhares de materiais. Cada um deles pode gerar muitos shaders diferentes. Por exemplo, existem shaders de vértice separados para renderizar um material em malhas estáticas, malhas revestidas e malhas de spline. O mesmo shader de vértice pode ser usado com vários shaders de pixel, e isso é multiplicado por diferentes conjuntos de configurações de pipeline. Isso pode resultar em milhões de PSOs diferentes que teriam que ser compilados antecipadamente para garantir que todas as possibilidades sejam atendidas, o que é obviamente inviável tanto em termos de tempo quanto de memória (levaria horas para carregar um nível).

Um subconjunto muito pequeno desses possíveis PSOs é usado em tempo de execução, mas não podemos determinar qual é esse subconjunto apenas analisando um material isoladamente. O subconjunto também pode variar entre diferentes sessões de jogo: modificar as configurações de vídeo ativa certas funcionalidades de renderização, o que faz a engine usar shaders ou estados de pipeline diferentes. As primeiras implementações da engine Direct3D 12 dependiam de testes de jogo, passagens automatizadas por níveis e outros métodos de descoberta para registrar quais PSOs eram encontrados na prática. Esses dados eram incluídos no jogo final e usados para criar os PSOs conhecidos na inicialização ou no carregamento do nível. A Unreal Engine chama isso de "Cache de PSO Agrupado", e essa era a nossa prática recomendada até a UE 5.2.

O cache agrupado é suficiente para alguns jogos, mas apresenta diversas limitações. Sua coleta exige muitos recursos, e ele precisa ser mantido atualizado sempre que o conteúdo mudar. O processo de gravação pode não ser capaz de descobrir todos os PSOs em jogos com mundos muito dinâmicos, por exemplo, se os objetos mudarem de materiais com base nas ações do jogador. 

O cache pode se tornar muito maior do que o necessário durante uma sessão de jogo se houver muita variação entre as sessões, como no caso de haver muitos mapas ou se os jogadores puderem escolher entre várias skins. O Fortnite é um bom exemplo onde o cache agrupado não é uma boa solução, pois enfrenta todas essas limitações. Além disso, o jogo possui conteúdo gerado pelo usuário, então, seria necessário utilizar um cache de PSO por experiência e colocar a responsabilidade de coletar esses caches nos criadores de conteúdo.

PSO precaching script in Unreal Engine.

Pré-cache do PSO

Para viabilizar mundos de jogo grandes e variados, bem como conteúdo gerado pelo usuário, a Unreal Engine 5.2 introduziu o pré-cache do PSO, uma técnica para determinar PSOs potenciais no momento do carregamento. Quando um objeto é carregado, o sistema examina seus materiais e usa informações da malha (por exemplo, estática vs. animada) bem como o estado global (por exemplo, configurações de qualidade de vídeo) para calcular um subconjunto de possíveis PSOs que podem ser usados para renderizar o objeto. 

Esse subconjunto é maior do que o que acaba sendo usado, mas muito menor do que o conjunto completo de possibilidades, tornando viável compilá-lo durante o carregamento. Por exemplo, o Fortnite Battle Royale compila cerca de 30 mil PSOs para uma partida e usa cerca de 10 mil deles, mas isso é uma porção muito pequena do espaço total de combinações, que contém milhões.

Objetos criados durante o carregamento do mapa fazem o pré-cache de seus PSOs enquanto a tela de carregamento é exibida. Aqueles que são transmitidos ou surgem durante o jogo podem aguardar até que seus PSOs estejam prontos antes de serem renderizados ou usar um material padrão que já foi compilado. Na maioria dos casos, isso só atrasa o carregamento por alguns quadros, o que não é perceptível. Esse sistema eliminou o stuttering na compilação de PSOs para materiais e funciona perfeitamente com conteúdo gerado pelo usuário.

Mudar o material numa malha visível é um caso mais difícil, porque não queremos escondê-la ou renderizá-la com um material padrão enquanto o novo PSO está sendo compilado. Estamos trabalhando numa API que permite que o código do jogo e os Blueprints avisem o sistema com antecedência, para que os PSOs extras também possam ser armazenados no pré-cache. Também queremos modificar a engine para continuar renderizando o material anterior enquanto o novo está sendo compilado.

A Unreal Engine tem uma classe separada de shaders que não estão relacionados a materiais. Esses são chamados de shaders globais, que são programas usados pelo renderizador para implementar vários algoritmos e efeitos, como desfoque de movimento, upscaling, remoção de ruído etc. O mecanismo de pré-cache também abrange os shaders de computação globais, no entanto, a partir da UE 5.5, não gerencia shaders gráficos globais. Esses tipos de PSOs ainda podem causar travamentos raros e únicos quando são usados pela primeira vez. Há trabalhos em andamento para corrigir esse problema na cobertura de pré-cache.

O cache agrupado pode ser usado em conjunto com o pré-cache, e isso pode trazer benefícios para alguns jogos. Alguns materiais comuns podem ser incluídos no cache agrupado, para que sejam compilados na inicialização, em vez de durante o jogo. Isso também pode ajudar com shaders gráficos globais, já que o processo de descoberta os encontra e os registra.

O cache do driver

Os drivers salvam os PSOs compilados no disco, para que possam ser carregados diretamente quando forem encontrados novamente em sessões de jogo futuras. Isso ajuda os jogos, não importando qual engine e estratégia de compilação do PSO eles utilizem. Para títulos da Unreal Engine que usam pré-cache do PSO, isso significa que a tela de carregamento será visivelmente mais curta na segunda execução. O Fortnite leva cerca de 20 a 30 segundos a mais para carregar numa partida de Battle Royale quando o cache do driver está vazio. O cache é limpo quando um novo driver é instalado, então, é normal ver telas de carregamento mais longas na primeira vez que um jogo é executado após uma atualização do driver.

A Unreal Engine aproveita o cache do driver criando PSOs durante o carregamento e descartando-os imediatamente após a compilação; por isso, a técnica é chamada de pré-cache. Quando um PSO é posteriormente necessário para renderização, a engine emite uma solicitação de compilação, mas o driver simplesmente o retorna do cache, porque o sistema de pré-cache garantiu que ele estivesse lá. Uma vez que um PSO é utilizado para desenho, ele permanecerá carregado até que todas as primitivas que o utilizam sejam removidas da cena, assim não precisamos fazer a solicitação ao driver a cada quadro.

O descarte após o pré-cache tem a vantagem de que os PSOs que não são usados não ficam armazenados na memória. A desvantagem é que buscar um PSO do cache do driver exatamente quando ele é necessário pode levar algum tempo, e, embora seja muito mais rápido do que compilá-lo, isso pode levar a micro-stutters na primeira vez que um material é renderizado. 

Uma solução simples é manter os PSOs em pré-cache em vez de descartá-los, mas isso pode aumentar o uso de memória em mais de 1 GB, então, só deve ser feito em máquinas com RAM suficiente. Estamos em busca de soluções para reduzir o impacto na memória e decidir automaticamente quando os PSOs em pré-cache podem ser mantidos na memória.

Apenas alguns dos estados afetam o código executável do PSO. Isso significa que, quando criamos dois PSOs que têm os mesmos shaders, mas diferem nas configurações de pipeline, é possível que apenas o primeiro passe pelo custoso processo de compilação, e o segundo seja retornado imediatamente do cache. 

Infelizmente, o conjunto de estados que são importantes para a geração de código é diferente entre GPUs e pode até mudar de uma versão do driver para outra. A Unreal Engine aproveita alguns conhecimentos práticos que nos permitem pular algumas permutações durante o processo de pré-cache. As solicitações redundantes são mais curtas graças ao cache do driver, mas a engine ainda precisa trabalhar para gerá-las. Esse trabalho se acumula, por isso o processo de remoção é útil para reduzir os tempos de carregamento, bem como o uso de memória.

Plataformas móveis e consoles

As plataformas móveis usam o mesmo modelo de compilação de shaders no dispositivo, e o sistema de pré-cache da Unreal Engine também é eficiente nesse contexto. Em geral, o renderizador móvel usa menos shaders do que no desktop, mas a compilação de PSOs leva muito mais tempo devido às CPUs serem mais lentas, então, tivemos que fazer alguns ajustes no processo para torná-lo viável. 

Pulamos algumas permutações raramente usadas, o que significa que o conjunto de pré-cache não é mais conservador, então, em alguns casos, pode haver travamentos se um dos estados incomuns acabar sendo renderizado. Também temos um limite de tempo para o pré-cache durante o carregamento do mapa para evitar que a tela de carregamento seja exibida por um tempo excessivo. Isso significa que o jogo pode começar enquanto ainda há tarefas de compilação pendentes, de modo que um stutter pode ocorrer se um dos PSOs em andamento for necessário imediatamente. Usamos um sistema de aumento de prioridade para mover tarefas para o início da fila quando um PSO é necessário, para minimizar esses travamentos.

Não precisamos resolver esse problema para os consoles, porque eles têm uma única GPU de destino. Shaders individuais são compilados diretamente em código executável e enviados com o jogo. Não há explosão combinatória pelo uso do mesmo shader de vértice com vários shaders de pixel, ou devido a estados de pipeline, porque esses fatores não causam recompilação. Shaders e estados podem ser montados em PSOs em tempo de execução sem gerar um custo significativo, portanto, não há travamentos do PSO nessas plataformas.

Saudades do Direct3D 11

Há um equívoco parcial de que o Direct3D 11 não tinha esses problemas, e ocasionalmente recebemos pedidos para voltar ao modelo antigo de compilação ou até mesmo para as antigas APIs gráficas. Como explicado anteriormente, os travamentos também aconteciam naquela época, e, devido ao design da API, as engines não tinham como evitá-los. Eles eram menos frequentes ou mais curtos, principalmente, porque os jogos tinham shaders mais simples e em menor quantidade, e algumas funcionalidades, como o traçado de raios, não existiam. 

Os drivers também faziam muita mágica para minimizar o stuttering, mas não conseguiam evitá-lo completamente. O Direct3D 12 tentou resolver o problema antes que ele piorasse por meio dos PSOs, mas as engines demoraram um pouco para usá-los efetivamente, em parte devido à dificuldade de adaptar sistemas de materiais existentes e em parte devido a deficiências na API, que só se tornaram aparentes à medida que os jogos cresciam em complexidade. 

A Unreal Engine é uma engine de propósito geral com muitos casos de uso e muito conteúdo e fluxos de trabalho existentes, então, o problema era particularmente difícil de resolver. Finalmente estamos chegando a um ponto onde temos uma solução viável, e também há boas iniciativas para abordar as deficiências da API, como a extensão da biblioteca do pipeline gráfico do Vulkan.

PSO precaching stats in Unreal Engine.
Console commands for PSO profiling and debugging in Unreal Engine.

Ainda não terminamos

O sistema de pré-cache evoluiu muito desde sua introdução experimental na 5.2 e agora previne a maioria dos tipos de stuttering na compilação de shaders. No entanto, ainda existem algumas falhas e outras limitações, por isso, esforços contínuos estão sendo realizados para melhorá-lo ainda mais. Também estamos trabalhando com fornecedores de hardware e software para adaptar drivers e APIs gráficas à forma como os jogos usam esses sistemas na prática.

Nosso objetivo final é gerenciar o pré-cache de forma automática e otimizada, para que os desenvolvedores de jogos não precisem fazer nada para evitar os travamentos. Até que o sistema esteja finalizado, ainda há algumas coisas que os licenciados podem fazer para garantir uma experiência de jogo fluida:

  • Use a versão mais recente da engine. Como o pré-cache ainda está em desenvolvimento, versões mais recentes da engine terão um desempenho melhor. Se uma atualização completa não for viável, você deverá ser capaz de retroceder a maioria das melhorias para a sua versão personalizada da engine.
  • Analise os travamentos de PSOs no seu jogo. Use r.PSOPrecache.Validation=2 como explicado na documentação para identificar PSOs perdidos ou tardios e entender as causas.
  • Limpe o cache do driver antes dos testes de jogo. Usar o argumento de linha de comando -clearPSODriverCache durante os testes de jogo mostrará como será a experiência do jogador ao executar o jogo pela primeira vez ou após uma atualização de driver. Fique atento a travamentos nesse modo e resolva-os usando as ferramentas de análise de perfil e depuração mencionadas acima.
  • Repita esse processo regularmente. Mudanças no conteúdo ou no código do jogo podem gerar novos travamentos ou bugs no sistema. Recomendamos fortemente que os licenciados monitorem as estatísticas do PSO como parte de seus procedimentos de teste automatizados.
  • Fique atento a outros tipos de stuttering de movimentação. A compilação do PSO não é a única causa de travamentos durante o jogo, e é difícil identificar a origem do problema sem instrumentação. Analise o jogo regularmente durante o desenvolvimento e os testes para rastrear outros processos custosos que podem causar picos no tempo de quadro, como carregamento síncrono, surgimento ou carregamento por streaming excessivo, capturas de cena acionadas por movimento, entre outros.

Lembre-se, para saber mais sobre este tópico, você pode participar da transmissão ao vivo do Inside Unreal nesta quinta-feira, 6 de fevereiro, na Twitch ou no YouTube, às 16h (horário de Brasília).

Como instalar a Unreal Engine

Instruções de download

Baixar o inicializador

Antes de instalar e executar o Unreal Editor, você precisará baixar e instalar o Inicializador da Epic Games.

Instale o Inicializador da Epic Games

Depois de baixá-lo e instalá-lo, abra o inicializador e inicie a sessão ou crie uma conta da Epic Games.

Obtenha suporte ou reinicie o download do Inicializador da Epic Games na Etapa 1.

Instalar a Unreal Engine

Depois de iniciar a sessão, vá até a aba "Unreal Engine" e clique no botão "Instalar" para baixar a versão mais recente.

Veja como instalar

Procurando o Unreal Editor para Fortnite?

Comece a usar o Unreal Editor para Fortnite por meio do Inicializador da Epic Games.

Baixe o UEFN