أقوم بنشر الفصل التالي من
دورة محاضراتي حول رسومات الكمبيوتر (
هنا يمكنك قراءة النص الأصلي باللغة الروسية ، على الرغم من أن الإصدار باللغة الإنجليزية أحدث). هذه المرة ، موضوع المحادثة هو
رسم المشاهد باستخدام تتبع الشعاع . كالمعتاد ، أحاول تجنب مكتبات الطرف الثالث ، لأن هذا يجعل الطلاب ينظرون تحت الغطاء.
يوجد بالفعل الكثير من المشاريع المشابهة على الإنترنت ، ولكن جميعها تقريبًا تعرض برامج مكتملة يصعب فهمها. هنا ، على سبيل المثال ،
برنامج عرض مشهور جدًا
يلائم بطاقة العمل . نتيجة رائعة للغاية ، ولكن فهم هذا الرمز صعب للغاية. هدفي هو عدم إظهار كيف يمكنني ذلك ، بل أن أتحدث بالتفصيل عن كيفية إعادة إنتاج هذا. علاوة على ذلك ، يبدو لي أن هذه المحاضرة على وجه التحديد ليست مفيدة بقدر ما هي مواد تدريبية على رسومات الحاسوب ، لكنها أداة برمجة. سأعرض باستمرار كيفية الوصول إلى النتيجة النهائية ، بدءًا من نقطة الصفر: كيفية حل مشكلة معقدة إلى مراحل قابلة للحل.
انتباه: مجرد النظر إلى الكود ، وكذلك قراءة هذه المقالة مع كوب من الشاي في متناول اليد ، لا معنى له. تم تصميم هذه المقالة لتتمكن من الحصول على لوحة مفاتيح وكتابة محركك الخاص. سيكون بالتأكيد أفضل مني. حسنا ، أو مجرد تغيير لغة البرمجة!لذلك ، سأبين اليوم كيفية رسم مثل هذه الصور:

المرحلة الأولى: حفظ الصورة على القرص
لا أريد أن أزعج مديري النوافذ ومعالجة الماوس / لوحة المفاتيح وما شابه ذلك. ستكون نتيجة برنامجنا صورة بسيطة محفوظة على القرص. لذا ، فإن أول ما نحتاج إلى القيام به هو حفظ الصورة على القرص.
هنا تكمن الكود الذي يسمح لك بالقيام بذلك. اسمحوا لي أن أقدم لكم ملفها الرئيسي:
#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; }
في الوظيفة الرئيسية ، يتم استدعاء الوظيفة render () فقط ، ولا شيء آخر. ما هو داخل وظيفة التقديم ()؟ أولاً وقبل كل شيء ، أقوم بتعريف الصورة على أنها صفيف أحادي البعد لقيم فرامبفير من النوع Vec3f ، وهي عبارة عن متجهات ثلاثية الأبعاد بسيطة تمنحنا اللون (r ، g ، b) لكل بكسل.
تعيش فئة المتجهات في ملف geometry.h ، ولن أصفها هنا: أولاً ، كل شيء تافه هناك ، والتعامل البسيط مع المتجهات ثنائية وثلاثية الأبعاد (الجمع والطرح والمهمة والضرب بمنتج عددي أو منتج عددي) وثانياً
gbg سبق
وصفها بالتفصيل كجزء من دورة محاضرة حول رسومات الحاسوب.
أحفظ الصورة
بتنسيق ppm ؛ هذه هي أسهل طريقة لحفظ الصور ، رغم أنها ليست الأكثر ملاءمة دائمًا للعرض الإضافي. إذا كنت تريد الحفظ بتنسيقات أخرى ، فما زلت أوصي بتوصيل مكتبة تابعة لجهة خارجية ، على سبيل المثال ،
stb . هذه مكتبة رائعة: يكفي تضمين ملف رأس واحد stb_image_write.h في المشروع ، وهذا سيسمح بالحفظ حتى في png ، حتى في jpg.
إجمالاً ، يتمثل الهدف من هذه المرحلة في التأكد من أننا نستطيع (أ) إنشاء صورة في الذاكرة وكتابة قيم ألوان مختلفة هناك ب) حفظ النتيجة على القرص بحيث يمكن عرضها في برنامج تابع لجهة خارجية. هذه هي النتيجة:

المرحلة الثانية ، أصعب: تتبع الأشعة مباشرة
هذه هي المرحلة الأكثر أهمية وصعوبة في السلسلة بأكملها. أريد تحديد مجال واحد في الكود الخاص بي وإظهاره على الشاشة دون عناء باستخدام المواد أو الإضاءة. هذا ما يجب أن تبدو عليه نتيجة:

للراحة ، في مستودع التخزين الخاص بي ، يوجد التزام واحد لكل مرحلة ؛ جيثب يجعلها مريحة جدا لعرض التغييرات الخاصة بك.
هنا ، على سبيل المثال ، ما الذي تغير في الالتزام الثاني مقارنة بالالتزام الأول.
بادئ ذي بدء: ماذا نحتاج إلى تمثيل كرة في ذاكرة الكمبيوتر؟ أربعة أرقام كافية بالنسبة لنا: متجه ثلاثي الأبعاد مع مركز الكرة وعددي يصف نصف القطر:
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; } };
الشيء الوحيد غير المميز في هذا الكود هو الوظيفة التي تسمح لك بالتحقق مما إذا كانت شعاع معين (مصدره الأصل في اتجاه دير) يتقاطع مع منطقتنا. وصف مفصل للخوارزمية لفحص تقاطع الحزمة ويمكن
قراءة المجال
هنا ، أوصي بشدة بذلك وفحص الكود.
كيف يعمل تتبع الأشعة؟ بسيط جدا في المرحلة الأولى ، قمنا ببساطة بتغطية الصورة بتدرج:
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); } }
الآن ، لكل بكسل ، سنشكل بصيصًا قادمًا من مركز الإحداثيات ويمر عبر بكسلنا ، ونتحقق مما إذا كان هذا الشعاع يتقاطع مع منطقتنا.

إذا لم يكن هناك تقاطع مع الكرة ، فسوف نضع color1 ، وإلا 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);
في هذه المرحلة ، أوصي بأخذ قلم رصاص والتحقق من الورق من جميع الحسابات ، تقاطع الشعاع مع كرة ، واجتاح صورة بالأشعة. فقط في حالة ، يتم تحديد الكاميرا لدينا من خلال الأشياء التالية:
- عرض الصورة
- ارتفاع الصورة
- زاوية الرؤية ، فوف
- موقع الكاميرا ، Vec3f (0،0،0)
- نظرة الاتجاه ، على طول المحور z ، في اتجاه ناقص اللانهاية
المرحلة الثالثة: إضافة المزيد من المجالات
كل ما هو أصعب هو وراءنا ، والآن طريقنا غائم. إذا استطعنا رسم كرة واحدة. ثم من الواضح إضافة المزيد من العمل ليس بالأمر الصعب.
هنا يمكنك رؤية التغييرات في الكود ، وهنا تكون النتيجة:

المرحلة الرابعة: الإضاءة
الجميع جيد في صورتنا ، لكن هذا لا يكفي الإضاءة فقط. خلال بقية المقال ، سنتحدث فقط عن هذا. إضافة بعض مصادر الضوء نقطة:
struct Light { Light(const Vec3f &p, const float &i) : position(p), intensity(i) {} Vec3f position; float intensity; };
إن التفكير في الإضاءة الحقيقية مهمة بالغة الصعوبة ، وبالتالي ، مثل أي شخص آخر ، فإننا سنخدع العين من خلال رسم نتائج غير مادية تمامًا ، ولكن محتملة إلى حد كبير. الملاحظة الأولى: لماذا يكون الجو باردًا في الشتاء وحارًا في الصيف؟ لأن تسخين سطح الأرض يعتمد على زاوية حدوث ضوء الشمس. كلما ارتفعت الشمس فوق الأفق ، كان السطح أكثر إشراقًا. والعكس بالعكس ، كلما كان الأفق أضعف. حسنًا ، بعد غروب الشمس فوق الأفق ، لا تصلنا الفوتونات مطلقًا. فيما يتعلق بمجالاتنا: هنا شعاعنا المنبعث من الكاميرا (لا علاقة للفوتونات ، انتبه!) تتقاطع مع الكرة. كيف نفهم كيف تضيء نقطة التقاطع؟ يمكنك ببساطة النظر إلى الزاوية بين المتجه العادي في هذه المرحلة والناقل الذي يصف اتجاه الضوء. أصغر زاوية ، كان السطح أفضل مضاءة. لجعله أكثر ملاءمة ، يمكنك ببساطة أخذ المنتج القياسي بين المتجه العادي وموجه الإضاءة. أذكر أن المنتج القياسي بين متجهين a و b يساوي منتج قواعد المتجهات بواسطة جيب تمام الزاوية بين المتجهات: a * b = | a | | ب | كوس (ألفا (أ ، ب)). إذا أخذنا نواقل بطول الوحدة ، فإن منتج أبسط عددي سوف يمنحنا كثافة إضاءة السطح.
وبالتالي ، في وظيفة cast_ray ، بدلاً من اللون الثابت ، سنقوم بإعادة اللون مع مراعاة مصادر الضوء:
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; }
شاهد التغييرات
هنا ، لكن نتيجة البرنامج:

المرحلة الخامسة: الأسطح اللامعة
خدعة بمنتج عددي بين المتجه العادي وناقل الضوء تقارب إضاءة الأسطح غير اللامعة جيدًا ، والتي تسمى الإضاءة المنتشرة في الأدب. ماذا تفعل إذا كنا نريد سلسة وبراقة؟ أريد الحصول على هذه الصورة:

انظر
كم هو مطلوب تغيير
طفيف . باختصار ، تكون الانعكاسات على الأسطح اللامعة أكثر إشراقًا ، أصغر زاوية بين اتجاه العرض واتجاه الضوء
المنعكس . حسنًا ، الزوايا ، بالطبع ، سوف نعول على المنتجات العددية ، تمامًا كما كان من قبل.
وتعرف هذه الجمباز مع الإضاءة غير لامع والأسطح لامعة باسم
نموذج فونج . تحتوي الويكي على وصف مفصل إلى حد ما لنموذج الإضاءة هذا ؛ فهو يقرأ جيدًا عند مقارنته بالتوازي مع الكود. فيما يلي صورة أساسية لفهمها:

المرحلة السادسة: الظلال
لماذا لدينا ضوء ، ولكن لا ظلال؟ فوضى اريد هذه الصورة
فقط ستة سطور من الكود تسمح لنا بتحقيق ذلك: عند رسم كل نقطة ، نتأكد فقط من أن مصدر الضوء لا يتقاطع مع كائنات مشهدنا ، وإذا حدث ذلك ، يتخطى مصدر الضوء الحالي. لا يوجد سوى دقة صغيرة: أقلب النقطة قليلاً في الاتجاه العادي:
Vec3f shadow_orig = light_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;
لماذا؟ نعم ، إنها مجرد نقطة تكمن في وجهة نظرنا على سطح الكائن ، و (باستثناء مسألة الأخطاء العددية) أي شعاع من هذه النقطة سوف يعبر المشهد لدينا.
الخطوة السابعة: تأملات
هذا أمر لا يمكن تصديقه ، ولكن لإضافة تأملات إلى مشهدنا ، نحتاج فقط إلى إضافة ثلاثة سطور من التعليمات البرمجية:
Vec3f reflect_dir = reflect(dir, N).normalize(); Vec3f reflect_orig = reflect_dir*N < 0 ? point - N*1e-3 : point + N*1e-3;
انظر لنفسك: عند تقاطع الكائن ، نحن ببساطة نحسب الأشعة المنعكسة (الدالة من حساب المطبات أصبحت في متناول اليد!) وندعو بشكل متكرر وظيفة cast_ray في اتجاه الشعاع المنعكس. تأكد من اللعب
بعمق العودية ، لقد قمت بضبطه على أربعة ، ابدأ من نقطة الصفر ، ما الذي سيتغير في الصورة؟ ها هي نتائجي مع انعكاس عملي وعمق أربعة:

المرحلة الثامنة: الانكسار
من خلال تعلم عد الانعكاسات ،
يتم احتساب الانكسارات كما هي تمامًا . إحدى الوظائف التي تسمح لك بحساب اتجاه الأشعة المنكسرة (
وفقًا لقانون Snell ) ، وثلاثة سطور من التعليمات البرمجية في الوظيفة العودية cast_ray. هذه هي النتيجة ، حيث أصبحت أقرب كرة "زجاجية" ، تنكسر وتعكس قليلاً:

المرحلة التاسعة: إضافة المزيد من الأشياء
لماذا كلنا بلا حليب ولكن بدون حليب. حتى هذه اللحظة ، قدمنا كرات فقط ، لأن هذا هو واحد من أبسط الأجسام الرياضية غير التافهة. ودعنا نضيف قطعة من الطائرة. الكلاسيكية من هذا النوع هو رقعة الشطرنج. لهذا ، فإن
عشرة خطوط في دالة تراعي تقاطع الحزمة مع المشهد تكفي تمامًا بالنسبة لنا.
حسنًا ، إليكم النتيجة:

كما وعدت ، بالضبط 256 سطر من التعليمات البرمجية ،
عد لنفسك !
المرحلة العاشرة: الواجب المنزلي
لقد قطعنا شوطًا طويلًا: تعلمنا كيفية إضافة أشياء إلى المشهد ، للنظر في إضاءة معقدة إلى حد ما. اسمحوا لي أن أترك مهمتين كواجب منزلي. تماما جميع الأعمال التحضيرية قد تم بالفعل في فرع
الواجب المنزلي . سوف تتطلب كل وظيفة عشرة أسطر من التعليمات البرمجية بحد أقصى.
المهمة الأولى: خريطة البيئة
في الوقت الحالي ، إذا لم يعبر الشعاع المشهد ، فسنقوم بتعيينه على لون ثابت. ولماذا ، في الواقع ، دائم؟ دعنا
نلتقط صورة كروية (ملف
envmap.jpg )
ونستخدمها كخلفية! لتسهيل الحياة ، ربطت مشروعنا مع مكتبة stb لتوفير الراحة للعمل مع ملفات jpegs. يجب أن يكون هذا مثل هذا:

المهمة الثانية: الدجال!
يمكننا تقديم كل من الطائرات والطائرات (انظر رقعة الشطرنج). لذلك دعونا نضيف رسمًا للنماذج المثلثة! كتبت رمزًا لقراءة شبكة المثلثات ، وأضفت وظيفة تقاطع مثلث الأشعة هناك. الآن إضافة بطة إلى المشهد لدينا يجب أن تكون تافهة تماما!

الخاتمة
تتمثل مهمتي الرئيسية في إظهار المشروعات المهمة (والسهلة!) للبرنامج ، وآمل حقًا أن أتمكن من القيام بذلك. هذا مهم للغاية ، فأنا مقتنع بأن المبرمج يجب أن يكتب الكثير وبذوق. لا أعرف عنك ، لكن المحاسبة الشخصية والمتعقّب ، مع تعقيد الكود القابل للمقارنة ، لا تجذبني على الإطلاق.
يمكن بالفعل كتابة مائتين وخمسين سطرًا من raytracing في غضون ساعات قليلة. يمكن إتقان
خمسمائة سطر من برنامج تنقيط البرامج في غضون أيام قليلة. في المرة القادمة سنقوم بالفرز عبر
rakecasting ، وفي نفس الوقت
سأعرض أبسط الألعاب التي يكتبها طلاب السنة الأولى لي كجزء من تدريس برمجة C ++. ترقبوا!