Canvas ve WebGL Projelerinde Görsel Bellek Yönetimi

Turuncu ışıklı bellek hücreleri ve dağılmış kareler

Canvas veya WebGL kullanan bir web uygulaması geliştirirken görsel yüklemek başlangıçta basit görünür: bir Image nesnesi oluşturursunuz, src atarsınız, yükleme tamamlanınca çizim yaparsınız. Ne var ki bu sürecin perde arkasında birden fazla bellek katmanı devreye girer ve her katman ayrı bir temizlik sorumluluğu getirir. Uygulamanız dakikalar içinde yavaşlamaya başlıyorsa ya da uzun süreli çalıştıktan sonra çöküyorsa, sorun büyük olasılıkla bu katmanlardan birinin hiç boşaltılmamasındadır.

Tarayıcı bellek yönetimi, normal DOM uygulamalarında çoğu zaman otomatik olarak çalışır: bir elemanı DOM'dan kaldırırsınız, referanslar sona erer, çöp toplayıcı gerisini halleder. Canvas ve WebGL ile bu varsayım geçersizleşir. GPU belleğine yüklenen dokular (texture), JavaScript tarafındaki referans sona erse bile GPU'da kalmaya devam eder. Çöp toplayıcı CPU tarafındaki nesneleri temizler; GPU kaynaklarına dokunamaz.

Bellek sızıntısı (memory leak) kavramı, bu iki katmanın -JavaScript heap ve GPU belleği- birbirinden bağımsız temizlik gerektirdiği fikrinden doğar. İkisini birbirinden ayırt etmeden yazılan kod kısa demolarda mükemmel çalışır; saatler içinde gerçek bir kullanım senaryosuna sokulduğunda ise sistemi yorar.

Canvas'ta görsel yükleme belleği nasıl etkiler

CanvasRenderingContext2D ile çalışırken drawImage() çağrısı bir görseli canvas piksel tamponuna (pixel buffer) kopyalar. Kaynak görselin kendisi -bir HTMLImageElement olsun ya da bir ImageBitmap- bu kopyadan sonra hâlâ bellektedir. Kaynak nesneye olan referansı kaybetmezseniz, nesne bellekte tutulmaya devam eder. Referansı kaybederseniz çöp toplayıcı nihayetinde temizler; ancak "nihayetinde" ifadesi belirsizdir ve baskı altındaki bir tarayıcıda bu süre uzayabilir.

HTMLImageElement ile ImageBitmap arasındaki fark pratikte önemlidir. HTMLImageElement, çözümleme (decode) işlemini çizim anında yapabilir ya da erteleyebilir; bu, birden fazla görselin aynı anda çizildiği senaryolarda gizli gecikmeler ve bellek artışı yaratır. createImageBitmap() ise çözümlemeyi önceden tamamlar ve bir ImageBitmap nesnesi döndürür. Önemli olan şudur: ImageBitmap kaynağını tükettiğinizde close() metodunu çağırmanız gerekir. Bunu yapmadan referansı kaybetmek, nesnenin ne zaman temizleneceğini belirsizleştirir.

Bir animasyon döngüsü içinde her kare için yeni bir HTMLImageElement oluşturmak klasik bir hatadır. Kare hızı 60 fps olduğunda saniyede 60 nesne üretilir; bunların serbest bırakılması çöp toplayıcıya kalır ve GC duraklamaları (GC pauses) animasyonu takla atar hâle getirir. Görseli döngünün dışında bir kez yükleyip referansı saklamak, hem bellek hem performans açısından doğru yaklaşımdır.

WebGL texture nesnelerinin yaşam döngüsü

WebGL'de bir görsel GPU'ya gl.texImage2D() çağrısıyla yüklenir. Bu çağrı öncesinde gl.createTexture() ile bir texture nesnesi oluşturulur; GPU bu nesne için bellek ayırır. JavaScript tarafında elinizde bu nesneye bir referans bulunur, ancak asıl bellek GPU tarafındadır. JavaScript referansını null yaparsanız veya kapsam dışına bırakırsanız, GPU belleği serbest kalmaz. Tarayıcının çöp toplayıcısı WebGL kaynaklarını takip etmez.

Texture'ı GPU'dan kaldırmanın tek yolu gl.deleteTexture(texture) çağrısıdır. Bu çağrı yapılmadan, aynı sahneye tekrar tekrar texture yüklerseniz GPU belleği her yüklemede büyür. Bir ürün galerisinde her görsel değişiminde yeni texture oluşturup eskiyi silmeden bırakmak, uzun bir oturumun sonunda GPU belleğini dolduracak kadar birikim yaratabilir.

