10.4 ผสาน Physics กับ Gameplay

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

เชื่อม physics กับ game logic ให้สะอาด — trigger, แรงและ impulse และคุมให้ deterministic ตรงที่จำเป็น

เรารู้จัก Rigidbody กับ Collider มาแล้ว เห็น Unity ทำให้ object ตกลงมา เด้ง แล้วหยุดชนกับกำแพงได้เอง บทนี้พูดถึงขั้นถัดไป คือการเชื่อม physics simulation นั้นเข้ากับ gameplay จริง ๆ เช่น เก็บเหรียญ โดนความเสียหายจากลาวา โดนแรงระเบิดกระแทกกระเด็น หรือยิง raycast ไปตามทางเดิน โค้ด physics ที่ดูถูกต้องตอนดูแยกส่วน ก็ยังเป็นสาเหตุของบั๊กที่งงที่สุดในโปรเจกต์ Unity ได้อยู่ดี เช่น การเคลื่อนที่กระตุก, เก็บของไม่ติด, ความสูงของการกระโดดขึ้นกับ frame rate เกือบทั้งหมดของปัญหาพวกนี้ย้อนกลับไปที่กฎไม่กี่ข้อที่บทนี้จะพูดถึง

1. เชื่อม Physics Engine เข้ากับ Gameplay Code

Unity มี physics engine (ระบบแยกต่างหากที่จำลอง rigid body, แรงโน้มถ่วง, และการชนกัน — engine 3D ตัว default ของ Unity ชื่อ PhysX) รันอยู่ข้างใต้สคริปต์ของเราตลอดเวลา มันมีนาฬิกาและ loop ของตัวเอง แยกจาก game code ส่วนที่เหลือโดยสิ้นเชิง หน้าที่ของเราไม่ใช่คำนวณ physics เอง แต่คือการคุยกับ physics engine ให้เรียบร้อย บอกมันว่าเราต้องการอะไร (แรง, ตำแหน่งเป้าหมาย) แล้วฟังสิ่งที่มันรายงานกลับมา (การชน, การซ้อนทับ, ตำแหน่งใหม่)

มี component สองตัวที่ทำให้ GameObject อยู่ภายใต้การควบคุมของ physics engine

Rigidbody มีได้สามแบบ และการสับสนระหว่างสามแบบนี้เป็นสาเหตุความงงที่พบบ่อย

วิธีคิดที่สะอาดที่สุดเกี่ยวกับความสัมพันธ์นี้คือ gameplay code ส่ง ความตั้งใจ ไปให้ physics engine (แรง, impulse, ตำแหน่งเป้าหมาย) แล้ว physics engine ส่ง ข้อเท็จจริง กลับมา (สิ่งต่าง ๆ ไปจบที่ไหน อะไรสัมผัสกับอะไร) การฝืนกฎนี้ — เขียนทับ transform.position ของ dynamic Rigidbody ตรง ๆ ทุกเฟรม — จะทำให้ simulation พังลง เพราะตอนนี้มีสองระบบที่คิดว่าตัวเองเป็นเจ้าของค่าเดียวกัน

Gameplay code ของเรา Physics engine (PhysX) ------------------- ----------------------- AddForce() / MovePosition() ----------> จำลอง Rigidbody ทุกตัว (ส่งครั้งเดียว เมื่อจำเป็น) ทีละ fixed step OnCollisionEnter/Exit() <---------- รายงานการสัมผัส OnTriggerEnter/Exit() <---------- รายงานการซ้อนทับ rb.position, rb.linearVelocity <--------- รายงานสถานะที่ได้ Gameplay code ไม่เคยตั้งค่า transform.position ตรง ๆ บน dynamic Rigidbody -- มันส่งแรงหรือเรียก MovePosition แล้ว อ่านผลลัพธ์กลับมาผ่าน event กับ property ของ rb

นี่คือความผิดพลาดในรูปแบบโค้ด พร้อมวิธีแก้


public class BadMover : MonoBehaviour
{
    public float speed = 5f;

    // BAD: this object also has a non-kinematic Rigidbody attached.
    // Overwriting transform.position every frame ignores the physics
    // engine completely -- the object can clip straight through walls,
    // and any collision that does happen looks jittery, because the
    // physics engine and this script are fighting over the same value.
    void Update()
    {
        transform.position += Vector3.forward * speed * Time.deltaTime;
    }
}

public class GoodMover : MonoBehaviour
{
    public float speed = 5f;
    private Rigidbody rb;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    // GOOD: MovePosition hands the move to the physics engine. It sweeps
    // the body through space between steps, so collisions along the way
    // are still detected correctly, and it belongs in FixedUpdate --
    // more on that split in Section 4.
    void FixedUpdate()
    {
        Vector3 target = rb.position + Vector3.forward * speed * Time.fixedDeltaTime;
        rb.MovePosition(target);
    }
}

ผลลัพธ์ที่คาดหวัง: GoodMover เคลื่อนที่ไปข้างหน้าด้วยความเร็วคงที่ และยังหยุดถูกต้องเมื่อชน collider ของกำแพง เพราะ physics engine เป็นคนทำการเคลื่อนที่เองและตรวจจับกำแพงได้ระหว่างทาง ส่วน BadMover เคลื่อนที่ด้วยความเร็วเดียวกันบนพื้นโล่ง ๆ แต่จะทะลุกำแพงบาง ๆ ไปเลยถ้ามันเคลื่อนที่เร็วพอระหว่างเฟรม เพราะมันไม่เคยขอให้ physics engine ตรวจสอบให้เลย

Tip กฎง่าย ๆ ที่ครอบคลุมเนื้อหาส่วนใหญ่ของบทนี้ ถ้า GameObject มี Rigidbody แบบ non-kinematic อย่าเขียนทับ transform.position หรือ transform.rotation ของมันตรง ๆ เด็ดขาด ใช้ AddForce, AddTorque, MovePosition, หรือ linearVelocity แทน แล้วปล่อยให้ physics engine เป็นเจ้าของ Transform ไป

2. Solid Collider กับ Trigger Collider

Collider ทุกตัวมี checkbox ใน Inspector ชื่อ Is Trigger มันเปลี่ยน collider ของเราให้เป็นคนละแบบกัน

นี่คือเครื่องมือสำหรับกรณีที่เราคาดเดาได้เลย เช่น เหรียญ ยาเพิ่มเลือด zone ที่ทำความเสียหาย volume ที่มองไม่เห็นแบบ "ผู้เล่นเข้ามาในห้องนี้แล้ว" — ทุกอย่างที่ควร รู้ ว่ามีอะไรสัมผัสมัน โดยไม่ต้องหยุดมันทางกายภาพ

