Hierarchischer Tiefenpuffer


Kurze Review


Ein hierarchischer Tiefenpuffer ist ein mehrstufiger Tiefenpuffer (Z-Puffer), der als Beschleunigungsstruktur zum Abfragen von Tiefen verwendet wird. Wie im Fall von Textur-Mip-Ketten sind die Größen jeder Ebene normalerweise das Ergebnis der Division der Größe des Puffers mit voller Auflösung durch den Grad zwei. In diesem Artikel werde ich über zwei Möglichkeiten sprechen, einen hierarchischen Tiefenpuffer aus einem Puffer mit voller Auflösung zu generieren.

Zunächst werde ich zeigen, wie eine vollständige Mip-Kette für einen Tiefenpuffer generiert wird, wodurch die Genauigkeit von Tiefenabfragen im Texturkoordinatenraum (NDC) auch für Tiefenpuffergrößen ungleich Zweierpotenzen erhalten bleibt. (Im Internet bin ich auf Codebeispiele gestoßen, die diese Genauigkeit nicht garantieren, was die Ausführung genauer Abfragen bei hohen Mip-Levels erschwert.)

In Fällen, in denen nur eine Downsampling-Ebene erforderlich ist, werde ich zeigen, wie diese Ebene mit einem einzigen Aufruf eines Compute-Shaders mithilfe atomarer Operationen im gemeinsam genutzten Speicher der Arbeitsgruppe generiert wird. Für meine Anwendung, bei der nur eine Auflösung von 1/16 x 1/16 erforderlich ist (Mip-Level 4), ist die Methode mit dem Computational Shader 2-3 Mal schneller als der übliche Ansatz, bei dem die Mip-Kette in mehreren Durchgängen heruntergesampelt wird.

Einführung


Hierarchische Tiefen (auch Hi-Z genannt) sind eine Technik, die häufig in 3D-Grafiken verwendet wird. Es wird verwendet, um das Trimmen unsichtbarer Geometrie (Okklusions-Culling) (sowohl in der CPU als auch in der GPU ), die Berechnung von Reflexionen im Bildschirmraum , volumetrischen Nebel und vieles mehr zu beschleunigen .

Darüber hinaus werden Hi-Z- GPUs häufig als Teil einer Rasterisierungspipeline implementiert . Schnelle Hi-Z-Suchvorgänge in den Caches auf dem Chip ermöglichen es Ihnen, Fragmentkacheln vollständig zu überspringen, wenn sie vollständig von zuvor gerenderten Grundelementen abgedeckt sind.

Die Grundidee von Hi-Z besteht darin, Tiefenabfragevorgänge durch Lesen aus Puffern mit niedrigerer Auflösung zu beschleunigen. Dies ist aus zwei Gründen schneller als das Lesen von Tiefen mit voller Auflösung aus dem Puffer:

  1. Ein Texel (oder nur einige Texel) eines Puffers mit niedrigerer Auflösung kann als ein ungefährer Wert einer Vielzahl von Texeln eines Puffers mit hoher Auflösung verwendet werden.
  2. Ein Puffer mit niedrigerer Auflösung kann klein genug sein und zwischengespeichert werden, was die Ausführung von Suchvorgängen (insbesondere bei wahlfreiem Zugriff) erheblich beschleunigt.

Der Inhalt der heruntergesampelten Hi-Z-Pufferpegel hängt davon ab, wie sie verwendet werden (ob der Tiefenpuffer "invertiert" wird , welche Arten von Anforderungen verwendet werden sollten). Im Allgemeinen speichert ein Texel auf der Hi-Z-Pufferebene minoder maxalle ihm entsprechenden Texel auf der vorherigen Ebene. Manchmal werden die Werte von minund gleichzeitig gespeichert max. Einfache gemittelte Werte (die häufig in den Mip-Ebenen regulärer Texturen verwendet werden) werden selten verwendet, da sie für solche Abfragetypen selten nützlich sind.

