رسومات ثلاثية الأبعاد: برنامج تعليمي WebGL

نشرت: 2022-03-11

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

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

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

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

رسم توضيحي لشعار Toptal ثلاثي الأبعاد على قماش WebGL

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

الكود النهائي متاح لك للتشعب والتلاعب به.

تمثيل نماذج ثلاثية الأبعاد

أول شيء يجب أن تفهمه هو كيفية تمثيل النماذج ثلاثية الأبعاد. النموذج مصنوع من شبكة مثلثات. يتم تمثيل كل مثلث بثلاثة رؤوس لكل ركن من أركان المثلث. هناك ثلاث خصائص شائعة مرتبطة بالرؤوس.

موقف الرأس

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

فيرتكس عادي

المجالات التي لها نفس الإطار السلكي ، والتي لها تظليل مسطح وسلس مطبق

النظر في النموذجين أعلاه. إنها تتكون من نفس مواضع الرأس ، لكنها تبدو مختلفة تمامًا عند عرضها. كيف يعقل ذلك؟

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

مقارنة بين القواعد العادية للتظليل المسطح والسلس

يتوافق السطح الأيمن والأيسر مع الكرة اليسرى واليمنى في الصورة السابقة ، على التوالي. تمثل الأسهم الحمراء القواعد المعيارية المحددة للرأس ، بينما تمثل الأسهم الزرقاء حسابات العارض لكيفية البحث الطبيعي عن جميع النقاط بين الرؤوس. تُظهر الصورة عرضًا توضيحيًا للفضاء ثنائي الأبعاد ، لكن نفس المبدأ ينطبق على الأبعاد الثلاثية.

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

إحداثيات الملمس

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

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

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

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

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

الإطارات الشبكية ليست جزءًا من النسيج ، ولكنها متراكبة فقط فوق الصورة حتى تتمكن من رؤية كيفية تعيين الأشياء معًا.

تحميل نموذج OBJ

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

يسرد الملف مواضع الرأس بتنسيق v <float> <float> <float> ، مع تعويم رابع اختياري ، والذي سنتجاهله ، لإبقاء الأمور بسيطة. يتم تمثيل الأعراف الرأسية بالمثل مع vn <float> <float> <float> . أخيرًا ، يتم تمثيل إحداثيات النسيج بـ vt <float> <float> ، مع عوامة ثالثة اختيارية يجب أن نتجاهلها. في جميع الحالات الثلاث ، تمثل العوامات الإحداثيات المعنية. يتم تجميع هذه الخصائص الثلاثة في ثلاث مصفوفات.

يتم تمثيل الوجوه بمجموعات من الرؤوس. يتم تمثيل كل رأس بفهرس كل خاصية ، حيث تبدأ المؤشرات من 1. هناك طرق مختلفة لتمثيل هذا ، لكننا f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 ، مما يتطلب توفير جميع الخصائص الثلاثة ، وتحديد عدد الرؤوس لكل وجه بثلاثة. يتم إجراء كل هذه القيود لإبقاء أداة التحميل بسيطة قدر الإمكان ، نظرًا لأن جميع الخيارات الأخرى تتطلب بعض المعالجة التافهة الإضافية قبل أن تكون بتنسيق يحبه WebGL.

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

يوزع الكود التالي سلسلة تمثل ملف OBJ ، وينشئ نموذجًا في شكل مصفوفة من الوجوه.

 function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }

يحتفظ الهيكل Geometry بالبيانات الدقيقة اللازمة لإرسال نموذج إلى بطاقة الرسومات لمعالجته. قبل القيام بذلك ، ربما ترغب في امتلاك القدرة على تحريك النموذج على الشاشة.

أداء التحولات المكانية

