Schneller und einfacher Algorithmus zum Rendern von Volumes


Ich habe kürzlich einen kleinen ShaderToy geschrieben, der einfaches volumetrisches Rendern ausführt, und dann beschlossen, einen Beitrag zu veröffentlichen, in dem seine Arbeit erklärt wird. Das interaktive ShaderToy selbst kann hier angesehen werden . Wenn Sie von einem Telefon oder Laptop lesen, empfehle ich, diese Schnellversion anzusehen. Ich habe Codefragmente in den Beitrag aufgenommen, die Ihnen helfen, die Leistung von ShaderToy auf hohem Niveau zu verstehen, aber sie enthalten nicht alle Details. Wenn Sie tiefer graben möchten, empfehle ich, den ShaderToy-Code zu verwenden.

Mein ShaderToy hatte drei Hauptaufgaben:

  1. Echtzeitausführung
  2. Einfachheit
  3. Körperliche Korrektheit (... oder so ähnlich)

Ich werde mit dieser leeren Codeszene beginnen. Ich werde nicht auf Details der Implementierung eingehen, da diese nicht sehr interessant ist, aber ich werde Ihnen kurz sagen, wo wir anfangen:

  1. Raytracing von undurchsichtigen Objekten. Alle Objekte sind Grundelemente mit einfachen Schnittpunkten mit Strahlen (1 Ebene und 3 Kugeln)
  2. Zur Berechnung der Beleuchtung wird die Phong-Schattierung verwendet, und bei drei sphärischen Lichtquellen wird ein benutzerdefinierter Lichtschwächungskoeffizient verwendet. Schattenstrahlen sind nicht erforderlich, da wir nur die Ebene beleuchten.

So sieht es aus:

ShaderToy-Screenshot

Wir werden den Band als separate Passage rendern, die sich mit einer undurchsichtigen Szene mischt. Dies ähnelt der Art und Weise, wie alle Echtzeit-Rendering-Engines undurchsichtige und durchscheinende Oberflächen einzeln verarbeiten.

Teil 1: Volumen simulieren


Aber bevor wir mit dem volumetrischen Rendern beginnen können, benötigen wir dasselbe Volume! Um die Lautstärke zu simulieren, habe ich mich für die Verwendung von SDF (Signed Distance Functions) entschieden. Warum genau die Funktionen von Distanzfeldern? Weil ich kein Künstler bin, aber sie ermöglichen es Ihnen, sehr organische Formen in nur wenigen Codezeilen zu erstellen. Ich werde nicht im Detail über die Funktionen von Entfernungen mit einem Zeichen sprechen, da Inigo Kiles sie bereits wunderbar erklärt hat. Wenn Sie neugierig sind, gibt es eine große Liste verschiedener Funktionen von Vorzeichenabständen und Modifikatoren. Und hier ist ein weiterer Artikel über diese Raymarching SDF.

Beginnen wir mit einer einfachen und fügen hier eine Kugel hinzu:

ShaderToy-Screenshot

Jetzt fügen wir eine weitere Kugel hinzu und verwenden eine glatte Konjugation, um die Abstandsfunktionen der Kugeln zusammenzuführen. Diesen Code habe ich direkt von der Inigo-Seite übernommen, aber aus Gründen der Übersichtlichkeit werde ich ihn hier einfügen:

// Taken from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSmoothUnion( float d1, float d2, float k ) 
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

Smooth Pairing ist ein äußerst leistungsfähiges Werkzeug, da Sie etwas ganz Interessantes erhalten können, indem Sie es einfach mit ein paar einfachen Formen kombinieren. So sehen meine vielen glatt konjugierten Kugeln aus:

ShaderToy-Screenshot

Wir haben also etwas Tropfenförmiges, aber wir brauchen eher eine Wolke als einen Tropfen. Ein großartiges Merkmal von SDF ist, wie einfach es ist, die Oberfläche durch einfaches Hinzufügen von etwas Rauschen zum SDF zu verzerren. Fügen wir also eine fraktale Brownsche Bewegung (fBM) über das Rauschen hinzu und verwenden Sie die Position, um die Rauschfunktion zu indizieren. Inigo Kiles behandelte dieses Thema auch in einem großartigen Artikel über fBM-Rauschen. So sieht das Bild mit überlagertem fBM-Rauschen aus:

ShaderToy-Screenshot