________________________ | | | Trigger Volume | Collider.isTrigger = true | (health pickup) | Player --> | | --> player เดินต่อไป walks in |________________________| ไม่มีอะไรผลักกลับ | v OnTriggerEnter(Collider other) ทำงานครั้งเดียว ทันทีที่ collider ทั้งสองเริ่มซ้อนทับกัน
Solid collider (isTrigger = false) Trigger collider (isTrigger = true) ----------------------------------- ------------------------------------ Player --> [ WALL ] Player --> [ ZONE ] --> เดินต่อไป ถูกหยุดทางกายภาพ ผ่านไม่ได้ ทะลุผ่านไปได้เลย ไม่ถูกบล็อก OnCollisionEnter ทำงาน OnTriggerEnter ทำงาน

เพื่อให้ collider สองตัวเกิด event อะไรก็ตาม (collision หรือ trigger) อย่างน้อยหนึ่งใน object ทั้งสองต้องมี Rigidbody trigger volume ที่มีแค่ Collider ไม่มี Rigidbody เลย ก็ยังตรวจจับ player ที่มี Rigidbody เดินเข้ามาได้ ในทางกลับกันก็ทำงานเช่นกัน collider เปล่า ๆ สองตัวที่ไม่มี Rigidbody เลยจะไม่ส่ง event หากันเลย เพราะ physics engine ไม่เสียเวลาเช็คคู่ static-กับ-static สำหรับ event เนื่องจากทั้งคู่ขยับไม่ได้อยู่แล้ว

ข้อผิดพลาดที่พบบ่อย ลืมใส่ Rigidbody ไปเลย trigger volume ที่มีแค่ Collider วางคู่กับ player ที่มีแค่ Collider เหมือนกันโดยไม่มี Rigidbody จะไม่มีวันเรียก OnTriggerEnter เลย ถ้าดูเหมือนไม่มีอะไรตรวจจับ player ได้ ให้เช็คว่า player (หรือ trigger) มี Rigidbody ติดอยู่จริงหรือเปล่า

ตอนไหนควรใช้ solid collider: กำแพง พื้น ตัวละคร ลัง — อะไรก็ตามที่ควรหยุดหรือผลัก object อื่นทางกายภาพ ตอนไหนควรใช้ trigger: pickup, damage zone, checkpoint, volume เปลี่ยนด่าน, sensor แบบ "player อยู่ใกล้สิ่งนี้ไหม" — อะไรก็ตามที่ควรแค่ รู้ ไม่ใช่บล็อก

3. ตรวจจับการซ้อนทับ: OnTriggerEnter กับ OnTriggerExit

Unity เรียก method สามตัวบน MonoBehaviour ที่แนบอยู่กับ trigger collider (หรือ collider อีกฝั่งที่เกี่ยวข้อง) ให้เองอัตโนมัติ ทุกครั้งที่การซ้อนทับเริ่ม ดำเนินต่อ หรือจบลง

pickup ต้องการแค่ OnTriggerEnter


using UnityEngine;

public class CoinPickup : MonoBehaviour
{
    public int value = 10;

    void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out PlayerScore score))
        {
            score.Add(value);
            Debug.Log("Coin collected, +" + value);
            Destroy(gameObject);
        }
    }
}

ผลลัพธ์ที่คาดหวัง: ทันทีที่ collider ของ player สัมผัส trigger collider ของเหรียญ console จะพิมพ์ Coin collected, +10 แล้วเหรียญก็หายไป ถ้ามีอะไรที่ไม่มี component PlayerScore มาโดน trigger แทน (เช่น enemy หลงเข้ามา) จะไม่มีอะไรเกิดขึ้น เพราะเช็ค TryGetComponent จะข้ามไปเฉย ๆ

damage zone ต้องการการตรวจจับต่อเนื่อง เลยใช้ OnTriggerStay แทน


using UnityEngine;

public class LavaPit : MonoBehaviour
{
    public float damagePerSecond = 10f;

    void OnTriggerStay(Collider other)
    {
        if (other.TryGetComponent(out Health health))
        {
            // OnTriggerStay fires once per physics step, so scale the
            // damage by the fixed step's length to get a steady
            // per-second rate no matter how often the step runs.
            float damageThisStep = damagePerSecond * Time.fixedDeltaTime;
            health.TakeDamage(damageThisStep);
        }
    }

    void OnTriggerExit(Collider other)
    {
        if (other.TryGetComponent(out Health health))
        {
            Debug.Log(other.name + " left the lava");
        }
    }
}

ผลลัพธ์ที่คาดหวัง: ขณะที่ player ยืนอยู่ในลาวา Health.TakeDamage จะถูกเรียกทุก physics step ด้วยจำนวนเล็กน้อย รวมกันแล้วเท่ากับ damagePerSecond hit point ที่เสียไปต่อวินาทีจริง พอ player ก้าวออกมา OnTriggerExit จะพิมพ์ Player left the lava ครั้งเดียว แล้วความเสียหายต่อเนื่องก็หยุด

Tip TryGetComponent เป็นนิสัยที่ปลอดภัยกว่าการใช้ GetComponent แล้วเช็ค null ทีหลัง มันคืนค่า bool ตรง ๆ อ่านในประโยค if ได้เป็นธรรมชาติ และหลีกเลี่ยงการเทียบ reference ที่ได้กลับมากับ null แยกต่างหาก
ข้อผิดพลาดที่พบบ่อย ทำงานที่หนัก (spawn particle, เล่นเสียง, รัน AI check) ข้างใน OnTriggerStay โดยไม่รู้ตัวว่ามันทำงานทุก physics step เดียว อาจถึง 50 ครั้งต่อวินาที สำหรับทุก object ที่ยังซ้อนทับอยู่ การคำนวณเล็ก ๆ ต่อ step แบบ damage tick ด้านบนนั้นโอเค แต่การสร้าง particle system ใหม่ทุก step ไม่โอเค

4. การแบ่ง FixedUpdate / Update

game loop ของ Unity เรียก method แบบ "ทำงานหนึ่งครั้งต่อ X" สองแบบที่ต่างกันมาก และการสับสนระหว่างสองแบบนี้เป็นบั๊ก physics ที่มือใหม่เขียนกันบ่อยที่สุดอย่างหนึ่ง

Rendered frames (variable rate, Time.deltaTime): |---frame1---|-frame2-|-----frame3-----|-frame4-|---frame5---| Fixed physics steps (constant rate, Time.fixedDeltaTime = 0.02s): |-step-|-step-|-step-|-step-|-step-|-step-|-step-|-step-|-step-| Update() -> ทำงานครั้งเดียวต่อเฟรมที่ render พอดี FixedUpdate() -> ทำงาน 0, 1, หรือหลายครั้งต่อเฟรมที่ render ห่างกันเท่ากับ fixedDeltaTime เป๊ะเสมอ ไม่ว่า เกมจะ render เร็วหรือช้าแค่ไหน