Mipmap üretimi de ek bellek anlamına gelir. gl.generateMipmap() çağrısı, orijinal görselin yaklaşık üçte biri kadar ek GPU belleği kullanır; mipmap piramidinin bu maliyeti sabit bir özelliğidir. Mipmaplara ihtiyaç duymayan 2D arayüzler için bu çağrıyı yapmamak, doğrudan bellek tasarrufu sağlar. Filtreleme parametrelerini gl.NEAREST veya gl.LINEAR olarak bırakmak yeterlidir.

Bellek sızıntısı nasıl oluşur: tekrarlayan kalıplar

Sızıntının en yaygın nedeni, yaşam döngüsü düşünülmeden yazılmış kaynak oluşturma kodudur. Bir React bileşeni her render'da gl.createTexture() çağırıyor, ancak temizlik fonksiyonu (cleanup) yazmıyor olabilir. Bileşen DOM'dan kaldırılıp yeniden eklendiğinde GPU'da eski texture'lar birikir.

Event listener'ların görsel nesnelere referans tutması da bir sızıntı kaynağıdır. Bir HTMLImageElement'in load olayını dinleyen ve bu olayda texture oluşturan bir fonksiyon düşünün: fonksiyon kapanımı (closure) aracılığıyla WebGL context'e ve canvas'a referans tutuyorsa, her yeni görsel yüklenişinde bu referans zinciri büyür. Listener'ı kaldırmadan (removeEventListener) görsel nesnesini değiştirmeye devam etmek, zincirin kesilmemesi anlamına gelir.

Hatalı context paylaşımı da sık görülen bir sorundur. Birden fazla canvas öğesi arasında kaynak paylaşımı WebGL'de doğrudan mümkün değildir; her context kendi izole GPU alanında çalışır. Paylaşım gibi görünen senaryolarda aslında kaynak kopyalanır ve iki tarafın da bağımsız olarak temizlenmesi gerekir. Bunu fark etmeden yazan bir geliştirici, bir tarafı temizlerken diğerini unutabilir.

Sızıntıyı ilk fark ettiren belirti genellikle performans düşüşüdür. GPU belleği dolmaya başladığında sürücü sıkıştırma yapar ya da bazı kaynakları ana belleğe taşır; bu durum render gecikmesi olarak görünür. Daha az sık rastlanan ama daha belirgin belirti, tarayıcı sekmesinin çökmesidir.

Canvas context ve ilişkili kaynakların temizlenmesi

Canvas elemanını DOM'dan kaldırmak yeterli değildir. Canvas'ın ilişkili context'i -CanvasRenderingContext2D ya da WebGLRenderingContext- JavaScript tarafında tuttuğunuz referanslar aracılığıyla yaşamaya devam eder. Referans zincirini kesmediğiniz sürece canvas ve context bellekte kalır.

2D context için özel bir "yok etme" API'si yoktur; referansları serbest bırakmak ve görsel kaynakları kapatmak (ImageBitmap.close()) yeterlidir. Kullandığınız ImageBitmap nesnelerini bir listede izleyip işiniz bittiğinde hepsini close() ile kapatmak, temizlik disiplininin temel adımıdır.

OffscreenCanvas kullandığınızda Worker ile iletişim kuran mesaj kanalları da kaynak tutar. Worker'ı sonlandırmadan (worker.terminate()) OffscreenCanvas'ı kaldırmak, Worker'ın bellekte asılı kalmasına yol açar. Worker içindeki context ve kaynaklar Worker kapanana kadar serbest kalmaz.

WebGL'de doğru temizleme sırası

WebGL kaynaklarının temizlenmesinde sıra önemlidir: önce bağlı kaynaklar (buffer, texture, framebuffer, renderbuffer), sonra program ve shader'lar. Her create* çağrısına karşılık bir delete* çağrısı yazma disiplini, kaynak sızıntısını kaynağında keser.

gl.deleteTexture(texture);
gl.deleteBuffer(buffer);
gl.deleteFramebuffer(framebuffer);
gl.deleteRenderbuffer(renderbuffer);
gl.deleteProgram(program);
gl.deleteShader(shader);

Bu çağrıları yapmadan sadece JavaScript referanslarını serbest bırakmak, GPU kaynaklarını serbest bırakmaz. Silme çağrılarını bileşen kaldırma, sahne değişimi veya uygulama kapatma anlarına bağlamak, bu sorumluluğu unutma riskini azaltır.

Context'i tamamen kapatmak istediğinizde WEBGL_lose_context eklentisini kullanabilirsiniz:

const ext = gl.getExtension('WEBGL_lose_context');
if (ext) ext.loseContext();

Bu çağrı tüm GPU kaynaklarını serbest bırakır ve context'i geçersiz kılar. Canvas'ı tamamen kaldırmadan önce bu yöntemi uygulamak, GPU temizliğini garantilemenin en kesin yoludur. Ancak bu noktadan sonra context üzerinden herhangi bir işlem yapılamaz; context'i yeniden kazanmak (restoreContext()) mümkündür fakat tipik temizlik senaryolarında kullanılmaz.

Texture atlası ve önbellek stratejileri

Her görsel için ayrı texture oluşturmak, çok sayıda görsel içeren uygulamalarda hem bellek hem draw call sayısı açısından maliyetli olur. Texture atlası, birden fazla görseli tek bir büyük texture'a paketler; böylece tek bir gl.bindTexture() bağlamasıyla tüm görsel kümesine erişilir.

Atlas ne zaman mantıklıdır? Sabit bir görsel kümesiyle çalışıyorsanız, görseller sık değişmiyorsa ve GPU belleğini daha verimli kullanmak istiyorsanız atlas anlamlıdır. Görseller dinamik olarak geliyorsa, kullanıcı her oturumda farklı içerik yüklüyorsa, atlas paketleme maliyeti faydayı geçebilir. Her uygulamaya atlas uydurmak gereksiz karmaşıklık yaratır; kararı görsel kümesinin değişim sıklığına göre vermek gerekir.

LRU (Least Recently Used) tabanlı bir texture havuzu, dinamik görsel senaryolarında iyi bir alternatiftir. Sabit boyutlu bir havuz tutarsınız; yeni bir texture ekleyeceğinizde en uzun süredir kullanılmayanı siler (gl.deleteTexture()) yerine yenisini oluşturursunuz. Havuz büyüklüğünü GPU bellek bütçesine göre belirlemek, sızıntı yerine kontrollü bellek kullanımı sağlar. Bütçeyi belirlemek için kesin bir kural yoktur; uygulamanın hedeflediği cihaz profiline bakarak karar verilir.

Profil araçlarıyla sızıntı tespiti

Bellek sızıntısını gözlemlemek için Chrome DevTools'un Memory sekmesi başlangıç noktasıdır. Heap snapshot alarak JavaScript nesneleri incelenebilir; ancak bu yöntem GPU belleğini göstermez. GPU bellek kullanımını izlemek için tarayıcının task manager'ında GPU bellek bilgisi takip edilebilir.

Daha sistematik bir yaklaşım için bir test senaryosu kurun: uygulamanın yükle-kaldır döngüsünü otomatik tekrarlayan bir script yazın ve her döngü sonrası heap boyutunu kaydedin. Boyut monoton artıyorsa sızıntı vardır. Sabit kalıyorsa ya da belirli bir değere oturuyorsa, çöp toplayıcı işini yapıyor demektir.

WebGL için GPU belleğini doğrudan izleyen standart bir tarayıcı API'si henüz yoktur. GPU sızıntısı, dolaylı göstergelerden -render süresinin kademeli bozulması, tarayıcı GPU process'inin bellek artışı- tespit edilir. Texture oluşturma ve silme çağrılarını sayan basit bir sarmalayıcı (wrapper) yazmak ise en pratik yöntemdir: her createTexture'ı bir sayaç artıran, her deleteTexture'ı azaltan bir yapı, aktif texture sayısını anlık olarak görünür kılar. Sayaç sıfıra inmiyorsa, açıkta kalan kaynaklar vardır.

Canvas ve WebGL projelerinde bellek yönetimi, API'lerin tasarımından kaynaklanan bir sorumluluk devridir: tarayıcı normal DOM için üstlendiği temizlik görevini burada geliştiriciye bırakır. Bunu bir kısıtlama olarak görmek yerine, kaynak ömrü üzerinde tam kontrol sahibi olma fırsatı olarak okumak mümkündür. GPU belleğinin ne zaman ayrıldığını ve ne zaman serbest bırakıldığını siz belirlersiniz.

Büyük projelerde bu disiplin, kod tabanına yayılmış rastgele temizlik çağrıları yerine merkezi bir kaynak yöneticisiyle sağlanır. Oluşturulan her texture ve buffer'ın bir yöneticiye kaydedilmesi, sahne değişiminde ya da bileşen kaldırılmasında yöneticinin tüm kaynakları serbest bırakması, bireysel çağrılardaki unutma riskini ortadan kaldırır. Küçük projeler için bu yapı abartılı olabilir; bir animasyon demosunda iki texture'ı elle silmek yeterlidir.

Hangi ölçekte olursa olsun, temel kural aynıdır: GPU'ya giden her kaynak, çıkışı planlanmış bir kaynaktır. Oluşturmak ile silmek arasındaki simetriye özen göstermek, performans ve kararlılık sorunlarının büyük bölümünü baştan engeller.