Erstellen eines Grasshaders in der Unity-Engine


In diesem Tutorial erfahren Sie, wie Sie einen geometrischen Shader schreiben, um Grashalme aus den Spitzen des eingehenden Netzes zu generieren und die Dichte des Grases mithilfe von Tessellation zu steuern.

Der Artikel beschreibt den schrittweisen Prozess des Schreibens eines Grasshaders in Unity. Der Shader empfängt das eingehende Netz und generiert aus jedem Scheitelpunkt des Netzes mithilfe des geometrischen Shaders einen Grashalm. Aus Gründen des Interesses und des Realismus haben die Grashalme eine zufällige Größe und Rotation und werden auch vom Wind beeinflusst . Um die Dichte des Grases zu kontrollieren, verwenden wir Tessellation , um das eingehende Netz zu trennen. Das Gras kann Schatten werfen und empfangen .

Das fertige Projekt wird am Ende des Artikels veröffentlicht. Die generierte Shader-Datei enthält eine große Anzahl von Kommentaren, die das Verständnis erleichtern.

Anforderungen


Um dieses Tutorial abzuschließen, benötigen Sie praktische Kenntnisse über die Unity-Engine und ein erstes Verständnis der Syntax und Funktionalität von Shadern.

Laden Sie den Entwurf des Projekts herunter (.zip) .

An die Arbeit gehen


Laden Sie den Entwurf des Projekts herunter und öffnen Sie ihn im Unity-Editor. Öffnen Sie die Hauptszene und dann den Grass Shader in Ihrem Code-Editor.

Diese Datei enthält einen Shader, der weiße Farbe erzeugt, sowie einige Funktionen, die wir in diesem Tutorial verwenden werden. Sie werden feststellen, dass diese Funktionen zusammen mit dem Vertex-Shader im CGINCLUDE Block außerhalb von SubShader . Der in diesem Block platzierte Code wird automatisch in alle Durchgänge im Shader aufgenommen. Dies wird später nützlich sein, da unser Shader mehrere Durchgänge hat.

Wir beginnen mit dem Schreiben eines geometrischen Shaders , der aus jedem Scheitelpunkt auf der Oberfläche unseres Netzes Dreiecke generiert.

1. Geometrische Shader


Geometrische Shader sind ein optionaler Bestandteil der Rendering-Pipeline. Sie werden nach dem Vertex-Shader (oder Tessellation-Shader, wenn Tessellation verwendet wird) und vor der Verarbeitung der Vertices für den Fragment-Shader ausgeführt.


Direct3D Graphics Pipeline 11. Beachten Sie, dass in diesem Diagramm der Fragment-Shader als Pixel-Shader bezeichnet wird .

Geometrische Shader erhalten am Eingang ein einzelnes Grundelement und können null, ein oder mehrere Grundelemente erzeugen. Wir beginnen mit dem Schreiben eines geometrischen Shaders, der einen Scheitelpunkt (oder Punkt ) an der Eingabe empfängt und ein Dreieck füttert, das einen Grashalm darstellt.

 // Add inside the CGINCLUDE block. struct geometryOutput { float4 pos : SV_POSITION; }; [maxvertexcount(3)] void geo(triangle float4 IN[3] : SV_POSITION, inout TriangleStream<geometryOutput> triStream) { } … // Add inside the SubShader Pass, just below the #pragma fragment frag line. #pragma geometry geo 

Der obige Code deklariert einen geometrischen Shader namens geo mit zwei Parametern. Das erste triangle float4 IN[3] , dass ein Dreieck (bestehend aus drei Punkten) als Eingabe verwendet wird. Der zweite, z. B. TriangleStream , richtet einen Shader für die Ausgabe eines Dreiecksstroms ein, sodass jeder Scheitelpunkt die geometryOutput Struktur zum Übertragen seiner Daten verwendet.

Wir haben oben gesagt, dass der Shader einen Scheitelpunkt erhält und einen Grashalm ausgibt. Warum bekommen wir dann ein Dreieck?
Es ist weniger kostspielig, einen als Eingabe zu nehmen. Dies kann wie folgt erfolgen.

 void geo(point vertexOutput IN[1], inout TriangleStream<geometryOutput> triStream) 

Da unser eingehendes Netz (in diesem Fall GrassPlane10x10 im Ordner "Netz") eine Dreieckstopologie aufweist , führt dies zu einer Nichtübereinstimmung zwischen der eingehenden Netz-Topologie und dem erforderlichen Eingabeprimitiv. Obwohl dies in DirectX HLSL zulässig ist, ist es in OpenGL nicht zulässig , sodass ein Fehler angezeigt wird.

Zusätzlich fügen wir den letzten Parameter in eckigen Klammern über der Funktionsdeklaration hinzu: [maxvertexcount(3)] . Er teilt der GPU mit, dass wir nicht mehr als 3 Eckpunkte ausgeben werden (aber nicht müssen). Wir lassen SubShader auch einen geometrischen Shader verwenden, indem wir ihn in Pass deklarieren.

Unser geometrischer Shader macht noch nichts; Fügen Sie zum Zeichnen eines Dreiecks den folgenden Code in den geometrischen Shader ein.

 geometryOutput o; o.pos = float4(0.5, 0, 0, 1); triStream.Append(o); o.pos = float4(-0.5, 0, 0, 1); triStream.Append(o); o.pos = float4(0, 1, 0, 1); triStream.Append(o); 


Dies ergab sehr seltsame Ergebnisse. Wenn Sie die Kamera bewegen, wird deutlich, dass das Dreieck im Bildschirmbereich gerendert wird . Dies ist logisch: Da der geometrische Shader unmittelbar vor der Verarbeitung der Scheitelpunkte ausgeführt wird, entzieht er dem Scheitelpunkt-Shader die Verantwortung dafür, dass die Scheitelpunkte im Kürzungsbereich angezeigt werden . Wir werden unseren Code ändern, um dies widerzuspiegeln.

 // Update the return call in the vertex shader. //return UnityObjectToClipPos(vertex); return vertex; … // Update each assignment of o.pos in the geometry shader. o.pos = UnityObjectToClipPos(float4(0.5, 0, 0, 1)); … o.pos = UnityObjectToClipPos(float4(-0.5, 0, 0, 1)); … o.pos = UnityObjectToClipPos(float4(0, 1, 0, 1)); 


