Écriture de shaders dans Unity. GrabPass, PerRendererData

Salut Je voudrais partager mon expérience d'écriture de shaders dans Unity. Commençons par le shader Déplacement / Réfraction en 2D, considérons les fonctionnalités utilisées pour l'écrire (GrabPass, PerRendererData), et prêtons également attention aux problèmes qui se poseront nécessairement.

Les informations sont utiles à ceux qui ont une idée générale des shaders et ont essayé de les créer, mais qui ne connaissent pas les capacités offertes par Unity et ne savent pas de quel côté s'approcher. Jetez un oeil, peut-être que mon expérience vous aidera à le découvrir.



C'est le résultat que nous voulons atteindre.

image

La préparation


Tout d'abord, créez un shader qui dessinera simplement le sprite spécifié. Il sera notre base pour de nouvelles manipulations. Quelque chose y sera ajouté, quelque chose sera supprimé au contraire. Il se distinguera du standard «Sprites-Default» par l'absence de quelques balises et actions qui n'affecteront pas le résultat.

Code de shader pour le rendu du sprite
Shader "Displacement/Displacement_Wave" { Properties { [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} _Color ("Color" , Color) = (1,1,1,1) } SubShader { Tags { "RenderType" = "Transparent" "Queue" = "Transparent" } Cull Off Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; fixed4 _Color; sampler2D _MainTex; v2f vert (appdata v) { v2f o; o.uv = v.uv; o.color = v.color; o.vertex = UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 texColor = tex2D(_MainTex, i.uv)*i.color; return texColor; } ENDCG } } } 

Sprite à afficher
Le fond est en fait transparent, intentionnellement assombri.

image

La pièce résultante.

image

Grabpass


Maintenant, notre tâche consiste à apporter des modifications à l'image actuelle à l'écran, et pour cela, nous devons obtenir une image. Et le passage GrabPass nous y aidera. Ce passage capturera l'image de l'écran dans la texture _GrabTexture . La texture ne contiendra que ce qui a été dessiné avant que notre objet utilisant ce shader ne soit rendu.

En plus de la texture elle-même, nous avons besoin des coordonnées du scan pour en obtenir la couleur des pixels. Pour ce faire, ajoutez des coordonnées de texture supplémentaires aux données du fragment shader. Ces coordonnées ne sont pas normalisées (les valeurs ne sont pas comprises entre 0 et 1) et décrivent la position d'un point dans l'espace de la caméra (projection).

 struct v2f { float4 vertex : SV_POSITION; float2 uv : float4 color : COLOR; float4 grabPos : TEXCOORD1; }; 

Et dans le vertex shader, remplissez-les.

 o.grabPos = ComputeGrabScreenPos (o.vertex); 

Afin d'obtenir la couleur de _GrabTexture , nous pouvons utiliser la méthode suivante si nous utilisons des coordonnées non normalisées

 tex2Dproj(_GrabTexture, i.grabPos) 

Mais nous utiliserons une méthode différente et normaliserons nous-mêmes les coordonnées, en utilisant la division en perspective, c'est-à-dire divisant tous les autres dans la composante w.

 tex2D(_GrabTexture, i.grabPos.xy/i.grabPos.w) 

composant w
La division en une composante w n'est nécessaire que lors de l'utilisation de la perspective, en projection orthographique, elle sera toujours 1. En fait, w stocke la valeur de la distance, pointez vers la caméra. Mais ce n'est pas la profondeur - z , dont la valeur doit être comprise entre 0 et 1. Travailler avec la profondeur est digne d'un sujet séparé, nous allons donc revenir à notre shader.

La division en perspective peut également être effectuée dans le vertex shader, et les données déjà préparées peuvent être transférées vers le fragment shader.

 v2f vert (appdata v) { v2f o; o.uv = v.uv; o.color = v.color; o.vertex = UnityObjectToClipPos(v.vertex); o.grabPos = ComputeScreenPos (o.vertex); o.grabPos /= o.grabPos.w; return o; } 

