sdl-painter – Temel Şekiller ve Kabiliyetler (faz-1)

Sevgili yazılımperver dostlarım, tekrar merhaba. Bu zaman zarfında, gerek kütüphane gerekse siteme dair bir çok değişiklik oldu 🙂 Bunları, ilgili yerlerde kısa notlarla belirteceğim; hangi tasarımın ilk hâliyle kaldığı ve hangisinin sonradan değiştiği.

Öncelikle sitemin hostingini değiştirdim ve artık daha hızlı. Kütüphaneye ise bir çok yeni kabiliyet ekledim, bunlara zaten yakında değineceğim. Neyse şimdi konumuza geri dönelim. Serinin önceki yazılarında, CMake, Conan ve IRenderer arayüzüyle projenin temelleri üzerinden geçtik. Shader derleniyor, VAO/VBO hazırlanıyordu ama ekrana henüz hiçbir şey çizilmiyordu. Sonrasında da CI/CD ve kod içi dokümantasyon kabiliyetlerine baktık.

Bu yazımda şu konulara göz atacağız: OpenGL backend’i, backend bağımsızTessellator mimarisi ve glLineWidth yerine neden quad geometrisi kullandığımız. Sonunda konkav poligonlar için ear clipping ve çizimleri toparlayan RenderBatcher ile tamamlayacağız.

OpenGL 3.3 Core Backend

OpenGLRendererIRenderer arayüzünü implement eden sınıfımız. Initialize üç iş yapıyor: context oluştur, GLAD’i yükler ve shader’ları derler.

Burada ilk güncellemeye bakıyor olacağız. Shader’lar artık diskten okunmuyor. Faz 1’de shader dosyaları exe’nin yanındaki shaders/ klasöründen (SDL_GetBasePath()) ya da derleme sırasında gömülen bir kaynak dizini makrosundan yüklüyorduk. Bu aslında, repo içinde çalıştığınız müddetçe bir sıkıntı yaratmaz, ama bunun dışındaki durumlarda probleme yol açar: SDL_GetBasePath() tüketicinin exe dizinini döndürür, yani kütüphaneyi paket olarak alan herkesin shader dosyalarını elle kopyalaması gerekirdi. Şimdi burada paket olayı nereden çıktı diyebilirsiniz. O da kütüphaneyi, nihai olarak bir Conan paketi olarak kullanma vizyonundan geliyor.

Derleme makinesinin mutlak yolunun pakete de girmesi, Conan Center’ın relocatable paket kuralına uymuyor. Bu konuya, ADR-009’da değindik. Bu sayede, GLSL kaynakları artık ham string literal, SPIR-V ise uint32_t dizisi olarak binary’ye  gömülüyor. ShaderProgram::Build(vert_src, frag_src) API’sini ise değiştirmeye gerek kalmadı (zaten kaynaktan derliyor).

Blend durumu tek yerden kuruluyor. Burada eskiden ayrı bir glBlendFunc çağrısı vardı.  Bu da OpenGL ile Vulkan arasında bir ayrışmaya sebebiyet veriyordu, bu sebeple artık burası, SetBlendMode üzerinden ayarlanıyor. Detaylara ilgili dosyadan bakabilirsiniz.

VAO ve VBO

SetupBuffers çizimlerde kullanmak için iki VAO/VBO çifti oluşturuyor: biri düz renkli vertex’ler, biri texture koordinatlı olanlar için.

Vertex basit bir yapı: iki float pozisyon, dört byte renk. Rengin uniform yerine vertex’te taşınması da burada bilinçli olarak yapıldı, neden mi?Batch’leme bölümünde göreceğiz.

Shader Tasarımı

Şu an için iki shader çiftimiz var: basic (düz renkli şekiller) ve textured (imaj/dokular, faz 3’de bakıyor olacağız).

Fragment shader’ın tek bir işi var: vertex renginin alfasını global u_opacity ile çarpıp yazmak.

Faz 1’deki akış ise şuydu: Painter her çizimden önce 3×3 affine matrisini SetModelMatrix ile renderer’a yazar, shader ise onu köşe koordinatlarına uygular ve ortografik projeksiyon ekran koordinatına çevirirdi. u_model shader’da hâlâ duruyor ama artık her frame başına bir kez, daima birim matris olarak yazılıyor. Dönüşüm vertex’lere CPU tarafında uygulanıyor. Ekstra iş gibi gelebilir ama faydasına yine batch’leme bölümünde bakacağız. Kısaca: model matrisi bir uniform olduğu sürece her Rotate çağrısı daha önceden batch’i kırıyor, performans kaybına sebebiyet veriyordu. Belki buna yönelik ayrı bir yazı da ekliyor olabiliriz.

Tessellator

sdl-painter’ın asıl işi yaptığı ve şekilleri vertex listelerine çeviren kodun tamamı Tessellator sınıfında bulunuyor. ADR-004’teki kararın özü tek cümle: Tessellator renderer’ı tanımaz/tanımamalı baba, sadece std::vector<Vertex>üretir.

