أساسيات هندسة الحاسوب. كتابة تصيير ثلاثي الأبعاد بسيط

مرحبًا ، اسمي ديفيد ، وها أنا نفسي ، تم تقديمه بواسطة تصيير مرسوم يدويًا:



صورة


لسوء الحظ ، لم أجد نموذجًا مجانيًا بجودة أفضل ، لكنني ما زلت أعبر عن امتناني للنحات الخارجي الذي أسرني في عالم رقمي! وكما قد تكون خمنت ، سنتحدث عن كتابة وحدة المعالجة المركزية - تصيير.



فكرة



مع تطور لغات التظليل وزيادة قوة وحدة معالجة الرسومات ، أصبح المزيد والمزيد من الأشخاص مهتمين ببرمجة الرسومات. ظهرت اتجاهات جديدة مثل مسيرة راي مع نمو سريع في شعبيتها.



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



انخفض اختيار اللغة في البداية إلى ++ C أو الصدأ ، لكنني استقرت على C #نظرًا لسهولة كتابة الكود وفرص التحسين العديدة. سيكون المنتج النهائي لهذه المقالة عبارة عن عرض قادر على إنتاج صور مثل هذا:



صورة


صورة


جميع النماذج التي استخدمتها هنا موزعة في المجال العام ، فلا تقرصن وتحترم أعمال الفنانين!



رياضيات



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



يتجه المتجه. مصفوفة الدوران



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



  • التناوب نسبة إلى الأصل
  • التناوب حول نقطة ما


هنا سأفكر فقط في النوع الأول ، منذ ذلك الحين والثاني هو مشتق من الأول ويختلف فقط في تغيير نظام إحداثيات الدوران (سنقوم بتحليل نظام الإحداثيات بشكل أكبر).



لنشتق صيغًا لتدوير متجه في فضاء ثنائي الأبعاد. دعنا نشير إلى إحداثيات المتجه الأصلي - {x، y} . إحداثيات المتجه الجديد ، التي يتم تدويرها بالزاوية f ، سيتم الإشارة إليها كـ {x 'y'} .



صورة


نحن نعلم أن طول هذه المتجهات شائع وبالتالي يمكننا استخدام مفاهيم جيب التمام والجيب للتعبير عن هذه المتجهات من حيث الطول والزاوية حول محور OX :



صورة


لاحظ أنه يمكننا استخدام صيغ الجمع وجيب التمام لتوسيع قيمتي x و y . بالنسبة لأولئك الذين نسوا ، سوف أذكر هذه الصيغ:



صورة


بتوسيع إحداثيات المتجه المستدير من خلالها ، نحصل على:



صورة


من السهل أن نرى هنا أن العاملين l * cos a و l * sin a هما إحداثيات المتجه الأصلي: x = l * cos a، y = l * sin a . دعنا نستبدلها بـ x و y :



صورة


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



صورة


اضرب وتحقق من أن النتيجة تعادل ما استنتجناه.



تدوير في مساحة ثلاثية الأبعاد



لقد درسنا الدوران في الفضاء ثنائي الأبعاد ، واشتقنا له أيضًا مصفوفة. الآن السؤال الذي يطرح نفسه ، كيف نحصل على مثل هذه التحولات لثلاثة أبعاد؟ في الحالة ثنائية الأبعاد ، قمنا بتدوير المتجهات على مستوى ، ولكن يوجد هنا عدد لا حصر له من المستويات التي يمكننا القيام بذلك بالنسبة لها. ومع ذلك ، هناك ثلاثة أنواع أساسية من التدويرات التي يمكنك من خلالها التعبير عن أي دوران لمتجه في مساحة ثلاثية الأبعاد - هذه هي دورات XY و XZ و YZ . دوران



XY .



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



صورة


علما بأن هذا التناوب، و ض إحداثيات ناقلات لا تتغير، ولكن س و س الإحداثيات التغيير - لهذا السبب وهذا ما يسمى XY التناوب.



صورة


ليس من الصعب اشتقاق الصيغ لمثل هذا الدوران: z - يظل الإحداثي كما هو ، ويتغير x و y وفقًا لنفس المبادئ كما في الدوران ثنائي الأبعاد.



صورة


الشيء نفسه في شكل مصفوفة:



صورة


بالنسبة لدورات XZ و YZ ، كل شيء هو نفسه:



صورة
صورة


تنبؤ



يمكن أن يختلف مفهوم الإسقاط اعتمادًا على السياق الذي يتم استخدامه فيه. ربما سمع الكثير عن مفاهيم مثل الإسقاط على مستوى أو الإسقاط على محور إحداثيات.



بالمفهوم الذي نستخدمه هنا ، فإن الإسقاط على المتجه هو أيضًا متجه. إحداثياته ​​هي نقطة تقاطع الخط العمودي المسقط من المتجه أ إلى ب مع المتجه ب .



صورة


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



صورة


يتطابق اتجاه متجه الإسقاط بحكم التعريف مع المتجه b ، مما يعني أن الإسقاط يتم تحديده بواسطة الصيغة:



صورة


نحصل هنا على اتجاه الإسقاط كمتجه للوحدة ونضربه في طول الإسقاط. ليس من الصعب أن نفهم أن النتيجة ستكون بالضبط ما نبحث عنه.



الآن دعنا نمثل كل شيء من حيث حاصل الضرب القياسي :



صورة


نحصل على صيغة مناسبة لإيجاد الإسقاط:



صورة


نظم الإحداثيات. القواعد



اعتاد الكثيرون على العمل في نظام إحداثيات XYZ القياسي ، حيث سيكون أي محورين متعامدين مع بعضهما البعض ، ويمكن تمثيل محاور الإحداثيات كمتجهات وحدة:



صورة


في الواقع ، هناك عدد لا نهائي من أنظمة الإحداثيات ، كل منها أساس . أساس الفضاء ذو ​​البعد n هو مجموعة من المتجهات {v1، v2 …… vn} يتم من خلالها تمثيل جميع نواقل هذا الفضاء. في هذه الحالة ، لا يمكن تمثيل أي ناقل من الأساس من خلال نواقله الأخرى. في الواقع ، كل أساس هو نظام إحداثيات منفصل يكون فيه للمتجهات إحداثيات فريدة خاصة بها.



دعنا نلقي نظرة على ماهية أساس الفضاء ثنائي الأبعاد. خذ ، على سبيل المثال ، نظام الإحداثيات الديكارتية المألوف للمتجهات X {1، 0} ، Y {0، 1} ، والتي تعد إحدى قواعد الفضاء ثنائي الأبعاد:



صورة




يمكن تمثيل أي متجه على مستوى كمجموع متجهات لهذا الأساس مع معاملات معينة ، أو كمجموعة خطية . تذكر ما تفعله عندما تكتب إحداثيات المتجه - تكتب س - الإحداثي ، ثم - ص . هذه هي الطريقة التي تحدد بها معاملات التمدد بدلالة متجهات الأساس.



صورة




لنأخذ الآن أساسًا آخر:



صورة




يمكن أيضًا تمثيل أي ناقل ثنائي الأبعاد من خلال نواقله:



صورة




لكن مثل هذه المجموعة من المتجهات ليست أساس الفضاء ثنائي الأبعاد:



صورة




فيه ، متجهان {1،1} و {2،2} يقعان على خط مستقيم واحد. مهما كانت التركيبات التي تأخذها ، ستتلقى فقط متجهات تقع على الخط المستقيم المشترك y = x . لأغراضنا ، لن تكون هذه المعيبة مفيدة ، ومع ذلك ، أعتقد أن الأمر يستحق فهم الفرق. بحكم التعريف ، يتم توحيد جميع القواعد بواسطة خاصية واحدة - لا يمكن تمثيل أي من المتجهات الأساسية كمجموع متجهات أساسية أخرى مع معاملات ، أو لا يمثل أي من المتجهات الأساسية مزيجًا خطيًا من المتجهات الأخرى. فيما يلي مثال على مجموعة من 3 نواقل ليست أيضًا أساسًا :



صورة




يمكن التعبير عن أي متجه لمستوى ثنائي الأبعاد من خلاله ، ولكن المتجه {1، 1} فيه غير ضروري ، حيث يمكن التعبير عنه من خلال المتجهات {1 ، 0} و {0،1} كـ {1،0} + {0،1 } .



بشكل عام ، أي أساس للفضاء ذي البعد n سيحتوي على n متجهات بالضبط ، لأن 2e هذا n يساوي 2.



دعونا ننتقل إلى 3d. سيحتوي الأساس ثلاثي الأبعاد على 3 ناقلات:



صورة




إذا كان هناك متجهان كافيان على أساس ثنائي الأبعاد لا يقعان على خط مستقيم واحد ، ففي الفضاء ثلاثي الأبعاد ستكون مجموعة من المتجهات هي الأساس إذا:



  • 1) متجهان لا يقعان على خط مستقيم واحد
  • 2) والثالث لا يقع على المستوى الذي شكله الآخران.




من الآن فصاعدًا ، ستكون القواعد التي نعمل بها متعامدة (أي من نواقلها متعامدة) وطبيعتها (طول أي متجه أساسي هو 1). نحن ببساطة لن نحتاج إلى الآخرين. على سبيل المثال ، الأساس القياسي



صورة




يفي بهذه المعايير.



الانتقال إلى أساس آخر



حتى الآن ، كتبنا تحلل المتجه كمجموع متجهات أساسية مع معاملات:



صورة


ضع في اعتبارك مرة أخرى الأساس القياسي - يمكن كتابة المتجه {1 ، 3 ، 6} كما يلي:



صورة


كما ترى ، معاملات التوسع للمتجه في الأساس هي إحداثياته ​​في هذا الأساس . لنلق نظرة على المثال التالي:



صورة




يُشتق هذا الأساس من المعيار من خلال تطبيق دوران 45 درجة XY عليه . خذ المتجه أ في النظام القياسي بالإحداثيات {0 ، 1 ، 1}



صورة




من خلال نواقل الأساس الجديد يمكن توسيعها على النحو التالي:



صورة




إذا قمت بحساب هذا المقدار ، فستحصل على {0 ، 1 ، 1} - المتجه a في الأساس القياسي. بناءً على هذا التعبير في الأساس الجديد ، يكون للمتجه a إحداثيات {0.7 ، 0.7 ، 1} - معاملات التوسع. سيكون هذا أكثر وضوحًا إذا نظرت من زاوية مختلفة:



صورة




لكن كيف تجد هذه المعاملات؟ بشكل عام ، الطريقة العالمية هي حل نظام معقد إلى حد ما من المعادلات الخطية. لكن، وكما قلت سابقا، سوف نستخدم فقط المتعامدة و تطبيع قواعد، وبالنسبة لهم هناك طريقة الغش جدا. وهو يتألف من إيجاد الإسقاطات على نواقل الأساس. دعنا نستخدمها لإيجاد تحلل المتجه a في الأساس X {0.7، 0.7، 0} Y {-0.7، 0.7، 0} Z {0، 0، 1}



صورة




أولًا ، لنجد معامل y ' . تتمثل الخطوة الأولى في العثور على إسقاط المتجه a على المتجه y ' (ناقشت كيفية القيام بذلك أعلاه):



صورة




الخطوة الثانية: نقسم طول الإسقاط الذي تم العثور عليه على طول المتجه y ' ، وبالتالي نكتشف "عدد المتجهات y' الملائمة في متجه الإسقاط" - سيكون هذا الرقم هو معامل y ، وأيضًا y - إحداثيات المتجه a في الأساس الجديد! بالنسبة إلى x ' و z' ، كرر عمليات مماثلة:



صورة




الآن لدينا صيغ للانتقال من الأساس القياسي إلى الجديد:



صورة




حسنًا ، نظرًا لأننا نستخدم القواعد المعيارية فقط وأن أطوال متجهاتها تساوي 1 ، فلا داعي للقسمة على طول المتجه في صيغة الانتقال:



صورة




قم بتوسيع تنسيق x من خلال صيغة الإسقاط:



صورة




لاحظ أن المقام (x '، x') والمتجه x ' في حالة الأساس الطبيعي هو أيضًا 1 ويمكن تجاهله. نحن نحصل:



صورة