جميع النقاط في النموذج التي قمنا بتحميلها مرتبطة بنظام الإحداثيات الخاص به. إذا أردنا ترجمة النموذج وتدويره وقياس حجمه ، فكل ما علينا القيام به هو إجراء هذه العملية على نظام الإحداثيات الخاص به. نظام الإحداثيات A ، بالنسبة إلى نظام الإحداثيات B ، يتم تحديده من خلال موضع مركزه باعتباره متجه p_ab ، والمتجه لكل من محاوره ، x_ab ، y_ab ، و z_ab ، يمثل اتجاه ذلك المحور. لذلك إذا تحركت نقطة بمقدار 10 على المحور x لنظام الإحداثيات A ، فستتحرك - في نظام الإحداثيات B - في اتجاه x_ab ، مضروبًا في 10.

يتم تخزين كل هذه المعلومات في نموذج المصفوفة التالية:

 x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1

إذا أردنا تحويل المتجه ثلاثي الأبعاد q ، فعلينا فقط ضرب مصفوفة التحويل بالمتجه:

 qx qy qz 1

يؤدي هذا إلى تحريك النقطة بمقدار qx على طول المحور x الجديد ، وبواسطة qy على طول المحور y الجديد ، وبواسطة qz على طول المحور z الجديد. أخيرًا يتسبب في تحريك النقطة بشكل إضافي بواسطة المتجه p ، وهذا هو سبب استخدامنا للواحد كعنصر نهائي في عملية الضرب.

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

هناك العديد من التحولات التي يمكن إجراؤها ، وسوف نلقي نظرة على التحولات الرئيسية.

لا تحول

إذا لم تحدث أي تحويلات ، فإن متجه p يكون متجهًا صفريًا ، ومتجه x يكون [1, 0, 0] ، y هو [0, 1, 0] ، و z هو [0, 0, 1] . من الآن فصاعدًا ، سنشير إلى هذه القيم على أنها القيم الافتراضية لهذه المتجهات. يمنحنا تطبيق هذه القيم مصفوفة هوية:

 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1

هذه نقطة انطلاق جيدة لتسلسل التحولات.

ترجمة

تحويل الإطار للترجمة

عندما نقوم بالترجمة ، فإن جميع المتجهات باستثناء المتجه p لها قيمها الافتراضية. ينتج عن هذا المصفوفة التالية:

 1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1

تحجيم

تحويل الإطار للقياس

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

 s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1

هنا s_x و s_y و s_z القياس المطبق على كل محور.

دوران

تحويل الإطار للدوران حول المحور Z.

توضح الصورة أعلاه ما يحدث عندما نقوم بتدوير إطار الإحداثيات حول المحور Z.

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

لتبسيط الأمور ، سنعرض لك فقط كيف تبحث مصفوفات التحويل عن التدويرات حول المحاور الرئيسية.

 Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1

تطبيق

يمكن تنفيذ كل هذا كفئة تخزن 16 رقمًا ، وتخزين المصفوفات بترتيب عمود رئيسي.

 function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }

النظر من خلال الكاميرا

هنا يأتي الجزء الأساسي من عرض الأشياء على الشاشة: الكاميرا. هناك نوعان من المكونات الرئيسية للكاميرا ؛ أي موقعه وكيف يعرض الأشياء المرصودة على الشاشة.

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

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

الإسقاط الهجائي

يتم تحويل المساحة المستطيلة إلى أبعاد الإطار المؤقت المناسبة باستخدام الإسقاط الهجائي

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

 2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1

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

يتم تحويل Frustum إلى أبعاد الإطار المؤقت المناسبة باستخدام إسقاط المنظور

لن نتطرق إلى تفاصيل كيفية تصميم هذا الإسقاط ، ولكن فقط استخدم الصيغة النهائية ، والتي أصبحت قياسية إلى حد كبير الآن. يمكننا تبسيطه بوضع الإسقاط في موضع الصفر على المحور x و y ، مما يجعل الحدين الأيمن / الأيسر والأعلى / السفلي مساويين width/2 height/2 على التوالي. تمثل المعلمتان n و f مستويات القطع near far ، وهي أصغر وأكبر مسافة يمكن أن تلتقطها الكاميرا. يتم تمثيلهم من خلال الجوانب المتوازية من frustum في الصورة أعلاه.

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

 2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0

