Compute shader ile GPU parçacık sistemi: 1 milyon sınırı
40 bin parçacıkta düşen kare hızını, parçacık durumunu tamamen GPU'da tutup DrawProceduralIndirect ile çizerek 1 milyona çıkardım; ölçümler ve tuzaklar.
Kırk bin parçacıkta çöken kare süresi
Geçen yıl üzerinde çalıştığım bir aksiyon prototipinde ekranda aynı anda otuza yakın patlama olabiliyordu. CPU tarafında yazılmış parçacık sistemi kırk bin canlı parçacığa çıktığında profiler ana iş parçacığında 11,4 ms gösteriyordu. Kare bütçemiz 16,6 ms idi ve bu rakamın içinde henüz oyun mantığı, animasyon ve fizik yoktu.
Zamanın nereye gittiği açıktı. Her karede kırk bin Particle yapısını dolaşıp konum ve hız güncelliyor, sonra aynı veriyi vertex tamponuna kopyalıyorduk. Mesh.SetVertexBufferData çağrısı tek başına 2,1 ms tutuyordu. Güncelleme kısmını Burst ve Job System ile 3,8 ms'ye indirdim ama kopyalama olduğu yerde kaldı.
İlk denemem parçacık sayısını kısmak oldu: efekt başına adedi yarıya indirdim. Kare süresi 7,9 ms'ye düştü ama patlamalar cılız göründü ve sanat ekibi haklı olarak itiraz etti. Sayıyı düşürmek bir çözüm değil, sorunun kazandığını kabul etmektir.
Asıl sorun hesaplamanın maliyeti değil, verinin CPU ile GPU arasında gidip gelmesiydi. Bir parçacığın konumunu sadece GPU okuyorsa, o konumun CPU belleğinde durması için hiçbir sebep yok. HLSL compute shader ile sistemi tamamen GPU tarafına taşıdım; aşağısı o geçişin teknik detayı ve ölçülen sonuçları.
Durumu StructuredBuffer içinde tutmak
Parçacık durumu tek bir struct içinde duruyor: float3 pos, float3 vel, float life, uint seed. Toplam 32 bayt, yani hizalama için dolgu alanı eklemek zorunda kalmadım. Bir milyon parçacık 32 MB tutuyor; ping-pong yaptığım için iki tampon birlikte 64 MB VRAM demek. seed alanı yalnızca doğumda bir kez yazılıyor ve parçacığın rastgeleliğini ömrü boyunca deterministik tutuyor.
Okuma için StructuredBuffer<Particle>, yazma için RWStructuredBuffer<Particle> kullanıyorum. Aynı tamponu hem okuyup hem yazmak tanımsız davranıştır, çünkü farklı thread grupları hangi sırada çalışacağını size söylemez. ParticleSystemGPU.cs içinde her karede iki tampon referansını takas ediyorum; bellek kopyalanmıyor, sadece iki değişken yer değiştiriyor.
Yapı boyutunu 32 baytta tutmanın ölçülebilir bir karşılığı var. Denemek için half4 color ekleyip 40 bayta çıktığımda, güncelleme çekirdeği RTX 3060'ta 0,82 ms'den 1,19 ms'ye çıktı. Rengi zaten ömürden türetebiliyordum; tamponda taşımanın hiçbir anlamı yoktu. GPU'da darboğaz çoğu zaman aritmetik değil, bellek bant genişliği oluyor.
Tamponları ComputeBuffer yerine GraphicsBuffer ile oluşturuyorum. İkisi de çalışıyor, ama GraphicsBuffer aynı tamponu hem compute hedefi hem de dolaylı çizim argümanı olarak işaretlememe izin veriyor. Aynı belleği iki amaçla kullanmak, ayrı bir kopya tutmaktan hem hızlı hem de hata yapmaya daha kapalı.
Thread grup boyutunu neden 64 veya 128 seçiyorum
Çekirdeğin başında [numthreads(128, 1, 1)] yazıyorum, çünkü donanım zaten dalgalar hâlinde çalışıyor. NVIDIA tarafında warp 32, AMD tarafında wave 64 iş parçacığından oluşuyor. Grup boyutunuz bunun tam katı değilse son dalganın bir kısmı boşa dönüyor ve o dönüşün ölçülebilir bir bedeli var.
Aynı çekirdeği bir milyon parçacıkla dört farklı boyutta ölçtüm: 32 iş parçacığında 1,41 ms, 64'te 0,91 ms, 128'de 0,82 ms, 256'da 1,05 ms. 32 kötü, çünkü grup başına sabit maliyet — sınır kontrolü, sabit tampon okuması — çok fazla kez tekrar ediyor. 256 kötü, çünkü çekirdeğim 40 register kullanıyor ve register basıncı arttıkça SM başına eşzamanlı grup sayısı düşüyor.
Kapasite grup boyutunun tam katı olmadığı için çekirdeğin ilk satırında if (id.x >= _Capacity) return; duruyor. Bunu unutursanız son grup tamponun dışına yazar; Windows'ta genelde sessizce yanlış sonuç alırsınız, bazen sürücü TDR ile sıfırlanır. Dispatch sayısını Mathf.CeilToInt(capacity / 128f) ile hesaplıyorum ve kapasiteyi 128'in katına yuvarlamak bu kontrolü ucuzlatıyor.
Bir milyon parçacık, 128'lik gruplarla 7813 grup ediyor; tek eksende 65535 sınırının çok altında kaldığı için ikinci boyuta yayılmam gerekmedi. Kapasite dört milyonu geçtiğinde o sınıra çarpıyorsunuz ve id.y ile ikinci bir eksen kurmak gerekiyor. Bunu baştan yazmadım; ihtiyacım olmayan bir genelleme çekirdeği okunmaz hâle getiriyordu.
Ölü parçacıkları append/consume ile toplamak
Ömrü biten bir parçacığın yerini yeni doğana devretmek gerekiyor. Bunu AppendStructuredBuffer<uint> _DeadList ile yapıyorum: ölen parçacık kendi indeksini listeye ekliyor. Spawn çekirdeği ise ConsumeStructuredBuffer<uint> ile aynı listeden indeks çekiyor. Böylece boş yer arayan bir tarama döngüsü hiç yazmıyorum.
Append ve consume'un altında atomik bir sayaç var, yani her çağrı sıraya giriyor. Bütün parçacıklar aynı karede ölürse bu sayaç boğazlaşıyor: sentetik testte hepsini tek karede öldürdüğümde çekirdek 0,82 ms'den 1,26 ms'ye çıktı. Gerçek sahnelerde ölümler zamana yayıldığı için fark 0,05 ms'nin altında kaldı, o yüzden bu davranışı optimize etmedim.
Spawn çekirdeğini ayrı bir dispatch olarak, güncelleme çekirdeğinden önce çalıştırıyorum. Sırayı ters çevirdiğimde yeni doğan parçacıklar aynı karede bir kez güncelleniyor ve bir kare öne kayıyordu; harekette fark edilmiyordu ama patlamanın merkezinde küçük bir boşluk kalıyordu. Doğacak adedi CPU'dan tek bir int olarak geçiyorum, çünkü o sayıyı zaten oyun mantığı belirliyor.
Bu bölümün iki klasik tuzağı var. Birincisi tamponu ComputeBufferType.Append bayrağı olmadan oluşturmak; shader derleniyor, çalışıyor, sayaç hep sıfır kalıyor ve hiçbir hata mesajı görmüyorsunuz. İkincisi sayacı yanlış yerde sıfırlamak: SetCounterValue(0) çağrısını yalnızca canlı indeks tamponunda, her karede, dispatch'ten hemen önce yapıyorum. Aynı çağrıyı ölü listesine uygularsanız birikmiş boş yerleri silersiniz ve parçacıklar bir daha doğmaz.
DrawProceduralIndirect: CPU'ya hiç dönmemek
Canlı parçacık sayısı GPU'da belli, CPU'da değil. Bu sayıyı GetData ile sormak, komut kuyruğunun boşalmasını beklemek demek; ölçtüğümde tek bir okuma kareye 4–6 ms ekliyordu. Bu yüzden çizimi Graphics.DrawProceduralIndirect ile yapıyorum ve sayı CPU'ya hiç uğramıyor.
Argüman tamponu dört uint: vertex sayısı, örnek sayısı, başlangıç vertexi, başlangıç örneği. Her karede GraphicsBuffer.CopyCount(_aliveIndices, _argsBuffer, 4) çağırarak canlı sayacını ikinci alana kopyalıyorum. CPU o sayının kaç olduğunu bilmiyor; sadece kuyruğa bir kopyalama komutu yazıyor.
Vertex tarafında da mesh yok. SV_VertexID'den parçacık indeksini ve köşe numarasını türetip quad'ı doğrudan vertex shader içinde kuruyorum; _ParticlesOut ve _AliveIndices tamponlarını oraya StructuredBuffer olarak bağlıyorum. Vertex tamponu bağlama, index tamponu, mesh güncelleme adımlarının hepsi ortadan kalkıyor.
Ölçülen fark şu oldu. CPU sistemi kırk bin parçacıkta ana iş parçacığında 11,4 ms tutuyordu; GPU sistemi bir milyon parçacıkta 0,82 ms compute, 1,6 ms çizim ve ana iş parçacığında 0,05 ms harcıyor. Yirmi beş kat fazla parçacık, kare süresinde yaklaşık sekiz kat düşüş. Daha önemlisi CPU tamamen boşta kaldı ve o bütçeyi yapay zekâya verebildik.
Senkronizasyon tuzakları ve nereden başlamalı
GroupMemoryBarrierWithGroupSync() yalnızca kendi grubunu senkronize eder. Bir dispatch içinde gruplar arası senkronizasyon yoktur ve GPU'ya geçerken en çok kafa karıştıran şey bu. Komşu parçacıkların birbirini okuması gerekiyorsa — çarpışma, sürü davranışı, komşuluk ızgarası — o adımı ikinci bir dispatch'e ayırmak zorundasınız.
İkinci tuzak sıralama. _AliveIndices içindeki sıra her karede değişiyor, çünkü grupların bitiş sırası garanti değil. Alfa harmanlamalı parçacıkları bu sırayla çizerseniz görüntü karelerde titriyor; bunu ilk fark eden ben değildim, QA'nın gönderdiği yavaş çekim kayıtta apaçık görünüyordu. İki çözüm var: derinliğe göre GPU'da sıralamak (bitonic sort, bir milyon eleman için ölçtüğümde 0,9 ms) ya da additive harmanlamaya geçmek. Ben ikincisini seçtim, çünkü efektlerin çoğu zaten additive idi.
Hata ayıklama alışkanlıklarını da değiştirmek gerekiyor. Breakpoint yok, Debug.Log yok. Ben ayrı bir RWStructuredBuffer<float4> açıp şüphelendiğim ara değerleri oraya yazıyorum ve yalnızca hata ararken geri okuyorum. Bu okuma yine kareye 4–6 ms ekliyor, o yüzden bir #if DEBUG_PARTICLES bloğunun içinde duruyor.
Başlamak için tüm sistemi bir seferde taşımaya çalışmayın. Önce tek bir efekti — bende mermi izleri oldu — sabit kapasiteli bir tamponla GPU'ya alın, dispatch süresini RenderDoc ile ölçün, ölü listesini ve indirect çizimi ondan sonra ekleyin. Kapasiteyi baştan bir milyon seçmek de gereksiz: gerçek sahnede tepe canlı parçacık sayısını ölçüp iki katını almak, VRAM'i ve güncelleme süresini birlikte doğru yere oturtuyor.