กฎที่ตามมาจากตรงนี้คือ อ่าน input ใน Update (frame rate ที่เร็วไม่ควรทำให้เรากดปุ่มไม่ทันเลย Input.GetKeyDown ถูกออกแบบให้เช็คครั้งเดียวต่อเฟรมที่ render) และ ใส่แรง, impulse, หรือเปลี่ยนความเร็วใน FixedUpdate (เพื่อให้ physics engine ได้รับค่าที่เราตั้งใจเป๊ะ ๆ ครั้งเดียวต่อ physics step เสมอ ไม่ว่า frame rate จะเท่าไหร่)

นี่คือบั๊กในรูปแบบที่พบบ่อยที่สุด — thruster ที่ดันยานไปข้างหน้าขณะกดปุ่มค้าง


public class BuggyThruster : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 20f;

    // BUG: AddForce is called once per Update -- once per RENDERED
    // frame. At 240 fps, Update runs about 5 times for every single
    // FixedUpdate (at the default 50 Hz). That means roughly 5 times
    // as much force gets queued up per physics step as at 48 fps,
    // where Update barely keeps up with FixedUpdate. Thrust ends up
    // depending on the player's frame rate, not on the thrust value.
    void Update()
    {
        if (Input.GetKey(KeyCode.W))
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}

public class FixedThruster : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 20f;
    private bool thrusting;

    // Only READ input here. Polling once per rendered frame is exactly
    // right for input -- it is cheap, and GetKey/GetKeyDown are built
    // to be checked this often.
    void Update()
    {
        thrusting = Input.GetKey(KeyCode.W);
    }

    // Only APPLY physics here. This runs at a fixed rate, so the force
    // added per physics step is now the same no matter how fast the
    // game is rendering.
    void FixedUpdate()
    {
        if (thrusting)
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}

ผลลัพธ์ที่คาดหวัง: FixedThruster ไปถึงความเร็วสูงสุดเท่ากันหลังจากเวลาจริงจำนวนวินาทีเท่ากัน ไม่ว่าเกมจะรันที่ 30 fps หรือ 300 fps ส่วน BuggyThruster เร่งความเร็วเร็วกว่าเห็นชัด ๆ บนเครื่องแรงเทียบกับเครื่องช้า ทั้งที่รันสคริปต์เดียวกันเป๊ะ

ข้อผิดพลาดที่พบบ่อย อ่าน input ข้างใน FixedUpdate แทน Update ที่ frame rate ต่ำ FixedUpdate อาจไม่ทำงานเลยในเฟรมนั้น หรือทำงานติดกันหลายครั้งเพื่อไล่ตามให้ทัน ทำให้ Input.GetKeyDown (จริงแค่ครั้งเดียวตอนถูกเรียก) ถูกเช็คผิดจังหวะจนพลาดการกด หรือถูกเช็คซ้ำสองครั้งจนทำงานซ้ำ ให้ poll input ใน Update เก็บไว้ในตัวแปร แล้วอ่านตัวแปรนั้นใน FixedUpdate
Tip ข้างใน FixedUpdate ค่า Time.deltaTime จะเท่ากับ Time.fixedDeltaTime โดยอัตโนมัติ — Unity ตั้งค่าให้เราเองตอนเรียก method นี้ การใช้ Time.fixedDeltaTime ตรง ๆ แบบในตัวอย่าง lava ก็ยังเป็นนิสัยที่ดี เพราะทำให้ความตั้งใจของโค้ดชัดเจนสำหรับคนอ่านทีหลัง

5. Layer-Based Collision Matrix

GameObject ทุกตัวมี layer (หมวดหมู่ที่มีหมายเลข สูงสุด 32 อัน ตั้งชื่อได้ใน Project Settings > Tags and Layers — เช่น Player, Enemy, EnemyBullet, Scenery) layer เฉย ๆ ไม่ทำอะไรด้วยตัวมันเอง แต่มีสองระบบที่อ่านมัน คือ rendering (กล้องไหนเห็น layer ไหน) กับ physics

Project Settings > Physics > Layer Collision Matrix คือตาราง checkbox ขนาดใหญ่ หนึ่งแถวหนึ่งคอลัมน์ต่อ layer ช่องที่ติ๊กไว้แปลว่า collider บนสอง layer นั้นได้รับอนุญาตให้เกิด collision หรือ trigger event กันได้ ช่องที่ไม่ติ๊กแปลว่า physics engine ข้ามการเช็คคู่นั้นไปเลย ไม่ใช่แค่เพิกเฉยผลลัพธ์ แต่ไม่ทำงานเช็คตั้งแต่แรก

Player Enemy EnemyBullet PlayerBullet Scenery Player - X X . X Enemy X . . X X EnemyBullet X . . . X PlayerBullet . X . . X Scenery X X X X - X = layer สอง layer นี้ชนกัน / ส่ง trigger event หากันได้ . = physics engine เพิกเฉยคู่นี้ไปเลย (ไม่มี event, ไม่มี collision response, ไม่เสียเวลาเช็คด้วย) อ่านตารางนี้แบบนี้: PlayerBullet กับ Player คือ "." -- กระสุน ของเราเองไม่โดนตัวเราเอง EnemyBullet กับ Enemy คือ "." -- ไม่มี friendly fire ระหว่าง enemy ด้วยกัน

การตั้งค่านี้ครั้งเดียวใน Project Settings มักเป็นทางเลือกที่ถูกต้อง เพราะมันเป็นกฎระดับทั้งโปรเจกต์ที่ตั้งครั้งเดียวจบ มีเครื่องมือเพิ่มอีกสองตัวช่วยจากในโค้ด


using UnityEngine;

public class SetupLayers : MonoBehaviour
{
    void Awake()
    {
        // Same effect as unchecking a box in the matrix, done at runtime.
        int enemyLayer = LayerMask.NameToLayer("Enemy");
        int enemyBulletLayer = LayerMask.NameToLayer("EnemyBullet");
        Physics.IgnoreLayerCollision(enemyLayer, enemyBulletLayer, true);

        // Assigning a layer to a spawned object, in code.
        gameObject.layer = LayerMask.NameToLayer("EnemyBullet");
    }
}

