digitart-asso.

Génération procédurale 3D : ce que mes crashs m'ont appris

Technologies Créatives. Génération procédurale 3D : ce que mes crashs m'ont appris

Un pipeline de génération procédurale 3D peut fonctionner parfaitement pendant plusieurs minutes, puis devenir instable sans qu'aucun changement visible n'apparaisse à l'écran.

Génération procédurale 3D: ce que mes crashs m'ont appris

Le terrain semble identique, le nombre de chunks ne bouge pas, le graphe de nœuds n'a pas été modifié. Pourtant, la mémoire occupée augmente, les temps de calcul s'allongent et le rendu finit par décrocher.

La cause n'est pas toujours un algorithme trop ambitieux. Elle se trouve souvent dans les frontières entre la génération et le runtime: une ressource créée au mauvais endroit, un mesh partagé modifié par erreur, une conversion de collection répétée à chaque frame ou un buffer conservé plus longtemps que prévu. Les bugs de génération procédurale sont rarement spectaculaires dans le code. Ils se manifestent plutôt par une accumulation de petits coûts que le moteur ne peut pas résorber au moment où on le lui demande.

Le piège de l'instanciation dans la boucle Update(): pourquoi votre RAM sature

Toute la chaîne de production procédurale — chunks de terrain, meshes d'architecture, végétation générée à la volée — finit par rencontrer la boucle d'exécution du moteur. Update() est appelée à chaque frame lorsque le composant est actif. Sa fréquence dépend de la charge, du matériel, de la synchronisation verticale et de la configuration du projet. Elle n'est donc pas nécessairement stable, ni égale à 60 images par seconde.

C'est précisément ce qui rend les allocations dans cette méthode trompeuses. Une opération qui semble négligeable pendant une frame peut devenir coûteuse lorsqu'elle se répète, surtout si elle produit des objets temporaires ou force le moteur à reconstruire des ressources natives.

Instancier un GameObject, créer un Mesh, construire un nouveau Material ou redimensionner plusieurs listes à chaque passage n'est pas automatiquement une fuite mémoire. Une allocation temporaire peut devenir éligible au ramassage lorsqu'aucune référence ne la conserve plus. Le problème est ailleurs: la répétition augmente la pression sur le tas managé, déclenche davantage de travail pour le Garbage Collector et peut aussi provoquer des allocations côté moteur, côté GPU ou dans les systèmes de rendu.

La saturation dépend alors de plusieurs éléments:

  • les références encore conservées par les composants, les collections ou les caches;
  • la durée de vie effective des ressources natives associées aux objets C#;
  • le type de collecteur utilisé et sa configuration;
  • la taille des buffers et la fréquence des reconstructions;
  • la mémoire disponible pour le processus et les autres systèmes du moteur;
  • le moment où les ressources sont détruites, et non seulement celui où elles cessent d'être visibles.

Une application peut donc ralentir fortement sans atteindre une OutOfMemoryException. À l'inverse, un projet peut allouer beaucoup d'objets temporaires sans s'arrêter immédiatement si le collecteur parvient à les récupérer et si les ressources natives sont correctement libérées. Le crash n'est pas une conséquence automatique de toute allocation dans Update(); c'est le résultat possible d'un système dont le coût cumulé dépasse les capacités de récupération et de gestion de la mémoire.

Les signaux à lire dans le Profiler

Trois observations sont particulièrement utiles avant de chercher une cause unique:

  • Le tas managé augmente-t-il durablement, ou monte-t-il par vagues avant de redescendre?
  • Les allocations apparaissent-elles dans Update(), dans un callback de rendu, lors d'une reconstruction de mesh ou pendant une conversion de données?
  • La mémoire native, la mémoire graphique et le tas managé évoluent-ils de la même manière?

