فهم الدوران ثلاثي الأبعاد في Three.js: زوايا أويلر والكواتيرنيون وGimbal Lock

الدوران من أهم المفاهيم في البرمجة ثلاثية الأبعاد.

في البداية يبدو تدوير الكائن أمرًا بسيطًا:

mesh.rotation.y = Math.PI / 2;

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

السبب بسيط: يمكن تمثيل الدوران ثلاثي الأبعاد بطرق مختلفة. وفي Three.js أهم تمثيلين هما:

  • زوايا أويلر (Euler Angles)
  • الكواتيرنيون (Quaternions)

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

1. ماذا يعني الدوران في الفضاء ثلاثي الأبعاد؟

تخيّل أنك تمسك كتابًا بيديك. يمكنك:

  • إمالته إلى الأمام والخلف.
  • تدويره يمينًا ويسارًا.
  • تدويره مثل العجلة.

في العالم ثلاثي الأبعاد تحدث هذه الحركات حول ثلاثة محاور:

      Y
      ↑
      |
      |
      O --------→ X
     /
    /
   Z

يمكن لأي كائن أن يدور حول المحاور X وY وZ. في Three.js:

mesh.rotation.x;
mesh.rotation.y;
mesh.rotation.z;

2. الدوران بزوايا أويلر

تصف زوايا أويلر الدوران بثلاث قيم: الدوران حول X وحول Y وحول Z.

فكّر في كاميرا على حامل ثلاثي. يمكنك وصف اتجاهها بقول: أدرها 20 درجة للأعلى، و45 درجة لليمين، وأمِلها 10 درجات. هذه هي فكرة زوايا أويلر.

mesh.rotation.set(
  0,
  Math.PI / 4,
  0
);

أي X = 0 درجة، وY = 45 درجة، وZ = 0 درجة. يخزّن Three.js قيمة Object3D.rotation ككائن Euler.

الدرجات أم الراديان

تستخدم الحواسيب عادةً الراديان بدل الدرجات. التحويلات الشائعة:

180° = Math.PI
 90° = Math.PI / 2
 45° = Math.PI / 4

mesh.rotation.y = Math.PI / 2; يدوّر الكائن 90 درجة حول المحور Y. طريقة أوضح:

mesh.rotation.y = THREE.MathUtils.degToRad(90);

3. ترتيب الدوران: لماذا يهم؟

هنا يتفاجأ كثير من المطورين. أمسك كتابًا: أدره أولًا للأعلى ثم لليمين. النتيجة تختلف عن تدويره لليمين أولًا ثم للأعلى.

الدوران ليس مثل جمع الأعداد: دوران X ← دوران Y لا يساوي دوران Y ← دوران X. لزوايا أويلر ترتيب:

mesh.rotation.order = "XYZ";

الترتيب الافتراضي في Three.js هو XYZ، ويمكن تغييره.

4. المشكلة: Gimbal Lock

يُعد Gimbal Lock من أكبر نقاط ضعف زوايا أويلر. لفهمه، تخيّل كاميرا مثبتة داخل ثلاث حلقات دوّارة — حلقة لكل محور. في الوضع الطبيعي تمنحنا الحلقات X وY وZ ثلاث دورانات مستقلة.

الآن أدر النظام 90 درجة حول X. يحدث أمر غير متوقع: يصطف محوران للدوران على الخط نفسه. يفقد النظام درجة حرية واحدة، فقد تتصرف حركة يُفترض أنها حول Y كأنها حول Z.

الكائن ليس معطّلًا؛ المشكلة في طريقة التمثيل. تصف زوايا أويلر الدوران كتسلسل من دورانات حول المحاور، لذلك تنتج بعض الاتجاهات نتائج ملتبسة.

مثال واقعي: الطائرة

للطائرة ثلاث حركات: Pitch (رفع المقدمة وخفضها)، وYaw (الانعطاف يمينًا ويسارًا)، وRoll (دوران الأجنحة). في الوضع الطبيعي تكون مستقلة. لكن إذا اتجهت الطائرة عموديًا إلى الأعلى، يتطابق محوران، فلا يستطيع النظام التمييز بين «هل أنعطف يسارًا؟» و«هل أقوم بـ Roll؟». هذا هو Gimbal Lock.

5. لماذا وُجد الكواتيرنيون 🤩

الكواتيرنيون طريقة أخرى لتمثيل الدوران. بدل قول «أدِر X بمقدار 20° وY بمقدار 40° وZ بمقدار 10°»، يصف الكواتيرنيون الاتجاه النهائي للكائن.

تشبيه مفيد هو كرة التتبع. عندما تمسك كرة لا تفكر «أدِر حول X ثم Y ثم Z»، بل تدير الكرة بيدك والمهم هو وضعها النهائي — والكواتيرنيون أقرب إلى هذه الفكرة.

6. الكواتيرنيون في Three.js

يحتوي الكواتيرنيون على أربع قيم: x وy وz وw.

const q = new THREE.Quaternion();
console.log(q); // Quaternion { x, y, z, w }

هذه الأرقام ليست مثل الموضع حيث x لليمين وy للأعلى وz للأمام. قيم الكواتيرنيون لا معنى لها إلا مجتمعة — تمامًا مثل RGB، حيث لا تحدد قناة واحدة اللون، بل تركيبها.

7. إنشاء دوران بالكواتيرنيون