Hi-Z-Puffer werden am häufigsten fast sofort am Ausgang angefordert, um eine weitere Verarbeitung und genauere Suchvorgänge im Puffer mit voller Auflösung zu vermeiden. Zum Beispiel, wenn wir Werte speichernmaxFür einen nicht invertierten Tiefenpuffer (in dem das Objekt umso größer ist, je größer der Tiefenwert ist) können wir schnell genau bestimmen, ob eine bestimmte Position im Bildschirmbereich von einem Tiefenpuffer abgedeckt wird (wenn seine Koordinate Z> der in einigen gespeicherte Wert (max) ist höherer Pegel (d. h. niedrigere Auflösung) des Hi-Z-Puffers).

Bitte beachten Sie, dass ich den Ausdruck „genau“ verwendet habe: Wenn die Koordinate Z <= der empfangene Wert (max) ist, ist nicht bekannt, ob sich der Puffer überlappt. In einigen Anwendungen kann es in Fällen von Unsicherheit erforderlich sein, den Puffer nach Tiefen mit voller Auflösung zu durchsuchen. In anderen Fällen ist dies nicht erforderlich (z. B. wenn nur unnötige Berechnungen auf dem Spiel stehen und nicht das richtige Rendering).

Meine Anwendung: Rendern von Partikeln in einem Computational Shader


Ich war mit der Notwendigkeit konfrontiert, Hi-Z bei der Implementierung des Partikel-Renderings in einem Computational Shader in der Engine meiner PARTICULATE VR-Anwendung zu verwenden . Da diese Rendering-Technik keine Rasterung mit festen Funktionen verwendet, muss für jedes Partikel mit einer Größe von einem Pixel eine eigene Tiefenprüfung verwendet werden. Und da die Partikel in keiner Weise sortiert sind, ist der Zugriff auf den Tiefenpuffer (im schlimmsten Fall) nahezu zufällig.

Suchvorgänge auf einer Vollbild-Textur mit wahlfreiem Zugriff führen zu einer schlechten Leistung. Um die Last zu reduzieren, suche ich zuerst nach Tiefen im Puffer mit reduzierter Tiefe mit einer Auflösung von 1/16 x 1/16 gegenüber dem Original. Dieser Puffer enthält Tiefenwerte.minDadurch kann der Computer-Rendering-Shader für die überwiegende Mehrheit der sichtbaren Partikel den Tiefentest mit voller Auflösung überspringen. (Wenn die Partikeltiefe <die minimale Tiefe ist, die im Puffer mit niedrigerer Auflösung gespeichert ist, wissen wir, dass sie absolut sichtbar ist. Wenn sie> = min ist, müssen wir den Tiefenpuffer mit voller Auflösung überprüfen.)

Dank dessen ist der Tiefentest für sichtbare Partikel im allgemeinen Fall wird zu einem kostengünstigen Betrieb. (Für Partikel, die sich durch Geometrie überlappen, ist es teurer, aber es passt zu uns, weil es keine Renderkosten verursacht. Daher erfordern Partikel immer noch wenig Berechnung.)

Aufgrund der Tatsache, dass die Suche zuerst im Puffer von Tiefen mit niedrigerer Auflösung durchgeführt wird (wie oben erwähnt). wird die Partikelwiedergabezeit um maximal 35% reduziertim Vergleich zu dem Fall, in dem die Suche nur im Puffer mit voller Auflösung durchgeführt wird. Daher ist Hi-Z für meine Anwendung sehr vorteilhaft.

Nun werden zwei Techniken zum Erzeugen eines hierarchischen Tiefenpuffers betrachtet.

Technik 1: Generieren einer vollständigen Mip-Kette


In vielen Hi-Z-Anwendungen ist die Erstellung einer vollständigen Tiefenpuffer-Mip-Kette erforderlich. Wenn Sie beispielsweise ein Okklusions-Culling mit Hi-Z durchführen, wird das Begrenzungsvolumen in den Bildschirmbereich projiziert und anhand der projizierten Größe die geeignete Mip-Ebene ausgewählt (sodass bei jedem Überlappungstest eine feste Anzahl von Texeln beteiligt ist).

