anasayfa / blog / motor
Yayın: 20 Şubat 2026 14 dk okuma motor
Sıfırdan oyun motoru mimarisi: altı katman, bir döngü

Sıfırdan oyun motoru mimarisi: altı katman, bir döngü

Kendi motorunu yazmak katman sırasını doğru kurmakla başlar. Alt sistem sırası, sabit adımlı simülasyon ve hazır motora geçme eşiği üzerine notlar.

Motor on birinci haftada neden çöktü

2023 kışında kendi motorumu sıfırdan yazmaya başladım. Amaç bir oyun bitirmek değildi; oyun motoru mimarisi denen şeyin gerçekte nereye oturduğunu görmekti. İlk on hafta sorunsuz geçti: pencere açılıyor, üçgen çiziliyor, kamera dönüyordu. On birinci haftada editörü kapatırken tutarsız bir çökme başladı — on kapanışın üçünde access violation.

Yığın izi her seferinde farklıydı ama kök aynıydı. ResourceManager kapanırken doku ve tampon nesnelerini siliyor, Renderer ise hâlâ o nesnelerin GPU tanıtıcılarını taşıyan bir komut listesi tutuyordu. Kapatma sırası, başlatma sırasının tersi değildi. Aslında bir sıra hiç yoktu: alt sistemler Engine::Init içine yazıldıkları gibi, yani rastgele kurulmuştu.

Sıfırdan oyun motoru yazarken öğrendiğim ilk şey bu oldu. Motorun zor kısmı render değil, sahne grafiği değil, fizik hiç değil. Zor kısmı kimin kimi tanımasına izin verdiğin. Bağımlılık yönü belirsizse hata her yerden çıkar ve hiçbir yerde durmaz. Bu yön bir kez bozulduğunda düzeltme birkaç dosya taşımak olmuyor; katman sınırını yeniden çizmek gerekiyor.

Altı katman ve tek yönlü bağımlılık

Motoru altı katmana böldüm ve bağımlılığın yalnızca aşağı akmasına izin verdim: Platform, RHI, Render, Scene, Gameplay, Editor. Platform katmanı pencereyi, girdiyi, dosya erişimini ve iş parçacığı havuzunu verir; işletim sistemi başlıklarını gören tek katman odur. Üstteki hiçbir dosyada windows.h geçmez, bu bir derleme kuralıyla zorlanır. Platform katmanı toplamda 3 100 satır ve motorun en az değişen parçası oldu.

RHI katmanı grafik API'sini tek bir arayüz ardına saklar: IDevice, ICommandList, IBuffer, ITexture. D3D11 arka ucunu yazdıktan altı ay sonra Vulkan arka ucunu eklerken üst katmanlarda 40 satırdan az değişiklik yaptım ve bunun tamamı Present ile eşzamanlama ilgiliydi. İlk sürümde RHI'yı atlayıp doğrudan D3D11 çağırsaydım aynı iş binlerce satırla ölçülürdü. Soyutlamanın bedeli de ölçülebilir düzeyde kaldı: fazladan sanal çağrılar kare başına 0.1 ms civarı tuttu.

Render katmanı geçişleri ve materyalleri bilir, altta hangi API'nin durduğunu bilmez. Sahne katmanı dönüşümleri, hiyerarşiyi ve görünürlüğü tutar; kendisi çizim yapmaz, yalnızca görünür nesne listesi üretir. Oynanış katmanı sahneyi okur ve yazar, editör ise en üstte oturan ve herkesi görebilen tek katmandır. Kural basit: Scene içinde Renderer başlığını görürsem o dosya yanlış yere yazılmıştır.

Alt sistem başlatma ve kapatma sırası

Alt sistemleri artık bir dizide tutuyorum ve sırayı yazma alışkanlığı değil, bağımlılık belirliyor: Log, Memory, JobSystem, Window, RHIDevice, ResourceManager, Renderer, SceneManager, ScriptVM, EditorUI. Kapatma aynı dizide ters yönde ilerler. Kural yorumda değil, kodun kendisinde durur; kimse yanlışlıkla bozamaz. Dizi tek bir yerde tanımlı olduğu için yeni bir alt sistem eklerken sırayı düşünmek zorunlu hale geliyor.

Bu düzenin görünmeyen faydası hata yönetiminde ortaya çıkıyor. Başlatma yedinci alt sistemde başarısız olursa altıncıdan geriye doğru sadece gerçekten kurulmuş olanları kapatırım. Eskiden bir başlatma hatası, hiç kurulmamış nesneler üzerinde ikinci bir çökme doğuruyordu; asıl hata mesajı da o çökmenin altında kayboluyordu. Bu geri sarma mantığı 12 satır tuttu ve yazdığım günden beri bir kez bile dokunmadım.

Bir uyarı: Log ilk açılan ve en son kapanan sistem olmalı. Kendi motorumda Log nesnesini dizinin ortasına koyduğum iki hafta boyunca kapanış hatalarının hiçbirini göremedim, çünkü hatayı yazacak sistem çoktan kapanmış oluyordu. Ucuz bir hata ama bulması pahalı.

Engine.cpp
// Subsystems come up in dependency order and go down in reverse. bool Engine::Init() { m_subsystems = { &m_log, &m_memory, &m_jobs, &m_window, &m_device, &m_resources, &m_renderer, &m_scene, &m_scripts, &m_editor }; for (size_t i = 0; i < m_subsystems.size(); ++i) { if (m_subsystems[i]->Init(*this)) continue; // Roll back only the ones that actually started. for (size_t j = i; j-- > 0; ) m_subsystems[j]->Shutdown(); return false; } return true; } void Engine::Shutdown() { for (size_t i = m_subsystems.size(); i-- > 0; ) m_subsystems[i]->Shutdown(); }
cppEngine.cpp

Sabit adımlı simülasyon, değişken render

Simülasyon sabit adımla ilerliyor: saniyede 60 adım, adım başına 16.667 ms. Render ise ekranın verdiği hızda çalışıyor; 144 Hz monitörde kare süresi 6.9 ms. Bu ikisini ayırmazsanız fizik, oyuncunun monitörüne göre farklı davranır. 30 fps'te bir boşluğu geçen karakterin 144 fps'te geçememesi tam olarak budur.

Ana döngü geçen süreyi bir accumulator değişkenine ekler ve içinde 16.667 ms kaldığı sürece FixedUpdate çağırır. Kritik ayrıntı üst sınırdır: bir karede en fazla beş adım atıyorum. Sınır koymazsanız yavaşlayan bir kare daha çok adım ister, fazla adım kareyi daha da yavaşlatır ve ölüm sarmalı dediğimiz şey uygulamayı iki saniyede kilitler. Sınıra takılan kare, simülasyonun gerçek zamandan geri kalması demektir; bunu bir sayaçla ölçüp profil çıktısına yazıyorum.

Adımlardan artan süre render için bir karışım katsayısına dönüşür: alpha = accumulator / FIXED_DT. Çizim sırasında her nesnenin önceki ve mevcut dönüşümü bu katsayıyla harmanlanır. Interpolation eklemeden önce 60 Hz simülasyon 144 Hz ekranda gözle görülür biçimde takılıyordu; ekledikten sonra aynı sahne pürüzsüz aktı ve 5000 nesnede kare süresine eklediği maliyet 0.3 ms oldu.

İki tuzak var. Birincisi, kamera da interpolasyona dahil edilmeli; yalnızca nesneleri harmanlayıp kamerayı anlık okursanız titreme kaybolmaz, yalnızca yer değiştirir. İkincisi, girdi okuma FixedUpdate içinde değil kare başında yapılmalı ve tuş basışları adımlara birikimli aktarılmalı; yoksa 144 Hz'de basılan kısa bir tuş bazen hiç görülmez.