الطريقة الأكثر شيوعًا هي setFromAxisAngle(): «أدِر حول هذا المحور بهذه الزاوية». تدوير كائن 20 درجة حول Y:

const quaternion = new THREE.Quaternion();
const axis = new THREE.Vector3(0, 1, 0);
const angle = THREE.MathUtils.degToRad(20);

quaternion.setFromAxisAngle(axis, angle);
mesh.quaternion.copy(quaternion);

8. دمج الدورانات: copy() مقابل multiply()

من أكبر مزايا الكواتيرنيون أنه يمكن دمج الدورانات بأمان. تخيّل هاتفًا في يدك: تديره أولًا 30 درجة لليمين، ثم تميله 20 درجة أخرى للأعلى. الاتجاه النهائي مزيج من الحركتين. مع زوايا أويلر يصبح هذا معقدًا لأن ترتيب المحاور مهم؛ أما مع الكواتيرنيون فندمج الدورانات بالضرب.

copy(): استبدال الدوران الحالي

copy() تعني «انسَ الدوران السابق واستخدم هذا»:

const q = new THREE.Quaternion();
q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(45));
mesh.quaternion.copy(q);

multiply(): إضافة دوران آخر

multiply() تعني «طبّق هذا الدوران الجديد فوق الدوران الحالي»:

const spin = new THREE.Quaternion();
spin.setFromAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(20));
mesh.quaternion.multiply(spin);

مثل تدوير مقود السيارة: الوضع الحالي + دوران إضافي = النتيجة المدمجة.

9. أكثر الطرق فائدة: setFromUnitVectors()

أحيانًا لا نعرف زاوية الدوران، بل نعرف فقط: «هذا الكائن يتجه نحو هذا الاتجاه وأريده أن يتجه نحو اتجاه آخر». تخيّل مصباحًا يشير للأعلى ونريده أن يشير للأمام.

const from = new THREE.Vector3(0, 1, 0); // الاتجاه الحالي
const to = new THREE.Vector3(1, 0, 0);   // الاتجاه المطلوب

const q = new THREE.Quaternion();
q.setFromUnitVectors(from, to);
mesh.quaternion.copy(q);

وهذا من أكبر الفروق بينهما: أويلر يسأل «كم درجة حول كل محور؟»، والكواتيرنيون يسأل «كيف أحوّل هذا الاتجاه إلى ذاك؟»

10. الكواتيرنيون والتفكير الواقعي

تخيّل أنك تمسك مجسّم الكرة الأرضية. لا تفكر «أدِر X بمقدار 15 درجة ثم Y بمقدار 40 درجة» — بل تدير المجسّم فقط. يخزّن الكواتيرنيون الاتجاه النهائي للكائن، لا تاريخ كيفية الوصول إليه.

11. دوران سلس باستخدام slerp()

أحيانًا لا نريد أن يقفز الكائن فجأة إلى دوران جديد: كاميرا تنظر بسلاسة إلى هدف، روبوت يدير رأسه، مركبة فضائية تغيّر اتجاهها. لهذا نستخدم slerp() — الاستيفاء الخطي الكروي، أي ببساطة «الانتقال بسلاسة من دوران إلى آخر»:

mesh.quaternion.slerp(targetQuaternion, 0.1);

القيمة 0.1 تعني «تقدّم 10٪ نحو الدوران المطلوب» في كل إطار.

12. أويلر أم كواتيرنيون: متى نستخدم كلًّا منهما؟

استخدم أويلر عندما تفكر بالزوايا — «أدِر هذا الكائن 30 درجة»:

mesh.rotation.y = THREE.MathUtils.degToRad(30);

مناسب للدوران البسيط، وعناصر التحكم في الواجهة، والزوايا المعروفة، ومدخلات المحررات.

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

13. العلاقة بين أويلر والكواتيرنيون في Three.js

ليسا دورانين مختلفين — بل طريقتان لتمثيل الدوران نفسه. يحتفظ Three.js بالاثنين: mesh.rotation هو تمثيل أويلر وmesh.quaternion هو تمثيل الكواتيرنيون. خلف الكواليس:

Euler → Quaternion → Matrix4 → GPU Rendering

14. مثال عملي

طريقة أويلر البسيطة:

function animate() {
  mesh.rotation.y += 0.01;
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

طريقة الكواتيرنيون:

const rotation = new THREE.Quaternion();
rotation.setFromAxisAngle(new THREE.Vector3(0, 1, 0), 0.01);

function animate() {
  mesh.quaternion.multiply(rotation);
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

تمنح الطريقة الثانية تحكمًا أكبر عند دمج عدة دورانات.

النموذج الذهني النهائي

  • أويلر: «أدِر حول هذه المحاور الثلاثة» (X ← Y ← Z). سهل الفهم، لكنه قد يواجه Gimbal Lock.
  • الكواتيرنيون: «اجعل للكائن هذا الاتجاه.» أصعب في البداية، لكنه أقوى بكثير.

الخلاصة

الدوران ليس مجرد تغيير ثلاثة أرقام. السؤال المهم هو: «هل أعرف الزوايا؟» أم «هل أعرف الاتجاه الذي أريده؟»

إذا كنت تعرف الزوايا، فأويلر كافٍ عادةً. وإذا كنت تعرف الاتجاه المطلوب أو تحتاج إلى دمج الدورانات، فالكواتيرنيون هو الأداة المناسبة. وعندما تفهم هذا الفرق، تصبح كثير من مواضيع Three.js المتقدمة أسهل بكثير.