Skip to content

Tarayıcıda açık dünya inşa etmek, bölüm 13: Arazi şekillendirme ve matematik fonksiyonunun ölümü

Cinevva CTO'su ve Kurucu Ortağı Oleg Sidorkin tarafından

Burada yeni misiniz? Seri rehberine göz atın. Bu rehber, spike'ın ne olduğunu açıklıyor ve tüm bölümlere bağlantı veriyor.

On iki bölüm boyunca bakabileceğiniz bir arazi motoru geliştirdik. Üzerinde uçabiliyor, dikişlerin çatlamamasını hayranlıkla izleyebiliyordunuz. Bu kez ona dokunmak istedik.

Hedef kulağa basit geliyordu: oyuncunun araziyi bir fırçayla gerçek zamanlı olarak şekillendirmesini sağlamak ve bunu, 24 spike boyunca geliştirdiğimiz hiçbir sistemi bozmadan yapmak. Üç deneme, render hatası gibi görünen ama aslında veri modeli hatası olan iki bug ve arazi verilerinin nasıl çalışması gerektiğine dair köklü bir yeniden değerlendirme gerekti.

Üç başarısız başlangıç

Spike 25'in kolay olması gerekiyordu. Üretim kod tabanında arazi mesh'lerine isabet eden bir raycaster zaten vardı. Yerleştirme aracı, nesneleri bırakmak için bunu kullanıyordu. Fırça aracı da aynı yapıyı izler; yalnızca bir prefab oluşturmak yerine heightmap değerlerini değiştirir. Basit.

Birinci deneme: Aracı doğrudan üretimdeki world/client/ TypeScript kodunun içine geliştirdim. Yeni bir terrain-brush.ts, chunk.ts üzerinde değişiklikler, protokol değişiklikleri, Vue bileşeni güncellemeleri. Bir saat içinde kısmen çalışan bir fırçam vardı ama chunk sınırlarında görünür normal süreksizlikleri oluşuyordu. Hatanın fırça kodumdan mı, mevcut chunk birleştirmesinden mi, yoksa tam render döngüsüyle bir etkileşimden mi kaynaklandığını anlayamıyordum. Spike metodolojisi tam da bu durumu önlemek için var. Kuralı atlamıştım ve bedelini hemen ödedim. Her şeyi geri aldım.

İkinci deneme: Bağımsız bir spike hazırladım ama Three.js 0.170.0 ve WebGL'e yöneldim. Üretim kodu WebGL kullandığı için doğal bir seçim gibi gelmişti. Ancak Spike 13-24'ün tamamı WebGPU'ya geçmişti. WebGL tabanlı bir fırça spike'ı geliştirmek, bunun geçiş yapmayı planladığımız renderer'da değil, eski renderer'da çalıştığını kanıtlayacaktı. Yanlış yön. Baştan başladım.

Üçüncü deneme: WebGPU, WebGPURenderer, vertex üretimi için compute shader ve Spike 22 ile eşleşen teknoloji yığını. Bu kez mimari doğruydu. Beş fırça işlemi çalışıyordu: yükseltme, alçaltma, yumuşatma, düzleştirme ve gürültü. M1'de fırça döngüsü P95 değeri 4 ms'nin altındaydı.

Ama dikiş hatası hâlâ oradaydı.

Spike 25'i yeni sekmede aç ↗ · Kaynak kodu görüntüle

Bir türlü çözülemeyen dikiş hatası

Chunk'lar arası normal hesaplamasında standart çözüm sınır örtüşmesidir: her chunk, komşularından gelen fazladan bir veri halkası saklar; böylece sınırdaki normal hesabı her iki taraftan da örnek alabilir. Bunu uyguladım. Komşu kenar verilerini genişletilmiş bir buffer'a kopyaladım. Dikişler yine de çatlıyordu.

Matematiği incelemeye başladım. Chunk A (cx=-1) ile chunk B (cx=0) arasındaki sınırda iki chunk'ın da ortak vertex için aynı normal'i hesaplaması gerekir. Chunk A'nın shader'ı mix(own_col31, own_col32, 0.85) örneğini alıyordu. Chunk B ise mix(neighbor_edge, own_col0, 0.85) örneğini alıyordu. Bunlar, farklı veriler üzerinde ilerleyen farklı bilineer interpolasyon yollarıydı. Sınır verileri doğru olsa bile iki chunk aynı nokta için farklı normal'ler hesaplıyordu.

İşte o anda asıl hatanın sınır kopyalama olmadığını fark ettim. Asıl hata veri modeliydi.

