Approche du shader clip d'Outbound : Mise au rebut précise des feuillages pour les environnements en temps réel

Comment empêcher l'herbe de passer par terre dans votre jeu de van life en monde ouvert ? Dans cet article d'invité, les programmeurs de Square Glade Games Tony Fial et Michiel Procé expliquent en détail comment ils ont abordé et résolu ce problème dans Outbound grâce à une solution personnalisée de clip de shader.
Nous sommes Tony Fial et Michiel Procé, qui font partie de l'équipe de Square Glade Games, et nous travaillons actuellement sur le dernier titre du studio, Outbound, qui est un jeu d'exploration en monde ouvert se déroulant dans un futur proche utopique. Le joueur commence avec un camping-car vide et peut en faire la maison mobile de ses rêves, en la construisant exactement comme il le souhaite.
Le véhicule est un grand point central pour le jeu, tout comme le conduire à travers la nature. Le monde dans Outbound est fabriqué à la main et comprend beaucoup de feuillages et d'herbe, qui sont pulpeux, hauts et abondants. Bien que nous puissions créer un monde magnifique avec ces ressources, les combiner avec un véhicule qui conduit dans de tels environnements a causé quelques problèmes visuels.
Le problème
Le joueur est capable de conduire son camping-car à travers pratiquement n'importe quelle zone ouverte. Les buissons et l'herbe ne sont pas des obstacles à cela. La camionnette étant assez proche du sol, l'herbe du terrain se découpait souvent sur le fond ou les côtés du véhicule.
Il y a aussi des endroits où la camionnette peut atteindre les feuillages plus hauts comme les fleurs et les buissons. Pour montrer le problème, la capture d'écran ci-dessous montre un cas où l'herbe et les buissons sont fortement coupés dans le véhicule. Cela n'est pas seulement visuellement peu attrayant, mais cause également divers problèmes d'expérience de jeu tels que des interactions visuellement bloquantes ou des informations importantes.

