Masaüstünde Bir Mercan Resifi: Aquarium Duvar Kâğıdı Nasıl Yapıldı?
macOS ve Windows'ta masaüstü simgelerinin arkasında yaşayan gerçek zamanlı bir 3B resif: vertex shader'da yüzen balıklar, Rust ile yazılmış WebAssembly simülasyon çekirdeği, doğan, büyüyen ve yaşlanan bir ekosistem, masaüstünden dosya silince gelen yem ve Ed25519 lisans biletleri.

Masaüstü duvar kâğıdı çoğu zaman bir fotoğraftır; arada bir de döngüye alınmış bir video. Aquarium bunun yerine gerçek zamanlı çalışan, yaşayan bir mercan resifi: balıkların yüzme hareketi her karede hesaplanıyor, aç kalınca yem arıyorlar, büyüyorlar, yaşlanıyorlar, ürüyorlar ve ölüyorlar. Uygulamanın bir penceresi yok, çünkü kendisi masaüstü. macOS'ta ve Windows'ta masaüstü simgelerinin hemen arkasında çalışıyor.
Bu yazıda projeyi katman katman anlatacağım: balıkların nasıl yüzdüğünü, Rust ile yazılmış simülasyon çekirdeğini, bir pencereyi masaüstü simgelerinin arkasına yerleştirmenin iki platformdaki yolunu ve masaüstünden bir dosyayı silince balıkların neden oraya üşüştüğünü.

Web sürümünü tarayıcıda hemen deneyebilirsiniz: deskreef.kartex.net. Fareyi oynatınca balıklar ürküp kaçıyor. Yazıdaki bütün ekran görüntüleri bu sayfadan, çalışan sahneden alındı.
Sahnede neler var?
Resifte altı tür balık yaşıyor: melek balığı, mavi cerrah (blue tang), palyaço balığı, diskus, lepistes (guppy) ve neon tetra. Bunların yanında kayaların arasında yaşayan "sakinler" var: bir murana, denizatları ve kumun üstünde süzülen bir pisi balığı. Palyaço balıkları kendi anemonlarına dönüyor, neon tetralar sürü hâlinde yüzüyor.
Çevre büyük ölçüde çalışma anında, prosedürel olarak üretiliyor: kum, kayalar, bitkiler, anemon ve shader'lar. Balık modelleri CC0 lisanslı bir paketten, mercanlar Google'ın WebGL Aquarium örneklerinden ve bir Smithsonian 3B taramasından, arka plan fotoğrafları NOAA ve USFWS resif fotoğraflarından geliyor. Altı arka plan arasında seçim yapılabiliyor:

Işık tarafında suyun altına özgü efektler var: kumun üstünde dans eden kostikler, yüzeyden inen ışık hüzmeleri (god rays), gerçek bir hava kabarcığı gibi ışığı kıran kabarcıklar ve dalgalanan bitkiler.
Balıklar nasıl yüzüyor?
Balık modelleri hareketsiz, iskeleti olmayan 3B ağlar (mesh). Yüzme hareketinin tamamı vertex shader'da üretiliyor. Gövde boyunca ilerleyen bir dalga kuyruğu sallıyor, dönüşlerde gövdenin arka kısmı baş etrafında bükülüyor, sırt ve anal yüzgeçleri dalgalanıyor, göğüs yüzgeçleri çırpıyor. Bunların her biri türe göre ayarlanabiliyor: kuyruk frekansı ve genliği, dalga sayısı, başın ne kadar sert olduğu, boynun nereden büküldüğü.
Asıl zor kısım, yüzgeçleri gövdeden ayırmak. Model bir bütün; hangi köşenin yüzgece ait olduğunu söyleyen bir bilgi yok. Bunun için küçük bir Python aracı (tools/finmask.py) her köşeden ağın içine ışın atarak kalınlığı ölçüyor. İnce yüzeyler yüzgeç kabul ediliyor ve her köşe için 4 baytlık bir .fin dosyası yazılıyor. Shader bu ağırlıkları kullanarak yüzgeçleri gövdeden bağımsız hareket ettiriyor:
attribute vec4 aFin; // median-fin weight, median edge, pectoral weight, pectoral edge
// swimming wave
float env = uEnv.x + (1.0 - uEnv.x) * pow(tW, uEnv.y);
float ampT = uSwim.y * env;
float ph = uSwim.x - uSwim.w * tW;
float dz = ampT * sin(ph);
Dalganın genliği bir zarf (env) ile burundan kuyruğa doğru büyüyor; faz da gövde boyunca ilerliyor (uSwim.w * tW). Böylece balık bir kırbaç gibi değil, gerçek bir balık gibi, hareketin büyük kısmını arka yarısıyla yapıyor. Shader ayrıca dalganın gövdeyi uzatmaması için yay uzunluğu düzeltmesi yapıyor ve sert başlı türlerde (diskus gibi) dalgayı baş eklemi hizasında durduruyor: baş tek parça hâlinde, kuyruğa karşı hafifçe sallanıyor. Betta'nın yüzgeçleri ise kumaş gibi ataletle akıyor.
Beyin: Rust ile yazılmış simülasyon çekirdeği
Balıkların ne yaptığı sayfanın JavaScript'inde değil, core/ altındaki Rust kodunda: yönlendirme, beslenme, solucan avı, büyüme, yaşlanma, doğumlar, sürüler; yem pulları ve solucanlar; denizatlarının dolaşması ve pisi balığının süzülüşü. Çekirdek wasm32-unknown-unknown hedefine derleniyor ve 140–195 KB'lık bir WebAssembly modülü olarak sayfaya gömülüyor. Rust araç zinciri olmadan da npm run build çalışıyor, çünkü derlenmiş modül depoda.