Ajoutez un fragment shader, respectivement.

 fixed4 frag (v2f i) : SV_Target { fixed4 = grabColor = tex2d(_GrabTexture, i.grabPos.xy); fixed4 texColor = tex2D(_MainTex, i.uv)*i.color; return grabColor; } 

Désactivez le mode de mixage spécifié, car maintenant, nous implémentons notre mode de fusion dans le fragment shader.

 //Blend SrcAlpha OneMinusSrcAlpha Blend Off 

Et regardez le résultat de GrabPass .

image

Rien ne semble s'être produit, mais ce n'est pas le cas. Pour plus de clarté, nous introduisons un léger décalage, pour cela nous allons ajouter la valeur de la variable aux coordonnées de texture. Pour que nous puissions modifier la variable, ajoutez une nouvelle propriété _DisplacementPower .

 Properties { [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} _Color ("Color" , Color) = (1,1,1,1) _DisplacementPower ("Displacement Power" , Float) = 0 } SubShader { Pass { ... float _DisplacementPower; ... } } 

Et encore une fois, apportez des modifications au fragment shader.

 fixed4 grabColor = tex2d(_GrabTexture, i.grabPos.xy + _DisplaccementPower); 

Op hop et résultat! Image avec décalage.



Après un décalage réussi, vous pouvez procéder à une distorsion plus complexe. Nous utilisons des textures pré-préparées qui stockeront la force de déplacement au point spécifié. Couleur rouge pour la valeur de décalage sur l'axe x et verte sur l'axe y.

Textures utilisées pour la distorsion



Commençons. Ajoutez une nouvelle propriété pour stocker la texture.

 _DisplacementTex ("Displacement Texture", 2D) = "white" {} 

Et une variable.

 sampler2D _DisplacementTex; 

Dans le fragment shader, nous obtenons les valeurs de décalage de la texture et les ajoutons aux coordonnées de la texture.

 fixed4 displPos = tex2D(_DisplacementTex, i.uv); float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a; fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset); 

Maintenant, en changeant les valeurs du paramètre _DisplacementPower , nous ne déplaçons pas seulement l'image d'origine, mais la déformons.



Superposition


Maintenant, sur l'écran, il n'y a qu'une distorsion de l'espace, et le sprite, que nous avons montré au tout début, est absent. Nous allons le remettre à sa place. Pour ce faire, nous utiliserons un mélange difficile de couleurs. Prenez autre chose, comme le mode de fusion de superposition. Sa formule est la suivante:



où S est l'image d'origine, C est correctif, c'est-à-dire notre sprite, R est le résultat.

Transférez cette formule dans notre shader.

 fixed4 color = grabColor < 0.5 ? 2*grabColor*texColor : 1-2*(1-texColor)*(1-grabColor); 

L'utilisation d'opérateurs conditionnels dans un shader est un sujet assez déroutant. Cela dépend beaucoup de la plate-forme et de l'API graphique utilisée. Dans certains cas, les instructions conditionnelles n'affecteront pas les performances. Mais cela vaut toujours la peine de se replier. L'opérateur conditionnel peut être remplacé à l'aide des mathématiques et des méthodes disponibles. Nous utilisons la construction suivante

 c = step ( y, x); r = c * a + (1 - c) * b; 

Fonction pas à pas
La fonction step renverra 1 si x est supérieur ou égal à y . Et 0 si x est inférieur à y .

Par exemple, si x = 1 et y = 0,5, alors le résultat de c sera 1. Et l'expression suivante ressemblera à
r = 1 * a + 0 * b
Parce que multiplier par 0 donne 0, alors le résultat sera juste la valeur de a .
Sinon, si c est 0,
r = 0 * a + 1 * b
Et le résultat final sera b .

Réécrivez la couleur pour le mode superposition .

 fixed s = step(grabColor, 0.5); fixed4 color = s * (2 * grabColor * texColor) + (1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor)); 

Assurez-vous de tenir compte de la transparence du sprite. Pour ce faire, nous utiliserons une interpolation linéaire entre les deux couleurs.

 color = lerp(grabColor, color ,texColor.a); 

