Hallo! Ich möchte meine Erfahrungen mit dem Schreiben von Shadern in Unity teilen. Beginnen wir mit dem Displacement / Refraction-Shader in 2D, betrachten wir die zum Schreiben verwendete Funktionalität (GrabPass, PerRendererData) und achten wir auch auf die Probleme, die notwendigerweise auftreten werden.
Die Informationen sind nützlich für diejenigen, die eine allgemeine Vorstellung von Shadern haben und versucht haben, diese zu erstellen, aber nicht mit den Funktionen von Unity vertraut sind und nicht wissen, an welche Seite sie sich wenden sollen. Schauen Sie mal, vielleicht hilft Ihnen meine Erfahrung dabei, es herauszufinden.

Dies ist das Ergebnis, das wir erreichen wollen.

Vorbereitung
Erstellen Sie zunächst einen Shader, der einfach das angegebene Sprite zeichnet. Er wird unsere Basis für weitere Manipulationen sein. Es wird etwas hinzugefügt, etwas im Gegenteil wird gelöscht. Es unterscheidet sich vom Standard „Sprites-Default“ durch das Fehlen einiger Tags und Aktionen, die das Ergebnis nicht beeinflussen.
Shader-Code zum Rendern von SpriteShader "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 zum AnzeigenDer Hintergrund ist tatsächlich transparent und absichtlich abgedunkelt.

Das resultierende Werkstück.

Grabpass
Jetzt ist es unsere Aufgabe, Änderungen am aktuellen Bild auf dem Bildschirm vorzunehmen, und dafür müssen wir ein Bild erhalten. Und die
GrabPass- Passage wird uns dabei helfen. Diese Passage erfasst das Bildschirmbild in der
_GrabTexture- Textur. Die Textur enthält nur das, was gezeichnet wurde, bevor unser Objekt mit diesem Shader gerendert wurde.
Zusätzlich zur Textur selbst benötigen wir die Koordinaten des Scans, um die Pixelfarbe daraus zu erhalten. Fügen Sie dazu den Fragment-Shader-Daten zusätzliche Texturkoordinaten hinzu. Diese Koordinaten werden nicht normalisiert (Werte liegen nicht im Bereich von 0 bis 1) und beschreiben die Position eines Punktes im Raum der Kamera (Projektion).
struct v2f { float4 vertex : SV_POSITION; float2 uv : float4 color : COLOR; float4 grabPos : TEXCOORD1; };
Und im Vertex-Shader füllen Sie sie.
o.grabPos = ComputeGrabScreenPos (o.vertex);
Um die Farbe von
_GrabTexture zu erhalten , können wir die folgende Methode verwenden, wenn wir nicht normalisierte Koordinaten verwenden
tex2Dproj(_GrabTexture, i.grabPos)
Wir werden jedoch eine andere Methode verwenden und die Koordinaten selbst normalisieren, indem wir die Perspektiventeilung verwenden, d. H. Aufteilung aller anderen in die w-Komponente.
tex2D(_GrabTexture, i.grabPos.xy/i.grabPos.w)
w KomponenteDie Unterteilung in eine w-Komponente ist nur bei Verwendung der Perspektive erforderlich. Bei der orthografischen Projektion ist dies immer 1. Tatsächlich speichert w den Wert der Entfernung und zeigt auf die Kamera. Es ist jedoch nicht die Tiefe - z , deren Wert im Bereich von 0 bis 1 liegen sollte. Die Arbeit mit der Tiefe verdient ein separates Thema, daher kehren wir zu unserem Shader zurück.
Die Perspektiventeilung kann auch im Vertex-Shader durchgeführt werden, und bereits vorbereitete Daten können an den Fragment-Shader übertragen werden.
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; }
Fügen Sie jeweils einen Fragment-Shader hinzu.
fixed4 frag (v2f i) : SV_Target { fixed4 = grabColor = tex2d(_GrabTexture, i.grabPos.xy); fixed4 texColor = tex2D(_MainTex, i.uv)*i.color; return grabColor; }
Schalten Sie den angegebenen Mischmodus aus, weil Jetzt implementieren wir unseren Mischmodus im Fragment-Shader.
Und schauen Sie sich das Ergebnis von
GrabPass an .

Nichts scheint passiert zu sein, ist es aber nicht. Aus Gründen der Übersichtlichkeit führen wir eine leichte Verschiebung ein. Dazu addieren wir den Wert der Variablen zu den Texturkoordinaten.
Fügen Sie eine neue
_DisplacementPower- Eigenschaft hinzu, damit wir die Variable ändern können.
Properties { [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} _Color ("Color" , Color) = (1,1,1,1) _DisplacementPower ("Displacement Power" , Float) = 0 } SubShader { Pass { ... float _DisplacementPower; ... } }
Nehmen Sie erneut Änderungen am Fragment-Shader vor.
fixed4 grabColor = tex2d(_GrabTexture, i.grabPos.xy + _DisplaccementPower)
Op Hop und Ergebnis! Bild mit einer Verschiebung.

