Skip to content

Membangun dunia terbuka di browser, bagian 23: Lima puluh avatar dan satu suara di ruangan

Oleh Oleg Sidorkin, CTO dan Salah Satu Pendiri Cinevva

Baru di sini? Gunakan panduan seri. Panduan ini menjelaskan apa itu spike dan menautkan semua bagiannya.

Bagian 22 menempatkan langit di atas dunia. Bagian ini menempatkan orang-orang di dalamnya. Dunia terbuka orang ketiga ingin menampilkan lebih dari 50 karakter pada waktu tertentu, dan ingin mendengar mereka yang berdiri di dekat Anda. Spike 45 membahas sisi rendering: memasukkan avatar animasi sebanyak itu ke GPU tanpa membebani main thread secara berlebihan. Spike 46 membahas sisi audio: suara peer-to-peer yang menggeser kanal dan meredam volume sesuai posisi, disetel agar terdengar seperti panggilan video biasa, bukan demo teknologi.

Satu draw call untuk lima puluh penari

Buka Spike 45 di tab baru ↗ · Lihat kode sumber

Jalur bawaan Three.js memberikan setiap karakter SkinnedMesh sendiri, AnimationMixer sendiri, pengunggahan matriks tulang sendiri, dan draw call sendiri. Dengan 50 avatar di Mac lokal, beban ini mencapai sekitar 13 md overhead JavaScript murni per frame sebelum GPU melakukan apa pun. Pertanyaan pengurangan risiko untuk seluruh jalur multipemain adalah apakah satu arsitektur skinning secara batch dapat mengendalikan beban tersebut dan berskala linear terhadap jumlah karakter.

Jawabannya adalah memisahkan tempat animasi dihitung dan tempat animasi digambar. Tiga kelas karakter berbagi satu templat FBX. Pemain lokal merupakan Avatar biasa, yaitu klona kerangka lengkap dengan mixer-nya sendiri dan jalur Three.js standar, karena jumlahnya selalu hanya satu. Setiap peer jarak jauh merupakan VirtualSkeleton: juga klona lengkap dengan mixer sendiri yang menjalankan klip yang sama, tetapi setiap node SkinnedMesh langsung dihapus setelah kloning sehingga hanya tulangnya yang tersisa. Objek ini tidak pernah masuk ke scene. Pada setiap frame, setelah mixer diperbarui dan matriksnya stabil, objek ini mengemas (bone.matrixWorld × boneInverse) untuk seluruh 100 tulang ke dalam satu slot pada Float32Array bersama. BatchSkinnedRenderer kemudian memiliki satu InstancedMesh untuk setiap bagian geometri, yang semuanya membaca dari satu StorageBufferAttribute berisi matriks tulang dengan ukuran maxInstances × numBones × mat4, yaitu 60 × 100 × 64 = 384 KB. Sebuah MeshStandardNodeMaterial dengan positionNode dan normalNode khusus membaca empat pengaruh tulang per vertex langsung dari storage buffer tersebut. Hasilnya adalah satu pengunggahan storage dan satu draw call per bagian geometri untuk seluruh kerumunan, berapa pun jumlah orang di dalamnya. Skinning berjalan di vertex shader, sedangkan biaya JavaScript per avatar turun menjadi menjalankan mixer dan menyalin 100 matriks.

HUD yang mengukurnya juga harus dibangun ulang. Versi lama menandai "melebihi anggaran" ketika waktu GPU seluruh frame melewati 3 md, tetapi frame tersebut selalu mencakup shadow map, permukaan tanah, dan skinned mesh lengkap milik pemain lokal, yang bersama-sama membutuhkan 3 hingga 5 md pada perangkat keras nyata, berapa pun jumlah peer sintetis yang ada. Solusinya adalah anggaran yang mengalibrasi dirinya sendiri: selama belum ada avatar batch, HUD mengambil waktu GPU aktif sebagai baseline melalui EMA cepat, lalu membekukan baseline tersebut dan menaikkan anggaran secara linear sebesar 0,06 md untuk setiap avatar tambahan setelah avatar sintetis muncul. HUD menampilkan PASS saat idle di setiap mesin dan memperketat batas secara proporsional seiring membesarnya kerumunan.

