Skip to content

Tarayıcıda açık bir dünya inşa etmek, bölüm 23: Elli avatar ve odada bir ses

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

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

22. bölüm dünyanın üzerine bir gökyüzü yerleştirdi. Bu bölümse dünyaya insanları yerleştiriyor. Üçüncü şahıs bakış açılı bir açık dünyada her an 50'den fazla karakterin görünür olması ve yanınızda duran kişileri duyabilmeniz gerekir. Spike 45 işin render tarafı: ana iş parçacığını eritmeden bu kadar çok animasyonlu avatarı GPU'ya aktarmak. Spike 46 ise ses tarafı: konuma göre sağa-sola dağılan ve zayıflayan eşler arası sesli iletişimi, bir teknoloji demosundan çok normal bir görüntülü görüşme gibi duyulacak şekilde ayarlamak.

Elli dansçı için tek çizim çağrısı

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

Varsayılan Three.js yaklaşımı her karaktere kendi SkinnedMesh'ini, kendi AnimationMixer'ını, kendi kemik matrisi yüklemesini ve kendi çizim çağrısını verir. Yerel Mac'te 50 avatar, GPU daha tek bir işlem bile yapmadan önce kare başına yaklaşık 13 ms saf JavaScript yükü oluşturuyordu. Çok oyunculu sistemin tamamı için risk azaltma sorusu, tek bir toplu iskelet animasyonu mimarisinin bunu kontrol altına alıp karakter sayısıyla doğrusal biçimde ölçeklenip ölçeklenemeyeceğiydi.

Çözüm, animasyonun hesaplandığı yer ile çizildiği yeri ayırmak. Üç karakter sınıfı aynı FBX şablonunu paylaşıyor. Yerel oyuncu normal bir Avatar: kendi mixer'ına sahip tam bir iskelet klonu ve standart Three.js yaklaşımı; çünkü bundan her zaman yalnızca bir tane bulunuyor. Her uzak eş ise bir VirtualSkeleton: aynı klipleri çalıştıran kendi mixer'ına sahip tam bir klon, ancak yalnızca kemiklerin kalması için her SkinnedMesh düğümü klonlamanın hemen ardından kaldırılıyor. Bu nesne sahneye hiç girmiyor. Her karede, mixer güncellendikten ve matrisler son hâlini aldıktan sonra, 100 kemiğin tamamı için (bone.matrixWorld × boneInverse) değerlerini paylaşılan bir Float32Array içindeki bir yuvaya paketliyor. Ardından BatchSkinnedRenderer, geometri parçası başına bir InstancedMesh barındırıyor; bunların tümü maxInstances × numBones × mat4, yani 60 × 100 × 64 = 384 KB boyutundaki tek bir kemik matrisi StorageBufferAttribute'ından veri okuyor. Özel positionNode ve normalNode içeren bir MeshStandardNodeMaterial, tepe noktası başına dört kemik etkisini doğrudan bu depolama tamponundan okuyor. Sonuç, kalabalıkta kaç kişi olursa olsun geometri parçası başına tek bir depolama yüklemesi ve tek bir çizim çağrısı. İskelet animasyonu tepe noktası gölgelendiricisinde çalışıyor ve avatar başına JavaScript maliyeti bir mixer çalıştırıp 100 matrisi kopyalamaya düşüyor.

Bunu ölçen HUD'ın da yeniden yapılması gerekti. Eski sürüm, tüm karenin GPU süresi 3 ms'yi aştığında "bütçe aşıldı" uyarısı veriyordu; ancak kare her zaman gölge haritasını, zemini ve yerel oyuncunun tam iskelet animasyonlu mesh'ini içeriyor ve bunlar, kaç sentetik eş bulunursa bulunsun gerçek donanımda toplam 3 ila 5 ms sürüyor. Çözüm, kendini kalibre eden bir bütçe oldu: toplu avatar bulunmadığı sırada hızlı bir EMA üzerinden canlı GPU süresini taban değer olarak yakalıyor, ardından bu taban değeri donduruyor ve sentetik avatarlar belirdiğinde eklenen avatar başına bütçeyi doğrusal biçimde 0,06 ms artırıyor. Her makinede boşta PASS gösteriyor ve kalabalık büyüdükçe bütçeyi orantılı olarak sıkılaştırıyor.

Hata, göremediğiniz bir yüzdü

