13.2 3D / Spatial Audio

เฟส 13 · เสียง · เวลาเรียน: 15–25 h

การวางเสียงในพื้นที่ 3D — distance attenuation, panning, occlusion และ HRTF เพื่อความสมจริง

เสียงทุกอย่างในโลกของเกมมันมาจากที่ใดที่หนึ่งเสมอ ไม่ว่าจะเป็นคบเพลิงที่ลุกไหม้ติดผนัง มอนสเตอร์ที่คำรามอยู่หลังประตูที่ล็อกไว้ หรือเสียงฝีเท้าที่ค่อย ๆ ใกล้เข้ามาจากตรอกที่เรามองไม่เห็น การทำให้เสียงรู้สึกเหมือนมันมาจากตำแหน่งนั้นจริง ๆ คือเบาลงเมื่อเราเดินห่างออกไป เอียงไปทางหูซ้ายหรือขวา และอู้อี้ลงเมื่อมีกำแพงมาบัง เรียกว่า spatial audio หรือเรียกอีกอย่างว่า 3D audio บทนี้จะสร้างระบบนี้ขึ้นมาใน Unity และ C# และจะใช้เครื่องมือ vector, dot product กับ cross product จากบท 2.1 หนักมาก เพราะ sound source กับ listener ก็แค่จุดสองจุดกับทิศทางในสเปซ เหมือนกับที่เราเคยทำงานด้วยในบทนั้นเป๊ะ ๆ

เหมือนเดิม แต่ละไอเดียจะแสดงเป็นโปรแกรมเล็ก ๆ ที่รันได้จริงพร้อม output จริง มีบางไอเดียที่เข้าใจได้ในรูปแบบ Unity component เท่านั้น ไม่มี console ให้ print — สำหรับพวกนั้นเราจะ trace ทีละเฟรมแทน แล้วอธิบายด้วยคำง่าย ๆ

1. From flat sound to spatial sound

โค้ดเสียงของมือใหม่ส่วนใหญ่จะเล่นเสียงด้วยความดังเท่ากันทุกที่ ออกจากลำโพงทั้งสองข้างเท่ากัน ไม่ว่าผู้เล่นจะยืนอยู่ตรงไหน แบบนี้เรียกว่า 2D sound (เสียงแบน ไม่มีตำแหน่ง) — ใช้ได้ถูกต้องสำหรับเสียงคลิก UI หรือเพลงพื้นหลัง แต่ผิดสำหรับเกือบทุกอย่างที่เหลือ ส่วน 3D sound (positional / spatial sound) จะมีตำแหน่งจริง ๆ อยู่ในโลกเกม และมีสามอย่างที่จะเปลี่ยนไปตามตำแหน่งของ listener เทียบกับตำแหน่งนั้น:

ระบบเสียงของ Unity จะมี AudioListener component ที่ active อยู่แค่หนึ่งตัวเสมอในซีน — เกือบทุกครั้งจะติดอยู่กับกล้องหลักหรือตัวผู้เล่น — และมี AudioSource component ได้กี่ตัวก็ได้ แต่ละตัวคือเสียงหนึ่งเสียงที่มีตำแหน่งของตัวเอง (Transform) เอฟเฟกต์ทุกอย่างข้างบนคำนวณจากข้อมูลแค่สองอย่าง คือตำแหน่ง/ทิศทางของ listener กับตำแหน่งของ source นั่นคือจุด จุด และเวกเตอร์ระหว่างสองจุดนั้นพอดี ซึ่งเป็นแพทเทิร์นเดียวกับบท 2.1 เป๊ะ ๆ คือ point - point = vector

using System;

// a plain C# console program (no Unity here) -- just to see the underlying math clearly.
// The Unity-specific versions that follow reuse this exact same math through UnityEngine.Vector3.

struct Vec3
{
    public float x, y, z;
    public Vec3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
}

class Program
{
    static Vec3 Sub(Vec3 a, Vec3 b) => new Vec3(a.x - b.x, a.y - b.y, a.z - b.z);
    static float Length(Vec3 v) => MathF.Sqrt(v.x * v.x + v.y * v.y + v.z * v.z);

    static void Main()
    {
        Vec3 listenerPos = new Vec3(0f, 0f, 0f);   // the player / camera
        Vec3 sourcePos   = new Vec3(3f, 0f, 4f);   // a torch crackling somewhere in the world

        Vec3 toSource  = Sub(sourcePos, listenerPos);   // point - point = vector (chapter 2.1)
        float distance = Length(toSource);

        Console.WriteLine($"vector to source = ({toSource.x}, {toSource.y}, {toSource.z})");
        Console.WriteLine($"distance = {distance}");
    }
}

Output:

vector to source = (3, 0, 4)
distance = 5
listener (0,0,0) source (3,0,4) o -------------------------------------- * toSource = source - listener = (3, 0, 4) distance = length(toSource) = sqrt(9+0+16) = 5 Everything in this chapter is built from just this: two points, the vector between them (its LENGTH gives loudness, section 2-3), and its DIRECTION relative to which way the listener is facing (gives left/right balance, section 4).

นั่นคือสามเหลี่ยม 3-4-5 ตัวเดียวกับที่เจอในบท 2.1 หัวข้อ vector length — การจำได้ว่านี่คือตัวเดียวกันนั่นแหละคือประเด็น ที่เหลือของบทนี้ทั้งหมดคือ "เอาระยะทางนั้นไปทำอะไรต่อ และเอาทิศทางนั้นไปทำอะไรต่อ"

2. Distance attenuation: linear vs. logarithmic rolloff

Attenuation แปลตรง ๆ ว่า "ค่อย ๆ เบาลง" ส่วน Distance attenuation คือกฎที่แปลงตัวเลขระยะทางให้เป็นตัวเลขความดัง และมันต้องใช้ระยะอ้างอิงสองค่า:

ระหว่างระยะทั้งสองนี้ ความดังจะลดลงตามรูปทรงใดรูปทรงหนึ่งในสองแบบที่พบบ่อย:

using System;

class Program
{
    const float MinDistance = 2f;
    const float MaxDistance = 20f;

    // straight-line fade: 100% at MinDistance, exactly 0% at MaxDistance
    static float LinearAttenuation(float distance)
    {
        if (distance <= MinDistance) return 1f;
        if (distance >= MaxDistance) return 0f;
        return 1f - (distance - MinDistance) / (MaxDistance - MinDistance);
    }