ตอนไหนควรใช้: เมื่อไหร่ก็ตามที่ object สองประเภทไม่ควรมีปฏิสัมพันธ์ทางกายภาพกัน หรือไม่ควรถูกเช็ค event เลยด้วยซ้ำ — ปิด friendly fire, ให้กระสุนไม่โดนคนยิงเอง, ผีเดินทะลุกำแพงได้, layer แบบ "ใช้ตรวจจับอย่างเดียว" ที่ใช้กับ raycast ล้วน ๆ สิ่งนี้ยังลดภาระงานของ physics engine โดยตรงด้วย (Section 12 จะย้อนกลับมาพูดเรื่องนี้) เพราะคู่ที่ถูกเพิกเฉยจะไม่ถูกเช็คเลยด้วยซ้ำ

Tip Layer Collision Matrix มีผลกับคู่ OnCollisionEnter/OnTriggerEnter มันไม่ได้มีผลกับ raycast หรือ overlap query โดยอัตโนมัติ — สองอย่างนั้นต้องการ parameter LayerMask ของตัวเอง ซึ่งจะพูดถึงต่อไป

6. ใส่แรง: AddForce กับ Force Mode

Rigidbody.AddForce(Vector3 force, ForceMode mode) คือเครื่องมือหลักสำหรับผลัก dynamic Rigidbody argument ตัวที่สอง ForceMode เปลี่ยนความหมายของตัวเลขที่เราใส่ การสับสนระหว่างโหมดเหล่านี้เป็นสาเหตุของความงงแบบ "ทำไมแรงของฉันอ่อนไปหรือแรงเกินไป" ที่พบบ่อย

ForceMode.Force ต่อเนื่อง, ใช้ mass, เหมือนเครื่องยนต์จรวด (ใส่ทุก FixedUpdate ขณะเร่งเครื่อง) ForceMode.Acceleration ต่อเนื่อง, ไม่สนใจ mass, ความเร่งเท่ากัน ไม่ว่าจะเป็นขนนกหรือก้อนหิน ForceMode.Impulse ทันที, ใช้ mass, เหมือนแรงกระแทกจากกระสุน (ใส่ครั้งเดียว) ForceMode.VelocityChange ทันที, ไม่สนใจ mass, เปลี่ยนความเร็วตรงเป๊ะ (ใส่ครั้งเดียว)

"ใช้ mass" แปลว่า Rigidbody ที่หนักกว่า (rb.mass สูงกว่า) ต้องการแรงมากกว่าเพื่อขยับในระยะเท่ากัน ตรงกับสามัญสำนึกในโลกจริง (ผลักเบา ๆ แทบไม่ทำให้รถบรรทุกขยับ แต่ทำให้รถเข็นซูเปอร์มาร์เก็ตปลิวไปเลย) "ไม่สนใจ mass" แปลว่าทุก object ได้ความเร่งหรือการเปลี่ยนความเร็วเท่ากันเป๊ะ มีประโยชน์เมื่อเราต้องการ game feel ที่คาดเดาได้และปรับแต่งง่าย มากกว่าความสมจริงทางฟิสิกส์

thruster แบบต่อเนื่องใช้ ForceMode.Force ใส่ทุก FixedUpdate ขณะที่กำลังทำงาน (pattern เดียวกับใน Section 4)


void FixedUpdate()
{
    if (thrusting)
    {
        rb.AddForce(transform.forward * thrust, ForceMode.Force);
    }
}

ผลลัพธ์ที่คาดหวัง: ยานเร่งความเร็วขึ้นอย่างนุ่มนวล ความเร็วเพิ่มขึ้นตามระยะเวลาที่ thrusting เป็นจริง และยานที่หนักกว่า (rb.mass สูงขึ้น) จะเร่งความเร็วช้ากว่าภายใต้ค่า thrust เดียวกันเป๊ะ เพราะ ForceMode.Force สนใจ mass — เหมือนเครื่องยนต์จรวดจริง ๆ บนจรวดที่หนักกว่า

Tip AddForce ที่ไม่ใส่ argument ตัวที่สอง จะใช้ค่า default เป็น ForceMode.Force การเขียนระบุตรง ๆ แบบด้านบน ช่วยคนอ่านคนถัดไป (ซึ่งบ่อยครั้งก็คือตัวเราเองอีกหกเดือนข้างหน้า) ไม่ต้องไปเปิดหาค่า default

7. Raycast กับ Overlap Query: การยิงและการตรวจจับ

ไม่ใช่ทุกคำถามด้าน physics ที่ต้องการ Rigidbody เคลื่อนที่ผ่านโลกเกม บางครั้งเราแค่อยากถาม physics engine เดี๋ยวนี้เลยว่า "มีอะไรอยู่ตรงหน้าปืนกระบอกนี้" หรือ "มีอะไรอยู่ใกล้การระเบิดนี้บ้าง" คำถามแบบครั้งเดียวจบพวกนี้เรียกว่า query และมันอ่านสถานะปัจจุบันของ collider ทุกตัวในฉากโดยไม่จำลองอะไรเลย

raycast (ยิงเส้นตรงบาง ๆ ที่มองไม่เห็นผ่านฉาก แล้วถามว่ามันโดนอะไรก่อน) เป็นวิธีมาตรฐานสำหรับทำ hitscan shooting — ปืนที่ยิงโดนทันที ไม่มีเวลาเดินทางของกระสุนให้เห็น


using UnityEngine;

public class Gun : MonoBehaviour
{
    public float range = 100f;
    public int damage = 25;
    public LayerMask hittableLayers; // set in the Inspector: Enemy + Scenery

    public void Fire()
    {
        Ray ray = new Ray(transform.position, transform.forward);

        // Physics.Raycast returns true the instant it finds something,
        // and fills "hit" with details -- what it hit, where, how far.
        if (Physics.Raycast(ray, out RaycastHit hit, range, hittableLayers))
        {
            Debug.Log("Hit " + hit.collider.name + " at distance " + hit.distance);

            if (hit.collider.TryGetComponent(out Health targetHealth))
            {
                targetHealth.TakeDamage(damage);
            }
        }
        else
        {
            Debug.Log("Shot missed everything");
        }
    }
}

ผลลัพธ์ที่คาดหวัง: เล็งไปที่ enemy ที่อยู่ห่างออกไป 12 หน่วยแล้วเรียก Fire() จะพิมพ์ Hit Enemy at distance 12 และลดเลือดของ enemy ตัวนั้นลง 25 เล็งไปที่ท้องฟ้าโล่ง ๆ จะพิมพ์ Shot missed everything การส่ง hittableLayers (ที่ไม่รวม layer Player) แปลว่า raycast ไม่มีทางรายงานว่ายิงโดนผู้เล่นที่ยิงมันเองได้เลย ไม่ว่า ray จะเริ่มจากตรงไหน

