2.1 พีชคณิตเชิงเส้น

เฟส 2 · คณิตศาสตร์สำหรับเกม · เวลาเรียน: 60–90 h

เวกเตอร์, dot และ cross product, เมทริกซ์ในฐานะ transform และ pipeline model / view / projection ที่เอาวัตถุขึ้นจอ คณิตที่ใช้บ่อยที่สุดในเกม

วัตถุทุกชิ้นบนหน้าจอมีตำแหน่ง กล้องทุกตัวมองไปในทิศทางหนึ่ง ตัวละครทุกตัวหมุน ขยาย/ย่อ และเคลื่อนที่ทุกเฟรม ทั้งหมดนี้สร้างจากเครื่องมือคณิตศาสตร์ชุดเล็ก ๆ คือ vector, dot product, cross product และ matrix บทนี้จะสร้างเครื่องมือพวกนี้ขึ้นมาเองใน C++ โดยใช้ struct Vec2/Vec3 เล็ก ๆ เพื่อให้คุณเห็นชัด ๆ ว่า engine อย่าง Unity หรือ Unreal ทำอะไรกับตำแหน่งและการหมุนของคุณในทุก ๆ เฟรม

แต่ละหัวข้อด้านล่างจะเป็นรูปแบบเดียวกันเสมอ: โปรแกรมสั้น ๆ ที่รันได้จริง, output จริงที่มันพิมพ์ออกมา, แล้วค่อยอธิบายแบบเข้าใจง่ายว่าเกิดอะไรขึ้น ลองพิมพ์ตามแล้วรันดูเพื่อเช็คว่า compiler ของคุณได้ผลลัพธ์ตรงกัน

1. Point กับ vector ต่างกันยังไง

ในบทก่อน ๆ ตัวเลขคู่หนึ่งอย่าง x, y มักใช้บอกตำแหน่ง — struct นั้นอยู่ที่ไหน หรือ tile นั้นอยู่ตรงไหนบน grid ในบทนี้ตัวเลขสองตัวเดิมบางทีก็หมายถึงตำแหน่ง แต่บางทีก็หมายถึงอีกอย่างหนึ่งไปเลย นั่นคือ ทิศทางบวกกับระยะทาง (direction + magnitude) ทั้งสองแบบเก็บด้วย float ชุดเดียวกันเป๊ะ แต่ความหมายต่างกัน และการสับสนสองอย่างนี้เป็นสาเหตุบั๊กที่พบบ่อย

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)
A POINT answers "where": A VECTOR answers "which way, how far": y y 4 4 3 3 2 * playerPos (3,4) 2 1 1 (0,0) o----------> (4,0) windDir 0 +----+----+----+----+-- x 0 +----+----+----+----+-- x 0 1 2 3 4 0 1 2 3 4 Both are stored as the same two floats (x, y). The MEANING is different: a fixed location vs. a direction + a length.

struct Vec2 เองไม่รู้หรอกว่าคุณหมายถึงแบบไหน — playerPos กับ windDir ก็แค่ float สองตัวเรียงกันอยู่ใน memory เหมือนกัน ความหมายมาจากวิธีที่คุณเอาไปใช้ต่างหาก จำความต่างนี้ไว้ตลอดบทนี้เลย — บาง operation (อย่างในหัวข้อถัดไป) จะสมเหตุสมผลแค่กับอย่างใดอย่างหนึ่งเท่านั้น

Tip เทคนิคที่มีประโยชน์: เอา point สองตัวมาลบกันจะได้ vector (ระยะห่างระหว่างสองจุด) เอา vector มาบวกกับ point จะได้ point ใหม่ (ตำแหน่งที่ขยับแล้ว) นี่คือ pattern เดียวกับที่หัวข้อถัดไปใช้เป๊ะ

2. บวก ลบ และคูณ scale ให้ vector

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(a, b): put the tail of b at the tip of a -- the sum runs from a's tail to b's tip pos (2,3) ----vel (1,-1)----> nextPos (3,2) nextPos = pos + vel = (2+1, 3-1) = (3, 2)

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

