מבוא להצללות

מבוא

מומלץ לקרוא קודם את המאמר הזה שמציג את Three.js, כי הוא הבסיס למסמך הזה.

אני רוצה לדבר על הצללות. ‫WebGL הוא נהדר, וכמו שאמרתי קודם, Three.js (וספריות אחרות) עושות עבודה מצוינת בהפשטת הקשיים בשבילכם. אבל יהיו מקרים שבהם תרצו להשיג אפקט ספציפי, או להבין לעומק איך הדברים המדהימים האלה הופיעו על המסך, ובמקרים כאלה, סביר להניח ששיידרים יהיו חלק מהמשוואה. אולי תרצו לעבור מהדברים הבסיסיים במדריך הקודם למשהו קצת יותר מסובך.

אני אצא מנקודת הנחה שאתם משתמשים ב-Three.js, כי היא עושה הרבה מהעבודה בשבילנו מבחינת הפעלת ה-shader. קודם נסביר את ההקשר של shaders, ואחר כך נציג שלבים מתקדמים יותר בהמשך המסמך.

1. שני השיידרים שלנו

‫WebGL לא מציע שימוש ב-Fixed Pipeline, שזו דרך מקוצרת לומר שהוא לא מספק לכם אמצעים לרינדור התוכן שלכם מחוץ לקופסה. אבל יש בו את ה-Programmable Pipeline, שהוא חזק יותר אבל גם קשה יותר להבנה ולשימוש. בקיצור, צינור הנתונים הניתן לתכנות אומר שאתם, המתכנתים, אחראים לרנדר את הקודקודים וכן הלאה במסך. ה-Shaders הם חלק מהצינור הזה, ויש שני סוגים שלהם:

  1. Vertex shaders
  2. Fragment shaders

אני בטוחה שתסכימו איתי שכל אחד מהם בנפרד לא אומר כלום. מה שחשוב לדעת עליהן הוא ששתיהן פועלות באופן מלא על ה-GPU של כרטיס המסך. כלומר, אנחנו רוצים להפחית עומס ככל שניתן אליהם, כדי שהמעבד (CPU) שלנו יוכל לבצע עבודות אחרות. ה-GPU המודרני מותאם מאוד לפונקציות שנדרשות ל-shaders, ולכן כדאי להשתמש בו.

2. Vertex Shaders

קחו צורה פרימיטיבית רגילה, כמו כדור. הוא מורכב מקודקודים, נכון? כל אחד מהקודקודים האלה מועבר בתורו ל-vertex shader, והוא יכול לשנות אותם. האחריות של vertex shader היא להגדיר בשלב מסוים את gl_Position, וקטור של מספרים ממשיים בתלת-ממד, שהוא המיקום הסופי של הקודקוד במסך. זה תהליך מעניין בפני עצמו, כי אנחנו מדברים על קבלת מיקום תלת-ממדי (קודקוד עם x,‏ y,‏ z) על מסך דו-ממדי או הקרנה שלו על מסך דו-ממדי. למזלנו, אם אנחנו משתמשים במשהו כמו Three.js, יש לנו דרך מקוצרת להגדיר את gl_Position בלי שהדברים יהיו מסובכים מדי.

3. Fragment Shaders

אז יש לנו את האובייקט עם הקודקודים שלו, והקרנו אותם למסך הדו-ממדי, אבל מה לגבי הצבעים שבהם אנחנו משתמשים? מה לגבי טקסטורות ותאורה? לשם כך בדיוק נועד shader המקטעים. בדומה ל-vertex shader, גם ל-fragment shader יש רק משימה אחת שחובה לבצע: הוא צריך להגדיר או לבטל את המשתנה gl_FragColor, שהוא עוד וקטור של מספרים ממשיים ב-4 מימדים, שמשמש כצבע הסופי של הפרגמנט.

אבל מה זה פרגמנט? חושבים על שלושה קודקודים שיוצרים משולש. צריך לצייר כל פיקסל בתוך המשולש הזה. פרגמנט הוא הנתונים ששלושת הקודקודים האלה מספקים לצורך ציור כל פיקסל במשולש. לכן השברים מקבלים ערכים שעברו אינטרפולציה מהקודקודים המרכיבים שלהם. אם קודקוד אחד צבוע באדום והשכן שלו בכחול, נראה את ערכי הצבעים עוברים אינטרפולציה מאדום, דרך סגול, לכחול.

4. משתני Shader

אפשר להגדיר שלושה סוגים של משתנים: Uniforms,‏ Attributes ו-Varyings. השפות האלה עלולות לבלבל, כי הן לא תואמות לשפות נפוצות אחרות. כך אפשר להבין אותן:

  1. משתני Uniform נשלחים גם ל-vertex shaders וגם ל-fragment shaders, והם מכילים ערכים שנשארים זהים בכל הפריים שעובר רינדור. דוגמה טובה לכך היא מיקום של מקור אור.

  2. מאפיינים הם ערכים שמוחלים על קודקודים בודדים. מאפיינים זמינים רק ל-vertex shader. לדוגמה, לכל קודקוד יש צבע שונה. למאפיינים יש קשר של אחד לאחד עם קודקודים.

  3. משתני Varying הם משתנים שמוצהרים ב-vertex shader ורוצים לשתף אותם עם ה-fragment shader. כדי לעשות את זה, אנחנו מוודאים שמצהירים על משתנה משתנה מאותו סוג ואותו שם גם ב-vertex shader וגם ב-fragment shader. דוגמה קלאסית לשימוש כזה היא הנורמל של קודקוד, כי אפשר להשתמש בו בחישובי התאורה.