    // real-world-like fade: drops FAST close up, then trails off slowly, never quite hitting 0
    static float LogarithmicAttenuation(float distance)
    {
        if (distance <= MinDistance) return 1f;
        return MinDistance / distance;
    }

    static void Main()
    {
        float[] distances = { 2f, 5f, 10f, 15f, 20f, 25f };
        foreach (float d in distances)
        {
            float lin = LinearAttenuation(d);
            float log = LogarithmicAttenuation(d);
            Console.WriteLine($"distance={d,5:F1}  linear={lin:F4}  logarithmic={log:F4}");
        }
    }
}

Output:

distance=  2.0  linear=1.0000  logarithmic=1.0000
distance=  5.0  linear=0.8333  logarithmic=0.4000
distance= 10.0  linear=0.5556  logarithmic=0.2000
distance= 15.0  linear=0.2778  logarithmic=0.1333
distance= 20.0  linear=0.0000  logarithmic=0.1000
distance= 25.0  linear=0.0000  logarithmic=0.0800
volume 1.0 |L | L G | L G | L G 0.5 | L G | L G | L G | L G 0.0 +--------L--------------------G----- distance 2 (min) 11 20 (max) L = Linear rolloff -- straight line, hits EXACTLY 0 right at maxDistance G = Logarithmic rolloff -- falls fast near the source, then flattens out and keeps trailing off quietly even past maxDistance

สังเกตว่ากราฟทั้งสองเส้นเล่าเรื่องราวที่ต่างกันมากตรงขอบไกลสุด LinearAttenuation ตกลงถึง 0.0000 พอดีที่ระยะ 20 แล้วก็คงที่อยู่แบบนั้น เพราะสูตรมันเป็นเส้นตรงที่ถูกกำหนดให้จบพอดีที่ MaxDistance ส่วน LogarithmicAttenuation ที่ระยะ 20 ยังอยู่ที่ 0.1000 และที่ระยะ 25 ก็ยังอยู่ที่ 0.0800 — มันไม่มีวันตกถึงศูนย์จริง ๆ แค่ค่อย ๆ เบาลงช้าลงเรื่อย ๆ นี่แหละที่ใกล้เคียงกับพฤติกรรมเสียงในโลกจริงมากกว่า (ความเข้มเสียงลดลงตามประมาณส่วนกลับของระยะทาง) ซึ่งเป็นเหตุผลว่าทำไม Unity ถึงเรียกออปชันนี้ว่า "Logarithmic Rolloff" และแนะนำให้ใช้เป็นค่า default ที่ฟังดูเป็นธรรมชาติกว่าสำหรับเสียง 3D ส่วนใหญ่

Common mistake เข้าใจผิดว่า minDistance หมายถึง "เสียงจะเริ่มดังที่ระยะนี้" ซึ่งไม่ใช่เลย — เสียง 3D จะได้ยินได้ในทุกระยะ ต่อให้ยืนทับอยู่บนตัว source เอง minDistance ควบคุมแค่จุดที่ความดัง หยุดไต่ขึ้นสูงกว่านี้ ถ้าตั้งค่าเล็กเกินไป (เช่น 0) เสียงระเบิดหรือฝีเท้าที่อยู่ใกล้ ๆ อาจดังจนแสบหูแบบผิดธรรมชาติ ฝีเท้าทั่วไปมักใช้ minDistance ประมาณ 1–3 หน่วย เพื่อไม่ให้ความดังพุ่งวูบวาบทันทีที่กล้องเข้าใกล้

3. Unity's AudioSource distance settings in practice

ในเกมที่ shipped จริง เรามักไม่ต้องเขียนสูตรพวกนี้เอง — AudioSource component ของ Unity คำนวณ distance attenuation ให้เราอยู่แล้ว ทุกเฟรม สำหรับเสียง 3D ทุกตัว สิ่งที่เราทำจริง ๆ ใน Inspector (หรือในโค้ดแบบด้านล่าง) คือการเลือกรูปทรงและระยะอ้างอิงสองค่าจากหัวข้อ 2:

using UnityEngine;

public class TorchAudio : MonoBehaviour
{
    [SerializeField] AudioSource source;

    void Awake()
    {
        source.spatialBlend = 1f;                       // 0 = flat 2D, 1 = fully positional 3D
        source.rolloffMode  = AudioRolloffMode.Custom;   // use our own curve, not a built-in shape
        source.minDistance  = 2f;                        // full volume anywhere inside 2 units
        source.maxDistance  = 20f;                        // silent (for Custom/Linear) past 20 units

        AnimationCurve curve = AnimationCurve.Linear(2f, 1f, 20f, 0f);
        source.SetCustomCurve(AudioSourceCurveType.CustomRolloff, curve);
    }
}
Inspector / code field matches the code from section 2 ------------------------------- --------------------------------------- Min Distance = 2 the MinDistance constant Max Distance = 20 the MaxDistance constant Volume Rolloff = Logarithmic LogarithmicAttenuation(distance) Volume Rolloff = Linear LinearAttenuation(distance) Volume Rolloff = Custom your own AnimationCurve, any shape at all Spatial Blend = 1 "is this even a positional 3D sound?"

spatialBlend เป็น field ที่มือใหม่พลาดบ่อยที่สุด มันเป็น slider ตั้งแต่ 0 ถึง 1 ไม่ใช่สวิตช์เปิด/ปิด โดย 0 หมายถึงเสียง 2D แบนสนิท (ไม่มี distance attenuation ไม่มี panning เล่นเหมือนกันทุกที่) และ 1 หมายถึง 3D เต็มรูปแบบ เราสามารถ blend ระหว่างสองค่านี้ได้ — มีประโยชน์สำหรับอะไรแบบวิทยุที่ผู้เล่นถือติดตัว ซึ่งควรฟังดู "อยู่ในหัว" เป็นส่วนใหญ่ (ใกล้เคียง 2D) แต่ยังขยับเล็กน้อยตามทิศทางที่หันหน้า