Jetzt ist unser Dreieck in der Welt richtig gerendert. Es scheint jedoch, dass nur eine erstellt wird. Tatsächlich wird für jeden Scheitelpunkt unseres Netzes ein Dreieck gezeichnet , aber die den Scheitelpunkten des Dreiecks zugewiesenen Positionen sind konstant - sie ändern sich nicht für jeden eingehenden Scheitelpunkt. Daher befinden sich alle Dreiecke übereinander.

Wir werden dies beheben, indem wir die Positionen der ausgehenden Scheitelpunkte relativ zum eingehenden Punkt versetzen.

 // Add to the top of the geometry shader. float3 pos = IN[0]; … // Update each assignment of o.pos. o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0)); … o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0)); … o.pos = UnityObjectToClipPos(pos + float3(0, 1, 0)); 


Warum bilden einige Eckpunkte kein Dreieck?

Obwohl wir festgestellt haben, dass das eingehende Grundelement ein Dreieck ist , wird ein Grashalm nur von einem der Punkte des Dreiecks übertragen, wobei die beiden anderen verworfen werden. Natürlich können wir einen Grashalm von allen drei ankommenden Punkten übertragen, aber dies führt dazu, dass benachbarte Dreiecke übermäßig Grashalme übereinander bilden.

Sie können dieses Problem auch lösen, indem Sie Netze mit dem Typ Topologiepunkte als eingehende Netze des geometrischen Shaders verwenden.

Dreiecke werden jetzt korrekt gezeichnet und ihre Basis befindet sich an der Spitze, die sie aussendet. Bevor Sie GrassPlane , machen Sie das GrassPlane Objekt in der Szene inaktiv und GrassBall das GrassBall Objekt. Wir möchten, dass das Gras auf verschiedenen Oberflächentypen korrekt erzeugt wird. Daher ist es wichtig, es an Maschen unterschiedlicher Form zu testen.


Bisher werden alle Dreiecke in einer Richtung und nicht von der Oberfläche der Kugel nach außen emittiert. Um dieses Problem zu lösen, werden wir Grashalme in einem tangentialen Raum erzeugen.

2. Tangentenraum


Im Idealfall möchten wir Grashalme erstellen, indem wir eine andere Breite, Höhe, Krümmung und Drehung einstellen, ohne den Winkel der Oberfläche zu berücksichtigen, von der der Grashalm emittiert wird. Einfach ausgedrückt, definieren wir einen Grashalm in einem Raum lokal zum Scheitelpunkt, der ihn aussendet , und transformieren ihn dann so, dass er lokal zum Netz ist . Dieser Raum wird Tangentenraum genannt .


Im Tangentenraum werden die X- , Y- und Z- Achsen relativ zur Normalen und zur Position der Oberfläche (in unserem Fall den Eckpunkten) definiert.

Wie in jedem anderen Raum können wir den Tangentenraum eines Scheitelpunkts mit drei Vektoren definieren: rechts , vorwärts und aufwärts . Mit diesen Vektoren können wir eine Matrix erstellen, um den Grashalm von der Tangente in den lokalen Raum zu drehen.

Sie können direkt und nach oben auf die Vektoren zugreifen, indem Sie neue Eingabescheitelpunktdaten hinzufügen.

 // Add to the CGINCLUDE block. struct vertexInput { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; struct vertexOutput { float4 vertex : SV_POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; … // Modify the vertex shader. vertexOutput vert(vertexInput v) { vertexOutput o; o.vertex = v.vertex; o.normal = v.normal; o.tangent = v.tangent; return o; } … // Modify the input for the geometry shader. Note that the SV_POSITION semantic is removed. void geo(triangle vertexOutput IN[3], inout TriangleStream<geometryOutput> triStream) … // Modify the existing line declaring pos. float3 pos = IN[0].vertex; 

Der dritte Vektor kann berechnet werden, indem das Vektorprodukt zwischen zwei anderen genommen wird. Ein Vektorprodukt gibt einen Vektor senkrecht zu zwei eingehenden Vektoren zurück.

 // Place in the geometry shader, below the line declaring float3 pos. float3 vNormal = IN[0].normal; float4 vTangent = IN[0].tangent; float3 vBinormal = cross(vNormal, vTangent) * vTangent.w; 

Warum wird das Ergebnis des Vektorprodukts mit der Koordinate der Tangente w multipliziert?
Beim Exportieren eines Netzes aus einem 3D-Editor sind in der Regel bereits Binormale (auch Tangenten an zwei Punkte genannt ) in den Netzdaten gespeichert. Anstatt diese Binormale zu importieren, nimmt Unity einfach die Richtung jeder Binormale und weist sie der Koordinate der Tangente w zu . Auf diese Weise können Sie Speicherplatz sparen und gleichzeitig das richtige Binormal wiederherstellen. Eine ausführliche Diskussion zu diesem Thema finden Sie hier .

Mit allen drei Vektoren können wir eine Matrix für die Transformation zwischen tangentialen und lokalen Räumen erstellen. Wir werden jeden Scheitelpunkt des Grashalms mit dieser Matrix UnityObjectToClipPos , bevor UnityObjectToClipPos ihn an UnityObjectToClipPos , das einen Scheitelpunkt im lokalen Raum erwartet.

 // Add below the lines declaring the three vectors. float3x3 tangentToLocal = float3x3( vTangent.x, vBinormal.x, vNormal.x, vTangent.y, vBinormal.y, vNormal.y, vTangent.z, vBinormal.z, vNormal.z ); 

