Skip to content

Membangun dunia terbuka di browser, bagian 16: Struktur untuk dunia yang terus berkembang

Oleh Oleg Sidorkin, CTO dan Salah Satu Pendiri Cinevva

Baru mengikuti seri ini? Gunakan panduan seri. Panduan tersebut menjelaskan apa itu spike dan menautkan semua bagian.

Setiap spike sejak bagian 13 mengikuti resep yang sama: salin monolit sebelumnya, lalu tambahkan satu fitur. Pada akhir spike 32, monolit tersebut telah mencapai 6.285 baris index.html dalam satu <script type="module">. Hasil pencarian kode penuh gangguan, mencari tempat untuk menambahkan fitur membutuhkan waktu lebih lama daripada menulisnya, dan setiap perubahan arsitektur menyentuh berkas yang terlalu besar untuk dipahami perbedaannya hanya dalam pikiran. Sebelum menambahkan fitur lain, kami membenahi strukturnya.

Memecah monolit tanpa mengubah perilaku

Buka Spike 33 di tab baru ↗ · Lihat kode sumber

Batasannya ketat: setiap pemisahan harus berupa refaktor murni, bukan desain ulang. Monolit tersebut berubah menjadi 19 berkas .mjs ditambah shell host sepanjang 151 baris. Ada modul tingkat atas untuk scene, air, rumput, karakter, fisika, multipemain, dan UI; sebuah wgsl.mjs yang menyimpan setiap string sumber WGSL sebagai satu-satunya sumber kebenaran GPU; serta subpohon terrain/ untuk heightmap, SDF, chunk, pembungkus buffer GPU, brush, LOD, dan persistensi. Total kodenya menjadi 6.555 baris, pada dasarnya monolit tersebut ditambah boilerplate impor. Tidak ada perubahan bersih pada volumenya, tetapi kemudahan navigasinya berubah drastis.

Lalu halaman dimuat sebagai layar hitam. Dua pesan kesalahan, dengan dua akar masalah yang tidak berkaitan. Yang pertama adalah keluhan WebGPU tentang binding buffer berukuran nol byte. Dalam monolit, buffer brush SDF dialokasikan secara lazy ketika chunk marching cubes pertama muncul, dan factory bind group kebetulan dijalankan belakangan, setelah buffer tersedia. Memisahkan terrain/gpu.mjs dari terrain/brush.mjs mengubah urutan evaluasi modul sehingga factory kini berjalan lebih dahulu dan mencoba melakukan binding pada placeholder null. Solusinya adalah menunda pembuatan bind group hingga dispatch pertama dengan helper getOrCreateBindGroup(chunk). Pola "buat semuanya di awal" merupakan artefak dari jalur inisialisasi tunggal milik monolit.

Pesan kedua adalah peringatan skeleton FBX yang tampak menakutkan, tetapi ternyata hanya pengalih perhatian. Peringatan itu telah muncul sejak spike 25 dan tidak berbahaya. Karakternya hilang hanya karena bug pertama memicu efek berantai: setiap compute dispatch melempar error, heightmap tidak pernah ditulis, sampel ketinggian mengembalikan 0, dan karakter muncul di titik origin lalu jatuh menembus dunia. Perbaiki buffernya, dan karakter kembali beranimasi dengan baik, meskipun peringatannya tetap ada.

Itulah pelajaran sebenarnya dari refaktor ini. Monolit menyembunyikan setiap hubungan "X harus tersedia sebelum Y dibuat" dalam urutan skrip dari atas ke bawah. Modularisasi mengacak urutan tersebut dan mengungkap tiga bug laten lain terkait urutan: penyebaran rumput sebelum heightmap diunggah, bidang air ditambahkan sebelum environment map selesai didekode, dan pemuatan persistensi selesai setelah frame pertama. Ketiganya hanya memerlukan perbaikan satu baris, dan tidak satu pun akan ditemukan tanpa pemisahan tersebut.

Seratus prop tanpa membuat apa pun membengkak

Buka Spike 34 di tab baru ↗ · Lihat kode sumber

Spike 34 menjadi pengujian apakah struktur tersebut benar-benar membuahkan hasil. Tujuannya adalah membuat palet sudut pandang orang pertama untuk menempatkan pohon, batu, semak, jamur, dan jalan setapak dari paket model CC0, lengkap dengan penyelarasan yang mengikuti medan, persistensi, sinkronisasi multipemain, dan collider fisika, semuanya tanpa keluar dari kontrol permainan. Setiap baris kode baru ditempatkan dalam lima berkas baru di bawah src/props/, dan tidak ada modul yang sudah ada bertambah lebih dari sepuluh baris penghubung.