overlap query ถามคำถามต่างออกไป คือ "มี collider อะไรอยู่ข้างในรูปทรงนี้ตอนนี้บ้าง" — มีประโยชน์สำหรับการตรวจจับพื้นที่ เช่น สัญญาณเตือนที่รู้ว่า player อยู่ใกล้ ๆ


using UnityEngine;

public class ProximityAlarm : MonoBehaviour
{
    public float radius = 5f;
    public LayerMask playerLayer;

    void FixedUpdate()
    {
        // Physics.OverlapSphere returns every collider touching the
        // sphere -- this object does not even need a Collider itself.
        Collider[] nearby = Physics.OverlapSphere(transform.position, radius, playerLayer);

        if (nearby.Length > 0)
        {
            Debug.Log("Player detected, colliders in range: " + nearby.Length);
        }
    }
}

ผลลัพธ์ที่คาดหวัง: console จะพิมพ์ Player detected, colliders in range: 1 ทุก physics step ที่ player ยังอยู่ในระยะ 5 หน่วย แล้วหยุดพิมพ์ทันทีที่เดินออกไป

Tip ใส่ LayerMask ให้ raycast กับ overlap query เสมอเมื่อเรารู้ว่ากำลังหาอะไรอยู่ ถ้าไม่ใส่ มันจะเช็คกับ collider ทุกตัวในฉาก รวมถึงตัวที่เราไม่ได้ตั้งใจจะโดนด้วย ทำให้ผลลัพธ์รกกว่าและกรองทีหลังช้ากว่า
ข้อผิดพลาดที่พบบ่อย ลืมไปว่า OnTriggerEnter/OnCollisionEnter กับ query อย่าง Physics.Raycast ตอบคำถามคนละแบบ คนละเวลากัน trigger กับ collision event บอกเราเกี่ยวกับสิ่งที่กำลังจะเกิดหรือเพิ่งเกิดขึ้นระหว่าง physics step ส่วน raycast บอกเราเกี่ยวกับสถานะของฉาก ณ ตอนนี้เดี๋ยวนี้ ทุกครั้งที่เราเรียกมัน มีประโยชน์สำหรับ action ที่ player สั่งเมื่อต้องการ เช่น การยิงปืน

8. Impulse, Knockback, และการระเบิด

แรงผลักทันทีครั้งเดียว — การตีระยะประชิด แรงกระแทกจากกระสุน แผ่นเด้ง — ใช้ ForceMode.Impulse แทน ForceMode.Force แบบต่อเนื่องจาก Section 6 โดยใส่ครั้งเดียวเป๊ะ


void ApplyKnockback(Rigidbody target, Vector3 direction, float strength)
{
    // Impulse: an instant velocity change that still respects mass,
    // so a heavy enemy flies back less than a light one for the
    // same strength value -- feels right without extra tuning.
    target.AddForce(direction.normalized * strength, ForceMode.Impulse);
}

การระเบิดต้องผลัก Rigidbody ทุกตัวที่อยู่ใกล้ ๆ พร้อมกันหมด ด้วยแรงที่ค่อย ๆ จางลงตามระยะทาง Rigidbody.AddExplosionForce จัดการเรื่อง falloff นี้ให้เราเลย ผสมกับ query Physics.OverlapSphere จาก Section 7 เพื่อหาทุกอย่างที่อยู่ในระยะ


using UnityEngine;

public class Grenade : MonoBehaviour
{
    public float explosionForce = 700f;
    public float explosionRadius = 5f;
    public float upwardsModifier = 0.5f;
    public LayerMask affectedLayers;

    public void Explode()
    {
        Collider[] hits = Physics.OverlapSphere(transform.position, explosionRadius, affectedLayers);

        foreach (Collider hit in hits)
        {
            if (hit.attachedRigidbody != null)
            {
                hit.attachedRigidbody.AddExplosionForce(
                    explosionForce,
                    transform.position,
                    explosionRadius,
                    upwardsModifier,
                    ForceMode.Impulse);
            }
        }

        Destroy(gameObject);
    }
}

parameter ตามลำดับ: explosionForce (ความแรงตรงจุดศูนย์กลางของแรงระเบิด), explosionPosition (จุดที่เกิดระเบิด — object ที่อยู่ไกลจากจุดนี้จะได้แรงน้อยลง จนเหลือศูนย์ที่ explosionRadius), explosionRadius (ระยะที่แรงระเบิดไปถึง), และ upwardsModifier (แรงดันขึ้นเพิ่มเติมแบบเทียม ๆ ที่ทำให้เศษซากกับ ragdoll ปลิวขึ้นและออกด้านนอก แทนที่จะไถลไปตามพื้นแบนราบ — การระเบิดจริงก็ทำแบบนี้เหมือนกัน แต่ parameter นี้ทำให้เราขยายผลให้ดูดีขึ้นได้)

ผลลัพธ์ที่คาดหวัง: การเรียก Explode() ใกล้ลังสามใบ จะเจอทั้งสามใบด้วย OverlapSphere และแต่ละใบจะปลิวออกด้านนอกและขึ้นด้านบนเล็กน้อย โดยลังที่อยู่ติดกับระเบิดจะปลิวไปไกลกว่าลังที่อยู่ใกล้ขอบของ explosionRadius มาก — falloff เกิดขึ้นอัตโนมัติข้างใน AddExplosionForce เราไม่ต้องคำนวณระยะทางเองเลย

Tip ตั้ง affectedLayers ให้ไม่รวม Scenery ถ้ากำแพงกับภูมิประเทศมี Rigidbody ที่เราไม่อยากให้ปลิวไปมาเวลาระเบิดใกล้ ๆ หรือไม่รวม Player ถ้าการระเบิดควรทำร้ายแค่ enemy เท่านั้น นี่คือแนวคิด Layer Collision Matrix จาก Section 5 ที่นำมาใช้กับ query แทนที่จะเป็นคู่ collision

9. Velocity กับ Force: กรณีศึกษาการกระโดด

ตอนนี้เรามีเครื่องมือสองแบบที่ทำให้อะไรบางอย่างเคลื่อนที่ได้ คือใส่แรงหรือ impulse หรือตั้งค่า rb.linearVelocity ตรง ๆ (เขียนทับความเร็วปัจจุบันของ object ไปเลย — property นี้ชื่อ velocity ใน Unity เวอร์ชันเก่า) การกระโดดเป็นตัวอย่างที่ชัดที่สุดว่าแต่ละแบบเหมาะกับตอนไหน เพราะทั้งคู่ดู "ถูกต้อง" ในการทดสอบเร็ว ๆ และจะเผยความต่างออกมาก็ตอนเจอ edge case เท่านั้น


public class JumpWithForce : MonoBehaviour
{
    public Rigidbody rb;
    public float jumpForce = 8f;

