Algoritma rendering volume yang cepat dan mudah


Saya baru-baru ini menulis ShaderToy kecil yang melakukan rendering volumetrik sederhana, dan kemudian memutuskan untuk menerbitkan posting yang menjelaskan pekerjaannya. ShaderToy interaktif itu sendiri dapat dilihat di sini . Jika Anda membaca dari ponsel atau laptop, saya sarankan menonton versi cepat ini . Saya menyertakan cuplikan kode di pos yang akan membantu Anda memahami kinerja ShaderToy di tingkat tinggi, tetapi mereka tidak memiliki semua detailnya. Jika Anda ingin menggali lebih dalam, saya sarankan memeriksa dengan kode ShaderToy.

ShaderToy saya memiliki tiga tugas utama:

  1. Eksekusi waktu nyata
  2. Kesederhanaan
  3. Kebenaran fisik (... atau sesuatu seperti itu)

Saya akan mulai dengan adegan kode kosong ini. Saya tidak akan membahas detail implementasi, karena ini tidak terlalu menarik, tetapi saya akan memberi tahu Anda secara singkat di mana kita mulai:

  1. Ray menelusuri benda-benda buram. Semua benda primitif dengan persimpangan sederhana dengan sinar (1 bidang dan 3 bola)
  2. Untuk menghitung pencahayaan, Phong shading digunakan, dan dalam tiga sumber cahaya bulat, koefisien redaman cahaya khusus digunakan. Sinar bayangan tidak diperlukan, karena kami hanya menerangi pesawat.

Begini tampilannya:

Tangkapan layar ShaderToy

Kami akan membuat volume sebagai bagian terpisah yang bercampur dengan adegan buram; ini mirip dengan bagaimana semua mesin render waktu-nyata secara individual memproses permukaan yang buram dan tembus cahaya.

Bagian 1: mensimulasikan volume


Tetapi pertama-tama, sebelum kita dapat memulai rendering volumetrik, kita membutuhkan volume yang sama! Untuk mensimulasikan volume, saya memutuskan untuk menggunakan fungsi jarak yang ditandatangani (SDF). Mengapa tepatnya fungsi field jarak? Karena saya bukan seorang seniman, tetapi mereka memungkinkan Anda untuk membuat formulir yang sangat organik hanya dalam beberapa baris kode. Saya tidak akan berbicara secara rinci tentang fungsi jarak dengan tanda, karena Inigo Kiles telah menjelaskannya dengan sangat baik. Jika Anda penasaran, maka ada daftar hebat berbagai fungsi jarak tanda dan pengubah. Dan di sini ada artikel lain tentang SDF raymarching ini.

Mari kita mulai dengan yang sederhana dan menambahkan bola di sini:

Tangkapan layar ShaderToy

Sekarang kita akan menambahkan bola lain dan menggunakan konjugasi halus untuk menggabungkan fungsi jarak bola. Kode ini saya ambil langsung dari halaman Inigo, tetapi untuk kejelasan, saya akan memasukkannya di sini:

// Taken from https://iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSmoothUnion( float d1, float d2, float k ) 
{
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

Pasangan berpasangan adalah alat yang sangat kuat, karena Anda bisa mendapatkan sesuatu yang cukup menarik hanya dengan menggabungkannya dengan beberapa bentuk sederhana. Inilah yang terlihat dari banyak bidang konjugasi saya yang lancar:

Tangkapan layar ShaderToy

Jadi, kami mendapatkan sesuatu yang berbentuk tetesan air mata, tetapi kami membutuhkan sesuatu yang lebih seperti awan daripada setetes. Fitur hebat dari SDF adalah betapa mudahnya mengubah permukaan dengan hanya menambahkan sedikit noise ke SDF. Jadi mari kita tambahkan beberapa gerakan Brown fraktal (fBM) di atas kebisingan, menggunakan posisi untuk mengindeks fungsi kebisingan. Inigo Kiles juga membahas topik ini dalam artikel hebat tentang fBM noise. Inilah yang akan terlihat seperti gambar dengan noise fBM:

Tangkapan layar ShaderToy

Baik! Berkat kebisingan fBM, objek tiba-tiba mulai terlihat jauh lebih menarik!

Sekarang kita perlu menciptakan ilusi bahwa volume berinteraksi dengan bidang bumi. Untuk melakukan ini, saya menambahkan jarak bidang yang ditandatangani sedikit di bawah bidang tanah dan menggunakan kembali kombinasi pasangan halus dengan nilai pemasangan yang sangat agresif (parameter k). Setelah itu, kami mendapat gambar ini:

Tangkapan layar ShaderToy

Sentuhan terakhir adalah perubahan dalam indeks xz dari kebisingan fBM dari waktu ke waktu, sehingga volumenya terlihat seperti kabut yang berputar-putar. Bergerak, itu terlihat sangat bagus!

Tangkapan layar ShaderToy

Hebat, kami punya sesuatu seperti awan! Kode perhitungan SDF juga cukup kompak:

float QueryVolumetricDistanceField( in vec3 pos)
{    
    vec3 fbmCoord = (pos + 2.0 * vec3(iTime, 0.0, iTime)) / 1.5f;
    float sdfValue = sdSphere(pos, vec3(-8.0, 2.0 + 20.0 * sin(iTime), -1), 5.6);
    sdfValue = sdSmoothUnion(sdfValue,sdSphere(pos, vec3(8.0, 8.0 + 12.0 * cos(iTime), 3), 5.6), 3.0f);
    sdfValue = sdSmoothUnion(sdfValue, sdSphere(pos, vec3(5.0 * sin(iTime), 3.0, 0), 8.0), 3.0) + 7.0 * fbm_4(fbmCoord / 3.2);
    sdfValue = sdSmoothUnion(sdfValue, sdPlane(pos + vec3(0, 0.4, 0)), 22.0);
    return sdfValue;
}

Ini hanya merender objek yang buram. Kita membutuhkan kabut yang luar biasa indah!

Bagaimana kita membuatnya dalam bentuk volume, dan bukan objek buram? Mari kita bicara tentang fisika yang kita simulasikan. Volume adalah sejumlah besar partikel di area ruang tertentu. Dan ketika saya mengatakan "besar", maksud saya "BESAR". Sedemikian rupa sehingga pemodelan masing-masing partikel hari ini adalah tugas yang mustahil, bahkan untuk render offline. Contoh bagus dari ini adalah api, kabut, dan awan. Sebenarnya, semuanya volume, tetapi demi kecepatan perhitungan, lebih mudah untuk menutup mata kita untuk ini dan berpura-pura tidak. Kami mewakili akumulasi partikel-partikel ini sebagai nilai kerapatan yang biasanya disimpan dalam beberapa jenis kisi 3D (atau sesuatu yang lebih kompleks, misalnya, dalam OpenVDB).

Ketika cahaya melewati volume, sepasang fenomena dapat terjadi ketika cahaya bertabrakan dengan sebuah partikel. Ia dapat menyebar dan pergi ke arah lain, atau sebagian dari cahaya dapat diserap oleh partikel dan larut. Untuk mematuhi persyaratan eksekusi waktu-nyata, kami akan melakukan apa yang disebut pencar tunggal. Ini berarti yang berikut: kita akan mengasumsikan bahwa cahaya tersebar hanya sekali, ketika cahaya bertabrakan dengan partikel dan terbang ke arah kamera. Artinya, kita tidak akan dapat mensimulasikan efek dari hamburan berganda, misalnya, kabut, di mana objek di kejauhan biasanya terlihat lebih kabur. Tetapi untuk sistem kami ini sudah cukup. Inilah yang tampak seperti hamburan tunggal saat raymarching:

Tangkapan layar ShaderToy

Kode semu untuknya terlihat seperti ini:

for n steps along the camera ray:
   Calculate what % of your ray hit particles (i.e. were absorbed) and needs lighting
   for m lights:
      for k steps towards the light:
         Calculate % of light that were absorbe in this step
      Calculate lighting based on how much light is visible
Blend results on top of opaque objects pass based on % of your ray that made it through the volume

Artinya, kita berhadapan dengan perhitungan dengan kompleksitas O (n * m * k). Jadi GPU harus bekerja keras.

Kami menghitung penyerapan


Pertama, mari kita lihat penyerapan cahaya dalam volume di sepanjang sorotan kamera (mis., Jangan melakukan raymarching ke arah sumber cahaya). Untuk melakukan ini, kita perlu dua tindakan:

  1. Lakukan raymarching di dalam volume
  2. Hitung penyerapan / pencahayaan pada setiap langkah

Untuk menghitung berapa banyak cahaya yang diserap pada setiap titik, kami menggunakan hukum Bouguer - Lambert - Beer , yang menjelaskan redaman cahaya ketika melewati suatu material. Perhitungannya sangat sederhana:

float BeerLambert(float absorptionCoefficient, float distanceTraveled)
{
    return exp(-absorptionCoefficient * distanceTraveled);
}

Koefisien absorpsi adalah parameter material. Misalnya, dalam volume transparan, misalnya, dalam air, nilai ini akan rendah, dan untuk sesuatu yang lebih tebal, misalnya, susu, koefisiennya akan lebih tinggi.

Untuk melakukan volume raymarching, kami cukup mengambil langkah-langkah dengan ukuran tetap di sepanjang balok dan mendapatkan penyerapan di setiap langkah. Anda mungkin tidak mengerti mengapa harus mengambil langkah-langkah tetap alih-alih sesuatu yang lebih cepat, misalnya, melacak bola, tetapi jika Anda ingat bahwa kepadatan di dalam volume itu heterogen, maka semuanya menjadi jelas. Di bawah ini adalah kode penyerapan dan akumulasi raymarching. Beberapa variabel berada di luar cakupan snipet kode ini, jadi lihat implementasi penuh di ShaderToy.

float opaqueVisiblity = 1.0f;
const float marchSize = 0.6f;
for(int i = 0; i < MAX_VOLUME_MARCH_STEPS; i++) {
	volumeDepth += marchSize;
	if(volumeDepth > opaqueDepth) break;
	
	vec3 position = rayOrigin + volumeDepth*rayDirection;
	bool isInVolume = QueryVolumetricDistanceField(position) < 0.0f;
	if(isInVolume) 	{
		float previousOpaqueVisiblity = opaqueVisiblity;
		opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
		float absorptionFromMarch = previousOpaqueVisiblity - opaqueVisiblity;
		for(int lightIndex = 0; lightIndex < NUM_LIGHTS; lightIndex++) {
			float lightDistance = length((GetLight(lightIndex).Position - position));
			vec3 lightColor = GetLight(lightIndex).LightColor * GetLightAttenuation(lightDistance);  
			volumetricColor += absorptionFromMarch * volumeAlbedo * lightColor;
		}
		volumetricColor += absorptionFromMarch * volumeAlbedo * GetAmbientLight();
	}
}

Dan inilah yang kita dapatkan dengan ini:

Tangkapan layar ShaderToy

Sepertinya benang permen! Mungkin untuk beberapa efek ini sudah cukup! Tetapi kita kurang memiliki bayangan diri. Cahaya mencapai semua bagian volume secara merata. Tapi ini tidak benar secara fisik, tergantung pada ukuran volume antara titik yang diberikan dan sumber cahaya, kami akan menerima jumlah cahaya yang masuk berbeda.

Membayangi diri sendiri


Kami sudah melakukan yang paling sulit. Kita perlu melakukan hal yang sama seperti yang kita lakukan untuk menghitung penyerapan sepanjang sinar kamera, tetapi hanya sepanjang sinar cahaya. Kode untuk menghitung jumlah cahaya yang mencapai setiap titik pada dasarnya akan menjadi pengulangan kode, tetapi menduplikasinya lebih mudah daripada meretas HLSL untuk mendapatkan rekursi yang kita butuhkan. Jadi seperti inilah tampilannya:

float GetLightVisiblity(in vec3 rayOrigin, in vec3 rayDirection, in float maxT, in int maxSteps, in float marchSize) {
    float t = 0.0f;
    float lightVisiblity = 1.0f;
    for(int i = 0; i < maxSteps; i++) {                       
        t += marchSize;
        if(t > maxT) break;

        vec3 position = rayOrigin + t*rayDirection;
        if(QueryVolumetricDistanceField(position) < 0.0) {
            lightVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT, marchSize);
        }
    }
    return lightVisiblity;
}

