sdl-painter – Vulkan Backend (faz-5)

Tekrar merhaba sevgili yazılımperver dostlarım. Bu yazı, temel kabiliyetler açısından serimin son yazısı.

İlk yazıdan bu yana altyapıyı kurduk, OpenGL backend’ini yazdık, transform stack ve tessellator’ı oturttuk, görüntü ve metin desteği ekledik. Şimdi sıra IRenderer arayüzünün ikinci implementasyonunda: Vulkan.

Açık konuşmak gerekirse Vulkan ilk etapta aklımda pek yoktu; fakat bu vesileyle onu biraz daha yakından öğrenme fırsatı bulurum diye düşündüm. Vulkan’ın OpenGL’e kıyasla çok daha ayrıntılı (biraz da detaylı) bir API’si var: swapchain, senkronizasyon nesneleri, descriptor set’ler, vb. Birçok şeyi sizin halletmeniz gerekiyor.

Bunun yanında IRenderer soyutlamasının gerçekten işe yarayıp yaramadığını da bu aşamada görmüş olacağız: Vulkan eklenirken Painter ve Tessellator değişmemeli, yalnızca yeni bir IRenderer implementasyonu ekleyerek işimiz hallolmalı.

Bu yazıda, bunun gerçekten böyle olduğunu nasıl doğruladığımı da anlatacağım. Kısacası iki backend aynı sonucu veriyor mu, birlikte göreceğiz.

tl;dr: Elbette veriyor 😉

OpenGL ve Vulkan Arasında Nasıl Bir İlişki Var?

OpenGL ve Vulkan, uygulamanın GPU’ya çizim işi yaptırmasını sağlayan iki grafik API’si. Üçgenler, vertex buffer’ları, dokular ve shader’lar gibi kavramlar her ikisinde de var. 2B bir arayüzü de 3B bir sahneyi de bu araçlarla çizebiliyoruz. Asıl fark, bu işlerin ne kadarını bizim, ne kadarını sürücünün üstlendiğinde. Bunun yanında Vulkan, OpenGL’e göre daha modern ve sonradan sunulan, daha düşük seviyeli bir API.

OpenGL’de bir doku veya shader bağlıyor, çizim ayarlarını değiştiriyor ve çizim çağrısını yapıyoruz. Sürücü bu çağrıları GPU’nun çalıştıracağı işlere dönüştürüyor. Bellek yerleşimini ve normal çizim sırasında gerekli beklemelerin çoğunu da bizim için hallediyor. Böylece daha az kodla ve efor haracayarak çizim yapmaya başlayabiliyoruz.

Vulkan’da ise kararlarda daha fazla söz sahibiyiz (yükün çoğu bizde demenin daha kibar hali 🙂 ): kaynaklar için bellek ayırıyoruz, çizim komutlarını command buffer denen komut listelerine kaydediyoruz ve bu listeleri GPU’ya gönderiyoruz. Hangi işin hangi kaynağı kullanacağını ve başka bir işi ne zaman beklemesi gerektiğini de açıkça belirtiyoruz. Vulkan’ın “daha düşük seviyeli” olmasıyla kastedilen bu.

Vulkan, OpenGL’in yeni bir sürümü veya onun üzerinde çalışan bir katman değil; GPU üzerinde daha doğrudan kontrol sunan ayrı bir API.

Kod tarafındaki farkları özetleyecek olursak:

İşOpenGL tarafındaVulkan tarafında
Çizim komutlarını vermekGeçerli bağlamın ayarlarını değiştirip glDrawArrays gibi çağrılar yapıyoruz.Komutları bir command buffer’a kaydedip kuyruğa gönderiyoruz.
Buffer belleği oluşturmakglBufferData ile boyutu ve veriyi bildiriyoruz; alttaki bellek ayırma işini sürücü yapıyor.Buffer’ı oluşturuyor, uygun belleği ayırıyor ve ikisini birbirine bağlıyoruz.
Kullanımdaki kaynakları yeniden kullanmakNormal çizim sırasında gerekli beklemelerin çoğunu sürücü yönetiyor.CPU ve GPU’nun aynı kaynağa çakışarak erişmemesi için beklemeleri biz düzenliyoruz.
Frame’i pencerede göstermekSDL üzerinden SDL_GL_SwapWindow() çağırıyoruz.Swapchain’den görüntü alıyor, ona çiziyor ve sunum isteği gönderiyoruz.

Vulkan’ın sunduğu kontrol, özellikle CPU’nun çok sayıda çizim komutu hazırladığı işlerde bize fayda sağlayabilir. Örneğin komut listelerini birden fazla CPU threadinde hazırlamak mümkün. Bunun karşılığında daha fazla kurulum, kaynak yönetimi ve senkronizasyon kodu yazıyoruz. Dolayısıyla Vulkan’a geçmek tek başına daha yüksek FPS garantisi değil; kazancı görmek için uygulamanın nerede zaman harcadığını ölçmek gerekiyor.

SDLPainter’da bu iki API, aynı çizim arayüzünün arkasındaki iki seçenek olarak sunulmakta. VulkanRenderer, OpenGL çağrılarını Vulkan’a çevirmiyor; Tessellator ve RenderBatcher tarafından hazırlanan geometriyi doğrudan vulkan ile çiziyor. SDL3 ise her iki yolda da pencere ve platform bağlantısını sağlıyor. Benim buradaki hedefim, aynı Painter çağrılarıyla hem vulkan hem de opengl kullanabilmek ve aynı görüntüyü elde edebilmekti ki bunu sağladığımı düşünüyorum.

Geometri aynı; bellek ve diğer yönetme işinin daha büyük bir kısmı vulkan tarafında bize kalıyor.

Çizim Kodunu Değiştirmeden Vulkan’a Geçmek