نرى أن إحداثي x في الأساس يتم التعبير عنه على أنه حاصل الضرب النقطي (a ، x ') ، إحداثي y ، على التوالي ، مثل (a ، y') ، الإحداثي z هو (a ، z ') . يمكنك الآن إنشاء مصفوفة انتقال إلى إحداثيات جديدة:



صورة




نظم إحداثيات الأوفست



جميع أنظمة الإحداثيات التي ذكرناها أعلاه لها أصل النقطة {0،0،0} . بالإضافة إلى ذلك ، هناك أنظمة ذات نقطة أصل متحولة:



صورة




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



كتابة محرك هندسي. إنشاء تحويل الأسلاك.





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



صورة




رسومات متعددة الأضلاع



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



في محركنا ، سيكون العنصر البدائي هو الهدف الرئيسي للهندسة ثلاثية الأبعاد - سترث جميع الكائنات الأخرى منه. دعنا نصف الفئة البدائية:



    abstract class Primitive
    {
        public Vector3[] Vertices { get; protected set; }
        public int[] Indexes { get; protected set; }
    }


حتى الآن ، كل شيء بسيط - هناك رؤوس للبدائية وهناك مؤشرات لتشكيل المضلعات. يمكنك الآن استخدام هذه الفئة لإنشاء مكعب:



   public class Cube : Primitive
      {
        public Cube(Vector3 center, float sideLen)
        {
            var d = sideLen / 2;
            Vertices = new Vector3[]
                {
                    new Vector3(center.X - d , center.Y - d, center.Z - d) ,
                    new Vector3(center.X - d , center.Y - d, center.Z) ,
                    new Vector3(center.X - d , center.Y , center.Z - d) ,
                    new Vector3(center.X - d , center.Y , center.Z) ,
                    new Vector3(center.X + d , center.Y - d, center.Z - d) ,
                    new Vector3(center.X + d , center.Y - d, center.Z) ,
                    new Vector3(center.X + d , center.Y + d, center.Z - d) ,
                    new Vector3(center.X + d , center.Y + d, center.Z + d) ,
                };

            Indexes = new int[]
                {
                    1,2,4 ,
                    1,3,4 ,
                    1,2,6 ,
                    1,5,6 ,
                    5,6,8 ,
                    5,7,8 ,
                    8,4,3 ,
                    8,7,3 ,
                    4,2,8 ,
                    2,8,6 ,
                    3,1,7 ,
                    1,7,5
                };
        }
    }

int Main()
{
        var cube = new Cube(new Vector3(0, 0, 0), 2);
}


صورة


تنفيذ أنظمة الإحداثيات



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



صورة


يتيح لك تمثيل كائن ما في الإحداثيات المحلية إجراء أي عمليات معه بسهولة. على سبيل المثال ، لتحريك كائن بواسطة المتجه a ، سيكون كافيًا إزاحة مركز نظام الإحداثيات الخاص به بواسطة هذا المتجه ، لتدوير كائن - تدوير إحداثياته ​​المحلية.



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



  • 1) تمثيل نقطة بالنسبة لمركز الإحداثيات الجديدة
  • 2) التوسع في نواقل الأساس الجديد


على العكس من ذلك ، من أجل تمثيل الرأس المحلي لكائن ما في إحداثيات عالمية ، يجب عليك تنفيذ هذه الإجراءات بترتيب عكسي:



  • 1) التوسع في النواقل العالمية
  • 2) التمثيل النسبي للمركز العالمي


لنكتب فصلًا لتمثيل أنظمة الإحداثيات:



    public class Pivot
    {
        // 
        public Vector3 Center { get; private set; }
        //   -   
        public Vector3 XAxis { get; private set; }
        public Vector3 YAxis { get; private set; }
        public Vector3 ZAxis { get; private set; }

        //    
        public Matrix3x3 LocalCoordsMatrix => new Matrix3x3
            (
                XAxis.X, YAxis.X, ZAxis.X,
                XAxis.Y, YAxis.Y, ZAxis.Y,
                XAxis.Z, YAxis.Z, ZAxis.Z
            );

        //    
        public Matrix3x3 GlobalCoordsMatrix => new Matrix3x3
            (
                XAxis.X , XAxis.Y , XAxis.Z,
                YAxis.X , YAxis.Y , YAxis.Z,
                ZAxis.X , ZAxis.Y , ZAxis.Z
            );

        public Vector3 ToLocalCoords(Vector3 global)
        {
            //          
            return LocalCoordsMatrix * (global - Center);
        }
        public Vector3 ToGlobalCoords(Vector3 local)
        {
            //    -            
            return (GlobalCoordsMatrix * local)  + Center;
        }

        public void Move(Vector3 v)
        {
            Center += v;
        }

        public void Rotate(float angle, Axis axis)
        {
            XAxis = XAxis.Rotate(angle, axis);
            YAxis = YAxis.Rotate(angle, axis);
            ZAxis = ZAxis.Rotate(angle, axis);
        }
    }


الآن ، باستخدام هذه الفئة ، أضف وظائف الدوران والحركة والزيادة إلى الأوليات:



    public abstract class Primitive
    {
        //  
        public Pivot Pivot { get; protected set; }
        // 
        public Vector3[] LocalVertices { get; protected set; }
        // 
        public Vector3[] GlobalVertices { get; protected set; }
        // 
        public int[] Indexes { get; protected set; }

        public void Move(Vector3 v)
        {
            Pivot.Move(v);

            for (int i = 0; i < LocalVertices.Length; i++)
                GlobalVertices[i] += v;
        }

        public void Rotate(float angle, Axis axis)
        {
            Pivot.Rotate(angle , axis);

            for (int i = 0; i < LocalVertices.Length; i++)
                GlobalVertices[i] = Pivot.ToGlobalCoords(LocalVertices[i]);
        }

        public void Scale(float k)
        {
            for (int i = 0; i < LocalVertices.Length; i++)
                LocalVertices[i] *= k;

            for (int i = 0; i < LocalVertices.Length; i++)
                GlobalVertices[i] = Pivot.ToGlobalCoords(LocalVertices[i]);
        }
    }


صورة


تدوير وتحريك كائن باستخدام الإحداثيات المحلية



رسم المضلعات. الة تصوير



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



صورة


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



صورة


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



صورة




على العكس من ذلك ، فبمعرفة ارتفاع الشجرة ، يمكنه معرفة ارتفاع نقطة على مستوى الإسقاط:



صورة




الآن دعنا نعود إلى الكاميرا. تخيل أن هناك من طائرة إسقاط المرفقة إلى Z- محور الكاميرا على مسافة ض ' من الأصل. صيغة هذا المستوى هي z = z ' ، ويمكن الحصول عليها برقم واحد - z' . تسقط أشعة من رؤوس أجسام مختلفة على هذا المستوى. عندما يصطدم الشعاع بالطائرة ، سيترك نقطة عليها. من خلال ربط هذه النقاط ، يمكنك رسم كائن.



صورة




