Skip to content

Membangun dunia terbuka di browser, bagian 19: Imposter yang harus bertahan di tengah hutan

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.

Bagian 18 memberi kreator kuas yang dapat memenuhi lereng bukit dengan pepohonan. Masalahnya adalah biaya rendering pohon-pohon tersebut ketika ada puluhan ribu di layar. Pohon yang jauh tidak memerlukan 2.000 segitiga hanya untuk menghasilkan empat piksel. Bagian ini membahas LOD terdalam: imposter, sebuah quad datar yang mengenakan foto pohon, serta perjalanan dari "satu pohon yang terlihat benar" hingga sejuta pohon di GPU.

Sebuah pohon adalah dua tekstur pada billboard

Buka Spike 38 di tab baru ↗ · Lihat kode sumber

Imposter melakukan pra-render sebuah prop dari kisi sudut pandang ke dalam dua atlas tekstur, satu untuk warna dan satu untuk normal ruang dunia, lalu saat runtime menampilkan satu quad yang menghadap kamera dan mengambil sampel dari tile yang cocok dengan sudut pandang saat ini. Proses bake terdiri dari dua pass per tile: diffuse dengan material tanpa pencahayaan agar pencahayaan tidak ikut terpanggang ke dalam tekstur, serta normal yang dienkode sebagai normalWorld × 0.5 + 0.5, dengan alpha diteruskan dari sumber agar siluetnya cocok piksel demi piksel. Material runtime-nya adalah MeshStandardNodeMaterial penuh, sehingga imposter tetap menerima cahaya matahari dan IBL dari scene seperti permukaan lainnya. Keuntungannya ada pada geometri: satu quad alih-alih ribuan segitiga, dengan detail yang tersimpan dalam tekstur berukuran 1 MB.

Memisahkan ini menjadi spike tersendiri juga memberikan pelajaran. Imposter bermula sebagai LOD terdalam di dalam sistem scatter spike 37, dan setiap iterasi pada proses bake harus diuji melalui seluruh pipeline scatter, sementara kebenaran hasil bake bercampur dengan migrasi matriks instance dan pergantian LOD. Memisahkannya menjadi satu prop dan satu quad yang ditampilkan berdampingan dengan versi asli memangkas waktu iterasi dari hitungan menit menjadi detik.

Ketika jawaban dari buku teks adalah jawaban yang keliru

Implementasi pertama menggunakan encoding oktahedral, pemetaan standar dari buku teks untuk memadatkan arah pada bola ke dalam persegi. Implementasi tersebut lolos pengujian roundtrip numerik, tetapi pengguna terus mengirimkan tangkapan layar imposter yang melompat ke tile yang membuat pohon terlihat sedikit dari atas, bukan lurus dari depan. Enam putaran perbaikan menyusul (uniform arah pandang per quad, aspek bake persegi, material debug, billboarding statis), dan semuanya memang diperlukan, tetapi tidak satu pun merupakan bug yang sebenarnya. Perbaikannya baru ditemukan setelah kami memutuskan untuk "memikirkannya ulang dari awal, KISS, tanpa tambalan."

Penulisan ulang tersebut membuang lipatan oktahedral dan menggantinya dengan azimut berdasarkan elevasi biasa: az = atan2(dir.x, dir.z), el = asin(dir.y), uv = (az/2π, el/π + ½). Itulah keseluruhan encoding-nya, tanpa normalisasi L1 dan tanpa kasus sudut sign-of-zero. Alasan pendekatan ini lebih baik bukan karena lebih akurat (pengambilan sampelnya pada bola kurang seragam), melainkan karena pemilih sel di CPU dan shader GPU menggunakan primitif yang sama, sehingga keduanya tidak mungkin berselisih pada arah batas seperti yang diam-diam terjadi pada pasangan oktahedral. Atlas tersebut terbaca seperti lembar kontak: kolom menunjukkan sudut mengelilingi prop, baris menunjukkan elevasi, dan semuanya langsung terlihat jelas pada overlay.

Bahkan setelah itu, keluhan "terlihat sedikit dari atas" tetap ada, dan penyebabnya adalah pilihan kuantisasi, bukan encoding. Dengan kisi 4×4, pusat baris berada pada ±22,5° dan ±67,5°, sehingga tidak ada baris yang tepat berada pada elevasi 0°. Penonton yang melihat secara horizontal—kasus yang jauh paling umum—selalu masuk ke baris yang dipanggang dalam posisi miring. Solusinya adalah N ganjil: kisi 5×5 menempatkan pusat baris pada 0°, ±36°, dan ±72°, sehingga penonton horizontal mendapatkan tile yang dipanggang tepat secara horizontal. Keluarga kesalahan "meleset setengah sel" yang sama muncul lagi dalam pembahasan paralaks di bagian berikutnya, dan penyembuhnya adalah pertanyaan yang sama: apakah titik sampel diskret saya benar-benar berada di tempat yang saya kira untuk input kanonis?

