Gestione dati Vertex

Un buon layout e compressione dei dati del vertice è parte integrante delle prestazioni di qualsiasi applicazione grafica, indipendentemente dal fatto che un'app sia costituita da interfacce utente 2D o sia un grande gioco open world 3D. Test interni con 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 precisione completa, valori in virgola mobile a 32 bit per tutti gli attributi dei vertici e un layout del buffer del vertice che utilizza un array di strutture formattate con attributi completamente interleaving.

In questo articolo viene spiegato come ottimizzare le prestazioni grafiche della tua app per Android utilizzando le seguenti tecniche:

  • Compressione dei vertici
  • Suddivisione dei flussi di Vertex

L'implementazione di queste tecniche può migliorare l'utilizzo della larghezza di banda della memoria verticale fino al 50%, ridurre la contesa del bus di memoria con la CPU, ridurre gli stalli della memoria di sistema e migliorare la durata della batteria. Tutto questo è vantaggio 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 Pixel 4:

Scena di esempio con 6 anelli e 19 metri di vertici

Figura 1: scena di esempio con 6 anelli e 19 metri di vertici

Compressione dei vertici

Vertex Compression è un termine generico che indica le tecniche di compressione con perdita di dati che utilizzano una pacchettizzazione efficiente per ridurre le dimensioni dei dati vertex sia durante il runtime che nell'archiviazione. La riduzione delle dimensioni dei vertici presenta diversi vantaggi, tra cui la riduzione della larghezza di banda della memoria della GPU (commutando il calcolo per la larghezza di banda), il miglioramento dell'utilizzo della cache e la potenziale riduzione del rischio di spilling dei registri.

Gli approcci comuni a Vertex Compression includono:

  • Ridurre la precisione numerica degli attributi dei dati del vertice (ad es. da virgola mobile a 32 bit a numero in virgola mobile a 16 bit)
  • Rappresentare gli attributi in formati diversi

Ad esempio, se un vertice utilizza galleggianti completi a 32 bit per le coordinate di posizione (vec3), normale (vec3) e texture (vec2), la sostituzione di tutte queste con valori 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 dei vertex possono essere compressi da valori in virgola mobile a 32 bit di precisione completa a valori in virgola mobile a 16 bit di precisione nella stragrande maggioranza dei mesh e i metà float sono supportati nell'hardware su quasi tutti i dispositivi mobili. Una funzione di conversione che va da float32 a float16 ha il seguente aspetto (adattata 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;
}

C'è un limite a questo approccio: la precisione si riduce man mano che il vertice si allontana dall'origine, il che lo rende meno adatto per mesh molto grandi dal punto di vista spaziale (vertici che hanno elementi che vanno oltre 1024). Puoi risolvere questo problema dividendo una mesh in blocchi più piccoli, centrando ogni blocco sull'origine del modello e ridimensionando in modo che tutti i vertici di ogni blocco rientrino nell'intervallo [-1, 1], che contiene la precisione più alta 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));

Modifica il fattore di scala e la traslazione nella matrice del modello per decomprimere i dati del vertice durante il rendering. Tieni presente che non vuoi utilizzare questa stessa matrice di modello per trasformare le normali, perché non era stata applicata la stessa compressione. Avrai bisogno di una matrice senza queste trasformazioni di decompressione per le normali oppure puoi usare la matrice del modello di base (che puoi usare per le normali) e poi applicare le trasformazioni di decompressione aggiuntive alla matrice del modello all'interno dello mesh. 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 con segno (SNORM). I tipi di dati SNORM utilizzano numeri interi anziché rappresentazione in virgola mobile per rappresentare i valori compresi tra [-1, 1]. L'utilizzo 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 del vertice e spazio tangente

I Vertex Normals sono necessari per l'illuminazione, mentre lo spazio tangente è necessario per le tecniche più complesse, come la mappatura normale.

Spazio tangente

Lo spazio 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, ne dobbiamo solo archiviarne due e possiamo calcolare il terzo prendendo un prodotto incrociato degli altri due in Vertex Shader.

