iPhone'u Isıtan Oyun: Godot'da Mobil Performans
8 dk okuma

Kalecik, Godot 4 ile yaptığım, parmakla çizgi çizerek taş sur, ev ve köprü kurduğunuz sakin bir kale ve köy oyunu. İlk build'leri iPhone 15 Pro Max'imde denerken bir şey dikkatimi çekti: oynarken telefon elimde belirgin şekilde ısınıyordu. Ekranda dönen şey bir çayır, birkaç koyun ve taş duvarlardı; bir aksiyon oyunu değildi. Bu kadar ısınmanın bir sebebi olmalıydı.
Bu yazı o sebebi ve arkasından gelen düzeltmeleri anlatıyor: ekranın 120 Hz'de çalışması, bir kare hızı yöneticisi, gölge vekilleri, parçalara bölünmüş çimen, iş parçacıklarıyla mesh kurma ve denenip atılan bir fikir. Serinin genel hikâyesini, yani oyunun beş paralel Claude Code oturumuyla nasıl yapıldığını merkez yazıda anlattım.
Baştan iki şeyi netleştireyim. Birincisi: bu projede kodu Claude Code ajanları yazdı. Ben ısınmayı fark edip bildirdim, performans için ayrı bir oturum açtım, kararları verdim ve her build'i telefonda denedim. İkincisi: yazıdaki bütün milisaniye değerleri M1 Mac'te ölçüldü. Telefonda ölçülmüş bir sıcaklık ya da fps rakamım yok, o yüzden "telefon şu kadar derece soğudu" gibi bir cümle kurmayacağım.
Önce ölçmek: Metal sıfır dediğinde
Performans oturumu işe tahminle değil ölçümle başladı. scripts/perf.gd iki araç ekliyor:
- Cihaz günlüğü: Debug build'ler saniyede bir satır
user://perf.csvdosyasına yazıyor: fps, o anki kare sınırı, en uzun kare, CPU ve GPU süresi, draw call ve primitif sayısı. iOS'ta bu dosya uygulamanın Documents klasörüne düşüyor vexcrun devicectlile Mac'e çekilebiliyor. - Karşılaştırmalı ölçüm: Oyun
-- --benchile açılınca yüklü köyü değiştirmeden render ayarlarını tek tek açıp kapatıyor (glow kapalı, gölge filtresi 1 ve 0, gölge atlası 1024, tek gölge bölmesi, çözünürlük ölçeği 0,7, MSAA kapalı, çimen yok, gölge yok) ve her durum için ortalama kare süresini yazıyor.
İlk sürprizi burada yaşadık: Godot'nun Metal sürücüsü viewport GPU süresini 0 olarak raporluyordu. Yani Mac'te varsayılan sürücüyle GPU'nun ne kadar çalıştığını göremiyorduk. Ajanın çözümü, aynı sahneyi Vulkan üzerinden, macOS'ta MoltenVK ile çalıştırmak oldu:
# Metal GPU süresini 0 gösterdiği için ölçümler Vulkan (MoltenVK) ile alındı
godot --path . --rendering-driver vulkan --rendering-method mobile -- --benchBir uyarı: M1'de aynı ölçüm çalıştırmadan çalıştırmaya belirgin biçimde oynuyor. Bu yüzden küçük farkları değil, yalnızca büyük farkları ciddiye aldık.
Kök neden: oyun 120 fps'te dönüyordu
Asıl bulgu render ayarlarında değil, dışa aktarma ayarlarındaydı. Godot'nun iOS dışa aktarması Info.plist içine CADisableMinimumFrameDurationOnPhone = true yazıyor. Bu anahtar, iPhone'da uygulamanın 60 Hz'in üstüne çıkmasına izin veriyor. Godot'nun display/window/ios/allow_high_refresh_rate ayarı da varsayılan olarak true. İkisi birleşince oyun, ProMotion ekranlı iPhone 15 Pro Max'te saniyede 120 kare çiziyordu.
Sakin bir köy oyunu için bu, çoğu zaman hiç değişmeyen bir sahneyi gereğinin iki katı sıklıkta yeniden çizmek demek. Düzeltme project.godot içinde tek satır:
[display]
window/ios/allow_high_refresh_rate=falseBu ayar yalnızca açılışta okunuyor; oyun çalışırken 120'ye geri dönmenin bir yolu yok. Bizim için sorun değil, çünkü zaten dönmek istemiyoruz.
Buradan çıkardığım ders: bir motor ya da şablonla çalışırken dışa aktarmanın varsayılanlarını bir kez gözden geçirin. Isınmanın kök nedeni oyun kodunda değil, kimsenin bakmadığı bir plist anahtarı ile bir varsayılan ayardaydı.
Kare hızı yöneticisi: 60, 30, 10
60 fps bile her an gerekli değil. Oyuncu parmağını kaldırıp köyüne bakarken koyunların yürümesi ve çimenin dalgalanması 30 fps'te de aynı görünüyor. perf.gd bu yüzden basit bir kare hızı yöneticisi içeriyor:
const FPS_ACTIVE := 60
const FPS_IDLE := 30
const FPS_AWAY := 10 # uygulama arka planda ya da odakta değil
const IDLE_S := 1.5 # bu kadar saniye sakinlikten sonra 30'a in
var _calm := 0.0
var _away := false
func _cap(fps: int) -> void:
if Engine.max_fps != fps:
Engine.max_fps = fps
## Parmak ekranda mı, kamera kayıyor mu, arka planda mesh kuruluyor mu?
func _busy() -> bool:
if not (game.touches as Dictionary).is_empty():
return true
if game.view != null and game.view.busy():
return true
return game.rig != null and game.rig.moving()
func _input(_ev: InputEvent) -> void:
_calm = 0.0
if not _away:
_cap(FPS_ACTIVE) # ilk dokunuş beklemeden tam hıza döner
func _notification(what: int) -> void:
match what:
NOTIFICATION_APPLICATION_FOCUS_OUT, NOTIFICATION_APPLICATION_PAUSED:
_away = true
_cap(FPS_AWAY)
NOTIFICATION_APPLICATION_FOCUS_IN, NOTIFICATION_APPLICATION_RESUMED:
_away = false
_calm = 0.0
func _process(delta: float) -> void:
if _away:
return
_calm = 0.0 if _busy() else _calm + delta
_cap(FPS_ACTIVE if _calm < IDLE_S else FPS_IDLE)Önemli nokta, "meşgul" tanımının doğru olması. Yalnızca dokunuşa bakmak yetmiyor: parmak kalktıktan sonra kamera kaymaya devam ediyorsa ya da büyük bir sur arka planda kuruluyorsa hâlâ 60 fps gerekiyor, yoksa hareket takılıyor. Otomatik testlerde yönetici kapalı, çünkü testler sınırsız kare hızı istiyor.
Gölgeler: ayrıntılı taş gölge çizmesin
Ölçümlere göre düzeltmeden önce GPU süresinin yaklaşık %32'sini gölgeler, %24'ünü çimen alıyordu (M1, MoltenVK). Gölgenin bu kadar pahalı olmasının sebebi, sur mesh'lerinin çok ayrıntılı olması: her metrede yüzlerce taş, üstüne sarmaşık yaprakları. Güneş gölge haritası çizilirken bütün bu taşlar ikinci kez çiziliyordu. Oysa gölge için yalnızca siluet önemli.