Chrome'daki ilk çalıştırmalarda yerde gümüş renkli gölgeler görünürken avatarlar hiç görünmedi ve şu WGSL ayrıştırma hatası çıktı: cannot index type 'f32'. Hata, uniform skaler olarak tanımlanmışken object.nodeUniform2[i] ifadesini indekslemeye çalışan bir satırdaydı. Bu hikâyenin dürüst kısmı şu: ilk düzeltme yanlıştı ama yine de işe yaradı. Tahmin, hatalı kodu InstancedMesh'in örnek matrisi yolunun ürettiğiydi ve bunu bir StorageInstancedBufferAttribute ile değiştirmek hatayı Chrome'da ortadan kaldırdı. Ancak hata, yeni yol farklı gölgelendirici kodu ürettiği için kaybolmuştu; neden ele alındığı için değil. Bu da en tehlikeli düzeltme türüdür.

Asıl suçlu morph target'lardı. 3MIKE.fbx, blend shape tabanlı yüz ifadeleriyle geliyor; klonlanan geometri morphAttributes değerlerini devralıyor ve Three.js'in MorphNode.setup() metodu morphTargetInfluences değerini skaler bir float olarak tanımladıktan sonra oluşturulmuş bir döngü içinde buna .element(i) uygulamaya çalışıyor. Derleyicinin reddettiği skaler indeksleme tam olarak buydu. Düzeltme tek satır: morph kullanmayan geometrilerde geometry.morphAttributes = {} değerini ayarlamak. Böylece Three.js, MorphNode'u hiçbir zaman eklemiyor. Kazara bulunan Chrome düzeltmesi bir süre kaldıktan sonra geri tepti: Safari'de depolama tabanlı örnekleme yolu 256 kez Vertex buffer is not big enough hatası üretti, çünkü Safari'nin WebGPU arka ucu bunu düzgün biçimde çeviremiyor. Geri almak doğru karardı ve oluşturulmuş bir örnekleme yolu yerine temel WebGPU'nun parçası olan sade kemik matrisi depolama tamponu her yerde sorunsuz çalışıyor. Buradan çıkarılması gereken ders şu: bir düzeltme tek bir tarayıcıda çalışıyor ve mekanizmasını açıklayamıyorsanız yalnızca bir belirtiyi yamamışsınızdır; bu nedenle gerçekten üretilen WGSL'yi okuyun. Spike'a daha sonra eklenen bir getCompilationInfo() shim'i, Three.js'in genel "module is not valid" mesajını gerçek Tint hatasına dönüştürdü ve harcanan emeğin karşılığını defalarca verdi.

Bunun yanında, framework'ün otomatik davranışından kaçınmak için ilgili bir yöntem daha bulunuyor. Three.js, standart skinIndex ve skinWeight öznitelik adlarını algılıyor ve özel positionNode zaten iskelet animasyonunu yapıyor olsa bile bir InstancedMesh içine kendi SkinningNode'unu eklemeye çalışıyor. Bu öznitelikleri boneIndex ve boneWeight olarak yeniden adlandırmak onları framework'ten gizliyor; özel TSL de bunları yeni adlarıyla okuyor.

Kelimeler arasında sizi unutan bir aktarıcı

İlk sürüm, gerçek kablo biçimini ve gönderim sıklığını kullanan aynı tarayıcı içi bir yer tutucu olan BroadcastChannel üzerinden eşleri senkronize ediyordu ve protokol yorumunda gerçek aktarıma geçişin tek satırlık bir değişiklik olacağı vaat ediliyordu. Bu vaadi yerine getirmek, 36 baytlık ikili kareyi çözümlemeyen 74 satırlık bir Cloudflare Durable Object olan AvatarRoomDO anlamına geldi. Gönderenin kimliği karenin içine gömülü olduğu ve her alıcı istemci tarafında kendi yankısını filtrelediği için her mesajı olduğu gibi odadaki diğer tüm eşlere iletiyor. Aktarıcı, kimlik konusunda hiçbir farkındalığa sahip değil. Hazırda bekleyen WebSocket'ler, boşta duran bir odayı ücretsiz hâle getiriyor: DO, mesajlar arasında bellekten çıkıyor ve çalışma ortamı bir sonraki pakette etiketlenmiş soketleri geri yüklüyor. Eş başına saniyede 10 olayla bu, eş-saat başına 36.000 DO isteği, yani yaklaşık yarım sent ediyor; Cloudflare'da çıkış trafiği ücretsiz ve eşdeğer AWS WebSocket yapısından yaklaşık 6 ila 10 kat daha ucuz.

