10.1 Rigid Body และ Collision

เฟส 10 · ฟิสิกส์สำหรับเกม · เวลาเรียน: 30–50 h

rigid-body dynamics, collider, collision detection (broad และ narrow phase) และทำไม physics รันบน fixed timestep

ที่ผ่านมาเราพูดถึงแค่วัตถุชิ้นเดียวที่เคลื่อนที่ในพื้นที่: ตำแหน่งของมัน การหมุนของมัน เวกเตอร์ที่ใช้อธิบายมัน บทนี้จะพูดถึงสิ่งที่เกิดขึ้นเมื่อวัตถุพวกนี้หลายชิ้นอยู่ในพื้นที่เดียวกันแล้วเริ่มชนกัน — ลูกบอลตกลงพื้น กล่องไถลไปชนกำแพง กองกล่องที่วางซ้อนกันแล้วไม่ทะลุกันไปเอง นี่คือ rigid body physics และมันตั้งอยู่บนสองอย่างที่เรารู้จักอยู่แล้วทั้งหมด คือ semi-implicit Euler stepping และ fixed timestep จากบทแคลคูลัส กับเวกเตอร์ dot product และ AABB (axis-aligned bounding box) จากบทเรขาคณิต จริง ๆ แล้วกล่อง tip ในบทเรขาคณิตเรื่องวงกลมกับกล่องสี่เหลี่ยมเคยพูดคำว่า "broad phase" กับ "narrow phase" ไปแล้วโดยไม่ได้อธิบาย บทนี้คือคำสัญญานั้นที่จะจ่ายคืนให้ครบ

ทุกไอเดียด้านล่างนี้จะเดินตามรูปแบบเดิม: โปรแกรม C++ เล็ก ๆ ที่รันได้จริง เอาต์พุตจริงที่มันพิมพ์ออกมา แล้วค่อยอธิบายง่าย ๆ ว่าเกิดอะไรขึ้น หัวข้อ 1 ถึง 11 จะสร้าง physics engine เล็ก ๆ ขึ้นมาเองตั้งแต่ต้นด้วย C++ โดยใช้ struct Vec2 กับฟังก์ชัน add/sub/scale/dot/length จากบทเวกเตอร์ซ้ำ เพื่อให้เห็นชัด ๆ ว่าข้างใต้มันทำงานยังไง หัวข้อ 12 จะสลับไปใช้ C# แล้วโชว์ว่า component Rigidbody กับ Collider ของ Unity ทำงานแบบเดียวกันนี้ให้เราโดยอัตโนมัติยังไง เพื่อให้เรามองออกว่าแต่ละคอนเซปต์ที่เรียนไปอยู่ตรงไหนในเครื่องมือที่จะใช้จริงทุกวัน

1. Rigid body คืออะไร

Rigid body คือวัตถุที่รูปร่างไม่เปลี่ยนแปลงเลย — ระยะห่างระหว่างจุดสองจุดใด ๆ บนตัวมันจะคงที่เสมอ ไม่ว่ามันจะเคลื่อนที่หรือหมุนยังไงก็ตาม กล่อง ลูกบอล ตัวถังรถ ล้วนเป็น rigid body ส่วนผ้า เชือก หรือผิวหนังที่ยุบตัวได้ ไม่ใช่ — พวกนั้นเรียกว่า soft body ซึ่งเป็นหัวข้อที่ต่างออกไปและยากกว่า บทนี้จะไม่พูดถึง การจำกัดตัวเองให้อยู่กับรูปร่างที่ไม่เปลี่ยนรูปเลยนี่แหละ ที่ทำให้ rigid body physics คำนวณได้จริงในทางปฏิบัติ: แทนที่จะต้องติดตามทุกจุดบนวัตถุแยกกัน เราแค่ต้องติดตามตัวเลขไม่กี่ตัวสำหรับวัตถุทั้งชิ้นในคราวเดียว

ตัวเลขพวกนี้คือ state ของ rigid body:

angular velocity (spinning) (~) | velocity +-----------+ ----------> | | | mass | position = center of this box | m | rotation = which way it's turned | | +-----------+ state = { position, rotation, velocity, angularVelocity, mass }

นี่คือ state นั้นในรูปแบบ struct ของ C++ ซึ่งเป็นฐานที่ทุกหัวข้อถัดไปจะต่อยอดขึ้นมา:

#include <iostream>

struct Vec2 { float x, y; };

struct RigidBody {
    Vec2 position;
    float rotation;         // 2D: a single angle, in radians
    Vec2 velocity;          // linear velocity, units per second
    float angularVelocity;  // 2D: radians per second
    float mass;
    float invMass;          // 1 / mass; 0 means "infinite mass", i.e. never moves
};

RigidBody makeBody(Vec2 pos, float mass) {
    RigidBody b;
    b.position = pos;
    b.rotation = 0.0f;
    b.velocity = {0.0f, 0.0f};
    b.angularVelocity = 0.0f;
    b.mass = mass;
    b.invMass = (mass > 0.0f) ? 1.0f / mass : 0.0f;
    return b;
}

int main() {
    RigidBody box   = makeBody({0.0f, 5.0f}, 2.0f);
    RigidBody floor = makeBody({0.0f, 0.0f}, 0.0f);   // mass 0 = static, never moves

    std::cout << "box:   mass=" << box.mass   << " invMass=" << box.invMass   << "\n";
    std::cout << "floor: mass=" << floor.mass << " invMass=" << floor.invMass << "\n";
}

ผลลัพธ์:

box:   mass=2 invMass=0.5
floor: mass=0 invMass=0

สังเกตว่าเราเก็บ invMass (inverse mass หรือ 1/mass) แทนที่จะเก็บแค่ mass เฉย ๆ เพราะหัวข้อถัดไปทุกหัวข้อจะต้องหารด้วย "วัตถุสองชิ้นนี้รวมกันต้านการเคลื่อนที่มากแค่ไหน" — และกำแพงหรือพื้นไม่ควรขยับเลยไม่ว่าจะโดนกระแทกแรงแค่ไหน การเก็บ "mass เป็นอนันต์" ตรง ๆ ทำไม่ได้ (หารด้วยอนันต์ไม่ได้) แต่การเก็บ "inverse mass เป็นศูนย์" ทำได้ง่าย ๆ แถมคูณอะไรกับศูนย์ก็ได้ศูนย์แบบไม่มีปัญหา เคล็ดลับตัวนี้แหละที่ทำให้ invMass ไม่ใช่ mass โผล่มาในทุกสูตรตลอดบทนี้ วัตถุที่ invMass = 0 เรียกว่า static ส่วนที่เหลือเรียกว่า dynamic

Tip ตัวอย่างในบทนี้จะอยู่ใน 2D ทั้งหมด และเก็บแค่ rotation/angularVelocity เป็นตัวเลขธรรมดาเพื่อความง่าย การแปลงแรงผลักจาก contact ให้กลายเป็นการหมุน — คิด torque ใช้ moment of inertia — เป็นคณิตศาสตร์เพิ่มเติมจริง ๆ ที่ต่อยอดจากทุกอย่างในบทนี้ และเป็นหัวข้อถัดไปที่เหมาะจะเรียนพอไอเดียในบทนี้เข้าใจแล้ว ทุกคอนเซปต์ด้านล่าง (integration, broad phase, narrow phase, contacts, impulses) ทำงานแบบเดียวกันใน 3D เต็มรูปแบบ แค่การจัดการเรื่อง rotation จะซับซ้อนขึ้น

2. Integrate การเคลื่อนที่: semi-implicit Euler กับ rigid body

ย้อนกลับไปที่บทแคลคูลัส: physics engine ไม่เคยแก้สมการการเคลื่อนที่แบบตรง ๆ ทั้งหมด มันจะประมาณค่าไปทีละก้าวเล็ก ๆ คงที่ ด้วยวิธี semi-implicit (symplectic) Euler — อัปเดต velocity ก่อนจาก acceleration ปัจจุบัน แล้วค่อยอัปเดต position โดยใช้ velocity ตัวใหม่ ไม่ใช่ตัวเก่า แค่ลำดับบรรทัดเดียวนี้แหละที่ทำให้พลังงานไม่รั่วหายหรือระเบิดออกไป และนี่คือสิ่งที่ physics engine จริง ๆ ทุกตัวทำในทุกสเต็ป มาดูกันว่าเอามาใช้กับ RigidBody แล้วสเต็ปภายใต้แรงโน้มถ่วงเป็นยังไง:

#include <iostream>

struct Vec2 { float x, y; };

struct RigidBody {
    Vec2 position;
    Vec2 velocity;
    float invMass;
};

void integrate(RigidBody& body, Vec2 gravity, float dt) {
    if (body.invMass <= 0.0f) return;         // static bodies never move

    // 1. velocity FIRST, using the current acceleration (here: just gravity)
    body.velocity.x += gravity.x * dt;
    body.velocity.y += gravity.y * dt;

    // 2. position using the NEW velocity -- this is what makes it semi-implicit
    body.position.x += body.velocity.x * dt;
    body.position.y += body.velocity.y * dt;
}

int main() {
    RigidBody ball;
    ball.position = {0.0f, 0.0f};
    ball.velocity = {3.0f, 0.0f};
    ball.invMass  = 1.0f;

    Vec2 gravity = {0.0f, -10.0f};
    float dt = 0.1f;

    for (int tick = 1; tick <= 4; tick++) {
        integrate(ball, gravity, dt);
        std::cout << "tick " << tick << ": pos=(" << ball.position.x << ", " << ball.position.y
                   << ") vel=(" << ball.velocity.x << ", " << ball.velocity.y << ")\n";
    }
}

ผลลัพธ์:

tick 1: pos=(0.3, -0.1) vel=(3, -1)
tick 2: pos=(0.6, -0.3) vel=(3, -2)
tick 3: pos=(0.9, -0.6) vel=(3, -3)
tick 4: pos=(1.2, -1) vel=(3, -4)

ถ้าสเต็ป 2 ใช้ velocity ตัวเก่าในการขยับ position แทน (นี่คือ explicit Euler ตัวที่ไม่เสถียรซึ่งบทแคลคูลัสเคยเตือนไว้) ตำแหน่ง y หลังจากสี่ tick เดิมจะกลายเป็น 0, -0.1, -0.3, -0.6 แทนที่จะเป็น -0.1, -0.3, -0.6, -1.0 — ช้าไปหนึ่งสเต็ปเต็ม ๆ เพราะมันขยับ position ด้วย velocity ที่ยังไม่ "เห็น" แรงโน้มถ่วงของสเต็ปนี้เลย จำนวนการคำนวณเท่ากัน ตัวเลขอินพุตเท่ากัน แต่ trajectory ต่างกันอย่างมีนัยสำคัญ (แถมเสถียรน้อยกว่าด้วย) นี่คือลำดับบรรทัดเดียวกันเป๊ะกับที่ตัวอย่าง FixedUpdate ในบท game loop เคยใช้ไปแล้วโดยไม่ได้เอ่ยชื่อมัน — อัปเดต velocityY ก่อนเอาไปขยับ transform.position

Explicit Euler: ดูพลังงานมันระเบิด

ทำไมต้องยืนกรานลำดับ "velocity ก่อน"? เพราะลำดับอีกแบบ — explicit (forward) Euler ที่ขยับ position ด้วย velocity ตัวเก่าแล้วค่อยอัปเดต velocity ทีหลัง — ไม่ได้แค่ช้าไปหนึ่งสเต็ปเฉย ๆ แต่กับอะไรก็ตามที่แกว่งไปมา มันจะเพิ่มพลังงานเข้าไปทุกสเต็ปอย่างเงียบ ๆ จนกระทั่ง simulation ระเบิด ที่ที่เห็นชัดที่สุดคือสปริง: มวลที่ถูกดึงกลับเข้าหา origin ด้วย acceleration a = -k*x ซึ่งในโลกจริงจะแกว่งไปมาตลอดกาลด้วย amplitude คงที่ ลองรัน integrator ทั้งสองตัวกับสปริงตัวเดียวกันนี้ แล้วพิมพ์พลังงานรวม (0.5*(v*v + k*x*x) ซึ่งทางฟิสิกส์ต้องคงที่) ออกมา:

#include <cstdio>
#include <cmath>

int main() {
    float k = 1.0f, dt = 0.2f;      // spring constant, fixed step
    float ex = 1.0f, ev = 0.0f;     // explicit Euler: position, velocity
    float sx = 1.0f, sv = 0.0f;     // semi-implicit Euler: position, velocity

    printf("step | explicit x   energy | semi-impl x   energy\n");
    for (int step = 0; step <= 40; step++) {
        if (step % 8 == 0) {
            float eE = 0.5f * (ev * ev + k * ex * ex);
            float sE = 0.5f * (sv * sv + k * sx * sx);
            printf("%3d  | %9.4f  %.4f | %9.4f  %.4f\n",
                   step, ex, eE, sx, sE);
        }
        // explicit (forward) Euler: move x with OLD v, then v with OLD x
        float exNew = ex + ev * dt;
        float evNew = ev - k * ex * dt;
        ex = exNew; ev = evNew;
        // semi-implicit: v FIRST (current x), then x with the NEW v
        sv = sv - k * sx * dt;
        sx = sx + sv * dt;
    }
}

ผลลัพธ์:

step | explicit x   energy | semi-impl x   energy
  0  |    1.0000  0.5000 |    1.0000  0.5000
  8  |   -0.0098  0.6843 |   -0.1323  0.5133
 16  |   -1.3684  0.9365 |   -0.9916  0.4936
 24  |    0.0402  1.2817 |    0.1955  0.5196
 32  |    1.8719  1.7540 |    0.9791  0.4875
 40  |   -0.0917  2.4005 |   -0.2580  0.5256

สปริงตัวนี้ควรจะรักษาพลังงานไว้ที่ 0.5 ตลอดไป semi-implicit Euler (สองคอลัมน์ขวา) ทำแบบนั้นเป๊ะ — พลังงานของมันสั่นอยู่รอบ ๆ 0.5 นิดเดียว (0.49 ถึง 0.53) และไม่เคยเลื่อนหนีไปไหน มวลก็แกว่งอยู่ระหว่างประมาณ -1 กับ +1 นานเท่าที่เรารันเลย ส่วน explicit Euler (สองคอลัมน์ซ้าย) กลับไต่ขึ้นไม่หยุด: 0.50, 0.68, 0.94, 1.28, 1.75, 2.40 — เกือบห้าเท่าของพลังงานเริ่มต้นในแค่ 40 สเต็ป และยังโตต่อ ช่วงการแกว่งก็กว้างขึ้นทุกรอบ ปล่อยไปอีกไม่กี่ร้อยสเต็ปตัวเลขก็หนีไปเป็นอนันต์ นี่ไม่ใช่ error จากการปัดเศษที่ย่อให้เล็กลงได้ด้วยการใช้ double แต่เป็น bias เชิงทิศทางที่ฝังอยู่ในลำดับการอัปเดต นี่แหละคือเหตุผลที่ไม่มี engine ตัวไหนใช้ explicit Euler กับ dynamics และเป็นเหตุผลว่าทำไมการสลับลำดับบรรทัดเดียวจากบทแคลคูลัสถึงสำคัญมาก

Tip ชื่อทางเทคนิคของสิ่งที่ semi-implicit Euler มีแต่ explicit Euler ไม่มีคือความเป็น symplectic: มันอนุรักษ์ปริมาณที่ใกล้เคียงพลังงานจริงมาก ทำให้ error ถูกจำกัดขอบเขตและแกว่งไปมาแทนที่จะสะสมไปทางเดียว เราไม่ต้องรู้ทฤษฎีก็ได้ — จำกฎง่าย ๆ ก็พอ: สำหรับอะไรที่เป็นสปริงหรือโคจร integrator ที่เพิ่มพลังงานจะระเบิดในที่สุด ส่วนตัวที่สูญเสียพลังงานจะค่อย ๆ หยุดนิ่ง ส่วน symplectic ไม่เป็นทั้งสองอย่าง

Verlet integration: velocity ที่ไม่เคยเก็บ

มี integrator ตัวที่สามที่ควรรู้จักไว้ เพราะเอฟเฟกต์ทั้งตระกูล — ผ้า เชือก เส้นผม และ position-based dynamics — สร้างขึ้นบนมัน นั่นคือ Verlet integration เคล็ดลับของมันคือไม่เก็บ velocity เลย แต่จะเก็บ position ก่อนหน้าไว้แทน แล้วอนุมานการเคลื่อนที่จากช่องว่างระหว่าง "เมื่อกี้ฉันอยู่ไหน" กับ "ตอนนี้ฉันอยู่ไหน":

next = current + (current - previous) + acceleration * dt*dt \________/ \_________________/ carry on the implied velocity is just last step's (inertia) movement, (current - previous) -- no v stored
#include <cstdio>

int main() {
    float g = -10.0f, dt = 0.1f;   // gravity, fixed step
    float y     = 0.0f;            // current position (start at rest at 0)
    float yPrev = 0.0f;            // previous position; at rest it equals y

    printf("step | y (Verlet) | implied v = (y - yPrev)/dt\n");
    for (int step = 1; step <= 5; step++) {
        // position Verlet: next = 2*current - previous + a*dt*dt
        float yNext = 2.0f * y - yPrev + g * dt * dt;
        float impliedV = (yNext - y) / dt;   // velocity is never stored -- it is inferred
        yPrev = y;
        y = yNext;
        printf("%3d  | %8.2f  | %8.1f\n", step, y, impliedV);
    }
}

ผลลัพธ์:

step | y (Verlet) | implied v = (y - yPrev)/dt
  1  |    -0.10  |     -1.0
  2  |    -0.30  |     -2.0
  3  |    -0.60  |     -3.0
  4  |    -1.00  |     -4.0
  5  |    -1.50  |     -5.0

ค่า y พวกนั้น — -0.10, -0.30, -0.60, -1.00, -1.50 — คือการตกแบบเดียวกันเป๊ะกับที่ semi-implicit Euler สร้างไว้เมื่อสองลิสต์ก่อน แต่ได้มาโดยไม่เคยเก็บตัวแปร velocity เลย Verlet เป็น symplectic เหมือนกัน (มันจะไม่ทำให้สปริงระเบิด) และมีคุณสมบัติหนึ่งที่ทำให้มันเป็นตัวเลือกหลักสำหรับผ้ากับเชือก: เพราะไม่มี velocity แยกมาขัดกับ position เราเลยจับจุดแล้วย้ายมันได้เลย — ตรึงมุมธง ดึงเชือกกลับมาที่ความยาวสูงสุด — แล้ว (current - previous) ในสเต็ปถัดไปจะแปลงการเปลี่ยนแปลงนั้นให้เป็น velocity ที่สมเหตุสมผลให้เอง การแก้ position ตรง ๆ แล้วปล่อยให้ integrator กู้ velocity คืนมาฟรี ๆ นี่แหละคือไอเดียทั้งหมดของ position-based dynamics

ทำไม physics ถึงยืนกรานต้องใช้ fixed step

บท game loop เคยโชว์ให้เห็นว่า deltaTime ของ Update สั่นไปมาในแต่ละเฟรม และบทแคลคูลัสก็สร้าง fixed-timestep-plus-accumulator loop เต็มรูปแบบขึ้นมาแก้ปัญหานี้ collision detection นี่แหละคือเหตุผลที่ physics ข้ามกลไกนั้นไปไม่ได้ มีเหตุผลจริง ๆ สามข้อที่ซ้อนทับกันอยู่:

ดังนั้นวัตถุทุกชิ้นในโลกจะถูก integrate ด้วย dt คงที่ตัวเดียวกันในทุก physics step — ซึ่งเซ็ตอัพส่วนที่เหลือของบทนี้ได้พอดี: พอรู้ตำแหน่งใหม่ของวัตถุทุกชิ้นในสเต็ปนี้แล้ว engine ก็ต้องหาว่าตัวไหนบ้างที่ตอนนี้ทับกันอยู่

Accumulator: เฟรมไทม์สั่น ๆ หนึ่งอัน แปลงเป็นสเต็ปคงที่เต็มจำนวน

"ใช้ dt คงที่" ไปเจอกำแพงเข้าอย่างจัง: เฟรมจริง ๆ ไม่ได้มาตามตารางคงที่ เฟรมหนึ่งใช้ 12 ms เฟรมถัดไป 22 ms เฟรมถัดไปอีก 9 ms ขึ้นอยู่กับว่า GPU และส่วนที่เหลือของเกมกำลังทำอะไรอยู่ตอนนั้น ถ้าเราส่งระยะเวลาจริงของแต่ละเฟรมเข้า integrate ตรง ๆ physics จะสเต็ปด้วย dt ต่างกันทุกเฟรม — และคำตอบของ Euler ขึ้นอยู่กับ dt การเคลื่อนที่แบบเดียวกันเลยจะไปตกคนละที่ ขึ้นอยู่กับว่าเฟรมมันบังเอิญตกลงมายังไง:

#include <iostream>

// integrate a falling body for a total of 1.0s, split into `steps` equal parts
float fallFor(float totalTime, int steps) {
    float y = 0.0f, v = 0.0f, g = -10.0f;
    float dt = totalTime / steps;
    for (int i = 0; i < steps; i++) {
        v += g * dt;      // semi-implicit Euler
        y += v * dt;
    }
    return y;
}

int main() {
    std::cout << "1.0s of gravity, stepped different ways:\n";
    std::cout << "  1 step  of 1.00s : y = " << fallFor(1.0f, 1) << "\n";
    std::cout << "  4 steps of 0.25s : y = " << fallFor(1.0f, 4) << "\n";
    std::cout << "  10 steps of 0.10s: y = " << fallFor(1.0f, 10) << "\n";
}

ผลลัพธ์:

1.0s of gravity, stepped different ways:
  1 step  of 1.00s : y = -10
  4 steps of 0.25s : y = -6.25
  10 steps of 0.10s: y = -5.5

แรงโน้มถ่วงตัวเดียวกันหนึ่งวินาที ทำให้วัตถุไปตกที่ -10 หรือ -6.25 หรือ -5.5 ขึ้นอยู่กับว่าวินาทีนั้นถูกซอยยังไงล้วน ๆ (สเต็ปเล็กกว่าจะใกล้คำตอบจริงที่ -5.0 มากกว่า แต่นั่นไม่ใช่ประเด็นตรงนี้) ความต่างนั้นคือความไม่ determinism: ส่ง dt อะไรก็ตามที่เฟรมบังเอิญใช้เข้าไปให้ physics แล้วผลลัพธ์ก็ขึ้นอยู่กับจังหวะเฟรม — ทำ replay ซ้ำไม่ได้ และรับประกันว่าจะทำให้ผู้เล่นสองคนใน networked game เลื่อนหนีจากกัน วิธีแก้คือ accumulator: บวกเวลาจริงที่ผ่านไปของแต่ละเฟรมเข้าไปในยอดสะสม แล้วค่อยใช้ยอดนั้นเป็นก้อนขนาดคงที่ พร้อมยกเศษที่เหลือไปเฟรมถัดไป

#include <iostream>

int main() {
    const float FIXED = 0.25f;   // physics ALWAYS steps by this, never anything else
    float acc = 0.0f;
    float frameTimes[] = {0.30f, 0.10f, 0.28f, 0.40f};   // jittery real frame durations

    for (int f = 0; f < 4; f++) {
        acc += frameTimes[f];
        int stepsThisFrame = 0;
        while (acc >= FIXED) {
            acc -= FIXED;        // integrate(FIXED) would run here
            stepsThisFrame++;
        }
        std::cout << "  frame dt=" << frameTimes[f]
                  << " -> ran " << stepsThisFrame
                  << " fixed step(s), leftover=" << acc << "\n";
    }
}

ผลลัพธ์:

  frame dt=0.3 -> ran 1 fixed step(s), leftover=0.05
  frame dt=0.1 -> ran 0 fixed step(s), leftover=0.15
  frame dt=0.28 -> ran 1 fixed step(s), leftover=0.18
  frame dt=0.4 -> ran 2 fixed step(s), leftover=0.08

เฟรมไทม์สั่นสะบัด — 0.30, 0.10, 0.28, 0.40 — แต่ทุก physics step เป็น 0.25 เป๊ะ เฟรมยาว (0.40) รันสองสเต็ปเพื่อไล่ให้ทัน ส่วนเฟรมสั้น (0.10) รันศูนย์สเต็ปแล้วแค่ฝากเวลาไว้ใน accumulator ไว้ใช้ทีหลัง physics ไม่เคยเห็น dt ที่เปลี่ยนไปมาเลยสักครั้ง มันเลยยังคง deterministic เต็มที่ไม่ว่าเฟรมจะกระตุกยังไง นี่คือสิ่งที่ FixedUpdate ของ Unity ทำเป๊ะ ๆ: มันถูกเรียกศูนย์ หนึ่ง หรือหลายครั้งต่อเฟรมที่เรนเดอร์ เพื่อให้ physics เดินหน้าเป็นก้อนขนาด Time.fixedDeltaTime เสมอ มีอีกเรื่องหนึ่งที่ควรรู้: เฟรมที่เรนเดอร์มักตกลงระหว่างสองสเต็ปคงที่ (เศษที่ค้างอยู่ใน accumulator นั่นแหละ) engine ที่ลื่น ๆ เลยจะ interpolate ตำแหน่งภาพระหว่าง state ของ physics สองตัวล่าสุด — ไม่งั้นวัตถุที่เร็ว ๆ จะกระตุกให้เห็นทั้ง ๆ ที่ simulation ข้างใต้สมบูรณ์แบบ

3. Broad phase: ไม่ต้องเทสต์ทุกคู่กับทุกคู่

ถ้ามีวัตถุ n ชิ้นในโลก จะมีคู่ที่อาจจะแตะกันได้ทั้งหมด n * (n-1) / 2 คู่ การเทสต์ทุกคู่ด้วยการเช็กรูปร่างแบบละเอียดเป็นการเสียเวลาโดยใช่เหตุ: วัตถุ 100 ชิ้นหมายถึงต้องเช็ก 4,950 คู่ในทุก physics step ทั้ง ๆ ที่เกือบทั้งหมดอยู่ห่างกันคนละที่ งานเดียวของ broad phase collision detection คือคัดคู่ที่เห็นชัด ๆ ว่าไม่มีทางแตะกันทิ้งไปแบบไว ๆ และถูก ๆ โดยใช้ AABB จากบทเรขาคณิต เหลือแค่ลิสต์คู่ candidate เล็ก ๆ ไว้ให้เทสต์แบบละเอียดต่อไป

