Bagaimana menulis Shader asap
() translation by (you can also view the original English article)
Selalu ada udara tertentu misteri di sekitar asap. It's estetis menyenangkan untuk menonton dan sukar untuk model. Seperti banyak fenomena fisik, itu adalah sistem yang kacau, yang membuatnya sangat sulit untuk memprediksi. Keadaan simulasi sangat bergantung pada interaksi antara partikel yang individu.
Ini adalah persis apa yang membuatnya seperti masalah besar untuk menangani dengan GPU: dapat dipecah menjadi perilaku partikel tunggal, diulang secara bersamaan jutaan kali di lokasi yang berbeda.
Dalam tutorial ini, saya akan memandu Anda melalui menulis shader asap dari awal, dan mengajarkan Anda beberapa teknik shader berguna sehingga Anda dapat memperluas gudang senjata Anda dan mengembangkan efek Anda sendiri!
Apa yang akan Anda Pelajari
Ini adalah hasil akhir yang kita akan bekerja menuju:
Kami akan menerapkan algoritma yang disajikan dalam makalah Jon Stam pada Dinamika fluida Real-Time dalam permainan. Anda juga akan belajar bagaimana membuat tekstur, juga dikenal sebagai menggunakan buffer frame, yang merupakan teknik yang sangat berguna dalam pemrograman shader untuk mencapai banyak efek.
Sebelum Anda memulai
Contoh dan detail penerapan spesifik dalam tutorial ini menggunakan JavaScript dan ThreeJS, tetapi Anda harus dapat mengikuti semua platform yang mendukung shader. (Jika Anda tidak terbiasa dengan dasar-dasar pemrograman shader, pastikan Anda melalui setidaknya dua tutorial pertama dalam seri ini.)
Semua contoh kode di-host di CodePen, tetapi Anda juga dapat menemukannya di repositori GitHub yang terkait dengan artikel ini (yang mungkin lebih mudah dibaca).
Teori dan latar belakang
Algoritma dalam makalah Jos Stam lebih mengutamakan kecepatan dan kualitas visual atas akurasi fisik, yang persis seperti yang kami inginkan dalam pengaturan gim.
Makalah ini dapat terlihat jauh lebih rumit daripada yang sebenarnya, terutama jika Anda tidak berpengalaman dalam persamaan diferensial. Namun, seluruh intisari teknik ini dirangkum dalam gambar ini:



Ini semua yang perlu kita terapkan untuk mendapatkan efek asap yang tampak realistis: nilai di setiap sel menghilang ke semua sel tetangganya pada setiap iterasi. Jika tidak jelas cara kerjanya, atau jika Anda hanya ingin melihat bagaimana tampilan ini, Anda dapat mengotak-atik demo interaktif ini:



Mengeklik sel mana pun menetapkan nilainya menjadi 100
. Anda dapat melihat bagaimana setiap sel secara perlahan kehilangan nilainya kepada tetangganya seiring waktu. Mungkin paling mudah untuk melihat dengan mengklik Next untuk melihat masing-masing frame. Ganti Display Mode untuk melihat bagaimana tampilannya jika kami membuat nilai warna sesuai dengan angka-angka ini.
Demo di atas semua dijalankan pada CPU dengan loop melalui setiap sel. Inilah tampilan lingkaran itu:
1 |
//W = number of columns in grid
|
2 |
//H = number of rows in grid
|
3 |
//f = the spread/diffuse factor
|
4 |
//We copy the grid to newGrid first to avoid editing the grid as we read from it
|
5 |
for(var r=1; r<W-1; r++){ |
6 |
for(var c=1; c<H-1; c++){ |
7 |
newGrid[r][c] += |
8 |
f * |
9 |
(
|
10 |
gridData[r-1][c] + |
11 |
gridData[r+1][c] + |
12 |
gridData[r][c-1] + |
13 |
gridData[r][c+1] - |
14 |
4 * gridData[r][c] |
15 |
);
|
16 |
}
|
17 |
}
|
Cuplikan ini benar-benar inti dari algoritma. Setiap sel mendapatkan sedikit dari empat sel tetangganya, minus nilainya sendiri, di mana f
adalah faktor yang kurang dari 1. Kami mengalikan nilai sel saat ini sebesar 4 untuk memastikannya berdifusi dari nilai yang lebih tinggi ke nilai yang lebih rendah.
Untuk memperjelas poin ini, pertimbangkan skenario ini:

Ambil sel di tengah (pada posisi [1,1]
di grid) dan menerapkan persamaan difusi di atas. Mari kita asumsikan f
adalah 0,1
:
1 |
0.1 * (100+100+100+100-4*100) = 0.1 * (400-400) = 0 |
Tidak ada difusi yang terjadi karena semua sel memiliki nilai yang sama!
Jika kita menganggap sel di bagian kiri atas sebagai gantinya (asumsikan sel di luar kisi yang digambarkan semuanya 0
):
1 |
0.1 * (100+100+0+0-4*0) = 0.1 * (200) = 20 |
Jadi kita mendapat kenaikan bersih 20! Mari kita pertimbangkan kasus terakhir. Setelah satu kali (menerapkan formula ini ke semua sel), grid kami akan terlihat seperti ini:

Mari kita lihat difus pada sel di tengah lagi:
1 |
0.1 * (70+70+70+70-4*100) = 0.1 * (280 - 400) = -12 |
Kami mendapat penurunan bersih 12! Jadi selalu mengalir dari nilai yang lebih tinggi ke yang lebih rendah.
Sekarang, jika kami ingin ini terlihat lebih realistis, kami dapat mengurangi ukuran sel (yang dapat Anda lakukan di demo), tetapi pada titik tertentu, semuanya akan menjadi sangat lambat, karena kami dipaksa untuk menjalankan secara berurutan. melalui setiap sel. Tujuan kami adalah untuk dapat menulis ini dalam shader, di mana kita dapat menggunakan kekuatan GPU untuk memproses semua sel (sebagai piksel) secara bersamaan secara paralel.
Jadi, untuk meringkas, teknik umum kami adalah memiliki setiap piksel memberikan beberapa nilai warnanya, setiap bingkai, ke piksel tetangganya. Kedengarannya cukup sederhana, bukan? Ayo terapkan itu dan lihat apa yang kita dapatkan!
Implementasi
Kami akan mulai dengan shader dasar yang menarik seluruh layar. Untuk memastikannya berfungsi, coba atur layar menjadi hitam pekat (atau warna sewenang-wenang). Begini cara pengaturan yang saya gunakan terlihat di Javascript.
Shader kami adalah:
1 |
uniform vec2 res; |
2 |
void main() { |
3 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
4 |
gl_FragColor = vec4(0.0,0.0,0.0,1.0); |
5 |
}
|
res
dan pixel
ada untuk memberi kita koordinat piksel saat ini. Kami melewatkan dimensi layar dalam res
sebagai variabel yang seragam. (Kami tidak menggunakannya sekarang, tapi kami akan segera.)
Langkah 1: Memindahkan Nilai di Seluruh Piksel
Langkah 1: Memindahkan Nilai di Seluruh Piksel
Teknik umum kami adalah memiliki setiap piksel memberikan beberapa nilai warnanya setiap frame ke piksel tetangganya.
Dinyatakan dalam bentuknya saat ini, ini tidak mungkin dilakukan dengan shader. Bisakah Anda melihat mengapa? Ingat bahwa semua shader dapat lakukan adalah mengembalikan nilai warna untuk piksel yang sedang diproses — jadi kita perlu menyatakan kembali ini dengan cara yang hanya memengaruhi piksel saat ini. Kita dapat mengatakan:
Setiap piksel harus mendapatkan beberapa warna dari tetangganya, sementara kehilangan sebagian dari warnanya sendiri.
Sekarang ini adalah sesuatu yang bisa kita terapkan. Namun, jika Anda benar-benar mencoba melakukan ini, Anda mungkin mengalami masalah mendasar ...
Pertimbangkan kasus yang jauh lebih sederhana. Katakanlah Anda hanya ingin membuat shader yang mengubah gambar secara perlahan seiring waktu. Anda mungkin menulis shader seperti ini:
1 |
uniform vec2 res; |
2 |
uniform sampler2D texture; |
3 |
void main() { |
4 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
5 |
|
6 |
gl_FragColor = texture2D( tex, pixel );//This is the color of the current pixel |
7 |
gl_FragColor.r += 0.01;//Increment the red component |
8 |
}
|
Dan mengharapkan bahwa, setiap frame, komponen merah dari setiap pixel akan meningkat 0,01
. Sebaliknya, semua yang Anda dapatkan adalah gambar statis di mana semua piksel hanya sedikit lebih merah daripada yang dimulai. Komponen merah setiap piksel hanya akan bertambah sekali, terlepas dari fakta bahwa shader menjalankan setiap frame.
Dapatkah Anda melihat mengapa?
Masalah
Masalahnya adalah setiap operasi yang kami lakukan di shader kami dikirim ke layar dan kemudian hilang selamanya. Proses kami sekarang terlihat seperti ini:



Kami meneruskan variabel dan tekstur seragam kami ke shader, itu membuat piksel sedikit lebih merah, menggambar itu ke layar, dan kemudian mulai lagi dari awal lagi. Apa pun yang kita gambar dalam shader akan dihapus pada saat kita menggambar.
Yang kami inginkan adalah sesuatu seperti ini:



Alih-alih menggambar ke layar secara langsung, kita bisa menggambar ke beberapa tekstur sebagai gantinya, dan kemudian menggambar tekstur itu ke layar. Anda mendapatkan gambar yang sama di layar seperti yang Anda lakukan sebaliknya, kecuali sekarang Anda dapat mengembalikan output Anda sebagai input. Jadi Anda dapat memiliki shader yang membangun atau menyebarkan sesuatu, daripada dibersihkan setiap saat. Itulah yang saya sebut "frame buffer trick".
Trik Frame Buffer
Teknik umumnya sama di semua platform. Mencari "render to texture" dalam bahasa atau alat apa pun yang Anda gunakan harus memunculkan detail penerapan yang diperlukan. Anda juga dapat mencari cara menggunakan objek penyangga frame, yang hanya merupakan nama lain untuk dapat merender ke beberapa buffer, bukan rendering ke layar.
Di ThreeJS, yang setara dengan ini adalah WebGLRenderTarget. Ini yang akan kami gunakan sebagai tekstur perantara kami. Ada satu peringatan kecil yang tersisa: Anda tidak dapat membaca dari dan render ke tekstur yang sama secara bersamaan. Cara termudah untuk melakukannya adalah dengan menggunakan dua tekstur.
Biarkan A dan B menjadi dua tekstur yang Anda buat. Metode Anda kemudian akan menjadi:
- Lewatkan A melalui shader Anda, render ke B.
- Render B ke layar.
- Lewati B melalui shader, render ke A.
- Render A ke layar Anda.
- Ulangi 1.
Atau, cara yang lebih ringkas untuk mengkode ini adalah:
- Atau, cara yang lebih ringkas untuk mengkode ini adalah:
- Render B ke layar.
- Tukar A dan B (jadi variabel A sekarang menyimpan tekstur yang ada di B dan sebaliknya).
- Ulangi 1.
Hanya itu yang dibutuhkan. Berikut ini implementasi dari yang di ThreeJS:
Ini masih layar hitam, yang adalah apa yang kita mulai dengan. Shader kami tidak terlalu berbeda:
1 |
uniform vec2 res; //The width and height of our screen |
2 |
uniform sampler2D bufferTexture; //Our input texture |
3 |
void main() { |
4 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
5 |
gl_FragColor = texture2D( bufferTexture, pixel ); |
6 |
}
|
Kecuali sekarang jika Anda menambahkan baris ini (coba saja!):
1 |
gl_FragColor.r += 0.01; |
Anda akan melihat layar perlahan memerah, bukan hanya meningkat 0.01
kali. Ini adalah langkah yang cukup signifikan, jadi Anda harus meluangkan waktu untuk membandingkan dan membandingkannya dengan bagaimana pengaturan awal kami bekerja.
Tantangan: Apa yang terjadi jika Anda menempatkan gl_FragColor.r + = pixel.x;
saat menggunakan contoh penyangga frame, dibandingkan ketika menggunakan contoh pengaturan? Luangkan waktu sejenak untuk memikirkan mengapa hasilnya berbeda dan mengapa itu masuk akal.
Langkah 2: Mendapatkan Sumber Asap
Sebelum kita bisa bergerak apa-apa, kita perlu cara untuk menciptakan asap di tempat pertama. Cara termudah adalah mengatur secara manual beberapa area acak ke putih di shader Anda.
1 |
//Get the distance of this pixel from the center of the screen
|
2 |
float dist = distance(gl_FragCoord.xy, res.xy/2.0); |
3 |
if(dist < 15.0){ //Create a circle with a radius of 15 pixels |
4 |
gl_FragColor.rgb = vec3(1.0); |
5 |
}
|
Jika kita ingin menguji apakah penyangga frame kita berfungsi dengan benar, kita dapat mencoba untuk menambah nilai warna daripada hanya mengaturnya. Anda harus melihat lingkaran perlahan-lahan menjadi lebih putih dan lebih putih.
1 |
//Get the distance of this pixel from the center of the screen
|
2 |
float dist = distance(gl_FragCoord.xy, res.xy/2.0); |
3 |
if(dist < 15.0){ //Create a circle with a radius of 15 pixels |
4 |
gl_FragColor.rgb += 0.01; |
5 |
}
|
Cara lain adalah mengganti titik tetap itu dengan posisi mouse. Anda dapat memberikan nilai ketiga untuk apakah mouse ditekan atau tidak, dengan cara itu Anda dapat mengklik untuk menciptakan asap. Berikut ini implementasi untuk itu.
Inilah yang terlihat seperti shader kami sekarang:
1 |
//The width and height of our screen
|
2 |
uniform vec2 res; |
3 |
//Our input texture
|
4 |
uniform sampler2D bufferTexture; |
5 |
//The x,y are the posiiton. The z is the power/density
|
6 |
uniform vec3 smokeSource; |
7 |
|
8 |
void main() { |
9 |
vec2 pixel = gl_FragCoord.xy / res.xy; |
10 |
gl_FragColor = texture2D( bufferTexture, pixel ); |
11 |
|
12 |
//Get the distance of the current pixel from the smoke source
|
13 |
float dist = distance(smokeSource.xy,gl_FragCoord.xy); |
14 |
//Generate smoke when mouse is pressed
|
15 |
if(smokeSource.z > 0.0 && dist < 15.0){ |
16 |
gl_FragColor.rgb += smokeSource.z; |
17 |
}
|
18 |
}
|
Tantangan: Ingat bahwa percabangan (conditional) biasanya mahal dalam shader. Bisakah Anda menulis ulang ini tanpa menggunakan pernyataan if? (Solusinya ada di CodePen.)
Jika ini tidak masuk akal, ada penjelasan lebih rinci tentang penggunaan mouse dalam shader di tutorial pencahayaan sebelumnya.
Langkah 3: Baur Asap
Sekarang ini adalah bagian yang mudah — dan yang paling berharga! Kami punya semua bagian sekarang, kita hanya perlu akhirnya memberi tahu shader: setiap pixel harus mendapatkan warna dari tetangganya, sementara kehilangan beberapa miliknya.
Yang terlihat seperti ini:
1 |
//Smoke diffuse
|
2 |
float xPixel = 1.0/res.x; //The size of a single pixel |
3 |
float yPixel = 1.0/res.y; |
4 |
|
5 |
vec4 rightColor = texture2D(bufferTexture,vec2(pixel.x+xPixel,pixel.y)); |
6 |
vec4 leftColor = texture2D(bufferTexture,vec2(pixel.x-xPixel,pixel.y)); |
7 |
vec4 upColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y+yPixel)); |
8 |
vec4 downColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y-yPixel)); |
9 |
|
10 |
//Diffuse equation
|
11 |
gl_FragColor.rgb += |
12 |
14.0 * 0.016 * |
13 |
(
|
14 |
leftColor.rgb + |
15 |
rightColor.rgb + |
16 |
downColor.rgb + |
17 |
upColor.rgb - |
18 |
4.0 * gl_FragColor.rgb |
19 |
);
|
Kami punya faktor f
kami seperti sebelumnya. Dalam hal ini kami memiliki timestep (0.016
adalah 1/60, karena kami berjalan pada 60 fps) dan saya terus mencoba angka sampai saya tiba di 14
, yang tampaknya terlihat bagus. Inilah hasilnya:
Uh Oh, Ini Terjebak!
Ini adalah persamaan berdifusi yang sama yang kami gunakan dalam demo CPU, namun simulasi kami macet! Apa yang menyebabkannya?
Ternyata tekstur (seperti semua angka di komputer) memiliki ketepatan yang terbatas. Pada titik tertentu, faktor yang kita kurangi dengan menjadi sangat kecil sehingga dibulatkan ke 0, sehingga simulasi yang macet. Untuk memperbaikinya, kita perlu memeriksa bahwa itu tidak jatuh di bawah beberapa nilai minimum:
1 |
float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); |
2 |
//We have to account for the low precision of texels
|
3 |
float minimum = 0.003; |
4 |
if (factor >= -minimum && factor < 0.0) factor = -minimum; |
5 |
|
6 |
gl_FragColor.rgb += factor; |
Saya menggunakan komponen r
bukan rgb
untuk mendapatkan faktor, karena lebih mudah untuk bekerja dengan nomor tunggal, dan karena semua komponen adalah nomor yang sama pula (karena asap kita putih).
Dengan trial and error, saya menemukan 0.003
menjadi ambang yang bagus di mana tidak terjebak. Saya hanya khawatir tentang faktor ketika itu negatif, untuk memastikan itu selalu bisa berkurang. Setelah kami menerapkan perbaikan ini, inilah yang kami dapatkan:
Langkah 4: Diffuse Asap Ke Atas
Ini tidak terlihat seperti asap. Jika kita ingin mengalir ke atas daripada ke segala arah, kita perlu menambahkan beberapa bobot. Jika piksel bawah selalu memiliki pengaruh yang lebih besar daripada arah lainnya, maka piksel kita akan tampak naik.
Dengan bermain-main dengan koefisien, kita bisa sampai pada sesuatu yang terlihat cukup layak dengan persamaan ini:
1 |
//Diffuse equation
|
2 |
float factor = 8.0 * 0.016 * |
3 |
(
|
4 |
leftColor.r + |
5 |
rightColor.r + |
6 |
downColor.r * 3.0 + |
7 |
upColor.r - |
8 |
6.0 * gl_FragColor.r |
9 |
);
|
Dan inilah yang terlihat seperti:
Catatan tentang Persamaan Diferensial
Saya pada dasarnya bermain-main dengan koefisien di sana untuk membuatnya terlihat bagus mengalir ke atas. Anda juga bisa membuatnya mengalir ke arah lain.
Penting untuk dicatat bahwa sangat mudah untuk membuat simulasi ini "meletus". (Coba ubah 6.0
di sana menjadi 5.0
dan lihat apa yang terjadi). Ini jelas karena sel-sel memperoleh lebih dari yang mereka kehilangan.
Persamaan ini sebenarnya adalah makalah yang saya sebut mengacu sebagai model "difus buruk". Mereka menyajikan persamaan alternatif yang lebih stabil, tetapi sangat tidak nyaman bagi kita, terutama karena perlu menulis ke grid yang dibacanya. Dengan kata lain, kita harus bisa membaca dan menulis pada tekstur yang sama pada saat yang bersamaan.
Apa yang kami miliki cukup untuk tujuan kami, tetapi Anda dapat melihat penjelasan di koran jika Anda ingin tahu. Anda juga akan menemukan persamaan alternatif yang diimplementasikan dalam demo CPU interaktif dalam fungsi diffuse_advanced ()
.
Perbaikan Cepat
Satu hal yang mungkin Anda perhatikan, jika Anda bermain-main dengan asap Anda, adalah bahwa ia terjebak di bagian bawah layar jika Anda menghasilkan beberapa di sana. Ini karena piksel pada baris bawah tersebut mencoba untuk mendapatkan nilai dari piksel di bawah ini. mereka, yang tidak ada.
Untuk memperbaikinya, kami hanya memastikan bahwa piksel di baris bawah menemukan 0
di bawahnya:
1 |
//Handle the bottom boundary
|
2 |
//This needs to run before the diffuse function
|
3 |
if(pixel.y <= yPixel){ |
4 |
downColor.rgb = vec3(0.0); |
5 |
}
|
Dalam demo CPU, saya berurusan dengan itu dengan tidak membuat sel-sel di perbatasan menyebar. Anda dapat secara alternatif hanya secara manual mengatur setiap out-of-bounds sel untuk memiliki nilai 0
. (Grid dalam demo CPU meluas oleh satu baris dan kolom sel di setiap arah, sehingga Anda tidak pernah benar-benar melihat batas)
A Velocity Grid
Selamat! Anda sekarang memiliki shader asap kerja! Hal terakhir yang ingin saya diskusikan secara singkat adalah bidang kecepatan yang disebutkan oleh makalah.