1'den 24'e kadar her spike, height_at() adlı prosedürel bir matematik fonksiyonu kullanıyordu. Dünya koordinatlarını verin, yüksekliği alın. Temiz, global ve durumsuz. Fırça bir matematik fonksiyonunu değiştiremediği için üstüne bir displacement buffer'ı eklemiştim. Arazi artık height_at(x,z) + displacement[i] olmuştu. GPU shader'ı, temel arazi için 30 satırlık gürültü fonksiyonlarının yanı sıra displacement katmanı için bilineer interpolasyon kodunu da içeriyordu. Düzleştirme fırçası, hedef yüksekliği üretecek displacement değerini bulabilmek için height_at() değerini çıkarmak zorundaydı. Birbiri üzerine bindirilmiş, farklı örnekleme stratejileriyle farklı şeyler hesaplayan iki sistem.

Gerçek bir oyun bunların hiçbirini böyle yapmaz. Üretimde, tasarlanmış arazi buffer'larda saklanan örneklenmiş verilerden oluşur. Prosedürel fonksiyon, ilk spike'lardan kalma kullanışlı bir geçici çözümdü. Amacına hizmet etmişti. Artık etkin biçimde hatalara neden oluyordu.

Onu kaldırdım.

Artık her chunk, gerçek yükseklik değerlerini içeren bir heightmap Float32Array'e sahip. Chunk oluşturulurken bu dizi prosedürel gürültüyle dolduruluyor. Bundan sonra gürültü fonksiyonu bir daha hiç çağrılmıyor. Fırça, saklanan yükseklikleri doğrudan değiştiriyor. GPU shader'ı tek bir fonksiyon kullanarak tek bir buffer'dan okuyor: hm_at(i,j). Normal'ler, aynı veri üzerinde grid hizalı merkezi farklarla hesaplanıyor. Bilineer interpolasyon belirsizliği yok. İki sistem arasında uyumsuzluk yok. Shader 90 satırdan 40 satıra indi.

Dikişler kendiliğinden düzeldi. Ortak bir kenardaki iki chunk da artık kendi buffer'larından aynı ayrık yükseklik değerlerini okuyordu; sınır örtüşmesinde komşunun doğru iç noktaları bulunuyordu. Aynı veri girince aynı normal'ler çıkıyordu.

Bu, fırçayla ilgili bir ders değildi. Fırçanın açığa çıkardığı bir veri mimarisi dersiydi.

Patlayan mesh

Spike 26 bunun hacimsel karşılığıydı. 64 küplük bir SDF hacmini fırçayla değiştirip ardından marching cubes ile yeniden mesh üretmek. Spike 25 ile aynı soru, ancak bu kez 3B.

İlk çalıştırdığımda mesh patladı. Her yöne doğru uzanan uzun sivri uçlar vardı; kötü bir gün geçiren denizkestanesi gibiydi.

Spike 26'yı yeni sekmede aç ↗ · Kaynak kodu görüntüle

Ürettiğim MC durum tablosunda 4096 yerine 3840 girdi vardı. Tam tablo 256 durum×16 yuva=4096 olmalıydı ama benim tablom 256×15=3840 boyutundaydı; 112. durumdan başlayan on altı satır eksikti. Bu indeksten sonraki her arama kaymıştı, dolayısıyla durum numarası artık üçgenleme verileriyle eşleşmiyordu. Marching cubes yanlış girdiyi okuduğunda iki uç noktası da yüzeyin aynı tarafında bulunan kenarlar oluşturur. Gerçek bir kesişim kenarında interpolasyon parametresi

t=vavbva

[0,1] aralığında kalır çünkü va ve vb zıt işaretlere sahiptir. Sahte bir kenarda ise aynı işarete sahiptirler; bu yüzden vbva sıfıra yaklaşır veya işaret değiştirir ve t, [0,1] aralığının dışına fırlar. Böylece vertex, hacmin çok uzağına yerleştirilir. Bunu birkaç yüz hatalı hücreyle çarptığınızda ortaya bir kirpi çıkar.

Çözüm son derece basitti: Kanıtlanmış tabloyu Spike 12'den bayt bayt kopyalamak. Dersimi aldım. Kanıtlanmış bir kopya mevcutken lookup table'ı asla yeniden üretmeyin.