Bevor wir die Matrix verwenden, übertragen wir den Vertex-Ausgabecode an die Funktion, um nicht immer wieder dieselben Codezeilen zu schreiben. Dies wird als DRY-Prinzip bezeichnet . Wiederholen Sie sich nicht .

 // Add to the CGINCLUDE block. geometryOutput VertexOutput(float3 pos) { geometryOutput o; o.pos = UnityObjectToClipPos(pos); return o; } … // Remove the following from the geometry shader. //geometryOutput o; //o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0)); //triStream.Append(o); //o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0)); //triStream.Append(o); //o.pos = UnityObjectToClipPos(pos + float3(0, 1, 0)); //triStream.Append(o); // ...and replace it with the code below. triStream.Append(VertexOutput(pos + float3(0.5, 0, 0))); triStream.Append(VertexOutput(pos + float3(-0.5, 0, 0))); triStream.Append(VertexOutput(pos + float3(0, 1, 0))); 

Schließlich multiplizieren wir die Ausgabescheitelpunkte mit der tangentToLocal Matrix und tangentToLocal sie korrekt an der Normalen ihres Eingabepunkts aus.

 triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0)))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0)))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 1, 0)))); 

Bild

Dies ist eher das, was wir brauchen, aber nicht ganz richtig. Das Problem hierbei ist, dass wir anfangs die Richtung „up“ (up) der Y- Achse zugewiesen haben; Im Tangentenraum befindet sich die Aufwärtsrichtung jedoch normalerweise entlang der Z- Achse. Jetzt werden wir diese Änderungen vornehmen.

 // Modify the position of the third vertex being emitted. triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1)))); 


3. Aussehen von Gras


Damit die Dreiecke eher wie Grashalme aussehen, müssen Sie Farben und Variationen hinzufügen. Wir beginnen mit dem Hinzufügen eines Gefälles , das von der Spitze des Grashalms nach unten verläuft.

3.1 Farbverlauf


Unser Ziel ist es, dem Künstler zu ermöglichen, zwei Farben einzustellen - oben und unten, und zwischen diesen beiden Farben zu interpolieren, die er auf die Basis des Grashalms kippt. Diese Farben sind in der Shader-Datei bereits als _TopColor und _BottomColor . Für die ordnungsgemäße Abtastung müssen Sie die UV-Koordinaten an den Fragment-Shader übergeben.

 // Add to the geometryOutput struct. float2 uv : TEXCOORD0; … // Modify the VertexOutput function signature. geometryOutput VertexOutput(float3 pos, float2 uv) … // Add to VertexOutput, just below the line assigning o.pos. o.uv = uv; … // Modify the existing lines in the geometry shader. triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1)), float2(0.5, 1))); 

Wir haben UV-Koordinaten für einen Grashalm in Form eines Dreiecks erstellt, dessen zwei Eckpunkte sich unten links und rechts befinden und dessen Spitzenscheitelpunkt in der Mitte oben liegt.


UV-Koordinaten der drei Eckpunkte der Grashalme. Obwohl wir die Grashalme mit einem einfachen Farbverlauf bemalen, können Sie mit einer ähnlichen Anordnung von Texturen Texturen überlagern.

Jetzt können wir die oberen und unteren Farben im Fragment-Shader mit UV lerp und sie dann mit lerp interpolieren. Wir müssen auch die Parameter des Fragment- float4 ändern und geometryOutput als Eingabe und nicht nur die Position von float4 .

 // Modify the function signature of the fragment shader. float4 frag (geometryOutput i, fixed facing : VFACE) : SV_Target … // Replace the existing return call. return float4(1, 1, 1, 1); return lerp(_BottomColor, _TopColor, i.uv.y); 


3.2 Zufällige Blattrichtung


Um Variabilität zu erzeugen und dem Gras ein natürlicheres Aussehen zu verleihen, lassen wir jeden Grashalm in eine zufällige Richtung schauen. Dazu müssen wir eine Rotationsmatrix erstellen, die den Grashalm zufällig um seine obere Achse dreht.

Die Shader-Datei enthält zwei Funktionen, die uns dabei helfen: rand , das aus dreidimensionalen Eingaben eine Zufallszahl generiert, und AngleAxis3x3 , das den Winkel (im Bogenmaß ) empfängt und eine Matrix zurückgibt, die diesen Wert um die angegebene Achse dreht. Die letztere Funktion funktioniert genauso wie die C # Quaternion.AngleAxis- Funktion (nur AngleAxis3x3 gibt eine Matrix zurück, keine Quaternion).

Die rand Funktion gibt eine Zahl im Bereich 0 ... 1 zurück. Wir multiplizieren es mit 2 Pi , um den gesamten Bereich der Winkelwerte zu erhalten.

 // Add below the line declaring the tangentToLocal matrix. float3x3 facingRotationMatrix = AngleAxis3x3(rand(pos) * UNITY_TWO_PI, float3(0, 0, 1)); 

Wir verwenden die eingehende Positionsposition als Startwert für eine zufällige Rotation. Aus diesem Grund hat jeder Grashalm seine eigene Rotation, die in jedem Rahmen konstant ist.

Die Drehung kann auf den Grashalm angewendet werden, indem er mit der erstellten tangentToLocal Matrix tangentToLocal wird. Beachten Sie, dass die Matrixmultiplikation nicht kommutativ ist . Die Reihenfolge der Operanden ist wichtig .

 // Add below the line declaring facingRotationMatrix. float3x3 transformationMatrix = mul(tangentToLocal, facingRotationMatrix); … // Replace the multiplication matrix operand with our new transformationMatrix. triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0.5, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-0.5, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, 1)), float2(0.5, 1))); 


3.3 Zufälliges Vorwärtsbiegen


Wenn alle Grashalme perfekt ausgerichtet sind, sehen sie gleich aus. Dies mag für gepflegtes Gras geeignet sein, zum Beispiel auf einem gepflegten Rasen, aber in der Natur wächst das Gras nicht so. Wir werden eine neue Matrix erstellen, um das Gras entlang der X- Achse zu drehen, sowie eine Eigenschaft, um diese Drehung zu steuern.

 // Add as a new property. _BendRotationRandom("Bend Rotation Random", Range(0, 1)) = 0.2 … // Add to the CGINCLUDE block. float _BendRotationRandom; … // Add to the geometry shader, below the line declaring facingRotationMatrix. float3x3 bendRotationMatrix = AngleAxis3x3(rand(pos.zzx) * _BendRotationRandom * UNITY_PI * 0.5, float3(-1, 0, 0)); 

