Skip to content

Membangun dunia terbuka di browser, bagian 20: Memalsukan kedalaman pada bidang datar

Oleh Oleg Sidorkin, CTO dan Salah Satu Pendiri Cinevva

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

Bagian 19 menggunakan quad datar untuk memalsukan sebuah pohon utuh dari kejauhan. Bagian ini menggunakan quad datar untuk memalsukan kedalaman dari dekat: parallax occlusion mapping, trik yang membuat jalan berbatu terlihat memiliki nat sedalam 5 cm tanpa menggunakan satu pun vertex tambahan. Tujuannya adalah menerapkannya pada stack produksi (Three.js r184, WebGPU, TSL), sehingga material detail medan dapat menghadirkan ilusi kedalaman tersebut di tempat yang diperlukan dan hanya menanggung biaya tekstur datar di tempat lain.

Tiga cara memalsukan kedalaman, ditampilkan berdampingan

Buka Spike 39 di tab baru ↗ · Lihat kode sumber

Spike ini menempatkan tiga bidang datar berukuran 5×5 m secara berdampingan. Semuanya menggunakan kerangka material yang sama dan hanya berbeda pada UV yang diteruskan ke sampler. Flat mengambil sampel tekstur secara langsung sebagai baseline referensi. Parallax sampel tunggal menggeser UV satu kali sepanjang arah pandang berdasarkan ketinggian pada titik tersebut. Teknik ini murah dan cukup baik pada amplitudo rendah, tetapi tampak bergeser pada sudut pandang landai. POM melakukan ray marching dalam ruang tangen: bergerak sepanjang sinar pandang, menemukan lapisan pertama tempat sinar melewati bagian bawah heightfield, lalu menyempurnakan titik perpotongannya. Ruang tangen tetap sederhana karena setiap bidang pengujian sejajar dengan sumbu, sehingga arah pandang dapat dikemas ke dalam ruang tangen hanya dengan beberapa pembalikan tanda, alih-alih matriks TBN lengkap per vertex. Set teksturnya diambil secara langsung dari API berkas Polyhaven, melalui jalur yang sama dengan pencarian model pada Bagian 17.

Dua kendala WebGPU dan ray marching tanpa percabangan

Loop POM standar berhenti dari pencarian pada perpotongan pertama. Pada r184, cara ini tidak berfungsi karena dua alasan terpisah. If(...).and(...) dapat dikompilasi tanpa error, tetapi menghasilkan WGSL yang tidak pernah menjalankan isi loop. Akibatnya, penyempurnaan setelah loop dijalankan pada data sampah dan bidang dirender hampir putih. Selain itu, Break() sebagai node mandiri belum tersedia dalam build r184, sehingga bahkan dengan If yang berfungsi, tidak ada cara untuk menyatakan "berhenti pada perpotongan pertama." Keduanya berkaitan dengan masalah three.js yang telah diketahui seputar alur kontrol TSL yang dioptimalkan secara berlebihan melintasi batas If dan Loop pada rentang versi ini.

Versi yang ditulis ulang tidak menggunakan percabangan. Setiap iterasi selalu mengambil sampel tekstur, sehingga akses tekstur tetap berada dalam alur kontrol seragam sebagaimana diharuskan spesifikasi WGSL. Kemudian, state baru digabungkan melalui flag done yang disimpan sebagai float. Setelah done berubah menjadi 1, pemanggilan mix per iterasi menyederhana menjadi "pertahankan state tanpa perubahan," yang merupakan padanan tanpa percabangan dari break. Flag done dibuat dengan helper step yang diimplementasikan sebagai 0.5 + 0.5 × sign(x + ε) karena konversi boolean ke float tidak konsisten pada seri r18x, sedangkan sign() aman digunakan di semua versi. Konsekuensinya, setiap fragmen menjalankan seluruh 64 iterasi, terlepas dari posisi perpotongan sebenarnya. Namun, itu adalah kompromi yang tepat pada skala fragmen: runtime tetap membatasi berdasarkan jumlah langkah maksimum, dan GPU nyata juga akan melakukan spekulasi melewati break "nyata". Fallback yang bersih sangat penting di sini, yaitu mix(baseUV, refined, done), sehingga pada amplitudo nol (ujung jauh dari fade berdasarkan jarak), tidak ada fragmen yang berpotongan, done tetap 0, dan material POM identik secara bit dengan material datar. Itulah inti trik LOD berbasis jarak: kembali ke biaya material datar ketika efeknya sudah berada di bawah ukuran piksel.

Bug tersebut adalah kegagalan disiplin, bukan kegagalan matematika