Fein! Dank fBM-Rauschen sah das Objekt plötzlich viel interessanter aus!

Jetzt müssen wir die Illusion erzeugen, dass das Volumen mit der Ebene der Erde interagiert. Dazu habe ich einen Abstand der vorzeichenbehafteten Ebene etwas unterhalb der Grundebene hinzugefügt und die Kombination aus glatter Paarung mit einem sehr aggressiven Paarungswert (Parameter k) wiederverwendet. Danach haben wir dieses Bild bekommen:

ShaderToy-Screenshot

Der letzte Schliff wird die Änderung des xz-Index des fBM-Rauschens im Laufe der Zeit sein, so dass die Lautstärke wie wirbelnder Nebel aussieht. Unterwegs sieht es sehr gut aus!

ShaderToy-Screenshot

Großartig, wir haben so etwas wie eine Wolke! Der SDF-Berechnungscode ist auch recht kompakt:

float QueryVolumetricDistanceField( in vec3 pos)
{    
    vec3 fbmCoord = (pos + 2.0 * vec3(iTime, 0.0, iTime)) / 1.5f;
    float sdfValue = sdSphere(pos, vec3(-8.0, 2.0 + 20.0 * sin(iTime), -1), 5.6);
    sdfValue = sdSmoothUnion(sdfValue,sdSphere(pos, vec3(8.0, 8.0 + 12.0 * cos(iTime), 3), 5.6), 3.0f);
    sdfValue = sdSmoothUnion(sdfValue, sdSphere(pos, vec3(5.0 * sin(iTime), 3.0, 0), 8.0), 3.0) + 7.0 * fbm_4(fbmCoord / 3.2);
    sdfValue = sdSmoothUnion(sdfValue, sdPlane(pos + vec3(0, 0.4, 0)), 22.0);
    return sdfValue;
}

Dies rendert nur ein undurchsichtiges Objekt. Wir brauchen einen schönen herrlichen Nebel!

Wie rendern wir es in Form eines Volumens und nicht als undurchsichtiges Objekt? Lassen Sie uns zuerst über die Physik sprechen, die wir simulieren. Das Volumen ist eine große Anzahl von Partikeln in einem bestimmten Raumbereich. Und wenn ich "riesig" sage, meine ich "RIESIG". So sehr, dass die Modellierung jedes dieser Partikel heute selbst für das Offline-Rendering eine unmögliche Aufgabe ist. Gute Beispiele dafür sind Feuer, Nebel und Wolken. Genau genommen ist alles Volumen, aber aus Gründen der Geschwindigkeit der Berechnungen ist es einfacher, die Augen davor zu schließen und so zu tun, als ob dies nicht der Fall wäre. Wir stellen die Akkumulation dieser Partikel als Dichtewerte dar, die normalerweise in einer Art 3D-Gitter (oder etwas Komplexerem, beispielsweise in OpenVDB) gespeichert sind.

Wenn Licht durch ein Volumen tritt, können zwei Phänomene auftreten, wenn Licht mit einem Partikel kollidiert. Es kann entweder streuen und in die andere Richtung gehen, oder ein Teil des Lichts kann vom Partikel absorbiert werden und sich auflösen. Um die Anforderungen an die Echtzeitausführung zu erfüllen, führen wir eine sogenannte Einzelstreuung durch. Dies bedeutet Folgendes: Wir gehen davon aus, dass Licht nur einmal gestreut wird, wenn das Licht mit einem Partikel kollidiert und in Richtung Kamera fliegt. Das heißt, wir werden nicht in der Lage sein, die Auswirkungen der Mehrfachstreuung zu simulieren, z. B. Nebel, bei dem Objekte in einiger Entfernung normalerweise vager aussehen. Für unser System ist dies jedoch völlig ausreichend. So sieht Einzelstreuung beim Raymarchen aus:

ShaderToy-Screenshot

Der Pseudocode dafür sieht ungefähr so ​​aus:

for n steps along the camera ray:
   Calculate what % of your ray hit particles (i.e. were absorbed) and needs lighting
   for m lights:
      for k steps towards the light:
         Calculate % of light that were absorbe in this step
      Calculate lighting based on how much light is visible
Blend results on top of opaque objects pass based on % of your ray that made it through the volume

Das heißt, wir haben es mit Berechnungen mit der Komplexität O (n * m * k) zu tun. Die GPU muss also hart arbeiten.