Wieder verwenden wir die Position des Grashalms als zufälligen Samen, diesmal indem wir ihn fegen , um einen einzigartigen Samen zu erzeugen. Wir werden auch UNITY_PI mit 0,5 multiplizieren; Dies gibt uns ein zufälliges Intervall von 0 ... 90 Grad.

Wir wenden diese Matrix erneut durch Rotation an und multiplizieren alles in der richtigen Reihenfolge.

 // Modify the existing line. float3x3 transformationMatrix = mul(mul(tangentToLocal, facingRotationMatrix), bendRotationMatrix); 


3.4 Breite und Höhe


Während die Größe des Grashalms auf eine Breite von 1 Einheit und eine Höhe von 1 Einheit begrenzt ist. Wir werden Eigenschaften hinzufügen, um die Größe zu steuern, sowie Eigenschaften, um zufällige Variationen hinzuzufügen.

 // Add as new properties. _BladeWidth("Blade Width", Float) = 0.05 _BladeWidthRandom("Blade Width Random", Float) = 0.02 _BladeHeight("Blade Height", Float) = 0.5 _BladeHeightRandom("Blade Height Random", Float) = 0.3 … // Add to the CGINCLUDE block. float _BladeHeight; float _BladeHeightRandom; float _BladeWidth; float _BladeWidthRandom; … // Add to the geometry shader, above the triStream.Append calls. float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight; float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth; … // Modify the existing positions with our new height and width. triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(width, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-width, 0, 0)), float2(1, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, height)), float2(0.5, 1))); 


Dreiecke sind jetzt viel mehr wie Grashalme, aber auch zu wenig. Es gibt einfach nicht genug Spitzen im eingehenden Netz, um den Eindruck eines dicht bewachsenen Feldes zu erwecken.

Eine Lösung besteht darin, ein neues, dichteres Netz zu erstellen, entweder mit C # oder in einem 3D-Editor. Dies wird funktionieren, aber es wird uns nicht ermöglichen, die Dichte des Grases dynamisch zu steuern. Stattdessen teilen wir das eingehende Netz mithilfe der Tessellation auf .

4. Tessellation


Die Tessellation ist eine optionale Phase der Rendering-Pipeline, die nach dem Vertex-Shader und vor dem geometrischen Shader (falls vorhanden) ausgeführt wird. Seine Aufgabe ist es, eine eingehende Oberfläche in viele Grundelemente zu unterteilen. Die Tessellation wird in zwei programmierbaren Schritten implementiert: Hull- und Domain- Shader.

Für Oberflächen-Shader verfügt Unity über eine integrierte Tessellierungsimplementierung . Da wir jedoch keine Oberflächen-Shader verwenden, müssen wir unsere eigenen Shell- und Domain-Shader implementieren. In diesem Artikel werde ich die Implementierung von Tessellation nicht im Detail diskutieren, und wir verwenden einfach die vorhandene Datei CustomTessellation.cginc . Diese Datei wurde aus dem Artikel Catlike Coding übernommen , der eine hervorragende Informationsquelle zur Implementierung von Tessellation in Unity darstellt.

Wenn wir das TessellationExample Objekt in die Szene aufnehmen, werden wir feststellen, dass es bereits Material enthält, das die Tessellation implementiert. Das Ändern der Eigenschaft Tessellation Uniform zeigt den Unterteilungseffekt.


Wir implementieren eine Tessellation im Grasshader, um die Dichte des Flugzeugs und damit die Anzahl der erzeugten Grashalme zu steuern. Zuerst müssen Sie die Datei CustomTessellation.cginc hinzufügen. Wir werden es durch seinen relativen Pfad zum Shader bezeichnen.

 // Add inside the CGINCLUDE block, below the other #include statements. #include "Shaders/CustomTessellation.cginc" 

Wenn Sie CustomTessellation.cginc öffnen, werden Sie feststellen, dass darin bereits vertexInput und vertexOutput sowie Vertex-Shader definiert sind. Sie müssen sie in unserem Grasshader nicht neu definieren. Sie können gelöscht werden.

 /*struct vertexInput { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; struct vertexOutput { float4 vertex : SV_POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; }; vertexOutput vert(vertexInput v) { vertexOutput o; o.vertex = v.vertex; o.normal = v.normal; o.tangent = v.tangent; return o; }*/ 

Beachten Sie, dass der vert Vertex-Shader in CustomTessellation.cginc die Eingabe einfach direkt an die Tessellation-Stufe übergibt. Die im Domain-Shader vertexOutput Funktion vertexOutput übernimmt die Erstellung der vertexOutput Struktur.

Jetzt können wir dem Grassader Shader- und Domain- Shader hinzufügen. Wir werden auch eine neue _TessellationUniform Eigenschaft hinzufügen, um die Einheitengröße zu steuern. Die dieser Eigenschaft entsprechende Variable wurde bereits in CustomTessellation.cginc .

 // Add as a new property. _TessellationUniform("Tessellation Uniform", Range(1, 64)) = 1 … // Add below the other #pragma statements in the SubShader Pass. #pragma hull hull #pragma domain domain 

Durch Ändern der Eigenschaft Tessellation Uniform können wir nun die Dichte des Grases steuern. Ich fand heraus, dass mit einem Wert von 5 gute Ergebnisse erzielt werden.


5. Der Wind


Wir implementieren den Wind, indem wir die Verzerrungstextur abtasten. Diese Textur sieht aus wie eine normale Karte , nur gibt es nur zwei statt drei Kanäle. Wir werden diese beiden Kanäle als Windrichtungen entlang X und Y verwenden.