Bug-nya adalah wajah yang tak terlihat

Pengujian pertama di Chrome menampilkan bayangan keperakan di tanah tanpa avatar sama sekali, disertai error parsing WGSL: cannot index type 'f32' pada baris yang mencoba mengakses object.nodeUniform2[i], padahal uniform tersebut dideklarasikan sebagai skalar. Bagian jujur dari kisah ini adalah bahwa perbaikan pertama ternyata salah, tetapi tetap berhasil. Dugaan awalnya adalah jalur instance matrix milik InstancedMesh menghasilkan kode yang bermasalah, dan menggantinya dengan StorageInstancedBufferAttribute membuat error tersebut hilang di Chrome. Namun, error itu hilang karena jalur baru menghasilkan kode shader yang berbeda, bukan karena penyebabnya telah ditangani. Ini adalah jenis perbaikan yang paling berbahaya.

Penyebab sebenarnya adalah morph target. 3MIKE.fbx menyertakan ekspresi wajah blend-shape, geometri hasil kloning mewarisi morphAttributes, dan MorphNode.setup() milik Three.js mendeklarasikan morphTargetInfluences sebagai float skalar lalu mencoba memanggil .element(i) di dalam loop hasil sintesis. Itulah operasi subskrip skalar yang ditolak compiler. Perbaikannya hanya satu baris, yaitu mengosongkan geometry.morphAttributes = {} pada geometri yang tidak menggunakan morph, sehingga Three.js tidak pernah menyisipkan MorphNode. Perbaikan Chrome yang tidak disengaja itu dipertahankan untuk sementara waktu, lalu menimbulkan masalah: di Safari, jalur storage-instanced menghasilkan Vertex buffer is not big enough sebanyak 256 kali karena backend WebGPU Safari tidak menerjemahkannya dengan baik. Mengembalikannya adalah keputusan yang tepat, dan storage buffer matriks tulang biasa, yang merupakan bagian inti WebGPU alih-alih jalur instance hasil generasi, berfungsi dengan baik di semua browser. Pelajarannya patut diingat: ketika sebuah perbaikan berhasil di satu browser dan Anda tidak dapat menjelaskan mekanismenya, berarti Anda hanya menambal gejala, jadi bacalah WGSL yang benar-benar dihasilkan. Shim getCompilationInfo() yang kemudian ditambahkan dalam spike mengubah pesan umum Three.js, "module is not valid", menjadi error Tint yang sebenarnya dan berkali-kali membuktikan manfaatnya.

Di sebelahnya terdapat trik terkait untuk menghindari intervensi framework. Three.js mendeteksi nama atribut standar skinIndex dan skinWeight, lalu mencoba menyisipkan SkinningNode sendiri, bahkan pada InstancedMesh yang positionNode khususnya sudah melakukan skinning. Mengganti nama atribut tersebut menjadi boneIndex dan boneWeight menyembunyikannya dari framework, lalu TSL khusus membacanya dengan nama baru.

Relay yang melupakan Anda di antara kata-kata

Versi pertama menyinkronkan peer melalui BroadcastChannel, pengganti dalam browser yang sama dengan format wire dan frekuensi pengiriman sebenarnya, dan komentar protokolnya menjanjikan bahwa peralihan ke transportasi nyata hanya memerlukan perubahan satu baris. Mewujudkan janji itu menghasilkan AvatarRoomDO, Cloudflare Durable Object sepanjang 74 baris yang bahkan tidak mendekode frame biner 36 byte. Objek tersebut meneruskan setiap pesan apa adanya kepada semua peer lain di ruangan, karena id pengirim tertanam di dalam frame dan setiap penerima menyaring echo miliknya sendiri di sisi klien. Relay sama sekali tidak mengetahui identitas. Hibernating WebSockets membuat ruangan idle tidak memerlukan biaya: DO dikeluarkan dari memori di antara pesan, lalu runtime memulihkan socket bertag saat paket berikutnya tiba. Dengan 10 event per detik per peer, jumlahnya menjadi 36.000 permintaan DO per peer-jam, sekitar setengah sen, dengan egress gratis di Cloudflare dan kira-kira 6 hingga 10 kali lebih murah daripada konfigurasi WebSocket AWS yang setara.