Olay sistemi hangi bağı gerçekten koparır

Katmanlar arasında yukarı doğru haber göndermek gerekiyor: oynanış kodu bir sahnenin yüklendiğini editöre bildirmeli, ama Scene katmanının Editor katmanını tanıması yasak. Çözüm ortada duran bir EventBus. Yayınlayan kimin dinlediğini, dinleyen kimin yayınladığını bilmez; bağ derleme zamanında değil, çalışma zamanında kurulur. Olay türlerini tek bir başlıkta topladım; kimin neyi dinlediğini böylece tek bir dosyaya bakarak görüyorum.

Olayları iki türe ayırdım. Anında işlenenler doğrudan çağrılır ve pencere yeniden boyutlandırma gibi aynı karede sonuçlanması gereken şeyler için kullanılır. Kuyruklananlar kare sonunda tek bir noktada boşaltılır; hasar, ölüm, ses tetikleme gibi oynanış olayları buraya girer. Bu ayrımı yapmadan önce bir işleyicinin içinde yeni olay yayınlamak yeniden giriş hatalarına ve dinleyici listesi ortada değişince çökmelere yol açıyordu.

Ölçüm şuydu: kare başına 4000 kuyruklu olay, std::function tabanlı ilk sürümde 1.1 ms yiyordu. Olay verisini sabit boyutlu düz bir yapıya, dinleyicileri de tek bir diziye taşıyınca aynı yük 0.2 ms'ye indi. Yine de her şeyi olaya çevirmeyin; aynı katmandaki iki sistem birbirini doğrudan çağırabiliyorsa çağırsın. Olay sistemi bağımlılığı koparmak içindir, kod akışını gizlemek için değil.

ECS mi OOP mu, ve ne zaman durmalı

Pratik cevap ikisi birden. Motorun alt sistemleri rahatça OOP kalabilir; Renderer, AudioDevice, ResourceManager tekil, uzun ömürlü ve birbirinden farklı nesnelerdir, ECS mimarisi bunlara hiçbir şey katmaz. ECS asıl kazancını aynı işlemin binlerce nesnede tekrarlandığı yerde verir: dönüşüm güncelleme, görünürlük ayıklama, parçacıklar, mermiler.

Kendi motorumda 20 000 hareketli nesnenin dönüşüm güncellemesi, sanal fonksiyon çağıran nesne hiyerarşisiyle 4.8 ms sürüyordu. Aynı veriyi bileşen dizilerine taşıyıp döngüyü düz yazınca 0.9 ms'ye indi. Kazancın büyük kısmı SIMD'den gelmedi; verinin bellekte bitişik durmasından ve iç döngüdeki dallanmanın yok olmasından geldi. Aynı testte bileşen dizilerinin bellek kullanımı yüzde 12 arttı; hız için ödenen bedel buydu.

Kendi motorunu yazmak üç durumda mantıklı. Öğrenmek asıl hedefse; oyunun çekirdek gereksinimi hazır motorların varsayımlarıyla çatışıyorsa, örneğin deterministik ağ simülasyonu, alışılmadık bir render tekniği ya da çok dar bir bellek bütçesi varsa; veya uzun ömürlü bir teknoloji üzerinde tam kontrol gerekiyorsa. Bunların hiçbiri geçerli değilse motor yazmak, oyun yazmanın yerine geçer.

Durma noktası için somut bir eşik kullanıyorum: motorun kendi bakımı haftalık çalışmanın yarısından fazlasını yiyorsa ve bu üç hafta üst üste sürüyorsa, oyun için hazır bir motora geçilir. Öğrendikleriniz kaybolmaz; hazır motorda da kare bütçesini, bellek düzenini ve alt sistem sırasını artık farklı okursunuz. Kendi motorumu bugün hâlâ geliştiriyorum, ama yayınladığım oyunları onunla yazmıyorum — bu ikisini ayırmak verdiğim en iyi karar oldu.

← Tüm yazılar