Une courbe qui redescend après un passage du GC ne raconte pas la même histoire qu'une courbe qui continue de monter parce que des références restent actives. De même, une hausse de la mémoire GPU ne prouve pas que le Garbage Collector est en cause: le GC ne gère pas directement toutes les ressources natives utilisées par Unity.

Les chutes de performances ne permettent pas non plus d'identifier à elles seules une compaction. Elles peuvent venir d'un passage du GC, d'une synchronisation entre le CPU et le GPU, de la reconstruction des index d'un mesh, d'un upload vers la carte graphique, d'un chargement de texture ou d'un pic de compilation. Des FPS qui diminuent par paliers peuvent coïncider avec des cycles de collecte, mais chaque palier ne correspond pas nécessairement à une compaction du ramasse-miettes.

Le problème d'une allocation dans Update() n'est pas sa présence isolée. C'est le fait de laisser une opération répétée décider, à chaque frame, de la quantité de travail que le runtime devra récupérer plus tard.

La règle pratique reste simple, mais elle doit être formulée correctement: ne créez pas de ressources lourdes dans une boucle d'exécution si vous pouvez les réutiliser. Pré-allouez les buffers lorsque leur taille maximale est connue, conservez les objets nécessaires dans un pool et mettez à jour les données en place lorsque la topologie le permet.

Pour un mesh qui change souvent, les APIs telles que Mesh.SetVertices, Mesh.SetIndices ou Mesh.SetTriangles permettent de limiter les chemins de copie inutiles. Elles ne suppriment pas par magie tous les coûts: modifier une géométrie, changer sa topologie ou transférer ses données vers le moteur peut toujours demander du travail. L'objectif est de maîtriser ce travail et de savoir à quel moment il intervient.

Un profilage utile consiste à comparer plusieurs scénarios:

1. une géométrie reconstruite à chaque frame;

2. une géométrie reconstruite uniquement lorsqu'un paramètre change;

3. un buffer réutilisé avec une taille réservée;

4. une file de tâches qui regroupe les modifications avant leur application au mesh.

Ce type de comparaison révèle souvent que le véritable gain ne vient pas d'une micro-optimisation de syntaxe, mais de la réduction du nombre de reconstructions.

MeshFilter contre sharedMesh: la source insoupçonnée des fuites

La propriété MeshFilter.mesh est pratique, et c'est justement pour cela qu'elle provoque tant de confusions. Elle ne doit pas être considérée comme un simple alias interchangeable de sharedMesh.

sharedMesh renvoie le mesh partagé par le composant. Plusieurs objets peuvent utiliser la même ressource. Modifier cette ressource modifie alors le mesh partagé, avec des conséquences possibles pour tous les utilisateurs et, selon son origine, pour l'asset lui-même.

mesh, au contraire, fournit une instance utilisable comme mesh propre au composant. Lorsque le composant utilise encore un mesh partagé, l'accès à cette propriété peut entraîner l'instanciation d'une copie. Cette copie n'est pas créée à chaque lecture ni à chaque écriture: l'important est de comprendre le moment où le composant cesse d'utiliser le mesh partagé et commence à référencer son instance propre. Les accès suivants renvoient cette instance tant qu'elle reste associée au composant.

PropriétéUsage principalRisque à surveiller
MeshFilter.sharedMeshUtiliser un mesh partagé entre plusieurs objetsUne modification peut affecter tous les composants qui le référencent
MeshFilter.meshTravailler sur une instance propre au composantUne copie peut être instanciée lors du passage du mesh partagé à une instance locale
Référence de mesh créée au runtimeGénérer une ressource indépendanteLa ressource doit avoir un propriétaire et un moment de destruction clairement définis

La différence devient critique dans un générateur de terrain. Si chaque chunk accède à mesh, puis remplace régulièrement son contenu, il faut savoir si l'on réutilise toujours la même instance ou si l'on remplace la référence par de nouveaux meshes. Le second cas peut produire une accumulation de ressources, surtout si les anciennes instances ne sont plus référencées par un composant mais restent des objets natifs que le moteur n'a pas encore détruits.