ALL POSSIBLE PAIRS BROAD PHASE (cheap AABB test) (n bodies -> n*(n-1)/2 pairs) throws out pairs that obviously cannot be touching A---B---C---D A---B C D \ / \ / \ / (kept: (dropped: AABBs X X X 6 pairs checked AABBs don't even / \ / \ / \ overlap) overlap) A---C B---D (and A-D, B-C too) narrow phase (section 4) only runs on what survives broad phase

นี่คือวงกลมสี่วง โดยใช้ struct Circle/AABB จากบทเรขาคณิต พร้อม broad phase แบบง่าย ๆ ที่เช็ก AABB ทับกันของทุกคู่:

#include <iostream>

struct Vec2 { float x, y; };
struct AABB { Vec2 min, max; };

AABB circleAABB(Vec2 center, float radius) {
    return { {center.x - radius, center.y - radius},
             {center.x + radius, center.y + radius} };
}

bool aabbOverlap(AABB a, AABB b) {
    if (a.max.x < b.min.x || a.min.x > b.max.x) return false;
    if (a.max.y < b.min.y || a.min.y > b.max.y) return false;
    return true;
}

int main() {
    struct Named { const char* name; Vec2 center; float radius; };
    Named bodies[] = {
        {"A", {0.0f, 0.0f}, 1.0f},
        {"B", {1.7f, 0.0f}, 1.0f},
        {"C", {5.0f, 5.0f}, 1.0f},
        {"D", {0.0f, 3.0f}, 1.0f},
    };

    int pairsChecked = 0, candidates = 0;
    for (int i = 0; i < 4; i++) {
        for (int j = i + 1; j < 4; j++) {
            AABB boxI = circleAABB(bodies[i].center, bodies[i].radius);
            AABB boxJ = circleAABB(bodies[j].center, bodies[j].radius);
            pairsChecked++;
            if (aabbOverlap(boxI, boxJ)) {
                std::cout << bodies[i].name << "-" << bodies[j].name << ": CANDIDATE\n";
                candidates++;
            }
        }
    }
    std::cout << "checked " << pairsChecked << " pairs, found " << candidates << " candidate(s)\n";
}

ผลลัพธ์:

A-B: CANDIDATE
checked 6 pairs, found 1 candidate(s)

เช็กไปหกคู่ เหลือรอดมาคู่เดียว คู่นั้นคือ A กับ B ซึ่งเป็นคู่เดียวที่ narrow phase (หัวข้อ 4) ต้องไปยุ่งด้วย เวอร์ชันง่าย ๆ นี้ยังนับว่าเป็น O(n^2) ในแง่จำนวนคู่ที่ต้อง "เช็ก" อยู่ดี (แค่ทำให้แต่ละการเช็กถูกลง) ซึ่งก็โอเคถ้ามีวัตถุแค่ไม่กี่ชิ้น แต่พอฉากมีวัตถุเป็นร้อย ๆ ตัว มันจะเริ่มสะสมจนช้าลง มีวิธีทั่วไปสองแบบที่หลีกเลี่ยงการเช็กทุกคู่ไปเลย:

Uniform grid: จัดวัตถุลงในเซลล์

แบ่งโลกออกเป็นเซลล์สี่เหลี่ยมขนาดคงที่ แล้วโยนแต่ละวัตถุลงในเซลล์ที่จุดศูนย์กลางของมันตกอยู่ (ใช้ floor(position / cellSize)) จะมีแค่วัตถุที่อยู่เซลล์เดียวกัน (หรือเซลล์ข้างเคียง สำหรับวัตถุที่อยู่ใกล้ขอบเซลล์) เท่านั้นที่จะถูกเอามาเทียบกัน วัตถุที่อยู่เซลล์ห่างกันมากจะไม่ถูกพิจารณาเลย:

y ^ +------+------+------+ 4 | D | | | cell size = 2 +------+------+------+ 2 | A B | | C | A and B land in the SAME cell (0,0) +------+------+------+ -> only they get compared 0 | | | | +------+------+------+---> x 0 2 4 6
#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };

int cellOf(float coord, float cellSize) {
    return (int)std::floor(coord / cellSize);
}

int main() {
    struct Named { const char* name; Vec2 center; };
    Named bodies[] = {
        {"A", {0.0f, 0.0f}},
        {"B", {1.7f, 0.0f}},
        {"C", {5.0f, 5.0f}},
        {"D", {0.0f, 3.0f}},
    };
    float cellSize = 2.0f;

    for (auto& b : bodies) {
        int cx = cellOf(b.center.x, cellSize);
        int cy = cellOf(b.center.y, cellSize);
        std::cout << b.name << ": cell (" << cx << ", " << cy << ")\n";
    }
}

ผลลัพธ์:

A: cell (0, 0)
B: cell (0, 0)
C: cell (2, 2)
D: cell (0, 1)

A กับ B ตกเซลล์เดียวกัน ส่วน C กับ D ต่างก็อยู่เซลล์ของตัวเองคนเดียว การ implement จริงจะจัดกลุ่มวัตถุตามเซลล์ (ปกติใช้ hash map ที่ key เป็นพิกัดเซลล์) แล้วเทสต์แค่คู่ที่อยู่เซลล์เดียวกันหรือเซลล์ข้างเคียง — ได้คู่ candidate เดียวกับที่ AABB sweep เจอ แค่ไปถึงด้วยวิธีคนละแบบ grid นั้นเรียบง่ายและไวเมื่อวัตถุกระจายตัวสม่ำเสมอ แต่จะเปลืองหน่วยความจำไปกับเซลล์ที่ว่างเปล่าเป็นส่วนใหญ่ ถ้าวัตถุไปกระจุกกันแน่น ๆ ในพื้นที่เดียว หรือกระจายอยู่ในโลกที่ใหญ่และโปร่งมาก

Sweep and prune: เรียงตามแกนเดียว

Sweep and prune (บางทีก็เรียกว่าวิธี "sort and sweep") มองปัญหาจากมุมที่ต่างออกไป: เรียง AABB ของแต่ละวัตถุตามพิกัด x ต่ำสุด แล้วไล่เดินไปตามลิสต์ที่เรียงแล้วจากซ้ายไปขวา คอยจดว่า AABB ตัวไหน "เปิด" อยู่ตอนนี้บ้าง (ช่วงของมันเริ่มแล้วแต่ยังไม่จบ) AABB สองตัวจะมีโอกาสทับกันได้ก็ต่อเมื่อทั้งคู่เปิดอยู่พร้อมกันเท่านั้น:

sorted by minX: A |-----| B |-----| D |---| C |---| -----------------------> x sweeping left to right: A opens, B opens while A is still open (A,B pair!), A closes, B closes, D opens (nothing else open -- no pair), D closes, C opens (nothing open -- no pair) only the A-B overlap in x needs a y check -- same result as the grid and the AABB sweep above, found by sorting instead of bucketing

engine อย่าง Box2D ชอบใช้ sweep and prune เพราะเพื่อนบ้านของวัตถุแต่ละตัวแทบไม่เปลี่ยนจากเฟรมหนึ่งไปอีกเฟรม — ลำดับที่เรียงไว้จากเฟรมก่อนแทบจะถูกอยู่แล้ว การเรียงใหม่เลยแทบไม่เสียอะไรเลย (สลับกันแค่ไม่กี่ตัว ไม่ใช่เรียงใหม่ทั้งหมด) ในเกือบทุกสเต็ป ไม่ว่า broad phase จะใช้วิธีไหน งานของมันก็เหมือนเดิมเสมอ: เปลี่ยน "คู่ที่เป็นไปได้ทั้งหมด" ให้กลายเป็น "คู่ candidate" อย่างไว เพื่อให้การเทสต์แบบละเอียด (ที่แพงกว่ามาก) ในหัวข้อถัดไปต้องรันแค่ไม่กี่ครั้ง ไม่ใช่เป็นพัน

Common mistake ข้าม broad phase ไปเลยเพราะ "ตอนนี้ฉากยังเล็กอยู่" ฉากที่มีวัตถุ 20 ชิ้นจะไม่รู้สึกช้าเลยแม้จะเช็กทุกคู่แบบตรง ๆ โค้ดตัวเดียวกันนี้พอมีวัตถุ 2,000 ชิ้น (ฉากต่อสู้ที่คนแน่น ฝูงชน กำแพงที่แตกเป็นเศษชิ้น ๆ) จะรัน O(n^2) exact shape test ทุก physics step ทันที แล้ว frame rate ก็จะร่วงลงเหวเลย สร้างนิสัยใช้ broad phase ไว้ตั้งแต่เนิ่น ๆ ก่อนที่มันจะกลายเป็นไฟที่ต้องมาดับทีหลัง

4. Narrow phase: การเทสต์แบบละเอียด

Narrow phase collision detection เอาลิสต์คู่ candidate เล็ก ๆ ที่ broad phase สร้างมา แล้วรันการเทสต์แบบละเอียดเฉพาะรูปร่างนั้น ๆ กับแต่ละคู่: รูปร่างสองอันนี้ทับกันจริงไหม แล้วถ้าใช่ ทับกันแค่ไหน สำหรับวงกลมสองวงเรื่องนี้ง่าย — เป็นการเทสต์แบบละเอียดที่ต่อยอดตรง ๆ จากไอเดีย point-in-circle ในบทเรขาคณิต:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };

Vec2 sub(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); }

bool circlesOverlap(Vec2 centerA, float rA, Vec2 centerB, float rB) {
    float dist = length(sub(centerB, centerA));
    return dist < (rA + rB);
}

int main() {
    Vec2 A = {0.0f, 0.0f}, B = {1.7f, 0.0f};
    std::cout << "A-B exact test: " << (circlesOverlap(A, 1.0f, B, 1.0f) ? "COLLIDING" : "clear") << "\n";
}

ผลลัพธ์:

A-B exact test: COLLIDING

broad phase บอกเราไปแล้วว่า A กับ B เป็นคู่เดียวที่คุ้มค่าจะเช็ก narrow phase เลยมีแค่การเทสต์เดียวที่ต้องรัน ไม่ใช่หกครั้งแบบที่การเช็กทุกคู่ตรง ๆ ต้องทำ "จุดศูนย์กลางสองจุดอยู่ใกล้กันน้อยกว่าผลรวมของรัศมี" คือการเทสต์แบบละเอียดทั้งหมดสำหรับวงกลม นี่คือเหตุผลที่วงกลม (และทรงกลมใน 3D) เป็นรูปร่างที่ถูกที่สุดที่ physics engine จะเอามาชนกันได้

วงกลมกับกล่อง: clamp ไปหาจุดที่ใกล้ที่สุด

ระหว่าง "วงกลมสองวง" กับ "polygon สองอัน" มีเคสหนึ่งที่พบบ่อยพอจะมีทางลัดของตัวเอง คือวงกลมกับกล่องแบบ axis-aligned การเทสต์แบบละเอียดเป็นทริกสวย ๆ — หาจุดบนกล่องที่ใกล้ศูนย์กลางวงกลมที่สุด ด้วยการ clamp ศูนย์กลางเข้าไปในช่วงของกล่องในแต่ละแกน จากนั้นก็เหลือแค่การเช็กวงกลมกับจุดเท่านั้น:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };
struct AABB { Vec2 min, max; };

float clampf(float v, float lo, float hi) {
    return v < lo ? lo : (v > hi ? hi : v);
}

int main() {
    AABB box = { {0.0f, 0.0f}, {3.0f, 2.0f} };
    Vec2 center = {3.5f, 1.0f};
    float radius = 1.0f;

    // closest point on the box to the circle center = clamp center into the box
    Vec2 closest = { clampf(center.x, box.min.x, box.max.x),
                     clampf(center.y, box.min.y, box.max.y) };
    Vec2 d = { center.x - closest.x, center.y - closest.y };
    float dist = std::sqrt(d.x * d.x + d.y * d.y);

    std::cout << "closest point on box = (" << closest.x << ", " << closest.y << ")\n";
    std::cout << "distance to center   = " << dist << "\n";
    if (dist < radius) {
        Vec2 normal = { d.x / dist, d.y / dist };   // from box toward circle
        float penetration = radius - dist;
        std::cout << "COLLIDING  normal=(" << normal.x << ", " << normal.y
                  << ")  penetration=" << penetration << "\n";
    } else {
        std::cout << "clear\n";
    }
}

ผลลัพธ์:

closest point on box = (3, 1)
distance to center   = 0.5
COLLIDING  normal=(1, 0)  penetration=0.5

วงกลมอยู่ที่ x=3.5 เลยขอบขวาของกล่องที่ x=3 ไปนิดเดียว การ clamp ศูนย์กลางเข้าช่วง [0,3] x [0,2] ของกล่องจะดึงมันไปที่ (3, 1) — จุดที่ใกล้ที่สุดบนกล่อง — ซึ่งอยู่ห่างไป 0.5 อยู่ในรัศมี 1 มันเลยชนกัน 0.5 โดยมี normal ชี้ตรงออกไปตามแนว +x เคสเดียวที่เวอร์ชันง่าย ๆ นี้จัดการไม่ได้คือตอนที่ศูนย์กลางอยู่ข้างในกล่อง (ตอนนั้น clamp จะคืนศูนย์กลางกลับมาเอง dist เป็น 0 แล้วเราหารเพื่อหา normal ไม่ได้) การ implement เต็ม ๆ จะตรวจจับเคสนั้นแล้วผลักออกไปตามหน้าที่ใกล้ที่สุดแทน engine เก็บทางลัดเขียนมือแบบนี้ไว้สำหรับ circle-vs-circle, circle-vs-box และ box-vs-box ก็เพราะมันถูกขนาดนี้แหละ

กล่องและ polygon: Separating Axis Theorem