Yakından her taş görünüyor; gölgeyi ise sade bir vekil çiziyor.
Ajanın çözümü gölge vekilleri oldu: her sur parçası, kule ve ev için yanında sade bir şekil üretiliyor (iki yüz, üst yüzey, mazgallar düz kutu, kapı kemerleri açık). Ayrıntılı mesh gölge geçişine hiç girmiyor, gölgeyi sade şekil çiziyor:
## Görünen mesh gölge çizmez; gölgeyi yalnızca siluetten ibaret vekil çizer.
func _pair(mesh: Mesh, shadow: Mesh) -> Node3D:
var n := Node3D.new()
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
n.add_child(mi)
var sh := MeshInstance3D.new()
sh.mesh = shadow
sh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
n.add_child(sh)
return nSonuç: gölge primitifleri 164 binden 31 bine indi, uzak görünümde GPU süresi 4,57 ms'den 3,81 ms'ye düştü. Koddaki yorumun dediği gibi: gölgede yalnızca siluet önemli, taşın tek tek çıkıntıları değil.
Aynı turda iki ayar daha değişti. Glow (parıltı) artık yalnızca pencereler yandığında, yani esas olarak akşam ve gece açık; gündüz boşuna çalışmıyor. Karşılaştırmalı ölçümden sonra gölge filtresi kalitesi 2'den 1'e indi; MSAA 2x ve 0,8 çözünürlük ölçeği ise görüntü bozulmasın diye yerinde kaldı.
Gökyüzü de gizli bir maliyet çıkardı. ProceduralSkyMaterial rengine her yazış, ortam ışığını veren radiance haritasını yeniden çizdiriyor; aynı değeri yazsanız bile. Gün batımında ve yağmur başlarken bu her kare oluyordu. Varsayılan 256 piksel boyutta bu +1,4 ms demekti. Boyut 64 piksele indi (+0,16 ms, görüntü aynı) ve gökyüzü artık yalnızca renk gerçekten değiştiğinde, geçişler sırasında da en fazla 0,2 saniyede bir yazılıyor:
sky.radiance_size = Sky.RADIANCE_SIZE_64 # varsayılan 256
# her yazış radiance haritasını yeniden çizdirir: renk değişmediyse dokunma
if sky.sky_top_color != p.top or sky.sky_horizon_color != p.hor:
sky.sky_top_color = p.top
sky.sky_horizon_color = p.horÇimen: MultiMesh parçaları ve 7'den 4'e yaprak
Çayırda 39.000 çimen öbeği var. Hepsini tek bir MultiMesh'e koymak, kamera tek bir köşeye baksa bile hepsinin çizilmesi demek. grass.gd bunun yerine alanı 12,5 metrelik karelere bölüyor; her kare ayrı bir MultiMesh, böylece kamera görmediği kareleri çizmiyor. Arazi değişince de yalnızca birkaç küçük kare yeniden yükleniyor.