Peki OpenGL yerine Vulkan kullanmak için çizim kodunda neyi değiştirmemiz gerekiyor? Sadece Painter oluştururken seçtiğimiz backend’i değiştirmemiz yeterli:

Döngü içindeki Begin(), Clear(), FillRect() ve DrawCircle() çağrıları aynı kalıyor. CreateRenderer fabrika fonksiyonu, seçime göre OpenGLRenderer veya VulkanRenderer oluşturuyor. Painter çizim işini bu nesnenin IRenderer arayüzüne bırakıyor.

Buradaki tek satırlık değişiklik çizim API’si için geçerli. Vulkan backend’inin derlemede etkin olması ve SDL penceresinin de OpenGL için kullanılan SDL_WINDOW_OPENGL yerine SDL_WINDOW_VULKAN bayrağıyla oluşturulması gerekiyor.

Bu sınıflar Vulkan tarafındaki işleri bölüşüyor: VkContext kurulumu yapıyor, VkSwapchain ekrana sunulacak görüntüleri yönetiyor, VkFrameSync ise frame’ler arasındaki beklemeleri düzenliyor. Pipeline sınıfları çizimin GPU’da nasıl yapılacağını, vertex zincirleri de çizilecek geometri verisinin nerede tutulacağını belirliyor. VulkanRenderer bu parçaları bir araya getiriyor.

VkContext, VkSwapchain ve VkFrameSync isimleri SDLPainter’ın kendi yardımcı sınıflarına ait. Örneğin Vulkan API’sindeki asıl swapchain nesnesinin türü VkSwapchainKHR; bizim VkSwapchain sınıfımız bu nesneyi ve onunla ilişkili kaynakları yönetiyor.

VkContext: Vulkan’ı Başlatmak ve GPU’yu Seçmek

OpenGL tarafında SDL, SDL_GL_CreateContext() ile bağlam oluşturma işinin önemli bir kısmını üstleniyor. Vulkan’da ise hangi cihazı kullanacağımızı ve pencereye nasıl görüntü sunacağımızı daha açık biçimde tanımlıyoruz. VkContext::Initialize(), bu kurulum sırasını aşağıdaki gibi gerçekleştiriyor:

Bu adımlardaki kavramlar şöyle:

  • Instance (VkInstance): Uygulamanın Vulkan ile bağlantısını başlatıyor. Kullanılacak API sürümü, instance uzantıları ve doğrulama katmanları burada seçiliyor. Pencere sistemiyle ilgili gerekli uzantı listesini SDL3’ten SDL_Vulkan_GetInstanceExtensions ile alıyoruz.
  • Surface (VkSurfaceKHR): SDL penceresinin Vulkan tarafındaki sunum yüzeyini temsil ediyor. Bir GPU’nun çizim yapabilmesi, bu pencereye görüntü sunabildiği anlamına gelmediği için surface’i cihaz seçiminden önce oluşturuyoruz.
  • Physical device (VkPhysicalDevice): Sistemdeki bir Vulkan cihazını temsil ediyor; normal kullanımda bu bir GPU. Uygun cihazlar arasından seçim yapıyoruz.
  • Logical device (VkDevice) ve queue’lar: Seçtiğimiz GPU’da kullanacağımız özellikleri ve iş kuyruklarını açıyoruz. Graphics queue çizim komutlarını, present queue ise hazırlanan görüntüyü pencereye sunma işini yürütüyor. Aynı tür işleri destekleyen kuyrukların grubuna queue family deniyor; tek bir grup hem çizimi hem sunumu destekleyebiliyor.

Debug derlemelerinde VK_LAYER_KHRONOS_validation, sistemde kuruluysa otomatik açılıyor. Bu doğrulama katmanı, örneğin hala kullanımda olan bir kaynağı yok etmek gibi Vulkan kullanım hatalarını bildiriyor. CreateDebugMessenger() bu mesajları uygulamanın log’una aktarıyor.

Fiziksel cihaz seçimini basit bir puanlamayla yapıyoruz: ayrı ekran kartı (discrete GPU) 1000, işlemciyle bütünleşik GPU (integrated GPU) 100 puan alıyor. Çizim ve sunum için gerekli queue family’ler ya da VK_KHR_swapchain desteği yoksa cihaz eleniyor. Bu puanlama bir performans ölçümü değil; uygun cihazlar arasında ayrı ekran kartına öncelik verme yolu.

Vulkan’da başlangıç beş adım. Surface, cihaz seçiminden önce oluşturuluyor; çünkü present desteği bu yüzeye göre sorgulanıyor.

VkSwapchain: Çizilen Frame’i Pencerede Göstermek

GPU’nun bir görüntü çizmesiyle o görüntünün pencerede gösterilmesi aslında iki ayrı iş. Ekranda bir frame gösterilirken uygulama sonraki frame’i başka bir görüntüye çizebilir. Swapchain, bu amaçla kullanılan görüntüleri ve bunların sunum için dolaşıma girmesini yöneten sınıfımız. OpenGL’de SDL_GL_SwapWindow() ile başlattığımız sunumun ayrıntılarını büyük ölçüde sürücüye bırakırken, Vulkan’da bu yapıyı kendimiz kuruyoruz.

Bir frame için temel sıra şöyle:

  1. Görüntüyü al/acquire: vkAcquireNextImageKHR, kullanılacak swapchain görüntüsünün indeksini veriyor. Görüntüye ne zaman güvenle yazılabileceğini senkronizasyon nesnesiyle takip ediyoruz.
  2. Çizim komutlarını gönder/ submit: O görüntüyü hedefleyen komutları kaydedip vkQueueSubmit ile GPU kuyruğuna gönderiyoruz.
  3. Sunumu iste/present: vkQueuePresentKHR ile tamamlanan görüntünün pencerede gösterilmesini istiyoruz. Sunum, çizimin bittiğini bildiren semaphore’u bekliyor.

