Skip to content

Tarayıcıda açık bir dünya oluşturmak, bölüm 18: Yapay zekâ yerleştirmiş hissi veren bir dağıtma fırçası

Oleg Sidorkin, Cinevva CTO'su ve Kurucu Ortağı

Burada yeni misiniz? Seri rehberini kullanın. Rehber, spike'ın ne olduğunu açıklıyor ve tüm bölümlere bağlantı veriyor.

17. Bölüm, oyuncuya çatışmaya uygun bir animasyon seti ve herhangi bir CC0 modeli dünyaya aktarmanın yolunu sundu. Bu bölümde yeniden içerik üreticisinin tarafına dönüyoruz. Spike 34'ün paleti her tıklamada bir nesne yerleştiriyor; bu, öne çıkan bir nesneyi sahneye yerleştirmek için yeterli ama bir orman oluşturmak için işe yaramaz. Spike 37 ise fırçanın kendisi: arazi üzerinde sürüklediğinizde ağaçlar ait oldukları yerleri dolduruyor.

Yapay zekâ olmadan "yapay zekâ yerleştirmiş" hissi

Spike 37'yi yeni bir sekmede açın ↗ · Kaynak kodunu görüntüleyin

Bu spike'ın yanıtladığı soru, bütünüyle sezgisel kurallara dayanan bir fırçanın LLM'i atlayacak kadar akıllı hissettirip hissettirmediği. "Yapay zekâ yerleştirmiş" olmanın testi somut: ağaçlar uçurumlardan uzak duruyor, kayalar eğime doğru yatıyor, sahil çakılları su çizgisinde son buluyor ve bunların hepsi ilk darbede gerçekleşiyor. Bunu eğim ve rakım koşulları, ağırlıklı seçimler ve aile başına aralık kurallarıyla, tek bir model çağrısı bile yapmadan başardık.

Fırça, elle ayarlanmış özelliklere sahip 257×257 boyutunda bir CPU yükseklik haritası üzerinde çalışıyor; böylece her hazır ayar yerleşebileceği bir alan buluyor: karma eğim seçimleri için kuzeyde dağlar, taş döküntüsü için doğuda bir uçurum şeridi, plaj ve çayır için güneyde bir kıyı ovası, güneybatıda ise göl çanağı. Arazi, (altitude, slope) biyom sınıflandırıcısından tepe renklerini önceden hesaplıyor; böylece tek bir ağaç bile boyamadan önce hazır ayarın nerede devreye gireceğini görebiliyorsunuz. Beş hazır ayar, her biri { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope } biçiminde seçim listeleri içeren düz veriler olarak sunuluyor. Uçurum ve Taş Döküntüsü, kayaların yalnızca gerçek eğimlere yerleşmesi için slopeMin: 0.3, her kayanın yukarı vektörünün yüzey normalini izlemesi için de alignToSlope: true kullanıyor.

Dağıtma motoru her darbede fırça diski içinde densityPerM2 × area adet aday nokta örnekliyor, her adayın yüksekliğini ve eğimini okuyor, hazır ayarın seçimlerini koşulları geçenlerle sınırlandırıyor, bunlardan birini ağırlıklı olarak seçiyor ve ardından yarıçap içi uzamsal karma tablosunda aralık denetimi yapıyor. Her şey deterministik: tüm seçimler tohumlanabilir Mulberry32 RNG tarafından yönetiliyor; dolayısıyla (seed, brush events) herhangi bir oturumu birebir yeniden üretiyor. Başlangıç arazisinde düz çayır üzerindeki bir Karma Orman darbesi, 158 adaydan 139'unu 5 ms'de yerleştirdi. Aynı hazır ayar bir uçurumda 226 adaydan yalnızca 106'sını yerleştirdi ve HUD, bunların 81'inin eğim nedeniyle reddedildiğini bildirdi. Bu ret dökümü kullanıcı deneyiminin özünü oluşturuyor: uçurumun neden yalnızca birkaç ağacı kabul ettiğini tahmin etmek yerine doğrudan görebiliyorsunuz.