Bevor wir die Windtextur abtasten, müssen wir eine UV-Koordinate erstellen. Anstatt die dem Netz zugewiesenen Texturkoordinaten zu verwenden, wenden wir die Position des eingehenden Punkts an. Wenn es auf der Welt mehrere Grasmaschen gibt, entsteht die Illusion, dass sie alle Teil desselben Windsystems sind. Wir verwenden auch die _Time Variable _Time Shader, um die _Time entlang der Grasoberfläche zu scrollen.

 // Add as new properties. _WindDistortionMap("Wind Distortion Map", 2D) = "white" {} _WindFrequency("Wind Frequency", Vector) = (0.05, 0.05, 0, 0) … // Add to the CGINCLUDE block. sampler2D _WindDistortionMap; float4 _WindDistortionMap_ST; float2 _WindFrequency; … // Add to the geometry shader, just above the line declaring the transformationMatrix. float2 uv = pos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y; 

Wir wenden die Skalierung und den Versatz von _WindDistortionMap auf die Position an und verschieben sie dann weiter auf _Time.y , skaliert auf _WindFrequency . Jetzt werden wir diese UVs verwenden, um die Textur abzutasten und eine Eigenschaft zu erstellen, um die Stärke des Windes zu steuern.

 // Add as a new property. _WindStrength("Wind Strength", Float) = 1 … // Add to the CGINCLUDE block. float _WindStrength; … // Add below the line declaring float2 uv. float2 windSample = (tex2Dlod(_WindDistortionMap, float4(uv, 0, 0)).xy * 2 - 1) * _WindStrength; 

Beachten Sie, dass wir den abgetasteten Wert von der Textur vom Intervall 0 ... 1 bis zum Intervall -1 ... 1 skalieren. Als nächstes können wir einen normalisierten Vektor erzeugen, der die Windrichtung angibt.

 // Add below the line declaring float2 windSample. float3 wind = normalize(float3(windSample.x, windSample.y, 0)); 

Jetzt können wir eine Matrix erstellen, um diesen Vektor zu drehen und mit unserer transformationMatrix multiplizieren.

 // Add below the line declaring float3 wind. float3x3 windRotation = AngleAxis3x3(UNITY_PI * windSample, wind); … // Modify the existing line. float3x3 transformationMatrix = mul(mul(mul(tangentToLocal, windRotation), facingRotationMatrix), bendRotationMatrix); 

Schließlich übertragen wir die Windtextur (an der Wurzel des Projekts) in das Feld Wind Distortion Map des Grasmaterials im Unity-Editor. Wir setzen auch den Tiling- Parameter der Textur auf 0.01, 0.01 .


Wenn das Gras im Szenenfenster nicht animiert wird, klicken Sie auf die Schaltfläche Skybox, Nebel und verschiedene andere Effekte umschalten, um animierte Materialien zu aktivieren.

Aus der Ferne sieht das Gras richtig aus, aber wenn wir uns den Grashalm genau ansehen, stellen wir fest, dass sich der gesamte Grashalm dreht, weshalb die Basis nicht mehr am Boden befestigt ist.


Die Basis des Grashalms ist nicht mehr am Boden befestigt, sondern schneidet ihn ( rot dargestellt ) und hängt über der Bodenebene (angezeigt durch die grüne Linie).

Wir werden dies beheben, indem wir eine zweite Transformationsmatrix definieren, die nur für zwei Eckpunkte der Basis gilt. In dieser Matrix nicht werden enthalten Matrix windRotationund bendRotationMatrixDank, auf dem die Basis auf die Grasfläche angebracht ist.

 // Add below the line declaring float3x3 transformationMatrix. float3x3 transformationMatrixFacing = mul(tangentToLocal, facingRotationMatrix); … // Modify the existing lines outputting the base vertex positions. triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(width, 0, 0)), float2(0, 0))); triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(-width, 0, 0)), float2(1, 0))); 

6. Krümmung der Grashalme


Jetzt werden einzelne Grashalme durch ein Dreieck definiert. In großen Entfernungen ist dies kein Problem, aber in der Nähe des Grashalms sehen sie eher sehr steif und geometrisch aus als organisch und lebhaft. Wir werden dies beheben, indem wir Grashalme aus mehreren Dreiecken bauen und sie entlang der Kurve biegen .

Jeder Grashalm wird in mehrere Segmente unterteilt . Jedes Segment hat eine rechteckige Form und besteht aus zwei Dreiecken, mit Ausnahme des oberen Segments - es ist ein Dreieck, das die Spitze des Grashalms bezeichnet.

Bisher haben wir nur drei Eckpunkte gezeichnet und so ein einziges Dreieck erstellt. Woher weiß der geometrische Shader dann, wenn es mehr Eckpunkte gibt, welche zu verbinden und Dreiecke zu bilden sind? Die Antwort liegt in der DatenstrukturDreiecksstreifen . Die ersten drei Scheitelpunkte verbinden sich und bilden ein Dreieck, und jeder neue Scheitelpunkt bildet mit den beiden vorherigen ein Dreieck.


Ein unterteilter Grashalm, der als Dreiecksstreifen dargestellt wird und jeweils einen Scheitelpunkt erstellt. Nach den ersten drei Scheitelpunkten bildet jeder neue Scheitelpunkt mit den beiden vorherigen Scheitelpunkten ein neues Dreieck.

Dies ist nicht nur effizienter in Bezug auf die Speichernutzung, sondern ermöglicht es Ihnen auch, einfach und schnell Dreieckssequenzen in Ihrem Code zu erstellen. Wenn wir mehrere Dreiecksstreifen erstellen möchten , können wir RestartStrip für die TriangleStreamFunktion aufrufen . Bevor wir beginnen, mehr Scheitelpunkte aus dem geometrischen Shader zu zeichnen, müssen wir ihn vergrößern . Wir werden das Design verwenden , damit der Shader-Autor die Anzahl der Segmente steuern und die Anzahl der angezeigten Scheitelpunkte daraus berechnen kann.

