مقدمة عن أدوات التظليل

مقدمة

يُرجى قراءة مقدمة حول Three.js أولاً، لأنّها الأساس الذي يستند إليه هذا المستند.

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

سأعمل على أساس أنك تستخدم Three.js، لأنها تقوم بالكثير من العمل بالنسبة لنا من حيث تشغيل التظليل. ستتعرّف أولاً على سياق أدوات التظليل، ثم على الخطوات المتقدّمة لاحقًا في هذا المستند.

1. لدينا برنامجا تظليل

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

  1. مظلِّلات الرؤوس
  2. أدوات تظليل الأجزاء

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

2. مظلِّلات الرؤوس

لنأخذ شكلاً أساسيًا عاديًا، مثل الكرة. يتكوّن من رؤوس زوايا، أليس كذلك؟ يتم إعطاء مظلل الرأس كل واحد من هذه الرؤوس بدوره ويمكنه العبث بها. ويعود الأمر إلى "مُظلِّل الرؤوس" في ما يفعله فعليًا بكل رأس، ولكن لديه مسؤولية واحدة: يجب عليه في مرحلة ما ضبط ما يُسمى gl_Position، وهو متجه عائم رباعي الأبعاد، وهو الموضع النهائي للرأس على الشاشة. هذه العملية بحد ذاتها مثيرة للاهتمام، لأنّنا نتحدث عن الحصول على موضع ثلاثي الأبعاد (رأس بإحداثيات x وy وz) على شاشة ثنائية الأبعاد أو إسقاطه عليها. لحسن الحظ، إذا كنا نستخدم شيئًا مثل Three.js، فلدينا طريقة مختصرة لضبط gl_Position بدون أن تصبح الأمور ثقيلة للغاية.

3- أدوات تظليل الأجزاء

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

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

4. متغيّرات أداة التظليل

هناك ثلاثة تعريفات للمتغيّرات يمكنك إجراؤها: Uniforms وAttributes وVaryings. قد تكون هذه مربكة، لأنها لا تتطابق مع أي لغات شائعة أخرى. وإليك كيفية التفكير فيها:

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

  2. السمات هي قيم يتم تطبيقها على الرؤوس الفردية. تتوفّر السمات فقط لمُظلِّل الرؤوس. قد يكون هذا شيئًا مثل كل رأس له لون مميز. تتسم السمات بعلاقة واحد لواحد مع الرؤوس.

  3. المتغيرات هي متغيرات تم تعريفها في مُظلِّل الرأس الذي نريد مشاركته مع مُظلِّل الأجزاء. للقيام بذلك، نتأكد من تعريف متغير من النوع والاسم نفسهما في كل من مظلل الرؤوس ومظلل الأجزاء. من الاستخدامات الكلاسيكية لذلك هو المتجه العمودي للرأس، إذ يمكن استخدامه في حسابات الإضاءة.

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

لا، ألقِ نظرة على أحد أبسط برامج التظليل التي يمكنك إنشاؤها.

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);
}

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

// 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 إلى 1
  2. حلقة تكرار للصور المتحركة في JS

سنضيف المتغيّر الموحّد إلى كلّ من MeshShaderMaterial و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);

الخاتمة

هذا كل ما في الأمر! يمكنك الآن رؤية أنه يتحرك بطريقة غريبة (ومثيرة للدهشة قليلاً) بطريقة نابضة.

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