لحساب العرض والارتفاع ، يمكن استخدام الصيغ التالية:

 height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height

يمثل مجال الرؤية (FOV) الزاوية الرأسية التي تلتقطها الكاميرا بعدستها. تمثل نسبة العرض إلى الارتفاع النسبة بين عرض الصورة وارتفاعها ، وتستند إلى أبعاد الشاشة التي نعرضها.

تطبيق

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

 function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }

هذه هي القطعة الأخيرة التي نحتاجها قبل أن نبدأ في رسم الأشياء على الشاشة.

رسم كائن بخط أنابيب رسومات WebGL

أبسط سطح يمكنك رسمه هو مثلث. في الواقع ، تتكون غالبية الأشياء التي ترسمها في مساحة ثلاثية الأبعاد من عدد كبير من المثلثات.

نظرة أساسية على ما تفعله خطوات خط أنابيب الرسومات

أول شيء يجب أن تفهمه هو كيفية تمثيل الشاشة في WebGL. إنها مساحة ثلاثية الأبعاد ، تمتد بين -1 و 1 على المحور x و y و z . بشكل افتراضي ، لا يتم استخدام هذا المحور z ، لكنك مهتم بالرسومات ثلاثية الأبعاد ، لذا سترغب في تمكينه على الفور.

مع أخذ ذلك في الاعتبار ، ما يلي هو ثلاث خطوات مطلوبة لرسم مثلث على هذا السطح.

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

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

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

هذه هي العناصر الأربعة اللازمة لرسم أي شيء تريده ، وهي أبسط مثال على خط أنابيب الرسومات. ما يلي هو نظرة على كل منهم ، وتنفيذ بسيط.

المخزن المؤقت الافتراضي

أهم عنصر لتطبيق WebGL هو سياق WebGL. يمكنك الوصول إليه باستخدام gl = canvas.getContext('webgl') ، أو استخدام 'experimental-webgl' كبديل احتياطي ، في حالة عدم دعم المستعرض المستخدم حاليًا جميع ميزات WebGL حتى الآن. canvas التي أشرنا إليها هي عنصر DOM في اللوحة التي نريد الرسم عليها. يحتوي السياق على أشياء كثيرة ، من بينها المخزن المؤقت للإطار الافتراضي.

يمكنك وصف إطار التخزين المؤقت بشكل فضفاض بأنه أي مخزن مؤقت (كائن) يمكنك الاعتماد عليه. بشكل افتراضي ، يخزن الإطار المؤقت الافتراضي اللون لكل بكسل من لوحة الرسم التي يرتبط بها سياق WebGL. كما هو موضح في القسم السابق ، عندما نرسم على الإطار المؤقت ، يقع كل بكسل بين -1 و 1 على محوري x و y . شيء ذكرناه أيضًا هو حقيقة أن WebGL لا يستخدم المحور z افتراضيًا. يمكن تمكين هذه الوظيفة عن طريق تشغيل gl.enable(gl.DEPTH_TEST) . عظيم ، لكن ما هو اختبار العمق؟

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

تظل أي عمليات سحب تقوم بها على الشاشة حتى تخبرهم بإزالتها. للقيام بذلك ، عليك الاتصال gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) . يؤدي هذا إلى مسح كل من المخزن المؤقت للون والعمق. لاختيار اللون الذي تم ضبط البكسلات التي تم مسحها عليها ، استخدم gl.clearColor(red, green, blue, alpha) .

لنقم بإنشاء عارض يستخدم لوحة قماشية ويمسحها عند الطلب:

 function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }

سيؤدي إرفاق هذا البرنامج النصي إلى HTML التالي إلى منحك مستطيل أزرق فاتح على الشاشة

 <!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>

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

