مقدمه
ابتدا مقدمهی Three.js را بخوانید، زیرا پایه و اساس این سند است.
کاری که میخواهم انجام دهم بحث در مورد سایهزنها است. WebGL فوقالعاده است، و همانطور که قبلاً گفتم Three.js (و کتابخانههای دیگر) کار فوقالعادهای در حذف مشکلات برای شما انجام میدهند. اما مواقعی وجود خواهد داشت که میخواهید به یک جلوه خاص برسید، یا میخواهید کمی عمیقتر به نحوه نمایش آن چیزهای شگفتانگیز روی صفحه نمایش خود بپردازید، و سایهزنها تقریباً مطمئناً بخشی از این معادله خواهند بود. ممکن است بخواهید از موارد اساسی در آموزش قبلی به چیزی کمی پیچیدهتر بروید.
من بر این اساس کار خواهم کرد که شما از Three.js استفاده میکنید، زیرا بخش زیادی از کار را برای ما در زمینه راهاندازی shader انجام میدهد. ابتدا زمینه shaderها را یاد خواهید گرفت و سپس مراحل پیشرفته را بعداً در این سند خواهید آموخت.
۱. دو سایهزن ما
WebGL از Fixed Pipeline استفاده نمیکند، که به طور خلاصه به این معنی است که هیچ راهی برای رندر کردن محتوای شما به صورت آماده ارائه نمیدهد. چیزی که ارائه میدهد، Programmable Pipeline است که قدرتمندتر است اما درک و استفاده از آن نیز دشوارتر است. به طور خلاصه، Programmable Pipeline به این معنی است که شما به عنوان برنامهنویس، مسئولیت رندر کردن رئوس و غیره را روی صفحه بر عهده میگیرید. Shaderها بخشی از این Pipeline هستند و دو نوع از آنها وجود دارد:
- سایهزنهای رأسی
- سایهزنهای قطعهای
مطمئنم که موافقید، هر دو به خودی خود هیچ معنایی ندارند. چیزی که باید در مورد آنها بدانید این است که هر دو کاملاً روی پردازنده گرافیکی کارت گرافیک شما اجرا میشوند. این بدان معناست که ما میخواهیم تمام بار را به آنها واگذار کنیم و پردازنده مرکزی خود را برای انجام کارهای دیگر بگذاریم. یک پردازنده گرافیکی مدرن به شدت برای عملکردهایی که سایهزنها به آن نیاز دارند بهینه شده است، بنابراین استفاده از آن بسیار عالی است.
۲. سایهزنهای رأسی
یک شکل اولیه استاندارد، مانند یک کره را در نظر بگیرید. از رئوس تشکیل شده است، درست است؟ یک سایهزن رأس به نوبت تک تک این رئوس را دریافت میکند و میتواند با آنها بازی کند. اینکه با هر کدام چه کاری انجام دهد به سایهزن رأس بستگی دارد، اما یک مسئولیت دارد: باید در مقطعی چیزی به نام gl_Position را تنظیم کند، یک بردار شناور چهاربعدی که موقعیت نهایی رأس روی صفحه نمایش است. این به خودی خود یک فرآیند کاملاً جالب است، زیرا ما در واقع در مورد گرفتن یک موقعیت سهبعدی (یک رأس با x، y، z) روی یک صفحه نمایش دوبعدی یا تصویر شده به آن صحبت میکنیم. خوشبختانه برای ما، اگر از چیزی مانند Three.js استفاده کنیم، یک روش مختصر برای تنظیم gl_Position بدون اینکه کارها خیلی سنگین شوند، داریم.
۳. سایهزنهای قطعهای
خب، ما شیء خودمان را با رئوسش داریم و آنها را روی صفحه نمایش دوبعدی نمایش دادهایم، اما در مورد رنگهایی که استفاده میکنیم چطور؟ در مورد بافتدهی و نورپردازی چطور؟ دقیقاً همان کاری که fragment shader برای آن وجود دارد. خیلی شبیه به vertex shader، fragment shader هم فقط یک کار ضروری دارد: باید متغیر gl_FragColor ، یک بردار شناور چهاربعدی دیگر، را که رنگ نهایی fragment ماست، تنظیم یا حذف کند.
اما یک قطعه چیست؟ سه رأس را در نظر بگیرید که یک مثلث را تشکیل میدهند. هر پیکسل درون آن مثلث باید ترسیم شود. یک قطعه، دادههایی است که توسط آن سه رأس برای ترسیم هر پیکسل در آن مثلث فراهم میشود. به همین دلیل، قطعات مقادیر درونیابی شده را از رأسهای تشکیلدهنده خود دریافت میکنند. اگر یک رأس به رنگ قرمز و رأس همسایه آن به رنگ آبی باشد، خواهیم دید که مقادیر رنگ از قرمز، از طریق بنفش، به آبی درونیابی میشوند.
۴. متغیرهای سایهزن
سه نوع اعلان متغیر وجود دارد که میتوانید انجام دهید: Uniforms ، Attributes و Varyings . این موارد ممکن است گیجکننده باشند، زیرا با هیچ زبان رایج دیگری مطابقت ندارند. در اینجا نحوهی تصور آنها را آوردهایم:
Uniformها هم به vertex shaders و هم به fragment 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);
}
و همین مورد برای سایهزن قطعهای نیز به شرح زیر است:
/**
* 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 چند uniform برای ما ارسال میکند. این دو uniform، ماتریسهای چهاربعدی هستند که ماتریس Model-View و ماتریس Projection نامیده میشوند. لازم نیست دقیقاً بدانید که اینها چگونه کار میکنند، اگرچه همیشه بهتر است که در صورت امکان، نحوهی عملکرد چیزها را بفهمید. خلاصه کلام این است که آنها نحوهی نمایش موقعیت سهبعدی رأس به موقعیت نهایی دوبعدی روی صفحه هستند.
من در واقع آنها را از قطعه کد مثال حذف کردهام زیرا Three.js آنها را به بالای کد شیدر شما اضافه میکند، بنابراین نیازی نیست نگران انجام آن باشید. حقیقت این است که در واقع چیزهای بیشتری مانند دادههای نور، رنگهای رأس و نرمالهای رأس را اضافه میکند. اگر این کار را بدون Three.js انجام میدادید، باید خودتان تمام آن یونیفرمها و ویژگیها را ایجاد و تنظیم میکردید. داستان واقعی است.
۶. استفاده از 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. هر دو میتوانند بردار، عدد صحیح یا اعشاری بگیرند، اما همانطور که قبلاً اشاره کردم uniforms برای کل فریم یا برای همه رئوس یکسان است، بنابراین تمایل دارند مقادیر واحدی داشته باشند. با این حال، ویژگیها متغیرهایی برای هر رأس هستند، بنابراین انتظار میرود که یک آرایه باشند. باید یک رابطه یک به یک بین تعداد مقادیر در آرایه attributes و تعداد رئوس در مش وجود داشته باشد.
۷. مراحل بعدی
حالا قرار است کمی وقت صرف اضافه کردن یک حلقه انیمیشن، ویژگیهای رأس و یک یونیفرم کنیم. همچنین یک متغیر متغیر اضافه خواهیم کرد تا سایهزن رأس بتواند مقداری داده به سایهزن قطعه ارسال کند. نتیجه نهایی این است که کره ما که صورتی بود، از بالا و کنار روشن به نظر میرسد و ضربان خواهد داشت. کمی گیجکننده است، اما امیدواریم که شما را به درک خوبی از سه نوع متغیر و همچنین نحوه ارتباط آنها با یکدیگر و هندسه زیربنایی برساند.
۸. نور مصنوعی
حالا، رنگآمیزی را بهروزرسانی کنید تا یک شیء با رنگ مسطح نباشد. میتوانیم نگاهی به نحوهی مدیریت نورپردازی توسط Three.js بیندازیم، اما مطمئنم که متوجه خواهید شد که پیچیدهتر از آن چیزی است که نیاز داریم، بنابراین میخواهیم آن را شبیهسازی کنیم. شما باید به طور کامل سایهزنهای فوقالعادهای که بخشی از Three.js هستند و همچنین سایهزنهای پروژهی شگفتانگیز WebGL توسط کریس میلک و گوگل، رم را بررسی کنید. برگردیم به سایهزنهایمان. ما سایهزن ورتکس خود را بهروزرسانی خواهیم کرد تا هر رأس نرمال را به سایهزن فرگمنت ارائه دهد. ما این کار را با تغییرات زیر انجام میدهیم:
// 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);
}
بنابراین دلیل اینکه ضرب نقطهای کار میکند این است که با توجه به دو بردار، عددی به دست میآید که به شما میگوید دو بردار چقدر «شباهت» دارند. با بردارهای نرمالشده، اگر دقیقاً در یک جهت باشند، مقدار ۱ به دست میآید. اگر در جهتهای مخالف باشند، مقدار -۱ به دست میآید. کاری که ما انجام میدهیم این است که آن عدد را میگیریم و آن را به نورپردازی خود اعمال میکنیم. بنابراین یک رأس در بالا سمت راست مقداری نزدیک یا مساوی ۱ (کاملاً روشن) خواهد داشت، در حالی که یک رأس در کنار مقداری نزدیک به ۰ و در پشت آن -۱ خواهد بود. ما برای هر مقدار منفی، مقدار را روی ۰ نگه میداریم، اما وقتی اعداد را وارد میکنید، در نهایت به نورپردازی پایهای که میبینیم میرسید.
قدم بعدی چیست؟ خب، خوب است که شاید بتوانیم موقعیتهای رأسها را تغییر دهیم.
۹. ویژگیها
کاری که الان میخواهم انجام دهیم این است که با استفاده از یک ویژگی، یک عدد تصادفی به هر رأس اضافه کنیم. ما از این عدد برای هل دادن رأس در امتداد حالت عادیاش استفاده خواهیم کرد. نتیجه نهایی نوعی توپ میخدار عجیب خواهد بود که هر بار صفحه را رفرش میکنید تغییر میکند. هنوز متحرکسازی نمیشود (این اتفاق بعداً میافتد) اما با چند رفرش صفحه، تصادفی بودن آن را خواهید دید.
با اضافه کردن ویژگی به سایهزن رأس شروع کنید:
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 در جاوا اسکریپت اضافه میکنیم و Three.js این دو را به طور خودکار برای ما به هم متصل میکند.
همچنین نکته قابل توجه این است که من مجبور شدم موقعیت بهروزرسانیشده را به یک متغیر vec3 جدید اختصاص دهم زیرا ویژگی اصلی، مانند همه ویژگیها، فقط خواندنی است.
۱۰. بهروزرسانی 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) اتفاق میافتد.
۱۱. آن را متحرک کنید
ما باید کاملاً این را متحرک کنیم. چگونه این کار را انجام دهیم؟ خب، دو چیز وجود دارد که باید انجام دهیم:
- یک یونیفرم برای متحرکسازی میزان جابجایی که باید در هر فریم اعمال شود. میتوانیم از سینوس یا کسینوس برای این کار استفاده کنیم زیرا آنها از -۱ تا ۱ متغیر هستند.
- یک حلقه انیمیشن در 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()
});
فعلاً کار شیدرهای ما تمام شده است. اما درست است، به نظر میرسد که یک قدم به عقب برداشتهایم. این عمدتاً به این دلیل است که مقدار دامنه ما روی ۰ است و از آنجایی که آن را در جابجایی ضرب میکنیم، هیچ تغییری نمیبینیم. همچنین حلقه انیمیشن را تنظیم نکردهایم، بنابراین هرگز نمیبینیم که آن ۰ به چیز دیگری تغییر کند.
در جاوا اسکریپت خود، اکنون باید فراخوانی رندر را در یک تابع جمعبندی کنیم و سپس از requestAnimationFrame برای فراخوانی آن استفاده کنیم. در آنجا، همچنین باید مقدار uniform را بهروزرسانی کنیم.
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);
نتیجهگیری
و تمام! حالا میتوانید ببینید که به شکلی عجیب (و کمی عجیب و غریب) و ضرباندار حرکت میکند.
خیلی چیزهای دیگه هم هست که میتونیم در مورد شیدرها به عنوان یه موضوع پوشش بدیم، و امیدوارم این مقدمه براتون مفید بوده باشه. حالا باید بتونید وقتی شیدرها رو میبینید، اونها رو درک کنید، و اعتماد به نفس داشته باشید که خودتون چند تا شیدر شگفتانگیز بسازید.