Hazır ayarları düz veri olarak tutmanın amacı, LLM sürümü geldiğinde bunun yeniden yazım değil, yalnızca bir JSON değişimi olması. paint({ preset }), preset.picks değerinin elle ayarlanmış bir tariften mi yoksa "yosunlu kayalar içeren yaprak döken orman" ifadesini ağırlıklara dönüştüren bir worker'dan mı geldiğini önemsemiyor. Motor ayrıca hiçbir nesne kimliğini sabit kodlamıyor; dolayısıyla farklı bir katalog eklemek motor değişikliği gerektirmiyor.

300 çizim çağrısından 49'a

İlk sürüm, her yerleşimi çoklu mesh grubunun clone(true) kopyası olarak işliyordu. Bu, birkaç yüz nesne için yeterliydi ama 2.500 sınırında duvara tosluyordu; çizim çağrılarının sayısı binlere çıkıyordu. Bu noktaya gelmeden InstancedMesh kullanımına geçtik ve her (propId, partIndex) için bir bucket oluşturduk. Her bucket iki katına çıkarak büyüyor: daha büyük bir InstancedMesh ayır, canlı matrisleri kopyala, sahne üst öğesini değiştir, eski niteliği dispose et. Silme işlemi swap-remove kullanıyor; bu nedenle bucket boyutundan bağımsız olarak tek bir örneği kaldırmak O(1). Determinizm, aralık kuralları ve ret HUD'u değişmeden korunuyor çünkü bu değişiklik bütünüyle yerleşim kaydının altında gerçekleşiyor.

MegaKit paketi üzerinde yaptığımız bir tanılama, gerçek bir mimari soruyu çözüme kavuşturdu. Çok primitife sahip bir glTF mesh'i (gövde ve yapraklar), three.js'e ya dizi malzemeli ve geometry.groups içeren tek bir mesh olarak ya da her biri tek malzemeye sahip ayrı kardeş mesh'ler olarak ulaşabilir. Yükleyici bu paket için ikinci yolu izliyor: her parça, grupları boş olan tek malzemeli bir mesh. Bu, dağıtma için daha iyi bir yapı; çünkü primitif başına ayrı bucket'lar, sayıları farklılaşırsa gövde bucket'ının yaprak bucket'ından bağımsız büyümesini sağlıyor. Her iki durumda da çizim çağrısı sayısı aynı, ancak ayrık yapıda bellek düzeni daha iyi. Ölçülen kazanç kalıcı oldu: yaklaşık 300 çizim çağrısı kullanan bir orman darbesi 49'a düştü; çok darbeli tam bir oturum ise 51 çizim çağrısıyla 75 FPS'de 3.221 örneğe ulaştı. Kopyalama yolu, kare bütçesi çökmeden önce bu sınıra asla ulaşamazdı.

Mesafe LOD'u ve içinde saklanan dört hata

Örnekleme çizim çağrılarını azalttı ama her örnek hâlâ tam üçgen sayısıyla çiziliyordu; 90 metre uzaktaki ve görüntüye yalnızca iki piksellik yaprak ayrıntısı katan ağaçlar bile buna dahildi. Bu nedenle meshoptimizer ile her nesne parçası için üç LOD seviyesi (tam, %50, %15) hazırladık, bucket anahtarını (propId, partIndex, lod) olarak genişlettik ve bir yerleşimi ayırma yapmadan kardeş bucket'lar arasında taşıyan move() işlevini ekledik. Mesafe bantları 0–30 m, 30–90 m ve ötesi olarak belirlendi. Her sınırın çevresinde ±4 m histerezis var; böylece bant kenarında gezinen bir kamera, yerleşimi sürekli ileri geri taşıyıp matrisini her karede yeniden yüklemiyor. Yeniden değerlendirme 4 Hz ile sınırlandırılıyor ve yalnızca kamera gerçekten hareket ettiğinde çalışıyor; dolayısıyla sabit bir kamera kare başına yalnızca bir karesel mesafe karşılaştırmasına mal oluyor.