Üstte üç görüntünün aynı andaki örnek durumu, altta tek bir görüntünün yaşam döngüsü var. Görüntü sayısı ve acquire sırası sabit değil; aynı görüntüye yeniden yazmak için acquire beklemesinin tamamlanması gerekiyor.

Render Pass ve Framebuffer Ne İşe Yarıyor?

SDLPainter’daki VkSwapchainsınıfı, swapchain görüntülerinin yanında image view, render pass ve framebuffer nesnelerini de yönetiyor. Bu isimler ilk bakışta aynı işi yapıyormuş gibi görünebilir; aralarındaki fark ise şu:

  • Image: Piksellerin tutulduğu görüntü kaynağı.
  • Image view: Bu görüntünün hangi bölümünün, hangi formatla kullanılacağını tanımlayan görünüm. Burada her swapchain görüntüsünün renk bölümünün tamamını kullanıyoruz.
  • Render pass: Çizim sırasında hangi tür görüntülere yazılacağını ve bu görüntülerin içeriğinin başlangıçta ve bitişte nasıl ele alınacağını tanımlıyor.
  • Framebuffer: Render pass’in kullanacağı somut image view’ları bağlıyor. Burada her swapchain görüntüsü için bir framebuffer var; alınan image_index ile doğru olanı seçiyoruz.

Bu 2B çizim yolu yalnızca bir renk çıktısı kullanıyor; derinlik veya stencil tamponuna ihtiyaç duymuyor. Render pass’e bağlanan bu renk görüntüsüne color attachment deniyor. Frame başında loadOp = CLEAR ile arka plan rengine temizleniyor, çizim sonunda storeOp = STORE ile üretilen pikseller saklanıyor.

Bir de image layout var. Bu, görüntünün o anda hangi kullanım için hazır olduğunu Vulkan’a bildiriyor: çizim hedefi olmak, doku olarak okunmak veya ekrana sunulmak gibi. Ekran için çizim bittiğinde son layout VK_IMAGE_LAYOUT_PRESENT_SRC_KHR oluyor; yani görüntü sunuma uygun duruma geçiriliyor.

Çizimi başlatırken hem render pass’i hem framebuffer’ı veriyoruz: biri kuralları, diğeri kullanılacak görüntüyü belirliyor.

Renk Formatı ve Sunum Modu Nasıl Seçiliyor?

Renk formatında VK_FORMAT_B8G8R8A8_UNORM ve VK_COLOR_SPACE_SRGB_NONLINEAR_KHR birleşimini tercih ediyoruz. B8G8R8A8, mavi, yeşil, kırmızı ve alfa kanallarının sekizer bit olmasını; UNORM ise bu tamsayı değerlerin shader tarafında 0–1 aralığında ele alınmasını belirtiyor. Tercih edilen birleşim yoksa yüzeyin sunduğu ilk format kullanılıyor.

Present mode, çizim hızımızla ekranın yenileme hızı arasındaki ilişkiyi belirliyor. SDLPainter önce MAILBOX arıyor, desteklenmiyorsa FIFO kullanıyor:

ModHazır frame’lere ne oluyor?
MAILBOXEkranın sonraki yenilemesi için bekleyen tek bir sunum isteği var. Daha yeni bir frame hazır olursa bekleyen isteğin yerini alıyor; aradaki frame gösterilmeyebiliyor.
FIFOSunum istekleri sıraya giriyor. Ekranın her yenilemesinde sıranın başındaki hazır frame gösteriliyor.

Örnekte K1, K2 ve K3 bir sonraki ekran yenilemesinden önce hazır oluyor; ardından yeni istek gelmiyor. FIFO bunları sırayla sunuyor. MAILBOX’ta K2, K1’in; K3 de K2’nin yerini alıyor ve sonraki yenilemede K3 gösteriliyor.

İki mod da ekran yenilemesini bekleyerek görüntü yırtılmasını önlüyor. FIFO desteği zorunlu, MAILBOX desteği ise cihaza ve yüzeye bağlı. Sunum modu ile görüntü sayısını ayrı düşünmek gerekiyor: MAILBOX bekleyen frame’lerden hangisinin gösterileceğini belirliyor, swapchain’in kaç görüntüden oluşacağını değil. Kod, görüntü sayısı için yüzeyin istediği minimumun bir fazlasını talep ediyor ve varsa maksimum sınırı gözetiyor.

Pencere Yeniden Boyutlandırılınca Ne Oluyor?

Örneğin 800 × 600 piksellik pencereyi 1200 × 800 yaptığımızda, swapchain görüntülerinin de yeni çizim boyutuna uygun olması gerekiyor. Bu yüzden swapchain, ona bağlı image view’lar ve framebuffer’lar yeniden oluşturulur.

Vulkan, swapchain’in yenilenmesi gerektiğini iki sonuçla bildirebiliyor: VK_ERROR_OUT_OF_DATE_KHR, mevcut swapchain’in artık yüzeyle uyumlu olmadığını; VK_SUBOPTIMAL_KHR ise hala kullanılabildiğini ama yüzeye en uygun durumda olmadığını söylüyor. SDLPainter’da, her iki durumda da yeniden oluşturma yoluna gidiyoruz.

Yalnızca bu bildirimleri beklemiyoruz, bazı pencere sistemlerinde görüntü boyutunu uygulama seçebildiği için pencerenin değişmesi sürücünün mutlaka OUT_OF_DATE döndürmesini gerektirmiyor. BeginFrame, SDL’den pencerenin piksel boyutunu alıp son kullanılan istekle karşılaştırıyor. Yüzey boyutu 0 × 0 olarak bildiriliyorsa, pencere simge durumuna küçültüldüğünde, çizim ve yeniden oluşturma erteleniyor; pencere tekrar çizilebilir olduğunda devam ediliyor.