Common mistake ตั้งค่า minDistance/maxDistance อย่างละเอียด แต่พอผู้เล่นเดินออกไปแล้วไม่ได้ยินความต่างเลย เลยคิดว่าคณิตศาสตร์มันพัง เกือบทุกครั้งบั๊กจริง ๆ คือ spatialBlend ถูกปล่อยไว้ที่ค่า default คือ 0 (2D) — เสียง 2D จะเมินการตั้งค่า distance กับ rolloff ทั้งหมดโดยสิ้นเชิง ไม่ว่าจะตั้งค่าไว้ยังไงก็ตาม ให้เช็ก spatialBlend ก่อนเป็นอันดับแรกเมื่อเสียง "3D" ไม่ยอมทำตัวเป็น 3D

4. Panning: left and right from dot and cross products

Panning คือการตัดสินว่าเสียงจะออกจากลำโพง/หูซ้ายเทียบกับขวามากแค่ไหน distance (หัวข้อ 2-3) ต้องการแค่ ความยาว ของเวกเตอร์จาก listener ไปยัง source แต่ panning ต้องการ ทิศทาง ของมัน เทียบกับทิศที่ listener กำลังหันหน้าไป ตรงนี้แหละที่ dot product กับ cross product จากบท 2.1 กลับมาใช้ตรง ๆ

Listener มีเวกเตอร์ forward (ทิศที่กำลังมอง) และเวกเตอร์ up (ทิศที่เป็น "บน" สำหรับมัน ปกติก็คือ world up นั่นแหละ) จากสองเวกเตอร์นี้เราสร้างเวกเตอร์ที่สาม คือ right ได้ด้วย cross product — ให้นึกย้อนไปบท 2.1 ว่า cross(a, b) จะให้เวกเตอร์ใหม่ที่ตั้งฉากกับทั้งสองอินพุต ตามกฎมือขวา (right-hand rule):

using System;

struct Vec3
{
    public float x, y, z;
    public Vec3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
}

class Program
{
    static Vec3 Sub(Vec3 a, Vec3 b) => new Vec3(a.x - b.x, a.y - b.y, a.z - b.z);
    static float Dot(Vec3 a, Vec3 b) => a.x * b.x + a.y * b.y + a.z * b.z;
    static Vec3 Cross(Vec3 a, Vec3 b) => new Vec3(
        a.y * b.z - a.z * b.y,
        a.z * b.x - a.x * b.z,
        a.x * b.y - a.y * b.x);
    static float Length(Vec3 v) => MathF.Sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
    static Vec3 Normalize(Vec3 v)
    {
        float len = Length(v);
        return new Vec3(v.x / len, v.y / len, v.z / len);
    }

    static void Main()
    {
        Vec3 listenerPos = new Vec3(0f, 0f, 0f);
        Vec3 forward = new Vec3(0f, 0f, 1f);   // the way the listener is facing
        Vec3 up      = new Vec3(0f, 1f, 0f);
        Vec3 right   = Cross(up, forward);     // chapter 2.1's cross product, reused here

        Console.WriteLine($"right = ({right.x}, {right.y}, {right.z})");

        (string name, Vec3 pos)[] sources =
        {
            ("A", new Vec3( 5f, 0f,  0f)),
            ("B", new Vec3(-5f, 0f,  0f)),
            ("C", new Vec3( 0f, 0f,  5f)),
            ("D", new Vec3( 0f, 0f, -5f)),
            ("E", new Vec3( 3f, 0f,  3f)),
        };

        foreach (var (name, pos) in sources)
        {
            Vec3 dir = Normalize(Sub(pos, listenerPos));
            float pan   = Dot(dir, right);     // -1 = full left, +1 = full right
            float front = Dot(dir, forward);   // +1 = straight ahead, -1 = straight behind

            Console.WriteLine($"{name}: pan={pan:F4}  front={front:F4}");
        }
    }
}

Output:

right = (1, 0, 0)
A: pan=1.0000  front=0.0000
B: pan=-1.0000  front=0.0000
C: pan=0.0000  front=1.0000
D: pan=0.0000  front=-1.0000
E: pan=0.7071  front=0.7071
C (0,0,5) "straight ahead" pan=0.00 front=1.00 ^ | | E (3,0,3) "ahead and to the right" | / pan=0.71 front=0.71 | / | / B (-5,0,0) <---------o---------> A (5,0,0) "left" listener "right" pan=-1.00 facing north pan=1.00 front=0.00 front=0.00 | | v D (0,0,-5) "straight behind" pan=0.00 front=-1.00

นี่คือ dot-product trick ตัวเดียวกับ "อยู่ข้างหน้าฉันไหม" จากบท 2.1 เป๊ะ ๆ แค่เอาไปเล็งแกนอื่น Dot(dir, right) ตอบคำถามว่า "เสียงนี้อยู่เอียงไปทางหูขวาของฉันแค่ไหน" — source A อยู่บนแกน right เต็ม ๆ เลยได้ค่า +1 สะอาด ๆ (ลำโพงขวาเต็ม) ส่วน B เป็นภาพกลับกัน ได้ -1 (ลำโพงซ้ายเต็ม) Dot(dir, forward) ตอบคำถามหน้า/หลังแบบเดิมจากบท 2.1 คือ C อยู่ตรงหน้าพอดี (+1) D อยู่ด้านหลังพอดี (-1) ส่วน source E อยู่กึ่งกลางระหว่าง "ขวา" กับ "ข้างหน้า" พอดี และตัวเลขก็แสดงออกมาแบบนั้น คือ 0.7071 ทั้งสองค่า ซึ่งเป็น sqrt(2)/2 ตัวเดียวกับที่เคยเห็นสำหรับมุม 45 องศา

Tip ในโค้ด Unity ที่ใช้งานจริง เราแทบไม่เคยต้องเขียน cross product เองแบบนี้ — Transform เปิดให้ใช้ transform.right, transform.up, และ transform.forward ตรง ๆ อยู่แล้ว และ transform.right ก็คือ Cross(up, forward) เป๊ะ ๆ ภายใต้ผ้าคลุม การรู้แบบนี้แหละที่เปลี่ยน "property ในตัวอะไรสักอย่าง" ให้กลายเป็นสิ่งที่เราเข้าใจจริง ๆ
using UnityEngine;

public class ManualPan : MonoBehaviour
{
    public Transform listener;
    public AudioSource source;

