Zarządzanie danymi Vertex

Dobry układ danych wierzchołków i kompresja mają kluczowe znaczenie dla wydajności każdej aplikacji graficznej, niezależnie od tego, czy składa się ona z interfejsów 2D, czy jest dużą grą z otwartym światem 3D. Wewnętrzne testy za pomocą narzędzia do profilowania klatek w Inspektorze GPU w Androidzie w dziesiątkach najpopularniejszych gier na Androida pokazują, że można by wiele zrobić, aby usprawnić zarządzanie danymi wierzchołków. Zaobserwowaliśmy, że w przypadku danych wierzchołkowych często używane są pełne precyzję, 32-bitowe wartości zmiennoprzecinkowe dla wszystkich atrybutów wierzchołków oraz układ bufora wierzchołków, który wykorzystuje tablicę struktur sformatowanych z w pełni przeplatanymi atrybutami.

W tym artykule omówiono sposoby optymalizacji grafiki w aplikacji na Androida przy użyciu następujących technik:

  • Kompresja Vertex
  • Podział Vertex Stream

Zastosowanie tych technik może zwiększyć wykorzystanie przepustowości pamięci wierzchołkowej nawet o 50%, zmniejszyć rywalizację z busem pamięci z procesorem, ograniczyć przerwy w pamięci systemowej i wydłużyć czas pracy na baterii – wszystko to przynosi korzyści zarówno deweloperom, jak i użytkownikom.

Wszystkie przedstawione dane pochodzą z przykładowej sceny statycznej obejmującej ok. 19 000 000 wierzchołków działających na Pixelu 4:

Przykładowa scena z 6 pierścieniami i 19 m wierzchołkami

Rysunek 1. Przykładowa scena z 6 pierścieniami i 19 m wierzchołkami

Kompresja Vertex

Kompresja Vertex to ogólny termin określający techniki kompresowania stratnego, które wykorzystują skuteczne pakowanie w celu zmniejszenia rozmiaru danych wierzchołkowych zarówno w czasie działania, jak i w przechowywaniu danych. Zmniejszenie rozmiaru wierzchołków przynosi kilka korzyści, w tym zmniejszenie przepustowości pamięci przez GPU (przez zamianę mocy obliczeniowej na przepustowość), poprawienie wykorzystania pamięci podręcznej i potencjalnie zmniejszenie ryzyka zalania rejestrów.

Typowe metody kompresji Vertex to:

  • Zmniejszenie precyzji liczbowej atrybutów danych wierzchołków (np. z 32-bitowej liczby zmiennoprzecinkowej na 16-bitową).
  • Prezentowanie atrybutów w różnych formatach

Jeśli na przykład wierzchołek używa pełnych 32-bitowych liczb zmiennoprzecinkowych na potrzeby pozycji (vec3), normalnej (vec3) i współrzędnych tekstury (vec2), zastąpienie ich 16-bitowymi liczbami zmiennoprzecinkowych spowoduje zmniejszenie rozmiaru wierzchołków o 50% (16 bajtów przy średnim wierzchołku o 32-bajtowym rozmiarze).

Pozycje wierzchołków

Dane o pozycji Vertex można kompresować z pełnej precyzji 32-bitowych wartości zmiennoprzecinkowych do połowy dokładności 16-bitowych wartości zmiennoprzecinkowych w przypadku większości siatek. Natomiast części zmiennoprzecinkowe są obsługiwane sprzętowo w prawie wszystkich urządzeniach mobilnych. Funkcja konwersji zmieniająca się z float32 na float16 wygląda tak (dostosowana na podstawie tego przewodnika):

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;
}

Takie podejście ma pewne ograniczenie: precyzja zmniejsza się w miarę oddalania się wierzchołka od punktu początkowego, co czyni go mniej odpowiednim dla sieci, które są bardzo duże przestrzenie (wierzchołki zawierające elementy wykraczające poza zakres 1024). Aby rozwiązać ten problem, podziel siatkę na mniejsze fragmenty, wyśrodkuj każdy fragment wokół punktu początkowego modelu i skaluj tak, aby wszystkie wierzchołki każdego fragmentu mieściły się w zakresie [-1, 1], który zapewnia największą precyzję dla wartości zmiennoprzecinkowych. Pseudokod kompresji wygląda tak:

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));

Aby dekompresować dane wierzchołków podczas renderowania, zapisujesz współczynnik skalowania i przekształcenie w matrycę modelu. Pamiętaj, że nie chcesz używać tej samej macierzy modeli do przekształcania normalnych, ponieważ nie zostały one objęte taką samą kompresją. W przypadku normalnych potrzebujesz macierzy bez tych przekształceń dekompresyjnych lub możesz użyć macierzy modelu podstawowego (której możesz użyć w przypadku normalnych), a potem zastosować te dodatkowe transformacje do macierzy modelu w shingrze. Przykład:

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;
}