Öğretici hatalar işte bu LOD yolunda ortaya çıktı. İlki, kamera yörüngede dönerken yerleşimlerin kaybolması veya çoğalması şeklinde görülüyordu ve sahne doldukça kötüleşiyordu. Nedeni ortak bir geçici matristi: move(), yerleşimin dönüşümünü modül kapsamındaki _tmpMat içine okuyordu; ancak kaynak bucket'ın swap-remove işlemi de kendi iç taşıması için aynı _tmpMat değişkenini kullanıyor ve hedef yazmadan önce taşınan matrisin üzerine yazıyordu. Hata yalnızca taşınan slot bucket'ta zaten son sıradaysa ortaya çıkmıyordu; bunun olasılığı yaklaşık 1/count idi. Bu da oyun testinde görülen, "sahne büyüdükçe kötüleşen nadir titreme" durumuyla birebir örtüşüyor. Çözüm, yalnızca move() için ayrılmış özel bir _carryMat kullanmaktı. Toplam 1.274 taşıma içeren stres testinde küme piksel piksel aynı kaldı.

İkinci hata daha incelikliydi: ilk geçiş dışında her LOD geçişi akıcı görünüyordu. LOD1'e geçen ağaçların silüeti neredeyse hiç değişmemesine rağmen gölgelendirmeleri belirgin biçimde kayıyordu; buna karşılık merdivenin sonraki aşamalarındaki daha büyük üçgen azalmaları sorunsuz görünüyordu. LockBorder kullanan sadeleştirici hiçbir zaman tepe noktalarını taşımıyor veya yenilerini üretmiyor; dolayısıyla kalan tepe noktaları normallerini aynen koruyor. Buna rağmen her sadeleştirmeden sonra computeVertexNormals() çağırıyorduk. LOD0, sanatçı tarafından hazırlanmış özgün normalleri değiştirmeden döndürüyor; LOD1 ve üstü ise three.js'in genel amaçlı yüz ortalaması yeniden hesaplamasını kullanıyordu. Merdivende normal düzeninin değiştiği tek yer 0'dan 1'e geçiş sınırıydı; dolayısıyla sıçrama da orada görülüyordu. O tek savunmacı satırı kaldırmak gölgelendirmeyi düzeltti ve ek bir kazanç olarak nesne başına hazırlama süresini yaklaşık yarıya indirdi; çünkü artık parça başına dört LOD için normalleri yeniden hesaplamıyorduk.

Sadeleştiricinin ürettiklerini denetlemek üçüncü bir kazancı ortaya çıkardı. Her LOD, yeni bir indeks içeren original.clone() idi ve BufferGeometry.clone() her niteliği derinlemesine kopyaladığı için beş LOD; değerleri bit düzeyinde aynı olan konum, normal, UV ve renk tamponlarının beş bağımsız kopyasını tutuyordu. Nitelik referanslarını paylaşacak ve her LOD için yalnızca özel bir indeks tamponuna sahip olacak şekilde yeniden düzenledik. Böylece tipik bir ağaç parçasındaki farklı nitelik kimliği sayısı 20'den 9'a düştü ve her tepe noktası tamponu GPU'ya yalnızca bir kez yüklendi. Paylaşımlı depolama iki kural getiriyor: herhangi bir LOD üzerinden nitelik verilerini değiştirmeyin ve tek bir LOD geometrisine dispose() uygulamayın; çünkü ikisi de tamponu paylaşan tüm kardeşleri etkiler.

