Meine letzte Aufgabe im Bereich technische Grafik / Rendering war es, eine gute Lösung für das Rendern von Wasser zu finden. Insbesondere das Rendern von dünnen und sich schnell bewegenden Wasserstrahlen auf Partikelbasis. In der letzten Woche habe ich an gute Ergebnisse gedacht, deshalb werde ich einen Artikel darüber schreiben.
Ich mag den Ansatz der voxelisierten / marschierenden Würfel beim Rendern von Wasser nicht wirklich (siehe zum Beispiel das Rendern einer Flüssigkeitssimulation in Blender). Wenn das Wasservolumen im gleichen Maßstab liegt wie das zum Rendern verwendete Gitter, ist die Bewegung spürbar diskret. Dieses Problem kann durch Erhöhen der Auflösung des Gitters gelöst werden, aber für dünne Jets über relativ große Entfernungen in Echtzeit ist es einfach unpraktisch, da es die Ausführungszeit und den belegten Speicher stark beeinflusst. (Es gibt einen Präzedenzfall für die Verwendung spärlicher Voxelstrukturen, um die Situation zu verbessern. Ich bin mir jedoch nicht sicher, wie gut dies für dynamische Systeme funktioniert. Dies ist auch nicht der Grad an Komplexität, mit dem ich arbeiten möchte.)
Die erste Alternative, die ich erkundete, war Müllers Screen Space Meshes. Sie verwenden das Rendern von Wasserpartikeln in einen Tiefenpuffer, glätten ihn, erkennen verbundene Fragmente ähnlicher Tiefe und bauen aus dem Ergebnis mithilfe von Marschquadraten ein Netz auf. Heute ist diese Methode wahrscheinlich anwendbarer geworden als 2007 (da wir jetzt ein Netz im Compute-Shader erstellen können), aber sie ist immer noch mit einem höheren Grad an Komplexität und Kosten verbunden, als ich es gerne hätte.
Am Ende fand ich Simon Green's Präsentation mit GDC 2010, Screen Space Fluid Rendering für Spiele. Es beginnt genauso wie Screen Space Meshes: Partikel werden in den Tiefenpuffer gerendert und geglättet. Aber anstatt das Netz zu konstruieren, wird der resultierende Puffer verwendet, um die Flüssigkeit in der Hauptszene zu schattieren und zusammenzusetzen (indem die Tiefe explizit aufgezeichnet wird). Ich habe beschlossen, ein solches System zu implementieren.
Vorbereitung
In mehreren früheren Unity-Projekten habe ich gelernt, mich nicht mit den Einschränkungen beim Rendern der Engine zu befassen. Daher werden Flüssigkeitspuffer von einer zweiten Kamera mit einer geringeren Schärfentiefe gerendert, so dass sie vor der Hauptszene gerendert wird. Jedes Fluidsystem existiert auf einer separaten Rendering-Schicht; Die Hauptkammer schließt eine Wasserschicht aus, und die zweite Kammer gibt nur Wasser ab. Beide Kameras sind Kinder eines leeren Objekts, um ihre relative Ausrichtung sicherzustellen.
Ein solches Schema bedeutet, dass ich fast alles in der Flüssigkeitsschicht rendern kann, und es wird so aussehen, wie ich es erwartet habe. Im Kontext meiner Demoszene bedeutet dies, dass einige Jets und Spritzer von Sub-Emittern zusammengeführt werden können. Darüber hinaus ermöglicht dies das Mischen anderer Wassersysteme, z. B. Volumina basierend auf Höhenfeldern, die dann gleich wiedergegeben werden können. (Ich habe dies noch nicht getestet.)
Die Wasserquelle in meiner Szene ist ein Standardpartikelsystem. Tatsächlich wird keine Flüssigkeitssimulation durchgeführt. Dies bedeutet wiederum, dass sich die Partikel nicht vollständig physikalisch überlappen, das Endergebnis jedoch in der Praxis akzeptabel erscheint.
Flüssigkeitspuffer-Rendering
Der erste Schritt bei dieser Technik besteht darin, den Basisflüssigkeitspuffer zu rendern. Dies ist ein Off-Screen-Puffer, der (zum gegenwärtigen Zeitpunkt meiner Implementierung) Folgendes enthält: Fluidbreite, Bewegungsvektor im Bildschirmraum und Rauschwert. Zusätzlich rendern wir den Tiefenpuffer, indem wir die Tiefe des Fragment-Shaders explizit aufzeichnen, um jedes Viereck eines Partikels in eine kugelförmige (eigentlich elliptische) „Kugel“ zu verwandeln.
Tiefen- und Breitenberechnungen sind ziemlich einfach:
frag_out o; float3 N; N.xy = i.uv*2.0 - 1.0; float r2 = dot(N.xy, N.xy); if (r2 > 1.0) discard; Nz = sqrt(1.0 - r2); float4 pixel_pos = float4(i.view_pos + N * i.size, 1.0); float4 clip_pos = mul(UNITY_MATRIX_P, pixel_pos); float depth = clip_pos.z / clip_pos.w; o.depth = depth; float thick = Nz * i.size * 2;
(Natürlich können Tiefenberechnungen vereinfacht werden; von der Clipposition benötigen wir nur z und w.)
Wenig später kehren wir zum Fragment-Shader für die Bewegungs- und Rauschvektoren zurück.
Der Spaß beginnt im Vertex-Shader, und hier weiche ich von der Green-Technik ab. Das Ziel dieses Projekts ist es, Hochgeschwindigkeits-Wasserstrahlen zu rendern. es kann mit Hilfe von kugelförmigen Partikeln realisiert werden, aber eine große Menge von ihnen wird benötigt, um einen kontinuierlichen Strahl zu erzeugen. Stattdessen werde ich die Vierecke der Partikel basierend auf ihrer Geschwindigkeit dehnen, was wiederum die Tiefenkugeln streckt und sie nicht kugelförmig, sondern elliptisch macht. (Da Tiefenberechnungen auf UV basieren, die sich nicht ändern, funktioniert alles nur.)
Erfahrene Unity-Benutzer fragen sich möglicherweise, warum ich den im Unity-Partikelsystem verfügbaren integrierten Stretched Billboard-Modus einfach nicht verwende. Stretched Billboard führt eine bedingungslose Dehnung entlang des Geschwindigkeitsvektors im Weltraum der Welt durch. Im allgemeinen Fall ist dies durchaus geeignet, führt jedoch zu einem sehr auffälligen Problem, wenn der Geschwindigkeitsvektor zusammen mit dem nach vorne gerichteten Kameravektor (oder sehr nahe daran) gerichtet ist. Die Werbetafel erstreckt sich auf dem Bildschirm, was ihre zweidimensionale Natur sehr deutlich macht.
Stattdessen verwende ich eine Werbetafel, die auf die Kamera gerichtet ist, und projiziere den Geschwindigkeitsvektor auf die Ebene des Partikels, um das Viereck zu dehnen. Wenn der Geschwindigkeitsvektor senkrecht zur Ebene ist (auf den Bildschirm gerichtet oder von ihm weg), bleibt das Partikel ungedehnt und sphärisch, wie es sollte, und wenn es gekippt wird, wird das Partikel in diese Richtung gedehnt, was wir brauchen.
Lassen wir eine lange Erklärung, hier ist eine ziemlich einfache Funktion:
float3 ComputeStretchedVertex(float3 p_world, float3 c_world, float3 vdir_world, float stretch_amount) { float3 center_offset = p_world - c_world; float3 stretch_offset = dot(center_offset, vdir_world) * vdir_world; return p_world + stretch_offset * lerp(0.25f, 3.0f, stretch_amount); }
Um den Bewegungsvektor des Bildschirmraums zu berechnen, berechnen wir zwei Sätze von Positionen von Vektoren:
float3 vp1 = ComputeStretchedVertex( vertex_wp, center_wp, velocity_dir_w, rand); float3 vp0 = ComputeStretchedVertex( vertex_wp - velocity_w * unity_DeltaTime.x, center_wp - velocity_w * unity_DeltaTime.x, velocity_dir_w, rand); o.motion_0 = mul(_LastVP, float4(vp0, 1.0)); o.motion_1 = mul(_CurrVP, float4(vp1, 1.0));
Beachten Sie, dass Unity uns keine vorherige oder unverzerrte aktuelle Projektion aus der Ansicht liefert, da wir Bewegungsvektoren im Hauptdurchgang und nicht im Durchgang von Geschwindigkeitsvektoren berechnen. Um dies zu beheben, habe ich den entsprechenden Partikelsystemen ein einfaches Skript hinzugefügt:
public class ScreenspaceLiquidRenderer : MonoBehaviour { public Camera LiquidCamera; private ParticleSystemRenderer m_ParticleRenderer; private bool m_First; private Matrix4x4 m_PreviousVP; void Start() { m_ParticleRenderer = GetComponent(); m_First = true; } void OnWillRenderObject() { Matrix4x4 current_vp = LiquidCamera.nonJitteredProjectionMatrix * LiquidCamera.worldToCameraMatrix; if (m_First) { m_PreviousVP = current_vp; m_First = false; } m_ParticleRenderer.material.SetMatrix("_LastVP", GL.GetGPUProjectionMatrix(m_PreviousVP, true)); m_ParticleRenderer.material.SetMatrix("_CurrVP", GL.GetGPUProjectionMatrix(current_vp, true)); m_PreviousVP = current_vp; } }
Ich speichere die vorherige Matrix manuell zwischen, da Camera.previousViewProjectionMatrix falsche Ergebnisse liefert.
¯ \ _ (ツ) _ / ¯
(Außerdem verstößt diese Methode gegen das Rendern. In der Praxis kann es ratsam sein, globale Matrixkonstanten festzulegen, anstatt sie für jedes Material zu verwenden.)
Kehren wir zum Fragment-Shader zurück: Wir verwenden die projizierten Positionen, um die Bewegungsvektoren des Bildschirmraums zu berechnen:
float3 hp0 = i.motion_0.xyz / i.motion_0.w; float3 hp1 = i.motion_1.xyz / i.motion_1.w; float2 vp0 = (hp0.xy + 1) / 2; float2 vp1 = (hp1.xy + 1) / 2; #if UNITY_UV_STARTS_AT_TOP vp0.y = 1.0 - vp0.y; vp1.y = 1.0 - vp1.y; #endif float2 vel = vp1 - vp0;
(Die Berechnung der Bewegungsvektoren erfolgt nahezu unverändert aus
https://github.com/keijiro/ParticleMotionVector/blob/master/Assets/ParticleMotionVector/Shaders/Motion.cginc )
Schließlich ist der letzte Wert im Flüssigkeitspuffer Rauschen. Ich verwende eine stabile Zufallszahl für jedes Partikel, um eines von vier Geräuschen auszuwählen (in eine einzelne Textur gepackt). Dann wird es durch Geschwindigkeit und Einheit abzüglich der Partikelgröße skaliert (daher sind schnelle und kleine Partikel lauter). Dieser Rauschwert wird im Schattierungsdurchgang verwendet, um die Normalen zu verzerren und eine Schaumschicht hinzuzufügen. Die Arbeit von Green verwendet dreikanaliges weißes Rauschen, aber eine neuere Arbeit (Screen Space Fluid Rendering mit Krümmungsfluss) schlägt die Verwendung von Perlin-Rauschen vor. Ich verwende Voronoi-Rauschen / Zellenrauschen mit verschiedenen Skalen:
Mischen von Problemen (und Problemumgehungen)
Und hier treten die ersten Probleme meiner Implementierung auf. Zur korrekten Berechnung der Dicke der Partikel werden additiv gemischt. Da das Mischen die gesamte Ausgabe beeinflusst, bedeutet dies, dass Rausch- und Bewegungsvektoren auch additiv gemischt werden. Additives Rauschen passt gut zu uns, aber nicht zu additiven Vektoren. Wenn Sie sie so lassen, wie sie sind, erhalten Sie ekelhaftes Zeit-Anti-Aliasing (TAA) und Bewegungsunschärfe. Um dieses Problem zu lösen, multipliziere ich beim Rendern eines Flüssigkeitspuffers einfach die Bewegungsvektoren mit der Dicke und dividiere durch die Gesamtdicke im Schattierungsdurchlauf. Dies gibt uns einen gewichteten durchschnittlichen Bewegungsvektor für alle überlappenden Partikel; nicht ganz das, was wir brauchen (seltsame Artefakte entstehen, wenn sich mehrere Jets schneiden), aber durchaus akzeptabel.
Ein komplexeres Problem ist die Tiefe; Für eine ordnungsgemäße Wiedergabe des Tiefenpuffers müssen sowohl die Tiefenaufzeichnung als auch die Tiefenprüfung aktiviert sein. Dies kann zu Problemen führen, wenn die Partikel nicht sortiert sind (da der Unterschied in der Renderreihenfolge dazu führen kann, dass die Ausgabe von Partikeln, die von anderen überlappt werden, abgeschnitten wird). Deshalb befehlen wir dem Unity-Partikelsystem, die Partikel nach Tiefe zu sortieren, und drücken dann die Daumen und hoffen. Diese Systeme werden auch in der Tiefe rendern. Wir werden * Fälle * von überlappenden Systemen haben (zum Beispiel den Schnittpunkt zweier Partikelstrahlen), die nicht korrekt verarbeitet werden, was zu einer geringeren Dicke führt. Dies kommt jedoch nicht sehr oft vor und hat keinen großen Einfluss auf das Erscheinungsbild.
Der richtige Ansatz wäre höchstwahrscheinlich, die Tiefen- und Farbpuffer vollständig voneinander zu trennen. Die Amortisation hierfür ist das Rendern in zwei Durchgängen. Es lohnt sich, dieses Problem beim Einrichten des Systems zu untersuchen.
Tiefenglättung
Schließlich das Wichtigste in der Green-Technik. Wir haben ein paar kugelförmige Kugeln in den Tiefenpuffer gerendert, aber in Wirklichkeit besteht Wasser nicht aus „Kugeln“. Nun nehmen wir diese Annäherung und verwischen sie, um sie der Oberfläche einer Flüssigkeit ähnlicher zu machen.
Der naive Ansatz besteht darin, einfach Gaußsche Rauschtiefen auf den gesamten Puffer anzuwenden. Es erzeugt seltsame Ergebnisse - es glättet die entfernten Punkte mehr als die nahen und verwischt die Ränder der Silhouetten. Stattdessen können wir den Unschärferadius in der Tiefe ändern und zweiseitige Unschärfe verwenden, um die Kanten zu speichern.
Hier tritt nur ein Problem auf: Solche Änderungen machen die Unschärfe ununterscheidbar. Gemeinsame Unschärfe kann in zwei Durchgängen ausgeführt werden: horizontal und dann vertikal. Die nicht unterscheidbare Unschärfe erfolgt in einem Durchgang. Dieser Unterschied ist wichtig, da die gemeinsame Unschärfe linear skaliert (O (w) + O (h)) und die nicht gemeinsam genutzte Unschärfe genau skaliert (O (w * h)). Große, nicht gemeinsam genutzte Unschärfen werden in der Praxis schnell nicht mehr anwendbar.
Als Erwachsene, verantwortungsbewusste Entwickler, können wir den offensichtlichen Schritt machen: Schließen Sie unsere Augen, tun Sie so, als ob das Zwei-Wege-Geräusch * geteilt * wird, und implementieren Sie es dennoch mit getrennten horizontalen und vertikalen Gängen.
Green hat in seiner Präsentation gezeigt, dass dieser Ansatz zwar Artefakte im resultierenden Ergebnis erzeugt (insbesondere bei der Rekonstruktion von Normalen), diese jedoch durch die Schattierungsphase gut ausgeblendet werden. Bei der Arbeit mit den von mir erzeugten schmaleren Wasserströmen sind diese Artefakte noch weniger auffällig und wirken sich nicht besonders auf das Ergebnis aus.
Schattierung
Wir haben endlich die Arbeit mit dem Flüssigkeitspuffer beendet. Fahren wir nun mit dem zweiten Teil des Effekts fort: Schattieren und Zusammensetzen des Hauptbilds.
Hier stoßen wir auf viele Unity-Rendering-Einschränkungen. Ich beschloss, das Wasser nur mit dem Licht der Sonne und der Skybox zu beleuchten. Die Unterstützung zusätzlicher Lichtquellen erfordert entweder mehrere Durchgänge (dies ist verschwenderisch!) Oder den Aufbau einer Beleuchtungssuchstruktur auf der GPU-Seite (kostspielig und ziemlich kompliziert). Da Unity keinen Zugriff auf Schattenkarten bietet und gerichtetes Licht Bildschirmschatten verwendet (basierend auf einem Tiefenpuffer, der durch undurchsichtige Geometrie gerendert wird), haben wir keinen Zugriff auf Schatteninformationen von einer Sonnenlichtquelle. Sie können einen Befehlspuffer an eine Sonnenlichtquelle anhängen, um eine Schattenkarte des Bildschirmbereichs speziell für Wasser zu erstellen. Bisher habe ich dies jedoch noch nicht getan.
Die letzte Stufe der Schattierung wird über ein Skript gesteuert und verwendet den Befehlspuffer zum Senden von Zeichnungsaufrufen. Dies ist
erforderlich, da die Bewegungsvektortextur (die für temporäres Anti-Aliasing (TAA) und Bewegungsunschärfe verwendet wird) nicht für das direkte Rendern mit Graphics.SetRenderTarget () verwendet werden kann. In dem an die Hauptkamera angehängten Skript schreiben wir Folgendes:
void Start() {
Farbpuffer und Bewegungsvektoren können nicht gleichzeitig mit MRT (Multi-Rendering-Ziele) gerendert werden. Ich konnte den Grund nicht herausfinden. Außerdem müssen sie an verschiedene Tiefenpuffer gebunden werden. Glücklicherweise schreiben wir die Tiefe in diese
beiden Tiefenpuffer, sodass das erneute Projizieren von temporärem Anti-Aliasing gut funktioniert (oh, es ist eine Freude, mit der Black-Box-Engine zu arbeiten).
In jedem Frame wird ein zusammengesetztes Rendering aus OnPostRender () ausgegeben:
RenderTexture GenerateRefractionTexture() { RenderTexture result = RenderTexture.GetTemporary(m_MainCamera.activeTexture.descriptor); Graphics.Blit(m_MainCamera.activeTexture, result); return result; } void OnPostRender() { if (ScreenspaceLiquidCamera && ScreenspaceLiquidCamera.IsReady()) { RenderTexture refraction_texture = GenerateRefractionTexture(); m_Mat.SetTexture("_MainTex", ScreenspaceLiquidCamera.GetColorBuffer()); m_Mat.SetVector("_MainTex_TexelSize", ScreenspaceLiquidCamera.GetTexelSize()); m_Mat.SetTexture("_LiquidRefractTexture", refraction_texture); m_Mat.SetTexture("_MainDepth", ScreenspaceLiquidCamera.GetDepthBuffer()); m_Mat.SetMatrix("_DepthViewFromClip", ScreenspaceLiquidCamera.GetProjection().inverse); if (SunLight) { m_Mat.SetVector("_SunDir", transform.InverseTransformVector(-SunLight.transform.forward)); m_Mat.SetColor("_SunColor", SunLight.color * SunLight.intensity); } else { m_Mat.SetVector("_SunDir", transform.InverseTransformVector(new Vector3(0, 1, 0))); m_Mat.SetColor("_SunColor", Color.white); } m_Mat.SetTexture("_ReflectionProbe", ReflectionProbe.defaultTexture); m_Mat.SetVector("_ReflectionProbe_HDR", ReflectionProbe.defaultTextureHDRDecodeValues); Graphics.ExecuteCommandBuffer(m_CommandBuffer); RenderTexture.ReleaseTemporary(refraction_texture); } }
Und hier endet die CPU-Beteiligung, später gehen nur noch Shader.
Beginnen wir mit dem Durchgang von Bewegungsvektoren. So sieht der gesamte Shader aus:
#include "UnityCG.cginc" sampler2D _MainDepth; sampler2D _MainTex; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata v) { v2f o; o.vertex = mul(UNITY_MATRIX_P, v.vertex); o.uv = v.uv; return o; } struct frag_out { float4 color : SV_Target; float depth : SV_Depth; }; frag_out frag(v2f i) { frag_out o; float4 fluid = tex2D(_MainTex, i.uv); if (fluid.a == 0) discard; o.depth = tex2D(_MainDepth, i.uv).r; float2 vel = fluid.gb / fluid.a; o.color = float4(vel, 0, 1); return o; }
Die Geschwindigkeit im Bildschirmbereich wird im grünen und blauen Kanal des Flüssigkeitspuffers gespeichert. Da wir die Geschwindigkeit beim Rendern des Puffers durch die Dicke skaliert haben, teilen wir erneut die Gesamtdicke (im Alphakanal), um eine gewichtete Durchschnittsgeschwindigkeit zu erhalten.
Es ist anzumerken, dass beim Arbeiten mit großen Wassermengen möglicherweise eine andere Methode zur Verarbeitung des Geschwindigkeitspuffers erforderlich ist. Da wir ohne Mischen rendern, gehen die Bewegungsvektoren für alles
hinter dem Wasser verloren, wodurch die TAA und die Bewegungsunschärfe dieser Objekte zerstört werden. Wenn Sie mit dünnen Wasserströmen arbeiten, ist dies kein Problem, kann jedoch bei der Arbeit mit einem Pool oder See stören, wenn TAA- oder Bewegungsunschärfeobjekte erforderlich sind, um durch die Oberfläche deutlich sichtbar zu sein.
Interessanter ist der Hauptschattierungspass. Unsere erste Priorität nach dem Maskieren mit der Dicke der Flüssigkeit ist die Rekonstruktion der Position und Normalen des Betrachtungsraums (Sichtraum).
float3 ViewPosition(float2 uv) { float clip_z = tex2D(_MainDepth, uv).r; float clip_x = uv.x * 2.0 - 1.0; float clip_y = 1.0 - uv.y * 2.0; float4 clip_p = float4(clip_x, clip_y, clip_z, 1.0); float4 view_p = mul(_DepthViewFromClip, clip_p); return (view_p.xyz / view_p.w); } float3 ReconstructNormal(float2 uv, float3 vp11) { float3 vp12 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, 1)); float3 vp10 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(0, -1)); float3 vp21 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(1, 0)); float3 vp01 = ViewPosition(uv + _MainTex_TexelSize.xy * float2(-1, 0)); float3 dvpdx0 = vp11 - vp12; float3 dvpdx1 = vp10 - vp11; float3 dvpdy0 = vp11 - vp21; float3 dvpdy1 = vp01 - vp11;
Dies ist eine kostspielige Methode, um die Position des Betrachtungsraums zu rekonstruieren: Wir nehmen die Position im Clipraum ein und führen den umgekehrten Vorgang der Projektion durch.
Nachdem wir eine Möglichkeit zur Rekonstruktion von Positionen erhalten haben, sind die Normalen einfacher: Wir berechnen die Position benachbarter Punkte im Tiefenpuffer und konstruieren daraus eine Tangentenbasis. Um mit den Kanten von Silhouetten zu arbeiten, probieren wir in beide Richtungen und wählen den Punkt aus, der dem Ansichtsraum am nächsten liegt, um die Normalen zu rekonstruieren. Diese Methode funktioniert überraschend gut und verursacht nur bei sehr dünnen Objekten Probleme.
Dies bedeutet, dass wir fünf separate Rückprojektionsoperationen pro Pixel ausführen (für den aktuellen Punkt und vier benachbarte). Es gibt einen günstigeren Weg, aber dieser Beitrag ist bereits zu lang, sodass ich ihn für später belassen werde.
Die resultierenden Normalen sind:
Ich verzerre diese berechnete Normalen unter Verwendung der Ableitungen des Rauschwerts aus dem Flüssigkeitspuffer, skaliert durch den Kraftparameter und normalisiert durch Teilen durch die Dicke des Strahls (aus dem gleichen Grund wie für die Geschwindigkeit):
N.xy += NoiseDerivatives(i.uv, fluid.r) * (_NoiseStrength / fluid.a); N = normalize(N);
Wir können endlich mit der Schattierung selbst fortfahren. Wasserschattierung besteht aus drei Hauptteilen: Spiegelreflexion, Spiegelrefraktion und Schaum.
Reflection ist ein Standard-GGX, der vollständig aus dem Standard-Unity-Shader stammt. (Bei einer Korrektur wird das korrekte F0 von 2% für Wasser verwendet.)
Mit der Brechung ist alles interessanter. Die korrekte Brechung erfordert Raytracing (oder Raymarching für ein ungefähres Ergebnis). Glücklicherweise ist die Brechung für das Auge weniger intuitiv als die Reflexion, und daher sind falsche Ergebnisse nicht so auffällig. Daher verschieben wir die UV-Probe für die Brechungstextur um x- und y-Normalen, skaliert nach dem Parameter Dicke und Kraft:
float aspect = _MainTex_TexelSize.y * _MainTex_TexelSize.z; float2 refract_uv = (i.grab_pos.xy + N.xy * float2(1, -aspect) * fluid.a * _RefractionMultiplier) / i.grab_pos.w; float4 refract_color = tex2D(_LiquidRefractTexture, refract_uv);
(Beachten Sie, dass die Korrelationskorrektur verwendet wird. Sie ist
optional - schließlich handelt es sich nur um eine Annäherung, aber das Hinzufügen ist recht einfach.)
Dieses gebrochene Licht geht durch die Flüssigkeit, so dass ein Teil davon absorbiert wird:
float3 water_color = _AbsorptionColor.rgb * _AbsorptionIntensity; refract_color.rgb *= exp(-water_color * fluid.a);
Beachten Sie, dass _AbsorptionColor genau umgekehrt wie erwartet bestimmt wird: Die Werte jedes Kanals geben die Menge des
absorbierten und nicht des durchgelassenen Lichts an. Daher ergibt _AbsorptionColor mit einem Wert von (1, 0, 0) kein Rot, sondern eine türkisfarbene Farbe (blaugrün).
Reflexion und Brechung werden unter Verwendung von Fresnel-Koeffizienten gemischt:
float spec_blend = lerp(0.02, 1.0, pow(1.0 - ldoth, 5)); float4 clear_color = lerp(refract_color, spec, spec_blend);
Bis zu diesem Moment haben wir uns (meistens) an die Regeln gehalten und physische Schattierungen verwendet.
Er ist ziemlich gut, aber er hat ein Problem mit Wasser. Es ist ein wenig schwer zu sehen:
Um das Problem zu beheben, fügen wir etwas Schaum hinzu.
Schaum entsteht, wenn Wasser turbulent ist und sich Luft mit Wasser unter Bildung von Blasen vermischt. Solche Blasen erzeugen alle Arten von Variationen in Reflexion und Brechung, was dem gesamten Wasser ein Gefühl diffuser Beleuchtung verleiht. Ich werde dieses Verhalten mit umwickeltem Umgebungslicht modellieren:
float3 foam_color = _SunColor * saturate((dot(N, L)*0.25f + 0.25f));
Es wird der endgültigen Farbe unter Verwendung eines speziellen Faktors hinzugefügt, abhängig vom Geräusch der Flüssigkeit und dem erweichten Fresnel-Koeffizienten:
float foam_blend = saturate(fluid.r * _NoiseStrength) * lerp(0.05f, 0.5f, pow(1.0f - ndotv, 3)); clear_color.rgb += foam_color * saturate(foam_blend);
Eingewickeltes Umgebungslicht wird normalisiert, um Energie zu sparen, sodass es als Annäherung an die Diffusion verwendet werden kann. Das Mischen der Farbe des Schaums ist deutlicher. Es ist ein ziemlich klarer Verstoß gegen das Energieerhaltungsgesetz.
Aber im Allgemeinen sieht alles gut aus und macht den Stream auffälliger:
Weitere Arbeiten und Verbesserungen
In dem erstellten System kann viel verbessert werden.
- Mehrere Farben verwenden. Im Moment wird die Absorption erst in der letzten Stufe der Schattierung berechnet und verwendet eine konstante Farbe und Helligkeit für die gesamte Flüssigkeit auf dem Bildschirm. Die Unterstützung für verschiedene Farben ist möglich, erfordert jedoch einen zweiten Farbpuffer und die Lösung des Absorptionsintegrals für jedes Partikel beim Rendern des Basisflüssigkeitspuffers. Dies könnte möglicherweise eine kostspielige Operation sein.
- Volle Abdeckung. Durch den Zugriff auf die Beleuchtungssuchstruktur auf der GPU-Seite (entweder von Hand oder dank der Bindung an die neue Unity HD-Rendering-Pipeline) können wir Wasser mit einer beliebigen Anzahl von Lichtquellen richtig beleuchten und die richtige Umgebungsbeleuchtung erzeugen.
- Verbesserte Brechung. Mit den verschwommenen Mip-Texturen der Hintergrundtextur können wir die Brechung für raue Oberflächen besser simulieren. In der Praxis ist dies für kleine Flüssigkeitssprays nicht sehr nützlich, kann jedoch für größere Volumina nützlich sein.
Wenn ich die Gelegenheit hätte, würde ich dieses System bis zum Verlust eines Pulses verbessern, aber im Moment kann es als vollständig bezeichnet werden.