Common mistake เอา point สองตัวมาบวกกัน เช่น enemyPos + playerPos จะได้ตำแหน่งที่ไม่มีความหมายอะไรเลย — point บวกกันไม่ได้ ถ้าอยากได้จุดกึ่งกลางระหว่างสองจุด ให้ใช้ scale(add(a, b), 0.5f) แล้วคิดว่ามันคือ "ค่าเฉลี่ย" ไม่ใช่ "ผลบวก"

3. Length กับ normalize: เปลี่ยน vector ให้เหลือแค่ทิศทางล้วน ๆ

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
(0,4) (3,4) <- v = (3, 4) +--------------------------+ | / | / 4 | / length = sqrt(3*3 + 4*4) = sqrt(25) = 5 | / | / +--------------------+ (0,0) 3 (3,0) normalize(v) = v / length(v) = (3/5, 4/5) = (0.6, 0.8) <- same direction, length 1

นี่คือสามเหลี่ยมมุมฉาก 3-4-5 คลาสสิก normalize เอาแต่ละ component หารด้วย length ซึ่งจะย่อ (หรือขยาย) vector จนความยาวเท่ากับ 1 พอดี โดยทิศทางยังเหมือนเดิมไม่เปลี่ยน vector ที่มีความยาว 1 เรียกว่า unit vector และการเปลี่ยน vector ใด ๆ ให้เป็น unit vector เรียกว่า normalizing มัน

ทำไมต้องทำแบบนี้? เพราะหลายครั้งเราสนใจแค่ ทิศทาง ไม่ใช่ระยะทาง เช่น player หันหน้าไปทางไหน, พื้นผิวนี้ชี้ไปทางไหน, กระสุนนี้ควรวิ่งไปทางไหน normalize จะทิ้ง magnitude ไปแล้วเหลือแค่ทิศทาง ซึ่งตรงกับที่คำถามพวกนี้ต้องการพอดี

Common mistake normalize vector ที่มีความยาวเป็น 0 การหารด้วย length ที่เป็น 0 จะได้ inf หรือ NaN ("not a number") ซึ่งเป็นค่า floating-point ที่จะทำให้ทุกการคำนวณที่ใช้มันต่อไปเสียหายแบบเงียบ ๆ โค้ดจริงจะเช็ค if (length > 0.00001f) (threshold เล็ก ๆ ที่เรียกว่า "epsilon") ก่อนจะหาร แล้วค่อยตัดสินใจว่าจะใช้ทิศทางสำรองอะไรถ้า vector สั้นเกินกว่าจะ normalize ได้อย่างปลอดภัย

4. Dot product: วัดความเข้ากันของทิศทาง

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 สองตัวชี้ไปทิศทางเดียวกันมากแค่ไหน

sign of a.b angle between a and b example (a = right = (1,0)) ------------ ----------------------- ---------------------------- positive less than 90 degrees right . (1,1) = 1 zero exactly 90 degrees right . (0,1) = 0 negative more than 90 degrees right . (-1,0) = -1 the exact relationship: a . b = |a| * |b| * cos(theta) cos(theta) is + when theta < 90, 0 when theta = 90, - when theta > 90

มีอีกสูตรหนึ่งที่ให้ตัวเลขเดียวกันแต่อธิบายว่า ทำไม: a . b = |a| * |b| * cos(theta) โดย |a| กับ |b| คือความยาว ส่วน theta คือมุมระหว่าง vector สองตัว เพราะ cos เป็นบวกเมื่อต่ำกว่า 90 องศา เป็น 0 ที่ 90 องศา และเป็นลบเมื่อเกิน 90 องศา เครื่องหมายของ dot product จึงบอกได้ทันทีว่ามุมระหว่าง vector สองตัวเป็นมุมแหลม มุมฉาก หรือมุมป้าน โดยไม่ต้องคำนวณมุมจริง ๆ เลย ตัวเลขตัวเดียวนี้โผล่มาบ่อยมากในเกม: แสง (พื้นผิวหันเข้าหาแสงมากแค่ไหน), vision cone ของ AI, และการเลี้ยว (steering) ก็ใช้มันทั้งนั้น

