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
OpenGLRenderer, IRenderer 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.
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 | bool OpenGLRenderer::Initialize(SDL_Window* window) { mWindow = window; SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); mGLContext = SDL_GL_CreateContext(window); if (mGLContext == nullptr) { spdlog::error("[OpenGLRenderer] Failed to create GL context: {}", SDL_GetError()); return false; } if (gladLoadGLLoader(reinterpret_cast<GLADloadproc>(SDL_GL_GetProcAddress)) == 0) { spdlog::error("[OpenGLRenderer] Failed to load GLAD"); return false; } // Baslangic karistirma durumu tek kaynaktan: SetBlendMode. SetBlendMode(BlendMode::kAlpha); // Shader kaynaklari binary'ye gomulu; calisma zamaninda dosya aranmaz. if (!mBasicShader.Build(detail::kBasicVert, detail::kBasicFrag)) { return false; } // ... SetupBuffers(); return true; } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 | glBindVertexArray(mVao); glBindBuffer(GL_ARRAY_BUFFER, mVbo); // Location 0: pozisyon (2x float) glEnableVertexAttribArray(0); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), reinterpret_cast<void*>(static_cast<uintptr_t>(0))); // Location 1: renk (4x uint8; GL_TRUE ile [0,255] -> [0,1] normalize edilir) glEnableVertexAttribArray(1); glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(Vertex), reinterpret_cast<void*>(static_cast<uintptr_t>(2 * sizeof(float)))); |
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).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | // basic.vert #version 330 core layout(location = 0) in vec2 a_position; layout(location = 1) in vec4 a_color; uniform mat3 u_model; uniform mat4 u_projection; out vec4 v_color; void main() { vec3 transformed = u_model * vec3(a_position, 1.0); gl_Position = u_projection * vec4(transformed.xy, 0.0, 1.0); v_color = a_color; } |
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.
1 2 3 4 5 6 7 8 9 10 11 | // Basit bir çember çizimi için izlenen akış Painter::DrawCircle(cx, cy, radius) | v Tessellator::TessellateFilledCircle(...) -> std::vector<Vertex> | v RenderBatcher (biriktirir, dönüşümü uygular) | v IRenderer::DrawTriangles(vertices) -> OpenGL veya Vulkan |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 | class Tessellator { public: static std::vector<Vertex> TessellateFilledCircle(float cx, float cy, float radius); static std::vector<Vertex> TessellateThickLine(float x1, float y1, float x2, float y2, float line_width, LineCap cap = LineCap::kButt); static std::vector<Vertex> TessellateFilledPolygon(const std::vector<Point>& points); // ... private: static int32_t AdaptiveSegments(float radius); static std::vector<Vertex> EarClipping(const std::vector<Point>& points); }; |
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:
1 2 3 4 5 6 7 8 9 10 11 12 | static int32_t AdaptiveSegments(float radius) { constexpr int32_t kMinSegments = 16; constexpr int32_t kMaxSegments = 512; if (!(radius > 0.0F)) { // NaN ve negatif yaricap korumasi return kMinSegments; } const float kScaled = radius * 0.5F; if (kScaled >= static_cast<float>(kMaxSegments)) { return kMaxSegments; } return std::max(kMinSegments, static_cast<int32_t>(kScaled)); } |
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.
1 2 3 4 5 6 7 8 9 10 | int32_t segments = AdaptiveSegments(radius); result.reserve(static_cast<std::size_t>(segments) * 3); for (int32_t i = 0; i < segments; ++i) { float a0 = kTwoPi * static_cast<float>(i) / static_cast<float>(segments); float a1 = kTwoPi * static_cast<float>(i + 1) / static_cast<float>(segments); result.emplace_back(cx, cy); // merkez result.emplace_back(cx + std::cos(a0) * radius, cy + std::sin(a0) * radius); result.emplace_back(cx + std::cos(a1) * radius, cy + std::sin(a1) * radius); } |
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.
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 | Orijinal çizgi: A -------------------- B p0 --------------------- p2 (+half * n) | | | quad (2 üçgen) | | | p1 --------------------- p3 (-half * n) float dx = x2 - x1; float dy = y2 - y1; float len = std::sqrt(dx * dx + dy * dy); if (len < 1e-6F) { return {}; // dejenere cizgi korumasi } float nx = -dy / len; // cizgi yonune dik, normalize float ny = dx / len; float hw = line_width * 0.5F; std::vector<Vertex> result = { {x1 + hw * nx, y1 + hw * ny}, {x1 - hw * nx, y1 - hw * ny}, {x2 + hw * nx, y2 + hw * ny}, {x1 - hw * nx, y1 - hw * ny}, {x2 - hw * nx, y2 - hw * ny}, {x2 + hw * nx, y2 + hw * ny}, }; if (cap != LineCap::kButt) { // uc stili: sonradan eklendi AppendCap(result, Point{x1, y1}, -dx / len, -dy / len, hw, cap); AppendCap(result, Point{x2, y2}, dx / len, dy / len, hw, cap); } |
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 🙂
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:
- Sarma yönünü shoelace formülüyle bul, gerekiyorsa indeksleri ters çevirerek her zaman CCW üzerinden çalış,
- 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,
- Kulağı üçgen olarak kaydet, indeks listesinden çıkar,
- Üç nokta kalana kadar tekrarla, son üçgeni ekle.
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 30 31 32 33 34 35 36 37 38 39 | while (indices.size() > 3) { bool found_ear = false; std::size_t n = indices.size(); for (std::size_t i = 0; i < n; ++i) { std::size_t iprev = (i + n - 1) % n; std::size_t inext = (i + 1) % n; const Point& a = points[indices[iprev]]; const Point& b = points[indices[i]]; const Point& c = points[indices[inext]]; // CCW poligonda disbukey kose: cross(a->b, b->c) > 0 float cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); if (cross <= 0.0F) { continue; // icbukey kose, kulak degil } bool is_ear = true; for (std::size_t j = 0; j < n; ++j) { if (j == iprev || j == i || j == inext) continue; if (PointInTriangle(points[indices[j]], a, b, c)) { is_ear = false; break; } } if (is_ear) { result.emplace_back(a.x, a.y); result.emplace_back(b.x, b.y); result.emplace_back(c.x, c.y); indices.erase(indices.begin() + static_cast<std::ptrdiff_t>(i)); found_ear = true; break; } } if (!found_ear) { // dejenere girdi — sessizce yarida kesme, uyar spdlog::warn("Tessellator: poligon tam üçgenlenemedi — {} köşe atlandı.", indices.size() - 3); break; } } |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | void RenderBatcher::PushTriangles(const std::vector<Vertex>& vertices, const glm::mat3& transform, const Color& color, float opacity) { if (mCurrentMode != DrawMode::kBasic || // mod degisti !SameOpacity(mCurrentOpacity, opacity) || // opaklik degisti (mVertexBuffer.size() + vertices.size()) > kMaxVertices) { // 8192 limit Flush(); } mCurrentMode = DrawMode::kBasic; mCurrentOpacity = opacity; const Affine2D t(transform); for (auto v : vertices) { const float x = v.x; const float y = v.y; v.x = t.X(x, y); v.y = t.Y(x, y); v.r = color.r; v.g = color.g; v.b = color.b; v.a = color.a; mVertexBuffer.push_back(v); } } |
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: Translate, Rotate, Scale, Save, Restore.
Ş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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | painter.Begin(); painter.Clear(sdl_painter::Color{25, 25, 35, 255}); painter.SetPen(sdl_painter::Pen(sdl_painter::Color{255, 220, 50, 255}, 3.0f)); painter.DrawRect(430.0f, 70.0f, 180.0f, 100.0f); // Konkav poligon — ear clipping'in isi painter.SetBrush(sdl_painter::Brush(sdl_painter::Color{200, 180, 50, 210})); painter.FillPolygon({{600, 350}, {750, 350}, {750, 420}, {680, 420}, {680, 480}, {600, 480}}); // Transform: dondurulmus dikdortgen painter.Save(); painter.Translate(150.0f, 580.0f); painter.Rotate(30.0f); painter.SetBrush(sdl_painter::Brush(sdl_painter::Color{80, 180, 255, 180})); painter.FillRect(-60.0f, -35.0f, 120.0f, 70.0f); painter.Restore(); painter.End(); |
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):
glLineWidthkullanı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.
