sdl-painter – Transformasyonlar (faz-2)

Tekrar merhaba dostlar. sdl-painter’ı tanımak için fazlara bölerek kabiliyetleri anlatnaya devam ediyorum. Bir önceki yazıda OpenGL üzerinde temel şekilleri nasıl çizebileceğimize bakmıştık: çizgi, dikdörtgen, daire, elips ve konkav poligon. Şimdi de, şekillerimizi nasıl hareket ettireceğimize ilişkin transformasyon hususlarına bakıyor olacağız.

Bir önceki yazımda, tüm şekiller sabit koordinatlarda görselleştiriliyordu. Bu yazımda, bunları nasıl hareket ettirip/öteleyip, döndürüp ya da boyutlandırabileceğimize, bir diğer ifade ile transformasyon kabiliyetlerine bakacağız: Save() / Restore(), Translate / Rotate / Scale ve scissor tabanlı kırpma. Matris olarak glm::mat3 kullanıyoruz ama affine matrisleri elle kuruyoruz.

Bu konulara aslında yıllar önce uEngine4 Serüveni – BasicGLPainter – II yazımda da değinmiştim. Dönüşümlerin matematiğini ve kırpma alanlarını orada anlatmıştım; burada aynı şeyleri tekrar etmek yerine, sdl-painter’da işlerin nerede farklılaştığına odaklanacağım.

Dönüşüm Matrisleri ve Çarpım Sırası

2B çizim için 3×3 affine matrisler bize yetiyor; öteleme, döndürme ve ölçeklemenin hepsini z bileşenini 1.0 ile sabitleyerek ifade edebiliyoruz. Bu matrislerin nasıl kurulduğunu ve neden 3×3 olduklarını BasicGLPainter yazısında anlatmıştım, oraya bakmanızı tavsiye ediyorum. sdl-painter tarafında ise iki ayrıntı öne çıkıyor.

Birincisi, glm::mat3 sütun-major saklanıyor (glm tercihi ADR-007; header-only ve OpenGL ile uyumlu). Yani indeksleme m[col]

 şeklinde ve öteleme sütun 2‘de duruyor:

GLM kullanmamıza rağmen matrisleri glm::rotate gibi gtx/experimental yardımcılarıyla değil elle kuruyoruz:

Translate ve Scale de aynı kalıpta: birim matris, ilgili hücreler (t[2][0]=dx, t[2][1]=dy / sc[0][0]=sx, sc[1][1]=sy) ve yine sağdan çarpım.

İkincisi, çarpım sırası. Post-multiply (M = M * op) ile QPainter ve benzeri kütüphanelerin kuralını izliyoruz: ilk çağrılan dönüşüm en dışta, son çağrılan en içte uygulanır. Translate(dx, dy) sonra Rotate(a) çağırırsak şekil önce kendi merkezinde döner, sonra (dx, dy)‘ye taşınır — çarpım M·v = T·R·v olduğu için R önce etki eder.

Şimdi bu durumu ifade eden bir resim ile bu başlığı da kapatalım (daha önce bu tarz resimleri bulmak biraz oluyordu artık kolayca oluşturabiliyoruz 🙂 ).

Save() / Restore() ve RenderState

Transform’u tek başına tutmak yetmiyor elbette, Painter‘ın taşıdığı başka durum bilgileri de var:

Save() temelde, bu yapının tamamını kopyalıyor, Restore() yığından çıkarıp yerine koyuyor. Yani SetBrush, SetOpacity ya da SetBlendMode de Save/Restore kapsamında eski haline dönüyor. Pen‘in kesik deseninin std::vector değil sabit boyutlu bir dizi olmasının sebebi de tam olarak burası: her Save/Restore ile kopyalanıyor. OpenGL ve benzeri grafik API’lerinde bu yığın mantığına rastlayabilirsiniz.

Beklenmedik Restore() çağrısında uyarı basıyoruz; böyle bir uyarı görürseniz genellikle erken bir return vardır ya da bir Restore atlanmıştır.

İç içe transform

Transformasyonların asıl mahareti, silsile halinde uygulandığında ortaya çıkıyor:

Her Save/Restore çifti bağımsız bir koordinat uzayı açıyor; iç katmanın Rotate(-angle * 2.0f) çağrısı dış katmanın dönüşünü iptal etmiyor, üzerine ters yönde bir dönüş ekliyor.

Bir şeyi kendi merkezi etrafında döndürmenin genel kalıbı da bu: Translate(merkez), Rotate(açı), sonra şekli merkezi orijinde olacak şekilde çiz (FillRect(-w/2, -h/2, w, h)).

Şimdi bu durumu gösterecek bir resim oluşturtup, bakalım:

Peki Dönüşüm Nereye Uygulanıyor?

İlk başta, Painter her çizimden önce mCurrentState.transform‘u renderer’a yazıyor, vertex shader da u_model uniform’uyla çarpıyordu. Model matrisi bir uniform olduğu için de değiştiği anda biriken çizimlerin flushlanması gerekiyordu; yani Translate, Rotate, Scale, Save ve Restore, hepsi için Flush() ile başlıyordu.

Faz 1 yazısında RenderBatcher‘ı anlatırken bu duruma değinmiştim: şekil başına transform kullanan kod — ki 2B’de en yaygın kalıp budur — batch’lemeden hiç fayda görmüyordu:

Sahne (2000 şekil)Draw callSüre
Transform yok2—
Şekil başına Save+Translate+Restore20009.44 ms
Aynısı, dönüşüm CPU’da20.30 ms

Açıkçası, çözüm dönüşümü CPU’ya almak oldu: RenderBatcher matrisi vertex’lere, rengin yazıldığı aynı kopyalama döngüsünde uyguluyor. Ek geçiş yok, ek tahsis yok; u_model artık frame başına bir kez, daima birim matris olarak yazılıyor. Bu elbette tek doğrudur diyemiyorum, bunula birlikte bunu uzun süredir yapmak istiyordum ve bu vesile ile bunu deneyeceğim. Çoklu ve hareketli 2B şekiller için şu an başarılı bir iş ortaya koyuyor.

Affine2D, bu bileşenleri elle çarpıyor; affine bir matriste alt satır daima [0 0 1] olduğu için glm::vec3 üzerinden gidip üçüncü bileşeni hesaplamanın anlamı yok.

Sonuç: Translate/Rotate/Scale artık hiçbir şey flushlamıyor, Save() de flushlamıyor, Restore() yalnızca kırpma gerçekten değiştiyse flushlıyor (eskiden her Restore koşulsuz bir ClearScissor üretiyordu ve 2000 şekilde frame başına 2000 gereksiz durum değişimine sebebiyet veriyordu).

Scissor Kırpma

Kırpma, çizimi ekranın belirli bir dikdörtgeniyle sınırlamak demek: o dikdörtgenin dışına taşan her şeyi kırpar. uEngine4’te bunu stencil testiyle yapıyordum — önce maske şeklini stencil tamponuna çiz, sonra yalnızca maskeye denk gelen pikselleri geçir. Esnek bir yöntem, çünkü maskenin dikdörtgen olma zorunluluğu yok; bununla birlikte fazladan bir çizim daha yapılır.

sdl-painter’da ihtiyaç dikdörtgen kırpmayla sınırlı olduğu için glScissor(x, y, width, height) bize yetiyor (şimdilik): GPU’ya bir dikdörtgen veriyoruz, o da dışarıda kalan fragment’ları rasterizasyon sırasında hiç işlemeden eliyor. Ne fazladan geçiş var ne fazladan tampon.

Kullanan taraf için API iki fonksiyondan ibaret:

clip_rect, RenderState‘in bir üyesi olduğu için kırpma da Save/Restore kapsamında: bir blokta kırpıp Restore() ile dıştaki kırpmaya geri dönebiliyoruz.

SetClipRect‘in içi şöyle:

Buradaki Flush(), bir önceki bölümden sonra tuhaf görünebilir — dönüşümü vertex’lere gömerek flush’lardan kurtulmuştuk. Kırpmada aynı numarayı yapamıyoruz: scissor kutusu vertex verisinin bir parçası değil, GPU’nun kendi durumu. Tek bir draw call içindeki şekiller farklı kırpma kutuları kullanamaz. Bu yüzden kırpma her değiştiğinde o ana kadar birikeni göndermek zorundayız; ClearClip de aynı sebeple flush ediyor.

ApplyScissor: iki koordinat düzeltmesi

Kırpma dikdörtgenini GPU’ya olduğu gibi veremiyoruz, çünkü Rect‘in ve glScissor‘ın konuştuğu koordinat sistemleri aynı değil. Arada iki düzeltme var; ikisi de unutulduğunda derleyici uyarmıyor, ekranda garip bir sonuç olarak karşınıza çıkıyor.

1) Viewport ofseti — “hangi (0,0)?”

