Vertex veri yönetimi

İster 2D kullanıcı arayüzlerine sahip olsun ister büyük bir 3D açık dünya oyunu olsun, iyi köşe veri düzeni ve sıkıştırması, her türlü grafik uygulamanın performansının ayrılmaz bir parçasıdır. En popüler onlarca Android Oyunu üzerinde Android GPU Denetleyici'nin Frame Profiler aracıyla yapılan dahili testler, köşe veri yönetimini iyileştirmek için çok şey yapılabileceğini gösteriyor. Köşe noktası verilerinin tam hassasiyet, tüm köşe özellikleri için 32 bit kayma değerleri ve tamamen araya eklenmiş özelliklerle biçimlendirilmiş bir yapı dizisi kullanan tepe arabellek düzeni kullanımının yaygın olduğunu gözlemledik.

Bu makalede, aşağıdaki teknikleri kullanarak Android uygulamanızın grafik performansını nasıl optimize edeceğiniz açıklanmaktadır:

  • Köşe Sıkıştırması
  • Vertex Akış Bölme

Bu tekniklerin uygulanması, köşe bellek bant genişliği kullanımını %50'ye kadar iyileştirebilir, CPU ile bellek veri yolu anlaşmazlığını azaltabilir, sistem belleğindeki duraklamaları azaltabilir ve pil ömrünü iyileştirebilir. Tüm bunlar hem geliştiriciler hem de son kullanıcılar için avantajlıdır.

Sunulan tüm veriler, Pixel 4'te çalışan yaklaşık 19.000.000 köşe içeren örnek bir statik düzenden gelir:

6 halka ve 19 metre tepe noktası içeren örnek sahne

Şekil 1: 6 halka ve 19 m tepe noktası içeren örnek sahne

Köşe sıkıştırma

Vertex Sıkıştırma, hem çalışma zamanında hem de depolama sırasında köşe verilerinin boyutunu azaltmak için verimli paketleme kullanan kayıplı sıkıştırma teknikleri için kullanılan genel bir terimdir. Köşelerin boyutunu azaltmanın GPU'daki bellek bant genişliğini azaltma (bant genişliği için bilgi ticareti yaparak), önbellek kullanımını iyileştirme ve potansiyel olarak kayıtların dökülmesi riskini azaltma gibi çeşitli avantajları vardır.

Vertex Sıkıştırmasında yaygın olarak kullanılan yaklaşımlar arasında şunlar yer alır:

  • Köşe veri özelliklerinin sayısal kesinliğini azaltma (ör. 32 bit kayan noktalı 16 bit kayan)
  • Farklı biçimlerdeki özellikleri temsil etme

Örneğin, bir köşe noktası konum (vec3), normal (vec3) ve doku koordinatı (vec2) için tam 32 bit kayma kullanırsa bunların hepsinin 16 bit kayan öğelerle değiştirilmesi köşe boyutunu %50 oranında azaltır (ortalama 32 baytlık tepe noktası için 16 bayt).

Köşe konumları

Köşe konumu verileri, tam hassasiyetli 32 bit kayan nokta değerlerinden yarı hassasiyetli 16 bit kayan nokta değerlerine sıkıştırılabilir. Örgülerin büyük çoğunluğunda yarım kayan noktalar ise hemen hemen tüm mobil cihazlarda donanım tarafından desteklenir. float32'den float16'ya giden bir dönüşüm işlevi, aşağıdaki gibi görünür (bu kılavuzdan uyarlanmıştır):

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

Bu yaklaşımın bir sınırlaması vardır; köşe noktasından uzaklaştıkça hassasiyet düşer ve uzamsal olarak çok büyük olan ağlar (1024'ü aşan elemanlara sahip köşeler) için daha az uygun hale gelir. Bir ağı daha küçük parçalara bölerek, her parçayı model kaynağı çevresinde ortalayarak ve her bir parçanın tüm köşelerinin, kayan nokta değerleri için en yüksek hassasiyeti içeren [-1, 1] aralığına sığacak şekilde ölçeklendirerek bunu ele alabilirsiniz. Sıkıştırma işlemi için sözde kod şöyle görünür:

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

Oluşturma sırasında köşe verilerinin sıkıştırmasını açmak için ölçek faktörünü ve çeviriyi model matrisine ekleyebilirsiniz. Aynı sıkıştırma işlemi uygulanmadığından normalleri dönüştürmek için aynı model matrisini kullanmak istemediğinizi unutmayın. Normaller için bu sıkıştırma açma dönüşümleri içermeyen bir matrise ihtiyacınız vardır veya temel model matrisini (normaller için kullanabilirsiniz) kullanıp ardından ek sıkıştırma açma dönüşümlerini gölgelendirici içindeki model matrisine uygulayabilirsiniz. Örnek:

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

Diğer bir yaklaşım da İmzalı Normalleştirilmiş Tam Sayıların (SNORM) kullanılmasını içerir. SNORM veri türleri, [-1; 1] arasındaki değerleri temsil etmek için kayan nokta yerine tamsayı kullanır. Konumlar için 16 bit SNORM kullanılması, tek tip olmayan dağıtımların dezavantajları olmadan float16'yla aynı bellek tasarrufunu sağlar. SNORM kullanımı için önerdiğimiz bir uygulama aşağıdaki gibidir:

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) 
Biçim Boyut
Önce vec4<float32> 16 bayt
Sonra vec3<float16/SNORM16> 6 bayt