בהמשך נשתמש בכל שלושת הסוגים כדי שתבינו איך הם פועלים בפועל.

לא, כדאי להסתכל על אחד מה-shaders הכי פשוטים שאפשר ליצור.

5. Bonjourno World

הנה דוגמה ל-Hello World של vertex shaders:

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

והנה אותו הדבר לגבי shader של פרגמנט:

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

אבל זה לא מסובך מדי, נכון?

ב-vertex shader, המערכת Three.js שולחת לנו כמה משתנים מסוג uniform. שני ה-uniforms האלה הם מטריצות 4D, שנקראות Model-View Matrix ו-Projection Matrix. לא צריך לדעת בדיוק איך הם פועלים, אבל תמיד כדאי להבין איך דברים פועלים אם אפשר. בקצרה, הן קובעות איך המיקום התלת-ממדי של הקודקוד מוקרן למיקום הדו-ממדי הסופי במסך.

למעשה, השמטתי אותם מקטע הקוד לדוגמה כי Three.js מוסיף אותם לחלק העליון של קוד ה-shader עצמו, כך שלא צריך לדאוג לגבי זה. למעשה, הוא מוסיף הרבה יותר מזה, כמו נתוני תאורה, צבעי קודקודים ונורמלים של קודקודים. אם הייתם עושים את זה בלי Three.js, הייתם צריכים ליצור ולהגדיר בעצמכם את כל המאפיינים האחידים והמאפיינים האלה. סיפור אמיתי.

6. שימוש ב-MeshShaderMaterial

אוקיי, הגדרנו shader, אבל איך משתמשים בו עם 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. שני המאפיינים יכולים לקבל וקטורים, מספרים שלמים או מספרים עשרוניים, אבל כמו שציינתי קודם, ערכי המאפיין uniform זהים לכל המסגרת או לכל הקודקודים, ולכן בדרך כלל מדובר בערכים בודדים. לעומת זאת, מאפיינים הם משתנים לכל קודקוד, ולכן הם צפויים להיות מערך. צריך להיות יחס של אחד לאחד בין מספר הערכים במערך המאפיינים לבין מספר הקודקודים ברשת.

7. השלבים הבאים

עכשיו נקדיש קצת זמן להוספת לולאת אנימציה, מאפייני קודקודים ומשתנה אחיד. נוסיף גם משתנה משתנה כדי שקוד ה-Vertex Shader יוכל לשלוח נתונים לקוד ה-Fragment Shader. בסופו של דבר, הכדור הוורוד שלנו ייראה כאילו הוא מואר מלמעלה ומהצד, והוא יהבהב. זה קצת מבלבל, אבל אני מקווה שתצליחו להבין את שלושת סוגי המשתנים ואת הקשר ביניהם לבין הגיאומטריה הבסיסית.

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 או שווה ל-1 (מואר לחלוטין), ואילו לקודקוד בצד יהיה ערך שקרוב ל-0, ולחלק האחורי יהיה ערך של -1. אנחנו מגבילים את הטווח את הערך ל-0 לכל דבר שלילי, אבל כשמזינים את המספרים מקבלים את התאורה הבסיסית שרואים.

מה השלב הבא? אולי כדאי לנסות לשנות את המיקום של כמה קודקודים.

9. מאפיינים

מה שאני רוצה שנעשה עכשיו זה לצרף מספר אקראי לכל קודקוד באמצעות מאפיין. נשתמש במספר הזה כדי להזיז את הקודקוד לאורך הנורמל שלו. התוצאה הסופית תהיה כדור מוזר עם קוצים שמשתנה בכל פעם שמבצעים רענון של הדף. הוא עדיין לא יהיה מונפש (זה יקרה בהמשך), אבל אחרי כמה רענונים של הדף תראו שהוא אקראי.

מתחילים בהוספת המאפיין ל-vertex shader:

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, ולכן בפועל השיידר משתמש בערך אפס במקום זאת. כרגע זה סוג של placeholder. בעוד שנייה נוסיף את המאפיין ל-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);
}

עכשיו אנחנו רואים כדור מעוות, אבל מה שחשוב הוא שכל ההזזה מתבצעת ב-GPU.

11. להנפשה

כדאי להוסיף לזה אנימציה. איך אנחנו עושים את זה? יש שני דברים שצריך לעשות:

  1. ‫uniform להנפשה של מידת ההזזה שצריך להחיל בכל פריים. אנחנו יכולים להשתמש בפונקציות סינוס או קוסינוס, כי הן נעות בין -1 ל-1
  2. לולאת אנימציה ב-JS

נוסיף את המאפיין uniform גם ל-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()
});

סיימנו עם ה-shaders לעכשיו. אבל נראה שעשינו צעד אחורה. הסיבה העיקרית לכך היא שערך האמפליטודה שלנו הוא 0, ומכיוון שאנחנו מכפילים אותו בהעתקה, אנחנו לא רואים שינוי. בנוסף, לא הגדרנו את לולאת האנימציה, ולכן אנחנו אף פעם לא רואים את הערך 0 משתנה למשהו אחר.

ב-JavaScript שלנו, צריך עכשיו לעטוף את הקריאה לפונקציה render בפונקציה אחרת, ואז להשתמש ב-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);

סיכום

זה הכול! עכשיו אפשר לראות שהאנימציה שלו מוזרה (ומעט הזויה) ושהוא פועם.

יש עוד הרבה מידע על שיידרים, ואני מקווה שההסבר הזה עזר לך. עכשיו אתם אמורים להבין מה הם שיידרים כשאתם רואים אותם, וגם להיות בטוחים בעצמכם מספיק כדי ליצור שיידרים מדהימים משלכם.