Geçiş, saklamaya değer bir durum makinesi hatasını ortaya çıkardı. Uzak bir oyuncu durduktan sonra yürümeye devam ediyordu. Animasyon isteği son kuyruğa alınan adı değil, o anda oynatılan klip olan this._state değerini kontrol ediyordu. Bu nedenle aynı tick içinde önce walk, ardından idle olmak üzere iki ağ mesajı geldiğinde idle, henüz ilerlememiş bir durumla karşılaştırılıyor ve sessizce bırakılıyordu. Gelecekteki idle paketleri değişmediği için üst katmanda yinelenen veri olarak ayıklandığından eş sonsuza dek yürümeye devam ediyordu. Düzeltme, bekleyen adın üzerine her zaman yazmak ve geçiş yardımcısının gerçek aynı durum isteklerini kısa devre etmesine izin vermekti; zaten yaptığı şey de buydu. Bu hata sınıfı geneldir: yanlış referans değerine göre yapılan yinelenen veri kontrolü, önemli girdiyi sessizce yutar.

Safari için iki ek koruma gerekti. WebSocket'i Chrome'dan daha hızlı açtığı için ilk gelen eş mesajı, toplu render sistemi oluşturulmadan önce ulaşabiliyor ve null referansına erişebiliyordu; eşler her 100 ms'de bir yeniden yayın yaptığı için render sistemi yokken mesajları bırakmak güvenli. Ayrıca 'gpu' in navigator true döndürürken requestAdapter() null döndürüyordu. Bu da Three.js'in sessizce WebGL2'ye dönmesine neden oluyordu; depolama tamponlu iskelet animasyonu zincirinin burada geçerli bir çevirisi olmadığından sistem hata yağdırıyordu. Gerçek bir adaptörün varlığını kontrol etmek ve arka ucun gerçekten WebGPU olduğunu doğrulamak, bozuk bir render yerine yükleme ekranında açık bir mesaj gösterilmesini sağlıyor. Bir WGSL lehçe farkı bile vardı: Three.js, WebKit'in derleyicisinin henüz desteklemediği modern iki bağımsız değişkenli @interpolate(flat, either) ifadesini üretiyor. Bu, gölgelendirici kaynağını createShaderModule'a giderken yeniden yazıp ikinci bağımsız değişkeni kaldırarak yamalandı; düz enterpolasyon, hangi tepe noktası kullanılırsa kullanılsın aynı değeri taşıdığı için bunun hiçbir maliyeti yok.

Odaya göre sağa-sola dağılan ses

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

Spike 46, yakınlık tabanlı sesli iletişimdir: sessiz veya orta düzeyde gürültülü bir odada Google Meet ve Microsoft Teams kalitesini yakalamak üzere açıkça kapsamlandırılmış, HRTF uzamsal ses kullanan eşler arası WebRTC. Bir VoiceRoomDO, JSON aktarıcısı olarak sinyalleşmeyi yönetiyor: her yeni eşe katılımcı listesini gönderiyor, katılım ve ayrılmaları duyuruyor, SDP ve ICE verilerini soket etiketine göre belirli bir eşe yönlendiriyor ve uzamsal konumlandırıcıları yöneten konum güncellemelerini yayınlıyor. Eşlerin birbirlerinin kimliğine bürünememesi için her mesaja gönderen kimliğini ekliyor; sesin kendisiyse DO'ya hiç uğramıyor. Her uzak eş için bir RTCPeerConnection kullanılıyor ve sözlük sıralamasında daha küçük eş kimliği her zaman teklifi yapıyor. Böylece iki taraf, eksiksiz bir perfect negotiation uygulamasına gerek kalmadan kimin başlatacağı konusunda anlaşabiliyor.

Alıcı tarafında her eşin sesi, ters mesafe azalmasıyla HRTF'ye ayarlanmış bir PannerNode üzerinden geçiyor. AudioListener, yerel oyuncunun konumu ve baktığı yön üzerinden her karede forwardX = sin(facing), forwardZ = cos(facing) kullanılarak güncelleniyor; bu da sahnenin atan2(wx, wz) yön kuralıyla eşleşiyor. Chrome'a özgü bir tuhaflık bir saate mal oldu: yalnızca Web Audio tarafından tüketilen bir MediaStream bazen paketleri çekmiyor. Bu nedenle her akış, kod çözücüyü zamanlamaya zorlamak için gizli ve sessize alınmış bir <audio> öğesine de bağlanıyor. Kalite tarafında tarayıcılar varsayılan olarak yaklaşık 32 kbps mono Opus kullanıyor. Bu yüzden spike, bant içi FEC'yi etkinleştirip DTX'i devre dışı bırakarak bit hızını 128 kbps'ye çıkarmak için her teklif ve yanıttaki fmtp satırını değiştiriyor; ardından kodlayıcının SDP'de belirtilen değeri gerçekten kullanmasını garanti etmek için yüksek bir maksimum bit hızıyla setParameters çağrısı yapıyor. FEC, bit hızı artışından sonraki en büyük ikinci işitsel iyileştirmedir ve yeniden anlaşma gerektirmeden paket kaybının telafi edilmesini sağlar.