ใช้ dot product: หามุมระหว่าง vector สองตัว

จัดสูตรที่สองใหม่จะได้มุมจริง ๆ ออกมา: 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 องศา

ใช้ dot product: อยู่ข้างหน้าฉันไหม

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 ง่าย ๆ แล้ว

ใช้ dot product: projection

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)
a = (5,3) wallDir = (1,1) (the direction a diagonal wall runs) scalar projection = how far along the wall a reaches = 5.65685 vector projection = the part of a that lies along the wall = (4, 4) the leftover, a - vectorProjection = (5,3) - (4,4) = (1,-1), is the part pushing INTO the wall -- that is exactly the part you throw away when you make a character slide along a surface.

scalar projection คือตัวเลขธรรมดา: a ยื่นไปตามทิศทางของ b ได้ไกลแค่ไหน vector projection เปลี่ยนตัวเลขนั้นกลับเป็น vector จริง ๆ ที่ชี้ไปตาม b สูตร (dot(a,b) / dot(b,b)) * b ใช้ได้กับ b ที่ยาวเท่าไหร่ก็ได้ เพราะการหารด้วย dot(b,b) (ซึ่งก็คือ length(b) ยกกำลังสอง) จะหักล้างความยาวของ b เองออกก่อนที่จะไปคูณ scale

ใช้ dot product: rejection และ reflection

projection เก็บส่วนของ vector ที่วางตัว ตาม ทิศทางหนึ่ง ญาติใกล้ชิดอีกสองตัวจะเก็บหรือพลิก อีกส่วน — ส่วนที่ตั้งฉากกับมัน — และทั้งคู่เป็นเครื่องมือที่ใช้กันประจำในเกม ทั้งสองสูตรเขียนได้สะอาดที่สุดโดยอิงกับ unit normal n ของพื้นผิว (unit 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)
vel = (5,3) hits a wall whose UNIT normal is n = (1,0): into-wall part = (vel . n) n = 5 * (1,0) = (5, 0) reject(vel, n) = (5,3) - (5,0) = ( 0, 3) slide: into-wall part removed once reflect(vel, n) = (5,3) - 2*(5,0) = (-5, 3) bounce: removed twice, sent back out

slide ก็คือไอเดีย "โยนส่วนที่พุ่งเข้ากำแพงทิ้ง" จาก diagram ของ projection ด้านบนนั่นเอง แค่ตอนนี้ห่อเป็นฟังก์ชันเดียว: เก็บไว้แค่การเคลื่อนที่ตามกำแพง เพื่อให้ตัวละครเฉียดผ่านไปได้แทนที่จะหยุดนิ่ง ส่วน bounce ไปอีกขั้นด้วยการเอาส่วนที่พุ่งเข้านั้นออกเป็นครั้งที่สอง ส่ง vector กลับออกมาด้วยมุมที่เท่ากันแต่กลับด้าน — เป็นพื้นฐานของกระสุนที่เด้ง, การ ricochet, หรือลูกบิลเลียด ทั้งสองสูตรถือว่า n มีความยาวเท่ากับ 1 ถ้าไม่ใช่ ให้เอาเทอม (a . n) หารด้วย dot(n, n) ก่อน เหมือนกับที่สูตร projection ทำเป๊ะ

Common mistake reflect หรือ reject กับ normal ที่ลืม normalize ถ้า n ยาวเป็นสองเท่าของที่ควรจะเป็น เทอม (a . n) n จะโตขึ้นสี่เท่า และ "bounce" ของคุณจะออกมาแรงเกินไปมากและชี้ผิดทาง normalize surface normal ให้เรียบร้อยครั้งเดียวตั้งแต่ต้น แล้วสูตรพวกนี้จะยังเรียบง่ายอยู่