Nach einer erfolgreichen Schicht können Sie zu einer komplexeren Verzerrung übergehen. Wir verwenden vorbereitete Texturen, die die Verschiebungskraft am angegebenen Punkt speichern. Rote Farbe für den Versatzwert auf der x-Achse und grün auf der y-Achse.
Fangen wir an. Fügen Sie eine neue Eigenschaft hinzu, um die Textur zu speichern.
_DisplacementTex ("Displacement Texture", 2D) = "white" {}
Und eine Variable.
sampler2D _DisplacementTex;
Im Fragment-Shader erhalten wir die Versatzwerte aus der Textur und fügen sie den Texturkoordinaten hinzu.
fixed4 displPos = tex2D(_DisplacementTex, i.uv)
Wenn Sie nun die Werte des Parameters
_DisplacementPower ändern , verschieben wir das Originalbild nicht nur, sondern verzerren es.

Überlagerung
Jetzt gibt es auf dem Bildschirm nur noch eine Verzerrung des Raums, und das Sprite, das wir ganz am Anfang gezeigt haben, fehlt. Wir werden es an seinen Platz zurückbringen. Dazu verwenden wir eine schwierige Farbmischung. Nehmen Sie etwas anderes, wie den Overlay-Mischmodus. Die Formel lautet wie folgt:

wobei S das Originalbild ist, C korrigierend ist, dh unser Sprite, R ist das Ergebnis.
Übertragen Sie diese Formel auf unseren Shader.
fixed4 color = grabColor < 0.5 ? 2*grabColor*texColor : 1-2*(1-texColor)*(1-grabColor);
Die Verwendung von bedingten Operatoren in einem Shader ist ein ziemlich verwirrendes Thema. Viel hängt von der Plattform und der verwendeten Grafik-API ab. In einigen Fällen wirken sich bedingte Anweisungen nicht auf die Leistung aus. Aber es lohnt sich immer, einen Fallback zu haben. Der bedingte Operator kann mithilfe von Mathematik und verfügbaren Methoden ersetzt werden. Wir verwenden die folgende Konstruktion
c = step ( y, x); r = c * a + (1 - c) * b;
SchrittfunktionDie Schrittfunktion gibt 1 zurück, wenn x größer oder gleich y ist . Und 0, wenn x kleiner als y ist .
Wenn zum Beispiel x = 1 und y = 0,5 ist, ist das Ergebnis von c 1. Und der folgende Ausdruck sieht so aus
r = 1 * a + 0 * b
Weil Multiplizieren mit 0 ergibt 0, dann ist das Ergebnis nur der Wert von a .
Andernfalls, wenn c 0 ist,
r = 0 * a + 1 * b
Und das Endergebnis wird b sein .
Schreiben Sie die Farbe für den
Overlay- Modus neu.
fixed s = step(grabColor, 0.5); fixed4 color = s * (2 * grabColor * texColor) + (1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor));
Berücksichtigen Sie unbedingt die Transparenz des Sprites. Dazu verwenden wir eine lineare Interpolation zwischen den beiden Farben.
color = lerp(grabColor, color ,texColor.a);
Vollfragment-Shader-Code.
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; }
Und das Ergebnis unserer Arbeit.

GrabPass-Funktion
Es wurde oben erwähnt, dass der
GrabPass- Pass
{} den Inhalt des Bildschirms in einer
_GrabTexture- Textur
erfasst . Gleichzeitig wird bei jedem Aufruf dieser Passage der Inhalt der Textur aktualisiert.
Eine ständige Aktualisierung kann vermieden werden, indem der Name der Textur angegeben wird, in der der Inhalt des Bildschirms erfasst wird.
GrabPass{"_DisplacementGrabTexture"}
Jetzt wird der Inhalt der Textur nur beim ersten Aufruf des GrabPass-Durchlaufs pro Frame aktualisiert. Dies spart Ressourcen, wenn
viele Objekte
GrabPass {} verwenden . Wenn sich jedoch zwei Objekte überlappen, fallen Artefakte auf, da beide Objekte dasselbe Bild verwenden.
Verwenden von GrabPass {"_ DisplacementGrabTexture"}.

Verwenden von GrabPass {}.

Animation
Jetzt ist es Zeit, unseren Effekt zu animieren. Wir wollen die Verzerrungskraft sanft reduzieren, wenn die Druckwelle wächst, und ihre Auslöschung simulieren. Dazu müssen wir die Eigenschaften des Materials ändern.
Skript für die 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; } } }
Das Ergebnis der Animation.