    public void Jump()
    {
        // Adds ON TOP of whatever vertical velocity already exists.
        rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
    }
}

public class JumpWithVelocity : MonoBehaviour
{
    public Rigidbody rb;
    public float jumpSpeed = 8f;

    public void Jump()
    {
        // OVERWRITES vertical velocity outright.
        Vector3 v = rb.linearVelocity;
        v.y = jumpSpeed;
        rb.linearVelocity = v;
    }
}

ลองทั้งสองแบบตอนที่ตัวละครกำลังตกจากขอบอยู่แล้ว (กำลังเคลื่อนที่ลง rb.linearVelocity.y เป็นค่าลบ) JumpWithForce เอา jump impulse ไปบวกกับค่าลบนั้น ทำให้ความสูงของการกระโดดขึ้นอยู่กับว่าตัวละครกำลังตกเร็วแค่ไหนอยู่แล้ว — บางทีแทบไม่ลอยขึ้นจากพื้นเลย บางทีก็พุ่งขึ้นสูงเกินคาด JumpWithVelocity ไม่สนใจว่าตัวละครกำลังทำอะไรอยู่ และตั้งค่า y เป็น jumpSpeed เป๊ะ ๆ เสมอ — การกระโดดขึ้นถึงความสูงเท่าเดิมทุกครั้งไม่มีเว้น

ยืนนิ่ง ๆ แล้วกระโดด: AddForce(Impulse): 0 --> jumpForce (คาดเดาได้ตรงนี้) Set linearVelocity: 0 --> jumpSpeed (คาดเดาได้ตรงนี้) กำลังตกอยู่แล้ว (y = -6) แล้วกระโดด: AddForce(Impulse): -6 --> -6 + jumpForce (ความสูงไม่คงที่!) Set linearVelocity: -6 --> jumpSpeed (ยังเท่าเดิมเป๊ะ)

ตอนไหนควรใส่แรงหรือ impulse: อะไรก็ตามที่ควรดูและรู้สึกสมจริงทางฟิสิกส์ ที่การเปลี่ยนแปลงตาม mass และการเคลื่อนที่เดิมคือจุดประสงค์เลย — การระเบิด, knockback, ragdoll, แรงผลักจากสิ่งแวดล้อม, ก้อนหินกลิ้งลงเนินแล้วเร็วขึ้นเรื่อย ๆ

ตอนไหนควรตั้งค่า velocity ตรง ๆ: action ที่ผู้เล่นควบคุมและต้องการความแม่นยำสูง ที่ความคงเส้นคงวาสำคัญกว่าความสมจริง — การกระโดดในเกม platformer ที่ต้องสูงเท่าเดิมทุกครั้ง ท่า dash ที่มีความเร็วคงที่แน่นอน การ clamp Rigidbody ไม่ให้เกินความเร็วสูงสุด character controller สไตล์ arcade ที่ตอบสนองไว "กระชับ" ส่วนใหญ่พึ่งพาการตั้งค่า velocity ตรง ๆ มากกว่าการสะสมแรงเรื่อย ๆ

ข้อผิดพลาดที่พบบ่อย ใช้ AddForce สำหรับการเคลื่อนที่ของ player แล้วสงสัยว่าทำไมความเร่งถึงรู้สึกหน่วงหรือไม่คงที่ โดยเฉพาะตอน mass ต่ำหรือตอนที่กำลังเคลื่อนที่อยู่แล้ว ถ้าเป้าหมายคือ "เคลื่อนที่ด้วยความเร็วนี้เป๊ะ เดี๋ยวนี้" ให้ตั้งค่า linearVelocity เก็บ AddForce ไว้ใช้กับกรณีที่ความเร่งแบบค่อยเป็นค่อยไปและสนใจ mass คือเป้าหมายจริง ๆ

10. Interpolation: ทำให้ภาพที่ขับเคลื่อนด้วย Physics ลื่นขึ้น

ย้อนกลับไปที่ Section 4 FixedUpdate ทำงานที่อัตราคงที่ (50 Hz เป็น default) ในขณะที่การ render อาจเร็วกว่านั้นมาก (144 Hz, 240 Hz, หรือไม่จำกัด) Transform ของ Rigidbody เปลี่ยนจริง ๆ ก็ต่อเมื่อมี physics step เท่านั้น ถ้าจอ redraw บ่อยกว่านั้น ตำแหน่งเดิมจะถูกวาดซ้ำหลายเฟรมติดกัน แล้วก็กระโดดไปตำแหน่ง physics ถัดไป — เห็นเป็นอาการกระตุกเล็กน้อย โดยเฉพาะกับ object ที่เคลื่อนที่เร็ว

Physics steps (50/sec): P0 ---------- P1 ---------- P2 Render frames (144/sec): r r r r r r r r r r r r ไม่มี interpolation: object ที่ render อยู่ที่ P0 หลายเฟรม แล้วกระโดดไป P1 แล้วก็อยู่นิ่งอีก -- เห็นอาการกระตุกชัดเจน ที่ frame rate สูง มี interpolation: Unity ผสมตำแหน่งที่ render ให้ลื่นระหว่าง P0 กับ P1 ทุกเฟรมที่คั่นอยู่ ทำให้ object ดูไหลลื่นทั้งที่ physics อัปเดตแค่ 50 ครั้งต่อวินาที

วิธีแก้คือการตั้งค่า Rigidbody.interpolation จะทำใน Inspector หรือในโค้ดก็ได้


void Awake()
{
    Rigidbody rb = GetComponent<Rigidbody>();
    rb.interpolation = RigidbodyInterpolation.Interpolate;
}

ผลลัพธ์ที่คาดหวัง: ลูกบอลที่กลิ้งบนพื้นโดยตั้ง Interpolate ไว้ จะดูลื่นสมบูรณ์แบบที่ 144 fps ทั้งที่ยังถูกจำลองทาง physics แค่ 50 ครั้งต่อวินาทีเท่านั้น ลูกบอลลูกเดียวกันที่ตั้ง None ไว้ จะเห็นการก้าวข้ามตำแหน่งชัด ๆ บนจอที่ refresh เร็วมาก ๆ สังเกตได้ชัดเป็นพิเศษตอน camera pan ช้า ๆ

Tip ตั้ง interpolation ให้ player กับ object ที่กล้องตามอยู่ ปล่อยไว้เป็น None สำหรับของประกอบฉาก เศษซาก หรืออะไรก็ตามที่อยู่นอกจอ — interpolation กิน CPU เพิ่มขึ้นเล็กน้อยต่อ Rigidbody หนึ่งตัว และ object ที่ไม่มีใครจ้องมองใกล้ ๆ ก็ไม่จำเป็นต้องมีมัน