5. Cross product: vector ตั้งฉากตัวใหม่

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
right x up = ? up (0,1,0) ^ | | o--------> right (1,0,0) / / forward (0,0,1) = right x up -- this axis points OUT of the screen, toward you curl your right hand's fingers from "right" toward "up" -- your thumb points along right x up. This is the "right-hand rule".

dot product สองตัวนั้นยืนยันเลยว่า ผลลัพธ์ตั้งฉากกับทั้ง right และ up ตามที่สัญญาไว้ สังเกตด้วยว่า cross(a, b) กับ cross(b, a) ชี้ไปทิศทางตรงข้ามกัน — cross product เป็นแบบ anti-commutative (สลับข้างแล้วได้ค่าตรงข้าม) ลำดับมีผลตรงนี้เหมือนกัน ซึ่งเป็นธีมที่จะกลับมาแรง ๆ อีกครั้งตอนเราไปถึงเรื่อง matrix

ใช้ cross product: surface normal

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)
C /| / | / | A---B edge1 = B - A (along the bottom) edge2 = C - A (up the side) normal = normalize(cross(edge1, edge2)) -- perpendicular to the triangle's face

สามเหลี่ยมนี้วางแบนอยู่บนระนาบ x-y ดังนั้น normal ของมันต้องชี้ไปตามแกน z ตรง ๆ — และก็เป็นแบบนั้นจริง ๆ: (0, 0, 1) นี่คือวิธีที่ normal สำหรับแสงของโมเดล 3D ถูกคำนวณจากข้อมูลสามเหลี่ยมดิบ ๆ ก่อนที่จะเอาไป render จริง ๆ เลย

ใช้ cross product: พื้นที่

ความยาวของ 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 เอียงยังไงก็ได้ด้วย

6. Matrix: เครื่องจักรที่แปลง vector

matrix คือตารางตัวเลข ตัวมันเองไม่ทำอะไรเลย — แต่พอเอาไปคูณกับ vector มันจะสร้าง vector ตัวใหม่ ออกมา นี่คือแก่นของมันเลย: matrix คือ "สูตร" ที่ใช้ซ้ำได้สำหรับเปลี่ยน vector ใด ๆ ให้กลายเป็น vector อีกตัว โดยทำตามกฎเดิมทุกครั้ง หมุน 30 องศา, ขยาย 2 เท่า, กลับหัว — แต่ละสูตรพวกนี้เขียนเป็น matrix ได้ครั้งเดียว แล้วเอาไปใช้กับกี่จุดก็ได้ตามใจ

Mat2 = | m00 m01 | v = | x | | m10 m11 | | y | Mat2 * v = | m00*x + m01*y | = new x | m10*x + m11*y | new y

แต่ละค่าในผลลัพธ์คือหนึ่งแถวของ 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 ที่ทำอะไรจริง ๆ กันบ้าง

7. Translate, rotate, และ scale ในรูป matrix

Scale

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)

Rotate

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 นี้ไว้ตั้งแต่ครั้งแรกที่เจอ แทนที่จะตกใจว่าคณิตศาสตร์การหมุนของคุณพัง

Translate ต้องใช้เทคนิคพิเศษ

คุณอาจคิดว่า 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)
SCALE (2, 0.5) on point (10,10): (10,10) --> (20,5) ROTATE 90 CCW on point (1,0): (1,0) --> (0,1) * TRANSLATE (5,3) on point (2,2): (2,2) --> (7,5) * printed as (-4.37114e-08, 1) in real floating point -- see the note above. A 2x2 matrix can SCALE and ROTATE (stretch/turn things around the origin). It can NEVER translate: any 2x2 matrix times the origin (0,0) gives (0,0) back. That is why moving things needs the "extra 1" trick, formalized in section 10.

origin ย้ายไปที่ (5, 3) จริง ๆ ตรงตามค่า translation เป๊ะ และ (2, 2) ก็ไปลงที่ (7, 5)2+5 กับ 2+3 แถวและคอลัมน์ที่สามนั้นทำให้การบวกธรรมดา ๆ แอบเนียนไปกับการคูณ matrix ได้