Perrendererdata
Achten Sie auf die folgende Zeile.
_renderer.material.SetFloat("_DisplacementPower", property);
Hier ändern wir nicht nur eine der Eigenschaften des Materials, sondern erstellen eine Kopie des Quellmaterials (nur beim ersten Aufruf dieser Methode) und arbeiten damit. Es ist eine ziemlich funktionierende Option, aber wenn sich mehr als ein Objekt auf der Bühne befindet, zum Beispiel tausend, führt das Erstellen so vieler Kopien nicht zu etwas Gutem. Es gibt eine bessere Option: Verwenden Sie dazu das Attribut
[PerRendererData] im Shader und das
MaterialPropertyBlock- Objekt im Skript.
Fügen Sie dazu der Eigenschaft
_DisplacementPower im Shader ein Attribut
hinzu .
[PerRendererData] _DisplacementPower ("Displacement Power" , Range(-.1,.1)) = 0
Danach wird die Eigenschaft im Inspektor nicht mehr angezeigt, weil Jetzt ist es für jedes Objekt individuell, wodurch die Werte festgelegt werden.

Wir kehren zum Skript zurück und nehmen Änderungen daran vor.
private MaterialPropertyBlock _propertyBlock; private void Start() { _renderer = GetComponent<SpriteRenderer>(); _propertyBlock = new MaterialPropertyBlock(); } void Update() { ...
Um nun die Eigenschaft zu ändern, aktualisieren wir den
MaterialPropertyBlock unseres Objekts, ohne Kopien des Materials zu erstellen.
Über SpriteRendererSchauen wir uns diese Zeile im Shader an.
[PerRendererData] _MainTex ("Main Texture", 2D) = "white" {}
SpriteRenderer funktioniert ähnlich mit Sprites. Es setzt die
_MainTex- Eigenschaft mithilfe von
MaterialPropertyBlock auf
ihren Wert. Daher wird im Inspektor die Eigenschaft
_MainTex für das Material nicht angezeigt, und in der
SpriteRenderer- Komponente geben wir die benötigte Textur an. Gleichzeitig können sich auf der Bühne viele verschiedene Sprites befinden, aber nur ein Material wird für das Rendern verwendet (wenn Sie es nicht selbst ändern).
PerRendererData-Funktion
Sie können
MaterialPropertyBlock von fast allen Komponenten erhalten, die sich auf das Rendern beziehen. Zum Beispiel
SpriteRenderer ,
ParticleRenderer ,
MeshRenderer und andere
Renderer- Komponenten. Es gibt jedoch immer eine Ausnahme, dies ist ein
CanvasRenderer . Es ist unmöglich, Eigenschaften mit dieser Methode zu erhalten und zu ändern. Wenn Sie ein 2D-Spiel mit UI-Komponenten schreiben, tritt dieses Problem beim Schreiben von Shadern auf.
Drehung
Ein unangenehmer Effekt tritt auf, wenn das Bild gedreht wird. Dies macht sich am Beispiel einer Rundwelle besonders bemerkbar.
Die rechte Welle beim Drehen (90 Grad) führt zu einer weiteren Verzerrung.

Rot zeigt die Vektoren an, die von demselben Punkt in der Textur erhalten wurden, jedoch mit einer anderen Drehung dieser Textur. Der Versatzwert bleibt gleich und berücksichtigt keine Drehung.
Um dieses Problem zu lösen, verwenden wir die Transformationsmatrix
unit_ObjectToWorld . Es wird helfen, unseren Vektor von lokalen Koordinaten zu Weltkoordinaten wiederzugeben.
float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a; offset = mul( unity_ObjectToWorld, offset);
Die Matrix enthält jedoch auch Daten zur Skalierung des Objekts. Wenn Sie also die Stärke der Verzerrung angeben, müssen Sie die Skalierung des Objekts selbst berücksichtigen.
_propertyBlock.SetFloat("_DisplacementPower", property/transform.localScale.x);
Die rechte Welle wird ebenfalls um 90 Grad gedreht, aber die Verzerrung wird jetzt korrekt berechnet.

Clip
Unsere Textur hat genügend transparente Pixel (insbesondere wenn wir den
Rect- Maschentyp verwenden). Der Shader verarbeitet sie, was in diesem Fall keinen Sinn ergibt. Daher werden wir versuchen, die Anzahl unnötiger Berechnungen zu reduzieren. Wir können die Verarbeitung transparenter Pixel mit der
Clip (x) -Methode unterbrechen. Wenn der übergebene Parameter kleiner als Null ist, wird der Shader beendet. Da der Alpha-Wert jedoch nicht kleiner als 0 sein kann, wird ein kleiner Wert davon abgezogen. Es kann auch in Eigenschaften (
Ausschnitt )
eingefügt und zum Abschneiden der transparenten Teile des Bildes verwendet werden. In diesem Fall benötigen wir keinen separaten Parameter, daher verwenden wir nur die Zahl
0,01 .
Vollfragment-Shader-Code.
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: Der Quellcode für den Shader und das Skript ist ein
Link zu Git . Das Projekt hat auch einen kleinen Texturgenerator für Verzerrungen. Der Kristall mit dem Sockel wurde aus dem Asset - 2D Game Kit - entnommen.