anasayfa / blog / performans
Yayın: 16 Nisan 2026 10 dk okuma performans
Draw call ve overdraw: mobil oyunda 17 FPS nasıl kazandım

Draw call ve overdraw: mobil oyunda 17 FPS nasıl kazandım

Orta segment bir Android telefonda kule savunma sahnem 41 FPS'te takılıyordu. Draw call, materyal ve overdraw tarafında ölçerek 58 FPS'e nasıl çıktığımı anlatıyorum.

41 FPS'te takılan kule savunma sahnesi

Geçen kış bir kule savunma projesinin son optimizasyon turunu ben aldım. Redmi Note 10 üstünde — Snapdragon 678, Adreno 612 — dalga 12'de kare süresi 24,3 ms'e çıkıyor, ortalama 41 FPS görünüyordu. İlk varsayımım klasikti: ekranda çok fazla düşman var, demek ki yapay zekâ ve fizik pahalı. İki gün görev planlayıcısını profilledim ve hiçbir şey bulamadım; oyun mantığı thread'i 6,1 ms'de bitiyordu.

Unity Profiler'da render thread 14,8 ms, GPU ise 26 ms okuyunca resim netleşti. Frame Debugger 780'in üzerinde draw call sayıyordu ve bunların yaklaşık 400'ü kule menzil halkaları, hasar sayıları, zemin decal'leri ve duman partikülleriydi. Yani ekranın büyük bölümü üst üste binmiş saydam katmanlarla kaplıydı ve her katman aynı alanı baştan boyuyordu. Düşman sayısını yarıya indirdiğimde kare süresi sadece 1,2 ms düştü; suçlu orada değildi.

Asıl hata benim mobil oyun optimizasyonu tanımımdaydı: yıllarca bunu "poligon sayısını düşürmek" diye düşünmüştüm. Sahnedeki toplam üçgen 210 bindi ve Adreno 612 bu geometriyi rahatça çiziyordu. Yük iki ayrı cepheden geliyordu: draw call başına düşen CPU kurulum maliyeti ve piksel başına ana belleğe yazılan bant genişliği. Aşağısı o iki cepheyi nasıl ayırdığımın ve hangi değişiklikten kaç milisaniye aldığımın kaydı.

Hangi batching ne zaman devreye girer

Üç mekanizma var ve üçü aynı işi yapmıyor. SRP Batcher draw call sayısını düşürmez; aynı shader varyantını paylaşan çizimlerde materyal sabitlerini kalıcı bir GPU buffer'ında tutar ve çağrı başına CPU kurulumunu ucuzlatır. Yani 780 çağrı 780 kalır, ama her biri belirgin biçimde hafifler. Materyal sayısı değil, shader varyantı sayısı önemlidir; bunu kaçırdığım için uzun süre yanlış şeyi birleştirmeye çalıştım.

GPU instancing ise sayıyı gerçekten düşürür: aynı mesh ve aynı materyal, tek çağrıda 1023 örneğe kadar. Buradaki tuzak şu — URP'de shader SRP Batcher uyumluysa instancing yolu hiç devreye girmez, SRP Batcher öncelik alır. Kule tabanlarını Graphics.DrawMeshInstanced ile elle çizene kadar bunu fark etmemiştim; Frame Debugger'da "SRP Batch" satırlarını görüp instancing çalışıyor sanıyordum.

Static batching build sırasında mesh'leri tek büyük bir vertex buffer'ında birleştirir. Kazanç gerçek ama bedeli var: bizim sahnede 38 MB ek mesh belleği ve culling granülaritesinin kaybı, çünkü birleşmiş grup ya tümüyle çizilir ya hiç çizilmez. Sadece hiç hareket etmeyen ve kamerada zaten birlikte görünen zemin parçalarında açık bıraktım. Ağaçlar ve kayalar için kapatmak hem belleği hem de çizilen üçgen sayısını düşürdü.

Bir de sessiz batch kırıcı var: MaterialPropertyBlock. Kule seviyelerini renkle ayırmak için her renderer'a bir tane takmıştık ve bu, o nesnelerin SRP Batcher uyumunu tamamen bozuyordu. Renk varyasyonunu instance verisine ve vertex color'a taşıdıktan sonra render thread 14,8 ms'ten 11,0 ms'e indi — tek satır shader değişikliği olmadan.