À l'inverse, utiliser sharedMesh pour personnaliser chaque chunk est une mauvaise idée dès que ces chunks doivent posséder des géométries différentes. On évite alors une allocation locale, mais on introduit une modification globale du mesh partagé. Le résultat peut être visuellement incorrect avant même de devenir un problème de mémoire.

Donner un propriétaire à chaque mesh

Dans un système procédural, chaque mesh devrait avoir une histoire lisible:

  • qui l'a créé;
  • quel composant en est responsable;
  • s'il est partagé ou local;
  • à quel moment il est remplacé;
  • qui détruit l'ancienne instance;
  • ce qui arrive lorsque le chunk est désactivé, recyclé ou supprimé.

Le cas le plus dangereux est le remplacement silencieux:

  • le chunk reçoit un nouveau mesh;
  • le MeshFilter ne référence plus l'ancien;
  • le cache du générateur conserve peut-être encore une référence;
  • aucun code ne détruit explicitement la ressource devenue inutile.

La mémoire ne devient pas forcément inaccessible immédiatement. Elle devient surtout difficile à attribuer. Dans le Memory Profiler, il faut alors chercher les meshes qui ne correspondent plus à un composant actif, comparer les références et observer si leur nombre augmente à chaque reconstruction.

Quelques réflexes réduisent fortement les ambiguïtés:

1. Utiliser sharedMesh lorsque le partage est réellement voulu et que le mesh ne doit pas être personnalisé par objet.

2. Utiliser mesh ou une instance créée explicitement lorsque chaque objet doit posséder sa propre géométrie.

3. Conserver la référence des ressources générées au runtime au lieu de laisser leur propriété au seul composant visuel.

4. Détruire l'ancienne instance au moment où elle est remplacée, si elle n'est plus utilisée ailleurs.

5. Vérifier le comportement en mode édition et en mode lecture: DestroyImmediate et Destroy ne répondent pas aux mêmes usages.

Le point important est de ne pas résumer le problème à « mesh alloue toujours ». L'instanciation dépend de l'état initial du composant et de la ressource qu'il référence. La fuite éventuelle vient ensuite de la gestion de la durée de vie, pas d'une allocation automatique à chaque écriture.

L'impact caché des conversions de listes sur le Garbage Collector

La conversion .ToArray() paraît inoffensive parce qu'elle ne modifie pas visiblement la scène. Elle crée pourtant un nouveau tableau, copie les éléments de la liste et transfère la responsabilité de ce nouvel objet au runtime managé.

Dans un générateur procédural classique, les sommets, les indices, les coordonnées UV et les attributs de vertex sont souvent accumulés dans des List<T>. À un moment donné, ces listes doivent être envoyées au mesh. Si le code appelle .ToArray() à chaque reconstruction, il produit autant de tableaux temporaires que nécessaire pour alimenter les différentes propriétés ou méthodes.

Le coût dépend de la taille réelle des listes, de leur capacité, du nombre de chunks actifs et de la fréquence de reconstruction. Il ne peut pas être résumé par une quantité fixe valable pour tous les projets. Un petit chunk reconstruit rarement ne posera pas le même problème qu'un terrain vaste dont plusieurs régions changent simultanément.

Il faut également distinguer trois situations:

  • le tableau temporaire n'est plus référencé après l'appel et devient éligible à la collecte;
  • le tableau est conservé dans un cache, une file ou un composant, et sa durée de vie est donc prolongée;
  • les données sont copiées vers une structure native ou vers un buffer graphique, ce qui ajoute un autre coût qui n'est pas géré par le GC managé.

Un tableau sans référence n'attend pas une compaction pour être « libéré » au sens où il serait bloqué en mémoire jusqu'à cet instant. Il devient éligible au ramassage dès qu'il n'est plus atteignable. La récupération effective dépend ensuite du cycle du collecteur. La compaction, lorsqu'elle intervient, est une opération possible de gestion du tas; elle n'est pas la condition qui rend l'objet récupérable.