Recreate() sırasında eski swapchain, yenisinin oluşturma bilgisine oldSwapchain olarak veriliyor. Böylece sürücüye önceki yapının yerine yenisinin geldiği bildiriliyor ve kaynakları yeniden kullanma fırsatı sunuluyor. SDLPainter yeni swapchain’i oluşturduktan sonra eskisini yok ediyor; yeni görüntüler için image view ve framebuffer’ları yeniden kuruyor.

VkFrameSync: CPU ve GPU Arasındaki Beklemeleri Yönetmek

CPU, çizim komutlarını GPU’ya gönderdiğinde GPU’nun işi hemen bitmiş olmuyor. CPU bir sonraki frame’i hazırlarken GPU önceki frame’i çiziyor olabilir. Bu yüzden command buffer’ı veya vertex belleğini yeniden kullanmadan önce, önceki işin o kaynaklarla işinin bittiğinden emin olmamız gerekiyor.

Command buffer, GPU’nun çalıştıracağı komutları kaydettiğimiz nesne. SDLPainter bu nesneyi ve frame’e ait diğer geçici kaynakları iki frame slotu içinde tutuyor (kMaxFramesInFlight = 2). Slotlar sırayla kullanılıyor: 0, 1, sonra tekrar 0. Slot 0’a döndüğümüzde onun önceki işi bitmemişse bekliyoruz. Böylece aynı anda en fazla iki frame’in çizim işi beklemede veya çalışır durumda olabiliyor.

Burada iki tür senkronizasyon nesnesi kullanıyoruz:

  • Fence: CPU’nun GPU işinin tamamlanmasını beklemesini sağlıyor. Bir slotu tekrar kullanmadan önce vkWaitForFences çağrısıyla o slotun önceki komutlarının bittiğini doğruluyoruz.
  • Binary semaphore: Kuyruktaki işlerin ve sunumun birbirini beklemesini sağlıyor. mImageAvailable, çizimin görüntü kullanılabilir olana kadar başlamamasını; mRenderFinished, sunumun çizim bitmeden başlamamasını sağlıyor.

Frame Slotu ve Swapchain Görüntüsü

Frame slotu, uygulamanın geçici kaynak grubunu seçiyor. image_index ise Vulkan’ın o frame için verdiği swapchain görüntüsünü seçiyor. Bunların aynı sayı olması gerekmiyor: iki frame slotumuz varken swapchain’de üç görüntü bulunabilir.

Bu ayrım semaphore’ları nasıl sakladığımızı da etkiliyor:

KaynakSayısı ve seçim yöntemiYeniden kullanmadan önce neyi bekliyoruz?
Fence ve command bufferFrame slotu başına bir tane; slot ile seçiliyor.O slotun önceki GPU işinin bitmesini.
mImageAvailableFrame slotu başına bir tane; slot ile seçiliyor.Slotun fence’ini. Önceki iş tamamlanınca bu semaphore üzerindeki bekleme de tamamlanmış oluyor.
mRenderFinishedSwapchain görüntüsü başına bir tane; image_index ile seçiliyor.Aynı görüntünün yeniden alınmasını ve acquire beklemesinin tamamlanmasını.

Son satırın nedeni önemli: çizimin fence’i sinyal verdiğinde GPU komutları bitmiş oluyor, ama sunum işleminin mRenderFinished ile işi hala devam ediyor olabilir. Bu yüzden onu yalnızca frame slotuna göre tekrar kullanmak güvenli değil. Görüntü başına ayrı semaphore tutma gerekçesi Khronos’un semaphore yeniden kullanımı rehberinde de anlatılıyor.

İki frame slotu sırayla kullanılıyor; slot tekrar kullanılmadan önce fence bekleniyor. Fence ve imageAvailable frame slotuyla, renderFinished ise swapchain görüntüsünün indeksiyle seçiliyor.

Pipeline ve Push Constants: Çizim Ayarlarını GPU’ya Vermek

Graphics pipeline, vertex verisinin nasıl okunacağını, hangi shader’ların çalışacağını ve çıkan renklerin hedefe nasıl yazılacağını bir araya getirir. OpenGL’de farklı çağrılarla ayarladığımız bu durumun önemli bir kısmını Vulkan’da pipeline oluştururken tanımlıyoruz.

Projeksiyon matrisi, renk çarpanı (tint) ve genel opaklık gibi çizimden çizime değişen küçük verileri ise push constants ile gönderiyoruz. vkCmdPushConstants, bu değerleri command buffer’a kaydediyor; bu veri için ayrıca uniform buffer ve descriptor set yönetmemiz gerekmiyor. Küçük ve sık güncellenen değerleri bu yolla iletmek pratik oluyor.

Blend Modları İçin Pipeline Seçimi

Blending, yeni çizilen rengin hedefteki mevcut renkle nasıl birleşeceğini belirler. Yarı saydam bir şeklin arkasını göstermesi bunun en tanıdık örneği. OpenGL’de karıştırma faktörlerini glBlendFunc veya glBlendFuncSeparate ile değiştirebiliyoruz. Burada kullandığımız Vulkan 1.1 yolunda ise bu ayarlar pipeline oluşturulurken sabitleniyor; sonradan eklenen dinamik durum uzantılarını kullanmıyoruz.

Bu yüzden her blend modu için ayrı bir pipeline hazırladım. Bunları çizim sırasında oluşturmak yerine başlangıçta hazır tutmayı tercih ettim:

Dört blend modunun (kAlpha, kAdditive, kMultiply, kNone) pipeline’ları tek bir vkCreateGraphicsPipelines çağrısıyla oluşturuluyor. Çizim sırasında yalnızca uygun olanı seçip bağlıyoruz; bu sayede mod değiştiğinde yeni bir pipeline oluşturmamız gerekmiyor.