Asap Anda tidak harus tersebar merata ke atas atau ke arah tertentu; itu bisa mengikuti pola umum seperti yang digambarkan. Anda dapat melakukannya dengan mengirimkan tekstur lain di mana nilai warna menunjukkan arah asap yang akan mengalir di lokasi tersebut, dengan cara yang sama seperti kami menggunakan peta normal untuk menentukan arah pada setiap piksel dalam tutorial pencahayaan kami.
Bahkan, tekstur kecepatan Anda tidak harus statis juga! Anda dapat menggunakan trik penyangga frame untuk juga mengubah kecepatan secara real time. Saya tidak akan membahasnya di tutorial ini, tetapi ada banyak potensi untuk dijelajahi.
Kesimpulan
Jika ada sesuatu yang bisa diambil dari tutorial ini, itu adalah kemampuan untuk membuat tekstur bukan hanya ke layar adalah teknik yang sangat berguna.
Apakah Frame Buffer Bagus Untuk?
Salah satu penggunaan umum untuk ini adalah pasca-pemrosesan dalam game. Jika Anda ingin menerapkan semacam filter warna, alih-alih menerapkannya ke setiap objek tunggal, Anda bisa merender semua objek ke tekstur ukuran layar, lalu menerapkan shader ke tekstur akhir dan menggambarnya ke layar.
Contoh lain adalah ketika menerapkan shader yang memerlukan beberapa pass, seperti blur. Anda biasanya menjalankan gambar Anda melalui shader, mengaburkan arah x, lalu menjalankannya lagi untuk memburamkan arah y.
Contoh terakhir adalah rendering yang ditangguhkan, seperti yang dibahas dalam tutorial pencahayaan sebelumnya, yang merupakan cara mudah untuk menambahkan banyak sumber cahaya secara efisien ke adegan Anda. Hal keren tentang ini adalah bahwa menghitung pencahayaan tidak lagi tergantung pada jumlah sumber cahaya yang Anda miliki.
Jangan Khawatir tentang Makalah Teknis
Pasti ada lebih banyak detail yang dibahas dalam makalah yang saya sebutkan, dan itu mengasumsikan Anda memiliki sedikit keakraban dengan aljabar linier, tetapi jangan biarkan hal itu menghalangi Anda untuk membedahnya dan mencoba untuk mengimplementasikannya. Inti dari itu akhirnya cukup sederhana untuk diterapkan (setelah beberapa mengotak-atik koefisien).
Semoga Anda telah belajar sedikit lebih banyak tentang shader di sini, dan jika Anda memiliki pertanyaan, saran, atau peningkatan apa pun, silakan bagikan di bawah ini!