Sayfa yalnızca çiziyor. fish.js her balık için malzemeleri ve shader değişkenlerini tutuyor ve her karede çekirdekteki o balığın kaydını, modülün belleği üzerine açılmış bir Float32Array ile okuyor. Arada kopyalama ya da serileştirme yok. Dünya, sayfanın okuduğu düzende kelime kelime yerleştirilmiş, sınırları denetlenen bloklardan oluşuyor; tek unsafe satır, host adını okuyan dış çağrı.
Yönlendirme klasik "steering behaviours" fikrine dayanıyor: her balık için birkaç kuvvet hesaplanıp toplanıyor. Ayrışma (separation) adımı, balıkların birbirine çarpmasını önlüyor, sürü balıklarını birbirine yaklaştırıp aynı yöne çeviriyor ve önünden hızla geçen bir balığın komşularını ürkütüyor:
pub fn steer_separate(w: &mut World, i: i64) {
let s = w.sb_of(i);
let school = f2b(w.sg(s, s_school()));
let pos = w.fgv(i, f_pos());
let size = w.fg(i, f_size());
// ...
for j in 0..capacity() {
// ...
let v = vsub(pos, fpos);
let d = vlen(v);
let min_d = (size + fsize) * (if school && same { 0.6 } else { 1.0 });
if d < min_d && d > 0.0001 && !feeding {
let q = min_d / max(d, min_d * 0.2);
steer_add(w, i, vscale(v, 1.0 / d), q * q * (if chasing { 0.15 } else { 0.6 }));
}
// a fish dashing past startles the ones it comes close to:
// they shy away a little for half a second
// ...
}
if n_coh > 0 {
let c = vsub(vscale(coh, 1.0 / to_float(n_coh)), pos);
let dc = if vlen(c) > 0.0 { vlen(c) } else { 1.0 };
steer_add(w, i, vscale(c, 1.0 / dc), min(dc / (size * 3.0), 1.0) * 0.8);
steer_add(w, i, vnorm(ali), 1.0);
}
}
Bunun yanında sınırlarda kalma, kayalardan kaçınma, ortanın dolmasını engelleme, yeme yönelme, solucan avı ve fareden kaçma adımları var. Bir ilginç ayrıntı: çekirdek önce Rill ile yazılmıştı ve satır satır Rust'a taşındı. İki sürümün aynı davranması için ?trace parametresi çekirdeğe yapılan her çağrıyı kaydediyor; tools/core-diff.mjs bir kaydı iki farklı derlemeye karşı yeniden oynatıp sayfanın okuduğu herhangi bir değer ilk farklılaştığında duruyor. Kum yüksekliği de iki tarafta aynı Perlin gürültüsünden hesaplanıyor, böylece çekirdeğin "zemin" sandığı yer ile çizilen zemin birebir örtüşüyor.
Bir ekosistem: doğum, büyüme, yaşlılık
Akvaryum zamanla kendi kendine değişiyor. Yavru balıklar yedikçe büyüyor; uzun süre aç kalan yavru ölüyor. Yetişkinler yaşlanıyor ve ömürleri dolunca ölüyor, ama bir türün nüfusu başlangıç sayısının altına düşmüyor. Doğum için balığın yeterince yemiş olması gerekiyor; türlerin doğurganlığı ve iştahı farklı. Lepistesler canlı doğuruyor, her doğumda 2–4 yavru. Mavi cerrah beslenmelerin çoğunu kaçırıyor, melek balığı yalnızca dörtte birine katılıyor.
Bilgi panelini açınca her türün toplam, yetişkin, yaşlı ve yavru sayısını, doğum olasılığını ve tek tek balıkların ne kadar tok olduğunu canlı görüyorsunuz:

Yaşam döngüsünün özü life_tick fonksiyonunda. Yavru büyümesi tokluğa bağlı: balık ancak yediği kadar büyüyebiliyor. Ölüm de bir anda değil, zamanla geliyor; ölmekte olan balığın hızı yavaş yavaş düşüyor:
pub fn life_tick(w: &mut World, i: i64, dt: f64) {
let s = w.sb_of(i);
let life = w.wf(w_life());
let age = w.fg(i, f_age());
if age < 1.0 {
let fed = min(1.0, w.fg(i, f_fed()) + dt * life * 0.00025);
w.fput(i, f_fed(), fed);
if fed > age {
let age2 = min(fed, age + dt * life / w.sg(s, s_grow_time()));
w.fput(i, f_age(), age2);
// ...
}
}
// ...
}
Menüden ekosistemin hızını yarıya indirebilir ya da 10 katına çıkarabilir, bir türe elle doğum yaptırabilir ya da bir balığı öldürebilirsiniz. Kalite (düşük/orta/yüksek), kare hızı (24/30/60), arka plan, ses ve FPS göstergesi de buradan ayarlanıyor:

Masaüstünden besleme
En sevdiğim özellik bu: masaüstünüzden bir dosyayı çöpe attığınızda (sürükleyip bırakarak ya da ⌘⌫ ile), balıklar o simgenin bulunduğu yere koşuyor. Yem pulları oradan batıyor, balıklar bir süre üşüşüp yiyor, sonra dağılıyorlar.