Dokulu çizimde sayı sekize çıkıyor. Çünkü normal dokuların yanında, üzerine çizim yapılmış hedefleri de doku olarak kullanabiliyoruz. Bu hedeflerdeki RGB değerleri alfayla önceden çarpılmış (premultiplied alpha) olarak tutuluyor. Örneğin yarı saydam kırmızı, (1, 0, 0, 0.5) yerine (0.5, 0, 0, 0.5) oluyor. Böyle bir rengi karıştırırken bir daha alfayla çarparsak gereğinden koyu bir sonuç çıkar. İkinci varyant grubu bu farkı karşılıyor; kAlpha ve kAdditive için kaynak renk faktörü SRC_ALPHA yerine ONE seçiliyor.

Vulkan tarafındaki faktörler src/vulkan/vk_blend.h içinde tanımlı. Bunları OpenGL tarafıyla aynı sonucu verecek şekilde ayarladım; alfa kanalındaki bir farkı ise ancak piksel karşılaştırmasıyla fark edebildim.

VulkanBuffer: Vertex Belleğini Frame’ler Arasında Yeniden Kullanmak

Tessellator’ın ürettiği vertex’leri GPU’nun okuyabileceği bir belleğe yazmamız gerekiyor. Her çizimde yeni buffer ve bellek ayırmak yerine, önceden ayırdığımız alanı tekrar kullanıyoruz. VulkanBuffer içindeki ring buffer düzeni bunu sağlıyor: veriler sırayla yazılıyor, ilgili frame tamamlandığında o frame’e ayrılmış alan yeniden kullanılabiliyor.

Bellek seçimini anlatan iki bayrak var:

  • HOST_VISIBLE: CPU bu belleği adres alanına eşleyip (map) bir işaretçi üzerinden yazabiliyor. vkMapMemory çağrısını başlangıçta bir kez yapıyor ve eşlemeyi buffer yaşadığı sürece açık tutuyoruz.
  • HOST_COHERENT: CPU’nun yazdığı veriler için ayrıca vkFlushMappedMemoryRanges çağırmamız gerekmiyor. GPU’nun okumakta olduğu verinin üzerine yazmamak için gereken beklemeleri ise yine yapıyoruz.

Buffer iki frame slotuna bölünüyor. Her slotun kendi yazma konumu (head) var. CPU sonraki frame’i bir slota yazarken GPU diğer slotu okumaya devam edebiliyor. Bir slotu yeniden kullanacağımızda önce fence’ini bekliyor, sonra yalnızca o slotun yazma konumunu başa alıyoruz. Buradaki sıfırlama pikselleri veya bellekteki bütün byteları temizlemek değil; yeni verinin nereye yazılacağını belirleyen sayacı sıfırlamak.

Slot 0’ı yeniden kullanmadan önce fence’ini bekliyoruz. Slot 1 bu işlemden etkilenmiyor.

VulkanRenderer, düz renkli ve dokulu çizimler için ayrı VertexChain nesneleri tutuyor. Bunları birer buffer listesi olarak düşünebiliriz. Listedeki her VulkanBuffer, slot başına 4 MB alan sağlıyor; bir frame’in verisi mevcut buffer’lara sığmazsa listeye yenisi ekleniyor. Böylece 4 MB, toplam geometri için bir üst sınır olmuyor.

Bu vertex yolunda ayrıca bir staging buffer oluşturup GPU’ya kopyalama komutu göndermiyoruz; GPU, CPU’nun yazdığı buffer’ı doğrudan okuyor. SDLPainter’ın 2D geometri verisi için bu yaklaşımı seçtim. RenderBatcher da uyumlu çizimleri birleştirerek veriyi daha büyük bloklar halinde gönderiyor. Doku piksellerini yüklerken ise staging buffer kullanıyoruz.

Dokular: Pikselleri Yüklemek ve Shader’a Bağlamak

Bir resmi GPU belleğine kopyalamak tek başına yeterli değildir; shader’ın hangi görüntüyü, hangi örnekleme kurallarıyla okuyacağını da belirtmemiz gerekiyor. Bu bağlantıyı descriptor set ile kuruyoruz. Descriptor set pikselleri saklamıyor; shader’ın erişeceği kaynakların tanımlarını taşıyor.

Dokulu çizimde iki nesne birlikte kullanılıyor: image view okunacak görüntüyü, sampler ise filtreleme ve doku koordinatlarının görüntü sınırını aşması gibi durumların nasıl ele alınacağını belirliyor. İkisini tek bağlamada sunan descriptor türüne combined image sampler deniyor.

VulkanTexturedPipeline, shader’daki set=0, binding=0 konumunun bu tür bir kaynak beklediğini belirten descriptor set layout‘u oluşturuyor. Set’lerin ayrıldığı havuzu da yönetiyor; mevcut havuz en fazla 256 set için ayrılmış durumda ve her doku bir set alıyor.

VulkanTexture::Upload() sırasında ise şu adımlar izleniyor:

  1. Pikselleri geçici belleğe yazıyoruz. CPU’nun yazabildiği bir staging buffer oluşturuluyor. Bir, iki veya üç kanallı giriş verisi önce dört kanallı RGBA biçimine genişletiliyor.
  2. Asıl doku görüntüsünü oluşturuyoruz. DEVICE_LOCAL bellek üzerinde R8G8B8A8_UNORM formatında bir VkImage ve ona ait image view oluşturuluyor. TRANSFER_DST ve SAMPLED kullanım bayrakları, görüntünün hem kopyalama hedefi hem shader’ın okuyacağı doku olacağını belirtiyor.
  3. Kopyalamayı ve layout geçişlerini kaydediyoruz. Görüntü önce UNDEFINED durumundan TRANSFER_DST_OPTIMAL durumuna geçiriliyor; staging buffer’daki pikseller görüntüye kopyalanıyor. Ardından SHADER_READ_ONLY_OPTIMAL durumuna geçirilerek shader’ın okumasına hazırlanıyor. Tek seferlik command buffer gönderilip fence beklendikten sonra geçici buffer serbest bırakılıyor.
  4. Örnekleme kurallarını oluşturuyoruz. VkSampler, dokunun nasıl filtreleneceğini ve koordinatların nasıl ele alınacağını belirliyor.
  5. Shader bağlantısını kuruyoruz. Image view ve sampler descriptor set’e yazılıyor; çizim sırasında bu set bağlanıyor.