Dua bagian lain juga penting. Billboard harus bersifat statis per segmen, bukan terus-menerus menghadap kamera. Imposter adalah foto datar yang diambil dari arah bake tertentu, sehingga bidang gambar quad saat runtime harus cocok dengan bidang kamera bake tersebut. Artinya, orientasinya dipertahankan sepanjang busur tempat satu sel tetap terpilih, lalu melompat pada batasnya. Selain itu, anggaran sebaiknya mengikuti arah pandang pemain yang sebenarnya: pass berikutnya membuang seluruh baris miring dari atas dan menggantinya dengan 24 slot cincin horizontal berjarak 15° serta satu tile lurus dari atas, karena pohon hampir selalu dilihat dari ketinggian mata.

Lompatan, dan bagaimana blending menghapusnya

Buka Spike 42 di tab baru ↗ · Lihat kode sumber

Billboard yang statis per segmen tidak terlihat dari kejauhan tetapi menimbulkan pop dari jarak dekat, yang tidak menjadi masalah sampai Anda mengorbit kamera. Spike 42 menempatkan empat varian berdampingan (prop asli, baseline az/el 5×5, dan dua kisi hemi-oktahedral) untuk mengisolasi kedipan tersebut dan menghilangkannya. Dua artefak mendorong terjadinya lompatan. Pop sel terjadi karena fragment shader mengkuantisasi arah pandang menjadi salah satu dari 25 sel, sehingga ketika melewati batas, tile yang diambil sebagai sampel diganti dan quad diarahkan ulang pada frame yang sama. Degenerasi kutub terjadi pada tampilan lurus dari atas, ketika setiap azimut runtuh menjadi satu titik dan jembatan antara cincin dengan tile atas menjadi transisi terburuk dalam atlas.

Pemetaan hemi-oktahedral memperbaiki keduanya. Pemetaan ini memetakan belahan bola atas secara kontinu ke persegi satuan, sehingga arah 3D yang berdekatan berakhir pada UV yang berdekatan, tanpa singularitas kutub dan tanpa memerlukan tile khusus untuk pandangan lurus dari atas. Kedipan diatasi dengan blending sel bilinear: alih-alih melompat ke tile terdekat, temukan kelompok tile 2×2 yang mengapit arah hasil encoding dan blend keempatnya, dengan total 8 pengambilan sampel tekstur (4 diffuse, 4 normal). Tampilan yang berdekatan kini saling bertransisi dengan cross-fade alih-alih mengalami pop. Hasil blending dua vektor normal satuan bukanlah vektor satuan, sehingga hasilnya dinormalisasi ulang, yang berperilaku seperti slerp untuk sudut kecil di antara tile yang berdekatan. Biayanya memang nyata (atlas 12×12 berukuran sekitar 9 MB dibandingkan 1,6 MB untuk az/el, dan proses bake berlangsung kira-kira 5× lebih lama dengan 288 pass render target), tetapi bake hanya dilakukan sekali saat pemuatan dan blending menghasilkan tampilan tanpa pop. Inilah yang membuat imposter dapat digunakan ketika kamera benar-benar bergerak.

Sejuta pohon, satu penyalinan posisi kamera per frame

Buka Spike 41 di tab baru ↗ · Lihat kode sumber

Runtime Spike 38 menjalankan satu lookAt CPU per quad per frame, yang baik-baik saja untuk satu pohon tetapi mematikan bagi sebuah hutan. Dengan sejuta pohon, pembaruan matriks per frame dan pengunggahan buffer instance akan mendominasi semuanya. Spike 41 memindahkan seluruh pipeline per frame ke GPU. Pusat, yaw, dan skala setiap instance diunggah satu kali saat build sebagai atribut instance dan tidak pernah berubah. Vertex shader membangun basis billboard dari arah pandang ruang dunia camPos − center dan memperluas sebuah quad satuan bersama ke ruang dunia. Fragment shader menjalankan encoding hemi-oktahedral dan blending bilinear per piksel. Satu-satunya pekerjaan CPU per frame untuk seluruh hutan adalah satu Vector3.copy untuk memperbarui uniform posisi kamera, yang sama sekali tidak meningkat seiring jumlah pohon.

