วัตถุทุกชิ้นบนหน้าจอมีตำแหน่ง กล้องทุกตัวมองไปในทิศทางหนึ่ง ตัวละครทุกตัวหมุน ขยาย/ย่อ และเคลื่อนที่ทุกเฟรม ทั้งหมดนี้สร้างจากเครื่องมือคณิตศาสตร์ชุดเล็ก ๆ คือ vector, dot product, cross product และ matrix บทนี้จะสร้างเครื่องมือพวกนี้ขึ้นมาเองใน C++ โดยใช้ struct Vec2/Vec3 เล็ก ๆ เพื่อให้คุณเห็นชัด ๆ ว่า engine อย่าง Unity หรือ Unreal ทำอะไรกับตำแหน่งและการหมุนของคุณในทุก ๆ เฟรม
แต่ละหัวข้อด้านล่างจะเป็นรูปแบบเดียวกันเสมอ: โปรแกรมสั้น ๆ ที่รันได้จริง, output จริงที่มันพิมพ์ออกมา, แล้วค่อยอธิบายแบบเข้าใจง่ายว่าเกิดอะไรขึ้น ลองพิมพ์ตามแล้วรันดูเพื่อเช็คว่า compiler ของคุณได้ผลลัพธ์ตรงกัน
ในบทก่อน ๆ ตัวเลขคู่หนึ่งอย่าง x, y มักใช้บอกตำแหน่ง — struct นั้นอยู่ที่ไหน หรือ tile นั้นอยู่ตรงไหนบน grid ในบทนี้ตัวเลขสองตัวเดิมบางทีก็หมายถึงตำแหน่ง แต่บางทีก็หมายถึงอีกอย่างหนึ่งไปเลย นั่นคือ ทิศทางบวกกับระยะทาง (direction + magnitude) ทั้งสองแบบเก็บด้วย float ชุดเดียวกันเป๊ะ แต่ความหมายต่างกัน และการสับสนสองอย่างนี้เป็นสาเหตุบั๊กที่พบบ่อย
std::vector!) ตอบคำถามว่า "ไปทางไหน และไกล/แรงแค่ไหน" เช่น ลมพัดไปทาง (1, 0) หมายถึง 1 หน่วยไปทางตะวันออก 0 หน่วยไปทางเหนือvector มีสองคุณสมบัติ: direction (ทิศทางที่มันชี้ไป) กับ magnitude (มันยาวแค่ไหน — เรียกอีกอย่างว่าความยาว) ส่วน point ไม่มีทั้งสองอย่างนั้นเลย มันเป็นแค่ตำแหน่งเฉย ๆ นี่คือ struct เล็ก ๆ ที่เราจะใช้ตลอดทั้งบทนี้ พร้อมโปรแกรมที่เก็บข้อมูลทั้งสองแบบไว้ในนั้น:
#include <iostream>
struct Vec2 {
float x, y;
};
int main() {
Vec2 playerPos = {3.0f, 4.0f}; // a POINT: a location in space
Vec2 windDir = {1.0f, 0.0f}; // a VECTOR: a direction, no fixed location
std::cout << "player at (" << playerPos.x << ", " << playerPos.y << ")\n";
std::cout << "wind blows toward (" << windDir.x << ", " << windDir.y << ")\n";
}
Output:
player at (3, 4)
wind blows toward (1, 0)
struct Vec2 เองไม่รู้หรอกว่าคุณหมายถึงแบบไหน — playerPos กับ windDir ก็แค่ float สองตัวเรียงกันอยู่ใน memory เหมือนกัน ความหมายมาจากวิธีที่คุณเอาไปใช้ต่างหาก จำความต่างนี้ไว้ตลอดบทนี้เลย — บาง operation (อย่างในหัวข้อถัดไป) จะสมเหตุสมผลแค่กับอย่างใดอย่างหนึ่งเท่านั้น
vector รองรับ operation กลุ่มเล็ก ๆ และแต่ละอันมีความหมายชัดเจนในเกม:
แต่ละ operation ทำงานทีละ component — x กับ y ไม่เกี่ยวข้องกัน:
#include <iostream>
struct Vec2 {
float x, y;
};
Vec2 add(Vec2 a, Vec2 b) { return { a.x + b.x, a.y + b.y }; }
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s){ return { a.x * s, a.y * s }; }
int main() {
Vec2 pos = {2, 3}; // a point
Vec2 vel = {1, -1}; // a vector: move 1 right, 1 down per step
Vec2 nextPos = add(pos, vel); // point + vector = a new point
Vec2 diff = sub(nextPos, pos); // point - point = the vector between them
Vec2 doubled = scale(vel, 2.0f); // same direction, twice as long
Vec2 reversed = scale(vel, -1.0f); // same length, opposite direction
std::cout << "nextPos = (" << nextPos.x << ", " << nextPos.y << ")\n";
std::cout << "diff = (" << diff.x << ", " << diff.y << ")\n";
std::cout << "doubled = (" << doubled.x << ", " << doubled.y << ")\n";
std::cout << "reversed = (" << reversed.x << ", " << reversed.y << ")\n";
}
Output:
nextPos = (3, 2)
diff = (1, -1)
doubled = (2, -2)
reversed = (-1, 1)
add(pos, vel) ขยับตำแหน่งของ player ด้วย velocity ของมัน — หนึ่ง step ของการจำลอง sub(nextPos, pos) จะได้ vector ตัวเดิมที่เราเริ่มต้นกลับคืนมาเป๊ะ เพราะการเอา point ลบกับ point จะตอบคำถาม "ห่างกันแค่ไหน และไปทางไหน" เสมอ scale(vel, 2.0f) ทำให้ magnitude ของ vector เพิ่มเป็นสองเท่าแต่ทิศทางเหมือนเดิม ส่วน scale(vel, -1.0f) magnitude เท่าเดิมแต่ทิศทางพลิกกลับ 180 องศา — เทคนิคที่ใช้บ่อยสำหรับเอฟเฟกต์กระเด้งกลับหรือ recoil
enemyPos + playerPos จะได้ตำแหน่งที่ไม่มีความหมายอะไรเลย — point บวกกันไม่ได้ ถ้าอยากได้จุดกึ่งกลางระหว่างสองจุด ให้ใช้ scale(add(a, b), 0.5f) แล้วคิดว่ามันคือ "ค่าเฉลี่ย" ไม่ใช่ "ผลบวก"length ของ vector (เรียกอีกอย่างว่า magnitude) คือมันยาวไปได้ไกลแค่ไหน สำหรับ vector 2D นี่คือทฤษฎีบทพีทาโกรัสที่คุณรู้จักอยู่แล้ว: length = sqrt(x*x + y*y) ใน 3D ก็แค่เพิ่มเทอม z*z เข้าไปในรูท
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
float length(Vec2 v) {
return std::sqrt(v.x * v.x + v.y * v.y);
}
Vec2 normalize(Vec2 v) {
float len = length(v);
return { v.x / len, v.y / len };
}
int main() {
Vec2 v = {3, 4};
std::cout << "length = " << length(v) << "\n";
Vec2 u = normalize(v);
std::cout << "unit vector = (" << u.x << ", " << u.y << ")\n";
std::cout << "length of unit vector = " << length(u) << "\n";
}
Output:
length = 5
unit vector = (0.6, 0.8)
length of unit vector = 1
นี่คือสามเหลี่ยมมุมฉาก 3-4-5 คลาสสิก normalize เอาแต่ละ component หารด้วย length ซึ่งจะย่อ (หรือขยาย) vector จนความยาวเท่ากับ 1 พอดี โดยทิศทางยังเหมือนเดิมไม่เปลี่ยน vector ที่มีความยาว 1 เรียกว่า unit vector และการเปลี่ยน vector ใด ๆ ให้เป็น unit vector เรียกว่า normalizing มัน
ทำไมต้องทำแบบนี้? เพราะหลายครั้งเราสนใจแค่ ทิศทาง ไม่ใช่ระยะทาง เช่น player หันหน้าไปทางไหน, พื้นผิวนี้ชี้ไปทางไหน, กระสุนนี้ควรวิ่งไปทางไหน normalize จะทิ้ง magnitude ไปแล้วเหลือแค่ทิศทาง ซึ่งตรงกับที่คำถามพวกนี้ต้องการพอดี
inf หรือ NaN ("not a number") ซึ่งเป็นค่า floating-point ที่จะทำให้ทุกการคำนวณที่ใช้มันต่อไปเสียหายแบบเงียบ ๆ โค้ดจริงจะเช็ค if (length > 0.00001f) (threshold เล็ก ๆ ที่เรียกว่า "epsilon") ก่อนจะหาร แล้วค่อยตัดสินใจว่าจะใช้ทิศทางสำรองอะไรถ้า vector สั้นเกินกว่าจะ normalize ได้อย่างปลอดภัยdot product รับ vector สองตัวแล้วคืนตัวเลขเดี่ยว ๆ ธรรมดา (เรียกว่า scalar) ไม่ใช่ vector ตัวใหม่ ใน 2D สูตรคือ dot(a, b) = a.x*b.x + a.y*b.y ส่วนใน 3D ก็เพิ่ม a.z*b.z เข้าไป
#include <iostream>
struct Vec2 {
float x, y;
};
float dot(Vec2 a, Vec2 b) {
return a.x * b.x + a.y * b.y;
}
int main() {
Vec2 right = {1, 0};
Vec2 up = {0, 1};
Vec2 diag = {1, 1};
Vec2 left = {-1, 0};
std::cout << "right . up = " << dot(right, up) << "\n";
std::cout << "right . diag = " << dot(right, diag) << "\n";
std::cout << "right . left = " << dot(right, left) << "\n";
}
Output:
right . up = 0
right . diag = 1
right . left = -1
สังเกต pattern ของเครื่องหมายผลลัพธ์: ตั้งฉากกันได้ 0, vector มุม 45 องศาได้ค่าบวก, ส่วน vector ที่ชี้ไปทางตรงข้ามได้ค่าลบ นี่คือแก่นของ dot product เลย: มัน วัดความเข้ากัน (alignment) — vector สองตัวชี้ไปทิศทางเดียวกันมากแค่ไหน
มีอีกสูตรหนึ่งที่ให้ตัวเลขเดียวกันแต่อธิบายว่า ทำไม: a . b = |a| * |b| * cos(theta) โดย |a| กับ |b| คือความยาว ส่วน theta คือมุมระหว่าง vector สองตัว เพราะ cos เป็นบวกเมื่อต่ำกว่า 90 องศา เป็น 0 ที่ 90 องศา และเป็นลบเมื่อเกิน 90 องศา เครื่องหมายของ dot product จึงบอกได้ทันทีว่ามุมระหว่าง vector สองตัวเป็นมุมแหลม มุมฉาก หรือมุมป้าน โดยไม่ต้องคำนวณมุมจริง ๆ เลย ตัวเลขตัวเดียวนี้โผล่มาบ่อยมากในเกม: แสง (พื้นผิวหันเข้าหาแสงมากแค่ไหน), vision cone ของ AI, และการเลี้ยว (steering) ก็ใช้มันทั้งนั้น
จัดสูตรที่สองใหม่จะได้มุมจริง ๆ ออกมา: theta = acos( dot(a, b) / (length(a) * length(b)) ) acos ("arc cosine") คือฟังก์ชันผกผันของ cosine — มันเอาค่า cosine กลับมาเป็นมุม
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }
float angleDegrees(Vec2 a, Vec2 b) {
float cosTheta = dot(a, b) / (length(a) * length(b));
float radians = std::acos(cosTheta);
return radians * 180.0f / 3.14159265f;
}
int main() {
Vec2 right = {1, 0};
Vec2 diag = {1, 1};
Vec2 steep = {3, 4};
std::cout << "angle(right, diag) = " << angleDegrees(right, diag) << " degrees\n";
std::cout << "angle(right, steep) = " << angleDegrees(right, steep) << " degrees\n";
}
Output:
angle(right, diag) = 45 degrees
angle(right, steep) = 53.1301 degrees
(1, 1) อยู่ตรงกึ่งกลางระหว่าง "right" กับ "up" พอดี ดังนั้น 45 องศาถูกต้อง (3, 4) คือ vector ตัวเดียวกับสามเหลี่ยมในหัวข้อ 3 — ชันกว่า 45 องศานิดหน่อย และผลคำนวณก็ตรงกัน: ประมาณ 53 องศา
AI ในเกมใช้ตัวนี้บ่อยมาก ถ้า guard หันหน้าไปทางหนึ่ง และคุณรู้ vector จาก guard ไปยัง target เครื่องหมายของ dot product จะบอกได้ทันทีว่า target อยู่ข้างหน้าหรือข้างหลัง guard โดยประมาณ — ไม่ต้องใช้ตรีโกณมิติเลย
#include <iostream>
struct Vec2 {
float x, y;
};
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
bool isInFront(Vec2 selfPos, Vec2 facing, Vec2 targetPos) {
Vec2 toTarget = sub(targetPos, selfPos); // direction from self to target
return dot(facing, toTarget) > 0.0f; // positive = same side as facing
}
int main() {
Vec2 selfPos = {0, 0};
Vec2 facing = {0, 1}; // guard is looking "north"
Vec2 targetA = {3, 4}; // ahead of the guard
Vec2 targetB = {1, -2}; // behind the guard
std::cout << "targetA in front? " << isInFront(selfPos, facing, targetA) << "\n";
std::cout << "targetB in front? " << isInFront(selfPos, facing, targetB) << "\n";
}
Output:
targetA in front? 1
targetB in front? 0
std::cout พิมพ์ bool เป็น 1 สำหรับ true และ 0 สำหรับ false ยกเว้นคุณจะสั่งให้มันสะกดเป็นคำ (ด้วย std::boolalpha) target A อยู่ด้านบนขวาของ guard ซึ่งเป็นฝั่งเดียวกับ "north" โดยรวม ดังนั้น dot product เป็นบวก ส่วน target B อยู่ข้างหลัง เลยได้ค่าลบ การเช็ค vision cone แบบเต็มรูปแบบจะเทียบมุมกับ field of view สูงสุดด้วย แต่แค่เช็คหน้า/หลังอย่างเดียวก็มักพอสำหรับ AI ง่าย ๆ แล้ว
projection ตอบคำถามว่า "vector a ชี้ไปตามทิศทางของ vector b มากแค่ไหน" มันคือสิ่งที่ทำให้ตัวละครไถลไปตามกำแพงได้ลื่น ๆ แทนที่จะหยุดนิ่งทันทีที่ชนกำแพง — คุณ project velocity ลงบนทิศทางของกำแพง แล้วเก็บไว้แค่ส่วนนั้น
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }
int main() {
Vec2 v = {5, 3}; // a character's velocity
Vec2 wallDir = {1, 1}; // the direction a diagonal wall runs (not unit length)
float scalarProj = dot(v, wallDir) / length(wallDir); // how far along the wall
Vec2 vectorProj = scale(wallDir, dot(v, wallDir) / dot(wallDir, wallDir)); // the sliding velocity
std::cout << "scalar projection = " << scalarProj << "\n";
std::cout << "vector projection = (" << vectorProj.x << ", " << vectorProj.y << ")\n";
}
Output:
scalar projection = 5.65685
vector projection = (4, 4)
scalar projection คือตัวเลขธรรมดา: a ยื่นไปตามทิศทางของ b ได้ไกลแค่ไหน vector projection เปลี่ยนตัวเลขนั้นกลับเป็น vector จริง ๆ ที่ชี้ไปตาม b สูตร (dot(a,b) / dot(b,b)) * b ใช้ได้กับ b ที่ยาวเท่าไหร่ก็ได้ เพราะการหารด้วย dot(b,b) (ซึ่งก็คือ length(b) ยกกำลังสอง) จะหักล้างความยาวของ b เองออกก่อนที่จะไปคูณ scale
projection เก็บส่วนของ vector ที่วางตัว ตาม ทิศทางหนึ่ง ญาติใกล้ชิดอีกสองตัวจะเก็บหรือพลิก อีกส่วน — ส่วนที่ตั้งฉากกับมัน — และทั้งคู่เป็นเครื่องมือที่ใช้กันประจำในเกม ทั้งสองสูตรเขียนได้สะอาดที่สุดโดยอิงกับ unit normal n ของพื้นผิว (unit vector ที่ชี้ตั้งฉากออกจากพื้นผิว):
reject(a, n) = a - (a . n) n มันตัดส่วนของ a ที่พุ่งเข้าหาพื้นผิวทิ้ง แล้วเก็บส่วนที่ไถลไปตามพื้นผิวไว้reflect(a, n) = a - 2 (a . n) n มันเอาส่วนที่พุ่งเข้าหาพื้นผิวออกไป สองเท่า ทำให้ vector กระเด้งกลับออกมา#include <iostream>
struct Vec2 { float x, y; };
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
// the part of a perpendicular to UNIT vector n (a with its n-component removed)
Vec2 reject(Vec2 a, Vec2 n) { return sub(a, scale(n, dot(a, n))); }
// mirror a across the surface whose UNIT normal is n
Vec2 reflect(Vec2 a, Vec2 n) { return sub(a, scale(n, 2.0f * dot(a, n))); }
int main() {
Vec2 vel = {5, 3}; // a character's velocity
Vec2 wallN = {1, 0}; // wall runs vertically; its normal points along +x
Vec2 slide = reject(vel, wallN); // slide along the wall (drop the into-wall part)
Vec2 bounce = reflect(vel, wallN); // bounce off the wall
std::cout << "slide = (" << slide.x << ", " << slide.y << ")\n";
std::cout << "bounce = (" << bounce.x << ", " << bounce.y << ")\n";
}
Output:
slide = (0, 3)
bounce = (-5, 3)
slide ก็คือไอเดีย "โยนส่วนที่พุ่งเข้ากำแพงทิ้ง" จาก diagram ของ projection ด้านบนนั่นเอง แค่ตอนนี้ห่อเป็นฟังก์ชันเดียว: เก็บไว้แค่การเคลื่อนที่ตามกำแพง เพื่อให้ตัวละครเฉียดผ่านไปได้แทนที่จะหยุดนิ่ง ส่วน bounce ไปอีกขั้นด้วยการเอาส่วนที่พุ่งเข้านั้นออกเป็นครั้งที่สอง ส่ง vector กลับออกมาด้วยมุมที่เท่ากันแต่กลับด้าน — เป็นพื้นฐานของกระสุนที่เด้ง, การ ricochet, หรือลูกบิลเลียด ทั้งสองสูตรถือว่า n มีความยาวเท่ากับ 1 ถ้าไม่ใช่ ให้เอาเทอม (a . n) หารด้วย dot(n, n) ก่อน เหมือนกับที่สูตร projection ทำเป๊ะ
n ยาวเป็นสองเท่าของที่ควรจะเป็น เทอม (a . n) n จะโตขึ้นสี่เท่า และ "bounce" ของคุณจะออกมาแรงเกินไปมากและชี้ผิดทาง normalize surface normal ให้เรียบร้อยครั้งเดียวตั้งแต่ต้น แล้วสูตรพวกนี้จะยังเรียบง่ายอยู่cross product ต่างจาก dot product ในจุดสำคัญ: มันรับ vector 3D สองตัวแล้วคืน vector ตัวที่สาม ออกมา ไม่ใช่ตัวเลข vector ใหม่ตัวนั้นจะตั้งฉาก (มุม 90 องศา) กับ vector ตั้งต้นทั้งสองตัวเสมอ
#include <iostream>
struct Vec3 {
float x, y, z;
};
Vec3 cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
float dot(Vec3 a, Vec3 b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
int main() {
Vec3 right = {1, 0, 0};
Vec3 up = {0, 1, 0};
Vec3 n = cross(right, up);
std::cout << "right x up = (" << n.x << ", " << n.y << ", " << n.z << ")\n";
// proof that n is perpendicular to BOTH inputs: dot product should be 0
std::cout << "n . right = " << dot(n, right) << "\n";
std::cout << "n . up = " << dot(n, up) << "\n";
}
Output:
right x up = (0, 0, 1)
n . right = 0
n . up = 0
dot product สองตัวนั้นยืนยันเลยว่า ผลลัพธ์ตั้งฉากกับทั้ง right และ up ตามที่สัญญาไว้ สังเกตด้วยว่า cross(a, b) กับ cross(b, a) ชี้ไปทิศทางตรงข้ามกัน — cross product เป็นแบบ anti-commutative (สลับข้างแล้วได้ค่าตรงข้าม) ลำดับมีผลตรงนี้เหมือนกัน ซึ่งเป็นธีมที่จะกลับมาแรง ๆ อีกครั้งตอนเราไปถึงเรื่อง matrix
surface normal คือ unit vector ที่ชี้ตั้งฉากออกจากพื้นผิวเรียบ — ทั้งเรื่องแสง, การสะท้อน, และ physics ต้องใช้มันหมด สำหรับสามเหลี่ยมที่มีมุม A, B, C ให้สร้าง edge vector สองเส้นจากมุมใดมุมหนึ่ง แล้วเอาไป cross กัน:
#include <iostream>
#include <cmath>
struct Vec3 {
float x, y, z;
};
Vec3 sub(Vec3 a, Vec3 b) {
return { a.x - b.x, a.y - b.y, a.z - b.z };
}
Vec3 cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
float length(Vec3 v) {
return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}
Vec3 normalize(Vec3 v) {
float len = length(v);
return { v.x / len, v.y / len, v.z / len };
}
int main() {
Vec3 A = {0, 0, 0};
Vec3 B = {4, 0, 0};
Vec3 C = {0, 3, 0};
Vec3 edge1 = sub(B, A);
Vec3 edge2 = sub(C, A);
Vec3 rawNormal = cross(edge1, edge2);
Vec3 normal = normalize(rawNormal);
std::cout << "raw normal = (" << rawNormal.x << ", " << rawNormal.y << ", " << rawNormal.z << ")\n";
std::cout << "unit normal = (" << normal.x << ", " << normal.y << ", " << normal.z << ")\n";
}
Output:
raw normal = (0, 0, 12)
unit normal = (0, 0, 1)
สามเหลี่ยมนี้วางแบนอยู่บนระนาบ x-y ดังนั้น normal ของมันต้องชี้ไปตามแกน z ตรง ๆ — และก็เป็นแบบนั้นจริง ๆ: (0, 0, 1) นี่คือวิธีที่ normal สำหรับแสงของโมเดล 3D ถูกคำนวณจากข้อมูลสามเหลี่ยมดิบ ๆ ก่อนที่จะเอาไป render จริง ๆ เลย
ความยาวของ cross product แบบดิบ (ที่ยังไม่ normalize) เท่ากับพื้นที่ของ parallelogram ที่ edge vector สองเส้นกวาดออกมา เพราะสามเหลี่ยมคือครึ่งหนึ่งของ parallelogram นั้นพอดี หารสองก็จะได้พื้นที่สามเหลี่ยม — ของแถมที่ดีทีเดียว
#include <iostream>
#include <cmath>
struct Vec3 {
float x, y, z;
};
Vec3 sub(Vec3 a, Vec3 b) {
return { a.x - b.x, a.y - b.y, a.z - b.z };
}
Vec3 cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
float length(Vec3 v) {
return std::sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}
int main() {
Vec3 A = {0, 0, 0};
Vec3 B = {4, 0, 0};
Vec3 C = {0, 3, 0};
Vec3 edge1 = sub(B, A);
Vec3 edge2 = sub(C, A);
float parallelogramArea = length(cross(edge1, edge2));
float triangleArea = parallelogramArea * 0.5f;
std::cout << "parallelogram area = " << parallelogramArea << "\n";
std::cout << "triangle area = " << triangleArea << "\n";
}
Output:
parallelogram area = 12
triangle area = 6
เช็คด้วยมือดู: นี่คือสามเหลี่ยมมุมฉากที่มีขาสองข้างยาว 4 กับ 3 ดังนั้นพื้นที่คือ 0.5 * 4 * 3 = 6 cross product ตรงกัน และต่างจากสูตรที่คำนวณด้วยมือ วิธีนี้ใช้ได้กับสามเหลี่ยมที่ลอยอยู่ตรงไหนก็ได้ใน 3D เอียงยังไงก็ได้ด้วย
matrix คือตารางตัวเลข ตัวมันเองไม่ทำอะไรเลย — แต่พอเอาไปคูณกับ vector มันจะสร้าง vector ตัวใหม่ ออกมา นี่คือแก่นของมันเลย: matrix คือ "สูตร" ที่ใช้ซ้ำได้สำหรับเปลี่ยน vector ใด ๆ ให้กลายเป็น vector อีกตัว โดยทำตามกฎเดิมทุกครั้ง หมุน 30 องศา, ขยาย 2 เท่า, กลับหัว — แต่ละสูตรพวกนี้เขียนเป็น matrix ได้ครั้งเดียว แล้วเอาไปใช้กับกี่จุดก็ได้ตามใจ
แต่ละค่าในผลลัพธ์คือหนึ่งแถวของ matrix เอาไปคูณทีละ component กับ vector แล้วบวกกัน matrix ที่ง่ายที่สุดคือ identity matrix: มันมี 1 อยู่บนเส้นทแยงมุม และ 0 ที่เหลือทั้งหมด และมันไม่เปลี่ยนอะไรเลย — เป็น transform แบบ "ไม่ทำอะไร" มีประโยชน์เป็นจุดเริ่มต้นและใช้ทดสอบ
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2]; // m[row][col]
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y, // new x
M.m[1][0] * v.x + M.m[1][1] * v.y // new y
};
}
int main() {
Mat2 identity = { { {1, 0},
{0, 1} } };
Vec2 p = {3, 4};
Vec2 r = mul(identity, p);
std::cout << "(" << r.x << ", " << r.y << ")\n";
}
Output:
(3, 4)
ใส่ (3, 4) เข้าไป ได้ (3, 4) ออกมา — ไม่เปลี่ยนแปลง ตรงตามที่สัญญาไว้เป๊ะ ทีนี้มาสร้าง matrix ที่ทำอะไรจริง ๆ กันบ้าง
scale matrix เอา x คูณด้วยตัวเลขหนึ่ง และ y คูณด้วยอีกตัวเลขหนึ่ง แยกจากกันเป็นอิสระ วางตัวเลขสองตัวนั้นไว้บนเส้นทแยงมุม แล้วที่เหลือใส่ 0:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2];
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y,
M.m[1][0] * v.x + M.m[1][1] * v.y
};
}
Mat2 scaleMatrix(float sx, float sy) {
return { { {sx, 0},
{0, sy} } };
}
int main() {
Mat2 S = scaleMatrix(2.0f, 0.5f); // twice as wide, half as tall
Vec2 p = {10, 10};
Vec2 r = mul(S, p);
std::cout << "(" << r.x << ", " << r.y << ")\n";
}
Output:
(20, 5)
rotation matrix ใช้ sine กับ cosine ของมุมที่จะหมุน เพื่อหมุน vector รอบจุดกำเนิด: [cos(theta), -sin(theta); sin(theta), cos(theta)] การหมุน (1, 0) ไป 90 องศาทวนเข็มนาฬิกา ควรจะได้ (0, 1):
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2];
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y,
M.m[1][0] * v.x + M.m[1][1] * v.y
};
}
Mat2 rotationMatrix(float degrees) {
float radians = degrees * 3.14159265f / 180.0f;
float c = std::cos(radians);
float s = std::sin(radians);
return { { {c, -s},
{s, c} } };
}
int main() {
Mat2 R = rotationMatrix(90.0f); // rotate 90 degrees counter-clockwise
Vec2 p = {1, 0};
Vec2 r = mul(R, p);
std::cout << "(" << r.x << ", " << r.y << ")\n";
}
Output:
(-4.37114e-08, 1)
เกือบจะเป็น (0, 1) — แต่ไม่เป๊ะเสียทีเดียว ตัวเลข -4.37114e-08 เล็ก ๆ นั้น (ค่าที่ใกล้เคียงกับ 0.0000000437) คือ floating-point rounding error ไม่ใช่บั๊ก 90 องศาที่แปลงเป็น radian ไม่สามารถเก็บได้เป๊ะ ๆ ใน float ดังนั้น cos ของ "เกือบจะ 90 องศาพอดี" จึงได้ค่ากลับมาเป็น "เกือบจะ 0 พอดี" แทนที่จะเป็น 0 เป๊ะ ๆ ในเกมจริง ๆ สิ่งนี้จะมองไม่เห็นบนหน้าจอเลย — แต่ก็ควรจำ pattern นี้ไว้ตั้งแต่ครั้งแรกที่เจอ แทนที่จะตกใจว่าคณิตศาสตร์การหมุนของคุณพัง
คุณอาจคิดว่า translation matrix (matrix สำหรับย้ายตำแหน่ง) ก็น่าจะง่ายพอ ๆ กัน แต่ไม่ใช่เลย — และเหตุผลก็สำคัญด้วย การคูณ matrix 2x2 คือสิ่งที่นักคณิตศาสตร์เรียกว่าฟังก์ชัน linear และคุณสมบัติหนึ่งของฟังก์ชัน linear ทุกตัวคือมันจะส่ง origin (0, 0) ไปที่ (0, 0) เสมอ ไม่มีการรวมกันของ scale กับ rotate ใด ๆ ที่จะขยับ origin ได้ เพราะทั้งสอง operation แค่ยืด/หดและหมุนสิ่งต่าง ๆ รอบ ๆ มันเท่านั้น
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat2 {
float m[2][2];
};
Vec2 mul(Mat2 M, Vec2 v) {
return {
M.m[0][0] * v.x + M.m[0][1] * v.y,
M.m[1][0] * v.x + M.m[1][1] * v.y
};
}
int main() {
Mat2 anyMatrix = { { {2, 0},
{0, 3} } }; // could be any scale or rotation at all
Vec2 origin = {0, 0};
Vec2 r = mul(anyMatrix, origin);
std::cout << "(" << r.x << ", " << r.y << ")\n"; // still the origin!
}
Output:
(0, 0)
แล้วเกมย้ายตำแหน่งของอะไรได้ยังไง? เทคนิคคือเพิ่มพิกัดปลอมตัวที่สามเข้าไป ที่มีค่าเป็น 1 เสมอ แล้วใช้ matrix 3x3 แทนที่จะเป็น 2x2 คอลัมน์พิเศษนั้นจะแอบใส่ offset คงที่เข้าไปในผลลัพธ์ได้ — offset นั้นแหละคือ translation เราจะอธิบายว่าทำไมมันถึงได้ผล และ "1 พิเศษ" นั้นหมายความว่ายังไงจริง ๆ ในหัวข้อ 10 ตอนนี้แค่ดูก่อนว่ามันได้ผลจริง:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
// treat the point as (x, y, 1) -- that extra 1 is explained later in this chapter
Vec2 transformPoint(const Mat3& M, Vec2 p) {
float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
return { x, y };
}
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
int main() {
Mat3 T = translateMatrix(5, 3);
Vec2 origin = {0, 0};
Vec2 point = {2, 2};
Vec2 r1 = transformPoint(T, origin);
Vec2 r2 = transformPoint(T, point);
std::cout << "origin moved to (" << r1.x << ", " << r1.y << ")\n";
std::cout << "point moved to (" << r2.x << ", " << r2.y << ")\n";
}
Output:
origin moved to (5, 3)
point moved to (7, 5)
origin ย้ายไปที่ (5, 3) จริง ๆ ตรงตามค่า translation เป๊ะ และ (2, 2) ก็ไปลงที่ (7, 5) — 2+5 กับ 2+3 แถวและคอลัมน์ที่สามนั้นทำให้การบวกธรรมดา ๆ แอบเนียนไปกับการคูณ matrix ได้
วัตถุจริง ๆ ต้องการมากกว่าหนึ่ง transform ในเวลาเดียวกัน — scale แล้วค่อย rotate แล้วค่อยย้ายไปตำแหน่งในโลก คุณจะเอา matrix แต่ละตัวไปคูณกับจุดทีละตัวก็ได้ แต่มันมีประโยชน์กว่ามากถ้า รวม matrix เข้าด้วยกันก่อน ให้เหลือ matrix ตัวเดียว แล้วเอา matrix ตัวเดียวนั้นไปใช้กับทุก vertex ของ mesh การรวม matrix สองตัวทำด้วย matrix multiplication: combined = A * B จะได้ matrix ตัวหนึ่งที่ให้ผลเหมือนกับการเอา B ไปใช้ก่อน แล้วค่อยตามด้วย A เป๊ะ
#include <iostream>
#include <cmath>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
Vec2 transformPoint(const Mat3& M, Vec2 p) {
float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
return { x, y };
}
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
Mat3 rotationMatrix(float degrees) {
float radians = degrees * 3.14159265f / 180.0f;
float c = std::cos(radians);
float s = std::sin(radians);
return { { {c, -s, 0},
{s, c, 0},
{0, 0, 1} } };
}
// standard 3x3 matrix multiply: result = A * B
Mat3 mul(const Mat3& A, const Mat3& B) {
Mat3 R{};
for (int row = 0; row < 3; row++)
for (int col = 0; col < 3; col++) {
float sum = 0;
for (int k = 0; k < 3; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
int main() {
Mat3 R = rotationMatrix(90.0f);
Mat3 T = translateMatrix(5.0f, 0.0f);
Mat3 rotateThenTranslate = mul(T, R); // T * R: R happens first, then T
Mat3 translateThenRotate = mul(R, T); // R * T: T happens first, then R
Vec2 p = {1, 0};
Vec2 a = transformPoint(rotateThenTranslate, p);
Vec2 b = transformPoint(translateThenRotate, p);
std::cout << "rotate then translate = (" << a.x << ", " << a.y << ")\n";
std::cout << "translate then rotate = (" << b.x << ", " << b.y << ")\n";
}
Output:
rotate then translate = (5, 1)
translate then rotate = (-2.62268e-07, 6)
(-2.62268e-07 คือ floating-point rounding แบบเดียวกับในหัวข้อ 7 — มองว่าเป็น 0 ก็ได้)
นี่คือนิสัยที่สำคัญที่สุดที่ต้องสร้างขึ้นเกี่ยวกับ matrix: matrix multiplication ไม่ commute — A * B ไม่เหมือนกับ B * A หมุนก่อนแล้วค่อยขยับไปทางขวา 5 หน่วย จะไปลงที่ (5, 1) ขยับไปทางขวา 5 หน่วยก่อนแล้วค่อยหมุนรอบจุดหมุน (ที่ยังอยู่ที่ origin) จะทำให้ offset ทั้งก้อนนั้นหมุนตามไปด้วย ไปลงที่ (0, 6) ทั้งคู่ใช้ rotation กับ translation ค่าเดียวกันเป๊ะ — แค่ ลำดับ เปลี่ยน ผลลัพธ์ก็ไปคนละทางเลย ในโค้ด mul(A, B) จะเอา B ไปใช้ก่อน (มันอยู่ใกล้จุดที่สุดถ้าอ่านการคูณจากขวาไปซ้าย) แล้วค่อย A ทีหลัง ถ้าสลับลำดับผิดใน engine วัตถุของคุณจะบินไปอยู่ตำแหน่งประหลาด ๆ ทันทีที่คุณเพิ่มการหมุนให้วัตถุที่กำลังเคลื่อนที่
model = T * R * S เพื่อให้ S (ตัวที่ใกล้จุดที่สุด) ทำงานก่อน scale กับ rotate รอบ local origin ของวัตถุเองก่อน แล้วค่อยย้ายทั้งก้อนที่มีรูปทรงเรียบร้อยแล้วไปตำแหน่งในโลกเป็นขั้นตอนสุดท้ายตอนนี้เราอธิบายได้แล้วว่าจุด 3D บน mesh ของตัวละครกลายเป็น pixel 2D บนจอของคุณได้ยังไง มันเดินทางผ่าน matrix สามตัว ตามลำดับ:
w ในหัวข้อ 10นี่คือเวอร์ชัน 2D แบบง่าย ๆ ของการเดินทางทั้งหมด โดยใช้ matrix ที่เราสร้างไว้แล้ว บวกกับ projection แบบ orthographic ธรรมดา (ไม่มี perspective) ในขั้นตอนสุดท้าย เพื่อให้ทุกตัวเลขเช็คด้วยมือได้:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
Vec2 transformPoint(const Mat3& M, Vec2 p) {
float x = M.m[0][0] * p.x + M.m[0][1] * p.y + M.m[0][2];
float y = M.m[1][0] * p.x + M.m[1][1] * p.y + M.m[1][2];
return { x, y };
}
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
Mat3 scaleMatrix(float sx, float sy) {
return { { {sx, 0, 0},
{0, sy, 0},
{0, 0, 1} } };
}
Mat3 mul(const Mat3& A, const Mat3& B) {
Mat3 R{};
for (int row = 0; row < 3; row++)
for (int col = 0; col < 3; col++) {
float sum = 0;
for (int k = 0; k < 3; k++)
sum += A.m[row][k] * B.m[k][col];
R.m[row][col] = sum;
}
return R;
}
int main() {
// -- MODEL: place a local mesh vertex into the game world --
Vec2 localVertex = {1, 1}; // corner of a unit square, in the mesh's own space
Mat3 model = mul(translateMatrix(10, 5), scaleMatrix(2, 2)); // scale x2, then move to world pos (10,5)
Vec2 worldVertex = transformPoint(model, localVertex);
// -- VIEW: re-measure the world relative to the camera --
Vec2 cameraPos = {10, 0};
Mat3 view = translateMatrix(-cameraPos.x, -cameraPos.y);
Vec2 viewVertex = transformPoint(view, worldVertex);
// -- PROJECTION: squash the visible [-10,10] range down to [-1,1] (NDC) --
float ndcX = viewVertex.x / 10.0f;
float ndcY = viewVertex.y / 10.0f;
// -- VIEWPORT: map NDC [-1,1] onto an 800x600 pixel screen (Y flipped: screen Y grows downward) --
float screenX = (ndcX * 0.5f + 0.5f) * 800.0f;
float screenY = (1.0f - (ndcY * 0.5f + 0.5f)) * 600.0f;
std::cout << "world = (" << worldVertex.x << ", " << worldVertex.y << ")\n";
std::cout << "view = (" << viewVertex.x << ", " << viewVertex.y << ")\n";
std::cout << "ndc = (" << ndcX << ", " << ndcY << ")\n";
std::cout << "screen = (" << screenX << ", " << screenY << ")\n";
}
Output:
world = (12, 7)
view = (2, 7)
ndc = (0.2, 0.7)
screen = (480, 90)
ไล่ตามดู: มุม local (1, 1) ถูก scale เป็น (2, 2) แล้วย้ายไปที่ world position (12, 7) กล้องอยู่ที่ (10, 0) ดังนั้นเทียบกับกล้องแล้วจุดนี้อยู่ที่ (2, 7) — ขวา 2 หน่วย ขึ้น 7 หน่วย projection บีบช่วงที่มองเห็น -10..10 ให้เหลือ -1..1 ได้ (0.2, 0.7) สุดท้ายขั้นตอน viewport จะยืด square -1..1 นั้นออกไปเป็นหน้าต่างขนาด 800x600 pixel (พลิกแกน Y เพราะแถวบนจอมักนับจากบนลงล่าง) ทำให้ vertex ไปลงที่ pixel (480, 90) — ค่อนไปทางขวาของกลางจอนิดหน่อย และอยู่ใกล้ด้านบนของจอ ซึ่งตรงกับจุดที่ "อยู่บนขวา" ของกล้องพอดี pipeline 3D จริง ๆ จะมีแกน z เพิ่มเข้ามาและมี perspective จริง (ซึ่งต้องใช้ค่า w จากหัวข้อถัดไป) แต่รูปร่างของ pipeline — model แล้วก็ view แล้วก็ projection แล้วก็ viewport — เหมือนกันเป๊ะ
ย้อนกลับไปหัวข้อ 7 translation ต้องการ "1 พิเศษ" แปะเข้าไปกับทุกจุด ตัวเลขพิเศษนั้นมีชื่อเรียก: homogeneous coordinates เพิ่มค่าที่สี่เข้ามา ปกติเรียกว่า w ควบคู่ไปกับ x, y, z engine 3D เต็มรูปแบบใช้ matrix 4x4 และ vector 4 ค่า (x, y, z, w) ทุกที่เลย ด้วยเหตุผลที่คุณกำลังจะเห็นต่อไปนี้
w ไม่ได้เป็นแค่ 1 คงที่ — มันมีความหมายจริง ๆ point (ตำแหน่ง) ใช้ w = 1 ส่วน direction ล้วน ๆ (อย่าง surface normal หรือทิศทางของ ray) ใช้ w = 0 ดูสิว่ามันทำอะไรกับ translation matrix:
#include <iostream>
struct Vec2 {
float x, y;
};
struct Mat3 {
float m[3][3];
};
Mat3 translateMatrix(float tx, float ty) {
return { { {1, 0, tx},
{0, 1, ty},
{0, 0, 1 } } };
}
// this time w is a real parameter, not always 1
Vec2 transform(const Mat3& M, Vec2 v, float w) {
float x = M.m[0][0] * v.x + M.m[0][1] * v.y + M.m[0][2] * w;
float y = M.m[1][0] * v.x + M.m[1][1] * v.y + M.m[1][2] * w;
return { x, y };
}
int main() {
Mat3 T = translateMatrix(5, 3);
Vec2 point = transform(T, {1, 1}, 1.0f); // a POINT: w = 1
Vec2 direction = transform(T, {1, 1}, 0.0f); // a DIRECTION: w = 0
std::cout << "point moved to (" << point.x << ", " << point.y << ")\n";
std::cout << "direction stayed (" << direction.x << ", " << direction.y << ")\n";
}
Output:
point moved to (6, 4)
direction stayed (1, 1)
ตอน w = 1 คอลัมน์ translation (tx, ty) จะถูกบวกเข้าไปเต็ม ๆ ทำให้ point ขยับ ตอน w = 0 คอลัมน์เดียวกันนั้นจะถูกคูณด้วยศูนย์แล้วหายไป ทำให้ direction ไม่ถูกแตะต้องเลย นี่คือเหตุผลว่าทำไมความต่างระหว่าง point กับ vector จากหัวข้อ 1 ไม่ใช่แค่เรื่องตั้งชื่อเฉย ๆ — engine เข้ารหัสมันด้วยตัวเลขจริง ๆ และมันเปลี่ยนวิธีที่ matrix ตัวเดียวกันปฏิบัติกับตัวเลขคู่เดียวกัน
w ยังมีเคล็ดลับอีกอย่าง หลังจากผ่าน projection matrix แบบ perspective จริง ๆ (ไม่ใช่แบบแบน orthographic ที่เราใช้ในหัวข้อ 9) w จะเลิกเป็นแค่ 0 หรือ 1 — มันจะกลายเป็นตัวเลขที่เกี่ยวข้องกับ depth (ระยะห่างจากกล้อง) จากนั้น GPU จะเอา x, y, และ z ไปหารด้วย w ขั้นตอนนี้เรียกว่า perspective divide การหารด้วยตัวเลขที่ใหญ่กว่าจะทำให้ผลลัพธ์หดตัวมากกว่า และการหดเข้าหากลางจอนั้นแหละคือสิ่งที่ทำให้วัตถุที่อยู่ไกลดูเล็กลง
#include <iostream>
int main() {
// x and y BEFORE the perspective divide; w carries the depth (distance from camera)
float x = 4.0f, y = 4.0f;
float wNear = 2.0f; // a close object
float wFar = 8.0f; // a far-away object, same x and y before dividing
std::cout << "near: (" << x / wNear << ", " << y / wNear << ")\n";
std::cout << "far: (" << x / wFar << ", " << y / wFar << ")\n";
}
Output:
near: (2, 2)
far: (0.5, 0.5)
x กับ y ที่ใส่เข้าไปเหมือนกันเป๊ะ แต่ w ที่ใหญ่กว่าของวัตถุที่อยู่ไกล จะหารตัวเลขนั้นให้เล็กลงจนเหลือจุดที่ใกล้กลางจอมากกว่าเดิมสี่เท่า — เล็กลงและใกล้กลางมากขึ้น ซึ่งตรงกับที่ตาคุณมองเห็น perspective จริง ๆ พอดี การหารตัวเดียวนี้ ที่แอบซ่อนอยู่ในพิกัดตัวที่สี่ คือกลเม็ดทั้งหมดเบื้องหลังการ render แบบ 3D perspective
หัวข้อ 6 เรียก matrix ว่า "เครื่องจักรที่แปลง vector" แล้วก็ปล่อยไว้แค่นั้น แต่มีภาพที่ง่ายกว่าซึ่งทำให้อ่าน matrix ทุกตัวออกได้ในพริบตา: คอลัมน์ของ matrix คือที่ที่ basis vector ไปตกลง basis vector ก็คือแกนหน่วย (unit axes) นั่นเอง — (1, 0) สำหรับ x และ (0, 1) สำหรับ y matrix ทำอะไรกับลูกศรสองตัวนั้น เอามาเขียนเรียงกัน นั่นแหละ คือ ตัว matrix
#include <iostream>
struct Vec2 { float x, y; };
struct Mat2 { float m[2][2]; };
Vec2 mul(Mat2 M, Vec2 v) {
return { M.m[0][0]*v.x + M.m[0][1]*v.y,
M.m[1][0]*v.x + M.m[1][1]*v.y };
}
int main() {
// a matrix whose COLUMNS are the vectors we want the axes to land on:
// x-axis (1,0) -> (2, 1) y-axis (0,1) -> (-1, 3)
Mat2 M = { { {2, -1},
{1, 3} } };
Vec2 xAxis = {1, 0};
Vec2 yAxis = {0, 1};
Vec2 ix = mul(M, xAxis);
Vec2 iy = mul(M, yAxis);
std::cout << "x-axis lands on (" << ix.x << ", " << ix.y << ")\n";
std::cout << "y-axis lands on (" << iy.x << ", " << iy.y << ")\n";
// any vector is just a blend of the columns: (3,2) means 3*xAxis + 2*yAxis
Vec2 v = {3, 2};
Vec2 r = mul(M, v);
std::cout << "(3, 2) lands on (" << r.x << ", " << r.y << ")\n";
// by hand: 3*(2,1) + 2*(-1,3) = (6,3) + (-2,6) = (4,9)
}
Output:
x-axis lands on (2, 1)
y-axis lands on (-1, 3)
(3, 2) lands on (4, 9)
อ่าน matrix ทีละคอลัมน์: คอลัมน์แรก (2, 1) คือที่ที่แกน x ไปตกลงพอดี และคอลัมน์ที่สอง (-1, 3) คือที่ที่แกน y ไปตกลง การคูณกับ vector ใด ๆ ก็แค่ก้าวไปตามแกนที่ตกลงแล้วแต่ละแกนเป็นจำนวนเท่านั้น ๆ แล้วบวกกัน — (3, 2) หมายถึง "คอลัมน์แรก 3 ที บวกคอลัมน์ที่สอง 2 ที" ซึ่งไปตกที่ (4, 9) นี่คือเหตุผลว่าทำไม rotation matrix ถึงสร้างจาก sine และ cosine: คอลัมน์ของมันก็คือแกน x และแกน y หลังจาก ที่ถูกหมุนแล้วนั่นเอง และนี่คือเหตุผลว่าทำไมคอลัมน์แรก ๆ ของ transform 3D ของวัตถุ ถึงเป็นแกน right, up, และ forward ของวัตถุนั้นเองแบบตรงตัวเลย เขียนอยู่ในพิกัดของโลก — ข้อเท็จจริงที่สองหัวข้อถัดไปจะพึ่งพาโดยตรง
ถ้าคอลัมน์ของ transform ของวัตถุคือแกน right, up, และ forward ของมันเองใน world space แล้ว matrix ก็ทำมากกว่าแค่ผลักจุดไปมา: มันแปลงไปมาระหว่างสองวิธีในการอธิบายตำแหน่งเดียวกัน — world space ที่ทุกคนใช้ร่วมกัน กับ local space ส่วนตัวของวัตถุ (ที่วัตถุนั่งอยู่ที่ origin หันหน้าไปตามแกนของมันเอง) การแปลงจากอันหนึ่งไปอีกอันเรียกว่า change of basis และมันตอบคำถามที่ทำด้วยวิธีอื่นแล้วปวดหัว เช่น "ศัตรูตัวนั้นอยู่ทางซ้ายหรือขวาของฉัน?"
เมื่อแกนต่าง ๆ เป็น orthonormal basis (ยาวหน่วยละ 1 ทุกตัวและตั้งฉากกันหมด — rotation ล้วน ๆ ทุกตัวเป็นแบบนี้) การแปลง vector ของโลกเข้าสู่ local space ก็แค่ dot product กับแต่ละแกน ตรงนี้ยานลำหนึ่งหมุนไปจนแกน right และ forward ของมันเองชี้ไปคนละทิศในโลก แล้วเราถามว่า target อยู่ตรงไหน เมื่อมองจากมุมของยาน:
#include <iostream>
struct Vec2 { float x, y; };
float dot(Vec2 a, Vec2 b) { return a.x*b.x + a.y*b.y; }
int main() {
// the ship's local axes, written in WORLD coordinates (an orthonormal basis)
Vec2 shipRight = {0, 1}; // ship's +X (right) points toward world north
Vec2 shipForward = {-1, 0}; // ship's +Y (forward) points toward world west
// a world-space vector from the ship to a target
Vec2 toTarget = {3, 4};
// change of basis WORLD -> LOCAL: dot with each local axis
float localX = dot(toTarget, shipRight); // how far to the ship's right
float localY = dot(toTarget, shipForward); // how far ahead of the ship
std::cout << "target in ship space = (" << localX << ", " << localY << ")\n";
std::cout << (localX > 0 ? "target is to my RIGHT\n" : "target is to my LEFT\n");
std::cout << (localY > 0 ? "target is AHEAD\n" : "target is BEHIND\n");
}
Output:
target in ship space = (4, -3)
target is to my RIGHT
target is BEHIND
target ไม่ได้ขยับไปไหน — (3, 4) ใน world space กับ (4, -3) ใน space ของยาน คือจุดเดียวกัน แค่วัดเทียบกับแกนคนละชุด เพราะแกนของยานเป็น orthonormal การเอา dot product สองตัวนั้นมาเรียงกัน ก็คือ การคูณด้วย matrix ที่มี แถว เป็นแกนของยาน ซึ่งก็คือ transpose ของ transform ของยานเอง และสำหรับ matrix แบบ orthonormal (rotation) transpose เท่ากับ inverse — ดังนั้น world-to-local ก็คือ inverse ของ local-to-world และคุณได้มันมาฟรี ๆ ด้วยการ transpose นี่แหละคือสิ่งที่ view matrix จากหัวข้อ 9 เป็นเป๊ะ ๆ: มันแสดงโลกทั้งใบในระบบแกน (basis) ของกล้อง ซึ่งเป็นเหตุผลว่าทำไมเราถึงอธิบายมันตรงนั้นว่าเป็น inverse ของ transform ของกล้องเอง
นี่คือบั๊กที่เคยหลุดไปอยู่ในเกมจริงมาแล้ว: โมเดลถูกยืดให้สูงกว่าความกว้าง แล้วจู่ ๆ แสงของมันก็ดูผิดเพี้ยน — พื้นผิวเหมือนถูกส่องจากมุมที่ผิด สาเหตุคือ surface normal แปลงด้วย matrix ตัวเดียวกับที่ใช้กับตัวพื้นผิวไม่ได้ เมื่อ matrix นั้น scale แบบไม่เท่ากันทุกแกน
normal ถูกนิยามด้วยการที่มันตั้งฉากกับพื้นผิวเสมอ ถ้าคุณยืด geometry แต่ดัน normal ผ่านการยืดแบบเดียวกันเป๊ะ มันจะเลิกตั้งฉาก มาดูมันเกิดขึ้นกับพื้นผิวมุม 45 องศาที่ถูกยืดกว้างเป็นสองเท่าในแกน x:
#include <iostream>
struct Vec2 { float x, y; };
struct Mat2 { float m[2][2]; };
Vec2 mul(Mat2 M, Vec2 v){ return { M.m[0][0]*v.x + M.m[0][1]*v.y,
M.m[1][0]*v.x + M.m[1][1]*v.y }; }
float dot(Vec2 a, Vec2 b){ return a.x*b.x + a.y*b.y; }
int main() {
// a flat surface at 45 degrees:
Vec2 tangent = {1, 1}; // runs ALONG the surface
Vec2 normal = {1, -1}; // sticks straight OUT (perpendicular: tangent . normal = 0)
// non-uniform scale: stretch X by 2, leave Y alone
Mat2 S = { { {2, 0}, {0, 1} } };
Mat2 Sinv = { { {0.5f, 0}, {0, 1} } }; // inverse of S (its transpose is itself here)
Vec2 newTangent = mul(S, tangent); // geometry is transformed by S
Vec2 wrongNormal = mul(S, normal); // WRONG: reuse the geometry matrix
Vec2 rightNormal = mul(Sinv, normal); // RIGHT: inverse-transpose of S
std::cout << "new tangent = (" << newTangent.x << ", " << newTangent.y << ")\n";
std::cout << "wrong normal = (" << wrongNormal.x << ", " << wrongNormal.y
<< ") dot with tangent = " << dot(wrongNormal, newTangent) << "\n";
std::cout << "right normal = (" << rightNormal.x << ", " << rightNormal.y
<< ") dot with tangent = " << dot(rightNormal, newTangent) << "\n";
}
Output:
new tangent = (2, 1)
wrong normal = (2, -1) dot with tangent = 3
right normal = (0.5, -1) dot with tangent = 0
ทางแก้คือกฎที่ควรจำไว้: ในการแปลง normal ให้ใช้ inverse-transpose ของ matrix ที่คุณใช้กับ geometry — เขียนเป็น (M^-1)^T dot product พิสูจน์ให้เห็น: normal ที่ "ผิด" ได้ 3 (ไม่ตั้งฉากอีกต่อไป แสงเลยออกมาผิด) ในขณะที่ normal แบบ inverse-transpose ได้ 0 (ยังตั้งฉากอยู่) หลังจากนั้นค่อย re-normalize เพราะการแปลงเปลี่ยนความยาวของ normal ไปด้วย
สองเหตุผลที่คุณอาจไม่เคยสังเกตเรื่องนี้มาก่อน:
หัวข้อ 11 บอกว่าคอลัมน์ของ rotation 3D คือแกน right, up, และ forward ของวัตถุ เรื่องนี้ทำย้อนกลับได้ด้วย: ถ้าคุณรู้ว่าวัตถุควร หันหน้า ไปทางไหน คุณก็ สร้าง rotation ของมันได้ด้วยการผลิตแกนสะอาด ๆ สามแกนขึ้นจากทิศทางเดียวนั้น นี่คือสิ่งที่ Quaternion.LookRotation ใน Unity และ "look-at" ของกล้องทำอยู่เบื้องหลัง และเครื่องมือสำหรับมันคือ cross product
ป้อนทิศทาง forward กับ "up" คร่าว ๆ เข้าไป (ปกติคือ world up, (0, 1, 0)) เอามา cross กันจะได้ vector right ที่ตั้งฉากกับทั้งคู่ แล้วเอา สองตัวนั้น มา cross กันอีกที จะได้ up สะอาด ๆ ที่ตั้งฉากกับอีกสองแกนพอดี:
#include <iostream>
#include <cmath>
struct Vec3 { float x, y, z; };
float dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
Vec3 cross(Vec3 a, Vec3 b) { return { a.y*b.z - a.z*b.y,
a.z*b.x - a.x*b.z,
a.x*b.y - a.y*b.x }; }
float length(Vec3 v) { return std::sqrt(dot(v, v)); }
Vec3 normalize(Vec3 v) { float l = length(v); return { v.x/l, v.y/l, v.z/l }; }
int main() {
Vec3 forward = normalize({1, 0, 1}); // the direction the object should face
Vec3 worldUp = {0, 1, 0}; // a rough "up" hint (need not be exact)
Vec3 right = normalize(cross(worldUp, forward)); // perpendicular to both
Vec3 up = cross(forward, right); // exact up, already unit length
std::cout << "right = (" << right.x << ", " << right.y << ", " << right.z << ")\n";
std::cout << "up = (" << up.x << ", " << up.y << ", " << up.z << ")\n";
std::cout << "forward = (" << forward.x << ", " << forward.y << ", " << forward.z << ")\n";
std::cout << "right.up = " << dot(right, up) << "\n";
std::cout << "right.forward = " << dot(right, forward) << "\n";
std::cout << "up.forward = " << dot(up, forward) << "\n";
}
Output:
right = (0.707107, 0, -0.707107)
up = (-0, 1, 0)
forward = (0.707107, 0, 0.707107)
right.up = 0
right.forward = -2.50326e-08
up.forward = 0
dot product ทั้งสามค่าแทบจะเป็นศูนย์หมด แสดงว่าแกนทั้งสามออกมาตั้งฉากกัน — และแต่ละแกนยาวหน่วยละ 1 เอามาเรียงเป็นคอลัมน์ คุณก็ได้ rotation matrix ที่ถูกต้องซึ่งหันหน้าไปทาง forward (-2.50326e-08 คือฝุ่น floating-point แบบเดียวกับหัวข้อ 7 ไม่ใช่ค่าที่ไม่เป็นศูนย์จริง ๆ ส่วน -0 คือลบศูนย์ ซึ่งเท่ากับศูนย์) ท่า "cross เพื่อหาตัวตั้งฉาก แล้ว cross อีกทีเพื่อเก็บกวาดแกนที่สาม" คือ Gram-Schmidt orthonormalization สองขั้น: มันเอา vector ที่ถูกคร่าว ๆ มาบังคับให้ตั้งฉากเป๊ะและยาวหน่วยละ 1
เรื่องนี้ยังปิดจ็อบที่ค้างอยู่ด้วย หัวข้อ 11 และข้อสังเกตเรื่อง quaternion ด้านล่าง เตือนว่า rotation matrix สามารถ drift ได้ — การคูณซ้ำ ๆ ปล่อยให้ floating-point error ค่อย ๆ แทรกเข้ามาจนคอลัมน์ไม่ตั้งฉากหรือไม่ยาวหน่วยละ 1 อีกต่อไป แล้ววัตถุก็เริ่ม shear หรือหดตัว การ re-orthonormalize คือทางแก้: เอา forward กับ up ที่ drift แล้ว มาวิ่งผ่าน cross product ชุดเดิมนี้อีกที คุณก็ได้ rotation สะอาด ๆ กลับมา engine ที่เก็บ matrix ไว้หลายเฟรมจะทำแบบนี้เป็นระยะ ๆ เป๊ะ ๆ การเก็บ rotation เป็น quaternion (หัวข้อ 17) เลี่ยงปัญหานี้ไปได้เกือบหมด เพราะ quaternion renormalize ได้ถูกกว่ามาก — แค่ scale มันกลับให้ยาวเท่ากับ 1
ก๊อป transform matrix จาก tutorial ของ OpenGL ไปวางในโค้ด DirectX ตรง ๆ แล้ววัตถุของคุณจะกระจัดกระจายไปอยู่ตำแหน่งมั่ว ๆ เหตุผลคือ convention คู่หนึ่งที่ engine แต่ละเจ้าเห็นไม่ตรงกัน และมือใหม่ชอบสับสนรวมกันเป็นเรื่องเดียว จริง ๆ มันคือสองทางเลือกที่ แยกจากกัน:
M * v (column vector) หรือวางไว้ข้างซ้ายเป็น v * M (row vector)? matrix สองตัวสำหรับ transform เดียวกันเป็น transpose ของกันและกันผลที่ตามมาแบบเจ็บ ๆ: ตัวเลขชุดเดียวกัน ถ้าอ่านด้วย convention ผิด จะกลายเป็น transpose ของสิ่งที่คุณตั้งใจ ตรงนี้ matrix "translate by (5, 0)" ตัวเดียวกันเป๊ะ ถูกเอาไปใช้ทั้งสองแบบ:
#include <iostream>
struct Vec3 { float x, y, z; };
struct Mat3 { float m[3][3]; };
// column-vector convention: result = M * v (Unity / OpenGL / textbook math)
Vec3 mulColumn(const Mat3& M, Vec3 v) {
return { M.m[0][0]*v.x + M.m[0][1]*v.y + M.m[0][2]*v.z,
M.m[1][0]*v.x + M.m[1][1]*v.y + M.m[1][2]*v.z,
M.m[2][0]*v.x + M.m[2][1]*v.y + M.m[2][2]*v.z };
}
// row-vector convention: result = v * M (DirectX / Unreal traditional)
Vec3 mulRow(Vec3 v, const Mat3& M) {
return { v.x*M.m[0][0] + v.y*M.m[1][0] + v.z*M.m[2][0],
v.x*M.m[0][1] + v.y*M.m[1][1] + v.z*M.m[2][1],
v.x*M.m[0][2] + v.y*M.m[1][2] + v.z*M.m[2][2] };
}
int main() {
// a "translate by (5,0)" matrix for the COLUMN-vector convention
// (translation sits in the last COLUMN):
Mat3 M = { { {1, 0, 5},
{0, 1, 0},
{0, 0, 1} } };
Vec3 p = {2, 3, 1}; // a point, w = 1
Vec3 a = mulColumn(M, p); // M * p -- correct for this matrix
Vec3 b = mulRow(p, M); // p * M -- SAME numbers, wrong convention
// the row-vector convention needs the TRANSPOSE (translation in the last ROW):
Mat3 Mt = { { {1, 0, 0},
{0, 1, 0},
{5, 0, 1} } };
Vec3 c = mulRow(p, Mt); // correct again
std::cout << "M * p (column convention) = (" << a.x << ", " << a.y << ", " << a.z << ")\n";
std::cout << "p * M (row conv, NOT transposed) = (" << b.x << ", " << b.y << ", " << b.z << ")\n";
std::cout << "p * Mt (row conv, transposed) = (" << c.x << ", " << c.y << ", " << c.z << ")\n";
}
Output:
M * p (column convention) = (7, 3, 1)
p * M (row conv, NOT transposed) = (2, 3, 11)
p * Mt (row conv, transposed) = (7, 3, 1)
convention แบบ column ย้ายจุดไปที่ (7, 3, 1) — บวก 5 ในแกน x สะอาด ๆ พอเอา ตัวเลขชุดเดียวกัน ป้อนให้ convention แบบ row กลับได้ (2, 3, 11): translation รั่วไปอยู่ผิดพิกัด และ x ไม่ได้ขยับเลย การ transpose matrix ก่อน (ตอนนี้ translation อยู่แถวสุดท้าย) แก้มันกลับไปเป็น (7, 3, 1) transform เดียวกัน แต่ storage กลับด้านกันเหมือนภาพในกระจก
ยังมีผลที่เห็นได้อีกอย่างหนึ่ง เพราะ (A B)^T = B^T A^T การสลับ convention ยัง กลับลำดับการคูณ transform ด้วย นี่คือเหตุผลว่าทำไมการประกอบ scale-rotate-translate แบบเดียวกันถึงถูกเขียนสองแบบตรงข้ามกันขึ้นอยู่กับ engine:
สิ่งที่ควรจำไว้ตอนอ่านโค้ด engine: เช็คสองอย่างก่อนจะเชื่อ matrix — vector ไปอยู่ฝั่งไหน (M * v หรือ v * M) และ API ส่ง storage แบบ row-major หรือ column-major มาให้ กฎของหัวข้อ 8 ที่ว่า "T * R * S scale ทำงานก่อน" เขียนไว้สำหรับโลกแบบ column-vector ของบทนี้ (และของ Unity กับ OpenGL) ในโลกแบบ row-vector ของ DirectX และ Unreal ดั้งเดิม สูตรเดียวกันเป๊ะจะสะกดเป็น S * R * T — operation เกิดขึ้นในลำดับจริงเหมือนกัน แค่สัญกรณ์กลับด้านเท่านั้น
หัวข้อ 7 เจอ artifact จากการปัดเศษไปแล้วหนึ่งอย่าง — การหมุนที่ได้ -4.37114e-08 แทนที่จะเป็นศูนย์สะอาด ๆ อันนั้นไม่มีพิษภัย แต่บางอันมี และมันทำให้เกิด crash และ glitch จริง ๆ สองอันที่ควรรู้ไว้ก่อนที่มันจะกินเวลาคุณไปครึ่งวัน
สูตรหามุมระหว่าง vector จากหัวข้อ 4 ป้อน dot(a,b) / (length(a) * length(b)) เข้า acos ตรง ๆ ในทางคณิตศาสตร์ อัตราส่วนนี้อยู่ระหว่าง -1 กับ 1 เสมอ แต่ใน floating point มันอาจไปตกที่ 1.0000001 — เกินออกไปนิดเดียว — และ acos ของอะไรก็ตามที่เกิน 1 นั้นไม่นิยาม มันเลยคืน NaN ("not a number") ซึ่งจะไปทำให้ทุกการคำนวณต่อจากนั้นเสียหาย เรื่องนี้เกิดบ่อยที่สุดกับมุมระหว่าง vector สองตัวที่เกือบขนานกัน ซึ่งเป็นตอนที่คุณคาดไม่ถึงว่าจะมีปัญหาพอดี
#include <iostream>
#include <cmath>
#include <algorithm>
int main() {
// two unit vectors pointing almost the same way: the true angle is ~0.
// floating-point rounding can push their dot product just OVER 1.0
float cosTheta = 1.0000001f;
float bad = std::acos(cosTheta); // no clamp
float clamped = std::acos(std::min(1.0f, std::max(-1.0f, cosTheta))); // clamp first
std::cout << "acos(1.0000001) unclamped = " << bad << "\n";
std::cout << "acos, clamped to [-1,1] = " << clamped << "\n";
}
Output:
acos(1.0000001) unclamped = nan
acos, clamped to [-1,1] = 0
ทางแก้คือบรรทัดเดียว: clamp ค่า cosine ให้อยู่ในช่วง [-1, 1] ก่อนเรียก acos โค้ดหามุมในหัวข้อ 4 ละเรื่องนี้ไว้เพื่อให้สั้น แต่โค้ดใช้งานจริงไม่มีทางละ Vector3.Angle ของ Unity และ math library จริงจังทุกตัว clamp ภายในด้วยเหตุผลนี้เป๊ะ
การปัดเศษยังหมายความว่า ค่าสองค่าที่ ควรจะ เท่ากัน มักจะไม่เท่ากันในระดับบิต การบวก 0.1f สิบครั้งเป็นเคสคลาสสิก — มันพิมพ์ออกมาเป็น 1 ด้วยซ้ำ แต่กลับสอบตกการทดสอบความเท่ากันแบบเป๊ะ ๆ:
#include <iostream>
#include <cmath>
int main() {
float sum = 0.0f;
for (int i = 0; i < 10; i++) sum += 0.1f;
std::cout << "sum of ten 0.1f = " << sum << "\n";
std::cout << "sum == 1.0f ? " << (sum == 1.0f) << "\n";
std::cout << "close enough (eps) ? " << (std::fabs(sum - 1.0f) < 0.00001f) << "\n";
}
Output:
sum of ten 0.1f = 1
sum == 1.0f ? 0
close enough (eps) ? 1
ผลรวม แสดง เป็น 1 เพราะการพิมพ์ปัดเศษให้ แต่ sum == 1.0f เป็น 0 (false): ค่าที่เก็บไว้จริงเพี้ยนไปนิดเดียว กฎคือให้เทียบด้วย tolerance เล็ก ๆ — epsilon — แทน ==: ถามว่า fabs(a - b) ต่ำกว่า threshold เล็ก ๆ สักค่าไหม นี่คือไอเดีย epsilon เดียวกับที่ warning เรื่อง normalize ในหัวข้อ 3 ใช้เพื่อเลี่ยงการหารด้วยความยาวที่เกือบเป็นศูนย์ และเป็นเหตุผลว่าทำไมโค้ด engine ถึงเต็มไปด้วยการเทียบแบบ if (fabs(x) < 1e-5f) แทนที่จะเป็น if (x == 0)
บทนี้เราหมุนสิ่งต่าง ๆ ด้วย rotation matrix แบบ 2D (หัวข้อ 7) และพูดถึง rotation matrix แบบ 3D ใน pipeline (หัวข้อ 9) ทั้งสองแบบใช้งานได้ แต่ใน 3D มันมีปัญหาจริง ๆ: rotation matrix ใช้ตัวเลข 9 ตัวเก็บสิ่งที่จริง ๆ ต้องการแค่ 3 ตัว (แกนกับมุม) และการคูณซ้ำ ๆ กันหลายครั้งอาจทำให้ floating-point error เล็ก ๆ สะสมขึ้นเรื่อย ๆ จนกระทั่ง matrix นั้นไม่ใช่ rotation ที่ "ถูกต้อง" อีกต่อไป การอธิบาย rotation แบบ 3D ด้วยมุมสามตัวแยกกัน (pitch, yaw, roll — เรียกว่า Euler angles) อ่านง่ายกว่า แต่อาจเจอ gimbal lock: สถานการณ์ที่แกนหมุนสองในสามแกนมาเรียงตัวตรงกัน ทำให้วัตถุเสียความสามารถในการหมุนอย่างอิสระรอบทิศทางหนึ่งไปถาวร
quaternion เก็บ rotation แบบ 3D ด้วยตัวเลขแค่ 4 ตัว ไม่มี gimbal lock และผสม (blend) ระหว่าง rotation สองค่าได้ลื่นไหล (operation ที่เรียกว่า slerp) ในแบบที่ Euler angle ทำไม่ได้ ทั้ง Unity และ Unreal เก็บ rotation ภายในเป็น quaternion แล้วค่อยแปลงเป็น rotation matrix ตอนจำเป็นจริง ๆ เท่านั้น คือตอนที่ vertex ต้องถูก transform จริง ๆ นั่นคือหัวข้อทั้งหมดของบทถัดไป 2.3 — ตอนนี้แค่รู้ไว้ว่าเวลาคุณเห็น "rotation" ถูกเก็บไว้ใน engine มันมักจะเป็นตัวเลขสี่ตัว ไม่ใช่สามหรือเก้าตัว
sqrt(x*x + y*y [+ z*z])a.x*b.x + a.y*b.y [+ a.z*b.z] ตัวเลขเดียวที่วัดว่า vector สองตัวเข้ากันมากแค่ไหนn: a - 2 (a . n) n เป็นพื้นฐานของการเด้งหรือ ricochetw) และทันทีหลัง perspective divide (NDC ประมาณ -1..1)(x, y, z, w) เพื่อให้ translation, rotation, และ scale ทำได้ด้วยการคูณ matrix ครั้งเดียว1 สำหรับ point, 0 สำหรับ direction หลัง perspective projection จะเกี่ยวข้องกับ depthx, y, z หารด้วย w หลัง projection ทำให้วัตถุที่อยู่ไกลดูเล็กลง(1,0), (0,1) (และตัวที่สามใน 3D) คอลัมน์ของ matrix คือที่ที่พวกมันไปตกลง(M^-1)^T matrix ที่ต้องใช้แปลง surface normal เพื่อให้มันยังตั้งฉากอยู่ภายใต้ non-uniform scalev * M, row) หรือข้างขวา (M * v, column) ของ matrix matrix สองตัวเป็น transpose กัน และ convention นี้กลับลำดับการคูณM * vacos ของค่าที่เกิน 1 clamp input เพื่อเลี่ยงมันfabs(a - b) < eps) แทน == และใช้กันการหารด้วยค่าที่เกือบเป็นศูนย์(2, 2) หันหน้าไปทาง (1, 0) (หันไปทางตะวันออก) มีเสียงแปลก ๆ ดังมาจาก P = (5, 2) และ Q = (0, 5) สำหรับแต่ละเสียง ให้คำนวณ vector จาก guard ไปยังเสียงนั้น แล้วหา dot product ของทิศทางที่ guard หันหน้ากับ vector นั้น แล้วบอกว่า guard หันหน้าเข้าหาเสียงนั้นไหม (อยู่ข้างหน้า) หรือหันหลังให้ (อยู่ข้างหลัง) — โดยไม่ต้องรันโค้ดใด ๆvector ไปยัง P: P - guard = (5-2, 2-2) = (3, 0) dot กับทิศทางที่หันหน้า (1,0): 1*3 + 0*0 = 3 เป็นค่าบวก ดังนั้น P อยู่ข้างหน้า guard
vector ไปยัง Q: Q - guard = (0-2, 5-2) = (-2, 3) dot กับทิศทางที่หันหน้า (1,0): 1*(-2) + 0*3 = -2 เป็นค่าลบ ดังนั้น Q อยู่ข้างหลัง guard (เทียบกับทิศทางที่มันหันหน้า) — แม้ว่า Q จะอยู่เหนือ guard ก็ตาม คำว่า "ข้างหลัง" ที่นี่หมายถึง "อยู่ฝั่งตรงข้ามกับทิศทางที่หันหน้า" เท่านั้น ไม่ใช่ "อยู่ต่ำกว่าในทางกายภาพ"
(3, 1) แล้วค่อย ย้าย ด้วย translation (2, 0) โดยใช้ mul(A, B) หมายถึง "เอา B ไปใช้ก่อน แล้วค่อย A" (เหมือนในหัวข้อ 8) ให้คำนวณว่าจุด local p = (1, 1) จะไปลงที่ไหนภายใต้ ทั้ง mul(S, T) และ mul(T, S) โดย S = scale(3, 1) และ T = translate(2, 0) ลำดับไหนถูกต้องสำหรับการ scale วัตถุแล้วค่อยวางลงในโลก และอีกลำดับหนึ่งจะพังยังไงmul(S, T) เอา T ไปใช้ก่อน แล้วค่อย S: (1,1) ย้ายไปเป็น (3,1) แล้วค่อยถูก scale เป็น (9,1)
mul(T, S) เอา S ไปใช้ก่อน แล้วค่อย T: (1,1) ถูก scale เป็น (3,1) แล้วค่อยถูกย้ายเป็น (5,1)
mul(T, S) คือลำดับที่ถูกต้อง — ได้ (5,1) ซึ่งใกล้เคียงกับตำแหน่งในโลกที่ตั้งใจไว้รอบ ๆ (2,0) ส่วน mul(S, T) ได้ (9,1) ที่น่าประหลาดใจ เพราะการ translate ก่อน scale หมายความว่า scale จะไปยืด offset ของ translation เองด้วย ทำให้วัตถุลากไปไกลกว่า (2,0) มาก นี่คือบั๊กแบบ "วัตถุของฉันบินไปอยู่ตำแหน่งประหลาด ๆ หลังจากที่ฉันเพิ่ม scale เข้าไป" ที่พูดถึงในหัวข้อ 8 เป๊ะ — ทางแก้คือ scale ก่อน แล้ว rotate แล้วค่อย translate เสมอ
A = (1, 0, 0), B = (1, 4, 0), และ C = (1, 0, 5) ให้คำนวณ edge1 = B - A, edge2 = C - A, cross product แบบดิบ cross(edge1, edge2), ความยาวของมัน, unit surface normal, และพื้นที่ของสามเหลี่ยมedge1 = B - A = (0, 4, 0) edge2 = C - A = (0, 0, 5)
cross(edge1, edge2) = (4*5 - 0*0, 0*0 - 0*5, 0*0 - 4*0) = (20, 0, 0)
ความยาว: sqrt(20*20 + 0*0 + 0*0) = 20
unit normal: (20/20, 0/20, 0/20) = (1, 0, 0) — สามเหลี่ยมนี้วางแบนอยู่บนระนาบ x = 1 ดังนั้นก็สมเหตุสมผลที่ normal ของมันจะชี้ไปตามแกน x ตรง ๆ
พื้นที่สามเหลี่ยม: 20 / 2 = 10
d = (3, -4) แล้วชนพื้นราบที่มี unit normal n = (0, 1) ให้คำนวณ d . n แล้วหา velocity ที่สะท้อน reflect(d, n) = d - 2 (d . n) n component ไหนที่พลิก และทำไมมันถึงตรงกับลูกบอลที่กระเด้งขึ้นจากพื้นd . n = 3*0 + (-4)*1 = -4
reflect(d, n) = (3,-4) - 2*(-4)*(0,1) = (3,-4) - (0,-8) = (3,-4) + (0,8) = (3, 4)
component y พลิกจาก -4 (เคลื่อนที่ลง) เป็น +4 (เคลื่อนที่ขึ้น) ในขณะที่ x ไม่ถูกแตะต้อง นั่นคือการเด้งพอดี: พื้นกลับทิศการเคลื่อนที่ที่พุ่งเข้าพื้น (แนวตั้ง) แล้วปล่อยการเคลื่อนที่ตามพื้น (แนวนอน) ไว้เหมือนเดิม
right = (0, -1) และ forward = (1, 0) (มันหมุนไปจน "forward" ชี้ไปทางตะวันออกของโลก) กระสุนตกที่ offset ของโลก toTarget = (2, 3) จากรถถัง ใช้เทคนิค change-of-basis จากหัวข้อ 12 คำนวณ localX = toTarget . right และ localY = toTarget . forward แล้วบอกว่าจุดที่กระสุนตกอยู่ทางซ้ายหรือขวา และอยู่ข้างหน้าหรือข้างหลังของรถถังlocalX = (2,3) . (0,-1) = 2*0 + 3*(-1) = -3 เป็นลบ ดังนั้นจุดที่ตกอยู่ทาง ซ้าย ของรถถัง
localY = (2,3) . (1,0) = 2*1 + 3*0 = 2 เป็นบวก ดังนั้นจุดที่ตกอยู่ ข้างหน้า รถถัง
ในระบบแกนของรถถังเอง กระสุนตกที่ (-3, 2): ข้างหน้าสองหน่วยและซ้ายสามหน่วย — แม้ว่าในพิกัดของโลกมันจะอยู่บนขวาก็ตาม จุดเดียวกัน คนละแกน
t = (1, 1) และ normal n = (1, -1) (เช็ค: t . n = 0) geometry ถูก scale ด้วย S = "x คูณ 1, y คูณ 3" (การยืดแบบ non-uniform) คำนวณ (ก) tangent ใหม่ S * t; (ข) normal ที่ผิด S * n และ dot product ของมันกับ tangent ใหม่; (ค) normal ที่ถูกโดยใช้ inverse-transpose (สำหรับ S ที่เป็น diagonal นี้ ก็คือ S^-1 = "x คูณ 1, y คูณ 1/3") และ dot product ของมันกับ tangent ใหม่ อันไหนที่ยังตั้งฉากอยู่(ก) tangent ใหม่: S * (1,1) = (1*1, 3*1) = (1, 3)
(ข) normal ที่ผิด: S * (1,-1) = (1*1, 3*(-1)) = (1, -3) dot กับ tangent ใหม่: (1,-3) . (1,3) = 1 - 9 = -8 — ไม่ใช่ศูนย์ ดังนั้นมันไม่ตั้งฉากอีกต่อไป แสงจะออกมาผิด
(ค) normal ที่ถูก: S^-1 * (1,-1) = (1*1, (1/3)*(-1)) = (1, -0.333...) dot กับ tangent ใหม่: (1,-1/3) . (1,3) = 1 - 1 = 0 — ยังตั้งฉากอยู่ normal แบบ inverse-transpose คือตัวที่ถูกต้อง
นั่นคือคณิตศาสตร์ที่วางวัตถุทุกชิ้นลงบนหน้าจอ vector คือทิศทางบวกความยาว บวก ลบ และ scale มันเพื่อขยับสิ่งต่าง ๆ ไปมา dot product วัดความเข้ากันของทิศทางและให้มุม, การเช็คหน้า/หลัง, และ projection มาให้ cross product สร้าง vector ตั้งฉากตัวใหม่ — surface normal — และความยาวของมันให้พื้นที่มาฟรี ๆ matrix รวม scale, rotate, และ translate ไว้เป็นวัตถุที่ใช้ซ้ำได้ตัวเดียว การคูณมันเข้าด้วยกันคือการรวม transform แต่อย่าลืมว่าลำดับเปลี่ยนคำตอบเสมอ chain model-view-projection ก็คือการรวมกันแบบนั้นเป๊ะ ใช้ซ้ำสามครั้ง เพื่อพา vertex จาก local space เล็ก ๆ ของ mesh ไปจนถึง pixel บนจอของคุณ — โดยมีพิกัดตัวที่สี่ต่ำต้อยอย่าง w เงียบ ๆ ทำให้ทั้ง translation และ perspective เป็นไปได้ การหมุนที่ทำอย่างถูกต้องใน 3D สมควรมีเครื่องมือของตัวเอง — quaternion — ซึ่งเป็นจุดที่บท 2.3 จะเริ่มต้นต่อจากตรงนี้เป๊ะ