accueil / blog / moteur
Publié: 24 mai 2026 9 min de lecture moteur
Unreal Blueprint ou C++ : le vrai coût mesuré en millisecondes

Unreal Blueprint ou C++ : le vrai coût mesuré en millisecondes

La VM Blueprint reste invisible dans la plupart des scènes, mais à des dizaines de milliers de nodes par tick elle coûtait 7 ms. Voici le partage, chiffré.

Quand 41 000 nodes s'exécutent dans un seul tick

Sur un projet de tower defense fin 2023, le temps par frame passait de 11,2 ms à 19,4 ms dès que la vague 14 démarrait. Dans l'éditeur, tout semblait acceptable ; dans un build Development packagé, l'écart sautait aux yeux. La première ligne d'Unreal Insights portait le nom BlueprintTime et engloutissait à elle seule 7,1 ms.

Le coupable était l'Event Tick de BP_TowerBase. Chaque tour parcourait les 220 ennemis avec un ForEachLoop, calculait une distance au carré et retenait le plus proche. 24 tours multipliés par 220 ennemis, avec une poignée d'opérations par node, donnaient environ 41 000 exécutions de nodes par frame.

Le problème n'était pas que « Blueprint est lent ». Le problème, c'était une recherche en O(n·m) exécutée dans une machine virtuelle à chaque frame. Déplacer ce code en C++ réduit bien le coût, mais l'erreur véritable était algorithmique, et dans le débat Unreal Blueprint ou C++ ces deux choses sont sans arrêt confondues.

Nous étions trois dans l'équipe à l'époque, et aucun de nous ne profilait la partie Blueprint. À mesure que le temps par frame se dégradait, nous avons regardé les draw calls, puis les réglages d'ombres, puis les résolutions de textures. Après deux jours perdus, il a suffi d'ouvrir Insights et de lire la bonne ligne. La suite de cet article, ce sont les règles que j'ai notées pour ne pas revivre ces deux jours.

Où la performance des Blueprints devient mesurable

Pour obtenir un chiffre, j'ai fait un test tout simple : appeler une fonction vide 100 000 fois. Côté C++, le total atteignait 0,4 ms ; la même fonction en Blueprint demandait 6,8 ms. Cela revient à environ 68 ns de surcoût par appel.

68 ns, cela ressemble à rien, et la plupart du temps c'en est. Pour une séquence d'ouverture de porte de 200 nodes ou pour la logique d'un écran d'inventaire, le coût reste sous le seuil de bruit ; en dessous d'environ 2 000 nodes par frame, l'impact total n'a jamais dépassé 0,15 ms dans mes mesures.

Le seuil à partir duquel ce n'est plus du bruit est net : des dizaines de milliers de nodes par frame. Ma règle approximative tient en une phrase : si un Blueprint tourne à chaque frame et contient une boucle, cette logique devient candidate au C++. Pour les flux déclenchés par des événements ponctuels, le surcoût de la VM ne mérite aucune discussion.

Il y a aussi un piège de mesure. En PIE, les appels Blueprint paraissent plus coûteux qu'ils ne le sont réellement, à cause de l'instrumentation de debug, donc décider à partir d'un chiffre relevé dans l'éditeur induit en erreur. Je ne déplace rien vers le C++ avant d'avoir vérifié un build Development packagé avec stat game et Insights.

Workflow hybride : le cœur en C++, le réglage en Blueprint

La sélection de cible, le calcul des dégâts, le suivi des cooldowns et la machine à états sont passés en C++ dans AOFKTowerBase. La recherche de cible ne tourne plus à chaque frame : elle s'exécute sur un timer de 0,2 seconde au-dessus d'une grille spatiale. Ce qui est resté en Blueprint : le timing des effets, les déclenchements audio, les retours d'interface et les valeurs que le designer retouche en permanence.

Le temps par frame est descendu de 19,4 ms à 11,8 ms et BlueprintTime de 7,1 ms à 0,9 ms. Tout cela ne vient pas de la VM : environ 5 ms reviennent au changement d'algorithme et 2,6 ms au passage en code natif. Sans cette distinction, écrire « le C++ a rendu le jeu 40 % plus rapide » serait malhonnête.

Deux solutions ont été écartées. Le tout-C++ était techniquement le plus rapide, mais un designer qui voulait tester une seule valeur de dégâts devait encaisser 90 secondes de compilation puis un redémarrage de l'éditeur ; pour quelqu'un qui fait 40 essais par jour, c'est inacceptable. Tout laisser en Blueprint en se contentant de couper le tick n'a rien donné, puisque la boucle responsable du coût restait en place.

La question que je me pose pour tracer la frontière est simple : modifier cette valeur exige-t-il une compilation ? Si oui, et si un designer y touche plusieurs fois par semaine, cette valeur appartient à un Blueprint ou à un data asset. Notre table d'équilibrage des tours vit dans un UDataAsset : le C++ la lit, le designer l'édite dans l'éditeur, et personne n'attend un build.