    void Update()
    {
        Vector3 toSource = transform.position - listener.position;
        Vector3 flatDir  = new Vector3(toSource.x, 0f, toSource.z).normalized;

        float pan = Vector3.Dot(flatDir, listener.right);
        source.panStereo = pan;   // -1 = full left speaker, +1 = full right speaker
    }
}

Unity คำนวณ panning ให้อัตโนมัติอยู่แล้วสำหรับ AudioSource ตัวไหนก็ตามที่ spatialBlend ตั้งไว้ที่ 1 โดยใช้คณิตศาสตร์แบบเดียวกันนี้ภายใน panStereo ถูกเปิดให้ใช้ตรง ๆ สำหรับกรณีที่หายาก ที่เราอยากปลอมสมดุลซ้าย/ขวาให้เสียงหนึ่งเสียงโดยไม่ต้องการให้มันเบาลงตามระยะด้วย (เช่น เสียง compass ping บน UI ที่ควรเอียงไปทางอันตรายนอกจอโดยไม่เปลี่ยนความดัง)

Tip ลองดู C กับ D ในตารางข้างบนดี ๆ ทั้งคู่ได้ pan=0.00 คือตรงกลางเป๊ะ — ทั้งที่ตัวหนึ่งอยู่ตรงหน้าและอีกตัวอยู่ด้านหลังพอดี Plain stereo panning ไม่มีทางแยกสองอันนี้ออกจากกันได้ เพราะมันมีแกนให้ใช้แค่แกนเดียว (ซ้าย/ขวา) จำช่องว่างตรงนี้ไว้ — หัวข้อ 8 จะอธิบายว่าการได้ยินจริงของคนเราแก้ปัญหานี้ยังไง

5. The Doppler effect: pitch shift from relative motion

เราเคยได้ยินเสียงนี้จากชีวิตจริงอยู่แล้ว คือไซเรนรถพยาบาลจะฟังดูเสียงสูงขึ้นตอนวิ่งเข้าหาเรา แล้วก็ตกฮวบตอนที่มันแซงผ่านไปพอดี แล้วก็ฟังดูเสียงต่ำลงตอนมันขับห่างออกไป การเปลี่ยน pitch แบบนี้เรียกว่า Doppler effect และมันขึ้นอยู่กับว่า source กำลังปิดระยะห่างกับ listener เร็วแค่ไหน ซึ่งก็คือ dot product อีกครั้ง คือเอา velocity ของ source มา dot กับทิศทางไปยัง listener

using System;

struct Vec3
{
    public float x, y, z;
    public Vec3(float x, float y, float z) { this.x = x; this.y = y; this.z = z; }
}

class Program
{
    const float SpeedOfSound = 343f;                              // meters per second, in air
    static readonly Vec3 ListenerPos    = new Vec3(0f, 0f, 0f);
    static readonly Vec3 SourceVelocity = new Vec3(20f, 0f, 0f);  // a car driving along +x

    static Vec3 Sub(Vec3 a, Vec3 b) => new Vec3(a.x - b.x, a.y - b.y, a.z - b.z);
    static float Dot(Vec3 a, Vec3 b) => a.x * b.x + a.y * b.y + a.z * b.z;
    static Vec3 Normalize(Vec3 v)
    {
        float len = MathF.Sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
        return new Vec3(v.x / len, v.y / len, v.z / len);
    }

    static float PitchFactor(Vec3 sourcePos)
    {
        Vec3 dirToListener   = Normalize(Sub(ListenerPos, sourcePos));
        float approachSpeed  = Dot(SourceVelocity, dirToListener);   // + closing in, - pulling away
        return SpeedOfSound / (SpeedOfSound - approachSpeed);
    }

    static void Main()
    {
        Console.WriteLine($"approaching (x=-50): {PitchFactor(new Vec3(-50f, 0f, 0f)):F4}");
        Console.WriteLine($"alongside   (z=30):  {PitchFactor(new Vec3(0f, 0f, 30f)):F4}");
        Console.WriteLine($"receding    (x=50):  {PitchFactor(new Vec3(50f, 0f, 0f)):F4}");
    }
}

Output:

approaching (x=-50): 1.0619
alongside   (z=30):  1.0000
receding    (x=50):  0.9449
pitch factor > 1.0 = higher pitch (source closing the distance) pitch factor = 1.0 = no shift (moving sideways, distance not changing) pitch factor < 1.0 = lower pitch (source pulling away) car--> car--> car--> [listener] approaching: pitch RISES car--> right beside: pitch = normal (1.0) car--> car--> receding: pitch FALLS

เมื่อรถอยู่ทางซ้ายไกล ๆ และขับเข้าหา listener (x=-50, velocity +x) มันกำลังปิดระยะห่างอยู่ ค่า factor เลยมากกว่า 1 และ pitch ฟังดูสูงขึ้นเล็กน้อย ตอนอยู่ข้าง ๆ listener พอดี (z=30, ยังคงเคลื่อนที่ตามแกน x) velocity ของรถตั้งฉากกับเส้นที่เชื่อมมันกับ listener พอดี ระยะทาง ณ ตอนนั้นไม่ได้เปลี่ยนเลย dot product เลยเป็น 0 และ factor เลยเป็น 1.0 พอดี ซึ่งเป็นช่วงเวลาคลาสสิกที่ไซเรนที่กำลังแซงผ่านฟังดู "ปกติ" เพียงชั่วครู่ พอมันแซงไปแล้วขับห่างออก factor ก็ตกลงต่ำกว่า 1

Unity เปิดให้ใช้ตรงนี้ผ่าน AudioSource.dopplerLevel โดย 0 คือปิดเอฟเฟกต์นี้ไปเลย 1 คือใกล้เคียงกับปริมาณที่อิงหลักฟิสิกส์จริงประมาณนี้ และค่าที่สูงกว่า 1 คือขยายมันให้เกินจริงเพื่อให้ได้ "whoosh" ที่ดูมีสไตล์มากขึ้น Unity คำนวณ velocity ของ source กับ listener ให้อัตโนมัติระหว่างเฟรม และเอามาปรับ pitch ให้เรา แทบไม่ต้องเรียกสูตรแบบนี้ด้วยมือเลย