8. รวม transform เข้าด้วยกัน: matrix multiplication และทำไมลำดับถึงสำคัญ

วัตถุจริง ๆ ต้องการมากกว่าหนึ่ง 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 ก็ได้)

rotate(90) THEN translate(5,0), starting at (1,0): (1,0) --rotate--> (0,1) --translate--> (5,1) translate(5,0) THEN rotate(90), starting at (1,0): (1,0) --translate--> (6,0) --rotate--> (0,6) SAME two operations, SAME starting point, DIFFERENT final answer: (5,1) is not the same point as (0,6)

นี่คือนิสัยที่สำคัญที่สุดที่ต้องสร้างขึ้นเกี่ยวกับ matrix: matrix multiplication ไม่ commuteA * B ไม่เหมือนกับ B * A หมุนก่อนแล้วค่อยขยับไปทางขวา 5 หน่วย จะไปลงที่ (5, 1) ขยับไปทางขวา 5 หน่วยก่อนแล้วค่อยหมุนรอบจุดหมุน (ที่ยังอยู่ที่ origin) จะทำให้ offset ทั้งก้อนนั้นหมุนตามไปด้วย ไปลงที่ (0, 6) ทั้งคู่ใช้ rotation กับ translation ค่าเดียวกันเป๊ะ — แค่ ลำดับ เปลี่ยน ผลลัพธ์ก็ไปคนละทางเลย ในโค้ด mul(A, B) จะเอา B ไปใช้ก่อน (มันอยู่ใกล้จุดที่สุดถ้าอ่านการคูณจากขวาไปซ้าย) แล้วค่อย A ทีหลัง ถ้าสลับลำดับผิดใน engine วัตถุของคุณจะบินไปอยู่ตำแหน่งประหลาด ๆ ทันทีที่คุณเพิ่มการหมุนให้วัตถุที่กำลังเคลื่อนที่

Tip สูตรปกติสำหรับวางตำแหน่งวัตถุให้ถูกต้องคือ scale ก่อน แล้ว rotate แล้วค่อย translate — เขียนเป็น matrix คือ model = T * R * S เพื่อให้ S (ตัวที่ใกล้จุดที่สุด) ทำงานก่อน scale กับ rotate รอบ local origin ของวัตถุเองก่อน แล้วค่อยย้ายทั้งก้อนที่มีรูปทรงเรียบร้อยแล้วไปตำแหน่งในโลกเป็นขั้นตอนสุดท้าย

9. Pipeline ของ Model, View, และ Projection

ตอนนี้เราอธิบายได้แล้วว่าจุด 3D บน mesh ของตัวละครกลายเป็น pixel 2D บนจอของคุณได้ยังไง มันเดินทางผ่าน matrix สามตัว ตามลำดับ:

local vertex (the mesh's own coordinates, e.g. a corner at (1,1)) | MODEL matrix (this object's own scale / rotate / translate) v world vertex (everyone shares this one coordinate system) | VIEW matrix (re-measure the world relative to the camera) v view vertex (the camera now sits at the origin, looking down an axis) | PROJECTION matrix (sets up perspective, produces clip space x,y,z,w) v clip space | divide x, y, z by w <- the "perspective divide", see section 10 v NDC (Normalized Device Coordinates, roughly -1..1 on each axis) | viewport transform (map -1..1 onto actual pixel coordinates) v screen pixels

นี่คือเวอร์ชัน 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 — เหมือนกันเป๊ะ

10. Homogeneous coordinates: ค่าพิเศษตัวที่สี่ w

ย้อนกลับไปหัวข้อ 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)
point: (x, y, 1) w=1 -- translation DOES move it (it has a location) direction: (x, y, 0) w=0 -- translation does NOT move it (only rotate/scale apply) why this matters: wind, gravity, and surface normals are DIRECTIONS. moving a character 100 units east should never change which way the wind blows -- setting w=0 makes the matrix math enforce that automatically.