Sadeleştirerek temiz sese ulaşmak

Yayınlanan ses zinciri, başladığım zincirden çok daha küçük ve asıl ders de onu küçültmekti. İlk sürümde bir yüksek geçiren filtre, klavye seslerini yakalamak üzere ayarlanmış bir tıklama sınırlayıcı, bir kompresör, bir gürültü kapısı ve bir wet-dry çapraz geçişi vardı; bunların önünde de on ikiden fazla kaydırıcı içeren yüzen bir panel bulunuyordu. Kullanıcı klavye tıklamalarını duyduğunu bildirdiğinde ilk içgüdü, peş peşe yamalar ekleyerek tıklama sınırlayıcıyı daha sert ayarlamak ve dry karışımını düşürmekti. Yapısal yanıt şuydu: zincirde bir makine öğrenimi gürültü gidericisi bulunduğunda tıklama sınırlayıcı, gürültü kapısı ve yüksek geçiren filtrenin büyük kısmı gereksizdir; çünkü RNNoise tam olarak klavye, fare ve yazma sesleri üzerinde eğitilmiştir ve genlik kırpma aynı işin kesinlikle daha kötü bir sürümüdür. Üretim istemcileri makine öğrenimi tabanlı gürültü giderme, yankı engelleme, otomatik kazanç ve seviye kontrolü için yumuşak bir kompresör sunar; başka hiçbir şey kullanmaz. Böylece dört aşama, kaydırıcı paneli ve "gürültü azaltma yöntemini seç" anahtarları çıkarıldı; geriye tek ve sabit bir işleme hattı kaldı.

Kalan her aşamanın bir işlevi var. RNNoise yankıyı gidermediği ve onsuz hoparlörden mikrofona geri besleme sınırsız olduğu için tarayıcı yankı engellemesi açık kalıyor. RNNoise ile üst üste kullanıldığında sürtünmeli ünsüzlerde bozulma ürettiği için tarayıcının gürültü bastırması kapatılıyor; tek bir gürültü giderici seçmek gerekiyor. Tarayıcının otomatik kazancı açık kalıyor; çünkü kapatıldığında sinyal kompresörün çalışamayacağı kadar sessiz hâle geliyor ve Web Audio'nun DynamicsCompressorNode öğesinde bunu telafi edecek bir makeup-gain parametresi bulunmuyor. Tarayıcının geniş zaman ölçekli seviye ayarı ile spike'ın hızlı kompresörü farklı zaman ölçeklerinde çalışıyor ve bir arada kullanılabiliyor. RNNoise, yüzde 92 wet ile yüzde 8 dry karışımında çalışıyor; çünkü ses olasılığı düşen s, ş ve f gibi ötümsüz ünsüzleri aşırı bastırabiliyor. Küçük dry yolu ise biraz tuş sesi sızıntısı pahasına bu sesleri koruyor. İki özellik sistemi tamamlıyor. Bas-konuş, track.enabled değerini değiştirmiyor; çünkü bu, işlem hattı tamponlarında kalan her şeyi atarak tuş bırakıldığında son heceyi kesiyor. Bunun yerine, zincirin sonuna yakın bir GainNode, setTargetAtTime ile kademeli olarak değişiyor: ilk hecenin korunması için hızlı bir atak, son ünsüzün tamamlanması içinse yavaş bir bırakma uygulanıyor ve parça sürekli etkin kalıyor. Radyo tarzı bir özellik olarak istenen beş saniyelik yayın gecikmesi ise bir baypas kolunu ve bir DelayNode kolunu çapraz geçişle birlikte çalıştırıyor. Kesme düğmesine basıldığında gecikmeli çıkış anında sessize alınıyor ve ses yeniden başlamadan önce HUD üzerinde geri sayım gösteriliyor. Gürültü gidericiyi paketlemek başlı başına küçük bir maceraydı: yayımlanan RNNoise worklet'i, hiçbir CDN'nin çözümleyemediği yalın belirteçli içe aktarımlar kullanıyor. Çözüm, WASM'ın base64 olarak içine gömüldüğü, kendi kendine yeten 1,9 MB'lık tek bir dosya üreten yerel bir esbuild paketi hazırlamak, bunu depoya eklemek ve geliştirme sunucusunda, VitePress derlemesinde ve özel alan adında aynı şekilde çözümlenmesi için modüle göreli bir URL ile referans vermek oldu. Worklet herhangi bir zamanda yüklenemezse zincir, sade bir yüksek geçiren filtre ve kompresör üzerinden yine de ses üretmeye devam ediyor; HUD ise hatayı kırmızı renkte gösteriyor.