Das Erzeugen einer Mip-Kette aus dem Tiefenpuffer mit voller Auflösung ist normalerweise eine einfache Aufgabe - für jedes Texel auf Stufe N nehmen wir max(oder minoder beide) die entsprechenden 4 Texel in der zuvor erzeugten Stufe N-1. Wir führen sequentielle Durchgänge durch (jedes Mal, wenn die Größe um die Hälfte reduziert wird), bis wir die letzte Mip-Stufe 1x1 in der Größe erhalten.

Bei Tiefenpuffern, deren Größe nicht der Zweierpotenz entspricht, ist jedoch alles komplizierter. Da Hi-Z für Tiefenpuffer häufig aus Standard-Bildschirmauflösungen (die selten Zweierpotenzen sind) erstellt wird, müssen wir eine zuverlässige Lösung für dieses Problem finden.

Lassen Sie uns zunächst entscheiden, was der Wert jedes Tiefenpuffers auf Texel-Mip-Ebene bedeutet. In diesem Artikel wird davon ausgegangen, dass die MIP-Kette Werte speichert min. Tiefensuchoperationen sollten die Filterung der nächsten Nachbarn verwenden, da die Interpolation von Werten minfür uns nutzlos ist und die hierarchische Natur der erstellten Mip-Kette von Tiefen beeinträchtigt.

Was genau bedeutet der von uns erhaltene Wert des einzelnen Texels auf der Mip-Ebene N? Dies sollte der Mindestwert sein (min) aller Texel des Vollbild-Tiefenpuffers, der denselben Platz im (normalisierten) Texturkoordinatenraum einnimmt.

Mit anderen Worten, wenn eine separate Koordinate der Textur (im Intervall[0,1]2) auf ein einzelnes Texel des Puffers mit voller Auflösung abgebildet wird (durch Filtern der nächsten Nachbarn), dann sollte dieses Texel mit voller Auflösung als Kandidat für den minfür das Texel berechneten Wert bei jedem nachfolgenden höheren Mip-Level betrachtet werden, mit dem dieselbe Texturkoordinate abgebildet wird.

Wenn diese Entsprechung garantiert ist, werden wir sicher sein, dass die Suchoperation bei hohen Mip-Pegeln niemals den Tiefenwert> Texelwert in derselben Texturkoordinate zurückgibt, die dem Puffer mit voller Auflösung (Ebene 0) entspricht. Bei einem separaten N bleibt diese Garantie für alle darunter liegenden Ebenen erhalten (<N).

Für gerade Dimensionen (und im Fall von Puffern mit voller Auflösung, bei denen es sich um Zweierpotenzen handelt, sind gerade Dimensionen auf jeder Ebene bis zur allerletzten Ebene, bei der die Dimensionen gleich 1 werden) einfach durchzuführen. Im eindimensionalen Fall für Texel mit einem Indexi auf Stufe N müssen wir Texel auf Stufe N-1 mit Indizes nehmen 2und 2i+1und finden ihre Bedeutung min. AlsoDN[i]=min(DN1[2i],DN1[2i+1]). Wir können die Texel direkt im Verhältnis „2 zu 1“ (und damit in der Größe der Texturkoordinaten) vergleichen, da die Größe jeder Ebene genau zweimal kleiner ist als die der vorherigen.


Ein Beispiel für gleichmäßige Levelgrößen: 6 Texel auf diesem Level werden auf einem höheren Level auf 3 reduziert. Die Texturkoordinatengrößen jedes der drei Texel auf hoher Ebene werden genau auf zwei Texel auf niedrigerer Ebene überlagert. (Punkte sind die Zentren von Texeln, und Quadrate sind die Abmessungen der Texturkoordinate beim Filtern der nächsten Nachbarn.)

Bei ungeraden Ebenengrößen (und Puffer mit voller Auflösung, die keine Zweierpotenz haben, haben mindestens eine Ebene mit einer ungeraden Größe) alles Härter werden. Für Stufe N-1 von ungerader GrößedimN1 Die Größe der nächsten Ebene (N) ist gleich dimN=dimN12, also dimN12.

Dies bedeutet, dass wir jetzt keine eindeutige 2-zu-1-Zuordnung von Texeln der Ebene N-1 zu Texeln der Ebene N haben. Nun wird die Größe der Texturkoordinate jedes Texels der Ebene N der Größe von 3 Texeln der Ebene N-1 überlagert .