Menambahkan bayangan diri memberi kita hal berikut:

Tangkapan layar ShaderToy

Lembutkan ujungnya


Saat ini, saya sudah cukup menyukai volume kami. Saya menunjukkannya kepada pemimpin berbakat departemen VFX The Coalition, James Sharp. Dia segera memperhatikan bahwa tepi-tepi volume terlihat terlalu tajam. Dan ini benar-benar benar - benda-benda seperti awan terus-menerus tersebar di ruang di sekitarnya, sehingga tepinya bercampur dengan ruang kosong di sekitar volume, yang seharusnya mengarah pada penciptaan tepi yang sangat halus. James menawarkan saya ide bagus - untuk mengurangi kepadatan tergantung pada seberapa dekat kita ke tepi. Dan karena kami bekerja dengan fungsi jarak dengan tanda, sangat mudah untuk diterapkan! Jadi mari kita tambahkan fungsi yang dapat digunakan untuk meminta kepadatan di setiap titik di volume:

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
    return sdfMultiplier;
}

Dan kemudian kita hanya mengelompokkannya ke dalam nilai penyerapan:

opaqueVisiblity *= BeerLambert(ABSORPTION_COEFFICIENT * GetFogDensity(position), marchSize);

Dan inilah tampilannya:

Tangkapan layar ShaderToy

