Ce didacticiel concerne
les cartes interactives et leur création dans Unity à l'aide de shaders.
Cet effet peut servir de base à des techniques plus complexes, telles que les projections holographiques ou même une table de sable du film "Black Panther".
Une inspiration pour ce tutoriel est le tweet publié par
Baran Kahyaoglu , montrant un exemple de ce qu'il crée pour
Mapbox .
La scène (à l'exception de la carte) a été prise à partir de la démo du vaisseau spatial graphique à effet visuel Unity (voir ci-dessous), qui peut être téléchargée
ici .
Partie 1. Décalage de sommet
Anatomie de l'effet
La première chose que vous pouvez immédiatement remarquer est que les cartes géographiques sont
plates : si elles sont utilisées comme textures, elles n'ont pas la tridimensionnalité qu'un véritable modèle 3D de la zone de carte correspondante aurait.
Vous pouvez appliquer cette solution: créez un modèle 3D de la zone nécessaire dans le jeu, puis appliquez-y une texture de la carte. Cela aidera à résoudre le problème, mais cela prend beaucoup de temps et ne permettra pas de réaliser l'effet de «défilement» de la vidéo Baran Kahyaoglu.
De toute évidence, une approche plus technique est préférable. Heureusement, les shaders peuvent être utilisés pour modifier la géométrie d'un modèle 3D. Avec leur aide, vous pouvez transformer n'importe quel avion en vallées et montagnes de la région dont nous avons besoin.
Dans ce tutoriel, nous utilisons une carte
de Chillot , Chilli, célèbre pour ses collines caractéristiques. L'image ci-dessous montre la texture de la région tracée sur un maillage rond.
Bien que nous voyions des collines et des montagnes, elles sont encore complètement plates. Cela détruit l'illusion du réalisme.
Extrusion de normales
La première étape pour utiliser des shaders pour changer la géométrie est une technique appelée
extrusion normale . Elle a besoin d'
un modificateur de sommet : une fonction qui peut manipuler les sommets individuels d'un modèle 3D.
La façon dont le modificateur de sommet est utilisé dépend du type de shader utilisé. Dans ce didacticiel, nous allons modifier le
Shader standard de surface - l'un des types de shaders que vous pouvez créer dans Unity.
Il existe de nombreuses façons de manipuler les sommets d'un modèle 3D. L'une des toutes premières méthodes décrites dans la plupart des didacticiels de vertex shader est l'
extrusion de normales . Elle consiste à repousser chaque sommet (
extrusion ), ce qui donne au modèle 3D un aspect plus gonflé. «À l'extérieur» signifie que chaque sommet se déplace dans la direction de la normale.
Pour les surfaces lisses, cela fonctionne très bien, mais dans les modèles avec de mauvaises connexions de vertex, cette méthode peut créer d'étranges artefacts. Cet effet est bien expliqué dans l'un de mes premiers tutoriels:
Une introduction douce aux shaders , où j'ai montré comment
extruder et
intruder un modèle 3D.
L'ajout de normales extrudées à un shader de surface est très facile. Chaque shader de surface a une
#pragma
, qui est utilisée pour transmettre des informations et des commandes supplémentaires. L'une de ces commandes est
vert
, ce qui signifie que la fonction
vert
sera utilisée pour traiter chaque sommet du modèle 3D.
Le shader édité est le suivant:
#pragma surface surf Standard fullforwardshadows addshadow vertex:vert ... float _Amount; ... void vert(inout appdata_base v) { v.vertex.xyz += v.normal * _Amount; }
Puisque nous
addshadow
la position des sommets, nous devons également utiliser
addshadow
si nous voulons que le modèle projette correctement des ombres sur lui-même.
Qu'est-ce que appdata_base?Comme vous pouvez le voir, nous avons ajouté une fonction de modificateur de sommets (
vert
), qui prend en paramètre une
structure appelée
appdata_base
. Cette structure stocke des informations sur chaque sommet individuel du modèle 3D. Il contient non seulement
la position du sommet (
v.vertex
), mais également d'autres champs, par exemple
, la direction normale (
v.normal
) et
les informations de texture associées au sommet (
v.texcoord
).
Dans certains cas, cela ne suffit pas et nous pouvons avoir besoin d'autres propriétés, telles que la
couleur du sommet (
v.color
) et la
direction tangente (
v.tangent
). Les modificateurs de sommet peuvent être spécifiés à l'aide d'une variété d'autres structures d'
appdata_tan
, y compris
appdata_tan
et
appdata_full
, qui fournissent plus d'informations au prix de faibles performances. Vous pouvez en savoir plus sur
appdata
(et ses variantes) dans le
wiki Unity3D .
Comment les valeurs sont-elles renvoyées par vert?La fonction supérieure n'a pas de valeur de retour. Si vous connaissez le langage C #, vous devez savoir que les structures sont transmises par valeur, c'est-à-dire que lorsque v.vertex
change v.vertex
cela n'affecte que la copie de v
, dont la portée est limitée par le corps de la fonction.
Cependant, v
également déclaré inout
, ce qui signifie qu'il est utilisé à la fois pour l'entrée et la sortie. Toute modification apportée change la variable elle-même, que nous transmettons à vert
. Les mots clés inout
et out
très souvent utilisés en infographie, et ils peuvent à peu près être corrélés avec ref
et out
en C #.
Extrusion de normales avec des textures
Le code que nous avons utilisé ci-dessus fonctionne correctement, mais il est loin de l'effet que nous voulons atteindre. La raison en est que nous ne voulons pas extruder tous les sommets de la même quantité. Nous voulons que la surface du modèle 3D corresponde aux vallées et montagnes de la région géographique correspondante. Tout d'abord, nous devons en quelque sorte stocker et récupérer des informations sur la quantité de chaque point sur la carte qui est soulevée. Nous voulons que l'extrusion soit influencée par la texture dans laquelle les hauteurs du paysage sont encodées. Ces textures sont souvent appelées
cartes de hauteur , mais souvent elles sont également appelées
cartes de profondeur , selon le contexte. Après avoir reçu des informations sur les hauteurs, nous pourrons modifier l'extrusion de l'avion en fonction de la carte des hauteurs. Comme le montre le schéma, cela nous permettra de contrôler la montée et la descente des zones.
Il est assez simple de trouver une image satellite de la zone géographique qui vous intéresse et une carte d'élévation associée. Voici la carte satellite de Mars (ci-dessus) et la carte d'altitude (ci-dessous) qui ont été utilisées dans ce tutoriel:
J'ai parlé en détail du concept de la carte de profondeur dans une autre série de tutoriels intitulée
"Photos 3D de Facebook de l'intérieur: shaders de parallaxe" [
traduction en Habré].
Dans ce didacticiel, nous supposerons que la carte des hauteurs est stockée sous forme d'image en niveaux de gris, où le noir et le blanc correspondent à des hauteurs inférieures et supérieures. Nous avons également besoin de ces valeurs pour une mise à l'échelle
linéaire , c'est-à-dire que la différence de couleur, par exemple, à
0.1
correspond à une différence de hauteur entre
0
et
0.1
ou entre
0.9
et
1.0
. Pour les cartes de profondeur, ce n'est pas toujours vrai, car beaucoup d'entre elles stockent des informations de profondeur à une
échelle logarithmique .
Pour échantillonner une texture, deux éléments d'information sont nécessaires: la texture elle-même et les
coordonnées UV du point que nous voulons échantillonner. Ce dernier est accessible via le champ
texcoord
, stocké dans la structure
appdata_base
. Il s'agit de la coordonnée UV associée au sommet actuel en cours de traitement. L'échantillonnage de texture dans une
fonction de surface se fait à l'aide de
tex2D
, cependant lorsque nous sommes dans une
,
tex2Dlod
est requis.
Dans l'extrait de code ci-dessous, une texture appelée
_HeightMap
utilisée pour modifier la valeur d'extrusion effectuée pour chaque sommet:
sampler2D _HeightMap; ... void vert(inout appdata_base v) { fixed height = tex2Dlod(_HeightMap, float4(v.texcoord.xy, 0, 0)).r; vertex.xyz += v.normal * height * _Amount; }
Pourquoi tex2D ne peut-il pas être utilisé comme fonction de sommet?
Si vous regardez le code généré par Unity pour le Surface Shader standard, vous remarquerez qu'il contient déjà un exemple de la façon d'échantillonner des textures. En particulier, il échantillonne la
texture principale (appelée
_MainTex
) dans une
fonction de surface (appelée
surf
) à l'aide de la fonction
tex2D
intégrée.
Et en fait,
tex2D
utilisé pour échantillonner les pixels d'une texture, indépendamment de ce qui y est stocké, de sa couleur ou de sa hauteur. Cependant, vous pouvez remarquer que
tex2D
ne peut pas être utilisé dans une fonction de sommet.
La raison en est que
tex2D
ne lit
pas seulement les pixels de la texture. Elle décide également de la version de la texture à utiliser, en fonction de la distance à la caméra. Cette technique est appelée
mipmapping : elle vous permet d'avoir des versions plus petites d'une seule texture qui peuvent être utilisées automatiquement à différentes distances.
Dans la fonction de surface, le shader sait déjà quelle
texture MIP utiliser. Ces informations peuvent ne pas encore être disponibles dans la fonction vertex, et par conséquent
tex2D
ne peut pas être utilisé en toute confiance. Contrairement à cela, la fonction
tex2Dlod
peut recevoir deux paramètres supplémentaires, qui dans ce didacticiel peuvent avoir une valeur nulle.
Le résultat est clairement visible dans les images ci-dessous.
Dans ce cas, une légère simplification peut être apportée. Le code que nous avons examiné précédemment peut fonctionner avec n'importe quelle géométrie. Cependant, nous pouvons supposer que la surface est absolument plate. En fait, nous voulons vraiment appliquer cet effet au plan.
Par conséquent, vous pouvez supprimer
v.normal
et le remplacer par
float3(0, 1, 0)
:
void vert(inout appdata_base v) { float3 normal = float3(0, 1, 0); fixed height = tex2Dlod(_HeightMap, float4(v.texcoord.xy, 0, 0)).r; vertex.xyz += normal * height * _Amount; }
Nous pourrions le faire car toutes les coordonnées dans
appdata_base
sont stockées dans
l'espace modèle , c'est-à-dire qu'elles sont définies par rapport au centre et à l'orientation du modèle 3D. La transition, la rotation et la mise à l'échelle avec
transformation dans Unity modifient la position, la rotation et l'échelle de l'objet, mais n'affectent pas le modèle 3D d'origine.
Partie 2. Effet de défilement
Tout ce que nous avons fait ci-dessus fonctionne plutôt bien. Avant de continuer, nous allons extraire le code nécessaire pour calculer la nouvelle hauteur de sommet dans une fonction
getVertex
distincte:
float4 getVertex(float4 vertex, float2 texcoord) { float3 normal = float3(0, 1, 0); fixed height = tex2Dlod(_HeightMap, float4(texcoord, 0, 0)).r; vertex.xyz += normal * height * _Amount; return vertex; }
Ensuite, toute la fonction
vert
aura la forme:
void vert(inout appdata_base v) { vertex = getVertex(v.vertex, v.texcoord.xy); }
Nous l'avons fait car ci-dessous, nous devons calculer la hauteur de plusieurs points. En raison du fait que cette fonctionnalité sera dans sa propre fonction distincte, le code deviendra beaucoup plus simple.
Calcul des coordonnées UV
Cependant, cela nous amène à un autre problème. La fonction
getVertex
dépend non seulement de la position du sommet actuel (v.vertex), mais aussi de ses coordonnées UV (
v.texcoord
).
Lorsque nous voulons calculer le décalage de hauteur de sommet que la fonction
vert
traite actuellement, les deux éléments de données sont disponibles dans la structure
appdata_base
. Cependant, que se passe-t-il si nous devons échantillonner la position d'un point voisin? Dans ce cas, nous pouvons connaître la position xyz dans
l'espace du modèle , mais nous n'avons pas accès à ses coordonnées UV.
Cela signifie que le système existant est capable de calculer le décalage de hauteur uniquement pour le sommet actuel. Une telle restriction ne nous permettra pas d'avancer, nous devons donc trouver une solution.
Le moyen le plus simple est de trouver un moyen de calculer les coordonnées UV d'un objet 3D, en connaissant la position de son sommet. C'est une tâche très difficile, et il existe plusieurs techniques pour la résoudre (l'une des plus populaires est la
projection triplanaire ). Mais dans ce cas particulier, nous n'avons pas besoin de faire correspondre UV et géométrie. Si nous supposons que le shader sera toujours appliqué au maillage plat, la tâche devient triviale.
Nous pouvons calculer
les coordonnées UV (image inférieure) à partir des
positions des sommets (image supérieure) du fait que les deux sont superposés linéairement sur un maillage plat.
Cela signifie que pour résoudre notre problème, nous devons transformer les
composantes XZ de la position du sommet en
coordonnées UV correspondantes.
Cette procédure est appelée
interpolation linéaire . Il est discuté en détail sur mon site Web (par exemple:
The Secrets Of Color Interpolation ).
Dans la plupart des cas, les valeurs UV sont comprises entre
0 avant
1 ; les coordonnées de chaque sommet, en revanche, sont potentiellement illimitées. Du point de vue des mathématiques, pour la conversion de XZ en UV, nous n'avons besoin que de leurs valeurs limites:
- X m i n , X m a x
- Z m i n , Z m a x
- U m i n , U m a x
- V m i n , V m a x
qui sont indiqués ci-dessous:
Ces valeurs varient en fonction du maillage utilisé. Sur le plan Unity, les
coordonnées UV sont comprises entre
0 avant
1 et les
coordonnées des sommets sont comprises entre
- 5 avant
+ 5 .
Les équations pour convertir XZ en UV sont:
(1)

