Skip to content

Membangun dunia terbuka di browser, bagian 13: Memahat medan dan matinya fungsi matematika

Oleh Oleg Sidorkin, CTO dan Salah Satu Pendiri Cinevva

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

Selama dua belas bagian, kami membangun mesin medan yang bisa dilihat. Terbang di atasnya. Mengagumi sambungannya yang tidak retak. Kali ini kami ingin menyentuhnya.

Tujuannya terdengar sederhana: memungkinkan pemain memahat medan dengan kuas secara real-time, tanpa merusak sistem apa pun yang telah kami bangun selama 24 spike. Dibutuhkan tiga percobaan, dua bug yang tampak seperti kegagalan rendering tetapi ternyata merupakan kegagalan model data, serta pemikiran ulang yang mendasar tentang cara kerja data medan.

Tiga kegagalan awal

Spike 25 seharusnya menjadi bagian yang mudah. Basis kode produksi sudah memiliki raycaster yang mengenai mesh medan. Alat penempatan menggunakannya untuk meletakkan objek. Alat kuas mengikuti pola yang sama, hanya saja alat ini mengubah nilai heightmap alih-alih memunculkan prefab. Sederhana.

Percobaan pertama: saya membangunnya langsung di dalam kode TypeScript produksi world/client/. terrain-brush.ts baru, modifikasi pada chunk.ts, perubahan protokol, pembaruan komponen Vue. Dalam waktu satu jam, saya memiliki kuas yang kurang lebih berfungsi, tetapi batas chunk memperlihatkan diskontinuitas normal yang terlihat jelas. Saya tidak bisa memastikan apakah bug tersebut berasal dari kode kuas saya, sistem penyambungan chunk yang sudah ada, atau interaksi tertentu dengan loop rendering lengkap. Inilah persisnya situasi yang ingin dicegah oleh metodologi spike. Saya melewatkan aturannya dan langsung menanggung akibatnya. Semuanya dikembalikan.

Percobaan kedua: spike mandiri, tetapi saya memilih Three.js 0.170.0 dan WebGL. Kode produksi menggunakan WebGL, jadi pilihan itu terasa wajar. Namun, Spike 13–24 semuanya sudah beralih ke WebGPU. Membangun spike kuas WebGL hanya akan membuktikan bahwa sistem tersebut bekerja pada renderer lama, bukan renderer yang sedang menjadi tujuan migrasi kami. Salah arah. Mulai lagi dari awal.

Percobaan ketiga: WebGPU, WebGPURenderer, shader komputasi untuk pembuatan vertex, dengan stack yang sama seperti Spike 22. Kali ini arsitekturnya benar. Lima operasi kuas berfungsi: naikkan, turunkan, haluskan, ratakan, noise. P95 siklus kuas di bawah 4 md pada M1.

Dan bug sambungannya masih ada.

Buka Spike 25 di tab baru ↗ · Lihat kode sumber

Bug sambungan yang tak kunjung mati

Perbaikan standar untuk perhitungan normal lintas chunk adalah tumpang tindih perbatasan: setiap chunk menyimpan satu lapisan data tambahan dari chunk tetangganya, sehingga perhitungan normal pada batas dapat mengambil sampel dari kedua sisi. Saya menerapkannya. Data tepi tetangga disalin ke buffer yang diperluas. Sambungannya masih retak.

Saya menyelidiki perhitungannya. Pada batas antara chunk A (cx=-1) dan chunk B (cx=0), kedua chunk harus menghitung normal yang sama pada vertex bersama. Shader chunk A mengambil sampel mix(own_col31, own_col32, 0.85). Chunk B mengambil sampel mix(neighbor_edge, own_col0, 0.85). Keduanya merupakan jalur interpolasi bilinear yang berbeda melalui data yang berbeda. Bahkan dengan data perbatasan yang benar, kedua chunk menghitung normal yang berbeda untuk titik yang sama.

Pada saat itulah saya menyadari bahwa penyalinan perbatasan bukanlah bug yang sebenarnya. Bug yang sebenarnya ada pada model datanya.

