Cada seção a seguir descreve uma situação que pode afetar o desempenho do aplicativo e fornece orientações para abordagens alternativas e soluções que podem ajudar a lidar com possíveis problemas.
Antes de continuar
Se você não estiver familiarizado com a criação de perfil de desempenho na Unreal Engine, recomendamos que leia esta Introdução à criação de perfil e configuração de desempenho para estabelecer os fundamentos neste tópico antes de continuar lendo as seções a seguir.
Objetos gerenciados, coleta de lixo e picos de processamento
Na Unreal Engine, os UObjects e quaisquer classes derivadas deles (como atores e ativos de dados) são gerenciados pelo coletor de lixo da engine. O coletor de lixo limpa periodicamente UObjects que são excluídos no mundo e limpa todas as referências existentes a esse objeto.
Em comparação, objetos C++ padrão são não gerenciados. Isso significa que, ao excluir ou anular uma cópia de um objeto, você precisa limpar manualmente as referências a ele. Se não for manuseado com cuidado, isso introduz riscos, pois qualquer lacuna na lógica de limpeza pode resultar em vazamentos de memória (se os objetos não forem limpos) e referências inválidas (se os objetos forem excluídos, mas as referências permanecerem).
O suporte a objetos gerenciados aumenta o uso de memória. Os UObjects carregam metadados adicionais como um FName e uma referência externa que ocupam memória adicional. O coletor de lixo precisa ser executado de vez em quando para limpar os objetos de forma automática. Portanto, um sistema de back-end precisa ser capaz de monitorar todos os lugares onde os objetos são referenciados. Os picos de processamento costumam ocorrer durante os quadros em que o coletor de lixo é executado, especialmente se o aplicativo destruiu muitos objetos recentemente.
Você pode configurar a coleta de lixo em Configurações de Projeto > Engine > Coleta de lixo, incluindo o intervalo da coleta de lixo, a quantidade máxima de objetos que ela pode limpar a qualquer momento e outras configurações de processamento da coleta de lixo. Embora seja improvável que você precise fazer ajustes finos no início do seu projeto, isso fornece opções para adaptar o comportamento do coletor de lixo da Unreal Engine às necessidades específicas do projeto.
Recomendamos usar a coleta de lixo automática. Se necessário, você também pode chamar o coletor de lixo manualmente usando o nó Coletar lixo no Blueprint ou a função UObjectGlobals::CollectGarbage no C++.
Isso causará um pico de processamento, mas pode haver momentos em que chamar a coleta de lixo manualmente evitará que você acumule lixo em segundo plano, causando um pico maior quando a coleta for executada de forma automática mais tarde.
A coleta de lixo manual é adequada nas seguintes situações:
Quando o programa está em um estado em que picos de quadro são mais toleráveis do ponto de vista da EU, como durante uma tela de carregamento. Isso reduz a chance de ocorrer durante um estado em que seria mais perceptível ou intolerável.
Antes de uma operação que aloca muita memória, se você descobrir durante o teste que a operação pode causar falhas por falta de memória ou problemas na troca de páginas, a menos que a coleta de lixo seja realizada imediatamente antes.
Como criar e destruir objetos vs. agrupamento de objeto
Para criar um objeto, o computador deve alocar um novo bloco de memória para manter uma cópia do objeto e, em seguida, inicializá-lo junto com os subobjetos necessários. Ao destruir um objeto, você deve excluir essas informações, desalocá-lo e limpar todas as referências a esse objeto que possam existir em outras partes do código do seu aplicativo.
Ambas as operações podem ser bastante caras, especialmente quando a inicialização envolve a coordenação com outros sistemas. Na maioria das vezes, a Unreal Engine é eficiente o suficiente para lidar com essas operações e você pode usá-las com segurança em muitos contextos em PC e consoles. No entanto, em projetos com uma margem limitada para processamento na CPU, você pode querer usar o agrupamento de objeto em vez disso. O agrupamento de objeto é o ato de criar todas as cópias de um objeto necessárias antecipadamente, alocá-las na memória e mantê-las desabilitadas ou ocultas até que sejam necessárias.
Quanto mais alto o nível de um objeto, mais caro será criar e destruí-lo. O agrupamento tem mais chances de ser útil para atores do que para componentes, e mais chances de ser útil para componentes do que para outros UObjects. Isso ocorre porque o custo de criar um ator também envolve inseri-lo na lista de atores do mundo, criar seus componentes e registrar a si mesmo e a seus componentes com infraestrutura adicional, como renderização e física. Quando se trata de estruturas C++ que não interagem com classes adicionais durante a criação e a destruição, uma tentativa de agrupá-las pode acabar sendo menos eficiente do que permitir que o alocador do sistema recicle a memória bruta.
Por exemplo, pense em uma arma que dispara projéteis. É bastante comum que uma arma gere um projétil ao disparar. Esse projétil se destrói ao colidir com outro objeto.
Com o agrupamento de objetos, em vez de gerar um novo projétil toda vez que você precisa disparar um, sua arma pré-geraria o número máximo de projéteis que ela poderia ter ativos a qualquer momento, e então os ocultaria e os desabilitaria. Esse grupo de projéteis desabilitados é o grupo de objetos. Quando a arma dispara um projétil, ela retira o projétil do grupo, move-o para a extremidade da arma, exibe-o, habilita-o e inicializa-o na direção correta. Assim, quando o projétil atingir um alvo, ele se ocultaria e se desabilitaria, retornando ao grupo para ser reutilizado mais tarde.
O benefício dos grupos de objetos é que você não precisa criar ou destruir objetos, o que economiza muito tempo de processamento gasto ao inicializar e na limpeza. A desvantagem é que eles usam memória que, de outra forma, estaria desocupada, mesmo quando os objetos no grupo estão inativos. No entanto, em muitas situações, você precisaria deixar espaço para o número máximo de objetos necessários para o grupo. Além disso, a memória para esses objetos permanece mais estável, pois você aloca e limpa em grandes partes em vez de pequenas, reduzindo a possibilidade de fragmentação da memória.
Lógica de marcação vs. retornos de chamadas, temporizadores e lógicas agendadas
O evento de marcação em UObjects e atores marcáveis fornece uma maneira de criar lógica que se repete a cada quadro. Isso é útil para gerenciar o movimento em tempo real. No entanto, usar marcação para rotinas ocasionais em vez de contínuas pode resultar em um uso desnecessário da CPU.
Em particular, costuma ser subotimizado usar uma lógica que verifica se uma variável foi alterada em cada quadro, como no exemplo a seguir. Uma classe usa marcação para verificar repetidamente quando a variável de outra classe muda.
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AChildActor* ChildActor;
protected:
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
int32 getMyInt(){
return MyInt;
}
private:
int32 myInt = 0;
Em vez de usar marca de verificação para monitorar o valor, você pode criar uma função setter personalizada para envolver a operação de alteração da variável e, em seguida, chamar outra função ou evento que executa a lógica necessária somente quando você altera esse valor.
O exemplo a seguir inclui as classes do último exemplo, mas agora usa um retorno de chamada para realizar uma ação apenas quando uma variável é alterada:
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
void OnChildIntChanged(int32 NewValue)
{
if (newValue > 3)
{
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AMyActor* ParentActor;
Isso garante que a lógica só seja executada quando a variável mudar, em vez de consultar um valor a cada quadro.
No entanto, a abordagem acionada por evento pode não ser tão ideal dependendo da frequência com que a condição muda. Se um evento for disparado várias vezes por quadro, ou se uma função estiver anexada a muitos eventos que podem ser alterados no mesmo quadro, poderá ser mais eficiente usar uma marcação ou o "padrão de comando". Isso evita o cálculo de resultados que acabam sendo substituídos antes de serem renderizados.
Quando você deseja agendar um evento para ocorrer após um período definido, pode iniciar um temporizador, que rastreará temporariamente o tempo decorrido até o término e se limpará. Outra opção é usar o nó de Atraso em um gráfico de eventos de Blueprints.
Se precisar que a lógica ocorra com frequência, mas não a cada quadro, considere fazer com que ela ocorra em um intervalo específico de quadros ou segundos. Você pode fazer isso em objetos individuais e componentes de Ator definindo o intervalo de marcação para um determinado número de segundos. Outra opção é criar intervalos para subconjuntos da lógica na função marcação. Embora ainda seja necessário acumular e restaurar uma variável para fazer isso, ainda é mais barato do que executar a lógica em cada quadro.
Lógica assíncrona vs. síncrona
Lógica síncrona refere-se à conclusão de ações do começo ao fim, em sequência. A maioria da lógica que você escreve em Blueprints ou C++ será síncrona por padrão. Por exemplo, se você criar um evento em Blueprints, mas não adicionar nós de atraso, temporizadores ou tarefas de jogabilidade, toda a lógica decorrente desse evento de Blueprints será executada de uma vez no mesmo quadro. O processamento do quadro não pode ser concluído até que a lógica seja executada. Quando você executa operações extensas, especialmente em grandes conjuntos de dados ou grandes objetos que devem ser carregados ou descarregados da memória, isso pode causar picos de processamento significativos.
Lógica assíncrona refere-se à conclusão de ações em paralelo, literalmente ao mesmo tempo (em núcleos de CPU separados), ou logicamente ao mesmo tempo (entrelaçamento em partes menores que são tecnicamente executadas de forma síncrona em um nível baixo). Uma operação assíncrona é executada até ser concluída, enquanto o programa principal continua em execução sem aguardar a atualização da operação. Em geral, operações assíncronas usam retornos de chamada para sinalizar quando são concluídas.
Vários frameworks da Unreal Engine, como o sistema World Partition e vários sistemas de entrega de conteúdo sob demanda, já são assíncronos. Nos seus projetos, considere a possibilidade de implementar uma lógica assíncrona para distribuir as operações por um período e evitar colocar muito peso em uma única operação ou quadro.
Por exemplo, talvez você precise carregar e instanciar um grande número de inimigos (por exemplo, 30 ou mais) em um jogo de defesa baseado em ondas. Como criar um Ator em tempo de execução já é caro, tentar processar todos eles no mesmo quadro é muito complicado. Em vez disso, você pode criar uma operação assíncrona para surgir apenas até cinco inimigos por quadro até atingir o limite especificado ou esgotar todas as posições de surgimento especificadas. Isso resultaria em todos os 30 inimigos surgindo ao longo de seis quadros, após o que você sinalizaria que a operação de surgimento em massa foi concluído. Embora isso alivie drasticamente a carga de trabalho de gerar tantos inimigos, a maioria dos jogadores não notará a duração do processo, que ocorre em um décimo ou um quinto de segundo.
Processamento paralelo na Unreal Engine
O processamento paralelo é um tipo de processamento assíncrono em que as ações são processadas no mesmo computador, mas em threads ou núcleos de CPU diferentes. Alguns exemplos de processamento paralelo na Unreal Engine incluem:
Resolver ponteiros suaves.
Carregar níveis e ativos em segundo plano.
Carregar ativos de forma assíncrona a partir de um sistema de entrega de conteúdo on-line.
Um thread é um caminho dedicado para processar instruções, na CPU ou na GPU. A maioria das CPUs tem vários núcleos, que são processadores individuais, e cada núcleo pode ter vários threads. Tirar proveito do processamento paralelo é chave para garantir que um programa não fique sobrecarregado com processos da CPU, especialmente ao lidar com tarefas mais complexas e grandes quantidades de dados.
Threads de processamento notáveis
A Unreal Engine tem threads dedicados para:
| Nome do thread | Descrição |
|---|---|
Game | Lida com a lógica UObject e ator em C++ e Blueprints, bem como a lógica da IU. A maior parte da programação ocorrerá nesse thread. |
Renderização | Converte a estrutura da cena em comandos de desenho. |
RHI | Envia comandos de desenho à GPU. |
Grupos de tarefas | Lida com várias tarefas em threads reutilizáveis. |
Áudio | Processamento de som e música. |
Carregando | Lida com o carregamento e descarregamento de dados. |
Você pode ver esses threads na janela Insights de tempo do Unreal Insights.
Como o thread do jogo lida com uma grande quantidade de lógica, é importante analisar e otimizar seu código com cuidado.
Como criar sua própria lógica em thread
A UE tem vários recursos para adicionar sua própria lógica de processamento paralelo:
O sistema de tarefas fornece um framework robusto e relativamente leve para dividir a lógica em tarefas que podem ser executadas em paralelo em threads separados.
FRunnable fornece a interface mais direta e de baixo nível para executar uma função em um thread arbitrário. Isso deve ser evitado, a menos que você saiba o que está fazendo e tenha uma justificativa para usar um thread dedicado em vez do grupo de threads.
Tenha cuidado ao criar sua própria lógica com threads, pois isso pode resultar em condições race, em que ocorrem erros se as operações ocorrerem em uma ordem inesperada.
Além disso, a página Renderização em threads fornece informações sobre a lógica de threads específicas da renderização.
Compilação de shader, problemas de taxa de quadros e cache de PSO
A Unreal Engine compila instruções de material em shaders para prepará-los para execução na GPU. Embora o desempenho geral dos materiais melhore significativamente após a compilação, o ato de compilar shaders pode resultar em picos significativos de processamento, o que, por sua vez, causa problemas momentâneos, mas perceptíveis, na taxa de quadros.
Para neutralizar isso, a UE implementa o cache de PSO. Colete PSOs de forma manual, reproduzindo e testando o aplicativo, ou use o pré-cache de PSO para gerá-los de forma automática. Em ambos os casos, a ideia é gravar todos os estados possíveis que a placa de vídeo renderizará quando o aplicativo estiver em execução, armazenar esses dados em cache e agrupá-los para usar em versões subsequentes. Isso reduz drasticamente a quantidade de compilação de shader que precisa acontecer no tempo de execução, pois você pode carregar a maioria deles com antecedência. Isso reduz a quantidade de problemas para os usuários ao carregarem novas áreas e materiais.