Fungsi kepadatan


Sekarang kami memiliki fungsi kerapatan, Anda dapat dengan mudah menambahkan sedikit noise ke volume untuk memberikan detail dan kemegahan tambahan. Dalam hal ini, saya hanya menggunakan kembali fungsi fBM yang kami gunakan untuk menyesuaikan bentuk volume.

float GetFogDensity(vec3 position)
{   
    float sdfValue = QueryVolumetricDistanceField(position)
    const float maxSDFMultiplier = 1.0;
    bool insideSDF = sdfDistance < 0.0;
    float sdfMultiplier = insideSDF ? min(abs(sdfDistance), maxSDFMultiplier) : 0.0;
   return sdfMultiplier * abs(fbm_4(position / 6.0) + 0.5);
}

Dan jadi kami mendapat yang berikut:

Tangkapan layar ShaderToy

Membayangi bayangan diri sendiri


Volume sudah terlihat sangat cantik! Tapi sedikit cahaya masih bocor melalui itu. Di sini kita melihat bagaimana warna hijau merembes di mana volume pasti menyerapnya:

Tangkapan layar ShaderToy

Ini terjadi karena objek buram dirender sebelum volume diberikan, sehingga mereka tidak memperhitungkan bayangan yang disebabkan oleh volume. Ini cukup mudah untuk diperbaiki - kami memiliki fungsi GetLightVisiblity yang dapat digunakan untuk menghitung bayangan, jadi kami hanya perlu memanggilnya untuk menerangi objek buram. Kami mendapatkan yang berikut ini:

Tangkapan layar ShaderToy

Selain menciptakan bayangan multi-warna yang indah, ini membantu meningkatkan bayangan dan membangun volume ke dalam adegan. Selain itu, berkat tepi yang halus dari volume, kami mendapatkan bayangan lembut, meskipun pada kenyataannya, kami berbicara dengan sumber pencahayaan titik. Itu saja! Banyak hal yang dapat dilakukan di sini, tetapi bagi saya tampaknya saya telah mencapai kualitas visual yang saya butuhkan, dengan tetap menjaga kesederhanaan relatif dari contoh tersebut.

Optimalisasi


Pada akhirnya, saya akan mendaftar secara singkat beberapa kemungkinan optimasi:

  1. Sebelum melakukan raymarching ke arah sumber cahaya, perlu untuk memeriksa dengan nilai kepunahan cahaya apakah sejumlah besar cahaya ini benar-benar mencapai titik yang dimaksud. Dalam implementasi saya, saya melihat kecerahan cahaya, dikalikan dengan bahan albedo, dan memastikan bahwa nilainya cukup besar untuk melakukan raymarching.
  2. , , raymarching
  3. raymarching . , . , raymarching , .


Itu saja! Secara pribadi, saya terkejut bahwa Anda dapat membuat sesuatu yang secara fisik cukup benar dalam jumlah kode yang kecil (sekitar 500 baris). Terima kasih sudah membaca, semoga menarik.

Dan satu lagi catatan: inilah perubahan yang menyenangkan - saya menambahkan emisi cahaya berdasarkan jarak SDF untuk menciptakan efek ledakan. Lagi pula, ledakan tidak pernah banyak.

Tangkapan layar ShaderToy

All Articles