256 baris telanjang C ++: menulis pelacak sinar dari awal dalam beberapa jam

Saya menerbitkan bab selanjutnya dari kuliah saya tentang grafik komputer (di sini Anda dapat membaca yang asli dalam bahasa Rusia, meskipun versi bahasa Inggrisnya lebih baru). Kali ini, topik pembicaraan adalah menggambar adegan menggunakan ray tracing . Seperti biasa, saya mencoba menghindari perpustakaan pihak ketiga, karena ini membuat siswa melihat di bawah tenda.

Sudah ada banyak proyek serupa di Internet, tetapi hampir semuanya menunjukkan program yang sudah selesai yang sangat sulit untuk dipahami. Di sini, misalnya, program rendering yang sangat terkenal yang cocok dengan kartu nama . Hasil yang sangat mengesankan, tetapi memahami kode ini sangat sulit. Tujuan saya bukan untuk menunjukkan bagaimana saya bisa, tetapi untuk memberi tahu secara detail bagaimana mereproduksi ini. Terlebih lagi, bagi saya kelihatannya kuliah ini bermanfaat bahkan tidak sebanyak materi pelatihan tentang grafik komputer, tetapi lebih sebagai manual pemrograman. Saya akan secara konsisten menunjukkan bagaimana mencapai hasil akhir, mulai dari awal: bagaimana menguraikan masalah yang rumit menjadi tahap-tahap dasar yang dapat dipecahkan.

Perhatian: hanya melihat kode saya, serta hanya membaca artikel ini dengan secangkir teh di tangan, tidak masuk akal. Artikel ini dirancang bagi Anda untuk mengambil keyboard dan menulis mesin Anda sendiri. Dia pasti akan lebih baik daripada milikku. Ya, atau ubah saja bahasa pemrograman!

Jadi, hari ini saya akan menunjukkan cara menggambar gambar seperti itu:



Tahap satu: menyimpan gambar ke disk


Saya tidak ingin repot dengan manajer jendela, pemrosesan mouse / keyboard, dan sejenisnya. Hasil dari program kami adalah gambar sederhana yang disimpan ke disk. Jadi, hal pertama yang perlu kita lakukan adalah menyimpan gambar ke disk. Di sinilah letak kode yang memungkinkan Anda melakukan ini. Biarkan saya memberi Anda file utamanya:

#include <limits> #include <cmath> #include <iostream> #include <fstream> #include <vector> #include "geometry.h" void render() { const int width = 1024; const int height = 768; std::vector<Vec3f> framebuffer(width*height); for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } std::ofstream ofs; // save the framebuffer to file ofs.open("./out.ppm"); ofs << "P6\n" << width << " " << height << "\n255\n"; for (size_t i = 0; i < height*width; ++i) { for (size_t j = 0; j<3; j++) { ofs << (char)(255 * std::max(0.f, std::min(1.f, framebuffer[i][j]))); } } ofs.close(); } int main() { render(); return 0; } 

Di fungsi utama, hanya fungsi render () yang dipanggil, bukan yang lain. Apa yang ada di dalam fungsi render ()? Pertama-tama, saya mendefinisikan gambar sebagai array satu dimensi dari nilai-nilai framebuffer dari tipe Vec3f, ini adalah vektor tiga dimensi sederhana yang memberi kita warna (r, g, b) untuk setiap piksel.

Kelas vektor tinggal di file geometry.h, saya tidak akan menjelaskannya di sini: pertama, semuanya sepele di sana, manipulasi sederhana vektor dua dan tiga dimensi (penambahan, pengurangan, penugasan, perkalian dengan skalar, produk skalar), dan kedua, gbg sudah menggambarkannya secara rinci sebagai bagian dari kursus kuliah tentang grafik komputer.

Saya menyimpan gambar dalam format ppm ; Ini adalah cara termudah untuk menyimpan gambar, meskipun tidak selalu paling nyaman untuk dilihat lebih lanjut. Jika Anda ingin menyimpan dalam format lain, saya masih merekomendasikan menghubungkan perpustakaan pihak ketiga, misalnya, stb . Ini adalah pustaka yang luar biasa: cukup untuk memasukkan satu file header stb_image_write.h dalam proyek, dan ini akan memungkinkan Anda untuk menyimpannya bahkan di png, bahkan di jpg.