Peralihan ini mengungkap bug state machine yang layak dicatat. Seorang pemain jarak jauh terus berjalan setelah mereka berhenti. Permintaan animasi melakukan pemeriksaan terhadap this._state, yaitu klip yang sedang diputar, bukan nama terakhir dalam antrean. Akibatnya, ketika dua pesan jaringan tiba dalam tick yang sama, pertama walk lalu idle, idle dibandingkan dengan state yang belum sempat berubah dan dibuang diam-diam. Peer tersebut terus berjalan selamanya karena paket idle berikutnya dideduplikasi di hulu sebagai sesuatu yang tidak berubah. Perbaikannya adalah selalu menimpa nama yang tertunda dan membiarkan helper transisi menghentikan lebih awal permintaan dengan state yang benar-benar sama, sebagaimana memang sudah dilakukannya. Kelas bug ini bersifat umum: pemeriksaan deduplikasi terhadap nilai acuan yang salah diam-diam menelan input yang justru penting.

Safari membutuhkan dua guard tambahan. Browser ini membuka WebSocket lebih cepat daripada Chrome, sehingga pesan peer masuk yang pertama dapat tiba sebelum batch renderer selesai dibuat dan menyebabkan dereferensi null; membuang pesan saat renderer belum tersedia aman dilakukan karena peer menyiarkan ulang setiap 100 md. Selain itu, 'gpu' in navigator mengembalikan true sementara requestAdapter() mengembalikan null, sehingga Three.js diam-diam beralih ke WebGL2. Di sana, rantai skinning storage-buffer tidak memiliki terjemahan yang valid dan memuntahkan error. Memeriksa keberadaan adapter sungguhan dan memastikan backend benar-benar WebGPU mengubah render yang terdegradasi menjadi pesan yang jelas di layar pemuatan. Bahkan ada perbedaan dialek WGSL: Three.js menghasilkan @interpolate(flat, either) modern dengan dua argumen yang belum didukung compiler WebKit. Ini ditambal dengan menulis ulang source shader saat masuk ke createShaderModule untuk menghapus argumen kedua, tanpa biaya apa pun karena interpolasi flat membawa nilai yang sama pada setiap vertex.

Suara yang bergeser mengikuti ruangan

Buka Spike 46 di tab baru ↗ · Lihat kode sumber

Spike 46 adalah suara berbasis kedekatan: WebRTC peer-to-peer dengan audio spasial HRTF, yang cakupannya secara eksplisit ditetapkan agar menyamai kualitas Google Meet dan Microsoft Teams di ruangan yang tenang hingga cukup bising. Sebuah VoiceRoomDO menangani signaling sebagai relay JSON, mengirimkan daftar penghuni kepada setiap peer baru, mengumumkan peer yang bergabung dan keluar, merutekan SDP dan ICE ke satu peer tertentu berdasarkan tag socket, serta menyiarkan pembaruan posisi yang menggerakkan panner spasial. DO tersebut membubuhkan id pengirim pada setiap pesan agar peer tidak dapat menyamar sebagai peer lain, sementara audio itu sendiri tidak pernah melewati DO. Ada satu RTCPeerConnection untuk setiap peer jarak jauh, dan peer dengan id yang lebih kecil secara leksikografis selalu membuat offer agar kedua sisi sepakat tentang siapa yang memulai tanpa implementasi perfect negotiation secara penuh.

Di sisi penerima, audio setiap peer melewati PannerNode yang disetel ke HRTF dengan pelemahan inverse distance, dan AudioListener diperbarui setiap frame berdasarkan posisi dan arah hadap pemain lokal menggunakan forwardX = sin(facing), forwardZ = cos(facing), yang sesuai dengan konvensi arah hadap atan2(wx, wz) pada scene. Satu keanehan Chrome menghabiskan waktu satu jam: MediaStream yang hanya digunakan oleh Web Audio terkadang tidak menarik paket, sehingga setiap stream juga dihubungkan ke elemen <audio> tersembunyi yang dibisukan untuk memaksa decoder menjadwalkan pemrosesan. Dari sisi kualitas, browser secara default menggunakan Opus mono sekitar 32 kbps. Karena itu, spike mengubah baris fmtp pada setiap offer dan answer untuk menaikkannya menjadi 128 kbps dengan FEC in-band diaktifkan dan DTX dinonaktifkan, lalu memanggil setParameters dengan bitrate maksimum yang tinggi untuk menjamin encoder benar-benar menggunakan nilai yang diiklankan SDP. FEC memberikan peningkatan audio terbesar kedua setelah kenaikan bitrate, dengan memulihkan kehilangan paket tanpa renegosiasi.