Bunun için ekstra bir izin gerekmiyor. macOS uygulaması ~/Desktop klasörünü bir DispatchSource ile izliyor. Bir öğe kaybolduğunda, son tıklamanın konumunu simgenin yeri kabul ediyor ve o noktanın gerçekten masaüstü olduğunu, üstünde başka bir pencere bulunmadığını pencere listesinden doğruluyor. Ardından web görünümüne "şuraya yem at" diyor. Windows sürümü aynı şeyi kendi DesktopWatcher'ıyla yapıyor.
Pencereyi masaüstünün arkasına koymak
Uygulama her ekranda bir web görünümü çiziyor ve bu görünümü masaüstü simgelerinin arkasına yerleştiriyor. İki platformda bunun yolu çok farklı.
macOS'ta bu oldukça temiz: çerçevesiz bir NSWindow, masaüstü pencere seviyesine yerleştiriliyor, tüm Spaces'lerde görünüyor ve fare olaylarını yok sayıyor:
window = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false, screen: screen)
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)))
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.isOpaque = true
window.hasShadow = false
window.ignoresMouseEvents = true
Uygulama ekran uyuduğunda ya da kilitlendiğinde ve pencere tamamen kapandığında (occlusionState) kendiliğinden duraklıyor; kimsenin görmediği bir akvaryum için işlemci harcamıyor. Menü çubuğundaki 🐠 simgesinden duraklatma, kalite, kare hızı, oturum açılışında başlatma, yeniden yükleme ve çıkış seçilebiliyor.
Windows'ta resmî bir yol yok. "Wallpaper Engine" tarzı uygulamaların kullandığı klasik numara kullanılıyor: Progman penceresine belgelenmemiş 0x052C mesajı gönderilip bir WorkerW oluşturması isteniyor, sonra simge görünümünün (SHELLDLL_DefView) arkasındaki WorkerW bulunup pencere ona bağlanıyor. Bu WorkerW'nun yeri Windows sürümüne göre değişiyor; kod her iki bilinen düzeni de ele alıyor:
public static IntPtr FindWorkerW()
{
IntPtr progman = FindWindow("Progman", null);
if (progman != IntPtr.Zero)
{
SendMessageTimeout(progman, 0x052C, IntPtr.Zero, IntPtr.Zero, 0, 1000, out _);
}
// Windows 11 (observed on build 26200): Progman hosts the spawned WorkerW as its own
// child, below SHELLDLL_DefView in z-order.
IntPtr child = FindWindowEx(progman, IntPtr.Zero, "WorkerW", null);
if (child != IntPtr.Zero) return child;
// Windows 10 and earlier: the WorkerW is a top-level window sitting just behind the one
// that hosts the icon view.
// ...
}
Windows ana uygulaması .NET 8 ve WebView2 ile yazılmış ve aynı web/ paketini kullanıyor. .NET çalışma zamanı exe'nin içinde, kurulum yönetici izni istemiyor. WebView2 Runtime Windows 11'de ve güncel Windows 10'da zaten yüklü.
Web sürümü ve lisans biletleri
Web sürümünün kendine özgü bir sorunu var: sayfa ve WebAssembly modülü herkese açık, kopyalanıp başka bir yerde yayımlanabilir. Bunun önüne geçmek için çekirdek kendini kendisi lisanslamıyor. Sayfa beş dakikada bir sunucudan bir bilet alıp çekirdeğe veriyor. Bilet host|issued|expires|Ed25519 imzası biçiminde. Çekirdek bileti, kendi okuduğu host adına göre doğruluyor: imza geçerli mi, bilet bir öncekinden yeni mi, süresi dolmamış mı, çalışma süresinin her 25 dakikasında en az bir tane gelmiş mi.
Çekirdek yalnızca açık anahtarı taşıdığı için bilet sahtelenemiyor. Uç nokta CORS başlığı göndermiyor ve istemci başına hız sınırlı; sitenin başka bir yerdeki kopyası bilet alamıyor ve 150 saniye sonra sönümleniyor: bütün balıklar hastalanıp batıyor. İmza anahtarı kaynak kodda değil, yalnızca makinede ve sunucuda duruyor ve her dağıtımda yenisi üretiliyor. Masaüstü uygulamalarındaki çekirdek ise kendi kaynaklarını çevrimdışı lisanslıyor.

Sayılarla proje
| Rust (simülasyon çekirdeği) | ~5.300 satır |
| JavaScript (görüntü, shader'lar, menü) | ~3.400 satır |
| C# (Windows ana uygulaması) | ~1.200 satır |
| Swift (macOS ana uygulaması) | ~570 satır |
| Python (yüzgeç maskesi ve model araçları) | ~500 satır |
| Commit | 173 |
| WebAssembly çekirdeği | 140–195 KB |
| Türler | 6 balık türü + murana, denizatı, pisi balığı |
macOS sürümü tek bir evrensel ikili olarak hem Intel hem Apple Silicon'da çalışıyor. Windows için hem kurulum dosyası hem taşınabilir bir zip var. İkisi de kod imzalı değil, bu yüzden ilk açılışta macOS'ta sağ tıklayıp "Aç" demek, Windows'ta SmartScreen'de "Yine de çalıştır" seçmek gerekiyor.
Sonuç
Aquarium bana birkaç şey öğretti. Bir duvar kâğıdı, sürekli açık kalan ve kimsenin bakmadığı zamanlarda bile çalışan bir program; bu yüzden performans ve duraklatma kuralları en az görsellik kadar önemli. Görüntüyü web teknolojileriyle, beyni Rust ile yazmak iki dünyanın iyi yanlarını birleştirdi: aynı sahne iki işletim sisteminde ve tarayıcıda çalışıyor, simülasyon ise her yerde birebir aynı davranıyor. Ve bir dosyayı çöpe atınca balıkların oraya koşması, beklemediğim kadar keyifli bir etkileşim oldu.
Yazılım Kartex Teknoloji ve Bilişim A.Ş.'ye ait. Kullanılan üçüncü taraf modeller, dokular, fotoğraflar ve seslerin kendi lisansları NOTICES.txt dosyasında listeleniyor. Web sürümünü deskreef.kartex.net adresinden deneyebilirsiniz. Sorularınızı bana X ya da LinkedIn üzerinden iletebilirsiniz.