Mappage de lumière GPU : Une plongée technique en profondeur

L'équipe d'éclairage mise tout sur la vitesse d'itération. Nous avons conçu le Progressive Lightmapper avec cet objectif en tête. Notre objectif est de vous fournir un retour rapide sur toute modification que vous apportez à l'éclairage de votre projet. En 2018.3, nous avons introduit un aperçu de la version GPU du Progressive Lightmapper. Nous nous dirigeons désormais vers la parité des fonctionnalités et de la qualité visuelle avec son frère CPU. Notre objectif est de rendre la version GPU d’un ordre de grandeur plus rapide que la version CPU. Cela apporte un lightmapping interactif aux flux de travail artistiques, avec de grandes améliorations de la productivité de l'équipe.
Dans cette optique, nous avons choisi d'utiliser RadeonRays: une bibliothèque de ray tracing open source d'AMD. Unity et AMD ont collaboré sur le GPU Lightmapper pour implémenter plusieurs fonctionnalités et optimisations clés. À savoir : échantillonnage de puissance, compactage des rayons et parcours BVH personnalisé.
L'objectif de conception du GPU Lightmapper était d'offrir les mêmes fonctionnalités que le CPU Lightmappertout en obtenant des performances supérieures :
- Lightmapping interactif impartial
- Parité des fonctionnalités entre les backends CPU et GPU
- Solution basée sur le calcul
- Traçage du chemin du front d'onde pour des performances maximales
Nous savons que le temps d’itération est la clé pour permettre aux artistes d’améliorer la qualité visuelle et de libérer la créativité. Le lightmapping interactif est l’objectif ici. Non seulement les temps de cuisson globaux sont impressionnants, mais nous souhaitons également que l'expérience utilisateur offre un retour d'information immédiat.
Nous avons dû résoudre un certain nombre de problèmes intéressants pour y parvenir. Dans cet article, nous explorerons certaines des décisions que nous avons prises.
Pour que le Lightmapper offre des mises à jour progressives à l'utilisateur, nous avons dû prendre certaines décisions de conception.
Nous ne mettons pas en cache l'irradiance ou la visibilité lors de l'éclairage direct (l'éclairage direct peut être mis en cache et réutilisé pour l'éclairage indirect). En général, nous ne mettons pas en cache de données et privilégions des pas de calcul suffisamment petits pour ne pas créer de blocages et fournir un affichage progressif et interactif pendant la cuisson.

Les scènes peuvent potentiellement être très grandes et contenir de nombreuses lightmaps. Pour garantir que le travail soit effectué là où il offre le plus d’avantages à l’utilisateur, il est important de concentrer la cuisson sur la zone actuellement visible. Pour ce faire, nous détectons d'abord lesquelles des lightmaps contiennent le plus de texels visibles non convergés sur un écran, puis nous rendons ces lightmaps et priorisons les texels visibles (les texels hors écran seront cuits une fois que tous les texels visibles auront convergé).
Un texel est défini comme visible s'il se trouve dans le tronc de caméra actuel et s'il n'est pas occulté par des géométries statiques de scène.
Nous effectuons cette sélection sur le GPU (pour profiter du traçage de rayons rapide). Voici le déroulement d'un travail d'abattage.