วงกลมเป็นเคสง่าย สำหรับกล่องและ convex polygon อื่น ๆ (รูปร่างจะเป็น convex ถ้าเส้นตรงระหว่างจุดสองจุดใด ๆ ข้างในมันไม่มีทางออกนอกรูปร่างเลย) การเทสต์แบบละเอียดคลาสสิกคือ Separating Axis Theorem (SAT): รูปทรง convex สองอันจะไม่ชนกันก็ต่อเมื่อเราหาแกนได้อย่างน้อยหนึ่งแกนที่พอฉายรูปทรงทั้งสองลงไปแล้วมีช่องว่างระหว่างกัน ลองทุกแกนที่เป็นไปได้ (สำหรับ polygon หมายถึง normal ที่ตั้งฉากกับทุกขอบ) ถ้าทุกแกนแสดงว่าทับกันหมด แสดงว่ารูปทรงชนกันจริง และแกนที่มีช่วงทับซ้อนน้อยที่สุดจะบอก collision normal ให้เรา

SEPARATING AXIS FOUND -> NOT colliding NO separating axis -> COLLIDING [A] axis [A] [B] [A] \ / \ [ [B] ] overlapping on \ / \ every axis tried [B] \/ gap between every projected shadow /\ A's shadow and overlaps every other -- / \ B's shadow on no gap exists anywhere this axis

SAT ในโค้ด: ฉายกล่องสองใบลงบนแกน

นี่คือทฤษฎีนั้นในรูปแบบการเทสต์ที่รันได้จริง สำหรับแต่ละแกนที่เป็นไปได้ (edge normal ทุกอันของรูปทรงทั้งสอง) ให้ ฉาย polygon ทั้งสองลงบนมัน — ย่อแต่ละอันให้เหลือแค่เงา [min, max] ที่มันทอดลงบนแกนนั้น — แล้วเช็กว่าเงาทับกันไหม พอมีแกนใดแกนหนึ่งแสดงช่องว่าง ให้หยุดเลย: รูปทรงแยกกันอยู่ ถ้าทุกแกนทับกันหมด แสดงว่าชนกัน และแกนที่มีช่วงทับน้อยที่สุดคือ collision normal ส่วนช่วงทับนั้นคือ penetration depth:

#include <iostream>
#include <cmath>
#include <vector>

struct Vec2 { float x, y; };
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

// project every vertex of `poly` onto `axis`, return the [min,max] shadow
void projectOnto(const std::vector<Vec2>& poly, Vec2 axis, float& mn, float& mx) {
    mn = mx = dot(poly[0], axis);
    for (const Vec2& v : poly) {
        float p = dot(v, axis);
        if (p < mn) mn = p;
        if (p > mx) mx = p;
    }
}

bool satOverlap(const std::vector<Vec2>& A, const std::vector<Vec2>& B,
                Vec2& normal, float& depth) {
    depth = 1e30f;
    for (int poly = 0; poly < 2; poly++) {               // axes from BOTH shapes
        const std::vector<Vec2>& P = (poly == 0) ? A : B;
        for (size_t i = 0; i < P.size(); i++) {
            Vec2 a = P[i], b = P[(i + 1) % P.size()];
            Vec2 edge = { b.x - a.x, b.y - a.y };
            Vec2 axis = { -edge.y, edge.x };             // edge normal (perpendicular)
            float len = std::sqrt(axis.x * axis.x + axis.y * axis.y);
            axis = { axis.x / len, axis.y / len };
            float minA, maxA, minB, maxB;
            projectOnto(A, axis, minA, maxA);
            projectOnto(B, axis, minB, maxB);
            float overlap = std::min(maxA, maxB) - std::max(minA, minB);
            if (overlap <= 0.0f) return false;           // a gap -> separated, done
            if (overlap < depth) { depth = overlap; normal = axis; }
        }
    }
    return true;
}

void test(const char* name, std::vector<Vec2> A, std::vector<Vec2> B) {
    Vec2 n; float d;
    if (satOverlap(A, B, n, d))
        std::cout << name << ": COLLIDING  normal=(" << n.x << ", " << n.y
                  << ")  depth=" << d << "\n";
    else
        std::cout << name << ": separated (a gap axis was found)\n";
}

int main() {
    std::vector<Vec2> A = {{0,0},{2,0},{2,2},{0,2}};                 // axis-aligned square
    std::vector<Vec2> Bhit  = {{2.8f,0},{3.8f,1},{2.8f,2},{1.8f,1}}; // diamond, overlaps A
    std::vector<Vec2> Bmiss = {{4.2f,0},{5.2f,1},{4.2f,2},{3.2f,1}}; // diamond, clears A
    test("overlapping", A, Bhit);
    test("separated  ", A, Bmiss);
}

ผลลัพธ์:

overlapping: COLLIDING  normal=(-1, 0)  depth=0.2
separated  : separated (a gap axis was found)

กล่องใบที่สองเป็นสี่เหลี่ยมข้าวหลามตัด (สี่เหลี่ยมจัตุรัสหมุน 45 องศา) นี่เลยเป็น SAT ของจริง ไม่ใช่การเช็ก AABB ที่ปลอมตัวมา — การฉายลงบน edge normal เอียง ๆ ของข้าวหลามตัดคืองานจริง ๆ ในเคสที่ทับกัน สี่เหลี่ยม A กินช่วง x เป็น [0, 2] ส่วนปลายซ้ายสุดของข้าวหลามตัดอยู่ที่ x=1.8 เงาของมันบนแกน x เลยทับกันแค่ 0.2 — น้อยกว่าช่วงทับบนแกนอื่น ๆ ที่ลองมาทั้งหมด แกนนั้นเลยชนะเป็น collision normal โดยมี penetration 0.2 (เครื่องหมายของ normal ขึ้นอยู่กับว่าขอบไหนสร้างมันขึ้นมา engine จริง ๆ จะกลับด้านให้ชี้จาก A ไปทาง B เสมอ ที่นี่มันเลยจะรายงานเป็น (1, 0)) ส่วนในเคสที่แยกกัน ข้าวหลามตัดถูกเลื่อนไปทางขวาจนปลายของมันพ้นขอบ A แล้วแกนแรกที่แสดงช่องว่างก็ทำให้ satOverlap คืนค่าออกไปเลยทันที — การออกก่อนกำหนดนี่แหละที่ทำให้ SAT ไวในเคสทั่วไปที่ไม่ได้แตะกัน

รูปทรง convex ทั่วไป: GJK

SAT ใช้ได้ดีกับ polygon เพราะ "ลองทุก edge normal" เป็นลิสต์แกนที่สั้นและรู้ล่วงหน้าได้ แต่สำหรับรูปทรง convex ทั่วไป — capsule โค้งมน หรือ convex mesh แบบใดก็ได้ — ไม่มีลิสต์แกนตายตัวสั้น ๆ ให้ลอง คำตอบมาตรฐานคือ GJK algorithm (Gilbert-Johnson-Keerthi ตั้งชื่อตามผู้เขียนทั้งสามคน) ซึ่งเลี่ยงปัญหาการค้นหาแกนไปเลย GJK ทำงานบน Minkowski difference ของรูปทรงสองอัน (พูดง่าย ๆ คือ "รูปทรง A ลบรูปทรง B" สร้างขึ้นโดยเอาทุกจุดของ B ไปลบออกจากทุกจุดของ A) — ข้อเท็จจริงที่สวยงามของโครงสร้างนี้คือ รูปทรงต้นฉบับทั้งสองจะทับกันก็ต่อเมื่อรูปทรงที่รวมกันนี้มีจุด origin (0,0) อยู่ข้างในเท่านั้น

shape A shape B Minkowski difference (A - B) /\ __ __________ / \ / \ / \ /____\ /____\ / origin? \ / (0,0) here? \ \________________/ A and B overlap in the real world <=> (0,0) is INSIDE (A - B)

GJK ไม่จำเป็นต้องสร้างรูปทรง Minkowski difference เต็มรูปแบบเลย (ซึ่งอาจใหญ่มาก) แต่จะใช้ support function แทน — "ขอจุดของรูปทรงนี้ที่อยู่ไกลที่สุดในทิศทาง D" — เพื่อค่อย ๆ ขยายสามเหลี่ยมเล็ก ๆ (เรียกว่า simplex) ไปทีละขั้น แต่ละครั้งจะเลือกทิศทางใหม่ที่ทำให้ simplex เข้าใกล้การล้อมรอบจุด origin มากขึ้น ถ้า simplex ล้อมรอบ origin ได้เมื่อไหร่ แสดงว่ารูปทรงทับกัน แต่ถ้าทิศทางการค้นหาชี้ออกจาก origin โดยไม่มีทางเข้าใกล้ได้อีก แสดงว่าไม่ทับกัน อัลกอริทึมเต็มรูปแบบนั้น implement ให้ถูกต้องยากจริง ๆ ซึ่งเป็นเหตุผลที่แทบไม่มีใคร hand-write มันเอง — PhysX backend ของ Unity, Chaos physics ของ Unreal และ physics engine ทั่วไปแทบทุกตัวมากับ GJK ที่ผ่านการพิสูจน์มาแล้ว (มักจะจับคู่กับอัลกอริทึมที่เกี่ยวข้องอย่าง EPA เพื่อหา penetration depth หลังจาก GJK ยืนยันว่าทับกัน) สิ่งที่สำคัญสำหรับเราคือจำชื่อนี้ให้ได้และรู้ว่ามันแก้ปัญหาอะไร: การเทสต์การทับกันแบบละเอียดสำหรับรูปทรง convex ทั่วไป ในกรณีที่ไม่มีลิสต์แกนสั้น ๆ แบบ SAT ให้ใช้

Tip circle vs circle, circle vs box, และ box vs box พบได้บ่อยพอที่ engine ส่วนใหญ่จะแยกเคสพิเศษให้ด้วยสูตรตรง ๆ (แบบ circlesOverlap ในหัวข้อ 4) แทนที่จะส่งผ่าน GJK — มันไวกว่าถ้าข้ามกลไกทั่วไปไปเมื่อรู้ทางลัดอยู่แล้ว GJK คือตัวที่จัดการกับทุกอย่างที่เหลือ: capsule, convex hull, convex mesh แบบใดก็ได้

5. สร้าง contact: point, normal, penetration depth

คำตอบแบบ yes/no ว่า "ชนกันไหม" ยังไม่พอที่จะแก้ปัญหาการชนได้จริง — engine ต้องรู้ด้วยว่ารูปทรงแตะกันตรงไหนแน่ ๆ และต้องผลักแยกกันไปทางไหน เอาต์พุตจริงของ narrow phase หลังจากยืนยันว่าทับกันแล้ว คือ struct เล็ก ๆ ที่เรียกว่า contact (หรือ manifold เมื่อคู่เดียวสร้าง contact point ได้มากกว่าหนึ่งจุด เช่น กล่องที่วางราบอยู่บนพื้น):

A B ***** ***** * * * * * o----+-- normal --+----o * normal: points from A toward B * *| |* * point: roughly where the surfaces meet ***** |<--penetr->| ***** penetration: how far they overlap | -ation | | (overlap)|

สำหรับวงกลมสองวง ทั้งสามอย่างนี้ได้มาตรงจากการเทสต์แบบละเอียดในหัวข้อ 4 เลย — เส้นจากศูนย์กลางถึงศูนย์กลางก็คือทิศทาง normal อยู่แล้ว:

#include <iostream>
#include <cmath>

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 }; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }

struct Contact {
    Vec2 point;
    Vec2 normal;       // points from A toward B
    float penetration;
};

Contact makeCircleContact(Vec2 posA, float rA, Vec2 posB, float rB) {
    Vec2 delta = sub(posB, posA);
    float dist = length(delta);
    Vec2 normal = scale(delta, 1.0f / dist);

    Vec2 pointOnA = add(posA, scale(normal, rA));
    Vec2 pointOnB = sub(posB, scale(normal, rB));
    Vec2 midpoint = scale(add(pointOnA, pointOnB), 0.5f);

    float penetration = (rA + rB) - dist;
    return { midpoint, normal, penetration };
}

int main() {
    Contact c = makeCircleContact({0.0f, 0.0f}, 1.0f, {1.7f, 0.0f}, 1.0f);
    std::cout << "point=(" << c.point.x << ", " << c.point.y << ")\n";
    std::cout << "normal=(" << c.normal.x << ", " << c.normal.y << ")\n";
    std::cout << "penetration=" << c.penetration << "\n";
}

ผลลัพธ์:

point=(0.85, 0)
normal=(1, 0)
penetration=0.3

A (ศูนย์กลางอยู่ที่ origin) กับ B (ศูนย์กลางอยู่ทางขวา 1.7 หน่วย) ทับกันอยู่ 0.3 หน่วย contact จะอยู่ตรงกึ่งกลางของช่วงที่ทับกันนั้น และ normal ก็ชี้ตรงจาก A ไปทาง B ทุกหัวข้อที่เหลือในบทนี้ทำงานอยู่บน struct เล็ก ๆ ตัวนี้ทั้งหมด — มันคือจุดส่งต่อระหว่าง "ชนกันไหม" (หัวข้อ 1-4) กับ "แล้วเราจะทำยังไงกับมัน" (หัวข้อ 6 เป็นต้นไป)

6. แก้ penetration: positional correction