Inna metoda to użycie syntetycznych, znormalizowanych liczb całkowitych (SNORM). Typy danych SNORM do przedstawienia wartości z zakresu [–1, 1] wykorzystują liczby całkowite, a nie liczbę zmiennoprzecinkową. Użycie 16-bitowego algorytmu SNORM dla pozycji zapewnia taką samą oszczędność pamięci jak liczba zmiennoprzecinkowa 16 bez wad niejednorodnych rozkładów. Implementacja SNORM zalecana przez nas wygląda tak:

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) 
Format Rozmiar
Przed vec4<float32> 16 bajtów
Po vec3<float16/SNORM16> 6 bajtów

Normalne w Vertex i przestrzeń styczną

Normalne wartości Vertex Normal są potrzebne do oświetlenia, a przestrzeń styczna jest potrzebna w bardziej skomplikowanych technikach, takich jak normalne tworzenie map.

Spacja styczna

Przestrzeń styczna to układ współrzędnych, w którym każdy wierzchołek składa się z wektora normalnego, tangensa i bitangenta. Ponieważ te 3 wektory są zwykle ortogonalne do siebie, wystarczy zapisać 2 z nich, a trzeci obliczyć iloczyn w obrębie pozostałych 2 wektorów w funkcji cieniowania wierzchołków.

Te wektory mogą być zwykle reprezentowane za pomocą 16-bitowych liczb zmiennoprzecinkowych, które nie powodują utraty jakości obrazu, dlatego od tego zacząć.

Możemy dalej skompresować, korzystając z techniki zwanej QTangents, która przechowuje całą przestrzeń styczną w jednym kwaternionie. Ponieważ kwaternony mogą być używane do przedstawiania obrotów, traktujemy wektory przestrzeni stycznej jako wektory kolumnowe macierzy 3 x 3 reprezentujące obrót (w tym przypadku z przestrzeni modelu na przestrzeń styczną), możemy więc dokonać konwersji między nimi. Kwantonion można traktować jako z punktu widzenia danych vec4, a konwersja z wektorów przestrzeni stycznej na kwantowy na podstawie papieru, do którego link znajdziesz powyżej i dostosowanego na podstawie takiej implementacji, wygląda tak:

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;
}

Kwionion zostanie znormalizowany i będzie można go skompresować za pomocą sekwencji SNORM. 16-bitowe wskaźniki SNORM zapewniają wysoką precyzję i oszczędność pamięci. 8-bitowe SNORM mogą zapewnić jeszcze większe oszczędności, ale mogą powodować występowanie artefaktów na materiałach o wysokiej zwierciadleniach. Możesz wypróbować oba te rozwiązania i zobaczyć, co sprawdzi się najlepiej w przypadku Twoich komponentów. Kodowanie kwaternionu wygląda tak:

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);

Aby zdekodować kwaternion w cieniowaniu wierzchołków (dostosowano tutaj):

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;
  ...
}
Format Rozmiar
Przed vec3<float32> + vec3<float32> + vec3<float32> 36 bajtów
Po vec4<SNORM16> 8 bajtów

Tylko normalne

Jeśli chcesz przechowywać tylko wektory normalne, możesz to zrobić inaczej. Do skompresowania wektorów standardowych użyj mapowania ośmiokątnego wektorów jednostkowych zamiast współrzędnych kartezjańskich. Mapowanie ośmiościanu polega na rzutowaniu sfery jednostkowej na ośmiścian, a następnie rzutu ośmiościanu w dół do płaszczyzny 2D. W efekcie każdy wektor normalny można przedstawić za pomocą tylko dwóch liczb. Te 2 liczby można traktować jako współrzędne tekstury, które wykorzystujemy do „próbkowania” płaszczyzny 2D, na którą rzutowała ona sferę, umożliwiając nam odzyskanie pierwotnego wektora. Te 2 numery można zapisać w elemencie SNORM8.

Wyświetlanie kulki jednostkowej na ośmiościan i rzutowanie ośmiościanu na płaszczyznę 2D

Rys. 2. Zwizualizowane mapowanie ośmiokątne (źródło)

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)

Dekompresja w cieniowaniu wierzchołków (aby przekonwertować z powrotem na współrzędne kartezjańskie) jest niedroga; w przypadku większości nowoczesnych urządzeń mobilnych nie zaobserwowaliśmy poważnego spadku wydajności po wdrożeniu tej techniki. Dekompresja w cieniowaniu wierzchołków:

//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;

W ten sposób można również przechowywać całą przestrzeń styczną i zapisywać wektory normalne i tangensowe przy użyciu wektorów vec2<SNORM8>, ale trzeba znaleźć sposób na zapisanie kierunku bitangenta (co jest konieczne w przypadku typowego scenariusza, w którym w modelu powielane są współrzędne UV). Jednym ze sposobów implementacji jest zmapowanie komponentu kodowania wektorów tangensowych na zawsze dodatnie, a następnie odwrócenie znaku, jeśli trzeba odwrócić kierunek bitangenta i sprawdzić go w cieniowaniu wierzchołków:

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);
Format Rozmiar
Przed vec3<float32> 12 bajtów
Po vec2<SNORM8> 2 bajty

Współrzędne Vertex UV

Współrzędne UV, używane do mapowania tekstur (między innymi), są zwykle przechowywane za pomocą 32-bitowych pływaków. Kompresowanie ich za pomocą 16-bitowych liczb zmiennoprzecinkowych powoduje problemy z precyzją w przypadku tekstur większych niż 1024 x 1024.Dokładność zmiennoprzecinkowa z zakresu od [0,5 do 1,0] oznacza, że wartości są zwiększane o więcej niż 1 piksel.

Lepszym podejściem jest użycie niepodpisanych znormalizowanych liczb całkowitych (UNORM), a w szczególności UNORM16. Zapewnia to jednolity rozkład w całym zakresie współrzędnych tekstury i obsługuje tekstury do rozmiaru 65536 x 65536. Zakładamy, że współrzędne tekstury mieszczą się w zakresie [0,0, 1,0] na element, co może nie mieć miejsca w zależności od siatki (na przykład ściany mogą używać współrzędnych tekstury opakowujących, które wykraczają poza zakres 1,0). Pamiętaj o tym podczas analizy tej techniki. Funkcja konwersji będzie wyglądać tak:

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
Format Rozmiar
Przed vec2<float32> 8 bajtów
Po vec2<UNORM16> 4 bajty

Wyniki kompresji Vertex

Te techniki kompresji wierzchołków pozwoliły zmniejszyć ilość miejsca na dane wierzchołków o 66% – z 48 do 16 bajtów. Wyglądało to tak:

  • Przepustowość odczytu pamięci Vertex:
    • Podział: od 27 GB/s do 9 GB/s
    • Renderowanie: 4,5 B/s do 1,5 GB/s
  • Punkty pobierania Vertex:
    • Podział: 50% do 0%
    • Renderowanie: 90%–90%
  • Średnia liczba bajtów/wertek:
    • Podział: 48 mld do 16 mld
    • Renderowanie: od 52 B do 18 B

Widok inspektora GPU w Androidzie z nieskompresowanymi wierzchołkami

Rysunek 3. Widok inspektora GPU w Androidzie z nieskompresowanymi wierzchołkami

Widok inspektora GPU w Androidzie skompresowanych wierzchołków

Rysunek 4. Widok inspektora GPU w Androidzie skompresowanych wierzchołków

Podział Vertex Stream

Podział Vertex Stream optymalizuje organizację danych w buforze wierzchołków. Jest to optymalizacja wydajności pamięci podręcznej, która ma wpływ na GPU oparte na kafelkach, które zwykle są stosowane na urządzeniach z Androidem, a zwłaszcza na etapie łączenia procesu renderowania.

Procesory graficzne oparte na kafelkach tworzą program do cieniowania, który oblicza znormalizowane współrzędne urządzenia na podstawie udostępnionego programu cieniowania wierzchołków na potrzeby łączenia. Jest wykonywany jako pierwszy na każdym wierzchołku sceny, niezależnie od tego, czy jest widoczny. Utrzymywanie ciągłości danych o pozycji wierzchołków w pamięci jest więc dużym plusem. Układ strumienia wierzchołków może być też przydatny w przypadku przejść w cieniu, ponieważ do obliczeń w postaci cienia potrzebne są zwykle tylko dane o pozycji i przejścia głębinowe, które jest techniką zwykle stosowaną w przypadku renderowania konsoli i komputera. Taki układ strumienia wierzchołków może być skutecznym rozwiązaniem dla wielu klas mechanizmu renderowania.

Podział strumienia obejmuje skonfigurowanie bufora wierzchołków z przyległą sekcją danych o pozycji wierzchołków oraz innej sekcji zawierającej atrybuty przeplatanych wierzchołków. Większość aplikacji ma zazwyczaj w pełni przeplatane wszystkie atrybuty. Ilustracja pokazująca różnicę między tymi elementami:

