Pengelolaan data Vertex

Tata letak dan kompresi data vertex yang baik merupakan bagian tak terpisahkan dari performa aplikasi grafis apa pun, baik aplikasi yang terdiri dari antarmuka pengguna 2D atau game dunia terbuka 3D yang besar. Pengujian internal dengan Frame Profiler Android GPU Inspector di puluhan Game Android teratas menunjukkan bahwa banyak yang dapat dilakukan untuk meningkatkan pengelolaan data vertex. Kami telah mengamati bahwa sangat umum untuk data vertex menggunakan presisi penuh, nilai float 32-bit untuk semua atribut vertex, dan tata letak buffer vertex yang menggunakan array struktur yang diformat dengan atribut yang disisipkan sepenuhnya.

Artikel ini membahas cara mengoptimalkan performa grafis aplikasi Android Anda dengan menggunakan teknik berikut:

  • Kompresi Vertex
  • Pembagian Aliran Vertex

Penerapan teknik ini dapat meningkatkan penggunaan bandwidth memori vertex hingga 50%, mengurangi pertentangan bus memori dengan CPU, mengurangi kemacetan pada memori sistem, serta meningkatkan masa pakai baterai yang merupakan keberhasilan bagi developer dan pengguna akhir!

Semua data yang ditampilkan berasal dari contoh adegan statis yang berisi ~ 19.000.000 vertex yang berjalan pada Pixel 4:

Contoh adegan dengan 6 lingkaran dan 19 m puncak

Gambar 1: Contoh scene dengan 6 lingkaran dan 19 m verteks

Kompresi Vertex

Kompresi Vertex adalah istilah umum untuk teknik kompresi lossy yang menggunakan pengemasan yang efisien untuk mengurangi ukuran data vertex baik selama runtime ataupun di penyimpanan. Mengurangi ukuran vertex akan memiliki beberapa keuntungan, termasuk mengurangi bandwidth memori pada GPU (dengan berhitung komputasi untuk bandwidth), meningkatkan penggunaan cache, dan berpotensi mengurangi risiko tumpahnya register.

Pendekatan umum terhadap Kompresi Vertex meliputi:

  • Mengurangi presisi numerik atribut data vertex (misalnya: float 32-bit menjadi float 16-bit)
  • Menampilkan atribut dalam berbagai format

Misalnya, jika sebuah sudut menggunakan float 32-bit penuh untuk posisi (vec3), normal (vec3), dan koordinat tekstur (vec2), ganti semua ini dengan float 16-bit akan mengurangi ukuran puncak sebesar 50% (16 byte pada vertex rata-rata 32 byte).

Posisi Vertex

Data posisi vertex dapat dikompresi dari nilai floating point 32-bit presisi penuh menjadi nilai floating point 16-bit presisi setengah di sebagian besar mesh, dan setengah float didukung dalam hardware di hampir semua perangkat seluler. Fungsi konversi yang berubah dari float32 ke float16 yang terlihat seperti ini (diadaptasi dari panduan ini):

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

Terdapat batasan untuk pendekatan ini; presisi menurun saat vertex lebih jauh dari aslinya sehingga kurang cocok untuk mesh yang sangat besar secara spasial (vertex yang memiliki elemen yang dapat melampaui 1024). Anda dapat mengatasinya dengan memisahkan mesh menjadi potongan yang lebih kecil, memusatkan setiap potongan di sekitar asal model, serta menskalakan sehingga semua vertex untuk setiap potongan sesuai dalam rentang [-1, 1], yang berisi presisi tertinggi untuk nilai floating point. Kode semu agar kompresi terlihat seperti ini:

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

Anda memasukkan faktor penskalaan dan terjemahan ke dalam matriks model untuk melakukan dekompresi data vertex saat rendering. Perlu diingat bahwa Anda tidak ingin menggunakan matriks model yang sama ini untuk transformasi normal karena tidak ada kompresi yang sama yang akan diterapkan. Anda akan memerlukan matriks tanpa transformasi dekompresi ini untuk yang normal, atau Anda dapat menggunakan matriks model dasar (yang dapat Anda gunakan untuk yang normal) lalu menerapkan transformasi dekompresi tambahan ke matriks model dalam shader. Contoh:

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

