Les sections suivantes décrivent chacune une situation pouvant influencer les performances de votre application et propose des approches alternatives et des solutions de contournement pour résoudre les problèmes que vous pourriez rencontrer.
Avant de continuer
Si vous ne connaissez pas encore le profilage de performances dans l'Unreal Engine, nous vous recommandons de consulter la page Introduction to Performance Profiling and Configuration (Introduction au profilage et à la configuration des performances avant de lire ces sections, afin d'acquérir les connaissances de base.
Objets gérés, nettoyage de la mémoire et pics de traitement
Dans l'Unreal Engine, les UObjects et toutes les classes qui en dérivent (comme les acteurs et les ressources de données) sont gérés par le nettoyage de mémoire du moteur. Cet outil nettoie régulièrement les UObjects qui sont supprimés dans le monde ainsi que toutes les références correspondantes.
En comparaison, les objets C++ standard ne sont pas gérés. Vous devez alors nettoyer manuellement les références d'un objet que vous supprimez ou dont vous invalidez la copie. Cette méthode présente des risques si elle n'est pas gérée avec précaution, car toute omission dans la logique de nettoyage peut entraîner des fuites de mémoire (si les objets ne sont pas nettoyés) ou des références non valides (si les objets sont supprimés mais sont toujours référencés).
La prise en charge des objets gérés entraîne une consommation de mémoire supplémentaire. Les UObjects contiennent des métadonnées supplémentaires, comme un FName et une référence externe, qui occupent davantage de mémoire. Le nettoyage de mémoire doit être exécuté régulièrement pour nettoyer automatiquement les objets. Le système back-end doit donc être capable de surveiller tous les endroits où les objets sont référencés. Les pics de traitement surviennent souvent lors d'une opération de nettoyage de mémoire, notamment si votre application a depuis peu éliminé un grand nombre d'objets.
Vous pouvez configurer le nettoyage de la mémoire dans les Paramètres du projet > Moteur > Nettoyage de la mémoire, notamment son intervalle, la quantité maximale d'objets pouvant être nettoyés à tout moment et d'autres paramètres de traitement. Même si vous n'aurez probablement pas besoin d'effectuer des ajustements au début de votre projet, ces options vous permettront d'adapter le comportement du nettoyage de la mémoire de l'Unreal Engine à vos besoins spécifiques.
Il est recommandé d'utiliser le nettoyage de la mémoire automatique. Si nécessaire, vous pouvez également appeler manuellement le nettoyage de la mémoire à l'aide du nœud Collect Garbage en blueprint, ou la fonction UObjectGlobals::CollectGarbage en C++.
Un pic de traitement se produira alors, mais il peut arriver que l'appel manuel du nettoyage de la mémoire empêche l'accumulation de mémoire en arrière-plan, ce qui provoquera un pic plus important lorsqu'il s'exécutera automatiquement plus tard.
Le nettoyage de la mémoire manuel est adapté aux cas suivants :
Lorsque le programme se trouve dans un état où, du point de vue de l'expérience utilisateur, les pics de latence d'images sont plus acceptables, par exemple pendant un écran de chargement. Cela réduit le risque que cette opération automatique se déclenche dans un état où elles serait plus visible ou inacceptable.
Avant une opération qui nécessite une allocation de mémoire importante, si vous constatez lors de vos tests qu'elle peut entraîner des plantages par manque de mémoire ou des problèmes de changement de page, sauf si le nettoyage de la mémoire est effectué immédiatement avant.
Création et destruction d'objets ou regroupement d'objets
Pour créer un objet, votre ordinateur doit allouer un nouveau bloc de mémoire pour y stocker une copie, puis l'initialiser avec tous les sous-objets requis. Lorsque vous détruisez un objet, vous devez le supprimer, annuler son allocation et effacer toute référence correspondante éventuellement présente ailleurs dans le code de votre application.
Ces deux opérations peuvent se révéler relativement coûteuses, notamment si leur initialisation implique une coordination avec d'autres systèmes. Dans la plupart des cas, Unreal Engine gère efficacement ces opérations et vous pouvez les utiliser en toute sécurité dans de nombreux contextes sur PC et consoles. Toutefois, dans les projets dont la marge de traitement est limitée au niveau du processeur, il peut être préférable d'utiliser le regroupement d'objets. Le regroupement d'objets consiste à créer toutes les instances nécessaires d'un objet, à les allouer en mémoire et à les maintenir désactivées ou masquées jusqu'à ce qu'elles soient requises.
Plus le niveau d'un objet est élevé, plus sa création et sa destruction seront coûteuses. Le regroupement est plus utile pour les acteurs que pour les composants, et plus utile pour les composants que pour les autres UObjects. En effet, le coût de la création d'un acteur implique également son insertion dans la liste des acteurs du monde, la création de ses composants et son inscription ainsi que celle de ses composants auprès d'une infrastructure supplémentaire, telle que le rendu ou la physique. Dans le cas de structures C++ qui n'interagissent pas avec d'autres classes lors de leur création et de leur destruction, tenter de les regrouper peut s'avérer moins efficace que de laisser l'allocateur du système recycler leur mémoire brute.
Prenons l'exemple d'une arme qui tire des projectiles. Il n'est pas rare qu'une arme qui tire génère un projectile, puis que le projectile s'autodétruise lorsqu'il touche un autre objet.
Au lieu de faire apparaître un nouveau projectile chaque fois que vous devez tirer, le regroupement d'objets permet à votre arme de générer à l'avance le nombre maximal de projectiles dont elle dispose à tout moment, puis de les masquer et de les désactiver. Ce groupe de projectiles désactivés forme le groupe d'objets. Lorsqu'un tir est déclenché, votre arme récupère le projectile du groupe, le place à l'extrémité du canon, le rend visible et le réactive, puis l'initialise dans la bonne direction. Lorsque le projectile touche une cible, il est alors masqué, se désactive et réintègre le groupe pour être réutilisé plus tard.
Les groupes d'objets vous évite d'avoir à créer ou détruire des objets, ce qui vous fait gagner un temps de traitement considérable pour les initialiser et les nettoyer. En revanche, ils consomment davantage de mémoire qui serait autrement libre, car les objets contenus dans un groupe occupent de l'espace même lorsqu'ils sont inactifs. Toutefois, dans de nombreux cas, vous devrez de toute façon prévoir de la mémoire pour le nombre maximal d'objets requis. D'autre part, la mémoire dédiée à ces objets reste plus stable, car elle est allouée et nettoyée en segments importants, ce qui limite les risques de fragmentation de mémoire.
Logique de tick ou logique de rappels, de chronomètres et planifiée
L'événement de tick des UObjects et acteurs tickables permet de créer une logique qui se répète à chaque image. Cela est utile pour la gestion des mouvements en temps réel. Cependant, utiliser le tick pour des routines ponctuelles peut entraîner une consommation inutile du processeur.
Par exemple, une mauvaise pratique consiste à utiliser une logique pour vérifier si une variable a changé à chaque image, comme illustré ci-après. Une classe utilise le tick pour vérifier plusieurs fois si la variable d'une autre classe change.
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;
Au lieu d'utiliser la fonction tick pour surveiller la valeur, vous pouvez créer une fonction setter personnalisée pour encapsuler l'opération de modification de la variable, puis appeler une autre fonction ou un événement qui exécute la logique nécessaire uniquement lorsque vous modifiez cette valeur.
Dans l'exemple suivant, les classes de l'exemple précédent sont incluses, mais un rappel est exécuté pour lancer une action uniquement si une variable a changé :
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;
De cette manière, la logique ne s'exécute que lorsque votre variable change au lieu d'interroger une valeur à chaque image.
Cependant, l'approche pilotée par les événements peut être moins optimale, selon la fréquence des changements de condition. Si un événement est déclenché plusieurs fois par image ou si une fonction est associée à des événements multiples susceptibles de changer dans la même image, il peut être plus efficace d'utiliser le tick ou le "modèle de commande". Cela évite de calculer des résultats qui seraient écrasés avant d'être rendus.
Si vous souhaitez planifier l'exécution d'un événement après un certain délai, vous pouvez démarrer un chronomètre, qui suivra momentanément le temps écoulé jusqu'à son terme, puis s'auto-nettoiera. Vous pouvez également utiliser le nœud Delay dans un graphique d'événements de blueprint.
Si une logique doit être répétée souvent mais pas à chaque image, vous pouvez définir un intervalle spécifique d'images ou de secondes. Pour appliquer cette méthode à des objets ou des composants d'acteur individuels, vous pouvez définir leur intervalle de tick sur un nombre de secondes défini. Vous pouvez également créer des intervalles pour des sous-ensembles de la logique dans votre fonction tick. Même si cela implique d'accumuler une variable de temps et de la réinitialiser, c'est tout de même moins coûteux que d'exécuter la logique à chaque image.
Logique asynchrone contre logique synchrone
La logique synchrone correspond à l'exécution d'actions du début à la fin, en séquence. La plupart de la logique que vous écrivez en blueprint ou en C++ sera synchrone par défaut. Par exemple, si vous créez un événement en blueprint sans y ajouter de nœuds Delay, de chronomètres ou de tâches de gameplay, toute la logique issue de cet événement blueprint sera exécutée immédiatement sur la même image. L'image ne peut pas se terminer tant que cette logique n'a pas fini de s'exécuter. Les opérations lourdes, notamment sur de vastes ensembles de données ou des objets volumineux à charger ou décharger dans la mémoire, peuvent entraîner des pics de traitement significatifs.
La logique asynchrone consiste à exécuter des actions en parallèle, soit littéralement en même temps (sur différents cœurs de processeur), soit logiquement en même temps (entrelacées en segments plus petits, techniquement exécutés de façon synchronisée à un niveau bas). Une opération asynchrone s'exécute jusqu'à la fin, pendant que le programme principal poursuit son exécution sans attendre qu'elle le rattrape. En général, les opérations asynchrones utilisent des rappels pour signaler qu'elles sont terminées.
Plusieurs frameworks d'Unreal Engine, comme le système World Partition ou divers systèmes de livraison de contenu à la demande, sont déjà asynchrones. Dans vos propres projets, vous pouvez également implémenter une logique asynchrone pour répartir les opérations dans le temps, afin d'éviter de surcharger une seule opération ou une seule image.
Imaginons un jeu de défense par vagues où vous devez charger et instancier un grand nombre d'ennemis, au moins 30. Créer un nouvel acteur à l'exécution est déjà coûteux, mais il peut être laborieux d'essayer de tous les traiter sur la même image. À la place, vous pourriez créer une opération asynchrone pour ne faire apparaître que 5 ennemis par image, jusqu'à atteindre la limite spécifiée ou épuiser tous les points d'apparition spécifiés. Ainsi, les 30 ennemis apparaissent sur 6 images, après quoi un signal peut indiquer que l'opération de génération massive est terminée. Bien que cela répartisse considérablement la charge d'apparition d'un nombre si élevé d'ennemis, la plupart des joueurs ne remarqueront pas la durée au cours de laquelle l'apparition survient, car elle se produit sur un dixième ou un cinquième de seconde.
Traitement parallèle dans l'Unreal Engine
Le traitement parallèle est une forme de traitement asynchrone dans lequel les actions sont traitées sur le même ordinateur, mais dans des threads ou des cœurs de processeur différents. Voici quelques exemples de traitement en parallèle dans l'Unreal Engine :
Résolution de pointeurs logiciels
Chargement des niveaux et des ressources en arrière-plan
Chargement asynchrone des ressources à partir d'un système de livraison de contenu en ligne
Un thread est un chemin dédié au traitement d'instructions, sur votre processeur ou votre processeur graphique. La plupart des processeurs sont dotés de plusieurs cœurs, chacun étant un processeur à part entière, et chaque cœur peut avoir plusieurs threads. Tirer parti du traitement parallèle est essentiel pour éviter les goulots d'étranglement sur les processus d'un processeur, notamment lorsqu'il gère des tâches plus complexes et des quantités de données plus importantes.
Threads de traitement notables
Unreal Engine dispose de threads dédiés aux tâches suivantes :
| Nom du thread | Description |
|---|---|
Game (jeu) | Gère la logique des UObjects et des acteurs en C++ et en blueprint, ainsi que la logique d'interface. L'essentiel de la programmation s'exécutera sur ce thread. |
Rendu | Convertit la structure de la scène en commandes de dessin. |
RHI | Envoie des commandes de dessin au processeur graphique. |
Groupes de tâches | Gère diverses tâches dans des threads réutilisables. |
Audio | Traite le son et la musique. |
Chargement | Gère le chargement et le déchargement des données. |
Vous pouvez afficher ces threads dans la fenêtre Timing Insights d'Unreal Insights.
Étant donné que le thread du jeu gère une quantité importante de logique, il est essentiel de profiler et d'optimiser soigneusement votre code.
Créer votre propre logique thread
L'UE propose de nombreuses ressources pour intégrer votre propre logique de traitement parallèle :
Le système de tâches fournit un framework robuste et relativement léger permettant de diviser la logique en tâches exécutables en parallèle sur des threads distincts.
FRunable fournit l'interface de bas niveau la plus directe pour exécuter une fonction sur un thread arbitraire. À éviter, sauf si vous savez exactement ce que vous faites et que vous avez une raison pour utiliser un thread dédié plutôt qu'un groupe de threads.
Faites attention lorsque vous créez votre propre logique de thread, car cela peut entraîner des conditions de course, où des erreurs se produisent si des opérations s'exécutent dans un ordre imprévu.
Par ailleurs, la page Threaded Rendering (Rendu à thread) fournit des informations sur la logique à thread propre au rendu.
Compilation des shaders, à-coups de fréquence d'images et mise en cache PSO
Unreal Engine compile les instructions de matériau en shaders pour préparer leur exécution sur le processeur graphique. Bien que les performances globales des matériaux s'améliorent considérablement une fois la compilation terminée, compiler les shaders peut entraîner des pics de traitement importants, ce qui se traduit par des problèmes de fréquence d'images momentanés mais notables.
Pour remédier à cela, l'UE a implémenté la mise en cache PSO. Vous pouvez soit rassembler manuellement des PSO en jouant et en testant votre application, soit utiliser la mise en cache anticipée de PSO pour les générer automatiquement. Dans les deux cas, l'idée est d'enregistrer tous les états possibles que votre carte graphique devra rendre durant l'exécution de l'application, puis de mettre ces données en cache et de les regrouper pour les utiliser dans les builds suivants. Cela réduit considérablement la quantité de compilation des shaders à l'exécution, car vous pouvez charger la plupart d'entre eux à l'avance, ce qui limite les à-coups ressentis par les utilisateurs lors du chargement de nouvelles zones et de nouveaux matériaux.