Tip ตั้ง dopplerLevel เป็น 0 สำหรับเสียงที่ติดอยู่กับกล้องเอง หรือสำหรับเสียง 3D ที่ออกแนว UI กล้องที่กระตุกหรือหมุนเร็ว ๆ จะเคลื่อนที่ "เร็ว" เทียบกับทุกเสียงในซีนในเฟรมเดียว และถ้าไม่ปิด Doppler ไว้ ตรงนี้จะแสดงออกมาเป็นเสียง pitch สั่นแบบกวนใจและไม่มีความหมายอะไรเลย ทั้งที่ไม่มีอะไรในโลกเกมเคลื่อนที่จริง ๆ

6. Occlusion and obstruction: raycasting and the low-pass filter

ทั้ง distance และ panning ต่างสมมติว่ามีเส้นตรงที่โล่งไม่มีอะไรบังระหว่าง source กับ listener แต่ level จริง ๆ มีกำแพง Occlusion คือสิ่งที่เกิดขึ้นเมื่อมีของแข็งขวางอยู่ตรงกลางระหว่าง source กับ listener พอดี บังทั้งเสียงตรงและ reflection ที่ปกติจะแอบอ้อมมาได้ ผลลัพธ์ควรฟังดูเบาลงและทึบลงชัดเจน ส่วน Obstruction เป็นน้องเล็กที่เบากว่า คือมีอะไรมาขวางเส้นทางตรง แต่เสียงยังไปถึง listener ได้ทางอ้อม (อ้อมผ่านประตู ข้ามกำแพงเตี้ย ๆ) ดังนั้นมันควรอู้อี้น้อยกว่า

ขั้นตอนตรวจจับเป็นส่วนที่ง่าย คือยิง ray จาก source ไปทาง listener (หรือกลับกัน) แล้วดูว่ามันชนอะไรที่เป็นของแข็งระหว่างทางไหม ซึ่งก็คือ Physics.Raycast ตัวเดียวกับที่เรารู้จักอยู่แล้วจากโค้ดชนกันและยิงปืน ส่วนการทำให้อู้อี้นั้นทำด้วย low-pass filter คือ filter ที่ปล่อยความถี่ต่ำ (เบส) ผ่านได้เกือบเหมือนเดิม แต่ตัดความถี่สูงออก ตรงนี้ตรงกับพฤติกรรมของกำแพงจริง ๆ คือเสียงเบสลึก ๆ ทะลุกำแพงได้ง่าย ในขณะที่เสียงพูดและโน้ตสูง ๆ จะถูกบังมากกว่ามาก ซึ่งเป็นเหตุผลว่าทำไมเสียงที่อู้อี้ผ่านกำแพงถึงฟังดู "อู้อึ้ง" มากกว่าแค่เบาลงเฉย ๆ

using UnityEngine;

[RequireComponent(typeof(AudioLowPassFilter))]
public class AudioOcclusion : MonoBehaviour
{
    public AudioSource source;
    public Transform listener;
    public LayerMask occluderMask;      // walls and doors only -- NOT the player or other sounds

    const float OpenCutoff     = 22000f; // effectively "no filtering" (top of human hearing)
    const float OccludedCutoff = 800f;   // muffled: only bass gets through

    AudioLowPassFilter lowPass;

    void Awake()
    {
        lowPass = GetComponent<AudioLowPassFilter>();
    }

