Bien que les maillages soient le moyen le plus simple et le plus polyvalent de rendre, il existe d'autres options pour représenter les formes en 2D et 3D. Une méthode couramment utilisée est les champs de distance signée (SDF). Les champs de distance signés offrent un tracé de rayons moins coûteux, permettent à différentes formes de se couler facilement les unes dans les autres et d'économiser sur des textures basse résolution pour des images de haute qualité.
Nous commencerons par générer le signe des champs de distance en utilisant des fonctions en deux dimensions, mais plus tard nous continuerons à les générer en 3D. J'utiliserai les coordonnées de l'espace mondial pour que nous ayons le moins possible la dépendance à l'échelle et aux coordonnées UV, donc si vous ne comprenez pas comment cela fonctionne, alors étudiez ce
tutoriel sur une superposition plate , qui explique ce qui se passe.
Préparation de la fondation
Nous allons temporairement jeter les propriétés du shader de superposition plat de base, car pour l'instant nous nous occupons de la base technique. Ensuite, nous écrivons la position du sommet dans le monde directement dans la structure du fragment, et nous ne la convertirons pas d'abord en UV. Au dernier stade de préparation, nous écrirons une nouvelle fonction qui calcule la scène et renvoie la distance à la surface la plus proche. Ensuite, nous appelons les fonctions et utilisons le résultat comme une couleur.
Shader "Tutorial/034_2D_SDF_Basics"{ SubShader{
J'écrirai toutes les fonctions pour les champs de distance signés dans un fichier séparé afin que nous puissions les utiliser à plusieurs reprises. Pour ce faire, je vais créer un nouveau fichier. Nous n'y ajouterons aucun mal, puis nous le définissons et complétons la protection d'inclusion conditionnelle, vérifiant d'abord si la variable du préprocesseur est définie. S'il n'est pas encore défini, nous le définissons et complétons la construction conditionnelle if après les fonctions que nous voulons inclure. L'avantage de cela est que si nous ajoutons le fichier deux fois (par exemple, si nous ajoutons deux fichiers différents, chacun ayant les fonctions dont nous avons besoin, et qu'ils ajoutent tous les deux le même fichier), cela cassera le shader. Si vous êtes sûr que cela ne se produira jamais, vous ne pouvez pas effectuer cette vérification.
Si le fichier include se trouve dans le même dossier que le shader principal, nous pouvons simplement l'inclure en utilisant la construction pragma.
Nous ne verrons donc qu'une surface noire sur la surface rendue, prête à afficher la distance avec un signe dessus.
Cercle
La fonction la plus simple du champ de distance signé est la fonction cercle. La fonction ne recevra que la position de l'échantillon et le rayon du cercle. Nous commençons par obtenir la longueur du vecteur de position de l'échantillon. Nous obtenons donc un point à la position (0, 0), qui est similaire à un cercle avec un rayon de 0.
float circle(float2 samplePosition, float radius){ return length(samplePosition); }
Ensuite, vous pouvez appeler la fonction cercle dans la fonction scène et renvoyer la distance qu'elle renvoie.
float scene(float2 position) { float sceneDistance = circle(position, 2); return sceneDistance; }
Ensuite, nous ajoutons le rayon aux calculs. Un aspect important des fonctions de distance signée est que lorsque nous sommes à l'intérieur de l'objet, nous obtenons une distance négative à la surface (c'est ce que le mot signé signifie dans le champ d'expression de distance signée). Pour augmenter le cercle à un rayon, nous soustrayons simplement le rayon de la longueur. Ainsi, la surface, qui est partout où la fonction renvoie 0, se déplace vers l'extérieur. Ce qui est à deux unités de la distance de la surface pour un cercle de taille 0, c'est seulement une unité d'un cercle avec un rayon de 1, et une unité à l'intérieur du cercle (la valeur est -1) pour un cercle avec un rayon de 3;
float circle(float2 samplePosition, float radius){ return length(samplePosition) - radius; }
Maintenant, la seule chose que nous ne pouvons pas faire est de déplacer le cercle du centre. Pour résoudre ce problème, vous pouvez ajouter un nouvel argument à la fonction cercle pour calculer la distance entre la position de l'échantillon et le centre du cercle, et soustraire le rayon de cette valeur pour définir un cercle. Ou, vous pouvez redéfinir l'origine en déplaçant l'espace du point d'échantillonnage, puis obtenir un cercle dans cet espace. La deuxième option semble beaucoup plus compliquée, mais comme le déplacement d'objets est une opération que nous voulons utiliser pour toutes les figures, elle est beaucoup plus universelle, et donc je vais l'expliquer.
Déménagement
"Transformation de l'espace d'un point" - sonne bien pire qu'il ne l'est réellement. Cela signifie que nous transmettons le point à la fonction et que la fonction le modifie pour que nous puissions toujours l'utiliser à l'avenir. Dans le cas d'un transfert, nous soustrayons simplement le décalage du point. La position est soustraite lorsque nous voulons déplacer les formes dans le sens positif, car les formes que nous rendons dans l'espace se déplacent dans la direction opposée au déplacement de l'espace.
Par exemple, si nous voulons dessiner une sphère en position
(3, 4)
, nous devons changer l'espace de sorte que
(3, 4)
transforme en
(0, 0)
, et pour cela, nous devons soustraire
(3, 4)
. Maintenant, si nous dessinons une sphère autour d'un
nouveau point d'origine, ce sera un
ancien point
(3, 4)
.
float scene(float2 position) { float2 circlePosition = translate(position, float2(3, 2)); float sceneDistance = circle(circlePosition, 2); return sceneDistance; }
Rectangle
Une autre forme simple est un rectangle. Pour commencer, nous considérons les composants séparément. Nous obtenons d'abord la distance du centre, en prenant la valeur absolue. Ensuite, de la même manière qu'un cercle, nous soustrayons la moitié de la taille (qui ressemble essentiellement au rayon d'un rectangle). Pour montrer à quoi ressembleront les résultats, nous ne retournerons qu'un seul composant pour l'instant.
float rectangle(float2 samplePosition, float2 halfSize){ float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize; return componentWiseEdgeDistance.x; }
Maintenant, nous pouvons obtenir une version bon marché du rectangle en renvoyant simplement le plus grand composant 2. Cela fonctionne dans de nombreux cas, mais pas correctement, car il n'affiche pas la bonne distance autour des coins.
Les valeurs correctes pour le rectangle à l'extérieur de la figure peuvent être obtenues en prenant d'abord le maximum entre les distances aux bords et 0, puis en prenant sa longueur.
Si nous ne limitons pas la distance de dessous à 0, nous calculons simplement la distance aux coins (où les EdgeDistances sont
(0, 0)
), mais les coordonnées entre les coins ne tomberont pas en dessous de 0, donc tout le bord sera utilisé. L'inconvénient de ceci est que 0 est utilisé comme distance du bord pour tout l'intérieur de la figure.
Pour corriger la distance 0 pour toute la partie intérieure, vous devez générer la distance interne, en utilisant simplement la formule du rectangle bon marché (en prenant la valeur maximale des composants x et y), puis en garantissant qu'elle ne dépassera jamais 0, en prenant la valeur minimale de celle-ci à 0. Ensuite, nous ajoutons la distance externe, qui n'est jamais inférieure à 0, et la distance interne, qui ne dépasse jamais 0, et nous obtenons la fonction de distance finie.
float rectangle(float2 samplePosition, float2 halfSize){ float2 componentWiseEdgeDistance = abs(samplePosition) - halfSize; float outsideDistance = length(max(componentWiseEdgeDistance, 0)); float insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0); return outsideDistance + insideDistance; }
Comme nous avions précédemment enregistré la fonction de transfert sous une forme universelle, nous pouvons maintenant également l'utiliser pour déplacer son centre à n'importe quel endroit.
float scene(float2 position) { float2 circlePosition = translate(position, float2(1, 0)); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Tourner
La rotation des formes est similaire au déplacement. Avant de calculer la distance à la figure, nous faisons pivoter les coordonnées dans la direction opposée. Pour simplifier au maximum la compréhension des rotations, nous multiplions la rotation par 2 * pi pour obtenir l'angle en radians. Ainsi, nous passons une rotation à la fonction, où 0,25 est un quart de tour, 0,5 est un demi-tour et 1 est un tour complet (vous pouvez effectuer des transformations différemment si cela vous semble plus naturel). Nous inversons également la rotation, car nous devons faire pivoter la position dans le sens opposé à la rotation de la figure pour la même raison que lors du déplacement.
Pour calculer les coordonnées pivotées, nous calculons d'abord le sinus et le cosinus en fonction de l'angle. Hlsl a une fonction sincos qui calcule ces deux valeurs plus rapidement que lorsqu'elle est calculée séparément.
Lors de la construction d'un nouveau vecteur pour la composante x, nous prenons la composante d'origine x multipliée par le cosinus et la composante y multipliée par le sinus. Cela peut être facilement mémorisé si vous vous souvenez que le cosinus de 0 est 1, et lorsqu'il est tourné de 0, nous voulons que la composante x du nouveau vecteur soit exactement la même qu'avant (c'est-à-dire, multiplie par 1). La composante y, qui pointait auparavant vers le haut, n'a pas contribué à la composante x, tourne vers la droite et ses valeurs commencent à 0, devenant d'abord plus grandes, c'est-à-dire que son mouvement est complètement décrit par un sinus.
Pour la composante y du nouveau vecteur, nous multiplions le cosinus par la composante y de l'ancien vecteur et soustrayons le sinus multiplié par l'ancienne composante x. Pour comprendre pourquoi nous soustrayons, plutôt que d'ajouter le sinus, multiplié par la composante x, il est préférable d'imaginer comment le vecteur
(1, 0)
change lorsqu'il est tourné dans le sens des aiguilles d'une montre. La composante y du résultat commence à 0 puis devient inférieure à 0. C'est l'opposé de la façon dont le sinus se comporte, nous changeons donc de signe.
float2 rotate(float2 samplePosition, float rotation){ const float PI = 3.14159; float angle = rotation * PI * 2 * -1; float sine, cosine; sincos(angle, sine, cosine); return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x); }
Maintenant que nous avons écrit la méthode de rotation, nous pouvons l'utiliser en combinaison avec le transfert pour déplacer et faire pivoter la figure.
float scene(float2 position) { float2 circlePosition = position; circlePosition = rotate(circlePosition, _Time.y); circlePosition = translate(circlePosition, float2(2, 0)); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Dans ce cas, nous faisons d'abord pivoter l'objet autour du centre de la scène entière, de sorte que la rotation affecte également le transfert. Pour faire pivoter une figure par rapport à son propre centre, vous devez d'abord la déplacer, puis la faire pivoter. En raison de cet ordre modifié au moment de la rotation, le centre de la figure deviendra le centre du système de coordonnées.
float scene(float2 position) { float2 circlePosition = position; circlePosition = translate(circlePosition, float2(2, 0)); circlePosition = rotate(circlePosition, _Time.y); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Mise à l'échelle
La mise à l'échelle fonctionne de manière similaire à d'autres façons de transformer des formes. Nous divisons les coordonnées par échelle, ce qui rend la figure dans l'espace avec une échelle réduite, et dans le système de coordonnées de base, elles deviennent plus grandes.
float2 scale(float2 samplePosition, float scale){ return samplePosition / scale; }
float scene(float2 position) { float2 circlePosition = position; circlePosition = translate(circlePosition, float2(0, 0)); circlePosition = rotate(circlePosition, .125); float pulseScale = 1 + 0.5*sin(_Time.y * 3.14); circlePosition = scale(circlePosition, pulseScale); float sceneDistance = rectangle(circlePosition, float2(1, 2)); return sceneDistance; }
Bien que cela effectue correctement la mise à l'échelle, la distance est également mise à l'échelle. Le principal avantage du champ de distance signé est que nous connaissons toujours la distance à la surface la plus proche, mais un zoom arrière détruit complètement cette propriété. Cela peut être facilement corrigé en multipliant le champ de distance obtenu à partir de la fonction de distance de signe (dans notre cas, le
rectangle
) par l'échelle. Pour la même raison, nous ne pouvons pas facilement mettre à l'échelle de manière inégale (avec des échelles différentes pour les axes x et y).
float scene(float2 position) { float2 circlePosition = position; circlePosition = translate(circlePosition, float2(0, 0)); circlePosition = rotate(circlePosition, .125); float pulseScale = 1 + 0.5*sin(_Time.y * 3.14); circlePosition = scale(circlePosition, pulseScale); float sceneDistance = rectangle(circlePosition, float2(1, 2)) * pulseScale; return sceneDistance; }
Visualisation
Les champs de distance signés peuvent être utilisés pour une variété de choses, telles que la création d'ombres, le rendu de scènes 3D, la physique et le rendu de texte. Mais nous ne voulons pas encore approfondir la complexité, donc je vais expliquer seulement deux techniques de leur visualisation. Le premier est une forme claire avec anticrénelage, le second est le rendu des lignes en fonction de la distance.
Forme claire
Cette méthode est similaire à celle qui est souvent utilisée lors du rendu de texte, elle crée un formulaire clair. Si nous voulons générer un champ de distance non pas à partir d'une fonction, mais le lire à partir d'une texture, cela nous permet d'utiliser des textures avec une résolution beaucoup plus faible que d'habitude et d'obtenir de bons résultats. TextMesh Pro utilise cette technique pour rendre le texte.
Pour appliquer cette technique, nous profitons du fait que les données dans les champs de distance sont signées, et nous connaissons le point de coupure. Nous commençons par calculer dans quelle mesure le champ de distance change au pixel suivant. Cela devrait être la même valeur que la longueur du changement de coordonnées, mais il est plus facile et plus fiable de calculer la distance avec un signe.
Après avoir reçu le changement de distance, nous pouvons faire un pas en
douceur de la moitié du changement de distance à moins / plus la moitié du changement de distance. Cela effectuera un écrêtage simple autour de 0, mais avec un lissage. Ensuite, vous pouvez utiliser cette valeur lissée pour toute valeur binaire dont nous avons besoin. Dans cet exemple, je vais changer le shader en un shader de transparence et l'utiliser pour le canal alpha. Je fais un passage progressif d'une valeur positive à une valeur négative parce que nous voulons que la valeur négative du champ de distance soit visible. Si vous ne comprenez pas bien comment fonctionne le rendu de transparence ici, je vous recommande de lire
mon didacticiel sur le rendu de transparence.
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); float distanceChange = fwidth(dist) * 0.5; float antialiasedCutoff = smoothstep(distanceChange, -distanceChange, dist); fixed4 col = fixed4(_Color, antialiasedCutoff); return col; }
Lignes d'élévation
Une autre technique courante pour visualiser les champs de distance consiste à afficher les distances sous forme de lignes. Dans notre implémentation, j'ajouterai quelques lignes épaisses et quelques lignes fines entre elles. Je vais également peindre l'intérieur et l'extérieur de la figure de différentes couleurs afin que vous puissiez voir où se trouve l'objet.
Nous commencerons par afficher la différence entre l'intérieur et l'extérieur de la figure. Les couleurs peuvent être personnalisées dans le matériau, nous ajouterons donc de nouvelles propriétés, ainsi que des variables de shader pour les couleurs internes et externes de la figure.
Properties{ _InsideColor("Inside Color", Color) = (.5, 0, 0, 1) _OutsideColor("Outside Color", Color) = (0, .5, 0, 1) }
Ensuite, dans le fragment shader, nous vérifions où se trouve le pixel, que nous rendons en comparant la distance avec le signe à 0 en utilisant la fonction
step
. Nous utilisons cette variable pour interpoler de la couleur intérieure vers la couleur extérieure et la rendre à l'écran.
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist)); return col; }
Pour rendre les lignes, nous devons d'abord spécifier la fréquence de rendu des lignes et leur épaisseur, en définissant les propriétés et les variables de shader correspondantes.
Ensuite, pour rendre les lignes, nous commencerons par calculer le changement de distance afin de pouvoir l'utiliser plus tard pour le lissage. Nous l'avons également déjà divisé par 2, car plus tard, nous en ajoutons la moitié et en soustrayons la moitié pour couvrir la distance de changement de 1 pixel.
float distanceChange = fwidth(dist) * 0.5;
Ensuite, nous prenons la distance et la transformons pour qu'elle ait le même comportement aux points de répétition. Pour ce faire, nous le divisons d'abord par la distance entre les lignes, alors que nous n'obtiendrons pas des nombres complets à chaque première étape, mais des nombres complets uniquement sur la base de la distance que nous avons définie.
Ensuite, nous ajoutons 0,5 au nombre, prenons la partie fractionnaire et soustrayons à nouveau 0,5. La partie fractionnaire et la soustraction sont nécessaires ici pour que la ligne passe par zéro dans le motif répétitif. Nous ajoutons 0,5 pour obtenir la partie fractionnaire afin de neutraliser une soustraction supplémentaire de 0,5 - le décalage entraînera le fait que les valeurs auxquelles le graphique est 0 sont à 0, 1, 2, etc., et non à 0,5, 1,5, etc.
Les dernières étapes pour convertir la valeur - nous prenons la valeur absolue et la multiplions à nouveau par la distance entre les lignes. La valeur absolue fait que les zones avant et après les points de la ligne restent les mêmes, ce qui facilite la création d'écrêtage pour les lignes. La dernière opération, dans laquelle nous multiplions à nouveau la valeur par la distance entre les lignes, est nécessaire pour neutraliser la division au début de l'équation, grâce à elle, la variation de la valeur est à nouveau la même qu'au début, et la variation précédemment calculée de la distance est toujours correcte.
float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance;
Maintenant que nous avons calculé la distance aux lignes en fonction de la distance à la figure, nous pouvons tracer les lignes. Nous faisons un pas en douceur de l'épaisseur de ligne moins la moitié du changement de distance à l'épaisseur de ligne plus la moitié du changement de distance et utilisons la distance de ligne juste calculée comme valeur de comparaison. Après avoir calculé cette valeur, nous la multiplions par couleur pour créer des lignes noires (vous pouvez également utiliser une couleur différente si vous avez besoin de lignes multicolores).
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist)); float distanceChange = fwidth(dist) * 0.5; float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance; float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance); return col * majorLines; }
Nous implémentons les lignes fines entre les lignes épaisses de la même manière - nous ajoutons une propriété qui détermine le nombre de lignes fines entre les lignes épaisses, puis nous faisons ce que nous avons fait avec les lignes épaisses, mais en raison de la distance entre les lignes fines, nous divisons la distance entre les lignes épaisses par le nombre de lignes fines entre eux. Nous allons également faire le nombre de lignes fines
IntRange
, grâce à cela, nous ne pouvons affecter que des valeurs entières et ne pas obtenir de lignes fines qui ne
IntRange
pas
IntRange
épaisses. Après avoir calculé les lignes fines, nous les multiplions par couleur de la même manière que les lignes épaisses.
fixed4 frag(v2f i) : SV_TARGET{ float dist = scene(i.worldPos.xz); fixed4 col = lerp(_InsideColor, _OutsideColor, step(0, dist)); float distanceChange = fwidth(dist) * 0.5; float majorLineDistance = abs(frac(dist / _LineDistance + 0.5) - 0.5) * _LineDistance; float majorLines = smoothstep(_LineThickness - distanceChange, _LineThickness + distanceChange, majorLineDistance); float distanceBetweenSubLines = _LineDistance / _SubLines; float subLineDistance = abs(frac(dist / distanceBetweenSubLines + 0.5) - 0.5) * distanceBetweenSubLines; float subLines = smoothstep(_SubLineThickness - distanceChange, _SubLineThickness + distanceChange, subLineDistance); return col * majorLines * subLines; }
Code source
Fonctionnalités SDF 2D
#ifndef SDF_2D #define SDF_2D float2 rotate(float2 samplePosition, float rotation){ const float PI = 3.14159; float angle = rotation * PI * 2 * -1; float sine, cosine; sincos(angle, sine, cosine); return float2(cosine * samplePosition.x + sine * samplePosition.y, cosine * samplePosition.y - sine * samplePosition.x); } float2 translate(float2 samplePosition, float2 offset){
Exemple de cercle
Shader "Tutorial/034_2D_SDF_Basics/Rectangle"{ SubShader{
Exemple de rectangle
Shader "Tutorial/034_2D_SDF_Basics/Rectangle"{ SubShader{
Coupure
Shader "Tutorial/034_2D_SDF_Basics/Cutoff"{ Properties{ _Color("Color", Color) = (1,1,1,1) } SubShader{ Tags{ "RenderType"="Transparent" "Queue"="Transparent"} Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Pass{ CGPROGRAM #include "UnityCG.cginc" #include "2D_SDF.cginc" #pragma vertex vert #pragma fragment frag struct appdata{ float4 vertex : POSITION; }; struct v2f{ float4 position : SV_POSITION; float4 worldPos : TEXCOORD0; }; fixed3 _Color; v2f vert(appdata v){ v2f o;
Lignes de distance
Shader "Tutorial/034_2D_SDF_Basics/DistanceLines"{ Properties{ _InsideColor("Inside Color", Color) = (.5, 0, 0, 1) _OutsideColor("Outside Color", Color) = (0, .5, 0, 1) _LineDistance("Mayor Line Distance", Range(0, 2)) = 1 _LineThickness("Mayor Line Thickness", Range(0, 0.1)) = 0.05 [IntRange]_SubLines("Lines between major lines", Range(1, 10)) = 4 _SubLineThickness("Thickness of inbetween lines", Range(0, 0.05)) = 0.01 } SubShader{
J'espère avoir réussi à expliquer les bases des champs de signe de distance, et vous attendez déjà quelques nouveaux tutoriels dans lesquels je parlerai d'autres façons de les utiliser.