ستمثل هذه الطائرة الشاشة. سنجد تنسيق إسقاط رأس الكائن على الشاشة على مرحلتين:



  • 1) نقوم بترجمة الرأس إلى الإحداثيات المحلية للكاميرا
  • 2) أوجد إسقاط نقطة من خلال نسبة المثلثات المتشابهة


صورة




سيكون الإسقاط متجهًا ثنائي الأبعاد ، وستحدد إحداثياته x 'و y' موضع النقطة على شاشة الكمبيوتر.



فئة الغرفة 1
public class Camera
{
    //  
    public Pivot Pivot { get; private set; }
    //   
    public float ScreenDist { get; private set; }

    public Camera(Vector3 center, float screenDist)
    {
        Pivot = new Pivot(center);
        ScreenDist = screenDist;
    }
    public void Move(Vector3 v)
    {
        Pivot.Move(v);
    }
    public void Rotate(float angle, Axis axis)
    {
        Pivot.Rotate(angle, axis);
    }
    public Vector2 ScreenProection(Vector3 v)
    {
        var local = Pivot.ToLocalCoords(v);
        //    
        var delta = ScreenDist / local.Z;
        var proection = new Vector2(local.X, local.Y) * delta;
        return proection;
    }
}




يحتوي هذا الرمز على عدة أخطاء سنتحدث عن إصلاحها لاحقًا.



اقطع المضلعات غير المرئية



