Dua tahun lalu, saya sudah bereksperimen dengan
zat bayangan di Phaser 2D. Pada Ludum Dare terakhir, kami tiba-tiba memutuskan untuk membuat kengerian, dan betapa mengerikannya tanpa bayangan dan cahaya! Saya memecahkan buku-buku jari saya ...
... dan bukan hal yang tepat waktu untuk LD. Dalam permainan, tentu saja, ada sedikit cahaya dan bayangan, tetapi ini adalah kemiripan yang menyedihkan dari apa yang seharusnya terjadi.
Setelah kembali ke rumah setelah mengirim permainan ke kontes, saya memutuskan untuk "menutup gestalt" dan menyelesaikan bayang-bayang malang ini. Apa yang terjadi - Anda bisa
merasakan di dalam game ,
bermain di demo , melihat gambar, dan membaca di artikel.
Seperti biasa dalam kasus seperti itu, tidak masuk akal untuk mencoba menulis solusi umum, Anda perlu fokus pada situasi tertentu. Dunia permainan dapat direpresentasikan dalam bentuk segmen - setidaknya entitas yang membuat bayangan. Dinding adalah persegi panjang, orang persegi panjang, hanya diputar, spoiler infernal adalah sebuah lingkaran, tetapi dalam model cut-off dapat disederhanakan menjadi panjang diameter yang selalu tegak lurus terhadap sinar cahaya.
Ada beberapa sumber cahaya (20-30), dan semuanya berbentuk lingkaran (lampu sorot) dan terletak bersyarat lebih rendah dari objek yang diterangi (sehingga bayangan dapat menjadi tak terbatas).
Saya melihat di kepala saya cara-cara berikut untuk menyelesaikan masalah:
- Untuk setiap sumber cahaya, kami membangun tekstur ukuran layar (baik, atau 2-4 kali lebih kecil). Pada tekstur ini, kita cukup menggambar BCC'D 'trapesium, di mana A adalah sumber cahaya, BC adalah segmennya, B'C' adalah proyeksi segmen ke tepi tekstur. Setelah itu, tekstur ini dikirim ke shader, di mana mereka dicampur menjadi satu gambar.
Penulis platformer Celeste melakukan sesuatu seperti ini, yang ditulis dengan baik dalam artikelnya di media: medium.com/@NoelFB/remaking-celestes-lighting-3478d6f10bf
Masalah: 20-30 tekstur ukuran layar yang perlu digambar ulang hampir setiap frame dan dimuat ke dalam GPU. Saya ingat bahwa ini adalah proses yang sangat, sangat tidak cepat.
- Metode yang dijelaskan dalam posting di habr - habr.com/post/272233 . Untuk setiap sumber cahaya, kami membangun "peta kedalaman", mis. tekstur seperti itu, di mana x = sudut "balok" dari sumber cahaya, y = jumlah sumber cahaya, dan warna == jarak dari sumber ke rintangan terdekat. Jika kita mengambil langkah 0,7 derajat (360/512), dan 32 sumber cahaya, kita mendapatkan satu tekstur 512x32, yang belum diperbarui begitu lama.
(contoh tekstur untuk langkah 45 derajat)
- Cara rahasia yang akan saya jelaskan di bagian paling akhir
Pada akhirnya, saya memilih metode 2. Namun, yang dijelaskan dalam artikel itu tidak cocok untuk saya sampai akhir. Di sana, tekstur juga dibangun di shader menggunakan rakecast - shader dalam siklus pergi dari sumber cahaya ke arah balok dan mencari penghalang. Dalam eksperimen saya yang lalu, saya juga membuat rakecast di shader, dan harganya sangat mahal, meskipun bersifat universal.
βKami hanya memiliki segmen dalam model,β saya pikir, βdan 10-20 segmen jatuh ke jari-jari sumber cahaya apa pun. Tidak bisakah saya dengan cepat menghitung peta jarak berdasarkan ini? "
Jadi saya memutuskan untuk melakukannya.
Untuk mulai dengan, saya hanya ditampilkan di layar dinding, "karakter utama" bersyarat dan sumber cahaya. Di sekitar sumber cahaya, lingkaran cahaya bening murni memotong dalam kegelapan. Untuk mendapatkan ini:
( demo )Saya segera mulai melakukannya dengan shader agar tidak rileks. Itu perlu untuk masuk ke dalamnya untuk setiap sumber cahaya koordinat dan jari-jarinya tindakan (di luar yang cahaya tidak mencapai), ini dilakukan hanya melalui array yang seragam. Dan kemudian dalam shader (yang terpisah-pisah, yang dilakukan untuk setiap piksel pada layar), tetap untuk memahami apakah piksel saat ini berada di lingkaran kita yang diterangi atau tidak.
class SimpleLightShader extends Phaser.Filter { constructor(game) { super(game); let lightsArray = new Array(MAX_LIGHTS*4); lightsArray.fill(0, 0, lightsArray.length); this.uniforms.lightsCount = {type: '1i', value: 0}; this.uniforms.lights = {type: '4fv', value: lightsArray}; this.fragmentSrc = ` precision highp float; uniform int lightsCount; uniform vec4 lights[${MAX_LIGHTS}]; void main() { float lightness = 0.; for (int i = 0; i < ${MAX_LIGHTS}; i++) { if (i >= lightsCount) break; vec4 light = lights[i]; lightness += step(length(light.xy - gl_FragCoord.xy), light.z); } lightness = clamp(0., 1., lightness); gl_FragColor = mix(vec4(0,0,0,0.5), vec4(0,0,0,0), lightness); } `; } updateLights(lightSources) { this.uniforms.lightsCount.value = lightSources.length; let i = 0; let array = this.uniforms.lights.value; for (let light of lightSources) { array[i++] = light.x; array[i++] = game.world.height - light.y; array[i++] = light.radius; i++; } } }
Sekarang kita perlu memahami untuk setiap sumber cahaya segmen mana yang akan memberikan bayangan. Sebaliknya, bagian mana dari segmen - pada gambar di bawah ini kita tidak tertarik pada bagian "merah" dari segmen, karena cahaya masih tidak mencapai mereka.
Catatan: definisi persimpangan adalah semacam optimisasi awal. Ini diperlukan untuk mengurangi waktu pemrosesan lebih lanjut, menghilangkan potongan-potongan besar segmen di luar radius sumber cahaya. Ini masuk akal ketika kita memiliki banyak segmen yang panjangnya jauh lebih besar dari jari-jari "cahaya". Jika ini bukan masalahnya, dan kami memiliki banyak segmen pendek, mungkin benar untuk tidak membuang waktu menentukan persimpangan dan memproses seluruh segmen, karena menghemat waktu masih tidak berfungsi.Untuk melakukan ini, saya menggunakan rumus terkenal untuk menemukan persimpangan garis lurus dan lingkaran, yang diingat oleh semua orang dari pelajaran sekolah dalam geometri ... di dunia imajiner seseorang. Saya tidak mengingatnya, jadi saya harus mencari di
google .
Kami menyandikan, lihat apa yang terjadi.
( demo )Tampaknya menjadi norma. Sekarang kita tahu segmen mana yang dapat memberikan bayangan dan dapat melakukan rakecast.
Di sini kami juga memiliki opsi:
- Kami hanya berputar-putar, melempar sinar dan mencari persimpangan. Jarak ke persimpangan terdekat adalah nilai yang kita butuhkan
- Anda hanya bisa pergi ke sudut-sudut yang masuk dalam segmen. Lagi pula, kita sudah tahu poinnya, tidak sulit untuk menghitung sudutnya.
- Selanjutnya, jika kita berjalan di sepanjang ruas, maka kita tidak perlu melempar sinar dan menghitung persimpangan - kita bisa bergerak di sepanjang ruas dengan langkah yang diinginkan. Begini cara kerjanya:
Di sini
- segmen (dinding),
Apakah pusat sumber cahaya,
- tegak lurus ke segmennya.
Biarkan
- sudut ke normal, di mana Anda perlu mencari tahu jarak dari sumber ke segmen,
- tunjuk pada segmen
di mana sinar itu jatuh. Segitiga
- persegi panjang
- Kaki, dan panjangnya diketahui dan konstan untuk segmen ini,
- panjang yang diinginkan.
. Jika Anda mengetahui langkah sebelumnya (dan kami tahu itu), maka Anda dapat menghitung awal tabel cosinus terbalik dan mencari jarak dengan sangat cepat.
Saya akan memberikan contoh kode untuk tabel seperti itu. Hampir semua pekerjaan dengan sudut digantikan oleh kerja dengan indeks, yaitu bilangan bulat dari 0 hingga N, di mana N = jumlah langkah dalam lingkaran (mis. langkah sudut =
)
class HypTable { constructor(steps = 512, stepAngle = 2*Math.PI/steps) { this.perAngleStep = [1]; for (let i = 1; i < steps/4; i++) {
Tentu saja, metode ini memperkenalkan kesalahan untuk kasus-kasus di mana ACD sudut awal bukan kelipatan langkah. Tapi untuk 512 langkah, saya tidak melihat perbedaan secara visual.
Jadi apa yang sudah kita ketahui bagaimana melakukannya:
- Temukan segmen dalam kisaran sumber cahaya yang dapat membuat bayangan
- Untuk langkah t, buat tabel dist (sudut), melewati setiap segmen dan menghitung jarak.
Seperti apa tabel ini jika Anda menggambarnya dalam sinar.
( demo )Dan berikut ini tampilannya 10 sumber cahaya, jika ditulis dalam tekstur.
Di sini, setiap piksel horizontal bersesuaian dengan sudut, dan warna dengan jarak dalam piksel.
Itu ditulis dalam js seperti ini menggunakan
imageData fillBitmap(data, index) { let total = index + this.steps*4; let d1, d2; let i = 0;
Sekarang kami meneruskan tekstur ke shader kami, yang sudah memiliki koordinat dan jari-jari sumber cahaya. Dan prosesnya seperti ini:
Hasil:
( demo )Sekarang kamu bisa menghadirkan sedikit keindahan. Biarkan cahaya memudar dengan jarak, dan bayangan akan buram.
Untuk kabur, saya melihat sudut yang berdekatan, + - langkah, seperti ini:
thisLightness = (1. - getShadow(i, angle, distance)) * 0.4 + (1. - getShadow(i, angle-SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle+SMOOTH_STEP, distance)) * 0.2 + (1. - getShadow(i, angle-SMOOTH_STEP*2., distance)) * 0.1 + (1. - getShadow(i, angle+SMOOTH_STEP*2., distance)) * 0.1;
Jika Anda menggabungkan semuanya dan mengukur FPS, hasilnya seperti ini:
- Pada kartu video built-in - semuanya buruk (<30-40), bahkan untuk contoh sederhana
- Segala sesuatu yang lain baik-baik saja, selama sumber cahaya tidak terlalu kuat. Artinya, jumlah sumber cahaya per piksel penting, bukan jumlah total.
Hasil ini cukup cocok untukku. Anda masih bisa bermain dengan warna pencahayaan, tetapi saya tidak. Setelah memutar sedikit dan menambahkan beberapa peta normal, saya mengunggah versi NOPE yang diperbarui. Dia tampak seperti ini sekarang:
Kemudian dia mulai menyiapkan artikel. Saya melihat gif dan pemikiran seperti itu.
βJadi ini hampir mirip pseudo-3D, seperti di Wolfenstein,β seruku (ya, aku punya imajinasi yang bagus). Dan faktanya - jika kita mengasumsikan bahwa semua dinding memiliki ketinggian yang sama, maka jarak peta akan cukup bagi kita untuk membangun pemandangan. Kenapa tidak mencobanya?
Adegan harus terlihat seperti ini.
Jadi tugas kita:
- Pada titik di layar, dapatkan koordinat dunia untuk kasing ketika tidak ada dinding.
Kami akan mempertimbangkan ini:
- Pertama, kita menormalkan koordinat titik di layar sehingga ada titik (0,0) di tengah layar, dan di sudut (-1, -1) dan (1,1), masing-masing
- Koordinat x menjadi sudut dari arah pandang, Anda hanya perlu mengalikannya dengan A / 2, di mana A adalah sudut pandangnya
- Koordinat y menentukan jarak dari pengamat ke titik, dalam kasus umum d ~ 1 / y. Untuk titik di tepi bawah layar, jarak = 1, untuk titik di tengah layar, jarak = tak terhingga.
- Jadi, jika Anda tidak memperhitungkan dinding, maka untuk setiap titik yang terlihat di dunia akan ada 2 titik di layar - satu di atas tengah (di "langit-langit") dan yang lainnya di bawah (di "lantai")
- Sekarang kita bisa melihat tabel jarak. Jika ada dinding yang lebih dekat dari titik kami, maka Anda perlu menggambar dinding. Jika tidak, itu berarti lantai atau langit-langit
Kami mendapatkan sesuai pesanan:
( demo )Tambahkan pencahayaan - dengan cara yang sama, beralih ke sumber cahaya dan periksa koordinat dunia. Dan - sentuhan terakhir - tambahkan tekstur. Untuk melakukan ini, dalam tekstur dengan jarak, Anda juga perlu menulis offset u untuk tekstur dinding pada titik ini. Di sinilah saluran b berguna.
( demo )Sempurna
Hanya bercanda
Tidak sempurna, tentu saja. Tapi sial, saya masih membaca tentang bagaimana membuat Wolfenstein saya melalui rakecast sekitar 15 tahun yang lalu, dan saya ingin melakukan semuanya, dan inilah kesempatan seperti itu!
Alih-alih sebuah kesimpulan
Di awal artikel, saya menyebutkan metode rahasia lain. Ini dia:
Ambil saja mesin yang sudah tahu caranya.
Bahkan, jika Anda perlu membuat game, maka ini akan menjadi cara yang paling benar dan tercepat. Mengapa Anda perlu memagari sepeda dan memecahkan masalah lama?
Tapi kenapa.
Di kelas 10, saya pindah ke sekolah lain dan mengalami masalah dalam matematika. Saya tidak ingat contoh persisnya, tetapi itu adalah persamaan dengan derajat, yang dalam segala hal perlu disederhanakan, tetapi itu tidak berhasil. Putus asa, saya berkonsultasi dengan saudara perempuan saya, dan dia berkata: "jadi tambahkan x
2 di kedua sisi, dan semuanya akan terurai." Dan itu solusinya: tambahkan apa yang tidak ada.
Ketika, jauh kemudian, saya membantu teman saya membangun rumah saya, saya harus meletakkan blok pada ambang pintu - untuk mengisi ceruk. Dan di sini saya berdiri dan memilah pinggiran jeruji. Seseorang tampaknya cocok, tetapi tidak cukup. Lainnya jauh lebih kecil. Saya sedang berpikir tentang cara mengumpulkan kata kebahagiaan di sini, dan seorang teman mengatakan: "jadi mereka meminum lekukan di tempat melingkar tempat itu mengganggu". Dan sekarang bar besar sudah berdiri diam.
Kisah-kisah ini disatukan oleh efek seperti itu, yang saya sebut "efek inventaris". Ketika Anda mencoba membuat keputusan dari bagian yang ada, tanpa melihat materi yang dapat diproses dan disempurnakan di bagian ini. Angka adalah kayu, uang, atau kode.
Banyak kali saya mengamati efek yang sama dengan rekan kerja dalam pemrograman. Tidak merasa percaya diri pada materi, mereka terkadang menyerah ketika perlu untuk melakukan, katakanlah, kontrol non-standar. Atau tambahkan tes unit ke tempat mereka tidak. Atau mereka mencoba menyediakan segalanya, semuanya saat merancang kelas, dan kemudian kita mendapatkan dialog seperti:
- Ini tidak perlu sekarang
- Bagaimana jika itu perlu?
- Lalu kita akan menambahkan. Tinggalkan poin ekspansi, itu saja. Kode bukan granit, melainkan plastisin.
Dan untuk belajar melihat dan merasakan materi yang kami kerjakan, kami juga membutuhkan sepeda.
Ini bukan hanya latihan untuk pikiran atau pelatihan. Ini adalah cara untuk mencapai tingkat pekerjaan yang berbeda secara kualitatif dengan kode.
Terima kasih sudah membaca.
Tautan, jika Anda lupa mengklik di suatu tempat: