sdl-painter – Resim Çizimi (faz-2)

Sevgili yazılımperver dostlarım, tekrar merhaba.
Önceki yazılarımda, altyapıya göz attık, temel şekilleri çizdik ve dönüşüm yığınıyla bu şekillere raks ettirdik.
Bu yazıda ise sıra resimlerde, bir dosyadan okuduğumuz ya da kodla ürettiğimiz pikselleri ekrana nasıl taşıyoruz sorusunun cevabını arayacağız. Özellikle oyun benzeri uygulamalarda en çok ihtiyaç duyulan kabiliyetlerden birisi de resimlerin çizdirilmesidir. Burada temel kabiliyetlere değineceğim, muhtemelen sonraki yazılarımda, özellikle oyunlarınız için sizlere faydalı olacağını düşündüğüm bir takım ek yetenekleri de sizlerle kısa kısa paylaşacağım (merak edenler için repodaki örneklerde bunlar var 😉 bu arada )
SDLPainter’da bu işin iki ciheti var ve bu yazımda bunları belirli kavramlar ile ifade etmeye çalışacağım. Image, piksel verisini ana bellekte tutuyor; Texture ise çizim altyapımızın (backend) oluşturduğu GPU’daki dokusunun sahipliğini yönetiyor. Painter::DrawImage ise bu ikisini bir araya getiriyor.
Faz 3’ün temel tasarımını anlatırken güncel uygulamadaki filtreleme, renk tonlama, aynalama ve doku güncelleme özelliklerine de yeri geldikçe değineceğim.

Neden stb_image?

İlk konu resim dosyalarını nasıl yükleyeceğimiz. uEngine’de SDL_Image kullanmıştım, onun öncesinde de DevIL kütüphanesini kullanmıştım.
Açıkçası bu kütüphanede, hızlıca bu kabiliyeti kazandırmak adına stb_image’i kullanmaya karar verdim (ADR-005). Temel ihtiyacımız basit: PNG ve JPEG gibi dosyaları okuyup piksel verisini IRenderer arayüzüne iletmek.
stb_image tek başlık dosyasından oluşuyor ve kullanmak için aşağıdaki tanım yapmak yeterli. Kütüphanemde bunu src/image.cpp içerisinde tanımladım:

Bu kullanım, ayrıca libpng veya libjpeg bağlamayı gerektirmiyor. Projede stb yi conan üzerinden kullanıyorum, bu sebeple de kurulum kolay. Entegrasyon detayları için stb_image kaynak dosyası dokümanına başvurulabilir.

SDL_image da elbette kullanılabilirdi (ileride belki kullanırız, daha önce kullandım),  ancak onun SDL_Surface tabanlı çıktısı, doğrudan ham piksellerle çalışan bu görüntü yükleme yolunda ek bir avantaj bize sağlamıyor gibiydi.

Image: Pikseller Ana Bellekte

Bir görüntüyü dosyadan oluşturmak için aşağıdaki kod parçasını kullanıyoruz:

Bu aşamada dosya okunup çözülüyor, fakat GPU’ya doku/texture’u henüz aktarmadık. Resim okunmadan, stbi_load çağrısından önce, stbi_info ile boyutları kontrol ediyoruz ve toplam piksel sınırını burada kontrol ediyoruz. Okuma ya da çözme başarısız olduğunda da hata veriyoruz; bu durum da IsValid() ile sorgulanabilir. Bu tarz kontroller, sizleri erkenden haberdar etme açısından önemli.
Boyutları Width() ve Height(), kanal sayısını Channels(), piksel verisini ise RawData() ile sorgulayabiliyoruz. Image kopyalanamıyor, ancak taşınabiliyor.
CreateFromData, kodla üretilen veya başka bir kaynaktan gelen pikseller için kullanıyoruz. Veriyi kopyaladığı için, çağıranın tamponunu daha sonra serbest bırakılabilirsiniz. Burada kanal kanal sayıları (1, 2, 3 ve 4); tamponun en az width × height × channels byte içermesi ve satırların arada boşluk olmadan sığması gerekiyor.
İçeride piksel verisi, özel destructor (StbDeleter)’a sahip bir std::unique_ptr ile tutuluyor. Destructor stbi_image_free çağırıyor. Projenin mevcut ayarlarında hem stbi_load hem de CreateFromData için kullanılan malloc bu serbest bırakma yöntemiyle uyumlu.
IsValid() yalnızca ana bellekte piksel verisi bulunduğunu gösterir. GPU’ya yüklemenin başarılı olup olmadığı ayrı bir konu; Upload başarısız olursa kInvalidTexture döndürülüyor.

Texture: GPU Kaynağının Sahibi

