ทุกอย่างในเกมมี position และส่วนใหญ่ยังมี direction ด้วย: ตัวละครหันหน้าไปทางไหน กระสุนพุ่งไปทางไหน กล้องมองไปทางไหน การแปลง "หันไปทางไหน" ให้เป็นตัวเลขคือหน้าที่ของ trigonometry (ตรีโกณมิติ — คณิตศาสตร์ว่าด้วยมุมกับวงกลม) บทนี้ยังครอบคลุมรูปทรงพื้นฐานที่เกมใช้เช็ค collision (การชนกัน) ด้วย: circle, box, ray และชุด test ที่ตอบคำถามว่า "สองสิ่งนี้แตะกันหรือเปล่า?" คุณรู้ C++ พื้นฐานและวิธีคิดกับข้อมูลอยู่แล้ว — บทนี้โค้ดจะสั้นลง คณิตศาสตร์จะเยอะขึ้น แต่รูปแบบยังเหมือนเดิม: โค้ด, output จริง, อธิบายแบบเข้าใจง่าย
ลองพิมพ์ตัวอย่างแล้วรันดูเอง บั๊กเรื่อง trig มักมองไม่เห็นจนกว่าจะเห็นตัวเลขผิดขึ้นบนจอ ดังนั้นการคุ้นเคยกับ output จริงตั้งแต่ตอนนี้จะช่วยประหยัดเวลา debug ในอนาคต
มุมวัดได้สองแบบ: degree (องศา — วงกลมเต็มวงคือ 360) หรือ radian (เรเดียน — วงกลมเต็มวงคือ 2*pi ประมาณ 6.28318) Degree คือหน่วยที่คนทั่วไปใช้ ("หมุน 90 องศา") ส่วน radian คือหน่วยที่ math library เกือบทุกตัวใช้ภายใน รวมถึงฟังก์ชันใน <cmath> ของ C++ อย่าง sin, cos, atan2 ถ้าคุณส่ง degree เข้าไปในที่ที่มันต้องการ radian คุณจะได้คำตอบผิดโดยไม่มี error หรือ warning ใด ๆ — ฟังก์ชันยังคืนตัวเลขมาให้ปกติ แค่เป็นตัวเลขที่ผิด
การแปลงหน่วยเป็นอัตราส่วนตรง ๆ: 360 องศาเท่ากับ 2*pi เรเดียน ดังนั้น 180 องศาเท่ากับ pi เรเดียน ได้สองสูตร:
radians = degrees * (PI / 180.0)
degrees = radians * (180.0 / PI)
#include <iostream>
const double PI = 3.14159265358979323846; // C++ has no standard PI constant,
// so define your own once, here
double toRadians(double degrees) { return degrees * PI / 180.0; }
double toDegrees(double radians) { return radians * 180.0 / PI; }
int main() {
double degs[] = {0, 45, 90, 180, 270, 360};
for (double d : degs)
std::cout << d << " degrees = " << toRadians(d) << " radians\n";
}
Output:
0 degrees = 0 radians
45 degrees = 0.785398 radians
90 degrees = 1.5708 radians
180 degrees = 3.14159 radians
270 degrees = 4.71239 radians
360 degrees = 6.28319 radians
ดูรูปแบบ: 90 องศากลายเป็นประมาณ 1.57 ซึ่งคือ pi/2 (3.14159 / 2) 180 องศากลายเป็น 3.14159 ซึ่งคือ pi พอดี นั่นคือความสัมพันธ์ทั้งหมด — degree กับ radian เป็นแค่ไม้บรรทัดสองแบบสำหรับวัดการหมุนแบบเดียวกัน
PI มาตรฐานให้ใช้ตรง ๆ (บาง platform มี M_PI จาก <cmath> แต่ไม่การันตีว่าจะมีทุกที่) โปรเจกต์เกมส่วนใหญ่จะประกาศ constant ของตัวเองครั้งเดียวแบบบรรทัดข้างบน แล้วใช้ซ้ำทุกที่แทนการพิมพ์เลข pi ใหม่ทุกครั้งคุณอาจเคยเรียน sine, cosine, tangent จากสามเหลี่ยมมุมฉากมาก่อน: สำหรับมุม theta (ธีตา) ในสามเหลี่ยม sin(theta) = ด้านตรงข้าม / ด้านตรงข้ามมุมฉาก, cos(theta) = ด้านประชิด / ด้านตรงข้ามมุมฉาก, และ tan(theta) = ด้านตรงข้าม / ด้านประชิด (จำง่าย ๆ ว่า SOH-CAH-TOA) นิยามนั้นถูกต้อง แต่ใช้ในเกมได้ไม่คล่องนัก เพราะโค้ดเกมแทบไม่มีสามเหลี่ยมวางอยู่ให้ใช้ — มันมีแค่มุมและอยากได้ direction
Unit circle (วงกลมหนึ่งหน่วย) คือวงกลมรัศมี 1 ที่ศูนย์กลางอยู่ที่ origin (0,0) ลองนึกภาพยืนอยู่ตรงกลางแล้วหมุนตัวไปเป็นมุม theta วัดทวนเข็มนาฬิกาจากแกน x บวก แล้วเดินออกไปถึงขอบวงกลม จุดที่คุณไปยืนอยู่จะเป็น (cos(theta), sin(theta)) เสมอ นั่นคือทริกทั้งหมด: cos ให้พิกัด x, sin ให้พิกัด y สำหรับจุดที่ห่างจากศูนย์กลางพอดี 1 หน่วย ที่มุม theta
ทำไมมันตรงกับนิยามสามเหลี่ยม? ลากเส้นตรงจากจุด P ลงมาตั้งฉากกับแกน x จะได้สามเหลี่ยมมุมฉากที่ด้านตรงข้ามมุมฉากคือรัศมี (ยาว 1) ด้านประชิดยาว cos(theta) และด้านตรงข้ามยาว sin(theta) เพราะด้านตรงข้ามมุมฉากยาวพอดี 1 เศษส่วน "ด้านตรงข้าม/ด้านตรงข้ามมุมฉาก" กับ "ด้านประชิด/ด้านตรงข้ามมุมฉาก" จึงย่อเหลือแค่ความยาวด้านตรงข้ามกับด้านประชิดตรง ๆ คณิตเดิม แต่ภาพเข้าใจง่ายกว่า
#include <iostream>
#include <cmath>
#include <iomanip>
const double PI = 3.14159265358979323846;
int main() {
std::cout << std::fixed << std::setprecision(3);
double anglesDeg[] = {0, 30, 45, 60};
for (double d : anglesDeg) {
double rad = d * PI / 180.0;
std::cout << "deg=" << d
<< " cos=" << std::cos(rad)
<< " sin=" << std::sin(rad)
<< " tan=" << std::tan(rad) << "\n";
}
}
Output:
deg=0 cos=1.000 sin=0.000 tan=0.000
deg=30 cos=0.866 sin=0.500 tan=0.577
deg=45 cos=0.707 sin=0.707 tan=1.000
deg=60 cos=0.500 sin=0.866 tan=1.732
ดูแถว 45 องศา: cos=0.707 และ sin=0.707 — เท่ากัน เพราะที่ 45 องศาคุณเดินมาได้ครึ่งทางพอดีระหว่าง "x ล้วน ๆ" กับ "y ล้วน ๆ" และ tan ก็คือ sin/cos ในทุกแถว (เช็คที่ 60 องศา: 0.866 / 0.500 = 1.732) — tangent เป็นแค่อัตราส่วนของอีกสองตัว ไม่ใช่ไอเดียแยกต่างหาก
pi ได้เป๊ะ ดังนั้น cos ของมุม 90 องศาจึงไม่ได้เป็นศูนย์แบบเป๊ะ ๆ:#include <iostream>
#include <cmath>
int main() {
double rad = 1.5707963267948966; // 90 degrees in radians
double c = std::cos(rad);
double t = std::tan(rad);
std::cout << "cos(90) = " << c << "\n";
std::cout << "tan(90) = " << t << "\n";
std::cout << (c == 0.0 ? "exactly zero" : "NOT exactly zero") << "\n";
}
Output:
cos(90) = 6.12323e-17
tan(90) = 1.63312e+16
NOT exactly zero
cos(90 องศา) ในทางคณิตศาสตร์ควรเป็น 0 แต่มันพิมพ์ 6.12323e-17 ออกมา — ตัวเลขเล็กจนไม่มีความหมาย แต่ก็ไม่ใช่ bit pattern เดียวกับ 0.0 เพราะ tan = sin/cos การหารด้วยตัวเลขที่เกือบเป็นศูนย์นี้เลยระเบิดกลายเป็นตัวเลขมหาศาลไร้ความหมาย แทนที่จะเป็น "undefined" อย่างที่คุณคาดหวัง บทเรียน: อย่าเทียบผลลัพธ์ trig ด้วย == ให้เทียบกับ tolerance เล็ก ๆ แทน เช่น std::fabs(c) < 0.0001
เมื่อคุณแปลงมุมให้เป็นจุดบน unit circle ได้แล้ว การทำให้อะไรบางอย่างเคลื่อนที่เป็นวงกลมก็เหลือแค่บรรทัดเดียว: scale จุดบน unit circle ด้วย radius ของวงกลม แล้วเลื่อนด้วย center ของวงกลม
#include <iostream>
#include <cmath>
#include <iomanip>
const double PI = 3.14159265358979323846;
int main() {
std::cout << std::fixed << std::setprecision(3);
double radius = 5.0;
int steps = 5;
for (int i = 0; i < steps; i++) {
double angle = (2.0 * PI / steps) * i; // spread evenly around the circle
double x = radius * std::cos(angle);
double y = radius * std::sin(angle);
std::cout << "step " << i << ": angle=" << angle
<< " pos=(" << x << ", " << y << ")\n";
}
}
Output:
step 0: angle=0.000 pos=(5.000, 0.000)
step 1: angle=1.257 pos=(1.545, 4.755)
step 2: angle=2.513 pos=(-4.045, 2.939)
step 3: angle=3.770 pos=(-4.045, -2.939)
step 4: angle=5.027 pos=(1.545, -4.755)
ห้า step กระจายเท่า ๆ กันรอบวงกลมรัศมี 5 ที่ศูนย์กลางอยู่ที่ origin สังเกตว่าจุดต่าง ๆ เรียงเป็นรูปดาวห้าแฉกรอบ (0,0) แต่ละจุดห่างจากศูนย์กลางพอดี 5 หน่วย (เช็คได้: 1.545^2 + 4.755^2 ออกมาประมาณ 25 ซึ่งคือ radius^2) ในเกมจริง "angle" จะค่อย ๆ โตขึ้นทุกเฟรม (angle += turnSpeed * deltaTime) แทนที่จะกระโดดทีละ 5 step แบบตายตัว — นี่คือวิธี animate ดาวเทียมที่โคจร, เส้นเรดาร์ที่กวาด, หรือเข็มนาฬิกา
angle = (2*PI / count) * i สำหรับแต่ละไอเทม i คุณก็จะได้วงแหวนที่กระจายเท่า ๆ กันแบบฟรี ๆหัวข้อ 3 หมุน จุดรอบ origin โดยใช้มุมที่เปลี่ยนไปเรื่อย ๆ ทีนี้มาดูเครื่องมือที่ทั่วไปกว่านั้น: กำหนด 2D vector ใดก็ได้ (x, y) กับมุมหนึ่ง แล้วสร้าง vector เดิมที่หมุนไปตามมุมนั้น เรียกว่า rotation (การหมุน) และสูตรคือ:
คุณไม่จำเป็นต้อง derive สูตรนี้เพื่อใช้มัน — ใช้มันเป็นสูตรสำเร็จ เหมือนที่คุณใช้ sqrt เป็นสูตรสำเร็จ มันเอา vector มาหมุนรอบ (0,0) ไปเป็นมุม angle ทวนเข็มนาฬิกาถ้ามุมเป็นบวก
#include <iostream>
#include <cmath>
#include <iomanip>
const double PI = 3.14159265358979323846;
struct Vec2 { double x, y; };
Vec2 rotate(Vec2 v, double angleRad) {
double c = std::cos(angleRad);
double s = std::sin(angleRad);
return { v.x * c - v.y * s, // new x
v.x * s + v.y * c }; // new y
}
int main() {
std::cout << std::fixed << std::setprecision(3);
Vec2 v = {2.0, 0.0};
double anglesDeg[] = {0, 90, 180, 270};
for (double deg : anglesDeg) {
Vec2 r = rotate(v, deg * PI / 180.0);
std::cout << deg << " deg: (" << r.x << ", " << r.y << ")\n";
}
}
Output:
0 deg: (2.000, 0.000)
90 deg: (0.000, 2.000)
180 deg: (-2.000, 0.000)
270 deg: (0.000, -2.000)
เริ่มจาก vector ที่ชี้ไปทางขวา (2, 0) หมุน 90 องศา มันจะชี้ขึ้นตรง ๆ (0, 2) หมุนอีก 90 มันชี้ไปทางซ้าย (-2, 0) หมุนอีก 90 มันชี้ลง (0, -2) มันเดินรอบวงกลมเต็มวงด้วยการหมุนทีละ 90 องศาสี่ครั้ง โดยความยาวคงเดิมตลอด (2 หน่วย)
หัวข้อ 3 กับ 4 เริ่มจากมุมแล้วไปหา position หรือ vector ที่หมุนแล้ว การเล็ง (aiming) เป็นปัญหากลับกัน: คุณมีสอง position อยู่แล้ว — ตัวยิงกับเป้าหมาย — และอยากได้ มุม ระหว่างสองจุดนั้น เครื่องมือสำหรับงานนี้คือ atan2(dy, dx) ซึ่งรับผลต่างของ y กับผลต่างของ x แล้วคืนมุมของ vector (dx, dy) วัดแบบเดียวกับ unit circle ในหัวข้อ 2
#include <iostream>
#include <cmath>
#include <iomanip>
const double PI = 3.14159265358979323846;
double toDegrees(double rad) { return rad * 180.0 / PI; }
int main() {
std::cout << std::fixed << std::setprecision(2);
struct { double dx, dy; } targets[] = {
{5, 0}, {0, 5}, {-5, 0}, {0, -5}, {3, 3}
};
for (auto t : targets) {
double angle = std::atan2(t.dy, t.dx);
std::cout << "dx=" << t.dx << " dy=" << t.dy
<< " -> angle=" << toDegrees(angle) << " deg\n";
}
}
Output:
dx=5.00 dy=0.00 -> angle=0.00 deg
dx=0.00 dy=5.00 -> angle=90.00 deg
dx=-5.00 dy=0.00 -> angle=180.00 deg
dx=0.00 dy=-5.00 -> angle=-90.00 deg
dx=3.00 dy=3.00 -> angle=45.00 deg
ทุกผลลัพธ์ตรงกับภาพ unit circle: เป้าหมายที่อยู่ขวาตรง ๆ คือ 0 องศา, บนตรง ๆ คือ 90, ซ้ายตรง ๆ คือ 180, ล่างตรง ๆ คือ -90 (ติดลบเพราะหมุนไปอีกทาง) และแนวทแยงบนขวาคือ 45
atan(dy / dx) แทนที่จะเป็น atan2(dy, dx) มีปัญหาสองอย่าง: การหารด้วย dx = 0 (เป้าหมายอยู่ตรงบนหรือล่างพอดี) จะพัง หรือได้ infinity และ atan ธรรมดาไม่สามารถแยกเป้าหมายที่อยู่ข้างหน้ากับข้างหลังได้ — atan(1/1) กับ atan(-1/-1) ได้มุมเท่ากัน ทั้งที่สองเป้าหมายอยู่คนละทิศตรงข้ามกันเป๊ะ atan2 ดูเครื่องหมายของ ทั้ง dx และ dy แยกกัน จึงคืนมุมที่ถูกต้องครบทุกทิศเสมอพอได้มุมมาแล้ว ทริกจากหัวข้อ 2 จะแปลงมันกลับเป็น direction: (cos(angle), sin(angle)) คือ unit vector (ความยาวเท่ากับ 1 พอดี) ที่ชี้ตรงไปที่เป้าหมาย เอาไป scale ด้วยความเร็ว คุณก็จะได้ velocity ที่พุ่งเข้าหาเป้าหมาย — นี่คือวิธีที่กระสุนโฮมมิ่งง่าย ๆ หรือการหมุน "มองไปที่ผู้เล่น" ทำงาน
#include <iostream>
#include <cmath>
#include <iomanip>
int main() {
std::cout << std::fixed << std::setprecision(2);
double px = 0, py = 0; // bullet start position
double tx = 8, ty = 6; // target position (does not move)
double speed = 5.0; // units per step
double dx = tx - px;
double dy = ty - py;
double angle = std::atan2(dy, dx); // angle from bullet to target
double vx = std::cos(angle) * speed; // velocity x
double vy = std::sin(angle) * speed; // velocity y
std::cout << "aim angle = " << angle << " rad\n";
for (int step = 1; step <= 3; step++) {
px += vx;
py += vy;
std::cout << "step " << step << ": pos=(" << px << ", " << py << ")\n";
}
}
Output:
aim angle = 0.64 rad
step 1: pos=(4.00, 3.00)
step 2: pos=(8.00, 6.00)
step 3: pos=(12.00, 9.00)
เป้าหมายอยู่ที่ (8, 6) ซึ่งบังเอิญห่างออกไปพอดี 10 หน่วย (สามเหลี่ยมมุมฉาก 6-8-10 เหมือนสามเหลี่ยม 3-4-5 ที่คุ้นเคยแต่คูณสอง) ที่ความเร็ว 5 กระสุนจะเดินทางระยะนั้นได้พอดีใน 2 step ลงจอดตรง (8, 6) เป๊ะ — แล้วก็เดินทางเลยเป้าหมายไปต่อใน step 3 เพราะไม่มีอะไรบอกให้มันหยุด กระสุนโฮมมิ่งจริง ๆ จะคำนวณ angle ใหม่ทุกเฟรมไปยัง position ปัจจุบัน ของเป้าหมาย และเช็คระยะที่เหลือเพื่อให้มันหยุดหรือระเบิดตอนถึงเป้า
ก่อนจะไปถึง collision test คุณต้องรู้จักรูปทรงที่ test เหล่านั้นทำงานด้วยก่อน มีรูปทรง "อนันต์" สามแบบที่เจอบ่อยมาก:
Ray เขียนเป็นสูตรได้: เลือกตัวเลข t >= 0 อะไรก็ได้ จุดที่ "ระยะ" t ตามแนว ray คือ origin + t * direction t ยิ่งมากยิ่งไกลตามแนว ray t ตัวนี้จะโผล่มาอีกในทุก intersection test ต่อจากนี้ในบทนี้ — มันคือคำตอบของ "เราชนอะไรตรงระยะไหนตามแนว ray"
#include <iostream>
struct Vec2 { double x, y; };
Vec2 pointOnRay(Vec2 origin, Vec2 dir, double t) {
return { origin.x + t * dir.x, origin.y + t * dir.y };
}
int main() {
Vec2 origin = {0, 0};
Vec2 dir = {1, 0.5}; // the direction the ray travels
for (double t = 0; t <= 3; t += 1.0) {
Vec2 p = pointOnRay(origin, dir, t);
std::cout << "t=" << t << " -> point=(" << p.x << ", " << p.y << ")\n";
}
}
Output:
t=0 -> point=(0, 0)
t=1 -> point=(1, 0.5)
t=2 -> point=(2, 1)
t=3 -> point=(3, 1.5)
dir มีความยาว sqrt(1^2 + 0.5^2) ~= 1.118 ไม่ใช่ 1 พอดี นั่นแปลว่า t ไม่เท่ากับระยะทางจริงที่เดินไป — ที่ t=1 จุดเคลื่อนไป 1.118 หน่วย ไม่ใช่ 1 สูตร intersection เกือบทุกอันในบทนี้สมมติว่า dir เป็น unit vector (ความยาวเท่ากับ 1 พอดี) เพื่อให้ t เท่ากับระยะทางตรง ๆ ให้ normalize direction ของ ray (หารมันด้วยความยาวของตัวมันเอง) ก่อนส่งเข้า collision test เสมอทีนี้มาถึง dot product — เครื่องมือเล็ก ๆ ที่คุณจะใช้ซ้ำ ๆ ต่อจากนี้ สำหรับ vector สองตัว a กับ b, dot(a, b) = a.x*b.x + a.y*b.y มันเป็นตัวเลขตัวเดียว ไม่ใช่ vector และเครื่องหมายของมันบอกว่า vector ทั้งสองชี้ไปทางไหนเทียบกัน
#include <iostream>
struct Vec2 { double x, y; };
double 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 alsoRight = {5, 0};
Vec2 leftish = {-1, 0.2};
std::cout << "right . up = " << dot(right, up) << "\n";
std::cout << "right . alsoRight = " << dot(right, alsoRight) << "\n";
std::cout << "right . leftish = " << dot(right, leftish) << "\n";
}
Output:
right . up = 0
right . alsoRight = 5
right . leftish = -1
right กับ up ตั้งฉากกัน (ห่างกัน 90 องศา) และ dot product ของมันได้ 0 เป๊ะ — นี่คือกฎทั่วไป: dot product เป็น 0 เมื่อ vector สองตัวตั้งฉากกัน right กับ alsoRight ชี้ไปทางเดียวกัน dot product เลยออกมาเป็นบวก right กับ leftish ชี้ไปคนละทางเกือบตรงข้าม เลยออกมาเป็นลบ บวกแปลว่า "ชี้ไปทางเดียวกันโดยรวม", ลบแปลว่า "ชี้ไปทางตรงข้ามโดยรวม", ศูนย์แปลว่า "ตั้งฉากกัน"
ทริกเรื่องเครื่องหมายนี้แหละคือวิธีที่ signed distance (ระยะทางแบบมีเครื่องหมาย) ของ plane ทำงาน: สำหรับ plane ที่มี normal n ผ่านจุด Q signed distance ของจุด P จาก plane คือ dot(n, P - Q) ศูนย์แปลว่า P อยู่บน plane, บวกแปลว่า P อยู่ฝั่งที่ normal ชี้ไป, ลบแปลว่าอยู่อีกฝั่ง
#include <iostream>
struct Vec2 { double x, y; };
double dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
int main() {
Vec2 pointOnPlane = {0, 0}; // the ground, at y=0
Vec2 normal = {0, 1}; // "up" is the positive side
Vec2 above = {3, 5};
Vec2 below = {3, -2};
Vec2 toAbove = { above.x - pointOnPlane.x, above.y - pointOnPlane.y };
Vec2 toBelow = { below.x - pointOnPlane.x, below.y - pointOnPlane.y };
std::cout << "above: signed dist = " << dot(normal, toAbove) << "\n";
std::cout << "below: signed dist = " << dot(normal, toBelow) << "\n";
}
Output:
above: signed dist = 5
below: signed dist = -2
จุด (3, 5) อยู่เหนือ ground plane ขึ้นไป 5 หน่วย และสูตรรายงาน +5 จุด (3, -2) อยู่ต่ำกว่า 2 หน่วย และมันรายงาน -2 ใน 3D สูตรเดียวกันเป๊ะ ๆ (ด้วย normal และจุดแบบ 3D) คือวิธีที่ engine เช็คว่าอะไรบางอย่างอยู่ฝั่งไหนของพื้น, กำแพง, หรือหน้าของ camera frustum
การเช็คว่า mesh ตัวละครที่มีรายละเอียดสองตัวทับกันหรือเปล่า ทีละ triangle นั้นแพงมาก เกมแทบไม่เคยทำแบบนั้นตรง ๆ แต่จะห่อแต่ละ object ด้วย bounding shape (รูปทรงล้อมรอบ) ง่าย ๆ แล้วเช็คตัวนั้นแทน — ถูก, ประมาณ, และดีพอสำหรับ gameplay ส่วนใหญ่ bounding shape ที่ง่ายที่สุดสองแบบคือ circle (เรียก sphere ใน 3D) กับ box
Circle ต้องการแค่ center กับ radius ใน 3D ไอเดียเดียวกันเรียกว่า sphere AABB (axis-aligned bounding box) คือสี่เหลี่ยม (หรือใน 3D คือ box) ที่ขอบขนานกับแกน x และ y เสมอ — ไม่มีการหมุนเลย ข้อจำกัดนี้แหละที่ทำให้มันถูก: คุณต้องเก็บแค่สองมุม คือมุมค่าน้อยสุดกับมุมค่ามากสุด
#include <iostream>
struct Vec2 { double x, y; };
struct Circle {
Vec2 center;
double radius;
};
struct AABB { // Axis-Aligned Bounding Box
Vec2 min; // corner with the smallest x and y
Vec2 max; // corner with the largest x and y
};
int main() {
Circle c = { {10.0, 10.0}, 3.0 };
AABB box = { {0.0, 0.0}, {4.0, 2.0} };
std::cout << "circle: center=(" << c.center.x << "," << c.center.y
<< ") radius=" << c.radius << "\n";
double width = box.max.x - box.min.x;
double height = box.max.y - box.min.y;
std::cout << "box: width=" << width << " height=" << height << "\n";
}
Output:
circle: center=(10,10) radius=3
box: width=4 height=2
ไม่มีอะไรซับซ้อน: circle เก็บตัวเลขสามตัว, box เก็บสี่ตัว (สองมุม) และทั้งคู่ให้คุณคำนวณค่าอื่นที่มีประโยชน์ (width, height, diameter) ได้ด้วยการลบง่าย ๆ
คำถาม collision ที่ง่ายที่สุดคือ "จุดนี้อยู่ในรูปทรงนี้ไหม?" — ใช้กับการคลิกเมาส์, spawn check, และ trigger zone สำหรับ circle จุดจะอยู่ข้างในเมื่อระยะห่างจาก center น้อยกว่าหรือเท่ากับ radius
#include <iostream>
struct Vec2 { double x, y; };
bool pointInCircle(Vec2 p, Vec2 center, double radius) {
double dx = p.x - center.x;
double dy = p.y - center.y;
double distSq = dx*dx + dy*dy; // squared distance -- no sqrt needed
return distSq <= radius * radius;
}
int main() {
Vec2 center = {0, 0};
double radius = 5.0;
Vec2 inside = {3, 4}; // distance = 5 exactly (a 3-4-5 triangle)
Vec2 outside = {5, 5}; // distance = sqrt(50) ~= 7.07
std::cout << "inside point in circle? " << pointInCircle(inside, center, radius) << "\n";
std::cout << "outside point in circle? " << pointInCircle(outside, center, radius) << "\n";
}
Output:
inside point in circle? 1
outside point in circle? 0
(3, 4) ห่างจาก origin พอดี 5 หน่วย (สามเหลี่ยมมุมฉาก 3-4-5) ซึ่งเท่ากับ radius พอดี — เส้นขอบนับเป็น "อยู่ข้างใน" ในที่นี้เพราะ test ใช้ <= (5, 5) ห่างประมาณ 7.07 หน่วย ไกลกว่า radius เลยไม่ผ่าน std::cout พิมพ์ bool เป็น 1 สำหรับ true และ 0 สำหรับ false
distSq <= radius*radius แทนที่จะเป็น sqrt(distSq) <= radius ทั้งสองแบบให้คำตอบ true/false เหมือนกัน เพราะการยกกำลังสองรักษาลำดับไว้สำหรับตัวเลขที่ไม่ติดลบ แต่แบบยกกำลังสองข้าม sqrt ไปได้เลย ดูเหมือนประหยัดนิดเดียว แต่เมื่อคุณเช็ค object เป็นร้อยตัวทุกเฟรม การข้าม square root ที่ไม่จำเป็นเป็นพัน ๆ ครั้งรวมกันแล้วมีผล ให้ใช้แบบ squared-distance เป็นนิสัยPoint-in-rectangle (AABB) test ยิ่งง่ายกว่าอีก: เช็คว่า x ของจุดอยู่ระหว่าง min กับ max x ของ box แล้วแยกเช็ค y ว่าอยู่ระหว่าง min กับ max y
#include <iostream>
struct Vec2 { double x, y; };
struct AABB { Vec2 min, max; };
bool pointInAABB(Vec2 p, AABB box) {
return p.x >= box.min.x && p.x <= box.max.x
&& p.y >= box.min.y && p.y <= box.max.y;
}
int main() {
AABB box = { {0, 0}, {10, 5} };
Vec2 inside = {4, 3};
Vec2 outside = {12, 3};
std::cout << "inside point in box? " << pointInAABB(inside, box) << "\n";
std::cout << "outside point in box? " << pointInAABB(outside, box) << "\n";
}
Output:
inside point in box? 1
outside point in box? 0
สี่การเปรียบเทียบ เอามา "and" กันด้วย && ถ้าอันไหนอันหนึ่งไม่ผ่าน จุดนั้นก็อยู่ข้างนอก นี่คือ intersection test ที่ถูกที่สุดในบทนี้ทั้งบท และยังเป็น building block หลักที่ AABB-vs-AABB collision เอาไปใช้ซ้ำในแบบพี่น้องกัน (box overlap test ก็แค่ทำ min/max comparison แบบเดียวกันนี้กับ box ทั้งสองแทนที่จะเป็น box กับจุด)
บางครั้งคุณไม่ได้ต้องการ "อยู่ข้างในหรือข้างนอก" แค่ต้องการ "จุดนี้ห่างจากเส้นนั้นแค่ไหน" — มีประโยชน์กับเรื่องอย่างการรักษาระยะห่างขั้นต่ำของ object จากกำแพง หรือเช็คว่ากระสุนที่ยิงพลาดผ่านเป้าหมายไปใกล้แค่ไหน เรื่องนี้ต้องการเครื่องมือเล็ก ๆ เพิ่มอีกอัน: 2D cross product ต่างจาก cross product แบบ 3D (ที่คืนค่าเป็น vector) เวอร์ชัน 2D คืนตัวเลขตัวเดียว: cross(a, b) = a.x*b.y - a.y*b.x ขนาดของมันเกี่ยวข้องกับพื้นที่ของ parallelogram ที่ vector สองตัวนั้นจะสร้างขึ้น และเครื่องหมายของมันบอกว่า b อยู่ฝั่งไหนเทียบกับ a
#include <iostream>
#include <cmath>
struct Vec2 { double x, y; };
double distancePointToLine(Vec2 p, Vec2 a, Vec2 b) {
Vec2 d = { b.x - a.x, b.y - a.y }; // line direction (A to B)
Vec2 ap = { p.x - a.x, p.y - a.y }; // vector from A to P
double cross = d.x * ap.y - d.y * ap.x; // 2D cross product (a single number)
double lenD = std::sqrt(d.x*d.x + d.y*d.y);
return std::fabs(cross) / lenD;
}
int main() {
Vec2 a = {0, 0};
Vec2 b = {10, 0}; // a horizontal line along the x-axis
Vec2 p = {4, 3};
std::cout << "distance = " << distancePointToLine(p, a, b) << "\n";
}
Output:
distance = 3
คุณเช็คด้วยตาได้เลยว่าคำตอบนี้ถูก: เส้นวิ่งตามแนว y = 0 และจุดอยู่ที่ y = 3 ดังนั้นระยะตั้งฉากก็ชัดเจนว่าเป็น 3 สูตรก็ให้คำตอบตรงกัน: cross = 10*3 - 0*4 = 30, ความยาวของเส้น lenD = 10, และ 30 / 10 = 3 cross product วัดพื้นที่ของ parallelogram ที่เกิดจาก direction ของเส้นกับ vector ไปยังจุดนั้น การหารด้วยความยาวของเส้นจะแปลงพื้นที่นั้นให้กลายเป็นระยะตั้งฉากธรรมดา
std::fabs ออก แล้ว เครื่องหมาย ของ cross product จะบอกว่าจุดนั้นอยู่ฝั่งไหนของเส้น — บวกฝั่งหนึ่ง ลบอีกฝั่งหนึ่ง ไอเดียเดียวนี้ (เครื่องหมายของ 2D cross product) คือวิธีที่ triangle rasterizer ใช้ตัดสินว่า pixel ไหนอยู่ในสามเหลี่ยม และวิธีที่โค้ด collision 2D ง่าย ๆ ใช้ตัดสินว่าคุณยืนอยู่ฝั่งไหนของกำแพงTest นี้ตอบคำถาม "ray นี้ชน circle นี้ไหม แล้วชนที่ระยะเท่าไหร่ตามแนว ray?" — คำถามที่อาวุธแบบ hitscan, ray สำหรับ mouse-picking, หรือการเช็ค line-of-sight ต้องการคำตอบพอดี ไอเดียทางเรขาคณิต: หาจุดบนเส้น ray ที่ใกล้ center ของ circle ที่สุด แล้วดูว่าจุดที่ใกล้ที่สุดนั้นอยู่ในระยะ radius หรือเปล่า
#include <iostream>
#include <cmath>
struct Vec2 { double x, y; };
double dot(Vec2 a, Vec2 b) { return a.x*b.x + a.y*b.y; }
bool raySphere(Vec2 origin, Vec2 dir, Vec2 center, double radius, double& tHit) {
Vec2 L = { center.x - origin.x, center.y - origin.y };
double tca = dot(L, dir); // closest-approach distance along the ray
double d2 = dot(L, L) - tca * tca; // squared distance from center to the ray line
double r2 = radius * radius;
if (d2 > r2) return false; // ray line misses the circle entirely
double thc = std::sqrt(r2 - d2); // half-chord length
double t0 = tca - thc; // near hit
double t1 = tca + thc; // far hit
if (t0 >= 0) { tHit = t0; return true; }
if (t1 >= 0) { tHit = t1; return true; }
return false; // circle is entirely behind the ray's origin
}
int main() {
Vec2 origin = {0, 0};
Vec2 center = {10, 0};
double radius = 2.0;
Vec2 dir1 = {1, 0}; // points straight at the circle, unit length
Vec2 dir2 = {0, 1}; // points straight up, away from the circle
double t;
if (raySphere(origin, dir1, center, radius, t))
std::cout << "dir1: hit at t=" << t << "\n";
else
std::cout << "dir1: no hit\n";
if (raySphere(origin, dir2, center, radius, t))
std::cout << "dir2: hit at t=" << t << "\n";
else
std::cout << "dir2: no hit\n";
}
Output:
dir1: hit at t=8
dir2: no hit
Circle มี center อยู่ที่ (10, 0) radius 2 ดังนั้นมันครอบคลุมตั้งแต่ x=8 ถึง x=12 ตามแนวแกน x ray แรกยิงตรงตามแนว +x จาก origin และตามที่คาดไว้ มันแตะ circle ครั้งแรกที่ x=8 ซึ่งคือ t=8 step ตามแนว direction ที่ยาว 1 หน่วย ray ที่สองยิงขึ้นตรง ๆ — มันไม่มีทางเข้าใกล้ x=10 เลย จึงพลาดไม่ว่าจะเดินทางไกลแค่ไหน และฟังก์ชันรายงานว่าไม่ชนได้ถูกต้อง
dir ที่ไม่ใช่ unit vector เข้าไป ถ้า dir มีความยาว 2 ทุก "ระยะทาง" ที่สูตรคำนวณจะคลาดเคลื่อนไปด้วยตัวคูณเดียวกันนั้น และ tHit ก็เลิกมีความหมาย "ระยะถึงจุดที่ชน" — มันยังบอกได้ว่ามีการชนเกิดขึ้น แต่ตัวเลขที่รายงานผิด normalize direction ของ ray ก่อนเรียก test แบบนี้เสมอTest สุดท้าย: ray ชน axis-aligned box หรือเปล่า? วิธีคลาสสิกเรียกว่า slab test ลองนึกภาพช่วง x ของ box เป็น "slab" (แผ่น) — แถบยาวไม่มีที่สิ้นสุดระหว่าง minX กับ maxX คำนวณช่วงของค่า t ที่ ray อยู่ในแถบนั้น ทำแบบเดียวกันกับช่วง y ray จะชน box จริง ๆ ก็ต่อเมื่อช่วงทั้งสองทับซ้อนกัน
#include <iostream>
#include <algorithm>
#include <limits>
struct Vec2 { double x, y; };
struct AABB { Vec2 min, max; };
bool raySlabAABB(Vec2 origin, Vec2 dir, AABB box, double& tHit) {
double tMin = -std::numeric_limits<double>::infinity();
double tMax = std::numeric_limits<double>::infinity();
// X axis slab
if (dir.x != 0.0) {
double t1 = (box.min.x - origin.x) / dir.x;
double t2 = (box.max.x - origin.x) / dir.x;
if (t1 > t2) std::swap(t1, t2);
tMin = std::max(tMin, t1);
tMax = std::min(tMax, t2);
} else if (origin.x < box.min.x || origin.x > box.max.x) {
return false; // parallel to the x-slab and outside it -- can never hit
}
// Y axis slab
if (dir.y != 0.0) {
double t1 = (box.min.y - origin.y) / dir.y;
double t2 = (box.max.y - origin.y) / dir.y;
if (t1 > t2) std::swap(t1, t2);
tMin = std::max(tMin, t1);
tMax = std::min(tMax, t2);
} else if (origin.y < box.min.y || origin.y > box.max.y) {
return false;
}
if (tMin > tMax || tMax < 0) return false; // slabs don't overlap, or box is behind us
tHit = tMin;
return true;
}
int main() {
AABB box = { {4, 1}, {8, 5} };
Vec2 origin1 = {0, 3};
Vec2 dir1 = {1, 0}; // straight along +x, at y=3 (inside the box's y-range)
Vec2 origin2 = {0, 3};
Vec2 dir2 = {0, 1}; // straight up -- x never reaches the box
double t;
if (raySlabAABB(origin1, dir1, box, t))
std::cout << "ray1: hit at t=" << t << "\n";
else
std::cout << "ray1: no hit\n";
if (raySlabAABB(origin2, dir2, box, t))
std::cout << "ray2: hit at t=" << t << "\n";
else
std::cout << "ray2: no hit\n";
}
Output:
ray1: hit at t=4
ray2: no hit
Ray 1 เริ่มที่ (0, 3) แล้วเดินตามแนว +x ค่า y ของมันไม่เปลี่ยนเลย และ 3 ก็อยู่ในช่วง y ของ box [1, 5] อยู่แล้ว ดังนั้น y-slab จึงไม่เพิ่มข้อจำกัดอะไร การเช็ค x-slab ของมันพบว่ามันเข้าสู่ช่วง x ของ box [4, 8] ที่ t=4 — นั่นกลายเป็นคำตอบ Ray 2 เดินขึ้นตรง ๆ จากจุดเริ่มเดียวกัน ค่า x ของมันเป็น 0 ตลอดไป ซึ่งอยู่นอกช่วง x ของ box [4, 8] ไม่ว่าจะเดินทางไกลแค่ไหน ดังนั้นการเช็คแรกสุด (dir.x == 0 และ origin.x อยู่นอก slab) ก็ปฏิเสธมันทันที
dir.x หรือ dir.y โดยไม่เช็คว่าเป็นศูนย์ก่อน ray ที่เดินทางแนวนอนล้วน ๆ จะมี dir.y == 0 และการหารด้วยศูนย์เป็น undefined behavior สำหรับ integer และให้ inf/nan สำหรับ floating point — ไม่ว่าแบบไหนมันก็ทำให้การคำนวณที่เหลือเสียหายแบบเงียบ ๆ โค้ดข้างบนจัดการแกนนั้นเป็นกรณีพิเศษ: ถ้า ray ไม่เคลื่อนที่ตามแกนนั้นเลย มันจะอยู่ใน slab นั้นตลอด (origin อยู่ระหว่าง min กับ max อยู่แล้ว) หรือไม่อยู่เลย และไม่ต้องหารเลยไม่ว่ากรณีไหน2*pi; math library ส่วนใหญ่ต้องการ radian ไม่ใช่ degreetheta บนมันคือ (cos(theta), sin(theta))(dx, dy) โดยจัดการทั้งสี่ quadrant ได้ถูกต้อง ต่างจาก atan ธรรมดาnew_x = x*cos(a) - y*sin(a), new_y = x*sin(a) + y*cos(a); หมุน vector ไปเป็นมุม aa.x*b.x + a.y*b.y; บวกถ้า vector ชี้ไปทางคล้ายกัน, ลบถ้าชี้ตรงข้าม, ศูนย์ถ้าตั้งฉากกันa.x*b.y - a.y*b.x; ตัวเลขตัวเดียวที่ขนาดเกี่ยวข้องกับพื้นที่ และเครื่องหมายบอกฝั่งorigin + t*direction สำหรับ t >= 0t ในแต่ละแกนที่ ray อยู่ใน slab ของแกนนั้น แล้วเอามาตัดกันrotate() จากหัวข้อ 4 คำนวณผลลัพธ์ของการหมุน vector (1, 0) ไป 135 องศา ปัดเศษให้เหลือ 3 ตำแหน่งทศนิยม คุณใช้ค่าที่รู้อยู่แล้วได้: cos(135deg) = -0.707107, sin(135deg) = 0.707107(a) radians = degrees * PI / 180 = 135 * 3.14159265 / 180 ~= 2.356 radian (นี่คือ 3*PI/4)
(b) ใช้ new_x = x*cos(a) - y*sin(a) และ new_y = x*sin(a) + y*cos(a) โดย x=1, y=0:
new_x = 1 * (-0.707107) - 0 * 0.707107 = -0.707
new_y = 1 * 0.707107 + 0 * (-0.707107) = 0.707
ดังนั้น (1, 0) หมุนไป 135 องศา ได้ประมาณ (-0.707, 0.707) — ชี้ขึ้นและไปทางซ้าย ซึ่งตรงกับสามัญสำนึก: 135 องศาผ่านจุดชี้ขึ้นตรง ๆ (90) ไปแล้วและมาได้ครึ่งทางสู่จุดชี้ซ้ายตรง ๆ (180)
(2, 3) radius 4 สี่เหลี่ยม (AABB) มีมุม min=(0, 0) และ max=(5, 5) สำหรับจุด (5, 5): มันอยู่ใน circle ไหม? อยู่ในสี่เหลี่ยมไหม? แสดงวิธีทำโดยใช้สูตรจากหัวข้อ 8Circle test: dx = 5 - 2 = 3, dy = 5 - 3 = 2, distSq = 3*3 + 2*2 = 9 + 4 = 13 radius*radius = 4*4 = 16 เพราะ 13 <= 16 จุดนี้ อยู่ข้างใน circle
AABB test: 5 >= 0 && 5 <= 5 ไหม? ใช่ (5 อยู่บนขอบพอดี ซึ่งนับด้วย) แล้ว 5 >= 0 && 5 <= 5 สำหรับ y ล่ะ? ใช่เหมือนกัน ดังนั้นจุดนี้ อยู่ข้างใน AABB ด้วย (แตะมุมบนขวาพอดีเป๊ะ)
(5, 5) ผ่านทั้งสอง test อยู่ข้างใน circle แบบมีที่เหลือนิดหน่อย และอยู่บนมุมของ box พอดีเป๊ะ
min=(2, 2) และ max=(6, 6) Ray เริ่มที่ origin=(0, 0) ด้วย direction dir=(1, 1) (ไม่ใช่ unit length แต่ไม่เป็นไรสำหรับแบบฝึกหัดนี้) ใช้ slab test จากหัวข้อ 11 คำนวณช่วง x-slab, ช่วง y-slab และบอกว่า ray ชน box ไหม ที่ t เท่าไหร่X slab: t1 = (2 - 0) / 1 = 2, t2 = (6 - 0) / 1 = 6 ช่วง: [2, 6]
Y slab: ตัวเลขเดียวกันเพราะสมมาตร: t1 = (2 - 0) / 1 = 2, t2 = (6 - 0) / 1 = 6 ช่วง: [2, 6]
Overlap: tMin = max(2, 2) = 2, tMax = min(6, 6) = 6 เพราะ tMin <= tMax และ tMax >= 0 มันคือ hit ที่ t = 2
เช็คความสมเหตุสมผล: จุดที่ชนคือ origin + t*dir = (0 + 2*1, 0 + 2*1) = (2, 2) — มุมของ box ที่ใกล้ origin ที่สุดพอดีเป๊ะ สมเหตุสมผล: direction ของ ray (1,1) ชี้ที่มุม 45 องศาพอดี พุ่งตรงไปหามุมที่ใกล้ origin ที่สุด (หมายเหตุ: เพราะ dir ไม่ใช่ unit length ในที่นี้ t จึงไม่เท่ากับระยะทางจริงที่เดินไป แต่ผลลัพธ์ hit/miss กับจุดที่ชนยังถูกต้องอยู่)
นี่คือคณิตศาสตร์ที่โค้ด gameplay จำนวนมากพึ่งพาอยู่เงียบ ๆ มุมแปลงไปมาระหว่าง degree กับ radian ด้วยสูตรเดียว; unit circle แปลงมุมใด ๆ ให้เป็น direction ผ่าน sin/cos; atan2 แปลงสอง position ใด ๆ ให้เป็นมุมสำหรับเล็ง; และรูปทรงถูก ๆ กลุ่มหนึ่ง — circle, AABB, ray — ครอบคลุมคำถาม collision ส่วนใหญ่ที่เกมถามทุกเฟรม ไม่ต้องท่องจำคำต่อคำ สิ่งที่สำคัญคือการจำรูปแบบของปัญหาได้ ("ฉันมีสอง position และอยากได้มุม" -> atan2; "ฉันมี ray กับ box" -> slab test) แล้วรู้ว่าจะหยิบเครื่องมือไหนมาใช้