Membangun dunia terbuka di browser, bagian 24: Menyimpan dunia dan angin yang terlihat
Oleh Oleg Sidorkin, CTO dan Co-Founder Cinevva
Baru di sini? Gunakan panduan seri. Panduan ini menjelaskan apa itu spike dan menautkan semua bagiannya.
Bagian 23 menempatkan orang-orang di dalam dunia dan memberi mereka suara. Bagian ini membahas cara membuat dunia mengingat apa yang mereka lakukan terhadapnya, serta membuatnya terasa hidup ketika tak seorang pun menyentuhnya. Spike 47 membahas persistensi: kreator membentuk terrain dan menempatkan prop, lalu perubahan tersebut tetap ada setelah pemuatan ulang, disinkronkan ke setiap peer lain, dan diatur dengan rapi ketika dua orang mengedit secara bersamaan. Spike 49 membahas angin: mem-port shader vegetasi dari sebuah paket alam bergaya agar pepohonan, semak-semak, dan rumput bergerak seperti yang dimaksudkan oleh senimannya—yang ternyata lebih banyak menjadi pertarungan melawan compiler shader daripada melawan perhitungannya.
Dunia yang mengingat
Buka Spike 47 di tab baru ↗ · Lihat kode sumber
Skenarionya adalah sesi pembuatan bersama. Pemain menjelajahi dunia, menempatkan prop dengan sekali klik, menghapusnya dengan klik kanan, dan membentuk permukaan tanah dengan menyeret kuas, baik untuk menaikkan dan menurunkan heightmap maupun mengukir gua volumetrik melalui terrain SDF yang mengalihkan sebuah chunk ke marching cubes. Semua yang mereka lakukan disimpan secara persisten di WorldChunkDO Cloudflare, sebuah Durable Object otoritatif-server yang didukung oleh penyimpanan SQLite-nya sendiri. Klien tidak menulis state secara langsung. Mereka mengirimkan intent, DO mengatur dan menyiarkan hasilnya, dan DO menjadi satu-satunya sumber kebenaran. Dengan demikian, peserta baru menerima snapshot dan masuk ke dunia yang persis sama dengan yang dilihat semua orang lain.
Dua detail persistensi terbukti sangat berguna. Terrain tidak disimpan sebagai log peristiwa yang harus diputar ulang saat bergabung; terrain disimpan sebagai blob biner per chunk yang menjadi sumber kebenaran dan diunggah ketika stroke berakhir. Dengan begitu, peserta baru langsung memuat byte yang sudah di-commit alih-alih menjalankan ulang ribuan sampel kuas. Blob tersebut juga menggunakan satu storage key per chunk, bukan satu baris besar, karena satu chunk SDF berukuran 168 KB dan DO memiliki batas 2 MB per baris. Indeks chunk melacak key yang tersedia agar DO dapat memuat kembali seluruh peta ketika aktif lagi. Identitas ditangani dengan dua jenis penyimpanan di sisi klien: id pemain disimpan di sessionStorage sehingga dua tab menjadi dua peer berbeda, bukan satu peer yang menimpa dirinya sendiri di peta pemain milik DO, sedangkan nama tampilan disimpan di localStorage agar perubahan nama di satu tab diterapkan ke semua tab.
Membuat pengeditan serentak mencapai hasil yang sama
Bagian yang benar-benar sulit dari pembuatan dunia multipemain adalah apa yang terjadi ketika dua orang mengedit area permukaan tanah yang tumpang tindih pada saat yang sama. Spike ini membagi pengeditan berdasarkan sifat aljabarnya. Operasi aditif—menaikkan dan menurunkan heightmap serta menambah dan mengurangi SDF—bersifat komutatif: menerapkannya dalam urutan apa pun menghasilkan hasil yang sama. Karena itu, operasi tersebut menggunakan jalur optimistic stamp, tempat setiap klien menerapkan perubahan secara lokal dan mengirim stamp-nya, lalu DO menyiarkannya kepada semua orang tanpa koordinasi. Urutan benar-benar tidak berpengaruh, jadi tidak ada yang perlu dikoordinasikan.
Operasi yang bergantung pada urutan—smooth dan flatten—menjadi kasus yang menarik. Desain pertama menggunakan kunci wilayah: klien meminta kunci saat pointer ditekan, DO menyetujui atau menolaknya, klien menampung sampel selama penekanan, lalu ketika pointer dilepas, DO menerapkan seluruh stroke secara atomik. Cara ini berfungsi, tetapi memerlukan protokol terpisah dengan TTL kunci serta perjalanan bolak-balik untuk persetujuan atau penolakan. Solusi lebih bersih yang menggantikannya adalah delta yang telah dihitung sebelumnya: pihak yang memulai menjalankan kuas smooth atau flatten secara lokal, lalu mengirim daftar delta per sel yang dihasilkan, dan setiap peer cukup menambahkan delta tersebut ke selnya sendiri tanpa menghitung ulang apa pun. Cara ini mengubah operasi yang bergantung pada urutan menjadi operasi komutatif dengan membekukan hasilnya di sumber. Dengan demikian, seluruh sistem pengeditan berjalan pada satu protokol komutatif yang seragam, dengan konvergensi identik dan tanpa kunci sama sekali. Kunci prop tetap dipertahankan karena alasan berbeda: penguncian per record menggantikan penghapusan khusus pemilik, sehingga peer mana pun dapat menghapus prop apa pun kecuali seseorang telah menguncinya, dan hanya pengunci yang dapat melepaskan kunci tersebut. Undo dan redo bekerja dengan mengizinkan klien menentukan id prop sebelum menempatkannya. Dengan begitu, klien mengetahui id tersebut sebelum echo dari server dan dapat membalik tindakannya sendiri secara deterministik. WebSocket yang berhibernasi membuat room yang tidak aktif tetap gratis sepanjang waktu, sifat yang sama yang membuat relay avatar pada bagian sebelumnya berbiaya rendah.
Angin yang di-port secara akurat, lalu dilawan
Buka Spike 49 di tab baru ↗ · Lihat kode sumber
Spike 49 mengambil Stylized Nature MegaKit milik Quaternius dan mem-port sistem anginnya ke stack kami. Paket tersebut menyertakan empat shader Godot sumber, dan langkah yang tepat adalah menerjemahkannya secara akurat, bukan menciptakannya kembali. Bark memiliki fungsi vertex kosong, sehingga batang pohon tetap kaku; percobaan procedural mask sebelumnya membuat batang pohon ikut bergoyang, dan solusinya cukup dengan tidak menerapkan angin pada bark sama sekali. Daun mendapatkan goyangan kacau per vertex dari hash pulsa segitiga yang diberi mask berdasarkan ketinggian, sehingga tajuk bergerak sementara pangkalnya tetap tertanam. Dedaunan dasar mendapatkan goyangan sin/cos yang dimodulasi noise dalam world space. Rumput adalah dedaunan dasar ditambah ayunan garis angin, dengan mengambil sampel dari tekstur noise yang bergulir melalui kurva pangkat sehingga hanya pita terang pada tekstur yang berkontribusi. Ini menghasilkan riak terlihat yang bergerak melintasi padang rumput. Dispatch mengikuti konvensi penamaan material milik paket itu sendiri, sehingga material bernama Leaves_Birch diarahkan ke jalur daun dan Grass_Common ke jalur rumput, tanpa perlu menebak.
Salah satu kejutan dari proses port ini adalah warna daun tidak berasal dari tekstur. Quaternius membangun tampilan daun sepenuhnya dari gradien vertikal dan tepian Fresnel: albedo merupakan campuran dari warna tambahan di bagian bawah tajuk ke warna daun di bagian atas berdasarkan ketinggian, dengan tint subsurface scattering yang ditambahkan sebagai emission dan diskalakan oleh suku Fresnel heightFactor yang telah di-bake, bukan nilai Y lokal mentah, dan menormalisasikannya per kelompok daun pada waktu pemuatan berdasarkan Y world-space. Dengan demikian, gradien dan mask angin berperilaku dengan benar, terlepas dari bagaimana impor FBX memutar sumbu lokal setiap mesh. Konstanta warna Godot ditandai sebagai sRGB dan dikonversi ke linear sebelum diterima shader, sehingga port ini melakukan konversi yang sama alih-alih memasukkan angka sRGB terang sebagai linear dan membuat dedaunan tampak pudar.
Kompilasi ulang yang menghabiskan frame rate
Alasan fitur ini pertama-tama dikirimkan dengan baseline FBX, sementara seluruh set material angin disisihkan dalam file .bak, adalah bug kompilasi ulang per frame yang menurunkan scene hingga sekitar 1 fps. Menambahkan TSL kustom di atas material yang dimuat dari FBX memicu Three.js untuk membangun ulang program shader setiap frame, dengan needsUpdate yang seolah-olah terus aktif. Pendekatan diagnostiknya adalah menyederhanakan setiap material dedaunan menjadi pass bertekstur biasa tanpa node kustom, lalu mengamati apakah loop kompilasi ulang tetap terjadi. Jika berhenti, graph kustomlah penyebabnya; jika berlanjut, penyebabnya berada di hulu, pada setup material FBX atau Three.js itu sendiri. Perbaikan yang memungkinkan shader sebenarnya digunakan kembali adalah mengikat setiap perbedaan per material—warna daun, warna SSS, strength, dan blend—sebagai uniform. Dengan begitu, semua aset daun berbagi satu program yang telah dikompilasi, alih-alih compiler menghasilkan shader baru untuk setiap kombinasi warna unik dan membuat antrean kompilasi terus bergejolak.
Dua bagian lain juga layak dipertahankan. Rumput dirender sebagai satu InstancedMesh per file sumber, dan anginnya dihitung dalam world space karena fase sin bergantung pada posisi dunia. Namun, displacement harus diterapkan dalam local space sebelum instance matrix dijalankan, sementara WGSL tidak memiliki inverse() yang dapat dipanggil. Untuk instance matrix yang terdiri dari translasi, rotasi Y, dan skala seragam, invers matriks 3×3 bagian atas hanyalah transposenya yang dibagi dengan kuadrat skala. Karena itu, shader mengalikan displacement dunia dengan model matrix yang ditransposisi dan membaginya dengan kuadrat panjang kolom pertama matriks, sehingga skala dapat diperoleh kembali tanpa akar kuadrat. Setelah transformasi vertex menerapkan kembali matriks tersebut, gerakannya mendarat di world space persis seperti yang dirancang, terlepas dari rotasi atau skala setiap rumpun. Setiap rumpun juga menjalankan frustum cull GPU per vertex: pusat instance diproyeksikan ke clip space, dan jika berada di luar frustum beserta marginnya, setiap vertex diciutkan ke titik asal lokal sehingga ketiga vertex dari setiap segitiga berhimpit. Rasterizer membuang segitiga degeneratif tersebut, dan tidak ada pekerjaan fragment, alpha-test, maupun shadow untuk rumput di luar layar. Teknik ini ditumpuk di atas cull kasar bounding sphere per chunk yang sudah dilakukan Three.js, dan dibangun menggunakan step bertipe float, bukan boolean, agar dapat langsung dikalikan ke dalam position mix.
Teknologi yang dibahas dalam bab ini
Persistensi dunia otoritatif-server. Sebuah Durable Object WorldChunkDO mengatur penempatan prop dan pengeditan terrain, menyimpan blob biner per chunk sebagai sumber kebenaran (sehingga peserta baru memuat byte yang telah di-commit alih-alih memutar ulang log peristiwa), serta menggunakan satu storage key per chunk agar tetap berada di bawah batas 2 MB per baris milik DO. Id pemain disimpan di sessionStorage agar setiap tab menjadi peer berbeda; nama tampilan disimpan di localStorage agar perubahan nama diterapkan ke semua tab.
Konvergensi pengeditan komutatif. Operasi terrain aditif (naik/turun, tambah/kurang SDF) bersifat komutatif dan menggunakan jalur optimistic stamp tanpa koordinasi. Operasi yang bergantung pada urutan (smooth, flatten) dibuat komutatif dengan mengirim delta per sel yang telah dihitung sebelumnya, alih-alih memperoleh kunci wilayah. Dengan demikian, seluruh sistem mencapai hasil yang sama melalui satu protokol seragam tanpa kunci. Kunci prop per record menggantikan penghapusan khusus pemilik, dan id objek yang ditentukan klien memungkinkan undo/redo deterministik sebelum echo server tiba. Lihat LOD berbasis GPU.
Port shader yang akurat dari Godot ke TSL. Empat shader angin sumber milik Quaternius diterjemahkan baris demi baris: bark yang kaku, goyangan daun dengan mask berdasarkan ketinggian, goyangan dedaunan dalam world space, dan rumput dengan ayunan garis angin yang bergulir, semuanya di-dispatch berdasarkan konvensi penamaan material milik paket tersebut. Warna daun berasal dari gradien ketinggian ditambah tepian SSS yang digerakkan Fresnel, bukan dari tekstur, dan konstanta authoring sRGB dikonversi ke linear agar tampilannya sesuai dengan render referensi.
Menghindari kompilasi ulang shader per frame. Menambahkan TSL kustom pada material FBX dapat membuat needsUpdate terus aktif dan membangun ulang program setiap frame, sehingga performa merosot hingga ~1 fps. Mengikat setiap perbedaan per material sebagai uniform memungkinkan semua varian berbagi satu program yang telah dikompilasi, alih-alih menghasilkan shader baru untuk setiap set parameter unik. Pass diagnostik bertekstur biasa mengisolasi apakah penyebabnya adalah graph kustom atau setup di hulu.
Angin world-space pada dedaunan terinstansiasi. Angin rumput dihitung dalam world space dan ditransformasikan kembali ke local space dengan invers yang diturunkan secara manual (transpose dibagi kuadrat skala), karena WGSL tidak memiliki inverse(). Frustum cull GPU per vertex menciutkan rumpun di luar layar menjadi segitiga degeneratif agar tidak ada pekerjaan fragment atau shadow yang dijalankan, sebagai lapisan tambahan di atas cull bounding sphere per chunk milik Three.js.
Bagian 24 dari 29. Sebelumnya: Bagian 23 - Lima puluh avatar dan satu suara di dalam room Berikutnya: Bagian 25 - Satu skeleton, setiap pakaian Panduan seri: /id/blog/2026-02-25-open-world-browser-series-guide