Menghapus komponen demi audio yang jernih

Rantai audio yang akhirnya dirilis jauh lebih kecil daripada versi awal saya, dan menyusutkannya merupakan pelajaran yang sebenarnya. Versi pertama memiliki high-pass filter, click limiter yang disetel untuk menangkap suara keyboard, compressor, noise gate, dan crossfade wet-dry, didahului panel mengambang dengan lebih dari dua belas slider. Ketika pengguna melaporkan suara klik keyboard yang terdengar, naluri awalnya adalah menyetel click limiter lebih agresif dan mengurangi dry mix, sebuah tumpukan tambalan. Jawaban strukturalnya adalah bahwa setelah denoiser ML masuk ke dalam rantai, click limiter, gate, dan sebagian besar high-pass menjadi berlebihan, karena RNNoise memang dilatih untuk menangani suara keyboard, tetikus, dan ketikan, sedangkan amplitude clipping hanyalah versi yang lebih buruk untuk pekerjaan yang sama. Klien produksi menggunakan denoise berbasis ML, echo cancellation, automatic gain, dan soft compressor untuk meratakan level, tanpa komponen lain. Jadi empat tahap dihapus, panel slider dihapus, dan opsi "pilih pengurangan noise" juga dihapus, menyisakan satu pipeline tetap.

Setiap tahap yang tersisa harus membuktikan manfaatnya. Echo cancellation browser tetap aktif karena RNNoise tidak menangani echo, dan tanpanya feedback dari speaker ke mikrofon tidak memiliki batas. Noise suppression browser dimatikan karena menumpuknya di atas RNNoise menghasilkan artefak pada konsonan frikatif, jadi hanya satu denoiser yang dipilih. Automatic gain browser tetap aktif karena menonaktifkannya membuat sinyal terlalu pelan untuk diproses compressor, sedangkan DynamicsCompressorNode milik Web Audio tidak memiliki parameter makeup gain sebagai kompensasi; perataan level luas dari browser dan compressor cepat milik spike bekerja pada rentang waktu berbeda dan dapat digunakan bersamaan. RNNoise berjalan dengan 92 persen wet yang dicampur 8 persen dry karena RNNoise dapat meredam secara berlebihan konsonan tak bersuara seperti s, sh, dan f ketika probabilitas suaranya turun, sementara jalur dry yang kecil mempertahankan konsonan tersebut dengan konsekuensi sedikit suara ketikan ikut bocor. Dua fitur melengkapinya. Push-to-talk tidak mengubah track.enabled, karena tindakan itu membuang semua data yang masih berada dalam buffer pipeline dan memotong suku kata terakhir saat tombol dilepas. Sebagai gantinya, sebuah GainNode di dekat ujung rangkaian melakukan ramp dengan setTargetAtTime: attack cepat agar suku kata pertama tetap terdengar dan release lambat agar konsonan terakhir sempat tersalurkan, sementara track dibiarkan selalu aktif. Selain itu, penundaan siaran lima detik yang diminta sebagai fitur bergaya radio dijalankan melalui jalur bypass dan jalur DelayNode yang di-crossfade bersama, dengan tombol dump yang langsung membisukan output tertunda dan menampilkan hitung mundur di HUD sebelum audio dilanjutkan. Membundel denoiser menjadi kisah kecil tersendiri: worklet RNNoise yang dipublikasikan menggunakan impor bare-specifier yang tidak dapat di-resolve oleh CDN mana pun, sehingga solusinya adalah bundle esbuild lokal yang menghasilkan satu berkas mandiri berukuran 1,9 MB dengan WASM yang disisipkan sebagai base64, di-commit ke repo, dan dirujuk melalui URL relatif terhadap modul agar dapat di-resolve dengan cara yang sama di server pengembangan, build VitePress, dan domain khusus. Jika worklet gagal dimuat, rangkaian tetap menghasilkan audio melalui high-pass dan kompresor biasa, sementara HUD menampilkan kegagalan tersebut dengan warna merah.

