Unreal'de Blueprint mi C++ mi: ölçtüğüm gerçek maliyet
Blueprint VM'in maliyeti çoğu sahnede görünmez, ama tick başına on binlerce node çalışınca 7 ms'e çıktı. Hibrit iş akışını sayılarla anlatıyorum.
Bir tick'te 41 bin node çalışınca
2023 sonunda çalıştığım kule savunması projesinde 14. dalgadan sonra kare süresi 11,2 ms'ten 19,4 ms'e çıkıyordu. Editörde her şey kabul edilebilir görünüyordu, paketlenmiş Development build'de fark net biçimde vardı. Unreal Insights'ta en üstteki satır BlueprintTime idi ve tek başına 7,1 ms yiyordu. Hedefimiz 60 fps olduğu için bütçenin yarısından fazlası tek bir sistem tarafından tüketiliyordu.
Suçlu BP_TowerBase içindeki Event Tick'ti. Her kule, sahnedeki 220 düşmanı ForEachLoop ile geziyor, mesafe karesini hesaplayıp en yakınını seçiyordu. 24 kule çarpı 220 düşman, node başına birkaç işlemle birlikte kare başına yaklaşık 41 bin node yürütmesi demekti.
Sorun "Blueprint yavaş" değildi. Sorun, O(n·m) bir aramayı her karede sanal makinede çalıştırmamızdı. Aynı kodu C++'a taşımak maliyeti düşürür, ama asıl hata algoritmadaydı; Unreal Blueprint mi C++ mi tartışmasında bu iki şeyi birbirine karıştırmamak gerekiyor.
O sırada ekipte üç kişiydik ve hiçbirimiz Blueprint tarafını ölçmüyorduk. Kare süresi bozuldukça önce çizim çağrılarına, sonra gölge ayarlarına ve doku çözünürlüklerine baktık. İki gün kaybettikten sonra Insights'ı açıp doğru satıra bakmak yetti. Yazının geri kalanı, o iki günü bir daha yaşamamak için çıkardığım kurallar.
Blueprint performansı nerede ölçülebilir olur
Ölçmek için sade bir test yaptım: boş gövdeli bir fonksiyonu 100.000 kez çağırdım. C++ tarafında toplam süre 0,4 ms, birebir aynı fonksiyon Blueprint'te 6,8 ms. Çağrı başına yaklaşık 68 ns'lik bir fark ediyor.
68 ns hiçbir şey gibi durur ve çoğu durumda öyledir. 200 node'luk bir kapı açılma akışı ya da envanter ekranının mantığı için bu maliyet gürültünün altında kalıyor; kare başına 2.000 node'un altında toplam etki hiçbir ölçümde 0,15 ms'i geçmedi. Aynı testi 4.27 ve 5.3 üzerinde tekrarladım; iki sürüm arasında çağrı başına maliyet anlamlı biçimde değişmedi, yani bu sayı bir süre daha geçerli kalacak.
Maliyetin görünür hale geldiği yer belli: kare başına on binlerce node. Kullandığım kaba eşik şu — bir Blueprint her karede çalışıyorsa ve içinde döngü varsa, o mantık artık C++ adayıdır. Tek seferlik olaylarla tetiklenen akışlarda VM maliyeti tartışma konusu bile değil.
Bir de ölçüm tuzağı var. PIE'de Blueprint çağrıları debug enstrümantasyonu yüzünden gerçekte olduğundan pahalı görünür, bu yüzden editörde alınan sayıya bakıp karar vermek yanıltıcı. Paketlenmiş Development build'de stat game ve Insights ile bakmadan hiçbir şeyi C++'a taşımıyorum.
Hibrit iş akışı: çekirdek C++, ayarlar Blueprint
Hedef seçimi, hasar hesabı, cooldown takibi ve durum makinesi AOFKTowerBase içine C++ olarak taşındı. Hedef araması artık her karede değil, 0,2 saniyelik bir zamanlayıcıyla ve mekânsal bir ızgara üzerinden çalışıyor. Blueprint tarafında kalanlar: efekt zamanlaması, ses tetikleri, arayüz geri bildirimi ve tasarımcının sürekli oynadığı sayılar. Tek bir kuralımız vardı: C++ hiçbir zaman somut bir Blueprint sınıfına doğrudan bağımlı olmayacak, iletişim yalnızca olaylar ve arayüzler üzerinden yürüyecek.
Sonuç olarak kare süresi 19,4 ms'ten 11,8 ms'e indi, BlueprintTime 7,1 ms'ten 0,9 ms'e düştü. Bu düşüşün tamamı VM'den gelmedi; algoritma değişikliğinin payı yaklaşık 5 ms, saf C++'a geçişin payı 2,6 ms. Bu ayrımı yapmadan "C++ %40 hızlandırdı" demek dürüst olmazdı.
Elediğimiz iki alternatif vardı. Her şeyi C++'a almak teknik olarak en hızlısıydı, ama tasarımcı tek bir hasar değerini denemek için 90 saniyelik derleme ve editör yeniden başlatması bekliyordu; günde 40 deneme yapan biri için bu kabul edilemez. Her şeyi Blueprint'te bırakıp sadece tick'i kapatmak ise asıl döngüyü ortadan kaldırmadığı için hiçbir işe yaramadı.
Sınırı çizerken sorduğum soru şu: bu değeri değiştirmek için derleme gerekiyor mu? Gerekiyorsa ve tasarımcı onu haftada birkaç kez değiştiriyorsa, o değer Blueprint'e ya da bir veri varlığına çıkmalı. Kule dengeleme tablosunu UDataAsset olarak tutuyoruz; C++ okuyor, tasarımcı editörde düzenliyor, kimse derleme beklemiyor.
UPROPERTY ve UFUNCTION ile doğru yüzeyi açmak
Hibrit yaklaşımın kalitesi, C++'ın Blueprint'e tam olarak neyi açtığına bağlı. Ayarlanabilir sayıları UPROPERTY(EditAnywhere, BlueprintReadOnly) ile açıyorum; tasarımcı değeri editörde değiştirebiliyor ama çalışma zamanında yazamıyor. meta = (ClampMin, UIMax) ile sınır koymak, "yanlışlıkla 0 girildi" tipi hataların yarısını daha ortaya çıkmadan siliyor. Category vermeyi de ihmal etmeyin; kırk alanlı bir aktörde kategorisiz özellikler tasarımcı için okunamaz bir listeye dönüşüyor.
Fonksiyon tarafında üç seçenek arasındaki fark önemli. BlueprintCallable Blueprint'in C++'a soru sormasıdır; BlueprintImplementableEvent C++'ın Blueprint'e "şu an oldu, görselini sen hallet" demesidir; BlueprintNativeEvent ise C++'ta varsayılan bir davranış verip Blueprint'in gerekirse üzerine yazmasına izin verir. Kural basit: ne zaman olacağına C++ karar verir, nasıl göründüğüne Blueprint.
En sık gördüğüm hata her alana BlueprintReadWrite koymak. Bir kez açtığınızda tasarımcı durum değişkenlerine yazmaya başlıyor ve C++'ta koruduğunuz değişmezler sessizce bozuluyor; bizde bu, mermi sayacının negatife düşmesi olarak ortaya çıktı. İkinci tuzak yeniden adlandırma: bir UPROPERTY'nin adını değiştirdiğinizde Blueprint referansları kopar, o yüzden Core Redirects girişi yazmadan hiçbir alanın adını değiştirmiyoruz.
Bir de derleme süresi tarafı var. Header'daki bir UPROPERTY'ye dokunmak, o header'ı içeren her şeyi yeniden derletiyor; bizde bu iyi bir makinede 4 dakikaya kadar çıkıyordu. Sık değişen ayarları ayrı ve küçük bir yapıya toplamak hem tasarımcının döngüsünü hızlandırdı hem de günlük tam derleme sayımızı belirgin biçimde düşürdü.
Nativization kaldırıldıktan sonra ne değişti
4.26 döneminde Blueprint nativization'a güvenen ekipler vardı, biz de bir süre denedik. Ölçülen kazanç ağır sahnelerde 1,6 ms civarındaydı; karşılığında paketleme süresi 18 dakika uzadı ve sadece nativize build'de görülen üç hata çıktı. UE5 ile bu seçenek tamamen kaldırıldı.
Pratik sonucu şu: geç aşamada "derleyici halleder" diyebileceğiniz bir kaçış kapısı artık yok. Sıcak yolun C++ olması bir optimizasyon adımı değil, mimari karar; projenin başında verilmesi gerekiyor. UE5 C++ tarafını sonradan eklenen bir yama gibi düşünmek, prodüksiyonun ortasında karşınıza çıkıyor.
Bunun iyi tarafı da var. Nativization varken insanlar Blueprint'te ağır mantık yazıp "nasılsa nativize ederiz" diye planlıyordu ve o an hiç gelmiyordu. Seçenek ortadan kalkınca sınırı baştan çizmek zorunlu hale geldi, bu da uzun vadede daha temiz bir kod tabanı bıraktı. Ayrıca paketleme süresinin kısalması, günde iki yerine dört test build'i almamızı mümkün kıldı.
Nativization'ın yerine koyduğumuz şey ölçüm oldu. Her sürüm öncesi paketlenmiş build'den Insights izi alıyoruz ve BlueprintTime 1 ms'i geçtiğinde hangi Blueprint'in sorumlu olduğunu buluyoruz. Bu kontrol on beş dakika sürüyor. Son bir yılda üç kez, sahaya çıkmadan önce ciddi bir gerilemeyi yakaladı.
Ekip büyüdükçe merge ve versiyon kontrolü
Dört kişiyken Blueprint'in ikili dosya olması sorun değildi. On bir kişiye çıktığımızda haftada iki-üç kez aynı .uasset dosyasına iki kişi dokunuyordu ve .uasset merge edilemiyor; kaybeden taraf işini baştan yapıyordu. Bir sprintte bu şekilde kaybedilen süreyi yaklaşık iki gün olarak hesapladık.
Perforce'ta zorunlu checkout ve exclusive lock kurduk; çakışma bitti ama yerine bekleme geldi. Bir Blueprint kilitliyken ikinci kişi ya bekliyor ya da başka işe geçiyor. C++ tarafında bu sorun yok: metin dosyası satır satır merge oluyor, pull request'te gerçekten okunabiliyor. Blueprint kod incelemesi ise pratikte ekran görüntüsü göndermekten ibaret.
Bugün Unreal Engine geliştirme tarafında dört kural uyguluyorum. Her karede çalışan hiçbir mantık Blueprint'te kalmaz ve Blueprint'lerde tick varsayılan olarak kapalıdır. Bir Blueprint 150 node'u geçtiyse içinden bir parça C++'a çıkar. Bir sprint içinde aynı Blueprint'e iki kişinin dokunması gerekiyorsa, o mantık zaten C++'a aittir.
Bu kurallar performans kadar ekip hızıyla da ilgili. Blueprint'i tasarımcının hızlı denemesi için, C++'ı sistemin omurgası için kullanın ve sınırı ilk satırı yazmadan önce çizin. Sonradan taşımak her zaman daha pahalı; bizim ödediğimiz fatura üç haftalık bir refactor oldu.