بعد إسقاط ثلاث نقاط من المضلع على الشاشة بهذه الطريقة ، نحصل على إحداثيات المثلث الذي يتوافق مع عرض المضلع على الشاشة. ولكن بهذه الطريقة ، ستعالج الكاميرا أي رؤوس ، بما في ذلك تلك التي تتجاوز إسقاطاتها مساحة الشاشة ، إذا حاولت رسم مثل هذا الرأس ، فهناك احتمال كبير لالتقاط الأخطاء. ستعالج الكاميرا أيضًا المضلعات الموجودة خلفها (إحداثيات z لنقاطها في خط الأساس للكاميرا المحلية أقل من z ' ) - نحن أيضًا لا نحتاج إلى مثل هذه الرؤية "القذالية".



صورة




لقص الرؤوس غير المرئية في gl المفتوحة ، يتم استخدام طريقة هرم الاقتطاع. يتكون من وضع طائرتين - بالقرب (بالقرب من الطائرة) وبعيد (الطائرة البعيدة). كل ما يقع بين هاتين الطائرتين سيخضع لمزيد من المعالجة. أستخدم نسخة مبسطة مع مستوى قص واحد - z ' . كل القمم خلفه ستكون غير مرئية.



دعنا نضيف حقلين جديدين إلى الكاميرا - عرض الشاشة وارتفاعها.

الآن سوف نتحقق من كل نقطة مسقطة لضرب منطقة الشاشة. دعونا أيضًا نقطع النقاط الموجودة خلف الكاميرا. إذا كانت النقطة خلفها أو لم يقع إسقاطها على الشاشة ، فإن الطريقة ستعيد النقطة {float.NaN، float.NaN} .



كود الكاميرا 2
public Vector2 ScreenProection(Vector3 v)
{
    var local = Pivot.ToLocalCoords(v);
    //   
    if (local.Z < ScreenDist)
    {
        return new Vector2(float.NaN, float.NaN);
    }
    //    
    var delta = ScreenDist / local.Z;
    var proection = new Vector2(local.X, local.Y) * delta;
    //     -  
    if (proection.X >= 0 && proection.X < ScreenWidth && proection.Y >= 0 && proection.Y < ScreenHeight)
    {
        return proection;
    }
    return new Vector2(float.NaN, float.NaN);
}




الترجمة إلى شاشة الإحداثيات



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



صورة




كود الكاميرا 3
public Vector2 ScreenProection(Vector3 v)
{
    var local = Pivot.ToLocalCoords(v);
    //   
    if (local.Z < ScreenDist)
    {
        return new Vector2(float.NaN, float.NaN);
    }
    //    
    var delta = ScreenDist / local.Z;
    var proection = new Vector2(local.X, local.Y) * delta;
    //        
    var screen = proection + new Vector2(ScreenWidth / 2, -ScreenHeight / 2);
    var screenCoords = new Vector2(screen.X, -screen.Y);
    //     -  
    if (screenCoords.X >= 0 && screenCoords.X < ScreenWidth && screenCoords.Y >= 0 && screenCoords.Y < ScreenHeight)
    {
        return screenCoords;
    }
    return new Vector2(float.NaN, float.NaN);
}




ضبط حجم الصورة المعروضة



إذا كنت تستخدم الكود السابق لرسم كائن ، فستحصل على شيء مثل هذا:



صورة




لسبب ما ، يتم رسم جميع الكائنات صغيرة جدًا. لفهم السبب ، تذكر كيف حسبنا الإسقاط - لقد ضربنا إحداثيات x و y في دلتا النسبة z '/ z . هذا يعني أن حجم الكائن على الشاشة يعتمد على المسافة إلى مستوى الإسقاط z ' . ولكن يمكننا ضبط z ' صغيرة كما نريد. لذلك نحن بحاجة إلى ضبط حجم الإسقاط اعتمادًا على قيمة z الحالية . للقيام بذلك ، دعنا نضيف مجالًا آخر إلى الكاميرا - زاوية رؤيتها .



صورة




نحتاجها لمطابقة الحجم الزاوي للشاشة مع عرضها. ستتم مطابقة الزاوية مع عرض الشاشة بهذه الطريقة: الزاوية القصوى التي تنظر فيها الكاميرا هي الحافة اليسرى أو اليمنى للشاشة. عندئذ تكون الزاوية القصوى من المحور z للكاميرا هي o / 2 . يجب أن يكون للإسقاط الذي يصل إلى الحافة اليمنى للشاشة إحداثيات x = width / 2 ، والإحداثي الأيسر: x = -width / 2 . بمعرفة ذلك ، نشتق معادلة إيجاد معامل تمدد الإسقاط:



صورة




كود الكاميرا 4
public float ObserveRange { get; private set; }
public float Scale => ScreenWidth / (float)(2 * ScreenDist * Math.Tan(ObserveRange / 2));
public Vector2 ScreenProection(Vector3 v)
{
    var local = Pivot.ToLocalCoords(v);
    //   
    if (local.Z < ScreenDist)
    {
        return new Vector2(float.NaN, float.NaN);
    }
    //          
    var delta = ScreenDist / local.Z * Scale;
    var proection = new Vector2(local.X, local.Y) * delta;
    //        
    var screen = proection + new Vector2(ScreenWidth / 2, -ScreenHeight / 2);
    var screenCoords = new Vector2(screen.X, -screen.Y);
    //     -  
    if (screenCoords.X >= 0 && screenCoords.X < ScreenWidth && screenCoords.Y >= 0 && screenCoords.Y < ScreenHeight)
    {
        return screenCoords;
    }
    return new Vector2(float.NaN, float.NaN);
}




إليك رمز عرض بسيط استخدمته للاختبار:



كود رسم الكائن
public DrawObject(Primitive primitive , Camera camera)
{
    for (int i = 0; i < primitive.Indexes.Length; i+=3)
    {
        var color = randomColor();
        //   
        var i1 = primitive.Indexes[i];
        var i2 = primitive.Indexes[i+ 1];
        var i3 = primitive.Indexes[i+ 2];
        //  
        var v1 = primitive.GlobalVertices[i1];
        var v2 = primitive.GlobalVertices[i2];
        var v3 = primitive.GlobalVertices[i3];
        //  
        DrawPolygon(v1,v2,v3 , camera , color);
    }
}

public void DrawPolygon(Vector3 v1, Vector3 v2, Vector3 v3, Camera camera , color)
{
    // 
    var p1 = camera.ScreenProection(v1);
    var p2 = camera.ScreenProection(v2);
    var p3 = camera.ScreenProection(v3);
    // 
    DrawLine(p1, p2 , color);
    DrawLine(p2, p3 , color);
    DrawLine(p3, p2 , color);
}




دعنا نتحقق من العرض في المشهد والمكعبات:



صورة




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



نتيجة العرض


image



image





تنقيط المضلعات. نجلب الجمال.





في القسم الأخير ، كتبنا تصيير إطار سلكي. الآن سنتعامل مع تحديثها - سنقوم بتنفيذ تنقيط المضلعات. يعني تنقيط مضلع



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



صورة




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



خوارزمية Bresenham لرسم الخط.



لنبدأ بالخطوط. إذا كان أي شخص لا يعرف خوارزمية Bresenham ، فهذه هي الخوارزمية الرئيسية لرسم خطوط مستقيمة في رسومات الكمبيوتر. يتم استخدامه أو تعديلاته حرفيًا في كل مكان: رسم الخطوط ، والمقاطع ، والدوائر ، إلخ. أي شخص مهتم بمزيد من الوصف التفصيلي - اقرأ الويكي. خوارزمية Bresenham



هناك قطعة خطية تربط النقطتين {x1، y1} و {x2، y2} . لرسم جزء بينهما ، يجب أن ترسم كل البكسلات التي تقع عليها. بالنسبة إلى نقطتين من المقطع ، يمكنك العثور على إحداثيات x للبكسل التي تقع فيها: ما عليك سوى أخذ أجزاء كاملة من الإحداثيين x1 و x2 . لرسم وحدات بكسل على مقطع ما ، ابدأ دورة من x1 إلى x2 واحسب عند كل تكرارy - تنسيق البكسل الذي يقع على الخط. ها هو الكود:



void Brezenkhem(Vector2 p1 , Vector2 p2)
{
    int x1 = Floor(p1.X);
    int x2 = Floor(p2.X);
    if (x1 > x2) {Swap(x1, x2); Swap(p1 , p2);}
    float d = (p2.Y - p1.Y) / (x2 - x1);
    float y = p1.Y;
    for (int i = x1; i <= x2; i++)
    {
        int pixelY = Floor(y);
        FillPixel(i , pixelY);
        y += d;
    }
}


صورة


صورة من ويكي



تنقيط مثلث. خوارزمية التعبئة



نحن نعرف كيفية رسم الخطوط ، لكن مع المثلثات سيكون الأمر أكثر صعوبة (ليس كثيرًا)! يتم تقليل مهمة رسم المثلث إلى عدة مهام لرسم الخطوط. أولاً ، دعونا نقسم المثلث إلى قسمين ، بعد أن قمنا بفرز النقاط مسبقًا بترتيب تصاعدي x :



صورة




لاحظ - الآن لدينا قسمين فيها أقل و العليا الحدود يتم التعبير بشكل واضح . كل ما تبقى هو ملء جميع وحدات البكسل بينهما! يمكن القيام بذلك في دورتين: من x1 إلى x2 ومن x3 إلى x2 .



void Triangle(Vector2 v1 , Vector2 v2 , Vector2 v3)
{
    // BubbleSort    x
    if (v1.X > v2.X) { Swap(v1, v2); }
    if (v2.X > v3.X) { Swap(v2, v3); }
    if (v1.X > v2.X) { Swap(v1, v2); }

    //    y    x
    //   0:  x1 == x2     - 
    var steps12 = max(v2.X - v1.X , 1);
    var steps13 = max(v3.X - v1.X , 1);
    var upDelta = (v2.Y - v1.Y) / steps12;
    var downDelta = (v3.Y - v1.Y) / steps13;

    //     
    if (upDelta < downDelta) Swap(upDelta , downDelta);

    //     y1
    var up = v1.Y;
    var down = v1.Y;

    for (int i = (int)v1.X; i <= (int)v2.X; i++)
    {
        for (int g = (int)down; g <= (int)up; g++)
        {
            FillPixel(i , g);
        }
        up += upDelta;
        down += downDelta;
    }

    //       
    var steps32 = max(v2.X - v3.X , 1);
    var steps31 = max(v1.X - v3.X , 1);
    upDelta = (v2.Y - v3.Y) / steps32;
    downDelta = (v1.Y - v3.Y) / steps31;

    if (upDelta < downDelta) Swap(upDelta, downDelta);

    up = v3.Y;
    down = v3.Y;

    for (int i = (int)v3.X; i >=(int)v2.X; i--)
    {
        for (int g = (int)down; g <= (int)up; g++)
        {
            FillPixel(i, g);
        }
        up += upDelta;
        down += downDelta;
    }
}


مما لا شك فيه أن هذا الكود يمكن إعادة بنائه وليس تكرار الحلقة:



void Triangle(Vector2 v1 , Vector2 v2 , Vector2 v3)
{
    if (v1.X > v2.X) { Swap(v1, v2); }
    if (v2.X > v3.X) { Swap(v2, v3); }
    if (v1.X > v2.X) { Swap(v1, v2); }

    var steps12 = max(v2.X - v1.X , 1);
    var steps13 = max(v3.X - v1.X , 1);
    var steps32 = max(v2.X - v3.X , 1);
    var steps31 = max(v1.X - v3.X , 1);

    var upDelta = (v2.Y - v1.Y) / steps12;
    var downDelta = (v3.Y - v1.Y) / steps13;
    if (upDelta < downDelta) Swap(upDelta , downDelta);

    TrianglePart(v1.X , v2.X , v1.Y , upDelta , downDelta);

    upDelta = (v2.Y - v3.Y) / steps32;
    downDelta = (v1.Y - v3.Y) / steps31;
    if (upDelta < downDelta) Swap(upDelta, downDelta);

    TrianglePart(v3.X, v2.X, v3.Y, upDelta, downDelta);
}

void TrianglePart(float x1 , float x2 , float y1  , float upDelta , float downDelta)
{
    float up = y1, down = y1;
    for (int i = (int)x1; i <= (int)x2; i++)
    {
        for (int g = (int)down; g <= (int)up; g++)
        {
            FillPixel(i , g);
        }
        up += upDelta; down += downDelta;
    }
}


قص النقاط غير المرئية.



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



صورة




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



صورة




الآن السؤال الذي يطرح نفسه - كيف تجد إحداثيات z- للنقاط على المضلع الأصلي؟ ويمكن القيام بذلك بعدة طرق. على سبيل المثال ، يمكنك تصوير شعاع من أصل الكاميرا ، مروراً بنقطة على مستوى الإسقاط {x، y، z '} وإيجاد تقاطعها مع المضلع. لكن البحث عن التقاطعات عملية مكلفة للغاية ، لذلك سنستخدم طريقة مختلفة. لرسم مثلث ، قمنا بإقحام إحداثيات إسقاطاته ، والآن ، بالإضافة إلى ذلك ، سنقوم أيضًا بإقحام إحداثيات المضلع الأصلي . لقطع النقاط غير المرئية ، سنستخدم حالة zbuffer للإطار الحالي في طريقة التنقيط .



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



الرمز
public void ComputePoly(Vector3 v1, Vector3 v2, Vector3 v3 , Vector3[] zbuffer)
{
    //  
    var v1p = Camera.ScreenProection(v1);
    var v2p = Camera.ScreenProection(v2);
    var v3p = Camera.ScreenProection(v3);

    //   x - 
    //,     -    
    if (v1p.X > v2p.X) { Swap(v1p, v2p); Swap(v1p, v2p); }
    if (v2p.X > v3p.X) { Swap(v2p, v3p); Swap(v2p, v3p); }
    if (v1p.X > v2p.X) { Swap(v1p, v2p); Swap(v1p, v2p); }

    //       
    int x12 = Math.Max((int)v2p.X - (int)v1p.X, 1);
    int x13 = Math.Max((int)v3p.X - (int)v1p.X, 1);

    //       
    float dy12 = (v2p.Y - v1p.Y) / x12; var dr12 = (v2 - v1) / x12;
    float dy13 = (v3p.Y - v1p.Y) / x13; var dr13 = (v3 - v1) / x13;

    Vector3 deltaUp, deltaDown; float deltaUpY, deltaDownY;
    if (dy12 > dy13) { deltaUp = dr12; deltaDown = dr13; deltaUpY = dy12; deltaDownY = dy13;}
    else { deltaUp = dr13; deltaDown = dr12; deltaUpY = dy13; deltaDownY = dy12;}

    TrianglePart(v1 , deltaUp , deltaDown , x12 , 1 , v1p , deltaUpY , deltaDownY , zbuffer);
    //    -   
}
public void ComputePolyPart(Vector3 start, Vector3 deltaUp, Vector3 deltaDown,
    int xSteps, int xDir, Vector2 pixelStart, float deltaUpPixel, float deltaDownPixel , Vector3[] zbuffer)
{
    int pixelStartX = (int)pixelStart.X;
    Vector3 up = start - deltaUp, down = start - deltaDown;
    float pixelUp = pixelStart.Y - deltaUpPixel, pixelDown = pixelStart.Y - deltaDownPixel;
    for (int i = 0; i <= xSteps; i++)
    {
        up += deltaUp; pixelUp += deltaUpPixel;
        down += deltaDown; pixelDown += deltaDownPixel;
        int steps = ((int)pixelUp - (int)pixelDown);
        var delta = steps == 0 ? Vector3.Zero : (up - down) / steps;
        Vector3 position = down - delta;
        for (int g = 0; g <= steps; g++)
        {
            position += delta;
            var proection = new Point(pixelStartX + i * xDir, (int)pixelDown + g);
            int index = proection.Y * Width + proection.X;
            //  
            if (zbuffer[index].Z == 0 || zbuffer[index].Z > position.Z)
            {
                zbuffer[index] = position;
            }
        }
    }
}




صورة


رسم متحرك لخطوات نقطي (عند إعادة كتابة العمق في zbuffer ، يتم تمييز البكسل باللون الأحمر):



للراحة ، قمت بنقل كل الكود إلى وحدة Rasterizer منفصلة:



فئة Rasterizer
    public class Rasterizer
    {
        public Vertex[] ZBuffer;
        public int[] VisibleIndexes;
        public int VisibleCount;
        public int Width;
        public int Height;
        public Camera Camera;

        public Rasterizer(Camera camera)
        {
            Shaders = shaders;
            Width = camera.ScreenWidth;
            Height = camera.ScreenHeight;
            Camera = camera;

        }
        public Bitmap Rasterize(IEnumerable<Primitive> primitives)
        {
            var buffer = new Bitmap(Width , Height);
            ComputeVisibleVertices(primitives);
            for (int i = 0; i < VisibleCount; i++)
            {
                var vec = ZBuffer[index];
                var proec = Camera.ScreenProection(vec);
                buffer.SetPixel(proec.X , proec.Y);
            }
            return buffer.Bitmap;
        }
        public void ComputeVisibleVertices(IEnumerable<Primitive> primitives)
        {
            VisibleCount = 0;
            VisibleIndexes = new int[Width * Height];
            ZBuffer = new Vertex[Width * Height];
            foreach (var prim in primitives)
            {
                foreach (var poly in prim.GetPolys())
                {
                    MakeLocal(poly);
                    ComputePoly(poly.Item1, poly.Item2, poly.Item3);
                }
            }
        }
        public void MakeLocal(Poly poly)
        {
            poly.Item1.Position = Camera.Pivot.ToLocalCoords(poly.Item1.Position);
            poly.Item2.Position = Camera.Pivot.ToLocalCoords(poly.Item2.Position);
            poly.Item3.Position = Camera.Pivot.ToLocalCoords(poly.Item3.Position);

        }
    }




الآن دعنا نتحقق من عمل التصيير. لهذا أستخدم نموذج Sylvanas من لعبة RPG الشهيرة "WOW":



صورة




ليس واضحًا جدًا ، أليس كذلك؟ هذا بسبب عدم وجود مواد أو إضاءة هنا. لكننا سنصلحها قريبًا.



القوام! عادي! إضاءة! محرك!



لماذا جمعت كل ذلك في قسم واحد؟ ولأن التركيب وحساب القواعد متطابقان تمامًا في جوهرهما وستفهمون ذلك قريبًا.



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



صورة


لاحظ أن بداية الزخرفة ( الجزء السفلي الأيسر من البكسل ) في إحداثيات النسيج هي {0 ، 0} ، والنهاية ( أعلى يمين بكسل ) هي {1 ، 1} . ضع في الاعتبار نظام إحداثيات النسيج وإمكانية تجاوز حدود الصورة عندما يكون تنسيق النسيج هو 1.



لنقم بإنشاء فئة لتمثيل بيانات قمة الرأس على الفور:



  public class Vertex
    {
        public Vector3 Position { get; set; }
        public Color Color { get; set; }
        public Vector2 TextureCoord { get; set; }
        public Vector3 Normal { get; set; }

        public Vertex(Vector3 pos , Color color , Vector2 texCoord , Vector3 normal)
        {
            Position = pos;
            Color = color;
            TextureCoord = texCoord;
            Normal = normal;
        }
    }


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



نموذج محكم
image




توجد جميع المعلومات حول إحداثيات نسيج النموذج في ملف OBJ. لاستخدام هذا ، تعرف على التنسيق: تنسيق OBJ.



إضاءة





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



نموذج Phong



بشكل عام ، تحاكي هذه الطريقة وجود 3 مكونات للإضاءة: الخلفية (المحيطة) والمشتتة (المنتشرة) والمرآة (الانعكاس). مجموع هذه المكونات الثلاثة سيحاكي في النهاية السلوك المادي للضوء.



صورة


نموذج Phong



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



صورة


صورة من ويكي



لحساب المعدل الطبيعي في كل نقطة على المضلع ، تحتاج إلى استيفاء هذه القيم ، فنحن نعرف بالفعل كيفية القيام بذلك. الآن دعونا نلقي نظرة على جميع المكونات لحساب إضاءة Phong.



ضوء الخلفية (محيط)



في البداية ، قمنا بتعيين إضاءة خلفية ثابتة ، للكائنات غير المنسوجة ، يمكنك اختيار أي لون للكائنات ذات القوام أقسم كل مكون من مكونات RGB بنسبة تظليل أساسي ( تظليل أساسي ).



منتشر الضوء



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



الرمز
public interface IShader
    {
        void ComputeShader(Vertex vertex, Camera camera);
    }

    public struct Light
    {
        public Vector3 Pos;
        public float Intensivity;
    }

public class PhongModelShader : IShader
    {
        public static float DiffuseCoef = 0.1f;
        public Light[] Lights { get; set; }

        public PhongModelShader(params Light[] lights)
        {
            Lights = lights;
        }
        public void ComputeShader(Vertex vertex, Camera camera)
        {
            if (vertex.Normal.X == 0 && vertex.Normal.Y == 0 && vertex.Normal.Z == 0)
            {
                return;
            }
            var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);
            foreach (var light in Lights)
            {
                var ldir = Vector3.Normalize(light.Pos - gPos);
                var diffuseVal = Math.Max(VectorMath.Cross(ldir, vertex.Normal), 0) * light.Intensivity;
                vertex.Color = Color.FromArgb(vertex.Color.A,
                    (int)Math.Min(255, vertex.Color.R * diffuseVal * DiffuseCoef),
                    (int)Math.Min(255, vertex.Color.G * diffuseVal * DiffuseCoef,
                    (int)Math.Min(255, vertex.Color.B * diffuseVal * DiffuseCoef));
            }
        }
    }




لنطبق الضوء المنتشر ونبدد الظلام:



صورة


ضوء المرآة (انعكاس)



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



صورة


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



الرمز
    public class PhongModelShader : IShader
    {
        public static float DiffuseCoef = 0.1f;
        public static float ReflectCoef = 0.2f;
        public Light[] Lights { get; set; }

        public PhongModelShader(params Light[] lights)
        {
            Lights = lights;
        }
        public void ComputeShader(Vertex vertex, Camera camera)
        {
            if (vertex.Normal.X == 0 && vertex.Normal.Y == 0 && vertex.Normal.Z == 0)
            {
                return;
            }
            var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);
            foreach (var light in Lights)
            {
                var ldir = Vector3.Normalize(light.Pos - gPos);
                //         
                var proection = VectorMath.Proection(ldir, -vertex.Normal);
                var d = ldir - proection;
                var reflect = proection - d;
                var diffuseVal = Math.Max(VectorMath.Cross(ldir, -vertex.Normal), 0) * light.Intensivity;
                //  
                var eye = Vector3.Normalize(-vertex.Position);
                var reflectVal = Math.Max(VectorMath.Cross(reflect, eye), 0) * light.Intensivity;
                var total = diffuseVal * DiffuseCoef + reflectVal * ReflectCoef;
                vertex.Color = Color.FromArgb(vertex.Color.A,
                    (int)Math.Min(255, vertex.Color.R * total),
                    (int)Math.Min(255, vertex.Color.G * total),
                    (int)Math.Min(255, vertex.Color.B * total));
            }
        }
    }




الآن تبدو الصورة كما يلي:



صورة




الظلال



ستكون نقطة نهاية العرض التقديمي الخاص بي هي تنفيذ الظلال للعرض. أول فكرة طريق مسدود نشأت في جمجمتي هي التحقق من كل نقطة إذا كان هناك أي مضلع بينها وبين الضوء . إذا كان الأمر كذلك ، فلن تحتاج إلى إضاءة البكسل. يحتوي نموذج سيلفاناس على أكثر من 220 ألف مضلع. إذا كان الأمر كذلك بالنسبة لكل نقطة للتحقق من التقاطع مع كل هذه المضلعات ، فأنت بحاجة إلى إجراء 220.000 * 1920 * 1080 * 219999 مكالمات كحد أقصى إلى طريقة التقاطع! في غضون 10 دقائق ، تمكن جهاز الكمبيوتر الخاص بي من إتقان الجزء العاشر من جميع العمليات الحسابية (2600 مضلع من أصل 220.000) ، وبعد ذلك كان لدي وردية وذهبت في البحث عن طريقة جديدة.



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



الرمز
public class ShadowMappingShader : IShader
{
    public Enviroment Enviroment { get; set; }
    public Rasterizer Rasterizer { get; set; }
    public Camera Camera => Rasterizer.Camera;
    public Pivot Pivot => Camera.Pivot;
    public Vertex[] ZBuffer => Rasterizer.ZBuffer;
    public float LightIntensivity { get; set; }

    public ShadowMappingShader(Enviroment enviroment, Rasterizer rasterizer, float lightIntensivity)
    {
        Enviroment = enviroment;
        LightIntensivity = lightIntensivity;
        Rasterizer = rasterizer;
        //     ,      
        //  /         
        Camera.OnRotate += () => UpdateDepthMap(Enviroment.Primitives);
        Camera.OnMove += () => UpdateDepthMap(Enviroment.Primitives);
        Enviroment.OnChange += () => UpdateDepthMap(Enviroment.Primitives);
        UpdateVisible(Enviroment.Primitives);
    }
    public void ComputeShader(Vertex vertex, Camera camera)
    {
        //   
        var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);
        //  
        var lghDir = Pivot.Center - gPos;
        var distance = lghDir.Length();
        var local = Pivot.ToLocalCoords(gPos);
        var proectToLight = Camera.ScreenProection(local).ToPoint();
        if (proectToLight.X >= 0 && proectToLight.X < Camera.ScreenWidth && proectToLight.Y >= 0
            && proectToLight.Y < Camera.ScreenHeight)
        {
            int index = proectToLight.Y * Camera.ScreenWidth + proectToLight.X;
            if (ZBuffer[index] == null || ZBuffer[index].Position.Z >= local.Z)
            {
                vertex.Color = Color.FromArgb(vertex.Color.A,
                    (int)Math.Min(255, vertex.Color.R + LightIntensivity / distance),
                    (int)Math.Min(255, vertex.Color.G + LightIntensivity / distance),
                    (int)Math.Min(255, vertex.Color.B + LightIntensivity / distance));
            }
        }
        else
        {
            vertex.Color = Color.FromArgb(vertex.Color.A,
                    (int)Math.Min(255, vertex.Color.R + (LightIntensivity / distance) / 15),
                    (int)Math.Min(255, vertex.Color.G + (LightIntensivity / distance) / 15),
                    (int)Math.Min(255, vertex.Color.B + (LightIntensivity / distance) / 15));
        }
    }
    public void UpdateDepthMap(IEnumerable<Primitive> primitives)
    {
        Rasterizer.ComputeVisibleVertices(primitives);
    }
}




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