ถ้าปล่อยไว้เฉย ๆ วัตถุสองชิ้นที่ทับกันก็จะติดค้างอยู่ในกันและกันแบบนั้น Positional correction จะขยับวัตถุสองชิ้นให้แยกออกจากกันตรง ๆ ตามแนว contact normal ให้พอดีกับที่ทำให้ penetration หายไป ว่าวัตถุไหนจะขยับมากแค่ไหนก็ใช้ตรรกะ invMass เดิมจากหัวข้อ 1: วัตถุที่มี inverse mass มากกว่า (เบากว่า ผลักง่ายกว่า) จะถูกผลักไปไกลกว่า ส่วนวัตถุ static (invMass = 0) จะไม่ขยับเลย และวัตถุอีกตัวจะรับ correction ไปเต็ม ๆ:

#include <iostream>

struct Vec2 { float x, y; };
struct RigidBody { Vec2 position; float invMass; };
struct Contact { Vec2 point; Vec2 normal; float penetration; };

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 }; }

void correctPenetration(RigidBody& a, RigidBody& b, Contact c) {
    float totalInvMass = a.invMass + b.invMass;
    if (totalInvMass <= 0.0f) return;   // both static -- nothing can move

    float shareA = a.invMass / totalInvMass;
    float shareB = b.invMass / totalInvMass;

    Vec2 correction = scale(c.normal, c.penetration);
    a.position = sub(a.position, scale(correction, shareA));
    b.position = add(b.position, scale(correction, shareB));
}

int main() {
    RigidBody a = { {0.0f, 0.0f}, 0.5f };   // mass 2 -> invMass 0.5
    RigidBody b = { {1.7f, 0.0f}, 1.0f };   // mass 1 -> invMass 1.0
    Contact c = { {0.85f, 0.0f}, {1.0f, 0.0f}, 0.3f };

    correctPenetration(a, b, c);

    std::cout << "a.position=(" << a.position.x << ", " << a.position.y << ")\n";
    std::cout << "b.position=(" << b.position.x << ", " << b.position.y << ")\n";
}

ผลลัพธ์:

a.position=(-0.1, 0)
b.position=(1.9, 0)

วัตถุ A (หนักเป็นสองเท่า inverse mass ครึ่งเดียว) ขยับไปทางซ้ายแค่ 0.1 หน่วย ส่วนวัตถุ B (เบากว่า inverse mass เต็ม) ขยับไปทางขวา 0.2 หน่วย — รวมกันแล้วครอบคลุม penetration เต็ม 0.3 พอดี แบ่งกันตามสัดส่วนว่าแต่ละตัว "ผลักง่าย" แค่ไหน ลองเช็กระยะห่างใหม่ระหว่างสองตัว: 1.9 - (-0.1) = 2.0 ซึ่งเท่ากับผลรวมของรัศมีพอดีเป๊ะ — การทับกันหายไปหมดแล้ว

Common mistake แก้ penetration 100% ทุกสเต็ปโดยไม่เผื่อไว้เลย มองแบบแยกเดี่ยว ๆ มันดูถูกต้อง (เดโมนี้ก็ทำแบบนั้นเป๊ะ และก็โอเคสำหรับ contact เดียว) แต่ฉากจริง ๆ มี contact หลายอันแตะวัตถุตัวเดียวกันพร้อมกัน และหัวข้อ 9 จะโชว์ให้เห็นว่าการแก้ contact หนึ่งให้เต็มร้อยสามารถทำให้ penetration กลับมาที่อีก contact หนึ่งได้ engine จริง ๆ จะแก้แค่บางส่วนของ penetration ในแต่ละสเต็ป (ปกติ 20-80%) และยอมให้มีการทับกันเล็กน้อยที่เรียกว่า slop (สักไม่กี่ในพันหน่วย) แล้วค่อยแก้ที่เหลือในสเต็ปถัด ๆ ไป การแก้แบบดุดันเกินไปในทีเดียวเป็นสาเหตุทั่วไปที่ทำให้กองวัตถุ "pop" หรือสั่นให้เห็นชัด ๆ แทนที่จะนิ่งลงอย่างนุ่มนวล

Baumgarte: ดันออกผ่าน velocity solver แทน

การแก้ position ตรง ๆ แบบข้างบนใช้ได้ แต่มันไปสู้กับ velocity solver ที่กำลังจะมาในหัวข้อ 7 — เราขยับวัตถุไปทางหนึ่ง แล้วสเต็ป impulse ก็ขยับมันไปอีกทาง engine ส่วนใหญ่เลยเลือกรวมการแก้ penetration เข้าไปในการแก้ velocity ด้วยวิธี Baumgarte stabilization: เปลี่ยนการทับที่เหลือให้เป็นความเร็วแยกจากกันเพิ่มขึ้นนิดหน่อย เรียกว่า bias แล้วปล่อยให้ normal impulse เป็นตัวส่งมันไป bias คือ (beta / dt) * (penetration - slop) โดยที่ beta เป็นเศษส่วนเล็ก ๆ (ปกติ 0.1 ถึง 0.2) ที่กำหนดว่าจะปิดช่องว่างดุแค่ไหน:

#include <iostream>

int main() {
    float penetration = 0.30f;   // how deep the bodies overlap
    float slop        = 0.01f;   // permitted overlap left uncorrected
    float beta        = 0.20f;   // Baumgarte factor (fraction closed per step)
    float dt          = 1.0f / 60.0f;

    float correctable = penetration - slop;
    if (correctable < 0.0f) correctable = 0.0f;
    float biasVel = (beta / dt) * correctable;   // added to the target separating speed

    float pushThisStep = biasVel * dt;           // how far it moves them apart this step
    std::cout << "penetration      = " << penetration << "\n";
    std::cout << "bias velocity    = " << biasVel << " units/s\n";
    std::cout << "push-out this dt = " << pushThisStep << "\n";
    std::cout << "= beta*(pen-slop)= " << beta * correctable << "  (same number)\n";
}

ผลลัพธ์:

penetration      = 0.3
bias velocity    = 3.48 units/s
push-out this dt = 0.058
= beta*(pen-slop)= 0.058  (same number)

จุดสำคัญอยู่ในสองบรรทัดสุดท้าย: การดันวัตถุแยกออกจากกันด้วย bias velocity 3.48 เป็นเวลาหนึ่งสเต็ป ขยับมันไป 0.058 — ซึ่งเท่ากับ beta * (penetration - slop) = 0.2 * 0.29 พอดี ตัว /dt ใน bias กับ *dt ของหนึ่งสเต็ป integration ตัดกันพอดี beta เลยเป็นแค่ "จะกำจัดการทับกันกี่เศษส่วนต่อสเต็ป" — ไอเดีย 20% ต่อสเต็ปเดียวกับ split positional correction ข้างบน แค่ส่งมาในรูปของความเร็วที่ impulse solver เดิมรู้จักวิธีใช้อยู่แล้ว นั่นคือเหตุผลที่มันเป็นที่นิยม: ไม่ต้องมีสเต็ปแก้ position แยก solver ตัวเดียวทำงานทั้งสองอย่าง

Common mistake ตั้ง beta สูงเกินไป (เช่น 0.8 เพื่อ "ปิดช่องว่างให้ไว") เพราะ bias เป็นความเร็วจริงที่เพิ่มเข้าไปในวัตถุ ส่วนที่เหลือของมันหลังจากรูปทรงแยกกันแล้วจะกลายเป็น momentum จริงที่วัตถุเก็บไว้ — กองวัตถุจะได้พลังงานเพิ่มแล้วเด้งหรือสั่น ซึ่งเป็นอาการ pop ที่วิธีนี้ควรจะป้องกันพอดี การตั้ง beta ให้ต่ำ (0.1 ถึง 0.2) คือวิธีแก้ทั่วไป ส่วนวิธีที่ครบถ้วนกว่าที่ Box2D และตัวอื่น ๆ ใช้คือ split impulse (หรือ position projection): ใช้ bias บน "pseudo-velocity" ที่ทิ้งได้ต่างหาก ซึ่งดันรูปทรงให้แยกกันเพื่อจัดตำแหน่ง แต่ถูกโยนทิ้งก่อนที่มันจะป้อนกลับเข้าไปใน momentum จริง

7. แก้ velocity: impulse กับ restitution

การแก้ position ทำให้วัตถุหยุดทับกัน แต่ไม่ได้บอกอะไรเลยว่าหลังชนแล้วมันควรจะเคลื่อนที่ยังไง — ลูกบอลควรจะเด้ง ไม่ใช่แค่เทเลพอร์ตออกจากพื้นแล้วตกทะลุมันต่อในสเต็ปถัดไป นี่คืองานของ impulse: การเปลี่ยน velocity แบบทันทีทันใด ใช้ตรง ๆ (ไม่ค่อย ๆ สะสมแบบที่แรงที่กระทำตลอดช่วงเวลาทำ) ตามแนว contact normal โดยเฉพาะ

ส่วนประกอบสำคัญคือ relative velocity ตามแนว normal — วัตถุสองชิ้นเข้าใกล้กันเร็วแค่ไหน วัดตามทิศทางที่มันกำลังจะแยกออกจากกัน ถ้าค่านี้เป็นศูนย์หรือบวกอยู่แล้ว แสดงว่ามันกำลังแยกออกจากกัน (หรือแค่แตะกันพอดี) เองอยู่แล้ว ไม่ต้องใช้ impulse เลย แต่ถ้าเป็นลบ แสดงว่ามันกำลังเข้าใกล้กัน impulse ต้องยกเลิกการเข้าใกล้นั้น และขึ้นอยู่กับ restitution (ค่าสัมประสิทธิ์ความเด้ง ตั้งแต่ 0 = วัตถุติดกัน "inelastic เต็มที่" ไปจนถึง 1 = เด้งแบบ elastic สมบูรณ์ไม่มีพลังงานสูญเสีย) ส่งมันแยกออกจากกันอีกครั้ง:

BEFORE the impulse AFTER the impulse A --vA--> <--vB-- B A <--vA'-- --vB'--> B (approaching along normal) (separating along normal, scaled by restitution e) restitution e = 0 -> they stick together, no bounce restitution e = 1 -> perfectly elastic, bounces back just as fast

สูตรขนาดของ impulse (เป็นผลลัพธ์มาตรฐาน ได้มาจากการกำหนดให้ momentum ต้องอนุรักษ์ และความเร็วที่แยกออกจากกันหลังชนต้องเท่ากับ restitution * approaching speed) คือ:

j = -(1 + restitution) * velocityAlongNormal / (invMassA + invMassB) impulse vector = j * normal velocityA -= impulse * invMassA velocityB += impulse * invMassB
#include <iostream>

struct Vec2 { float x, y; };
struct RigidBody { Vec2 velocity; float invMass; };
struct Contact { Vec2 normal; };

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 }; }
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

float resolveVelocity(RigidBody& a, RigidBody& b, Contact c, float restitution) {
    Vec2 relVel = sub(b.velocity, a.velocity);
    float velAlongNormal = dot(relVel, c.normal);

    if (velAlongNormal >= 0.0f) return 0.0f;   // already separating -- nothing to do

    float totalInvMass = a.invMass + b.invMass;
    float j = -(1.0f + restitution) * velAlongNormal / totalInvMass;

    Vec2 impulse = scale(c.normal, j);
    a.velocity = sub(a.velocity, scale(impulse, a.invMass));
    b.velocity = add(b.velocity, scale(impulse, b.invMass));
    return j;
}

int main() {
    RigidBody a = { {4.0f, 0.0f}, 0.5f };    // mass 2
    RigidBody b = { {-2.0f, 0.0f}, 1.0f };   // mass 1
    Contact c = { {1.0f, 0.0f} };

    float momentumBefore = (1.0f / a.invMass) * a.velocity.x + (1.0f / b.invMass) * b.velocity.x;
    float j = resolveVelocity(a, b, c, 0.5f);
    float momentumAfter = (1.0f / a.invMass) * a.velocity.x + (1.0f / b.invMass) * b.velocity.x;

    std::cout << "impulse j=" << j << "\n";
    std::cout << "a.velocity=(" << a.velocity.x << ", " << a.velocity.y << ")\n";
    std::cout << "b.velocity=(" << b.velocity.x << ", " << b.velocity.y << ")\n";
    std::cout << "momentum before=" << momentumBefore << " after=" << momentumAfter << "\n";
}

ผลลัพธ์:

impulse j=6
a.velocity=(1, 0)
b.velocity=(4, 0)
momentum before=6 after=6

วัตถุ A (มวล 2) กำลังเคลื่อนที่ไปทางขวาด้วยความเร็ว 4 วัตถุ B (มวล 1) กำลังเคลื่อนที่ไปทางซ้ายด้วยความเร็ว 2 — มันกำลังปิดช่องว่างกันด้วยความเร็ว 6 หน่วยต่อวินาที หลังจาก impulse แล้ว A ช้าลงเหลือ 1 และ B เร็วขึ้นเป็น 4 ตอนนี้มันแยกออกจากกันด้วยความเร็ว 3 หน่วยต่อวินาที ซึ่งเท่ากับครึ่งหนึ่งของความเร็วที่เข้าใกล้กันพอดี ตรงกับ restitution 0.5 momentum (mass * velocity รวมทั้งสองวัตถุ) อ่านได้ 6 ทั้งก่อนและหลัง — เป็นกฎฟิสิกส์จริง ๆ ที่ได้มาฟรี ๆ จากสูตร ไม่ใช่สิ่งที่โค้ดบังคับไว้ตรง ๆ ซึ่งเป็นสัญญาณที่ดีว่าสูตรถูกต้อง

8. Friction: impulse ตามแนวพื้นผิว