ตอน w = 1 คอลัมน์ translation (tx, ty) จะถูกบวกเข้าไปเต็ม ๆ ทำให้ point ขยับ ตอน w = 0 คอลัมน์เดียวกันนั้นจะถูกคูณด้วยศูนย์แล้วหายไป ทำให้ direction ไม่ถูกแตะต้องเลย นี่คือเหตุผลว่าทำไมความต่างระหว่าง point กับ vector จากหัวข้อ 1 ไม่ใช่แค่เรื่องตั้งชื่อเฉย ๆ — engine เข้ารหัสมันด้วยตัวเลขจริง ๆ และมันเปลี่ยนวิธีที่ matrix ตัวเดียวกันปฏิบัติกับตัวเลขคู่เดียวกัน

หน้าที่ที่สองของ w: perspective

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

11. Basis vectors: คอลัมน์ของ matrix จริง ๆ แล้วคืออะไร

หัวข้อ 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)
Mat2 = | 2 -1 | first column (2, 1) = where x-axis (1,0) lands | 1 3 | second column (-1,3) = where y-axis (0,1) lands mul(M, v) = v.x * (first column) + v.y * (second column) = 3 * (2, 1) + 2 * (-1, 3) = (6, 3) + (-2, 6) = (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 ของวัตถุนั้นเองแบบตรงตัวเลย เขียนอยู่ในพิกัดของโลก — ข้อเท็จจริงที่สองหัวข้อถัดไปจะพึ่งพาโดยตรง

12. Change of basis: world space กับ local space

ถ้าคอลัมน์ของ 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
world vector to target = (3, 4) ship's axes (in world coords): right = (0, 1) localX = toTarget . shipRight forward = (-1, 0) = (3,4).(0,1) = 4 (> 0 -> RIGHT) localY = toTarget . shipForward = (3,4).(-1,0) = -3 (< 0 -> BEHIND) same physical point, two coordinate systems: (3, 4) in world == (4, -3) in the ship's local space

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 ของกล้องเอง

Tip ทางลัด transpose-เท่ากับ-inverse ใช้ได้เฉพาะตอนที่ basis เป็น orthonormal เท่านั้น (rotation ล้วน ๆ ไม่มี scale) พอ matrix มี scale หรือ shear เข้ามาเมื่อไหร่ คุณต้องใช้ inverse ของ matrix จริง ๆ เพื่อย้อนกลับ — ซึ่งเป็นกับดักที่หัวข้อถัดไปจะพูดถึง

13. การแปลง normal: ทำไม non-uniform scale ถึงทำให้มันพัง

นี่คือบั๊กที่เคยหลุดไปอยู่ในเกมจริงมาแล้ว: โมเดลถูกยืดให้สูงกว่าความกว้าง แล้วจู่ ๆ แสงของมันก็ดูผิดเพี้ยน — พื้นผิวเหมือนถูกส่องจากมุมที่ผิด สาเหตุคือ 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
stretch X by 2: tangent (1,1) --> (2,1) the surface tilts flatter WRONG: normal (1,-1) pushed through the SAME stretch --> (2,-1) (2,-1) . (2,1) = 4 - 1 = 3 (not 0 --> no longer perpendicular!) RIGHT: normal through the INVERSE-TRANSPOSE --> (0.5,-1) (0.5,-1) . (2,1) = 1 - 1 = 0 (still perpendicular)

ทางแก้คือกฎที่ควรจำไว้: ในการแปลง normal ให้ใช้ inverse-transpose ของ matrix ที่คุณใช้กับ geometry — เขียนเป็น (M^-1)^T dot product พิสูจน์ให้เห็น: normal ที่ "ผิด" ได้ 3 (ไม่ตั้งฉากอีกต่อไป แสงเลยออกมาผิด) ในขณะที่ normal แบบ inverse-transpose ได้ 0 (ยังตั้งฉากอยู่) หลังจากนั้นค่อย re-normalize เพราะการแปลงเปลี่ยนความยาวของ normal ไปด้วย

สองเหตุผลที่คุณอาจไม่เคยสังเกตเรื่องนี้มาก่อน:

14. สร้าง rotation จากทิศทาง: orthonormalization

หัวข้อ 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

15. Row-major กับ column-major: อ่าน matrix ใน Unity, Unreal, OpenGL, และ DirectX

ก๊อป transform matrix จาก tutorial ของ OpenGL ไปวางในโค้ด DirectX ตรง ๆ แล้ววัตถุของคุณจะกระจัดกระจายไปอยู่ตำแหน่งมั่ว ๆ เหตุผลคือ convention คู่หนึ่งที่ engine แต่ละเจ้าเห็นไม่ตรงกัน และมือใหม่ชอบสับสนรวมกันเป็นเรื่องเดียว จริง ๆ มันคือสองทางเลือกที่ แยกจากกัน:

ผลที่ตามมาแบบเจ็บ ๆ: ตัวเลขชุดเดียวกัน ถ้าอ่านด้วย 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:

same object transform, different ecosystems: OpenGL / Unity column vectors, M * v model = T * R * S (S applied first) DirectX / Unreal row vectors, v * M model = S * R * T (S applied first) the two "model" matrices are transposes of each other and describe the IDENTICAL transform -- only the vector side and the multiply order flip. storage order (row- vs column-major) is a SEPARATE memory question from which side the vector goes on. Unity, for example, uses M * v AND stores its Matrix4x4 column-major -- do not assume one choice implies the other.

สิ่งที่ควรจำไว้ตอนอ่านโค้ด 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 เกิดขึ้นในลำดับจริงเหมือนกัน แค่สัญกรณ์กลับด้านเท่านั้น

16. Floating point: หลุมพรางเรื่องความละเอียดที่กัดคุณจริง ๆ

หัวข้อ 7 เจอ artifact จากการปัดเศษไปแล้วหนึ่งอย่าง — การหมุนที่ได้ -4.37114e-08 แทนที่จะเป็นศูนย์สะอาด ๆ อันนั้นไม่มีพิษภัย แต่บางอันมี และมันทำให้เกิด crash และ glitch จริง ๆ สองอันที่ควรรู้ไว้ก่อนที่มันจะกินเวลาคุณไปครึ่งวัน

acos ของ dot product เป็น NaN ได้

สูตรหามุมระหว่าง 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 ภายในด้วยเหตุผลนี้เป๊ะ

อย่าเทียบ float ด้วย == เด็ดขาด

การปัดเศษยังหมายความว่า ค่าสองค่าที่ ควรจะ เท่ากัน มักจะไม่เท่ากันในระดับบิต การบวก 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)