Before:
|Position1/Normal1/Tangent1/UV1/Position2/Normal2/Tangent2/UV2......|

After:
|Position1/Position2...|Normal1/Tangent1/UV1/Normal2/Tangent2/UV2...|

Poznanie sposobu pobierania danych wierzchołków przez GPU pomaga nam poznać korzyści z podziału strumieni. Zakładając dla argumentu:

  • 32-bajtowe wiersze pamięci podręcznej (dość typowy rozmiar).
  • Format Vertex składający się z:
    • Pozycja, vec3<float32> = 12 bajtów
    • Normalna wartość vec3<float32> = 12 bajtów
    • Współrzędne UV vec2<float32> = 8 bajtów
    • Łączny rozmiar = 32 bajty

Gdy GPU pobiera dane z pamięci do powiązania, pobiera 32-bajtowy wiersz pamięci podręcznej, na którym wykonuje operację. Bez podziału strumienia wierzchołków będzie w rzeczywistości wykorzystano tylko pierwsze 12 bajtów z tego wiersza pamięci podręcznej do podziału, a pozostałe 20 bajtów zostaną odrzucone podczas pobierania następnego wierzchołka. W przypadku podziału strumieni wierzchołków pozycje wierzchołków w pamięci są przylegające, więc po pobraniu tego 32-bajtowego fragmentu do pamięci podręcznej będzie on zawierać 2 całe pozycje wierzchołków, na których będą działać, zanim trzeba będzie wrócić do pamięci głównej w celu pobrania więcej, co stanowi dwukrotną poprawę.

Jeśli teraz połączymy podział strumienia wierzchołków ze kompresją wierzchołków, zmniejszymy rozmiar pojedynczego wierzchołku do 6 bajtów. W ten sposób pojedynczy 32-bajtowy wiersz pamięci podręcznej pobrany z pamięci systemowej będzie miał 5 pełnych pozycji wierzchołków, co jest 5-krotnym wzrostem.

Wyniki podziału Vertex Stream

  • Przepustowość odczytu pamięci Vertex:
    • bindowanie: od 27 GB/s do 6,5 GB/s,
    • Renderowanie: od 4,5 GB/s do 4,5 GB/s
  • Punkty pobierania Vertex:
    • Podział: 40% do 0%
    • Renderowanie: 90%–90%
  • Średnia liczba bajtów/wertek:
    • Podział: od 48 B do 12 B
    • Renderowanie: od 52 do 52 B

Widok inspektora GPU w Androidzie przedstawiający niepodzielone strumienie wierzchołków

Rysunek 5. Widok inspektora GPU w Androidzie z niepodzielonymi strumieniami wierzchołków

Widok inspektora GPU w Androidzie przedstawiających podzielone strumienie wierzchołków

Rysunek 6. Widok inspektora GPU w Androidzie z podzielonymi strumieniami wierzchołków

Wyniki złożone

  • Przepustowość odczytu pamięci Vertex:
    • bindowanie: od 25 GB/s do 4,5 GB/s,
    • Renderowanie: od 4,5 GB/s do 1,7 GB/s
  • Punkty pobierania Vertex:
    • Podział: 41% do 0%
    • Renderowanie: 90%–90%
  • Średnia liczba bajtów/wertek:
    • Podział na segmenty: od 48 B do 8 B
    • Renderowanie: od 52 B do 19 mld

Widok inspektora GPU w Androidzie przedstawiający niepodzielone strumienie wierzchołków

Rysunek 7. Widok inspektora GPU w Androidzie z niepodzielonymi, nieskompresowanymi strumieniami wierzchołków

Widok inspektora GPU w Androidzie przedstawiający niepodzielone strumienie wierzchołków

Rysunek 8. Widok inspektora GPU w Androidzie z podzielonymi, skompresowanymi strumieniami wierzchołków

Inne rzeczy, które warto wziąć pod uwagę

16- a 32-bitowe dane bufora indeksu

  • Zawsze dziel siatkę/fragmenty tak, aby mieściła się w 16-bitowym buforze indeksu (maksymalnie 65 536 unikalnych wierzchołków). Pomoże to w renderowaniu indeksowanym na urządzeniach mobilnych, ponieważ pobieranie danych wierzchołków jest tańsze i zużywa mniej energii.

Nieobsługiwane formaty atrybutów Vertex Buffer

  • SSCALNE formaty wierzchołków nie są powszechnie obsługiwane na urządzeniach mobilnych, a ich użycie może prowadzić do kosztownego spadku wydajności sterowników, którzy próbują je emulować, jeśli nie mają obsługi sprzętowej. Zawsze wybieraj SNORM i płać niewielki koszt przy korzystaniu z ALU.