Code de shader de fragment complet.

 fixed4 frag (v2f i) : SV_Target { fixed4 displPos = tex2D(_DisplacementTex, i.uv); float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a; fixed4 texColor = tex2D(_MainTex, i.uv + offset)*i.color; fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset); fixed s = step(grabColor, 0.5); fixed4 color = s * (2 * grabColor * texColor) + (1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor)); color = lerp(grabColor, color ,texColor.a); return color; } 

Et le résultat de notre travail.



Fonction GrabPass


Il a été mentionné ci-dessus que le pass GrabPass {} capture le contenu de l'écran dans une texture _GrabTexture . En même temps, à chaque appel de ce passage, le contenu de la texture sera mis à jour.
Une mise à jour constante peut être évitée en spécifiant le nom de la texture dans laquelle le contenu de l'écran sera capturé.
 GrabPass{"_DisplacementGrabTexture"} 

Désormais, le contenu de la texture ne sera mis à jour qu'au premier appel du pass GrabPass par image. Cela économise des ressources s'il y a beaucoup d' objets utilisant GrabPass {} . Mais si deux objets se chevauchent, les artefacts seront visibles, car les deux objets utiliseront la même image.

Utilisation de GrabPass {"_ DisplacementGrabTexture"}.



Utilisation de GrabPass {}.



L'animation


Il est maintenant temps d'animer notre effet. Nous voulons réduire en douceur la force de distorsion à mesure que l'onde de souffle se développe, simulant son extinction. Pour ce faire, nous devons modifier les propriétés du matériau.

Script d'animation
 public class Wave : MonoBehaviour { private float _elapsedTime; private SpriteRenderer _renderer; public float Duration; [Space] public AnimationCurve ScaleProgress; public Vector3 ScalePower; [Space] public AnimationCurve PropertyProgress; public float PropertyPower; [Space] public AnimationCurve AlphaProgress; private void Start() { _renderer = GetComponent<SpriteRenderer>(); } private void OnEnable() { _elapsedTime = 0f; } void Update() { if (_elapsedTime < Duration) { var progress = _elapsedTime / Duration; var scale = ScaleProgress.Evaluate(progress) * ScalePower; var property = PropertyProgress.Evaluate(progress) * PropertyPower; var alpha = AlphaProgress.Evaluate(progress); transform.localScale = scale; _renderer.material.SetFloat("_DisplacementPower", property); var color = _renderer.color; color.a = alpha; _renderer.color = color; _elapsedTime += Time.deltaTime; } else { _elapsedTime = 0; } } } 

Et ses paramètres


Le résultat de l'animation.



Perrendererdata


Faites attention à la ligne ci-dessous.

 _renderer.material.SetFloat("_DisplacementPower", property); 

Ici, nous ne changeons pas seulement l'une des propriétés du matériau, mais créons une copie du matériau source (uniquement au premier appel de cette méthode) et travaillons déjà avec. C'est tout à fait une option de travail, mais s'il y a plus d'un objet sur la scène, par exemple un millier, alors créer autant de copies ne mènera à rien de bon. Il y a une meilleure option - c'est d'utiliser l'attribut [PerRendererData] dans le shader et l'objet MaterialPropertyBlock dans le script.

Pour ce faire, ajoutez un attribut à la propriété _DisplacementPower dans le shader.

 [PerRendererData] _DisplacementPower ("Displacement Power" , Range(-.1,.1)) = 0 

Après cela, la propriété ne sera plus affichée dans l'inspecteur, car Maintenant, il est individuel pour chaque objet, qui définira les valeurs.



