กราฟิก 3 มิติ: บทช่วยสอน WebGL

เผยแพร่แล้ว: 2022-03-11

โลกของกราฟิก 3D นั้นน่ากลัวมากที่จะเข้าไปข้างใน ไม่ว่าคุณจะเพียงแค่ต้องการสร้างโลโก้ 3D แบบโต้ตอบหรือออกแบบเกมเต็มรูปแบบ หากคุณไม่ทราบหลักการของการเรนเดอร์ 3 มิติ แสดงว่าคุณกำลังติดอยู่กับการใช้ไลบรารีที่สรุปสิ่งต่างๆ มากมาย

การใช้ไลบรารี่เป็นเครื่องมือที่เหมาะสม และ JavaScript มีโอเพ่นซอร์สที่น่าทึ่งอยู่ในรูปแบบของ three.js มีข้อเสียบางประการในการใช้โซลูชันที่สร้างไว้ล่วงหน้าแม้ว่า:

  • พวกเขาสามารถมีคุณลักษณะมากมายที่คุณไม่ได้วางแผนที่จะใช้ ขนาดของคุณสมบัติฐานย่อขนาดสาม.js อยู่ที่ประมาณ 500kB และคุณสมบัติพิเศษใดๆ (การโหลดไฟล์โมเดลจริงเป็นหนึ่งในนั้น) ทำให้เพย์โหลดใหญ่ขึ้น การถ่ายโอนข้อมูลจำนวนมากเพียงเพื่อแสดงโลโก้ที่หมุนบนเว็บไซต์ของคุณจะเป็นการสิ้นเปลือง
  • นามธรรมอีกชั้นหนึ่งสามารถทำให้การปรับเปลี่ยนง่าย ๆ อย่างอื่นทำได้ยาก วิธีที่สร้างสรรค์ของคุณในการแรเงาวัตถุบนหน้าจออาจเป็นแบบตรงไปตรงมาในการติดตั้ง หรือต้องใช้เวลาหลายสิบชั่วโมงในการทำงานเพื่อรวมเข้ากับสิ่งที่เป็นนามธรรมของห้องสมุด
  • ในขณะที่ไลบรารี่ได้รับการปรับให้เหมาะสมในสถานการณ์ส่วนใหญ่ คุณสามารถตัดเสียงระฆังและนกหวีดจำนวนมากสำหรับกรณีการใช้งานของคุณ ตัวแสดงภาพสามารถทำให้ขั้นตอนบางอย่างทำงานหลายล้านครั้งบนการ์ดแสดงผล ทุกคำสั่งที่ถูกลบออกจากขั้นตอนดังกล่าวหมายความว่าการ์ดกราฟิกที่อ่อนแอกว่าสามารถจัดการเนื้อหาของคุณได้โดยไม่มีปัญหา

แม้ว่าคุณจะตัดสินใจใช้ไลบรารีกราฟิกระดับสูง การมีความรู้พื้นฐานเกี่ยวกับสิ่งต่าง ๆ ภายใต้ประทุนจะช่วยให้คุณใช้งานได้อย่างมีประสิทธิภาพมากขึ้น ไลบรารีสามารถมีคุณลักษณะขั้นสูง เช่น ShaderMaterial ใน three.js การรู้หลักการของการแสดงผลกราฟิกช่วยให้คุณใช้คุณสมบัติดังกล่าวได้

ภาพประกอบของโลโก้ 3D Toptal บนผืนผ้าใบ WebGL

เป้าหมายของเราคือการแนะนำสั้น ๆ เกี่ยวกับแนวคิดหลักทั้งหมดที่อยู่เบื้องหลังการเรนเดอร์กราฟิก 3D และการใช้ WebGL เพื่อนำไปใช้ คุณจะเห็นสิ่งที่ทำกันโดยทั่วไปซึ่งแสดงและเคลื่อนย้ายวัตถุ 3 มิติในที่ว่าง

รหัสสุดท้ายมีให้สำหรับคุณในการแยกและเล่น

เป็นตัวแทนของโมเดล 3 มิติ

สิ่งแรกที่คุณต้องเข้าใจคือวิธีการแสดงโมเดล 3 มิติ โมเดลทำจากตาข่ายสามเหลี่ยม สามเหลี่ยมแต่ละรูปแทนด้วยจุดยอดสามจุด สำหรับแต่ละมุมของรูปสามเหลี่ยม มีคุณสมบัติทั่วไปสามประการที่แนบมากับจุดยอด

ตำแหน่งจุดสุดยอด

ตำแหน่งเป็นคุณสมบัติที่เข้าใจง่ายที่สุดของจุดยอด มันคือตำแหน่งในพื้นที่ 3 มิติ ซึ่งแสดงโดยเวกเตอร์ 3 มิติของพิกัด หากคุณทราบพิกัดที่แน่นอนของจุดสามจุดในอวกาศ คุณจะมีข้อมูลทั้งหมดที่จำเป็นในการวาดรูปสามเหลี่ยมอย่างง่ายระหว่างจุดทั้งสอง ในการทำให้โมเดลดูดีจริง ๆ เมื่อเรนเดอร์ มีอีกสองสามสิ่งที่จำเป็นต้องจัดเตรียมให้กับเรนเดอร์

จุดสุดยอดปกติ

ทรงกลมที่มีโครงลวดเดียวกันซึ่งมีการแรเงาแบบเรียบและเรียบ

พิจารณาทั้งสองรุ่นข้างต้น ประกอบด้วยตำแหน่งจุดยอดเดียวกัน แต่จะดูแตกต่างไปจากเดิมอย่างสิ้นเชิงเมื่อแสดงผล เป็นไปได้อย่างไร?

นอกจากการบอกผู้แสดงภาพว่าเราต้องการวางจุดยอดไว้ที่ใดแล้ว เรายังสามารถให้คำแนะนำว่าพื้นผิวเอียงในตำแหน่งที่แน่นอนนั้นเป็นอย่างไร คำใบ้จะอยู่ในรูปของพื้นผิวปกติ ณ จุดเฉพาะนั้นบนแบบจำลอง ซึ่งแสดงด้วยเวกเตอร์ 3 มิติ รูปภาพต่อไปนี้ควรให้รายละเอียดเพิ่มเติมเกี่ยวกับวิธีการจัดการ

การเปรียบเทียบระหว่างค่าปกติสำหรับการแรเงาแบบเรียบและแบบเรียบ

พื้นผิวด้านซ้ายและขวาสอดคล้องกับลูกซ้ายและขวาในภาพก่อนหน้าตามลำดับ ลูกศรสีแดงแสดงถึงเส้นปกติที่กำหนดไว้สำหรับจุดยอด ในขณะที่ลูกศรสีน้ำเงินแสดงถึงการคำนวณของผู้เรนเดอร์ว่าจุดปกติควรมองหาจุดทั้งหมดระหว่างจุดยอดอย่างไร รูปภาพแสดงการสาธิตสำหรับพื้นที่ 2D แต่หลักการเดียวกันนี้ใช้กับ 3D

ภาวะปกติเป็นสัญญาณบ่งบอกว่าแสงจะส่องไปที่พื้นผิวอย่างไร ยิ่งทิศทางของรังสีแสงเข้าใกล้เส้นตั้งฉากมากเท่าใด จุดนั้นก็จะยิ่งสว่างมากขึ้นเท่านั้น การเปลี่ยนแปลงอย่างค่อยเป็นค่อยไปในทิศทางปกติทำให้เกิดการไล่ระดับของแสง ในขณะที่การเปลี่ยนแปลงอย่างกะทันหันโดยไม่มีการเปลี่ยนแปลงใด ๆ ระหว่างนั้นจะทำให้พื้นผิวมีการส่องสว่างอย่างต่อเนื่องทั่วทั้งพื้นผิว และการเปลี่ยนแปลงอย่างฉับพลันในการส่องสว่างระหว่างกัน

พิกัดพื้นผิว

คุณสมบัติที่สำคัญประการสุดท้ายคือพิกัดพื้นผิว ซึ่งโดยทั่วไปเรียกว่าการทำแผนที่ UV คุณมีแบบจำลองและพื้นผิวที่คุณต้องการนำไปใช้กับมัน พื้นผิวมีพื้นที่ต่างๆ แทนภาพที่เราต้องการนำไปใช้กับส่วนต่างๆ ของโมเดล ต้องมีวิธีทำเครื่องหมายว่าควรแสดงสามเหลี่ยมใดกับส่วนใดของพื้นผิว นั่นคือที่มาของการทำแผนที่พื้นผิว

สำหรับแต่ละจุดยอด เราจะทำเครื่องหมายสองพิกัด U และ V พิกัดเหล่านี้แสดงตำแหน่งบนพื้นผิว โดยที่ U แทนแกนนอน และ V แกนตั้ง ค่าไม่ได้เป็นพิกเซล แต่เป็นตำแหน่งเปอร์เซ็นต์ภายในรูปภาพ มุมล่างซ้ายของรูปภาพจะแสดงด้วยศูนย์สองตัว ในขณะที่มุมบนขวาจะแสดงด้วยสองตัว

สามเหลี่ยมถูกวาดโดยการใช้พิกัด UV ของแต่ละจุดยอดในรูปสามเหลี่ยม และใช้ภาพที่ถ่ายระหว่างพิกัดเหล่านั้นกับพื้นผิว

การสาธิตการทำแผนที่ UV โดยไฮไลต์หนึ่งแพทช์ และมองเห็นตะเข็บบนโมเดล

คุณสามารถดูการสาธิตการทำแผนที่ UV ได้จากภาพด้านบน แบบจำลองทรงกลมถูกถ่ายและตัดเป็นชิ้นส่วนที่มีขนาดเล็กพอที่จะทำให้แบนลงบนพื้นผิว 2 มิติ ตะเข็บที่ทำการตัดจะมีเส้นหนาขึ้น แพทช์หนึ่งถูกไฮไลท์ไว้ คุณจึงมองเห็นได้อย่างชัดเจนว่าสิ่งต่าง ๆ เข้ากันอย่างไร คุณยังสามารถดูได้ว่ารอยต่อตรงกลางของรอยยิ้มทำให้ส่วนต่างๆ ของปากแตกออกเป็นสองส่วนได้อย่างไร

โครงลวดไม่ได้เป็นส่วนหนึ่งของพื้นผิว แต่เป็นเพียงการซ้อนทับบนรูปภาพ เพื่อให้คุณเห็นว่าสิ่งต่างๆ เชื่อมโยงกันอย่างไร

กำลังโหลดโมเดล OBJ

เชื่อหรือไม่ นี่คือสิ่งที่คุณต้องรู้เพื่อสร้างตัวโหลดโมเดลที่เรียบง่ายของคุณเอง รูปแบบไฟล์ OBJ นั้นง่ายพอที่จะติดตั้ง parser ในโค้ดไม่กี่บรรทัด

ไฟล์แสดงตำแหน่งจุดยอดในรูปแบบ v <float> <float> <float> พร้อมตัวเลือกการลอยตัวที่สี่ ซึ่งเราจะละเว้น เพื่อให้ทุกอย่างง่ายขึ้น จุดสุดยอดปกติจะแสดงในทำนองเดียวกันกับ vn <float> <float> <float> ในที่สุด พิกัดพื้นผิวจะแสดงด้วย vt <float> <float> โดยมีตัวเลือกการลอยตัวที่สามซึ่งเราจะละเว้น ในทั้งสามกรณี ทุ่นลอยเป็นตัวแทนของพิกัดที่เกี่ยวข้อง คุณสมบัติทั้งสามนี้สะสมอยู่ในสามอาร์เรย์

ใบหน้าจะแสดงด้วยกลุ่มของจุดยอด จุดยอดแต่ละจุดจะแสดงด้วยดัชนีของคุณสมบัติแต่ละรายการ โดยดัชนีเริ่มต้นที่ 1 มีหลายวิธีในการแสดงสิ่งนี้ แต่เราจะยึดตามรูปแบบ f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 โดยกำหนดให้ต้องจัดเตรียมคุณสมบัติทั้งสามไว้ และจำกัดจำนวนจุดยอดต่อหนึ่งหน้าเป็นสาม ข้อจำกัดทั้งหมดเหล่านี้กำลังดำเนินการอยู่เพื่อให้ตัวโหลดทำงานได้ง่ายที่สุด เนื่องจากตัวเลือกอื่นๆ ทั้งหมดต้องมีการประมวลผลเล็กน้อยก่อนที่จะอยู่ในรูปแบบที่ WebGL ชอบ

เราได้ใส่ข้อกำหนดมากมายสำหรับตัวโหลดไฟล์ของเรา นั่นอาจฟังดูจำกัด แต่แอปพลิเคชันการสร้างแบบจำลอง 3 มิติมักจะให้ความสามารถในการตั้งค่าข้อจำกัดเหล่านั้นเมื่อส่งออกแบบจำลองเป็นไฟล์ OBJ

รหัสต่อไปนี้แยกวิเคราะห์สตริงที่แสดงถึงไฟล์ OBJ และสร้างแบบจำลองในรูปแบบของอาร์เรย์ของใบหน้า

 function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }

โครงสร้าง Geometry เก็บข้อมูลที่จำเป็นในการส่งแบบจำลองไปยังการ์ดกราฟิกเพื่อประมวลผล ก่อนที่คุณจะทำอย่างนั้น คุณอาจต้องการให้มีความสามารถในการย้ายโมเดลไปรอบๆ บนหน้าจอ

การดำเนินการแปลงเชิงพื้นที่

จุดทั้งหมดในแบบจำลองที่เราโหลดนั้นสัมพันธ์กับระบบพิกัด หากเราต้องการแปล หมุน และปรับขนาดโมเดล สิ่งที่เราต้องทำคือดำเนินการนั้นบนระบบพิกัดของมัน ระบบพิกัด A สัมพันธ์กับระบบพิกัด B ถูกกำหนดโดยตำแหน่งของจุดศูนย์กลางเป็นเวกเตอร์ p_ab และเวกเตอร์สำหรับแต่ละแกน x_ab , y_ab และ z_ab แทนทิศทางของแกนนั้น ดังนั้นหากจุดเคลื่อนที่ด้วย 10 บนแกน x ของระบบพิกัด A ดังนั้น—ในระบบพิกัด B—จุดนั้นจะเคลื่อนที่ไปในทิศทางของ x_ab คูณด้วย 10

ข้อมูลทั้งหมดนี้ถูกจัดเก็บในรูปแบบเมทริกซ์ต่อไปนี้:

 x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1

หากเราต้องการแปลงเวกเตอร์ 3 มิติ q เราแค่ต้องคูณเมทริกซ์การแปลงกับเวกเตอร์:

 qx qy qz 1

สิ่งนี้ทำให้จุดเคลื่อนที่ทีละ qx ตามแกน x ใหม่ โดย qy ตามแกน y ใหม่และโดย qz ตามแกน z ใหม่ สุดท้ายมันทำให้จุดเคลื่อนที่เพิ่มเติมโดยเวกเตอร์ p ซึ่งเป็นเหตุผลว่าทำไมเราใช้จุดหนึ่งเป็นองค์ประกอบสุดท้ายของการคูณ

ข้อได้เปรียบใหญ่ของการใช้เมทริกซ์เหล่านี้คือความจริงที่ว่า ถ้าเรามีหลายการแปลงที่จะดำเนินการบนจุดยอด เราสามารถรวมพวกมันเป็นการแปลงเดียวได้โดยการคูณเมทริกซ์ของมัน ก่อนที่จะแปลงจุดยอดเอง

มีการแปลงต่างๆ ที่สามารถทำได้ และเราจะมาดูการเปลี่ยนแปลงที่สำคัญกัน

ไม่มีการเปลี่ยนแปลง

หากไม่มีการแปลงเกิดขึ้น เวกเตอร์ p จะเป็นเวกเตอร์ศูนย์ เวกเตอร์ x คือ [1, 0, 0] , y คือ [0, 1, 0] และ z คือ [0, 0, 1] จากนี้ไปเราจะอ้างถึงค่าเหล่านี้เป็นค่าเริ่มต้นสำหรับเวกเตอร์เหล่านี้ การใช้ค่าเหล่านี้ทำให้เรามีเมทริกซ์เอกลักษณ์:

 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1

นี่เป็นจุดเริ่มต้นที่ดีสำหรับการโยงการเปลี่ยนแปลง

การแปล

การแปลงเฟรมสำหรับการแปล

เมื่อเราดำเนินการแปล เวกเตอร์ทั้งหมดยกเว้นเวกเตอร์ p จะมีค่าเริ่มต้น ซึ่งส่งผลให้เมทริกซ์ต่อไปนี้:

 1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1

มาตราส่วน

การแปลงเฟรมสำหรับการปรับขนาด

การปรับขนาดแบบจำลองหมายถึงการลดจำนวนที่แต่ละพิกัดมีส่วนทำให้เกิดตำแหน่งของจุด ไม่มีอ็อฟเซ็ตสม่ำเสมอที่เกิดจากการปรับขนาด ดังนั้นเวกเตอร์ p จะคงค่าเริ่มต้นไว้ เวกเตอร์แกนเริ่มต้นควรคูณด้วยปัจจัยมาตราส่วนตามลำดับ ซึ่งส่งผลให้เมทริกซ์ต่อไปนี้:

 s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1

ที่นี่ s_x , s_y และ s_z แสดงถึงการปรับขนาดที่ใช้กับแต่ละแกน

การหมุน

การแปลงเฟรมสำหรับการหมุนรอบแกน Z

ภาพด้านบนแสดงให้เห็นว่าเกิดอะไรขึ้นเมื่อเราหมุนกรอบพิกัดรอบแกน Z

การหมุนส่งผลให้ไม่มีการชดเชยที่สม่ำเสมอ ดังนั้นเวกเตอร์ p จะคงค่าเริ่มต้นไว้ ตอนนี้สิ่งต่าง ๆ เริ่มซับซ้อนขึ้นเล็กน้อย การหมุนทำให้การเคลื่อนที่ไปตามแกนในระบบพิกัดเดิมเคลื่อนไปในทิศทางอื่น ดังนั้นหากเราหมุนระบบพิกัด 45 องศารอบแกน Z การเคลื่อนไปตามแกน x ของระบบพิกัดเดิมจะทำให้เกิดการเคลื่อนที่ในแนวทแยงระหว่างแกน x และ y ในระบบพิกัดใหม่

เพื่อให้ง่ายขึ้น เราจะแสดงให้คุณเห็นว่าเมทริกซ์การแปลงมองหาการหมุนรอบแกนหลักอย่างไร

 Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1

การดำเนินการ

ทั้งหมดนี้สามารถใช้เป็นคลาสที่เก็บตัวเลขได้ 16 ตัว โดยจัดเก็บเมทริกซ์ในลำดับคอลัมน์หลัก

 function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }

มองผ่านกล้อง

นี่คือส่วนสำคัญของการนำเสนอวัตถุบนหน้าจอ นั่นคือ กล้อง กล้องมีองค์ประกอบหลักสองอย่าง กล่าวคือ ตำแหน่ง และวิธีที่มันฉายวัตถุที่สังเกตได้บนหน้าจอ

จัดการตำแหน่งของกล้องด้วยเคล็ดลับง่ายๆ เพียงข้อเดียว ไม่มีความแตกต่างทางภาพระหว่างการเคลื่อนกล้องไปข้างหน้าหนึ่งเมตรกับการย้ายโลกทั้งใบไปข้างหลังหนึ่งเมตร โดยธรรมชาติแล้ว เราทำอย่างหลัง โดยใช้อินเวอร์สของเมทริกซ์เป็นการแปลง

องค์ประกอบหลักที่สองคือวิธีที่วัตถุที่สังเกตพบถูกฉายลงบนเลนส์ ใน WebGL ทุกสิ่งที่ปรากฏบนหน้าจอจะอยู่ในกล่อง กล่องมีช่วงระหว่าง -1 ถึง 1 ในแต่ละแกน ทุกสิ่งที่มองเห็นได้อยู่ภายในกล่องนั้น เราสามารถใช้วิธีการเดียวกันของเมทริกซ์การแปลงเพื่อสร้างเมทริกซ์การฉายภาพ

การฉายภาพออร์โธกราฟิก

พื้นที่สี่เหลี่ยมถูกแปลงเป็นขนาดเฟรมบัฟเฟอร์ที่เหมาะสมโดยใช้การฉายภาพออร์โธกราฟิก

การฉายภาพที่ง่ายที่สุดคือการฉายภาพแบบออร์โธกราฟิก คุณใส่กล่องในช่องว่าง แสดงถึงความกว้าง ความสูง และความลึก โดยสันนิษฐานว่าจุดศูนย์กลางอยู่ที่ตำแหน่งศูนย์ จากนั้นการฉายภาพจะปรับขนาดกล่องให้พอดีกับกล่องที่อธิบายไว้ก่อนหน้านี้ซึ่ง WebGL สังเกตวัตถุ เนื่องจากเราต้องการปรับขนาดแต่ละส่วนให้เป็นสอง เราจึงปรับขนาดแต่ละแกนด้วย 2/size โดยที่ size จะเป็นมิติของแกนที่เกี่ยวข้อง ข้อแม้เล็กๆ น้อยๆ คือความจริงที่ว่าเรากำลังคูณแกน Z ด้วยค่าลบ ที่ทำได้เพราะเราต้องการพลิกทิศทางของมิตินั้น เมทริกซ์สุดท้ายมีรูปแบบนี้:

 2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1

การฉายภาพมุมมอง

Frustum ถูกแปลงเป็นมิติเฟรมบัฟเฟอร์ที่เหมาะสมโดยใช้การฉายภาพเปอร์สเปคทีฟ

เราจะไม่ลงรายละเอียดเกี่ยวกับวิธีการออกแบบการฉายภาพนี้ แต่ใช้สูตรสุดท้ายซึ่งค่อนข้างเป็นมาตรฐานในตอนนี้ เราสามารถทำให้มันง่ายขึ้นได้โดยวางการฉายภาพในตำแหน่งศูนย์บนแกน x และ y ทำให้ขีดจำกัดขวา/ซ้ายและบน/ล่างเท่ากับ width/2 และ height/2 ตามลำดับ พารามิเตอร์ n และ f แสดงถึงระนาบการตัด near และ far ซึ่งเป็นระยะที่เล็กที่สุดและใหญ่ที่สุดที่กล้องสามารถจับภาพได้ พวกมันแสดงโดยด้านขนานของ frustum ในภาพด้านบน

การฉายภาพเปอร์สเปคทีฟมักจะแสดงด้วยมุมมองภาพ (เราจะใช้มุมมองแนวตั้ง) อัตราส่วนภาพ และระยะทางระนาบใกล้และไกล ข้อมูลดังกล่าวสามารถใช้ในการคำนวณ width และ height จากนั้นจึงสร้างเมทริกซ์จากเทมเพลตต่อไปนี้

 2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0

ในการคำนวณความกว้างและความสูง สามารถใช้สูตรต่อไปนี้:

 height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height

FOV (ขอบเขตการมองเห็น) แสดงถึงมุมแนวตั้งที่กล้องจับภาพด้วยเลนส์ อัตราส่วนกว้างยาวแสดงถึงอัตราส่วนระหว่างความกว้างและความสูงของภาพ และขึ้นอยู่กับขนาดของหน้าจอที่เราแสดงผล

การดำเนินการ

ตอนนี้เราสามารถแสดงกล้องเป็นคลาสที่เก็บตำแหน่งกล้องและเมทริกซ์การฉายภาพ เราต้องรู้วิธีคำนวณการแปลงผกผันด้วย การแก้ปัญหาการผกผันของเมทริกซ์ทั่วไปอาจเป็นปัญหาได้ แต่มีวิธีการที่ง่ายกว่าสำหรับกรณีพิเศษของเรา

 function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }

นี่เป็นงานชิ้นสุดท้ายที่เราต้องการก่อนที่เราจะสามารถเริ่มวาดสิ่งต่างๆ บนหน้าจอได้

การวาดวัตถุด้วยไปป์ไลน์กราฟิก WebGL

พื้นผิวที่ง่ายที่สุดที่คุณสามารถวาดได้คือสามเหลี่ยม อันที่จริง สิ่งที่คุณวาดในพื้นที่ 3 มิติส่วนใหญ่ประกอบด้วยสามเหลี่ยมจำนวนมาก

ข้อมูลเบื้องต้นเกี่ยวกับขั้นตอนต่างๆ ของไปป์ไลน์กราฟิก

สิ่งแรกที่คุณต้องเข้าใจคือการแสดงหน้าจอใน WebGL เป็นช่องว่าง 3 มิติ ซึ่งขยายระหว่าง -1 ถึง 1 บนแกน x y และ z โดยค่าเริ่มต้นแกน z นี้ไม่ได้ใช้ แต่คุณสนใจกราฟิก 3 มิติ ดังนั้นคุณจะต้องเปิดใช้งานทันที

เมื่อคำนึงถึงสิ่งนั้นแล้ว สามขั้นตอนที่จำเป็นในการวาดรูปสามเหลี่ยมลงบนพื้นผิวนี้

คุณสามารถกำหนดจุดยอดสามจุด ซึ่งจะเป็นตัวแทนของสามเหลี่ยมที่คุณต้องการวาด คุณทำให้ข้อมูลเป็นอนุกรมและส่งไปยัง GPU (หน่วยประมวลผลกราฟิก) เมื่อมีแบบจำลองทั้งหมด คุณสามารถทำสิ่งนั้นกับสามเหลี่ยมทั้งหมดในแบบจำลองได้ ตำแหน่งจุดยอดที่คุณระบุอยู่ในพื้นที่พิกัดในตัวเครื่องของแบบจำลองที่คุณโหลด พูดง่ายๆ ก็คือ ตำแหน่งที่คุณระบุคือตำแหน่งที่แน่นอนจากไฟล์ ไม่ใช่ตำแหน่งที่คุณได้รับหลังจากทำการแปลงเมทริกซ์

เมื่อคุณกำหนดจุดยอดให้กับ GPU แล้ว คุณก็บอก GPU ว่าจะใช้ตรรกะใดในการวางจุดยอดลงบนหน้าจอ ขั้นตอนนี้จะใช้เพื่อใช้การแปลงเมทริกซ์ของเรา GPU นั้นเก่งมากในการคูณเมทริกซ์ 4x4 จำนวนมาก ดังนั้นเราจะนำความสามารถนั้นไปใช้ให้เกิดประโยชน์

ในขั้นตอนสุดท้าย GPU จะแรสเตอร์รูปสามเหลี่ยมนั้น การแรสเตอร์เป็นกระบวนการของการถ่ายภาพกราฟิกแบบเวกเตอร์และกำหนดพิกเซลของหน้าจอที่ต้องทาสีเพื่อให้วัตถุกราฟิกแบบเวกเตอร์นั้นแสดงได้ ในกรณีของเรา GPU พยายามกำหนดพิกเซลที่อยู่ภายในแต่ละรูปสามเหลี่ยม สำหรับแต่ละพิกเซล GPU จะถามคุณว่าคุณต้องการให้สีอะไร

นี่คือสี่องค์ประกอบที่จำเป็นในการวาดอะไรก็ได้ที่คุณต้องการ และเป็นตัวอย่างที่ง่ายที่สุดของไปป์ไลน์กราฟิก สิ่งที่ตามมาคือการดูแต่ละรายการและการนำไปใช้อย่างง่าย

เฟรมบัฟเฟอร์เริ่มต้น

องค์ประกอบที่สำคัญที่สุดสำหรับแอปพลิเคชัน WebGL คือบริบทของ WebGL คุณสามารถเข้าถึงได้ด้วย gl = canvas.getContext('webgl') หรือใช้ 'experimental-webgl' เป็นทางเลือก ในกรณีที่เบราว์เซอร์ที่ใช้อยู่ในปัจจุบันยังไม่รองรับคุณลักษณะ WebGL ทั้งหมด canvas ที่เราอ้างถึงคือองค์ประกอบ DOM ของผืนผ้าใบที่เราต้องการวาด บริบทประกอบด้วยหลายสิ่งหลายอย่าง ซึ่งในนั้นคือเฟรมบัฟเฟอร์เริ่มต้น

คุณสามารถอธิบาย framebuffer อย่างหลวม ๆ ว่าเป็นบัฟเฟอร์ (วัตถุ) ใด ๆ ที่คุณสามารถวาดได้ ตามค่าเริ่มต้น framebuffer เริ่มต้นจะเก็บสีสำหรับแต่ละพิกเซลของแคนวาสที่บริบท WebGL เชื่อมโยงอยู่ ตามที่อธิบายไว้ในส่วนก่อนหน้า เมื่อเราวาดบนเฟรมบัฟเฟอร์ แต่ละพิกเซลจะอยู่ระหว่าง -1 ถึง 1 บนแกน x และ y สิ่งที่เราพูดถึงก็คือความจริงที่ว่า WebGL ไม่ได้ใช้แกน z ตามค่าเริ่มต้น ฟังก์ชันนั้นสามารถเปิดใช้งานได้โดยใช้ gl.enable(gl.DEPTH_TEST) ดีมาก แต่การทดสอบเชิงลึกคืออะไร?

การเปิดใช้งานการทดสอบความลึกทำให้พิกเซลสามารถจัดเก็บทั้งสีและความลึกได้ ความลึกคือพิกัด z ของพิกเซลนั้น หลังจากที่คุณวาดเป็นพิกเซลที่ระดับความลึก z เพื่ออัปเดตสีของพิกเซลนั้น คุณต้องวาดที่ตำแหน่ง z ที่อยู่ใกล้กับกล้องมากขึ้น มิฉะนั้น ความพยายามในการจับฉลากจะถูกละเว้น สิ่งนี้ทำให้เกิดภาพลวงตาของ 3D เนื่องจากการวาดวัตถุที่อยู่ข้างหลังวัตถุอื่นจะทำให้วัตถุเหล่านั้นถูกวัตถุที่อยู่ข้างหน้าบดบัง

การจับฉลากใดๆ ที่คุณแสดงจะอยู่บนหน้าจอจนกว่าคุณจะบอกให้เคลียร์ ในการทำเช่นนั้น คุณต้องเรียก gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) สิ่งนี้จะล้างทั้งบัฟเฟอร์สีและความลึก หากต้องการเลือกสีที่ตั้งค่าพิกเซลที่ชัดเจน ให้ใช้ gl.clearColor(red, green, blue, alpha)

มาสร้างตัวแสดงภาพที่ใช้แคนวาสและล้างตามคำขอ:

 function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }

การแนบสคริปต์นี้กับ HTML ต่อไปนี้จะทำให้คุณมีสี่เหลี่ยมผืนผ้าสีฟ้าสดใสบนหน้าจอ

 <!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>

การเรียก requestAnimationFrame ทำให้การวนซ้ำถูกเรียกอีกครั้งทันทีที่เฟรมก่อนหน้าเสร็จสิ้นการเรนเดอร์และการจัดการเหตุการณ์ทั้งหมดเสร็จสิ้น

วัตถุบัฟเฟอร์จุดสุดยอด

สิ่งแรกที่คุณต้องทำคือกำหนดจุดยอดที่คุณต้องการวาด คุณสามารถทำได้โดยอธิบายผ่านเวกเตอร์ในพื้นที่ 3 มิติ หลังจากนั้น คุณต้องการย้ายข้อมูลนั้นไปยัง GPU RAM โดยสร้าง Vertex Buffer Object (VBO) ใหม่

Buffer Object โดยทั่วไปคืออ็อบเจ็กต์ที่เก็บอาร์เรย์ของหน่วยความจำบน GPU การเป็น VBO เป็นการบ่งบอกว่า GPU สามารถใช้หน่วยความจำทำอะไรได้บ้าง โดยส่วนใหญ่ Buffer Objects ที่คุณสร้างจะเป็น VBO

คุณสามารถเติม VBO ได้โดยใช้จุดยอด N ทั้งหมดที่เรามี และสร้างอาร์เรย์ของการลอยตัวด้วยองค์ประกอบ 3N สำหรับตำแหน่งจุดยอดและจุดยอดปกติของ VBO และ 2N สำหรับพื้นผิวพิกัด VBO แต่ละกลุ่มที่มีสามทุ่นหรือสองทุ่นสำหรับพิกัด UV แสดงถึงพิกัดแต่ละจุดของจุดยอด จากนั้นเราก็ส่งอาร์เรย์เหล่านี้ไปยัง GPU และจุดยอดของเราก็พร้อมสำหรับไปป์ไลน์ที่เหลือ

เนื่องจากตอนนี้ข้อมูลอยู่ใน GPU RAM คุณจึงสามารถลบออกจาก RAM ที่ใช้งานทั่วไปได้ นั่นคือ เว้นแต่คุณต้องการแก้ไขในภายหลัง และอัปโหลดอีกครั้ง การปรับเปลี่ยนแต่ละครั้งจะต้องตามมาด้วยการอัปโหลด เนื่องจากการปรับเปลี่ยนในอาร์เรย์ JS ของเราจะไม่มีผลกับ VBO ใน GPU RAM จริง

ด้านล่างนี้คือตัวอย่างโค้ดที่มีฟังก์ชันการทำงานที่อธิบายไว้ทั้งหมด หมายเหตุสำคัญที่ต้องทำคือข้อเท็จจริงที่ว่าตัวแปรที่จัดเก็บไว้ใน GPU นั้นไม่ได้ถูกรวบรวมเป็นขยะ นั่นหมายความว่าเราต้องลบออกด้วยตนเองเมื่อเราไม่ต้องการใช้อีกต่อไป เราจะยกตัวอย่างให้คุณเห็นถึงวิธีการดำเนินการที่นี่ และจะไม่เน้นที่แนวคิดนั้นเพิ่มเติม การลบตัวแปรออกจาก GPU จำเป็นเฉพาะเมื่อคุณวางแผนที่จะหยุดใช้รูปทรงเรขาคณิตบางอย่างตลอดทั้งโปรแกรม

เรายังเพิ่มการซีเรียลไลซ์เซชันให้กับคลาส Geometry และองค์ประกอบภายในนั้นด้วย

 Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }

ชนิดข้อมูล VBO สร้าง VBO ในบริบท WebGL ที่ส่งผ่าน โดยยึดตามอาร์เรย์ที่ส่งผ่านเป็นพารามิเตอร์ที่สอง

คุณสามารถดูการเรียกบริบท gl สามครั้งได้ การ createBuffer() จะสร้างบัฟเฟอร์ การ bindBuffer() บอกให้เครื่องสถานะ WebGL ใช้หน่วยความจำเฉพาะนี้เป็น VBO ปัจจุบัน ( ARRAY_BUFFER ) สำหรับการดำเนินการในอนาคตทั้งหมด จนกว่าจะแจ้งเป็นอย่างอื่น หลังจากนั้น เราตั้งค่าของ VBO ปัจจุบันเป็นข้อมูลที่จัดให้ด้วย bufferData()

เรายังจัดเตรียมวิธีการทำลายที่จะลบบัฟเฟอร์อ็อบเจ็กต์ของเราออกจาก GPU RAM โดยใช้ deleteBuffer()

คุณสามารถใช้ VBO สามรายการและการแปลงหนึ่งรายการเพื่ออธิบายคุณสมบัติทั้งหมดของเมช ร่วมกับตำแหน่งของเมช

 function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }

ตัวอย่างเช่น นี่คือวิธีที่เราสามารถโหลดโมเดล เก็บคุณสมบัติของมันไว้ในเมช แล้วทำลายมัน:

 Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })

Shaders

ต่อไปนี้คือกระบวนการสองขั้นตอนที่อธิบายไว้ก่อนหน้านี้ในการย้ายจุดไปยังตำแหน่งที่ต้องการและระบายสีแต่ละพิกเซล ในการทำเช่นนี้ เราเขียนโปรแกรมที่ทำงานบนการ์ดกราฟิกหลายครั้ง โปรแกรมนี้โดยทั่วไปประกอบด้วยอย่างน้อยสองส่วน ส่วนแรกคือ Vertex Shader ซึ่งทำงานสำหรับแต่ละจุดยอด และส่งออกตำแหน่งที่เราควรวางจุดยอดไว้บนหน้าจอ เหนือสิ่งอื่นใด ส่วนที่สองคือ Fragment Shader ซึ่งทำงานสำหรับแต่ละพิกเซลที่รูปสามเหลี่ยมครอบคลุมบนหน้าจอ และแสดงผลสีที่ควรทาสีพิกเซล

Vertex Shaders

สมมติว่าคุณต้องการให้มีแบบจำลองที่เคลื่อนที่ไปทางซ้ายและขวาบนหน้าจอ ด้วยวิธีที่ไร้เดียงสา คุณสามารถอัปเดตตำแหน่งของจุดสุดยอดแต่ละจุดและส่งไปยัง GPU อีกครั้งได้ กระบวนการนั้นมีราคาแพงและช้า อีกทางหนึ่ง คุณจะให้โปรแกรมสำหรับ GPU ทำงานสำหรับแต่ละจุดยอด และดำเนินการทั้งหมดควบคู่ไปกับโปรเซสเซอร์ที่สร้างขึ้นเพื่อทำงานนั้นอย่างแท้จริง นั่นคือบทบาทของ เชดเดอร์จุดยอด

Shader จุดยอดเป็นส่วนหนึ่งของไปป์ไลน์การเรนเดอร์ที่ประมวลผลจุดยอดแต่ละจุด การเรียกไปยังจุดยอดเชเดอร์จะได้รับจุดยอดเดียวและส่งออกจุดยอดเดียวหลังจากใช้การแปลงที่เป็นไปได้ทั้งหมดไปยังจุดยอด

Shaders เขียนด้วย GLSL มีองค์ประกอบที่เป็นเอกลักษณ์มากมายสำหรับภาษานี้ แต่ไวยากรณ์ส่วนใหญ่คล้ายกับ C มาก ดังนั้นคนส่วนใหญ่จึงควรเข้าใจได้

มีตัวแปรสามประเภทที่เข้าและออกจากจุดสุดยอด Shader และทั้งหมดนี้มีไว้เพื่อการใช้งานเฉพาะ:

  • attribute — เป็นอินพุตที่มีคุณสมบัติเฉพาะของจุดยอด ก่อนหน้านี้ เราได้อธิบายตำแหน่งของจุดยอดเป็นแอตทริบิวต์ ในรูปแบบของเวกเตอร์สามองค์ประกอบ คุณสามารถดูแอตทริบิวต์เป็นค่าที่อธิบายจุดยอดหนึ่งจุดได้
  • uniform — สิ่งเหล่านี้คืออินพุตที่เหมือนกันสำหรับทุกจุดยอดภายในการเรียกใช้การเรนเดอร์เดียวกัน สมมติว่าเราต้องการย้ายโมเดลของเราไปรอบๆ โดยกำหนดเมทริกซ์การแปลง คุณสามารถใช้ตัวแปร uniform เพื่ออธิบายว่า คุณสามารถชี้ไปที่ทรัพยากรบน GPU ได้เช่นกัน เช่น พื้นผิว คุณสามารถดูชุดเครื่องแบบเป็นค่าที่อธิบายแบบจำลองหรือส่วนหนึ่งของแบบจำลองได้
  • varying — นี่คือผลลัพธ์ที่เราส่งไปยังตัวแยกส่วน เนื่องจากอาจมีพิกเซลหลายพันพิกเซลสำหรับจุดยอดสามเหลี่ยม แต่ละพิกเซลจะได้รับค่าที่สอดแทรกสำหรับตัวแปรนี้ ขึ้นอยู่กับตำแหน่ง ดังนั้นหากจุดยอดหนึ่งส่ง 500 เป็นเอาต์พุต และอีกจุดหนึ่งส่ง 100 พิกเซลที่อยู่ตรงกลางระหว่างจุดทั้งสองจะได้รับ 300 เป็นอินพุตสำหรับตัวแปรนั้น คุณสามารถดูความแปรผันเป็นค่าที่อธิบายพื้นผิวระหว่างจุดยอดได้

So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” คุณอาจจะถาม

 attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }

Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main function. All values that we would want to return are assigned, either to varying variables, or to special variables. Here we assign to gl_Position , which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4 out of the position vector. You can construct a vec4 by using four float s, two vec2 s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.

You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.

For now, we will keep it simple, and move on to painting individual pixels.

Fragment Shaders

A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.

The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:

  • There are no more varying outputs, and attribute inputs have been replaced with varying inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader.
  • Our only output now is gl_FragColor , which is a vec4 . The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects.
  • At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.

With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }

The function clamp just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.

With all of this in mind, all that is left is to implement this in WebGL.

Combining Shaders into a Program

The next step is to combine the shaders into a program:

 function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }

There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.

Actually Drawing the Model

Last, but not least, you draw the model.

First you pick the shader program you want to use.

 ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }

Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.

 Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }

Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.

 VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }

Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays() from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES takes three by three vertices and draws a triangle for each triplet. Using POINTS would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:

 Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }

The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.

 Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

We can combine all the elements that we have to finally draw something on the screen:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) } 

Object drawn on the canvas, with colors depending on UV coordinates

This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); } 

Brown object drawn on the canvas

It doesn't look very convincing. It looks like the scene needs some shading effects.

Adding Light

Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.

Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.

Demonstration of angles between light rays and surface normals, for both flat and smooth shading

You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.

If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); } 

Brown object with sunlight

เราตั้งดวงอาทิตย์ให้ส่องแสงไปข้างหน้า-ซ้าย-ลง คุณสามารถดูได้ว่าการแรเงานั้นราบรื่นเพียงใด แม้ว่าโมเดลจะขรุขระมากก็ตาม คุณยังสังเกตได้ว่าด้านล่างซ้ายมืดแค่ไหน เราสามารถเพิ่มระดับของแสงโดยรอบ ซึ่งจะทำให้พื้นที่ในเงามืดสว่างขึ้น

 #ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); } 

วัตถุสีน้ำตาลที่มีแสงแดดและแสงโดยรอบ

คุณสามารถบรรลุผลเช่นเดียวกันนี้ได้โดยการแนะนำคลาสแสง ซึ่งเก็บทิศทางของแสงและความเข้มของแสงโดยรอบ จากนั้นคุณสามารถเปลี่ยนตัวแบ่งส่วนข้อมูลเพื่อรองรับการเพิ่มนั้นได้

ตอนนี้ shader กลายเป็น:

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }

จากนั้นคุณสามารถกำหนดแสง:

 function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }

ในคลาสโปรแกรม shader ให้เพิ่มชุดเครื่องแบบที่จำเป็น:

 this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')

ในโปรแกรม ให้เพิ่มการเรียกไปยังไฟใหม่ในตัวแสดงภาพ:

 Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }

ลูปจะเปลี่ยนเล็กน้อย:

 var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }

หากคุณทำทุกอย่างถูกต้องแล้ว รูปภาพที่แสดงผลควรจะเหมือนกับในภาพสุดท้าย

ขั้นตอนสุดท้ายที่ต้องพิจารณาคือการเพิ่มพื้นผิวจริงให้กับแบบจำลองของเรา มาทำกันตอนนี้เลย

การเพิ่มพื้นผิว

HTML5 รองรับการโหลดรูปภาพได้ดีเยี่ยม ดังนั้นจึงไม่จำเป็นต้องแยกวิเคราะห์รูปภาพอย่างบ้าคลั่ง รูปภาพจะถูกส่งไปยัง GLSL เป็น sampler2D โดยบอก shader ว่าพื้นผิวใดที่ถูกผูกไว้เพื่อสุ่มตัวอย่าง มีพื้นผิวจำนวนจำกัดที่สามารถผูกได้ และขีดจำกัดจะขึ้นอยู่กับฮาร์ดแวร์ที่ใช้ สามารถสอบถาม sampler2D สำหรับสีได้ในบางตำแหน่ง นี่คือที่มาของพิกัด UV นี่คือตัวอย่างที่เราแทนที่สีน้ำตาลด้วยสีตัวอย่าง

 #ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

ต้องเพิ่มชุดเครื่องแบบใหม่ในรายการในโปรแกรมเชดเดอร์:

 this.diffuse = gl.getUniformLocation(program, 'diffuse')

สุดท้าย เราจะใช้การโหลดพื้นผิว ดังที่กล่าวไว้ก่อนหน้านี้ HTML5 มีสิ่งอำนวยความสะดวกสำหรับการโหลดรูปภาพ สิ่งที่เราต้องทำคือส่งภาพไปยัง GPU:

 function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }

กระบวนการนี้ไม่แตกต่างจากกระบวนการที่ใช้ในการโหลดและผูก VBO มากนัก ความแตกต่างหลักคือ เราไม่ผูกกับแอตทริบิวต์อีกต่อไป แต่ผูกดัชนีของพื้นผิวกับชุดเลขจำนวนเต็ม ประเภท sampler2D ไม่มีอะไรมากไปกว่าการชดเชยตัวชี้ไปยังพื้นผิว

ตอนนี้สิ่งที่ต้องทำคือขยายคลาส Mesh เพื่อจัดการพื้นผิวด้วย:

 function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }

และสคริปต์หลักสุดท้ายจะมีลักษณะดังนี้:

 var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) } 

วัตถุที่มีพื้นผิวพร้อมเอฟเฟกต์แสง

แม้แต่แอนิเมชั่นก็เป็นเรื่องง่าย ณ จุดนี้ หากคุณต้องการให้กล้องหมุนไปรอบๆ วัตถุของเรา คุณสามารถทำได้โดยเพิ่มโค้ดหนึ่งบรรทัด:

 function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) } 

หัวหมุนระหว่างแอนิเมชั่นกล้อง

รู้สึกอิสระที่จะเล่นกับเชดเดอร์ การเพิ่มโค้ดหนึ่งบรรทัดจะทำให้แสงที่สมจริงนี้กลายเป็นการ์ตูน

 void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }

ง่ายพอๆ กับการบอกแสงให้สุดขั้วโดยพิจารณาว่าผ่านเกณฑ์ที่กำหนดหรือไม่

ติดไฟแบบการ์ตูน

จะไปที่ไหนต่อไป

มีแหล่งข้อมูลมากมายสำหรับการเรียนรู้กลเม็ดและความซับซ้อนทั้งหมดของ WebGL และส่วนที่ดีที่สุดก็คือ ถ้าคุณไม่พบคำตอบที่เกี่ยวข้องกับ WebGL คุณสามารถค้นหาได้ใน OpenGL เนื่องจาก WebGL นั้นค่อนข้างอิงจากชุดย่อยของ OpenGL โดยมีการเปลี่ยนชื่อบางชื่อ

ต่อไปนี้คือแหล่งข้อมูลที่ยอดเยี่ยมบางส่วนสำหรับข้อมูลโดยละเอียดเพิ่มเติม ทั้งสำหรับ WebGL และ OpenGL

  • ความรู้พื้นฐาน WebGL
  • การเรียนรู้ WebGL
  • บทช่วยสอน OpenGL ที่มีรายละเอียดมากจะแนะนำคุณเกี่ยวกับหลักการพื้นฐานทั้งหมดที่อธิบายไว้ในที่นี้ ด้วยวิธีที่ช้าและมีรายละเอียดมาก
  • และยังมีไซต์อื่นๆ อีกมากมายที่ทุ่มเทให้กับการสอนหลักคอมพิวเตอร์กราฟิกแก่คุณ
  • เอกสาร MDN สำหรับ WebGL
  • ข้อมูลจำเพาะ Khronos WebGL 1.0 หากคุณสนใจที่จะทำความเข้าใจรายละเอียดทางเทคนิคเพิ่มเติมว่า WebGL API ควรทำงานอย่างไรในทุกกรณีของ Edge