Les tâches d'élimination ont deux sorties :
- Un tampon de carte d'élimination, stockant si chaque texel de la carte lumineuse est visible. Ce tampon de carte d'élimination est ensuite utilisé par les tâches de rendu.
- Un entier représentant le nombre de texels visibles pour la lightmap actuelle. Cet entier sera relu de manière asynchrone par le processeur pour ajuster la planification de la carte lumineuse à l'avenir.
Dans la vidéo ci-dessous, nous pouvons voir l’effet de l’abattage. La cuisson est arrêtée à mi-chemin à des fins de démonstration. Ainsi, lorsque la vue Scène se déplace, nous pouvons voir des texels pas encore cuits (c'est-à-dire noirs) qui ne sont pas visibles depuis la position et la direction initiales de la caméra.
Pour des raisons de performances, les informations de visibilité sont mises à jour uniquement à chaque fois que l'état de la caméra se « stabilise ». De plus, le suréchantillonnage n’est pas pris en compte.
Les GPU sont optimisés pour traiter d'énormes lots de données et effectuer la même opération sur l'ensemble de ces données ; ils sont optimisés pour le débit. De plus, le GPU atteint cette accélération tout en étant plus économe en énergie et en coûts qu'un processeur à plusieurs cœurs. Cependant, les GPU ne sont pas aussi bons que les CPU en termes de latence (intentionnellement, de par la conception du matériel). C'est pourquoi nous utilisons un pipeline piloté par les données sans points de synchronisation CPU-GPU pour tirer le meilleur parti de la nature de calcul intrinsèquement parallèle du GPU.
Cependant, les performances brutes ne suffisent pas. L'expérience utilisateur est ce qui compte, et nous la mesurons en termes d'impact visuel au fil du temps, également appelé taux de convergence. Nous avons donc également besoin d’algorithmes efficaces.
Les GPU sont destinés à être utilisés sur de grands ensembles de données et sont capables d'un débit élevé au prix de la latence. De plus, ils sont généralement pilotés par une file d'attente de commandes remplie à l'avance par le processeur. L’objectif de ce flux continu de grandes commandes est de garantir que nous pouvons saturer le GPU de travail. Examinons les principales recettes que nous utilisons pour maximiser le débit et donc les performances brutes.
La façon dont nous abordons le pipeline de données de lightmapping GPU est basée sur les principes suivants :
1. Nous préparons les données une fois.
À ce stade, le CPU et le GPU peuvent être synchronisés afin de réduire l'allocation de mémoire.
2. Une fois la cuisson commencée, aucun point de synchronisation CPU-GPU n'est autorisé.
Le CPU envoie une charge de travail prédéfinie au GPU. Cette charge de travail sera trop conservatrice dans certains cas (par exemple en utilisant 4 rebonds mais tous les rayons indirects terminés après le 2ème rebond, nous aurons alors toujours des noyaux en file d'attente qui seront exécutés mais trop tôt).
3. Le GPU ne peut pas générer de rayons ni de noyaux.
Il pourrait plutôt lui être demandé de traiter des tâches vides (ou très petites). Pour gérer ces cas efficacement, les noyaux sont écrits de manière à maximiser la cohérence des données et des instructions. Nous gérons cela via la « compaction » des données, nous y reviendrons plus tard.
4. Nous ne voulons pas de points de synchronisation CPU-GPU, ni de bulles GPU une fois la cuisson commencée.
Par exemple, certaines commandes OpenCL peuvent créer de petites bulles GPU (c'est-à-dire des moments où le GPU n'a rien à traiter), comme clEnqueueFillBuffer ou clEnqueueReadBuffer (même dans les versions asynchrones), nous les évitons donc autant que possible. De plus, le traitement des données doit rester sur le GPU le plus longtemps possible (c'est-à-dire le rendu et la composition jusqu'à leur achèvement). Lorsque nous devons renvoyer des données au CPU pour un traitement supplémentaire, nous le ferons de manière asynchrone et ne les renverrons pas au GPU. Par exemple, la couture est actuellement un post-traitement du processeur.
5. Le CPU adaptera la charge du GPU de manière asynchrone.
La modification de la carte lumineuse rendue lorsque la vue de la caméra change ou lorsqu'une carte lumineuse est entièrement convergée entraînera une certaine latence. Les threads du processeur génèrent et gèrent ces événements de relecture à l'aide d'une file d'attente sans verrouillage pour éviter les conflits de mutex.

L’une des principales caractéristiques de l’architecture GPU est la prise en charge étendue des instructions SIMD. SIMD signifie Single Instruction Multiple Data. Un ensemble d'instructions sera exécuté séquentiellement en synchronisation sur une quantité donnée de données à l'intérieur de ce qu'on appelle une chaîne/un front d'onde. La taille de ces fronts d'onde/déformations est de 64, 32 ou 16 valeurs (selon l'architecture du GPU). Par conséquent, une seule instruction appliquera la même transformation à plusieurs données : une seule instruction, plusieurs données. Cependant, pour une plus grande flexibilité, le GPU est également capable de prendre en charge des chemins de code divergents dans son implémentation SIMD. Pour ce faire, il peut désactiver certains threads pendant qu'il travaille sur un sous-ensemble avant de rejoindre à nouveau. Ceci s'appelle SIMT : Instruction unique, plusieurs threads. Cependant, cela a un coût, car les chemins de code divergents au sein d'un front d'onde/d'une chaîne ne profiteront que d'une fraction de l'unité SIMD. Lisez cet excellent article de blog pour plus d’informations.
Enfin, une extension intéressante de l’idée SIMT est la capacité du GPU à conserver de nombreuses déformations/fronts d’onde par cœur SIMD. Si un front d'onde/une chaîne attend un accès mémoire lent, le planificateur peut passer à un autre front d'onde/chaîne et continuer à travailler dessus en attendant (à condition qu'il y ait suffisamment de travail en attente). Cependant, pour que cela fonctionne réellement, la quantité de ressources nécessaires par contexte doit être faible, afin que l’occupation (la quantité de travail en attente) puisse être élevée.
En résumé, nous devrions viser :
- De nombreux fils en vol
- Éviter les branches divergentes
- Bon taux d'occupation
Avoir une bonne occupation dépend entièrement du code du noyau et c'est un sujet trop vaste pour faire partie de cet article de blog. Voici quelques excellentes ressources :
- Comprendre la latence cachée sur les GPU par Vasily Volkov (NVIDIA)
- Introduction à la scalarisation du GPU par Francesco Cifariello (Unity Technologies)
En général, l’objectif est d’utiliser les ressources locales avec parcimonie, en particulier les registres vectoriels et la mémoire partagée locale.
Jetons un œil à ce que pourrait être le flux de cuisson de l’éclairage direct sur le GPU. Cette section couvre principalement les lightmaps. Cependant, les Light Probes fonctionnent de manière très similaire, sauf qu'elles n'ont pas de données de visibilité ou d'occupation.

Il y a quelques problèmes ici :
- L'occupation de la Lightmap dans cet exemple est de 44 % (4 texels occupés sur 9), donc seulement 44 % des threads GPU produiront réellement un travail utilisable ! De plus, les données utiles sont rares en mémoire, nous paierons donc pour la bande passante même pour les texels inoccupés. En pratique, l'occupation de la lightmap est généralement comprise entre 50 et 70 %, ce qui représente un gain potentiel énorme.
- L'ensemble de données est trop petit. L'exemple montre une carte lumineuse 3x3 pour plus de simplicité, mais même le cas courant d'une carte lumineuse 512x512 sera un ensemble de données trop petit pour que les GPU récents atteignent une efficacité maximale.
- Dans une section précédente, nous avons parlé de la priorisation des vues et du travail d’élimination. Les deux points ci-dessus sont encore plus vrais car certains texels occupés ne seront pas cuits car ils ne sont pas actuellement visibles dans la vue Scène, réduisant encore plus l'occupation et l'ensemble de données global.
Comment pouvons-nous résoudre cela ? Dans le cadre d'une collaboration avec AMD, la compaction des rayons a été ajoutée. L’idée améliore considérablement les performances du lancer de rayons et de l’ombrage. En bref, l’idée est de créer toutes les définitions de rayons dans une mémoire contiguë permettant à tous les threads d’un warp/wavefront de travailler sur des données chaudes.
En pratique, vous avez également besoin que chaque rayon connaisse l'index du texel auquel il est lié, nous le stockons dans la charge utile du rayon. Nous stockons également le nombre global de rayons compactés.
Voici le flux avec compactage :

Les deux noyaux qui ombragent et tracent les rayons peuvent désormais s'exécuter uniquement sur la mémoire chaude et avec une divergence minimale dans les chemins de code.
Quelle est la prochaine étape ? Eh bien, nous n’avons pas résolu le fait que l’ensemble de données pourrait être trop petit pour le GPU, en particulier si la priorisation des vues est activée. L’idée suivante est de décorréler la génération de rayons à partir de la représentation gbuffer. Avec l'approche naïve, nous ne générons qu'un seul rayon par texel. Puisque nous voudrons de toute façon générer davantage de rayons, nous pourrions tout aussi bien générer plusieurs rayons par texels à l'avance. De cette façon, nous pouvons créer du travail plus significatif pour le GPU. Voici le déroulement :

Avant le compactage, nous générons de nombreux rayons par texel et nous appelons cela l'expansion. Nous générons également des méta-informations qui sont utilisées dans l’étape de collecte pour s’accumuler dans le texel de destination correct.
Les noyaux d'expansion et de rassemblement ne sont pas exécutés très souvent. En pratique, nous élargissons puis ombrageons chaque lumière (pour la lumière directe) ou traitons tous les rebonds (pour la lumière indirecte), pour finalement n'en rassembler qu'une seule fois.
Avec ces techniques, nous atteignons notre objectif : nous générons suffisamment de travail pour saturer le GPU et nous dépensons de la bande passante uniquement sur les texels qui comptent.
Voici les avantages de tirer plusieurs rayons par texel :
- L'ensemble des rayons actifs sera toujours un grand ensemble de données, même en mode de priorisation des vues.
- La préparation, le traçage et l'ombrage fonctionnent tous sur des données très cohérentes car le noyau d'extension créera des rayons ciblant le même texel dans la mémoire continue.
- Le noyau d'extension gère l'occupation et la visibilité, rendant le noyau de préparation beaucoup plus simple et donc plus rapide.
- La taille des tampons de l'ensemble de données étendu/fonctionnel est découplée de la taille de la carte lumineuse.
- Le nombre de rayons que nous tirons par texel peut être piloté par n'importe quel algorithme, une extension naturelle sera l'échantillonnage adaptatif.
L'éclairage indirect utilise des idées très similaires, bien que plus complexes :

Avec la lumière indirecte, nous devons effectuer plusieurs rebonds, chacun pouvant rejeter des rayons aléatoires. Nous effectuons donc une compaction itérative pour continuer à travailler sur les données chaudes.
L'heuristique que nous utilisons actuellement favorise une quantité égale de rayons par texel. Le but est d'obtenir un rendu très progressif. Cependant, une extension naturelle de ceci serait d'améliorer ces heuristiques en utilisant l'échantillonnage adaptatif, afin de tirer plus de rayons là où les résultats actuels sont bruyants. De plus, l'heuristique pourrait viser une plus grande cohérence, à la fois dans la mémoire et dans l'exécution du groupe de threads, en prenant en compte la taille du front d'onde/de la chaîne du matériel.
Ressources d' ArchVizPRO créées avec GPU Lightmapper.
Il existe de nombreux cas d’utilisation de la transparence/translucidité. Une façon courante de gérer la transparence et la translucidité consiste à lancer un rayon, à détecter l'intersection, à récupérer le matériau et à programmer un nouveau rayon si le matériau rencontré est translucide ou transparent. Cependant, dans notre cas, le GPU ne peut pas générer de rayons pour des raisons de performances (veuillez vous référer à la section « Pipeline piloté par les données » ci-dessus). De plus, nous ne pouvons pas raisonnablement demander au processeur de programmer suffisamment de rayons à l'avance afin d'être sûrs de gérer le pire des cas possible, car cela constituerait un impact majeur sur les performances.
Nous avons donc opté pour une solution hybride. Nous traitons la translucidité et la transparence différemment, ce qui permet de résoudre les problèmes ci-dessus :
Transparence (lorsqu'un matériau n'est pas opaque en raison de trous qu'il contient). Dans ce cas, le rayon peut soit traverser le matériau, soit rebondir sur celui-ci en fonction d'une distribution de probabilité. Ainsi, la charge de travail préparée à l'avance par le CPU n'a pas besoin de changer, nous sommes toujours indépendants de la scène.
Translucidité (lorsqu'un matériau filtre la lumière qui le traverse). Dans ce cas, nous approximons et ne considérons pas la réfraction. En d’autres termes, nous laissons la matière colorer la lumière, mais sans changer sa direction. Cela nous permet de gérer la translucidité tout en parcourant le BVH, ce qui signifie que nous pouvons facilement gérer un grand nombre de matériaux découpés et évoluer très bien avec la complexité de la translucidité dans la scène.

Cependant, il y a une bizarrerie : la traversée BVH est dans le désordre :
Dans le cas des rayons d'occlusion, cela est tout à fait normal puisque nous nous intéressons uniquement à l'atténuation due à la translucidité de chaque triangle intersecté le long du rayon. La multiplication étant commutative, le parcours BVH hors service ne pose pas de problème.
Cependant, pour les rayons d'intersection, ce que nous souhaitons, c'est pouvoir s'arrêter sur un triangle (de manière probabiliste lorsque le triangle est transparent) et collecter l'atténuation de la translucidité pour chaque triangle depuis l'origine du rayon jusqu'au point d'impact. Comme la traversée BVH est en panne, la solution que nous avons choisie est d'abord d'exécuter uniquement l'intersection pour trouver le point d'impact et de marquer le rayon si une translucidité a été touchée. Pour chaque rayon marqué, nous générons ainsi un rayon d'occlusion supplémentaire depuis l'origine du rayon d'intersection jusqu'au rayon d'intersection touché. Pour ce faire efficacement, nous utilisons la compaction lors de la génération des rayons d'occlusion, ce qui signifie que l'on ne paiera le coût supplémentaire que si le rayon d'intersection a été marqué comme nécessitant une gestion de la translucidité.
Tout cela a été possible grâce à la nature open source de RadeonRays qui a été forké et personnalisé selon nos besoins dans le cadre de la collaboration avec AMD.
Nous avons vu ce que nous faisons en matière de performance brute, super ! Mais ce n’est que la première partie du puzzle. Un nombre élevé d'échantillons par seconde est une bonne chose, mais ce qui compte vraiment, en fin de compte, c'est le temps de cuisson. Autrement dit, nous voulons tirer le maximum de chaque rayon que nous lançons. Cette dernière affirmation est en réalité à l’origine de décennies de recherche en cours. Voici quelques excellentes ressources :
Traçage de rayons : La semaine prochaine
Traçage de rayons : Le reste de ta vie
Unity GPU Lightmapper est un lightmapper purement diffus. Cela simplifie grandement l’interaction de la lumière avec les matériaux et contribue également à atténuer les lucioles et le bruit. Il reste cependant encore beaucoup à faire pour améliorer le taux de convergence. Voici quelques-unes des techniques que nous utilisons :
Roulette russe
À chaque rebond, nous tuons le chemin de manière probabiliste en fonction de l'albédo accumulé. On peut trouver une excellente explication dans la thèse d'Eric Veach (page 67).
Échantillonnage à importance multiple (MIS) pour l'environnement
Les environnements HDR qui présentent une variance élevée peuvent entraîner une quantité considérable de bruit dans la sortie, nécessitant un nombre énorme d'échantillons pour produire des résultats agréables. Nous appliquons donc une combinaison de stratégies d’échantillonnage spécifiquement adaptées pour évaluer l’environnement en l’analysant d’abord, en identifiant les zones importantes et en échantillonnant en conséquence. Cette approche, qui n'est pas exclusive à l'échantillonnage environnemental, est généralement connue sous le nom d'échantillonnage à importance multiple et a été initialement proposée dans la thèse d'Eric Veach (page 252). Ceci a été réalisé en collaboration avec Unity Labs Grenoble.
Beaucoup de lumières
A chaque rebond, nous sélectionnons de manière probabiliste une lumière directe et nous limitons le nombre de lumières affectant les surfaces avec une structure de grille spatiale. Ceci a été réalisé en collaboration avec AMD. Nous étudions actuellement plus en profondeur le problème des lumières multiples, car l'échantillonnage de sélection de lumière est essentiel à la qualité.

Débruitage
Le bruit est supprimé à l’aide d’un débruiteur IA formé sur les sorties d’un traceur de chemin. Voir la présentation de Jesper Mortensen à Unity GDC 2019.
Nous avons vu comment un pipeline piloté par les données, l’attention portée aux performances brutes et des algorithmes efficaces sont combinés pour offrir une expérience de lightmapping interactive avec le GPU Lightmapper. Veuillez noter que le GPU Lightmapper est en développement actif et est constamment amélioré.
Faites-nous part de vos réflexions !
L'équipe d'éclairage
PS: Si vous pensez que c'était une lecture amusante et que vous êtes intéressé à relever un nouveau défi, nous recherchons actuellement un développeur d'éclairage à Copenhague, alors contactez-nous !
---
Vous souhaitez apprendre à optimiser les graphiques dans Unity? Découvrez ce tutoriel.