impulse ในหัวข้อ 7 แตะแค่ velocity ตามแนว normal เท่านั้น — ไม่ได้พูดถึงการไถลด้านข้างเลย Friction คือ impulse ตัวที่สอง ใช้ตามแนวพื้นผิว (ทิศทาง tangent ซึ่งตั้งฉากกับ normal) ที่ต้านการไถลด้านข้างนั้น friction จริง ๆ เดินตามกฎประมาณที่เรียกว่า Coulomb friction: แรงด้านข้างสูงสุดที่มีได้จะแปรผันตรงกับแรงที่พื้นผิวถูกกดเข้าหากัน จำกัดด้วย friction coefficient (มักเขียนเป็น mu ตัวอักษรกรีก) พูดให้เป็นรูปธรรม: คำนวณ impulse ที่จะยกเลิกความเร็วสัมพัทธ์ด้านข้างได้ เต็มที่ แล้ว clamp ขนาดของมันไว้ที่ mu * normalImpulse — ถ้าการหยุดเต็มที่ต้องการมากกว่านั้น พื้นผิวจะไถล (kinetic friction) ถ้าไม่ ก็จะยึดติดกันสนิท:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };
struct RigidBody { Vec2 velocity; float invMass; };
struct Contact { Vec2 normal; };

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 }; }
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); }

void applyFriction(RigidBody& a, RigidBody& b, Contact c, float normalImpulse, float friction) {
    Vec2 relVel = sub(b.velocity, a.velocity);

    // build the tangent: the part of relVel that is NOT along the normal
    float velAlongNormal = dot(relVel, c.normal);
    Vec2 tangentRaw = sub(relVel, scale(c.normal, velAlongNormal));
    float tangentLen = length(tangentRaw);
    if (tangentLen < 0.0001f) return;   // no sideways motion to resist
    Vec2 tangent = scale(tangentRaw, 1.0f / tangentLen);

    float totalInvMass = a.invMass + b.invMass;
    float velAlongTangent = dot(relVel, tangent);
    float jt = -velAlongTangent / totalInvMass;   // impulse to fully stop sliding

    float maxFriction = friction * normalImpulse;   // Coulomb's clamp
    if (jt > maxFriction) jt = maxFriction;
    if (jt < -maxFriction) jt = -maxFriction;

    Vec2 frictionImpulse = scale(tangent, jt);
    a.velocity = sub(a.velocity, scale(frictionImpulse, a.invMass));
    b.velocity = add(b.velocity, scale(frictionImpulse, b.invMass));
}

int main() {
    RigidBody floorBody = { {0.0f, 0.0f}, 0.0f };    // static
    RigidBody box       = { {3.0f, -2.0f}, 1.0f };   // sliding in and down
    Contact c = { {0.0f, 1.0f} };                    // normal points up

    // step 1: resolve the normal impulse (section 7's formula, restitution 0.2)
    Vec2 relVel = sub(box.velocity, floorBody.velocity);
    float velAlongNormal = dot(relVel, c.normal);
    float totalInvMass = floorBody.invMass + box.invMass;
    float j = -(1.0f + 0.2f) * velAlongNormal / totalInvMass;
    box.velocity = add(box.velocity, scale(c.normal, j * box.invMass));
    std::cout << "after normal impulse: box.velocity=(" << box.velocity.x << ", " << box.velocity.y << ")\n";

    // step 2: friction, clamped by mu = 0.5
    applyFriction(floorBody, box, c, j, 0.5f);
    std::cout << "after friction:       box.velocity=(" << box.velocity.x << ", " << box.velocity.y << ")\n";
}

ผลลัพธ์:

after normal impulse: box.velocity=(3, 0.4)
after friction:       box.velocity=(1.8, 0.4)

กล่องกระแทกพื้นด้วยความเร็วไปทางขวา 3 และลง 2 normal impulse (restitution 0.2) หยุดการเคลื่อนที่ลงและทำให้เด้งขึ้นเล็กน้อย 0.4 แต่ไม่แตะความเร็วด้านข้างเต็ม ๆ 3 เลย จากนั้น friction จะพยายามยกเลิกความเร็วด้านข้างนั้นให้เต็มที่ — ซึ่งต้องการ impulse ขนาด 3 — แต่ Coulomb clamp อนุญาตแค่ 0.5 * 2.4 = 1.2 เท่านั้น กล่องเลยยังไถลต่อ แค่ช้าลง: จาก 3 เหลือ 1.8 ถ้า friction coefficient สูงพอที่จะครอบคลุมทั้ง 3 กล่องก็จะหยุดไถลสนิทในสเต็ปเดียวแทน — clamp นี่แหละคือเส้นแบ่งพอดีระหว่าง "ยึดติด" กับ "ไถล"

Tip engine จริง ๆ มักเก็บ staticFriction (ต้านการเริ่มไถลจากท่านิ่ง) แยกจาก dynamicFriction (ต้านการไถลที่กำลังเกิดขึ้นอยู่แล้ว) เพราะในความเป็นจริงมักต้องใช้แรงมากกว่าในการเริ่มให้อะไรไถล เทียบกับการทำให้มันไถลต่อไป เวอร์ชันที่ทำให้ง่ายด้วย mu ตัวเดียวข้างบนนี้ก็คือไอเดียหลักตัวเดียวกัน แค่ตัดความแตกต่างนั้นออกเพื่อความง่ายในการอธิบาย

9. การซ้อนกันและทำไม solver ต้อง iterate

contact เดียวแก้ได้เกลี้ยงในรอบเดียว ตามที่หัวข้อ 6 กับ 7 เพิ่งโชว์ไป แต่ฉากจริง ๆ แทบไม่มีแค่ contact เดียวหรอก — กล่องที่วางอยู่บนพื้นแล้วมีอีกกล่องวางซ้อนอยู่ข้างบน มี contact สองอันที่ใช้วัตถุร่วมกันตัวหนึ่ง (กล่องตรงกลาง) และการแก้ contact หนึ่งอาจทำให้อีก contact หนึ่งพังไปแบบเงียบ ๆ ได้ ลองดูให้เห็นภาพ: พื้น static, box2 วางอยู่บนมัน (จมลงไป 0.3), box3 วางอยู่บน box2 (จมลงไป 0.3 เหมือนกัน) แต่ละกล่องสูงพอดี 1 หน่วย:

#include <iostream>

struct Box { float bottom; float invMass; };   // 1D vertical stack; height is always 1

void resolveContact(Box& lower, Box& upper) {
    float lowerTop = lower.bottom + 1.0f;
    float penetration = lowerTop - upper.bottom;
    if (penetration <= 0.0f) return;

    float totalInv = lower.invMass + upper.invMass;
    if (totalInv <= 0.0f) return;

    float shareLower = lower.invMass / totalInv;
    float shareUpper = upper.invMass / totalInv;
    lower.bottom -= penetration * shareLower;
    upper.bottom += penetration * shareUpper;
}

int main() {
    Box floorBox = { 0.0f, 0.0f };   // static: top always at 1.0
    Box box2     = { 0.7f, 1.0f };   // sunk 0.3 into the floor
    Box box3     = { 1.4f, 1.0f };   // sunk 0.3 into box2

    for (int iter = 1; iter <= 3; iter++) {
        resolveContact(floorBox, box2);   // contact A: floor - box2
        resolveContact(box2, box3);       // contact B: box2 - box3

        float penA = (floorBox.bottom + 1.0f) - box2.bottom;
        float penB = (box2.bottom + 1.0f) - box3.bottom;
        std::cout << "iteration " << iter << ": penetrationA=" << penA
                   << " penetrationB=" << penB << "\n";
    }
}

ผลลัพธ์:

iteration 1: penetrationA=0.3 penetrationB=0
iteration 2: penetrationA=0.15 penetrationB=0
iteration 3: penetrationA=0.075 penetrationB=0

ลองไล่ดูว่าเกิดอะไรขึ้นใน iteration 1: การแก้ contact A (floor-box2) ผลัก box2 ขึ้นเต็ม ๆ 0.3 ซึ่งดัน box2 ให้จมลึกเข้าไปใน box3 มากขึ้น — penetration ระหว่าง box2-box3 กระโดดจาก 0.3 เป็น 0.6 จากนั้นการแก้ contact B จะแบ่งช่องว่าง 0.6 นั้น 50/50 ระหว่าง box2 กับ box3 ซึ่งดึง box2 กลับลงมา 0.3 — กลับไปที่จุดเริ่มต้นเป๊ะ ทำให้ contact A เปิดขึ้นมาใหม่ หลังจากผ่านไปหนึ่งรอบเต็ม ๆ contact A ก็พังพอ ๆ กับตอนที่สเต็ปเริ่มต้น ต้องประมวลผลทั้งสอง contact อีกครั้ง (iteration 2) contact A ถึงจะดีขึ้น — penetration ลดลงครึ่งหนึ่งเหลือ 0.15 แล้วลดลงครึ่งอีกทีเหลือ 0.075 ใน iteration 3 และจะลดลงครึ่งไปเรื่อย ๆ แบบนี้ตลอดไปโดยไม่มีทางถึงศูนย์เป๊ะในจำนวนสเต็ปจำกัด

iteration 1: floor--[box2] [box2]--box3 still 0.3 gap push box2 up 0.3 pulls box2 back down at floor-box2 (fixes A, breaks B) 0.3 to fix B (right back where it started) iteration 2: push box2 up 0.15 pulls box2 back 0.075 0.15 gap (fixes A again) (fixes B again) shrinking each pass gets the whole stack a little closer to a state where EVERY contact is satisfied AT ONCE -- which is exactly why solvers run several passes ("iterations") instead of stopping after one

นี่คือเหตุผลที่ physics engine ถูกเรียกว่า iterative solver: การไล่ผ่านทุก contact แค่รอบเดียว (แพทเทิร์นนี้เรียกว่า sequential impulse เมื่อใช้กับ velocity และในทางคณิตศาสตร์มันคือรูปแบบหนึ่งของวิธี Gauss-Seidel) แทบไม่เคยทำให้ทุก contact พอใจพร้อมกันได้เลย ในตอนที่มี contact สองอันขึ้นไปใช้วัตถุร่วมกัน การรันหลาย ๆ รอบ — สัก 4 ถึง 10 iteration โดยทั่วไป — จะทำให้ทั้งระบบลู่เข้าใกล้สถานะที่ "ทุก contact พอใจพร้อมกัน" มากพอที่จะดูและรู้สึกแน่นหนา ยิ่ง iteration เยอะ กองวัตถุก็จะแน่นและแม่นยำขึ้นแต่แลกมาด้วย CPU ที่มากขึ้นต่อสเต็ป iteration น้อยจะถูกกว่าแต่ทำให้กองวัตถุจมลงเห็นได้ชัด สั่น หรือรู้สึกยวบ ๆ นิดหน่อย นี่คือ trade-off จริง ๆ ที่ปรับได้ ไม่ใช่บั๊กที่แก้ครั้งเดียวแล้วจบ — ซึ่งเป็นเหตุผลที่ engine อย่าง Unity เปิดให้ปรับจำนวน iteration เป็นค่าตั้งค่าแทนที่จะฝังตายตัวไว้ในโค้ด

10. Continuous collision detection: หยุด tunneling

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

#include <iostream>

struct Wall { float xStart, xEnd; };

bool discreteHitsWall(float x, Wall w) {
    return x >= w.xStart && x <= w.xEnd;
}

int main() {
    Wall wall = { 5.0f, 5.1f };   // a thin wall, only 0.1 units thick
    float x0 = 4.5f;              // position at the START of this step
    float speed = 80.0f;
    float dt = 0.02f;
    float x1 = x0 + speed * dt;   // position at the END of this step

    std::cout << "x0=" << x0 << " x1=" << x1 << "\n";
    std::cout << "discrete check at x0: " << (discreteHitsWall(x0, wall) ? "HIT" : "clear") << "\n";
    std::cout << "discrete check at x1: " << (discreteHitsWall(x1, wall) ? "HIT" : "clear") << "\n";
}

ผลลัพธ์:

x0=4.5 x1=6.1
discrete check at x0: clear
discrete check at x1: clear

ลูกบอลเคลื่อนที่ไป 1.6 หน่วยในสเต็ปนี้ (80 * 0.02) แต่กำแพงหนาแค่ 0.1 หน่วย — ลูกบอลกระโดดข้ามความกว้างทั้งหมดของกำแพงไปเลยระหว่างแซมเปิลหนึ่งกับอีกแซมเปิลหนึ่ง แล้วการเช็กแบบ discrete ทั้งสองครั้งก็รายงานว่า "clear" ลูกบอล tunnel ทะลุไปโดยไม่มีใครสังเกตเห็นเลย

discrete check ONLY looks at x0 and x1 -- misses the wall entirely: x0=4.5 WALL x1=6.1 o -------------------- [||] ------------------------> o 5.0 5.1 (checked here, clear) (checked here, clear -- but it passed straight through!)

Continuous collision detection (CCD) แก้ปัญหานี้ด้วยการเทสต์เส้นทางทั้งหมดที่วัตถุกวาดผ่านไปในสเต็ปนี้ ไม่ใช่แค่จุดปลายสองจุด — เรียกว่า swept test เวอร์ชันง่ายที่สุดจะมองการเคลื่อนที่เป็นเส้นตรงจากตำแหน่งเริ่มต้นไปยังตำแหน่งสุดท้าย แล้วหาว่าเส้นนั้นตัดกำแพงเป็นครั้งแรกตรงไหน:

#include <iostream>

struct Wall { float xStart, xEnd; };