Layout geçişleri yalnızca bir alanın değerini değiştirmek değil. Kopyalama ile shader okuması arasındaki erişim sırasını ve verinin görünürlüğünü de düzenliyoruz. Böylece shader, kopyalanması henüz tamamlanmamış bir görüntüyü okumuyor.

CPU pikselleri geçici buffer’a yazıyor; GPU bunları doku görüntüsüne kopyalıyor. Descriptor set ise shader’ın bu görüntüyü sampler ile okuyabilmesini sağlıyor.

Shader’ları Dosyadan Okumak Yerine Kütüphaneye Gömmek

Vulkan’a shader’ın GLSL kaynak metnini değil, derlenmiş SPIR-V kodunu veriyoruz. glslc, bu projede GLSL dosyalarını .spv dosyalarına dönüştüren araç. Faz 5’in ilk halinde bu dosyalar uygulama çalışırken diskten okunuyor, normal derleme sırasında da glslc aranıyordu. Bu durum hem shader dosyalarının uygulamayla birlikte doğru yere kopyalanmasını hem de kütüphaneyi derleyen herkesin shader derleyicisini kurmasını gerektiriyordu.

ADR-009 ile derlenmiş SPIR-V dosyalarını src/vulkan/shaders/spirv/ altında repoda tutmaya başladık. CMake bunlardan embedded_vk_shaders.h başlığını üretiyor; shader kodları uint32_t dizileri olarak derlenen kütüphanenin içine giriyor. glslc, artık yalnızca -DSDLPAINTER_REGENERATE_SHADERS=ON seçeneğiyle açılan regenerate_shaders hedefinde aranıyor. Shader kaynağını değiştiren geliştirici bu hedefi çalıştırıp yeni .spv dosyalarını da kaynak kontrolüne ekliyor.

Kütüphaneyi kullanan kişinin artık shader dosyalarını uygulamanın yanına kopyalaması gerekmiyor. Normal derleme için Vulkan başlıkları ve loader bağımlılıkları hala gerekli, fakat sırf shader derleyicisi için Vulkan SDK kurmak gerekmiyor.

GLSL kaynağı değiştiği halde SPIR-V çıktısı yenilenmezse uygulama eski shader’ı çalıştırmaya devam eder. Bunu yakalamak için cmake/CheckShaderFreshness.cmake, GLSL kaynaklarının SHA-256 özetlerini son üretimde kaydedilen sources.sha256 ile karşılaştırıyor. Kaynak değişmişse kontrol başarısız oluyor. Shader derleyicisine ihtiyaç duymayan bu kontrol, CI’daki quality:shader-freshness işinde çalışıyor.

Dokuya Çizmek ve Önceki İçeriği Korumak

Çizim hedefi (RenderTarget), sonucu doğrudan pencerede göstermek yerine bir dokuya çizmemizi sağlıyor. Örneğin bir paneli önce bu dokuya çizebilir, ardından panelin tamamını tek görüntü olarak ekrana yerleştirebiliriz.

SDLPainter bu hedeflerde ekran formatından bağımsız olarak R8G8B8A8_UNORM kullanıyor. Böylece hedefin piksellerini CPU’ya geri okurken RGBA kanal sırasını sabit tutabiliyoruz. Swapchain formatını kullansaydık BGRA/RGBA sırası veya sRGB dönüşümü yüzeye göre değişebilirdi.

Hedef için ayrı render pass ve pipeline takımları oluşturuyoruz. Çünkü kullandığımız Vulkan yolunda pipeline, birlikte kullanılacağı render pass ile uyumlu olmalı; renk attachment’ının formatı da bu uyumluluğun parçası. Ekranın BGRA, hedefin RGBA olması bu ayrımı gerektirebiliyor.

Hedef değiştirirken içeriği korumak da ayrı bir konu. Şu sırayı düşünelim: önce ekrana arka planı çiziyoruz, sonra bir dokuya geçip paneli hazırlıyoruz, ardından tekrar ekrana dönüyoruz. Dönüşte ekranın ilk render pass’ini yeniden başlatsaydık loadOp = CLEAR, az önce çizdiğimiz arka planı silerdi.

Bu yüzden her çizim yolu için iki render pass var: biri başlangıçta görüntüyü temizliyor (CLEAR), diğeri mevcut pikselleri koruyarak çizime devam ediyor (LOAD). Ekrana frame ortasında dönerken ikinciyi kullanıyoruz. Yeni oluşturulan bir çizim hedefine ilk bağlanışta da içeriği temizleyerek tanımlı hale getiriyoruz; sonraki bağlanışlarda koruyoruz. Bu ilk bağlanış, hedefin ömrü boyunca bir kez gerçekleşiyor; her frame’de otomatik olarak tekrarlanmıyor.

Panel iki durumda da çiziliyor. LOAD kullanılan sağ tarafta, önceki şekiller de yerinde kalıyor.

OpenGL ve Vulkan Aynı Görüntüyü Üretiyor mu? Piksel Karşılaştırması