Bu bölümde kullanılan teknolojiler

Kalabalıklar için toplu GPU iskelet animasyonu. Uzak avatarlar, her kemiğin bone.matrixWorld × boneInverse değerini paylaşılan bir StorageBufferAttribute içine paketleyen başsız bir VirtualSkeleton (iskelet kaplamalı mesh'leri çıkarılmış, kemikleri korunmuş ve kendi mikserine sahip eksiksiz bir klon) çalıştırıyor. Geometri parçası başına bir InstancedMesh, bu matrisleri özel bir TSL positionNode/normalNode içinde okuyor. Böylece avatar başına CPU işi bir mikser güncellemesi ve matris kopyasıyla sınırlı kalırken tüm kalabalığın maliyeti tek bir depolama yüklemesi ve parça başına tek bir çizim çağrısı oluyor. Bkz. GPU güdümlü LOD.

Belirtiyi değil, oluşturulan WGSL'yi okumak. cannot index type 'f32' derleme hatasının, Three.js MorphNode bileşeninin morphTargetInfluences değerini skaler olarak tanımlayıp alt indisle erişmesinden kaynaklandığı belirlendi. Sorun, biçim değiştirme kullanmayan geometrilerde morphAttributes temizlenerek giderildi. Yalnızca hangi gölgelendirici yolunun oluşturulduğunu değiştiren ilk düzeltme, asıl nedeni maskeledi ve daha sonra Safari'yi bozdu. skinIndex/skinWeight değerlerini boneIndex/boneWeight olarak yeniden adlandırmak, öznitelikleri Three.js'nin otomatik SkinningNode eklemesinden gizliyor; böylece matematiğin denetimi özel iskelet animasyonu malzemesinde kalıyor.

Hazırda bekleyen Durable Object aktarıcıları. Yalnızca ikili veri kullanan bir AvatarRoomDO, 36 baytlık kareleri çözmeden diğer tüm eşlere iletiyor; gönderenin kimliği karenin içine gömülüyor ve istemci kendi yankısını filtreliyor. Hazırda bekleyen WebSocket'ler sayesinde boşta duran bir odanın maliyeti olmuyor ve bu yapı 10 Hz'de eş-saat başına yaklaşık yarım sente mal oluyor; bu da eşdeğer yönetilen WebSocket fiyatlandırmasının çok altında. Oynatılan animasyon durumunu son kuyruğa alınan durum yerine karşılaştıran bir yinelenen ileti engelleme denetimi, durdurma mesajlarını sessizce düşürüyor ve uzaktaki oyuncuları yürüme döngüsünde takılı bırakıyordu.

HRTF ile WebRTC yakınlık sesi. Her eş için bir RTCPeerConnection kullanılıyor; teklif/yanıt rolleri eş kimliği sıralamasına göre belirleniyor. Ses, oyuncunun baktığı yöne göre her karede güncellenen bir AudioListener ile HRTF özellikli bir PannerNode üzerinden yönlendiriliyor ve Opus, dayanıklılık için bant içi FEC ile 128 kbps'e ayarlanıyor. Sessize alınmış, gizli bir <audio> öğesi Chrome'u yalnızca Web Audio kullanan akıştan paketleri çekmeye zorluyor.

Eksiltici ses mühendisliği. Meet/Teams kalitesini yakalamak, aşama eklemeyi değil kaldırmayı gerektirdi: makine öğrenimi tabanlı gürültü giderme, yankı engelleme, otomatik kazanç ve yumuşak bir kompresör kullanılırken geçit ve tıklama sınırlayıcı kullanılmadı; çünkü klavye gürültüsüyle eğitilmiş bir makine öğrenimi gürültü gidericisi, genlik kırpmayı gereksiz kılıyor. Bas-konuş, hecelerin kesilmemesi için parçayı açıp kapatmak yerine zincirin sonundaki bir GainNode değerini asimetrik bir zarfla kademeli olarak değiştiriyor. Gürültü giderici worklet'i ise yalın belirteçli içe aktarımların çözümlenmesi sorununu aşmak için kendi kendine yeten tek bir esbuild paketi olarak sunuluyor.


29 bölümün 23. bölümü. Önceki: Bölüm 22 - Aydınlatabileceğiniz bulutlar ve beslenmesi gereken ayıklama Sonraki: Bölüm 24 - Bir dünyayı kaydetmek ve görebileceğiniz rüzgâr Seri rehberi: /blog/2026-02-25-open-world-browser-series-guide