Maintenant que nous connaissons les bases de la combinaison de fonctions de distance signée, vous pouvez les utiliser pour créer des choses sympas. Dans ce didacticiel, nous les utiliserons pour créer des ombres douces à deux dimensions. Si vous n'avez pas lu mes précédents tutoriels sur les champs de distance signée (SDF), je vous recommande fortement de les étudier, en commençant par un
tutoriel sur la création de formes simples .
[GIF a généré des artefacts supplémentaires lors de la recompression.]
Configuration de base
J'ai créé une configuration simple avec une salle, elle utilise les techniques décrites dans les tutoriels précédents. Plus tôt, je n'ai pas mentionné que j'avais utilisé la fonction
abs
pour le vecteur2 pour refléter la position par rapport aux axes x et y, ainsi que que j'inversais la distance de la figure afin d'échanger les parties intérieure et extérieure.
Nous allons copier le fichier
2D_SDF.cginc du tutoriel précédent dans un dossier avec le shader, que nous écrirons dans ce tutoriel.
Shader "Tutorial/037_2D_SDF_Shadows"{ Properties{ } SubShader{
Si nous utilisions toujours la technique de visualisation du didacticiel précédent, la figure ressemblerait à ceci:
Ombres simples
Pour créer des ombres nettes, nous faisons le tour de l'espace de la position de l'échantillon à la position de la source lumineuse. Si nous trouvons un objet sur le chemin, nous décidons que le pixel doit être ombré, et si nous arrivons à la source sans entrave, nous disons qu'il n'est pas ombragé.
Nous commençons par calculer les paramètres de base du faisceau. Nous avons déjà un point de départ (la position du pixel que nous rendons) et un point cible (la position de la source lumineuse) pour le faisceau. Nous avons besoin d'une longueur et d'une direction normalisée. La direction peut être obtenue en soustrayant le début de la fin et en normalisant le résultat. La longueur peut être obtenue en soustrayant les positions et en passant la valeur à la méthode de
length
.
float traceShadow(float2 position, float2 lightPosition){ float direction = normalise(lightPosition - position); float distance = length(lightPosition - position); }
Ensuite, nous faisons un tour itératif du rayon dans la boucle. Nous allons définir des itérations de la boucle dans la déclaration define, ce qui nous permettra de configurer le nombre maximum d'itérations plus tard, et permettra également au compilateur d'optimiser un peu le shader en développant la boucle.
Dans la boucle, nous avons besoin de la position dans laquelle nous nous trouvons maintenant, nous la déclarons donc en dehors de la boucle avec la valeur initiale 0. Dans la boucle, nous pouvons calculer la position de l'échantillon en ajoutant l'avance du faisceau multipliée par la direction du faisceau avec la position de base. Ensuite, nous échantillonnons la fonction de distance signée dans la position qui vient d'être calculée.
Ensuite, nous vérifions si nous sommes déjà au point où nous pouvons arrêter le cycle. Si la distance de la scène de la fonction de distance avec le signe est proche de 1, alors nous pouvons supposer que le faisceau est bloqué par un chiffre et retourner 0. Si le faisceau se propage plus loin que la distance à la source de lumière, nous pouvons supposer que nous avons atteint la source sans collision et revenir 1.
Si le retour échoue, vous devez calculer la position suivante de l'échantillon. Cela se fait en ajoutant de la distance dans la scène d'avancement du faisceau. La raison en est que la distance dans la scène nous donne la distance au chiffre le plus proche, donc si nous ajoutons cette valeur au faisceau, nous ne serons probablement pas en mesure d'émettre le faisceau plus loin que le chiffre le plus proche, ni même au-delà, ce qui conduira à l'écoulement des ombres.
Dans le cas où nous n'avons rien rencontré et n'avons pas atteint la source de lumière au moment où le stock de l'échantillon a été terminé (le cycle s'est terminé), nous devons également retourner la valeur. Comme cela se produit principalement à côté des formes, peu de temps avant que le pixel ne soit toujours considéré comme ombré, nous utilisons ici la valeur de retour de 0.
#define SAMPLES 32 float traceShadows(float2 position, float2 lightPosition){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return 1; } rayProgress = rayProgress + sceneDist; } return 0; }
Pour utiliser cette fonction, nous l'appelons dans une fonction de fragment avec une position de pixel et une position de source de lumière. Ensuite, nous multiplions le résultat par n'importe quelle couleur pour le mélanger avec la couleur des sources lumineuses.
J'ai également utilisé la technique décrite dans le
premier tutoriel sur les champs de distance avec un signe pour visualiser la géométrie. Ensuite, je viens d'ajouter plié et la géométrie. Ici, nous pouvons simplement utiliser l'opération d'addition et ne pas effectuer d'interpolation linéaire ou d'actions similaires, car la forme est noire partout où la forme ne l'est pas, et l'ombre est noire où qu'elle se trouve.
fixed4 frag(v2f i) : SV_TARGET{ float2 position = i.worldPos.xz;
float2 lightPos; sincos(_Time.y, lightPos.x , lightPos.y ); float shadows = traceShadows(position, lightPos); float3 light = shadows * float3(.6, .6, 1); float sceneDistance = scene(position); float distanceChange = fwidth(sceneDistance) * 0.5; float binaryScene = smoothstep(distanceChange, -distanceChange, sceneDistance); float3 geometry = binaryScene * float3(0, 0.3, 0.1); float3 col = geometry + light; return float4(col, 1); }
Ombres douces
Passer de ces ombres dures à plus douces et plus réalistes est assez facile. Dans le même temps, le shader ne devient pas beaucoup plus coûteux en calcul.
Tout d'abord, nous obtenons simplement la distance de l'objet de scène le plus proche pour chaque échantillon que nous contournons, et sélectionnons le plus proche. Ensuite, là où nous avions l'habitude de renvoyer 1, il sera possible de renvoyer la distance au chiffre le plus proche. Pour que la luminosité de l'ombre ne soit pas trop élevée et ne conduise pas à la création de couleurs étranges, nous allons la passer par la méthode
saturate
, qui la limite à un intervalle de 0 à 1. On obtient un minimum entre la figure la plus proche actuelle et la suivante après avoir vérifié si le faisceau de la source lumineuse a déjà atteint la distribution sinon, nous pouvons prélever des échantillons qui dépassent la source de lumière et obtenir d'étranges artefacts.
float traceShadows(float2 position, float2 lightPosition){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0; float nearest = 9999; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return saturate(nearest); } nearest = min(nearest, sceneDist); rayProgress = rayProgress + sceneDist; } return 0; }