17. ข้อสังเกตเรื่องการหมุน: quaternion กำลังจะมา

บทนี้เราหมุนสิ่งต่าง ๆ ด้วย 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: สถานการณ์ที่แกนหมุนสองในสามแกนมาเรียงตัวตรงกัน ทำให้วัตถุเสียความสามารถในการหมุนอย่างอิสระรอบทิศทางหนึ่งไปถาวร

storing a rotation: 3x3 rotation matrix 9 numbers, can drift away from a valid rotation over time 3 Euler angles 3 numbers, easy to read, but can hit GIMBAL LOCK quaternion 4 numbers, no gimbal lock, blends smoothly between rotations

quaternion เก็บ rotation แบบ 3D ด้วยตัวเลขแค่ 4 ตัว ไม่มี gimbal lock และผสม (blend) ระหว่าง rotation สองค่าได้ลื่นไหล (operation ที่เรียกว่า slerp) ในแบบที่ Euler angle ทำไม่ได้ ทั้ง Unity และ Unreal เก็บ rotation ภายในเป็น quaternion แล้วค่อยแปลงเป็น rotation matrix ตอนจำเป็นจริง ๆ เท่านั้น คือตอนที่ vertex ต้องถูก transform จริง ๆ นั่นคือหัวข้อทั้งหมดของบทถัดไป 2.3 — ตอนนี้แค่รู้ไว้ว่าเวลาคุณเห็น "rotation" ถูกเก็บไว้ใน engine มันมักจะเป็นตัวเลขสี่ตัว ไม่ใช่สามหรือเก้าตัว