Une allocation temporaire n'est pas une fuite par définition. Elle devient un problème lorsqu'elle est produite plus vite que le runtime ne peut la récupérer, ou lorsqu'une référence inattendue prolonge sa durée de vie.

Réutiliser sans figer le pipeline

Le premier levier consiste à conserver les listes et à les vider avec Clear() plutôt qu'à créer une nouvelle liste à chaque itération. Cette méthode ne réduit pas toujours la capacité réservée, ce qui permet de réutiliser l'espace déjà obtenu. Il faut cependant surveiller les pics: une liste qui a exceptionnellement grandi peut conserver une capacité excessive pendant toute la durée de vie du générateur.

Le second levier consiste à éviter les conversions lorsqu'une API accepte directement une structure réutilisable. Selon la version de Unity et l'API utilisée, cela peut passer par des NativeArray, des NativeList, des buffers persistants ou les APIs de génération de mesh basées sur MeshData. Ces structures déplacent une partie de la gestion hors du tas managé, mais elles introduisent leur propre cycle de vie: allocation, accès, synchronisation et libération explicite.

Un NativeArray n'est donc pas une solution automatique. Il faut le disposer au bon moment, respecter ses règles d'accès et ne pas le conserver au-delà de la durée nécessaire. Remplacer un tableau managé par une ressource native oubliée ne supprime pas le problème; il le déplace.

Pour diagnostiquer la source de pression, je regarde habituellement:

  • les allocations par frame dans le Profiler;
  • les appels à .ToArray(), .ToList() et aux constructeurs de collections;
  • les redimensionnements implicites de listes;
  • la durée de vie des buffers placés dans des files de tâches;
  • les copies effectuées entre le code C#, les structures natives et le mesh.

Cette lecture permet de séparer l'optimisation du code créatif d'un simple réflexe consistant à remplacer toutes les collections par des types plus bas niveau.

Gestion des ressources orphelines: le cycle de vie oublié des matériaux

Un GameObject détruit n'est pas nécessairement un mesh détruit. Il faut aussi corriger une autre confusion fréquente: appeler Destroy(go) ne signifie pas qu'on appelle les finaliseurs C# des objets associés. La destruction Unity demande au moteur de détruire l'objet Unity concerné; elle ne se confond ni avec un appel direct aux finaliseurs, ni avec un ramassage immédiat par le GC.

Le sort des ressources dépend de leur nature et des références qui subsistent. Un Mesh, un Material, une Texture2D ou un ComputeBuffer créé au runtime peut posséder une représentation native distincte de son enveloppe managée. Si le composant qui l'utilisait disparaît mais qu'une autre référence la conserve, la ressource continue d'exister. Même lorsqu'elle n'est plus référencée par le code, sa destruction effective peut suivre les règles du moteur et le moment choisi par le programme.

Les matériaux sont particulièrement piégeux. Accéder à Renderer.material peut créer ou fournir une instance propre au renderer, tandis que sharedMaterial travaille sur la ressource partagée. Dans une boucle de génération, demander des matériaux individuels sans politique de destruction claire peut multiplier les instances. Le problème est comparable à celui de mesh, avec une difficulté supplémentaire: les changements de paramètres peuvent sembler anodins alors qu'ils modifient la relation entre l'objet et son matériau.

La même vigilance s'applique aux textures et aux buffers de calcul. Un ComputeBuffer ou un objet équivalent doit être libéré explicitement avec l'API prévue par son type. Le Destroy d'un objet visuel qui l'utilisait ne suffit pas nécessairement à gérer cette ressource auxiliaire.

