מבוא
קודם כל, קראו את המבוא ל-Three.js, כי הוא הבסיס למסמך הזה.
אני רוצה לדבר על הצללות. WebGL הוא כלי נהדר, וכמו שאמרתי קודם, Three.js (וספריות אחרות) עושה עבודה נהדרת בהפשטת הקשיים בשבילכם. אבל לפעמים תרצו להשיג אפקט ספציפי, או שתרצו להבין קצת יותר לעומק איך הדברים המדהימים האלה הופיעו על המסך, וכמעט בטוח שהצללות יהיו חלק מהמשוואה הזו. אחרי שלמדתם את הדברים הבסיסיים במדריך הקודם, אולי תרצו לנסות משהו קצת יותר מורכב.
אני אצא מנקודת הנחה שאתם משתמשים ב-Three.js, כי הוא עושה הרבה מהעבודה בשבילנו מבחינת הפעלת ה-shader. קודם נסביר את ההקשר של תוכניות הצללה, ואחר כך נפרט את השלבים המתקדמים.
1. שני ה-shaders שלנו
ב-WebGL אין אפשרות להשתמש ב-Fixed Pipeline, כלומר אין דרך קלה לרנדר את התוכן שלכם. במקום זאת, הוא מציע את Programmable Pipeline, שהוא חזק יותר אבל גם קשה יותר להבנה ולשימוש. בקיצור, צינור עיבוד נתונים שניתן לתכנות אומר שאתם, בתור המתכנתים, אחראים על רינדור הקודקודים וכן הלאה למסך. הצללות הן חלק מהתהליך הזה, ויש שני סוגים של הצללות:
- הצללות קודקודים
- הצללות של מקטעים (fragment shaders)
אני בטוח שתסכימו ששני הדברים האלה לא אומרים כלום בפני עצמם. מה שחשוב לדעת עליהם זה ששניהם פועלים לגמרי על ה-GPU של כרטיס המסך. זה אומר שאנחנו רוצים להעביר אליהם את כל מה שאפשר, כדי שהמעבד יוכל לבצע עבודה אחרת. מעבד גרפי מודרני מותאם מאוד לפונקציות שנדרשות להצללות, ולכן כדאי להשתמש בו.
2. הצללות קודקודים
בוחרים צורה פרימיטיבית רגילה, כמו כדור. הוא מורכב מקודקודים, נכון? הצללת קודקודים מקבלת כל אחד מהקודקודים האלה בתורו ויכולה לשנות אותם. הפעולה שמתבצעת בפועל עם כל אחד מהם תלויה ב-vertex shader, אבל יש לו אחריות אחת: בשלב כלשהו הוא חייב להגדיר משהו שנקרא gl_Position, וקטור float ב-4D, שהוא המיקום הסופי של הקודקוד במסך. זה תהליך מעניין בפני עצמו, כי אנחנו בעצם מדברים על קבלת מיקום תלת-ממדי (קודקוד עם x,y,z) על מסך דו-ממדי, או הקרנה שלו. למזלנו, אם אנחנו משתמשים במשהו כמו Three.js, יש לנו דרך מקוצרת להגדיר את gl_Position בלי שהדברים יהיו כבדים מדי.
3. הצללות של מקטעים (fragment shaders)
אז יש לנו את האובייקט עם הקודקודים שלו, והקרנו אותם למסך הדו-ממדי, אבל מה לגבי הצבעים שבהם אנחנו משתמשים? מה לגבי טקסטורה ותאורה? זה בדיוק מה שה-fragment shader עושה. בדומה מאוד ל-vertex shader, גם ל-fragment shader יש רק משימה אחת שחייבים לבצע: הוא חייב להגדיר או למחוק את המשתנה gl_FragColor, וקטור float נוסף ב-4D, שהוא הצבע הסופי של הפרגמנט שלנו.
אבל מה זה פרגמנט? חשבו על שלושה קודקודים שיוצרים משולש. צריך לצייר כל פיקסל בתוך המשולש הזה. פרגמנט הוא הנתונים שסופקו על ידי שלושת הקודקודים האלה לצורך שרטוט כל פיקסל במשולש הזה. לכן, הפרגמנטים מקבלים ערכים שעברו אינטרפולציה מהקודקודים שמרכיבים אותם. אם קודקוד אחד צבוע באדום והקודקוד השכן שלו צבוע בכחול, ערכי הצבעים יעברו אינטרפולציה מאדום, דרך סגול ועד לכחול.
4. משתני Shader
יש שלוש הצהרות משתנים שאפשר להגדיר: Uniforms, Attributes ו-Varyings. השמות האלה יכולים לבלבל, כי הם לא תואמים לשפות נפוצות אחרות. אפשר לחשוב עליהם כך:
משתנים אחידים נשלחים גם ל-vertex shaders וגם ל-fragment shaders, והם מכילים ערכים שנשארים זהים בכל הפריים שעובר רינדור. דוגמה טובה לכך היא מיקום האור.
מאפיינים הם ערכים שמוחלים על קודקודים ספציפיים. המאפיינים זמינים רק ל-vertex shader. למשל, לכל קודקוד יש צבע שונה. למאפיינים יש קשר של אחד לאחד עם קודקודים.
משתנים הם משתנים שמוצהרים ב-vertex shader שאנחנו רוצים לשתף עם ה-fragment shader. כדי לעשות את זה, אנחנו צריכים להצהיר על משתנה משתנה מאותו סוג ובאותו שם גם ב-vertex shader וגם ב-fragment shader. שימוש קלאסי בזה יהיה וקטור נורמל של קודקוד, כי אפשר להשתמש בו בחישובי תאורה.
בהמשך נשתמש בכל שלושת הסוגים כדי שתוכלו להבין איך הם מיושמים בפועל.
לא, בואו נסתכל על אחד מההצללות הכי פשוטות שאפשר ליצור.
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);
}
והנה אותו הדבר עבור ה-fragment 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. שני המשתנים האחידים האלה הם מטריצות 4D, שנקראות Model-View Matrix ו-Projection Matrix. אתם לא צריכים לדעת בדיוק איך הם פועלים, אבל תמיד כדאי להבין איך דברים פועלים אם אפשר. בקיצור, הן הדרך שבה המיקום התלת-ממדי של הקודקוד מוטל בפועל למיקום הדו-ממדי הסופי במסך.
למעשה, השמטתי אותם מקטע הקוד לדוגמה כי Three.js מוסיף אותם לחלק העליון של קוד ההצללה (shader) עצמו, כך שאתם לא צריכים לדאוג לגבי זה. למען האמת, הוא מוסיף הרבה יותר מזה, כמו נתוני אור, צבעי קודקודים וקודקודים נורמליים. אם הייתם עושים את זה בלי 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. שניהם יכולים לקבל וקטורים, מספרים שלמים או מספרים עם נקודה צפה, אבל כמו שציינתי קודם, משתני uniform זהים לכל הפריים או לכל הקודקודים, ולכן הם בדרך כלל ערכים בודדים. לעומת זאת, מאפיינים הם משתנים לכל קודקוד, ולכן הם צפויים להיות מערך. צריך להיות יחס של אחד לאחד בין מספר הערכים במערך המאפיינים לבין מספר הקודקודים ברשת.
7. השלבים הבאים
עכשיו נשקיע קצת זמן בהוספת לולאת אנימציה, מאפייני קודקוד ומשתנה אחיד. נוסיף גם משתנה מסוג varying כדי שה-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 או שווה לו (מואר לחלוטין), ואילו לקודקוד בצד יהיה ערך קרוב ל-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, ולכן ה-shader משתמש בערך אפס. זה כמו פלייסהולדר כרגע. עוד רגע נוסיף את המאפיין ל-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. להנפשה
אנחנו חייבים להפוך את זה לאנימציה. איך אנחנו עושים את זה? יש שני דברים שצריך להגדיר:
- משתנה אחיד (uniform) להנפשת מידת ההזזה שצריך להחיל בכל פריים. אנחנו יכולים להשתמש בסינוס או בקוסינוס בשביל זה, כי הם נעים בין 1- ל-1
- לולאת אנימציה ב-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);
}
בשלב הבא אנחנו מעדכנים את 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);
סיכום
זה הכול! עכשיו אפשר לראות שהאנימציה שלו פועמת בצורה מוזרה (וקצת פסיכדלית).
יש עוד הרבה מה ללמוד על שיידרים, ואני מקווה שהמבוא הזה עזר לכם. עכשיו אתם אמורים להבין את ההצללות כשאתם רואים אותן, ולהיות מסוגלים ליצור הצללות מדהימות משלכם.