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.Neden stb_image?
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:1 2 | #define STB_IMAGE_IMPLEMENTATION #include <stb_image.h> |
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
1 2 3 4 | sdl_painter::Image image("assets/resim.png"); if (!image.IsValid()) { // Yükleme başarısız } |
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.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.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:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | class Texture { public: explicit Texture(IRenderer* renderer = nullptr) : mRenderer(renderer) {} Texture(IRenderer* renderer, TextureHandle handle) : mRenderer(renderer), mHandle(handle) {} ~Texture() { Reset(); } Texture(const Texture&) = delete; Texture& operator=(const Texture&) = delete; Texture(Texture&& other) noexcept; Texture& operator=(Texture&& other) noexcept; void Reset() { if (mRenderer != nullptr && mHandle != kInvalidTexture) { mRenderer->DestroyTexture(mHandle); } mHandle = kInvalidTexture; mRenderer = nullptr; } TextureHandle Handle() const noexcept { return mHandle; } IRenderer* Owner() const noexcept { return mRenderer; } bool IsValid() const noexcept { return mHandle != kInvalidTexture; } private: IRenderer* mRenderer{nullptr}; TextureHandle mHandle{kInvalidTexture}; }; |
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:1 2 3 4 5 6 7 8 9 10 | { sdl_painter::Painter painter(window, sdl_painter::RendererBackend::kOpenGL); sdl_painter::Image image("assets/resim.png"); if (painter.IsValid() && image.IsValid()) { painter.Begin(); painter.DrawImage(image, 50.0f, 80.0f); painter.End(); } } // Önce image, ardından painter yok edilir. |
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:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | TextureHandle Image::Upload(IRenderer& renderer) const { if (!IsValid()) { return kInvalidTexture; } if (mHandle.IsValid() && mHandle.Owner() != &renderer) { mHandle.Reset(); } if (!mHandle.IsValid()) { mHandle = Texture( &renderer, renderer.CreateTexture(mRawData.get(), mWidth, mHeight, mChannels, mFilter)); } return mHandle.Handle(); } |
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.1 2 | sdl_painter::Image sprite("assets/karakter.png"); sprite.SetFilter(sdl_painter::TextureFilter::kNearest); |
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.
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ği | OpenGL formatı | Shader’ın gördüğü RGBA |
|---|---|---|---|
| 1 | Gri | GL_RED | (gri, gri, gri, 1) |
| 2 | Gri ve alfa | GL_RG | (gri, gri, gri, alfa) |
| 3 | RGB | GL_RGB | (r, g, b, 1) |
| 4 | RGBA | GL_RGBA | (r, g, b, a) |
1 2 3 | const std::array<GLint, 4> swizzle = { GL_RED, GL_RED, GL_RED, GL_GREEN}; glTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_RGBA, swizzle.data()); |
CreateTexture içinden, doku bağlandıktan ve kanal formatı seçildikten sonra çalışan ilgili bölüm:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | GLint prev_alignment = 4; glGetIntegerv(GL_UNPACK_ALIGNMENT, &prev_alignment); glPixelStorei(GL_UNPACK_ALIGNMENT, 1); const GLint gl_filter = (filter == TextureFilter::kNearest) ? GL_NEAREST : GL_LINEAR; glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, gl_filter); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, gl_filter); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); // 1 ve 2 kanallı dokuların swizzle ayarı da yüklemeden önce yapılır. glTexImage2D(GL_TEXTURE_2D, 0, static_cast<GLint>(format), width, height, 0, format, GL_UNSIGNED_BYTE, data); glPixelStorei(GL_UNPACK_ALIGNMENT, prev_alignment); glBindTexture(GL_TEXTURE_2D, 0); |
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:1 2 3 4 5 6 | using namespace sdl_painter; painter.DrawImage(image, 50.0f, 80.0f); // Özgün boyutu ile verilen konuma (sol üst köşe) göre çizer painter.DrawImage(image, Rect{210, 80, 180, 180}); // Hedefe verilen rect'e göre ölçekleyerek çizer painter.DrawImage(atlas, Rect{0, 0, 128, 128}, // Kaynak dilim Rect{420, 80, 100, 100}); // Hedef dikdörtgen |
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:
1 2 3 4 | u0 = src.x / image.Width() v0 = src.y / image.Height() u1 = (src.x + src.w) / image.Width() v1 = (src.y + src.h) / image.Height() |
Ö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.
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:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | void Painter::PushTexturedQuad(TextureHandle texture, float u0, float v0, float u1, float v1, const Rect& dest_rect, const Color& tint, ImageFlip flip) { if (flip == ImageFlip::kHorizontal || flip == ImageFlip::kBoth) { std::swap(u0, u1); } if (flip == ImageFlip::kVertical || flip == ImageFlip::kBoth) { std::swap(v0, v1); } const auto vertices = Tessellator::TessellateTexturedRect( dest_rect.x, dest_rect.y, dest_rect.w, dest_rect.h, u0, v0, u1, v1); mBatcher->SetBlendMode(mCurrentState.blend_mode); mBatcher->PushTexturedTriangles(vertices, mCurrentState.transform, texture, tint, mCurrentState.opacity); } |
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
DrawImage çağrısında isteğe bağlı olarak bir renk tonu (*tint*) ve aynalama seçeneği de verilebiliyor:1 2 | painter.DrawImage(image, Rect{50, 250, 128, 128}, Color{255, 100, 100, 255}, ImageFlip::kHorizontal); |
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.
Üstte beyaz sprite farklı tint değerleriyle çiziliyor. Altta asimetrik görüntü yatay, dikey ve iki eksende aynalanıyor.
1 2 3 4 5 6 7 8 9 10 11 12 | #version 330 core in vec2 v_tex_coord; in vec4 v_color; out vec4 frag_color; uniform sampler2D u_texture; uniform float u_opacity; void main() { vec4 tex_color = texture(u_texture, v_tex_coord); frag_color = tex_color * v_color * vec4(1.0, 1.0, 1.0, u_opacity); } |
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.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
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.
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:1 2 | // image 4 kanallı olmalı; rgba tam görüntü boyutunda veri içermeli. painter.UpdateImage(image, rgba.data()); |
Ç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.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:| Mod | RGB sonucu | Alfa sonucu |
|---|---|---|
kAlpha | Cs × as + Cd × (1 - as) | as + ad × (1 - as) |
kAdditive | Cs × as + Cd | as + ad |
kMultiply | Cs × Cd | as × ad |
kNone | Cs | as |
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:
1 2 3 | glEnable(GL_BLEND); glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); |
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.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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #include <sdl_painter/image.h> #include <cstddef> #include <cstdint> #include <vector> static sdl_painter::Image MakeCheckerboard(int32_t size, int32_t cell) { if (size <= 0 || size > 32767 || cell <= 0) { return {}; } const auto side = static_cast<std::size_t>(size); std::vector<uint8_t> pixels(side * side * 4); for (int32_t y = 0; y < size; ++y) { for (int32_t x = 0; x < size; ++x) { const bool light = ((x / cell) + (y / cell)) % 2 == 0; const auto idx = (static_cast<std::size_t>(y) * side + static_cast<std::size_t>(x)) * 4; pixels[idx + 0] = light ? 220 : 40; pixels[idx + 1] = light ? 220 : 60; pixels[idx + 2] = light ? 220 : 180; pixels[idx + 3] = 255; } } return sdl_painter::Image::CreateFromData(pixels.data(), size, size, 4); } |
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ç
SDL_ttf kullanarak metin çizmeye geçeceğiz. Buradaki atlas ve UV hesabı, karakterlerin aynı doku üzerinden çizilmesini anlamak için de önemli.