Il n'existe pas de ratio général et fiable de ressources orphelines valable pour tous les runtimes. La proportion dépend du projet, de la scène, du nombre de reconstructions et de la façon dont les références sont stockées. Une dérive importante dans le Memory Profiler doit être mesurée et attribuée, pas convertie en pourcentage théorique.

Le cycle de vie à écrire avant le générateur

Pour chaque ressource procédurale, je préfère écrire le cycle de vie avant de produire la géométrie:

1. Création — la ressource est-elle générée au démarrage, à l'activation du chunk ou à la première demande?

2. Propriété — quel composant ou quel système est responsable de sa destruction?

3. Utilisation — est-elle partagée, modifiable localement ou temporaire?

4. Remplacement — que devient l'ancienne ressource lorsqu'une nouvelle prend sa place?

5. Désactivation — la ressource reste-t-elle en cache lorsque le chunk sort de la vue?

6. Destruction — quel code appelle Destroy, Release ou la méthode de libération adaptée?

Un OnDestroy() peut servir de filet de sécurité pour les ressources appartenant clairement à un MonoBehaviour. Il ne doit pas être une excuse pour disperser les responsabilités. Si un gestionnaire de chunks possède les meshes et les matériaux, c'est à lui de les recycler ou de les détruire. Le composant visuel ne devrait pas supprimer une ressource qu'un autre système prévoit encore d'utiliser.

Le nettoyage doit aussi tenir compte du calendrier de destruction Unity. Destroy() marque généralement l'objet pour une destruction traitée par le moteur, tandis que DestroyImmediate() répond surtout à des besoins d'édition ou à des situations précises. Mélanger les deux dans le runtime peut produire des comportements difficiles à suivre.

Une méthode simple consiste à donner un identifiant aux ressources générées et à journaliser leur création, leur remplacement et leur libération pendant les sessions de diagnostic. Ce n'est pas une fonctionnalité à laisser activée en production, mais c'est un moyen rapide de repérer les ressources créées plusieurs fois sans jamais entrer dans le chemin de destruction.

La question utile n'est pas seulement « qui utilise ce mesh? », mais aussi « qui est censé le détruire lorsque cet utilisateur disparaît? ».

Structurer ses graphes de nœuds pour éviter l'effondrement logique

Les problèmes de stabilité ne viennent pas uniquement de la mémoire. Dans Blender, dans Geometry Nodes, dans un système maison ou dans un pipeline d'art algorithmique temps réel, un graphe peut devenir impossible à comprendre bien avant de devenir impossible à évaluer.

La difficulté vient de la circulation des dépendances. Un graphe mal structuré mélange souvent la production de données, la transformation, l'état de l'outil et la sortie vers le moteur. Tant que les paramètres restent fixes, le résultat semble correct. Dès qu'un artiste modifie la densité, la topologie ou le nombre de variations, un nœud dépend indirectement d'un autre et l'ordre d'évaluation devient difficile à prévoir.

Trois défauts reviennent souvent:

  • Le couplage temporel: une étape dépend d'une valeur modifiée lors d'une exécution précédente. Le résultat varie selon le moment où le graphe est évalué.
  • Les effets de bord cachés: une étape écrit dans une variable globale ou dans un cache partagé sans que cette dépendance soit visible dans les connexions du graphe.
  • La granularité incohérente: certaines opérations sont regroupées dans un bloc opaque, tandis que d'autres sont éclatées en une longue suite de micro-nœuds impossibles à relire.

Un graphe de grande taille n'est pas forcément mauvais. Il peut rester lisible si ses responsabilités sont nettes, si les données portent des noms explicites et si les sous-ensembles disposent d'entrées et de sorties compréhensibles. À l'inverse, un petit graphe peut être fragile si chaque nœud modifie un état implicite.

Il faut donc éviter les seuils universels. Au-delà de vingt nœuds, le découpage n'est pas automatiquement nécessaire; en dessous, il n'est pas automatiquement inutile. La bonne limite dépend de la densité des connexions, de la fréquence d'évaluation, du nombre de personnes qui maintiennent le graphe et de la facilité à tester chaque partie.