Köşe normalleri ve teğet alanı

Aydınlatma için Köşe Normalleri, normal haritalama gibi daha karmaşık teknikler için ise teğet alanı gereklidir.

Tanjant alanı

Teğet alan, her köşe noktasının normal, teğet ve bitangent vektörden oluştuğu bir koordinat sistemidir. Bu üç vektör genellikle birbirine dik olduğundan, yalnızca ikisini saklamamız gerekir ve köşe gölgelendiricide diğer ikisinin çapraz çarpımını alarak üçüncüsünü hesaplayabiliriz.

Bu vektörler, tipik olarak görsel doğrulukta herhangi bir algı kaybı olmadan 16 bitlik bolluklar kullanılarak temsil edilebilir. Bu nedenle iyi bir başlangıç yapabilirsiniz.

Tüm teğet alanını tek bir dörde depolayan QTangents olarak bilinen bir teknik ile daha fazla sıkıştırma yapabiliriz. Kuternyonlar dönmeleri temsil etmek için kullanılabildiğinden, teğet uzay vektörlerini bir döndürmeyi temsil eden 3x3’lük bir matrisin sütun vektörleri olarak düşünerek (bu örnekte, model uzayından teğet uzaya doğru) bu ikisi arasında dönüştürme yapabiliriz. Bir dördün, veri olarak vec4 olarak değerlendirilebilir. Yukarıda bağlantısı verilen ve buradaki uygulamadan uyarlanan belgeye göre teğet uzay vektörlerinden QTangent'e bir dönüşüm aşağıdaki gibi ele alınabilir:

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

Dörtgen normalleştirilecek ve SNORM'leri kullanarak sıkıştırabileceksiniz. 16 bit SNORM'ler, yüksek hassasiyet ve bellek tasarrufu sağlar. 8 bit SNORM'ler daha da fazla tasarruf sağlayabilir, ancak son derece iyi yansıtılmış malzemelerde hatalara neden olabilir. Her ikisini de deneyerek öğeleriniz için en iyi sonuç veren yöntemi öğrenebilirsiniz. Dördüncüsün kodlaması aşağıdaki gibidir:

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

Köşe gölgelendiricide dördün kodunu çözmek için (buradan uyarlanmıştır):

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;
  ...
}
Biçim Boyut
Önce vec3<float32> + vec3<float32> + vec3<float32> 36 bayt
Sonra vec4<SNORM16> 8 bayt

Yalnızca Normaller

Yalnızca normal vektörleri depolamanız gerekiyorsa daha fazla tasarruf sağlayabilecek farklı bir yaklaşım vardır: Normal vektörü sıkıştırmak için Kartezyen Koordinatlar yerine, birim vektörlerin Sekizli Eşlemesini kullanmak. Sekizli Haritalama, bir birim küreyi sekiz yüzlü bir yüzeye yansıtıp, ardından sekizli düzlemi iki boyutlu bir düzleme doğru yansıtacak şekilde çalışır. Sonuç olarak, yalnızca iki sayı kullanarak herhangi bir normal vektörü temsil edebilirsiniz. Bu iki sayı, kürenin üzerine yansıttığımız 2D düzlemi "örneklemek" için kullandığımız ve orijinal vektörü kurtarmamıza olanak tanıyan doku koordinatları olarak düşünülebilir. Bu iki numara daha sonra SNORM8'de saklanabilir.

Birim küreyi sekiz yüzlüye yansıtma ve sekiz yüzlüyü iki boyutlu düzleme yansıtma

Şekil 2: Sekizli Eşleme Görselleştirilmiş (kaynak)

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)

Köşe gölgelendiricisinde sıkıştırma açma işlemi (tekrar kartezyen koordinatlarına dönüştürmek için) pahalı değildir; çoğu modern mobil cihazda bu tekniği uygularken önemli bir performans düşüşü görmedik. Köşe gölgelendiricide sıkıştırma açma:

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