Setiap spike dari 1 hingga 24 menggunakan fungsi matematika prosedural bernama height_at(). Berikan koordinat dunia, dapatkan ketinggian. Bersih, global, tanpa status. Kuas tidak dapat mengubah fungsi matematika, jadi saya menambahkan buffer displacement di atasnya. Medannya kini menjadi height_at(x,z) + displacement[i]. Shader GPU memuat 30 baris fungsi noise untuk medan dasar, ditambah kode interpolasi bilinear untuk lapisan displacement. Kuas perataan harus mengurangi height_at() untuk mengetahui nilai displacement yang akan menghasilkan ketinggian target. Dua sistem yang ditumpuk satu sama lain, menghitung hal berbeda dengan strategi pengambilan sampel yang berbeda.

Bukan begitu cara kerja game sungguhan. Dalam produksi, medan yang dibuat kreator merupakan data sampel yang disimpan dalam buffer. Fungsi prosedural hanyalah pengganti praktis dari spike awal. Fungsi itu telah menunaikan tugasnya. Sekarang fungsi tersebut justru secara aktif menyebabkan bug.

Saya menghapusnya.

Setiap chunk kini memiliki heightmap Float32Array dengan nilai ketinggian aktual. Saat dibuat, noise prosedural mengisinya. Setelah itu, fungsi noise tidak pernah dipanggil lagi. Kuas mengubah langsung ketinggian yang tersimpan. Shader GPU membaca dari satu buffer menggunakan satu fungsi: hm_at(i,j). Normal menggunakan central difference yang selaras dengan grid pada data yang sama. Tidak ada ambiguitas interpolasi bilinear. Tidak ada ketidakselarasan antara dua sistem. Panjang shader berkurang dari 90 baris menjadi 40.

Sambungannya memperbaiki diri sendiri. Kedua chunk pada tepi bersama kini membaca nilai ketinggian diskret yang sama dari buffer masing-masing (dengan titik interior tetangga yang benar dalam tumpang tindih perbatasan). Data masuk sama, normal keluar pun sama.

Ini bukan pelajaran tentang kuas. Ini adalah pelajaran tentang arsitektur data yang terungkap karena kuas.

Mesh yang meledak

Spike 26 adalah versi volumetriknya. Ubah volume SDF berukuran 64 kubik dengan kuas, lalu buat ulang mesh menggunakan marching cubes. Pertanyaannya sama seperti Spike 25, tetapi dalam 3D.

Saat pertama kali menjalankannya, mesh itu meledak. Duri-duri panjang mencuat ke segala arah, seperti bulu babi yang sedang mengalami hari buruk.

Buka Spike 26 di tab baru ↗ · Lihat kode sumber

Tabel kasus MC yang saya buat memiliki 3840 entri, bukan 4096. Tabel lengkapnya adalah 256 kasus×16 slot=4096, tetapi tabel saya berukuran 256×15=3840, dengan enam belas baris yang hilang mulai dari kasus 112. Setiap pencarian setelah indeks tersebut bergeser, sehingga nomor kasus tidak lagi cocok dengan data triangulasi. Ketika marching cubes membaca entri yang salah, ia membuat edge yang kedua titik ujungnya berada di sisi permukaan yang sama. Pada edge perpotongan yang sebenarnya, parameter interpolasi

t=vavbva