maxvertexcount#define

 // Add to the CGINCLUDE block. #define BLADE_SEGMENTS 3 … // Modify the existing line defining the maxvertexcount. [maxvertexcount(BLADE_SEGMENTS * 2 + 1)] 

Zunächst setzen wir die Anzahl der Segmente auf 3 und aktualisieren maxvertexcount, um die Anzahl der Scheitelpunkte basierend auf der Anzahl der Segmente zu berechnen.

Um einen segmentierten Grashalm zu erstellen, verwenden wir einen Zyklus for. Jede Iteration der Schleife fügt zwei Eckpunkte hinzu : links und rechts . Nach Abschluss der Spitze fügen wir den letzten Scheitelpunkt an der Spitze des Grashalms hinzu.

Bevor wir dies tun, ist es nützlich, einen Teil der Rechenposition der Eckpunkte der Grashalme des Codes in die Funktion zu verschieben, da wir diesen Code innerhalb und außerhalb der Schleife mehrmals verwenden. Fügen Sie dem Block CGINCLUDEFolgendes hinzu:

 geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float2 uv, float3x3 transformMatrix) { float3 tangentPoint = float3(width, 0, height); float3 localPosition = vertexPosition + mul(transformMatrix, tangentPoint); return VertexOutput(localPosition, uv); } 

Diese Funktion führt dieselben Aufgaben aus, da sie die Argumente übergibt, die wir zuvor übergeben haben VertexOutput, um die Eckpunkte des Grashalms zu generieren. Wenn Sie eine Position, Höhe und Breite erhalten, transformiert es den Scheitelpunkt mithilfe der übertragenen Matrix korrekt und weist ihm eine UV-Koordinate zu. Wir werden den vorhandenen Code aktualisieren, damit die Funktion ordnungsgemäß funktioniert.

 // Update the existing code outputting the vertices. triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing)); triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix)); 

Die Funktion hat ordnungsgemäß funktioniert und wir sind bereit, den Vertex-Generierungscode in die Schleife zu verschieben for. Fügen Sie unter der Zeile float widthFolgendes hinzu :

 for (int i = 0; i < BLADE_SEGMENTS; i++) { float t = i / (float)BLADE_SEGMENTS; } 

Wir kündigen einen Zyklus an, der für jeden Grashalmsegment einmal ausgeführt wird. Fügen Sie innerhalb der Schleife eine Variable hinzu t. Diese Variable speichert einen Wert im Bereich von 0 bis 1, der angibt, wie weit wir uns entlang des Grashalms bewegt haben. Wir verwenden diesen Wert, um die Breite und Höhe des Segments in jeder Iteration der Schleife zu berechnen.

 // Add below the line declaring float t. float segmentHeight = height * t; float segmentWidth = width * (1 - t); 

Wenn Sie einen Grashalm nach oben bewegen, nimmt die Höhe zu und die Breite ab. Jetzt können wir der Schleife Aufrufe GenerateGrassVertexhinzufügen, um dem Dreiecksstrom Scheitelpunkte hinzuzufügen. Wir werden auch einen Aufruf GenerateGrassVertexaußerhalb der Schleife hinzufügen , um die Spitze des Grashalms zu erzeugen.

 // Add below the line declaring float segmentWidth. float3x3 transformMatrix = i == 0 ? transformationMatrixFacing : transformationMatrix; triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, float2(0, t), transformMatrix)); triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, float2(1, t), transformMatrix)); … // Add just below the loop to insert the vertex at the tip of the blade. triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix)); … // Remove the existing calls to triStream.Append. //triStream.Append(GenerateGrassVertex(pos, width, 0, float2(0, 0), transformationMatrixFacing)); //triStream.Append(GenerateGrassVertex(pos, -width, 0, float2(1, 0), transformationMatrixFacing)); //triStream.Append(GenerateGrassVertex(pos, 0, height, float2(0.5, 1), transformationMatrix)); 

Schauen Sie sich die Zeile mit der Deklaration an float3x3 transformMatrix- hier wählen wir eine von zwei Transformationsmatrizen aus: Wir nehmen transformationMatrixFacingfür die Eckpunkte der Basis und transformationMatrixfür alle anderen.


Grashalme sind jetzt in viele Segmente unterteilt, aber die Blattoberfläche ist immer noch flach - neue Dreiecke sind noch nicht beteiligt. Wir werden ein Grashalm Krümmung hinzufügen, um die Position des Scheitels der Verschiebung des Y . Zuerst müssen wir die Funktion GenerateGrassVertexso ändern , dass sie einen Offset in Y erhält , den wir aufrufen werden forward.

 // Update the function signature of GenerateGrassVertex. geometryOutput GenerateGrassVertex(float3 vertexPosition, float width, float height, float forward, float2 uv, float3x3 transformMatrix) … // Modify the Y coordinate assignment of tangentPoint. float3 tangentPoint = float3(width, forward, height); 

Um die Verschiebung jedes Scheitelpunkts zu berechnen, setzen wir einen powWert in die Funktion ein t. Nach dem Anheben tauf eine Kraft ist ihre Auswirkung auf die Vorwärtsverschiebung nichtlinear und verwandelt den Grashalm in eine Kurve.

 // Add as new properties. _BladeForward("Blade Forward Amount", Float) = 0.38 _BladeCurve("Blade Curvature Amount", Range(1, 4)) = 2 … // Add to the CGINCLUDE block. float _BladeForward; float _BladeCurve; … // Add inside the geometry shader, below the line declaring float width. float forward = rand(pos.yyz) * _BladeForward; … // Add inside the loop, below the line declaring segmentWidth. float segmentForward = pow(t, _BladeCurve) * forward; … // Modify the GenerateGrassVertex calls inside the loop. triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, segmentForward, float2(0, t), transformMatrix)); triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, segmentForward, float2(1, t), transformMatrix)); … // Modify the GenerateGrassVertex calls outside the loop. triStream.Append(GenerateGrassVertex(pos, 0, height, forward, float2(0.5, 1), transformationMatrix)); 

