Un buon layout e compressione dei dati ai vertici è parte integrante delle prestazioni di qualsiasi applicazione grafica, che si tratti di un'app costituita da interfacce utente 2D o di un grande gioco open world 3D. I test interni con il Frame Profiler di Android GPU Inspector su decine dei migliori giochi Android indicano che si potrebbe fare molto per migliorare la gestione dei dati dei vertici. Abbiamo osservato che è comune che i dati dei vertici utilizzino la precisione completa, i valori in virgola mobile a 32 bit per tutti gli attributi dei vertici e un layout del buffer dei vertici che utilizza un array di strutture formattate con attributi completamente interleali.
Questo articolo illustra come ottimizzare le prestazioni grafiche della tua applicazione Android utilizzando le seguenti tecniche:
- Compressione Vertex
- Suddivisione dei flussi di Vertex
L'implementazione di queste tecniche può migliorare l'utilizzo della larghezza di banda della memoria del vertex fino al 50%, ridurre il conflitto di bus di memoria con la CPU, ridurre gli arresti anomali sulla memoria di sistema e migliorare la durata della batteria; tutti vantaggi sia per gli sviluppatori che per gli utenti finali.
Tutti i dati presentati provengono da una scena statica di esempio contenente circa 19.000.000 di vertici in esecuzione su un Pixel 4:
Figura 1: scena di esempio con 6 anelli e 19 metri di vertici
Compressione Vertex
Vertex Compression è un termine generico per le tecniche di compressione con perdita di dati che utilizza una pacchettizzazione efficiente per ridurre le dimensioni dei dati dei vertici sia durante il runtime che nello spazio di archiviazione. La riduzione delle dimensioni dei vertici offre diversi vantaggi, tra cui la riduzione della larghezza di banda della memoria sulla GPU (compensando il calcolo con la larghezza di banda), migliorando l'utilizzo della cache e potenzialmente riducendo il rischio di fuoriuscita dei registri.
Gli approcci comuni a Vertex Compression includono:
- Ridurre la precisione numerica degli attributi dei dati ai vertici (ad es.: numero in virgola mobile da 32 bit a numero in virgola mobile a 16 bit)
- Rappresentazione degli attributi in formati diversi
Ad esempio, se un vertice utilizza valori in virgola mobile completi a 32 bit per la posizione (vec3), normale (vec3) e coordinata di trama (vec2), la sostituzione di questi valori con numeri in virgola mobile a 16 bit ridurrà la dimensione del vertice del 50% (16 byte su un vertice medio di 32 byte).
Posizioni dei vertici
I dati di posizione di Vertex possono essere compressi da valori in virgola mobile a 32 bit con precisione completa a valori in virgola mobile a 16 bit di precisione nella maggior parte delle mesh e metà numeri in virgola mobile sono supportati nell'hardware su quasi tutti i dispositivi mobili. Una funzione di conversione che va da float32 a float16 ha il seguente aspetto (adattato da questa guida):
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;
}
Esiste un limite a questo approccio: la precisione diminuisce man mano che il vertice si allontana dall'origine, rendendolo meno adatto per mesh che hanno dimensioni spaziali molto grandi (vertici con elementi che superano la soglia di 1024). È possibile risolvere questo problema dividendo un mesh in blocchi più piccoli, centrando ciascun blocco attorno all'origine del modello e scalando in modo che tutti i vertici di ciascun blocco rientrino nell'intervallo [-1, 1], che contiene la massima precisione per i valori in virgola mobile. Lo pseudocodice per la compressione ha il seguente aspetto:
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));
Il fattore di scala e la traslazione vengono inseriti nella matrice del modello per decomprimere i dati dei vertici durante il rendering. Tieni presente che non è consigliabile utilizzare la stessa matrice del modello per trasformare le normali, poiché non è stata applicata la stessa compressione. Avrai bisogno di una matrice senza queste trasformazioni di decompressione per le normali oppure puoi utilizzare la matrice del modello di base (che puoi utilizzare per le normali) e quindi applicare le trasformazioni di decompressione aggiuntive alla matrice del modello all'interno dello shaker. Esempio:
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;
}
Un altro approccio prevede l'utilizzo di numeri interi normalizzati firmati (SNORM). I tipi di dati SNORM utilizzano numeri interi anziché una virgola mobile per rappresentare i valori compresi tra [-1, 1]. L'uso di uno SNORM a 16 bit per le posizioni offre lo stesso risparmio di memoria di un float16 senza gli svantaggi delle distribuzioni non uniformi. Un'implementazione che consigliamo per l'utilizzo di SNORM ha il seguente aspetto:
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)
Formato | Dimensioni | |
---|---|---|
Prima | vec4<float32 > |
16 byte |
Dopo | vec3<float16/SNORM16 > |
6 byte |
Normali dei vertici e spazio della tangente
I Vertex Normals sono necessari per l'illuminazione, mentre lo spazio tangente è necessario per tecniche più complicate come la mappatura normale.
Tangente spazio
Lo spazio della tangente è un sistema di coordinate in cui ogni vertice è costituito dal vettore normale, tangente e bitangente. Poiché questi tre vettori sono solitamente ortogonali l'uno all'altro, dobbiamo solo memorizzarne due e possiamo calcolare il terzo prendendo un prodotto incrociato degli altri due nel vertex snapshot.
In genere questi vettori possono essere rappresentati utilizzando valori mobili a 16 bit senza alcuna perdita percettiva nella fedeltà visiva, quindi questo è un buon punto di partenza.
Possiamo comprimere ulteriormente con una tecnica nota come QTangent che memorizza l'intero spazio della tangente in un unico quaternione. Poiché i quaternioni possono essere usati per rappresentare le rotazioni, pensando ai vettori dello spazio tangente come vettori di colonna di una matrice 3x3 che rappresenta una rotazione (in questo caso dallo spazio del modello allo spazio tangente) possiamo convertire tra i due! Un quaternione può essere trattato come vec4 in base ai dati, mentre una conversione dai vettori dello spazio tangente a un QTangent basata sul documento collegato sopra e adattata dall'implementazione qui è il seguente:
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;
}
Il quaternione verrà normalizzato e potrai comprimerlo utilizzando gli SNORM. Gli SNORM a 16 bit offrono una buona precisione e un buon risparmio di memoria. Gli SNORM a 8 bit possono offrire risparmi ancora maggiori, ma potrebbero causare artefatti su materiali altamente speculari. Puoi provarli entrambi e vedere cosa funziona meglio per i tuoi asset. La codifica del quaternione ha il seguente aspetto:
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);
Per decodificare il quaternione in Vertex Shader (adattato da qui):
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;
...
}
Formato | Dimensioni | |
---|---|---|
Prima | vec3<float32 > + vec3<float32 > + vec3<float32 > |
36 byte |
Dopo | vec4<SNORM16 > |
8 byte |
Solo valori normali
Se devi solo archiviare i vettori normali, esiste un approccio diverso che può portare a maggiori risparmi, utilizzando la mappatura ottaedrica dei vettori unitari anziché le coordinate cartesiane per comprimere il vettore normale. Octaedral Mapping funziona proiettando una sfera unitaria su un ottaedro e quindi proiettando l'ottaedro su un piano 2D. Il risultato è che puoi rappresentare qualsiasi vettore normale usando solo due numeri. Questi due numeri possono essere considerati come coordinate di texture che utilizziamo per "campionare" il piano 2D su cui abbiamo proiettato la sfera, consentendoci di recuperare il vettore originale. Questi due numeri possono quindi essere memorizzati in uno SNORM8.
Figura 2: Octaedral Mapping Visualized (fonte)
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)
La decompressione nello Shar vertex (per convertire di nuovo in coordinate cartesiane) è poco costosa; con la maggior parte dei dispositivi mobili moderni non abbiamo riscontrato alcun peggioramento significativo delle prestazioni durante l'implementazione di questa tecnica. La decompressione in 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;
Questo approccio può essere utilizzato anche per archiviare l'intero spazio della tangente, utilizzando questa tecnica per memorizzare il vettore normale e tangente utilizzando vec2<SNORM8
> ma dovrai trovare un modo per memorizzare la direzione del bitangent (necessario per lo scenario comune in cui si hanno le coordinate UV speculari su un modello). Un modo per implementarlo è mappare un componente della codifica del vettore tangente in modo che sia sempre positivo, quindi capovolgerlo se devi invertire la direzione bitangent e verificarlo in vertex shaker:
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);
Formato | Dimensioni | |
---|---|---|
Prima | vec3<float32 > |
12 byte |
Dopo | vec2<SNORM8 > |
2 byte |
Coordinate UV di Vertex
Le coordinate UV, utilizzate per la mappatura della texture (tra le altre cose), vengono generalmente memorizzate utilizzando valori mobili a 32 bit. La loro compressione con valori mobili a 16 bit causa problemi di precisione per le texture più grandi di 1024 x 1024; La precisione con rappresentazione in virgola mobile compresa tra [0,5, 1,0] significa che i valori verranno incrementati di dimensioni superiori a 1 pixel.
L'approccio migliore consiste nell'utilizzare numeri interi normalizzati senza segno (UNORM), in particolare UNORM16; questo fornisce una distribuzione uniforme sull'intero intervallo di coordinate delle texture, supportando texture fino a 65536x65536! Ciò presuppone che le coordinate della trama siano comprese nell'intervallo [0,0, 1,0] per elemento, il che potrebbe non essere il caso a seconda della mesh (ad esempio i muri possono utilizzare coordinate di trama di wrapping che vanno oltre 1,0), quindi tienilo a mente quando si guarda questa tecnica. La funzione di conversione avrebbe il seguente aspetto:
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
Formato | Dimensioni | |
---|---|---|
Prima | vec2<float32 > |
8 byte |
Dopo | vec2<UNORM16 > |
4 byte |
Risultati di compressione di Vertex
Queste tecniche di compressione dei vertici hanno portato a una riduzione del 66% dello spazio di archiviazione della memoria dei vertici, passando da 48 a 16 byte. Ciò si è manifestato come:
- Larghezza di banda in lettura memoria Vertex:
- Raggruppamento: da 27 GB/s a 9 GB/s
- Rendering: da 4,5 B/s a 1,5 GB/s
- Si blocca il recupero di Vertex:
- Binning: da 50% a 0%
- Rendering: dal 90% al 90%
- Media byte/Vertex:
- Binning: da 48 B a 16 B
- Rendering: da 52 a 18 miliardi
Figura 3. Vista di Android GPU Inspector dei vertici non compressi
Figura 4. Visualizzazione GPU Inspector di Android dei vertici compressi
Suddivisione dei flussi di Vertex
Vertex Stream Splitting ottimizza l'organizzazione dei dati nel vertex buffer. Si tratta di un'ottimizzazione delle prestazioni della cache che fa la differenza nelle GPU basate su riquadri tipicamente presenti nei dispositivi Android, in particolare durante la fase di binning del processo di rendering.
Le GPU basate su riquadri creano uno shaker che calcola le coordinate normalizzate del dispositivo in base al vertex shaker fornito per eseguire il binning. Viene eseguito prima su ogni vertice della scena, visibile o meno. Mantenere i dati della posizione del vertice contigui in memoria è quindi un grande vantaggio. In altri casi, questo layout di flusso vertice può essere utile per i passaggi shadow, poiché di solito sono necessari solo dati di posizione per i calcoli delle ombre, oltre ai prepassi di profondità, una tecnica solitamente utilizzata per il rendering di console/desktop. questo layout al vertice può essere una soluzione vincente per più classi del motore di rendering.
La suddivisione dei flussi prevede l'impostazione del vertex buffer con una sezione contigua di dati sulla posizione dei vertici e un'altra sezione contenente attributi dei vertici con interleaving. La maggior parte delle applicazioni di solito imposta i buffer interleando completamente tutti gli attributi. Questa immagine spiega la differenza:
Before:
|Position1/Normal1/Tangent1/UV1/Position2/Normal2/Tangent2/UV2......|
After:
|Position1/Position2...|Normal1/Tangent1/UV1/Normal2/Tangent2/UV2...|
Il modo in cui la GPU recupera i dati dei vertici ci aiuta a comprendere i vantaggi la suddivisione dello stream. Supporre per motivi di argomentazione:
- Righe cache da 32 byte (una dimensione abbastanza comune)
- Formato Vertex composto da:
- Posizione, vec3<float32> = 12 byte
- vec3 normale<float32> = 12 byte
- Coordinate UV vec2<float32> = 8 byte
- Dimensione totale = 32 byte
Quando la GPU recupera i dati dalla memoria per il binning, estrae una riga della cache da 32 byte su cui operare. Senza la suddivisione del flusso dei vertici, utilizzerà in realtà solo i primi 12 byte di questa riga della cache per il binning ed scarterà gli altri 20 byte quando recupera il vertice successivo. Con la suddivisione del flusso dei vertici, le posizioni dei vertici saranno contigue nella memoria, quindi quando quel blocco da 32 byte viene estratto nella cache, conterrà in realtà 2 posizioni intere di vertice su cui operare prima di dover tornare alla memoria principale per recuperarne di più, un miglioramento 2 volte!
Se combiniamo la suddivisione del flusso del vertice con la compressione dei vertici, ridurremo la dimensione di una singola posizione del vertice a 6 byte, quindi una singola linea di cache da 32 byte estratta dalla memoria di sistema avrà 5 posizioni di vertice interi su cui operare, un miglioramento 5 volte!
Risultati della suddivisione dei flussi di Vertex
- Larghezza di banda in lettura memoria Vertex:
- Raggruppamento: da 27 GB/s a 6,5 GB/s
- Rendering: da 4,5 GB/s a 4,5 GB/s
- Si blocca il recupero di Vertex:
- Binning: da 40% a 0%
- Rendering: dal 90% al 90%
- Media byte/Vertex:
- Binning: da 48 B a 12 B
- Rendering: da 52 B a 52 B
Figura 5. Visualizzazione GPU Inspector di Android degli stream a vertice non suddivisi
Figura 6. Visualizzazione GPU Inspector di Android di stream a vertice suddivisi
Risultati composti
- Larghezza di banda in lettura memoria Vertex:
- Raggruppamento: da 25 GB/s a 4,5 GB/s
- Rendering: da 4,5 GB/s a 1,7 GB/s
- Si blocca il recupero di Vertex:
- Binning: da 41% a 0%
- Rendering: dal 90% al 90%
- Media byte/Vertex:
- Classificazione: da 48 B a 8 B
- Rendering: da 52 a 19 miliardi
Figura 7: visualizzazione GPU Inspector di Android di stream vertice non suddivisi e non compressi
Figura 8. Visualizzazione GPU Inspector di Android di stream a vertice compressi e divisi
Considerazioni aggiuntive
Dati del buffer di indice a 16 e a 32 bit
- Suddividi sempre i mesh/chunk in modo che rientrino in un buffer di indice a 16 bit (massimo 65.536 vertici unici). Ciò aiuterà il rendering indicizzato sui dispositivi mobili, in quanto è più economico recuperare i dati dei vertici e consuma meno energia.
Formati non supportati per gli attributi di buffer Vertex
- I formati vertex SSCALED non sono ampiamente supportati sui dispositivi mobili e, quando utilizzati, possono comportare costosi compromessi in termini di prestazioni per i driver che tentano di emularli se non dispongono del supporto hardware. Scegli sempre SNORM e paga il costo ALU trascurabile per la decompressione.