Vulkan backend’ini eklerken Tessellator‘ı değiştirmemiz gerekmedi. Şekiller yine aynı üçgenlere dönüştürülüyor; fark, bu üçgenleri GPU’ya gönderen renderer’da başlıyor:

Ancak iki backend’e aynı vertex’leri göndermek, aynı pikselleri üreteceklerini tek başına göstermiyor. Doku formatı veya karıştırma ayarındaki bir fark bile sonucu değiştirebilir.

İki backend’in çıktısını gözle karşılaştırmakla yetinmek istemedim. Bu yüzden aynı sahneyi ikisiyle de çizdirip pikselleri karşılaştıran bir test ekledim (tests/test_backend_parity.cpp). Bu tür bir karşılaştırmaya parite testi deniyor.

Testte dolgu, çerçeve, kesikli çizgi, bezier yolu, gradient, doku, kırpma, dönüşüm ve opaklık içeren aynı sahne, iki backend’de birer çizim hedefine çiziliyor. Sonra pikseller CPU’ya okunup kırmızı, yeşil, mavi ve alfa kanalları karşılaştırılıyor. Sabit bir referans PNG kullanmadım; burada kontrol etmek istediğim, iki backend’in birbirine uymasıydı.

Karşılaştırmanın Ortaya Çıkardığı Üç Hata

Testi ilk çalıştırdığımda, önceki görsel kontrollerde fark etmediğim üç hata ortaya çıktı:

1. Doku okunurken uygulanan renk dönüşümü farklıydı. Vulkan dokuları VK_FORMAT_R8G8B8A8_SRGB ile oluşturuluyordu. sRGB, renk değerlerinin doğrusal olmayan bir gösterimi; GPU bu formatı örneklerken değerleri doğrusal uzaya dönüştürüyor. OpenGL tarafında kullanılan GL_RGBA dokuda ise bu dönüşüm uygulanmıyordu. Vulkan’ın B8G8R8A8_UNORM ekran çıktısı da ters dönüşümü otomatik yapmadığı için aynı PNG’nin sayısal renkleri iki yolda farklı işleniyordu. Vulkan doku formatını R8G8B8A8_UNORM yaparak davranışları eşitledik. Yazı karakterlerinin tutulduğu glyph atlası da doku olduğu için metin çizimi de bu farktan etkileniyordu.

2. Alfa kanalı farklı hesaplanıyordu. OpenGL’deki glBlendFunc, seçilen faktörleri hem RGB’ye hem alfaya uyguluyor. Vulkan’da ise bu iki grubun faktörleri ayrı ayrı belirtiliyor ve bizim seçimlerimiz uyuşmuyordu. Pencerede gösterirken swapchain’in alfasını yok saydığımız için hata belirgin değildi; çizim hedefinin alfa değerleri karşılaştırıldığında ortaya çıktı. OpenGL tarafını glBlendFuncSeparate kullanacak şekilde düzelttik. Normal alfa karıştırmasında iki backend de artık aOut = aSrc + aDst * (1 - aSrc) formülünü kullanıyor: yeni şeklin alfasına, arkadaki içeriğin görünür kalan alfa payı ekleniyor.

3. OpenGL’in ilk karıştırma ayarı eski kalmıştı. SetBlendMode düzeltilmişti ama Initialize() içindeki ayrı glBlendFunc çağrısı hala eski faktörleri seçiyordu. Uygulama sonradan mod değiştirmezse düzeltme devreye girmiyordu. Başlangıç ayarını da aynı hesaplamayı kullanacak şekilde eşitledik.

Test Neden Küçük Farklara İzin Veriyor?

Windows’ta nvidia sürücüleriyle yaptığım ölçümde fark sıfırdı: 160 × 120’lik sahnenin 19.200 pikselinin tamamı byte byte aynı çıktı. Bu sonuç test sahnesi ve kullandığım sürücü için geçerli; farklı sürücülerde küçük farklar olabiliyor.

Testte kanal başına 8 birimlik tolerans var. 0–255 aralığındaki herhangi bir kanalda fark 8’i aşarsa o piksel farklı sayılıyor; farklı sayılan piksellerin oranı da en fazla %0,5 olabiliyor. Bu pay, CI’daki yazılım tabanlı Vulkan ve OpenGL uygulamalarının (lavapipe / llvmpipe) özellikle şekil kenarlarında üretebildiği küçük farklar için bırakıldı. Testin bulduğu üç hatanın en küçüğü bile piksellerin %4’ünü etkilediği için bu eşikler onları yakalayabiliyor.

Ölçülen oran her çalıştırmada yazdırılıyor. Test başarısız olursa farkların nerede toplandığını gösteren metin tabanlı bir harita ve en büyük farkın bulunduğu pikselin çevresi de raporlanıyor.

İkinci test, aynı backend’in aynı sahneyi iki kez çizdiğinde birebir aynı çıktıyı verdiğini kontrol ediyor. Sonuç her çalıştırmada değişseydi, backend’ler arasında gördüğümüz farkı yorumlamak da zorlaşırdı.

Vulkan Kullanım Hataları da Testi Başarısız Kılıyor

Doğrulama katmanının bildirdiği hataları da test sonucuna bağladım. Önceden bu mesajlar yalnızca log’a yazılıyordu; Vulkan kurallarına aykırı kullanım varken bile test başarılı görünebiliyordu. ValidationErrorCount() sayacı ve testlerdeki ValidationGuard, bildirilen hataların testi başarısız kılmasını sağlıyor.

CI’da SDLPAINTER_REQUIRE_VALIDATION=1 de ayarlı. Çünkü doğrulama katmanı hiç yüklenmediyse sıfır hata saymak anlamlı değil. Bu ayar, katmanın bulunmamasını da test hatası sayıyor.