Ein Beispiel für eine ungerade Levelgröße: 7 Texel dieses Levels werden auf dem nächsten Level auf 3 Texel reduziert. Die Dimensionen der Texturkoordinaten der drei übergeordneten Texel werden den Größen der drei Texel aus der unteren Ebene überlagert.

DaherDN[i]=min(DN1[2i],DN1[2i+1],DN1[2i+2]). Dies bedeutet, dass ein Texel auf der Ebene von N-1 manchmal den minfür 2 Texel auf der Ebene von N berechneten Wert beeinflusst . Dies ist erforderlich, um den oben beschriebenen Vergleich aufrechtzuerhalten.

Die obige Beschreibung wurde der Einfachheit halber nur in einer Dimension dargestellt. Wenn in zwei Dimensionen beide Dimensionen der N-1-Ebene gerade sind, wird der 2x2-Texelbereich auf der N-1-Ebene auf ein Texel auf der N-Ebene abgebildet. Wenn eine der Dimensionen ungerade ist, wird der 2x3- oder 3x2-Bereich auf der N-1-Ebene auf einen abgebildet Texel auf Ebene N. Wenn beide Dimensionen ungerade sind , sollte auch das "eckige" Texel berücksichtigt werden, dh der 3x3-Bereich auf Ebene N-1 wird mit einem Texel auf Ebene N verglichen.

Codebeispiel


Der unten gezeigte GLSL-Shader-Code implementiert den von uns beschriebenen Algorithmus. Es muss für jeden nachfolgenden Mip ab Stufe 1 ausgeführt werden (Stufe 0 ist die volle Auflösungsstufe).

uniform sampler2D u_depthBuffer;
uniform int u_previousLevel;
uniform ivec2 u_previousLevelDimensions;

void main() {
	ivec2 thisLevelTexelCoord = ivec2(gl_FragCoord);
	ivec2 previousLevelBaseTexelCoord = 2 * thisLevelTexelCoord;

	vec4 depthTexelValues;
	depthTexelValues.x = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord,
                                    u_previousLevel).r;
	depthTexelValues.y = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord + ivec2(1, 0),
                                    u_previousLevel).r;
	depthTexelValues.z = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord + ivec2(1, 1),
                                    u_previousLevel).r;
	depthTexelValues.w = texelFetch(u_depthBuffer,
                                    previousLevelBaseTexelCoord + ivec2(0, 1),
                                    u_previousLevel).r;

	float minDepth = min(min(depthTexelValues.x, depthTexelValues.y),
                         min(depthTexelValues.z, depthTexelValues.w));

    // Incorporate additional texels if the previous level's width or height (or both) 
    // are odd. 
	bool shouldIncludeExtraColumnFromPreviousLevel = ((u_previousLevelDimensions.x & 1) != 0);
	bool shouldIncludeExtraRowFromPreviousLevel = ((u_previousLevelDimensions.y & 1) != 0);
	if (shouldIncludeExtraColumnFromPreviousLevel) {
		vec2 extraColumnTexelValues;
		extraColumnTexelValues.x = texelFetch(u_depthBuffer,
                                              previousLevelBaseTexelCoord + ivec2(2, 0),
                                              u_previousLevel).r;
		extraColumnTexelValues.y = texelFetch(u_depthBuffer,
                                              previousLevelBaseTexelCoord + ivec2(2, 1),
                                              u_previousLevel).r;

		// In the case where the width and height are both odd, need to include the 
        // 'corner' value as well. 
		if (shouldIncludeExtraRowFromPreviousLevel) {
			float cornerTexelValue = texelFetch(u_depthBuffer,
                                                previousLevelBaseTexelCoord + ivec2(2, 2),
                                                u_previousLevel).r;
			minDepth = min(minDepth, cornerTexelValue);
		}
		minDepth = min(minDepth, min(extraColumnTexelValues.x, extraColumnTexelValues.y));
	}
	if (shouldIncludeExtraRowFromPreviousLevel) {
		vec2 extraRowTexelValues;
		extraRowTexelValues.x = texelFetch(u_depthBuffer,
                                           previousLevelBaseTexelCoord + ivec2(0, 2),
                                           u_previousLevel).r;
		extraRowTexelValues.y = texelFetch(u_depthBuffer,
                                           previousLevelBaseTexelCoord + ivec2(1, 2),
                                           u_previousLevel).r;
		minDepth = min(minDepth, min(extraRowTexelValues.x, extraRowTexelValues.y));
	}

	gl_FragDepth = minDepth;
}