11. Determinism: Fixed Step, Fixed Order

simulation จะ deterministic (กำหนดผลได้แน่นอน) เมื่อสถานะเริ่มต้นเดียวกันเป๊ะ และลำดับ input เดียวกันเป๊ะ ให้ผลลัพธ์เดียวกันเป๊ะทุกครั้งที่รัน มีสองฟีเจอร์ที่พึ่งพาสิ่งนี้โดยตรง

มีสองข้อกำหนดที่ทำให้สิ่งนี้เป็นไปได้ และทั้งคู่เชื่อมกับ section ก่อนหน้าโดยตรง


[System.Serializable]
public struct InputFrame
{
    public int tick;
    public float horizontal;
    public bool jumpPressed;
}

public class ReplayRecorder : MonoBehaviour
{
    private List<InputFrame> recordedFrames = new List<InputFrame>();
    private int currentTick;

    void FixedUpdate()
    {
        InputFrame frame = new InputFrame
        {
            tick = currentTick,
            horizontal = Input.GetAxisRaw("Horizontal"),
            jumpPressed = Input.GetButtonDown("Jump")
        };
        recordedFrames.Add(frame);

        ApplyInput(frame);
        currentTick++;
    }

    void ApplyInput(InputFrame frame)
    {
        // The SAME method drives live play and replay playback. As long
        // as this only ever runs from FixedUpdate (fixed step, fixed
        // order), replaying the same recordedFrames list reproduces the
        // exact same run.
    }
}
เล่นสด: input ทุก fixed tick --> ApplyInput() --> physics step (บันทึกลง list ด้วย หนึ่ง InputFrame ต่อ tick) Replay: list ที่บันทึกไว้ ทีละ tick --> ApplyInput() เดิม --> physics step เดิม ลำดับคงที่เดิม fixed dt เดิม = การรันเหมือนเดิมทุกครั้งที่เล่นซ้ำ
ข้อผิดพลาดที่พบบ่อย ใช้ random number generator ที่ไม่ได้ตั้ง seed ระหว่าง simulation Random.Range ที่ไม่ตั้ง seed คงที่ จะสุ่มลำดับต่างกันทุกครั้งที่รัน ซึ่งทำลาย determinism ทันที replay หรือ lockstep peer ที่ใช้ลำดับสุ่มต่างกัน จะให้ผลลัพธ์ต่างกันจาก input เดียวกัน ให้ seed RNG ตรง ๆ (Random.InitState(seed)) ด้วยค่าที่เป็นส่วนหนึ่งของสถานะที่บันทึกหรือ sync ไว้เอง

ข้อจำกัดหนึ่งที่ควรรู้ไว้ตรง ๆ คือ PhysX engine ที่ติดมากับ Unity ไม่ได้รับประกันว่าจะให้ผลลัพธ์ตรงกันแบบ bit-ต่อ-bit ข้าม CPU architecture หรือ platform ที่ต่างกัน เพราะการปัดเศษของ floating-point อาจต่างกันได้ในระดับนั้น ระบบ replay บนเครื่องเดียว (กรณีด้านบน) เชื่อถือได้ เพราะเครื่องเดียวกันรันทั้งการบันทึกและการเล่นซ้ำ lockstep multiplayer ข้าม platform จริง ๆ ที่ใช้ physics 3 มิติเต็มรูปแบบนั้นยากพอตัว จนเกม RTS หรือเกมต่อสู้แข่งขันหลายเกมเลือกจำกัด lockstep ให้เหลือแค่ simulation แบบ fixed-point ที่เขียนขึ้นเอง เรียบง่ายกว่า หรือไม่ก็พึ่งพา determinism แค่ภายใน platform เดียว แทนที่จะเชื่อ physics engine อเนกประสงค์ข้ามทุกเครื่อง

12. Performance: Active Body เยอะเกินไป

ทุก physics step engine ต้องทำงานจริงกับทุก Rigidbody ที่ยังไม่หลับ คือ หา collider คู่ไหนที่อาจสัมผัสกัน (broadphase), เช็คคู่ที่เป็นไปได้เหล่านั้นอย่างละเอียด (narrowphase), แล้วแก้ปัญหาการสัมผัสจริง (solver) FixedUpdate มีเวลาจริงจำกัดให้ทำงานเสร็จก่อนถึง step ถัดไป ถ้า active body เยอะเกินไป physics step เองก็จะเริ่มใช้เวลานานกว่างบเวลาที่มีอยู่ ทำให้เกมทั้งหมดช้าลงแม้ว่าฉากจะดูไม่ซับซ้อนทางภาพเลยก็ตาม

10 active Rigidbody -> physics step เสร็จเร็ว มีงบเหลือ 2,000 active Rigidbody -> แค่ physics step อย่างเดียวก็เกิน งบเวลาต่อเฟรมทั้งหมดได้ -> frame rate ตก ทั้งที่ฉากยังดูเหมือนเดิม

วิธีที่ทำได้จริงในการคุมจำนวนและต้นทุนให้ต่ำ


using System.Collections.Generic;
using UnityEngine;

public class DebrisSpawner : MonoBehaviour
{
    public GameObject debrisPrefab;
    public int maxActiveDebris = 30;

    private readonly Queue<GameObject> activeDebris = new Queue<GameObject>();

    public void SpawnDebris(Vector3 position)
    {
        if (activeDebris.Count >= maxActiveDebris)
        {
            // Cap the number of active physics bodies instead of letting
            // every explosion add more debris forever.
            GameObject oldest = activeDebris.Dequeue();
            Destroy(oldest);
        }

        GameObject piece = Instantiate(debrisPrefab, position, Quaternion.identity);
        activeDebris.Enqueue(piece);
    }
}

ผลลัพธ์ที่คาดหวัง: การระเบิดซ้ำ ๆ ยัง spawn เศษซากไปเรื่อย ๆ แต่จำนวน active Rigidbody ไม่มีวันโตเกิน maxActiveDebris — ชิ้นที่เก่าที่สุดจะถูกลบก่อนที่ชิ้นใหม่จะถูกเพิ่ม ทำให้ workload ของ physics step มีขอบเขตจำกัด ไม่ว่า player จะระเบิดของไปนานแค่ไหนก็ตาม

Tip Project Settings > Physics ยังมี Solver Iterations (solver แก้ปัญหาการสัมผัสกี่รอบ — ยิ่งเยอะยิ่งแม่นยำและแพงขึ้น) กับ Max Allowed Timestep (เพดานความปลอดภัย เพื่อไม่ให้ frame rate ที่ค้างนานมาก บังคับให้ physics ต้องจำลองการกระโดดของเวลาก้อนใหญ่ทีเดียว) ทั้งสองอย่างควรรู้ไว้ แต่ควรปรับก็ต่อเมื่อวัดได้จริงแล้วว่ามีอาการช้าที่เกี่ยวกับ physics ด้วย Profiler