Pour résumer notre problème principal, il existe différents types de feuillages et d'herbe qui traversent le camping-car, ce qui est indésirable du point de vue visuel et de l'expérience de jeu.
Maintenant, résolvons ça.
Remue-méninges sur les solutions possibles
Chez Square Glade Games, avant de commencer à travailler activement sur une solution, nous estimons personnellement utile de compiler une liste des exigences optimales.
Dans ce cas précis, nous avions besoin de notre solution pour :
• Soyez performant. Il y a beaucoup d'herbe dans Outbound, donc une solution non optimisée peut être très coûteuse dans les zones avec beaucoup plus d'herbe et de plantes.
• Conservez le style original intact. Nous sommes actuellement dans un état de développement où nous ne pouvons pas modifier l'aspect des éléments principaux dans Outbound, donc idéalement la solution utilise autant que possible les feuillages d'origine.
• Être compatible multiplateforme.Étant donné que le titre est prévu pour sortir sur plusieurs plateformes, la solution doit fonctionner sur Windows, Nintendo SwitchTM, Xbox et PlayStation®.
• Soyez intuitif à utiliser.La solution devrait idéalement être intuitive à la fois pour les concepteurs et les programmeurs de l'équipe.
• Appliquer sur plusieurs formes. L'idéal serait de couper les feuillages à la forme exacte du véhicule, éventuellement en utilisant plusieurs formes.
Maintenant, réfléchissons à des solutions qui pourraient satisfaire cette liste d'exigences. Nos premières pensées se sont tournées vers un élément que tous les brins d'herbe partagent... le shader.
Pratiquement toute la flore de Outbound est placée sur le terrain Unity avec les outils de terrain. Une partie importante de cette valeur est l'herbe, qui utilise le shader Grass par défaut. Ce shader utilise le GPU pour placer et afficher les plans d'herbe de manière très performante. D'autres éléments, comme les plus gros buissons montrés dans la capture d'écran ci-dessus, sont placés comme maillages détaillés, en utilisant le matériau et le shader qui leur sont assignés.
Cela présentait un autre détail important, à savoir que la solution proposée devait pouvoir fonctionner sur plusieurs shaders entièrement différents, de la même manière, en même temps.
Solutions proposées
Toutes les solutions proposées ci-dessous partagent également un « apport » majeur : La position du camping-car, ou pour être plus précis, la zone où le feuillage doit être coupé.
Compte tenu des exigences énoncées, nous voulions que notre solution soit intuitive pour le reste de l'équipe de Square Glade. D'après notre expérience, les outils de l'éditeur ne seront utilisés par les membres de l'équipe que s'ils sont intuitifs et faciles à saisir. En gardant cela à l'esprit, nous avons décidé de créer un cube 3D visuel qui pourrait être mis à l'échelle, tourné et manipulé pour clipper juste assez du corps du véhicule et l'ajuster pour qu'il soit juste. Tout feuillage à l'intérieur du cube serait coupé, tandis que tout ce qui se trouve à l'extérieur ressemblerait.
Shader pochoir
La toute première chose que nous avons essayé était d'utiliser un élément de shader appelé « tampon de pochoir ».
Cette partie de la programmation de shaders est très fascinante, mais aussi un peu difficile à gérer. Ce à quoi cela se résume pour notre objectif, c'est que nous disons à « l'élément de découpage », dans ce cas-ci, notre cube, d'écrire certaines informations dans le tampon du pochoir d'un frame rendu. Cela signifie que n'importe où sur l'écran où se trouve le cube, il écrira une valeur de 1. L'objet « clippé » (dans notre cas, l'herbe) peut lire à partir de ce buffer et rejeter tous les pixels dont la valeur est exactement 1.
Dans le code du shader, cela ressemblerait à ceci :
Clipping object 'Cube'
Stencil
{
Ref 1
Comp always
Pass replace
}
Clipped object 'Grass'
Stencil
{
Ref 1
Comp equal
}L'objet clipping écrit la valeur 1 dans la mémoire tampon, indiquée par la ligne Ref 1, et le fera toujours. Si une valeur de pochoir rendue ultérieurement correspond ou réussit la comparaison de pochoir, elle la remplacera par les informations de ce shader.L'herbe a une implémentation similaire : Il recherchera également la valeur de la référence 1 et ne passera la vérification que si la comparaison est égale à cette valeur de référence.
Cette implémentation a fonctionné pour couper l'herbe loin, et elle était très efficace, car elle fonctionne sur les pixels de l'image rendue et n'est pas affectée par la quantité d'herbe dans une scène donnée. Cependant, cette solution présentait un défaut fatal. Comme cette implémentation n'a aucun sens de la profondeur, elle coupera tout ce qui se trouve derrière le cube. Pratiquement cela signifiait que lorsque le joueur était assis à l'intérieur du véhicule, alors qu'il regardait depuis une vue à la première personne, l'écran entier était marqué comme « clippé », de sorte que le joueur ne voyait aucune herbe nulle part. Pour cette raison, nous avons dû essayer d'autres méthodes qui fonctionnaient également lorsque la caméra du joueur se trouvait à l'intérieur de l'objet « clipper ».
Découpage manuel
Une solution dont nous avons brièvement parlé était d'enlever manuellement l'herbe à l'emplacement de notre véhicule, en l'éloignant du terrain lui-même. Nous l'avions déjà fait pour d'autres parties du jeu, en utilisant la fonction 'TerrainData.SetDetailLayer' fournie par Unity sur le terrain. Cela positionnerait la couleur en niveaux de gris de la couche de détail à 0 sur les pixels juste en dessous du van, indiquant au terrain de supprimer tout maillage détaillé ou toute herbe à cet ensemble d'emplacements.
Comme les cartes de sortie sont plutôt grandes, cela signifie que la résolution de la couche de détail est sur la face inférieure, ce qui la rend un peu « dentelée ». Ceci est parfait pour le placement normal de détails de l'herbe et d'autres maillages, mais lorsque vous coupez manuellement des pièces, la résolution inférieure donnera une forme qui ne serait pas assez proche de la taille de la camionnette, soit trop petite ou trop grande.
Cette solution entraînerait également un scintillement des détails d'entrée/sortie lorsque le véhicule se trouvait juste à la limite de deux pixels de détail de terrain. Pour ces raisons, nous n'avons pas avancé dans la mise en place de cette solution. Notre voyage continue !
Clip shader
Avec le shader tampon pour pochoirs, nous pensions y être presque, car nous avons rendu les pixels invisibles là où c'était nécessaire avec la précision de la carrosserie extérieure du van. Si seulement il y avait une autre façon de le faire, tout en utilisant la profondeur du cube, connaître la solution ne devrait en fait couper que les pixels à l'intérieur de sa zone de délimitation.
Il ya une méthode qui fait exactement cela! Les shaders HLSL fournissent la fonction humble clip(), qui ne tient pas compte du pixel si la valeur spécifiée est inférieure à 0. Vous avez peut-être déjà vu cela dans un shader aléatoire où il est souvent utilisé pour le découpage alpha.
Par exemple, l'herbe d'Outbound ressemble à de véritables touffes d'herbe et non à des quads carrés avec une image d'herbe, car nous nous coupons partout où le canal alpha de notre texture d'herbe est noir.
Lorsque nous avons effectué un premier prototype/vérification rapide de cette solution, nous avions de grands espoirs que cette implémentation pourrait fonctionner, car nous avons pu rendre les pixels invisibles au-dessus d'une certaine position du monde. En pseudocode, la fonction ressemblait à la suivante :
// Return -1 when the Y position is above 0, and return 1 when it is not.
clip( worldPos.y > 0 ? -1 : 1 );La solution : Un shader clip
À ce stade, nous avions un exemple simple qui montrait une solution prometteuse, à savoir utiliser un shader clip. L'étape suivante consistait à créer une fonction pour fournir au shader les informations nécessaires pour clipper exactement là où nous le voulions.Cela comprenait deux parties :
• La partie où nous calculons essentiellement la « forme », y compris ses dimensions et transformations, et fournissons ces données au shader.
• La partie où le shader utilise ces données, vérifie si un point donné se trouve dans la forme et rejette ses pixels si nécessaire.
Pour la première étape de notre solution, nous avons créé un script 'GrassClipperShape', un MonoBehaviour que nous pouvions attacher à un objet de la scène, qui dicterait où se trouverait une zone de clipping. Un exemple de ceci est montré ci-dessous, où la zone de la forme utilisant OnDrawGizmos dans la vue de l'éditeur est affichée.

