บทนำ
โปรดอ่านบทนำเกี่ยวกับ Three.js ก่อน เนื่องจากเป็น พื้นฐานของเอกสารนี้
สิ่งที่ผมต้องการจะพูดถึงคือ Shader WebGL เป็นเครื่องมือที่ยอดเยี่ยม และอย่างที่ผมเคย บอกไปแล้วว่า Three.js (และไลบรารี อื่นๆ) ทำหน้าที่ได้อย่างยอดเยี่ยมในการลดความซับซ้อน ให้คุณ แต่บางครั้งคุณอาจต้องการสร้างเอฟเฟกต์ที่เฉพาะเจาะจง หรือต้องการเจาะลึกถึงวิธีการแสดงผลที่น่าทึ่งบนหน้าจอ และ Shader จะเป็นส่วนหนึ่งของสมการนั้นอย่างแน่นอน คุณอาจต้องการยกระดับจากพื้นฐานในบทแนะนำก่อนหน้าไปสู่สิ่งที่ซับซ้อนขึ้นเล็กน้อย
ผมจะอธิบายโดยอิงตามการใช้ Three.js เนื่องจากเครื่องมือนี้ช่วยเราได้มากในเรื่องการเริ่มต้นใช้งาน Shader คุณจะได้เรียนรู้บริบทของ Shader ก่อน จากนั้นจึงเรียนรู้ขั้นตอนขั้นสูงในเอกสารนี้
1. Shader 2 ประเภท
WebGL ไม่รองรับการใช้ Fixed Pipeline ซึ่งเป็นวิธีแบบย่อๆ ในการบอกว่าเครื่องมือนี้ไม่ได้มีวิธีแสดงผลชิ้นงานให้คุณโดยไม่ต้องตั้งค่า แต่มี Programmable Pipeline ซึ่งมีประสิทธิภาพมากกว่า แต่ก็เข้าใจและใช้งานได้ยากกว่า กล่าวโดยย่อคือ Programmable Pipeline หมายความว่าในฐานะโปรแกรมเมอร์ คุณต้องรับผิดชอบในการแสดงผลจุดยอดและอื่นๆ บนหน้าจอ Shader เป็นส่วนหนึ่งของ Pipeline นี้ และมี 2 ประเภท ได้แก่
- Vertex Shader
- Fragment Shader
ซึ่งทั้ง 2 ประเภทนี้ไม่มีความหมายในตัวเอง สิ่งที่คุณควรรู้เกี่ยวกับ Shader คือ Shader ทั้ง 2 ประเภททำงานบน GPU ของการ์ดกราฟิกทั้งหมด ซึ่งหมายความว่าเราต้องการลดภาระงานทั้งหมดที่ทำได้ไปยัง GPU เพื่อให้ CPU ทำงานอื่นๆ GPU สมัยใหม่ได้รับการปรับให้เหมาะสมอย่างมากสำหรับฟังก์ชันที่ Shader ต้องการ ดังนั้นจึงเป็นเรื่องดีที่เราจะใช้ GPU ได้
2. Vertex Shader
ลองดูรูปทรงพื้นฐานมาตรฐาน เช่น ทรงกลม ซึ่งประกอบด้วยจุดยอด ใช่ไหม Vertex Shader จะได้รับจุดยอดแต่ละจุดเหล่านี้ทีละจุดและสามารถปรับเปลี่ยนจุดยอดเหล่านั้นได้ Vertex Shader มีหน้าที่รับผิดชอบในการจัดการจุดยอดแต่ละจุด แต่มีหน้าที่รับผิดชอบอย่างหนึ่งคือต้องตั้งค่า สิ่งที่เรียกว่า gl_Position ซึ่งเป็นเวกเตอร์แบบลอยตัว 4 มิติ ซึ่งเป็นตำแหน่งสุดท้ายของ จุดยอดบนหน้าจอ กระบวนการนี้ค่อนข้างน่าสนใจ เนื่องจากเรากำลังพูดถึงการนำตำแหน่ง 3 มิติ (จุดยอดที่มี x, y, z) ไปวางหรือฉายภาพบนหน้าจอ 2 มิติ โชคดีที่หากเราใช้เครื่องมืออย่าง Three.js เราจะมีวิธีแบบย่อๆ ในการตั้งค่า gl_Position โดยไม่ต้องตั้งค่าที่ซับซ้อน
3. Fragment Shader
เรามีออบเจ็กต์ที่มีจุดยอดและฉายภาพจุดยอดเหล่านั้นไปยังหน้าจอ 2 มิติแล้ว แต่สีที่เราใช้ล่ะ แล้วการใส่พื้นผิวและการจัดแสงล่ะ Fragment Shader มีไว้เพื่อจัดการสิ่งเหล่านี้ Fragment Shader มีหน้าที่รับผิดชอบเพียงอย่างเดียวเช่นเดียวกับ Vertex Shader คือต้องตั้งค่าหรือละทิ้งตัวแปร gl_FragColor ซึ่งเป็นเวกเตอร์แบบลอยตัว 4 มิติอีกตัวหนึ่ง ซึ่งเป็นสีสุดท้ายของ Fragment
แต่ Fragment คืออะไร ลองนึกถึงจุดยอด 3 จุดที่ประกอบกันเป็นสามเหลี่ยม เราต้องวาดพิกเซลแต่ละพิกเซลภายในสามเหลี่ยมนั้น Fragment คือข้อมูลที่จุดยอด 3 จุดนั้นให้มาเพื่อจุดประสงค์ในการวาดพิกเซลแต่ละพิกเซลในสามเหลี่ยมนั้น ด้วยเหตุนี้ Fragment จึงได้รับค่าที่ประมาณจากจุดยอดที่เป็นส่วนประกอบ หากจุดยอดหนึ่งมีสีแดงและจุดยอดข้างเคียงมีสีน้ำเงิน เราจะเห็นค่าสีประมาณจากสีแดงผ่านสีม่วงไปเป็นสีน้ำเงิน
4. ตัวแปร Shader
คุณประกาศตัวแปรได้ 3 ประเภท ได้แก่ Uniforms, Attributes และ Varyings ตัวแปรเหล่านี้อาจทำให้สับสนเนื่องจากไม่ตรงกับภาษาอื่นๆ ที่ใช้กันทั่วไป คุณสามารถนึกภาพตัวแปรเหล่านี้ได้ดังนี้
Uniforms จะส่งไปยัง ทั้ง Vertex Shader และ Fragment Shader และมีค่าที่คงที่ตลอดทั้งเฟรมที่แสดงผล ตัวอย่างที่ดีคือตำแหน่งของแสง
Attributes คือค่าที่ใช้กับจุดยอดแต่ละจุด Attributes ใช้ได้กับ Vertex Shader เท่านั้น ตัวอย่างเช่น จุดยอดแต่ละจุดมีสีที่แตกต่างกัน Attributes มีความสัมพันธ์แบบหนึ่งต่อหนึ่งกับจุดยอด
Varying คือตัวแปรที่ประกาศใน Vertex Shader ที่เราต้องการแชร์กับ Fragment Shader ในการดำเนินการนี้ เราต้องประกาศตัวแปร Varying ที่มีประเภทและชื่อเดียวกันทั้งใน Vertex Shader และ Fragment Shader การใช้งานแบบคลาสสิกคือค่าปกติของจุดยอด เนื่องจากค่านี้ใช้ในการคำนวณแสงได้
เราจะใช้ตัวแปรทั้ง 3 ประเภทในภายหลังเพื่อให้คุณเห็นภาพการใช้งานจริง
มาดู Shader ที่ง่ายที่สุดตัวหนึ่งที่คุณสร้างได้
5. Bonjourno World
นี่คือ Hello World ของ Vertex Shader
/**
* 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);
}
และนี่คือ Hello World ของ 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 เราจะได้รับ Uniforms 2 รายการจาก Three.js Uniforms ทั้ง 2 รายการนี้คือเมทริกซ์ 4 มิติที่เรียกว่า Model-View Matrix และ Projection Matrix คุณไม่จำเป็นต้องทราบวิธีการทำงานของเมทริกซ์เหล่านี้อย่างละเอียด แม้ว่าการเข้าใจวิธีการทำงานของสิ่งต่างๆ จะเป็นเรื่องดีเสมอ กล่าวโดยย่อคือ เมทริกซ์เหล่านี้เป็นวิธีที่ตำแหน่ง 3 มิติของจุดยอดถูกฉายภาพไปยังตำแหน่ง 2 มิติสุดท้ายบนหน้าจอ
ผมได้ละเว้นเมทริกซ์เหล่านี้ไว้ในข้อมูลโค้ดตัวอย่างเนื่องจาก Three.js จะเพิ่มเมทริกซ์เหล่านี้ไว้ที่ด้านบนของโค้ด Shader เอง คุณจึงไม่ต้องกังวลเกี่ยวกับการดำเนินการนี้ พูดตามตรงคือ Three.js จะเพิ่มข้อมูลมากกว่านั้นมาก เช่น ข้อมูลแสง สีของจุดยอด และค่าปกติของจุดยอด หากคุณดำเนินการนี้โดยไม่ใช้ Three.js คุณจะต้องสร้างและตั้งค่า Uniforms และ Attributes ทั้งหมดด้วยตนเอง เรื่องจริง
6. การใช้ MeshShaderMaterial
เราตั้งค่า Shader ไว้แล้ว แต่จะใช้ 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 จะคอมไพล์และเรียกใช้ Shader ที่แนบกับ Mesh ที่คุณกำหนด Material นั้น ไม่มีวิธีที่ง่ายกว่านี้อีกแล้ว อาจจะมี แต่เรากำลังพูดถึงการแสดงผล 3 มิติในเบราว์เซอร์ ดังนั้นผมจึงคิดว่าคุณคงคาดหวังความซับซ้อนในระดับหนึ่ง
เราสามารถเพิ่มพร็อพเพอร์ตี้อีก 2 รายการลงใน MeshShaderMaterial ได้ ได้แก่ Uniforms และ Attributes ทั้ง 2 รายการนี้ใช้เวกเตอร์ จำนวนเต็ม หรือจำนวนจริงได้ แต่ดังที่ผมได้กล่าวไปแล้วว่า Uniforms จะเหมือนกันสำหรับทั้งเฟรมหรือสำหรับจุดยอดทั้งหมด ดังนั้นจึงมักจะเป็นค่าเดียว อย่างไรก็ตาม Attributes เป็นตัวแปรต่อจุดยอด ดังนั้นจึงคาดว่าจะเป็นอาร์เรย์ จำนวนค่าในอาร์เรย์ Attributes และจำนวนจุดยอดใน Mesh ควรมีความสัมพันธ์แบบหนึ่งต่อหนึ่ง
7. ขั้นตอนถัดไป
ตอนนี้เราจะใช้เวลาสักครู่ในการเพิ่มลูปภาพเคลื่อนไหว, Attributes ของจุดยอด และ Uniform นอกจากนี้ เราจะเพิ่มตัวแปร Varying เพื่อให้ Vertex Shader ส่งข้อมูลบางอย่างไปยัง Fragment Shader ได้ ผลลัพธ์สุดท้ายคือทรงกลมสีชมพูจะปรากฏขึ้นราวกับว่ามีแสงส่องจากด้านบนและด้านข้าง และจะกะพริบ อาจดูแปลกตา แต่หวังว่าคุณจะเข้าใจตัวแปรทั้ง 3 ประเภท รวมถึงความสัมพันธ์ระหว่างตัวแปรเหล่านั้นกับรูปทรงเรขาคณิตพื้นฐาน
8. แสงปลอม
ตอนนี้ให้อัปเดตสีเพื่อให้ไม่ใช่แค่ออบเจ็กต์สีทึบ เราสามารถดูวิธีที่ Three.js จัดการแสงได้ แต่ผมเชื่อว่าคุณคงทราบดีว่าวิธีนั้นซับซ้อนกว่าที่เราต้องการ ดังนั้นเราจึงจะใช้แสงปลอม คุณควร ลองดู Shader ที่ยอดเยี่ยม ซึ่งเป็นส่วนหนึ่งของ Three.js รวมถึง Shader จากโปรเจ็กต์ WebGL ที่ น่าทึ่งของ Chris Milk และ Google อย่าง Rome กลับมาที่ Shader ของเรา เราจะอัปเดต Vertex Shader เพื่อให้ค่าปกติของจุดยอดแต่ละจุดแก่ Fragment Shader เราทำได้โดยใช้ Varying ดังนี้
// 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 เราจะตั้งชื่อตัวแปรเดียวกัน จากนั้นใช้ผลคูณเชิงสเกลาร์ของค่าปกติของจุดยอดกับเวกเตอร์ที่แสดงถึงแสงที่ส่องจากด้านบนและด้านขวาของทรงกลม ผลลัพธ์สุทธิของการดำเนินการนี้จะทำให้เกิดเอฟเฟกต์คล้ายกับแสงทิศทางในแพ็กเกจ 3 มิติ
// 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);
}
เหตุผลที่ผลคูณเชิงสเกลาร์ทำงานได้ก็คือ เมื่อกำหนดเวกเตอร์ 2 ตัว ผลคูณเชิงสเกลาร์จะให้ตัวเลขที่บอกว่าเวกเตอร์ 2 ตัวนั้น "คล้ายกัน" มากน้อยเพียงใด เมื่อใช้เวกเตอร์ที่ทำให้เป็นมาตรฐานแล้ว หากเวกเตอร์ชี้ไปในทิศทางเดียวกัน คุณจะได้รับค่าเป็น 1 หากเวกเตอร์ชี้ไปในทิศทางตรงกันข้าม คุณจะได้รับค่าเป็น -1 สิ่งที่เราทำคือใช้ตัวเลขนั้นกับแสง ดังนั้นจุดยอดที่ด้านขวาบนจะมีค่าใกล้เคียงหรือเท่ากับ 1 (สว่างเต็มที่) ในขณะที่จุดยอดที่ด้านข้างจะมีค่าใกล้เคียง 0 และจุดยอดที่ด้านหลังจะมีค่าเป็น -1 เราจะจำกัดค่าเป็น 0 สำหรับค่าที่เป็นลบ แต่เมื่อคุณป้อนตัวเลข คุณจะได้แสงพื้นฐานที่เราเห็น
ต้องทำอะไรต่อ เราอาจลองปรับเปลี่ยนตำแหน่งของจุดยอด
9. Attributes
สิ่งที่เราจะทำตอนนี้คือแนบตัวเลขสุ่มกับจุดยอดแต่ละจุดโดยใช้ Attribute เราจะใช้ตัวเลขนี้เพื่อดันจุดยอดออกไปตามค่าปกติ ผลลัพธ์สุทธิจะเป็นลูกบอลหนามประหลาดที่จะเปลี่ยนไปทุกครั้งที่คุณรีเฟรชหน้าเว็บ ลูกบอลจะยังไม่เคลื่อนไหว (จะเคลื่อนไหวในขั้นตอนถัดไป) แต่การรีเฟรชหน้าเว็บ 2-3 ครั้งจะแสดงให้เห็นว่าลูกบอลมีการสุ่ม
เริ่มต้นด้วยการเพิ่ม Attribute ลงใน 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);
}
ผลลัพธ์เป็นอย่างไร
ไม่แตกต่างกันมากนัก เนื่องจากไม่ได้ตั้งค่า Attribute ใน MeshShaderMaterial ดังนั้น Shader จึงใช้ค่าเป็น 0 แทน ตอนนี้ Attribute จึงเป็นเหมือนตัวยึดตำแหน่ง เราจะเพิ่ม Attribute ลงใน MeshShaderMaterial ใน JavaScript ในอีกสักครู่ และ Three.js จะเชื่อมโยงทั้ง 2 อย่างเข้าด้วยกันโดยอัตโนมัติ
นอกจากนี้ ผมยังต้องกำหนดตำแหน่งที่อัปเดตให้กับตัวแปร vec3 ใหม่ เนื่องจาก Attribute เดิมเป็นแบบอ่านอย่างเดียวเช่นเดียวกับ Attribute ทั้งหมด
10. การอัปเดต MeshShaderMaterial
มาอัปเดต MeshShaderMaterial ด้วย Attribute ที่จำเป็นต่อการขับเคลื่อนการเคลื่อนที่กันเลย โปรดทราบว่า Attributes เป็นค่าต่อจุดยอด ดังนั้นเราจึงต้องมีค่า 1 ค่าต่อจุดยอดในทรงกลม ดังนี้
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. ทำให้เป็นภาพเคลื่อนไหว
เราควรทำให้ลูกบอลนี้เคลื่อนไหว เราจะทำได้อย่างไร เราต้องตั้งค่า 2 อย่าง ได้แก่
- Uniform เพื่อกำหนดการเคลื่อนที่ที่จะใช้ในแต่ละเฟรม เราสามารถใช้ฟังก์ชัน Sine หรือ Cosine ได้เนื่องจากฟังก์ชันเหล่านี้มีค่าตั้งแต่ -1 ถึง 1
- ลูปภาพเคลื่อนไหวใน 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()
});
Shader ของเราพร้อมใช้งานแล้วในตอนนี้ แต่ตอนนี้เราดูเหมือนจะถอยหลังไป 1 ก้าว สาเหตุหลักเป็นเพราะค่าแอมพลิจูดของเราเป็น 0 และเนื่องจากเราคูณค่าแอมพลิจูดกับการเคลื่อนที่ เราจึงไม่เห็นการเปลี่ยนแปลงใดๆ นอกจากนี้ เรายังไม่ได้ตั้งค่าลูปภาพเคลื่อนไหว ดังนั้นเราจึงไม่เห็นค่า 0 เปลี่ยนเป็นค่าอื่น
ใน JavaScript ตอนนี้เราต้องรวมการเรียกใช้การแสดงผลไว้ในฟังก์ชัน จากนั้นใช้ 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);
บทสรุป
เพียงเท่านี้ก็เรียบร้อยแล้ว ตอนนี้คุณจะเห็นว่าลูกบอลเคลื่อนไหวในลักษณะกะพริบที่แปลกตา (และดูแปลกๆ เล็กน้อย)
เรายังสามารถพูดถึง Shader ได้อีกมากมาย และหวังว่าบทนำนี้จะเป็นประโยชน์กับคุณ ตอนนี้คุณควรจะเข้าใจ Shader เมื่อเห็น Shader และมีความมั่นใจที่จะสร้าง Shader ที่น่าทึ่งด้วยตนเอง