Fehler in diesem Code


Erstens können Aufrufindizes bei Tiefenpuffern mit voller Auflösung, bei denen eine Dimension mehr als doppelt so groß ist wie eine andere Dimension, texelFetchdarüber hinausgehen u_depthBuffer. (In solchen Fällen wird die kleinere Dimension zu 1 vor der anderen.) Ich wollte in diesem Beispiel texelFetch(unter Verwendung von Ganzzahlkoordinaten) verwenden, damit das Geschehen so klar wie möglich war und nicht persönlich auf solche Puffer mit besonders großer / hoher Tiefe stieß. Wenn Sie auf solche Probleme stoßen, können Sie clampdie übertragenen texelFetchKoordinaten begrenzen ( ) oder die texturenormalisierten Koordinaten der Textur verwenden (legen Sie im Sampler eine Grenze für die Kante fest). Bei der Berechnung minoder maxsollte immer ein Texel mehrmals für das Vorhandensein von Grenzfällen berücksichtigt werden.

Zweitens erschwert dies die Sache , obwohl die ersten vier Anrufe texelFetchdurch einen ersetzt werden können textureGather(da der textureGatherMip-Pegel nicht angegeben werden kann). Außerdem habe ich bei der Verwendung keine Geschwindigkeitssteigerung festgestellt textureGather.

Performance


Ich habe den obigen Fragment-Shader verwendet, um zwei vollständige Mip-Ketten für zwei Tiefenpuffer (einen für jedes Auge) in meiner VR-Engine zu generieren. Im Test betrug die Auflösung für jedes Auge 1648 x 1776, was zur Schaffung von 10 zusätzlichen reduzierten Mip-Levels führte (was 10 Durchgänge bedeutet). Bei der NVIDIA GTX 980 dauerte es 0,25 ms und bei der AMD R9 290 0,30 ms, um eine vollständige Kette für beide Augen zu erzeugen.



Mip- 4, 5 6, , . ( , , , .) Mip- 4 — , (103x111) 2.

mip-


Die Aufgabe des oben beschriebenen Algorithmus besteht darin, die Genauigkeit von Tiefenabfragen im Texturkoordinatenraum (NDC) aufrechtzuerhalten. Der Vollständigkeit halber (und weil ich diese Garantie in der Technik unter 2 abgelehnt habe) möchte ich eine weitere Methode demonstrieren, auf die ich gestoßen bin (zum Beispiel in diesem Artikel ).

Beachten Sie, dass diese alternative Methode wie die vorherige für Puffer mit voller Auflösung ausgelegt ist, deren Größe keine Zweierpotenzen ist (aber natürlich mit Größen, die Zweierpotenzen entsprechen).

Wenn bei dieser alternativen Methode eine Ebene mit einer ungeraden Breite (oder Höhe) heruntergerechnet wird, anstatt für jedes Ausgabetext eine zusätzliche Spalte (oder Zeile) von Texeln aus der vorherigen (unteren) Ebene hinzuzufügen, führen wir diese Operation nur für Ausgabetexel mit maximalen Indizes („extreme“ Texel) aus ) Das einzige, was sich im oben dargestellten Fragment-Shader ändert, ist das Festlegen von Werten shouldIncludeExtraColumnFromPreviousLevelund shouldIncludeExtraRowFromPreviousLevel:

// If the previous level's width is odd and this is the highest-indexed "edge" texel for 
// this level, incorporate the rightmost edge texels from the previous level. The same goes 
// for the height. 
bool shouldIncludeExtraColumnFromPreviousLevel =
    (previousMipLevelBaseTexelCoords.x == u_previousLevelDimensions.x - 3);