صورة




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



الظلال المحسنة
public class ShadowMappingShader : IShader
{
    public Enviroment Enviroment { get; set; }
    public Rasterizer Rasterizer { get; set; }
    public Camera Camera => Rasterizer.Camera;
    public Pivot Pivot => Camera.Pivot;
    public Vertex[] ZBuffer => Rasterizer.ZBuffer;
    public float LightIntensivity { get; set; }

    public ShadowMappingShader(Enviroment enviroment, Rasterizer rasterizer, float lightIntensivity)
    {
        Enviroment = enviroment;
        LightIntensivity = lightIntensivity;
        Rasterizer = rasterizer;
        //     ,      
        //  /         
        Camera.OnRotate += () => UpdateDepthMap(Enviroment.Primitives);
        Camera.OnMove += () => UpdateDepthMap(Enviroment.Primitives);
        Enviroment.OnChange += () => UpdateDepthMap(Enviroment.Primitives);
        UpdateVisible(Enviroment.Primitives);
    }
    public void ComputeShader(Vertex vertex, Camera camera)
    {
        //   
        var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);
        //  
        var lghDir = Pivot.Center - gPos;
        var distance = lghDir.Length();
        var local = Pivot.ToLocalCoords(gPos);
        var proectToLight = Camera.ScreenProection(local).ToPoint();
        if (proectToLight.X >= 0 && proectToLight.X < Camera.ScreenWidth && proectToLight.Y >= 0
            && proectToLight.Y < Camera.ScreenHeight)
        {
            int index = proectToLight.Y * Camera.ScreenWidth + proectToLight.X;
            var n = Vector3.Normalize(vertex.Normal);
            var ld = Vector3.Normalize(lghDir);
            //  
            float bias = (float)Math.Max(10 * (1.0 - VectorMath.Cross(n, ld)), 0.05);
            if (ZBuffer[index] == null || ZBuffer[index].Position.Z + bias >= local.Z)
            {
                vertex.Color = Color.FromArgb(vertex.Color.A,
                    (int)Math.Min(255, vertex.Color.R + LightIntensivity / distance),
                    (int)Math.Min(255, vertex.Color.G + LightIntensivity / distance),
                    (int)Math.Min(255, vertex.Color.B + LightIntensivity / distance));
            }
        }
        else
        {
            vertex.Color = Color.FromArgb(vertex.Color.A,
                    (int)Math.Min(255, vertex.Color.R + (LightIntensivity / distance) / 15),
                    (int)Math.Min(255, vertex.Color.G + (LightIntensivity / distance) / 15),
                    (int)Math.Min(255, vertex.Color.B + (LightIntensivity / distance) / 15));
        }
    }
    public void UpdateDepthMap(IEnumerable<Primitive> primitives)
    {
        Rasterizer.ComputeVisibleVertices(primitives);
    }
}


image




علاوة



, , 3 . , .



image






:



            float angle = (float)Math.PI / 90;
            var shader = (preparer.Shaders[0] as PhongModelShader);
            for (int i = 0; i < 180; i+=2)
            {
                shader.Lights[0] = = new Light()
                    {
                        Pos = shader.Lights[0].Pos.Rotate(angle , Axis.X) ,
                        Intensivity = shader.Lights[0].Intensivity
                    };
                Draw();
            }


image



:



  • : 220 .

  • : 1920x1080.

  • : Phong model shader

  • : cpu — core i7 4790, 8 gb ram



FPS 1-2 /. realtime. , , .. cpu.



خاتمة



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



All Articles