מבוא להצללות

מבוא

העברתי לכם בעבר מבוא ל-Three.js. אם לא קראתם, אולי כדאי לכם לעשות זאת, מכיוון שזהו הבסיס שעליו אבנה במהלך מאמר זה.

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

1. שני הצללים שלנו

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

  1. תוכנות הצללה (shader) של Vertex
  2. תוכנות הצללה בקטעים

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

2. מכשירים מסוג Vertex Shader

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

3. חלוניות ריצוף

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

4. משתני גוון

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

  1. מדים נשלחים גם לתוכנות הצללה (shader) קודקודים (shaders) וגם למקטעי קטעים (shader), ומכיל ערכים שנשארים זהים בכל הפריים שעבר רינדור. דוגמה טובה לכך יכולה להיות מיקום של מנורה.

  2. מאפיינים הם ערכים שחלים על קודקודים נפרדים. המאפיינים זמינים רק להצללה (shader) של קודקוד. זה יכול להיות משהו כמו כל קודקוד שיש לו צבע ייחודי. למאפיינים יש קשר אחד לאחד עם קודקודים.

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

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

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

5. עולם בונג'ורנו

הנה, אם כן, 'העולם של מצלילים קודקודים קוד':

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

אבל לא כל כך מסובך, נכון?

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

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

6. שימוש בחומר רשת (MeshShader)

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

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

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

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

8. מנורה מזויפת

נעדכן את הצבע כך שלא יהיה אובייקט בצבע שטוח. אפשר לראות איך Three.js מטפל בתאורה, אבל אני בטוח שאתם יודעים שזה יותר מורכב ממה שאנחנו צריכים כרגע, אז נזיף אותו. כדאי לעיין בכלי ההצללה המדהימים ששייכים ל-Three.js, וגם באלה מפרויקט WebGL המדהים האחרון שנוצר על ידי כריס מילק ו-Google רומא. בחזרה להצללות שלנו. נעדכן את 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 colour
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 ולכן ביעילות רבה יותר, הגוון משתמש בערך אפס. זה סוג של placeholder כרגע. בעוד שנייה נוסיף את המאפיין ל-MeshShaderMaterial ב-JavaScript, ו-Three.js נקשר בין שני המאפיינים באופן אוטומטי.

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

10. עדכון של חומר MeshShader

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

אנחנו נוסיף את המדים גם ל-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);
}

לאחר מכן נעדכן את חומר MeshShader:

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

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

12. סיכום

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

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