11.2 Steering และ Flocking

เฟส 11 · AI สำหรับเกม · เวลาเรียน: 15–30 h

แรงการเคลื่อนที่เฉพาะที่ — seek, avoid, separate, align — ที่รวมเป็นการเคลื่อนที่ของตัวเดี่ยวและฝูงที่น่าเชื่อ

บทที่แล้ว (11.1 เรื่อง pathfinding) ตอบคำถามว่า "ต้องเดินเส้นทางไหนถึงจะข้ามด่านได้" บทนี้ตอบคำถามอีกแบบหนึ่ง คือเมื่อ agent รู้คร่าวๆ แล้วว่าอยากไปทางไหน หรือกำลังไล่ตาม หนี หรือเดินป้วนเปี้ยนอยู่ใกล้ agent ตัวอื่น มันจะเคลื่อนที่ไปยังจุดนั้นแต่ละเฟรมยังไงให้ดูมีชีวิตชีวา ไม่ใช่ดูเป็นหุ่นยนต์ ศัตรูที่หัน velocity เข้าหาผู้เล่นทันที เพื่อนร่วมทีมที่กระโดดวาร์ปเป็นช่วงๆ เข้าหาเรา หรือฝูงนกที่บินเป็นแถวตรงเป๊ะเหมือนไม้บรรทัด — ทั้งหมดนี้ดูแปลกๆ ทั้งนั้น Steering behaviors (ชุดกฎเล็กๆ ที่เอาไปใช้ซ้ำได้ สำหรับคำนวณแรงในการเคลื่อนที่ คิดค้นโดย Craig Reynolds ในปี 1987) คือคำตอบมาตรฐานของเรื่องนี้ และมันถูกใช้อยู่ทุกที่ ไม่ว่าจะเป็นยูนิตในเกม RTS ทุกตัว, NPC ในโลกเปิดทุกตัว, ฝูงปลาหรือฝูงนกในเกม, ไปจนถึงระบบฝูงชน (crowd system) ในเกมงบสูงแทบทุกเกม

บทนี้จะค่อยๆ สร้าง steering ขึ้นมาจากศูนย์ เริ่มจากโมเดลพื้นฐานที่ทุก behavior ใช้ร่วมกัน (position, velocity และแรงที่ไปดัน velocity — ใช้ pipeline force-to-position จากบทแคลคูลัสและชุดเครื่องมือ vector จากบทพีชคณิตเชิงเส้นตรงๆ เลย) จากนั้นค่อยไล่ทีละ behavior (seek, flee, arrive, pursue, evade, wander, obstacle avoidance) แล้วค่อยดูวิธีรวมหลาย behavior ให้ทำงานพร้อมกัน ตามด้วยกรณีที่ดังที่สุด คือตอนที่ agent หลายสิบหรือหลายร้อยตัวทำตามกฎง่ายๆ สามข้อชุดเดียวกัน แล้วฝูง (flock) ก็โผล่ขึ้นมาเองโดยไม่มีใครสั่ง ปิดท้ายด้วยเรื่อง steering กับ pathfinding เข้ากันยังไง และหัวข้อเกี่ยวกับคำบ่นที่พบบ่อยที่สุดของโค้ด steering ในโลกจริง คือทำไมมันสั่น (jitter) และจะหยุดมันยังไง

1. โมเดลของ steering: force, acceleration, velocity, position

steering agent ทุกตัวมีข้อมูลสถานะสองอย่างที่เหมือนกันหมด ซึ่งเราเคยเจอมาแล้วในบท 2.1 คือ position (จุด — บอกว่ามันอยู่ตรงไหน) กับ velocity (vector — บอกว่ามันกำลังเคลื่อนไปทางไหน และเร็วแค่ไหน) ในแต่ละเฟรม agent จะตัดสินใจว่าอยากทำอะไร — ไล่ตามเป้าหมาย วิ่งหนีภัย หรือรักษาระยะฟอร์เมชันกับตัวข้างๆ — แล้วแปลงการตัดสินใจนั้นเป็น vector เดียวเรียกว่า steering force แรงนี้ไม่ได้ไปขยับตัว agent ตรงๆ แต่มันไปดัน velocity ของ agent เหมือนกับที่แรงจริงๆ ไปดันวัตถุจริงๆ นั่นแหละ

นี่คือ chain เดียวกับที่บท 2.5 สร้างไว้เป๊ะๆ คือ แรงหารด้วยมวลได้ acceleration, acceleration integrate ออกมาเป็น velocity, velocity integrate ออกมาเป็น position โค้ด steering แทบทุกที่จะสมมติให้ mass = 1 เลย เพื่อให้ "steering force" กับ "acceleration" กลายเป็นตัวเลขตัวเดียวกัน ลดสิ่งที่ต้องตามอีกหนึ่งอย่าง ขั้นตอน integration ที่ใช้คือ semi-implicit Euler ซึ่งเป็นค่า default ที่เสถียรและคำนวณถูกจากบท 2.5 หัวข้อ 6 คือ อัปเดต velocity ก่อน แล้วค่อยเอา velocity ใหม่ไปอัปเดต position

THE STEERING PIPELINE -- runs once per agent, every frame "what do I want right now?" --> desired velocity | v steering force = desired - current velocity | v clamp to maxForce (mass = 1, so force = acceleration) velocity += steeringForce * dt <-- update velocity FIRST (ch. 2.5) velocity = clamp(velocity, maxSpeed) position += velocity * dt <-- THEN position, using the NEW velocity

มีตัวเลขสองตัวที่คุมว่า agent จะ "รู้สึก" ยังไง คือ maxSpeed (เร็วสุดที่มันเคลื่อนที่ได้) กับ maxForce (เลี้ยวหรือเร่งได้หักมุมแค่ไหน เพราะ force ก็คือ acceleration ที่ถูก clamp ไว้) ถ้า maxForce ต่ำแต่ maxSpeed สูง จะได้ของหนักๆ เลี้ยวยาก เหมือนเรือบรรทุกสินค้า ถ้า maxForce สูง จะได้ของที่ไวและตอบสนองฉับไว เหมือนแมลงวัน นี่คือโมเดลทั้งหมดในคลาสเล็กๆ คลาสเดียว ยังไม่มี target หรือ behavior ผูกอยู่ข้างในเลย มีแค่ physics pipeline ล้วนๆ:

using UnityEngine;

// The smallest possible steering agent: position + velocity, plus the
// two knobs every behavior in this chapter respects.
public class Steerable
{
    public Vector2 position;
    public Vector2 velocity;
    public float maxSpeed = 5f;
    public float maxForce = 10f;   // mass is assumed to be 1, so force == acceleration

    // Runs ANY steering force through the same pipeline from chapter 2.5:
    // clamp the force, step velocity first, then step position.
    public void ApplySteering(Vector2 steeringForce, float dt)
    {
        steeringForce = Vector2.ClampMagnitude(steeringForce, maxForce); // cap acceleration
        velocity += steeringForce * dt;                                  // acceleration -> velocity
        velocity = Vector2.ClampMagnitude(velocity, maxSpeed);           // cap top speed
        position += velocity * dt;                                       // velocity -> position
    }
}

สังเกตว่าโค้ดนี้ใช้ Vector2 (ไม่ใช่ Vector3) — เนื้อหาที่เหลือของบทนี้ทำงานอยู่บนระนาบแบนแบบมองจากด้านบน (top-down) เหมือนที่บท 2.1 แนะนำ Vec2 ก่อนจะค่อยไปสู่ 3D สูตรทุกสูตรในบทนี้ขยายไปใช้กับ Vector3 ได้เหมือนเดิมไม่ต้องเปลี่ยนอะไร (เช่นบนระนาบพื้น XZ แบบที่การเคลื่อนที่ตัวละครในบท 6.2 ทำ) สิ่งเดียวที่เปลี่ยนคือมี component เพิ่มมาอีกหนึ่งตัวต่อ vector

ก่อนจะผูก behavior จริงๆ เข้าไป ลองดันตัว agent ด้วยแรงคงที่ที่สมมติขึ้นมาก่อน เพื่อดูว่า pipeline ทำงานยังไง แล้วดูว่า velocity กับ position เปลี่ยนไปยังไง นี่คือเลขคณิตแบบ "อัปเดต velocity ก่อน แล้วค่อยอัปเดต position" ตัวเดียวกันเป๊ะกับสปริงไร้แรงเสียดทานในบท 2.5 แค่เปลี่ยนที่มาของแรงเท่านั้น:

using UnityEngine;

Steerable agent = new Steerable { maxSpeed = 10f, maxForce = 10f };
Vector2 constantForce = new Vector2(2f, 0f);   // a plain push, not aimed at any target yet
float dt = 0.5f;

for (int step = 0; step < 4; step++)
{
    Debug.Log($"step {step}: velocity=({agent.velocity.x:F1}, {agent.velocity.y:F1}) " +
              $"position=({agent.position.x:F1}, {agent.position.y:F1})");
    agent.ApplySteering(constantForce, dt);
}

Output (หน้าต่าง Console):

step 0: velocity=(0.0, 0.0) position=(0.0, 0.0)
step 1: velocity=(1.0, 0.0) position=(0.5, 0.0)
step 2: velocity=(2.0, 0.0) position=(1.5, 0.0)
step 3: velocity=(3.0, 0.0) position=(3.0, 0.0)

ในแต่ละเฟรม velocity จะเพิ่มขึ้นทีละ force * dt = 2 * 0.5 = 1 และ position จะเพิ่มขึ้นตาม velocity ใหม่ คูณด้วย dt — นี่คือ semi-implicit Euler เป๊ะๆ ทุก behavior ในบทนี้ ตั้งแต่ตอนนี้ไปจนถึง flocking จะเปลี่ยนอยู่แค่อย่างเดียวคือวิธีคำนวณ steeringForce ส่วน pipeline ที่แปลงมันเป็นการเคลื่อนที่จะไม่เปลี่ยนอีกเลย

Tip มีสูตรเดียวที่โผล่มาแทบทุก behavior ข้างล่างนี้ คือ steering = desired velocity - current velocity อ่านมันแบบนี้ "แรงที่ต้องใช้เพื่อปิดช่องว่างระหว่างที่กำลังเคลื่อนที่อยู่ กับที่อยากจะเคลื่อนที่" พอเห็น pattern นี้แล้ว จะเดาเนื้อหาส่วนใหญ่ของบทนี้ได้ล่วงหน้าก่อนอ่านด้วยซ้ำ
Common mistake ตั้งค่า velocity ตรงๆ ให้เท่ากับทิศทางที่ต้องการ (velocity = desired) แทนที่จะ steer เข้าหามัน (velocity += steering * dt) วิธีแรกจะทำให้ทิศทางของ agent หันปุ๊บทันทีทุกเฟรม ไม่มี momentum ไม่มีการเลี้ยวที่นุ่มนวล คืออาการ "ทันทีทันใด ไม่ลอยตัว" แบบหุ่นยนต์ที่บท 6.2 เคยเตือนไว้เรื่อง player controller เลย ประเด็นทั้งหมดของการส่งทุกอย่างผ่านแรงก็เพื่อให้ velocity เปลี่ยนแบบค่อยเป็นค่อยไป

2. Seek: steer เข้าหาเป้าหมาย

Seek คือ behavior จริงๆ ที่ง่ายที่สุด: เคลื่อนที่เข้าหาตำแหน่งเป้าหมายให้ตรงที่สุดเท่าที่จะทำได้ ขั้นตอน "อยากทำอะไร" จากหัวข้อ 1 กลายเป็น "อยากเคลื่อนที่ด้วยความเร็ว maxSpeed ตรงเข้าหาเป้าหมาย" คำนวณ desired velocity นั้นออกมา ลบด้วย velocity ปัจจุบัน ก็จะได้ steering force

SEEK -- steering = desired - velocity target * ^ / / desired = normalize(target - pos) * maxSpeed / pos o---------> velocity (where the agent is ACTUALLY heading right now) steering = desired - velocity (an arrow from velocity's tip to desired's tip -- the same "point minus point = vector" idea from chapter 2.1, section 2)
using UnityEngine;

public static class Steering
{
    public static Vector2 Seek(Vector2 position, Vector2 velocity, Vector2 target,
                                float maxSpeed, float maxForce)
    {
        Vector2 desired = (target - position).normalized * maxSpeed; // straight at the target, full speed
        Vector2 steer = desired - velocity;                          // "how wrong is my heading?"
        return Vector2.ClampMagnitude(steer, maxForce);
    }
}

ลองไล่มือดูทีละเฟรม 9 เฟรม: agent เริ่มจากหยุดนิ่งที่จุด origin กำลัง seek เป้าหมายที่อยู่ห่างไปทางขวา 10 หน่วย โดย maxSpeed = 5, maxForce = 10, dt = 0.2:

Vector2 pos = Vector2.zero, vel = Vector2.zero;
Vector2 target = new Vector2(10f, 0f);
float maxSpeed = 5f, maxForce = 10f, dt = 0.2f;

for (int step = 0; step < 9; step++)
{
    float dist = Vector2.Distance(target, pos);
    Debug.Log($"{step,2}  pos=({pos.x,6:F3},{pos.y,6:F3})  vel=({vel.x,6:F3},{vel.y,6:F3})  dist={dist,6:F3}");

    Vector2 steer = Steering.Seek(pos, vel, target, maxSpeed, maxForce);
    vel = Vector2.ClampMagnitude(vel + steer * dt, maxSpeed);
    pos += vel * dt;
}

Output (หน้าต่าง Console):

step  pos.x   pos.y   vel.x   vel.y    dist
 0    0.000   0.000   0.000   0.000  10.000
 1    0.200   0.000   1.000   0.000   9.800
 2    0.560   0.000   1.800   0.000   9.440
 3    1.048   0.000   2.440   0.000   8.952
 4    1.638   0.000   2.952   0.000   8.362
 5    2.311   0.000   3.362   0.000   7.689
 6    3.049   0.000   3.689   0.000   6.951
 7    3.839   0.000   3.951   0.000   6.161
 8    4.671   0.000   4.161   0.000   5.329

velocity จะเพิ่มเร็วในช่วงแรก (เพราะช่องว่างระหว่าง "ไม่ขยับเลย" กับ "desired" ห่างกันมากที่สุดตอนเริ่ม) แล้วค่อยๆ เพิ่มช้าลงเมื่อใกล้ maxSpeed = 5 เข้าไปเรื่อยๆ เพราะยิ่ง velocity ใกล้ desired มากเท่าไหร่ steering force (desired - velocity) ก็ยิ่งเล็กลงเท่านั้น อาการ ease-in แบบนี้แหละที่ทำให้ seek ดูเป็นธรรมชาติ แทนที่จะกระโดดไปความเร็วสูงสุดทันทีแบบตัวอย่าง Rigidbody แบบซื่อๆ ในบท 6.2 มีจุดหนึ่งที่ seek ไม่ทำ คือ มันไม่มีวันชะลอความเร็วตอนใกล้เป้าหมาย มันจะพุ่งทะลุเป้าหมายไปด้วย maxSpeed แล้ววนกลับมาใหม่ การแก้เรื่องนี้อยู่ในหัวข้อ 4

3. Flee: steer หนีออกจากภัยคุกคาม

Flee ก็คือ seek ที่กลับเครื่องหมาย: desired velocity ชี้ออกจากภัยคุกคาม (threat) แทนที่จะชี้เข้าหาเป้าหมาย โค้ดที่เหลือทุกบรรทัดเหมือนเดิมทุกอย่าง — นี่เป็นสัญญาณที่ดีว่าสูตรในหัวข้อ 1 ทำงานได้จริง ไม่ได้อธิบายแค่เฉพาะ seek เท่านั้น

FLEE -- same formula as SEEK, only "desired" is flipped desired = normalize(pos - threat) * maxSpeed (pos minus threat, not threat minus pos) threat * pos o ---------> velocity | | desired (points AWAY from threat) v steering = desired - velocity (identical formula to SEEK)
public static Vector2 Flee(Vector2 position, Vector2 velocity, Vector2 threat,
                            float maxSpeed, float maxForce)
{
    Vector2 desired = (position - threat).normalized * maxSpeed; // away from the threat, full speed
    Vector2 steer = desired - velocity;
    return Vector2.ClampMagnitude(steer, maxForce);
}

agent เริ่มอยู่ห่างจาก threat ที่ origin 2 หน่วย, maxSpeed = 4, maxForce = 8, dt = 0.2:

step  pos.x   vel.x    dist
 0    2.000   0.000   2.000
 1    2.160   0.800   2.160
 2    2.448   1.440   2.448
 3    2.838   1.952   2.838
 4    3.311   2.362   3.311
 5    3.849   2.689   3.849