Pendekatan lain melibatkan penggunaan Signed Normalized Integers (SNORM). Jenis data SNORM menggunakan bilangan bulat, bukan floating point, untuk menampilkan nilai antara [-1, 1]. Menggunakan SNORM 16-bit untuk posisi akan memberi Anda penghematan memori yang sama dengan float16 tanpa kelemahan dari distribusi yang tidak seragam. Implementasi yang kami sarankan untuk menggunakan SNORM terlihat seperti ini:

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 Ukuran
Sebelum vec4<float32> 16 byte
Setelah vec3<float16/SNORM16> 6 byte

Normal Vertex dan ruang tangen

Vertex Normal diperlukan untuk pencahayaan dan ruang tangen diperlukan untuk teknik yang lebih rumit seperti pemetaan normal.

Ruang tangen

Ruang tangen adalah sistem koordinat dengan setiap vertex terdiri dari vektor normal, tangen, dan bitangen. Karena ketiga vektor biasanya ortogonal satu sama lain, kita hanya perlu menyimpan dua vektor dan dapat menghitung yang ketiga dengan mengambil cross product dari dua vektor di shader vertex.

Vektor ini biasanya dapat ditampilkan menggunakan float 16-bit tanpa kehilangan persepsi apa pun dalam fidelitas visual, sehingga ini merupakan tempat yang bagus untuk memulai!

Kita dapat mengompresi lebih lanjut dengan teknik yang dikenal sebagai QTangent yang dapat menyimpan seluruh ruang tangen dalam satu angka empat. Karena kuaternion dapat digunakan untuk menampilkan rotasi, dengan menganggap vektor ruang tangen sebagai vektor kolom dari matriks 3x3 yang menampilkan rotasi (dalam hal ini dari ruang model menjadi ruang tangen), kita dapat mengonversi di antara keduanya. Kuaternion dapat diperlakukan sebagai data vec4, dan konversi dari vektor ruang tangen ke QTangent berdasarkan kertas yang ditautkan di atas dan diadaptasi dari penerapan di sini adalah sebagai berikut:

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

Kuarternion akan dinormalkan lalu Anda dapat mengompresinya dengan menggunakan SNORM. SNORM 16-bit memberikan presisi dan penghematan memori yang baik. SNORM 8-bit dapat memberikan lebih banyak penghematan, tetapi dapat menyebabkan artefak pada material yang sangat spesifik. Anda dapat mencoba keduanya dan lihat mana yang terbaik untuk aset Anda! Encoding kuarternion terlihat seperti ini:

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

Untuk mendekode kuarternion dalam vertex sudut (diadaptasi dari sini):

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 Ukuran
Sebelum vec3<float32> + vec3<float32> + vec3<float32> 36 byte
Setelah vec4<SNORM16> 8 byte

Khusus Normal

Jika Anda hanya perlu menyimpan vektor normal, ada pendekatan lain yang dapat menghemat lebih banyak penggunaan - dengan menggunakan Pemetaan Oktahedral vektor unit daripada Koordinat Kartesius untuk mengompresi vektor normal. Pemetaan Oktahedral berfungsi dengan memproyeksikan ruang unit ke sebuah oktahedron, kemudian memproyeksikan oktahedron ke bidang 2D. Hasilnya adalah Anda dapat menampilkan vektor normal hanya dengan menggunakan dua angka. Kedua angka ini dapat dianggap sebagai koordinat tekstur yang kami gunakan untuk "mengambil sampel" bidang 2D yang diproyeksikan ke ruang dan memungkinkan kami memulihkan vektor asli. Kedua angka ini kemudian dapat disimpan dalam SNORM8.

Memproyeksikan ruang dunia ke oktahedron dan memproyeksikan oktahedron ke bidang 2D

Gambar 2: Pemetaan Oktahedral divisualisasikan (sumber)

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)

Dekompresi pada shader vertex (untuk mengubah kembali ke koordinat kartesius) tidak mahal; dengan sebagian besar perangkat seluler modern, kami tidak melihat penurunan performa yang signifikan saat menerapkan teknik ini. Dekompresi pada shader vertex:

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

