تبسيط الرسومات ثلاثية الأبعاد: بناء محرك تصيير ثلاثي الأبعاد من الصفر في لوحة ثنائية الأبعاد

كتب بواسطة idriss douiri profile picture إدريس الدويري

11 min read

مكعب ثلاثي الأبعاد ملون يدور على خلفية فاتحة
مشاركة

بناء محرك تصيير ثلاثي الأبعاد من الصفر هو أحد تلك المشاريع التي تترك أثراً لا يُنسى؛ أن تنظر إلى كائن ثلاثي الأبعاد يدور على الشاشة، وأنت تعرف تماماً كيف وصلت كل بكسل إلى مكانها.

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

<canvas></canvas>

<script>
  const canvas = document.querySelector("canvas")
  const ctx = canvas.getContext("2d")

  // تحديد مساحة بنسبة 1:1
  canvas.width = 400
  canvas.height = 400
</script>

أولاً، دعونا نحدد نظام الإحداثيات لعالمنا ثلاثي الأبعاد.

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

إحداثيات الجهاز الموحدة (NDC)

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

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

تعيين مساحة NDC الجديدة هذه إلى مساحة الشاشة ثنائية الأبعاد النهائية أمر بسيط للغاية. نضرب ببساطة كل نقطة في نصف حجم اللوحة (Canvas) ونضيف نفس المقدار مرة أخرى لإزاحة المركز. لا تنسَ عكس المحور الصادي (y-axis) للانتقال من الأعلى إلى الأسفل!

xscreen=(xndc+1)×(width2)x_{screen} = (x_{ndc} + 1) \times \left(\frac{width}{2}\right)

yscreen=(1yndc)×(height2)y_{screen} = (1 - y_{ndc}) \times \left(\frac{height}{2}\right)

دعونا نختبر ذلك:

  • إذا كانت x هي -1 تماماً، فإن (x + 1) تساوي 0 * w/2، وهي 0 (الحافة اليسرى).
  • إذا كانت x هي 0، فإن (x + 1) تساوي 1 * w/2، وهو منتصف اللوحة.
  • إذا كانت x هي 1، فإن (x + 1) تساوي 2 * w/2، وهو ما يعادل العرض الكامل للوحة (الحافة اليمنى).

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

class Vector3 {
  constructor(x, y, z) {
    this.x = x
    this.y = y
    this.z = z
  }
}

const cubeVertices = [
  new Vector3(-0.5, -0.5,  0.5), // 0 أسفل-يسار الأمام
  new Vector3( 0.5, -0.5,  0.5), // 1 أسفل-يمين الأمام
  new Vector3( 0.5,  0.5,  0.5), // 2 أعلى-يمين الأمام
  new Vector3(-0.5,  0.5,  0.5), // 3 أعلى-يسار الأمام

  new Vector3(-0.5, -0.5, -0.5), // 4 أسفل-يسار الخلف
  new Vector3( 0.5, -0.5, -0.5), // 5 أسفل-يمين الخلف
  new Vector3( 0.5,  0.5, -0.5), // 6 أعلى-يمين الخلف
  new Vector3(-0.5,  0.5, -0.5), // 7 أعلى-يسار الخلف
];

يمكنك تصور هذه الرؤوس ثلاثية الأبعاد على النحو التالي:

img of 3d cube with labels on each vertex

الإسقاط المتعامد (Orthographic Projection)

نحتاج الآن إلى طريقة لإسقاط نقاطنا ثلاثية الأبعاد على لوحتنا ثنائية الأبعاد المسطحة.

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

نظراً لأننا حددنا الإحداثيات ثلاثية الأبعاد لمكعبنا باستخدام قيم تتراوح بين -1 و 1، يمكننا التعامل معها تماماً مثل قيم NDC! دعونا نكتب دالة إسقاط تتجاهل z ببساطة وتعين قيم x و y (من -1 إلى 1) مباشرة إلى بكسلات الشاشة باستخدام الصيغة التي تعلمناها للتو:

function project(p) {
  const x = (p.x + 1) * canvas.width / 2
  const y = (1 - p.y) * canvas.height / 2
  return {x, y}
}

ثم، داخل دالة التصيير (render function)، لنأخذ كل رأس (vertex)، ونسقطه، ونرسمه كنقطة:

function render() {
 ctx.clearRect(0, 0, canvas.width, canvas.height)

 for (const vertex of cubeVertices) {
  const {x, y} = project(vertex)
  drawPoint(x, y)
 }

 requestAnimationFrame(render)
}

requestAnimationFrame(render)

function drawPoint(x, y) {
 ctx.beginPath()
 ctx.arc(x, y, 2, 0, Math.PI * 2)
 ctx.fill()
}
output of orthographic projection cube points
نتيجة الإسقاط المتعامد الخاص بنا.

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

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

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

مصفوفة الدوران (Rotation Matrix)

لتدوير نقاطنا ثلاثية الأبعاد، النهج الرياضي القياسي هو ضرب كل نقطة في مصفوفة دوران 3x3. هناك مصفوفة محددة لكل محور تريد الدوران حوله (X أو Y أو Z). نتيجة هذا الضرب تعطينا الإحداثي ثلاثي الأبعاد الجديد والمدور للنقطة.

إليك مصفوفات الدوران الثلاث:

Rx(θ)=[1000cosθsinθ0sinθcosθ]Ry(θ)=[cosθ0sinθ010sinθ0cosθ]Rz(θ)=[cosθsinθ0sinθcosθ0001]\begin{aligned} R_x(\theta) &= \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta \\ 0 & \sin\theta & \cos\theta \end{bmatrix} \\[10pt] R_y(\theta) &= \begin{bmatrix} \cos\theta & 0 & \sin\theta \\ 0 & 1 & 0 \\ -\sin\theta & 0 & \cos\theta \end{bmatrix} \\[10pt] R_z(\theta) &= \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix} \end{aligned}

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

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

matrix multiplication explanation

الآن، دعونا نضيف منطق الدوران إلى فئة Vector الخاصة بنا:

class Vector3 {
 // ...
  rotateX(angle) {
  // تخزين استدعاءات حساب المثلثات المكلفة
  const c = Math.cos(angle)
  const s = Math.sin(angle)

  return new Vector3(
    this.x,
    this.y * c - this.z * s,
    this.y * s + this.z * c,
  )
 }
 // يمكننا فعل الشيء نفسه بالنسبة لـ rotateY و rotateZ أيضاً
}

الآن يمكننا تدوير كل نقطة قبل إسقاطها على الشاشة:

let angle = 0 // يمكننا إنشاء زاوية لكل محور (مثلاً cubeRotation = new Vector3(0, 0.2, 0.1))

function render() {
 //...
 angle += 0.1

 for (const vertex of cubeVertices) {
  const rotated = vertex.rotateX(angle).rotateY(angle).rotateZ(angle)
  const {x, y} = project(rotated)
  drawPoint(x, y)
 }
 //...
}

مع هذا الدوران، يمكننا أخيراً رؤية الوجه الخلفي وتصور مكعبنا ثلاثي الأبعاد، حتى لو كان مجرد 8 نقاط عائمة في الوقت الحالي.

كما ذكرنا، يستخدم هذا الإسقاط المتعامد، الذي يفتقر إلى وهم العمق، لذلك دعونا نُرقِّي محركنا لاستخدام الإسقاط المنظوري.

الإسقاط المنظوري (Perspective Projection)

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

perspective projection explained visually

الآن نحتاج إلى معرفة كيفية حساب نقطة الإسقاط هذه.

دعونا نقترب ونركز على نقطة واحدة ونستخرج أي معلومات مفيدة:

perspective projection side view

كما نرى، من خلال إطلاق شعاعنا التخيلي، نشكل مثلثاً قائم الزاوية عمودياً على المحور z.

zoom in triangles

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

yf=yz\frac{y^\prime}{f} = \frac{y}{z}

بضرب كلا الجانبين في البعد البؤري (focal length) نحصل على صيغة الإسقاط الخاصة بنا:

y=yfzy^\prime = \frac{y \cdot f}{z}

وبتطبيق نفس المنطق تماماً على المحور الأفقي:

x=xfzx^\prime = \frac{x \cdot f}{z}

لكن ما هي قيمة f (المسافة بين الكاميرا والشاشة)؟

camera with fields of view are in focus

من المخطط، نلاحظ أن قسمة مجال الرؤية (field of view) على اثنين يعطينا مثلثين قائمي الزاوية متصلين، لذلك يمكننا استخدام علم المثلثات لمعرفة مسافة الكاميرا:

نعلم أن ظل الزاوية (tangent) هو المقابل مقسوماً على المجاور: tan(θ)=oa\tan(\theta) = \frac{o}{a} ، وهو في هذه الحالة:

tan(θ2)=1f\tan\left(\frac{\theta}{2}\right) = \frac{1}{f}

أخيراً، ننقل متغير البعد البؤري إلى اليسار لنحصل على:

f=1tan(θ2)f = \frac{1}{\tan\left(\frac{\theta}{2}\right)}

وهذا كل شيء، لدينا الآن جميع القيم التي نحتاجها لتحديث دالة الإسقاط الخاصة بنا:

// مجال رؤية ضيق يعني تقريباً (zoom) أعلى

const fov = 90 * Math.PI / 180 // تحويل الدرجات إلى راديان

const focal = 1 / Math.tan(fov / 2)

function project(p) {
  const xp = p.x * focal / p.z
  const yp = p.y * focal / p.z

  const x = (xp + 1) * canvas.width / 2
  const y = (1 - yp) * canvas.height / 2

  return {x, y}
}

باستخدام منطق الإسقاط المُحدَّث هذا، تدور رؤوس المكعب حول الكاميرا، مما يتسبب في خروج بعض النقاط عن نطاق الرؤية. هذا منطقي لأن النقطة (0, 0, 0) هي مركز المكعب وموضع الكاميرا في نفس الوقت.

لإصلاح ذلك، يمكننا دفع المكعب إلى الخلف في المحور z بعد الدوران حتى نتمكن من رؤية المكعب بالكامل:

// داخل render()
for (const vertex of cubeVertices) {
    const rotated = vertex.rotateX(angle).rotateY(angle).rotateZ(angle)
    rotated.z += 1 // دفع المكعب في اتجاه z بعد تدويره حول المركز

    const {x, y} = project(rotated)
    drawPoint(x, y)
  }

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

الشبكات المثلثية (Triangular Meshes)

الشبكة المضلعة (Mesh) هي مجموعة من المضلعات، معظمها مثلثات، تحدد أي كائن ثلاثي الأبعاد.

ومع ذلك، يجب تحديد هذه المثلثات في تسلسل متسق يُعرف باسم ترتيب الالتفاف (Winding Order) (إما في اتجاه عقارب الساعة أو عكس اتجاه عقارب الساعة). يحدد هذا الترتيب الجانب المرئي من المثلث، حيث نقوم فقط بتصيير المثلثات التي تواجهنا. تُسمى هذه العملية “إخفاء الأوجه الخلفية” (Back-face culling) وستكون مفيدة عند تلوين المثلثات.

بعبارات أخرى:

  • ترتيب الالتفاف: أي وجه هو الأمامي وأيها هو الخلفي.
  • إخفاء الأوجه الخلفية: عدم تصيير (رسم) الأوجه الخلفية.

في هذه الحالة، اخترت العمل بعكس اتجاه عقارب الساعة (Counter-clockwise) كوجه أمامي.

mesh winding order

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

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

const cubeMesh = [
    // الأمام
    0, 1, 2,
    0, 2, 3,
    // الخلف
    5, 4, 7,
    5, 7, 6,
    // اليسار
    4, 0, 3,
    4, 3, 7,
    // اليمين
    1, 5, 6,
    1, 6, 2,
    // الأعلى
    3, 2, 6,
    3, 6, 7,
    // الأسفل
    4, 5, 1,
    4, 1, 0,
];

الآن يمكننا تحديث دالة التصيير لرسم مثلثات بدلاً من النقاط:

class Vector3 {
  // ... الكود السابق
  add(v) { 
    return new Vector3(this.x + v.x, this.y + v.y, this.z + v.z) 
  } 
}

const cubePosition = new Vector3(0, 0, 1); // جرب تغيير موضع المكعب 

function render() {
  // ...

  for (const vertex of cubeVertices) {...} 
  // القيام بالعمليات الرياضية الثقيلة على كل رأس مرة واحدة فقط
  const rotatedVertices = cubeVertices.map(vertex =>
      vertex.rotateX(angle).rotateZ(angle).rotateY(angle)
            .add(cubePosition) // دفع المكعب للخلف
  );

  // توصيل النقاط باتباع ترتيب الالتفاف عكس عقارب الساعة
  for (let i = 0; i < cubeMesh.length; i += 3) {
    const p1 = rotatedVertices[cubeMesh[i]];
    const p2 = rotatedVertices[cubeMesh[i + 1]];
    const p3 = rotatedVertices[cubeMesh[i + 2]];

    drawTriangle(
      project(p1),
      project(p2),
      project(p3)
    );
  }
  // ...
}

function drawTriangle(p1, p2, p3) {
  ctx.beginPath()
  ctx.moveTo(p1.x, p1.y)
  ctx.lineTo(p2.x, p2.y)
  ctx.lineTo(p3.x, p3.y)
  ctx.closePath()
  ctx.stroke();
}

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

مع ذلك، لم نستخدم إخفاء الأوجه الخلفية (Back-face culling) بعد؛ لهذا السبب إذا حاولت ملء كل وجه بلون، فسترى الأوجه الخلفية تُرسم فوق الوجه الأمامي.

سيغطي المقال التالي التلوين الصلب (Solid coloring). ضع بريدك الإلكتروني أدناه حتى لا يفوتك!

كن أول من يعرف! اشترك لتصلك أحدث المقالات مباشرة إلى بريدك الإلكتروني.

تغيير حجم الإطار (Resizing the frame)

قبل أن أختتم، دعونا نصلح مشكلة التمدد. في البداية، حددنا وعملنا مع لوحة (Canvas) مربعة بنسبة 1:1، ولكن أي نسبة عرض إلى ارتفاع أخرى (مثل 16:9) ستؤدي إلى تمدد أو انكماش الكائنات.

لحسن الحظ، هناك حل بسيط؛ قبل أن نسقط النقطة، سنقوم بعمل توسيع عكسي (counter-scale) لمحور واحد (x أو y) بواسطة نسبة العرض إلى الارتفاع للوحة لإلغاء التمدد:

const ar = canvas.width / canvas.height

function project(p) {
  const xp = p.x / ar * focal / p.z // قسمة x على ar
  const yp = p.y * focal / p.z

  const x = (xp + 1) * canvas.width / 2
  const y = (1 - yp) * canvas.height / 2

  return {x, y}
}

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

Share this post