berada dalam [0,1] karena va dan vb memiliki tanda yang berlawanan. Pada edge palsu, keduanya memiliki tanda yang sama, sehingga vbva mendekati nol atau berganti tanda dan t melesat keluar dari $[0, 1]`, menempatkan vertex jauh di luar volume. Kalikan hal itu dengan beberapa ratus sel yang salah dan Anda mendapatkan seekor landak.

Perbaikannya sangat sederhana: salin tabel yang telah terbukti dari Spike 12, byte demi byte. Pelajaran dipetik. Jangan pernah membuat ulang lookup table ketika salinan yang telah terbukti sudah tersedia.

Bug kedua lebih rumit. Kuas penghalus seharusnya melembutkan fitur medan. Namun, kuas itu justru menciptakan lipatan tajam. Masalahnya: saya menarik setiap nilai SDF menuju nol (isosurface). Kedengarannya seolah-olah itu akan menghaluskan permukaan, tetapi tindakan tersebut justru meruntuhkan distance field. Voxel di atas dan di bawah permukaan sama-sama bergerak cepat menuju nol, meratakan semuanya dalam radius kuas. Pada batasnya, voxel yang dihaluskan bertemu voxel yang tidak dihaluskan dengan perubahan mendadak. Kuas "penghalus" itu sebenarnya merupakan pembuat lipatan.

Solusinya adalah penghalusan Laplacian yang benar. Alih-alih menarik setiap nilai menuju nol, tarik nilainya menuju rata-rata dari 6 tetangga langsungnya:

ϕiϕi+λ(16jN(i)ϕjϕi)

Suku dalam tanda kurung adalah Laplacian diskret, dan λ(0,1] adalah kekuatan penghalusan. Cara ini merata-ratakan geometri di sekitarnya, melembutkan bentuk permukaan sembari mempertahankan gradien distance field alih-alih meruntuhkannya.

Semuanya sekaligus

Spike 27 adalah gerbang integrasi. Ambil pipeline lengkap dari Spike 24 (patch heightmap, chunk MC, sambungan Transvoxel, LOD geomorph), lalu gabungkan dengan model data berbasis sampel dari Spike 25 dan kedua jenis kuas.

Buka Spike 27 di tab baru ↗ · Lihat kode sumber

Hal pertama yang saya lakukan adalah mencabut height_at() dari setiap shader. Ketiga shader komputasi (pengisian SDF, patch heightmap, sambungan Transvoxel) kini mengikat buffer GPU heightmap 129x129 yang sama dan menggunakan fungsi interpolasi bilinear hm_sample() yang sama melalui preamble WGSL bersama. Satu sumber data, banyak pemakai. Fungsi noise prosedural yang telah menghuni setiap shader sejak Spike 1 kini hilang.

Lalu masalah-masalah menarik mulai bermunculan.

Ketika kuas SDF mengunci sebuah chunk dalam mode MC, sambungan Transvoxel antara chunk tersebut dan tetangga heightmap-nya perlu mengambil sampel dari volume SDF, bukan heightmap. Saya memperluas shader sambungan dengan binding storage buffer tambahan dan flag MC per chunk. Ada empat kombinasi batas yang harus ditangani: HM-HM, HM-MC, MC-HM, MC-MC.

LOD menjadi teka-teki lain. Pada spike sebelumnya, mengalihkan chunk MC ke LOD yang lebih rendah berarti mengisi ulang SDF pada resolusi yang lebih kasar. Saya menggantinya dengan pengambilan sampel berbasis stride: data SDF tetap pada resolusi penuh (65 titik grid). Shader MC menghitung stride dari rasio ukuran grid terhadap jumlah sel. Pada LOD0, stride-nya 1. Pada LOD1, stride-nya 2, dengan mengambil sampel setiap dua voxel. Chunk dapat mengubah LOD dengan bebas tanpa menyentuh data SDF-nya.

Perbaikan yang paling memuaskan adalah pemunculan chunk vertikal secara dinamis. Pahat ke atas hingga melewati puncak chunk, maka chunk khusus MC baru akan muncul di atasnya dengan SDF yang diinisialisasi dari sisi batas chunk di bawahnya. Pahat ke bawah, hal yang sama terjadi. Dunia berkembang untuk mengakomodasi perubahan.

Masalah terakhir adalah kuas heightmap yang diam-diam tidak melakukan apa pun terhadap chunk yang terkunci ke MC. Kuas HM mengubah heightmapCPU dan mengunggahnya kembali. Chunk MC tidak lagi membaca dari heightmap karena SDF-nya sudah diisi dari heightmap tersebut lalu berkembang secara terpisah. Saya menambahkan syncHeightmapToSdf(): setelah heightmap berubah, turunkan ulang kolom SDF untuk setiap chunk MC dalam radius kuas dan unggah nilai barunya. Kedua jenis kuas kini berfungsi pada kedua jenis chunk.

Apa yang sebenarnya kami pelajari

Spike kuas seharusnya menjawab pertanyaan tentang performa: bisakah pemahatan berjalan dalam batas anggaran frame? Bisa. Itu bagian yang mudah.

Bagian tersulitnya adalah menyadari bahwa penggunaan height_at() sebagai sumber kebenaran medan selama 24 spike telah menciptakan dependensi tak terlihat yang rusak begitu kami mencoba mengedit apa pun. Fungsi prosedural itu bersih, global, dan tanpa status—hingga akhirnya fungsi tersebut bukan lagi medannya.

Aturan yang kami catat dan tidak akan kami lupakan:

  1. Ketinggian medan berasal dari data sampel. Chunk memiliki buffernya sendiri.
  2. Pembuatan prosedural mengisi data awal. Itu bukan sumber kebenaran saat runtime.
  3. Kuas mengubah data chunk secara langsung. Tidak ada lapisan displacement.
  4. Normal berasal dari data yang sama melalui central difference yang selaras dengan grid.
  5. Tumpang tindih perbatasan (1 sel dari interior tetangga) menangani normal lintas chunk.
  6. Jangan pernah membuat ulang lookup table ketika salinan yang telah terbukti sudah tersedia.

Dalam bagian 14, kami berhenti memahat geometri debug dan mulai membuatnya terlihat dan terasa seperti tempat sungguhan.

Teknologi yang dirujuk dalam bab ini

Arsitektur heightmap berbasis sampel. Medan disimpan sebagai data milik masing-masing chunk, bukan dievaluasi dari fungsi prosedural saat runtime. Setiap chunk menyimpan Float32Array berisi nilai ketinggian aktual. Noise prosedural mengisi data awal pada saat pembuatan, lalu fungsi tersebut tidak pernah dipanggil lagi. Pendekatan ini menghilangkan ketidakselarasan sistem ganda antara medan berbasis matematika dan lapisan pengeditan, menyederhanakan operasi kuas (langsung mengedit nilai yang tersimpan), serta membuat shader GPU sangat sederhana (membaca dari buffer, menghitung normal melalui central difference yang selaras dengan grid). Untuk dunia terbuka dengan streaming, kepemilikan per chunk dengan tumpang tindih perbatasan 1 sel dari tetangga merupakan pendekatan standar. Lihat panduan pembuatan lanskap kami.

Operasi kuas SDF. Mengubah signed distance field untuk memahat medan. Tambah (mengembungkan) menggunakan falloff smooth-step di sekitar bola. Kurangi (mengukir) menggunakan bentuk yang sama dengan nilai terbalik. Penghalusan menggunakan perataan Laplacian: baca 6 tetangga langsung, hitung nilai rata-ratanya, lalu tarik nilainya menuju rata-rata tersebut. Pendekatan naif dengan menarik nilai menuju nol akan meruntuhkan distance field dan menciptakan tepi tajam. Penghalusan Laplacian mempertahankan gradien medan sembari melembutkan fitur. Lihat representasi medan SDF.

Transvoxel dengan sumber data campuran. Sel transisi pada batas antara chunk MC dan chunk heightmap perlu mengambil sampel dari data yang berbeda pada setiap sisi. Shader sambungan membawa flag per chunk dan binding buffer untuk menangani keempat kombinasi (HM-HM, HM-MC, MC-HM, MC-MC). Ketika salah satu sisi terkunci ke MC, shader melakukan interpolasi trilinear pada buffer SDF alih-alih mengambil sampel dari heightmap. LOD berbasis stride untuk marching cubes. Data SDF disimpan pada resolusi penuh terlepas dari level LOD chunk saat ini. Shader MC menghitung stride sampling dari rasio jumlah titik grid SDF terhadap jumlah sel MC. Pada resolusi penuh, stride-nya adalah 1; pada setengah resolusi, stride-nya adalah 2. Hal ini memisahkan data SDF dari perubahan LOD, sehingga chunk dapat bertransisi antar-LOD secara bebas tanpa membangun ulang SDF.


Bagian 13 dari 14. Sebelumnya: Bagian 12 - Cincin, kabut langit, dan hal yang akan kami lakukan lagi Berikutnya: Bagian 14 - Dunia menjadi hidup Panduan seri: /id/blog/2026-02-25-open-world-browser-series-guide