Evet arkadaşlar oyun geliştirme ile ilgili ilk yazımızı her oyunun temel taşlarından biri olan oyun döngülerine ayıracağız.
Her ne kadar son yıllarda artık tek bir döngü üzerinden dönen oyunlardan ziyade task/thread tabanlı diğer bir deyişle bütün işleri parçalara bölüp bunları bağımsız şekilde çalıştırmaya dayalı yöntemler ortaya çıksa da bütün bunların temeli yine de basit oyun döngülerine dayanmaktadır.
İnternet üzerinde bu konu ile ilgili oldukça çeşitli kaynaklar bulunmakta ve bu sayfanın sonunda da bunların arasında bana göre en faydalı olanları sizler ile paylaşacağım. Bu yazımı da aslında bu kaynakların derlenmiş ve süzülmüş hali olarak düşünebilirsiniz. Tabi bu kaynakların çoğunun da ingilizce olması türkçe kaynak bulmanın zorluğu da böyle bir yazı hazırlamaya itti.
Yavaş ve hızlı donanımlarda olan durumların ayrıca ifade edilmesi benim de hoşuma gitti ve ben de bu yazımda bu yaklaşımları bu şekilde ifade etme yoluna gittim.
Farklı oyun döngü yaklaşımlarını başlıklar altında vererek her birine ilişkin açıklamaları bunların altına eklemeye çalışacağım.
İçerik
Yöntem 1 : Allah ne verdiyse 😃:
Aşağıda C++ programlama dili ile yazılmış olan bir oyun döngüsünü görebilirsiniz. Bu adımlar eminim sizlere bir fikir vermiştir. Görebileceğiniz en basit oyun döngüsü muhtemelen buna benzeyecektir. Bu oyun döngüsü genel olarak bir oyun döngüsü içerisinde neler yapıldığını göstermek ile birlikte aynı zamanda basit oyunlar için kullanılabilecek bir döngü teşkil etmektedir.
1 2 3 4 5 6 7 8 9 10 11 12 |
// Basit bir oyun döngüsü int main() { ... while(true) { ProcessInputs(); Update(); Display(); } ... } |
Burada her bir satırda neneler yapıldığından kısaca bahsedelim.
ProcessInputs: Kullanıcı girdilerini kontrol et. Neler olabilir bunlar:
- Klavye tuş basımı (Space ile zıplama),
- Fare hareketi (Silah nişangahını hareket ettirme),
- Akıllı telefon ekranın dokunma (Seçim), vb.).
Update: Gerek kullanıcı girdileri ile gerekse diğer olay veya etkileşimler ışığında oyunun mevcut durumunu güncelle.
- Zıplama girdisi gelmişti o zaman karakterin yüksekliğini arttıralım vs
Display: İsminden de anlaşılacağı üzere bütün oyun ile ilgili çizimlerin ve benzeri işlerin gerçekleştirildiği adım işte budur.
- Kullanıcıyı çiz,
- Arka planı çiz.
Aşağı yukarı bütün oyunlar bu adımları içerse de elbette daha fazlası da vardır (fizik simülasyonu, yapay zeka aktiviteleri, ağ haberleşmesi vs). Ama bu yazımızda biz bu üçüne yoğunlaşacağız, fiziğe de değiniriz. Çünkü genelde bu döngüler arasında geçen zamandan en çok etkilenen kabiliyetler fizik ile ilgili olanlar olmaktadır.
Burada bahsetmemizde yarar olduğunu düşündüğüm bir değer kavramlar ise FPS (Frame Per Second) ve Oyun Hızı. Özellikle FPS çok sık bir şekilde duyduğumuz ve yukarıda verdiğimiz döngüde bulunan Display () metodunun saniyede kaç kere çağrıldığıdır. Oyun Hızı ise oyunun mevcut durumunun saniyede kaç kere güncellendiğidir (bir diğer deyişle Update() metodunun kaç kere çağrıldığıdır).
Şimdi bu döngüyü incelediğimiz zaman ilk gözümüze çarpan husus bu döngü üzerinden herhangi bir kontrolümüz olmaması ve zamanı hiç bir şekilde kullanımıyor olmasıdır. Bir diğer deyişle hızlı bir makinada oyununuz çok hızlı bir şekilde çalışacağı için oyuncu neler olduğunu kavrayamayabilir. Çok yavaş makinada da her çey çok yavaş olacağından her türlü pek tercih edilmeyecek bir yöntem olarak düşünebiliriz. Tabi bundan çok uzun zaman önce farklı konfigürasyonda bilgisayarların bu kadar yaygın olmadığı ve bilgisayaların hızlarının belli olduğu zamanlar için bu döngü oldukça makul.
Yöntem 2 (Sabit oyun hızına bağlı sabit FPS):
Zamanı döngüye dahil etme anlamında atacağımız ilk adım, oyunumuzun FPS’ni sabitlemek olacak. Aşağıda bu yaklaşım ışığında güncellenmiş döngüyü görebilirsiniz. Yazımın sonunda bu yaklaşımların hepsini içeren örnek kodlara ulaşabilirsiniz.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
constexpr int AIMED_FPS = 25; constexpr PER_TICK_SKIP_TIME_IN_MS = 1000 / AIMED_FPS ; // GetTimeInMSec() sistem baslatildigindan bu yana milisaniye // cinsinden ne kadar zaman gectigi unsigned int nextGameTime = GetTimeInMSec(); // Ne kadar uyuyacagiz int sleep_time = 0; while( game_is_running ) { // Temel oyun dongu elemanlari Input(); Update(); Display(); nextGameTime += PER_TICK_SKIP_TIME_IN_MS; // Ne kadar zaman gecti sleep_time = nextGameTime - GetTimeInMSec(); // Bakalim beklememiz gerekiyor mu? if( sleep_time >= 0 ) { Sleep( sleep_time ); } else { // Uykuya gerek yok, hatta geri kalmis durumdayiz! } } |
Evet bu yöntem oldukça basit, her bir döngünün kaç kere çağrılacağını biliyoruz. Ayrıca bu tarz döngülerde tekrar oynatma için de oldukça uygun.
Sonradan eklediğimiz Sleep metodu ile döngü fonksiyonlarının hızlı tamamlanması durumunda biraz döngüyü bekletiyoruz.
Şimdi bu durumda hızlı çalışan bir bilgisayarda bu döngüde ne kazanıyoruz? Aslında hedeflediğimiz FPS’i kazansak ta Sleep ile bir çok CPU zamanını boşa harcıyoruz. Gerçi mobil oyunlar için bu bir avantaj da sayılabilir 🙂
Peki döngü fonksiyonları daha fazla zaman alırsa ne yapacağız? Aslında yapabileceğimiz çok ta bir şey yok yani ya gerçekleştirdiğimiz işleri döngü hızımıza göre düzenleyeceğiz ya da oyunumuz yavaşlayacak.
Bu döngü yöntemi basit ve küçük oyunlarda hedef FPS nin doğru bir şekilde belirlenmesi durumunda kullanılabilir. FPS’nin çok yüksek tanımlanması yavaş bilgisayarlar için sıkıntı yaratabilir.
Yöntem 3 (Değişken FPS’ye Bağlı Oyun Hızı):
Bir diğer yaygın oyun döngü mekanizması ise oyunu ilk yöntemdeki gibi olabildiğince hızlı çalıştırmak fakat ilkinden farklı olarak bir önceki döngüden bu yana geçen zamanı girdi olarak kullanmak. Bu yaklaşım aşağıdaki gibi özetlenebilir:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
unsigned int prev_frame_time; unsigned int curr_frame_time = GetTimeInMSec(); bool game_is_running = true; while( game_is_running ) { prev_frame_time = curr_frame_time; curr_frame_time = GetTimeInMSec(); Input(); // Geçen zamanı oyun güncelleme metoduna geçir Update( curr_frame_time - prev_frame_time ); Display(); } |
Bu yaklaşımdaki en önemli nokta oyun mantığını güncellediğimiz Update metou içerisinde artık geçen zamanı da göz önünde bulundurmamız gerekmekte. Bu farkın büyümesi mevcut oyunun durumunun daha büyük adımlar ile güncellenmesi anlamına geliyor.
Şimdi gelelim bu yaklaşımın bize sunduklarında. Yavaş bir donanımda Update metoduna gelecek olan zaman değeri de hali ile oldukça büyük olacaktır. Dikkatli bir şekilde bu ele alınmaz ise normalde örneğin duvara çarpma durumları veya ince manevralar büyük zaman atlamaları sebebi ile kaybolabilir.
Bu duruma en güzel örnek aslında ekranın bir tarafından diğer tarafına giden mermi durumu ile ifade edilebilir. Hızlı bir makinada merminin konumu her bir döngüde azar azar güncellenirken, yavaş bir makinada her bir adımda daha büyük atlamalar olacaktır. Bu da ilk durumda animasyonun daha kusursuz olmasını sağlamaktadır.
Burada tabi bu tarz durumları kotarmaya yönelik bazı önlemler alınabilir fakat bu da kodu oldukça karmaşıklaştıracak ve genel olarak ta uygulamayı hantallaştırabilir. Ayrıca özellikle fizik motorlarının kullanılması durumunda büyük ve düzensiz zaman atlamaları bu motorların tutarsız davranmalarına yol açabilir.
Peki hızlı bir donanımda ne tür durumlar oluşabilir. Çoğu oyun ondalıklı (floating) sayıları kullanmaktadır ve bunlarda zaman ile yuvarlama hataları olabilmektedir. Tahmin edeceğiniz üzere hızlı bir makinada bu hata çok daha hızlı bir şekilde büyüyecektir. Ayrıca yavaş olması durumu gibi döngünün çok ta hızlı olması da yine fizik motorlarını tutarsızlaştırabilir.
Her ne kadar bu yaklaşım çekici görünse de, sonuç olarak aslında oyun döngüsü pek kestirilebilir ve tutarlı olmayan bir hale gelmektedir.
Yöntem 4 (Maksimum FPS ile Sabit Oyun Hızı):
Bu yaklaşım ile, ikinci yaklaşımda yavaş donanımda yaşadığımız sıkıntıyı bir nebze çözmeye çalışacağız. Neydi oradaki sıkıntı, donanım yavaş olduğu durumda her oyun hızı hem de FPS düşüyordu. Bu yaklaşımda FPS düşse bile oyun hızını korumaya yönelik olacak. Bu yaklaşım aşağıdaki kod ile özetlenebilir:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
constexpr int TICKS_PER_SECOND = 25; constexpr int PER_TICK_SKIP_TIME_IN_MS = 1000 / TICKS_PER_SECOND; constexpr int MAX_FRAMESKIP = 10; unsigned int next_game_tick_time = GetTimeInMSec(); int loops; bool game_is_running = true; while( game_is_running ) { loops = 0; while( GetTimeInMSec() > next_game_tick_time && loops < MAX_FRAMESKIP) { Input(); Update(); next_game_tick_time += PER_TICK_SKIP_TIME_IN_MS; loops++; } Display(); } |
Bu durumda da yine oyunumuz her saniye 25 kere güncelleniyor olacak ve görselleştirme ise kalan zaman olabildiğinde fazla yapılacak. Eğer burada güncellemeler çok hızlı olur ve görselleştirme saniye 25 kereyi geçer ise görsellenen sahneler (frame) bir öncekinin aynı olacak.
Burada yavaş bir makinada, her bir döngüdeki güncelleme adeti MAX_FRAMESKIP e gelen kadar oyun hızı değişmeyecek fakat FPS biraz düşebilir. Eğer güncelleme adeti MAX_FRAMESKIP i de geçmeye başlarsa hem oyun hem de FPS düşecektir.
Hızlı bir makinada ise bir önceki yöntemin aksine herhangi bir sıkıntı olmayacaktır (tabiki CPU ticklerinin boşa harcanması dışında ama bunu tabiki bizler değerlendirebiliriz 😃
Bu durumda da yine en önemli işlerde birisi TICKS_PER_SECOND belirlemek olacaktır. Çok büyük değerler yavaş donanımda sıkıntılar yaratacaktır. Çok düşük değerler de hızlı makinalarda aynı sahnenin boşu boşuna tekrar tekrar gösterilmesine sebep olacaktır.
Yöntem 5 (Değişken FPS’lerden bağımsız Sabit Oyun Hızı):
Bu yöntemde bir önceki yöntemi yavaş makinelerde daha hızlı çalıştırma ve hızlı makinelerde de görsellik anlamında daha fazla iyileştirmeye çalışacağız. Aslında oyun durumunu güncelleyen kısımları değiştirmeden görselleştirme tarafları ile oynayacağız.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
constexpr int TICKS_PER_SECOND = 25; constexpr int PER_TICK_SKIP_TIME_IN_MS = 1000 / TICKS_PER_SECOND; constexpr int MAX_FRAMESKIP = 10; unsigned int next_game_tick_time = GetTimeInMSec(); int loops; float interpolation; bool game_is_running = true; while( game_is_running ) { loops = 0; while( GetTimeInMSec() > next_game_tick_time && loops < MAX_FRAMESKIP) { Input(); Update(); next_game_tick_time += PER_TICK_SKIP_TIME_IN_MS; loops++; } interpolation = float( GetTimeInMSec() + PER_TICK_SKIP_TIME_IN_MS - next_game_tick_time ) / float( PER_TICK_SKIP_TIME_IN_MS ); Display( interpolation ); } |
Yukarıdaki koddan da anlayacağınız üzere üçüncü yöntemde oyun durumunu güncellemek için Update() metoduna geçirdiğimiz zaman farkını bu sefer Display() metoduna geçiriyoruz. Peki bu ne anlama geliyor? Oyun görselleştirmelerinde “interpolation” yapacağız. Update metodunu X zamanı için çağırdık ve sahneyi görselleştireceğiz. Bu da şu anlama geliyor aslında oyun zamanı olarak (X + 1) deki zaman yerine X + 0.4 sn göre görselleştireceğiz.
Yavaş bir makina durumunu düşünürsek. Çoğu oyunda Input ve Update kısımları genelde Display den çok daha az bir zaman alacaktır ve kuvvetle muhtemel Update metodu saniyede çok rahat 25 kere çalışacaktır. Görselleme kısmı da çok
büyük yavaşlamalar olmadığı müddetçe kabul edilebilir düzeyde olacaktır.
Hızlı makinelerde ise oyunu hızımı yine saniyede 25 olarak kalmaya devam edecektir. Fakat görselleştirme kısmında kullanacağımız “Interpolation” ile çok daha hızlı çalışıyor izlenimi veriyor olacağız.
Sonuç:
Evet arkadaşlar sonuç olarak en mantıklı yaklaşım son yöntem görünüyor. Ben de açıkçası oyunlarımda bu yöntemi kullanmayı tercih ediyorum. Fakat her şey olduğunu gibi yöntemin seçimi birazda ihtiyaç ve kısıtlara bağlı. Son yöntem bu yöntemler arasında en karmaşık olanı. Fakat hem hızlı hem de yavaş makine ve oyunlara adapte olabiliyor. Eğer interpolasyon vs ile uğraşmak istemiyorsanız 4. yöntem de yeterli olacaktır.
Birinci yöntem mobil oyunlar için pek uygun olmayabilir ama basit PC oyunları ve prototipler için tercih edilebilir.
İkinci yöntem basit olması, güç dostu olması onu öne çıkarıyor. Çok hızlı olmayan oyunlar için tercih edilebilir.
Evet arkadaşlar uzun bir yazımızın sonuna geldik. Umarım açıklayıcı olmuştur. Bu arada faydalandığım kaynaklara Kaynaklar kısmından ulaşabilirsiniz. 3. kaynak biraz daha fizik tabanlı bir yaklaşım. Ayrıca Unity’de bu işlerin nasıl kotarıldığını görmek için de 4. kaynağa bakabilirsiniz. Bir sonraki yazımı pencere yönetim kütüphanelerine ayırmayı düşünüyorum o zamana kadar görüşmek dileğiyle.
Kaynaklar:
- Game Loop, Game Programming Patterns/Sequencing Patterns, http://gameprogrammingpatterns.com/game-loop.html
- deWiTTERS Game Loop, Koen Witters, http://www.koonsolo.com/news/dewitters-gameloop/
- Fix Your Timestep!, Glenn Fiedler, https://gafferongames.com/post/fix_your_timestep/
- Execution Order of Event Functions in Unity, https://docs.unity3d.com/560/Documentation/Manual/ExecutionOrder.html