Comment sont-ils affichés?Si vous n'êtes pas familier avec le concept d'interpolation linéaire, ces équations peuvent sembler assez intimidantes.
Cependant, ils sont affichés tout simplement. Regardons juste un exemple.
U . Nous avons deux intervalles: l'un a des valeurs de
Xmin avant
Xmax un autre de
Umin avant
Umax . Données entrantes pour les coordonnées
X est la coordonnée du sommet en cours de traitement, et la sortie sera la coordonnée
U utilisé pour échantillonner la texture.
Nous devons maintenir la proportionnalité entre
X et son intervalle, et
U et son intervalle. Par exemple, si
X compte alors 25% de son intervalle
U comptera également pour 25% de son intervalle.
Tout cela est illustré dans le diagramme suivant:
On peut en déduire que la proportion composée du segment rouge par rapport au rose doit être la même que la proportion entre le segment bleu et le bleu:
(2)
Maintenant, nous pouvons transformer l'équation ci-dessus pour obtenir
U :
et cette équation a exactement la même forme que celle montrée ci-dessus (1).
Ces équations peuvent être implémentées dans le code comme suit:
float2 _VertexMin; float2 _VertexMax; float2 _UVMin; float2 _UVMax; float2 vertexToUV(float4 vertex) { return (vertex.xz - _VertexMin) / (_VertexMax - _VertexMin) * (_UVMax - _UVMin) + _UVMin; }
Maintenant, nous pouvons appeler la fonction
getVertex
sans avoir à lui passer
v.texcoord
:
float4 getVertex(float4 vertex) { float3 normal = float3(0, 1, 0); float2 texcoord = vertexToUV(vertex); fixed height = tex2Dlod(_HeightMap, float4(texcoord, 0, 0)).r; vertex.xyz += normal * height * _Amount; return vertex; }
Alors la fonction entière
vert
prend la forme:
void vert(inout appdata_base v) { v.vertex = getVertex(v.vertex); }
Effet de défilement
Grâce au code que nous avons écrit, la carte entière est affichée sur le maillage. Si nous voulons améliorer l'affichage, nous devons apporter des modifications.
Formalisons un peu plus le code. Premièrement, nous devrons peut-être zoomer sur une partie distincte de la carte, plutôt que de la regarder dans son ensemble.
Cette zone peut être définie par deux valeurs: sa taille (
_CropSize
) et son emplacement sur la carte (
_CropOffset
), mesurés dans l'
espace des sommets (de
_VertexMin
à
_VertexMax
).
Après avoir reçu ces deux valeurs, nous pouvons à nouveau utiliser l'interpolation linéaire pour que
getVertex
appelé non pas pour la position actuelle du haut du modèle 3D, mais pour le point mis à l'échelle et transféré.
Code pertinent:
void vert(inout appdata_base v) { float2 croppedMin = _CropOffset; float2 croppedMax = croppedMin + _CropSize;
Si nous voulons faire défiler, il suffira de mettre à jour
_CropOffset
travers le script. Pour cette raison, la zone de troncature se déplacera, faisant défiler le paysage.
public class MoveMap : MonoBehaviour { public Material Material; public Vector2 Speed; public Vector2 Offset; private int CropOffsetID; void Start () { CropOffsetID = Shader.PropertyToID("_CropOffset"); } void Update () { Material.SetVector(CropOffsetID, Speed * Time.time + Offset); } }
Pour que cela fonctionne, il est très important de définir le
mode Wrap de toutes les textures sur
Répéter . Si cela n'est pas fait, nous ne pourrons pas boucler la texture.
Pour l'effet zoom / zoom, il suffit de changer
_CropSize
.
Partie 3. Ombrage du terrain
Ombrage plat
Tout le code que nous avons écrit fonctionne, mais a un sérieux problème. L'ombrage du modèle est quelque peu étrange. La surface est correctement courbée, mais réagit à la lumière comme si elle était plate.
Cela se voit très clairement dans les images ci-dessous. L'image du haut montre un shader existant; le bas montre comment cela fonctionne réellement.
Résoudre ce problème peut être un grand défi. Mais d'abord, nous devons déterminer quelle est l'erreur.
L'opération d'extrusion normale a changé la géométrie générale du plan que nous avons utilisé initialement. Cependant, Unity n'a modifié que la position des sommets, mais pas leurs directions normales.
La direction du sommet
normal , comme son nom l'indique, est un vecteur de longueur unitaire (
direction ) indiquant la perpendiculaire à la surface.
Les normales sont nécessaires car elles jouent un rôle important dans l'ombrage d'un modèle 3D. Ils sont utilisés par tous les shaders de surface pour calculer la manière dont la lumière doit être réfléchie par chaque triangle du modèle 3D. Cela est généralement nécessaire pour améliorer la tridimensionnalité du modèle, par exemple, il fait rebondir la lumière sur une surface plane tout comme elle rebondirait sur une surface incurvée. Cette astuce est souvent utilisée pour rendre les surfaces low-poly plus lisses qu'elles ne le sont réellement (voir ci-dessous).
Cependant, dans notre cas, c'est le contraire qui se produit. La géométrie est courbe et lisse, mais comme toutes les normales sont dirigées vers le haut, la lumière est réfléchie par le modèle comme si elle était plate (voir ci-dessous):
Vous pouvez en savoir plus sur le rôle des normales dans l'ombrage des objets dans l'article sur le
mappage normal (Bump Mapping) , où les cylindres identiques sont très différents, malgré le même modèle 3D, en raison de différentes méthodes de calcul des normales de sommet (voir ci-dessous).
Malheureusement, ni Unity ni le langage de création de shaders n'ont de solution intégrée pour recalculer automatiquement les normales. Cela signifie que vous devez les modifier manuellement en fonction de la géométrie locale du modèle 3D.
Calcul normal
La seule façon de résoudre le problème de l'ombrage est de calculer manuellement les normales en fonction de la géométrie de la surface. Une tâche similaire a été discutée dans un article de
Vertex Déplacement - Melting Shader Part 1 , où elle a été utilisée pour simuler la fusion de modèles 3D dans
Cone Wars .
Bien que le code fini devra fonctionner en coordonnées 3D, limitons la tâche à seulement deux dimensions pour l'instant. Imaginez que vous devez calculer la
direction de la normale correspondant au point sur la courbe 2D (la grande flèche bleue dans le diagramme ci-dessous).
D'un point de vue géométrique, la
direction de la normale (grosse flèche bleue) est un vecteur perpendiculaire à la
tangente passant par le point qui nous intéresse (une fine ligne bleue).
La tangente peut être représentée comme une ligne située sur la courbure du modèle.
Un vecteur tangent est un
vecteur unitaire qui repose sur une tangente.
Cela signifie que pour calculer la normale, vous devez suivre deux étapes: premièrement, trouvez la ligne
tangente au point souhaité; puis calculez le vecteur perpendiculaire à celui-ci (qui sera la
direction nécessaire
de la normale ).
Calcul de la tangente
Pour obtenir la
normale, nous devons d'abord calculer la
tangente . Il peut être approximé en échantillonnant un point à proximité et en l'utilisant pour construire une ligne près du sommet. Plus la ligne est petite, plus la valeur est précise.
Trois étapes sont nécessaires:
- Étape 1. Déplacez une petite quantité sur une surface plane
- Étape 2. Calculez la hauteur du nouveau point.
- Étape 3. Utilisez la hauteur du point actuel pour calculer la tangente
Tout cela peut être vu dans l'image ci-dessous:
Pour que cela fonctionne, nous devons calculer les hauteurs de deux points, pas d'un. Heureusement, nous savons déjà comment procéder. Dans la partie précédente du didacticiel, nous avons créé une fonction qui échantillonne la hauteur d'un paysage en fonction d'un point de maillage. Nous l'avons appelé
getVertex
.
Nous pouvons prendre la nouvelle valeur de sommet au point courant, puis à deux autres. L'un sera pour la tangente, l'autre pour la tangente en deux points. Avec leur aide, nous obtenons la normale. Si le maillage d'origine utilisé pour créer l'effet est plat (et dans notre cas, il l'est), nous n'avons pas besoin d'accéder à
v.normal
et nous pouvons simplement utiliser
float3(0, 0, 1)
pour tangent et tangent à deux points, respectivement
float3(0, 0, 1)
et
float3(1, 0, 0)
. Si nous voulions faire de même, mais, par exemple, pour une sphère, il serait beaucoup plus difficile de trouver deux points appropriés pour calculer la tangente et la tangente à deux points.
Illustrations vectorielles
Après avoir obtenu les vecteurs tangents et tangents appropriés à deux points, nous pouvons calculer la normale en utilisant une opération appelée
produit vectoriel . Il existe de nombreuses définitions et explications d'une œuvre vectorielle et de ce qu'elle fait.
Un produit vectoriel reçoit deux vecteurs et renvoie un nouveau. Si deux vecteurs initiaux étaient unitaires (leur longueur est égale à l'unité) et qu'ils sont situés à un angle de 90, alors le vecteur résultant sera situé à 90 degrés par rapport aux deux.
Au début, cela peut être déroutant, mais graphiquement, il peut être représenté comme suit: le produit vectoriel de deux axes en crée un troisième. C’est
X f o i s Y = Z mais aussi
X f o i s Z = Y , et ainsi de suite.
Si nous faisons un pas suffisamment petit (dans le code, c'est
offset
), alors les vecteurs de la tangente et de la tangente à deux points seront à un angle de 90 degrés.
Avec le vecteur normal, ils forment trois axes perpendiculaires orientés le long de la surface du modèle.Sachant cela, nous pouvons écrire tout le code nécessaire pour calculer et mettre à jour le vecteur normal. void vert(inout appdata_base v) { float3 bitangent = float3(1, 0, 0); float3 tangent = float3(0, 0, 1); float offset = 0.01; float4 vertexBitangent = getVertex(v.vertex + float4(bitangent * offset, 0) ); float4 vertex = getVertex(v.vertex); float4 vertexTangent = getVertex(v.vertex + float4(tangent * offset, 0) ); float3 newBitangent = (vertexBitangent - vertex).xyz; float3 newTangent = (vertexTangent - vertex).xyz; v.normal = cross(newTangent, newBitangent); v.vertex.y = vertex.y; }
Tout mettre ensemble
Maintenant que tout fonctionne, nous pouvons retourner l'effet de défilement. void vert(inout appdata_base v) {
Et sur cela, notre effet est enfin terminé.Où aller ensuite
Ce tutoriel peut devenir la base d'effets plus complexes, par exemple des projections holographiques ou même une copie de la table de sable du film "Black Panther".Forfait Unity
Le package complet de ce tutoriel peut être téléchargé sur Patreon , il contient tous les actifs nécessaires pour jouer l'effet décrit.