    void Update()
    {
        Vector3 toListener = listener.position - transform.position;
        bool occluded = Physics.Raycast(transform.position, toListener.normalized,
                                         toListener.magnitude, occluderMask);

        float target = occluded ? OccludedCutoff : OpenCutoff;
        // ease toward the target instead of snapping -- an instant jump sounds like a click
        lowPass.cutoffFrequency = Mathf.Lerp(lowPass.cutoffFrequency, target, Time.deltaTime * 8f);
    }
}
SOURCE WALL LISTENER o ---------------------------[#####]----------------------- o ^ Physics.Raycast hits here occluded == true -> target cutoff drops to 800 Hz (muffled, bass-only) occluded == false -> target cutoff returns to 22000 Hz (full range, "open")

Worked trace ไล่ดูหลายเฟรม ตอนที่ผู้เล่นเดินอ้อมไปหลังกำแพงแล้วเดินกลับออกมา:

frame player position occluded? cutoffFrequency (Hz, easing toward target) 0 open hallway false 22000 (full range) 30 approaching the wall false 22000 45 steps behind the wall true ~14000 (starting to muffle) 60 still behind the wall true ~9000 90 still behind the wall true ~2200 (clearly muffled now) 120 steps back into the open false ~9000 (recovering...) 150 fully back in the open false 22000 (full range again)

Mathf.Lerp(current, target, Time.deltaTime * 8f) จะขยับ cutoff frequency ไปทางเป้าหมายทีละเศษส่วนในทุกเฟรม แทนที่จะกระโดดไปเลยทันที ซึ่งเป็นเหตุผลที่ตารางแสดงให้เห็นว่ามันค่อย ๆ ขยับเข้าใกล้เป้าหมาย แทนที่จะกระโดดตรงไปที่ 800 หรือ 22000 เลย ถ้าไม่มี easing นี้ ทุกครั้งที่ผู้เล่นเดินข้ามขอบกำแพง filter จะเปิด/ปิดในเฟรมเดียวทันที ฟังดูเป็นเสียงคลิกแหลม ๆ น่ารำคาญ แทนที่จะเป็นการอู้อี้แบบนุ่ม ๆ

Common mistake ปล่อย occluderMask ไว้เป็น "Everything" ราคาสัต ray จะไปชน collider ของผู้เล่นเอง collider ของ sound source เอง แขนขา ragdoll และไอเทมที่หยิบได้อย่างสนุกสนาน ทั้งที่ไม่ควรมีตัวไหนบังเสียงเลย แล้วเสียงก็จะบังตัวเองไปเรื่อย ๆ โดยไม่มีเหตุผลให้เห็น ให้เอากำแพงและ geometry ก้อนใหญ่ ๆ ไปไว้ layer ของตัวเอง (เช่น Occluders) แล้วเล็ง mask ไปที่ layer นั้นอย่างเดียว

7. Reverb zones: giving a space its own character

Reverb คือเสียงที่สะท้อนกลับจากพื้นผิวใกล้เคียงแล้วมาถึงหูเราทีหลังเสียงต้นฉบับเล็กน้อย มันคือสิ่งที่ทำให้การตะโกนในถ้ำหินฟังดูต่างจากการตะโกนในห้องเล็ก ๆ ปูพรมโดยสิ้นเชิง ทั้งที่ระยะระหว่าง source กับ listener เท่ากันเป๊ะ ห้องเล็ก ๆ มีพื้นผิวแข็งที่อยู่ใกล้กันมาก reverb จะกลับมาเกือบทันทีและหายไปเร็ว (ต่ำกว่าหนึ่งวินาทีมาก) ส่วนถ้ำมีพื้นผิวแข็งขนาดใหญ่ที่อยู่ห่างกันมาก reverb จะใช้เวลานานกว่าจะมาถึงและก้องอยู่หลายวินาทีก่อนจะจางหาย มักมีเสียงก้องแยกได้ชัดเจนแทนที่จะเป็นเสียงกลืนเรียบ ๆ

AudioReverbZone ของ Unity เป็น volume ในระดับ world-space คือ AudioListener ตัวไหนก็ตามที่เดินเข้าไปข้างในจะโดนเอฟเฟกต์ reverb นั้นใส่ไปกับ ทุกอย่างที่มันได้ยิน โดยจะ blend เข้าอย่างนุ่มนวลระหว่าง minDistance (เต็มเอฟเฟกต์) กับ maxDistance (ไม่มีเอฟเฟกต์เลย) จากจุดกึ่งกลางของ zone — เป็นไอเดีย min/max ตัวเดียวกับหัวข้อ 2-3 แค่เอามาใช้กับปริมาณ reverb แทนความดัง

using UnityEngine;

public class CaveReverbZone : MonoBehaviour
{
    void Awake()
    {
        AudioReverbZone zone = gameObject.AddComponent<AudioReverbZone>();
        zone.minDistance  = 5f;                          // full reverb starts fading in here
        zone.maxDistance  = 30f;                         // outside this radius, no reverb at all
        zone.reverbPreset = AudioReverbPreset.Cave;      // long, boomy decay with audible echo
    }
}
+--------------------------------------------+ | outside the zone: dry, no reverb at all | | +----------------------------------------+ | | between minDistance and maxDistance: | | | reverb blends in from 0% to 100% | | | +------------------------------+ | | | | inside minDistance: | | | | | full Cave reverb (100%) | | | | +------------------------------+ | | +----------------------------------------+ +--------------------------------------------+

Preset "Room" จะใช้ component เดียวกันแต่มีค่า decay ที่เล็กกว่ามากฝังอยู่ในตัว preset นั้น คือ reverb สั้น แน่น ฟังดูใกล้ ๆ แทนที่จะเป็น Cave ที่ยาวและก้อง ทั้งสอง preset ก็แค่ชุดตัวเลขจำนวนหนึ่งที่ tune มาไว้ล่วงหน้าเหมือนกัน คือ decay time, early reflection แรงแค่ไหน, และความถี่สูงถูกดูดกลืนไปมากแค่ไหนตอนสะท้อนกลับมา

Common mistake เอา AudioReverbZone ไปติดกับ sound source โดยหวังว่ามันจะเพิ่ม reverb ให้เสียงตัวนั้นตัวเดียว Reverb zone จะทำงานตาม ตำแหน่งของ listener ไม่ใช่ตำแหน่งของ source ตัวไหนเลย และพอมันทำงานแล้วมันจะมีผลกับ ทุกเสียง ที่ listener ได้ยิน ไม่ใช่แค่ source ที่เลือกไว้ ถ้าเราต้องการเอฟเฟกต์ reverb แยกให้ AudioSource ตัวใดตัวหนึ่งโดยเฉพาะ ไม่ว่า listener จะยืนอยู่ตรงไหนก็ตาม นั่นเป็น component คนละตัวไปเลย คือ AudioReverbFilter ซึ่งติดตรงกับ source โดยตรง

8. HRTF and binaural audio: localizing height and front/back

หัวข้อ 4 จบด้วยช่องว่างหนึ่งช่อง คือ plain stereo panning แยก "ตรงหน้า" กับ "ด้านหลังพอดี" ไม่ออก เพราะทั้งคู่ตกที่ pan = 0 เหมือนกัน คือตรงกลางบนแกนเดียวที่ panning มี การได้ยินของมนุษย์จริง ๆ ไม่มีปัญหานี้ และควรรู้ไว้คร่าว ๆ ว่าทำไม แม้ว่าตัวแก้ปัญหาจะอยู่ในโค้ดของ engine กับ headphone เป็นส่วนใหญ่ที่เราไม่ต้องเขียนเอง

หูของเราใช้ cue ง่าย ๆ สองอย่างหลักในการแยกซ้ายจากขวา คือความต่างเล็ก ๆ ของ เวลาที่เสียงมาถึง ระหว่างหูสองข้าง (เรียกว่า ITD, interaural time difference — เสียงจากทางขวาจะมาถึงหูขวาก่อนหูซ้ายเสี้ยวมิลลิวินาที) และความต่างของ ความดัง ระหว่างหูสองข้าง (ILD, interaural level difference — หัวของเราเองบังเสียงไม่ให้ไปถึงหูฝั่งไกลได้เต็มที่) ทั้งสองอย่างนี้สมมาตรกันระหว่างหน้ากับหลัง คือเสียงที่มาจากตรงหน้าพอดีกับเสียงที่มาจากด้านหลังพอดีจะให้ ITD กับ ILD ที่เกือบเหมือนกัน ซึ่งตรงกับความกำกวมที่เห็นใน C กับ D ในหัวข้อ 4 เป๊ะ วิศวกรเสียงเรียกวงแหวนของทิศทางทั้งหมดที่มี ITD/ILD เหมือนกันนี้ว่า cone of confusion

top-down view of a listener's head (forward) | possible source -------> (o) <------- possible source "directly ahead" ear "directly behind" same ITD, same ILD same ITD, same ILD | (behind) Plain left/right panning cannot tell these positions apart -- HRTF adds spectral coloring (from the outer ear's shape) that DOES change between them, which is what finally breaks the tie.

