บทที่แล้ว (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) และจะหยุดมันยังไง
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
มีตัวเลขสองตัวที่คุมว่า 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 ที่แปลงมันเป็นการเคลื่อนที่จะไม่เปลี่ยนอีกเลย
steering = desired velocity - current velocity อ่านมันแบบนี้ "แรงที่ต้องใช้เพื่อปิดช่องว่างระหว่างที่กำลังเคลื่อนที่อยู่ กับที่อยากจะเคลื่อนที่" พอเห็น pattern นี้แล้ว จะเดาเนื้อหาส่วนใหญ่ของบทนี้ได้ล่วงหน้าก่อนอ่านด้วยซ้ำvelocity = desired) แทนที่จะ steer เข้าหามัน (velocity += steering * dt) วิธีแรกจะทำให้ทิศทางของ agent หันปุ๊บทันทีทุกเฟรม ไม่มี momentum ไม่มีการเลี้ยวที่นุ่มนวล คืออาการ "ทันทีทันใด ไม่ลอยตัว" แบบหุ่นยนต์ที่บท 6.2 เคยเตือนไว้เรื่อง player controller เลย ประเด็นทั้งหมดของการส่งทุกอย่างผ่านแรงก็เพื่อให้ velocity เปลี่ยนแบบค่อยเป็นค่อยไปSeek คือ behavior จริงๆ ที่ง่ายที่สุด: เคลื่อนที่เข้าหาตำแหน่งเป้าหมายให้ตรงที่สุดเท่าที่จะทำได้ ขั้นตอน "อยากทำอะไร" จากหัวข้อ 1 กลายเป็น "อยากเคลื่อนที่ด้วยความเร็ว maxSpeed ตรงเข้าหาเป้าหมาย" คำนวณ desired velocity นั้นออกมา ลบด้วย velocity ปัจจุบัน ก็จะได้ steering force
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
Flee ก็คือ seek ที่กลับเครื่องหมาย: desired velocity ชี้ออกจากภัยคุกคาม (threat) แทนที่จะชี้เข้าหาเป้าหมาย โค้ดที่เหลือทุกบรรทัดเหมือนเดิมทุกอย่าง — นี่เป็นสัญญาณที่ดีว่าสูตรในหัวข้อ 1 ทำงานได้จริง ไม่ได้อธิบายแค่เฉพาะ 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) นิดหน่อย เพื่อไม่ให้วิ่งเป็นเส้นตรงที่เดาทางได้ง่ายเกินไป
Seek(position, velocity, threat, ...) แล้วเอาผลลัพธ์มา negate ทีหลัง แทนที่จะกลับทิศทางตรงจุดคำนวณ desired velocity ตั้งแต่ต้น การ negate steering force สุดท้ายไม่เหมือนกับการ flee — มันกลับแค่ตัว correction ไม่ได้กลับปลายทาง และทำให้การเคลื่อนที่ไม่ได้ชี้ออกจาก threat อย่างชัดเจน ยกเว้นจะบังเอิญพอดีArrive แก้ปัญหา "พุ่งทะลุเป้าหมาย" ของ seek ด้วยการลด desired speed ลงเมื่อ agent เข้าใกล้เป้าหมาย นอกรัศมี slowing radius arrive จะทำตัวเหมือน seek เป๊ะๆ คือวิ่งเต็มสปีดตรงเข้าหาเป้าหมาย แต่พอเข้ามาในรัศมี slowing radius แล้ว desired speed จะค่อยๆ ลดลงเป็นเส้นตรงตามระยะทาง จนเหลือศูนย์พอดีตอนถึงเป้าหมาย
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 มีอยู่ มันคือหน้าตาที่ตัวละครเพื่อนร่วมทีมหรือรถที่จอดเองควรจะเป็น
slowingRadius จากความสามารถในการชะลอความเร็วจริงๆ ของ agent อย่าเลือกมั่วๆ agent ที่เร็วแต่ maxForce ต่ำ ต้องใช้ slowing radius ที่ใหญ่กว่าเพื่อไม่ให้พุ่งเลยเป้า (เพราะ steering force ของมันอ่อนเกินกว่าจะหักล้างความเร็วสูงๆ ได้ทัน) ส่วน agent ที่ช้าหรือคล่องตัว ใช้ slowing radius เล็กๆ ได้เลยseek จะเล็งไปที่ตำแหน่งที่เป้าหมายอยู่ตอนนี้ ซึ่งใช้ได้ดีถ้าเป้าหมายอยู่นิ่ง แต่ถ้าเป้าหมายกำลังเคลื่อนที่ พอ agent ไปถึงจุดนั้น เป้าหมายก็ขยับหนีไปแล้ว agent จะกลายเป็นไล่ตามหางตัวเองตลอด เล็งพลาดไปข้างหลังนิดๆ เสมอ Pursue แก้ปัญหานี้ด้วยการเล็งไปที่ ตำแหน่งในอนาคตที่คาดการณ์ไว้ (predicted future position) แทน คือเอาตำแหน่งปัจจุบันของเป้าหมาย บวกด้วย velocity ของมัน คูณเวลามองล่วงหน้า (lookahead time) สักค่าหนึ่ง แล้ว seek ไปที่จุดนั้น Evade ก็คือไอเดียเดียวกันแต่เอาไปใช้กับ flee — คาดการณ์ก่อน แล้ววิ่งหนีจากจุดที่คาดการณ์นั้น
เวลามองล่วงหน้าต้องยิ่งสั้นลงเมื่อผู้ไล่ (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 ธรรมดาจะเป็น
Wander ให้ agent มีอะไรทำตอนที่ไม่มีเป้าหมายเลย — เช่น NPC พื้นหลังที่เดินป้วนเปี้ยน เพื่อนร่วมทีมที่ดูอยากรู้อยากเห็น หรือสัตว์ป่าบรรยากาศ วิธีง่ายๆ แบบซื่อๆ คือสุ่มทิศทางใหม่ทุกเฟรม แต่แบบนั้นดูแย่มาก เพราะ agent จะสั่นให้เห็นชัดๆ เนื่องจากทิศทางอาจพลิกเกือบ 180 องศาระหว่างสองเฟรมติดกันได้ Wander แก้ปัญหานี้ด้วยการเก็บมุมตัวเดียวที่วิ่งต่อเนื่อง (running angle) แล้วขยับมันทีละนิดด้วยค่าสุ่มเล็กๆ ในแต่ละเฟรม ทำให้ทิศทางค่อยๆ เลื่อนไปอย่างนุ่มนวลแทนที่จะกระโดด
เพื่อให้ได้ตัวเลข 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)
UnityEngine.Random.Range หรือ System.Random ที่ใส่ seed ไว้ แทนที่จะเขียน LCG เองแบบนี้ — LCG ในตัวอย่างนี้มีไว้แค่เพื่อให้ตัวเลขใน trace นี้ทำซ้ำได้แม่นยำบนกระดาษเท่านั้น สิ่งที่สำคัญคือเทคนิคของ wander (ขยับมุมทีละนิด ไม่ใช่สุ่มทิศทางใหม่ทั้งหมด) ซึ่งใช้ได้ผลเหมือนกันไม่ว่าจะใช้แหล่งสุ่มไหนก็ตามagent ที่ทำแค่ seek หรือ wander อย่างเดียว จะเดินทะลุกำแพง ลัง หรือสิ่งกีดขวางอื่นๆ ไปเลย Obstacle avoidance ให้วิธีง่ายๆ ในการสังเกตอันตรายข้างหน้า คือยิง "feeler" สั้นๆ (เส้นตรง หรือในเวอร์ชันย่อนี้คือแค่จุดเดียว) ออกไปข้างหน้า agent แล้วเช็คว่าจุดนั้นตกอยู่ในสิ่งกีดขวางไหน ถ้าใช่ ก็ steer หนีออกไปแรงๆ
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 ตามปกติ แต่คราวนี้เป็นเส้นทางที่โค้งอ้อมใต้สิ่งกีดขวางอย่างเรียบร้อย แทนที่จะพุ่งทะลุเข้าไป
Physics2D.Raycast หรือ Physics.SphereCast ยิงไปตามทิศทาง velocity เทียบกับ LayerMask ของสิ่งกีดขวาง จะได้ feeler ที่แม่นยำกว่าการเช็คจุดเดียวมาก รวมถึงชนกับ mesh รูปทรงอะไรก็ได้ ไม่ใช่แค่วงกลม สูตรคณิตของ steering ด้านบนเหมือนเดิมทุกอย่าง เปลี่ยนแค่วิธีตรวจจับว่า "มีอะไรขวางอยู่ไหม" เท่านั้น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 พอดี
Flocking คือสิ่งที่เกิดขึ้นเมื่อ agent ทุกตัวในกลุ่มรันกฎ steering ง่ายๆ สามข้อชุดเดียวกันเป๊ะๆ โดยมองแค่เพื่อนบ้าน (neighbor) ที่อยู่ใกล้ๆ เท่านั้น ไม่มีหัวหน้า ไม่มีแผนการรวมศูนย์ใดๆ Craig Reynolds เรียก agent จำลองเหล่านี้ว่า boids และตีพิมพ์เทคนิคนี้ในปี 1987 จนถึงตอนนี้มันก็ยังเป็นวิธีมาตรฐานในการทำฝูงนก ฝูงปลา ฝูงสัตว์ หรือฝูงชนที่ดูสมจริง โดยไม่ต้องเขียนสคริปต์กำหนดเส้นทางเฉพาะให้ทีละตัวเลย
แต่ละกฎจะมองแค่เพื่อนบ้านที่อยู่ในรัศมีที่กำหนดเท่านั้น (ถ้าเช็คทั้งฝูงทุกเฟรมจะสเกลไม่ไหว แล้ว 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 ก็คอยกันไม่ให้ทั้งกลุ่มลอยห่างออกจากกัน กฎง่ายๆ ที่มองแค่รอบตัวสามข้อ ก็ทำให้ฝูงเกิดขึ้นเองได้แล้ว
maxForce ทำให้ over-correct ทุกเฟรมPathfinding (บท 11.1 — A* บน grid หรือ navmesh) ตอบคำถามว่า "ต้องผ่านจุดไหนตามลำดับถึงจะไปจากที่นี่ถึงเป้าหมายได้โดยไม่เดินทะลุกำแพง" มันจะคืนค่าเป็นลิสต์ของ waypoint ออกมา คือช่วงเส้นตรงต่อๆ กัน ซึ่งมักจะมีมุมหักคมๆ ดูเป็นหุ่นยนต์ ส่วน steering ตอบคำถามคนละแบบเลย คือ "ถ้ารู้ waypoint ถัดไปที่ต้องไปแล้ว จะเคลื่อนที่เข้าหามันให้นุ่มนวลทีละเฟรมยังไง" ทั้งสองอย่างไม่ได้แทนที่กันเลย โค้ดการเคลื่อนที่ที่ใช้งานจริงแทบทุกที่จะเอา steering มาซ้อนทับผลลัพธ์ของ pathfinder เสมอ
รูปแบบง่ายๆ คือ: 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
"agent ของฉัน steering แล้วมันสั่น/กระตุก/สั่นไหว" เป็นบั๊กรีพอร์ตเกี่ยวกับ steering ที่พบบ่อยที่สุดแทบจะอันดับหนึ่งเลยก็ว่าได้ แทบทุกเคสมักย้อนกลับไปสู่สาเหตุจำนวนหนึ่งซึ่งมีไม่กี่แบบ และทุกแบบก็เคยโผล่มาให้เห็นแล้วในบทนี้
maxForce เต็มที่ทันที" ในวินาทีที่ feeler ข้ามระยะที่กำหนดไว้พอดี ถ้า agent อยู่ตรงขอบเขตนั้นพอดี — ซึ่งเกิดขึ้นบ่อยมากกับ agent ที่เคลื่อนที่ช้าๆ หรือลอยนิ่งๆ — จะทำให้แรงกระพริบ เปิด-ปิด ทุกเฟรม แก้ได้ด้วย hysteresis คือใช้ระยะที่ไกลกว่าอีกค่าหนึ่งสำหรับปิด avoidance ต่างจากระยะที่ใช้เปิดมัน เพื่อให้ agent ต้องออกห่างไปชัดเจนก่อนที่ avoidance จะปล่อยmaxForce สูงเกินไปเมื่อเทียบกับ maxSpeed agent ที่เปลี่ยน velocity ได้เกือบทันทีจะพุ่งเลยทิศทางที่ต้องการ แล้ว over-correct กลับไปอีกทาง แล้วก็พุ่งเลยอีก — เกิดการแกว่ง (oscillation) เร็วๆ เล็กๆ นี่คือปัญหาตระกูลเดียวกับ "พลังงานรั่วไหลเข้ามา" ที่บท 2.5 เคยพูดถึงเรื่อง physics integrator เลย แค่คราวนี้ออกมาเป็นมุมที่โยกไปมา แทนที่จะเป็นพลังงานที่เพิ่มขึ้นเรื่อยๆนอกจากการเอา 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 น้อยลง
maxForce กลับขึ้นไปอีก ซึ่งจะดึงเอาอาการแกว่งตัวเดียวกับที่ smoothing พยายามกำจัดกลับมาอีกครั้ง ถ้า agent ต้องการทั้งตอบสนองไวและไม่สั่น วิธีแก้ที่ถูกต้องมักไม่ใช่ smooth เพิ่มหรือเพิ่มแรง แต่คือเอา hard threshold ที่เป็นต้นเหตุของการสลับไปมาออกไปตั้งแต่แรก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 ใหม่หลังจากผ่านไปหนึ่ง stepDesired 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 จะลู่เข้าหาเป้าหมาย
maxSpeed = 8 และ slowingRadius = 4 คำนวณ desired speed ที่ระยะห่างจากเป้าหมายสามค่า: 10, 3 และ 1ที่ 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 แสดงไว้
separation = (-1, 3), alignment = (2, 1), cohesion = (4, -2) โดย weight wSep = 2, wAlign = 1, wCoh = 0.5 และ maxForce = 6 คำนวณผลรวมแบบถ่วงน้ำหนัก (weighted sum) และถ้าจำเป็น ให้คำนวณ steering force สุดท้ายที่ clamp แล้วด้วย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 ซ้อนทับกันสำคัญกว่าการทำให้กลุ่มอยู่ชิดศูนย์กลางกันแน่นๆ