Ein gutes Layout und eine gute Komprimierung der Vertex-Daten sind wesentlich für die Leistung grafischer Anwendungen, unabhängig davon, ob eine App aus 2D-Benutzeroberflächen besteht oder ein großes Open-World-Spiel in 3D ist. Interne Tests mit dem Frame Profiler von Android GPU Inspector in Dutzenden der beliebtesten Android-Spiele deuten darauf hin, dass noch viel getan werden könnte, um die Vertex-Datenverwaltung zu verbessern. Wir haben beobachtet, dass Scheitelpunktdaten häufig die höchste Genauigkeit, 32-Bit-Gleitkommawerte für alle Scheitelpunktattribute und ein Scheitelpunkt-Zwischenspeicherlayout verwenden, das ein Array von Strukturen verwendet, die mit vollständig verschränkten Attributen formatiert sind.
In diesem Artikel wird erläutert, wie Sie die Grafikleistung Ihrer Android-App mithilfe folgender Methoden optimieren können:
- Vertex-Komprimierung
- Vertex-Stream-Aufteilung
Die Implementierung dieser Techniken kann die Nutzung der Vertex-Speicherbandbreite um bis zu 50 % verbessern, Speicherbuskonflikte mit der CPU reduzieren, Verzögerungen im Systemspeicher reduzieren und die Akkulaufzeit verbessern. All dies sind Gewinne für Entwickler und Endnutzer.
Alle präsentierten Daten stammen aus einer statischen Szene mit etwa 19.000.000 Eckpunkte, die auf einem Pixel 4 ausgeführt werden:
Abbildung 1: Beispielszene mit 6 Ringen und 19 m Scheitelpunkten
Vertex-Komprimierung
Vertex-Komprimierung ist ein Oberbegriff für verlustbehaftete Komprimierungstechniken, Verwenden Sie ein effizientes Packen, um die Größe der Vertex-Daten sowohl während der Laufzeit als auch im Speicher zu reduzieren. Das Reduzieren der Größe von Eckpunkten hat mehrere Vorteile, z. B. die Reduzierung der Arbeitsspeicherbandbreite auf der GPU (durch den Austausch von Rechenressourcen gegen Bandbreite), eine verbesserte Cache-Auslastung und möglicherweise das Risiko, dass Register überlaufen.
Gängige Ansätze für Vertex Compression sind:
- Reduzieren der numerischen Genauigkeit von Vertex-Datenattributen (z. B. von 32-Bit-Gleitkomma auf 16-Bit-Gleitkommazahl)
- Attribute in verschiedenen Formaten darstellen
Wenn ein Scheitelpunkt beispielsweise vollständige 32-Bit-Gleitkommazahlen für Position (vec3), normal (vec3) und Texturkoordinaten (vec2) verwendet, wird die Vertex-Größe um 50 % reduziert (16 Byte bei einem durchschnittlichen 32-Byte-Vertex).
Scheitelpunktpositionen
Vertex-Positionsdaten können in den meisten Mesh-Netzwerken von 32-Bit-Gleitkommawerten mit voller Genauigkeit zu 16-Bit-Gleitkommawerten mit halber Genauigkeit komprimiert werden. In der Hardware werden auf fast allen Mobilgeräten halbe Gleitkommazahlen unterstützt. Eine Konvertierungsfunktion von „float32“ zu „float16“ sieht so aus (angepasst an dieser Anleitung):
uint16_t f32_to_f16(float f) {
uint32_t x = (uint32_t)f;
uint32_t sign = (unsigned short)(x >> 31);
uint32_t mantissa;
uint32_t exp;
uint16_t hf;
mantissa = x & ((1 << 23) - 1);
exp = x & (0xFF << 23);
if (exp >= 0x47800000) {
// check if the original number is a NaN
if (mantissa && (exp == (0xFF << 23))) {
// single precision NaN
mantissa = (1 << 23) - 1;
} else {
// half-float will be Inf
mantissa = 0;
}
hf = (((uint16_t)sign) << 15) | (uint16_t)((0x1F << 10)) |
(uint16_t)(mantissa >> 13);
}
// check if exponent is <= -15
else if (exp <= 0x38000000) {
hf = 0; // too small to be represented
} else {
hf = (((uint16_t)sign) << 15) | (uint16_t)((exp - 0x38000000) >> 13) |
(uint16_t)(mantissa >> 13);
}
return hf;
}
Bei diesem Ansatz gibt es eine Einschränkung: Die Genauigkeit verringert sich, wenn der Scheitelpunkt weiter vom Ursprung entfernt wird. Daher eignet er sich weniger für Mesh-Netzwerke, die räumlich sehr groß sind (Scheitelpunkte mit Elementen, die über 1024 hinausgehen). Sie können dies erreichen, indem Sie ein Mesh-Netzwerk in kleinere Blöcke aufteilen, jeden Block um den Modellursprung zentrieren und so skalieren, dass alle Eckpunkte für jeden Block in den Bereich [-1, 1] passen, der die höchste Genauigkeit für Gleitkommawerte enthält. Der Pseudocode für die Komprimierung sieht so aus:
for each position p in Mesh:
p -= center_of_bounding_box // Moves Mesh back to the center of model space
p /= half_size_bounding_box // Fits the mesh into a [-1, 1] cube
vec3<float16> result = vec3(f32_to_f16(p.x), f32_to_f16(p.y), f32_to_f16(p.z));
Sie binden den Skalierungsfaktor und die Übersetzung in die Modellmatrix ein, um die Scheitelpunktdaten beim Rendern zu dekomprimieren. Denken Sie daran, dass Sie nicht dieselbe Modellmatrix zum Transformieren von Normalen verwenden möchten, da auf sie nicht die gleiche Komprimierung angewendet wurde. Sie benötigen eine Matrix ohne diese Dekomprimierungstransformationen für Normalen. Alternativ können Sie die Basismodellmatrix, die Sie für Normalen verwenden können, verwenden und dann die zusätzlichen Dekomprimierungstransformationen auf die Modellmatrix innerhalb des Shaders anwenden. Ein Beispiel:
vec3 in in_pos;
void main() {
...
// bounding box data packed into uniform buffer
vec3 decompress_pos = in_pos * half_size_bounding_box + center_of_bounding_box;
gl_Position = proj * view * model * decompress_pos;
}
Eine andere Methode ist die Verwendung von signierten normalisierten Ganzzahlen (SNORM). SNORM-Datentypen verwenden Ganzzahlen anstelle von Gleitkommazahlen, um Werte zwischen [-1, 1] darzustellen. Mit einem 16-Bit-SNORM für Positionen erzielen Sie die gleiche Speichereinsparung wie mit einem Float16, ohne die Nachteile einer ungleichförmigen Verteilung. Eine Implementierung, die wir für die Verwendung von SNORM empfehlen, sieht so aus:
const int BITS = 16
for each position p in Mesh:
p -= center_of_bounding_box // Moves Mesh back to the center of model space
p /= half_size_bounding_box // Fits the mesh into a [-1, 1] cube
// float to integer value conversion
p = clamp(p * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1)
Formatieren | Größe | |
---|---|---|
Vorher | vec4<float32 > |
16 Byte |
Nachher | vec3<float16/SNORM16 > |
6 Byte |
Vertex-Normalen und Tangens
Vertex Normals werden für die Beleuchtung und der Tangens für kompliziertere Techniken wie das normale Kartografieren benötigt.
Tangens
Der Tangens ist ein Koordinatensystem, in dem jeder Scheitelpunkt aus dem Normal-, Tangens- und Bitangensvektor besteht. Da diese drei Vektoren normalerweise orthogonal zueinander sind, müssen wir nur zwei davon speichern und können den dritten berechnen, indem wir ein Kreuzprodukt der anderen beiden im Scheitel-Shader verwenden.
Diese Vektoren können in der Regel mit 16-Bit-Gleitkommazahlen dargestellt werden, ohne dass ein Wahrnehmungsverlust in der Grafikqualität entsteht. Das ist also ein guter Anfang!
Wir können diese Komprimierung mit einer als QTangens bezeichneten Technik weiter komprimieren, bei der der gesamte Tangensraum in einer einzigen Quaternion gespeichert wird. Da Quaternionen zur Darstellung von Rotationen verwendet werden können, können wir die Tangentenvektoren als Spaltenvektoren einer 3x3-Matrix betrachten, die eine Drehung darstellt (in diesem Fall vom Modellraum in den Tangens). Daher können wir zwischen beiden umrechnen! Ein Quaternion kann datenbezogen als vec4 behandelt werden und eine Umwandlung von Tangentenvektoren in einen QTangent basierend auf dem oben verlinkten Artikel und der Anpassung aus der Implementierung hier sieht so aus:
const int BITS = 16
quaternion tangent_space_to_quat(vec3 normal, vec3 tangent, vec3 bitangent) {
mat3 tbn = {normal, tangent, bitangent};
quaternion qTangent(tbn);
qTangent.normalize();
//Make sure QTangent is always positive
if (qTangent.w < 0)
qTangent = -qTangent;
const float bias = 1.0 / (2^(BITS - 1) - 1);
//Because '-0' sign information is lost when using integers,
//we need to apply a "bias"; while making sure the Quaternion
//stays normalized.
// ** Also our shaders assume qTangent.w is never 0. **
if (qTangent.w < bias) {
Real normFactor = Math::Sqrt( 1 - bias * bias );
qTangent.w = bias;
qTangent.x *= normFactor;
qTangent.y *= normFactor;
qTangent.z *= normFactor;
}
//If it's reflected, then make sure .w is negative.
vec3 naturalBinormal = cross_product(tangent, normal);
if (dot_product(naturalBinormal, binormal) <= 0)
qTangent = -qTangent;
return qTangent;
}
Die Quaternion wird normalisiert und kann mit SNORMs komprimiert werden. 16-Bit-SNORMs bieten eine gute Präzision und Arbeitsspeichereinsparungen. 8-Bit-SNORMs können noch mehr Einsparungen bieten, können jedoch auf stark spekulierten Materialien Artefakte verursachen. Sie können beide ausprobieren, um zu sehen, was für Ihre Assets am besten funktioniert. Die Codierung der Quaternion sieht so aus:
for each vertex v in mesh:
quaternion res = tangent_space_to_quat(v.normal, v.tangent, v.bitangent);
// Once we have the quaternion we can compress it
res = clamp(res * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1);
So decodieren Sie die Quaternion im Vertex-Shader (von hier angepasst):
vec3 xAxis( vec4 qQuat )
{
float fTy = 2.0 * qQuat.y;
float fTz = 2.0 * qQuat.z;
float fTwy = fTy * qQuat.w;
float fTwz = fTz * qQuat.w;
float fTxy = fTy * qQuat.x;
float fTxz = fTz * qQuat.x;
float fTyy = fTy * qQuat.y;
float fTzz = fTz * qQuat.z;
return vec3( 1.0-(fTyy+fTzz), fTxy+fTwz, fTxz-fTwy );
}
vec3 yAxis( vec4 qQuat )
{
float fTx = 2.0 * qQuat.x;
float fTy = 2.0 * qQuat.y;
float fTz = 2.0 * qQuat.z;
float fTwx = fTx * qQuat.w;
float fTwz = fTz * qQuat.w;
float fTxx = fTx * qQuat.x;
float fTxy = fTy * qQuat.x;
float fTyz = fTz * qQuat.y;
float fTzz = fTz * qQuat.z;
return vec3( fTxy-fTwz, 1.0-(fTxx+fTzz), fTyz+fTwx );
}
void main() {
vec4 qtangent = normalize(in_qtangent); //Needed because 16-bit quantization
vec3 normal = xAxis(qtangent);
vec3 tangent = yAxis(qtangent);
float biNormalReflection = sign(in_qtangent.w); //ensured qtangent.w != 0
vec3 binormal = cross(normal, tangent) * biNormalReflection;
...
}
Formatieren | Größe | |
---|---|---|
Vorher | vec3<float32 > + vec3<float32 > + vec3<float32 > |
36 Byte |
Nachher | vec4<SNORM16 > |
8 Byte |
Nur Normale
Wenn Sie nur normale Vektoren speichern müssen, gibt es einen anderen Ansatz, der zu mehr Einsparungen führen kann: Verwenden Sie die Oktaedrische Zuordnung von Einheitsvektoren anstelle von kartesischen Koordinaten, um den normalen Vektor zu komprimieren. Bei der Oktaedralzuordnung wird eine Einheitskugel auf ein Oktaeder projiziert und dann in eine 2D-Ebene projiziert. Das Ergebnis ist, dass Sie jeden normalen Vektor mit nur zwei Zahlen darstellen können. Diese beiden Zahlen können Sie sich als Texturkoordinaten vorstellen, mit denen wir die 2D-Ebene, auf die wir die Kugel projiziert haben, „abtasten“, damit wir den ursprünglichen Vektor wiederherstellen können. Diese beiden Nummern können dann in einem SNORM8 gespeichert werden.
Abbildung 2: Visualisierung der Oktahedral-Kartierung (Quelle)
const int BITS = 8
// Assumes the vector is unit length
// sign() function should return positive for 0
for each normal n in mesh:
float invL1Norm = 1.0 / (abs(n.x) + abs(n.y) + abs(n.z));
vec2 res;
if (n.z < 0.0) {
res.x = (1.0 - abs(n.y * invL1Norm)) * sign(n.x);
res.y = (1.0 - abs(n.x * invL1Norm)) * sign(n.y);
} else {
res.x = n.x * invL1Norm;
res.y = n.y * invL1Norm;
}
res = clamp(res * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1)
Die Dekomprimierung im Scheitelpunkt-Shader (zur Rückkonvertierung in kartesische Koordinaten) ist kostengünstig. Bei den meisten modernen Mobilgeräten konnten wir bei der Implementierung dieser Technik keine signifikante Leistungsverschlechterung feststellen. Die Dekomprimierung im Vertex-Shader:
//Additional Optimization: twitter.com/Stubbesaurus/status/937994790553227264
vec3 oct_to_vec(vec2 e):
vec3 v = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y));
float t = max(-v.z, 0.0);
v.xy += t * -sign(v.xy);
return v;
Dieser Ansatz kann auch verwendet werden, um den gesamten Tangensraum zu speichern, indem mit dieser Technik der Normal- und Tangensvektor mit vec2<SNORM8
> gespeichert werden. Sie müssen jedoch eine Möglichkeit finden, die Richtung der Bitangens zu speichern. Dies ist für das gängige Szenario mit gespiegelten UV-Koordinaten auf einem Modell erforderlich. Eine Möglichkeit zur Implementierung besteht darin, eine Komponente der Tangensvektorcodierung so abzubilden, dass sie immer positiv ist, und dann das Zeichen umdrehen, wenn Sie die Bitangensrichtung umkehren und das im Scheitel-Shader überprüfen müssen:
const int BITS = 8
const float bias = 1.0 / (2^(BITS - 1) - 1)
// Compressing
for each normal n in mesh:
//encode to octahedron, result in range [-1, 1]
vec2 res = vec_to_oct(n);
// map y to always be positive
res.y = res.y * 0.5 + 0.5;
// add a bias so that y is never 0 (sign in the vertex shader)
if (res.y < bias)
res.y = bias;
// Apply the sign of the binormal to y, which was computed elsewhere
if (binormal_sign < 0)
res.y *= -1;
res = clamp(res * (2^(BITS - 1) - 1), -2^(BITS - 1), 2^(BITS - 1) - 1)
// Vertex shader decompression
vec2 encode = vec2(tangent_encoded.x, abs(tangent_encoded.y) * 2.0 - 1.0));
vec3 tangent_real = oct_to_vec3(encode);
float binormal_sign = sign(tangent_encode.y);
Formatieren | Größe | |
---|---|---|
Vorher | vec3<float32 > |
12 Byte |
Nachher | vec2<SNORM8 > |
2 Byte |
Vertex-UV-Koordinaten
UV-Koordinaten, die unter anderem für die Texturzuordnung verwendet werden, werden in der Regel mit 32-Bit-Gleitkommazahlen gespeichert. Die Komprimierung mit 16-Bit-Gleitkommazahlen verursacht bei Texturen, die größer als 1024 x 1024 sind, Genauigkeitsprobleme. Eine Gleitkommagenauigkeit zwischen [0,5, 1,0] bedeutet, dass die Werte um mehr als ein Pixel ansteigen.
Der bessere Ansatz ist die Verwendung von vorzeichenlosen normalisierten Ganzzahlen (UNORM), insbesondere UNORM16. Dies sorgt für eine gleichmäßige Verteilung über den gesamten Texturkoordinatenbereich, wobei Texturen bis zu 65.536 x 65.536 unterstützt werden! Dies setzt voraus, dass Texturkoordinaten innerhalb des Bereichs [0,0 bis 1,0] pro Element liegen, was je nach Mesh möglicherweise nicht der Fall ist (z.B.können Wände Wrapping-Texturkoordinaten verwenden, die über 1,0 hinausgehen). Berücksichtigen Sie dies, wenn Sie sich diese Technik ansehen. Die Konvertierungsfunktion würde so aussehen:
const int BITS = 16
for each vertex_uv V in mesh:
V *= clamp(2^BITS - 1, 0, 2^BITS - 1); // float to integer value conversion
Formatieren | Größe | |
---|---|---|
Vorher | vec2<float32 > |
8 Byte |
Nachher | vec2<UNORM16 > |
4 Byte |
Vertex-Komprimierungsergebnisse
Diese Vertex-Komprimierungstechniken führten zu einer Reduzierung des Vertex-Speichers um 66% von 48 Byte auf 16 Byte. Dies äußerte sich so:
- Vertex Memory Lesebandbreite:
<ph type="x-smartling-placeholder">
- </ph>
- Binning: 27 GB/s bis 9 GB/s
- Rendering: 4,5 Mrd./s bis 1,5 GB/s
- Vertex-Abrufverzögerungen:
<ph type="x-smartling-placeholder">
- </ph>
- Gruppieren: 50% bis 0%
- Rendering: 90% bis 90%
- Durchschnittliche Byte/Vertex:
<ph type="x-smartling-placeholder">
- </ph>
- Binning: 48 Mrd. bis 16 Mrd.
- Rendering: 52 Mrd. bis 18 B
Abbildung 3: Android GPU Inspector-Ansicht von unkomprimierten Scheitelpunkten
Abbildung 4: Ansicht komprimierter Eckpunkte im Android GPU Inspector
Vertex-Stream-Aufteilung
Mit Vertex Stream Splitting wird die Organisation von Daten im Vertex-Zwischenspeicher optimiert. Dies ist eine Optimierung der Cache-Leistung, die einen Unterschied bei kachelbasierten GPUs bewirkt, die in der Regel auf Android-Geräten zu finden sind, insbesondere während des Binning-Schritts des Rendering-Prozesses.
Kachelbasierte GPUs erstellen einen Shader, der die normalisierten Gerätekoordinaten basierend auf dem bereitgestellten Vertex-Shader für das Binning berechnet. Sie wird zuerst für jeden Scheitelpunkt in der Szene ausgeführt, ob sichtbar oder nicht. Es ist ein großer Vorteil, die Vertex-Positionsdaten im Speicher fortlaufend zu halten. Dieses Vertex-Stream-Layout kann auch für Schattenübergänge nützlich sein, da Sie normalerweise nur Positionsdaten für Schattenberechnungen sowie Tiefenvorführungen benötigen. Diese Technik wird normalerweise für das Rendern in der Konsole und auf dem Desktop verwendet. Dieses Vertex-Stream-Layout kann für mehrere Klassen des Rendering-Moduls von Vorteil sein.
Bei der Streamaufteilung wird der Zwischenspeicher des Scheitelpunkts mit einem zusammenhängenden Abschnitt von Scheitelpunkt-Positionsdaten und einem weiteren Abschnitt mit verschränkten Scheitelpunktattributen eingerichtet. Die meisten Anwendungen richten ihre Puffer in der Regel ein, die alle Attribute vollständig verschränkt. In dieser Abbildung wird der Unterschied erläutert:
Before:
|Position1/Normal1/Tangent1/UV1/Position2/Normal2/Tangent2/UV2......|
After:
|Position1/Position2...|Normal1/Tangent1/UV1/Normal2/Tangent2/UV2...|
Wenn wir uns ansehen, wie die GPU Vertex-Daten abruft, Stream-Aufteilung. Angenommen, für das Argument wird Folgendes angenommen:
- 32-Byte-Cache-Zeilen (eine ziemlich gängige Größe)
- Vertex-Format bestehend aus:
<ph type="x-smartling-placeholder">
- </ph>
- Position, vec3<float32> = 12 Byte
- Normal vec3<float32> = 12 Byte
- UV-Koordinaten vec2<float32> = 8 Byte
- Gesamtgröße = 32 Byte
Wenn die GPU Daten für das Binning aus dem Arbeitsspeicher abruft, zieht sie eine 32-Byte-Cache-Zeile für den Vorgang. Ohne Vertex-Stream-Aufteilung werden nur die ersten 12 Byte dieser Cache-Zeile für das Binning verwendet. Die anderen 20 Byte werden beim Abrufen des nächsten Scheitelpunkts verworfen. Beim Aufteilen des Vertex-Streams sind die Scheitelpunktpositionen im Speicher fortlaufend. Wenn dieser 32-Byte-Block in den Cache geladen wird, enthält er tatsächlich zwei ganze Scheitelpunktpositionen, an denen er arbeiten kann, bevor er zum Hauptspeicher zurückkehren muss, um mehr abzurufen. Das ist eine doppelt so hohe Verbesserung.
Wenn wir jetzt die Scheitelpunkt-Stream-Aufteilung mit der Scheitelpunktkomprimierung kombinieren, reduzieren wir die Größe einer einzelnen Scheitelpunktposition auf 6 Byte, sodass eine einzelne 32-Byte-Cache-Zeile, die aus dem Systemspeicher abgerufen wird, 5 ganze Scheitelpunktpositionen hat, was eine Verbesserung um das Fünffache ergibt.
Vertex-Stream-Aufteilungsergebnisse
- Vertex Memory Lesebandbreite:
<ph type="x-smartling-placeholder">
- </ph>
- Binning: 27 GB/s bis 6,5 GB/s
- Rendering: 4,5 GB/s bis 4,5 GB/s
- Vertex-Abrufverzögerungen:
<ph type="x-smartling-placeholder">
- </ph>
- Gruppieren: 40% bis 0%
- Rendering: 90% bis 90%
- Durchschnittliche Byte/Vertex:
<ph type="x-smartling-placeholder">
- </ph>
- Binning: 48 Mrd. bis 12 Mrd.
- Rendering: 52 Mrd. bis 52 B
Abbildung 5: Android GPU Inspector-Ansicht von nicht geteilten Vertex-Streams
Abbildung 6: Android GPU Inspector-Ansicht von geteilten Vertex-Streams
Zusammengesetzte Ergebnisse
- Vertex Memory Lesebandbreite:
<ph type="x-smartling-placeholder">
- </ph>
- Binning: 25 GB/s bis 4,5 GB/s
- Rendering: 4,5 GB/s bis 1,7 GB/s
- Vertex-Abrufverzögerungen:
<ph type="x-smartling-placeholder">
- </ph>
- Gruppieren: 41% bis 0%
- Rendering: 90% bis 90%
- Durchschnittliche Byte/Vertex:
<ph type="x-smartling-placeholder">
- </ph>
- Binning: 48 Mrd. bis 8 Mrd.
- Rendering: 52 B bis 19 B
Abbildung 7: Ansicht von nicht aufgeteilten, unkomprimierten Vertex-Streams unter Android GPU Inspector
Abbildung 8: Ansicht geteilter, komprimierter Vertex-Streams unter Android GPU Inspector
Weitere Überlegungen
16-Bit- und 32-Bit-Indexpufferdaten
- Teilen Sie Meshes immer so auf, dass sie in einen 16-Bit-Indexpuffer passen (max. 65.536 eindeutige Eckpunkte). Dies hilft beim indexierten Rendering auf Mobilgeräten, da das Abrufen von Vertex-Daten kostengünstiger ist und weniger Strom verbraucht.
Nicht unterstützte Formate für Vertex-Zwischenspeicherattribute
- SSCALED-Vertex-Formate werden auf Mobilgeräten nicht allgemein unterstützt und können kostspielige Leistungseinbußen bei Treibern verursachen, die versuchen, sie zu emulieren, wenn sie keine Hardware-Unterstützung bieten. Entscheide dich immer für SNORM und bezahle nur die minimalen ALU-Kosten für die Dekomprimierung.