bool shouldIncludeExtraRowFromPreviousLevel =
    (previousMipLevelBaseTexelCoords.y == u_previousLevelDimensions.y - 3);

Aus diesem Grund werden die extremen Texel mit dem höchsten Index sehr "dick", da jede Division durch zwei ungerade Dimensionen dazu führt, dass sie ein proportional größeres Intervall des normalisierten Texturkoordinatenraums einnehmen.

Der Nachteil dieses Ansatzes besteht darin, dass es schwieriger wird, Tiefenabfragen mit hohen Mip-Pegeln durchzuführen. Anstatt nur die normalisierten Texturkoordinaten zu verwenden, müssen wir zuerst das diesen Koordinaten entsprechende Texel mit voller Auflösung bestimmen und dann die Koordinaten dieses Texels auf die Koordinaten der entsprechenden Mip-Ebene übertragen, deren Anforderung ausgeführt wird.

Das folgende Codefragment migriert aus dem NDC-Bereich[1,1]2zu texel Koordinaten auf Mip-Ebene higherMipLevel:

vec2 windowCoords = (0.5 * ndc.xy + vec2(0.5)) * textureSize(u_depthBuffer, 0);
// Account for texel centers being halfway between integers. 
ivec2 texelCoords = ivec2(round(windowCoords.xy - vec2(0.5)));
ivec2 higherMipLevelTexelCoords =
    min(texelCoords / (1 << higherMipLevel),
        textureSize(u_depthBuffer, higherMipLevel).xy - ivec2(1));

Technik 2: Generieren eines einzelnen Hi-Z-Levels mit einem Computing-Shader


Das Generieren einer vollständigen Mip-Kette ist ziemlich schnell, aber es hat mich ein wenig gestört, dass meine Anwendung alle diese Ebenen generiert und nur eine davon verwendet (Ebene 4). Zusätzlich zur Beseitigung dieser leichten Ineffizienz wollte ich auch sehen, um wie viel alles beschleunigt werden könnte, wenn ich nur einen Compute-Shader-Aufruf verwenden würde, um das benötigte Level zu generieren. (Es ist erwähnenswert, dass meine Anwendung bei Verwendung einer Lösung mit einem Fragment-Shader mit mehreren Durchgängen auf Stufe 4 anhalten kann. Daher habe ich sie am Ende dieses Abschnitts als Grundlage für den Vergleich der Laufzeiten verwendet.)

In den meisten Hi-Z-Anwendungen ist nur eine Tiefenstufe erforderlich, daher finde ich diese Situation häufig. Ich habe einen Computational Shader für meine eigenen spezifischen Anforderungen geschrieben (Generieren von Level 4 mit einer Auflösung von 1/16 x 1/16 gegenüber dem Original). Ähnlicher Code kann verwendet werden, um verschiedene Ebenen zu generieren.

Der Computational Shader ist für diese Aufgabe gut geeignet, da er gemeinsam genutzten Arbeitsgruppenspeicher zum Datenaustausch zwischen Threads verwenden kann. Jede Arbeitsgruppe ist für ein Ausgabe-Texel verantwortlich (reduziert durch Downsampling des Puffers), und die Threads der Arbeitsgruppe teilen sich die Arbeit der Berechnung der minentsprechenden Texel mit voller Auflösung und teilen die Ergebnisse über den gemeinsamen Speicher.

Ich habe zwei Hauptlösungen ausprobiert, die auf Computer-Shadern basieren. Im ersten Schritt forderte jeder Thread atomicMineine gemeinsam genutzte Speichervariable an.

Bitte beachten Sie, dass floathier einige Tricks erforderlich sind, da Programmierer (ohne Erweiterungen für die Hardware eines bestimmten Herstellers) keine atomaren Operationen mit nicht ganzzahligen Werten ausführen können (und meine Tiefen als gespeichert werden ). Da nicht negative Gleitkommawerte des IEEE 754-Standards ihre Reihenfolge beibehalten, wenn ihre Bits als vorzeichenlose ganzzahlige Werte verarbeitet werden, können wir floatBitsToUintdie Tiefenwerte floatauf (unter Verwendung einer Neuinterpretation der Umwandlung) bringen uintund dann aufrufen atomicMin(um dann uintBitsToFloatden fertigen Mindestwert auszuführen uint). .