Questi vettori possono in genere essere rappresentati utilizzando galleggianti 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 QTangents che archivia l'intero spazio della tangente in un singolo 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 modello nello spazio tangente), possiamo convertire tra i due! Un quaternione può essere trattato come vec4 dal punto di vista dei dati e una conversione da vettori spaziali tangenti a QTangent basata sul documento sopra riportato e adattato dall'implementazione qui è la 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 consentono di risparmiare ancora di più, ma possono causare artefatti su materiali altamente speculari. Prova entrambi per capire quale 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 normali

Se devi solo archiviare i vettori normali, esiste un approccio diverso che può portare a maggiori risparmi: utilizzare la mappatura ottaedrica dei vettori unitari anziché le coordinate cartesiane per comprimere il vettore normale. La mappatura ottaedrica funziona proiettando una sfera unitaria su un ottaedro, quindi proiettando l'ottaedro verso il basso su un piano 2D. Il risultato è che puoi rappresentare qualsiasi vettore normale utilizzando 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, in modo da poter recuperare il vettore originale. Questi due numeri possono quindi essere memorizzati in uno SNORM8.

Proiezione di una sfera unitaria su un ottaedro e proiezione dell&#39;ottaedro su un piano 2D

Figura 2: Octahedral 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 di Vertex Shader (per riconvertire in coordinate cartesiane) è economica; con la maggior parte dei dispositivi mobili moderni, non abbiamo riscontrato un degrado 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 tangente, utilizzando questa tecnica per memorizzare il vettore normale e tangente utilizzando vec2<SNORM8>, ma dovrai trovare un modo per memorizzare la direzione del bitangente (necessario per lo scenario comune in cui hai rispecchiato le coordinate UV su un modello). Un modo per implementare questa funzionalità è mappare un componente della codifica del vettore tangente in modo che sia sempre positivo, quindi capovolgere il segno se devi invertire la direzione bitangente e controllare che sia presente in Vertex Shader:

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 Vertex

Le coordinate UV, utilizzate tra le altre cose per la mappatura delle texture, vengono generalmente memorizzate utilizzando valori mobili a 32 bit. La loro compressione con galleggianti a 16 bit causa problemi di precisione per le texture più grandi di 1024 x 1024; la precisione in virgola mobile tra [0,5 e 1,0] significa che i valori incrementeranno di oltre 1 pixel!

L'approccio migliore è utilizzare i numeri interi normalizzati senza segno (UNORM), in particolare UNORM16; ciò fornisce una distribuzione uniforme sull'intero intervallo di coordinate delle texture, supportando texture fino a 65536x65536! Questo presuppone che le coordinate della texture siano comprese nell'intervallo [0,0, 1,0] per elemento, il che potrebbe non verificarsi a seconda della mesh (ad esempio, le pareti possono utilizzare le coordinate della trama dell'avvolgimento che vanno oltre 1,0), quindi tienilo a mente quando guardi questa tecnica. La funzione di conversione sarà simile alla seguente:

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 compressione Vertex

Queste tecniche di compressione del vertice hanno portato a una riduzione del 66% nello spazio di archiviazione della memoria vertex, passando da 48 byte a 16 byte. Questo si è manifestato come:

  • Larghezza di banda per la lettura della memoria Vertex:
    • Binning: da 27 GB/s a 9 GB/s
    • Rendering: da 4,5 B/s a 1,5 GB/s
  • Banchi di recupero Vertex:
    • Binning: da 50% a 0%
    • Rendering: dal 90% al 90%
  • Byte/vertex medi:
    • Binning: da 48 byte a 16 miliardi
    • Rendering: da 52 miliardi a 18 miliardi

Visualizzazione di Android GPU Inspector dei vertici non compressi

Figura 3: visualizzazione del controllo GPU Android dei vertici non compressi

Visualizzazione di Android GPU Inspector dei vertici compressi

Figura 4: visualizzazione di Android GPU Inspector dei vertici compressi

Suddivisione dei flussi di Vertex

Vertex Stream Splitting ottimizza l'organizzazione dei dati nel buffer del vertice. Si tratta di un'ottimizzazione delle prestazioni della cache che fa la differenza per le 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 unoshadowr che calcola le coordinate normalizzate del dispositivo in base al Vertex Shader fornito per eseguire il binning. Viene eseguita per prima su ogni vertice della scena, che sia visibile o meno. Mantenere contigui i dati sulla posizione del vertice in memoria è quindi un grande vantaggio. Altri casi in cui questo layout del flusso verticale può essere utile sono i passaggi shadow, poiché di solito sono necessari solo i dati sulla posizione per i calcoli delle ombre, nonché i prepassaggi di profondità, una tecnica solitamente utilizzata per il rendering su console/desktop; questo layout del flusso verticale può essere una soluzione vincente per più classi del motore di rendering.