Dies ist ein ziemlich großer Code, aber alle Arbeiten werden ähnlich ausgeführt wie für die Breite und Höhe des Grashalms. Bei niedrigeren Werten _BladeForward, und _BladeCurvebekommen wir einen geordnete, gepflegten Rasen, und größere Werte der entgegengesetzte Wirkung.


7. Licht und Schatten


Als letzten Schritt zur Vervollständigung des Shaders werden wir die Möglichkeit hinzufügen , Schatten zu werfen und zu empfangen . Wir werden auch eine einfache Beleuchtung von der Hauptlichtquelle hinzufügen.

7.1 Schatten werfen


Um Schatten in Unity zu werfen, müssen Sie dem Shader einen zweiten Durchgang hinzufügen. Diese Passage wird von den schattenerzeugenden Lichtquellen in der Szene verwendet, um die Tiefe des Grases in ihre Schattenkarte zu rendern . Dies bedeutet, dass der geometrische Shader im Schattengang gestartet werden muss, damit die Grashalme Schatten werfen können.

Da der geometrische Shader in Blöcken geschrieben ist CGINCLUDE, können wir ihn in beliebigen Durchläufen der Datei verwenden. Erstellen Sie einen zweiten Durchgang, der dieselben Shader wie der erste verwendet, mit Ausnahme des Fragment-Shaders. Wir definieren einen neuen, in den wir ein Makro schreiben, das die Ausgabe verarbeitet.

 // Add below the existing Pass. Pass { Tags { "LightMode" = "ShadowCaster" } CGPROGRAM #pragma vertex vert #pragma geometry geo #pragma fragment frag #pragma hull hull #pragma domain domain #pragma target 4.6 #pragma multi_compile_shadowcaster float4 frag(geometryOutput i) : SV_Target { SHADOW_CASTER_FRAGMENT(i) } ENDCG } 

Neben der Erstellung eines neuen Fragment-Shaders gibt es in dieser Passage einige wichtige Unterschiede. Die Beschriftung ist nicht LightModewichtig - dies sagt Unity, dass diese Passage verwendet werden sollte, um das Objekt in Schattenkarten zu rendern. Hier gibt es auch eine Präprozessor-Direktive . Es stellt sicher, dass der Shader alle erforderlichen Optionen kompiliert, um Schatten zu werfen. Lassen Sie uns ein Spiel Objekt machen , ist aktiv in der Szene; So erhalten wir eine Oberfläche, auf die die Grashalme einen Schatten werfen können.ShadowCasterForwardBasemulti_compile_shadowcaster

Fence


7.2 Schatten bekommen


Nachdem Unity die Schattenkarte aus der Sicht der Lichtquelle gerendert hat, die den Schatten erzeugt, wird eine Passage gestartet, die die Schatten in der Textur des Bildschirmbereichs "sammelt" . Um diese Textur abzutasten, müssen wir die Positionen der Scheitelpunkte im Bildschirmbereich berechnen und an den Fragment-Shader übertragen.

 // Add to the geometryOutput struct. unityShadowCoord4 _ShadowCoord : TEXCOORD1; … // Add to the VertexOutput function, just above the return call. o._ShadowCoord = ComputeScreenPos(o.pos); 

Im Fragment-Shader der Passage können ForwardBasewir ein Makro verwenden, um einen Wert zu erhalten, der floatangibt, ob sich die Oberfläche im Schatten befindet oder nicht. Dieser Wert liegt im Bereich von 0 bis 1, wobei 0 für vollständige Schattierung und 1 für vollständige Beleuchtung steht.

Warum heißt die UV-Koordinate des Bildschirmbereichs _ShadowCoord? Dies entspricht nicht den vorherigen Namenskonventionen.
Unity ( ). SHADOW_ATTENUATION . Autolight.cginc , , .

 #define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord) 

- , .

 // Add to the ForwardBase pass's fragment shader, replacing the existing return call. return SHADOW_ATTENUATION(i); //return lerp(_BottomColor, _TopColor, i.uv.y); 

Schließlich müssen wir den Shader richtig konfigurieren, um Schatten zu empfangen. Zu diesem Zweck fügen wir dem Pass eine ForwardBasePräprozessor-Direktive hinzu, damit alle erforderlichen Shader-Optionen kompiliert werden.

 // Add to the ForwardBase pass's preprocessor directives, below #pragma target 4.6. #pragma multi_compile_fwdbase 


Nachdem wir die Kamera näher gebracht haben, können wir Artefakte auf der Oberfläche der Grashalme feststellen. Sie werden durch die Tatsache verursacht, dass einzelne Grashalme Schatten auf sich selbst werfen. Wir können dies beheben, indem wir eine lineare Verschiebung anwenden oder die Positionen der Scheitelpunkte im Kürzungsraum etwas vom Bildschirm weg verschieben. Wir werden das Unity-Makro dafür verwenden und es in das Design #ifeinbeziehen, sodass die Operation nur im Schattenpfad ausgeführt wird.

 // Add at the end of the VertexOutput function, just above the return call. #if UNITY_PASS_SHADOWCASTER // Applying the bias prevents artifacts from appearing on the surface. o.pos = UnityApplyLinearShadowBias(o.pos); #endif 


Nach dem Anwenden der linearen Schattenverschiebung verschwinden Schattenartefakte in Form von Streifen von der Oberfläche der Dreiecke.

Warum gibt es Artefakte an den Rändern der schattierten Grashalme?

(multisample anti-aliasing MSAA ) Unity , . , .

— , , Unity . ( ); Unity .

7.3 Beleuchtung


Wir werden die Beleuchtung mit einem sehr einfachen und gebräuchlichen Berechnungsalgorithmus für diffuses Licht implementieren.


... wobei N die Normale zur Oberfläche ist, L die normalisierte Richtung der Hauptlichtquelle ist und I die berechnete Beleuchtung ist. In diesem Tutorial wird keine indirekte Beleuchtung implementiert.

Im Moment sind den Spitzen der Grashalme keine Normalen zugeordnet. Wie bei Scheitelpunktpositionen berechnen wir zuerst die Normalen im Tangentenraum und konvertieren sie dann in lokale.