Die naheliegendste Lösung atomicMinwäre, 16x16-Thread-Gruppen zu erstellen, in denen jeder Thread ein Texel empfängt und es dann atomicMinmit einem Wert im gemeinsam genutzten Speicher ausführt . Ich habe diesen Ansatz mit kleineren Stream-Blöcken (8x8, 4x8, 4x4, 2x4, 2x2) verglichen, bei denen jeder Stream eine Texelregion empfängt und sein eigenes lokales Minimum berechnet und dann aufruft atomicMin.

Die schnellste aller dieser getesteten Lösungen mitatomicMinSowohl NVIDIA als auch AMD hatten eine Lösung mit 4x4-Stream-Blöcken (in denen jeder Stream selbst einen 4x4-Texel-Bereich erhält). Ich verstehe nicht ganz, warum sich diese Option als die schnellste herausstellte, aber vielleicht spiegelt sie einen Kompromiss zwischen der Konkurrenz atomarer Operationen und Berechnungen in unabhängigen Flüssen wider. Es ist auch erwähnenswert, dass die Größe der 4x4-Arbeitsgruppe nur 16 Threads pro Warp / Welle verwendet (und es ist auch möglich, 32 oder 64 zu verwenden), was interessant ist. Das folgende Beispiel implementiert diesen Ansatz.

Als Alternative zur Verwendung habe atomicMinich versucht, eine parallele Reduktion mithilfe der in dieser aktiv zitierten NVIDIA-Präsentation verwendeten Techniken durchzuführen. (Die Grundidee besteht darin, ein gemeinsam genutztes Speicherarray mit der gleichen Größe wie die Anzahl der Threads in der Arbeitsgruppe zu verwendenlog2(n)Durchgänge für die sequentielle gemeinsame Berechnung der minMinima jedes Stroms, bis das endgültige Minimum der gesamten Arbeitsgruppe erreicht ist.)

Ich habe diese Lösung mit allen gleichen Größen von Arbeitsgruppen wie in Lösung c ausprobiert atomicMin. Trotz aller in der NVIDIA-Präsentation beschriebenen Optimierungen ist die Lösung zur parallelen Reduzierung etwas langsamer (innerhalb von zehn GPUs auf beiden GPUs) als die Lösung, zu der atomicMinich gekommen bin. Darüber hinaus ist die Lösung mit atomicMinin Bezug auf Code viel einfacher.

Codebeispiel


Bei dieser Methode ist es am einfachsten, nicht zu versuchen, die Konsistenz im normalisierten Texturkoordinatenraum zwischen Texeln mit reduzierten Puffern und voller Auflösung aufrechtzuerhalten. Sie können einfach Konvertierungen von Texelkoordinaten mit voller Auflösung in Texelkoordinaten mit reduzierter Auflösung durchführen:

ivec2 reducedResTexelCoords = texelCoords / ivec2(downscalingFactor);

In meinem Fall (das Äquivalent von Mip-Level 4 downscalingFactorwird generiert) ist 16.

Wie oben erwähnt, implementiert dieser GLSL-Computational-Shader eine Lösung mit atomicMin4x4-Arbeitsgruppengröße, bei der jeder Thread einen 4x4-Texelbereich aus dem Puffer mit voller Auflösung empfängt. Der resultierende Tiefenpuffer mit reduzierter Tiefe minbeträgt 1/16 x 1/16 der Puffergröße mit voller Auflösung (aufgerundet, wenn die Größen mit voller Auflösung nicht vollständig durch 16 teilbar sind).

uniform sampler2D u_inputDepthBuffer;
uniform restrict writeonly image2DArray u_outputDownsampledMinDepthBufferImage;
// The dimension in normalized texture coordinate space of a single texel in 
// u_inputDepthBuffer. 
uniform vec2 u_texelDimensions;

// Resulting image is 1/16th x 1/16th resolution, but we fetch 4x4 texels per thread, hence 
// the divisions by 4 here. 
layout(local_size_x = 16/4, local_size_y = 16/4, local_size_z = 1) in;