Des sous-graphes qui correspondent à des responsabilités

Un découpage efficace suit les étapes du générateur plutôt que la taille arbitraire du graphe:

1. produire ou charger les paramètres d'entrée;

2. générer la structure principale;

3. appliquer les variations et les règles de distribution;

4. calculer les attributs nécessaires au matériau ou à l'animation;

5. préparer la sortie vers le mesh ou le système de rendu;

6. mettre en cache uniquement les données dont la durée de vie est explicitement définie.

Chaque sous-graphe peut alors être testé avec des entrées simples. Si la densité change, on sait quelle étape doit être observée. Si la topologie est correcte mais que les normales sont fausses, le diagnostic ne porte plus sur l'ensemble du réseau.

J'évite également de confondre « fonction pure » et « absence totale d'état ». Certains systèmes ont besoin de caches, de seeds aléatoires ou de ressources persistantes. Le point important est que cet état soit déclaré, localisé et contrôlé. Une seed doit être un paramètre identifié, pas une valeur implicite modifiée par une autre branche du graphe. Un cache doit avoir une politique d'invalidation, pas seulement un dictionnaire qui grossit au fil des générations.

Un graphe procédural reste maintenable quand ses dépendances sont visibles. Ce n'est pas le nombre de nœuds qui le fragilise, mais l'état caché que personne ne sait remettre à zéro.

Les noms de buffers jouent ici un rôle aussi important que les connexions. Vertices_Final, Indices_Terrain ou Density_Input indiquent la fonction d'une donnée. Des noms génériques comme Result, Temp ou Buffer2 rendent les erreurs de branchement plus probables et compliquent les profils de mémoire.

Mettre en place une optimisation qui survive au projet

La génération procédurale 3D devient stable lorsque l'optimisation du code créatif cesse d'être une intervention de dernière minute. Il faut observer le pipeline pendant qu'il évolue, avec des scènes de test qui reproduisent les situations coûteuses: déplacement rapide de la caméra, reconstruction d'un grand nombre de chunks, changement de matériau, suppression d'un groupe d'objets et réinitialisation complète du graphe.

Quelques règles sont particulièrement robustes:

  • mesurer les allocations managées par frame au lieu de déduire leur importance à partir d'un seul ralentissement;
  • distinguer les objets temporaires récupérables des ressources natives qui nécessitent une destruction explicite;
  • réutiliser les listes, tableaux et buffers lorsque leurs tailles sont compatibles avec le traitement;
  • éviter de demander mesh ou material dans une boucle sans savoir si une instance locale est créée;
  • conserver une référence aux ressources générées et documenter leur propriétaire;
  • regrouper les modifications de mesh plutôt que de reconstruire plusieurs fois la même géométrie pendant une frame;
  • isoler les données de génération des composants qui ne font que les afficher;
  • vérifier les chemins d'activation, de désactivation, de remplacement et de destruction;
  • tester le graphe avec des paramètres extrêmes, sans en faire des seuils universels de conception.

Les pools peuvent être efficaces pour les GameObjects, les chunks et certains buffers. Ils ne sont pas une réponse automatique pour tous les meshes et tous les matériaux. Recycler un objet qui conserve de grandes références inutilisées peut simplement déplacer le pic de mémoire dans le temps. Un pool doit donc prévoir la remise à zéro de l'état, la capacité maximale raisonnable et la destruction des éléments qui ne seront plus nécessaires.

Le profilage doit comparer des états et non seulement des frames isolées. Je veux savoir ce qui se passe après une reconstruction, après la sortie d'une zone de la caméra, après plusieurs changements de paramètres et après une destruction complète de la scène. C'est dans ces transitions que les ressources orphelines apparaissent.