สิ่งที่แก้ปัญหานี้ได้จริง ๆ คือรูปทรงของใบหูด้านนอกของเราเอง (pinna) รอยพับและสันของมันจะสะท้อนและดูดกลืนความถี่ต่าง ๆ ไม่เหมือนกัน ขึ้นอยู่กับมุมที่เสียงมาถึงพอดี โดยเฉพาะเรื่องความสูงและหน้า/หลัง สมองของเราใช้เวลาทั้งชีวิตเรียนรู้ว่า "แพทเทิร์นความถี่ที่ถูกเสริมและตัดแบบนี้เฉพาะ" หมายถึงทิศทางไหน ตัวกรอง (filtering) แบบที่วัดมาจริง (หรือจำลองขึ้น) เรียกว่า HRTFHead-Related Transfer Function — และเสียงที่ผ่านการประมวลผลแบบนี้เพื่อหลอกหูเราแบบนี้เรียกว่า binaural audio มันทำงานได้ดีที่สุดผ่านหูฟัง เพราะหูฟังส่งช่องสัญญาณของแต่ละหูแยกจากกันโดยสิ้นเชิง ส่วนลำโพงจะมีสัญญาณของแต่ละช่องรั่วไปเข้าหูทั้งสองข้างบางส่วน ทำให้เอฟเฟกต์นี้เจือจางลงบางส่วน

ใน Unity สิ่งนี้แสดงออกมาในรูปแบบ spatializer plugin (Oculus Spatializer, Microsoft Spatial Sound, Steam Audio, Resonance Audio และอื่น ๆ) ซึ่งเลือกครั้งเดียวสำหรับทั้งโปรเจกต์ แล้วเปิดใช้ทีละ source ด้วย checkbox Spatialize บน AudioSource มันจะแทนที่คณิตศาสตร์ pan-and-attenuate ธรรมดาจากหัวข้อ 2-4 ด้วย HRTF filtering จริง ๆ ซึ่งมีต้นทุน CPU จริง ๆ ด้วย นั่นเป็นเหตุผลว่าทำไมมันมักถูกสงวนไว้ใช้กับเสียงสำคัญ ๆ ใกล้ตัวจำนวนหนึ่ง (ฝีเท้ารอบผู้เล่น VR มอนสเตอร์ที่วนอยู่ด้านหลังเรา) แทนที่จะใส่ให้ทุกเสียง 3D ในซีนที่วุ่นวาย

Tip checkbox Spatialize ที่ไม่มี spatializer plugin ถูกเลือกไว้ใน Project Settings > Audio จะไม่ทำอะไรเลยเงียบ ๆ — checkbox นี้มีอยู่บน AudioSource ทุกตัวไม่ว่าจะติดตั้ง plugin ไว้หรือไม่ก็ตาม ถ้า HRTF processing ดูเหมือนจะไม่มีผลอะไรเลย ให้เช็กการตั้งค่า plugin ระดับโปรเจกต์ก่อน ก่อนที่จะสรุปว่าโค้ดของเราผิด

9. Performance: limiting active 3D voices

ซีนที่วุ่นวายสามารถมีสิ่งที่กำลังส่งเสียงได้พร้อมกันเป็นร้อย ๆ อย่างง่าย ๆ ไม่ว่าจะฝีเท้า การชน เสียง ambience loop เสียงคำรามมอนสเตอร์ voice ที่ active อยู่แต่ละตัว (เสียงหนึ่งเสียงที่กำลังเล่นอยู่ ณ ตอนนั้น) มีต้นทุน CPU จริง ๆ คือ distance attenuation, panning, และ filter ต่าง ๆ ของมันจะถูกคำนวณใหม่ทุกเฟรม พอเกินจำนวนหนึ่งไปแล้ว voice ส่วนใหญ่พวกนั้นก็เบาเกินไปหรือไกลเกินไปจนไม่มีใครได้ยินแยกออกจากกันได้อยู่แล้ว มันจะแค่รวมกันเป็นเสียงพื้นหลังที่ได้ยินไม่ชัด ทางแก้คือจัดอันดับเสียงตามความสำคัญ ณ ตอนนั้นอย่างชัดเจน แล้วให้แค่ตัวท็อป ๆ เล่นได้จริง

using UnityEngine;
using System.Collections.Generic;
using System.Linq;

public class VoiceLimiter : MonoBehaviour
{
    public int maxActiveVoices = 16;
    public List<AudioSource> allSources = new List<AudioSource>();
    Transform listener;

    void Awake() { listener = Camera.main.transform; }

    void Update()
    {
        // score = how much this voice matters right now: louder AND closer wins
        var ranked = allSources
            .Where(s => s.isPlaying)
            .OrderByDescending(s => Score(s))
            .ToList();

        for (int i = 0; i < ranked.Count; i++)
        {
            bool shouldBeAudible = i < maxActiveVoices;
            ranked[i].mute = !shouldBeAudible;   // keep simulating position, just silence it
        }
    }

    float Score(AudioSource s)
    {
        float distance = Vector3.Distance(s.transform.position, listener.position);
        return s.volume / Mathf.Max(distance, 0.01f);
    }
}

Worked example โดยใช้ maxActiveVoices = 3:

source volume distance score = volume/distance rank audible? UI click 0.6 1 0.6000 1 yes Explosion 1.0 5 0.2000 2 yes Footstep 0.3 2 0.1500 3 yes Enemy growl 0.8 8 0.1000 4 no (muted) Ambience wind 0.5 50 0.0100 5 no (muted)

Footstep ชนะทั้ง volume ดิบของ explosion ที่ดังกว่ามาก และ ของ enemy growl เพราะมันอยู่ใกล้มาก score ให้รางวัลทั้งความดังและความใกล้ ซึ่งตรงกับสิ่งที่ listener จะสังเกตเห็นจริง ๆ การ sort list แบบนี้ทุกเฟรมโอเคสำหรับซีนที่มี source เป็นร้อย ๆ ตัว แต่ถ้าเป็นหมื่น ๆ ตัว เราควร re-rank ตามช่วงเวลา (เช่นทุก 0.2 วินาที) แทนที่จะทำทุกเฟรม เพราะอันดับของเสียงหนึ่งเสียงไม่ค่อยต้องเปลี่ยนบ่อยขนาดนั้น