Secara total, tujuan dari tahap ini adalah untuk memastikan bahwa kita dapat a) membuat gambar dalam memori dan menulis nilai warna yang berbeda di sana b) menyimpan hasilnya ke disk sehingga dapat dilihat dalam program pihak ketiga. Inilah hasilnya:



Tahap dua, yang paling sulit: penelusuran sinar langsung


Ini adalah tahap paling penting dan sulit dari keseluruhan rantai. Saya ingin mendefinisikan satu bola dalam kode saya dan menunjukkannya di layar tanpa mengganggu bahan atau pencahayaan. Beginilah seharusnya hasil kami:



Untuk kenyamanan, di repositori saya, ada satu komit untuk setiap tahap; Github membuatnya sangat nyaman untuk melihat perubahan Anda. Di sini, misalnya , apa yang telah berubah di komit kedua dibandingkan dengan komit pertama.

Untuk mulai dengan: apa yang kita butuhkan untuk mewakili sebuah bola dalam memori komputer? Empat angka cukup bagi kita: vektor tiga dimensi dengan pusat bola dan skalar yang menggambarkan jari-jari:

 struct Sphere { Vec3f center; float radius; Sphere(const Vec3f &c, const float &r) : center(c), radius(r) {} bool ray_intersect(const Vec3f &orig, const Vec3f &dir, float &t0) const { Vec3f L = center - orig; float tca = L*dir; float d2 = L*L - tca*tca; if (d2 > radius*radius) return false; float thc = sqrtf(radius*radius - d2); t0 = tca - thc; float t1 = tca + thc; if (t0 < 0) t0 = t1; if (t0 < 0) return false; return true; } }; 

Satu-satunya hal nontrivial dalam kode ini adalah fungsi yang memungkinkan Anda untuk memeriksa apakah sinar yang diberikan (yang berasal dari asal dalam arah dir) berpotongan dengan bola kami. Penjelasan terperinci dari algoritma untuk memeriksa persimpangan balok dan bola dapat dibaca di sini , saya sangat merekomendasikan melakukan ini dan memeriksa kode saya.

Bagaimana cara kerja ray tracing? Sangat sederhana. Pada tahap pertama, kita cukup menutupi gambar dengan gradien:

  for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { framebuffer[i+j*width] = Vec3f(j/float(height),i/float(width), 0); } } 

Sekarang, untuk setiap piksel, kita akan membentuk sinar yang datang dari pusat koordinat dan melewati piksel kita, dan memeriksa apakah sinar ini memotong bola kita.