Çayırdaki çimen, 12,5 metrelik MultiMesh parçalarından oluşuyor.
Her karede aynı öbekler için iki MultiMesh var: yakında 7 yapraklı öbek, 55 metreden sonra 4 geniş yapraklı öbek. Uzakta birkaç piksellik bir tutam aynı görünüyor ama üçgen sayısı yaklaşık %40 azalıyor. Godot'nun görünürlük aralığı bu geçişi kendisi yapıyor:
const CHUNK := 12.5 # metre
const LOD_M := 55.0 # bu mesafeden sonra 4 yapraklı öbek
if li == 0: # yakın mesh: 7 yaprak
mmi.visibility_range_end = LOD_M
mmi.visibility_range_end_margin = 5.0
else: # uzak mesh: 4 geniş yaprak
mmi.visibility_range_begin = LOD_M
mmi.visibility_range_begin_margin = 5.0
mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFFBir çizgi bitince çalışan güncelleme de ağırdı. Her çizgiden sonra bütün öbekler yeniden zemine oturtuluyordu; büyük bir köyde bu 26 ms sürüyordu. Artık yalnızca yükseklik haritasının değişen dikdörtgenindeki öbekler oturtuluyor: 0,46 ms. Göletler için benzer bir iş 14 ms'den 0,12 ms'ye indi ve çizgi sonrası toplam güncelleme 64 ms'den 18,6 ms'ye düştü.
Çimende bir de öğretici bir hata yaşandı. GDScript'te PackedFloat32Array gibi Packed diziler değer tipi. Sözlükten alınan diziye ekleme yapıp geri yazmazsanız değişiklik kaybolur:
var cell: PackedFloat32Array = cells.get(key, PackedFloat32Array())
cell.append_array([...])
cells[key] = cell # geri yazmazsan ekleme bir kopyaya giderBu hata bir ara 46 çimen parçasını boş bıraktı. Fark edilmesi de ilginç oldu: mağaza ekran görüntülerinde zemin çimensiz çıkınca.
Surlar: 4 metrelik parçalar ve iş parçacıkları
Surların nasıl üretildiğini prosedürel sur, kapı ve köprü yazısında anlattım; burada yalnızca maliyet tarafı var. İlk sürümde parmak her kıpırdadığında bütün sur baştan kuruluyordu. 50 metrelik bir surda bu 80 ms, yani çizerken belirgin bir takılma demekti. Çözüm, surları 4 metrelik parçalara bölüp her parçayı önbelleğe almak oldu. Çizerken yalnızca son 4 metre (kuyruk) yeniden kuruluyor. Sonuç: canlı sur güncellemesi ortalama 8 ms, en kötü 11 ms. Bir kule yaklaşık 7,5 ms, çizgiyi bitirmek yaklaşık 27 ms.
Büyük değişiklikler, yani dört ve daha fazla parçanın kurulması gerektiğinde, iş WorkerThreadPool'a gidiyor. Bu sırada eski görüntü ekranda kalıyor. Burada GDScript'e özgü bir tuzak vardı: iş parçacıkları aynı RefCounted nesnesini paylaşınca referans sayacında birbirini bekliyordu. Çözüm, her işe yolun kendi kopyasını vermek:
if jobs.size() >= ASYNC_MIN: # ASYNC_MIN = 4; küçük işler ana iş parçacığında
for j: Job in jobs:
if j.path != null:
j.path = j.path.copy() as WallPath # her iş kendi kopyasıyla: paylaşılan sayaç yok
var id := WorkerThreadPool.add_group_task(_run_job.bind(jobs), jobs.size(), -1, true, "castle meshes")Bunu ölçmek için tools/bench_threads.gd yazıldı: 56 metrelik kapalı bir suru 14 iş olarak önce sırayla, sonra havuzda kuruyor. Sırayla 354 ile 2.158 ms arasında, havuzda 141 ile 164 ms arasında sonuç verdi. Aralığın bu kadar geniş olması ölçümün çok gürültülü olduğunu gösteriyor; buradan "şu kadar kat hızlandı" demiyorum, yalnızca havuzun açıkça daha hızlı ve daha tutarlı olduğunu söylüyorum.
Denenip bırakılan: otomatik mesh LOD
Her optimizasyon işe yaramadı. Ajan evler, kuleler ve sur parçaları için Godot'nun otomatik LOD üretimini (ImporterMesh.generate_lods) denedi: mesh kurulduktan sonra düşük öncelikli bir işçi onu sadeleştiriyor, Godot da ekrandaki hataya göre seviye seçiyordu. Kâğıt üzerinde iyi bir fikir.
Ölçünce LOD'ların hiç devreye girmediği görüldü. Geçişler kameradan yaklaşık 280 metre ötede başlıyordu; oyunun kamerası ise en fazla 75 metre uzaklaşıyor. Yani sistem yalnızca iş yükü ekliyor, hiçbir kareyi hafifletmiyordu. Kaldırıldı. Bu bölüm, bir optimizasyonun gerçekten çalıştığını ölçmeden kabul etmemenin iyi bir örneği.
Kısa kontrol listesi
Kendi mobil Godot projeniz ısınıyorsa sırayla şunlara bakın:
- Oyun gerçekte kaç fps'te dönüyor? ProMotion'lı bir iPhone'da
allow_high_refresh_rateayarını kontrol edin. - Sahne durgunken kare hızını düşüren bir yönetici var mı?
- Gölge geçişine giren mesh'ler gereğinden ayrıntılı mı? Siluet için sade bir vekil yeter.
- Binlerce örnek (çimen, ağaç) parçalara bölünmüş mü, uzakta daha hafif bir mesh'e geçiyor mu?
- Her karede "aynı değeri" yazdığınız ama motorun pahalı bir şeyi yeniden hesapladığı bir yer var mı (gökyüzü gibi)?
- Eklediğiniz optimizasyon ölçümde gerçekten devreye giriyor mu?
Oyun hâlâ geliştiriliyor; App Store sürümü çok yakında, Android sürümü hazırlanıyor. Güncel durumu ve özellikleri Kalecik sayfasında bulabilirsiniz. Yayın tarafının nasıl otomatikleştirildiğini ise App Store Connect API yazısında anlatıyorum.
Sık Sorulan Sorular
Godot ile yapılan oyun iPhone'da neden ısınır?
En sık sebep gereğinden yüksek kare hızı. ProMotion ekranlı iPhone'larda Godot'nun iOS dışa aktarması 60 Hz üstüne izin veriyor ve allow_high_refresh_rate varsayılan olarak açık; oyun 120 fps'te dönebiliyor. Bunun üstüne ayrıntılı gölgeler ve binlerce örnekli çimen gibi GPU yükleri eklenince telefon hızla ısınıyor.
120 Hz'i kapatmak oyunu kötü gösterir mi?
Hızlı tepki isteyen bir oyunda fark hissedilebilir. Kalecik gibi sakin bir kurma oyununda 60 fps akıcı görünüyor, oyuncu dokunmadığında 30 fps'e inmek de fark edilmiyor. Karar oyunun türüne göre verilmeli; ama varsayılan değeri bilinçli seçmek her durumda gerekli.
Godot'da Metal ile GPU süresi neden 0 görünüyor?
Bu projede kullanılan sürümde Godot'nun Metal sürücüsü viewport GPU süresini 0 raporladı. Mac'te gerçek GPU süresini görmek için oyun --rendering-driver vulkan --rendering-method mobile ile MoltenVK üzerinden çalıştırıldı. Rakamlar M1'de ölçüldüğü için telefondaki değerlerle birebir aynı değil, ama hangi ayarın pahalı olduğunu göstermeye yetiyor.
Yazıdaki milisaniye değerleri telefonda da geçerli mi?
Hayır, hepsi M1 Mac'te ölçüldü. Telefon için oyun her saniye bir günlük satırı yazıyor ve Instruments ile güç izi almak için bir betik de hazırlandı, ama bu yazı yazılırken telefondan kaydedilmiş bir ölçüm yok. Mac rakamları hangi değişikliğin büyük fark yarattığını gösteriyor; telefondaki kesin değeri değil.
İlgili Yazılar
Yapay Zekâ Ajanlarıyla Mobil Oyun Yapmak: Kalecik
Boş bir klasörden iPhone’a: Kalecik’i beş paralel Claude Code oturumunu yöneterek nasıl yaptım, hangi kararları verdim, nerede duvara tosladık.
Godot'da Prosedürel Taş Sur, Kapı ve Köprü Üretmek
Kalecik'te parmakla çizilen bir çizgi nasıl taş sura, kemerli kapıya, köprüye ve eve dönüşüyor? Yumuşatma, tohumlu taş örgüsü ve parça parça mesh.
App Store Connect API ile Yayın Hazırlığı Otomasyonu
Kalecik'in App Store hazırlığı API ile: JWT, fiyat takvimi, 50 dilde mağaza metni, oyun motorunun çektiği ekran görüntüleri ve tek komutla build yükleme.