บทนำ
เป้าหมายของบทความนี้คือวาดตัวอักษรเคลื่อนไหว 1 ล้านตัวบนหน้าจอด้วยอัตราเฟรมที่ราบรื่น งานนี้น่าจะทำได้ด้วย GPU สมัยใหม่ แต่ละตัวอักษรประกอบด้วยสามเหลี่ยมที่มีพื้นผิว 2 รูป เราจึงพูดถึงสามเหลี่ยมเพียง 2 ล้านรูปต่อเฟรม
หากคุณมาจากพื้นฐานภาพเคลื่อนไหว JavaScript แบบดั้งเดิม ทั้งหมดนี้อาจฟังดูน่าสับสน การอัปเดตรูปสามเหลี่ยม 2 ล้านรูปในทุกเฟรมไม่ใช่สิ่งที่คุณอยากทำด้วย JavaScript ในปัจจุบัน แต่โชคดีที่เรามี WebGL ซึ่งช่วยให้เราใช้ประโยชน์จากพลังอันยอดเยี่ยมของ GPU สมัยใหม่ได้ และการแสดงภาพสามเหลี่ยมที่เคลื่อนไหว 2 ล้านรูปนั้นทำได้โดยใช้ GPU สมัยใหม่และเทคนิคการใช้เฉดสี
การเขียนโค้ด WebGL ที่มีประสิทธิภาพ
การเขียนโค้ด WebGL ที่มีประสิทธิภาพต้องใช้แนวคิดบางอย่าง วิธีปกติในการวาดโดยใช้ WebGL คือการตั้งค่ายูนิฟอร์ม บัฟเฟอร์ และโปรแกรมเปลี่ยนรูปแบบสำหรับแต่ละออบเจ็กต์ ตามด้วยการเรียกให้วาดออบเจ็กต์ วิธีวาดนี้ใช้ได้เมื่อวาดวัตถุจำนวนน้อย หากต้องการวาดวัตถุจํานวนมาก คุณควรลดจํานวนการเปลี่ยนแปลงสถานะของ WebGL เริ่มต้นด้วยการวาดวัตถุทั้งหมดโดยใช้โปรแกรมเปลี่ยนสีเดียวกันตามลำดับ เพื่อที่คุณจะได้ไม่ต้องเปลี่ยนโปรแกรมเปลี่ยนสีระหว่างวัตถุ สําหรับออบเจ็กต์ง่ายๆ เช่น อนุภาค คุณสามารถรวมออบเจ็กต์หลายรายการไว้ในบัฟเฟอร์เดียวและแก้ไขโดยใช้ JavaScript วิธีนี้จะทำให้คุณอัปโหลดเวกเตอร์บัฟเฟอร์ซ้ำได้โดยไม่ต้องเปลี่ยนชุดค่าผสมของโปรแกรมเปลี่ยนสีสำหรับอนุภาคแต่ละอนุภาค
แต่หากต้องการให้ทำงานได้เร็วมาก คุณต้องส่งการคำนวณส่วนใหญ่ไปยังเชดเดอร์ เราพยายามทำเช่นนั้น สร้างภาพเคลื่อนไหวตัวอักษรหลายล้านตัวโดยใช้ชิเดอร์
โค้ดของบทความใช้ไลบรารี Three.js ซึ่งจะแยกข้อมูลทั่วไปที่ยุ่งยากทั้งหมดออกจากการเขียนโค้ด WebGL คุณใช้ Three.js เพียงไม่กี่บรรทัดแทนที่จะต้องเขียนโค้ดการตั้งค่าสถานะ WebGL และการจัดการข้อผิดพลาดหลายร้อยบรรทัด นอกจากนี้ คุณยังใช้ระบบเชดเดอร์ WebGL จาก Three.js ได้อย่างง่ายดาย
การวาดหลายออบเจ็กต์โดยใช้การเรียกใช้การวาดเพียงครั้งเดียว
ต่อไปนี้คือตัวอย่างโค้ดจำลองสั้นๆ เกี่ยวกับวิธีวาดออบเจ็กต์หลายรายการโดยใช้การเรียกใช้การวาดเพียงครั้งเดียว วิธีดั้งเดิมคือการวาดวัตถุทีละรายการ ดังนี้
for (var i=0; i<objects.length; i++) {
// each added object requires a separate WebGL draw call
scene.add(createNewObject(objects[i]));
}
renderer.render(scene, camera);
แต่เมธอดข้างต้นต้องใช้การเรียกใช้การวาดแยกกันสำหรับแต่ละออบเจ็กต์ หากต้องการวาดหลายวัตถุพร้อมกัน คุณสามารถรวมวัตถุเหล่านั้นไว้ในเรขาคณิตเดียวและเรียกใช้การวาดเพียงครั้งเดียวได้ โดยทำดังนี้
var geo = new THREE.Geometry();
for (var i=0; i<objects.length; i++) {
// bundle the objects into a single geometry
// so that they can be drawn with a single draw call
addObjectToGeometry(geo, objects[i]);
}
// GOOD! Only one object to add to the scene!
scene.add(new THREE.Mesh(geo, material));
renderer.render(scene, camera);
โอเค เมื่อคุณทราบแนวคิดพื้นฐานแล้ว เรามากลับไปเขียนเดโมและเริ่มสร้างภาพเคลื่อนไหวตัวอักษรนับล้านตัวกัน
การตั้งค่าเรขาคณิตและพื้นผิว
ขั้นแรก เราจะสร้างพื้นผิวที่มีบิตแมปตัวอักษร ฉันใช้ Canvas 2 มิติ พื้นผิวที่ได้จะมีตัวอักษรทั้งหมดที่ฉันต้องการวาด ขั้นตอนถัดไปคือการสร้างบัฟเฟอร์ที่มีพิกัดพื้นผิวไปยังชีตสไปรต์ตัวอักษร แม้ว่าวิธีนี้จะง่ายและตรงไปตรงมาในการตั้งค่าตัวอักษร แต่ก็ค่อนข้างสิ้นเปลืองเนื่องจากใช้ Float 2 รายการต่อจุดยอดสำหรับพิกัดพื้นผิว วิธีสั้นลงซึ่งเราขอมอบหมายให้ผู้อ่านลองทำคือแพ็กดัชนีตัวอักษรและดัชนีมุมเป็นตัวเลข 1 ตัวเลข แล้วแปลงกลับเป็นพิกัดพื้นผิวในเวิร์กเทกซ์ชิเดอร์
วิธีสร้างพื้นผิวตัวอักษรโดยใช้ Canvas 2D มีดังนี้
var fontSize = 16;
// The square letter texture will have 16 * 16 = 256 letters, enough for all 8-bit characters.
var lettersPerSide = 16;
var c = document.createElement('canvas');
c.width = c.height = fontSize*lettersPerSide;
var ctx = c.getContext('2d');
ctx.font = fontSize+'px Monospace';
// This is a magic number for aligning the letters on rows. YMMV.
var yOffset = -0.25;
// Draw all the letters to the canvas.
for (var i=0,y=0; y<lettersPerSide; y++) {
for (var x=0; x<lettersPerSide; x++,i++) {
var ch = String.fromCharCode(i);
ctx.fillText(ch, x*fontSize, yOffset*fontSize+(y+1)*fontSize);
}
}
// Create a texture from the letter canvas.
var tex = new THREE.Texture(c);
// Tell Three.js not to flip the texture.
tex.flipY = false;
// And tell Three.js that it needs to update the texture.
tex.needsUpdate = true;
เรายังอัปโหลดอาร์เรย์รูปสามเหลี่ยมไปยัง GPU ด้วย เวิร์กเทอร์เหล่านี้ใช้โดยเวิร์กเทอร์เชดเดอร์เพื่อวางตัวอักษรบนหน้าจอ ระบบจะตั้งค่าจุดยอดเป็นตําแหน่งตัวอักษรในข้อความเพื่อให้คุณได้รับการแสดงผลเลย์เอาต์พื้นฐานของข้อความหากแสดงผลอาร์เรย์รูปสามเหลี่ยมตามที่เป็นอยู่
การสร้างเรขาคณิตสำหรับสมุดภาพ
var geo = new THREE.Geometry();
var i=0, x=0, line=0;
for (i=0; i<BOOK.length; i++) {
var code = BOOK.charCodeAt(i); // This is the character code for the current letter.
if (code > lettersPerSide * lettersPerSide) {
code = 0; // Clamp character codes to letter map size.
}
var cx = code % lettersPerSide; // Cx is the x-index of the letter in the map.
var cy = Math.floor(code / lettersPerSide); // Cy is the y-index of the letter in the map.
// Add letter vertices to the geometry.
var v,t;
geo.vertices.push(
new THREE.Vector3( x*1.1+0.05, line*1.1+0.05, 0 ),
new THREE.Vector3( x*1.1+1.05, line*1.1+0.05, 0 ),
new THREE.Vector3( x*1.1+1.05, line*1.1+1.05, 0 ),
new THREE.Vector3( x*1.1+0.05, line*1.1+1.05, 0 )
);
// Create faces for the letter.
var face = new THREE.Face3(i*4+0, i*4+1, i*4+2);
geo.faces.push(face);
face = new THREE.Face3(i*4+0, i*4+2, i*4+3);
geo.faces.push(face);
// Compute texture coordinates for the letters.
var tx = cx/lettersPerSide,
ty = cy/lettersPerSide,
off = 1/lettersPerSide;
var sz = lettersPerSide*fontSize;
geo.faceVertexUvs[0].push([
new THREE.Vector2( tx, ty+off ),
new THREE.Vector2( tx+off, ty+off ),
new THREE.Vector2( tx+off, ty )
]);
geo.faceVertexUvs[0].push([
new THREE.Vector2( tx, ty+off ),
new THREE.Vector2( tx+off, ty ),
new THREE.Vector2( tx, ty )
]);
// On newline, move to the line below and move the cursor to the start of the line.
// Otherwise move the cursor to the right.
if (code == 10) {
line--;
x=0;
} else {
x++;
}
}
ตัวปรับแสงเงา Vertex สำหรับทำให้ตัวอักษรเคลื่อนไหว
เมื่อใช้เวิร์กเทกซ์เชดเดอร์แบบง่าย ฉันจะเห็นข้อความเป็นภาพแบน ไม่มีอะไรซับซ้อน ทำงานได้ดี แต่หากต้องการสร้างภาพเคลื่อนไหว จะต้องสร้างภาพเคลื่อนไหวใน JavaScript และ JavaScript ทำงานช้าสำหรับการทำให้จุดยอด 6 ล้านจุดเคลื่อนไหว โดยเฉพาะหากต้องการทำในทุกเฟรม อาจมีวิธีที่เร็วกว่านั้น
ใช่ เราทำภาพเคลื่อนไหวแบบเป็นขั้นตอนได้ ซึ่งหมายความว่าเราจะทำการคำนวณตำแหน่งและการหมุนทั้งหมดในเวิร์กเทกซ์เชดเดอร์ ตอนนี้ฉันไม่จําเป็นต้องเรียกใช้ JavaScript เพื่ออัปเดตตําแหน่งของจุดยอด เวิร์กเทกซ์ Shader ทำงานได้เร็วมากและฉันได้อัตราเฟรมที่ราบรื่นแม้ว่าจะมีการแสดงภาพเคลื่อนไหวของสามเหลี่ยม 1 ล้านรูปในแต่ละเฟรมก็ตาม หากต้องการจัดการกับรูปสามเหลี่ยมแต่ละรูป เราจะปัดเศษพิกัดจุดยอดลงเพื่อให้จุดทั้ง 4 จุดของรูปสี่เหลี่ยมจัตุรัสของตัวอักษรแมปกับพิกัดที่ไม่ซ้ำกันเพียงพิกัดเดียว ตอนนี้ฉันใช้พิกัดนี้เพื่อตั้งค่าพารามิเตอร์ภาพเคลื่อนไหวของตัวอักษรที่เป็นปัญหาได้แล้ว
พิกัดจากตัวอักษร 2 ตัวที่แตกต่างกันต้องไม่ทับซ้อนกันเพื่อให้ปัดเศษพิกัดลงได้สําเร็จ วิธีที่ง่ายที่สุดคือการใช้สี่เหลี่ยมจัตุรัสตัวอักษรที่มีระยะห่างเล็กน้อยระหว่างตัวอักษรกับตัวอักษรทางด้านขวาและบรรทัดที่ด้านบน เช่น คุณอาจใช้ความกว้างและความสูง 0.5 สำหรับตัวอักษรและจัดแนวตัวอักษรตามพิกัดจำนวนเต็ม เมื่อปัดพิกัดของจุดยอดของตัวอักษรลง คุณจะได้รับพิกัดด้านซ้ายล่างของตัวอักษร
เราจะอธิบายเวิร์กเชดเวอร์เทกซ์ทั่วไปแบบง่ายๆ ก่อน เพื่อให้เข้าใจเวิร์กเชดเวอร์เทกซ์แบบเคลื่อนไหวได้ดียิ่งขึ้น กรณีนี้มักเกิดขึ้นเมื่อคุณวาดโมเดล 3 มิติบนหน้าจอ ระบบจะเปลี่ยนรูปแบบจุดยอดของโมเดลด้วยเมทริกซ์การเปลี่ยนรูปแบบคู่เพื่อฉายจุดยอด 3 มิติแต่ละจุดลงบนหน้าจอ 2 มิติ เมื่อใดก็ตามที่รูปสามเหลี่ยมที่กําหนดโดยจุดยอด 3 จุดเหล่านี้ปรากฏในวิวพอร์ต พิกเซลที่รูปสามเหลี่ยมนั้นครอบคลุมจะได้รับการประมวลผลโดยโปรแกรมเปลี่ยนสีเศษเสี้ยวเพื่อระบายสี Shader เวิร์กเท็กซ์แบบง่ายมีดังนี้
varying float vUv;
void main() {
// modelViewMatrix, position and projectionMatrix are magical
// attributes that Three.js defines for us.
// Transform current vertex by the modelViewMatrix
// (bundled model world position & camera world position matrix).
vec4 mvPosition = modelViewMatrix * position;
// Project camera-space vertex to screen coordinates
// using the camera's projection matrix.
vec4 p = projectionMatrix * mvPosition;
// uv is another magical attribute from Three.js.
// We're passing it to the fragment shader unchanged.
vUv = uv;
gl_Position = p;
}
และตอนนี้มาพูดถึงเวิร์กเชดเวอร์เทกซ์แบบเคลื่อนไหวกัน โดยพื้นฐานแล้ว Shader ประเภทนี้จะทํางานแบบเดียวกับ Vertex Shader แบบธรรมดา แต่มีการเปลี่ยนแปลงเล็กน้อย แทนที่จะเปลี่ยนรูปแต่ละจุดด้วยเมตริกการเปลี่ยนรูปแบบเพียงอย่างเดียว โปรแกรมจะใช้การเปลี่ยนรูปแบบแบบเคลื่อนไหวตามเวลาด้วย เวิร์กเทกซ์เชดเดอร์แบบเคลื่อนไหวจะแก้ไขภาพเคลื่อนไหวตามพิกัดของตัวอักษรด้วย เพื่อให้แต่ละตัวอักษรเคลื่อนไหวแตกต่างกันเล็กน้อย Shader ประเภทนี้จะดูซับซ้อนกว่า Vertex Shader แบบธรรมดามาก เนื่องจากมีความซับซ้อนมากกว่า
uniform float uTime;
uniform float uEffectAmount;
varying float vZ;
varying vec2 vUv;
// rotateAngleAxisMatrix returns the mat3 rotation matrix
// for given angle and axis.
mat3 rotateAngleAxisMatrix(float angle, vec3 axis) {
float c = cos(angle);
float s = sin(angle);
float t = 1.0 - c;
axis = normalize(axis);
float x = axis.x, y = axis.y, z = axis.z;
return mat3(
t*x*x + c, t*x*y + s*z, t*x*z - s*y,
t*x*y - s*z, t*y*y + c, t*y*z + s*x,
t*x*z + s*y, t*y*z - s*x, t*z*z + c
);
}
// rotateAngleAxis rotates a vec3 over the given axis by the given angle and
// returns the rotated vector.
vec3 rotateAngleAxis(float angle, vec3 axis, vec3 v) {
return rotateAngleAxisMatrix(angle, axis) * v;
}
void main() {
// Compute the index of the letter (assuming 80-character max line length).
float idx = floor(position.y/1.1)*80.0 + floor(position.x/1.1);
// Round down the vertex coords to find the bottom-left corner point of the letter.
vec3 corner = vec3(floor(position.x/1.1)*1.1, floor(position.y/1.1)*1.1, 0.0);
// Find the midpoint of the letter.
vec3 mid = corner + vec3(0.5, 0.5, 0.0);
// Rotate the letter around its midpoint by an angle and axis dependent on
// the letter's index and the current time.
vec3 rpos = rotateAngleAxis(idx+uTime,
vec3(mod(idx,16.0), -8.0+mod(idx,15.0), 1.0), position - mid) + mid;
// uEffectAmount controls the amount of animation applied to the letter.
// uEffectAmount ranges from 0.0 to 1.0.
float effectAmount = uEffectAmount;
vec4 fpos = vec4( mix(position,rpos,effectAmount), 1.0 );
fpos.x += -35.0;
// Apply spinning motion to individual letters.
fpos.z += ((sin(idx+uTime*2.0)))*4.2*effectAmount;
fpos.y += ((cos(idx+uTime*2.0)))*4.2*effectAmount;
vec4 mvPosition = modelViewMatrix * fpos;
// Apply wavy motion to the entire text.
mvPosition.y += 10.0*sin(uTime*0.5+mvPosition.x/25.0)*effectAmount;
mvPosition.x -= 10.0*cos(uTime*0.5+mvPosition.y/25.0)*effectAmount;
vec4 p = projectionMatrix * mvPosition;
// Pass texture coordinates and the vertex z-coordinate to the fragment shader.
vUv = uv;
vZ = p.z;
// Send the final vertex position to WebGL.
gl_Position = p;
}
หากต้องการใช้เวิร์กเทกซ์ Shader ฉันจะใช้ THREE.ShaderMaterial
ซึ่งเป็นประเภทวัสดุที่ให้คุณใช้ Shader ที่กําหนดเองและระบุยูนิฟอร์มสําหรับ Shader ได้ ตัวอย่างการใช้ THREE.ShaderMaterial ในเดโมมีดังนี้
// First, set up uniforms for the shader.
var uniforms = {
// map contains the letter map texture.
map: { type: "t", value: 1, texture: tex },
// uTime is the urrent time.
uTime: { type: "f", value: 1.0 },
// uEffectAmount controls the amount of animation applied to the letters.
uEffectAmount: { type: "f", value: 0.0 }
};
// Next, set up the THREE.ShaderMaterial.
var shaderMaterial = new THREE.ShaderMaterial({
uniforms: uniforms,
// I have my shaders inside HTML elements like
// <script id="vertex" type="text/x-glsl-vert">... shaderSource ... <script>
// The below gets the contents of the vertex shader script element.
vertexShader: document.querySelector('#vertex').textContent,
// The fragment shader is a bit special as well, drawing a rotating
// rainbow gradient.
fragmentShader: document.querySelector('#fragment').textContent
});
// I set depthTest to false so that the letters don't occlude each other.
shaderMaterial.depthTest = false;
ฉันจะอัปเดตชุดค่าผสมของโปรแกรมเปลี่ยนสีในเฟรมภาพเคลื่อนไหวทุกเฟรม ดังนี้
// I'm controlling the uniforms through a proxy control object.
// The reason I'm using a proxy control object is to
// have different value ranges for the controls and the uniforms.
var controller = {
effectAmount: 0
};
// I'm using <a href="http://code.google.com/p/dat-gui/">DAT.GUI</a> to do a quick & easy GUI for the demo.
var gui = new dat.GUI();
gui.add(controller, 'effectAmount', 0, 100);
var animate = function(t) {
uniforms.uTime.value += 0.05;
uniforms.uEffectAmount.value = controller.effectAmount/100;
bookModel.position.y += 0.03;
renderer.render(scene, camera);
requestAnimationFrame(animate, renderer.domElement);
};
animate(Date.now());
เท่านี้คุณก็สร้างภาพเคลื่อนไหวที่ใช้ชิดเดอร์ได้แล้ว ดูเหมือนจะซับซ้อน แต่สิ่งที่ทำจริงๆ คือย้ายตัวอักษรไปรอบๆ ในลักษณะที่ขึ้นอยู่กับเวลาปัจจุบันและดัชนีของแต่ละตัวอักษร หากไม่กังวลเรื่องประสิทธิภาพ คุณก็อาจใช้ตรรกะนี้ใน JavaScript ได้ อย่างไรก็ตาม เมื่อออบเจ็กต์ที่เคลื่อนไหวมีจำนวนหลายหมื่นรายการ JavaScript จะไม่ใช่โซลูชันที่ใช้งานได้อีกต่อไป
ข้อกังวลที่เหลืออยู่
ปัญหาอย่างหนึ่งตอนนี้คือ JavaScript ไม่ทราบตำแหน่งของอนุภาค หากต้องการทราบตำแหน่งของอนุภาคจริงๆ คุณอาจทำซ้ำตรรกะเวิร์กเชดเวอร์เทกซ์ใน JavaScript และคำนวณตำแหน่งเวิร์กเชดอีกครั้งโดยใช้เวิร์กเกอร์เว็บทุกครั้งที่ต้องการตำแหน่ง วิธีนี้จะทำให้เธรดการแสดงผลไม่ต้องรอการคำนวณ และคุณจะสร้างภาพเคลื่อนไหวต่อไปด้วยอัตราเฟรมที่ราบรื่นได้
หากต้องการภาพเคลื่อนไหวที่ควบคุมได้มากขึ้น คุณสามารถใช้ฟังก์ชันการเรนเดอร์เป็นพื้นผิวเพื่อสร้างภาพเคลื่อนไหวระหว่างตำแหน่ง 2 ชุดที่ JavaScript ระบุ ก่อนอื่น ให้แสดงผลตำแหน่งปัจจุบันเป็นพื้นผิว จากนั้นแสดงภาพเคลื่อนไหวไปยังตำแหน่งที่กําหนดไว้ในพื้นผิวแยกต่างหากซึ่ง JavaScript ระบุ ข้อดีของวิธีนี้คือคุณสามารถอัปเดตตำแหน่งที่ JavaScript ระบุเพียงส่วนเล็กๆ ต่อเฟรม และยังคงทำให้ตัวอักษรทั้งหมดเคลื่อนไหวต่อไปได้ทุกเฟรมด้วย Vertex Shader ที่ Tweening ตำแหน่ง
อีกข้อกังวลหนึ่งคือ 256 อักขระนั้นน้อยเกินไปที่จะใช้กับข้อความที่ไม่ใช่ ASCII หากเพิ่มขนาดแผนที่เท็กเจอร์เป็น 4096x4096 พร้อมกับลดขนาดแบบอักษรเป็น 8 พิกเซล คุณจะใส่ชุดอักขระ UCS-2 ทั้งหมดลงในแผนที่เท็กเจอร์ได้ อย่างไรก็ตาม ขนาดแบบอักษร 8 พิกเซลนั้นอ่านได้ยาก หากต้องการใช้แบบอักษรขนาดใหญ่ขึ้น คุณสามารถใช้พื้นผิวแบบต่างๆ กับแบบอักษรได้ ดูตัวอย่างได้จากการสาธิตสมุดภาพภาพสไปรต์นี้ อีกวิธีหนึ่งที่จะช่วยได้คือสร้างเฉพาะตัวอักษรที่ใช้ในข้อความ
สรุป
บทความนี้จะอธิบายวิธีใช้การสาธิตภาพเคลื่อนไหวที่ใช้เวิร์กเทกซ์ชิเดอร์โดยใช้ Three.js การสาธิตแสดงภาพเคลื่อนไหวของตัวอักษร 1 ล้านตัวแบบเรียลไทม์บน MacBook Air ปี 2010 การติดตั้งใช้งานจะรวมสมุดภาพทั้งเล่มไว้ในออบเจ็กต์เรขาคณิตรายการเดียวเพื่อให้วาดภาพได้อย่างมีประสิทธิภาพ แต่ละตัวอักษรมีภาพเคลื่อนไหวโดยหาว่าจุดยอดใดเป็นของตัวอักษรใดและทำให้จุดยอดเคลื่อนไหวตามดัชนีของตัวอักษรในข้อความของหนังสือ