ในบทก่อนหน้าเราเรียนไปแล้วว่า transform (ข้อมูลที่บอกว่าวัตถุอยู่ตรงไหนในโลก) เก็บข้อมูลอยู่ 3 อย่าง คือ position (ตำแหน่ง), scale (ขนาด) และ rotation (การหมุน) ตำแหน่งเป็นแค่ตัวเลข 3 ตัว (x, y, z) บอกว่าจุดศูนย์กลางของวัตถุอยู่ตรงไหน ส่วน scale ก็เป็นตัวเลขอีก 3 ตัวบอกว่าวัตถุใหญ่แค่ไหนในแต่ละแกน ทั้งสองอย่างนี้เข้าใจง่าย เลขยิ่งมากก็ยิ่งเยอะ และเราสามารถเอามาบวก เฉลี่ย หรือ blend กันได้โดยไม่ต้องคิดเยอะ
แต่ rotation ไม่เหมือนกัน ไม่มีวิธี "ที่ถูกต้องชัดเจนวิธีเดียว" ในการเก็บ "วัตถุหันไปทางไหน" เป็นตัวเลข วิธีที่เราเลือกใช้ส่งผลจริง ๆ บางวิธีอ่านง่าย พิมพ์ลงใน editor ได้ง่าย แต่จะพังแบบเละ ๆ debug ยากตรงมุมบางมุม บางวิธีปลอดภัยจากบั๊กนั้นแต่หน้าตาเหมือนพีชคณิตไร้ความหมายตอนเห็นครั้งแรก บทนี้พูดถึงการแลกเปลี่ยน (trade-off) นี้ และพูดถึง quaternion (วิธีเก็บการหมุนด้วยตัวเลข 4 ตัว) ซึ่งเป็นสิ่งที่ engine จริงแทบทุกตัว ไม่ว่าจะ Unity, Unreal, id Tech ใช้เก็บ orientation อยู่ข้างในเสมอ ถึงแม้ editor จะโชว์อะไรที่เป็นมิตรกว่าให้เราดูก็ตาม
ลองนึกถึงโมเดลยานอวกาศที่วางอยู่ใน scene ของเรา position ของมันตอบคำถามว่า "มันอยู่ตรงไหน" ส่วน orientation (ทิศทางที่มันหันไป รวมถึงเอียงหรือหมุนรอบตัวแค่ไหน) ตอบคำถามว่า "มันหันไปทางไหน กลับหัวอยู่รึเปล่า" ใน 3D นั้น orientation มี 3 degrees of freedom (จำนวนทิศทางอิสระที่หมุนได้: หันซ้าย-ขวา, ก้มขึ้น-ลง, และเอียงซ้าย-ขวา) พอดี ซึ่งเท่ากับจำนวนตัวเลขของ position (x, y, z) แต่ตัวเลขพวกนี้ไม่ได้รวมกันง่าย ๆ แบบ position
นี่คือข้อเท็จจริงที่บทนี้ทั้งบทจะสร้างต่อยอดขึ้นไป ไม่ต้องพิสูจน์ก็ได้ แค่เชื่อและนึกภาพตามไปก่อน: orientation ใด ๆ ที่เป็นไปได้ใน 3D สามารถอธิบายได้ด้วยการหมุนรอบแกนเดียว มุมเดียว ไม่ว่า orientation สุดท้ายจะพลิกกลับไปกลับมาแปลกประหลาดแค่ไหน จะมีเส้นสมมติเส้นหนึ่งพาดผ่านวัตถุ และมีมุมหนึ่งมุม ที่ถ้าหมุนรอบเส้นนั้นด้วยมุมนั้นแล้วจะได้ orientation นั้นในการหมุนครั้งเดียว ข้อเท็จจริงนี้แหละคือเมล็ดพันธุ์ที่ quaternion งอกออกมา
struct Vec3 { float x, y, z; };
struct Transform {
Vec3 position; // where the object is -- 3 numbers, easy
Vec3 scale; // how big the object is -- 3 numbers, easy
// rotation goes here -- but WHAT type should it be?
// that question is what this whole chapter answers.
};
(ไม่มี output ตรงนี้ นี่แค่รูปร่างของข้อมูล เหมือน struct ธรรมดาที่เราใช้ทำ linked-list node หรือ vector ในบท C มาก่อน Quat ก็จะออกมาเรียบง่ายพอ ๆ กัน คือ float 4 ตัววางเรียงกันในหน่วยความจำ copy ถูก ไม่มี pointer ไม่มีการจอง heap)
วิธีที่ตรงไปตรงมาที่สุดในการเก็บ orientation คือเก็บเป็นมุม 3 มุม แกนละมุม เรียกว่า Euler angles (ตั้งตามชื่อนักคณิตศาสตร์ Leonhard Euler) และในเกมมักจะมาในชื่อที่เป็นมิตร 3 ชื่อ ยืมมาจากวงการการบิน:
struct EulerAngles {
float yaw; // turn left/right around the up (Y) axis, in degrees
float pitch; // turn nose up/down around the side (X) axis, in degrees
float roll; // tilt side to side around the forward (Z) axis, in degrees
};
int main() {
EulerAngles cam{ 35.0f, -10.0f, 0.0f }; // turned 35 deg right, tilted down 10 deg
printf("yaw=%.1f pitch=%.1f roll=%.1f\n", cam.yaw, cam.pitch, cam.roll);
return 0;
}
นี่คือเสน่ห์ทั้งหมดของ Euler angles: มันคือ ตัวเลขธรรมดา 3 ตัวที่อ่านออกเสียงได้ "หันขวา 35 องศา ก้ม 10 องศา" เป็นประโยคที่คนพูดแล้วเข้าใจได้ทันที นี่คือเหตุผลตรง ๆ ที่ Unity Inspector โชว์ rotation เป็นช่อง X/Y/Z สามช่อง และทำไม animator ถึง key rotation curve แยกทีละแกน ไม่มีอะไรเกี่ยวกับ quaternion ที่จะอ่านง่ายขนาดนี้ได้เลย
Euler angles ทำงานโดยหมุนแยกกัน 3 ครั้งต่อเนื่องกัน และการหมุนแต่ละครั้งที่ตามมาจะหมุนรอบแกนที่ ติดอยู่กับวัตถุหลังจากที่การหมุนก่อนหน้าขยับมันไปแล้ว จุดนี้แหละ การหมุนที่ซ้อนทับกัน คือจุดเริ่มต้นของปัญหา
ลองนึกภาพ gimbal ของจริง (ชุดวงแหวนที่แต่ละวงหมุนอยู่ข้างในอีกวงหนึ่ง แต่ก่อนใช้ในเข็มทิศ gyroscope และตัวกันสั่นของกล้อง นี่คือที่มาจริง ๆ ของคำว่า "gimbal lock") วงนอกหมุนสำหรับ yaw วงกลางหมุนสำหรับ pitch วงในหมุนสำหรับ roll ปกติแล้วสามวงนี้ชี้ไปคนละทิศกัน แต่ละวงจึงหมุนวัตถุไปคนละแบบ
ทีนี้ลองเอียงวงกลาง (pitch) ไปจนสุดที่ 90 องศา วงใน ซึ่งแต่ก่อนชี้ไปข้างหน้า จะถูกลากตามไปด้วยจนตอนนี้ชี้ ขึ้นตรง ๆ ทิศเดียวกับที่วงนอกชี้อยู่แล้ว แกน yaw กับแกน roll ตอนนี้กลายเป็นเส้นเดียวกันในอวกาศ
เรื่องนี้ไม่ใช่แค่ทฤษฎีลอย ๆ มาวัดกันจริง ๆ ดีกว่า ด้านล่างนี้ upFor(yaw, pitch, roll) สร้าง orientation จาก Euler angles สามตัว (ใช้ลำดับ yaw แล้ว pitch แล้ว roll) แล้วรายงานว่าทิศ "up" ของวัตถุไปชี้อยู่ตรงไหน (ฟังก์ชันช่วยอย่าง matMul, matMulVec, rotX, rotY, rotZ ตัดออกไปตรงนี้เพื่อให้โฟกัสอยู่ที่เรื่องการหมุน ไม่ใช่เมทริกซ์ มันคือ rotation matrix 3x3 ธรรมดา ๆ กับการคูณเมทริกซ์ตามที่คาดไว้ แกนละฟังก์ชัน)
// build orientation from yaw-pitch-roll, then see where "up" (0,1,0) ends up
Vec3 upFor(float yawDeg, float pitchDeg, float rollDeg) {
Mat3 R = matMul(matMul(rotY(yawDeg*DEG), rotX(pitchDeg*DEG)), rotZ(rollDeg*DEG));
return matMulVec(R, Vec3{0,1,0});
}
int main() {
printf("-- pitch = 0: roll clearly tilts 'up' --\n");
Vec3 u1 = upFor(20, 0, 0);
Vec3 u2 = upFor(20, 0, 30);
printf("yaw=20 pitch=0 roll=0 -> up (%.3f, %.3f, %.3f)\n", u1.x, u1.y, u1.z);
printf("yaw=20 pitch=0 roll=30 -> up (%.3f, %.3f, %.3f)\n", u2.x, u2.y, u2.z);
printf("\n-- pitch = 90: yaw and roll now do the SAME thing --\n");
Vec3 g1 = upFor(20, 90, 10);
Vec3 g2 = upFor(10, 90, 0);
Vec3 g3 = upFor(30, 90, 20);
printf("yaw=20 pitch=90 roll=10 -> up (%.3f, %.3f, %.3f)\n", g1.x, g1.y, g1.z);
printf("yaw=10 pitch=90 roll=0 -> up (%.3f, %.3f, %.3f)\n", g2.x, g2.y, g2.z);
printf("yaw=30 pitch=90 roll=20 -> up (%.3f, %.3f, %.3f)\n", g3.x, g3.y, g3.z);
return 0;
}
ดูบล็อกที่สองให้ดี ๆ คู่ (yaw, roll) ที่ต่างกันโดยสิ้นเชิงถึงสามคู่ -- (20,10), (10,0), (30,20) -- ให้ทิศ "up" ออกมาเหมือนกันเป๊ะ เพราะทุกคู่นั้น yaw - roll = 10 คงที่เสมอ ตราบใดที่ pitch = 90 มีแค่ผลต่างตัวนี้เท่านั้นที่สำคัญ yaw กับ roll ยุบรวมกันเหลือปุ่มควบคุมเดียว คุณเสีย degree of freedom ไปหนึ่งทิศเต็ม ๆ ถ้าคุณลองหมุนกล้องผ่าน pose นี้แบบนุ่ม ๆ อาจเห็นมันกระตุกหรือหมุนแบบไม่คาดคิด เพราะตัวเลขที่เกมกำลัง animate อยู่ไม่ map เข้ากับการหมุนที่คุณต้องการอย่างสะอาดอีกต่อไป
จำข้อเท็จจริงจาก Section 1 ได้ไหม: orientation ใด ๆ ไปถึงได้ด้วยการหมุนรอบแกนเดียวมุมเดียว Euler angles ทิ้งข้อเท็จจริงนี้ไปแล้วบังคับให้ทุก orientation ผ่านการหมุนต่อเนื่องกันสามครั้งรอบแกนที่ตายตัว ซึ่งนั่นแหละคือสิ่งที่ทำให้สองแกนชนกันจนเกิด gimbal lock quaternion (วัตถุ 4 ตัวเลข มักเขียนเป็น w, x, y, z) ใช้วิธีอีกแบบ: มันเก็บแกนเดียวมุมเดียวนั้นตรง ๆ อัดรวมกันด้วยวิธีที่ฉลาด และคำนวณง่ายมากด้วย
ไม่ต้องกังวลว่าสูตรด้านล่างมาจากไหน นั่นคือวิชา linear algebra เต็มเทอมที่ไม่จำเป็นสำหรับการทำเกม สิ่งที่ต้องมีคือความเข้าใจแบบภาพ: quaternion คือ "จับวัตถุด้วยเส้นนี้ แล้วบิดมันไปเท่านี้" แค่นั้นเลย ที่เหลือในบทนี้คือ: เราสร้างมันยังไง เอาสองตัวมารวมกันยังไง และเอามันไปขยับเวกเตอร์จริง ๆ ยังไง
struct Quat {
float w, x, y, z; // w = scalar part, (x,y,z) = axis part
};
(ยังไม่มี output ตรงนี้ นี่แค่รูปร่างของมัน float 4 ตัว เหมือน Vec3 ที่ใช้มาแล้ว แค่มีอีกหนึ่งฟิลด์เพิ่มมา section ถัดไปจะสร้างตัวจริงตัวแรกให้ดู)
การแปลง "หมุนรอบแกนนี้ด้วยมุมนี้" ให้เป็นตัวเลข 4 ตัว (w, x, y, z) ใช้สูตรเดียว ป้อน unit axis (เวกเตอร์ทิศทางที่มีความยาวเท่ากับ 1 พอดี) กับมุมเป็น radian เข้าไป มันจะคืน quaternion กลับมา:
Quat quatFromAxisAngle(Vec3 axis, float angleRadians) {
float halfAngle = angleRadians * 0.5f;
float s = sinf(halfAngle);
Quat q;
q.w = cosf(halfAngle);
q.x = axis.x * s;
q.y = axis.y * s;
q.z = axis.z * s;
return q;
}
int main() {
float angle = 3.14159265358979323846f / 2.0f; // 90 degrees, in radians
Quat q = quatFromAxisAngle(Vec3{0,1,0}, angle); // 90 deg turn around the Y (up) axis
printf("q = (w=%.4f, x=%.4f, y=%.4f, z=%.4f)\n", q.w, q.x, q.y, q.z);
printf("length = %.4f\n", quatLength(q));
return 0;
}
นั่นคือ quaternion สำหรับ "หมุน 90 องศารอบแกนขึ้น" ฟิลด์ y ไม่เป็นศูนย์เพราะแกนที่ใช้คือ (0,1,0) (ชี้ไปตามแกน Y) ถ้าเราหมุนรอบแกน X แทน ฟิลด์ x จะเป็นตัวที่ไม่เป็นศูนย์แทน quatLength ก็แค่ทำแบบเดิม ๆ "ยกกำลังสองทุกตัว บวกกัน แล้วถอดราก" แบบที่เราทำกับความยาวของ Vec3 เดี๋ยว section 9 จะนิยามให้ครบ แต่ควร print ดูตรงนี้ก่อนเพื่อสังเกตอะไรบางอย่างที่สำคัญ: ความยาวออกมาเท่ากับ 1.0 พอดี นี่ไม่ใช่เรื่องบังเอิญ และ section 9 ทั้ง section พูดถึงว่าทำไมตัวเลขนี้ต้องคงเป็น 1 อยู่เสมอ
quatFromAxisAngle สมมติว่า axis ที่ส่งเข้าไปเป็น unit vector (ยาว 1) อยู่แล้ว ถ้าส่งทิศทางดิบ ๆ ที่ยังไม่ได้ normalize เช่น (0, 5, 0) เข้าไป สูตรจะรันได้โดยไม่ crash แต่ผลลัพธ์จะไม่ใช่การหมุนที่ถูกต้อง ต้อง normalize axis ก่อนเสมอสังเกตไหมว่าสูตรใช้ ครึ่งหนึ่ง ของมุม ไม่ใช่มุมเต็ม นี่คือเรื่องแปลกจริง ๆ เรื่องเดียวของ quaternion และมี pattern ที่สวยงามซ่อนอยู่ ถึงเราจะข้ามการพิสูจน์ไปก็ตาม ลอง print ตารางเล็ก ๆ มาดูด้วยตาตัวเอง: แกนเดียวกัน (ชี้ขึ้นตรง ๆ) มุมสี่แบบที่ต่างกัน
int main() {
const float PI = 3.14159265358979323846f;
float anglesDeg[] = {0, 90, 180, 270};
for (float d : anglesDeg) {
Quat q = quatFromAxisAngle(Vec3{0,1,0}, d * PI / 180.0f);
printf("angle=%5.1f deg -> w=%7.4f y=%7.4f\n", d, q.w, q.y);
}
return 0;
}
มีสองอย่างที่ควรสังเกต ทั้งคู่เป็นแค่การจับ pattern ไม่ต้องใช้พีชคณิตเลย:
w เริ่มที่ 1 (ไม่หมุนเลย) แล้วหดลงเข้าใกล้ 0 เมื่อมุมโตขึ้นเข้าใกล้ 180 องศา แล้วกลายเป็นลบหลังจากนั้น ให้นึกว่า w คือประมาณ "หมุนไปน้อยแค่ไหน" ใกล้ 1 คือหมุนน้อยมาก ใกล้ 0 คือหมุนไปเยอะแล้วw (ไม่นับ noise จาก floating-point) เท่ากับ 0 พอดี ส่วน -0.0000 ที่เห็นคือวิธีที่ float สะกดคำว่า "ศูนย์ แต่ sign bit ดันเป็นลบ" ไม่ใช่ค่าลบจริง ๆ และ y (ส่วนของแกนในกรณีนี้) โตขึ้นถึงค่าสูงสุด 1.0กฎที่แม่นยำ สำหรับใครอยากรู้ คือ w = cos(angle/2) และแต่ละตัวใน x, y, z = axis * sin(angle/2) ไม่ต้อง derive สูตรนี้ด้วยมือเองเลย แค่ต้องจำมันได้เวลาเปิดโค้ด engine หรือ debugger แล้วเห็นตัวเลข 4 ตัวของ quaternion ตอนนี้คุณรู้แล้วว่ามันไม่ใช่ตัวเลขสุ่ม ๆ แต่คือ cosine กับ sine ของครึ่งมุมที่อัดไว้
เกมต้องรวมการหมุนกันตลอดเวลา -- "เอา aim rotation ของตัวละครไปทับ body rotation ของมัน" "เอาการหมุนของเฟรมนี้ไปทับ orientation ของเฟรมที่แล้ว" ด้วย quaternion การรวมการหมุนสองอันเข้าด้วยกันคือการคูณครั้งเดียว:
Quat quatMultiply(const Quat& a, const Quat& b) {
Quat r;
r.w = a.w*b.w - a.x*b.x - a.y*b.y - a.z*b.z;
r.x = a.w*b.x + a.x*b.w + a.y*b.z - a.z*b.y;
r.y = a.w*b.y - a.x*b.z + a.y*b.w + a.z*b.x;
r.z = a.w*b.z + a.x*b.y - a.y*b.x + a.z*b.w;
return r;
}
ในงานจริงคุณแทบไม่ต้องพิมพ์สูตรนี้ด้วยมือเลย math library ของคุณ (หรือ Quaternion ของ Unity หรือ FQuat ของ Unreal) มีให้อยู่แล้ว สิ่งที่สำคัญจริง ๆ สำหรับ game programmer คือข้อเท็จจริงคมกริบข้อเดียว: การคูณ quaternion ไม่ commutative -- a * b กับ b * a มักเป็นการหมุนคนละแบบ ลำดับสำคัญ เหมือนกับที่ลำดับของ Euler angle ที่ apply ก่อนหลังสำคัญเป๊ะ ๆ
เพื่อเห็นความต่างจริง ๆ เราต้องเอาผลลัพธ์ไปหมุนจุดจริง ๆ สักจุด นั่นคือ quatRotateVec ซึ่ง section 8 จะอธิบายเต็ม ๆ ตอนนี้แค่เชื่อไปก่อนว่า quatRotateVec(q, v) แปลว่า "หมุนจุด v ด้วย quaternion q" แล้วดูว่าเกิดอะไรขึ้นเมื่อเราป้อนลำดับสองแบบที่ต่างกันด้านล่าง:
int main() {
const float PI = 3.14159265358979323846f;
Quat qYaw90 = quatFromAxisAngle(Vec3{0,1,0}, PI/2.0f); // 90 deg around Y (yaw)
Quat qPitch90 = quatFromAxisAngle(Vec3{1,0,0}, PI/2.0f); // 90 deg around X (pitch)
Vec3 v{0,0,1}; // a point 1 unit in front of us
Quat a = quatMultiply(qYaw90, qPitch90); // yaw THEN pitch (pitch applied first to v)
Quat b = quatMultiply(qPitch90, qYaw90); // pitch THEN yaw (yaw applied first to v)
Vec3 ra = quatRotateVec(a, v);
Vec3 rb = quatRotateVec(b, v);
printf("yaw * pitch applied to (0,0,1) -> (%.3f, %.3f, %.3f)\n", ra.x, ra.y, ra.z);
printf("pitch * yaw applied to (0,0,1) -> (%.3f, %.3f, %.3f)\n", rb.x, rb.y, rb.z);
return 0;
}
การหมุนสองอันเดียวกัน แค่สลับลำดับ จุดสุดท้ายออกมาต่างกันโดยสิ้นเชิง -- (0,-1,0) กับ (1,0,0) นี่ไม่ใช่บั๊ก มันคือข้อเท็จจริงแบบเดียวกับ "ใส่ถุงเท้าก่อนแล้วค่อยใส่รองเท้า" ที่ให้ผลต่างจาก (และดีกว่ามาก) "ใส่รองเท้าก่อนแล้วค่อยใส่ถุงเท้า"
quatMultiply(a, b) จากขวาไปซ้าย เหมือนที่อ่าน function ที่ compose กันแบบ f(g(x)): มันแปลว่า "apply การหมุนของ b ก่อน แล้วค่อย apply การหมุนของ a ทับ" ดังนั้น "world rotation ที่ apply กับวัตถุที่หมุนแบบ local อยู่แล้ว" มักเขียนเป็น quatMultiply(parentRotation, localRotation) โดย parent อยู่ซ้ายมือquaternion เดี่ยว ๆ ไม่ขยับอะไรทั้งนั้น เราต้องมีวิธี apply มันกับจุดหรือทิศทางจริง ๆ เช่น มุมของ mesh หรือทิศ forward ของกล้อง เทคนิคมาตรฐานคือ "แซนด์วิช": แปลงเวกเตอร์ให้เป็น quaternion ที่มี w เท่ากับศูนย์ คูณด้วย quaternion การหมุนทางซ้าย แล้วคูณด้วย conjugate ของมัน (w เท่าเดิม แต่ x, y, z กลับเครื่องหมาย) ทางขวา
Vec3 quatRotateVec(const Quat& q, const Vec3& v) {
Quat qv{0.0f, v.x, v.y, v.z}; // v, written as a quaternion
Quat qConj{q.w, -q.x, -q.y, -q.z}; // q's conjugate ("undo" version)
Quat result = quatMultiply(quatMultiply(q, qv), qConj);
return Vec3{result.x, result.y, result.z};
}
ไม่ต้องพิสูจน์ว่าทำไมรูปทรงแซนด์วิช q * v * q_conjugate ถึงหักล้างจนเหลือแค่การหมุนล้วน ๆ แค่เชื่อว่ามันเป็นแบบนั้น เหมือนที่คุณเชื่อ sqrt โดยไม่ต้อง derive Newton's method ใหม่ทุกครั้งที่เรียกใช้ สิ่งที่สำคัญคือสูตร: แซนด์วิชเวกเตอร์ไว้ระหว่าง quaternion กับ conjugate ของมัน แล้วสิ่งที่เหลือคือเวกเตอร์ที่ถูกหมุนแล้ว
int main() {
const float PI = 3.14159265358979323846f;
Quat q = quatFromAxisAngle(Vec3{0,1,0}, PI/2.0f); // 90 deg around Y (up)
Vec3 v{1,0,0}; // pointing right
Vec3 r = quatRotateVec(q, v);
printf("rotate (1,0,0) by 90deg around Y -> (%.3f, %.3f, %.3f)\n", r.x, r.y, r.z);
return 0;
}
จุดที่อยู่ห่างไปทางขวาหนึ่งหน่วย พอหมุน 90 องศารอบ "แกนขึ้น" จะไปอยู่ข้างหน้าหนึ่งหน่วยแทน (เป็น Z ติดลบในกรณีนี้ ตามธรรมเนียมแกนที่เราใช้) ถ้าหมุนอีก 90 องศาแบบเดิม มันจะไปอยู่ทางซ้ายที่ (-1,0,0) หมุนครบ 90 องศาทุกครั้งตามที่คาดไว้เป๊ะ
ย้อนกลับไปที่ section 5 เราสังเกตว่าความยาวของ quaternion ออกมาเท่ากับ 1.0 พอดี quaternion ที่มีความยาวเท่ากับ 1 พอดีเรียกว่า unit quaternion และมีแค่ unit quaternion เท่านั้นที่แทนการหมุนล้วน ๆ ได้ถูกต้อง ถ้าความยาวคลาดเคลื่อนไปจาก 1 แม้แต่นิดเดียว สูตรแซนด์วิชจาก section 8 จะไม่ใช่การหมุนที่สะอาดอีกต่อไป แต่จะแอบมี scaling เพิ่มเข้ามา ซึ่งอาจทำให้ mesh บิดเบี้ยวแทนที่จะแค่หมุน
float quatLength(const Quat& q) {
return sqrtf(q.w*q.w + q.x*q.x + q.y*q.y + q.z*q.z);
}
Quat quatNormalize(const Quat& q) {
float len = quatLength(q);
return Quat{q.w/len, q.x/len, q.y/len, q.z/len};
}
ทำไมความยาวถึงคลาดเคลื่อนได้ล่ะ ทุกการคูณ ทุกการ interpolate ทุกเฟรมของเกมมันเพิ่ม floating-point rounding error เล็ก ๆ เข้ามานิดหนึ่ง (แบบเดียวกับ rounding ที่คุณเคยเห็นตอนเรียนรู้ครั้งแรกว่า float แทนตัวเลขทุกตัวไม่ได้เป๊ะ) error เล็ก ๆ ต่อเฟรมฟังดูไม่เป็นอันตราย แต่เกมรันที่ 60, 120 บางทีเป็นร้อยเฟรมต่อวินาที ต่อเนื่องกันเป็นนาที ๆ มาดูกันจริง ๆ ว่ามันเกิดขึ้นยังไง:
int main() {
Quat orientation = quatFromAxisAngle(Vec3{0,1,0}, 0.0f); // identity, facing forward
Quat delta = quatFromAxisAngle(Vec3{0,1,0}, 0.001f); // tiny turn applied every frame
printf("length at frame 0 = %.8f\n", quatLength(orientation));
for (int frame = 1; frame <= 200000; frame++) {
orientation = quatMultiply(delta, orientation); // NOT normalized after each step
}
printf("length after 200000 frames = %.8f\n", quatLength(orientation));
Quat fixedUp = quatNormalize(orientation);
printf("length after normalize = %.8f\n", quatLength(fixedUp));
return 0;
}
สองแสนเฟรม (ครึ่งชั่วโมงกว่า ๆ ที่ 100 fps) ของการคูณเล็ก ๆ ที่ถูกต้องทุกครั้งทีละครั้ง ความยาวคืบคลานจาก 1.0 ที่สมบูรณ์แบบไปเป็น 1.00041544 เป็นตัวเลขที่เล็กมาก แต่ไม่ใช่ศูนย์ และจะยิ่งโตขึ้นเรื่อย ๆ ยิ่งเกมรันนานเท่าไหร่ แค่เรียก quatNormalize ครั้งเดียวก็ดึงมันกลับมาที่ 1.0 พอดีได้ทันที
กล้องกับ animation ต้อง blend จาก orientation หนึ่งไปอีก orientation หนึ่งแบบนุ่ม ๆ อยู่ตลอดเวลา -- กล้องที่ค่อย ๆ หันไปมองเป้าหมายใหม่ แขนตัวละครที่ animate จากท่าผ่อนคลายไปเป็นท่ายกแขน การ blend ตัวเลข 4 ตัวแบบ straight-line ตรง ๆ แล้วหวังว่าจะออกมาดี ไม่ได้ทำให้ผลลัพธ์เป็น unit quaternion ที่ถูกต้องโดยอัตโนมัติ แถม (ตามที่เราจะเห็นต่อไป) ยังไม่ได้เคลื่อนที่ด้วยความเร็วคงที่ด้วย เครื่องมือที่ถูกต้องสำหรับ "blend การหมุนแบบความเร็วคงที่" คือ SLERP (Spherical Linear intERPolation): แทนที่จะเป็นเส้นตรงระหว่าง quaternion สองตัว มันเดินตามผิวโค้งที่เชื่อมทั้งสองไว้ ด้วยความเร็วสม่ำเสมอ
float quatDot(const Quat& a, const Quat& b) {
return a.w*b.w + a.x*b.x + a.y*b.y + a.z*b.z;
}
Quat quatSlerp(Quat a, Quat b, float t) {
float dot = quatDot(a, b);
if (dot < 0.0f) { // take the SHORT way around, not the long way
a.w = -a.w; a.x = -a.x; a.y = -a.y; a.z = -a.z;
dot = -dot;
}
if (dot > 0.9995f) { // a and b are nearly identical: fall back to a cheap blend
Quat r{ a.w + t*(b.w-a.w), a.x + t*(b.x-a.x), a.y + t*(b.y-a.y), a.z + t*(b.z-a.z) };
return quatNormalize(r);
}
float theta0 = acosf(dot); // angle between a and b
float theta = theta0 * t; // angle at parameter t
float sinTheta0 = sinf(theta0);
float sinTheta = sinf(theta);
float s0 = cosf(theta) - dot * sinTheta / sinTheta0;
float s1 = sinTheta / sinTheta0;
return Quat{ s0*a.w + s1*b.w, s0*a.x + s1*b.x, s0*a.y + s1*b.y, s0*a.z + s1*b.z };
}
if (dot < 0.0f) ด้านบน นี่คือเหตุผลที่มันอยู่ตรงนั้น: q กับ -q (กลับเครื่องหมายทุกตัว) แทนการหมุนแบบเดียวกันเป๊ะ ปรากฏการณ์นี้เรียกว่า "double cover" ของ quaternion ถ้าไม่มีการเช็คตรงนี้ SLERP อาจจะเผลอไปอ้อมไกลถึง 350 องศาแทนที่จะเลี้ยวสั้น ๆ 10 องศาที่คุณต้องการจริง ๆ เพราะในทางคณิตศาสตร์ทั้งสองเส้นทาง "ถูกต้อง" เหมือนกัน แต่มีแค่เส้นทางเดียวที่ดูถูกต้องบนหน้าจอมาวัด claim เรื่อง "ความเร็วคงที่" กันจริง ๆ โดย blend จากหันหน้าตรง (0 องศา) ไปเป็นหมุน 150 องศา แล้วเช็คมุมผลลัพธ์ที่แต่ละช่วงของ t เราจะเทียบกับ quatNlerp ด้วย ซึ่งเป็นฟังก์ชัน blend อีกตัวที่ section 11 จะนิยามให้ครบ ตอนนี้แค่รู้ไว้ว่ามันเป็น blend แบบง่าย ๆ ราคาถูกกว่า เพื่อจะได้เห็นว่ามันเทียบกับ SLERP ได้แค่ไหน:
int main() {
const float PI = 3.14159265358979323846f;
Quat a = quatFromAxisAngle(Vec3{0,1,0}, 0.0f);
Quat b = quatFromAxisAngle(Vec3{0,1,0}, 150.0f * PI / 180.0f); // turned 150 deg around Y
printf("t slerp angle(deg) nlerp angle(deg)\n");
for (float t = 0.0f; t <= 1.0001f; t += 0.25f) {
Quat s = quatSlerp(a, b, t);
Quat n = quatNlerp(a, b, t);
float sAngle = 2.0f * acosf(s.w) * 180.0f / PI;
float nAngle = 2.0f * acosf(n.w) * 180.0f / PI;
printf("%.2f %14.2f %16.2f\n", t, sAngle, nAngle);
}
return 0;
}
ดูคอลัมน์ slerp: 0, 37.5, 75, 112.5, 150 -- แต่ละช่วงห่างกันพอดี 37.5 องศา จังหวะสม่ำเสมอเป๊ะ ๆ เท่ากับ 150 * t พอดี นี่แหละคือความหมายของ "constant angular speed" ในทางปฏิบัติ นี่คือสิ่งที่ต้องการเป๊ะ ๆ สำหรับกล้องที่ pan ระหว่างสองทิศทางแบบนุ่ม ๆ ไม่มีการเร่งหรือชะลอกะทันหันที่ผู้เล่นจะสังเกตเห็นว่ากระตุก
คอลัมน์ nlerp ด้านบนไม่ได้เคลื่อนที่ด้วยจังหวะสม่ำเสมอ (33.02 แล้วกระโดดไป 75 แล้วไป 116.98) มันเริ่มช้าแล้วจบเร็ว NLERP (Normalized Linear intERPolation) คือตามชื่อเป๊ะ ๆ: blend ตัวเลข 4 ตัวด้วยค่าเฉลี่ยแบบ straight-line ธรรมดา แล้ว normalize ผลลัพธ์กลับไปที่ผิว "การหมุนที่ถูกต้อง" ซึ่งมีความยาวเท่ากับ 1
Quat quatNlerp(const Quat& a, const Quat& b, float t) {
Quat r{ a.w + t*(b.w-a.w), a.x + t*(b.x-a.x), a.y + t*(b.y-a.y), a.z + t*(b.z-a.z) };
return quatNormalize(r);
}
แล้วทำไมใครถึงจะใช้ตัวที่เร็วไม่สม่ำเสมอนี้ล่ะ มีสองเหตุผล ทั้งคู่เกี่ยวกับต้นทุน: quatSlerp เรียก acosf กับ sinf อีกสองครั้ง ซึ่งช้ากว่าการบวกไม่กี่ครั้งกับ sqrtf หนึ่งครั้งใน quatNlerp อย่างเห็นได้ชัด และความไม่สม่ำเสมอของความเร็วที่เห็นด้านบนจะเห็นชัดจริง ๆ ก็ตอนที่ orientation สองอันห่างกันมาก (แบบตัวอย่าง 150 องศาของเรา) สำหรับการขยับเล็ก ๆ ทีละเฟรมที่ gameplay code ส่วนใหญ่ทำจริง ๆ (กล้องที่ค่อย ๆ ตามเป้าหมายไม่กี่องศาต่อเฟรม ไม่ใช่ 150 องศาทีเดียว) ความต่างระหว่าง SLERP กับ NLERP ผู้เล่นมองไม่เห็นหรอก แต่การประหยัด CPU จากการเรียก NLERP แทน SLERP หลายพันครั้งต่อเฟรม (ลองนึกภาพฝูง NPC ที่กำลังหันไปมองอะไรบางอย่างพร้อมกัน) นั้นไม่ใช่เรื่องเล็กเลย
หลังจากสอง section ที่พูดสนับสนุน quaternion มาตลอด นี่คือมุมกลับที่ต้องพูดตรง ๆ: คุณควรใช้ Euler angles อยู่ตลอดเวลาเหมือนเดิม แค่ไม่ใช่งานแบบเดียวกัน Euler angles มีไว้สำหรับคน quaternion มีไว้สำหรับคณิตศาสตร์
pattern ที่ engine แทบทุกตัวใช้: author และ expose rotation เป็น Euler angles ที่ขอบ (editor UI, script ง่าย ๆ) แต่เก็บและคำนวณด้วย quaternion ทุกที่ที่การหมุนถูกรวมกัน animate หรือ blend นี่คือหน้าตาของมันใน Unity เพราะเรื่องนี้เป็นเรื่องที่คุณจะเจอตลอดเมื่อเขียน gameplay code ของ Unity:
using UnityEngine;
public class Turret : MonoBehaviour {
public float yawDegreesPerSecond = 90f;
void Update() {
// Easy to author: just spin around world up (Y) over time.
transform.Rotate(Vector3.up, yawDegreesPerSecond * Time.deltaTime, Space.World);
}
}
(ไม่มี output ใน console -- ทุกเฟรมโค้ดนี้ขยับ rotation ของวัตถุไปอีกไม่กี่องศารอบแกน Y ของโลก transform.Rotate ถูกเขียนแบบ axis-angle turn แต่ transform.rotation ของ Unity เก็บเป็น Quaternion อยู่ข้างใน และ Quaternion.Slerp(a, b, t) ก็คือ SLERP ตัวเดียวกับ section 10 เตรียมไว้ให้ใช้เลย เผื่อเวลาที่ต้องการให้กล้องหรือตัวละครหันไปหา orientation เป้าหมายแบบนุ่ม ๆ)
transform.eulerAngles ปรับแกนเดียว แล้วเขียนกลับทุกเฟรม ดูเหมือนไม่มีอันตราย แต่การอ่าน/เขียนแต่ละครั้งวนผ่านการแปลง quaternion-to-Euler ที่ไม่ได้ unique เสมอไป (orientation เดียวกันอาจมี Euler angles ที่ถูกต้องได้มากกว่าหนึ่งชุด) ทำให้ค่ากระโดดหรือพลิกกลับแบบไม่คาดคิดข้ามเฟรมได้ ให้ใช้ transform.Rotate, Quaternion.Euler(...) ที่ apply ครั้งเดียว หรือคำนวณ quaternion ตรง ๆ สำหรับอะไรที่ animate ต่อเนื่องนี่คือทั้งบทในรูป pipeline เดียว: ตัวเลขที่ author เข้าใจง่ายเข้าไปทางหนึ่ง คณิตศาสตร์ที่ปลอดภัยเกิดขึ้นตรงกลาง แล้วการเคลื่อนไหวที่นุ่มนวลออกมาอีกทาง
quatFromAxisAngle, รวมสองตัวด้วย quatMultiply (ลำดับสำคัญ!) และ apply กับจุดหนึ่งด้วย quatRotateVecw, x, y, z) ในรูปแบบแกนเดียวมุมเดียวw เท่าเดิมแต่ x, y, z กลับเครื่องหมาย ใช้ "ย้อนกลับ" การหมุนq กับค่าตรงข้าม -q แทนการหมุนแบบเดียวกันเป๊ะquatFromAxisAngle สร้าง quaternion สำหรับ การหมุน 180 องศารอบแกน Z ก่อนจะรันอะไรเลย ใช้ pattern ครึ่งมุมจาก section 6 เดาดูว่า (w, x, y, z) ควรจะหน้าตาเป็นยังไง แล้วเขียนโค้ด print quaternion ออกมา และใช้ quatRotateVec หมุนจุด (1, 0, 0) ด้วยมัน คุณคาดว่าจุดนั้นจะไปตกอยู่ตรงไหน แค่นึกภาพการหมุนครึ่งรอบ? โค้ดตรงกับที่เดาไหม?ครึ่งหนึ่งของ 180 องศาคือ 90 องศา ดังนั้นเราคาดว่า w = cos(90deg) = 0 และส่วน Z = sin(90deg) = 1 (เพราะแกนคือ (0,0,1)) ส่วน X กับ Y เป็น 0 การหมุนครึ่งรอบควรจะส่ง (1,0,0) ไปยังจุดตรงข้ามเป๊ะ ๆ คือ (-1,0,0)
int main() {
const float PI = 3.14159265358979323846f;
Quat q = quatFromAxisAngle(Vec3{0,0,1}, PI); // 180 deg around Z
printf("q = (w=%.4f, x=%.4f, y=%.4f, z=%.4f)\n", q.w, q.x, q.y, q.z);
Vec3 v{1,0,0};
Vec3 r = quatRotateVec(q, v);
printf("rotate (1,0,0) -> (%.4f, %.4f, %.4f)\n", r.x, r.y, r.z);
return 0;
}
ตรงกับที่เดาไว้เป๊ะ (-0.0000 เป็นแค่ noise จาก floating-point สำหรับค่าที่ในทางคณิตศาสตร์เป็นศูนย์): w กับ z ตรงกับที่คาดจาก pattern ครึ่งมุม และการหมุน (1,0,0) ครึ่งรอบพาไปตกที่ (-1,0,0) พอดี ซึ่งเป็นภาพสะท้อนของมันผ่านจุดกำเนิด
quatConjugate เล็ก ๆ (กลับเครื่องหมาย x, y, z เก็บ w ไว้เหมือนเดิม) ใช้มันหมุนเวกเตอร์ไปข้างหน้าด้วย quaternion q สักตัว แล้วหมุนผลลัพธ์ย้อนกลับด้วย quatConjugate(q) คุณคาดว่าจะเกิดอะไรขึ้นกับเวกเตอร์ และทำไม? ยืนยันด้วยโค้ดconjugate ของ unit quaternion ก็คือ inverse ของมันด้วย มันแทน "แกนเดิม มุมตรงข้าม" ซึ่งย้อนกลับการหมุนเดิม ดังนั้นการหมุนไปข้างหน้าแล้วย้อนกลับควรจะได้เวกเตอร์เดิมกลับมา ต่างกันแค่ rounding เล็ก ๆ จาก floating-point
Quat quatConjugate(const Quat& q) {
return Quat{ q.w, -q.x, -q.y, -q.z };
}
int main() {
const float PI = 3.14159265358979323846f;
Quat q = quatFromAxisAngle(Vec3{0,1,0}, 40.0f * PI / 180.0f); // 40 deg around Y
Vec3 v{2,3,5};
Vec3 forward = quatRotateVec(q, v);
Vec3 back = quatRotateVec(quatConjugate(q), forward);
printf("original v = (%.3f, %.3f, %.3f)\n", v.x, v.y, v.z);
printf("rotated v = (%.3f, %.3f, %.3f)\n", forward.x, forward.y, forward.z);
printf("rotated back = (%.3f, %.3f, %.3f)\n", back.x, back.y, back.z);
return 0;
}
เวกเตอร์กลับมาอยู่ที่จุดเดิมพอดี สิ่งนี้มีประโยชน์จริง ๆ ในโค้ดจริง เช่น การแปลงทิศทางจาก world space ไปเป็น local space ของวัตถุ ก็คือ "หมุนย้อนกลับด้วย quaternion orientation ของวัตถุ" โดยใช้ conjugate นี่แหละ
quatSlerp และ quatNlerp ระหว่างสองอันนี้ที่ t = 0.25 และ t = 0.5 แล้ว print มุมผลลัพธ์ออกมาแต่ละครั้ง (ใช้ trick เดิมจาก section 10: angle = 2 * acos(w) * 180 / PI) คุณสังเกตอะไรเกี่ยวกับผลลัพธ์ทั้งสองที่ t = 0.5 เมื่อเทียบกับ t = 0.25?int main() {
const float PI = 3.14159265358979323846f;
Quat a = quatFromAxisAngle(Vec3{0,1,0}, 0.0f);
Quat b = quatFromAxisAngle(Vec3{0,1,0}, 170.0f * PI / 180.0f); // 170 deg apart
float ts[] = {0.25f, 0.5f};
for (float t : ts) {
Quat s = quatSlerp(a, b, t);
Quat n = quatNlerp(a, b, t);
float sAngle = 2.0f * acosf(s.w) * 180.0f / PI;
float nAngle = 2.0f * acosf(n.w) * 180.0f / PI;
printf("t=%.2f slerp -> %.2f deg nlerp -> %.2f deg\n", t, sAngle, nAngle);
}
return 0;
}
ที่ t = 0.25 NLERP ตามหลัง SLERP อย่างเห็นได้ชัด (35.77 เทียบกับ 42.50 องศาที่ "ถูกต้อง" จังหวะสม่ำเสมอ) แต่ที่ t = 0.5 ทั้งสองลงที่มุมเดียวกันเป๊ะ คือ 85.00 องศา นี่เป็นคุณสมบัติจริง ไม่ใช่ความบังเอิญของตัวเลขชุดนี้: NLERP จะตรงกับ SLERP เป๊ะเสมอที่จุดกึ่งกลาง (t = 0.5) ระหว่าง orientation สองอันไหนก็ได้ เพราะจุดกึ่งกลางแบบเส้นตรงระหว่างจุดสองจุดบนทรงกลมจะอยู่บนส่วนโค้ง great-circle ที่เชื่อมทั้งคู่ด้วยเสมอ มีแค่จุดกึ่งกลางจุดเดียวเท่านั้นที่ทางลัดเส้นตรงกับเส้นทางโค้งบังเอิญตรงกัน ส่วนจุดอื่นทั้งหมดทางลัดเส้นตรงกับเส้นทางโค้งจะไม่ตรงกัน และความไม่ตรงกันนั้นจะยิ่งมากขึ้นเมื่อ orientation สองอันห่างกันมากขึ้น