18. Glossary

19. แบบฝึกหัด

Exercise 1 guard คนหนึ่งยืนอยู่ที่ (2, 2) หันหน้าไปทาง (1, 0) (หันไปทางตะวันออก) มีเสียงแปลก ๆ ดังมาจาก P = (5, 2) และ Q = (0, 5) สำหรับแต่ละเสียง ให้คำนวณ vector จาก guard ไปยังเสียงนั้น แล้วหา dot product ของทิศทางที่ guard หันหน้ากับ vector นั้น แล้วบอกว่า guard หันหน้าเข้าหาเสียงนั้นไหม (อยู่ข้างหน้า) หรือหันหลังให้ (อยู่ข้างหลัง) — โดยไม่ต้องรันโค้ดใด ๆ
Show answer

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 ก็ตาม คำว่า "ข้างหลัง" ที่นี่หมายถึง "อยู่ฝั่งตรงข้ามกับทิศทางที่หันหน้า" เท่านั้น ไม่ใช่ "อยู่ต่ำกว่าในทางกายภาพ"

Exercise 2 คุณอยากวางโมเดล enemy ลงในโลก: scale มันด้วย (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 วัตถุแล้วค่อยวางลงในโลก และอีกลำดับหนึ่งจะพังยังไง
Show answer

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 เสมอ

Exercise 3 สามเหลี่ยมหนึ่งมีมุม 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, และพื้นที่ของสามเหลี่ยม
Show answer

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

Exercise 4 ลูกบอลเคลื่อนที่ด้วย velocity d = (3, -4) แล้วชนพื้นราบที่มี unit normal n = (0, 1) ให้คำนวณ d . n แล้วหา velocity ที่สะท้อน reflect(d, n) = d - 2 (d . n) n component ไหนที่พลิก และทำไมมันถึงตรงกับลูกบอลที่กระเด้งขึ้นจากพื้น
Show answer

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 ไม่ถูกแตะต้อง นั่นคือการเด้งพอดี: พื้นกลับทิศการเคลื่อนที่ที่พุ่งเข้าพื้น (แนวตั้ง) แล้วปล่อยการเคลื่อนที่ตามพื้น (แนวนอน) ไว้เหมือนเดิม

Exercise 5 แกน local ของรถถัง เขียนในพิกัดของโลก คือ right = (0, -1) และ forward = (1, 0) (มันหมุนไปจน "forward" ชี้ไปทางตะวันออกของโลก) กระสุนตกที่ offset ของโลก toTarget = (2, 3) จากรถถัง ใช้เทคนิค change-of-basis จากหัวข้อ 12 คำนวณ localX = toTarget . right และ localY = toTarget . forward แล้วบอกว่าจุดที่กระสุนตกอยู่ทางซ้ายหรือขวา และอยู่ข้างหน้าหรือข้างหลังของรถถัง
Show answer

localX = (2,3) . (0,-1) = 2*0 + 3*(-1) = -3 เป็นลบ ดังนั้นจุดที่ตกอยู่ทาง ซ้าย ของรถถัง

localY = (2,3) . (1,0) = 2*1 + 3*0 = 2 เป็นบวก ดังนั้นจุดที่ตกอยู่ ข้างหน้า รถถัง

ในระบบแกนของรถถังเอง กระสุนตกที่ (-3, 2): ข้างหน้าสองหน่วยและซ้ายสามหน่วย — แม้ว่าในพิกัดของโลกมันจะอยู่บนขวาก็ตาม จุดเดียวกัน คนละแกน

Exercise 6 พื้นผิวมุม 45 องศามี tangent 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 ใหม่ อันไหนที่ยังตั้งฉากอยู่
Show answer

(ก) 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 จะเริ่มต้นต่อจากตรงนี้เป๊ะ

← กลับไปหน้ารวมบท