Çağıran taraf kırpma dikdörtgenini çizim koordinatlarıyla verir. Bir sonraki bölümde anlatacağım SetViewport, çizim koordinatlarının başlangıcını pencerenin herhangi bir köşesine taşıyabiliyor; yani çizim koordinatları viewport’a göre yerel. glScissor ise daima pencerenin kendi koordinatlarını bekler. Aradaki farkı kapatmak için viewport’un konumunu eklemek gerekiyor:

Bu iki satır olmasa tam ekran çizen bir programda hiçbir şey değişmez, çünkü mViewportX/Y zaten sıfırdır. Bölünmüş ekrana geçtiğiniz gün ise sağ paneldeki kırpma sol panele düşer.

2) Y ekseninin yönü

sdl-painter, pencere sistemlerinin (QPainter’ın da takip ettiği, https://doc.qt.io/qt-6/coordsys.html) yaklaşımını izleyip Y=0’ı üstte tanımlıyor; Vulkan’ın clip uzayı da öyle. OpenGL’in ekran framebuffer’ında ise Y=0 altta. Yani GL’de scissor kutusunu çevirmemiz gerekiyor:

Formülün gözden kaçan kısmı rect.h çevirdiğimiz şey, bir nokta değil bir kutu: biz kutuyu üst kenarından tanımlıyoruz, glScissor ise alt kenarından. 600 piksel yüksekliğindeki bir pencerede, üstten 50 piksel aşağıda başlayan 120 piksel yüksekliğinde bir kırpma için:

rect.h unutulursa kutu kendi yüksekliği kadar kayar: küçük kırpmalarda “biraz oynamış” gibi görünür, büyüklerinde ise ekrandan çıkar.

Bir ayrıntı daha: çevirmeyi yüzeyin tamamına göre yapıyoruz (SurfaceHeight()), viewport yüksekliğine göre değil. Ölçü penceredir, çünkü glScissor pencere koordinatı üzerinden çalışır. Yanlışlıkla viewport yüksekliği kullanılırsa, üst yarıya yerleşmiş bir viewport’ta kırpma alt yarıya kayar.

Viewport

Faz 2’den sonra eklenen ama tam olarak bu bölüme ait bir diğer kabiliyetimiz: SetViewport. Kırpma pikselleri maskeler, koordinat sistemine dokunmaz; viewport ise koordinat sisteminin kendisini yeniden tanımlar — ayarlandıktan sonra (0, 0) artık panelin sol üst köşesidir.

Bu kabiliyetin en çok kullanıldığı yerler: bölünmüş ekranlar, mini harita ve panelli araç arayüzleridir. Dört panel de aynı çizim fonksiyonunu çağırabiliyor, aralarındaki tek fark kamera konumu. Tabi ki, bu API her çağrıldığında, flush API’sini te tekrar çağırıyoruz. Panel sayısı kadar taban draw call maliyeti oluşuyor ve examples/basics/viewports.cpp bunu gizlemek yerine istatistik katmanında gösteriyor.

Demo

Faz 2 demosu bugün examples/basics/transforms.cpp (eski adı phase2_demo). Yedi hususun hepsi bir arada:

ŞekilNe gösteriyor
1Kademeli Translate — üç dikdörtgen, her biri bir öncekine göre ofsetli
2Translate + Rotate — dikdörtgen kendi merkezi etrafında döner
3Translate + Scale — daire sin(angle) ile nefes alır
4Üç seviyeli iç içe Save/Restore, her katman farklı hızda
5Scissor kırpma — taşan daire ve dönen dikdörtgen
6ResetTransform — bozuk transform sonrası sıfırlama
7X ve Y’de bağımsız Scale

Küçük bir not: demoda Painter‘ın ömrünü bilerek bir scope bloğunda tutuyoruz. Pencere önce silinirse GL context’i de gider ve destructor’daki glDelete* çağrıları 1282 (GL_INVALID_OPERATION) verir; sıranın önemli olduğunu göstermek için güzel bir örnek.

Sonuç

Faz 2 kabiliyetleri ile birlikte kütüphanemizle, artık şekilleri raks ettirebileceğiz. Burada, farklı bir yaklaşıma tekrar değiniyorum o da dönüşümün artık shader’da değil, CPU’da uygulanması.

Faz 3’te, oyunlar ve diğer uygulamalar için kritik olan, resim/görsel/dokulara giriş yapacağız.

Bir sonraki yazıda görüşmek dileğiyle bol kodlu günler. Kütüphaneyi beğendiyseniz, takip etmeyi lütfen unutmayın.

Kaynak kod: GitHub · GitLab.