bool sweptHitsWall(float x0, float x1, Wall w, float& hitT, float& hitX) {
    if (x0 >= w.xStart) return false;   // already at/past the wall's near face
    if (x1 < w.xStart) return false;    // never reached the wall at all

    hitT = (w.xStart - x0) / (x1 - x0);
    hitX = x0 + hitT * (x1 - x0);
    return true;
}

int main() {
    Wall wall = { 5.0f, 5.1f };
    float x0 = 4.5f, x1 = 6.1f;

    float hitT, hitX;
    if (sweptHitsWall(x0, x1, wall, hitT, hitX)) {
        std::cout << "swept test: HIT at t=" << hitT << ", x=" << hitX << "\n";
    } else {
        std::cout << "swept test: clear\n";
    }
}

ผลลัพธ์:

swept test: HIT at t=0.3125, x=5

swept test จับสิ่งที่สแนปช็อต discrete ทั้งสองตัวพลาดไปได้พอดี: ลูกบอลตัดผ่านหน้ากำแพงด้านใกล้ที่ 31.25% ของระยะการเคลื่อนที่ในสเต็ปนี้ ที่ตำแหน่ง x = 5.0 engine จริง ๆ จะตอบสนองด้วยการหยุดวัตถุตรงนั้นเลย (หรือแก้ collision เต็มรูปแบบตรงจุดนั้น) แทนที่จะปล่อยให้มันไปต่อถึง x = 6.1 การ implement CCD จริง ๆ ไปไกลกว่าการเทสต์เส้นตรงง่าย ๆ แบบนี้อีก — conservative advancement จะขยาย swept segment ด้วยรัศมีของรูปทรงที่เคลื่อนที่เอง เพื่อให้ทรงกลมความเร็วสูง ไม่ใช่แค่จุดความเร็วสูง ถูกจับได้อย่างถูกต้อง และบาง engine ก็ใช้ speculative contacts แทน ซึ่งจะทำนาย collision ที่กำลังจะเกิดล่วงหน้าหนึ่งสเต็ป แล้วเริ่มแก้มันตั้งแต่ก่อนที่รูปทรงจะแตะกันจริง ๆ ไม่ว่าจะแบบไหน ไอเดียหลักก็เหมือนเดิม: มองเส้นทางทั้งหมดที่กวาดผ่านในสเต็ปนี้ ไม่ใช่แค่จุดปลายสองจุด

Common mistake เปิด CCD ให้กับทุกวัตถุ "เพื่อความปลอดภัย" swept test มีต้นทุนสูงกว่าการเช็กแบบ discrete อย่างเห็นได้ชัด และวัตถุส่วนใหญ่ในฉาก — กล่องที่วางนิ่ง ตัวละครที่เดินช้า ๆ — ไม่มีทางเคลื่อนที่เร็วพอเทียบกับขนาดตัวมันเองจนเกิด tunnel ได้ engine คาดหวังให้เราเปิด CCD แบบเลือกเฉพาะ ให้กับวัตถุที่มีความเสี่ยงจริง ๆ เท่านั้น: กระสุนปืน อาวุธที่ถูกขว้าง อะไรก็ตามที่เร็วและเล็กอยู่ใกล้ ๆ กับอะไรก็ตามที่บาง

11. Contact ที่นิ่ง: jitter, restitution, และ sleeping

กล่องที่หล่นลงพื้นควรจะ — หลังจากเด้งหนึ่งหรือสองครั้ง — วางนิ่งอยู่ตรงนั้น นิ่งสนิท ไม่กินทรัพยากรอะไรเลย การทำให้วัตถุมาหยุดนิ่งจริง ๆเป็นปัญหาเล็ก ๆ ของตัวมันเอง เพราะ loop แบบง่าย ๆ จากหัวข้อก่อน ๆ ไม่เคยยอมให้มันนิ่งได้จริง แรงโน้มถ่วงดึงกล่องที่วางนิ่งลงมานิดเดียวทุกสเต็ป contact ก็รับมันไว้ แล้ว restitution ในหัวข้อ 7 ก็เด้งเจ้านิดเดียวนั้นกลับขึ้นไปอย่างซื่อสัตย์ ผลลัพธ์คือวัตถุที่ไม่เคยหยุดกระตุกเลย:

#include <iostream>

// one step of a ball above a floor at y=0: semi-implicit Euler, then a bounce.
// `threshold`: approach speeds slower than this get restitution 0 (no bounce).
void step(float& y, float& v, float g, float dt, float e, float threshold) {
    v += g * dt;
    y += v * dt;
    if (y < 0.0f) {                 // hit the floor this step
        y = 0.0f;
        if (v < 0.0f) {
            float useE = (-v < threshold) ? 0.0f : e;   // slow contact -> no bounce
            v = -useE * v;
        }
    }
}

void run(const char* label, float threshold) {
    float y = 0.0f, v = 0.0f, g = -10.0f, dt = 1.0f / 60.0f, e = 0.5f;
    std::cout << label << "\n";
    for (int i = 1; i <= 6; i++) {
        step(y, v, g, dt, e, threshold);
        std::cout << "  step " << i << ": y=" << y << "  v=" << v << "\n";
    }
}

int main() {
    run("no threshold (restitution always on) -> never settles:", 0.0f);
    run("with threshold 1.0 -> settles to rest:", 1.0f);
}

ผลลัพธ์:

no threshold (restitution always on) -> never settles:
  step 1: y=0  v=0.0833333
  step 2: y=0  v=0.0416667
  step 3: y=0  v=0.0625
  step 4: y=0  v=0.0520833
  step 5: y=0  v=0.0572917
  step 6: y=0  v=0.0546875
with threshold 1.0 -> settles to rest:
  step 1: y=0  v=0
  step 2: y=0  v=0
  step 3: y=0  v=0
  step 4: y=0  v=0
  step 5: y=0  v=0
  step 6: y=0  v=0

เมื่อ restitution เปิดอยู่ตลอด (ด้านบน) velocity ไม่เคยถึงศูนย์ — มันล็อกอยู่ที่การสั่นถาวรรอบ ๆ +0.05 เพราะทุกสเต็ปแรงโน้มถ่วงป้อนความเร็วลงเข้ามานิดหน่อย แล้ว restitution ก็เตะมันกลับขึ้นไปทันที ในฉากจริง ๆ ความเร็วขึ้นที่เหลืออยู่นั้นจะยกกล่องลอยพ้นพื้นนิดหนึ่งทุกสเต็ป: นี่คือ resting jitter ที่เห็นได้ชัดของกองวัตถุที่ไม่ยอมนิ่ง วิธีแก้ (ด้านล่าง) คือ restitution threshold (เรียกอีกอย่างว่า bounce threshold): ถ้าความเร็วที่เข้าใกล้กันต่ำกว่าค่าเล็ก ๆ ค่าหนึ่ง ให้ถือว่า restitution เป็นศูนย์ contact ที่ช้าเลยแค่หยุดแทนที่จะเด้ง บรรทัดเดียว แล้วกล่องก็นิ่งตั้งแต่สเต็ปแรกและอยู่นิ่งเลย ทุก engine ทำแบบนี้ Unity เปิดให้ปรับผ่าน Physics.bounceThreshold

Sleeping: ปิดสวิตช์วัตถุที่นิ่งแล้ว

threshold ทำให้วัตถุมานิ่งได้ แต่วัตถุที่นิ่งก็ยังกิน CPU อยู่ดี: ทุกสเต็ปมันถูก integrate ถูกเทสต์การชน และถูกส่งผ่าน solver แค่เพื่อจะไปจบที่เดิมเป๊ะ ในฉากที่มีวัตถุนิ่งอยู่เป็นร้อย ๆ ชิ้น — โกดังที่เต็มไปด้วยลัง กองเศษซากปรักหักพัง — นั่นคือความสิ้นเปลืองมหาศาล และแย่กว่านั้น numerical noise เล็ก ๆ ที่เหลืออยู่ใน solver ก็คอยสะกิดวัตถุพวกนั้นจนมันไม่เคยนิ่งสนิทเลย คำตอบคือ sleeping: พอความเร็วของวัตถุอยู่ต่ำกว่า threshold นานพอ ให้ทำเครื่องหมายว่ามันหลับ แล้วหยุด simulate มันไปเลย จนกว่าจะมีอะไรมาแตะแล้วปลุกมันขึ้นมา

#include <iostream>
#include <cmath>

struct Body {
    float x, v;
    float idleTime;   // how long we have been below the sleep threshold
    bool  asleep;
};

int main() {
    Body b = {0.0f, 5.0f, 0.0f, false};
    float dt = 1.0f / 60.0f;
    float sleepSpeed = 0.10f;   // below this counts as "still"
    float sleepDelay = 0.05f;   // must stay still this long to fall asleep

    for (int i = 1; i <= 14; i++) {
        if (!b.asleep) {
            b.v *= 0.6f;                 // friction bleeds speed off each step
            b.x += b.v * dt;
            if (std::fabs(b.v) < sleepSpeed) b.idleTime += dt;
            else                             b.idleTime = 0.0f;
            if (b.idleTime >= sleepDelay) b.asleep = true;   // go to sleep
        }
        // an asleep body skips integration entirely -- zero CPU until woken
        std::cout << "step " << i << ": x=" << b.x << "  v=" << b.v
                  << (b.asleep ? "  [ASLEEP]" : "") << "\n";
    }
}

ผลลัพธ์:

step 1: x=0.05  v=3
step 2: x=0.08  v=1.8
step 3: x=0.098  v=1.08
step 4: x=0.1088  v=0.648
step 5: x=0.11528  v=0.3888
step 6: x=0.119168  v=0.23328
step 7: x=0.121501  v=0.139968
step 8: x=0.1229  v=0.0839808
step 9: x=0.12374  v=0.0503885
step 10: x=0.124244  v=0.0302331  [ASLEEP]
step 11: x=0.124244  v=0.0302331  [ASLEEP]
step 12: x=0.124244  v=0.0302331  [ASLEEP]
step 13: x=0.124244  v=0.0302331  [ASLEEP]
step 14: x=0.124244  v=0.0302331  [ASLEEP]

วัตถุค่อย ๆ ช้าลงด้วย friction จนกระทั่งที่สเต็ป 10 ความเร็วของมันอยู่ต่ำกว่า 0.1 ครบ 0.05 วินาทีตามที่กำหนด มันก็หลับ แล้วจากนั้นตำแหน่งก็ถูกแช่แข็งและ loop ก็ไม่ทำงานอะไรกับมันอีกเลย engine จริง ๆ ไปไกลกว่านั้นอีกขั้นด้วยการทำให้ทั้ง island หลับไปพร้อมกัน — กองกล่องที่วางพิงกันอยู่ต้องหลับและตื่นเป็นกลุ่ม เพราะการปลุกกล่องล่างสุดต้องปลุกกล่องที่อยู่ข้างบนมันด้วย นี่ยังเป็นแหล่งกำเนิด "บั๊กที่ไม่ใช่บั๊ก" ที่โด่งดัง: ลังที่วางอยู่บนแป้นกดที่ดีไซเนอร์คาดว่าจะทำงาน หรือวัตถุที่ควรตอบสนองต่อการดันช้า ๆ แต่กลับไม่ทำอะไรเลย — เพราะมันหลับอยู่ ใน Unity ปุ่มปรับคือ Rigidbody.sleepThreshold คำสั่งเช็กคือ rb.IsSleeping() และทางออกฉุกเฉินคือ rb.WakeUp() เพื่อบังคับปลุกวัตถุตอนที่โค้ดของเราเองเปลี่ยนอะไรบางอย่างที่ physics engine มองไม่เห็น

12. ใช้ physics ของ engine: Rigidbody กับ Collider ใน Unity

ทุกไอเดียในหัวข้อ 1 ถึง 11 คือสิ่งที่ physics ในตัวของ Unity (ขับเคลื่อนด้วย PhysX) ทำให้เราอยู่แล้วในทุก fixed step โดยที่เราไม่ต้องเขียนโค้ด broad phase, narrow phase หรือ impulse-resolution แม้แต่บรรทัดเดียว การมองออกว่าคอนเซปต์ไหนซ่อนอยู่ใน component ตัวไหนคือประเด็นทั้งหมดของหัวข้อนี้ — เราจะใช้เวลาไปกับการตั้งค่า Rigidbody กับ Collider มากกว่าการเขียน solver เองเยอะมาก

using UnityEngine;

public class BouncyBall : MonoBehaviour
{
    void Start()
    {
        Rigidbody rb = GetComponent<Rigidbody>();

        rb.mass = 2f;                  // section 1's RigidBody.mass
        rb.linearVelocity = new Vector3(3f, 0f, 0f);   // section 1's RigidBody.velocity
        rb.useGravity = true;          // section 2's integrate(), running every FixedUpdate

        // section 10: switch on CCD only for objects at real risk of tunneling
        rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic;
    }
}

Rigidbody ก็คือ struct RigidBody ของหัวข้อ 1 นั่นแหละ ตรงกันทุก field: mass, linearVelocity, angularVelocity, position/rotation (ตัวหลังเก็บเป็น quaternion ตามที่บทเรื่อง rotation เคยอธิบายไว้) ทุก tick ของ FixedUpdate — fixed timestep ตัวเดียวกับที่บท game loop พูดถึง — Unity จะรัน semi-implicit Euler step จากหัวข้อ 2 ให้เองภายใน โดยใช้ Time.fixedDeltaTime ด้วยเหตุผลเรื่อง stability กับ determinism แบบเดียวกับที่หัวข้อ 2 อธิบายไปเป๊ะ ๆ