Bagian menarik dari perhitungannya adalah bahwa yaw per instance saling meniadakan dalam decoding normal. Bake menyimpan normal dalam kerangka kamera bake, dan karena rotasi yaw di sekitar sumbu atas dunia mempertahankan +Y serta cross product bersifat ekuivarian terhadap rotasi, basis runtime yang dibangun dengan acuan sumbu atas dunia sudah sama dengan basis bake yang telah diputar. Jadi shader menerjemahkan normal secara langsung melalui varying basis runtime tanpa pernah menyentuh yaw per instance. Penempatan menggunakan kisi berjitter alih-alih scatter acak murni: area dibagi menjadi sel, lalu satu pohon ditempatkan per sel pada titik tengah ditambah offset terbatas. Ini menjamin jarak minimum (tidak ada dua pohon yang saling bertumpuk) sambil tetap terlihat seperti hutan alami. Satu detail yang mudah terlewat adalah bounding sphere. Template geometrinya hanyalah quad satuan, sehingga three.js akan melakukan frustum culling terhadap seluruh hutan begitu kamera memandang menjauh dari titik asal. Menetapkan bounding sphere secara eksplisit yang mencakup seluruh area ditambah margin quad mencegah pohon-pohon di sudut terpotong pada sudut pandang menyerong.

Teknologi yang dirujuk dalam bab ini

Atlas imposter oktahedral dan azimut-elevasi. Imposter memanggang sebuah prop dari kisi arah pandang ke dalam atlas diffuse dan atlas normal ruang dunia, lalu merender satu billboard yang mengambil sampel tile yang cocok, menggantikan ribuan segitiga dengan dua tekstur. Pemetaan oktahedral standar dari buku teks memberikan cakupan bola yang seragam, tetapi rentan terhadap perbedaan CPU/GPU pada batas lipatan. Kisi azimut berdasarkan elevasi biasa mengambil sampel bola dengan distribusi yang kurang seragam, tetapi secara konstruksi menjamin pemilih sel dan shader selalu sepakat. Gunakan N ganjil agar satu baris berada tepat pada elevasi 0°, dan alokasikan anggaran tile untuk cincin horizontal karena prop biasanya dilihat dari ketinggian mata.

Orientasi billboard statis per segmen. Imposter adalah foto dari arah bake tertentu, sehingga bidang gambar quad saat runtime harus cocok dengan bidang kamera bake, bukan terus-menerus menghadap kamera runtime. Quad mempertahankan orientasi sepanjang busur tempat satu sel tetap terpilih, lalu melompat pada batasnya. Hal ini tidak terlihat pada jarak penggunaan imposter dan hanya menimbulkan pop dari dekat, tempat imposter memang tidak digunakan.

Atlas hemi-oktahedral dengan blending sel bilinear. Pemetaan belahan bola atas secara kontinu ke persegi satuan menghapus singularitas kutub dan kebutuhan akan tile khusus untuk pandangan lurus dari atas. Lompatan dihilangkan dengan mengambil sampel kelompok tile 2×2 yang mengapit arah pandang hasil encoding dan melakukan blending bilinear terhadap keempat tile tersebut (8 pengambilan sampel), sehingga tampilan yang berdekatan saling bertransisi dengan cross-fade. Normal hasil blending dinormalisasi ulang, mendekati slerp untuk sudut kecil antartile. Biayanya adalah atlas yang lebih besar (sekitar 9 MB pada 12×12) dan proses bake satu kali yang lebih lama, sebagai imbalan atas shading bebas pop saat kamera bergerak.

Imposter instance berbasis GPU. Pusat, yaw, dan skala per instance diunggah satu kali sebagai atribut instance; vertex shader membangun basis billboard dan memperluas sebuah quad satuan bersama, sementara fragment shader melakukan encoding dan blending per piksel. Biaya CPU per frame untuk seluruh hutan hanyalah satu penyalinan uniform posisi kamera, terlepas dari jumlah instance. Yaw per instance saling meniadakan dalam decoding normal karena yaw di sekitar sumbu atas dunia dipertahankan oleh konstruksi basis cross product. Bounding sphere eksplisit yang mencakup seluruh hutan mencegah three.js melakukan frustum culling terhadap seluruh mesh instance ketika kamera memandang menjauh dari titik asal template quad satuan. Lihat LOD berbasis GPU.


Bagian 19 dari 29. Sebelumnya: Bagian 18 - Kuas scatter yang terasa seperti ditempatkan oleh AI Berikutnya: Bagian 20 - Memalsukan kedalaman pada bidang datar Panduan seri: /id/blog/2026-02-25-open-world-browser-series-guide