Comme nous aimerions idéalement utiliser plusieurs de ces clippers, nous avons besoin d'un script global (c'est-à-dire un « manager ») pour gérer tous les clippers disponibles. Chaque clipper fournirait les propriétés suivantes à ce script global, nommé 'GrassClipperManager' :
• Forme : le type de forme. Nous voulions que cette version fonctionne avec les cubes et les sphères, il s'agit donc d'une simple énumération réglée sur « cube » ou « sphère »
• Vector3 : la taille de l'objet dans la scène
• Matrix4x4 : l'objet tourné calculé dans l'espace monde
Le GrassClipperManager, dont il n'y en a qu'un dans la scène, récupérera ces informations dans les clippers de chaque image, et les enverra dans le shader comme suit :
Shader.SetGlobalInteger("_ShapeCount", count);
Shader.SetGlobalMatrixArray("_ShapeInvMatrix", inv);
Shader.SetGlobalVectorArray("_ShapeParams", size);
Shader.SetGlobalFloatArray("_ShapeType", type);Les lignes ci-dessus définiront les valeurs globales du shader. En bref, cela signifie que vous pouvez utiliser les valeurs des shaders avec ces noms et types exacts, et les utiliser dans n'importe quel shader.
Comme nous voulons que notre découpage se fasse sur plusieurs shaders différents, nous avons créé un script HLSL distinct à inclure dans le shader qui devait être affecté par notre clipper. Ce script expose une fonction personnalisée nommée 'ApplyClipVolumeSDF'. Il utilise les informations des valeurs globales du shader maintenant remplies et calculera si un pixel se trouve dans l'une des limites.
inline void ApplyClipVolumeSDF(float3 worldPos)
{
float clipVal = GetClipFade(worldPos);
if (clipVal <= 0.0)
clip(-1);
}Comme vous pouvez le voir ci-dessus, si le pixel est censé être rejeté, il appellera la fonction 'clip(-1)', renvoyant un pixel rejeté. Sinon, il progressera normalement dans le reste du shader.
Implémentation d'un shader clip
La fonction de coupe étant désormais créée et fournie avec les données nécessaires, il était temps de l'implémenter dans nos shaders.
Découvrons d'abord comment procéder pour les maillages détaillés, où nous pourrions créer une copie de l'original et la modifier. Tout en haut du shader, nous devons référencer le script personnalisé comme suit :
#include "Assets/Shaders/ClipVolume.hlsl"Puis, lorsque nous voulons utiliser la fonction, nous l'appelons simplement dans la partie fragment du shader comme suit :
float3 worldPos = mul(unity_ObjectToWorld, float4(input.positionOS, 1.0)).xyz;
ApplyClipVolumeSDF(worldPos);Dans notre cas, seuls deux shaders devaient l'inclure, à savoir le shader par défaut utilisé par Unity Grass et un shader personnalisé utilisé pour tous les autres feuillages rendus en tant que maillages détaillés. Maintenant que nous avons ceci, il peut être implémenté facilement dans n'importe quel autre shader si nécessaire.
Mais notre voyage n'était pas terminé : un dernier obstacle s'est présenté. Comment pourrions-nous maintenant modifier et conserver les modifications apportées au shader d'herbe par défaut ? Unity utilise quelques shaders spécifiques intégrés pour le rendu de l'herbe, dans notre cas le 'WavingGrassBillboard.shader'. Ce shader s'applique automatiquement à toute l'herbe, sans possibilité de fournir des variantes personnalisées. C'était essentiel pour faire fonctionner notre solution, car elle devait s'accrocher à ce shader pour pouvoir appeler la fonction personnalisée « ApplyClip » et écarter les pixels indésirables.
Après avoir essayé quelques solutions, Michiel Procé, membre de l'équipe, a trouvé un moyen de modifier et de conserver les modifications du shader d'herbe par défaut de manière fiable. En exécutant le code suivant pendant les compilations et dans l'éditeur, notre shader personnalisé remplace le shader URP par défaut :
string replacementShaderName = "Hidden/TerrainEngine/Details/UniversalPipeline/BillboardWavingDoublePass_Clipped";
if (GraphicsSettings.TryGetRenderPipelineSettings<UniversalRenderPipelineRuntimeShaders>(out var shadersResources))
{
if (shadersResources.terrainDetailGrassBillboardShader.name != replacementShaderName)
{
Shader replacementShader = Shader.Find(replacementShaderName);
shadersResources.terrainDetailGrassBillboardShader = replacementShader;
}
}Notez que ceci ne remplace que le shader WavingGrassBillboard, mais que son implémentation pour d'autres shaders serait similaire.
Réflexions finales
Notre solution finale d'utiliser un shader clip fonctionne bien à nos fins et nous sommes très satisfaits des résultats qu'il fournit. Regardez la capture d'écran ci-dessous pour une visualisation de la solution, où un cube rectangulaire coupe l'herbe à l'intérieur. Notez que la boîte est vue de dessus et est placée à travers le terrain pour une vue optimale de ce qui est clippé.