Dördüncü hatanın boyamayla hiçbir ilgisi yoktu. Herhangi bir düğmeye basılmamışken imleci yalnızca arazi üzerinde hareket ettirmek kare hızını düşürüyordu. pointermove işleyicisi, hiçbir uzamsal yapısı olmayan 131.072 üçgenlik bir düzlem olan arazi mesh'ine raycast uyguluyordu; bu nedenle three.js, saniyede 1.000 olaya varan hızlarda her olay için indeks tamponunun tamamını tarıyordu. Arazi parametrik bir yükseklik haritası olduğu için bu aramada mesh'e hiç ihtiyacımız yoktu. sampleHeight üzerinde uyarlanabilir bir ışın yürüyüşü (yüzeyden yüksekte büyük adımlar, yüzeye yaklaşınca 0,4 m alt sınır, ardından işaret değişiminde 12 ikili bölme), ışın başına 131.072 üçgen testi yerine yaklaşık 8–30 örneğe mal oluyor. Bu yaklaşık üç büyüklük mertebesi daha ucuz ve fareyle üzerine gelindiğinde kare hızı yeniden üst sınırda kalıyor.

Maliyet yalnızca yer değiştirir; tıklamadan uzaklaştığından emin olun

Spike'ı three r184 üzerindeki WebGPURenderer'a (üretim hedefi) geçirdikten sonra bir DevTools profili, ilk boyamanın 265 ms boyunca engellendiğini ve bunun %79'unun meshoptimizer WASM içinde geçtiğini gösterdi. Hazırlama gerçekten ağırdı; soğuk bir hazır ayar için yaklaşık 180 sadeleştirme çağrısı yapılıyordu. Ancak preloadProps yalnızca sahneleri getirip ayrıştırdığı ve LOD hazırlamasını hiç tetiklemediği için bu iş tıklama işleyicisinin içinde çalışıyordu. Çözüm, hazır ayar seçiminin tüm hazırlamayı arka planda yapmasını sağlamaktı: preloadProps artık parça çözümleme yolunu çağırıyor, hızlı bir tıklamanın yinelenen bir işlem başlatmak yerine mevcut işleme katılması için sürmekte olan promise'i önbelleğe alıyor ve sadeleştiricinin parça başına dört kez tekrarladığı geometri ön işlemesini memoize ediyor. İlk boyama süresi HUD'da 209 ms'den 4 ms'ye düştü. WASM süresi ortadan kalkmadı; yalnızca kullanıcının kritik yolundan çıktı ve kullanıcı araziye bakıp nereyi boyayacağına karar verirken çalışmaya başladı.

Bu spike'ın tekrarlanan dersi bu. Bu düzeltmelerin neredeyse hiçbiri fırçanın ne yaptığını değiştirmedi. Maliyetin ne zaman oluştuğunu değiştirdiler: tıklamanın dışında, üzerine gelmenin dışında, kameranın yakınında gezindiği sınırın dışında. Anında tepki veriyormuş gibi hissettiren bir dağıtma aracı daha az iş yapmaz; işi kullanıcının beklemediği anda yapar.

Bu bölümde değinilen teknolojiler

Sezgisel uygunluk dağıtımı. Bir fırça, disk içinde aday noktalar örnekler; CPU yükseklik haritasından her noktanın (height, slope) değerini okur; hazır ayarın seçimlerini eğim ve rakım koşullarına göre filtreler; birini ağırlıklı olarak seçer ve uzamsal karma tablosunda izlenen aile başına minimum aralığı ihlal ediyorsa reddeder. Eğime hizalanan seçimler, yukarı vektörlerini yüzey normaline döndürür. Bu yöntem, öğrenilmiş ağırlıklar olmadan kasıtlı görünen yerleşimler üretir (ağaçlar uçurumlardan uzak durur, kayalar eğimlere doğru yatar, çakıllar su çizgisinde son bulur) ve hazır ayarı düz veri olarak tuttuğu için LLM tarafından oluşturulan bir seçim listesi doğrudan yerine takılabilir.

