Accéder efficacement aux données de texture

Découvrez les avantages et les inconvénients des différentes manières d’accéder aux données de pixels de texture sous-jacentes dans votre projet Unity.
Les données de pixels décrivent la couleur des pixels individuels dans une texture. Unity fournit des méthodes qui vous permettent de lire ou d'écrire des données de pixels avec des scripts C#.
Vous pouvez utiliser ces méthodes pour dupliquer ou mettre à jour une texture (par exemple, ajouter un détail à la photo de profil d'un joueur), ou utiliser les données de la texture d'une manière particulière, comme lire une texture qui représente une carte du monde pour déterminer où placer un objet.
Il existe plusieurs façons d'écrire du code qui lit ou écrit dans des données de pixels. Celui que vous choisissez dépend de ce que vous prévoyez de faire avec les données et des besoins de performance de votre projet.
Ce blog et l'exemple de projet qui l'accompagne sont destinés à vous aider à naviguer dans l'API disponible et à éviter les pièges courants en matière de performances. Une compréhension des deux vous aidera à écrire une solution performante ou à résoudre les goulots d’étranglement des performances lorsqu’ils apparaissent.
Pour la plupart des types de textures, Unity stocke deux copies des données de pixels : une dans la mémoire GPU, nécessaire au rendu, et l'autre dans la mémoire CPU. Cette copie est facultative et vous permet de lire, d'écrire et de manipuler les données de pixels sur le processeur. Une texture avec une copie de ses données de pixels stockées dans la mémoire du processeur est appelée une texture lisible . Un détail à noter est que RenderTexture n'existe que dans la mémoire GPU.
La mémoire disponible pour le CPU diffère de celle du GPU sur la plupart des matériels. Certains appareils disposent d'une forme de mémoire partiellement partagée, mais pour ce blog, nous supposerons la configuration PC classique où le CPU n'a qu'un accès direct à la RAM branchée sur la carte mère et le GPU s'appuie sur sa propre RAM vidéo (VRAM). Toutes les données transférées entre ces différents environnements doivent passer par le bus PCI, ce qui est plus lent que le transfert de données au sein du même type de mémoire. En raison de ces coûts, vous devez essayer de limiter la quantité de données transférées à chaque image.

L'échantillonnage des textures dans les shaders est l'opération de données de pixels GPU la plus courante. Pour modifier ces données, vous pouvez copier entre les textures ou effectuer un rendu dans une texture à l'aide d'un shader. Toutes ces opérations peuvent être effectuées rapidement par le GPU.
Dans certains cas, il peut être préférable de manipuler vos données de texture sur le processeur, ce qui offre plus de flexibilité dans la manière dont les données sont accessibles. Les opérations sur les données des pixels du processeur agissent uniquement sur la copie des données du processeur et nécessitent donc des textures lisibles. Si vous souhaitez échantillonner les données de pixels mises à jour dans un shader, vous devez d'abord les copier du CPU vers le GPU en appelant Apply. Selon la texture concernée et la complexité des opérations, il peut être plus rapide et plus simple de s'en tenir aux opérations CPU (par exemple, lors de la copie de plusieurs textures 2D dans un élément Texture2DArray).
L'API Unity fournit plusieurs méthodes pour accéder ou traiter les données de texture. Certaines opérations agissent à la fois sur la copie GPU et CPU si les deux sont présents. Par conséquent, les performances de ces méthodes varient selon que les textures sont lisibles ou non. Différentes méthodes peuvent être utilisées pour obtenir les mêmes résultats, mais chaque méthode possède ses propres caractéristiques de performance et de facilité d’utilisation.
Répondez aux questions suivantes pour déterminer la solution optimale :
- Le GPU peut-il effectuer vos calculs plus rapidement que le CPU ?
- Quel niveau de pression le processus exerce-t-il sur les caches de texture ? (Par exemple, l’échantillonnage de nombreuses textures haute résolution sans utiliser de mipmaps est susceptible de ralentir le GPU.)
- Le processus nécessite-t-il une texture d'écriture aléatoire ou peut-il générer une sortie vers une pièce jointe de couleur ou de profondeur ? (L’écriture sur des pixels aléatoires sur une texture nécessite des vidages de cache fréquents qui ralentissent le processus.)
- Mon projet est-il déjà limité en termes de GPU ? Même si le GPU est capable d'exécuter un processus plus rapidement que le CPU, le GPU peut-il se permettre d'effectuer plus de travail sans dépasser son budget de temps d'image ?
- Si le GPU et le thread principal du CPU sont tous deux proches de leur limite de temps d'image, alors peut-être que la partie lente d'un processus pourrait être exécutée par les threads de travail du CPU.
- Quelle quantité de données doit être téléchargée ou téléchargée depuis le GPU pour calculer ou traiter les résultats ?
- Un shader ou un travail C# pourrait-il regrouper les données dans un format plus petit pour réduire la bande passante requise ?
- Une RenderTexture pourrait-elle être sous-échantillonnée dans une version de résolution plus petite qui est téléchargée à la place ?
- Le processus peut-il être exécuté par morceaux ? (Si une grande quantité de données doit être traitée simultanément, il y a un risque que le GPU ne dispose pas de suffisamment de mémoire pour cela.)
- Dans quel délai les résultats sont-ils attendus ? Les calculs ou les transferts de données peuvent-ils être effectués de manière asynchrone et traités ultérieurement ? (Si trop de travail est effectué dans une seule image, il y a un risque que le GPU n'ait pas assez de temps pour restituer les graphiques réels pour chaque image.)
Par défaut, les ressources de texture que vous importez dans votre projet ne sont pas lisibles, tandis que les textures créées à partir d'un script sont lisibles.
Les textures lisibles utilisent deux fois plus de mémoire que les textures non lisibles car elles doivent avoir une copie de leurs données de pixels dans la RAM du processeur. Vous ne devez rendre une texture lisible que lorsque vous en avez besoin, et la rendre illisible lorsque vous avez terminé de travailler avec les données sur le processeur.
Pour voir si un élément de texture dans votre projet est lisible et apporter des modifications, utilisez l'option Lecture/écriture activée dans les paramètres d'importation de textureou l'API TextureImporter.isReadable .
Pour rendre une texture illisible, appelez sa méthode Apply avec le paramètre makeNoLongerReadable défini sur « true » (par exemple, Texture2D.Apply ou Cubemap.Apply). Une texture non lisible ne peut pas être rendue à nouveau lisible.
Toutes les textures sont lisibles par l'éditeur dans les modes Édition et Lecture. L'appel de Apply pour rendre la texture illisible mettra à jour la valeur de isReadable, vous empêchant d'accéder aux données du processeur. Cependant, certains processus Unity fonctionneront comme si la texture était lisible car ils voient que les données internes du processeur sont valides.

Les performances diffèrent considérablement selon les différentes manières d'accéder aux données de texture, en particulier sur le processeur (bien que moins à des résolutions inférieures). Le référentiel d'exemples d'API Unity Texture Access sur GitHub contient un certain nombre d'exemples montrant les différences de performances entre différentes API qui permettent l'accès ou la manipulation des données de texture. L'interface utilisateur affiche uniquement les timings du processeur du thread principal. Dans certains cas, les fonctionnalités DOTS telles que Burst et le système de tâches sont utilisées pour maximiser les performances.
Voici les exemples inclus dans le référentiel GitHub :
- SimpleCopy: Copier tous les pixels d'une texture à une autre
- Texture plasma : Une texture plasma mise à jour sur le CPU par image
- TransfertGPUTexture : Transférer (copier vers une taille ou un format différent) tous les pixels du GPU d'une texture vers une RenderTexture
Vous trouverez ci-dessous les mesures de performances tirées des exemples sur GitHub. Ces chiffres sont utilisés pour étayer les recommandations qui suivent. Les mesures proviennent d'un lecteur construit sur un système avec un processeur Xeon® W-2145 à 8 cœurs à 3,7 GHz et une RTX 2080.
Il s'agit des temps CPU médians pour SimpleCopy.UpdateTestCase avec une taille de texture de 2 048.
Notez que les méthodes graphiques se terminent presque instantanément sur le thread principal car elles poussent simplement le travail sur le RenderThread, qui est ensuite exécuté par le GPU. Leurs résultats seront prêts lorsque l’image suivante sera rendue.
Résultats
- 1 326 ms – foreach(mip) pour(x en largeur) pour(y en hauteur) SetPixel(x, y, GetPixel(x, y, mip), mip)
- 32,14 ms – foreach(mip) SetPixels(source.GetPixels(mip), mip)
- 6,96 ms – foreach(mip) SetPixels32(source.GetPixels32(mip), mip)
- 6,74 ms – LoadRawTextureData(source.GetRawTextureData())
- 3,54 ms – Graphics.CopyTexture(source lisible, cible lisible)
- 2,87 ms – foreach(mip) SetPixelData<octet>(mip, GetPixelData<octet>(mip))
- 2,87 ms – LoadRawTextureData(source.GetRawTextureData<octet>())
- 0,00 ms – Graphics.ConvertTexture(source, cible)
- 0,00 ms – Graphics.CopyTexture(nonReadableSource, cible)
Il s'agit des temps CPU médians pour PlasmaTexture.UpdateTestCase avec une taille de texture de 512.
Vous verrez que SetPixels32 est étonnamment plus lent que SetPixels. Cela est dû au fait qu'il faut prendre le résultat de couleur basé sur des flottants à partir du calcul des pixels plasma et le convertir en structure Color32 basée sur des octets. SetPixels32NoConversion ignore cette conversion et attribue simplement une valeur par défaut au tableau de sortie Color32, ce qui entraîne de meilleures performances que SetPixels. Afin de battre les performances de SetPixels et la conversion de couleur sous-jacente effectuée par Unity, il est nécessaire de retravailler la méthode de calcul des pixels elle-même pour générer directement une valeur Color32. Une implémentation simple utilisant SetPixelData est presque garantie de donner de meilleurs résultats que les approches prudentes SetPixels et SetPixels32.
Résultats
- 126,95 ms – DéfinirPixel
- 113,16 ms – SetPixels32
- 88,96 ms – Définir les pixels
- 86,30 ms – SetPixels32NoConversion
- 16,91 ms – SetPixelDataBurst
- 4,27 ms – SetPixelDataBurstParallel
Voici les temps GPU de l'éditeur pour TransferGPUTexture.UpdateTestCase avec une taille de texture de 8 196 :
- Blit – 1,584 ms
- CopyTexture – 0,882 ms
Vous pouvez accéder aux données de pixels de différentes manières. Cependant, toutes les méthodes ne prennent pas en charge tous les formats, types de texture ou cas d’utilisation, et certaines prennent plus de temps à exécuter que d’autres. Cette section passe en revue les méthodes recommandées et la section suivante décrit celles à utiliser avec prudence.
CopyTexture est le moyen le plus rapide de transférer des données GPU d'une texture à une autre. Il n'effectue aucune conversion de format. Vous pouvez copier partiellement des données en spécifiant une position source et cible, en plus de la largeur et de la hauteur de la région. Si les deux textures sont lisibles, l'opération de copie sera également effectuée sur les données du processeur, rapprochant ainsi le coût total de cette méthode de celui d'une copie uniquement du processeur utilisant SetPixelData avec le résultat de GetPixelData à partir d'une texture source.
Blit est une méthode rapide et puissante de transfert de données GPU dans un RenderTexture à l'aide d'un shader. En pratique, cela doit configurer l'état de l'API du pipeline graphique pour effectuer le rendu vers la RenderTexture cible. Il est livré avec un faible coût d'installation indépendant de la résolution par rapport à CopyTexture. Le shader Blit par défaut utilisé par la méthode prend une texture d'entrée et la restitue dans la RenderTexture cible. En fournissant un matériau ou un shader personnalisé, vous pouvez définir des processus de rendu de texture à texture complexes.
GetPixelData et SetPixelData (avec GetRawTextureData) sont les méthodes les plus rapides à utiliser uniquement lorsque vous touchez aux données du processeur. Les deux méthodes nécessitent que vous fournissiez un type de structure comme paramètre de modèle utilisé pour réinterpréter les données. Les méthodes elles-mêmes n'ont besoin que de cette structure pour dériver la taille correcte, vous pouvez donc simplement utiliser byte si vous ne souhaitez pas définir une structure personnalisée pour représenter le format de la texture.
Lors de l'accès à des pixels individuels, c'est une bonne idée de définir une structure personnalisée avec certaines méthodes utilitaires pour faciliter l'utilisation. Par exemple, une structure de format R5G5B5A1 pourrait être constituée d'un membre de données ushort et de quelques méthodes get/set pour accéder aux canaux individuels sous forme d'octets.
Type de bloc inconnu « codeBlock », veuillez spécifier un sérialiseur pour celui-ci dans la propriété « serializers.types »
Le code ci-dessus est un exemple d'implémentation d'un objet représentant un pixel au format R5G5B5A5A1 ; les paramètres de propriété correspondants sont omis par souci de concision.
SetPixelData peut être utilisé pour copier un niveau mip complet de données dans la texture cible. GetPixelData renverra un NativeArray qui pointe réellement vers un niveau mip des données de texture du processeur interne d'Unity. Cela vous permet de lire/écrire directement ces données sans avoir besoin d’opérations de copie. Le problème est que le NativeArray renvoyé par GetPixelData n'est garanti valide que jusqu'à ce que le code utilisateur appelant GetPixelData renvoie le contrôle à Unity, comme lorsque MonoBehaviour.Update renvoie. Au lieu de stocker le résultat de GetPixelData entre les images, vous devez obtenir le NativeArray correct de GetPixelData pour chaque image à partir de laquelle vous souhaitez accéder à ces données.
La méthode Apply revient une fois que les données du processeur ont été téléchargées sur le GPU. Le paramètre makeNoLongerReadable doit être défini sur « true » lorsque cela est possible pour libérer la mémoire des données du processeur après le téléchargement.
Les méthodes RequestIntoNativeArray et RequestIntoNativeSlice téléchargent de manière asynchrone les données GPU de la texture spécifiée dans (une tranche de) un NativeArray fourni par l'utilisateur.
L'appel des méthodes renverra un handle de requête qui peut indiquer si le téléchargement des données demandées est terminé. La prise en charge est limitée à quelques formats seulement, utilisez donc SystemInfo.IsFormatSupported avec FormatUsage.ReadPixels pour vérifier la prise en charge des formats. La classe AsyncGPUReadback possède également une méthode Request , qui alloue un NativeArray pour vous. Si vous devez répéter cette opération, vous obtiendrez de meilleures performances si vous allouez un NativeArray que vous réutilisez à la place.
Il existe un certain nombre de méthodes qui doivent être utilisées avec prudence en raison d’impacts potentiellement importants sur les performances. Regardons-les plus en détail.
Ces méthodes effectuent des conversions de format de pixels de complexité variable. Les variantes Pixels32 sont les plus performantes du groupe, mais même elles peuvent effectuer des conversions de format si le format sous-jacent de la texture ne correspond pas parfaitement à la structure Color32. Lorsque vous utilisez les méthodes suivantes, il est préférable de garder à l'esprit que leur impact sur les performances augmente considérablement, à des degrés divers, à mesure que le nombre de pixels augmente :
GetRawTextureData et LoadRawTextureData sont des méthodes Texture2D uniquement qui fonctionnent avec des tableaux contenant les données de pixels brutes de tous les niveaux mip, l'un après l'autre. La disposition va du plus grand au plus petit mip, chaque mip représentant la « hauteur » et la « largeur » des valeurs de pixels. Ces fonctions permettent d'accéder rapidement aux données du processeur. GetRawTextureData a un « piège » où la variante non basée sur un modèle renvoie une copie des données. C'est un peu plus lent et ne permet pas de manipuler directement le tampon sous-jacent géré par Unity. GetPixelData n'a pas cette particularité et ne peut renvoyer qu'un NativeArray pointant vers le tampon sous-jacent qui reste valide jusqu'à ce que le code utilisateur renvoie le contrôle à Unity.
ConvertTexture est un moyen de transférer les données GPU d'une texture à une autre, lorsque les textures source et de destination n'ont pas la même taille ou le même format. Ce processus de conversion est aussi efficace que possible dans les circonstances actuelles, mais il n’est pas bon marché. Voici le processus interne :
Allouer une RenderTexture temporaire correspondant à la texture de destination.
Effectuez un Blit de la texture source vers la RenderTexture temporaire.
Copiez le résultat Blit de la texture de rendu temporaire vers la texture de destination.
Répondez aux questions suivantes pour déterminer si cette méthode est adaptée à votre cas d’utilisation :
- Dois-je effectuer cette conversion ?
- Puis-je m'assurer que la texture source est créée dans la taille/le format souhaité pour la plate-forme cible au moment de l'importation ?
- Puis-je modifier mes processus pour utiliser les mêmes formats, permettant ainsi au résultat d'un processus d'être directement utilisé comme entrée pour un autre processus ?
- Puis-je créer et utiliser une RenderTexture comme destination à la place ? Cela réduirait le processus de conversion à un seul Blit vers la RenderTexture de destination.
La méthode ReadPixels télécharge de manière synchrone les données GPU du RenderTexture actif (RenderTexture.active) dans les données CPU d'un Texture2D. Cela vous permet de stocker ou de traiter la sortie d'une opération de rendu. La prise en charge est limitée à quelques formats seulement, utilisez donc SystemInfo.IsFormatSupported avec FormatUsage.ReadPixels pour vérifier la prise en charge des formats.
Le téléchargement des données depuis le GPU est un processus lent. Avant de pouvoir commencer, ReadPixels doit attendre que le GPU termine tout le travail précédent. Il est préférable d'éviter cette méthode car elle ne reviendra pas tant que les données demandées ne seront pas disponibles, ce qui ralentira les performances. La convivialité est également une préoccupation car vous avez besoin que les données GPU soient dans une RenderTexture, qui doit être configurée comme celle actuellement active. La convivialité et les performances sont meilleures lorsque vous utilisez les méthodes AsyncGPUReadback décrites précédemment.
La classe ImageConversion possède des méthodes permettant de convertir entre Texture2D et plusieurs formats de fichiers image. LoadImage est capable de charger des données JPG, PNG ou EXR (depuis 2023.1) dans un Texture2D et de les télécharger sur le GPU pour vous. Les données de pixels chargées peuvent être compressées à la volée en fonction du format d'origine de Texture2D. D'autres méthodes peuvent convertir un tableau de données Texture2D ou de pixels en un tableau de données JPG, PNG, TGA ou EXR.
Ces méthodes ne sont pas particulièrement rapides, mais peuvent être utiles si votre projet doit transmettre des données de pixels via des formats de fichiers image courants. Les cas d'utilisation typiques incluent le chargement de l'avatar d'un utilisateur à partir du disque et son partage avec d'autres joueurs sur un réseau.
Il existe de nombreuses ressources disponibles pour en savoir plus sur l’optimisation graphique, les sujets connexes et les meilleures pratiques dans Unity. La section sur les performances graphiques et le profilage de la documentation constitue un bon point de départ.
Vous pouvez également consulter plusieurs livres électroniques techniques destinés aux utilisateurs avancés, notamment Guide ultime pour le profilage des jeux Unity,Optimisez les performances de vos jeux mobileset Optimisez les performances de vos jeux sur console et PC.
Vous trouverez de nombreuses autres bonnes pratiques avancées sur le hub pratique d'Unity.
Voici un résumé des points clés à retenir :
- Lors de la manipulation de textures, la première étape consiste à évaluer les opérations qui peuvent être effectuées sur le GPU pour des performances optimales. La charge de travail du processeur/GPU existante et la taille des données d’entrée/sortie sont des facteurs clés à prendre en compte.
- L'utilisation de fonctions de bas niveau comme GetRawTextureData pour implémenter un chemin de conversion spécifique si nécessaire peut offrir des performances améliorées par rapport aux méthodes plus pratiques qui effectuent des copies et des conversions (souvent redondantes).
- Les opérations plus complexes, telles que les relectures volumineuses et les calculs de pixels, ne sont viables sur le processeur que lorsqu'elles sont effectuées de manière asynchrone ou en parallèle. La combinaison de Burst et du système de tâches permet à C# d'effectuer certaines opérations qui, autrement, ne seraient performantes que sur un GPU.
- Profil fréquemment : Vous pouvez rencontrer de nombreux pièges au cours du développement, des conversions inattendues et inutiles aux blocages dus à l'attente d'un autre processus. Certains problèmes de performances n'apparaîtront qu'à mesure que le jeu évoluera et que certaines parties de votre code seront davantage utilisées. L'exemple de projet montre comment des augmentations apparemment minimes de la résolution de texture peuvent entraîner certaines API à devenir un problème de performances.
Partagez vos commentaires sur les données de texture avec nous dans les forums Scripting ou General Graphics . Assurez-vous de surveiller les nouveaux blogs techniques d'autres développeurs Unity dans le cadre de la SérieTech from the Trenches.