Wir berechnen die Absorption


Betrachten wir zunächst die Absorption von Licht im Volumen entlang des Strahls der Kamera (d. H. Lassen Sie uns noch kein Raymarchen in Richtung der Lichtquellen durchführen). Dazu benötigen wir zwei Aktionen:

  1. Führen Sie ein Raymarchen innerhalb des Volumens durch
  2. Berechnen Sie die Absorption / Beleuchtung bei jedem Schritt

Um zu berechnen, wie viel Licht an jedem Punkt absorbiert wird, verwenden wir das Bouguer-Lambert-Beer-Gesetz , das die Lichtschwächung beim Durchgang durch ein Material beschreibt. Die Berechnungen sind überraschend einfach:

float BeerLambert(float absorptionCoefficient, float distanceTraveled)
{
    return exp(-absorptionCoefficient * distanceTraveled);
}

Der Absorptionskoeffizient ist ein Materialparameter. Beispielsweise ist in einem transparenten Volumen, beispielsweise in Wasser, dieser Wert niedrig, und für etwas Dickeres, beispielsweise Milch, ist der Koeffizient höher.

Um Volumen-Raymarchen durchzuführen, machen wir einfach Schritte fester Größe entlang des Strahls und erhalten bei jedem Schritt Absorption. Sie verstehen vielleicht nicht, warum Sie feste Schritte anstelle von etwas Schnellerem ausführen sollen, z. B. das Verfolgen einer Kugel. Wenn Sie sich jedoch daran erinnern, dass die Dichte innerhalb des Volumens heterogen ist, wird alles klar. Unten finden Sie den Raymarch- und Akkumulationsabsorptionscode. Einige Variablen liegen außerhalb des Bereichs dieses Code-Snippets. Überprüfen Sie daher die vollständige Implementierung in ShaderToy.

float opaqueVisiblity = 1.0f;
const float marchSize = 0.6f;
for(int i = 0; i < MAX_VOLUME_MARCH_STEPS; i++) {
	volumeDepth += marchSize;
	if(volumeDepth > opaqueDepth) break;
	
	vec3 position = rayOrigin + volumeDepth*rayDirection;
	bool isInVolume = QueryVolumetricDistanceField(position) < 0.0f;
	if(isInVolume) 	{
		float previousOpaqueVisiblity = opaqueVisiblity;
		opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
		float absorptionFromMarch = previousOpaqueVisiblity - opaqueVisiblity;
		for(int lightIndex = 0; lightIndex < NUM_LIGHTS; lightIndex++) {
			float lightDistance = length((GetLight(lightIndex).Position - position));
			vec3 lightColor = GetLight(lightIndex).LightColor * GetLightAttenuation(lightDistance);  
			volumetricColor += absorptionFromMarch * volumeAlbedo * lightColor;
		}
		volumetricColor += absorptionFromMarch * volumeAlbedo * GetAmbientLight();
	}
}

Und hier ist, was wir damit bekommen:

ShaderToy-Screenshot

Sieht aus wie Zuckerwatte! Vielleicht reicht dies für einige Effekte! Aber es fehlt uns an Selbstbeschattung. Licht erreicht alle Teile des Volumens gleichermaßen. Dies ist jedoch physikalisch nicht korrekt. Abhängig von der Größe des Volumens zwischen dem gerenderten Punkt und der Lichtquelle erhalten wir eine unterschiedliche Menge an einfallendem Licht.

Selbstbeschattung


Das Schwierigste haben wir schon gemacht. Wir müssen das Gleiche tun wie bei der Berechnung der Absorption entlang des Kamerastrahls, jedoch nur entlang des Lichtstrahls. Der Code zum Berechnen der Lichtmenge, die jeden Punkt erreicht, ist im Wesentlichen eine Wiederholung des Codes, aber das Duplizieren ist einfacher als das Hacken von HLSL, um die benötigte Rekursion zu erhalten. So wird es also aussehen:

float GetLightVisiblity(in vec3 rayOrigin, in vec3 rayDirection, in float maxT, in int maxSteps, in float marchSize) {
    float t = 0.0f;
    float lightVisiblity = 1.0f;
    for(int i = 0; i < maxSteps; i++) {                       
        t += marchSize;
        if(t > maxT) break;

        vec3 position = rayOrigin + t*rayDirection;
        if(QueryVolumetricDistanceField(position) < 0.0) {
            lightVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
        }
    }
    return lightVisiblity;
}