Versi tanpa percabangan dapat berjalan, tetapi tampil terdistorsi, dengan artefak horizontal memanjang pada amplitudo sedang dan hasil yang agak keliru tetapi tidak tampak jelas pada amplitudo rendah. Perbaikannya berawal dari prompt satu baris: baca referensi kanonisnya. Tutorial LlamAcademy yang menginspirasi pekerjaan ini hanyalah sebuah node Unity ShaderGraph, jadi implementasi sebenarnya berada dalam PerPixelDisplacement.hlsl milik Unity. Membacanya baris demi baris mengungkap tiga perbedaan semantik yang tanpa sadar saya perkenalkan: kesalahan off-by-one pada baseline ketinggian sinar (Unity melakukan perpindahan awal sebelum loop, sehingga kerangka acuan saya berbeda satu langkah penuh dan membuat perpotongan berada di lapisan yang salah sekitar separuh waktu), konvensi tanda pada offset maksimum yang diperlukan oleh langkah penyempurnaan, serta pilihan pencatatan offset kumulatif versus UV kumulatif yang membuat matematika penyempurnaan saya bekerja lebih keras dan mengacaukan tandanya.

Penyebab utamanya bukan satu kesalahan tertentu, melainkan pencampuran dua referensi. Saya menggunakan tutorial POM LearnOpenGL sebagai panduan. Tutorial tersebut memakai konvensi tanda yang serupa tetapi berbeda serta rumus penyempurnaan yang berbeda. Akibatnya, saya berakhir dengan gabungan yang tidak konsisten: dua pertiga matematikanya mengikuti satu sumber dan sepertiga lainnya mengikuti sumber lain. Versi baru merupakan porting hampir verbatim dari HLSL Unity ke TSL, dengan nama variabel, perpindahan awal, dan penyempurnaan yang sama, ditambah flag done tanpa percabangan. Pelajarannya layak diingat: saat mem-porting shader yang telah terbukti berfungsi dari stack lain, porting terlebih dahulu baris demi baris dengan nama yang sama, lalu lakukan refactor agar sesuai dengan gaya lokal. Jangan melakukan derivasi ulang berdasarkan referensi kedua di tengah proses porting.

Bidang referensi yang tidak bisa berbohong

Perbandingan berdampingan tersebut belum menyertakan hal yang paling jelas: bidang dengan geometri nyata. Tanpanya, pernyataan "POM terlihat cukup bagus" tidak dapat diuji kebenarannya. Cukup bagus dibandingkan dengan apa? Karena itu, spike menambahkan bidang keempat dengan heightmap yang sama, tetapi diterapkan pada posisi vertex sebenarnya. WebGPU tidak memiliki tessellation perangkat keras (fitur tersebut memang tidak ada dalam spesifikasi karena dihapus demi kompatibilitas dengan Metal), sehingga penggantinya adalah bidang dengan subdivisi padat (256×256 segmen, 131.072 segitiga) dan displacement vertex pada tahap vertex. Uniform amplitudo yang sama mengendalikan POM dan bidang geometri, sehingga keduanya mengalami fade bersama dan perbandingannya tetap setara pada setiap jarak.

Dengan ground truth di layar, klaim kualitatif menjadi dapat diukur. Pada orbit 16° yang mengarah ke bawah, POM dan bidang yang ditessellasi menghasilkan shading internal yang serupa. Pada sudut pandang landai, keduanya menyimpang tepat di tempat yang semestinya: POM dibatasi oleh tepi persegi panjang geometri yang sepenuhnya lurus, sedangkan mesh nyata memperlihatkan profil horizon bergelombang dari puncak dan lembah aktual yang menangkap cahaya. Dengan demikian, efek "swimming" pada tepi POM kini dapat dibuktikan sebagai sifat bawaan algoritma, bukan artefak tekstur atau pencahayaan. Karakteristik biaya keduanya juga menjadi jelas: POM dibatasi oleh pemrosesan fragmen (biaya meningkat sesuai jumlah piksel yang dicakup), sedangkan bidang yang ditessellasi dibatasi oleh pemrosesan vertex (biaya meningkat sesuai kepadatan mesh, terlepas dari cakupannya). Untuk chunk medan yang sudah menanggung biaya vertex dari bidang berbasis heightmap, POM adalah pilihan yang tepat untuk detail yang lebih kecil daripada mesh.

Bidang referensi tersebut juga menemukan bug UX yang samar. Pengguna melihat permukaan seolah tenggelam ketika amplitudo ditingkatkan. Penyebabnya adalah konvensi Unity yang memperlakukan bidang geometris sebagai bagian teratas heightfield. Akibatnya, puncak tetap rata dengan bidang dan bagian lainnya mengalami parallax ke bawah, sehingga permukaan rata-rata turun di bawah baseline datar sebesar (1 − mean_h) × amplitude. Perbaikannya memusatkan ulang konvensi tersebut agar h = 0.5 menjadi posisi bidang, puncak naik menuju kamera, dan lembah masuk ke dalam. Algoritmanya tetap berjalan persis seperti yang ditentukan Unity; spike hanya melakukan pascapemrosesan pada output sebesar setengah offset agar sesuai dengan arti "amplitudo" yang diharapkan seseorang saat menggeser slider.