Pendekatan ini juga dapat digunakan untuk menyimpan seluruh ruang tangen menggunakan teknik ini untuk menyimpan vektor normal dan tangen menggunakan vec2<SNORM8> tetapi Anda harus menemukan cara untuk menyimpan bitangen (diperlukan untuk skenario umum di mana Anda memiliki pencerminan koordinat UV pada model). Salah satu cara untuk menerapkannya adalah dengan memetakan komponen enkode vektor tangen Anda agar selalu positif, lalu balikan tanda tersebut jika Anda perlu membalik arah bitangen dan memeriksa hal tersebut dalam shader vertex:

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 Ukuran
Sebelum vec3<float32> 12 byte
Setelah vec2<SNORM8> 2 byte

Koordinat EV Vertex

Koordinat uv digunakan untuk pemetaan tekstur (antara lain) yang biasanya disimpan dengan menggunakan float 32 bit. Mengompresinya dengan pelampung 16 bit menyebabkan masalah presisi untuk tekstur yang lebih besar dari 1024x1024; presisi floating point antara [0,5, 1,0] berarti bahwa nilai akan meningkat sebesar lebih besar dari 1 piksel!

Pendekatan yang lebih baik adalah menggunakan bilangan bulat yang dinormalkan (UNORM), khususnya UNORM16. Hal ini dapat memberikan distribusi yang merata di seluruh rentang koordinat tekstur dan mendukung tekstur hingga 65536x65536! Ini mengasumsikan bahwa koordinat tekstur berada dalam rentang [0.0, 1.0] per elemen, yang mungkin tidak demikian tergantung pada mesh (misalnya dinding bisa menggunakan gabungan koordinat tekstur yang lebih besar dari 1.0), jadi ingatlah hal tersebut ketika melihat teknik ini. Fungsi konversi akan terlihat seperti ini:

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 Ukuran
Sebelum vec2<float32> 8 byte
Setelah vec2<UNORM16> 4 byte

Hasil Kompresi Vertex

Teknik kompresi vertex ini menghasilkan pengurangan penyimpanan memori vertex 66%, menurun dari 48 byte menjadi 16 byte. Teknik ini memanifestasikan diri sebagai:

  • Vertex Memory Read Bandwidth:
    • Pengelompokan: 27 GB/dtk sampai 9 GB/dtk
    • Rendering: 4,5B/dtk sampai 1,5 GB/dtk
  • Kios Pengambilan Vertex:
    • Pengelompokan: 50% sampai 0%
    • Rendering: 90% sampai 90%
  • Rata-rata Byte/Vertex:
    • Pengelompokan: 48B sampai 16B
    • Rendering: 52B sampai 18B

Tampilan Android GPU Inspector dari vertex yang tidak dikompresi

Gambar 3: Tampilan Android GPU Inspector dari vertex yang tidak dikompresi

Tampilan Android GPU Inspector dari vertex terkompresi

Gambar 4: Tampilan Android GPU Inspector dari vertex yang dikompresi

Pembagian Aliran Vertex

Pembagian Aliran Vertex mengoptimalkan pengaturan data dalam buffer vertex. Hal ini merupakan pengoptimalan performa cache yang membuat perbedaan pada GPU berbasis kartu yang biasanya ditemukan di Perangkat Android - terutama selama langkah pengelompokan dalam proses rendering.

GPU berbasis kartu akan membuat shader yang menghitung koordinat perangkat yang dinormalkan berdasarkan shader vertex yang disediakan untuk melakukan pengelompokan. Hal ini akan dieksekusi terlebih dahulu pada setiap verteks di adegan, baik terlihat atau tidak. Oleh karena itu, menyimpan data posisi verteks yang berdekatan dalam memori merupakan nilai tambah yang besar. Di tempat lain, tata letak aliran vertex ini dapat bermanfaat untuk shadow pass, karena biasanya Anda hanya memerlukan data untuk shadow calculation, serta prepass kedalaman, yang merupakan teknik yang biasanya digunakan untuk rendering konsol/desktop; tata letak aliran verteks ini dapat menjadi keuntungan untuk beberapa class mesin rendering!

Pembagian Aliran melibatkan penyiapan buffer vertex dengan bagian data posisi vertex yang berdekatan dan bagian lain yang berisi atribut vertex yang disisipkan. Sebagian besar aplikasi biasanya menyiapkan buffer-nya yang sepenuhnya menyisipkan semua atribut. Visual ini menjelaskan perbedaannya:

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

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