Elbette bu soyutlama/ayrımın bir bedeli var. Bu ayrımın bedeli tek bir vektör kopyası, karşılığı ise Faz 5’te de göreceğimiz üzere Vulkan backend’i eklerken tessellator dosyalarını aynen kullanmak. Aynı vertex verisi her iki  (belki ileride daha fazla) backend’e de gidiyor.

Sınıfın tüm metotları statik ve hiç state tutmuyoruz:

Tahmin edebileceğiniz üzere, şekillere yönelik vertexlerin tanımlanarak üretildiği yer bu sınıf.

Bu arada /doc dizini altında da görebileceğiniz üzere örnek bir dikdörtgen çizimi için şöyle bir akışımız mevcut:

Daire ve Elips: Adaptif Segment Sayısı

OpenGL’de ve SDL’de daire çizim API’leri bulunmaz. Genelde bu şekiller segmentler kullanılarak çizilir. Biz de küçük bir daire için 16 segment kullanarak bunu oluşturuyoruz, tabi bu sabit değil, AdaptiveSegments bunu yarıçapa göre ayarlıyor:

Alt sınır görsel kalite için: 10 piksellik bir daire de 16 segment alıyor. Ayrıca hata durumlarına karşı kontroller de mevcut.

Dolu daire ise bir triangle fan aracılığıyla: merkez + her segmentte iki çevre noktası ile çizilir.

Elipste tek fark rx ve ry‘nin ayrı ölçeklenmesi; segment sayısı ise büyük yarı eksene göre hesaplanıyor.

Buradaki yaklaşım yay, dilim ve benzeri çizimler de bu mantığı takip eder. Bunlara da primitif örneğinden bakabilirsiniz.

Kalın Çizgiler: Quad Yaklaşımı

sdl-painter bir diğer önemli farklı çizgi çizimleri, daha önceki uygulamalarımda hep glLineWidth kullandım, fakat bu genel hep aynı sonuc vermiyor. Ayrıca glLineWidth(x > 1.0f) Core Profile’da da deprecated olmuş durumda yani pek çok sürücüde maksimum değer 1 piksel 🙂

Uç ve birleşim stili üzerinde de hiçbir kontrolümüz olmuyor. ADR-003’te bu hususu masaya yatırdık ve şöyle karar aldık: glLineWidth hiçbir koşulda kullanılmayacak, her çizgi segmenti bir quad olarak tessellate edilecek.

Yöntem basit: çizginin yönüne dik normal vektörü bul, kalınlığın yarısı kadar iki yöne uygula, dört köşeden iki üçgen üret.

Segmentler bağımsız quad’lar olarak üretildiği için her köşede kama biçimli bir boşluk kalır. Buradaki köşeye yuvarlak bir disk ekleyerek kapattık, ve ardından LineJoin ile miter ve bevel seçeneklerini sunduk. Miter, kMiterLimit (4.0, SVG’nin varsayılanı) aşıldığında keskin açılarda sivri çıkıntı üretmemek için kendiliğinden bevel’a düşüyor. Bu kavramlar ne anlama geldiğini açıklamak yerine sanırım şekil eklesem daha iyi olacak 🙂

Variable-Width Line Ends and Line Joins

Uç stili yalnızca açık geometriye uygulanıyor bu arada. Yani: DrawLine ve DrawPolyline, orada da sadece iki uç noktaya. Kapalı şekillerin (dikdörtgen, daire, poligon çerçevesi) ucu olmuyor, yalnızca birleşimi var.

Primitif uygulamasındaki örneklerimiz ise şöyle:

Konkav (iç bükey) Poligon: Ear Clipping

uEngine de olduğu gibi hedefimiz, FillPolygon ile konkav şekilleri de desteklemek. Örneğin: L, T, yıldız şekilleri. Basit triangle fan burada yetmiyor, çünkü merkezden çıkan bazı üçgenler şeklin dışına taşabiliyor.

ADR-006’da ear clipping’i seçmemize yönelik motivasyonu anlattım: uygulaması orta düzey, dış bağımlılık yok, 2B arayüz şekillerinin tipik nokta sayısında (< 200) O(n²) karmaşıklık da bizim için sorun değil.

Bir “kulak”, poligonun dışbükey bir köşesi ve iki komşusundan oluşan, içinde başka hiçbir köşe barındırmayan üçgen. Algoritma:

  1. Sarma yönünü shoelace formülüyle bul, gerekiyorsa indeksleri ters çevirerek her zaman CCW üzerinden çalış,
  2. Bir kulak bul: köşenin dışbükeyliğini cross product ile sına, sonra diğer noktaların üçgen içinde olup olmadığına bak,
  3. Kulağı üçgen olarak kaydet, indeks listesinden çıkar,
  4. Üç nokta kalana kadar tekrarla, son üçgeni ekle.