เส้นโค้งการเร่งความเร็วเหมือน seek ทุกอย่าง แค่วิ่งหนีแทนที่จะไล่ตาม ในทางปฏิบัติ flee เดี่ยวๆ แทบไม่เคยถูกใช้จริงแบบนั้นตรงๆ — ศัตรูที่หนีเป็นเส้นตรงออกจากผู้เล่นเฉยๆ จะถูกต้อนมุมได้ง่ายมาก โค้ด flee จริงๆ มักจะรวมกับ obstacle avoidance (หัวข้อ 7) เสมอ และบางทีก็ผสม wander (หัวข้อ 6) นิดหน่อย เพื่อไม่ให้วิ่งเป็นเส้นตรงที่เดาทางได้ง่ายเกินไป

Common mistake เรียก Seek(position, velocity, threat, ...) แล้วเอาผลลัพธ์มา negate ทีหลัง แทนที่จะกลับทิศทางตรงจุดคำนวณ desired velocity ตั้งแต่ต้น การ negate steering force สุดท้ายไม่เหมือนกับการ flee — มันกลับแค่ตัว correction ไม่ได้กลับปลายทาง และทำให้การเคลื่อนที่ไม่ได้ชี้ออกจาก threat อย่างชัดเจน ยกเว้นจะบังเอิญพอดี

4. Arrive: seek ที่รู้จักหยุด

Arrive แก้ปัญหา "พุ่งทะลุเป้าหมาย" ของ seek ด้วยการลด desired speed ลงเมื่อ agent เข้าใกล้เป้าหมาย นอกรัศมี slowing radius arrive จะทำตัวเหมือน seek เป๊ะๆ คือวิ่งเต็มสปีดตรงเข้าหาเป้าหมาย แต่พอเข้ามาในรัศมี slowing radius แล้ว desired speed จะค่อยๆ ลดลงเป็นเส้นตรงตามระยะทาง จนเหลือศูนย์พอดีตอนถึงเป้าหมาย

ARRIVE -- desired speed ramps down inside the slowing radius desiredSpeed maxSpeed |----------------------_ | -_ | -_ (straight-line ramp down) | -_ 0 +----------------------------------> distance to target 0 slowingRadius outside slowingRadius: desiredSpeed = maxSpeed inside slowingRadius: desiredSpeed = maxSpeed * (distance / slowingRadius)
public static Vector2 Arrive(Vector2 position, Vector2 velocity, Vector2 target,
                              float maxSpeed, float maxForce, float slowingRadius)
{
    Vector2 toTarget = target - position;
    float dist = toTarget.magnitude;

    float desiredSpeed = dist < slowingRadius
        ? maxSpeed * (dist / slowingRadius)   // ramp down, linear in distance
        : maxSpeed;                            // full speed, same as seek

    Vector2 desired = toTarget.normalized * desiredSpeed;
    Vector2 steer = desired - velocity;
    return Vector2.ClampMagnitude(steer, maxForce);
}

ใช้ setup เดียวกับตอน trace seek — เริ่มจากหยุดนิ่ง เป้าหมายอยู่ห่างออกไป 10 หน่วย, maxSpeed = 5, maxForce = 10, dt = 0.2 — แต่คราวนี้ใส่ slowingRadius เป็น 5:

step  pos.x   vel.x    dist   desiredSpeed
 0    0.000   0.000  10.000       5.000
 1    0.200   1.000   9.800       5.000
 2    0.560   1.800   9.440       5.000
 3    1.048   2.440   8.952       5.000
 4    1.638   2.952   8.362       5.000
 ...  (identical to SEEK while dist >= slowingRadius)
 9    5.537   4.329   4.463       4.463
10    6.408   4.356   3.592       3.592
11    7.249   4.203   2.751       2.751
12    8.031   3.913   1.969       1.969
13    8.736   3.524   1.264       1.264
14    9.350   3.072   0.650       0.650
15    9.868   2.587   0.132       0.132

ตั้งแต่ step 0 ถึง 8 ตัวเลขเหมือนกับ trace ของ seek เป๊ะทุกไบต์ เพราะ agent ยังอยู่นอก slowing radius พอถึง step 9 ระยะทาง (4.463) ลดลงต่ำกว่า slowingRadius = 5 ตั้งแต่นั้นมา desiredSpeed ก็จะวิ่งตามระยะทางตรงๆ velocity จะขึ้นไปสูงสุดแถวๆ step 10 (4.356) แล้วค่อยๆ ลดลงอย่างนุ่มนวล ไปจอดเกือบพอดีที่เป้าหมายโดยความเร็วเหลือแทบเป็นศูนย์ ไม่มีการพุ่งเลยเป้า ไม่มีการวนกลับ การจอดนิ่มๆ แบบนี้แหละคือเหตุผลทั้งหมดที่ arrive มีอยู่ มันคือหน้าตาที่ตัวละครเพื่อนร่วมทีมหรือรถที่จอดเองควรจะเป็น

Tip เลือกค่า slowingRadius จากความสามารถในการชะลอความเร็วจริงๆ ของ agent อย่าเลือกมั่วๆ agent ที่เร็วแต่ maxForce ต่ำ ต้องใช้ slowing radius ที่ใหญ่กว่าเพื่อไม่ให้พุ่งเลยเป้า (เพราะ steering force ของมันอ่อนเกินกว่าจะหักล้างความเร็วสูงๆ ได้ทัน) ส่วน agent ที่ช้าหรือคล่องตัว ใช้ slowing radius เล็กๆ ได้เลย

5. Pursue กับ Evade: เล็งเป้าหมายที่กำลังเคลื่อนที่

seek จะเล็งไปที่ตำแหน่งที่เป้าหมายอยู่ตอนนี้ ซึ่งใช้ได้ดีถ้าเป้าหมายอยู่นิ่ง แต่ถ้าเป้าหมายกำลังเคลื่อนที่ พอ agent ไปถึงจุดนั้น เป้าหมายก็ขยับหนีไปแล้ว agent จะกลายเป็นไล่ตามหางตัวเองตลอด เล็งพลาดไปข้างหลังนิดๆ เสมอ Pursue แก้ปัญหานี้ด้วยการเล็งไปที่ ตำแหน่งในอนาคตที่คาดการณ์ไว้ (predicted future position) แทน คือเอาตำแหน่งปัจจุบันของเป้าหมาย บวกด้วย velocity ของมัน คูณเวลามองล่วงหน้า (lookahead time) สักค่าหนึ่ง แล้ว seek ไปที่จุดนั้น Evade ก็คือไอเดียเดียวกันแต่เอาไปใช้กับ flee — คาดการณ์ก่อน แล้ววิ่งหนีจากจุดที่คาดการณ์นั้น

PURSUE -- aim where the target WILL BE, not where it is now target now * --velocity-> * predicted position (target position + target velocity * lookAheadTime) pursuer o ---------------------------> (seek the PREDICTED point, not the current one)

เวลามองล่วงหน้าต้องยิ่งสั้นลงเมื่อผู้ไล่ (pursuer) เข้าใกล้เป้าหมายมากขึ้น (เพราะถ้าใกล้จะชนกันอยู่แล้วยังคาดการณ์ไกลไปในอนาคต จะพุ่งเลยเป้าไปไกลมาก) และต้องยาวขึ้นเมื่ออยู่ไกล วิธีประมาณง่ายๆ ที่ได้ผลดีคือ lookAheadTime = distanceToTarget / pursuerMaxSpeed — พูดง่ายๆ คือ "ถ้าวิ่งเต็มสปีด จะใช้เวลานานแค่ไหนถึงจะปิดระยะห่างนี้ได้"

public static Vector2 Pursue(Vector2 position, Vector2 velocity,
                              Vector2 targetPos, Vector2 targetVel,
                              float maxSpeed, float maxForce)
{
    float dist = Vector2.Distance(targetPos, position);
    float lookAheadTime = dist / maxSpeed;
    Vector2 predicted = targetPos + targetVel * lookAheadTime;
    return Steering.Seek(position, velocity, predicted, maxSpeed, maxForce);
}

// Evade: predict the same way, then flee the predicted point instead.
public static Vector2 Evade(Vector2 position, Vector2 velocity,
                             Vector2 targetPos, Vector2 targetVel,
                             float maxSpeed, float maxForce)
{
    float dist = Vector2.Distance(targetPos, position);
    float lookAheadTime = dist / maxSpeed;
    Vector2 predicted = targetPos + targetVel * lookAheadTime;
    return Steering.Flee(position, velocity, predicted, maxSpeed, maxForce);
}