Jika tidak ada persimpangan dengan bola, maka kita akan meletakkan color1, jika tidak color2:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { float sphere_dist = std::numeric_limits<float>::max(); if (!sphere.ray_intersect(orig, dir, sphere_dist)) { return Vec3f(0.2, 0.7, 0.8); // background color } return Vec3f(0.4, 0.4, 0.3); } void render(const Sphere &sphere) {  [...] for (size_t j = 0; j<height; j++) { for (size_t i = 0; i<width; i++) { float x = (2*(i + 0.5)/(float)width - 1)*tan(fov/2.)*width/(float)height; float y = -(2*(j + 0.5)/(float)height - 1)*tan(fov/2.); Vec3f dir = Vec3f(x, y, -1).normalize(); framebuffer[i+j*width] = cast_ray(Vec3f(0,0,0), dir, sphere); } }  [...] } 

Pada titik ini, saya sarankan mengambil pensil dan memeriksa di atas kertas semua perhitungan, baik persimpangan sinar dengan bola, dan menyapu gambar dengan sinar. Untuk jaga-jaga, kamera kami ditentukan oleh hal-hal berikut:

  • lebar gambar
  • tinggi gambar
  • sudut pandang, fov
  • lokasi kamera, Vec3f (0,0,0)
  • arah pandangan, sepanjang sumbu z, ke arah minus tanpa batas

Tahap Tiga: Tambahkan Spheres Lainnya


Semua yang paling sulit ada di belakang kita, sekarang jalan kita tidak berawan. Jika kita bisa menggambar satu bola. maka jelas menambah beberapa pekerjaan tidak sulit. Di sini Anda dapat melihat perubahan dalam kode, dan inilah hasilnya:



Tahap Empat: Pencahayaan


Semua orang pandai foto kita, tapi itu tidak cukup pencahayaan. Sepanjang sisa artikel, kami hanya akan membicarakan hal ini. Tambahkan beberapa sumber cahaya titik:

 struct Light { Light(const Vec3f &p, const float &i) : position(p), intensity(i) {} Vec3f position; float intensity; }; 

Mempertimbangkan pencahayaan yang sebenarnya adalah tugas yang sangat, sangat sulit, oleh karena itu, seperti orang lain, kami akan menipu mata dengan menggambar sepenuhnya hasil yang tidak masuk akal, tetapi paling mungkin, masuk akal. Komentar pertama: mengapa dingin di musim dingin dan panas di musim panas? Karena memanaskan permukaan bumi tergantung dari sudut datangnya sinar matahari. Semakin tinggi matahari di atas cakrawala, semakin cerah permukaannya. Dan sebaliknya, semakin rendah cakrawala, semakin lemah. Nah, setelah matahari terbenam di cakrawala, foton tidak mencapai kita sama sekali. Berkenaan dengan bola kami: di sini adalah sinar kami dipancarkan dari kamera (tidak ada hubungannya dengan foton, perhatikan!) Berpotongan dengan bola. Bagaimana kita memahami bagaimana titik persimpangan menyala? Anda cukup melihat sudut antara vektor normal pada titik ini dan vektor yang menggambarkan arah cahaya. Semakin kecil sudutnya, semakin baik permukaannya menyala. Untuk membuatnya lebih nyaman, Anda cukup mengambil produk skalar antara vektor normal dan vektor pencahayaan. Saya ingat bahwa produk skalar antara dua vektor a dan b sama dengan produk norma-norma vektor oleh cosinus sudut antara vektor: a * b = | a | | b | cos (alpha (a, b)). Jika kita mengambil vektor satuan panjang, maka produk skalar yang paling sederhana akan memberi kita intensitas pencahayaan permukaan.

Jadi, dalam fungsi cast_ray, alih-alih warna konstan, kami akan mengembalikan warna dengan mempertimbangkan sumber cahaya:

 Vec3f cast_ray(const Vec3f &orig, const Vec3f &dir, const Sphere &sphere) { [...] float diffuse_light_intensity = 0; for (size_t i=0; i<lights.size(); i++) { Vec3f light_dir = (lights[i].position - point).normalize(); diffuse_light_intensity += lights[i].intensity * std::max(0.f, light_dir*N); } return material.diffuse_color * diffuse_light_intensity; } 

Lihat perubahan di sini , tetapi hasil dari program:



Tahap Lima: Permukaan Mengkilap


Trik dengan produk skalar antara vektor normal dan vektor cahaya mendekati pencahayaan dengan baik pada permukaan matte, dalam literatur disebut pencahayaan difus. Apa yang harus dilakukan jika kita ingin halus dan berkilau? Saya ingin mendapatkan gambar ini:



Lihatlah betapa sedikit perubahan yang perlu dilakukan. Singkatnya, pantulan pada permukaan mengkilap lebih terang, semakin kecil sudut antara arah pandang dan arah cahaya yang dipantulkan . Nah, sudut-sudutnya, tentu saja, kita akan menghitung melalui produk skalar, persis seperti sebelumnya.

Senam dengan permukaan matte dan mengkilap ini dikenal sebagai model Phong . Wiki ini memiliki deskripsi yang cukup terperinci tentang model pencahayaan ini, ia dapat dibaca dengan baik jika dibandingkan secara paralel dengan kode saya. Berikut ini adalah gambar utama untuk dipahami:


Tahap Enam: Bayangan


Mengapa kita memiliki cahaya, tetapi tidak ada bayangan? Kekacauan! Saya ingin gambar ini:



Hanya enam baris kode yang memungkinkan kita mencapai hal ini: ketika menggambar setiap titik, kita hanya memastikan bahwa sumber titik cahaya tidak memotong objek pemandangan kita, dan jika ya, maka sumber cahaya saat ini melompati. Hanya ada sedikit kehalusan: Saya menggeser titik sedikit ke arah yang normal:

 Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; 

Mengapa Ya, hanya saja titik kami terletak di permukaan objek, dan (tidak termasuk masalah kesalahan numerik), sinar apa pun dari titik ini akan melewati tempat kami.

Langkah Tujuh: Refleksi


Ini sulit dipercaya, tetapi untuk menambahkan refleksi ke adegan kami, kami hanya perlu menambahkan tiga baris kode:

  Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3; // offset the original point to avoid occlusion by the object itself Vec3f reflect_color = cast_ray(reflect_orig, reflect_dir, spheres, lights, depth + 1); 

Lihat sendiri: di persimpangan dengan objek, kita cukup menghitung sinar yang dipantulkan (fungsi dari perhitungan benjolan sangat berguna!) Dan secara rekursif memanggil fungsi cast_ray ke arah sinar yang dipantulkan. Pastikan untuk bermain dengan kedalaman rekursi , saya mengaturnya menjadi empat, mulai dari awal, apa yang akan berubah pada gambar? Inilah hasil saya dengan refleksi yang bekerja dan kedalaman empat:



Tahap Delapan: Refraksi


Dengan belajar menghitung refleksi, refraksi dihitung sama persis . Satu fungsi yang memungkinkan Anda menghitung arah sinar yang dibiaskan ( sesuai dengan hukum Snell ), dan tiga baris kode di cast_ray fungsi rekursif kami. Inilah hasilnya, di mana bola terdekat menjadi "kaca", itu membias dan sedikit mencerminkan:



Tahap sembilan: tambahkan lebih banyak objek


Mengapa kita semua tanpa susu, tetapi tanpa susu. Hingga saat ini, kami hanya membuat bidang, karena ini adalah salah satu objek matematika non-sepele yang paling sederhana. Dan mari kita tambahkan sepotong pesawat. Klasik genre adalah papan catur. Untuk ini, selusin garis dalam fungsi yang menganggap persimpangan balok dengan pemandangan cukup bagi kita.

Nah, inilah hasilnya:



Seperti yang saya janjikan, persis 256 baris kode, hitung sendiri !

Tahap Sepuluh: Pekerjaan Rumah


Kami menempuh jalan yang agak jauh: kami belajar cara menambahkan objek ke pemandangan, untuk mempertimbangkan pencahayaan yang agak rumit. Biarkan saya meninggalkan dua tugas sebagai pekerjaan rumah. Tentu saja semua pekerjaan persiapan telah dilakukan di cabang homework_assignment . Setiap pekerjaan akan membutuhkan maksimal sepuluh baris kode.

Tugas satu: Peta lingkungan


Saat ini, jika sinar tidak melewati tempat kejadian, maka kita cukup mengaturnya ke warna konstan. Dan mengapa, pada kenyataannya, permanen? Mari kita mengambil foto bulat (file envmap.jpg ) dan menggunakannya sebagai latar belakang! Untuk membuat hidup lebih mudah, saya menautkan proyek kami dengan perpustakaan stb untuk kenyamanan bekerja dengan jpegs. Ini harus menjadi render seperti ini:



Tugas kedua: dukun!


Kita dapat membuat bidang dan pesawat (lihat papan catur). Jadi mari kita tambahkan gambar model triangulasi! Saya menulis kode untuk membaca kisi-kisi segitiga, dan menambahkan fungsi persimpangan ray-triangle di sana. Sekarang menambahkan bebek ke adegan kita harus benar-benar sepele!



Kesimpulan


Tugas utama saya adalah menunjukkan proyek yang menarik (dan mudah!) Untuk diprogram, saya sangat berharap bisa melakukannya. Ini sangat penting, karena saya yakin bahwa seorang programmer harus menulis banyak dan dengan selera. Saya tidak tahu tentang Anda, tetapi akuntansi pribadi dan pencari ranjau, dengan kompleksitas kode yang cukup sebanding, tidak menarik saya sama sekali.

Dua ratus lima puluh baris raytracing sebenarnya dapat ditulis dalam beberapa jam. Lima ratus baris rasterizer perangkat lunak dapat dikuasai dalam beberapa hari. Lain kali kita akan memilah-milah rakecasting , dan pada saat yang sama saya akan menunjukkan permainan paling sederhana yang ditulis siswa tahun pertama saya sebagai bagian dari pengajaran pemrograman C ++. Tetap disini!

Source: https://habr.com/ru/post/id436790/


All Articles