İkinci hata daha incelikliydi. Yumuşatma fırçasının arazi özelliklerini yumuşatması gerekiyordu. Bunun yerine keskin kırışıklıklar oluşturuyordu. Sorun şuydu: Her SDF değerini sıfıra, yani izoyüzeye doğru çekiyordum. Bunun yüzeyi yumuşatması gerektiği düşünülebilir ama aslında distance field'ı çökertir. Yüzeyin hem üstündeki hem de altındaki voxel'ler sıfıra doğru hızla ilerleyerek fırça yarıçapındaki her şeyi düzleştirir. Sınırda, yumuşatılmış voxel'ler yumuşatılmamış olanlarla sert bir basamak oluşturarak buluşur. “Yumuşatma” fırçası aslında bir kırışıklık üretecine dönüşmüştü.

Çözüm, doğru Laplasyen yumuşatmaydı. Her değeri sıfıra doğru çekmek yerine, onu 6 doğrudan komşusunun ortalamasına doğru çekmek gerekiyordu:

ϕiϕi+λ(16jN(i)ϕjϕi)

Parantez içindeki terim ayrık bir Laplasyen, λ(0,1] ise yumuşatma kuvvetidir. Bu yöntem yakındaki geometriyi ortalamaya tabi tutar; distance field gradyanını çökertmek yerine koruyarak yüzey şeklini yumuşatır.

Her şey aynı anda

Spike 27 entegrasyon kapısıydı. Spike 24'ün tam pipeline'ını (heightmap patch'leri, MC chunk'ları, Transvoxel dikişleri, geomorph LOD) alıp Spike 25'in örneklenmiş veri modeli ve iki fırça türüyle birleştirdik.

Spike 27'yi yeni sekmede aç ↗ · Kaynak kodu görüntüle

