الإسقاط في الـ3D

كتبه بركات يوم الأحد, 11 كانون الأول 2016

تحدثنا في المقال السابق عن التحويلات الخطية والتآلفية الآساسية كالتحريك والتدوير والتكبير وغيرها من التحويلات، سنتحدث في هذا المقال عن الإسقاط ومتطلباته والذي يمثل آخر العمليات الهندسية، الإسقاط في سياقنا هذا هو نقل الجسم من $\mathbb{R}^3$ (نموذج ثلاثي الأبعاد) إلى $\mathbb{R}^2$ (صورة ثنائية الأبعاد)، فعندما تنظر لجسم ثلاثي الأبعاد خلال الشاشة، فإنك تنظر لإسقاطه عبر الشاشة الثنائية الأبعاد، هناك نوعين رئيسيين للإسقاط: الإسقاط العمودي والإسقاط المنظوري، سنتحدث في البداية عنهما كمفاهيم عامة، وبعدها سنتحدث عن كيف نستخدمها في الواجهات الرسومية مثل OpenGL.

الإسقاط العمودي

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

يمكننا إسقاط النقطة $p = (x, y, z)$ عمودياً على المستوى المحصور في $x$ و $y$ عند $z = 0$ وذلك بالإبقاء على $x$ و $y$ كما هي وجعل $z = 0$ لنحصل على النقطة $p' = (x, y, 0)$ (على فرض أن اتجاه الرؤية $-z$):

Image

وذلك يمكن عمله باستخدام المصفوفة:

$$\mathbf{M} = \begin{bmatrix} 1 & 0 & 0\\ 0 & 1 & 0\\ 0 & 0 & 0 \end{bmatrix}$$

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

الإسقاط المنظوري

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

في الإسقاط المنظوري نحتاج لنقطة تمثل البؤرة أو العين وسنرمز لها $e$ ونجعلها عند $e = (0, 0, 0)$، ونحتاج لمستوى يبعد عن النقطة مسافة $d$ في الإتجاه السالب من $z$ ليمثل لنا سطح الإسقاط، بعد السطح عن العين يتحكم في مجال الرؤية ثم حجم الأجسام عند إسقاطها:

Image

نحن بحاجة لاشتقاق قيمة $x'$ و $y'$ والتي تمثل قيم $x$ و $y$ بعد الإسقاط، لو دققت النظر ستجد أن هناك مثلثين متشابهين رأس أحدهما $p'$ وأحد رؤس الآخر $p$، والنسبة بين هذين المثلثين متساوية، أي أن النسبة بين ارتفاع المثلث الأول $y'$ وعرضه $d$ تساوي النسبة بين ارتفاع المثلث الثاني $y$ وعرضه $z$، بمعنى أن $y'/d=y/z$، يمكننا حل المعادلة بالنسبة لـ$y'$ لنحصل على قيمتها، ونفس الأمر مع $x'$:

$$\begin{align} x' = -d\frac{x}{z} \\ y' = -d\frac{y}{z} \\ \end{align}$$

ستلاحظ أننا قسمنا على $z$ وهي ماتجعل الأجسام الأبعد تبدو أصغر، الشيء الملاحظ من المعادلتين السابقة هو أن كل مانحتاجه هو قسمة قيم النقطتين $x$ و $y$ على الحد $-d/z$، لكن كيف نعبر عن هذا باستخدام مصفوفة؟

ربما تتذكر من المقالة السابقة عندما تحدثنا عن التحريك والإحداثيات المتجانسة، يمكننا الاستفادة من الإحداثيات المجانسة هنا في الإسقاط المنظوري، حيث سنستفيد من قيمة $w$ بحيث نجعلها $-d/z$ كي تقسم على باقي المركّبات عند مجانسة النقطة لاحقاً، ويمكننا عمل هذا باستخدام المصفوفة:

$$\mathbf{M} = \begin{bmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & -1/d & 0 \end{bmatrix}$$

الآن عند ضرب النقطة $(x, y, z, 1)$ بتلك المصفوفة ستنتج لنا النقطة $(x, y, z, -z/d)$، الآن عند مجانستها ستنتج لنا النقطة $(-dx/z, -dy/z, -d, 1)$ وهي تماماً مانريد.

الإسقاط في الواجهات الرسومية

التحويلات الهندسية كما أشرنا سابقاً يقوم بها مظلل النقاط vertex shader، والذي تمر عليه كل نقاط النموذج المراد تصييره ليجري تحويلات تحددها أنت على تلك النقاط وتخرج منه النقطة $(x, y, z, w)$ لتبدأ عملية القسمة المنظورية perspective division (مهما كان نوع الإسقاط المستخدم) التي يقوم المعالج الرسومي فيها بقسمة قيم المركبات على قيمة $w$ ليحول النقطة إلى نقطة متجانسة $(x/w, y/w, z/w, 1)$، ثم تبدأ بعدها عملية القطع والتي يقوم المعالج الرسومي فيها بحذف الأجزاء التي لايمكن رؤيتها من النموذج قبل اكمال باقي خطوات التصيير كالبكسلة.

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

عندما ننشيء مصفوفة الإسقاط فإننا بحاجة لجعل القيم المرئية في هذا المدى، طبعاً المعالج الرسومي الذي يدعم عدة واجهات تصيير قد يوحد الشكل داخلياً إلى مكعب أو شبه مكعب، يسمى الفضاء أو الإحداثي الثلاثي الأبعاد المحصور في المكعب أو شبه المكعب بإحداثي الجهاز الطبيعي Normalized Device Coordinate أو اختصار NDC والذي يسمى أيضاً بفضاء منفذ العرض viewport coordinate، صحيح أن الإسقاط سيحول النموذج من البعد الثالث إلى إسقاطه في البعد الثاني لعرضه على الشاشة، لذا قد تفترض أن قيمة $z$ لم يعد لها فائدة بعد الإسقاط، إلا أننا لن نتخلص منها لأن لها استخدام آخر في عملية اخبار العمق.

الخطوة التي بعد القطع تسمى اختبار العمق depth test والتي تستخدم لتحديد أي النقاط أقرب للشاشة وهي السبب الذي يجعلنا بحاجة لقيمة $z$ حتى بعد الإسقاط، هذه الخطوة ثابتة وغير قابلة للبرمجة لكن يمكن تفعيلها وتعطيلها والتحكم بها ببعض إعداداتها، حيث يحجز المعالج الرسومي جزء من الذاكرة تسمى ذاكرة الألوان color buffer والتي تمثل لون البكسل عند النقطة $(x, y)$ بعد تمريره على مظلل البكسلات fragment shader (أو pixel shader كما يسمى في Direct3D)، ويحجز أيضاً ذاكرة أخرى تسمى ذاكرة العمق depth buffer -في حال تفعيل اختبار العمق- ليخزن بها المعالج الرسومي قيمة $z$ والتي تمثل معلومات العمق عند النقطة $(x, y)$، فإذا أراد المعالج الرسومي رسم نموذج ما، فإنه يتحقق أولاً من قيمة العمق، فإذا كان النموذج مقابل مباشرة للكاميرا ولايوجد شيء بينه وبين الكاميرا، فإن قيمة العمق ستكون أصغر -أو أكبر، حسب إعدادات عملية مقارنة العمق- من القيمة الحالية في الذاكرة، لذا سيكتب اللون في ذاكرة الألوان ويكتب العمق الجديد في ذاكرة العمق، أما لو كان العمق أكبر -أو أصغر- من القيمة الحالية، فهذا يعني أن هناك نموذج أو جزء منه أقرب للكاميرا من النموذج الحالي، لذا لن يخزن شيء في أي منهما، عملية مقارنة قيم العمق يمكن التحكم فيها في OpenGL باستخدام الدالة glDepthFunc(GLenum func) والتي قيمتها الإفتراضية GL_LESS بمعنى أن البكسل سيُكتب في ذاكرة الألوان إذا كانت قيمة ذاكرة العمق أصغر من القيمة الحالية، معظم المعالجات الرسومية تستخدم اختبار العمق المبكر early-z test حيث تجري اختبار العمق أولاً قبل تنفيذ مظلل البكسلات الذي قد يجري حسابات مكلفة لا داعي لإجراها على نقطة لن ترسم.

أنتبه أنه حتى لو كنت ترسم الأجسام مرتبة حسب قربها من الكاميرا، فأجزاء من نفس النموذج قد تكون فوق بعضها البعض كحالة الكرة مثلاً، فنصف الكرة دوماً غير مرئي ويحجبه النصف الآخر، أيضاً أنتبه أن اختبار العمق عادةً يكون معطّل ويلزم تفعيله أولاً، في OpenGL تحتاج أن تستخدم glEnable(GL_DEPTH_TEST) قبل التصيير، اختبار العمق اختياري لأن ليس الكل بحاجة لاختبار العمق كما في حالة رسم الأشكال الثنائية الأبعاد، أيضاً ذاكرة اللون والعمق إن كان مفعّل عادة يلزم تنظيفها يدوياً قبل تصيير المشهد أو الإطار، في OpenGL تستخدم glClear(GL_COLOR_BUFFER | GL_DEPTH_BUFFER)، طبعاً تختلف الطريقة بين واجهة تصيير وأخرى، إلا أن المفاهيم والمصطلحات غالباً متشابهة.

سنبدأ في الحديث الفضاء المحلي وننتقل خطوة بخطوة حتى ننتهي من مهام مظلل النقاط.

الفضاء المحلي وفضاء العالم

عندما تتم نمذجة النموذج الثلاثي الأبعاد وتصديره باستخدام برامج النمذجة مثل Blender أو 3D Max، فعادةً يُصمم في إحداثي يسمى الإحداثي المحلي أو إحداثي النموذج أو إحداثي الكائن والذي سنرمز له $\mathbf{v}_{l}$، حيث تكون قيم النقاط نسبة لنقطة مرجعية تسمى نقطة الأصل، وذلك كي نتمكن مو وضع نفس الكائن في مواقع مختلفة من العالم (المشهد) دون الحاجة للعودة لبرنامج النمذجة وإعادة تصدير النموذج، نقطة الأصل هذه لايشترط أن تكون أحد نقاط النموذج، وقد تكون منتصف النموذج أو أسفله حسب اختيار المصمم، لو كان النموذج نموذج مبنى واخترت نقطة الأصل لتكون منتصفه، فلو وضعت المبنى على الأرض في إحداثي العالم $(0, 0, 0)$ فسيبدو نصف المبنى غائص في الأرض، بينما لو حددها بأسفل المبنى فسيبدو على سطح الأرض.

عندما تقوم بإجراء التحويلات الخطية والتآلفية التي تحدثنا عنها في المقال السابق، فإنك ستجريها على تلك النقاط وهي في الإحداثي المحلي وذلك لنقلها لإحداثي العالم عن طريق ضرب النقطة المحلية بمصفوفة النموذج التي تحتوي على التحريكات والتي ستنقل النموذج لإحداثي العالم $\mathbf{v}_{w} = \mathbf{M}_{w} \cdot \mathbf{v}_{l}$، طبعاً للعودة من إحداثي العالم إلى الإحداثي المحلي فإننا نضرب بمعكوس المصفوفة $\mathbf{v}_l = \mathbf{M}_{w}^{-1} \cdot \mathbf{v}_{w}$.

فضاء الرؤية

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

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

هناك عدة طرق لإنشاء فضاء الرؤية، ربما أشهرها الدالة التي ترمز لها عادة بـLookAt(Vec3 eye, Vec3 target, Vec3 up)، حيث تأخذ ثلاثة متغيرات، الأول يمثل موقع الكاميرا أو العين في العالم، والثاني يمثل الهدف الذي تنظر إليه الكاميرا، والثالث يمثل المتجه الذي يشير للأعلى أو أي اتجاه آخر، يمكننا باستخدام تلك المتغيرات بناء الإحداثيات الثلاثة لأعمدة المصفوفة، حيث سنشير للإحداثي الأول ولعمود المصفوفة الأول $\mathbf{x}$، و $\mathbf{y}$ للثاني و $\mathbf{z}$ للثالث، وباستخدام الضرب المتجهي (راجع المقال السابق):

Vec3 z = -(target - eye).Normalize();  // أو (eye - target).Normalize(), عمود المصفوفة الثالث
Vec3 x = (up.Cross(z)).Normalize();    // عمود المصفوفة الأول
Vec3 y = z.Cross(x);                   // عمود المصفوفة الثاني

انتبه لأن الإحداثي الثالث $\mathbf{z}$ يشير للجهة السالبة، الآن أصبح لدينا مصفوفة دوران الكاميرا في العالم:

$$\mathbf{R} = \begin{bmatrix} x_0 & y_0 & z_0 & 0 \\ x_1 & y_1 & z_1 & 0 \\ x_2 & y_2 & z_2 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}$$

الآن لتمثيل موقها يمكننا استخدام مصفوفة التحريك:

$$\mathbf{T} = \begin{bmatrix} 1 & 0 & 0 & e_0 \\ 0 & 1 & 0 & e_1 \\ 0 & 0 & 1 & e_2 \\ 0 & 0 & 0 & 1 \end{bmatrix}$$

هذا التحويل تحويل TRS عادي مثل الذي في المقال السابق دون التكبير (يمكنك اعتباره مصفوفة الوحدة)، الآن لتمثيل موقع الكاميرا نضرب تلك المصفوفات ببعض، لكن نحن نريد معكوس هذا التحويل، $(\mathbf{TR})^{-1} = \mathbf{R}^{-1}\mathbf{T}^{-1}$، ومن المقال السابق نعرف أن معكوس مصفوفة الدوران هو منقول المصفوفة $\mathbf{R}^{-1}=\mathbf{R}^{T}$ ومعكوس التحريك هو سالب التحريك، إذاً: $$\begin{align} (\mathbf{TR})^{-1} &= \mathbf{R}^{T} \mathbf{T}^{-1} \\ &= \begin{bmatrix} x_0 & x_1 & x_2 & 0 \\ y_0 & y_1 & y_2 & 0 \\ z_0 & z_1 & z_2 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 & -e_0 \\ 0 & 1 & 0 & -e_1 \\ 0 & 0 & 1 & -e_2 \\ 0 & 0 & 0 & 1 \end{bmatrix} \\ &= \begin{bmatrix} x_0 & x_1 & x_2 & -e_0 x_0-e_1 x_1-e_2 x_2 \\ y_0 & y_1 & y_2 & -e_0 y_0-e_1 y_1-e_2 y_2 \\ z_0 & z_1 & z_2 & -e_0 z_0-e_1 z_1-e_2 z_2 \\ 0 & 0 & 0 & 1 \end{bmatrix} \\ &= \begin{bmatrix} x_0 & x_1 & x_2 & -(\mathbf{e} \cdot \mathbf{x}) \\ y_0 & y_1 & y_2 & -(\mathbf{e} \cdot \mathbf{y}) \\ z_0 & z_1 & z_2 & -(\mathbf{e} \cdot \mathbf{z}) \\ 0 & 0 & 0 & 1 \end{bmatrix} \end{align}$$

المصفوفة الأخيرة تمثل مصفوفة الكاميرا النهائية التي سنقل لنا النقاط من فضاء العالم إلى فضاء الرؤية $\mathbf{v}_v = \mathbf{M} \mathbf{v}_w$، هكذا أصبح بإمكاننا التحكم في زاوية الرؤية (الكاميرا)، نضع النقاط بأي مكان نريد ونحدد زاوية الرؤية كيفما نريد ثم نضرب بتلك المصفوفة لتضع لنا النقاط بمحاذاة $-z$.

مصفوفة الرؤية أو الكاميرا التي استنتجناها تمثل إحداثي يد يمنى right-handed coordinate حيث تشير $+z$ لنفس الإتجاه الذي ستشير له الأصبع الوسطى في اليد اليمنى لو أشرت بالإبهام للإحداثي الأول $x$ و السبابة للإحداثي الثاني $y$، في حالتنا هذه، فإن قيمة $z$ ستصبح $-z$ كما سترى لاحقاً في الإسقاط.

إذا كنت تريد إحداثي يد يسرى left-handed coordinate فيمكنك تعديل k = (eye - target).Normalize() إلى z = (target - eye).Normalize() والتعامل مع $z$ كما هي دون عكس الإشارة، وضع هذا في الإعتبار عند اشتقاق مصفوفة الإسقاط.

الإسقاط العمودي في الواجهات الرسومية

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

في البداية سنتحدث عن الإسقاط العمودي، فلو أردنا إسقاط الجزء المحصور بين المكعب المحصور بين النقاط $(l, t, n)$ و $(r, b, f)$ في أي مكان في العالم (في المتراجحة الأخيرة $z$ سالبة لأن القيمة القادمة ليست فعلياً $z$، بل هي $z$ بعد ضربها بمصفوفة الرؤية والتي جعلت $z$ تشير للإتجاه السالب كما أشرنا سابقاً):

$$\begin{align} l \le x \le r \\ b \le y \le t \\ n \le -z \le f \\ \end{align}$$

وتحويله لمكعب وحدة محصور بين $(-1, -1, -1)$ و $(1, 1, 1)$.

Image

نريد تحويل تلك المتراجحات إلى متراجحات محصورة بين $1$ و $-1$، مثلاً $-1 \le x' \le 1$، سأوضح $x$ والباقي نفس المبدأ، نبدأ بالمتراجحة (ابحث عن حل المتراجحات إن كنت لاتعرفها):

$$l \le x \le r$$

نطرح $l$ منها لجعل اليسار صفر:

$$0 \le x - l \le r - l$$

الآن نريد جعل اليمين $1$ بالقسمة على $r - l$:

$$0 \le \frac{x - l}{r - l} \le 1$$

الآن المتراجحة بـ$2$:

$$0 \le \frac{2(x - l)}{r - l} \le 2$$

ثم نطرح $1$ منها:

$$-1 \le \frac{2(x - l)}{r - l} - 1 \le 1$$

الآن حصلنا على مانريد، حيث أن $x' = 2(x - l)/(r - l) - 1$، لكن نريد إعادة كتابة هذا الحد كمعادلة خطية $a_0 x + a_1 y + a_2 z + a_3$ بحيث تكون المتغير $x$ مضروبة في حد ثابت وذلك كي نتمكن من وضعها في المصفوفة، لذا:

$$\begin{align*} \frac{2(x - l)}{r - l} - 1 &= \frac{2x - 2l}{r - l} - \frac{r - l}{r - l}\\ &= \frac{2x - 2l - r + l}{r - l} \\ &= \frac{2x - l - r}{r - l} \\ &= \frac{2x}{r - l} - \frac{r + l}{r - l} \\ \end{align*}$$

هكذا نجد أن الصيغة الخطية:

$$x' = \frac{2}{r - l}\,x - \frac{r + l}{r - l}$$

يمكن اشتقاق $y'$ و $z'$ بنفس الطريقة مع الأخذ في الإعتبار السالب في $z$:

$$\begin{align*} y' &= \frac{2}{t - b}\,y - \frac{t + b}{t - b}\\ z' &= \frac{-2}{f - n}\,z - \frac{f + n}{f - n}\\ \end{align*}$$

لنكتبها كمصفوفة:

$$\mathbf{M} = \begin{bmatrix} \frac{2}{r - l} & 0 & 0 & -\frac{r + l}{r - l}\\ 0 & \frac{2}{t - b} & 0 & -\frac{t + b}{t - b}\\ 0 & 0 & -\frac{2}{f - n} & -\frac{f + n}{f - n}\\ 0 & 0 & 0 & 1 \end{bmatrix}$$

لو دققت في المصفوفة ستجد أنها مصفوفة $\mathbf{TRS}$ عادية توسّط المكعب وتكبره لمكعب وحده معكوس على محور $z$، تغيير محور الإسقاط يتطلب إعادة الإشتقاق وتعديل مصفوفة الرؤية، واختلاف أبعاد المكعب يتطلب تعديل المتراجحات تلك، لكن طريقة الإشتقاق هي نفسها.

هناك حالة خاصة شائعة من هذه المصفوفة، وهي عندما يكون مربع الإسقاط تماماً في المنتصف بحيث يقطع المحور $z$ منتصف سطح الإسقاط، في هذه الحالة ستكون قيمة $r = -l$ وكذلك $t = -b$، يمكننا اعتبار $w$ تمثل عرض سطح الإسقاط وجعل $h$ تمثل ارتفاع سطح الإسقاط، في هذه الحالة فإن:

$$\begin{align*} -\frac{w}{2} \le x \le \frac{w}{2} \Leftrightarrow -1 \le \frac{2x}{w} \le 1 \Rightarrow x' = \frac{2x}{w}\\ -\frac{h}{2} \le y \le \frac{h}{2} \Leftrightarrow -1 \le \frac{2y}{h} \le 1 \Rightarrow y' = \frac{2y}{h} \end{align*}$$

إذن، ستصبح مصفوفة الإسقاط في هذه الحالة:

$$\mathbf{M} = \begin{bmatrix} \frac{2}{w} & 0 & 0 & 0\\ 0 & \frac{2}{h} & 0 & 0\\ 0 & 0 & -\frac{2}{f - n} & -\frac{f + n}{f - n}\\ 0 & 0 & 0 & 1 \end{bmatrix}$$

قيمة $z'$ لن تتغير لأنها مستقلة عن $x$ وعن $y$.

الإسقاط المنظوري في الواجهات الرسومية

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

Image

حيث سنسقط الصورة على المستوى القريب عند $-z = n$ عن طريق مد شعاع من النقطة إلى مركز الكاميرة الذي ثبتناه في $(0, 0, 0)$ ونرى أين سيقطع الشعاع مع المستوى القريب، يشبه كثيراً ماعملناه في بداية المقال:

Image

لنبدأ مع $y'$، ستجد أن هناك مثلثين متناسبين عندما ننظر لجانب الهرم:

$$\frac{y'}{n} = \frac{y}{-z}$$

بإعادة الترتيب ستجد أن $y' = (yn)/-z$، من الصورة نعرف أن $y'$ محصورة بين $b$ و $t$:

$$b \le \frac{yn}{-z} \le t$$

بعد عدة لخطوات سنجد أن:

$$-1 \le \frac{2yn + 2zb}{-z(t - b)} - 1 \le 1$$

الآن نريد إعادة كتابة هذا الحد بصيغة خطية نضعها في المصفوفة:

$$\begin{align*} \frac{2yn + 2zb}{-z(t - b)} - 1 &= \frac{2yn + 2zb}{-z(t - b)} - \frac{-z(t - b)}{-z(t - b)} \\ & \cdots \\ &= \frac{2yn}{-z(t - b)} - \frac{b + t}{t - b} \end{align*}$$

بالمعادلة الأخيرة حصلنا على صيغة قريبة من الصيغة الخطية، عدى الحد المقسوم على $-z$ والذي يمكننا التعامل معه بالإستفادة من القسمة المنظورية عن طريق إعادة كتابة الحد إلى:

$$\begin{align*} y' &= \frac{2yn}{-z(t - b)} - \frac{b + t}{t - b} \\ &= (\frac{2yn}{t - b} + \frac{z(b + t)}{t - b}) / -z \end{align*}$$

الآن لو عملنا نفس الخطوات مع $x'$ سنجد أن:

$$\begin{align*} x' &= \frac{2xn}{-z(r - l)} - \frac{l + r}{r - l} \\ &= (\frac{2xn}{r - l} + \frac{z(l + r)}{r - l}) / -z \end{align*}$$

الآن حصلنا على معظم صفوف المصفوفة عدى الصف الثالث الخاص:

$$\mathbf{M} = \begin{bmatrix} \frac{2n}{r - l} & 0 & \frac{r + l}{r - l} & 0\\ 0 & \frac{2n}{t - b} & \frac{t + b}{t - b} & 0\\ 0 & 0 & A & B\\ 0 & 0 & -1 & 0 \end{bmatrix}$$

وضعنا أول عمودين في الصف الثالث أصفار لأن $z$ لا تتأثر بقيمة $x$ ولا $y$ الآن لو ضربنا المتجه $(x, y, z, 1)$ بالمصفوفة سنحصل على المتجه:

$$\begin{bmatrix} \frac{2xn}{r-l} + \frac{z(l+r)}{r-l} \\ \frac{2yn}{t-b} + \frac{z(b+t)}{t-b} \\ A z + B \\ -z \end{bmatrix}$$

وعند القيام بالقسمة المنظورية والقسمة على $w$ سنحصل على المعادلات التي كتبناها سابقاً:

$$\begin{bmatrix} (\frac{2xn}{r-l} + \frac{z(l+r)}{r-l})/-z \\ (\frac{2yn}{t-b} + \frac{z(b+t)}{t-b})/-z \\ (A z + B)/-z \\ 1 \end{bmatrix}$$

بالنسبة إلى $z'$، فنعلم أن $z' = -1$ عندما تكون قيمة $-z = n$، وقيمتها $z' = 1$ عندما تكون $-z = f$، هكذا يمكننا حل المعادلة $z' = (A z + B)/-z$ للحصول على قيم $A$ و $B$:

$$\begin{align*} \frac{A(-n) + B}{-(-n)} &= -1 &&\Leftrightarrow -nA + B = -n\\ \frac{A(-f) + B}{-(-f)} &= 1 &&\Leftrightarrow -fA + B = f\\ \end{align*}$$

بإعادة الترتيب سنجد أن $B = -n + An$، وبتعويضها في المعادلة الثانية سنحصل على $Af + (n - An) = -f$، ضع $A$ في طرف:

$$\begin{align*} -Af + An - n &= f \\ A(n - f) &= f + n \\ A &= \frac{f + n}{n - f} \\ &= -\frac{f + n}{f - n} \end{align*}$$

هكذا نكون قد حصلنا على قيمة $A$، يمكننا الحصول على قيمة $B$ بتعويض $A$ في أيّ من المعادلتين، لنعوض في الأولى:

$$\begin{align*} -f(-\frac{f + n}{f - n}) + B &= f \\ B &= f - \frac{f^2 + fn}{f - n} \\ &= \frac{f^2 - fn - f^2 - fn}{f -n} \\ &= \frac{-2fn}{f - n} \end{align*}$$

هكذا تصبح مصفوفة الإسقاط النهائية:

$$\mathbf{M} = \begin{bmatrix} \frac{2n}{r - l} & 0 & \frac{r + l}{r - l} & 0\\ 0 & \frac{2n}{t - b} & \frac{t + b}{t - b} & 0\\ 0 & 0 & -\frac{f + n}{f - n} & \frac{-2fn}{f - n}\\ 0 & 0 & -1 & 0 \end{bmatrix}$$

أيضاً هنا، فلو كان مسطح الإسقاط في المنتصف، فيمكننا استبدال $r- l$ بعرض السطح $w$ وجعل $h$ تمثل ارتفاعه:

$$\begin{align*} -\frac{w}{2} \le \frac{nx}{-z} \le \frac{w}{2} \Leftrightarrow -1 \le \frac{2nx}{-zw} \le 1 \Rightarrow x' = \frac{2nx}{-zw}\\ -\frac{h}{2} \le \frac{ny}{-z} \le \frac{h}{2} \Leftrightarrow -1 \le \frac{2ny}{-zh} \le 1 \Rightarrow y' = \frac{2ny}{-zh} \end{align*}$$

لتصبح المصفوفة الجديدة:

$$\mathbf{M} = \begin{bmatrix} \frac{2n}{w} & 0 & 0 & 0\\ 0 & \frac{2n}{h} & 0 & 0\\ 0 & 0 & -\frac{f + n}{f - n} & \frac{-2fn}{f - n}\\ 0 & 0 & -1 & 0 \end{bmatrix}$$

عند التعامل مع الإسقاط المنظوري، فالكثير يفضلون تحديد مع مجال الرؤية field of view بالزاوية بدلاً من استخدام القيم $(l, t, n, r, b, f)$، نظراً لأن مجال الرؤية يؤثر كثيراً في الصورة الناتجة، انظر لهذه الصورة من لعبة Battlefield 1 وتأثير زاوية مجال الرؤية على المشهد (معظم الألعاب وبرامج الـ3D تسمح للمستخدم بالتحكم وتعديل مجال الرؤية):

Image

لننظر لهرم الإسقاط من الجانب:

Image

بالنسبة لـ$y'$، فنحن نريد علاقة تربط بين الزاوية $fov$ التي تمثل مجال الرؤية العمودي، و $t$ و $b$ وكذلك $n$، يمكن التوصل لهذه العلاقة باستخدام العلاقات المثلثية نعرف أن ارتفاع المثلث هو $h/2$ وقاعدته $n$، ونعلم أن $\tan \theta$ تمثل مقابل الزاوية على المجاور، قيمة $\theta$ تمثل نصف مجال الرؤية $\theta = fov/2$ إذن:

$$y' = \frac{2n}{h} = \frac{1}{\tan{\theta}}$$

عادةً ماتكون قيمة $\theta = 60^{\circ}$ والتي تقابل مجال الرؤية لعين الإنسان، قد تصغرها، لتحاكي منظار التصويب في بندقية القنص والتي سينتج عنها تقليل مجال الرؤية لكن تقريب الأشياء التي ننظر لها مباشرة.

يمكننا تعويض قيمة $y'$ مباشرة في مصفوفة الإسقاط السابقة، الآن بقي قيمة $x'$، يمكننا استخدام زاوية أخرى تمثل مجال الرؤية الأفقي، لكن عادةً ماتستخدم النسبة aspect ratio والتي تمثل النسبة بين العرض $w$ والطول $h$ بدلاً من تحديد الزاوية، النسبة هذه يتم تحديدها عادة من حجم النافذة أو حجم الشاشة.

الآن لاشتقاق $x'$، فالنسبة بين العرض والطول تساوي $aspect = w/h$، يمكننا تبديل القيمة $2n / w$ في المصفوفة باستخدام $aspect$ لتصبح:

$$\frac{2n}{w} = \frac{2n}{aspect \times h}$$

ونعلم أن الحد $2n / h = 1/\tan{\theta}$، إذاً:

$$x' = \frac{2n}{w} = \frac{1}{aspect \times \tan{\theta}}$$

يمكننا تعويض تلك القيمة في مصفوفة الإسقاط السابقة لتصبح المصفوفة النهائية:

$$\mathbf{M} = \begin{bmatrix} \frac{1}{aspect\,\times\,\tan{\theta}} & 0 & 0 & 0\\ 0 & \frac{1}{\tan{\theta}} & 0 & 0\\ 0 & 0 & -\frac{f + n}{f - n} & \frac{-2fn}{f - n}\\ 0 & 0 & -1 & 0 \end{bmatrix}$$

البعض يستخدم $\cot{\theta} = 1/\tan{\theta}$، لكنني فضلت عدم استخدامها لأن معظم مكتبات الرياضيات في لغات البرمجة لاتوفر إلا الدوال الأساسية منها $\tan$.

عند تحديد القيم مثل $w$ و $h$ و $r - l$ و $t - b$ و $\tan{\theta}$ أو أي قيمة نقسم عليها تأكد أنها ليست صفر، في حالتنا نحن نستخدم الـfloats أو الـdoubles، والتي عادةً ماتمثل بـIEEE-754 والتي تعرّف القسمة على الصفر بأنها تعطي $\pm\infty$ حسب إشارة البسط، أو NaN إذا كان البسط أيضاً صفر، لاتستخدم المقارنة لو أردت التحقق من هذه القيم، خصوصاً NaN لأن NaN $\ne$ NaN، عادةً ماتكون هناك دالة لتحديد نوع القيمة مثل std::isnan للتحقق من أن العدد $\pm\infty$ في C++، أو std::isinf للتحقق من NaN.

تحديد القيم $n$ و $f$

كما أشرنا سابقاً، فالقيم $n$ و $f$ تستخدم لتحديد الجزء المنظور من العالم ولاستخدامها مع $z'$ التي ستستخدم في اختبار العمق كي نستطيع تحديد أي الأجسام أقرب للكاميرا أو العين وتخفي ماورائها

قيمة $n \le -z \le f$ سنكمش بعد تحويلها بمصفوفة الإسقاط لتصبح $-1 \le z' \le 1$، هذا الإنكماش قد يكون له تبعات على دقّة اختبار العمق في حال كانت المسافة بين $n$ و $f$ كبيرة بحيث تحدث مشكلة الـz-fighting والتي تحدث للأجسام المقاربة -خصوصاً الأجسام البعيدة عند الإسقاط المنظوري- بحيث أنه بعد الإسقاط تكون الأجسام تقاربت لدرجة أن دقّة الكسرية لاختبار العمق لاتكفي لتحديد أي الجسمين أمام الآخر، ثم يحدث التشويه الظاهر في الصورة حيث تتسرب أجزاء من النموذج المحجوب وتظهر فوق النموذج الذي حجبه.

Image

لنجري تحليل بسيط لقيم الدالة التي تمثل معادلة $z'$ في الإسقاط العمودي:

$$z' = \frac{-2}{f - n}\,z - \frac{f + n}{f - n}$$

الملاحظ هنا أن الدالة خطية، هذه مجموعة قيم لـ$n$ و$f$، حيث أن الخط الأفقي يمثل قيمة $z$ والخط العمودي يمثل قيمة $z'$، ماهو خارج المستطيل الأحمر سيتم قطعه (في الرسم الأخير قيمة $-f = 1000$):

Image

Image

Image

Image

نلاحظ هنا أنه يمكنك اختيار أي قيمة للثوابت $n$ و $f$ في حالة الإسقاط العمودي طالما أن الفرق بينهما ليس فرق هائل، فلو كان الفرق كبير، فإن الفرق بين قيمتين $z$ متجاورتين سيعطي قيم $z'$ بينها فرق صغير جداً قد يحدث لنا أخطأ في اختبار العمق، لاحظ في الصورة الأخيرة أن الميل صغير وقيم $z'$ المتجاورة متقاربة كثيراً، لذا في الإسقاط العمودي فالمهم هو أن تكون المسافة بين $n$ و$f$ أقرب مايمكن.

بالنسبة للإسقاط المنظوري:

$$z' = \frac{2nf}{z(f - n)} + \frac{f + n}{f - n}$$

فهذه الدالة ليست خطية، بل كسرية بالشكل $f(x) = 1/x$، لو رسمناها مع عدة قيم:

Image

Image

Image

Image

الملاحظ هنا أن الإسقاط المنظوري هو الأكثر عرضة لمشكلة الـz-fighting، فمن تلك الرسومات البيانية نجد أن قيمة $n$ يجب أن تكون $n \gt 0$ ، فلو كانت قيمة $n \le 0$ فإنه سيتم قطع كل شيء بحيث لن ترى المشهد.

الملاحظة الأخرى أن الفارق بين قيم $z'$ ومن ثم الدقة عند $n$ تكون عالية ثم تتهاوى بسرعة كلما ابتعدت باتجاه $f$، وهذا مايجعل الأجسام البعيدة هي الأكثر تأثر بمشكلة الـz-fighting الناتجة عن الدقة الغير كافية ومن ثم أخطاء العمق، الملاحظ أيضاً أنه كلما ابتعدت $n$ عن مركز الكاميرا وكلما قربت $f$ إلى $n$ كلما تحسنت الدقة.

لاتوجد هنا قيم محددة للثوابت $n$ و $f$ تناسب كل التطبيقات، بل تحديدها يعتمد كثيراً على ماذا تريد عمله، لكن على الأقل يمكنك الأن معرفة السبب خلف الـz-fighting لو ظهر لك، ولديك تصور عن تأثير اختيار قيم $n$ و $f$ على دقة اختبار العمق.