La suddivisione del flusso comporta l'impostazione del buffer del vertice con una sezione contigua di dati sulla posizione del vertice e un'altra sezione contenente attributi del vertice interleaving. La maggior parte delle applicazioni in genere imposta i buffer completamente interfoliando 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...|

L'analisi del modo in cui la GPU recupera i dati del vertice ci aiuta a comprendere i vantaggi della suddivisione dei flussi. Supponendo che, per motivi di argomentazione:

  • Righe di cache da 32 byte (dimensione piuttosto comune)
  • Formato Vertex composto da:
    • Posizione, vec3<float32> = 12 byte
    • Normale vec3<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 di cache di 32 byte su cui operare. Senza la suddivisione del flusso del vertice, utilizzerà solo i primi 12 byte di questa riga di cache per il binning e scarta gli altri 20 byte quando recupera il vertice successivo. Con la suddivisione del flusso del vertice, le posizioni dei vertici saranno contigue in memoria, quindi quando quel blocco di 32 byte viene estratto nella cache, conterrà effettivamente 2 posizioni di vertici interi su cui operare prima di dover tornare alla memoria principale per recuperarne di più, un miglioramento 2x!

Ora, se combiniamo la suddivisione del flusso del vertice con la compressione del vertice, ridurremo la dimensione di una singola posizione del vertice a 6 byte, quindi una singola riga di cache di 32 byte estratta dalla memoria di sistema avrà 5 posizioni di vertice interi su cui operare, un miglioramento di 5 volte!

Risultati della suddivisione dei flussi Vertex

  • Larghezza di banda per la lettura della memoria Vertex:
    • Binning: da 27 GB/s a 6,5 GB/s
    • Rendering: da 4,5 GB/s a 4,5 GB/s
  • Banchi di recupero Vertex:
    • Binning: da 40% a 0%
    • Rendering: dal 90% al 90%
  • Byte/vertex medi:
    • Binning: da 48 byte a 12 miliardi
    • Rendering: da 52 miliardi a 52 miliardi

Visualizzazione del controllo GPU Android degli stream vertici non suddivisi

Figura 5: visualizzazione del controllo GPU Android degli stream vertici non suddivisi

Visualizzazione del controllo GPU Android degli stream vertici suddivisi

Figura 6: visualizzazione del controllo GPU Android degli stream vertici divisi

Risultati composti

  • Larghezza di banda per la lettura della memoria Vertex:
    • Binning: da 25 GB/s a 4,5 GB/s
    • Rendering: da 4,5 GB/s a 1,7 GB/s
  • Banchi di recupero Vertex:
    • Binning: da 41% a 0%
    • Rendering: dal 90% al 90%
  • Byte/vertex medi:
    • Binning: da 48 B a 8 MLD
    • Rendering: da 52 byte a 19 miliardi

Visualizzazione del controllo GPU Android degli stream vertici non suddivisi

Figura 7: visualizzazione del controllo GPU Android dei flussi vertici non suddivisi e non compressi

Visualizzazione del controllo GPU Android degli stream vertici non suddivisi

Figura 8: visualizzazione del controllo GPU Android di stream vertici e divisi suddivisi

Considerazioni aggiuntive

Dati del buffer indice a 16 e 32 bit

  • Dividere sempre le mesh o i blocchi in modo che rientrino in un buffer indice a 16 bit (massimo 65.536 vertici unici). Ciò sarà utile per il rendering indicizzato sui dispositivi mobili, in quanto è più economico recuperare i dati del vertice e consuma meno energia.

Formati degli attributi di Vertex Buffer non supportati

  • I formati vertex SSCALED non sono ampiamente supportati sui dispositivi mobili e, se utilizzati, può comportare costosi compromessi in termini di prestazioni nei driver che cercano di emulare questi formati se non dispongono del supporto hardware. Scegli SNORM e paga un costo ALU trascurabile per decomprimere.