Piksel karşılaştırmasının bir sınırı var: iki backend aynı hatalı sonucu üretirse test bunu yakalayamaz. Bu test sonuçların birbirine uyduğunu denetliyor; çizimin doğruluğu için diğer geometri ve çizim testlerine de ihtiyaç var.

Kaynak Yönetimi ve Hata Kontrolü

Buffer, image ve pipeline gibi Vulkan kaynaklarını oluşturduktan sonra uygun Vulkan fonksiyonlarıyla serbest bırakmamız gerekiyor. Özellikle kurulum yarıda başarısız olduğunda, o ana kadar oluşturulan kaynakları unutmamak önemli.

Bu kaynakların yönetimini RAII ile C++ nesnelerine bıraktım. VulkanBuffer, VulkanPipeline, VulkanTexturedPipeline, VulkanTexture ve VulkanRenderTarget, oluşturuldukları VkDevice‘ı saklıyor. C++ nesnesinin yıkıcısı çalıştığında sahip olduğu Vulkan kaynakları da serbest bırakılıyor. Bu nesnelerin device’tan önce yok edilmesi ve GPU’nun artık onları kullanmıyor olması gerekiyor. Yalnızca pipeline kurulurken gereken shader modülleri gibi kısa ömürlü kaynakları ise iş bitince açıkça serbest bırakıyoruz.

VK_CHECK, sonuç kodu döndüren Vulkan çağrılarının hata kontrolünü kısaltıyor. Bir çağrı başarısız olursa hangi fonksiyonun, hangi sonuçla ve hangi kaynak kod satırında başarısız olduğunu log’a yazıp mevcut fonksiyondan dönüyor. Başlatma hatasında std::abort ile uygulamayı sonlandırmıyoruz; çağıran kodun hatayı ele almasına ve kaynaklarını temizlemesine izin veriyoruz. Farklı dönüş tipleri için üç varyant var: VK_CHECK, VK_CHECK_HANDLE ve VK_CHECK_RETURN.

Örneğin shader modülü için GPU belleği ayrılamadığında mesaj şöyle:

FindMemoryType, cihazın sunduğu bellek tipleri arasından kaynağa uygun olanı buluyor. Hem buffer veya image’ın desteklediği tiplerden birini seçmemiz hem de HOST_VISIBLE ya da DEVICE_LOCAL gibi istediğimiz özellikleri sağlamamız gerekiyor. Bu aramayı tek yardımcı fonksiyonda tutuyoruz; vertex buffer’ları, dokular ve çizim hedefleri aynı fonksiyonu kullanıyor.

Vulkan Desteğini Beş Çalışan Örnekle Geliştirmek

Vulkana özel kabiliyetler için ayrıca bir takım örnekler repo içerisin bulunuyor. Bunlar, temelden, metin gösterimine doğru adım adım karmaşıklaşan örnekler. Örneklere examples/vulkan/ altından ulaşabilirsiniz:

AdımÖrnekHedef
5avulkan_clearVulkan penceresi açmak; swapchain görüntüsünü alıp temizlemek ve ekrana sunmak.
5bvulkan_trianglesVertex buffer, pipeline ve push constants ile düz renkli dikdörtgen, daire ve çokgen çizmek.
5cvulkan_texturedDoku yüklemek ve descriptor set üzerinden shader’a bağlamak.
5dvulkan_demoÖzellikleri birlikte kullanmak; pencereyi yeniden boyutlandırırken çizimin sürmesini ve doğrulama hatası oluşmamasını kontrol etmek.
5evulkan_textSDL_ttf ile hazırlanan metinlerin Vulkan backend’inde de çizildiğini doğrulamak.

CI tarafında, piksel karşılaştırması da dahil Vulkan testleri lavapipe ile çalıştırılıyor. Lavapipe çizimi GPU yerine CPU üzerinde yapan bir Vulkan uygulaması; böylece fiziksel ekran kartına ve görünür bir pencereye ihtiyaç duymayan test ortamında Vulkan kodunu da çalıştırabiliyoruz. Örnek uygulamalar ise CI’da derleniyor, çalıştırılmıyor.

Altı Yazının Sonunda Nereye Geldik?

Altı yazıda şunları yaptık:

  • Faz 0: CMake + Conan altyapısı, IRenderer arayüzü, temel tipler
  • Faz 1: OpenGL 3.3 backend: primitifler, quad tabanlı kalın çizgiler, ear clipping, batch’leme
  • Faz 2: Transform stack: affine matris, Save/Restore, scissor kırpma
  • Faz 3: Görüntü ve doku: stb_image, RAII texture, UV hesabı, karıştırma modları
  • Faz 4: Metin: SDL_ttf, glyph atlası, UTF-8, hizalama
  • Faz 5: Vulkan 1.1 backend: context, swapchain, frame senkronizasyonu, push constant pipeline, ring buffer, descriptor set’ler

Vulkan eklerken en çok işimize yarayan şey, çizim API’siyle GPU’ya özgü kodu baştan ayırmış olmamızdı. Painter aynı çağrıları sunmaya, Tessellator aynı geometriyi üretmeye devam etti.

Yeni backend’in asıl işi bu geometriyi Vulkan kaynakları ve komutlarıyla çizmek oldu.

Sonuç

Evet, uzun bir yazının daha sonuna geldik. Peki bundan sonra ne yapıyor olacağız? Açıkçası, öncelikle kütüphanemi conan-center’a eklemeyi hedefliyorum, sonrasında ise biraz daha pratik uygulamaları sizlerle paylaşmak istiyorum. SDLPainter’a  yönelik her türlü görüş, öneri ve yıldızlarınızı bekliyorum. Bir sonraki yazıma kadar bol kodlu günler.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

Bu site istenmeyenleri azaltmak için Akismet kullanır. Yorum verilerinizin nasıl işlendiğini öğrenin.