Bu yaklaşım, vec2<SNORM8> kullanarak normal ve teğet vektörü depolamak için bu teknik kullanılarak tüm teğet alanı depolamak için de kullanılabilir, ancak bitangentin yönünü kaydetmenin bir yolunu bulmanız gerekir (bir model üzerinde yansıtılan UV koordinatlarının kullanıldığı yaygın senaryo için gereklidir). Bunu uygulamanın bir yolu, teğet vektör kodlamanızın bir bileşenini her zaman pozitif olacak şekilde eşlemek, ardından bitangent yönü tersine çevirmeniz gerekirse bunu tepe noktası gölgelendiricide kontrol etmeniz gerekirse işaretini çevirmektir.

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);
Biçim Boyut
Önce vec3<float32> 12 bayt
Sonra vec2<SNORM8> 2 bayt

Köşe UV Koordinatları

Doku eşleme (diğer şeylerin yanı sıra) için kullanılan UV Koordinatları genellikle 32 bitlik kayan öğeler kullanılarak depolanır. Bunların 16 bit kayan noktayla sıkıştırılması, 1024x1024'ten büyük dokular için hassasiyet sorunlarına neden olur; [0,5, 1,0] arasındaki kayan nokta hassasiyeti, değerlerin 1 pikselden daha fazla artacağı anlamına gelir.

Daha iyi yaklaşım, işaretlenmemiş normalleştirilmiş tam sayılar (UNORM) (özellikle UNORM16) kullanmaktır. Bu yöntem, 65536x65536'ya kadar dokuları destekleyerek doku koordinat aralığının tamamında tek tip dağılım sağlar. Bu, doku koordinatlarının öğe başına [0,0, 1,0] aralığında olduğunu varsayar. Ağa bağlı olarak bu durum geçerli olmayabilir (örneğin, duvarlar 1,0'dan sonraki sarmalama doku koordinatları kullanabilir). Bu nedenle, bu tekniğe bakarken bunu göz önünde bulundurun. Dönüştürme işlevi şöyle görünür:

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
Biçim Boyut
Önce vec2<float32> 8 bayt
Sonra vec2<UNORM16> 4 bayt

Vertex Sıkıştırma Sonuçları

Bu köşe noktası sıkıştırma teknikleri, tepe noktası belleğinin 48 bayttan 16 bayta indirilmesini sağladı. Bu durum kendini şu şekilde gösterdi:

  • Vertex Bellek Okuma Bant Genişliği:
    • Bağlama: 27 GB/sn - 9 GB/sn.
    • Oluşturma: 4,5 B/sn - 1,5 GB/sn
  • Vertex Fetch Stantları:
    • Gruplandırma: %50 - %0
    • Oluşturma: %90 - %90
  • Ortalama Bayt/Vertex:
    • Gruplandırma: 48 milyardan 16 milyara
    • Oluşturma: 52B - 18B

Sıkıştırılmamış köşelerin Android GPU Denetleyicisi görünümü

Şekil 3: Sıkıştırılmamış köşelerin Android GPU Inspector görünümü

Sıkıştırılmış köşelerin Android GPU Denetleyicisi görünümü

Şekil 4: Sıkıştırılmış köşelerin Android GPU Inspector görünümü

Vertex Akış Bölme

Vertex Akış Bölme, köşe arabelleğindeki verilerin düzenlenmesini optimize eder. Bu, genellikle Android cihazlarda bulunan parça tabanlı GPU'larda, özellikle de oluşturma işleminin bağlama adımında fark yaratan bir önbellek performansı optimizasyonudur.

Parça tabanlı GPU'lar, birleştirme işlemi için sağlanan köşe gölgelendiriciye dayalı olarak normalleştirilmiş cihaz koordinatlarını hesaplayan bir gölgelendirici oluşturur. Çalıştırma, görünür olsun veya olmasın sahnedeki her köşe noktasında ilk olarak yürütülür. Bu nedenle, tepe konumu verilerini bellekte tutarlı bir şekilde tutmak büyük bir avantaj sağlar. Bu köşe noktası akışı düzeninin yararlı olabileceği diğer yerler gölge geçişleridir. Bunun nedeni, genellikle yalnızca gölge hesaplamaları için konum verilerine ve genellikle konsol/masaüstü oluşturmada kullanılan derinlik ön geçişlerine ihtiyaç duyulmasıdır. Bu köşe noktası akışı düzeni, oluşturma motorunun birden çok sınıfı için kazanç sağlayabilir!