Nous revenons au script et y apportons des modifications.

 private MaterialPropertyBlock _propertyBlock; private void Start() { _renderer = GetComponent<SpriteRenderer>(); _propertyBlock = new MaterialPropertyBlock(); } void Update() { ... //_renderer.material.SetFloat("_DisplacementPower", property); _renderer.GetPropertyBlock(_propertyBlock); _propertyBlock.SetFloat("_DisplacementPower", property); _renderer.SetPropertyBlock(_propertyBlock); ... } 

Maintenant, pour modifier la propriété, nous mettrons à jour le MaterialPropertyBlock de notre objet sans créer de copies du matériau.

À propos de SpriteRenderer
Regardons cette ligne dans le shader.

 [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} 

SpriteRenderer fonctionne de manière similaire avec les sprites. Il définit la propriété _MainTex sur sa valeur à l'aide de MaterialPropertyBlock . Par conséquent, dans l'inspecteur, la propriété _MainTex n'est pas affichée pour le matériau et dans le composant SpriteRenderer , nous spécifions la texture dont nous avons besoin. Dans le même temps, il peut y avoir de nombreux sprites différents sur la scène, mais un seul matériau sera utilisé pour leur rendu (si vous ne le modifiez pas vous-même).

Fonction PerRendererData


Vous pouvez obtenir MaterialPropertyBlock à partir de presque tous les composants liés au rendu. Par exemple, SpriteRenderer , ParticleRenderer , MeshRenderer et d'autres composants Renderer . Mais il y a toujours une exception, c'est un CanvasRenderer . Il est impossible d'obtenir et de modifier des propriétés à l'aide de cette méthode. Par conséquent, si vous écrivez un jeu 2D à l'aide de composants d'interface utilisateur, vous rencontrerez ce problème lors de l'écriture de shaders.

Rotation


Un effet désagréable se produit lorsque l'image est pivotée. Sur l'exemple d'une onde ronde, cela est particulièrement visible.

L'onde droite en tournant (90 degrés) donne une autre distorsion.



Le rouge indique les vecteurs obtenus à partir du même point de la texture, mais avec une rotation différente de cette texture. La valeur de décalage reste la même et ne tient pas compte de la rotation.

Pour résoudre ce problème, nous utiliserons la matrice de transformation unit_ObjectToWorld . Cela aidera à recompter notre vecteur des coordonnées locales aux coordonnées mondiales.

 float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a; offset = mul( unity_ObjectToWorld, offset); 

Mais la matrice contient également des données sur l'échelle de l'objet, donc lors de la spécification de la force de la distorsion, nous devons prendre en compte l'échelle de l'objet lui-même.

 _propertyBlock.SetFloat("_DisplacementPower", property/transform.localScale.x); 

L'onde droite est également tournée de 90 degrés, mais la distorsion est maintenant calculée correctement.



Clip


Notre texture a suffisamment de pixels transparents (surtout si nous utilisons le type de maillage Rect ). Le shader les traite, ce qui n'a pas de sens dans ce cas. Par conséquent, nous essaierons de réduire le nombre de calculs inutiles. Nous pouvons interrompre le traitement des pixels transparents en utilisant la méthode clip (x) . Si le paramètre qui lui est transmis est inférieur à zéro, le shader se termine. Mais comme la valeur alpha ne peut pas être inférieure à 0, nous en soustraireons une petite valeur. Il peut également être placé dans les propriétés ( Découpe ) et utilisé pour couper les parties transparentes de l'image. Dans ce cas, nous n'avons pas besoin d'un paramètre séparé, nous allons donc simplement utiliser le nombre 0,01 .

Code de shader de fragment complet.

 fixed4 frag (v2f i) : SV_Target { fixed4 displPos = tex2D(_DisplacementTex, i.uv); float2 offset = (displPos.xy * 2 - 1) * _DisplacementPower * displPos.a; offset = mul( unity_ObjectToWorld,offset); fixed4 texColor = tex2D(_MainTex, i.uv + offset)*i.color; clip(texColor.a - 0.01); fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset); fixed s = step(grabColor, 0.5); fixed4 color = s * 2 * grabColor * texColor + (1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor)); color = lerp(grabColor, color ,texColor.a); return color; } 

PS: Le code source du shader et du script est un lien vers git . Le projet dispose également d'un petit générateur de texture pour la distorsion. Le cristal avec le piédestal a été tiré de l'actif - Kit de jeu 2D.

Source: https://habr.com/ru/post/fr427357/


All Articles