pursuer เริ่มที่ origin (maxSpeed = 6, maxForce = 15) ไล่ตามเป้าหมายที่เริ่มห่างออกไป 12 หน่วย และค่อยๆ ลอยขึ้นด้านบนคงที่ที่ (0, 3) ต่อวินาที, dt = 0.2:

step  tgt.x  tgt.y  pred.x  pred.y  pur.x  pur.y   dist
 0   12.000  0.000  12.000  6.000   0.000  0.000  12.000
 1   12.000  0.600  12.000  6.498   0.215  0.107  11.796
 2   12.000  1.200  12.000  6.919   0.597  0.308  11.437
 3   12.000  1.800  12.000  7.278   1.111  0.588  10.956
 4   12.000  2.400  12.000  7.588   1.727  0.938  10.377
 5   12.000  3.000  12.000  7.860   2.421  1.349   9.721
 6   12.000  3.600  12.000  8.103   3.174  1.812   9.005
 7   12.000  4.200  12.000  8.322   3.973  2.322   8.244
 8   12.000  4.800  12.000  8.525   4.803  2.874   7.450

ลองดูคอลัมน์ pred.y: มันจะสูงกว่า tgt.y อยู่เสมอ เพราะการคาดการณ์มองล่วงหน้าไปตามระยะเวลาที่ pursuer ยังต้องใช้ในการปิดช่องว่าง เส้นทางของ pursuer จะโค้งขึ้นด้านบนตั้งแต่เฟรมแรกเลย มุ่งไปยังที่ที่เป้าหมายกำลังจะไป แทนที่จะไล่ตามตำแหน่งปัจจุบันของมัน และระยะห่างก็ลดลงเรื่อยๆ อย่างสม่ำเสมอ (12.0 -> 7.45) แทนที่ pursuer จะตามหลังอยู่ที่ offset คงที่แบบที่ seek ธรรมดาจะเป็น

Common mistake ใช้เวลามองล่วงหน้าคงที่ (เช่น ตายตัวที่ 1 วินาทีเสมอ) โดยไม่สนระยะทาง ถ้าอยู่ไกล ค่านี้จะคาดการณ์ต่ำเกินไปและ pursuer ก็ยังตามหลังอยู่ดี ถ้าอยู่ใกล้มากๆ ค่านี้จะคาดการณ์เกินจริงไปไกลโข จน pursuer อาจแฉลบทะลุเป้าหมายที่กำลังจะจับได้อยู่แล้ว การปรับ lookahead ตามระยะทางแบบด้านบนนี้ทำให้การคาดการณ์สมเหตุสมผลในทุกระยะ

6. Wander: การเคลื่อนที่แบบอิสระ ไม่มีทิศทางตายตัว

Wander ให้ agent มีอะไรทำตอนที่ไม่มีเป้าหมายเลย — เช่น NPC พื้นหลังที่เดินป้วนเปี้ยน เพื่อนร่วมทีมที่ดูอยากรู้อยากเห็น หรือสัตว์ป่าบรรยากาศ วิธีง่ายๆ แบบซื่อๆ คือสุ่มทิศทางใหม่ทุกเฟรม แต่แบบนั้นดูแย่มาก เพราะ agent จะสั่นให้เห็นชัดๆ เนื่องจากทิศทางอาจพลิกเกือบ 180 องศาระหว่างสองเฟรมติดกันได้ Wander แก้ปัญหานี้ด้วยการเก็บมุมตัวเดียวที่วิ่งต่อเนื่อง (running angle) แล้วขยับมันทีละนิดด้วยค่าสุ่มเล็กๆ ในแต่ละเฟรม ทำให้ทิศทางค่อยๆ เลื่อนไปอย่างนุ่มนวลแทนที่จะกระโดด

WANDER -- a circle projected ahead of the agent; the aim point creeps around its rim wanderRadius +-----------+ | x <--- aim point, at wanderAngle around the circle agent o --forward--> (circle center, wanderDistance ahead of the agent) | | +-----------+ wanderAngle += small random value each frame (NOT a fresh random angle each frame) steering = circleCenter + (cos(wanderAngle), sin(wanderAngle)) * wanderRadius

เพื่อให้ได้ตัวเลข trace ที่จริงและทำซ้ำได้ ตัวอย่างนี้เอา LCG (linear congruential generator) ตัวเล็กๆ จากบท 2.6 กลับมาใช้ — state = state * 1103515245 + 12345 — แทนที่จะใช้ Random ในตัวของ Unity เหตุผลล้วนๆ ก็เพื่อให้ output ที่พิมพ์ด้านล่างนี้ทำซ้ำได้แม่นยำจาก seed เดิม

using UnityEngine;

public class SimpleRng
{
    uint state;
    public SimpleRng(uint seed) { state = seed; }

    public uint NextRaw()
    {
        state = state * 1103515245u + 12345u;   // same LCG as chapter 2.6
        return state;
    }

    public float NextRange(float lo, float hi)
    {
        float t = NextRaw() / 4294967295f;       // 0..1
        return lo + t * (hi - lo);
    }
}

public class Wanderer
{
    SimpleRng rng = new SimpleRng(7);
    float wanderAngle = 0f;
    public float wanderRadius = 1.2f;
    public float wanderDistance = 2f;
    public float wanderJitter = 0.5f;   // max radians the angle can change in one frame

    public Vector2 Wander(Vector2 velocity)
    {
        wanderAngle += rng.NextRange(-1f, 1f) * wanderJitter;

        Vector2 forward = velocity.sqrMagnitude > 0.0001f ? velocity.normalized : Vector2.right;
        Vector2 circleCenter = forward * wanderDistance;
        Vector2 displacement = new Vector2(Mathf.Cos(wanderAngle), Mathf.Sin(wanderAngle)) * wanderRadius;

        return circleCenter + displacement;   // used directly as a steering force
    }
}

เริ่มจาก velocity ที่เกือบนิ่ง (2, 0), maxSpeed = 3, maxForce = 6, dt = 0.2, seed 7:

step  wAngle  force.x  force.y   vel.x   vel.y   pos.x   pos.y
 0     0.299    3.147    0.353   2.000   0.000   0.000   0.000
 1    -0.052    3.198   -0.009   2.629   0.071   0.526   0.014
 2    -0.386    3.111   -0.410   2.999   0.063   1.126   0.027
 3    -0.540    3.029   -0.627   3.000  -0.016   1.726   0.024
 4    -0.544    3.025   -0.699   2.998  -0.117   2.325   0.000
 5    -0.492    3.052   -0.709   2.992  -0.213   2.924  -0.042
 6    -0.503    3.041   -0.775   2.986  -0.294   3.521  -0.101
 7    -0.518    3.027   -0.843   2.977  -0.372   4.116  -0.176

ลองดูคอลัมน์ wAngle: มันเปลี่ยนไม่เกิน 0.5 เรเดียนต่อ step (นี่คือ wanderJitter ที่กำลังทำหน้าที่ของมัน) และมันค่อยๆ ลดลงต่อเนื่องตลอดแปดเฟรมนี้ แทนที่จะกระเด้งไปมาแบบสุ่มไร้ทิศทาง ทิศทาง velocity ที่ได้ (vel.y ค่อยๆ ติดลบมากขึ้นเรื่อยๆ ในขณะที่ vel.x ยังอยู่ใกล้ maxSpeed) กลายเป็นเส้นทางโค้งนุ่มๆ ดูเป็นธรรมชาติ ไม่กระตุก ทั้งๆ ที่ตัวเลขทุกตัวในตารางนี้มาจากสูตรที่ตายตัวและกำหนดผลลัพธ์ล่วงหน้าได้ทั้งหมด (deterministic) ตรงตามที่บท 2.6 อธิบายไว้เรื่องเลขสุ่มเทียม (pseudo-random)

Tip โค้ด Unity จริงๆ ควรใช้ UnityEngine.Random.Range หรือ System.Random ที่ใส่ seed ไว้ แทนที่จะเขียน LCG เองแบบนี้ — LCG ในตัวอย่างนี้มีไว้แค่เพื่อให้ตัวเลขใน trace นี้ทำซ้ำได้แม่นยำบนกระดาษเท่านั้น สิ่งที่สำคัญคือเทคนิคของ wander (ขยับมุมทีละนิด ไม่ใช่สุ่มทิศทางใหม่ทั้งหมด) ซึ่งใช้ได้ผลเหมือนกันไม่ว่าจะใช้แหล่งสุ่มไหนก็ตาม