TextureHandle, kütüphane arayüzünde uint32_t tabanlı bir tanımlayıcısı. Bu OpenGL tarafında bir doku kimliğine karşılık geliyor. Texture, bu tanımlayıcıyı onu oluşturan IRenderer ile birlikte tutuyor. RAII tasarımının ilgili kısımları şöyle:

Kopyalama yapmıyoruz; taşıma (*move*) ile sahipliği devrediyoruz. Böylece aynı GPU kaynağını iki nesnenin ayrı ayrı silmesinin önüne geçmeyi hedefledim.

Texture, ham bir IRenderer* tutuyor. GPU’ya yüklenmiş bir Image, onu yükleyen renderer yok edilmeden önce yok etmeli. Çizimler de görüntü yok edilmeden önce End() ile tamamlanmalı. Pratikte, geçerli bir SDL penceresi için şu bildirim sırasını takip ediyoruz:

Bu tarz kaynakların yaşm döngülerini takip etme, özellikle bu tarz grafik uygulamaları için kritik. İleride benzer kaynaklar için bu konulara yine değiniyor olacağım.

GPU Yükleme ve Filtre Seçimi

DrawImage, çizimden önce Image::Upload çağırıyor. Tabi daha önce bu resmi GPU’ya aktarmışsak, yeniden yüklemiyoruz:
Bu bize neyi sağlıyor, hiç çizilmeyen bir görüntü GPU dokusu/kaynağı tüketmiyor. Yüklemeden sonra ana bellekteki piksel kopyası da korunuyor.
Aşağıda buraya kadar olan akışı YZ’nin de yardımı ile görselleştirilmiş halini görebilirsiniz (çok iyi değil mi 🙂 )

Pikseller Image oluşturulurken hazırlanır; GPU dokusu ilk yüklemede oluşturulur ve sonraki çizimlerde yeniden kullanılır.

Image tek bir GPU dokusunu önbelleğe alıyor. Farklı bir renderer ile kullanılırsa eskisini bırakıp ana bellekteki veriden yeni bir doku oluşturuluyor. Bu geçiş sırasında eski renderer da hâlâ geçerli olmalı tabi. Bu durumun çok olmayacağını ön görüyorum.
Filtreyi ise yine ilk yüklemeden önce seçiyoruz:

Varsayılan kLinear, örnekler arasında yumuşak geçiş sağlıyor. kNearest ise doku büyütülürken keskin kenarları korumak için kullanışlı. İlk çizimden sonra SetFilter çağırmak, önbellekteki GPU dokusunun filtresini değiştirmez. UpdateImage da ilk yüklemeyi tetikleyebildiğinden filtreyi ondan önce ayarlamak önemli.

Aynı 24 × 24 görüntünün 16 kat büyütülmüş hâli: kNearest piksel kenarlarını korurken kLinear komşu örnekler arasında geçiş oluşturur.

Bu konuya yönelik daha detaylı bilgiler için https://learnopengl.com/Getting-started/Textures adresine de bir göz atabilirsiniz.

OpenGL’de Doku Oluşturma

stbi_load çağrısında istenen kanal sayısı 0 bırakıldığı için çıktı zorla RGBA’ya dönüştürülmüyor. Dosya uzantısından kanal sayısı çıkarmak yerine Channels() değerini kullanıyoruz. Güncel OpenGL eşlemesi şöyle:
Kanal sayısıPiksel içeriğiOpenGL formatıShader’ın gördüğü RGBA
1GriGL_RED(gri, gri, gri, 1)
2Gri ve alfaGL_RG(gri, gri, gri, alfa)
3RGBGL_RGB(r, g, b, 1)
4RGBAGL_RGBA(r, g, b, a)
Bir ve iki kanallı dokularda bu sonuç, OpenGL’in kanal eşleme ayarıyla elde ediliyor. Örneğin gri ve alfa içeren dokuda:

Not: Vulkan tarafında aynı anlamı korumak için 1, 2 ve 3 kanallı veriyi yüklemeden önce RGBA’ya genişletiliyor.
CreateTexture içinden, doku bağlandıktan ve kanal formatı seçildikten sonra çalışan ilgili bölüm:
GL_CLAMP_TO_EDGE, dokunun dış kenarındaki örneklemeyi kenar pikselleriyle sınırlar. Ancak bir atlasın içindeki sprite sınırlarını bilmez; doğrusal filtrelemede komşu dilimlerin renkleri birbirine karışabilir. Atlas hazırlarken dilimler arasında pay bırakmak veya kullanıma göre kNearest seçmek bu nedenle önemlidir. Spritesheet’lere bakarken bu önemli olacak ama bu yazı için yeterli.

DrawImage ve UV Koordinatları

DrawImage üç overloaded API sunuyor:

Hedef dikdörtgene ölçekleme en-boy oranını kendiliğinden korumuyor. Kaynak dikdörtgen ise piksel cinsinden veriliyor ve normalleştirilmiş doku koordinatlarına, yani UV’lere dönüştürülüyor:


Örneğin 256 × 256 bir atlasın sol üst 128 × 128 bölgesi, (0, 0) ile (0.5, 0.5) arasına karşılık geliyor. Hesaplama gerçek kodda float tipi değerlere göre yapılıyor. Böylece tek dokunun farklı bölgelerini ayrı görüntüler gibi çizebiliyoruz.

256 × 256 atlasın sol üst 128 × 128 bölgesi, (0, 0)–(0.5, 0.5) UV aralığından 100 × 100 hedefe çiziliyor.

Üç çağrı da sonunda PushTexturedQuad yardımcı API’sini kullanıyor. Güncel uygulamada bu yardımcı UV’leri aynalama seçimine göre düzenliyor; iki üçgenin köşelerini ise Tessellator::TessellateTexturedRect üretiyor:
Bu köşelere mevcut dönüşüm matrisi de uygulanıyor. Dolayısıyla görüntüler Translate, Rotate ve Scale ile şekiller gibi dönüştürülebilir; ekrandaki sonuç eksen hizalı olmak zorunda değil.

Renk Tonlama, Aynalama ve Opaklık

Her DrawImage çağrısında isteğe bağlı olarak bir renk tonu (*tint*) ve aynalama seçeneği de verilebiliyor:

Tint, dokunun rengiyle bileşen bileşen çarpılıyor. Beyaz tint görüntüyü mevcut renkleri ile koruyor; yukarıdaki renk ise yeşil ve mavi bileşenleri azaltıyor. Bu işlem beyaz bir sprite’ı renklendirmek için uygun, fakat keyfî bir görüntüyü gri tonlamaya dönüştüren bir işlem değil. Bunun da uygulamalarına oyunlarda bakıyor olabiliriz. Tek bir resimi farklı renklerde render etmek için birebir.

Aynalama, hedef dikdörtgeni yerinde tutup UV uçlarını değiştirir, yatay, dikey veya iki eksende birden uygulanabiliyor ve tek başına ek geometri ya da çizim çağrısı da gerektirmiyor. Aşağıda bunların ve renklendirmenin mantığına yönelik görseli görebilirisniz:

Üstte beyaz sprite farklı tint değerleriyle çiziliyor. Altta asimetrik görüntü yatay, dikey ve iki eksende aynalanıyor.

OpenGL fragment shader’ı bu verileri şöyle kullanıyor:

Son alfa değeri, dokunun alfası × tint alfası × SetOpacity değeri. SetOpacity, Painter’ın sonraki çizimleri için geçerli durumun bir parçası; aynı frame içinde değiştirilebiliyor ve Save/Restore ile saklanıp geri alınabiliyor.
Burada toplu çizim (batching) açısından önemli bir konu var. Tint köşe verisinde taşınıyor; OpenGL’deki genel opaklık ise u_opacity uniform’unda tutuluyor. Aynı doku, karıştırma modu ve opaklıkla arka arkaya yapılan çizimler, tampon kapasitesi elverdiği sürece farklı tint değerleriyle aynı grupta kalabiliyor. Farklı opaklıkla gelen çizim ise önceki grubun gönderilmesini/flushlanmasını gerektiriyor. Yalnızca bir görüntünün saydamlığını değiştirmek için tint’in alfa bileşeni de kullanılabilir.

Izgara Üzerine Çizim ve Doku Güncelleme

İki ek özellikten daha bahsetmek istiyorum.
DrawImageMesh, köşeleri bağımsız hareket eden bir ızgara üzerine doku çizmek için kullanılmasına olanak sağlayan bir API’dir. cols ve rows hücre sayılarını; points ise satır sırasıyla (cols + 1) × (rows + 1) köşeyi veriyor. UV’ler (c / cols, r / rows) olarak türetiliyor. Noktaların konumlarını değiştirerek dalgalanan bayrak gibi bir yüzey gibi efektleri elde edebilirsiniz. Bir örneğini examples/graphics/mesh_warp.cpp dosyası içerisinde görebilirsiniz.

Aynı doku, düz ve deforme edilmiş 12 × 8 hücreli ızgarada. Köşeler hareket ederken UV hesabı aynı kalıyor.

Bir diğer önemli API’de UpdateImage. Bu API de isminden anlaşılabileceği gibi GPU dokusunu güncellemek için kullanabilirsiniz. Plazma, ısı haritası veya piksel tuvali gibi içeriği sık değişen görüntüler için mevcut GPU dokusunu güncelleyebiliyoruz:

Çağrı, Width() × Height() × 4 byte’lık RGBA8 verisi bekliyor. Görüntü henüz yüklenmemişse önce doku oluşturuluyor; sonraki çağrılar aynı dokunun içeriğini değiştiriyor. Burada, ana bellekteki Image::RawData() kopyası güncellenmiyor. Bu yüzden başka bir renderer’a yeniden yükleme, son GPU içeriğini değil başlangıçtaki piksel verisini kullanır.

Güncellemeden önce biriken çizimler gönderilir/flushlanır. Böylece güncellemeden önceki çizimler eski doku içeriğini kullanmaya devam eder (buan yönelik de bir hata vardı 😀 ). Bekleyen geometri varsa mevcut grup sonlandırılır. Elbette, piksel aktarımının maliyeti de ayrıca var, buna yönelik de güzel bir örnek için examples/graphics/plasma.cpp dosyasına göz atabilirsiniz.

Karıştırma Modları

SetBlendMode API’sini, çizilen rengin hedefteki renkle nasıl birleşeceğini belirlemek için kullanabilirsiniz ve yine Save/Restore tarafından tutuluyor. Mevcut dört modun davranışı şöyle; C RGB bileşenlerini, a alfayı gösteriyor:
ModRGB sonucuAlfa sonucu
kAlphaCs × as + Cd × (1 - as)as + ad × (1 - as)
kAdditiveCs × as + Cdas + ad
kMultiplyCs × Cdas × ad
kNoneCsas
Değerleri normalleştirilmiş olarak düşünün; RGBA8 hedefe yazılırken temsil edilebilir aralıkla sınırlanıyor. Varsayılan kAlpha, saydam görüntüler için alışıldık davranışı veriyor. kAdditive parlama ve ışık efektlerinde; kMultiply ise renkleri çarparak koyulaştırmada kullanılabilir. Mevcut kMultiply formülünde kaynak alfa RGB sonucunu ağırlıklandırmıyor. kNone ise kaynak RGBA değerini doğrudan yazar.

Aynı kaynak ve hedefin dört karıştırma modundaki RGB sonucu. Web sayfasında yeniden alfa karışımı oluşmaması için sunum PNG’si opak kaydedildi.

OpenGL’de varsayılan alfa modu için kullanılan çağrı şöyledir:

İlk iki parametre RGB, son ikisi alfa faktörlerini belirliyor. glBlendFuncSeparate ile bu iki hesabı ayrı kurabiliyoruz. Ayrıntılara https://raw.githubusercontent.com/KhronosGroup/OpenGL-Refpages/main/gl4/glBlendFuncSeparate.xml sayfasından ulaşabilirsiniz.
Kütüphanenin Vulkan renderer kullanımında ise, karıştırma ayarları grafik pipeline’ının sabit durumuna yazılıyor. Bu yüzden ilgili pipeline oluşturulurken dört modun varyantları birlikte hazırlanıyor; çizim sırasında uygun olan bağlanıyor.

Demo

Gelelim bu kabiliyetin demosuna. Demo, examples/graphics/images.cpp içinde yer alıyor (eski adı phase3_demo ydu). Görüntü dosyası gerektirmemesi için damalı desen, renk geçişi, kenara doğru saydamlaşan daire ve dört renkli atlas, kod içerisinde prosedürel olarak üretiliyor. Demo; ölçekleme, atlas dilimleme, tint, aynalama ve dönüşümleri bir arada göstermesi açısından faydalı olduğuna inanıyorum.

images.cpp içindeki çizim kodunun 1100 × 640 ekran dışı hedefe alınmış çıktısı. Dönüş açısı 25° olarak sabitlendi; atlas dilimlerinin tamamı görünür.

Damalı desen üreticisini yazıya uyarlayalım:
Örneğin MakeCheckerboard(128, 16) ile 16 piksel genişliğinde hücrelerden oluşan bir görüntü elde ediliyor. CreateFromData veriyi kopyaladığı için yerel pixels vektörünün fonksiyon sonunda uçurulması/silinmesi de sorun oluşturmaz.

Sonuç

Artık dosyadan veya bellekten gelen görüntüleri çizip ölçekleyebiliyor, bir resim atlasının belirli bölgesini seçebiliyor, renk tonuyla, aynalama uygulayabiliyoruz. Yeni artık uygulamalarımızda, SDLPainter ile resimleri de gösterebiliyoruz.
Bir sonraki yazımda, SDL_ttf kullanarak metin çizmeye geçeceğiz. Buradaki atlas ve UV hesabı, karakterlerin aynı doku üzerinden çizilmesini anlamak için de önemli.
Elbette, bu kabiliyet şu an var, hemen aşağıdaki adresten bunu deneyimleyebilirsiniz.

 

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.