La première chose que nous remarquons après cela est les étranges "dents" dans l'ombre. Ils surviennent parce que la distance entre la scène et la source de lumière est inférieure à 1. J'ai essayé de contrer cela de diverses manières, mais je n'ai pas trouvé de solution. Au lieu de cela, nous pouvons implémenter la netteté de l'ombre. La netteté sera un autre paramètre de la fonction d'ombre. Dans la boucle, nous multiplions la distance dans la scène par la netteté, puis avec une netteté de 2, la partie grise et douce de l'ombre deviendra moitié moins. Lorsque vous utilisez la netteté, la source de lumière peut provenir de la figure à une distance d'au moins 1 divisée par la netteté, sinon des artefacts apparaissent. Par conséquent, si vous utilisez une netteté de 20, la distance doit être d'au moins 0,05 unité.
float traceShadows(float2 position, float2 lightPosition, float hardness){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0; float nearest = 9999; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return saturate(nearest); } nearest = min(nearest, hardness * sceneDist); rayProgress = rayProgress + sceneDist; } return 0; }
En minimisant ce problème, nous remarquons ce qui suit: même dans les zones qui ne doivent pas être ombragées, un affaiblissement est toujours visible près des murs. De plus, la douceur de l'ombre semble être la même pour toute l'ombre, et pas nette à côté de la figure et plus douce en s'éloignant de l'objet émettant l'ombre.
Nous allons résoudre ce problème en divisant la distance dans la scène par la propagation du faisceau. Grâce à cela, nous diviserons la distance en très petits nombres au début du faisceau, c'est-à-dire que nous aurons toujours des valeurs élevées et une belle ombre claire. Lorsque nous trouvons le point le plus proche du rayon aux points suivants du rayon, le point le plus proche est divisé par un plus grand nombre, ce qui rend l'ombre plus douce. Comme cela n'est pas entièrement lié à la distance la plus courte, nous renommerons la variable en
shadow
.
Nous ferons également un autre changement mineur: puisque nous divisons par rayProgress, vous ne devriez pas commencer par 0 (diviser par zéro est presque toujours une mauvaise idée de diviser). Vous pouvez choisir n'importe quel très petit nombre pour commencer.
float traceShadows(float2 position, float2 lightPosition, float hardness){ float2 direction = normalize(lightPosition - position); float lightDistance = length(lightPosition - position); float rayProgress = 0.0001; float shadow = 9999; for(int i=0 ;i<SAMPLES; i++){ float sceneDist = scene(position + direction * rayProgress); if(sceneDist <= 0){ return 0; } if(rayProgress > lightDistance){ return saturate(shadow); } shadow = min(shadow, hardness * sceneDist / rayProgress); rayProgress = rayProgress + sceneDist; } return 0; }
Sources d'éclairage multiples
Dans cette implémentation simple à cœur unique, la façon la plus simple d'obtenir plusieurs sources de lumière est de les calculer individuellement et d'ajouter les résultats.
fixed4 frag(v2f i) : SV_TARGET{ float2 position = i.worldPos.xz; float2 lightPos1 = float2(sin(_Time.y), -1); float shadows1 = traceShadows(position, lightPos1, 20); float3 light1 = shadows1 * float3(.6, .6, 1); float2 lightPos2 = float2(-sin(_Time.y) * 1.75, 1.75); float shadows2 = traceShadows(position, lightPos2, 10); float3 light2 = shadows2 * float3(1, .6, .6); float sceneDistance = scene(position); float distanceChange = fwidth(sceneDistance) * 0.5; float binaryScene = smoothstep(distanceChange, -distanceChange, sceneDistance); float3 geometry = binaryScene * float3(0, 0.3, 0.1); float3 col = geometry + light1 + light2; return float4(col, 1); }
Code source
Bibliothèque SDF bidimensionnelle (n'a pas changé, mais est utilisée ici)
Ombres douces bidimensionnelles
Shader "Tutorial/037_2D_SDF_Shadows"{ Properties{ } SubShader{
Ceci n'est qu'un exemple parmi d'autres de l'utilisation de champs de distance signés. Jusqu'à présent, elles sont plutôt encombrantes, car toutes les formes doivent être enregistrées dans le shader ou passées dans les propriétés du shader, mais j'ai quelques idées sur la façon de les rendre plus pratiques pour les futurs tutoriels.