كائنات Vertex Buffer

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

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

يمكنك ملء VBO عن طريق أخذ جميع رؤوس N التي لدينا وإنشاء مصفوفة من العوامات بعناصر 3N لموضع الرأس والرأس VBO الطبيعي ، و 2N لإحداثيات النسيج VBO. تمثل كل مجموعة مكونة من ثلاثة عوامات ، أو عوامين لإحداثيات الأشعة فوق البنفسجية ، الإحداثيات الفردية للرأس. ثم نقوم بتمرير هذه المصفوفات إلى وحدة معالجة الرسومات (GPU) ، وتكون رؤوسنا جاهزة لبقية خط الأنابيب.

نظرًا لأن البيانات موجودة الآن على GPU RAM ، يمكنك حذفها من ذاكرة الوصول العشوائي للأغراض العامة. هذا ما لم ترغب في تعديله لاحقًا وتحميله مرة أخرى. يجب أن يتبع كل تعديل تحميل ، نظرًا لأن التعديلات في صفيفات JS الخاصة بنا لا تنطبق على VBO في ذاكرة الوصول العشوائي GPU الفعلية.

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

أضفنا أيضًا التسلسل إلى فئة Geometry والعناصر الموجودة بداخلها.

 Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }

يقوم نوع بيانات VBO بإنشاء VBO في سياق WebGL الذي تم تمريره ، بناءً على الصفيف الذي تم تمريره كمعامل ثاني.

يمكنك أن ترى ثلاث مكالمات إلى سياق gl . ينشئ استدعاء createBuffer() المخزن المؤقت. يخبر استدعاء bindBuffer() جهاز حالة WebGL باستخدام هذه الذاكرة المحددة مثل VBO الحالي ( ARRAY_BUFFER ) لجميع العمليات المستقبلية ، حتى يتم إخباره بخلاف ذلك. بعد ذلك ، قمنا بتعيين قيمة VBO الحالية على البيانات المقدمة ، مع bufferData() .

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

يمكنك استخدام ثلاثة أجهزة VBOs وتحويل لوصف جميع خصائص الشبكة ، مع موضعها.

 function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }

كمثال ، إليك كيفية تحميل نموذج ، وتخزين خصائصه في الشبكة ، ثم إتلافه:

 Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })

شادر

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

شادر فيرتكس

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

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

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

هناك ثلاثة أنواع من المتغيرات التي تدخل وتخرج من تظليل قمة الرأس ، وكلها تخدم استخدامًا محددًا:

  • attribute - هذه مدخلات تحتوي على خصائص معينة للرأس. سابقًا ، وصفنا موضع الرأس كسمة ، في شكل متجه ثلاثي العناصر. يمكنك النظر إلى السمات على أنها قيم تصف رأسًا واحدًا.
  • uniform - هذه مدخلات هي نفسها لكل رأس داخل نفس استدعاء العرض. لنفترض أننا نريد أن نكون قادرين على تحريك نموذجنا من خلال تحديد مصفوفة التحويل. يمكنك استخدام متغير uniform لوصف ذلك. يمكنك الإشارة إلى الموارد الموجودة على وحدة معالجة الرسومات أيضًا ، مثل الزخارف. يمكنك النظر إلى الزي الرسمي باعتباره قيمًا تصف نموذجًا أو جزءًا منه.
  • varying - هذه هي المخرجات التي نمررها إلى تظليل الأجزاء. نظرًا لوجود الآلاف من وحدات البكسل المحتملة لمثلث من الرؤوس ، فسيحصل كل بكسل على قيمة محرفة لهذا المتغير ، اعتمادًا على الموضع. لذلك إذا أرسل رأس واحد 500 كمخرج ، وآخر 100 ، فإن البكسل الموجود في المنتصف بينهما سيحصل على 300 كمدخل لهذا المتغير. يمكنك النظر إلى الاختلافات على أنها قيم تصف الأسطح بين الرؤوس.