İlk yaptığım şey, height_at() fonksiyonunu her shader'dan söküp atmaktı. Üç compute shader'ın tamamı (SDF doldurma, heightmap patch'i ve Transvoxel dikişi) artık aynı 129x129 heightmap GPU buffer'ını bağlıyor ve paylaşılan bir WGSL preamble üzerinden aynı hm_sample() bilineer interpolasyon fonksiyonunu kullanıyor. Tek veri kaynağı, birden fazla tüketici. Spike 1'den beri her shader'ın içinde yaşayan prosedürel gürültü fonksiyonları artık yoktu.

Ardından ilginç sorunlar başladı.

Bir SDF fırçası chunk'ı MC moduna kilitlediğinde, o chunk ile heightmap komşusu arasındaki Transvoxel dikişinin heightmap'ten değil, SDF hacminden örnek alması gerekir. Dikiş shader'ını ek storage buffer binding'leri ve chunk başına MC bayraklarıyla genişlettim. Ele alınması gereken dört sınır kombinasyonu vardı: HM-HM, HM-MC, MC-HM, MC-MC.

LOD başka bir bilmeceydi. Önceki spike'larda bir MC chunk'ını daha düşük bir LOD seviyesine geçirmek, SDF'yi daha kaba bir çözünürlükte yeniden doldurmak anlamına geliyordu. Bunun yerine stride tabanlı örnekleme kullandım: SDF verisi tam çözünürlükte, yani 65 grid noktası olarak kalıyor. MC shader'ı, grid boyutunun hücre sayısına oranından bir stride hesaplıyor. LOD0'da stride 1. LOD1'de stride 2 ve her iki voxel'den biri örnekleniyor. Chunk'lar SDF verilerine dokunmadan serbestçe LOD değiştirebiliyor.

En tatmin edici çözüm, dinamik dikey chunk oluşturmaydı. Bir chunk'ın üst sınırını aşacak şekilde yukarı doğru şekillendirdiğinizde, üstünde yeni ve yalnızca MC kullanan bir chunk beliriyor; SDF'si, alttaki chunk'ın sınır yüzünden başlatılıyor. Aşağı doğru şekillendirdiğinizde de aynısı gerçekleşiyor. Dünya, düzenlemelere uyacak şekilde büyüyor.

Son tuzak, heightmap fırçasının MC'ye kilitli chunk'larda hiçbir şey yapmamasıydı. HM fırçası heightmapCPU verisini değiştirip yeniden yüklüyor. MC chunk'ları artık heightmap'ten okuma yapmıyor; çünkü SDF'leri önce heightmap'ten doldurulmuş, ardından ondan ayrışmış durumda. syncHeightmapToSdf() fonksiyonunu ekledim: Heightmap değiştikten sonra, fırça yarıçapındaki tüm MC chunk'larının SDF sütunları yeniden türetiliyor ve yeni değerler yükleniyor. Artık her iki fırça türü de her iki chunk türünde çalışıyor.

Aslında ne öğrendik?

Fırça spike'larının bir performans sorusuna yanıt vermesi gerekiyordu: Şekillendirme, kare süresi bütçesi içinde çalışabilir mi? Evet, çalışabilir. Kolay kısım buydu.

Zor kısım, arazi gerçeği olarak 24 spike boyunca height_at() kullanmanın görünmez bir bağımlılık yarattığını ve herhangi bir şeyi düzenlemeye çalıştığımız anda bu bağımlılığın sistemi bozduğunu keşfetmekti. Prosedürel fonksiyon temiz, global ve durumsuzdu; ta ki artık arazinin kendisi olmayana kadar.

Yazdığımız ve unutmayacağımız kurallar:

  1. Arazi yüksekliği örneklenmiş veriden gelir. Chunk'lar kendi buffer'larına sahip olur.
  2. Prosedürel üretim ilk veriyi doldurur. Çalışma zamanındaki gerçek değildir.
  3. Fırça, chunk verilerini doğrudan değiştirir. Displacement katmanları kullanılmaz.
  4. Normal'ler, aynı veriden grid hizalı merkezi farklarla elde edilir.
  5. Sınır örtüşmesi (komşunun iç kısmından 1 hücre) chunk'lar arası normal'leri yönetir.
  6. Kanıtlanmış bir kopya mevcutken lookup table'ı asla yeniden üretmeyin.

Bölüm 14'te debug geometrisini şekillendirmeyi bırakıp dünyayı gerçek bir yer gibi göstermeye ve hissettirmeye başlıyoruz.

Bu bölümde değinilen teknolojiler

Örneklenmiş heightmap mimarisi. Arazinin çalışma zamanında prosedürel bir fonksiyonla hesaplanması yerine, her chunk'a ait veriler olarak saklanması. Her chunk, gerçek yükseklik değerlerini içeren bir Float32Array barındırır. Oluşturma sırasında prosedürel gürültü ilk veriyi doldurur ve ardından fonksiyon bir daha hiç çağrılmaz. Bu yaklaşım, matematik tabanlı arazi ile düzenleme katmanları arasındaki çift sistemli uyumsuzluğu ortadan kaldırır, fırça işlemlerini basitleştirir (saklanan değerler doğrudan düzenlenir) ve GPU shader'ını son derece basit hâle getirir (buffer'dan oku, normal'leri grid hizalı merkezi farklarla hesapla). Streaming kullanan açık dünyalarda, komşulardan alınan 1 hücrelik sınır örtüşmesiyle chunk başına veri sahipliği standart yaklaşımdır. Arazi oluşturma rehberimize bakın.

SDF fırça işlemleri. Araziyi şekillendirmek için signed distance field'ın değiştirilmesi. Ekleme (şişirme), bir kürenin çevresinde smooth-step falloff kullanır. Çıkarma (oyma), aynı şeklin ters işaretlisini kullanır. Yumuşatma ise Laplasyen ortalama kullanır: 6 doğrudan komşuyu oku, ortalamalarını hesapla ve değeri bu ortalamaya doğru çek. Değerleri sıfıra çekmeye dayalı naif yaklaşım distance field'ı çökertir ve keskin kenarlar oluşturur. Laplasyen yumuşatma, özellikleri yumuşatırken alan gradyanını korur. SDF arazi gösterimine bakın.

Karışık veri kaynaklarıyla Transvoxel. Bir MC chunk'ı ile heightmap chunk'ı arasındaki sınırda bulunan geçiş hücrelerinin, her iki tarafta farklı verilerden örnek alması gerekir. Dikiş shader'ı, dört kombinasyonun tamamını (HM-HM, HM-MC, MC-HM, MC-MC) yönetmek için chunk başına bayraklar ve buffer binding'leri taşır. Taraflardan biri MC'ye kilitliyse shader, heightmap'ten örnek almak yerine SDF buffer'ını trilineer olarak interpole eder. Marching cubes için adım aralığı tabanlı LOD. SDF verileri, parçanın mevcut LOD seviyesinden bağımsız olarak tam çözünürlükte saklanır. MC shader’ı, SDF ızgara noktalarının MC hücrelerine oranından bir örnekleme adım aralığı hesaplar. Tam çözünürlükte adım aralığı 1, yarı çözünürlükte ise 2’dir. Bu yaklaşım SDF verilerini LOD değişikliklerinden bağımsız hâle getirir; böylece parçalar SDF yeniden oluşturulmadan LOD seviyeleri arasında serbestçe geçiş yapabilir.


14 bölümün 13. bölümü. Önceki: Bölüm 12 - Halkalar, gökyüzü sisi ve yeniden yapacaklarımız Sonraki: Bölüm 14 - Dünya canlanıyor Seri rehberi: /blog/2026-02-25-open-world-browser-series-guide