A Collider (BoxCollider, SphereCollider, CapsuleCollider, MeshCollider สำหรับ convex mesh) คือรูปทรงที่ broad phase กับ narrow phase ของ PhysX ใช้ทำงานจริง ๆ — PhysX รัน broad phase ของตัวเองบน world-space bounds ของทุก collider (งานของหัวข้อ 3) และ narrow phase ของตัวเอง (การเทสต์แบบ SAT สำหรับรูปทรงง่าย ๆ และอัลกอริทึมตระกูล GJK สำหรับ convex mesh ตรงกับหัวข้อ 4 เป๊ะ) เพื่อหา contact ทั้งหมดนี้เกิดขึ้นเบื้องหลังโดยที่โค้ดของเราไม่เห็นเลย:

using UnityEngine;

public class ContactLogger : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        // section 5's Contact struct, handed to you directly by Unity
        foreach (ContactPoint contact in collision.contacts)
        {
            Debug.Log("point=" + contact.point + " normal=" + contact.normal
                     + " separation=" + contact.separation);   // separation < 0 means penetrating
        }
    }
}

ทุก field ของ struct Contact จากหัวข้อ 5 มีอยู่ใน ContactPoint ครบเลย: point, normal, และ separation (ชื่อที่ Unity ใช้เรียก penetration depth แต่กลับเครื่องหมาย — ค่าติดลบหมายถึงทับกันอยู่) ส่วน restitution กับ friction — หัวข้อ 7 กับ 8 — อยู่ใน Physic Material asset ที่ผูกกับ collider พร้อม combine mode (average, minimum, maximum, หรือ multiply) ที่คุมว่า material ของวัตถุสองตัวที่ต่างกันจะผสมกันยังไงตอนแตะกัน:

using UnityEngine;

public class BouncyMaterialSetup : MonoBehaviour
{
    void Start()
    {
        PhysicsMaterial mat = new PhysicsMaterial();
        mat.bounciness = 0.5f;                              // section 7's restitution
        mat.dynamicFriction = 0.5f;                          // section 8's friction (sliding)
        mat.staticFriction = 0.6f;                           // section 8's friction (starting from rest)
        mat.frictionCombine = PhysicsMaterialCombine.Average;

        GetComponent<Collider>().material = mat;
    }
}

จำนวน iteration จากหัวข้อ 9 ก็เป็นค่าตั้งค่าจริง ๆ ที่เปิดให้ปรับได้เหมือนกัน — Project Settings > Physics > Solver Iterations (position iterations) และ Solver Velocity Iterations คุมพอดีว่า solver ภายในของ PhysX จะรันกี่รอบต่อสเต็ป เป็นไอเดียเดียวกับการลู่เข้าด้วยการทำซ้ำจากตัวอย่างกล่องซ้อนกันในหัวข้อ 9 แค่รันกับทุก contact ในฉากพร้อมกัน แทนที่จะทำแค่สอง contact ด้วยมือ

Tip ในโปรเจกต์ Unity จริง ๆ เราแทบไม่ต้องเขียน broad phase, narrow phase หรือ impulse solver ของตัวเองเลย — PhysX ไวอยู่แล้ว ผ่านการทดสอบมาดีแล้ว และมีอยู่ให้ใช้แล้ว สิ่งที่หัวข้อ 1 ถึง 11 ให้กับเราคือความสามารถในการอ่านอาการ: กองที่สั่น ๆ ชี้ไปที่ solver iteration (หัวข้อ 9) วัตถุที่ทะลุกำแพงบาง ๆ ชี้ไปที่ collision detection mode (หัวข้อ 10) ลูกบอลที่เด้งไม่ถูกชี้ไปที่ restitution ของ Physic Material (หัวข้อ 7) หรือ combine mode ของมัน การดีบัก Unity physics โดยไม่มีความรู้จากบทนี้คือการเดาสุ่ม แต่ถ้ามีบทนี้แล้วก็เหมือนอ่าน checklist

13. คำศัพท์

14. แบบฝึกหัด

Exercise 1 วงกลมสามวง: E ที่ (2, 2) รัศมี 1, F ที่ (3.2, 2) รัศมี 1, G ที่ (10, 10) รัศมี 1 หา AABB ของแต่ละวงกลม ระบุว่าคู่ไหนบ้างที่ broad phase จะทำเครื่องหมายว่าเป็น candidate แล้วรันการเทสต์ narrow-phase แบบละเอียดกับคู่ candidate นั้น พร้อมคำนวณ contact ของมัน (point, normal, penetration depth)
Show answer
AABBs:
E: min=(1, 1)   max=(3, 3)
F: min=(2.2, 1) max=(4.2, 3)
G: min=(9, 9)   max=(11, 11)

E-F: overlap (x: 1<=4.2 and 3>=2.2; y: identical range) -> CANDIDATE
E-G: no overlap (E.max.x=3 < G.min.x=9)
F-G: no overlap (F.max.x=4.2 < G.min.x=9)

narrow phase E-F:
distance = 1.2, sum of radii = 2  ->  1.2 < 2, colliding
penetration = 2 - 1.2 = 0.8
normal = (1, 0)
point on E = (3, 2), point on F = (2.2, 2), contact = midpoint = (2.6, 2)

มีแค่ E-F เท่านั้นที่ AABB ทับกัน มันเลยเป็นคู่เดียวที่ narrow phase ต้องเทสต์ — G ไม่เคยถูกรัน exact test กับใครเลย ซึ่งนั่นคือประเด็นทั้งหมดของ broad phase การเทสต์แบบละเอียดยืนยันว่าชนกันจริง โดยมี penetration ค่อนข้างมาก (0.8 ซึ่งเกือบเท่ารัศมีของแต่ละวงกลมเลย) มี normal ที่ชี้ตรงจาก E ไปทาง F ตามแนวแกน x (เพราะวงกลมทั้งสองอยู่ที่ y เดียวกัน) และมี contact point อยู่ตรงกึ่งกลางของช่วงที่ทับกัน

Exercise 2 วัตถุ A มวล 3 velocity (2, 0) วัตถุ B มวล 1 velocity (-4, 0) มันชนกันด้วย contact normal (1, 0) (ชี้จาก A ไปทาง B) และ restitution 1 (elastic สมบูรณ์) คำนวณขนาดของ impulse j และ velocity ของวัตถุทั้งสองหลัง impulse แล้วตรวจสอบว่า momentum อนุรักษ์อยู่จริง
Show answer
invMassA = 1/3, invMassB = 1
relVel = velB - velA = (-4,0) - (2,0) = (-6, 0)
velocityAlongNormal = -6

j = -(1 + 1) * (-6) / (1/3 + 1) = 12 / (4/3) = 9

impulse = (9, 0)
velA_new = (2,0) - (9,0)*(1/3) = (2-3, 0) = (-1, 0)
velB_new = (-4,0) + (9,0)*1     = (-4+9, 0) = (5, 0)

momentum before = 3*2 + 1*(-4) = 6 - 4 = 2
momentum after  = 3*(-1) + 1*5 = -3 + 5 = 2   -- matches

ด้วย restitution 1 วัตถุจะแยกออกจากกันด้วยความเร็วเท่ากับตอนที่เข้าใกล้กันพอดี: มันกำลังปิดช่องว่างกันด้วยความเร็ว 6 หน่วยต่อวินาที และหลัง impulse มันแยกออกจากกันด้วย velB_new - velA_new = 5 - (-1) = 6 หน่วยต่อวินาที — คุณสมบัติที่นิยามการชนแบบ elastic สมบูรณ์เลย momentum ที่อ่านได้ 2 พอดีทั้งก่อนและหลังคือการเช็กความถูกต้องแบบฟรี ๆ เดียวกับที่หัวข้อ 7 ใช้ และมันก็ยังใช้ได้ที่นี่เหมือนกัน

Exercise 3 วัตถุเริ่ม physics step ที่ x = 9.0 เคลื่อนที่ด้วยความเร็ว 150 หน่วยต่อวินาที ด้วย fixed timestep 0.02 วินาที กำแพงบาง ๆ อยู่ระหว่าง x = 10.0 ถึง x = 10.2 การเช็ก collision แบบ discrete (แค่ต้น/ปลาย) จะจับการชนในสเต็ปนี้ได้ไหม ถ้าจับไม่ได้ ให้ใช้สูตร swept-test จากหัวข้อ 10 หาว่าวัตถุไปถึงกำแพงเป็นครั้งแรกตรงไหนของการเคลื่อนที่
Show answer
travel this step = 150 * 0.02 = 3.0
x0 = 9.0, x1 = 9.0 + 3.0 = 12.0

discrete check at x0=9.0: 10.0 <= 9.0 <= 10.2 ? NO
discrete check at x1=12.0: 10.0 <= 12.0 <= 10.2 ? NO
-> tunneling: the discrete check misses the wall completely

swept test:
hitT = (10.0 - 9.0) / (12.0 - 9.0) = 1.0 / 3.0 = 0.3333
hitX = 9.0 + 0.3333 * 3.0 = 10.0

ระยะที่วัตถุเคลื่อนที่ต่อสเต็ป (3.0 หน่วย) มากกว่าความหนาของกำแพง (0.2 หน่วย) ถึงสิบห้าเท่า สแนปช็อต discrete ทั้งสองเลยตกลงไปคนละฝั่งของกำแพงได้ง่าย ๆ โดยไม่เคยทับมันเลย — ตรงกับอาการ tunneling จากหัวข้อ 10 เป๊ะ swept test พบว่าวัตถุไปถึงหน้ากำแพงด้านใกล้จริง ๆ ที่หนึ่งในสามของการเคลื่อนที่ในสเต็ปนี้ ที่ x = 10.0 engine จริง ๆ จะหยุดหรือแก้ collision ตรงนั้น แทนที่จะปล่อยให้วัตถุไปถึง x = 12.0 เหมือนไม่มีอะไรขวางอยู่เลย

Exercise 4 วงกลมรัศมี 1.5 มีศูนย์กลางอยู่ที่ (6, 4) กล่องแบบ axis-aligned กินช่วง (2, 1) ถึง (5, 3) ใช้ closest-point test จากหัวข้อ 4 หาจุดบนกล่องที่ใกล้ศูนย์กลางวงกลมที่สุด ระยะห่างถึงจุดนั้น ว่าทั้งสองชนกันไหม และถ้าชน ให้หา contact normal กับ penetration depth
Show answer
closest point = clamp center (6,4) into [2,5] x [1,3]
              = (5, 3)   -- a corner of the box

d    = center - closest = (6-5, 4-3) = (1, 1)
dist = sqrt(1*1 + 1*1)  = sqrt(2) = 1.41421
1.41421 < 1.5  ->  COLLIDING

penetration = 1.5 - 1.41421 = 0.08579
normal      = d / dist = (0.7071, 0.7071)

ศูนย์กลางวงกลม clamp ไปที่ (5, 3) ซึ่งเป็นมุมของกล่อง ฟีเจอร์ที่ใกล้ที่สุดเลยเป็นจุดเดียว ไม่ใช่หน้า — และนั่นคือเหตุผลที่ normal ออกมาเป็นแนวทแยง (0.7071, 0.7071) แทนที่จะขนานแกน วงกลมทับมุมนั้นอยู่แค่ตื้น ๆ 0.086 และทิศทางการผลักออกก็ชี้ออกจากมุมอย่างสวยงามตามแนวเส้น 45 องศา เคสมุมนี้แหละคือเหตุผลทั้งหมดที่ closest-point test ชนะการเช็กแบบ "อยู่ฝั่งไหน" ที่ไร้เดียงสา: มันจัดการทั้งหน้าและมุมด้วย clamp ครั้งเดียวเหมือนกัน

นี่คือ pipeline เต็มรูปแบบที่ physics engine รันทุก fixed step: integrate position กับ rotation ของวัตถุทุกชิ้นไปข้างหน้าด้วย semi-implicit Euler ย่อโลกที่เต็มไปด้วยคู่ที่เป็นไปได้ให้เหลือแค่ candidate จริง ๆ ไม่กี่คู่ด้วย broad phase ยืนยันและวัดแต่ละคู่อย่างละเอียดด้วย narrow phase แปลงการทับกันที่ยืนยันแล้วให้กลายเป็น contact แล้วแก้ contact นั้นสองรอบ — รอบหนึ่งแก้ position (ดัน penetration ออก) และอีกรอบแก้ velocity (impulse, restitution, และ friction) — ทำซ้ำขั้นตอนแก้ทั้งหมดนี้สักไม่กี่รอบ เพื่อให้กองและกลุ่มของ contact ลงตัวเป็นสถานะที่สอดคล้องกัน แทนที่จะสู้กันเอง และสุดท้ายปล่อยให้วัตถุที่นิ่งสนิทแล้ว sleep เพื่อให้ฉากที่สงบแทบไม่กินทรัพยากรเลย Continuous collision detection คือสิ่งเดียวที่ต้องเพิ่มเข้ามาเพื่อไม่ให้ pipeline นี้พลาดอะไรก็ตามที่เร็วและบางไป Rigidbody กับ Collider ของ Unity รันทุกขั้นตอนนี้ให้เราเอง ซึ่งเป็นเหตุผลที่การเข้าใจมันในบทนี้เปลี่ยน "physics มันพังว่ะ" ให้กลายเป็น checklist สั้น ๆ เจาะจง แทนที่จะเป็นการเดาสุ่ม

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