مقدمة
يُرجى قراءة مقدمة عن Three.js أولاً، لأنّها تشكّل الأساس لهذا المستند.
ما أريد فعله هو مناقشة برامج التظليل. إنّ WebGL رائعة، وكما ذكرتُ من قبل، فإنّ Three.js (والمكتبات الأخرى) تؤدي وظيفة رائعة في إخفاء الصعوبات عنك. ولكن في بعض الأحيان، قد تحتاج إلى تحقيق تأثير معيّن أو معرفة المزيد عن كيفية ظهور المحتوى الرائع على شاشتك، وفي هذه الحالة، ستكون برامج التظليل جزءًا من المعادلة. ننصحك بالانتقال من المعلومات الأساسية الواردة في الدرس التعليمي الأخير إلى معلومات أكثر تعقيدًا.
سأفترض أنّك تستخدم Three.js، لأنّها تسهّل علينا الكثير من العمل المطلوب لتشغيل برنامج التظليل. ستتعرّف أولاً على سياق برامج التظليل، ثم على الخطوات المتقدّمة لاحقًا في هذا المستند.
1. برنامجا التظليل
لا يتيح WebGL استخدام Fixed Pipeline، وهي طريقة مختصرة للتعبير عن أنّه لا يوفّر لك أي وسائل لعرض المحتوى مباشرةً. أما ما توفّره، فهو Programmable Pipeline، وهو أكثر فعالية ولكن أيضًا أكثر صعوبة في الفهم والاستخدام. باختصار، يعني مسار العرض القابل للبرمجة أنّك بصفتك المبرمج تتحمّل مسؤولية عرض الرؤوس وما إلى ذلك على الشاشة. تشكّل برامج التظليل جزءًا من مسار العرض هذا، وهناك نوعان منها:
- أدوات تظليل الرؤوس
- Fragment shaders
وأنا على يقين من أنّك ستوافق على أنّ كلاهما لا يعنيان أي شيء على الإطلاق بمفردهما. والجدير بالذكر أنّ كليهما يعملان بالكامل على وحدة معالجة الرسومات في بطاقة الرسومات. وهذا يعني أنّنا نريد تخفيف الحِمل قدر الإمكان عليهم، ما يتيح لوحدة المعالجة المركزية (CPU) تنفيذ مهام أخرى. تم تحسين وحدة معالجة الرسومات الحديثة بشكل كبير لتنفيذ الوظائف التي تتطلبها برامج التظليل، لذا من الرائع أن تتمكّن من استخدامها.
2. Vertex Shaders
اختَر شكلاً أساسيًا، مثل كرة. وهي تتألف من رؤوس زوايا، أليس كذلك؟ يتم تزويد برنامج تظليل الرؤوس بكل رأس من هذه الرؤوس بدوره ويمكنه التلاعب بها. يعود إلى برنامج تظليل الرؤوس تحديد ما يفعله بكل رأس، ولكن لديه مسؤولية واحدة، وهي أنّه يجب في مرحلة ما ضبط قيمة gl_Position، وهي متّجه عائم رباعي الأبعاد، يمثّل الموضع النهائي للرأس على الشاشة. وهذه العملية بحد ذاتها مثيرة للاهتمام، لأنّنا نتحدث عن الحصول على موضع ثلاثي الأبعاد (رأس مع إحداثيات x وy وz) على شاشة ثنائية الأبعاد أو إسقاطه عليها. لحسن الحظ، إذا كنا نستخدم مكتبة مثل Three.js، يتوفّر لدينا طريقة مختصرة لضبط gl_Position بدون أن يصبح الأمر معقّدًا.
3- Fragment Shaders
لدينا الآن الكائن مع رؤوسه، وقد عرضناها على الشاشة الثنائية الأبعاد، ولكن ماذا عن الألوان التي نستخدمها؟ ماذا عن تصميم الأنسجة والإضاءة؟ وهذا هو بالضبط الغرض من برنامج تظليل الأجزاء. وكما هو الحال مع برنامج تظليل الرؤوس، فإنّ برنامج تظليل الأجزاء لديه أيضًا مهمة واحدة يجب تنفيذها، وهي ضبط المتغيّر gl_FragColor أو تجاهله، وهو متّجه آخر من النوع float رباعي الأبعاد، ويمثّل اللون النهائي للجزء.
ولكن ما هو الجزء؟ فكِّر في ثلاثة رؤوس تشكّل مثلثًا. يجب رسم كل بكسل داخل هذا المثلث. والجزء هو البيانات التي توفّرها هذه الرؤوس الثلاثة بغرض رسم كل بكسل في هذا المثلث. لهذا السبب، تتلقّى الأجزاء قيمًا معدَّلة من الرؤوس المكوّنة لها. إذا كان أحد الرؤوس ملوّنًا باللون الأحمر، وكان الرأس المجاور له ملوّنًا باللون الأزرق، سنرى قيم الألوان تتداخل من الأحمر إلى الأرجواني إلى الأزرق.
4. متغيّرات أداة التظليل
يمكنك إجراء ثلاثة إعلانات متغيرة: Uniforms وAttributes وVaryings. وقد تكون هذه الرموز مربكة لأنّها لا تتطابق مع أي لغات شائعة أخرى. إليك كيفية التفكير فيها:
يتم إرسال المتغيرات الموحّدة إلى برنامجَي تظليل الرؤوس وبرنامجَي تظليل الأجزاء معًا، وهي تتضمّن قيمًا تظل كما هي في جميع إطارات العرض. ومن الأمثلة الجيدة على ذلك موضع الضوء.
السمات هي قيم يتم تطبيقها على أشكال فردية. تتوفّر السمات فقط لبرنامج تظليل الرؤوس. يمكن أن يكون ذلك على شكل لون مميز لكل رأس. ترتبط السمات بالرؤوس بعلاقة مباشرة.
المتغيرات هي متغيرات يتم تعريفها في أداة تظليل الرؤوس ونريد مشاركتها مع أداة تظليل الأجزاء. للقيام بذلك، نتأكّد من تعريف متغيّر متغير من النوع والاسم نفسهما في كل من برنامج تظليل الرؤوس وبرنامج تظليل الأجزاء. من الاستخدامات الكلاسيكية لهذا النوع من البيانات، يمكننا ذكر المتّجه العمودي للرأس، إذ يمكن استخدامه في العمليات الحسابية الخاصة بالإضاءة.
لاحقًا، سنستخدم الأنواع الثلاثة حتى تتمكّن من التعرّف على كيفية تطبيقها.
لا، ألقِ نظرة على أحد أبسط برامج التظليل التي يمكنك إنشاؤها.
5- Bonjourno World
إليك إذًا برنامج Hello World الخاص ببرامج تظليل الرؤوس:
/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
وينطبق الأمر نفسه على أداة تظليل الأجزاء:
/**
* Set the color to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}
الأمر ليس معقّدًا جدًا، أليس كذلك؟
في برنامج تظليل الرؤوس، يرسل إلينا Three.js بعض المتغيرات الموحّدة. هذان المتغيران الموحّدان هما مصفوفتان رباعيتا الأبعاد، تُعرفان باسم مصفوفة العرض والنموذج ومصفوفة العرض. ليس عليك معرفة طريقة عملها بالتفصيل، ولكن من الأفضل دائمًا فهم طريقة عمل الأشياء إذا أمكنك ذلك. باختصار، هي الطريقة التي يتم بها عرض الموضع الثلاثي الأبعاد للرأس على الموضع الثنائي الأبعاد النهائي على الشاشة.
لقد استبعدتُها من مقتطف المثال لأنّ Three.js تضيفها إلى أعلى رمز أداة التظليل نفسه، لذا لن تحتاج إلى القلق بشأن إضافتها. في الواقع، يضيف هذا الملف الكثير من البيانات، مثل بيانات الإضاءة وألوان الرؤوس ومتجهات الرؤوس العادية. إذا كنت ستنفّذ ذلك بدون Three.js، عليك إنشاء جميع المتغيرات الموحّدة والسمات وضبطها بنفسك. قصة حقيقية
6. استخدام MeshShaderMaterial
حسنًا، لقد أعددنا برنامج تظليل، ولكن كيف يمكننا استخدامه مع Three.js؟
/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader: $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});
من هناك، سيقوم Three.js بتجميع وتشغيل أدوات التظليل المرفقة بالشبكة المتداخلة التي تمنحها هذه المادة. لا يمكن أن يكون الأمر أسهل من ذلك. من المحتمل أن يكون الأمر كذلك، ولكننا نتحدث عن تشغيل رسومات ثلاثية الأبعاد في المتصفح، لذا أعتقد أنّك تتوقّع قدرًا معيّنًا من التعقيد.
يمكننا في الواقع إضافة سمتَين أخريَين إلى MeshShaderMaterial، وهما uniforms وattributes. يمكن أن تأخذ المتجهات أو الأعداد الصحيحة أو الأعداد العشرية، ولكن كما ذكرت سابقًا، تكون المتغيرات الموحّدة هي نفسها للإطار بأكمله أو لجميع الرؤوس، لذا تميل إلى أن تكون قيمًا فردية. أما السمات، فهي متغيرات لكل رأس، لذا من المتوقّع أن تكون مصفوفة. يجب أن تكون هناك علاقة واحد لواحد بين عدد القيم في مصفوفة السمات وعدد الرؤوس في الشبكة المتداخلة.
7. الخطوات التالية
سنستغرق الآن بعض الوقت في إضافة حلقة رسوم متحركة وسمات رأسية وقيمة موحّدة. سنضيف أيضًا متغيرًا مختلفًا ليتمكّن برنامج تظليل الرؤوس من إرسال بعض البيانات إلى برنامج تظليل الأجزاء. والنتيجة النهائية هي أنّ الكرة الوردية ستظهر وكأنّها مضاءة من الأعلى ومن الجانب، وستنبض. قد يبدو الأمر غريبًا بعض الشيء، ولكن نأمل أن يساعدك ذلك في فهم الأنواع الثلاثة من المتغيرات وكيفية ارتباطها ببعضها البعض وبالأشكال الهندسية الأساسية.
8. إضاءة زائفة
الآن، عدِّل الألوان لكي لا يكون الكائن بلون واحد. يمكننا إلقاء نظرة على طريقة تعامل Three.js مع الإضاءة، ولكنّني متأكّد من أنّك تدرك أنّها أكثر تعقيدًا مما نحتاج إليه، لذا سنحاول محاكاتها. ننصحك بالاطّلاع على برامج التظليل الرائعة التي تشكّل جزءًا من Three.js، بالإضافة إلى برامج التظليل من مشروع WebGL المذهل الذي أطلقه "كريس ميلك" وGoogle، وهو Rome. نعود إلى برامج التظليل. سنعدّل Vertex Shader لتوفير المتجه العمودي لكل رأس إلى Fragment Shader. ننفّذ ذلك باستخدام ما يلي:
// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;
void main() {
// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
وفي Fragment Shader، سنضبط اسم المتغير نفسه، ثم نستخدم الضرب النقطي لمتجه الرأس العادي مع متجه يمثّل ضوءًا ساطعًا من أعلى ويمين الكرة. وتمنحنا النتيجة النهائية تأثيرًا مشابهًا لتأثير الضوء الاتجاهي في حزمة ثلاثية الأبعاد.
// same name and type as VS
varying vec3 vNormal;
void main() {
// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
// ensure it's normalized
light = normalize(light);
// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));
// feed into our frag color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);
}
إذًا، السبب الذي يجعل الضرب النقطي يعمل هو أنّه عند توفّر خطين متجهين، ينتج عنهما رقم يوضّح مدى "تشابه" الخطين المتجهين. باستخدام المتجهات العادية، إذا كانت تشير إلى الاتجاه نفسه تمامًا، ستحصل على القيمة 1. إذا كانت تشير إلى اتجاهين متعاكسين، ستحصل على القيمة -1. ما نفعله هو أخذ هذا الرقم وتطبيقه على الإضاءة. وبالتالي، ستكون قيمة الرأس في أعلى اليسار قريبة من 1 أو تساويها (مضاءة بالكامل)، بينما ستكون قيمة الرأس على الجانب قريبة من 0، وستكون قيمة الرأس في الخلف -1. نضبط القيمة على 0 لأي قيمة سالبة، ولكن عند إدخال الأرقام، ينتهي بك الأمر بالحصول على الإضاءة الأساسية التي نراها.
ما هي الخطوات التالية؟ حسنًا، سيكون من الجيد ربما محاولة التلاعب ببعض مواضع الرؤوس.
9. السمات
ما أريد أن نفعله الآن هو إرفاق رقم عشوائي بكل رأس باستخدام سمة. سنستخدم هذا الرقم لدفع الرأس إلى الخارج على طول المتجه العمودي. وستكون النتيجة النهائية عبارة عن كرة غريبة الشكل تتغيّر في كل مرة تعيد فيها تحميل الصفحة. لن يتم تحريكها بعد (سيحدث ذلك لاحقًا)، ولكن ستظهر لك بعد إعادة تحميل الصفحة عدة مرات أنّها عشوائية.
ابدأ بإضافة السمة إلى أداة تظليل الرؤوس:
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position +
normal *
vec3(displacement);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
كيف يبدو؟
ليس هناك فرق كبير بينهما. ويرجع ذلك إلى عدم إعداد السمة في MeshShaderMaterial، وبالتالي يستخدم برنامج التظليل القيمة صفر بدلاً من ذلك. وهو بمثابة عنصر نائب في الوقت الحالي. في غضون ثوانٍ، سنضيف السمة إلى MeshShaderMaterial في JavaScript، وسيربط Three.js بين السمتين تلقائيًا.
من الجدير بالذكر أيضًا أنّه كان عليّ تعيين الموضع المعدَّل إلى متغيّر vec3 جديد لأنّ السمة الأصلية، مثل جميع السمات، تكون للقراءة فقط.
10. تعديل MeshShaderMaterial
ابدأ مباشرةً بتعديل MeshShaderMaterial باستخدام السمة اللازمة لتفعيل الإزاحة. تذكير: السمات هي قيم لكل رأس، لذا نحتاج إلى قيمة واحدة لكل رأس في الكرة. مثال:
var attributes = {
displacement: {
type: 'f', // a float
value: [] // an empty array
}
};
// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}
نرى الآن كرة مشوّهة، ولكن الميزة الرائعة هي أنّ كل عمليات الإزاحة تتم على وحدة معالجة الرسومات.
11. إضافة حركة
يجب أن نجعل هذا العنصر متحركًا. كيف يمكننا إجراء ذلك؟ حسنًا، هناك أمران يجب إعدادهما:
- قيمة موحّدة لتحديد مقدار الإزاحة التي يجب تطبيقها في كل إطار. يمكننا استخدام دالة الجيب أو جيب التمام لذلك لأنّ نطاقها يتراوح بين -1 و1
- تكرار صورة متحركة في JavaScript
سنضيف المتغير الموحّد إلى كل من MeshShaderMaterial وVertex Shader. أولاً، برنامج Vertex Shader:
uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position +
normal *
vec3(displacement *
amplitude);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
بعد ذلك، نعدّل MeshShaderMaterial:
// add a uniform for the amplitude
var uniforms = {
amplitude: {
type: 'f', // a float
value: 0
}
};
// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms: uniforms,
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
لقد انتهينا من تظليل الصور في الوقت الحالي. لكن يبدو أنّنا تراجعنا خطوة إلى الوراء. ويرجع ذلك إلى أنّ قيمة السعة لدينا هي 0، وبما أنّنا نضربها في الإزاحة، لا نرى أي تغيير. لم نضبط أيضًا حلقة الرسوم المتحركة، لذا لن نرى أبدًا الرقم 0 يتغير إلى أي شيء آخر.
في JavaScript، علينا الآن تضمين طلب العرض في دالة ثم استخدام requestAnimationFrame لاستدعائها. في هذا القسم، علينا أيضًا تعديل قيمة المتغيّر الموحّد.
var frame = 0;
function update() {
// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;
renderer.render(scene, camera);
// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);
الخاتمة
هذا كل ما في الأمر! يمكنك الآن ملاحظة أنّها تتحرّك بطريقة غريبة (ومربكة بعض الشيء) وبشكل نابض.
يمكننا التحدّث عن المزيد من التفاصيل حول برامج التظليل، وآمل أن يكون هذا العرض التمهيدي مفيدًا لك. من المفترض أن تتمكّن الآن من فهم برامج التظليل عند رؤيتها، وأن يكون لديك الثقة لإنشاء بعض برامج التظليل الرائعة الخاصة بك.