En regardant notre liste d'exigences pour notre solution de coupe d'herbe, nous avons été heureux de constater qu'elle adhère à toutes !
• La solution est performante, car les fonctions utilisées pour calculer l'écrêtage sont très bon marché. Et parce qu'elle ne tient pas compte du pixel, notre implémentation n'effectuera aucun autre traitement inutile.
• Il garde le style original intact, car il est basé sur les shaders que nous utilisions déjà.
• L'implémentation est agnostique, car la fonction clip() l'est elle-même.
• La solution est intuitive à utiliser pour le reste de l'équipe. Les concepteurs peuvent créer et utiliser plusieurs formes et même les faire s'entrecroiser.
Nous pensons que des fonctionnalités comme celles ci-dessus sont extrêmement importantes, non seulement pour la créativité, mais aussi pour empêcher l'apparition ultérieure d'étranges bugs.
Exemple de projet
Pour partager cette solution avec la communauté, nous avons créé un exemple de projet en utilisant ces techniques détaillées ci-dessus, afin que vous puissiez l'essayer vous-même - découvrez-le ici sur GitHub.
Merci d'avoir lu notre article invité. J'espère que cela aidera beaucoup d'autres développeurs qui rencontrent le même problème que nous !
Outbound est actuellement en version bêta-test fermée ; suivez le jeu sur Steam pour les mises à jour. Découvrez d'autres jeux Made with Unity sur notre page Steam Curator, et découvrez d'autres témoignages de développeurs Unity sur notre hub de ressources.
Nintendo SwitchTM est une marque commerciale de Nintendo.