BatchingSetup.cs
using UnityEngine; using UnityEngine.Rendering; // Tower bases share one atlas material, so they can be submitted as one // instanced call. URP prefers the SRP Batcher over instancing here. public sealed class BatchingSetup : MonoBehaviour { private const int MaxPerBatch = 1023; [SerializeField] private Mesh _baseMesh; [SerializeField] private Material _atlasMaterial; [SerializeField] private Transform[] _slots; private readonly Matrix4x4[] _matrices = new Matrix4x4[MaxPerBatch]; private int _count; private void Awake() { _count = Mathf.Min(_slots.Length, MaxPerBatch); for (int i = 0; i < _count; i++) _matrices[i] = _slots[i].localToWorldMatrix; } private void Update() { // One draw call for up to 1023 bases instead of one per renderer. Graphics.DrawMeshInstanced(_baseMesh, 0, _atlasMaterial, _matrices, _count, null, ShadowCastingMode.Off, receiveShadows: false); } }
csharpBatchingSetup.cs

Materyal sayısı ve texture atlas düzeni

Draw call sayısını asıl düşüren şey materyal sayısıydı. Sahnede 34 farklı materyal vardı ve çoğunun tek farkı 512x512'lik başka bir dokuydu. Bunları 2048x2048'lik üç texture atlas altında topladım: çevre, kuleler, efekt ve UI. Bir materyal ne kadar çok nesne tarafından paylaşılırsa, instancing ve batching için o kadar geniş bir havuz oluşuyor.

Atlas işi göründüğü kadar mekanik değil. UV'leri yeniden paketlerken tiling kullanan her mesh'i dışarıda bırakmak zorunda kaldım, çünkü atlas içinde wrap mode repeat çalışmaz; komşu adanın pikselleri sızar. Zemin döşemeleri için tek bir ayrı materyal bıraktım ve onu instancing ile çizdim. Mipmap kenarlarında sızıntı olmasın diye her adaya 8 piksel padding verdim; 4 piksel ile uzak mesafede ince renk çizgileri görünüyordu.

Sıkıştırma tarafında ETC2 yerine ASTC 6x6'ya geçtim; aynı bellek bütçesinde gözle görülür biçimde daha temiz, özellikle atlas içindeki gradyanlarda. Atlas sonrası materyal sayısı 34'ten 6'ya, draw call 780'den 210'a düştü. Render thread 11,0 ms'ten 7,4 ms'e indi. Bu adım tüm optimizasyon turunun en büyük tek kazancıydı ve hiçbir shader satırı yazmadım.

Saydam katmanların overdraw faturası

Overdraw, aynı pikselin bir karede kaç kez yazıldığıdır. Saydam nesnelerde ZWrite kapalı olduğu için derinlik testi kimseyi elemez; arkadaki de öndeki de shade edilir. Rendering Debugger'ın overdraw görünümünde sahnenin ortası 11 kat okuyordu, yani bazı pikseller bir karede on bir kez boyanıyordu. GPU tarafındaki 26 ms'in büyük kısmı buradaydı.

Katmanları tek tek saydım: menzil halkası, zemin decal'i, duman, kıvılcım, hasar flash'i ve en üstte tam ekran bir vinyet quad'ı. Vinyet'i son post-process shader'ına taşıyıp ayrı quad'ı sildim — tek başına 1080p'de bir tam ekran katman demek. Menzil halkasını sadece kule seçiliyken çizdim. Duman partikül sayısını 240'tan 90'a indirip her partikülün boyutunu büyüttüm: aynı görsel yoğunluk, üçte bir fill maliyeti.

Alpha test'i çözüm sanmayın. Yaprak ve çit materyallerinde clip() kullanmak tile tabanlı GPU'da early-z ve gizli yüzey elemesini bozuyor; ölçtüğüm iki denemede de alpha blend'e geri dönmek 0,6 ms kazandırdı. Aynı şekilde saydam nesneleri önden arkaya sıralamak hiçbir şey kazandırmaz, çünkü hiçbiri derinlik yazmaz. Kazanç sadece katman sayısını ve kapladıkları ekran alanını düşürmekten gelir.

