İster 2D kullanıcı arayüzlerinden oluşmuş ister büyük bir 3D açık dünya oyunu olsun, köşe veri düzeninin iyi olması ve sıkıştırması grafik uygulamalarının performansına bağlıdır. En popüler onlarca Android oyununda Android GPU Denetleyici'nin Çerçeve Profil Aracı ile yapılan dahili testler, köşe veri yönetimini iyileştirmek için çok şey yapılabileceğini gösteriyor. Zirve verilerinde tam hassasiyet, tüm köşe özellikleri için 32 bit kayma değerleri ve tamamen boşluk eklenen özelliklerle biçimlendirilmiş bir yapı dizisi kullanan bir köşe arabellek düzeni kullanımının yaygın olduğunu gözlemledik.
Bu makalede, aşağıdaki teknikler kullanılarak Android uygulamanızın grafik performansını nasıl optimize edebileceğiniz açıklanmaktadır:
- Köşe Sıkıştırması
- Vertex Akışı Bölme
Bu tekniklerin uygulanması, tepe bellek bant genişliği kullanımını %50'ye kadar artırabilir, CPU ile bellek yolu çakışmasını azaltabilir, sistem belleğindeki duraklamaları azaltabilir ve pil ömrünü iyileştirebilir; Bunların hepsi 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 noktası içeren örnek bir statik sahneden gelir:
Figür 1: 6 halkalı ve 19 m köşeli örnek sahne
Köşe sıkıştırması
Vertex Sıkıştırma, kırılımını yapmanızı sağlayan kayıplı sıkıştırma tekniklerini hem çalışma zamanı hem de depolama sırasında köşe verilerinin boyutunu azaltmak için etkili paketleme tekniğini kullanır. Köşelerin boyutunu azaltmanın; GPU'daki bellek bant genişliğini azaltma (bant genişliği için bilgi işlem ticareti yaparak), önbellek kullanımını iyileştirme ve kayıtların dökülme riskini azaltma gibi çeşitli avantajları vardır.
Vertex Sıkıştırma işlemine yönelik yaygın yaklaşımlar şunlardır:
- Köşe noktası veri özelliklerinin sayısal hassasiyetini azaltma (ör. 32 bit kayan, 16 bit kayan noktalı)
- Özellikleri farklı biçimlerdeki temsil etme
Örneğin, bir köşe konum (vec3), normal (vec3) ve doku koordinatı (vec2) için 32 bitlik tam kayan öğeler kullanıyorsa bunların tümünü 16 bit kayan noktalarla değiştirmek, köşe boyutunu %50 oranında (ortalama 32 baytlık köşe için 16 bayt) azaltır.
Köşe konumları
Köşe konumu verileri, ağların büyük çoğunluğunda tam hassasiyetli 32 bit kayan nokta değerlerinden yarı hassasiyetli 16 bit kayan nokta değerlerine sıkıştırılabilir ve yarım kayan noktalar hemen hemen tüm mobil cihazlarda donanımda 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şımda bir sınırlama vardır; tepe noktası başlangıç noktasından uzaklaştıkça hassasiyet azalır. Bu da, uzamsal olarak çok büyük olan ağlar (1024'ten daha büyük elemanlar içeren köşeler) için daha az uygun hale gelir. Bir örgüyü daha küçük parçalara bölerek, her parçayı model kaynağının etrafına ortalayarak ve her 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 başarabilirsiniz. Sıkıştırma 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 çeviriyorsunuz. Normalleri dönüştürmek için aynı model matrisini kullanmak istemediğinizi unutmayın. Bunun nedeni, bu matrislere aynı sıkıştırma uygulanmamasıdır. Normaller için bu dekompresyon dönüşümleri olmadan bir matrise ihtiyacınız olur ya da temel model matrisini (normaller için kullanabilirsiniz) kullanıp ardından ek sıkıştırılmış 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 ise İ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 tam sayılar kullanır. Konumlar için 16 bit SNORM kullanmak, tek tip olmayan dağıtımların dezavantajları olmadan, float16 ile 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 uzayı
Işıklandırma için Vertex Normallerine, normal harita çıkarma gibi daha karmaşık teknikler için ise teğet boşluk gereklidir.
Teğet alanı
Tanjant alan, her köşenin normal, teğet ve bitangent vektörden oluştuğu bir koordinat sistemidir. Bu üç vektör genellikle birbirine dikey olduğundan, bunlardan sadece ikisini depolamamız gerekir ve üçüncüsünü, köşe gölgelendiricisinde diğer ikisinin çapraz çarpımını alarak hesaplayabiliriz.
Bu vektörler, genellikle görsel görüntü kalitesinde herhangi bir algı kaybı olmadan 16 bitlik kayan öğeler kullanılarak temsil edilebilir. Dolayısıyla, başlamak için iyi bir yer.
Tüm teğet alanını tek bir dördün içinde depolayan QTangents olarak bilinen bir teknik kullanarak daha fazla sıkıştırma yapabiliriz. Kuaterniyonlar döndürmeleri göstermek için kullanılabildiğinden teğet uzay vektörlerini, bir dönüşü temsil eden 3x3'lük bir matrisin sütun vektörleri olarak düşünerek (bu örnekte model alanından teğet alanına) ikisi arasında dönüştürme yapabiliriz. Dörtlük, vec4 verileri açısından değerlendirilebilir. Yukarıda bağlantısı verilen ve buradaki uygulamadan uyarlanan kâğıda göre teğet uzay vektörlerinden QTangent'e dönüşüm aşağıdaki gibidir:
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örtlük normalleştirilir ve SNORM'leri kullanarak onu sıkıştırabilirsiniz. 16 bit SNORM'ler yüksek hassasiyet ve bellek tasarrufu sağlar. 8 bit SNORM'ler daha da fazla tasarruf sağlayabilir, ancak yüksek ölçüde spesifik olan malzemelerde kusurlara neden olabilir. Her ikisini de deneyerek öğeleriniz için en iyi seçeneği belirleyebilirsiniz. Dördün kodlanması aşağıdaki gibi görünür:
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ölgelendiricisindeki dördün kodunu çözmek için (buradan uyarlanı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ğlayacak farklı bir yaklaşımdan yararlanabilirsiniz: Normal vektörü sıkıştırmak için Kartezyen Koordinatlar yerine birim vektörlerin Oktahedral Eşlemesi. Sekiz Yüzlü Haritalama, bir birim kürenin bir sekiz yüzyeye yansıtması, ardından da sekiz yüzlünün projeksiyonunu 2D bir düzleme izlenmesi şeklinde yapılır. Sonuç, herhangi bir normal vektörü yalnızca iki sayı kullanarak gösterebileceğinizdir. Bu iki sayı, küreye yansıttığımız 2D düzlemde "örneklemek" için kullandığımız doku koordinatları olarak düşünülebilir. Bu sayede, orijinal vektörü kurtarabiliriz. Bu iki numara daha sonra bir SNORM8'de saklanabilir.
Figür 2: Dikey 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 noktası gölgelendirmesinde baskı açma (kartezyen koordinatlarına geri dönüştürmek için) pahalı değildir; Bu tekniği uygularken performansta büyük bir düşüşle karşılaşmadık. Köşe gölgelendiricisindeki 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, teğet alanının tamamını depolamak için de kullanılabilir, bu teknik kullanılarak normal ve tanjant vektörü vec2<SNORM8
> kullanarak depolanabilir. ancak bitangentin yönünü saklamanın bir yolunu bulmanız gerekir (bir model üzerine yansıtılan UV koordinatlarını kullandığınız 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ünü değiştirmeniz gerekiyorsa işareti ters çevirmek ve köşe gölgesinde bunu kontrol etmektir.
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 |
Vertex UV Koordinatları
Doku eşleme için kullanılan UV Koordinatları (diğer özelliklerin yanı sıra), genellikle 32 bit kayan öğeler kullanılarak depolanır. Bunların 16 bit kaymayla 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 bir yaklaşım, işaretlenmemiş normalleştirilmiş tam sayıları (UNORM), özellikle de UNORM16'yı kullanmaktır. Bu, tüm doku koordinatı aralığında tek tip bir dağılım sağlar ve 65536x65536'ya kadar dokuları destekler! Bu, doku koordinatlarının öğe başına [0,0, 1,0] aralığında olduğu varsayılır ve bu, ağa bağlı olarak böyle olmayabilir (örneğin, duvarlar 1,0'dan daha fazla sarmalayan doku koordinatları kullanabilir), bu tekniğe bakarken bunu göz önünde bulundurun. Dönüşüm işlevi aşağıdaki gibi 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 tepe noktası sıkıştırma teknikleri, köşe bellek depolama alanının 48 bayttan 16 bayta inerek% 66 oranında azalmasını sağladı. Bu da 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 Getirme Stantları:
- Gruplandırma: %50 - %0
- Oluşturma: %90 ile %90
- Ortalama Bayt/Vertex:
- Gruplandırma: 48 B - 16 B
- Oluşturma: 52B - 18B
Figür 3: Sıkıştırılmamış köşelerin Android GPU Denetleyicisi görünümü
Figür 4: Sıkıştırılmış köşelerin Android GPU Denetleyicisi görünümü
Vertex Akışı Bölme
Vertex Akışı Bölme, köşe arabelleğindeki verilerin düzenlenmesini optimize eder. Bu, özellikle Android cihazlarda bulunan karo tabanlı GPU'larda, özellikle de oluşturma işleminin bölme adımında fark yaratan bir önbellek performansı optimizasyonudur.
Kutu tabanlı GPU'lar, bölme yapmak için sağlanan köşe gölgelendiriciye göre normalleştirilmiş cihaz koordinatlarını hesaplayan bir gölgelendirici oluşturur. Bu işlem, görünür olsun ya da olmasın, sahnedeki her tepe noktasında ilk olarak yürütülür. Bu nedenle, köşe konumu verilerini bellekte bitişik tutmak büyük bir avantajdır. Bu köşe akış düzeninin faydalı olabileceği diğer yerler gölge geçişleri için de faydalı olabilir. Zira genellikle gölge hesaplamaları için konum verilerine ve genellikle konsol/masaüstü oluşturmada kullanılan bir teknik olan derinlik ön geçişleri gerekir. bu köşe akış düzeni, oluşturma motorunun birden çok sınıfı için avantajlı olabilir.
Akış Bölme, köşe arabelleğinin köşe konumu verilerinin bitişik bir bölümüyle ve aralıklı köşe niteliklerini içeren başka bir bölümle ayarlanmasını içerir. Çoğu uygulama, arabelleklerini genellikle tüm özellikleri içerecek şekilde tamamen ayarlar. Aradaki fark şu görselde açıklanır:
Before:
|Position1/Normal1/Tangent1/UV1/Position2/Normal2/Tangent2/UV2......|
After:
|Position1/Position2...|Normal1/Tangent1/UV1/Normal2/Tangent2/UV2...|
GPU'nun tepe noktası verilerini nasıl getirdiğine bakmak, akış bölme. Bağımsız değişken olarak varsayıldığında:
- 32 bayt önbellek satırı (oldukça yaygın bir boyut)
- Şunlardan oluşan 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, bölme işlemi için bellekten veri aldığında, üzerinde çalışmak için 32 baytlık bir önbellek satırı çeker. Köşe akışı bölme işlemi yapılmadığında, aslında bölme için bu önbellek satırının yalnızca ilk 12 baytını kullanır ve bir sonraki köşeyi getirirken diğer 20 baytı siler. Köşe akışı bölme işlemi kullanıldığında köşe konumları bellekte bitişik olur. Dolayısıyla 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 önce çalışılacak 2 tam köşe konumu içerecektir. Bu 2 kat iyileştirmedir.
Şimdi, köşe akışı bölme işlemini tepe noktası sıkıştırmasıyla birleştirdiğimizde, tek bir köşe konumunun boyutunu 6 bayta kadar indireceğiz. Böylece, sistem belleğinden alınan tek bir 32 baytlık önbellek satırı, üzerinde çalışabileceği 5 tam köşe konumuna sahip olacak. Bu da 5 kat gelişme olacak.
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 Getirme Stantları:
- Gruplandırma: %40 - %0
- Oluşturma: %90 ile %90
- Ortalama Bayt/Vertex:
- Gruplandırma: 48 B - 12 B
- Oluşturma: 52B - 52B
Figür 5: Bölünmüş köşe akışlarının Android GPU Denetleyici görünümü
Figür 6: Bölünmüş köşe akışlarının Android GPU Denetleyici görünümü
Birleş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 Getirme Stantları:
- Gruplandırma: %41 - %0
- Oluşturma: %90 ile %90
- Ortalama Bayt/Vertex:
- Gruplandırma: 48 B - 8B
- Oluşturma: 52 milyar - 19 milyar
Figür 7: Bölünmüş, sıkıştırılmamış köşe akışlarının Android GPU Denetleyici görünümü
Figür 8: Bölünmüş, sıkıştırılmış köşe akışlarının Android GPU Denetleyici görünümü
Göz Önünde Bulundurulan Diğer Noktalar
16 - 32 bit Dizin Arabelleği Verileri
- Örgüleri her zaman 16 bitlik bir dizin arabelleğine (en fazla 65.536 benzersiz köşe) sığacak şekilde bölün/parçalar. Köşe noktası verilerini getirmek daha ucuz olduğundan ve daha az güç tüketeceğinden, bu yöntem, mobil cihazlarda dizine eklenmiş oluşturma işlemine yardımcı olur.
Desteklenmeyen Vertex Arabellek Özelliği Biçimleri
- ÖLÇEKLİ köşe biçimleri mobil cihazlarda geniş ölçüde desteklenmez ve kullanıldığında, donanım desteğine sahip olmadıkları durumlarda emüle etmeye çalışan sürücüler açısından yüksek performans açısından olumsuz sonuçlar doğurabilir. Her zaman SNORM'yu kullanın ve sıkıştırılmış verileri açmak için göz ardı edilebilir ALU maliyetini ödeyin.