Akış Bölme, tepe noktası arabelleğini, köşe noktası konumu verilerinin bitişik bir bölümü ve araya eklemeli köşe özellikleri içeren başka bir bölümle ayarlamayı içerir. Çoğu uygulama, arabelleklerini genellikle tüm özellikleri içerecek şekilde tamamen yerleştirir. Aradaki fark şu görselde açıklanmıştır:

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

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

GPU'nun tepe verilerini nasıl getirdiğini incelemek, akış bölmenin avantajlarını anlamamıza yardımcı olur. argüman sağlamak amacıyla:

  • 32 baytlık önbellek satırları (oldukça yaygın bir boyut)
  • Aşağıdakileri içeren köşe biçimi:
    • Konum, vec3<float32> = 12 bayt
    • Normal vec3<float32> = 12 bayt
    • UV koordinatları vec2<float32> = 8 bayt
    • Toplam boyut = 32 bayt

GPU, birleştirme için bellekten veri getirdiğinde üzerinde çalışmak üzere 32 baytlık bir önbellek satırı çeker. Köşe akışı bölmesi olmadığında, aslında bağlama için bu önbellek satırının yalnızca ilk 12 baytını kullanır ve bir sonraki tepe noktasını getirirken diğer 20 baytı siler. Köşe akışı bölmesiyle, tepe noktası konumları bellekte bitişik olacaktır. Böylece, bu 32 baytlık parça önbelleğe alındığında, daha fazla veri getirmek için ana belleğe geri dönmek zorunda kalmadan,

Şimdi, köşe noktası akışını köşe noktası sıkıştırma ile birleştirdiğimizde, tek bir köşe konumunun boyutunu 6 bayta indireceğiz. Böylece sistem belleğinden alınan tek bir 32 baytlık önbellek satırının üzerinde çalışabileceği 5 tam köşe konumu elde etmiş olursunuz. Bu da 5 kat iyileşme sağlar!

Vertex Akışı Bölme Sonuçları

  • Vertex Bellek Okuma Bant Genişliği:
    • Bağlama: 27 GB/sn - 6,5 GB/sn.
    • Oluşturma: 4,5 GB/sn - 4,5 GB/sn
  • Vertex Fetch Stantları:
    • Kutulama: %40 - %0
    • Oluşturma: %90 - %90
  • Ortalama Bayt/Vertex:
    • Gruplandırma: 48 milyardan 12 milyara
    • Oluşturma: 52B - 52B

Bölünmüş tepe noktası akışlarının Android GPU Denetleyicisi görünümü

Şekil 5: Bölünmemiş tepe noktası akışlarının Android GPU Inspector görünümü

Bölünmüş köşe akışlarının Android GPU Denetleyicisi görünümü

Şekil 6: Bölünmüş köşe akışlarının Android GPU Inspector görünümü

Bileşik Sonuçlar

  • Vertex Bellek Okuma Bant Genişliği:
    • Bağlama: 25 GB/sn - 4,5 GB/sn.
    • Oluşturma: 4,5 GB/sn - 1,7 GB/sn
  • Vertex Fetch Stantları:
    • Gruplandırma: %41 - %0
    • Oluşturma: %90 - %90
  • Ortalama Bayt/Vertex:
    • Gruplandırma: 48 milyardan 8 milyara
    • Oluşturma: 52B - 19B

Bölünmüş tepe noktası akışlarının Android GPU Denetleyicisi görünümü

Şekil 7: Bölünmemiş, sıkıştırılmamış köşe akışlarının Android GPU Inspector görünümü

Bölünmüş tepe noktası akışlarının Android GPU Denetleyicisi görünümü

Şekil 8: Bölünmüş, sıkıştırılmış köşe akışlarının Android GPU Inspector görünümü

Göz Önünde Bulundurulan Diğer Noktalar

16 ve 32 bit Dizin Arabelleği Verileri

  • Ağları her zaman 16 bit dizin arabelleğine (maks. 65.536 benzersiz köşe) sığacak şekilde bölün/parçalara ayırın. Bu, tepe noktası verilerini getirmek daha ucuz olduğundan ve daha az güç tüketeceğinden, mobil cihazlarda dizine eklenmiş oluşturma işlemine yardımcı olur.

Desteklenmeyen Vertex Buffer Özellik Biçimleri

  • SSCALED köşe biçimleri mobil cihazlarda yaygın olarak desteklenmez ve kullanıldığında donanım desteği sunulmuyorsa bunları taklit etmeye çalışan sürücüler için yüksek performanstan ödün verebilir. Her zaman SNORM tercih edin ve sıkıştırmayı açmak için önemsiz ALU maliyetini ödeyin.