Perjalanan asetnya sempat memutar dan layak dicatat, karena masalah semacam ini dapat menghabiskan satu hari. Kami memulai dengan Ultimate Nature pack dari Quaternius, sebuah pustaka FBX tanpa tekstur tertanam. Material FBX-nya disediakan sebagai MeshPhong tanpa map, jadi kami membuat tabel manual dari nama material ke PNG, mengonversi Phong menjadi Standard, dan mengatur ruang warna secara manual. Sekitar 30% material tidak memiliki PNG yang cocok, dan beberapa nama bersifat ambigu di antara pohon-pohon yang serupa. Paket FBX kedua memiliki kekurangan yang sama. Solusinya bukan menambah tabel pemetaan, melainkan memakai paket dengan authoring yang lebih baik: Stylized Nature MegaKit dari Quaternius menyediakan 116 glTF lengkap dengan material PBR tertanam dan normal yang telah di-bake. Mengganti FBXLoader dengan GLTFLoader menghapus penskalaan cm ke meter, tabel tekstur, dan konversi Phong, serta memangkas library.mjs sekitar 80 baris. Kesimpulannya: glTF dengan PBR adalah pipeline yang tepat untuk paket CC0 yang menyediakannya, sedangkan FBX dengan pemetaan tekstur manual membutuhkan kode dua kali lebih banyak untuk kualitas setengahnya.

Jalur glTF juga memiliki beberapa sisi tajam. Palet merender 116 thumbnail, dan canvas.toDataURL() milik WebGPU menghasilkan gambar kosong untuk permukaan GPUCanvasContext, sehingga thumbnail dirender ke RenderTarget, dibaca kembali dengan readRenderTargetPixelsAsync, lalu disalin ke kanvas 2D, dengan tetap memperhatikan alignment baris 256 byte milik WebGPU. Pratinjau bayangan mengkloning setiap material untuk mewarnainya hijau, tetapi ini gagal pada mesh yang material-nya berupa array; masalah tersebut diperbaiki dengan cabang Array.isArray. Selain itu, normal map 16-bit yang berukuran sekitar 200 MB diperkecil sekali saja dengan mogrify -depth 8 menjadi sekitar 32 MB, tanpa perbedaan visual karena browser tetap melakukan downsampling saat mengunggahnya.

Ketika geometri yang dirender hanya ada di GPU

Bug yang paling banyak memberi pelajaran adalah pratinjau bayangan yang tersentak dalam langkah 1 hingga 2 meter ketika kursor bergerak. Mesh medan menyimpan posisi vertex dalam StorageBufferAttribute karena compute pipeline menulisnya langsung di GPU, sehingga Raycaster CPU milik three.js tidak dapat melihatnya dan tidak menghasilkan apa pun. Fallback-nya berupa ray march kasar sejauh 1,5 m terhadap heightmap analitis, dan stride tetap itulah grid yang terlihat oleh pengguna. Kami menggantinya dengan march adaptif: stride 2,5 m saat jauh di atas permukaan, mengecil menjadi 0,4 m ketika berada dalam jarak 5 m darinya, lalu melakukan biseksi sebanyak 14 kali ketika tanda (rayyterrainy) berbalik. Hasilnya mencapai presisi submilimeter dengan sekitar 30 langkah lebar ditambah 14 biseksi per cast. Ketika geometri yang dirender hanya berada di GPU, jangan melawan raycaster; lakukan march terhadap sumber analitisnya.

Collider yang tetap akurat saat diedit

Kami memilih proxy primitif, bukan convex hull atau mesh collider. Prop Quaternius berbentuk gumpalan dan low-poly tanpa cekungan yang berarti, sehingga hull akan membutuhkan kira-kira 50 kali lebih banyak kode dan 10 kali biaya runtime untuk gameplay yang sama. Setiap prop direduksi menjadi satu bentuk yang diturunkan dari bounding box-nya: pohon dan kaktus menjadi kapsul vertikal, batu menjadi bola, batang kayu menjadi kapsul horizontal di sepanjang sumbu panjang, sedangkan semak dan bunga dekoratif tidak memiliki collider. Batu dan batang kayu dapat diinjak (dorongan hanya vertikal agar pemain dapat berdiri di atasnya), sedangkan pohon dan kaktus menghalangi (dorongan 3D penuh agar pemain tidak dapat memanjat batang). Spatial hash 8 m membatasi pengujian per frame pada lingkungan 3×3 di sekitar pemain, biasanya berisi nol hingga enam prop.