Wenn Schaufelkrümmung Betrag wird auf 1 , die alle das Gras im Tangentenraum in der gleichen Richtung: das Gegenteil von der Achse Y. Als ersten Durchgang unserer Lösung berechnen wir die Normalen unter der Annahme, dass keine Krümmung vorliegt.

 // Add to the GenerateGrassVertex function, belowing the line declaring tangentPoint. float3 tangentNormal = float3(0, -1, 0); float3 localNormal = mul(transformMatrix, tangentNormal); 

tangentNormal, definiert als direkt gegenüber der Y- Achse , wird durch dieselbe Matrix transformiert, mit der wir die Tangentenpunkte in den lokalen Raum konvertiert haben. Jetzt können wir es an eine Funktion VertexOutputund dann an eine Struktur übergeben geometryOutput.

 // Modify the return call in GenerateGrassVertex. return VertexOutput(localPosition, uv, localNormal); … // Add to the geometryOutput struct. float3 normal : NORMAL; … // Modify the existing function signature. geometryOutput VertexOutput(float3 pos, float2 uv, float3 normal) … // Add to the VertexOutput function to pass the normal through to the fragment shader. o.normal = UnityObjectToWorldNormal(normal); 

Beachten Sie, dass wir vor dem Abschluss das Normale in den Weltraum verwandeln . Die Einheit vermittelt den Shadern die Richtung der Hauptquelle des gerichteten Lichts im Weltraum, daher ist diese Transformation notwendig.

Jetzt können wir die Normalen im Shader-Fragment visualisieren ForwardBase, um das Ergebnis unserer Arbeit zu überprüfen.

 // Add to the ForwardBase fragment shader. float3 normal = facing > 0 ? i.normal : -i.normal; return float4(normal * 0.5 + 0.5, 1); // Remove the existing return call. //return SHADOW_ATTENUATION(i); 

Da in unserem Shader ein CullWert zugewiesen ist Off, werden beide Seiten des Grashalms gerendert. Damit die Normalen in die richtige Richtung gerichtet werden, verwenden wir einen Hilfsparameter VFACE, den wir dem Fragment-Shader hinzugefügt haben.

Das Argument fixed facinggibt eine positive Zahl zurück, wenn wir die Vorderseite der Oberfläche anzeigen, und eine negative Zahl, wenn es das Gegenteil ist. Wir verwenden dies im obigen Code, um bei Bedarf das Normale umzudrehen.


Wenn der Blattkrümmungsbetrag größer als 1 ist, wird die tangentiale Z- Position jedes Scheitelpunkts um den forwardan die Funktion übergebenen Betrag verschoben GenerateGrassVertex. Wir werden diesen Wert verwenden, um die Z- Achse der Normalen proportional zu skalieren .

 // Modify the existing line in GenerateGrassVertex. float3 tangentNormal = normalize(float3(0, -1, forward)); 

Fügen Sie abschließend den Code zum Fragment-Shader hinzu, um die Schatten, die gerichtete Beleuchtung und die Umgebungsbeleuchtung zu kombinieren. Ich empfehle, in meinem Tutorial zu Toon-Shadern detailliertere Informationen zur Implementierung der benutzerdefinierten Beleuchtung in Shadern zu lesen .

 // Add to the ForwardBase fragment shader, below the line declaring float3 normal. float shadow = SHADOW_ATTENUATION(i); float NdotL = saturate(saturate(dot(normal, _WorldSpaceLightPos0)) + _TranslucentGain) * shadow; float3 ambient = ShadeSH9(float4(normal, 1)); float4 lightIntensity = NdotL * _LightColor0 + float4(ambient, 1); float4 col = lerp(_BottomColor, _TopColor * lightIntensity, i.uv.y); return col; // Remove the existing return call. //return float4(normal * 0.5 + 0.5, 1); 


Fazit


In diesem Tutorial bedeckt Gras eine kleine Fläche von 10 x 10 Einheiten. Damit der Shader große Freiflächen abdecken und gleichzeitig eine hohe Leistung erzielen kann, müssen Optimierungen eingeführt werden. Sie können die Tessellierung basierend auf der Entfernung anwenden, damit weniger Grashalme von der Kamera entfernt werden. Darüber hinaus können über große Entfernungen anstelle einzelner Grashalme Gruppen von Grashalmen mit einem einzigen Viereck mit einer überlagerten Textur gezeichnet werden.


Die Grasstruktur ist im Standard Assets- Paket der Unity Engine enthalten . Viele Grashalme werden auf ein Viereck gezeichnet, wodurch die Anzahl der Dreiecke in der Szene verringert wird.

Obwohl wir von Natur aus keine geometrischen Shader mit Oberflächen-Shadern verwenden können, um die Funktionalität von Beleuchtung und Schattierung zu verbessern oder zu erweitern, können Sie dieses GitHub-Repository studieren , das die Lösung des Problems durch verzögertes Rendern und manuelles Füllen von G-Puffern demonstriert.

Shader-Quellcode im GitHub-Repository

Ergänzung: Zusammenarbeit


Ohne Interoperabilität können Grafikeffekte für Spieler statisch oder leblos erscheinen. Dieses Tutorial ist bereits sehr lang, daher habe ich keinen Abschnitt über die Interaktion von Weltobjekten mit Gras hinzugefügt.

Eine naive Implementierung interaktiver Kräuter würde zwei Komponenten enthalten: etwas in der Spielwelt, das Daten an den Shader übertragen kann, um ihm mitzuteilen, mit welchem ​​Teil des Grases interagiert wird, und Code im Shader, um diese Daten zu interpretieren .

Ein Beispiel, wie dies mit Wasser umgesetzt werden kann, wird hier gezeigt . Es kann angepasst werden, um mit Gras zu arbeiten; Anstatt an der Stelle, an der sich der Charakter befindet, Wellen zu zeichnen, können Sie den Grashalm nach unten drehen, um die Auswirkungen von Schritten zu simulieren.

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


All Articles