Membangun dunia terbuka di browser, bagian 15: Ganti fondasinya, lalu sinkronkan
Oleh Oleg Sidorkin, CTO dan Co-Founder Cinevva
Baru mengikuti seri ini? Gunakan panduan seri. Panduan tersebut menjelaskan apa itu spike dan menyediakan tautan ke semua bagian.
Empat belas bagian pertama membahas spike 1 hingga 30. Rangkaian itu berakhir dengan sistem medan yang dapat dibentuk secara real time, ditambah karakter yang dapat berjalan, meluncur, dan jatuh di atasnya. Bagian ini melanjutkan seri dari spike 31, dan hal pertama yang harus kami putuskan bukanlah persoalan teknis. Persoalannya adalah apa yang harus dilakukan dengan seluruh kode spike tersebut.
Keputusan "ganti, jangan backport"
Kami memiliki 30 file HTML mandiri, masing-masing membuktikan satu konsep terpisah, tanpa integrasi sama sekali. world/client/ produksi masih menggunakan stack lama: WebGL, heightmap sederhana, controller karakter sepanjang 75 baris, serta protokol MessagePack dengan sembilan jenis pesan. Tidak ada pengeditan, WebGPU, material, maupun vegetasi.
Rencana yang tampak jelas adalah melakukan backport hasil spike ke codebase produksi tersebut satu per satu. Kami membatalkan rencana itu. Spike 30 sudah memiliki medan, fisika, material, vegetasi, dan kamera yang lebih baik daripada apa pun yang pernah ada di world/client/. Melakukan backport ke kode WebGL lama berarti harus terus melawannya sepanjang proses. Jadi, kami memutuskan untuk mengganti implementasi dunia dengan spike yang paling berhasil dan melanjutkan pengembangan dari sana. Spike 30 menjadi fondasi baru, sedangkan world/client/ menjadi kode mati.
Keputusan itu mengubah kerangka kerja yang tersisa. Untuk beralih dari "demo teknologi single-player yang hebat" menjadi "produk", kami membutuhkan multiplayer, persistensi, streaming dunia tanpa batas, dan penempatan objek. Sinkronisasi medan multiplayer dikerjakan lebih dahulu karena aspek inilah yang memaksa terbentuknya arsitektur. Pertanyaan yang dijawabnya mudah diajukan, tetapi sangat mahal jika dijawab dengan keliru: ketika Pemain A membentuk medan, apa yang sebenarnya dikirim melalui jaringan?
Replay parameter kuas, bukan sinkronisasi piksel
Buka Spike 31 di tab baru ↗ · Lihat kode sumber
Sebelum menulis satu baris pun kode jaringan, kami menelusuri secara persis apa yang dilakukan satu sapuan kuas. Kuas heightmap menelusuri radius di sekitar kursor dalam Float32Array CPU, menerapkan falloff smoothstep, lalu menambah, mengurangi, menghaluskan, atau meratakan nilainya. Kuas SDF melakukan hal yang sama dalam 3D pada sebuah bola voxel. Kedua jalur tersebut sepenuhnya berupa operasi matematika array di CPU. Tidak ada komputasi GPU dalam loop, keacakan, ataupun floating point nondeterministik. Array input yang sama ditambah parameter yang sama menghasilkan output yang sama di setiap mesin.
Itulah keseluruhan triknya. Kami tidak mengirim medan yang telah diedit. Kami mengirim parameter kuas, 56 byte per tick sapuan, lalu setiap klien menjalankan ulang fungsi deterministik yang sama. Protokol sinkronisasi terdiri atas empat jenis pesan: penemuan peer, pesan kuas {op, wx, wy, wz, radius, strength, flattenTarget}, dan pesan posisi pemain pada 20Hz.
Untuk spike ini, kami sepenuhnya melewati server dan menggunakan BroadcastChannel, API browser untuk pengiriman pesan lintas tab dengan origin yang sama. Buka dua tab, keduanya langsung berkomunikasi tanpa infrastruktur apa pun. Dengan demikian, persoalan sinkronisasi dapat dipisahkan dari latensi, autentikasi, dan pengkabelan Durable Object. Jika replay parameter mencapai hasil yang sama di berbagai tab, pendekatan itu juga akan mencapai hasil yang sama melalui WebSocket.
Satu-satunya tempat replay dapat menyimpang adalah operasi yang bergantung pada urutan. Menaikkan dan menurunkan bersifat komutatif, sehingga val + strength * falloff menghasilkan nilai yang sama terlepas dari siapa yang menerapkannya lebih dahulu. Operasi penghalusan dan perataan membaca nilai tetangga, sehingga dua klien yang menghaluskan titik yang benar-benar sama pada saat yang sama dapat menyimpang beberapa fraksi milimeter per tick. Dalam praktiknya, kondisi itu hampir tidak pernah terjadi, dan solusi produksinya sudah jelas: rutekan pengeditan melalui DO, biarkan DO menetapkan nomor urut monotonik, terapkan secara optimistis pada klien, lalu koreksi urutannya jika urutan otoritatif tidak sesuai. Ini adalah optimistic concurrency klasik, dan DO memang merupakan titik serialisasi yang alami.
Kapsul peer yang terus menghilang
Pengeditan tersinkronisasi pada percobaan pertama. Kapsul pemain jarak jauh tidak. Kapsul itu berkedip, muncul dan menghilang di tab lain, dan diperlukan perbaikan terhadap tiga bug terpisah agar kapsul tersebut tetap terlihat solid.
Kapsul dibuat di titik asal dunia, yang terkubur di bawah medan, karena pesan join tiba sebelum data posisi apa pun. Solusinya: mulai dalam keadaan tersembunyi dan tampilkan pada pembaruan posisi pertama. Siaran posisi berada di dalam render loop, sedangkan Chrome membatasi requestAnimationFrame pada tab yang tidak aktif. Akibatnya, pemeriksaan kedaluwarsa pada tab lain akan menghapus peer, lalu pesan berikutnya akan membuatnya kembali. Solusinya: pindahkan siaran ke setInterval, yang tidak dibatasi untuk tab yang terlihat. Selain itu, batas waktu kedaluwarsa 5 detik terlalu agresif dan dapat terpicu oleh jeda GC apa pun. Solusinya: naikkan menjadi 30 detik dan andalkan pesan leave yang bersih untuk penutupan normal.
Persistensi dan late-join, format yang sama
Kami menyatukan persistensi ke dalam spike yang sama alih-alih membuat spike baru, karena format serialisasinya identik, baik tujuannya IndexedDB maupun tab lain. Snapshot berisi heightmap lengkap (Float32Array 129×129, sekitar 66 KB), hanya chunk SDF yang telah diedit (masing-masing
Pengujian persistensi pertama menemukan bug urutan yang menarik. Rumput disebarkan secara sinkron saat inisialisasi menggunakan ketinggian prosedural, tetapi pemulihan IndexedDB berlangsung secara asinkron dan menimpa heightmap setelahnya, sehingga setiap helai rumput mengambang atau tenggelam. Solusinya adalah pass refreshAllGrass() yang mengambil ulang sampel ketinggian di bawah setiap instance dan menyembunyikan helai rumput yang kini berada pada kemiringan atau ketinggian yang tidak sesuai. Fungsi yang sama digunakan untuk pemuatan maupun late-join.
Drama lereng
Medan yang dibentuk lebih kasar daripada fondasi prosedural yang mulus, dan hal itu mengungkap tiga bug fisika yang tidak mungkin ditemukan pada medan lama. Berjalan lurus menanjak membuat kapsul meluncur ke samping. Penyebabnya adalah proyeksi kecepatan yang dimaksudkan agar pergerakan tetap menyinggung permukaan tanah, tetapi ditulis hanya menggunakan komponen horizontal dari normal. Pada lereng diagonal dengan normal
Drift tetap terjadi akibat sumber kedua. Probe tabrakan SDF mendorong body keluar mengikuti gradien sebesar kedalaman penetrasi. Pada lereng apa pun, gradien memiliki komponen horizontal, sehingga penetrasi 0,1 m pada lereng 15° mendorong sekitar 0,026 m ke samping per langkah. Pada 120Hz, itu setara dengan drift tak terlihat sekitar 3 m/s. Solusinya: pisahkan respons berdasarkan kemiringan. Pada permukaan yang dapat dilalui (
Bug ketiga membuat kapsul membeku di batas chunk karena probe tabrakan mengambil sampel SDF dari satu chunk saja dan menerima sentinel "jauh di udara" ketika probe menyeberang ke chunk tetangga. Solusinya adalah sdfSampleWorld(wx, wy, wz) dan sdfGradientWorld(...), yang menemukan chunk yang tepat untuk posisi dunia mana pun dan menggunakan estimasi jarak heightmap sebagai fallback ketika SDF tidak tersedia. Transisi tabrakan dari SDF ke heightmap kini berlangsung mulus.
Air menyempurnakan dunia
Buka Spike 32 di tab baru ↗ · Lihat kode sumber
Setiap spike hingga titik ini berfokus pada "daratan di atas air". Spike 32 menambahkan lautan dan, bersamanya, aksi pergerakan baru. Kami menetapkan permukaan air pada ketinggian 22 di medan yang berkisar antara 8 hingga 58. Pengaturan ini membanjiri lembah rendah, menyisakan pantai di garis pesisir, dan mempertahankan banyak daratan kering untuk bermain.
Permukaannya menggunakan MeshStandardNodeMaterial yang dibuat dengan TSL, pendekatan node yang sama seperti pada medan. Tiga gelombang sinus yang saling bertumpuk pada frekuensi berbeda menggeser vertex, sedangkan normal permukaan berasal dari turunan kosinus analitik gelombang tersebut, bukan dari normal mesh. Warna bertransisi dari biru kehijauan di perairan dangkal menjadi teal gelap di perairan dalam menggunakan estimasi kedalaman
Mekanisme berenang menggunakan pegas daya apung. Pemain memasuki mode berenang ketika kaki turun melewati permukaan air dan pusat tubuh berada dalam jarak setengah tinggi kapsul dari permukaan. Sebuah pegas menarik tubuh menuju target tepat di bawah permukaan. Dengan konstanta daya apung 12 dan redaman air 4, pemain mengapung stabil dengan kepala di atas air tanpa osilasi. Kecepatan berenang lebih lambat daripada berjalan, dengan akselerasi dan drag yang terasa mengambang. Melompat di dekat permukaan akan melontarkan pemain keluar dengan 60% kecepatan lompatan normal, sedangkan saat masuk ke air, kecepatan turun dibatasi pada -5 m/s agar pemain tidak langsung terjun terlalu dalam. Tabrakan medan tetap berjalan di bawah air, sehingga Anda dapat berjalan di dasar danau ketika dasarnya naik melebihi target berenang. Flag berenang ikut dikirim dalam siaran posisi agar peer dapat melihat Anda berenang, dan overlay gradien HTML memberi rona pada tampilan ketika kamera turun ke bawah permukaan.
Teknologi yang dirujuk dalam bab ini
Replay parameter kuas deterministik. Alih-alih melakukan streaming medan yang telah diedit, setiap klien hanya mengirim parameter kuas dan menjalankan ulang fungsi CPU yang sama. Hal ini dimungkinkan karena kuas heightmap dan SDF sama-sama berupa operasi matematika murni Float32Array tanpa keacakan atau nondeterminisme GPU, sehingga input identik menghasilkan output yang identik hingga tingkat bit di mana pun. Payload-nya sebesar 56 byte per tick sapuan. Operasi komutatif (menaikkan, menurunkan) mencapai hasil yang sama terlepas dari urutan, sedangkan operasi yang membaca nilai tetangga (menghaluskan, meratakan) memerlukan titik serialisasi untuk menjamin konvergensi. Durable Object produksi menyediakannya melalui nomor urut monotonik.
BroadcastChannel sebagai pengganti sementara WebSocket. API browser untuk pengiriman pesan lintas tab dengan origin yang sama tanpa server. Di sini, API tersebut digunakan untuk menguji protokol sinkronisasi secara terpisah dari latensi jaringan dan autentikasi. Format serialisasinya (Float32Array heightmap mentah, chunk SDF yang diedit, serta ID chunk yang dikunci ke MC) menggunakan byte yang sama untuk persistensi IndexedDB dan transfer state late-join, sehingga satu format dapat menangani tiga tugas.
Respons tabrakan SDF yang dipisahkan berdasarkan kemiringan. Ketika probe kapsul menembus medan volumetrik, solusi naifnya adalah mendorong body keluar mengikuti gradien SDF sebesar kedalaman penetrasi. Pada lereng, gradien tersebut memiliki komponen horizontal yang menghasilkan drift lateral. Memisahkan respons agar permukaan yang dapat dilalui (
Air TSL dengan normal gelombang analitik. Lautan menggunakan material node dengan vertex yang digeser oleh penjumlahan tiga gelombang sinus. Alih-alih menghitung ulang normal mesh setelah pergeseran, normal permukaan diturunkan secara analitik dari turunan kosinus fungsi gelombang. Pendekatan ini lebih murah dan menghindari artefak normal beda hingga pada grid kasar. Warna berbasis kedalaman, buih garis pantai, dan transparansi berbasis kedalaman semuanya menggunakan satu estimasi kedalaman yang sama.
Berenang dengan pegas daya apung. Fisika berenang memodelkan tubuh sebagai pegas teredam yang ditarik menuju target tepat di bawah permukaan. Dengan konstanta daya apung 12 dan redaman 4, pemain menetap di permukaan tanpa berosilasi. Konstanta pergerakan yang berbeda—kecepatan lebih lambat, akselerasi mengambang, dan drag besar—membuat berenang terasa berbeda dari berjalan, sementara sistem tabrakan kapsul dengan medan yang sudah ada tetap berfungsi di bawah air.
Bagian 15 dari 29. Sebelumnya: Bagian 14 - Dunia menjadi hidup Berikutnya: Bagian 16 - Struktur untuk dunia yang terus berkembang Panduan seri: /id/blog/2026-02-25-open-world-browser-series-guide