Bidang referensi juga menyelesaikan satu persoalan lain. Slider "Langkah" tampak tidak melakukan apa pun, sehingga terlihat seperti bug pada rangkaian data, padahal bukan. Penyempurnaan secant tiga iterasi setelah pencarian linear sangat efektif (makalah POM Tatarchuk tahun 2006 menyatakan bahwa pencarian 4 langkah ditambah secant 3 langkah secara visual tidak dapat dibedakan dari pencarian 64 langkah). Karena itu, pada heightmap yang halus, setiap jumlah langkah dari 4 hingga 64 menghasilkan UV subteksel yang sama. Perbaikannya adalah toggle, bukan merombak rangkaian data: nonaktifkan secant dan slider langkah menjadi satu-satunya kontrol atas presisi perpotongan. Menurunkannya ke 4 akan membuat batu jalan terlihat berundak, sedangkan meningkatkannya ke 64 akan menghaluskannya kembali. Toggle tersebut adalah uniform 0/1 yang menggunakan mix untuk mengubah setiap pembaruan state secant menjadi no-op saat dinonaktifkan, sehingga toggle tidak pernah membangun ulang material dan tidak pernah menimbulkan stutter.

Teknologi yang dirujuk dalam bab ini

Parallax occlusion mapping dalam TSL. POM melakukan ray marching terhadap arah pandang melalui heightfield dalam ruang tangen, menemukan lapisan pertama tempat sinar turun ke bawah permukaan, lalu menyempurnakan titik perpotongannya. Hasilnya adalah kedalaman nat cekung pada quad datar tanpa geometri tambahan. mix(baseUV, refined, done) terakhir membuat material identik secara bit dengan material datar ketika tidak ada fragmen yang berpotongan. Hal inilah yang memungkinkan atenuasi amplitudo LOD berbasis jarak menurunkan biayanya menjadi biaya tekstur datar pada jarak jauh. Lihat material medan.

Loop tanpa percabangan untuk alur kontrol WebGPU. Pada Three.js r184, If(...).and(...) dalam TSL dapat dikompilasi menjadi WGSL yang tidak pernah menjalankan isi loop, sedangkan Break() mandiri belum tersedia. Pola yang portabel adalah mengambil sampel tekstur tanpa syarat pada setiap iterasi (menjaga akses tekstur dalam alur kontrol seragam sesuai spesifikasi WGSL), ditambah flag done yang disimpan sebagai float dan menggunakan mix untuk mengubah setiap pembaruan state menjadi no-op setelah flag ditetapkan. Helper step yang dibuat dari sign(x + ε) menghindari konversi boolean ke float yang tidak andal. Biayanya selalu sebesar jumlah iterasi maksimum, terlepas dari titik penghentian awal, yang merupakan kompromi tepat pada skala fragmen.

Porting shader secara verbatim. Shader yang telah terbukti berfungsi dari engine lain sebaiknya terlebih dahulu di-porting baris demi baris dengan nama variabel asli, baru kemudian di-refactor agar sesuai dengan gaya lokal. Mencampurkan dua referensi (PerPixelDisplacement.hlsl milik Unity dan tutorial LearnOpenGL) menghasilkan gabungan yang tidak konsisten, dengan baseline sinar yang off-by-one, tanda offset yang terbalik, dan rumus penyempurnaan yang clamp-nya menyamarkan bobot di luar rentang sebagai diskontinuitas spasial. Gunakan satu ground truth kanonis, bukan derivasi ulang.

Referensi ground truth dengan displacement vertex. Karena WebGPU tidak memiliki tessellation perangkat keras, bidang dengan subdivisi padat (256² segmen) yang diberi displacement pada tahap vertex berperan sebagai geometri nyata untuk memvalidasi efek palsu pada tahap fragmen. Mengendalikan keduanya dengan uniform amplitudo yang sama menjaga kejujuran perbandingan pada berbagai jarak. POM dibatasi oleh fragmen (meningkat sesuai piksel yang dicakup), sedangkan bidang geometri dibatasi oleh vertex (meningkat sesuai kepadatan mesh). Karena itu, keduanya menyimpang tepat pada tepi siluet, membuktikan bahwa efek swimming pada tepi POM merupakan sifat bawaan, bukan artefak.


Bagian 20 dari 29. Sebelumnya: Bagian 19 - Imposter yang harus bertahan di dalam hutan Berikutnya: Bagian 21 - Renderer lebih cepat yang ternyata tidak lebih cepat Panduan seri: /id/blog/2026-02-25-open-world-browser-series-guide