Membangun dunia terbuka di browser, bagian 18: Kuas sebar yang terasa ditempatkan oleh AI
Oleh Oleg Sidorkin, CTO dan Co-Founder Cinevva
Baru di sini? Gunakan panduan seri. Panduan itu menjelaskan apa itu spike dan menautkan semua bagian.
Bagian 17 memberi pemain satu set animasi berkualitas tempur dan cara untuk memasukkan model CC0 apa pun ke dalam dunia. Bagian ini kembali ke sisi kreator. Palet dari Spike 34 menempatkan satu prop per klik, yang cocok untuk menata objek utama tetapi tidak berguna untuk membuat hutan. Spike 37 adalah kuasnya: seret melintasi medan dan pohon akan mengisi area tempat semestinya pohon tumbuh.
"Ditempatkan oleh AI" tanpa AI
Buka Spike 37 di tab baru ↗ · Lihat sumber
Pertanyaan yang dijawab spike ini adalah apakah kuas yang sepenuhnya heuristik terasa cukup cerdas sehingga kita tidak perlu memakai LLM. Tolok ukur "ditempatkan oleh AI" bersifat konkret: pohon tidak tumbuh di tebing, batu miring mengikuti lereng, dan kerikil pantai berhenti di garis air, semuanya dalam sapuan pertama. Kami mencapainya dengan predikat kemiringan dan ketinggian, pengundian berbobot, serta jarak per keluarga, tanpa satu pun panggilan model.
Kuas ini bekerja pada heightmap CPU berukuran 257×257 dengan fitur yang disetel secara manual agar setiap preset memiliki tempat untuk diterapkan: pegunungan utara untuk pilihan lereng campuran, jalur tebing timur untuk scree, dataran pesisir selatan untuk pantai dan padang rumput, serta cekungan danau di barat daya. Medan membuat bake warna verteks dari pengklasifikasi bioma (altitude, slope), sehingga sebelum mengecat satu pohon pun, Anda dapat melihat di mana sebuah preset akan aktif. Lima preset disediakan sebagai data datar, masing-masing berupa daftar pilihan seperti { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope }. Cliff and Scree menetapkan slopeMin: 0.3 agar batu hanya ditempatkan pada lereng yang benar-benar miring dan alignToSlope: true agar vektor atas setiap bongkahan batu mengikuti normal permukaan.
Untuk setiap sapuan, mesin penyebaran mengambil sampel densityPerM2 × area titik kandidat di dalam cakram kuas, membaca ketinggian dan kemiringan tiap kandidat, menyaring pilihan preset berdasarkan predikat yang terpenuhi, mengundi satu pilihan secara berbobot, lalu menjalankan pemeriksaan jarak terhadap spatial hash dalam radius. Seluruh proses ini deterministik: RNG Mulberry32 yang dapat diberi seed mengendalikan setiap pengundian, sehingga (seed, brush events) dapat mereproduksi sesi apa pun secara persis. Pada medan awal, satu sapuan Mixed Forest di padang rumput datar menempatkan 139 dari 158 kandidat dalam 5 ms, sedangkan preset yang sama pada tebing hanya menempatkan 106 dari 226 kandidat dan HUD melaporkan bahwa 81 di antaranya ditolak karena kemiringan. Rincian penolakan itu merupakan inti UX-nya: Anda dapat melihat mengapa tebing hanya menerima beberapa pohon tanpa harus menebak-nebak.
Tujuan mempertahankan preset sebagai data datar adalah agar versi LLM, saat tersedia nanti, cukup menjadi penggantian JSON alih-alih penulisan ulang. paint({ preset }) tidak peduli apakah preset.picks berasal dari resep yang disetel manual atau worker yang mengembangkan "hutan gugur dengan bongkahan batu berlumut" menjadi bobot. Mesin juga tidak pernah melakukan hardcode pada ID prop, sehingga penggunaan katalog lain tidak memerlukan perubahan mesin.
Dari 300 draw call menjadi 49
Versi pertama merender setiap penempatan sebagai clone(true) dari grup multi-mesh, yang cukup baik untuk beberapa ratus prop tetapi menjadi penghalang pada batas 2.500, ketika jumlah draw call meningkat hingga ribuan. Kami beralih ke InstancedMesh sebelum mencapai titik itu, dengan satu bucket per (propId, partIndex). Setiap bucket tumbuh dengan menggandakan kapasitas: alokasikan InstancedMesh yang lebih besar, salin matriks aktif, ganti parent scene, lalu dispose atribut lama. Penghapusan menggunakan swap-remove, sehingga menghapus satu instance berbiaya O(1) terlepas dari ukuran bucket. Determinisme, jarak, dan HUD penolakan semuanya tetap tidak berubah karena pertukaran tersebut sepenuhnya berlangsung di bawah record penempatan.
Diagnostik pada paket MegaKit menjawab pertanyaan arsitektur yang nyata. Mesh glTF multi-primitif (batang ditambah dedaunan) dapat masuk ke three.js sebagai satu mesh dengan material array dan geometry.groups, atau sebagai mesh sibling terpisah dengan satu material untuk masing-masing mesh. Loader mengambil jalur kedua untuk paket ini: setiap bagian merupakan mesh bermaterial tunggal dengan groups kosong. Bentuk ini lebih baik untuk penyebaran karena bucket terpisah per primitif memungkinkan bucket batang tumbuh secara independen dari bucket daun jika jumlahnya berbeda. Jumlah draw call sama untuk kedua pendekatan, tetapi pembagian tersebut menghasilkan susunan memori yang lebih baik. Peningkatan yang terukur tetap bertahan: satu sapuan hutan yang sebelumnya menghasilkan ~300 draw call menjadi 49, dan satu sesi penuh dengan banyak sapuan mencapai 3.221 instance pada 75 FPS dalam 51 draw call, batas yang tidak mungkin dicapai jalur clone sebelum anggaran frame runtuh.
LOD berbasis jarak, dan empat bug yang bersembunyi di dalamnya
Instancing memangkas draw call, tetapi setiap instance masih menggambar seluruh jumlah segitiganya, bahkan pohon berjarak 90 m yang detail daunnya hanya menyumbang dua piksel. Karena itu, kami membuat bake tiga tingkat LOD per bagian prop dengan meshoptimizer (penuh, 50%, 15%), memperluas key bucket menjadi (propId, partIndex, lod), dan menambahkan move() yang memindahkan penempatan antarbucket sibling tanpa alokasi. Band jaraknya adalah 0 hingga 30 m, 30 hingga 90 m, dan selebihnya, dengan histeresis ±4 m di sekitar setiap batas agar kamera yang berada dekat tepi band tidak terus memindahkan penempatan bolak-balik dan mengunggah ulang matriksnya setiap frame. Evaluasi ulang dibatasi hingga 4 Hz dan hanya dijalankan jika kamera benar-benar bergerak, sehingga kamera diam hanya memerlukan satu perbandingan jarak kuadrat per frame.
Jalur LOD itulah yang menyimpan berbagai bug menarik. Bug pertama muncul sebagai penempatan yang menghilang atau terduplikasi saat kamera mengorbit, dan semakin parah ketika scene semakin penuh. Penyebabnya adalah matriks scratch bersama: move() membaca transformasi penempatan ke _tmpMat pada scope modul, tetapi swap-remove milik bucket sumber menggunakan _tmpMat yang sama untuk pengacakan internalnya, sehingga menimpa matriks yang dibawa sebelum bucket tujuan menuliskannya. Bug ini hanya tidak terjadi ketika slot yang dipindahkan sudah menjadi slot terakhir di bucket-nya, dengan peluang kira-kira 1/count, yang persis menjelaskan "kedipan langka yang semakin parah saat scene membesar" yang terlihat dalam playtest. Perbaikannya adalah _carryMat khusus yang hanya digunakan oleh move(). Dalam stress test dengan total kumulatif 1.274 perpindahan, cluster tetap identik hingga tingkat piksel.
Bug kedua lebih halus: setiap transisi LOD terasa mulus kecuali transisi pertama. Shading pohon yang beralih ke LOD1 tampak bergeser meskipun siluetnya nyaris tidak berubah, sementara pengurangan jumlah segitiga yang lebih besar pada tahap LOD berikutnya terlihat baik-baik saja. Simplifier dengan LockBorder tidak pernah memindahkan atau membuat verteks, sehingga verteks yang bertahan mempertahankan normalnya secara persis, tetapi kami tetap memanggil computeVertexNormals() setelah setiap penyederhanaan. LOD0 mengembalikan normal asli buatan artis tanpa perubahan; LOD1 dan seterusnya mendapatkan penghitungan ulang rata-rata face generik dari three.js. Batas 0-ke-1 menjadi satu-satunya tempat dalam rangkaian LOD yang mengganti sistem normal, sehingga popping muncul di sana. Menghapus satu baris defensif itu memperbaiki shading dan, sebagai bonus, memangkas waktu bake per prop hingga kira-kira separuh karena kami berhenti menghitung ulang normal pada empat LOD per bagian.
Audit terhadap hasil simplifier mengungkap peningkatan ketiga. Setiap LOD merupakan original.clone() dengan index baru, dan BufferGeometry.clone() menyalin setiap atribut secara mendalam, sehingga lima LOD menyimpan lima salinan independen dari buffer position, normal, UV, dan color yang nilainya identik secara bit. Kami melakukan refactor agar semuanya berbagi referensi atribut dan hanya memiliki buffer index privat per LOD, sehingga jumlah identitas atribut yang berbeda pada bagian pohon tipikal turun dari 20 menjadi 9 dan setiap buffer verteks hanya diunggah ke GPU satu kali. Penyimpanan yang menggunakan alias ini membawa dua kontrak: jangan memutasi data atribut melalui LOD mana pun, dan jangan memanggil dispose() pada satu geometri LOD saja, karena keduanya akan memengaruhi setiap sibling yang berbagi buffer tersebut.
Bug keempat tidak berkaitan dengan pengecatan. Sekadar menggerakkan kursor di atas medan menurunkan frame rate, bahkan tanpa menahan tombol. Handler pointermove melakukan raycast terhadap mesh medan, sebuah bidang dengan 131.072 segitiga tanpa struktur spasial, sehingga three.js menelusuri seluruh buffer index pada setiap event, dengan frekuensi hingga 1.000 event per detik. Kami sama sekali tidak memerlukan mesh untuk pencarian itu karena medan merupakan heightmap parametrik. Ray-march adaptif terhadap sampleHeight (langkah besar jauh di atas permukaan, batas bawah 0,4 m ketika mendekatinya, lalu 12 bisection saat tanda berbalik) membutuhkan sekitar 8 hingga 30 sampel per ray, bukan 131.072 pengujian segitiga, sekitar tiga tingkat besaran lebih murah, dan hover kembali mempertahankan batas frame.
Biayanya hanya berpindah; pastikan tidak berpindah ke klik
Setelah mengganti spike ke WebGPURenderer pada three r184 (target produksi), profil DevTools menunjukkan proses paint pertama memblokir selama 265 ms, dengan 79% waktunya dihabiskan di dalam WASM meshoptimizer. Proses bake tersebut memang pekerjaan nyata, sekitar 180 panggilan simplify untuk preset yang masih cold, tetapi proses itu berjalan di dalam handler klik karena preloadProps hanya mengambil dan mengurai scene, tanpa pernah memicu bake LOD. Perbaikannya adalah membuat pemilihan preset menjalankan bake penuh di latar belakang: preloadProps kini memanggil jalur resolusi bagian, menyimpan promise yang sedang berjalan dalam cache agar klik cepat bergabung dengannya alih-alih memulai proses duplikat, dan melakukan memoization terhadap prapemrosesan per geometri yang sebelumnya diulang simplifier empat kali per bagian. Paint pertama turun dari 209 ms menjadi 4 ms di HUD. Waktu WASM tidak lenyap; waktu itu hanya dikeluarkan dari jalur kritis pengguna dan dijalankan ketika mereka sedang melihat medan sambil memutuskan tempat untuk mengecat.
Itulah pelajaran yang terus berulang dari spike ini. Nyaris tidak ada perbaikan tersebut yang mengubah apa yang dilakukan kuas. Semuanya mengubah kapan biaya tersebut muncul: bukan saat klik, bukan saat hover, dan bukan di batas tempat kamera sedang berada. Alat penyebaran yang terasa instan bukan berarti melakukan lebih sedikit pekerjaan, melainkan mengerjakan pekerjaan itu ketika pengguna tidak sedang menunggunya.
Teknologi yang dirujuk dalam bab ini
Penyebaran kesesuaian heuristik. Kuas mengambil sampel titik kandidat dalam sebuah cakram, membaca (height, slope) per titik dari heightmap CPU, menyaring pilihan preset berdasarkan predikat kemiringan dan ketinggian, mengundi salah satunya secara berbobot, lalu menolaknya jika melanggar jarak minimum per keluarga yang dilacak dalam spatial hash. Pilihan yang diselaraskan dengan kemiringan memutar vektor atasnya agar mengikuti normal permukaan. Ini menghasilkan penempatan yang tampak disengaja (pohon tidak berada di tebing, batu miring mengikuti lereng, kerikil berhenti di garis air) tanpa bobot hasil pembelajaran, sekaligus mempertahankan preset sebagai data datar agar daftar pilihan buatan LLM dapat langsung dipertukarkan.
Penempatan deterministik di tengah pemuatan asinkron. RNG Mulberry32 yang dapat diberi seed mengendalikan setiap pengundian, sehingga (seed, brush events) mereproduksi sesi secara persis. Pengundian RNG berlangsung sebelum await apa pun, dan reservasi jarak dimasukkan ke index spasial sebelum clone glTF selesai, sehingga kandidat serentak saling mematuhi jarak dan pemuatan aset asinkron tidak dapat mengganggu urutannya.
InstancedMesh berbucket dengan pengeditan O(1). Satu InstancedMesh per (propId, partIndex, lod), dengan kapasitas yang digandakan sesuai kebutuhan melalui penyalinan matriks aktif ke buffer yang lebih besar. Penghapusan dan evict FIFO menggunakan swap-remove dengan array back-reference yang memperbarui index instance yang dipindahkan, sehingga biaya penghapusan tetap O(1) terlepas dari ukuran bucket. Diagnostik mengonfirmasi bahwa bagian glTF masuk sebagai mesh bermaterial tunggal, menjadikan satu bucket per primitif sebagai jalur aktif dan memberi setiap primitif bucket yang dapat tumbuh secara independen.
LOD berbasis jarak dengan histeresis dan buffer atribut bersama. Tiga tingkat hasil penyederhanaan meshopt per bagian, dipilih berdasarkan band jarak dengan histeresis ±4 m agar kamera di dekat batas tidak terus memindahkan instance, dievaluasi ulang pada frekuensi terbatas dan hanya ketika kamera benar-benar bergerak. Karena penyederhanaan LockBorder tidak pernah memindahkan verteks, semua LOD berbagi satu set buffer position/normal/UV/color dan hanya berbeda pada buffer index privatnya, sehingga jumlah buffer verteks GPU yang berbeda berkurang kira-kira separuh. Melewatkan computeVertexNormals defensif menjaga normal buatan artis tetap identik di semua LOD dan menghapus satu-satunya diskontinuitas shading dalam rangkaian tersebut. Lihat LOD dan meshoptimizer.
Raycast heightmap analitik untuk pencarian berfrekuensi tinggi. Pencarian kursor dengan frekuensi pointermove terhadap mesh bidang 131 ribu segitiga menelusuri seluruh buffer index pada setiap event. Menggantinya dengan ray-march adaptif terhadap fungsi ketinggian analitik (langkah besar ketika jauh dari permukaan, batas bawah kecil di dekatnya, bisection ketika tanda
Keluarkan pekerjaan dari jalur kritis interaksi. Pekerjaan satu kali yang mahal (bake LOD meshopt, kompilasi pipeline WGSL) sebaiknya dijalankan selama jeda idle, bukan di dalam handler klik. Memuat lebih awal bake penuh preset aktif saat preset dipilih, menyimpan promise yang sedang berjalan dalam cache agar klik cepat bergabung alih-alih memulai proses baru, dan melakukan memoization terhadap prapemrosesan per geometri menurunkan latensi paint pertama dari 209 ms menjadi 4 ms tanpa mengurangi total pekerjaan yang dilakukan.
Bagian 18 dari 29. Sebelumnya: Bagian 17 - Animasi yang tidak memerlukan retargeting, dan pencarian aset langsung Berikutnya: Bagian 19 - Imposter yang harus bertahan di dalam hutan Panduan seri: /id/blog/2026-02-25-open-world-browser-series-guide