7. Obstacle และ Wall Avoidance

agent ที่ทำแค่ seek หรือ wander อย่างเดียว จะเดินทะลุกำแพง ลัง หรือสิ่งกีดขวางอื่นๆ ไปเลย Obstacle avoidance ให้วิธีง่ายๆ ในการสังเกตอันตรายข้างหน้า คือยิง "feeler" สั้นๆ (เส้นตรง หรือในเวอร์ชันย่อนี้คือแค่จุดเดียว) ออกไปข้างหน้า agent แล้วเช็คว่าจุดนั้นตกอยู่ในสิ่งกีดขวางไหน ถ้าใช่ ก็ steer หนีออกไปแรงๆ

OBSTACLE AVOIDANCE -- a feeler out in front checks for trouble agent o ===feeler (length = feelerLength)==> x <- feeler tip ( ) obstacle (radius) if distance(feelerTip, obstacleCenter) < obstacleRadius + agentRadius: avoidForce = normalize(feelerTip - obstacleCenter) * maxForce (push straight away)
public struct Obstacle
{
    public Vector2 center;
    public float radius;
}

public static Vector2 AvoidObstacle(Vector2 position, Vector2 velocity, Obstacle obstacle,
                                     float agentRadius, float feelerLength, float maxForce)
{
    Vector2 feelerDir = velocity.sqrMagnitude > 0.0001f ? velocity.normalized : Vector2.right;
    Vector2 feelerTip = position + feelerDir * feelerLength;

    float d = Vector2.Distance(feelerTip, obstacle.center);
    if (d >= obstacle.radius + agentRadius)
        return Vector2.zero;   // feeler is clear -- nothing to avoid

    return (feelerTip - obstacle.center).normalized * maxForce;
}

agent เคลื่อนที่ไปทาง +x จาก origin ด้วย velocity (3, 0) มีสิ่งกีดขวางอยู่ที่ (6, 1) รัศมี 1, agentRadius = 0.5, feelerLength = 3 โดย seek เป้าหมายที่อยู่ไกลๆ ที่ (20, 0) ทุกครั้งที่ feeler โล่ง, maxSpeed = 4, maxForce = 10, dt = 0.15:

step  pos.x   pos.y  feeler.x  feeler.y  d2obs   mode
 0    0.000   0.000     3.000     0.000  3.162   seek
 1    0.472   0.000     3.473     0.000  2.718   seek
 2    0.964   0.000     3.964     0.000  2.268   seek
 3    1.472   0.000     4.472     0.000  1.826   seek
 4    1.994   0.000     4.994     0.000  1.419  AVOID
 5    2.356  -0.159     5.104    -1.362  2.526   seek
 6    2.754  -0.293     5.597    -1.250  2.286   seek
 7    3.182  -0.405     6.083    -1.167  2.168   seek
 8    3.636  -0.498     6.574    -1.103  2.180   seek
 9    4.111  -0.575     7.073    -1.052  2.316   seek
10    4.606  -0.637     7.582    -1.009  2.558   seek
11    5.116  -0.686     8.102    -0.972  2.882   seek
12    5.639  -0.723     8.632    -0.937  3.267   seek
13    6.174  -0.750     9.170    -0.903  3.697   seek

สำหรับ step 0 ถึง 3 ปลาย feeler ยังอยู่นอกโซนอันตรายของสิ่งกีดขวาง (d2obs > radius + agentRadius = 1.5) agent เลยแค่ seek เป้าหมายและเดินเป็นเส้นตรงไปเรื่อยๆ พอถึง step 4 ปลาย feeler ตกเข้าไปในโซนของสิ่งกีดขวาง (1.419 < 1.5) แรง avoidance ก็เลยทำงาน — ดันออกจากสิ่งกีดขวางแรงๆ ครั้งเดียว แค่แรงดันครั้งเดียวนี้ก็มากพอจะบิดเส้นทางทั้งหมดให้โค้งลงด้านล่าง (pos.y ติดลบและติดลบต่อไปเรื่อยๆ) แล้วตั้งแต่ step 5 เป็นต้นไป feeler ก็โล่งตลอด agent จึงกลับไป seek ตามปกติ แต่คราวนี้เป็นเส้นทางที่โค้งอ้อมใต้สิ่งกีดขวางอย่างเรียบร้อย แทนที่จะพุ่งทะลุเข้าไป

Tip Unity มี raycasting จริงๆ ให้ใช้สำหรับเรื่องนี้: Physics2D.Raycast หรือ Physics.SphereCast ยิงไปตามทิศทาง velocity เทียบกับ LayerMask ของสิ่งกีดขวาง จะได้ feeler ที่แม่นยำกว่าการเช็คจุดเดียวมาก รวมถึงชนกับ mesh รูปทรงอะไรก็ได้ ไม่ใช่แค่วงกลม สูตรคณิตของ steering ด้านบนเหมือนเดิมทุกอย่าง เปลี่ยนแค่วิธีตรวจจับว่า "มีอะไรขวางอยู่ไหม" เท่านั้น
Common mistake ใช้ feeler แค่เส้นเดียวที่ชี้ตรงไปข้างหน้าเท่านั้น สิ่งกีดขวางที่อยู่ค่อนไปทางด้านข้างอาจเฉี่ยวขอบของ agent ได้โดยไม่เคยตัดกับ feeler ที่ชี้ตรงไปข้างหน้าเลยสักครั้ง โค้ด steering ที่ใช้งานจริงมักจะยิง feeler สองหรือสามเส้น — เส้นตรงไปข้างหน้าหนึ่งเส้น และเฉียงซ้ายขวาเล็กน้อยอีกหนึ่งหรือสองเส้น เพื่อให้จับสิ่งกีดขวางที่อยู่แถวไหล่ของ agent ได้ด้วย

8. รวม behavior เข้าด้วยกัน: weighted blending และ priority

agent จริงๆ แทบไม่เคยรัน behavior เดียวโดดๆ ยาม (guard) ตัวหนึ่งอาจจะต้อง seek จุดลาดตระเวน, avoid obstacle และคง alignment หลวมๆ กับยามตัวอื่นที่อยู่ใกล้ๆ พร้อมกันทั้งหมด มีวิธีทั่วไปสองแบบในการรวม steering force หลายๆ ตัวให้เหลือตัวเดียว

Weighted blending ให้แต่ละ behavior ที่ทำงานอยู่มีค่า weight ของตัวเอง คูณแต่ละแรงด้วย weight ของมัน บวกรวมกันทั้งหมด แล้ว clamp ผลลัพธ์ให้ไม่เกิน maxForce ทุก behavior จะมีส่วนช่วยอยู่เสมอ ไม่มากก็น้อย

public static Vector2 CombineWeighted(float maxForce, params (Vector2 force, float weight)[] behaviors)
{
    Vector2 total = Vector2.zero;
    foreach (var (force, weight) in behaviors)
        total += force * weight;
    return Vector2.ClampMagnitude(total, maxForce);
}

ตัวอย่างเดินเลข: แรง seek (4, 2) น้ำหนัก 1.0 ผสมกับแรง avoidance (-3, 6) น้ำหนัก 2.0 (avoidance สำคัญเป็นสองเท่าของ seek), maxForce = 10:

raw sum = 1.0*(4, 2) + 2.0*(-3, 6) = (-2, 14)     length = 14.142
clamped = (-1.414, 9.899)                          length = 10.000

ผลรวมดิบเกิน maxForce ไปเยอะ (14.142 เทียบกับเพดาน 10) จึงต้องถูกสเกลลงแบบสม่ำเสมอ คงทิศทางเดิมไว้แต่ลดความยาวลง — เป็นการทำงานของ ClampMagnitude ตัวเดียวกับที่ทุก behavior ในบทนี้ใช้อยู่แล้ว

Priority-based combination จะลองไล่แต่ละ behavior ตามลำดับความสำคัญแทน behavior ตัวแรกที่มีอะไร "จะพูด" อย่างมีนัยสำคัญ จะชนะไปเลยในเฟรมนั้น — behavior ที่ priority ต่ำกว่าจะได้ทำงานก็ต่อเมื่อ behavior ที่ priority สูงกว่าทุกตัวคืนค่ามาใกล้ศูนย์ (แทบไม่มีอะไรเลย)