AOFKCharacter.h
#pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "AOFKCharacter.generated.h" UCLASS() class OFKGAME_API AOFKCharacter : public ACharacter { GENERATED_BODY() public: // Designer-tunable value, read-only in Blueprint so the rule stays in C++. UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combat", meta = (ClampMin = "0.05", UIMax = "2.0")) float DashCooldown = 0.35f; UFUNCTION(BlueprintCallable, Category = "Combat") bool CanDash() const; protected: // C++ decides when it fires; Blueprint decides how it looks and sounds. UFUNCTION(BlueprintImplementableEvent, Category = "Combat") void OnDashStarted(float Cooldown); private: float LastDashTime = -1000.f; };
cppAOFKCharacter.h

Exposer la bonne surface avec UPROPERTY

La qualité d'un montage hybride dépend de ce que le C++ expose exactement au Blueprint. Les valeurs réglables sortent en UPROPERTY(EditAnywhere, BlueprintReadOnly) : le designer peut les changer dans l'éditeur, mais pas y écrire à l'exécution. Ajouter meta = (ClampMin, UIMax) supprime la moitié des bugs du type « quelqu'un a tapé 0 par erreur » avant même qu'ils existent.

Côté fonctions, la différence entre trois specifiers compte. BlueprintCallable, c'est le Blueprint qui pose une question au C++ ; BlueprintImplementableEvent, c'est le C++ qui dit au Blueprint « cela vient d'arriver, occupe-toi de l'apparence » ; BlueprintNativeEvent fournit une implémentation par défaut en C++ que le Blueprint peut redéfinir si besoin. La règle tient en une ligne : le C++ décide quand, le Blueprint décide de l'apparence.

L'erreur que je vois le plus souvent, c'est de coller BlueprintReadWrite sur chaque champ. Une fois la porte ouverte, les designers écrivent dans les variables d'état et les invariants protégés en C++ cassent en silence ; chez nous, cela s'est manifesté par un compteur de munitions passé en négatif. Le second piège, c'est le renommage : changer le nom d'une UPROPERTY casse silencieusement les références Blueprint, donc nous ne renommons aucun champ sans ajouter une entrée dans les Core Redirects.

Il y a aussi un versant temps de compilation. Toucher une UPROPERTY dans un header reconstruit tout ce qui inclut ce header, ce qui montait chez nous à 4 minutes sur une machine correcte. Regrouper les réglages qui bougent souvent dans une petite structure séparée a accéléré la boucle du designer et fait visiblement baisser le nombre de rebuilds complets par jour.

Ce qui a changé après la suppression de la nativization

Vers la 4.26, certaines équipes s'appuyaient sur la Blueprint nativization, et nous l'avons essayée un temps. Le gain mesuré sur les scènes lourdes tournait autour de 1,6 ms ; en échange, le packaging prenait 18 minutes de plus et nous avons rencontré trois bugs qui n'apparaissaient que dans les builds nativisés. UE5 a supprimé l'option purement et simplement.

Conséquence pratique : il n'existe plus de porte de sortie tardive où le compilateur vous sauve. Avoir le hot path en C++ n'est plus une étape d'optimisation, c'est une décision d'architecture prise au début du projet. Traiter le C++ d'UE5 comme une rustine que l'on ajoute après coup, c'est précisément ce qui rattrape les équipes en pleine production.

Il y a aussi un bon côté. Tant que la nativization existait, les gens écrivaient de la logique lourde en Blueprint en prévoyant de « la nativiser plus tard », et ce plus tard n'arrivait jamais. L'option disparue, tracer la frontière dès le départ est devenu obligatoire, ce qui nous a laissé avec le temps une base de code plus propre.

Ce que nous avons mis à la place de la nativization, c'est la mesure. Avant chaque release, nous capturons une trace Insights depuis un build packagé, et dès que BlueprintTime dépasse 1 ms, nous cherchons quel Blueprint en est responsable. Le contrôle prend quinze minutes. Sur l'année écoulée, il a attrapé trois régressions sérieuses avant qu'elles ne partent en production.

Les conflits de merge quand l'équipe grandit

À quatre, le fait qu'un Blueprint soit un asset binaire ne posait aucun problème. À onze, deux ou trois fois par semaine, deux personnes touchaient le même .uasset, et un .uasset ne se merge pas ; celui qui perdait refaisait son travail. Nous avons chiffré le temps ainsi perdu sur un sprint à environ deux jours.

Nous avons mis en place le checkout obligatoire et les verrous exclusifs dans Perforce. Les conflits ont cessé, remplacés par l'attente : tant qu'un Blueprint est verrouillé, la deuxième personne attend ou change de tâche. Le C++ n'a pas ce défaut, car le texte se merge ligne par ligne et se lit vraiment dans une pull request. Relire un Blueprint, en pratique, consiste à s'envoyer des captures d'écran.

Aujourd'hui, j'applique quatre règles au développement sous Unreal Engine. Aucune logique exécutée à chaque frame ne reste en Blueprint, et le tick est désactivé par défaut sur chaque Blueprint que nous créons. Si un Blueprint dépasse 150 nodes, une partie en sort vers le C++. Et si deux personnes doivent éditer le même Blueprint pendant un même sprint, cette logique appartient déjà au C++.

Ces règles concernent le débit de l'équipe autant que la performance. Utilisez le Blueprint pour l'itération rapide des designers et le C++ pour la colonne vertébrale du système, et tracez cette frontière avant la première ligne de code. Déménager plus tard coûte toujours plus cher : notre facture a été un refactor de trois semaines.

← Tous les articles