Durch Hinzufügen von Selbstbeschattung erhalten wir Folgendes:

ShaderToy-Screenshot

Erweichen Sie die Kanten


Im Moment gefällt mir unser Volumen schon ganz gut. Ich zeigte ihn dem talentierten Leiter der VFX-Abteilung der Koalition, James Sharp. Er bemerkte sofort, dass die Ränder des Volumens zu scharf aussahen. Und das ist absolut richtig - Objekte wie Wolken sind ständig im Raum um sie herum verstreut, sodass sich ihre Kanten mit dem leeren Raum um das Volumen vermischen, was zur Bildung sehr glatter Kanten führen sollte. James bot mir eine großartige Idee an - die Dichte zu reduzieren, je nachdem, wie nah wir am Rand sind. Und da wir mit Distanzfunktionen mit Vorzeichen arbeiten, ist die Implementierung sehr einfach! Fügen wir also eine Funktion hinzu, mit der die Dichte an jedem Punkt des Volumes angefordert werden kann:

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
    return sdfMultiplier;
}

Und dann kollabieren wir es einfach in den Absorptionswert:

opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT * GetFogDensity(position), marchSize);

Und so sieht es aus:

ShaderToy-Screenshot

Dichtefunktion


Jetzt, da wir die Dichtefunktion haben, können Sie der Lautstärke leicht ein wenig Rauschen hinzufügen, um ihr zusätzliche Details und Pracht zu verleihen. In diesem Fall verwende ich nur die fBM-Funktion, mit der wir die Volumenform angepasst haben.

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
   return sdfMultiplier * abs(fbm_4(position / 6.0) + 0.5);
}

Und so haben wir folgendes bekommen:

ShaderToy-Screenshot

Undurchsichtige Selbstbeschattung


Die Lautstärke sieht schon ziemlich hübsch aus! Aber ein wenig Licht tritt immer noch durch. Hier sehen wir, wie die grüne Farbe dort versickert, wo das Volumen sie definitiv absorbieren sollte:

ShaderToy-Screenshot

Dies liegt daran, dass undurchsichtige Objekte gerendert werden, bevor das Volume gerendert wird, sodass sie die durch das Volume verursachte Schattierung nicht berücksichtigen. Dies ist recht einfach zu beheben. Wir haben eine GetLightVisiblity-Funktion, mit der die Schattierung berechnet werden kann. Sie müssen sie also nur aufrufen, um ein undurchsichtiges Objekt zu beleuchten. Wir bekommen folgendes:

ShaderToy-Screenshot

Dies erzeugt nicht nur schöne mehrfarbige Schatten, sondern verbessert auch die Schatten und sorgt für mehr Volumen in der Szene. Darüber hinaus erhalten wir dank der glatten Kanten des Volumens weiche Schatten, obwohl wir streng genommen mit punktuellen Beleuchtungsquellen arbeiten. Das ist alles! Hier kann noch viel mehr getan werden, aber es scheint mir, dass ich die visuelle Qualität erreicht habe, die ich brauche, während die relative Einfachheit des Beispiels erhalten bleibt.

Optimierungen


Am Ende werde ich einige mögliche Optimierungen kurz auflisten:

  1. Vor dem Raymarchen in Richtung der Lichtquelle muss anhand des Wertes der Lichtauslöschung überprüft werden, ob eine signifikante Menge dieses Lichts tatsächlich den fraglichen Punkt erreicht. In meiner Implementierung betrachte ich die Helligkeit des Lichts multipliziert mit der Albedo des Materials und stelle sicher, dass der Wert groß genug ist, damit Raymarching durchgeführt werden kann.
  2. , , raymarching
  3. raymarching . , . , raymarching , .


Das ist alles! Persönlich war ich überrascht, dass Sie mit so wenig Code (ca. 500 Zeilen) etwas ganz physikalisch Korrektes erstellen können. Danke fürs Lesen, ich hoffe es war interessant.

Und noch eine Anmerkung: Hier ist eine lustige Änderung: Ich habe die Lichtemission basierend auf der SDF-Entfernung hinzugefügt, um einen Explosionseffekt zu erzielen. Explosionen gibt es schließlich nie.

ShaderToy-Screenshot

All Articles