public static Vector2 CombinePriority(float maxForce, params Vector2[] behaviorsInPriorityOrder)
{
    foreach (Vector2 force in behaviorsInPriorityOrder)
    {
        if (force.sqrMagnitude > 0.0001f)
            return Vector2.ClampMagnitude(force, maxForce);   // first non-trivial behavior wins
    }
    return Vector2.zero;
}

ถ้าเช็ค avoidance ก่อนเป็นอันดับแรก: ถ้า feeler ของสิ่งกีดขวางจากหัวข้อ 7 โล่งอยู่ avoidForce ก็จะเป็น Vector2.zero พอดี priority ก็จะตกไปให้ตัวถัดไปทำงาน (seek, wander หรือผสมทั้งคู่) แต่ทันทีที่ feeler ตรวจเจอปัญหา avoidance จะคืนค่าแรงที่ไม่เป็นศูนย์ และเข้าควบคุมทั้งหมดในเฟรมนั้นทันที ไม่สน seek กับ wander เลยจนกว่าทางจะโล่งอีกครั้ง — ซึ่งตรงกับพฤติกรรมที่ trace ของ obstacle avoidance ในหัวข้อ 7 แสดงให้เห็นตอน step 4 พอดี

Tip Weighted blending นุ่มนวลกว่า แต่บางทีก็ได้ผลลัพธ์ที่ "สับสน" เวลาสองแรงที่แข็งแรงมาชนกัน (เช่น สิ่งกีดขวางอยู่ตรงกลางระหว่าง agent กับเป้าหมายที่ seek พอดี อาจทำให้แรงที่ผสมกันพา agent พุ่งตรงเข้าสิ่งกีดขวางเลย เพราะ seek กับ avoid หักล้างกันไปบางส่วน) Priority หลีกเลี่ยงปัญหาแบบนี้ได้ แต่จะรู้สึกกระตุกตอนสลับ behavior เกมที่วางขายจริงหลายเกมใช้แบบผสมกัน คือใช้ priority กับ behavior ที่เกี่ยวกับความปลอดภัยอย่าง obstacle avoidance และใช้ weighted blending กับอะไรที่นุ่มนวลกว่า เช่น seek บวก wander นิดหน่อย

9. Flocking: separation, alignment และ cohesion

Flocking คือสิ่งที่เกิดขึ้นเมื่อ agent ทุกตัวในกลุ่มรันกฎ steering ง่ายๆ สามข้อชุดเดียวกันเป๊ะๆ โดยมองแค่เพื่อนบ้าน (neighbor) ที่อยู่ใกล้ๆ เท่านั้น ไม่มีหัวหน้า ไม่มีแผนการรวมศูนย์ใดๆ Craig Reynolds เรียก agent จำลองเหล่านี้ว่า boids และตีพิมพ์เทคนิคนี้ในปี 1987 จนถึงตอนนี้มันก็ยังเป็นวิธีมาตรฐานในการทำฝูงนก ฝูงปลา ฝูงสัตว์ หรือฝูงชนที่ดูสมจริง โดยไม่ต้องเขียนสคริปต์กำหนดเส้นทางเฉพาะให้ทีละตัวเลย