Asenkron yüklemeler altında deterministik yerleşim. Tohumlanabilir Mulberry32 RNG tüm seçimleri yönetir; dolayısıyla (seed, brush events) bir oturumu birebir yeniden üretir. RNG seçimleri herhangi bir await öncesinde yapılır ve aralık rezervasyonları glTF kopyası çözülmeden önce uzamsal indekse eklenir. Böylece eşzamanlı adaylar birbirlerini dikkate alır ve asenkron varlık yükleme işlemleri diziyi değiştiremez.

O(1) düzenlemeli bucket tabanlı InstancedMesh. Her (propId, partIndex, lod) için bir InstancedMesh; kapasite gerektiğinde canlı matrislerin daha büyük bir tampona kopyalanmasıyla iki katına çıkarılır. Silme ve FIFO tahliyesi, taşınan örneğin indeksini güncelleyen bir geri referans dizisiyle swap-remove kullanır; böylece kaldırma işlemi bucket boyutundan bağımsız olarak O(1) olur. Bir tanılama, glTF parçalarının tek malzemeli mesh'ler olarak geldiğini doğruladı. Bu da primitif başına tek bucket yolunu etkinleştiriyor ve her primitife bağımsız büyüyebilen bir bucket sağlıyor.

Histerezisli ve paylaşılan nitelik tamponlu mesafe LOD'u. Parça başına meshopt ile sadeleştirilmiş üç seviye, sınır yakınındaki kameranın sürekli geçiş yapmasını önlemek için ±4 m histerezis içeren mesafe bantlarıyla seçilir; yeniden değerlendirme sınırlı bir hızda yapılır ve yalnızca gerçek kamera hareketinde çalışır. LockBorder sadeleştirmesi tepe noktalarını hiç taşımadığından tüm LOD'lar aynı konum/normal/UV/renk tamponlarını paylaşır ve yalnızca özel indeks tamponlarıyla birbirinden ayrılır; böylece farklı GPU tepe noktası tamponlarının sayısı yaklaşık yarıya iner. Savunma amacıyla kullanılan computeVertexNormals çağrısını atlamak, sanatçı normallerini LOD'lar arasında aynı tutar ve merdivendeki tek gölgelendirme süreksizliğini ortadan kaldırır. Bkz. LOD ve meshoptimizer.

Yüksek frekanslı aramalar için analitik yükseklik haritası raycast'i. pointermove hızındaki bir imleç aramasını 131 bin üçgenli bir düzlem mesh'ine uygulamak, her olayda indeks tamponunun tamamını tarar. Bunu analitik yükseklik işlevi üzerinde uyarlanabilir bir ışın yürüyüşüyle değiştirmek (yüzeyden uzakta büyük adımlar, yakınında küçük bir alt sınır, (rayyterrainy) ifadesinin işaret değişiminde ikili bölme), on binlerce üçgen testi yerine onlarca örneğe mal olur; bu yaklaşık üç büyüklük mertebesi daha ucuzdur. Önceden ayrılmış bir çıktı vektörü de sıcak yolu ayırmasız tutar.

İşi etkileşimin kritik yolundan çıkarın. Maliyetli tek seferlik işler (meshopt LOD hazırlamaları, WGSL pipeline derlemeleri) tıklama işleyicisinin içinde değil, boşta kalınan aralıklarda çalışmalıdır. Hazır ayar seçildiğinde etkin hazır ayarın tam hazırlamasını önceden yüklemek, hızlı bir tıklamanın yeni bir işlem başlatmak yerine mevcut promise'e katılması için sürmekte olan promise'i önbelleğe almak ve geometri başına ön işlemeyi memoize etmek, toplamda daha az iş yapmadan ilk boyama gecikmesini 209 ms'den 4 ms'ye düşürdü.


29 bölümden 18. bölüm. Önceki: 17. Bölüm - Yeniden hedefleme gerektirmeyen animasyonlar ve canlı varlık araması Sonraki: 19. Bölüm - Bir ormanda hayatta kalması gereken imposter Seri rehberi: /tr/blog/2026-02-25-open-world-browser-series-guide