Dua keputusan desain menjaga sistem tetap koheren. Penyelarasan dengan medan merupakan flag manifest, bukan enum kategori yang di-hard-code dalam kode, sehingga pratinjau bayangan dan penempatan final membaca nilai placement.alignToTerrain yang sama dan tidak mungkin berbeda. Selain itu, prop yang telah ditempatkan bereaksi terhadap pengeditan medan melalui satu helper: setelah sapuan brush—baik lokal maupun yang diputar ulang dari peer—refreshPlacementsInRadius mengambil ulang sampel permukaan tanah di bawah setiap prop dalam lingkaran yang terdampak, menerapkan kembali penyelarasan, dan menghitung ulang endpoint collider. Pahat bukit di bawah pohon, dan pohon tersebut akan ikut terangkat. Persistensi dan multipemain menggunakan kembali pola spike 31 secara persis, dengan menyimpan daftar datar {uid, propId, x, y, z, rotY, scale} serta mereplikasi event penempatan, penghapusan, dan pergeseran melalui BroadcastChannel.

Teknologi yang dirujuk dalam bab ini

Dekomposisi modul ES di WebGPU. Memisahkan <script type="module"> monolitik menjadi impor .mjs dengan bare path tidak memerlukan bundler ketika modul disajikan sebagai aset statis, dan TSL three.js berfungsi dengan baik melintasi batas modul. Biaya tersembunyinya adalah urutan inisialisasi: monolit menyandikan "buat X sebelum Y" melalui urutan skrip dari atas ke bawah, sedangkan modul dievaluasi berdasarkan urutan impor, yang dapat menjalankan factory bind group GPU sebelum buffernya tersedia. Pola solusinya adalah inisialisasi lazy (getOrCreate... pada penggunaan pertama) dan menunggu promise yang tepat, bukan mengandalkan urutan deklarasi.

glTF dengan PBR tertanam dibandingkan FBX dengan pemetaan manual. glTF menggunakan satuan meter, mereferensikan teksturnya sendiri, dan langsung menyediakan MeshStandardMaterial, sehingga paket CC0 yang dibuat sebagai glTF dapat langsung dimasukkan ke pipeline PBR. Paket FBX tanpa metadata pengikatan tekstur memerlukan tabel nama-material-ke-PNG yang dipelihara secara manual dan mudah tidak sinkron pada setiap pembaruan paket, ditambah konversi Phong ke Standard serta penandaan ruang warna manual. Sebuah jaring pengaman untuk dedaunan menaikkan material transparent tanpa alphaTest menjadi kartu cutout alphaTest: 0.5 agar urutan rendernya benar di belakang geometri opaque.

Thumbnail offscreen WebGPU. canvas.toDataURL() menghasilkan gambar kosong untuk kanvas yang didukung GPUCanvasContext karena tidak ada jalur dari permukaan presentasi kembali ke konteks 2D. Merender ke RenderTarget, membaca piksel dengan readRenderTargetPixelsAsync, dan menyalinnya ke kanvas 2D dapat bekerja, selama proses penyalinan melangkah mengikuti stride read-back WebGPU yang diselaraskan ke 256 byte. Hasilnya di-cache dalam localStorage dengan key yang versinya dinaikkan agar perubahan paket membatalkan hasil render lama.

Ray march adaptif terhadap heightmap analitis. Ketika vertex medan berada dalam StorageBufferAttribute GPU, raycaster CPU tidak dapat melihatnya. Melakukan march pada fungsi ketinggian analitis dengan stride besar ketika jauh dari permukaan, stride kecil ketika mendekatinya, dan penyempurnaan pencarian biner saat tanda (rayyterrainy) berbalik menghasilkan presisi kursor submilimeter dengan jumlah sampel terbatas. Primitif yang sama digunakan oleh kursor brush dan bayangan prop.

Collider kapsul primitif dengan spatial hash. Setiap prop direduksi menjadi kapsul atau bola yang diturunkan dari kategorinya berdasarkan bounding box, dicatat sebagai {kind, walkable, radius, p1, p2}, dan didaftarkan ke setiap bucket hash 8 m yang disentuhnya. Pada setiap frame, pemain hanya menguji prop dalam lingkungan bucket 3×3, masing-masing dengan satu penyelesaian kapsul-lawan-kapsul. Proxy yang dapat diinjak (batu, batang kayu) mendapat dorongan vertikal saja, sedangkan proxy penghalang (pohon) mendapat dorongan 3D penuh. Lihat tabrakan medan SDF untuk matematika kapsul yang menjadi dasar sistem ini.


Bagian 16 dari 29. Sebelumnya: Bagian 15 - Ganti baseline, lalu sinkronkan Berikutnya: Bagian 17 - Animasi yang tidak memerlukan retargeting, dan pencarian aset langsung Panduan seri: /blog/2026-02-25-open-world-browser-series-guide