THE THREE BOID RULES (Craig Reynolds, 1987) 1) SEPARATION -- steer away from neighbors that are too close o <-- ME --> o (nearby neighbors push me apart from both) 2) ALIGNMENT -- steer to match the average heading of nearby neighbors o --> o --> ME --> (turn to match the group's average velocity) o --> 3) COHESION -- steer toward the average position (center) of nearby neighbors o o * <- average position of all neighbors o ME --> (steer toward that center point)

แต่ละกฎจะมองแค่เพื่อนบ้านที่อยู่ในรัศมีที่กำหนดเท่านั้น (ถ้าเช็คทั้งฝูงทุกเฟรมจะสเกลไม่ไหว แล้ว boid ตัวหนึ่งก็ควรตอบสนองแค่สิ่งที่อยู่ใกล้มันจริงๆ) separation กับ cohesion จะสร้าง vector แบบเดียวกับ target แล้วใช้ไอเดีย "desired ลบ velocity" ซ้ำจาก seek ส่วน alignment ต่างออกไปนิดหน่อย คือมัน seek ไปที่ velocity ที่อยากให้ตรงกัน ไม่ใช่ตำแหน่ง

public class Boid
{
    public Vector2 position;
    public Vector2 velocity;
}

public static class Flocking
{
    public static Vector2 Separation(Boid self, System.Collections.Generic.List<Boid> neighbors, float sepRadius)
    {
        Vector2 force = Vector2.zero;
        int count = 0;
        foreach (Boid other in neighbors)
        {
            float d = Vector2.Distance(self.position, other.position);
            if (d > 0.00001f && d < sepRadius)
            {
                force += (self.position - other.position).normalized / d;   // closer = stronger push
                count++;
            }
        }
        return count > 0 ? force / count : Vector2.zero;
    }

    public static Vector2 Alignment(Boid self, System.Collections.Generic.List<Boid> neighbors)
    {
        if (neighbors.Count == 0) return Vector2.zero;
        Vector2 avgVel = Vector2.zero;
        foreach (Boid other in neighbors) avgVel += other.velocity;
        avgVel /= neighbors.Count;
        return avgVel - self.velocity;              // steer to match the group's heading
    }

    public static Vector2 Cohesion(Boid self, System.Collections.Generic.List<Boid> neighbors, float maxSpeed)
    {
        if (neighbors.Count == 0) return Vector2.zero;
        Vector2 center = Vector2.zero;
        foreach (Boid other in neighbors) center += other.position;
        center /= neighbors.Count;
        Vector2 desired = (center - self.position).normalized * maxSpeed;
        return desired - self.velocity;              // seek the flock's center
    }

    public static Vector2 Flock(Boid self, System.Collections.Generic.List<Boid> neighbors,
                                 float sepRadius, float maxSpeed, float maxForce,
                                 float wSep, float wAlign, float wCoh)
    {
        Vector2 sep = Separation(self, neighbors, sepRadius) * wSep;
        Vector2 ali = Alignment(self, neighbors) * wAlign;
        Vector2 coh = Cohesion(self, neighbors, maxSpeed) * wCoh;
        return Vector2.ClampMagnitude(sep + ali + coh, maxForce);
    }
}

boid สี่ตัวเริ่มอยู่ใกล้กัน แต่ละตัวหันคนละทิศทางเล็กน้อย — กลุ่มหลวมๆ ที่ยังไม่เป็นระเบียบ neighborRadius = 5 (ในตัวอย่างนี้ boid ทุกตัวมองเห็นกันหมด), sepRadius = 1.3, maxSpeed = 2.5, maxForce = 4, weight wSep = 1.5, wAlign = 1, wCoh = 1, dt = 0.2:

frame 0: headings(deg) = [0.0, 18.4, -31.0, 8.1]
   boid0: pos=(0.000, 0.000)  vel=(2.000, 0.000)
   boid1: pos=(1.000, 0.500)  vel=(1.800, 0.600)
   boid2: pos=(0.600,-0.800)  vel=(1.500,-0.900)
   boid3: pos=(-0.800,0.300)  vel=(2.100, 0.300)

frame 1: headings(deg) = [-0.0, 7.8, -12.3, 4.5]
   boid0: pos=(0.406, 0.000)  vel=(2.030,-0.001)
   boid1: pos=(1.254, 0.535)  vel=(1.269, 0.175)
   boid2: pos=(0.850,-0.855)  vel=(1.250,-0.273)
   boid3: pos=(-0.447,0.328)  vel=(1.763, 0.140)

frame 2: headings(deg) = [0.4, -2.2, 2.5, 1.3]
   boid0: pos=(0.801, 0.002)  vel=(1.973, 0.012)
   boid1: pos=(1.443, 0.528)  vel=(0.945,-0.037)
   boid2: pos=(1.060,-0.845)  vel=(1.049, 0.047)
   boid3: pos=(-0.142,0.335)  vel=(1.529, 0.035)

ลองดูบรรทัด headings(deg) ในแต่ละเฟรม: [0.0, 18.4, -31.0, 8.1] กระจายกันเกือบ 50 องศาตอนเริ่มต้น แต่พอถึงเฟรม 2 ก็หดเหลือ [0.4, -2.2, 2.5, 1.3] — ห่างกันไม่ถึง 5 องศา ไม่มีใครบอก boid ตัวไหนเลยว่าให้หันไปทางไหน แค่ alignment อย่างเดียวก็ดึงทิศทางทั้งสี่เข้ามาใกล้กันได้ภายในสอง simulation step โดยแต่ละ boid แค่เฉลี่ย velocity ของเพื่อนบ้านตัวเอง ในขณะเดียวกัน boid ก็ไม่ได้หดตัวมารวมเป็นจุดเดียวกัน — ระยะห่างระหว่างคู่ boid ที่เฟรม 2 คือ boid0-boid1: 0.830, boid0-boid2: 0.886, boid0-boid3: 1.000, boid1-boid2: 1.425, boid1-boid3: 1.597, boid2-boid3: 1.684 — separation กำลังทำหน้าที่กันไม่ให้ boid สองตัวที่ใกล้กันที่สุด (boid0 กับ boid1 ซึ่งอยู่ในระยะ separation radius 1.3) มาซ้อนทับกัน ในขณะที่ cohesion ก็คอยกันไม่ให้ทั้งกลุ่มลอยห่างออกจากกัน กฎง่ายๆ ที่มองแค่รอบตัวสามข้อ ก็ทำให้ฝูงเกิดขึ้นเองได้แล้ว

Tip ถ้ารันต่อไปอีกหลายเฟรม ทิศทางจะไม่หยุดนิ่งตายตัวเป๊ะๆ ฝูงจะยังคงขยับกระเพื่อมเบาๆ ต่อไปเรื่อยๆ โดย separation กับ cohesion จะแย้งกันเล็กๆ น้อยๆ อยู่ตลอดเวลา นี่คือเรื่องปกติและตรงกับฝูงจริงๆ ในธรรมชาติ ถ้า boid simulation หยุดนิ่งเป๊ะแบบไม่ขยับเลย มักแปลว่า weight แรงเกินไปเมื่อเทียบกับ maxForce ทำให้ over-correct ทุกเฟรม
Common mistake เช็ค boid ทุกตัวกับ boid ทุกตัวในทุกๆ เฟรม (การค้นหาเพื่อนบ้านแบบ O(n^2) — ดูบท 1.4 เรื่อง Big-O) วิธีนี้โอเคถ้ามี boid แค่หยิบมือเดียว แต่พังเร็วมากถ้ามีเป็นร้อยตัว ระบบ flocking ที่ใช้งานจริงจะจัด boid ลงใน spatial grid (หรือใช้โครงสร้างซ้ำจาก broad-phase ของ physics/collision) เพื่อให้ boid แต่ละตัวเช็คแค่ไม่กี่ cell ที่อยู่ใกล้ๆ เท่านั้น ไม่ต้องเช็คทั้งฝูง

10. Steering กับ Pathfinding: waypoint บวกกับการเคลื่อนที่นุ่มนวลในระยะใกล้

Pathfinding (บท 11.1 — A* บน grid หรือ navmesh) ตอบคำถามว่า "ต้องผ่านจุดไหนตามลำดับถึงจะไปจากที่นี่ถึงเป้าหมายได้โดยไม่เดินทะลุกำแพง" มันจะคืนค่าเป็นลิสต์ของ waypoint ออกมา คือช่วงเส้นตรงต่อๆ กัน ซึ่งมักจะมีมุมหักคมๆ ดูเป็นหุ่นยนต์ ส่วน steering ตอบคำถามคนละแบบเลย คือ "ถ้ารู้ waypoint ถัดไปที่ต้องไปแล้ว จะเคลื่อนที่เข้าหามันให้นุ่มนวลทีละเฟรมยังไง" ทั้งสองอย่างไม่ได้แทนที่กันเลย โค้ดการเคลื่อนที่ที่ใช้งานจริงแทบทุกที่จะเอา steering มาซ้อนทับผลลัพธ์ของ pathfinder เสมอ

PATHFINDING (previous chapter, 11.1) STEERING (this chapter) hands back a list of WAYPOINTS moves smoothly BETWEEN them start *---*---*---*---* goal start o~~curve~~o~~curve~~o goal (straight, jagged segments, (Seek each waypoint in turn, Arrive correct but stiff-looking) on the last one, curves the corners)

รูปแบบง่ายๆ คือ: seek waypoint ปัจจุบัน พอ agent เข้ามาอยู่ใน arrival radius เล็กๆ ของมันแล้ว ก็เลื่อนไป waypoint ถัดไป พอถึง waypoint สุดท้าย ให้สลับจาก seek เป็น arrive เพื่อให้ agent หยุดจริงๆ แทนที่จะพุ่งทะลุเป้าหมายแล้ววนกลับมาใหม่

public class PathFollower
{
    public System.Collections.Generic.List<Vector2> waypoints;
    public int currentIndex = 0;
    public float arrivalRadius = 0.6f;
    public float slowingRadius = 2f;
    public float maxSpeed = 5f;
    public float maxForce = 10f;

    public Vector2 GetSteering(Vector2 position, Vector2 velocity)
    {
        Vector2 target = waypoints[currentIndex];
        float dist = Vector2.Distance(position, target);
        bool isLastWaypoint = currentIndex == waypoints.Count - 1;

        if (dist < arrivalRadius && !isLastWaypoint)
        {
            currentIndex++;
            target = waypoints[currentIndex];
            isLastWaypoint = currentIndex == waypoints.Count - 1;
        }

        return isLastWaypoint
            ? Steering.Arrive(position, velocity, target, maxSpeed, maxForce, slowingRadius)
            : Steering.Seek(position, velocity, target, maxSpeed, maxForce);
    }
}

เส้นทางที่มีสอง waypoint คือ (4, 0) แล้วตามด้วย (4, 4) เริ่มจาก origin ที่หยุดนิ่ง, arrivalRadius = 0.6, slowingRadius = 2, maxSpeed = 5, maxForce = 10, dt = 0.2:

step  pos.x   pos.y  idx  distToWaypoint
 0    0.000   0.000    0     4.000
 1    0.200   0.000    0     3.800
 2    0.560   0.000    0     3.440
 3    1.048   0.000    0     2.952
 4    1.638   0.000    0     2.362
 5    2.311   0.000    0     1.689
 6    3.049   0.000    0     0.951
 7    3.839   0.000    0     0.161
 8    4.479   0.200    1     3.830
 9    4.966   0.558    1     3.575
10    5.302   1.037    1     3.236
11    5.490   1.604    1     2.822
12    5.535   2.227    1     2.345
13    5.440   2.876    1     1.826
14    5.220   3.508    1     1.315
15    4.922   4.063    1     0.924
16    4.591   4.501    1     0.775

พอถึง step 7 agent อยู่ห่างจาก waypoint 0 แค่ 0.161 — เข้ามาอยู่ใน arrival radius 0.6 แล้ว — ดังนั้นเฟรมถัดไป currentIndex จะเปลี่ยนเป็น 1 และเป้าหมายก็กระโดดไปที่ waypoint สุดท้าย เพราะตอนนี้เป็น waypoint สุดท้ายแล้ว steering จึงสลับจาก seek เป็น arrive และจะเห็นการชะลอความเร็วนุ่มๆ แบบเดียวกับหัวข้อ 4 เริ่มทำงาน คือระยะทางลดลงต่อเนื่อง (2.822 -> 2.345 -> 1.826 -> 1.315 -> 0.924 -> 0.775) โดยไม่มีการกระตุกหรือพุ่งเลยเป้าเลย การแบ่งงานเป็นสองชั้นแบบนี้ — pathfinder ตัดสินใจว่าไปทางไหน steering ตัดสินใจว่าจะเคลื่อนที่ไปยังไง — คือวิธีที่เกมที่วางขายจริงส่วนใหญ่จัดโครงสร้างการเคลื่อนที่ของ NPC

11. กำราบอาการ Jitter

"agent ของฉัน steering แล้วมันสั่น/กระตุก/สั่นไหว" เป็นบั๊กรีพอร์ตเกี่ยวกับ steering ที่พบบ่อยที่สุดแทบจะอันดับหนึ่งเลยก็ว่าได้ แทบทุกเคสมักย้อนกลับไปสู่สาเหตุจำนวนหนึ่งซึ่งมีไม่กี่แบบ และทุกแบบก็เคยโผล่มาให้เห็นแล้วในบทนี้

นอกจากการเอา hard threshold ออกแล้ว วิธีแก้ทั่วไปที่มีประโยชน์ที่สุดวิธีเดียวคือ smooth steering force เอง ก่อนจะเอาไปใช้ โดยใช้ Lerp จากบท 2.4 คือแทนที่จะเอา steering force ดิบของเฟรมนี้ไปใช้ตรงๆ ให้ผสมมันเข้ากับแรงที่ smooth ไว้แล้วจากเฟรมก่อนหน้าทีละนิด

Vector2 smoothedSteering;

Vector2 GetSmoothedSteering(Vector2 rawSteering, float smoothing)
{
    smoothedSteering = Vector2.Lerp(smoothedSteering, rawSteering, smoothing); // smoothing in (0, 1]
    return smoothedSteering;
}

หกเฟรมของสัญญาณ steering ดิบที่กระโดดหักดิบระหว่าง (5, 0) กับ (-4, 3) สองรอบติดกัน (ลองนึกภาพ behavior สลับกันแบบทันทีทันใด) smooth ด้วย Lerp(prev, raw, 0.3):

frame  raw.x   raw.y  smoothed.x  smoothed.y
  0    5.000   0.000       5.000       0.000
  1    5.000   0.000       5.000       0.000
  2   -4.000   3.000       2.300       0.900
  3   -4.000   3.000       0.410       1.530
  4    5.000   0.000       1.787       1.071
  5    5.000   0.000       2.751       0.750

สัญญาณดิบกระโดดทันทีทุกครั้งที่เปลี่ยนค่า (จากเฟรม 1 ไปเฟรม 2 แล้วก็จากเฟรม 3 ไปเฟรม 4 อีกรอบ) ส่วนค่าที่ smooth แล้วไม่เคยกระโดดเลย มันจะค่อยๆ ขยับเข้าใกล้ค่าดิบปัจจุบันทีละเสี้ยวเสมอ เป็นไอเดีย blending เดียวกับที่บท 2.4 ใช้กับ animation และการเคลื่อนกล้อง แค่เอามาใช้กับแรงแทนที่จะเป็นตำแหน่ง ค่า smoothing ที่เล็ก (ใกล้ 0) จะได้ agent ที่นิ่งขึ้นแต่ตอบสนองช้าลง ค่าที่ใหญ่กว่า (ใกล้ 1) จะตามสัญญาณดิบได้ใกล้เคียงกว่าแต่ smooth น้อยลง

Common mistake smooth หนักเกินไปจน agent รู้สึกอืดและตอบสนองช้า แล้วก็ไป "แก้" ด้วยการเพิ่ม maxForce กลับขึ้นไปอีก ซึ่งจะดึงเอาอาการแกว่งตัวเดียวกับที่ smoothing พยายามกำจัดกลับมาอีกครั้ง ถ้า agent ต้องการทั้งตอบสนองไวและไม่สั่น วิธีแก้ที่ถูกต้องมักไม่ใช่ smooth เพิ่มหรือเพิ่มแรง แต่คือเอา hard threshold ที่เป็นต้นเหตุของการสลับไปมาออกไปตั้งแต่แรก
Tip ก่อนจะรีบไปใช้ smoothing ให้ถามตัวเองก่อนเสมอว่า "มีอะไรเปลี่ยนไปเยอะๆ ในเฟรมเดียว โดยไม่มีเหตุผลแบบค่อยเป็นค่อยไปบ้าง" อาการ jitter แทบจะมีสาเหตุที่ชัดเจนและหาเจอได้เสมอ (threshold, ค่าสุ่มที่ถูกสุ่มใหม่, หรือ weight ที่แย่งกัน) — smoothing แค่ซ่อนอาการ แต่การหาสาเหตุจริงจะแก้ปัญหาได้ถาวร

12. อภิธานศัพท์ (Glossary)

13. แบบฝึกหัด

Exercise 1 agent อยู่ที่ position = (0, 0) ด้วย velocity = (1, 0) กำลัง seek เป้าหมายที่ (6, 8) โดย maxSpeed = 5, maxForce = 3, dt = 1 คำนวณตามลำดับ: desired velocity, steering force ดิบ (desired - velocity) พร้อมความยาวของมัน, steering force ที่ clamp แล้ว, velocity ใหม่หลังจากผ่านไปหนึ่ง step และ position ใหม่หลังจากผ่านไปหนึ่ง step
Show answer

Desired velocity: ทิศทางไปเป้าหมายคือ (6, 8) ซึ่งมีความยาว sqrt(6^2 + 8^2) = sqrt(100) = 10 (สามเหลี่ยมมุมฉาก 6-8-10) พอ normalize แล้วได้ (0.6, 0.8) สเกลด้วย maxSpeed = 5 ได้ desired = (3, 4)

Raw steering: desired - velocity = (3 - 1, 4 - 0) = (2, 4) ความยาว sqrt(4 + 16) = sqrt(20) ~= 4.472

Clamped steering: 4.472 เกิน maxForce = 3 จึงต้องสเกลด้วย 3 / 4.472 ~= 0.6708: steer ~= (1.342, 2.683)

New velocity: velocity + steer * dt = (1 + 1.342, 0 + 2.683) = (2.342, 2.683) ความยาวของมันคือ ~= 3.561 ซึ่งต่ำกว่า maxSpeed = 5 เลยไม่ต้อง clamp ความเร็ว

New position: position + newVelocity * dt = (0 + 2.342, 0 + 2.683) = (2.342, 2.683)

สังเกตว่า velocity ใหม่ยังไม่ได้ชี้ตรงไปที่เป้าหมาย และ agent ก็ยังอยู่ห่างจากเป้าหมายมากหลังจากผ่านไปหนึ่ง step — เป็นเรื่องปกติ เพราะ seek แค่ขยับ velocity ให้เข้าใกล้ desired ทีละนิดในแต่ละเฟรมเท่านั้น ต้องใช้เวลาหลายเฟรม (ตามที่ trace ของหัวข้อ 2 แสดงให้เห็น) กว่า velocity จะลู่เข้าหาเป้าหมาย

Exercise 2 arrive behavior ตัวหนึ่งมี maxSpeed = 8 และ slowingRadius = 4 คำนวณ desired speed ที่ระยะห่างจากเป้าหมายสามค่า: 10, 3 และ 1
Show answer

ที่ distance = 10: อยู่นอก slowingRadius = 4 ดังนั้น desiredSpeed = maxSpeed = 8 (ความเร็วเต็มที่ เหมือน seek เป๊ะๆ)

ที่ distance = 3: อยู่ในรัศมี slowing radius แล้ว ดังนั้น desiredSpeed = maxSpeed * (distance / slowingRadius) = 8 * (3 / 4) = 6

ที่ distance = 1: อยู่ในรัศมี slowing radius เช่นกัน ดังนั้น desiredSpeed = 8 * (1 / 4) = 2

desired speed จะเริ่มลดลงก็ต่อเมื่อ agent เข้ามาในรัศมี slowing radius แล้วเท่านั้น และมันจะลดลงเป็นเส้นตรงจนเหลือ 0 พอดีตอนถึงเป้าหมาย — เป็น ramp เดียวกับที่ไดอะแกรมในหัวข้อ 4 แสดงไว้

Exercise 3 boid ตัวหนึ่งมีค่าจาก flocking สามส่วน: separation = (-1, 3), alignment = (2, 1), cohesion = (4, -2) โดย weight wSep = 2, wAlign = 1, wCoh = 0.5 และ maxForce = 6 คำนวณผลรวมแบบถ่วงน้ำหนัก (weighted sum) และถ้าจำเป็น ให้คำนวณ steering force สุดท้ายที่ clamp แล้วด้วย
Show answer

Weighted sum: 2*(-1, 3) + 1*(2, 1) + 0.5*(4, -2) = (-2, 6) + (2, 1) + (2, -1) = (2, 6)

Length: sqrt(2^2 + 6^2) = sqrt(4 + 36) = sqrt(40) ~= 6.325 ซึ่งเกิน maxForce = 6

Clamped: สเกลด้วย 6 / 6.325 ~= 0.9487 ได้ steer ~= (1.897, 5.692) ซึ่งมีความยาวเท่ากับ 6.000 พอดี

separation ได้ weight สูงสุด (2) ส่วน cohesion ได้ weight ต่ำสุด (0.5) ตรงกับลำดับความสำคัญที่ใช้จริงทั่วไป คือการไม่ให้ boid ซ้อนทับกันสำคัญกว่าการทำให้กลุ่มอยู่ชิดศูนย์กลางกันแน่นๆ

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