So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” ربما تسال.

 attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }

Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main function. All values that we would want to return are assigned, either to varying variables, or to special variables. Here we assign to gl_Position , which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4 out of the position vector. You can construct a vec4 by using four float s, two vec2 s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.

For now, we will keep it simple, and move on to painting individual pixels.

Fragment Shaders

A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.

The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:

  • There are no more varying outputs, and attribute inputs have been replaced with varying inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader.
  • Our only output now is gl_FragColor , which is a vec4 . The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects.
  • At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.

With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }

The function clamp just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.

With all of this in mind, all that is left is to implement this in WebGL.

Combining Shaders into a Program

The next step is to combine the shaders into a program:

 function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }

There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.

Actually Drawing the Model

Last, but not least, you draw the model.

First you pick the shader program you want to use.

 ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }

Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.

 Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }

Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.

 VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }

Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays() from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES takes three by three vertices and draws a triangle for each triplet. Using POINTS would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:

 Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }

The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.

 Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

We can combine all the elements that we have to finally draw something on the screen:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) } 

Object drawn on the canvas, with colors depending on UV coordinates

This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); } 

Brown object drawn on the canvas

It doesn't look very convincing. It looks like the scene needs some shading effects.

Adding Light

Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.

Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.

Demonstration of angles between light rays and surface normals, for both flat and smooth shading

You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.

If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); } 

Brown object with sunlight

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

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); } 

جسم بني مع ضوء الشمس والضوء المحيط

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

الآن يصبح التظليل:

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }

ثم يمكنك تحديد الضوء:

 function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }

في فئة برنامج shader ، أضف الزي الرسمي المطلوب:

 this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')

في البرنامج ، أضف استدعاء للضوء الجديد في العارض:

 Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

ستتغير الحلقة بعد ذلك قليلاً:

 var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }

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

تتمثل الخطوة الأخيرة التي يجب مراعاتها في إضافة نسيج فعلي إلى نموذجنا. لنفعل ذلك الآن.

مضيفا القوام

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

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

يجب إضافة الزي الجديد إلى القائمة في برنامج shader:

 this.diffuse = gl.getUniformLocation(program, 'diffuse')

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

 function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }

لا تختلف العملية كثيرًا عن العملية المستخدمة لتحميل وربط أنظمة تشغيل VBOs. يتمثل الاختلاف الرئيسي في أننا لم نعد ملزمًا بسمة ما ، بل نربط فهرس النسيج بعدد صحيح موحد. نوع sampler2D ليس أكثر من مؤشر إزاحة إلى نسيج.

الآن كل ما يجب القيام به هو تمديد فئة Mesh ، للتعامل مع القوام أيضًا:

 function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }

وسيبدو النص الرئيسي النهائي على النحو التالي:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) } 

كائن محكم مع تأثيرات ضوئية

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

 function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) } 

استدارة الرأس أثناء الرسوم المتحركة للكاميرا

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

 void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

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

رأس مع إضاءة الكرتون المطبقة

إلى أين أذهب بعد ذلك

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

بدون ترتيب معين ، إليك بعض المصادر الرائعة للحصول على معلومات أكثر تفصيلاً ، لكل من WebGL و OpenGL.

  • أساسيات WebGL
  • تعلم WebGL
  • برنامج تعليمي OpenGL مفصل للغاية يرشدك خلال جميع المبادئ الأساسية الموضحة هنا ، بطريقة بطيئة للغاية ومفصلة.
  • وهناك العديد والعديد من المواقع الأخرى المخصصة لتعليمك مبادئ رسومات الكمبيوتر.
  • وثائق MDN لـ WebGL
  • مواصفات Khronos WebGL 1.0 إذا كنت مهتمًا بفهم المزيد من التفاصيل التقنية حول كيفية عمل WebGL API في جميع الحالات المتطورة.