13. Glossary

14. Exercises

Exercise 1 -- แก้บั๊กที่ขึ้นกับ Frame Rate สคริปต์ด้านล่างตั้งใจให้ hovercraft เร่งความเร็วไปข้างหน้าขณะกด W ค้างไว้ มัน "ทำงาน" ได้ในการทดสอบเร็ว ๆ แต่ความเร็วสูงสุดหลังจากผ่านไป 5 วินาที กลับต่างกันระหว่างเครื่องแรงกับเครื่องช้า อธิบายว่าทำไม โดยใช้สิ่งที่ Section 4 สอนเรื่อง Update กับ FixedUpdate แล้วเขียนใหม่ให้ถูกต้อง

public class Hovercraft : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 15f;

    void Update()
    {
        if (Input.GetKey(KeyCode.W))
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}
Show answer

AddForce ถูกเรียกจาก Update ซึ่งทำงานครั้งเดียวต่อเฟรมที่ render — อัตราที่แปรผัน บนเครื่องแรง Update ทำงานบ่อยกว่า fixed physics rate (50 Hz เป็น default) มาก ทำให้แรงถูกใส่เข้าไปต่อ physics step มากกว่าบนเครื่องช้ามาก ที่ Update แทบตามทัน FixedUpdate พอดี ๆ hovercraft จึงเร่งความเร็วเร็วกว่า เพียงเพราะเกม render เร็วกว่า ไม่เกี่ยวอะไรกับค่า thrust เลย วิธีแก้คืออ่าน input ใน Update อย่างเดียว และแตะ Rigidbody ใน FixedUpdate อย่างเดียว


public class Hovercraft : MonoBehaviour
{
    public Rigidbody rb;
    public float thrust = 15f;
    private bool thrustHeld;

    void Update()
    {
        thrustHeld = Input.GetKey(KeyCode.W);
    }

    void FixedUpdate()
    {
        if (thrustHeld)
        {
            rb.AddForce(transform.forward * thrust, ForceMode.Force);
        }
    }
}

ตอนนี้ AddForce ถูกเรียกอย่างมากครั้งเดียวต่อ fixed physics step ไม่ว่า Update จะทำงานกี่ครั้งระหว่างนั้นก็ตาม ทำให้ hovercraft ถึงความเร็วเท่ากันหลังจากเวลาจริงจำนวนวินาทีเท่ากัน ไม่ว่า frame rate จะเป็นเท่าไหร่

Exercise 2 -- Trigger หรือ Solid? เรากำลังสร้างด่านที่มีกำแพงหิน ยาเพิ่มเลือดที่เก็บได้ และบ่อลาวาที่ควรทำความเสียหายต่อเนื่องขณะ player ยืนอยู่ในนั้น สำหรับแต่ละ object ตัดสินใจว่า collider ของมันควรติ๊ก Is Trigger หรือไม่ พร้อมอธิบายเหตุผลสั้น ๆ หนึ่งประโยค จากนั้นเขียนสคริปต์ HealingPotion ให้เพิ่มเลือดผู้เล่น 20 HP, log ข้อความ, และทำลายตัวเองทันทีที่ player แตะมัน
Show answer

กำแพง ควรเป็น solid collider (isTrigger = false) — มันต้องหยุด player ทางกายภาพไม่ให้เดินทะลุผ่าน ยาเพิ่มเลือด ควรเป็น trigger (isTrigger = true) — มันต้องตรวจจับ player โดยไม่บล็อกการเคลื่อนที่ บ่อลาวา ก็ควรเป็น trigger เช่นกัน (isTrigger = true) — player ต้องเดินเข้าไปในมันได้ (แม้จะเป็นความคิดที่แย่) เพื่อให้เกมตรวจจับการซ้อนทับและใส่ความเสียหายได้ ถ้าเป็น solid collider มันจะทำตัวเหมือนกำแพงที่มองไม่เห็น และ player จะไม่มีวันได้สัมผัสลาวาเลย


using UnityEngine;

public class HealingPotion : MonoBehaviour
{
    public int healAmount = 20;

    void OnTriggerEnter(Collider other)
    {
        if (other.TryGetComponent(out Health health))
        {
            health.Heal(healAmount);
            Debug.Log("Healing potion used, +" + healAmount + " HP");
            Destroy(gameObject);
        }
    }
}

โครงสร้างนี้เหมือนกับตัวอย่าง CoinPickup จาก Section 3 เลย คือเช็ค component ที่ถูกต้องด้วย TryGetComponent ใส่ผลของมัน แล้วทำลาย pickup ทิ้งเพื่อไม่ให้เก็บซ้ำสองครั้งได้

Exercise 3 -- ระเบิดที่กรองด้วย Layer เขียนสคริปต์ Grenade ที่ method Explode() หา Rigidbody ทุกตัวในรัศมี 6 หน่วยด้วย overlap query แต่ควรมีผลกับ object บน layer Enemies เท่านั้น (ไม่ใช่ player หรือฉาก) โดยกระแทกแต่ละตัวด้วย AddExplosionForce ใช้แรง 500 และ upwards modifier 0.3 เปิดให้ layer ที่ได้รับผลเป็น field LayerMask เพื่อตั้งค่าได้ใน Inspector
Show answer

using UnityEngine;

public class Grenade : MonoBehaviour
{
    public float explosionForce = 500f;
    public float explosionRadius = 6f;
    public float upwardsModifier = 0.3f;
    public LayerMask enemyLayer; // set to "Enemies" only in the Inspector

    public void Explode()
    {
        Collider[] hits = Physics.OverlapSphere(transform.position, explosionRadius, enemyLayer);

        foreach (Collider hit in hits)
        {
            if (hit.attachedRigidbody != null)
            {
                hit.attachedRigidbody.AddExplosionForce(
                    explosionForce,
                    transform.position,
                    explosionRadius,
                    upwardsModifier,
                    ForceMode.Impulse);
            }
        }

        Destroy(gameObject);
    }
}

mask enemyLayer ทำหน้าที่กรองให้เราเลย Physics.OverlapSphere จะคืนแค่ collider บน layer นั้นตั้งแต่แรก ดังนั้น player กับฉากจะไม่มีวันอยู่ใน array hits เลยด้วยซ้ำ ไม่ต้องมีการเช็ค tag เพิ่มเติมทีหลัง นี่คือเทคนิคเดียวกับ Section 8 ผสมกับการกรอง layer จาก Section 5 นำมาใช้ด้วยกัน

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