PointInTriangle cross product işaretlerine bakıyor: üç kenar için de aynı yöndeyse nokta içeridedir.

Şimdi geldik bir diğer önemli kabiliyete: RenderBatcher.

RenderBatcher: Draw Call’ları Azaltmak

Her FillRect veya DrawCircle çağrısının vertex’lerini doğrudan GPU’ya göndermek, çok şekilli sahnelerde yüzlerce draw call anlamına geliyor ki, önceki kütüphanelerimde de hep böyle yaptım, bu sefer bu konuya eğilmeye karar verdim.

İşte tam bu noktada  Painter ile IRenderer arasındaki RenderBatcher bunları bir arabellekte biriktirip topluca GPU’ya gönderiyor.

PushTriangles, batch’i bozacak herhangi bir durumda ise önce Flush() çağrılıyor:

Texture’lı çizimde bir tetikleyici daha var: aktif texture değiştiğinde de flush gerekiyor, çünkü tek draw call tek texture bağlayabilir. Karıştırma modu da öyle — o bir GPU durumu, vertex’te taşınamıyor. Bu ne demek, eğer farklı farklı dokulara ait şekilleri çizmeye kalkarsanız, bu mekanizmanın çok bir faydasını göremeyebilirsiniz.

Peki neden rengi her vertex’e tek tek yazıyoruz? Çünkü batch içinde farklı renkli şekiller olabiliyor. Renk uniform olsaydı her renk değişimi batch’i kırardı; vertex’te taşınınca artık kırmıyor. Aynı esneklik bize şunları da sağladı: doku tonlaması (tint) ve gradient dolgular. Gradient shader gerektirmiyor — köşe renkleri tessellation sırasında hesaplanıyor, aradaki geçişi donanım enterpolasyonu yapıyor ve voila.

Evet gelelim, ilk etapta yapmadığımız fakat artık RenderBatcher’ın sahip olduğu bir özelliğe daha değinelim: Transformlar.

Faz 1’de, transformlar bir shader uniform’uydu ve Painter, görsel sıralamayı bozabilecek her noktada flush ediyordu: TranslateRotateScaleSaveRestore.

Şekil başına transform kullanılan durumlarda, ne yazık ki batch’lemeden hiç fayda göremiyorduk. Aynı 2000 şekil, transform’suz 2 draw call üretirken şekil başına Save+Translate+Restore ile 2000 draw calla kadar çıkabiliyordu.

Çözüm temelde bu transformasyon işlerini CPU’ya almak oldu. RenderBatcher transformasyon matrisini vertex’lere rengin yazıldığı aynı kopyalama döngüsünde uyguluyor: ek geçiş yok, ek tahsis yok. u_model artık daima birim gönderiliyor ve transform değişimi batch’i kırmıyor.

Windows, MSVC Release, 2000 şekil ile ölçtüğümüzde: şekil başına Save+Translate+Restore ile 9.44 ms → 0.30 ms, yaklaşık 31 kat. Rotate da eklendiğinde 9.37 ms → 0.37 ms’lere kadar düştüğünü gördük.

Bugün flush’u tetikleyen şeyler sadece gerçek GPU durumları: kırpma, viewport, çizim hedefi, karıştırma modu, doku değişimi, opaklık ve 8192 vertex’lik arabellek sınırı.

Demo

Faz 1 demosu,  examples/basics/primitives.cpp adıyla repoda (eski adı phase1_demo). Tüm primitifleri tek ekranda topluyor:

Save() / Restore() çifti burada işini yapıyor: dikdörtgen döndürülmüş koordinat sisteminde çiziliyor, Restore sonrası bir sonraki şekil yine ekran koordinatlarında. Buna faz 2’de ayrıntılı değineceğiz.

Sonuç

Bu yazımızla birlikte artık elle tutulur şekillerimizi görebildik. Burada önemli üç husus var:

  • Tessellator ayrımı (ADR-004): renderer’dan bağımsız vertex üretimi. Vulkan backend’i için de bu kullanılıyor olacak,
  • Quad tabanlı/kalınlığa sahip çizgiler (ADR-003): glLineWidth kullanımı yok,
  • RenderBatcher: GPU çağrılarını azalttık,
  • Konkav/İç Bükey Poligon: Standard poligonlar dışındaki şekilleri de çizebiliyoruz.

Faz 2’de transformasyon hususlarına bakıyor olacağız: glm::mat3 ile affine matris birikimi, Save/Restore yığını ve scissor tabanlı kırpma kabiliyetlerine bakacağız.

Bu arada kütüphane artık duyurulacak düzeye geldi, bunu da ayrı bir yazı ile yaparım muhtemelen. Kaynak kodlar için github.com/yazilimperver/sdl-painter a bakabilirsiniz.

Bir sonraki yazımda görüşmek dileğiyle kendinize çok iyi bakın.

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.