Teknologi yang dirujuk dalam bab ini

GPU skinning berkelompok untuk kerumunan. Avatar jarak jauh menjalankan VirtualSkeleton headless (klona penuh dengan mesh ber-skin yang dihapus, tulang dipertahankan, dan mixer tersendiri) yang mengemas bone.matrixWorld × boneInverse untuk setiap tulang ke dalam StorageBufferAttribute bersama. Satu InstancedMesh per bagian geometri membaca matriks tersebut dalam positionNode/normalNode TSL khusus, sehingga seluruh kerumunan hanya memerlukan satu unggahan storage dan satu draw call per bagian, dengan pekerjaan CPU per avatar terbatas pada pembaruan mixer dan penyalinan matriks. Lihat LOD berbasis GPU.

Membaca WGSL yang dihasilkan, bukan gejalanya. Error kompilasi cannot index type 'f32' ditelusuri hingga MorphNode Three.js yang mendeklarasikan morphTargetInfluences sebagai skalar lalu mengaksesnya dengan subskrip; masalah ini diperbaiki dengan mengosongkan morphAttributes pada geometri yang tidak menggunakan morph. Perbaikan pertama yang hanya mengubah jalur shader yang dihasilkan menutupi penyebabnya dan kemudian merusak dukungan Safari. Mengganti nama skinIndex/skinWeight menjadi boneIndex/boneWeight menyembunyikan atribut tersebut dari injeksi otomatis SkinningNode milik Three.js sehingga material skinning khusus dapat sepenuhnya menangani perhitungannya.

Relay Durable Object yang berhibernasi. AvatarRoomDO biner murni meneruskan frame 36 byte ke setiap peer lain tanpa mendekodenya, dengan identitas pengirim disematkan dalam frame dan gema balik ke pengirim sendiri difilter di sisi klien. WebSocket yang berhibernasi membuat ruang yang tidak aktif bebas biaya, dan arsitektur ini memerlukan biaya sekitar setengah sen per jam-peer pada 10 Hz, jauh di bawah harga layanan WebSocket terkelola yang setara. Guard deduplikasi yang membandingkan dengan status animasi yang sedang diputar, bukan status terakhir yang masuk antrean, secara diam-diam membuang pesan berhenti dan membuat pemain jarak jauh terjebak dalam loop berjalan.

Suara proksimitas WebRTC dengan HRTF. Satu RTCPeerConnection per peer dengan peran offer/answer yang ditentukan berdasarkan urutan ID peer, audio disalurkan melalui PannerNode HRTF dengan AudioListener yang diperbarui setiap frame berdasarkan arah hadap pemain, dan Opus diubah menjadi 128 kbps dengan FEC in-band agar lebih tangguh. Elemen <audio> tersembunyi yang dibisukan memaksa Chrome mengambil paket dari stream yang hanya menggunakan Web Audio.

Rekayasa audio subtraktif. Menyamai kualitas Meet/Teams berarti mengurangi tahap, bukan menambahkannya: denoise ML ditambah pembatalan gema, penguatan otomatis, dan kompresor halus, tanpa gate maupun click limiter, karena denoiser ML yang dilatih untuk menangani suara keyboard membuat clipping amplitudo tidak lagi diperlukan. Push-to-talk melakukan ramp pada GainNode di ujung rangkaian dengan envelope asimetris alih-alih mengaktifkan atau menonaktifkan track agar suku kata tidak terpotong, dan worklet denoiser dikirim sebagai satu bundle esbuild mandiri untuk menghindari masalah resolusi impor bare-specifier.


Bagian 23 dari 29. Sebelumnya: Bagian 22 - Awan yang dapat disinari, dan culling yang harus diberi data Berikutnya: Bagian 24 - Menyimpan dunia, dan angin yang dapat dilihat Panduan seri: /id/blog/2026-02-25-open-world-browser-series-guide