Melihat cara GPU mengambil data vertex dapat membantu kami memahami manfaat pemisahan aliran. Dengan asumsi demi argumen:

  • 32 baris cache 32 byte (ukuran yang cukup umum)
  • Format Vertex yang terdiri dari:
    • Posisi, vec3 {float32} = 12 byte
    • Vec3 normal<float32> = 12 byte
    • Koordinat UV vec2<float32> = 8 byte
    • Ukuran Total = 32 byte

Saat GPU mengambil data dari memori untuk pengelompokan, GPU akan menarik garis cache 32 byte untuk dioperasikan. Tanpa pemisahan aliran vertex, langkah ini hanya akan benar-benar menggunakan 12 byte pertama dari baris cache ini untuk pengelompokan dan membuang 20 byte lainnya saat mengambil vertex berikutnya. Dengan pembagian aliran vertex, posisi vertex akan berdekatan dalam memori, sehingga bila bagian 32-byte tersebut ditarik ke dalam cache, bagian tersebut sebenarnya akan berisi 2 keseluruhan posisi vertex yang beroperasi sebelum harus kembali ke memori utama untuk mengambil lebih banyak, 2x peningkatan!

Sekarang, jika kita menggabungkan pemisahan aliran vertex dengan kompresi vertex, kita akan mengurangi ukuran posisi vertex tunggal menjadi 6 byte, sehingga satu baris cache 32-byte yang ditarik dari memori sistem akan memiliki 5 posisi vertex untuk beroperasi, 5x peningkatan!

Hasil Pembagian Aliran Vertex

  • Vertex Memory Read Bandwidth:
    • Pengelompokan: 27 GB/s sampai 6,5 GB/s
    • Rendering: 4,5 GB/dtk sampai 4,5 GB/dtk
  • Kios Pengambilan Vertex:
    • Pengelompokan: 40% sampai 0%
    • Rendering: 90% sampai 90%
  • Rata-rata Byte/Vertex:
    • Pengelompokan: 48 B sampai 12 M
    • Rendering: 52B sampai 52B

Tampilan Android GPU Inspector untuk aliran vertex yang tidak terpisahkan

Gambar 5: Tampilan Android GPU Inspector dari aliran verteks yang tidak terpisahkan

Tampilan Android GPU Inspector dari aliran vertex terpisah

Gambar 6: Tampilan Android GPU Inspector dari aliran verteks terpisah

Hasil Gabungan

  • Vertex Memory Read Bandwidth:
    • Binning: 25 GB/dtk sampai 4,5 GB/dtk
    • Rendering: 4,5 GB/dtk sampai 2,7 GB/dtk
  • Kios Pengambilan Vertex:
    • Pengelompokan: 41% sampai 0%
    • Rendering: 90% sampai 90%
  • Rata-rata Byte/Vertex:
    • Pengelompokan: 48 B sampai 8 B
    • Rendering: 52B sampai 19B

Tampilan Android GPU Inspector untuk aliran vertex yang tidak terpisahkan

Gambar 7: Tampilan Android GPU Inspector untuk aliran verteks yang tidak terpisah dan tidak dikompresi

Tampilan Android GPU Inspector untuk aliran vertex yang tidak terpisahkan

Gambar 8: Tampilan Android GPU Inspector dari aliran verteks yang terkompresi dan terpisah

Pertimbangan Tambahan

16 vs 32 bit Data Buffer Indeks

  • Selalu pisah/potong mesh sehingga sesuai dengan buffer indeks 16-bit (maks 65536 vertex unik). Hal ini akan membantu rendering yang diindeks pada perangkat seluler karena mengambil data vertex lebih murah dan menghemat daya.

Format Atribut Buffer Vertex Tidak Didukung

  • Format vertex SSCALED tidak didukung secara luas di seluler, dan jika digunakan dapat memiliki konsekuensi performa yang mahal pada driver yang dapat mencoba mengemulasikan jika tidak memiliki dukungan hardware. Selalu gunakan SNORM dan bayar biaya ALU yang dapat diabaikan untuk melakukan dekompresi.