Il faut aussi regarder le temps entre deux collectes. Une hausse momentanée du tas peut être acceptable si elle est suivie d'une récupération complète et si le coût reste compatible avec le budget de la frame. Une hausse plus modeste mais continue est plus préoccupante lorsqu'elle signale des références conservées ou des ressources natives jamais libérées.

Ce que mes crashs ont réellement changé

Les crashs ne m'ont pas appris à bannir toute allocation. Ce serait une règle impossible à tenir et souvent inutile. Ils m'ont appris à demander, pour chaque allocation, ce qu'elle représente, combien de temps elle doit vivre et qui prend en charge sa disparition.

Dans un pipeline procédural, la génération est rarement le seul endroit où se trouve le coût. L'instanciation d'un objet peut créer une chaîne de ressources. L'accès à une propriété peut changer le statut partagé d'un mesh. Une conversion de liste peut produire une copie qui déclenche plus tard une collecte. La destruction d'un objet visible peut laisser derrière elle un matériau, une texture ou un buffer que le composant n'était pas chargé de gérer.

La stabilité vient alors d'une discipline assez concrète:

  • générer lorsque les données changent, pas uniquement parce qu'une nouvelle frame commence;
  • réutiliser les ressources lorsque leur structure le permet;
  • séparer les meshes partagés des instances locales;
  • traiter le GC comme un système de récupération, pas comme un destructeur instantané;
  • donner à chaque ressource native un propriétaire identifiable;
  • découper les graphes selon leurs responsabilités et rendre leur état visible;
  • confirmer les hypothèses avec le Profiler et le Memory Profiler.

La génération procédurale peut produire des mondes très complexes sans devenir une loterie de crashs. Mais elle ne pardonne pas les durées de vie implicites. Lorsqu'un chunk est créé, modifié, remplacé puis retiré, chaque étape doit avoir une réponse claire. Le code peut rester ambitieux; ce sont les allocations anonymes, les références oubliées et les dépendances cachées qui doivent disparaître.

Questions fréquentes

Pourquoi les allocations dans `Update()` peuvent-elles ralentir ou faire planter un pipeline 3D ?
Parce qu’une opération répétée à chaque frame peut augmenter la pression sur le tas managé, déclencher davantage de travail pour le Garbage Collector et provoquer aussi des allocations côté moteur, GPU ou rendu. Le crash n’est toutefois pas automatique : il dépend du coût cumulé, des références conservées et de la capacité du runtime à récupérer les ressources.
Quelle est la différence entre `MeshFilter.mesh` et `MeshFilter.sharedMesh` ?
`sharedMesh` renvoie le mesh partagé par le composant et sa modification peut affecter plusieurs objets. `mesh` permet de travailler sur une instance propre au composant et peut entraîner la création d’une copie lorsque le composant utilise encore un mesh partagé.
Pourquoi `.ToArray()` peut-il augmenter la pression sur le Garbage Collector ?
Chaque appel à `.ToArray()` crée un nouveau tableau et copie les éléments de la liste. Lorsqu’il est utilisé à chaque reconstruction, il produit des tableaux temporaires dont le coût dépend de la taille des listes, du nombre de chunks actifs et de la fréquence des reconstructions.
Détruire un `GameObject` détruit-il automatiquement ses meshes, matériaux et buffers ?
Pas nécessairement. Un mesh, un matériau, une texture ou un `ComputeBuffer` peut avoir une représentation native et rester vivant si une autre référence le conserve ; les buffers de calcul doivent être libérés avec l’API prévue pour leur type.
Comment réduire les ressources orphelines dans un générateur procédural ?
Il faut conserver les références aux ressources créées au runtime, définir leur propriétaire, détruire l’ancienne instance lorsqu’elle est remplacée si elle n’est plus utilisée et vérifier les chemins d’activation, de désactivation, de recyclage et de destruction. Le Profiler et le Memory Profiler servent à confirmer l’origine des ressources qui continuent de s’accumuler.