Tip Unity มีเวอร์ชัน built-in ของไอเดียนี้อยู่แล้ว คือ Project Settings > Audio > Real Voices / Virtual Voices รวมกับ AudioSource.priority (0 = ไม่มีวันถูกตัดทิ้ง, 255 = ถูกตัดทิ้งก่อนใคร) มันจะ "virtualize" source ที่อันดับต่ำสุดโดยข้าม DSP processing ของมันไปเลยทั้งหมด แทนที่จะแค่ mute เฉย ๆ ซึ่งถูกกว่าเวอร์ชันที่เขียนเองข้างบน ให้ลองใช้ตัวนี้ก่อนเป็นอันดับแรก แล้วค่อยสร้าง scorer แบบกำหนดเองแบบนี้เมื่อต้องการกฎที่ priority number ทั่วไปของ Unity แสดงออกมาไม่ได้ เช่น การให้เสียงบทพูดของบอสได้ยินเสมอไม่ว่าจะเบาหรือไกลแค่ไหน โดยให้มันมีอันดับท็อปอัตโนมัติก่อนแล้วค่อย fallback ไปที่ distance/volume score สำหรับทุกอย่างที่เหลือ

10. Glossary

11. Exercises

Exercise 1 AudioSource ตัวหนึ่งใช้ minDistance = 3 และ maxDistance = 15 Listener ยืนอยู่ห่างพอดี 9 หน่วย คำนวณความดังภายใต้ linear rolloff และแยกต่างหากภายใต้ logarithmic rolloff ด้วยมือ
Show answer

Linear: 1 - (distance - minDistance) / (maxDistance - minDistance) = 1 - (9-3)/(15-3) = 1 - 6/12 = 1 - 0.5 = 0.5

Logarithmic: minDistance / distance = 3 / 9 = 0.3333

ผลลัพธ์ logarithmic เบากว่าที่ระยะนี้พอดี เพราะมันเบาลงเร็วกว่าตอนอยู่ใกล้ source แล้วค่อย ๆ ลากยาวอย่างนุ่มนวลหลังจากนั้น ที่จุดกึ่งกลางพอดีระหว่าง min กับ max linear จะเป็น 0.5 เสมอ ในขณะที่ค่า logarithmic ขึ้นอยู่กับอัตราส่วน minDistance/distance ล้วน ๆ ไม่ได้ขึ้นอยู่กับว่าเรานั่งอยู่ตรงไหนระหว่าง min กับ max

Exercise 2 Listener นั่งอยู่ที่ origin หันหน้าไปทาง forward = (0, 0, 1) โดยมี up = (0, 1, 0) (ดังนั้นเหมือนในหัวข้อ 4 right = (1, 0, 0)) Sound source อยู่ที่ (-4, 0, 4) คำนวณทิศทาง normalized ไปยัง source แล้วหาค่า pan (dot กับ right) และค่า front (dot กับ forward) อธิบายด้วยคำง่าย ๆ ว่าเสียงมาจากตำแหน่งไหนโดยประมาณ
Show answer

toSource = (-4, 0, 4), length = sqrt(16 + 0 + 16) = sqrt(32) ≈ 5.6569

dir ≈ (-0.7071, 0, 0.7071)

pan = Dot(dir, right) = -0.7071 front = Dot(dir, forward) = 0.7071

ค่า pan เป็นลบหมายถึงเสียงเอียงไปทางลำโพง ซ้าย และค่า front เป็นบวกหมายถึงมันอยู่ค่อนไปทาง ข้างหน้า ของ listener พูดง่าย ๆ คือเสียงมาจากด้านหน้าเอียงไปทางซ้าย ประมาณมุม 45 องศาจากจุดกึ่งกลาง — เป็นภาพกลับของ source E จากหัวข้อ 4

Exercise 3 ใช้กฎการให้คะแนนของ VoiceLimiter จากหัวข้อ 9 (score = volume / distance) จัดอันดับ source ทั้งสี่ตัวต่อไปนี้ และบอกว่าสองตัวไหนยังได้ยินอยู่ถ้า maxActiveVoices = 2: Wind ambience (volume 0.4, distance 100), Sword clang (volume 0.9, distance 3), Distant thunder (volume 1.0, distance 200), Player heartbeat (volume 0.2, distance 0.5)
Show answer

Player heartbeat: 0.2 / 0.5 = 0.4000

Sword clang: 0.9 / 3 = 0.3000

Distant thunder: 1.0 / 200 = 0.0050

Wind ambience: 0.4 / 100 = 0.0040

จัดอันดับ: Player heartbeat (0.4000), Sword clang (0.3000), Distant thunder (0.0050), Wind ambience (0.0040) เมื่อ maxActiveVoices = 2 heartbeat กับ sword clang ยังได้ยินอยู่ ส่วน thunder กับ wind ถูก mute แม้ว่า thunder จะมี volume ดิบสูงสุดในทั้งสี่ตัวก็ตาม เพราะการอยู่ห่างถึง 200 หน่วยลาก score ของมันให้ต่ำกว่าแม้แต่ heartbeat ที่เบา ๆ แต่อยู่ใกล้

ตำแหน่งของเสียงในโลกเกมสรุปลงมาเหลือแค่เครื่องมือชุดเดียวกับที่ใช้ในทุกบทคณิตศาสตร์ของหลักสูตรนี้ คือสองจุด และเวกเตอร์ระหว่างสองจุดนั้น ความยาวของมันขับเคลื่อน distance attenuation ทำให้ความดังจางลงเป็นเส้นตรง (linear) หรือเป็นรูปทรงแบบโลกจริงที่เร็วแล้วค่อยแบนลง (logarithmic) ทิศทางของมัน เมื่อ dot กับเวกเตอร์ right และ forward ของ listener ขับเคลื่อน panning ซ้าย/ขวา และเป็นสัญญาณล่วงหน้าของความกำกวมหน้า/หลังที่มีแต่ HRTF filtering จริง ๆ เท่านั้นที่แก้ได้ dot product กับ relative velocity ให้ Doppler pitch shift ray ระหว่าง source กับ listener ที่ป้อนเข้า low-pass filter ให้ occlusion volume ระดับ world-space รอบ listener ให้ reverb มีความรู้สึกถึงขนาดของพื้นที่ ไม่มีอะไรในนี้ที่ฟรีเลย ซึ่งเป็นเหตุผลว่าทำไมบทเรียนสุดท้ายถึงเป็นบทเรียนเดียวกับที่อื่น ๆ ในงาน engine เสมอ คือจัดอันดับสิ่งที่สำคัญ แล้วใช้เวลา CPU ไปกับสิ่งนั้นเท่านั้น

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