// This is stored as uint because atomicMin only works on integer types. Luckily 
// (non-negative) floats maintain their order when their bits are interpreted as uint (using 
// floatBitsToUint). 
shared uint s_workgroupMinDepthEncodedAsUint;

void main() {
	if (gl_LocalInvocationIndex == 0) {
        // Initialize to 1.0 (max depth) before performing atomicMin's. 
		s_workgroupMinDepthEncodedAsUint = floatBitsToUint(1.0);
	}

	memoryBarrierShared();
	barrier();

	// Fetch a 4x4 texel region per thread with 4 calls to textureGather. 'gatherCoords' 
    // are set up to be equidistant from the centers of the 4 texels being gathered (which 
    // puts them on integer values). In my tests textureGather was not faster than 
    // individually fetching each texel - I use it here only for conciseness. 
    // 
    // Note that in the case of the full-res depth buffer's dimensions not being evenly 
    // divisible by the downscaling factor (16), these textureGather's may try to fetch 
    // out-of-bounds coordinates - that's fine as long as the texture sampler is set to 
    // clamp-to-edge, as redundant values don't affect the resulting min. 

	uvec2 baseTexelCoords = 4 * gl_GlobalInvocationID.xy;
	vec2 gatherCoords1 = (baseTexelCoords + uvec2(1, 1)) * u_texelDimensions;
	vec2 gatherCoords2 = (baseTexelCoords + uvec2(3, 1)) * u_texelDimensions;
	vec2 gatherCoords3 = (baseTexelCoords + uvec2(1, 3)) * u_texelDimensions;
	vec2 gatherCoords4 = (baseTexelCoords + uvec2(3, 3)) * u_texelDimensions;

	vec4 gatheredTexelValues1 = textureGather(u_inputDepthBuffer, gatherCoords1);
	vec4 gatheredTexelValues2 = textureGather(u_inputDepthBuffer, gatherCoords2);
	vec4 gatheredTexelValues3 = textureGather(u_inputDepthBuffer, gatherCoords3);
	vec4 gatheredTexelValues4 = textureGather(u_inputDepthBuffer, gatherCoords4);

	// Now find the min across the 4x4 region fetched, and apply that to the workgroup min 
    // using atomicMin. 
	vec4 gatheredTexelMins = min(min(gatheredTexelValues1, gatheredTexelValues2),
                                 min(gatheredTexelValues3, gatheredTexelValues4));
	float finalMin = min(min(gatheredTexelMins.x, gatheredTexelMins.y),
                         min(gatheredTexelMins.z, gatheredTexelMins.w));
	atomicMin(s_workgroupMinDepthEncodedAsUint, floatBitsToUint(finalMin));

	memoryBarrierShared();
	barrier();

    // Thread 0 writes workgroup-wide min to image. 
	if (gl_LocalInvocationIndex == 0) {
		float workgroupMinDepth = uintBitsToFloat(s_workgroupMinDepthEncodedAsUint);

		imageStore(u_outputDownsampledMinDepthBufferImage,
		           ivec2(gl_WorkGroupID.xy),
                   // imageStore can only be passed vec4, but only a float is stored. 
				   vec4(workgroupMinDepth));
	}
}

Performance


Ich habe den obigen Berechnungs-Shader verwendet, um den Tiefenpuffer mit voller Auflösung mit denselben Abmessungen zu verarbeiten, die zum Generieren der vollständigen Mip-Kette verwendet wurden (1648 x 1776 Puffer für jedes Auge). Es läuft in 0,12 ms auf der NVIDIA GTX 980 und 0,08 ms auf der AMD R9 290. Wenn wir mit der Generierungszeit von nur 1–4 Mip-Pegeln (0,22 ms auf NVIDIA, 0,25 ms AMD) vergleichen, dann die Lösung mit einem rechnerischen Shader stellte sich heraus , 87% schneller mit NVIDIA - GPUs zu sein und 197% schneller als AMD - GPUs .

In absoluten Zahlen ist die Beschleunigung nicht so groß, aber alle 0,1 ms ist wichtig, besonders in VR :)

All Articles