Tile tabanlı GPU'da asıl sınır bant genişliği

Mobil GPU'lar kareyi tile'lara böler, her tile'ı çip üstündeki küçük ve hızlı bir bellekte işler, sonucu ana belleğe yazar. Pahalı olan shader matematiği değil, bu yazma ve okuma trafiğidir. 1080p'de tek bir RGBA32 hedefi kare başına yaklaşık 8 MB, 60 kare/sn ile 500 MB/s eder — ve bu sadece bir geçiş. Sıcaklık yükseldiğinde ilk kısılan da bellek saati oluyor, bu yüzden bant genişliği aynı zamanda bir termal sorundur.

Bu yüzden kare ortasında render target değiştirmek pahalıdır: her geçiş tile belleğinin ana belleğe resolve edilmesini zorlar. Post-process zincirimizde üç ayrı blit vardı; bloom downsample ile renk düzeltmeyi tek pass'te birleştirince GPU süresi 1,7 ms düştü. Aynı sebeple içeriği önemsiz olan hedeflerde RenderBufferLoadAction.DontCare vermek bedava kazançtır; gereksiz bir load, tüm hedefi ana bellekten tile'lara okumak demek.

Depth prepass mobilde genelde ters teper. Masaüstünde overdraw'ı kesen bu teknik, tile mimarisinde geometriyi iki kez işleyip fazladan derinlik trafiği ürettiği için bizim sahnede 0,9 ms kaybettirdi. Adreno'nun kendi düşük çözünürlüklü Z elemesi zaten benzer işi bedavaya yapıyor. Denedim, ölçtüm, geri aldım — mobilde masaüstü reflekslerinin çalışmadığı yerlerden biri.

Saydam katmanlar için asıl çözüm yarım çözünürlüklü partikül render'ı oldu. Partikülleri yarı boyutlu bir render target'a çizip depth-aware upsample ile sahneyle birleştirdim: shade edilen piksel sayısı dörtte bire iniyor, birleştirme 0,4 ms'e mal oluyor, net kazanç 3,1 ms. Kenarlarda hafif merdivenlenme var ama duman ve toz gibi düşük frekanslı efektlerde görünmüyor. Kıvılcım ve mermi izi gibi keskin, ince efektleri tam çözünürlükte bıraktım.

Orta segment Android'de ölçülen sonuç

Aynı 60 saniyelik dalga 12 kaydında, aynı Redmi Note 10 üstünde: kare süresi 24,3 ms'ten 16,4 ms'e, ortalama 41 FPS'ten 58 FPS'e çıktı. Draw call 780'den 190'a, materyal 34'ten 6'ya, ölçülen en yüksek overdraw 11'den 4'e indi. 15 dakikalık oturumda pil sıcaklığı 44 °C yerine 39 °C'de kaldı, yani cihaz throttling'e hiç girmedi ve son beş dakikadaki FPS düşüşü kayboldu.

Kazancın dağılımını önemsiyorum, çünkü sıradaki projede nereden başlayacağımı o belirliyor: yaklaşık %40'ı materyal ve atlas işinden, %35'i overdraw azaltma ile yarım çözünürlüklü partikülden, %15'i bant genişliği düzenlemelerinden, kalanı SRP Batcher uyumunu geri kazanmaktan geldi. Poligon azaltma bu listenin hiçbir yerinde yok. Mesh'lere hiç dokunmadım ve LOD ayarlarını olduğu gibi bıraktım.

Kendi projenizde şu sırayı öneririm: önce Frame Debugger'da materyal sayısını ve batch kırılma sebeplerini yazın, sonra overdraw görünümünde en kötü üç katmanı bulun, ancak ondan sonra shader'a dokunun. Her adımı gerçek cihazda, aynı kayıtlı sahnede ve aynı sıcaklıkta ölçün; editörde 200 FPS gösteren bir değişikliğin telefonda 0,2 ms kaybettirdiği çok oldu. Ve tek bir sayıya bakmayın — draw call, overdraw ve bant genişliği ayrı ayrı sınırlar, biri düzelirken diğeri kolayca bozulur.

← Tüm yazılar