เสียงทุกอย่างในเกม -- เสียงเดินก้าวเท้า เสียงระเบิด เพลงประกอบ -- ลึก ๆ แล้วมันคือแค่ลิสต์ตัวเลขยาว ๆ ที่ถูกส่งออกไปที่ลำโพงหลายพันครั้งต่อวินาที บทนี้จะพูดถึงว่าเรื่องนี้ทำงานยังไงจริง ๆ ใน Unity ด้วย C# และทำไมเกมสตูดิโอที่ shipped ออกมาเกือบทุกเกมถึงไม่เขียนโค้ดทำ pipeline ทั้งหมดนั้นเอง -- พวกเขาสร้างเสียงในเครื่องมือแยกต่างหากที่เรียกว่า audio middleware (Wwise, FMOD) แล้วให้โปรแกรมเมอร์แค่พูดว่า "เล่นอันนี้" โดยใช้ชื่อ เราจะเริ่มจาก AudioSource ตัวเดียวที่เล่นคลิปเสียง ผ่าน mixer และระบบ bus ของ Unity ไปจนถึงโมเดล event/container/RTPC ที่ middleware ใช้ และการตัดสินใจเรื่อง streaming, memory, กับ voice limit ที่โปรเจกต์จริงต้องทำเมื่อมีเสียงเป็นพันแทนที่จะเป็นสิบ
ไมโครโฟนแปลงเสียงให้กลายเป็นแรงดันไฟฟ้าที่สั่นไปมา เพื่อเก็บสิ่งนี้ไว้ในคอมพิวเตอร์ เราวัด (sample) แรงดันนั้นหลายครั้งต่อวินาที แล้วเก็บค่าที่วัดได้แต่ละครั้งเป็นตัวเลข เสียงในเกมทั่วไปจะถูก sample 44,100 หรือ 48,000 ครั้งต่อวินาที (เรียกว่า sample rate หน่วยเป็น Hertz, Hz) เอาตัวเลขพวกนั้นมาเล่นผ่านลำโพงเร็วพอ ๆ กันตามลำดับเดิม ลำโพงก็จะสั่นเข้าออกแล้วสร้างการสั่นแบบเดิมขึ้นมาใหม่ -- คุณก็ได้ยินเสียงนั้นอีกครั้ง
การส่งตัวเลขพวกนั้นไปให้ลำโพงทันเวลาเป็นหน้าที่ของ audio thread โดยเฉพาะ (thread คือเส้นทางการทำงานแยกต่างหากที่รันอิสระจาก main thread ของเกม ซึ่งเป็นตัวที่รัน Update()) โดยประมาณมันทำงานแบบนี้ วนซ้ำไปเรื่อย ๆ:
deadline นั้นแหละคือเหตุผลที่ audio thread เป็นส่วนที่ไม่ให้อภัยที่สุดส่วนหนึ่งของ game engine ถ้ามันช้าไป ผู้เล่นจะไม่ได้ "เห็น" เฟรมช้า -- แต่จะ "ได้ยิน" มันแทน เป็นเสียงคลิก เสียงป็อป หรือช่วงเงียบสนิท (เรียกว่า buffer underrun หรือ dropout: buffer หมด sample ก่อนที่ sample ใหม่จะมาถึง) เรื่องนี้เป็นจริงแม้ในเครื่องที่ส่วนที่เหลือของเกมรันที่ 60 เฟรมต่อวินาทีลื่นไหลสมบูรณ์แบบ เพราะ audio thread รันตามตารางเวลาของตัวเอง เป็นอิสระจาก render loop
กฎที่ตามมาในทางปฏิบัติคือ: ห้ามทำงานที่ช้าหรือคาดเดาเวลาไม่ได้บน audio thread โดยตรงเด็ดขาด -- ห้าม allocate memory บน heap (ซึ่งอาจไป trigger garbage collector) ห้ามโหลดไฟล์ ห้ามใช้ lock ที่อาจ block การทำงาน ห้ามเรียก game code แบบที่คาดเดาไม่ได้ Unity เปิดให้เข้าถึง audio thread โดยตรงผ่าน OnAudioFilterRead ซึ่งเป็น callback ที่คุณเขียนเองเพื่อประมวลผล sample ดิบได้:
using UnityEngine;
using System.Collections.Generic;
public class BadAudioFilter : MonoBehaviour
{
// Unity calls this ON THE AUDIO THREAD, not the main thread,
// every time it needs the next batch of samples for this source.
void OnAudioFilterRead(float[] data, int channels)
{
// BAD: allocates a new List every single call. That allocation
// can trigger the garbage collector, which can pause execution
// -- including this thread -- for long enough to miss the deadline.
List<float> temp = new List<float>(data.Length);
for (int i = 0; i < data.Length; i++)
{
temp.Add(data[i] * 0.5f); // halve the volume
}
for (int i = 0; i < data.Length; i++)
{
data[i] = temp[i];
}
}
}
Worked trace: callback นี้อาจรันหลายร้อยครั้งต่อวินาที แต่ละครั้งคาดว่าจะเสร็จในเวลาน้อยกว่าหนึ่ง millisecond มาก ๆ ไม่กี่ครั้งแรกน่าจะรันได้ปกติ แล้วในจังหวะที่คาดเดาไม่ได้ garbage collector จะตัดสินใจว่าต้องเรียกคืนหน่วยความจำจาก List ที่ถูกทิ้งไปทั้งหมดนั้น แล้วก็หยุด (pause) การทำงานของ managed code ไปสองสามมิลลิวินาที ถ้า pause นั้นเกิดขึ้นตอน audio thread กำลังทำ callback อยู่กลางคัน buffer ก็จะพลาด deadline -- ผู้เล่นจะได้ยินเสียงคลิก โดยไม่มี error message ใด ๆ และไม่มีอะไรใน Console ที่ชี้ไปที่สาเหตุเลย ทางแก้คือห้าม allocate อะไรเลยข้างใน callback:
using UnityEngine;
public class OkAudioFilter : MonoBehaviour
{
public float volumeScale = 0.5f;
void OnAudioFilterRead(float[] data, int channels)
{
// GOOD: no allocation, just simple math directly on the buffer
// Unity already gave us.
for (int i = 0; i < data.Length; i++)
{
data[i] = data[i] * volumeScale;
}
}
}
นี่คือเหตุผลที่สตูดิโอมองว่าเสียงเป็นปัญหาทาง engineering ของตัวมันเอง ไม่ใช่แค่ "เพิ่มไฟล์เสียงเข้าไป" ทุกดีไซน์ในบทนี้ -- bus, event, streaming, voice limit -- มีอยู่ส่วนหนึ่งเพื่อเก็บการตัดสินใจที่มีต้นทุนสูง (จะเล่นเสียงไหน ดังแค่ไหน มาจากตำแหน่งไหน) ไว้บน main thread ที่ปลอดภัย หรือบน audio-engine thread เฉพาะที่ถูกสร้างมาเพื่อสิ่งนี้โดยเฉพาะ ให้ห่างไกลจาก deadline ต่อ buffer ที่ไม่ให้อภัยนั้น
OnAudioFilterRead เองในโปรเจกต์เกมเพลย์ทั่วไปหรอก -- มันมีไว้สำหรับ custom DSP (digital signal processing, การประมวลผลสัญญาณดิจิทัล) effect เป็นหลัก ที่ยกมาให้ดูตรงนี้เพราะมันเป็นจุดที่เห็น deadline ของ audio thread ได้ชัดที่สุดใน C# API ของ Unityโมเดลระบบเสียงของ Unity มีสองส่วน ตรงกับวิธีที่คุณจะอธิบายเสียงในชีวิตจริง: มีอะไรบางอย่างส่งเสียงออกมา และมีอะไรบางอย่างได้ยินมัน
ตั้งค่าแบบขั้นต่ำสุด: ติด AudioSource ไว้กับ GameObject ใส่คลิปเสียงเข้าไป แล้วสั่งเล่น
using UnityEngine;
[RequireComponent(typeof(AudioSource))]
public class SimplePlayer : MonoBehaviour
{
public AudioClip clip;
private AudioSource source;
void Awake()
{
source = GetComponent<AudioSource>();
source.clip = clip;
source.playOnAwake = false; // we will call Play() ourselves
}
void Start()
{
source.Play();
Debug.Log("Playing " + clip.name + ", length " + clip.length + "s");
}
}
Expected output (สมมติว่ามีคลิปชื่อ explosion_01 ยาว 1.8 วินาที):
Playing explosion_01, length 1.8s
ถ้า AudioSource ตั้งค่า Spatial Blend ไว้มากกว่า 0 (ครึ่งทางหรือเต็มไปทาง "3D") Unity จะลดระดับเสียง (attenuate) ตามระยะห่างจาก AudioListener ด้วย และ pan ซ้าย/ขวาตามทิศทาง -- นี่คือสิ่งที่ทำให้ระเบิดที่อยู่ทางขวาของคุณฟังดูเหมือนมาจากทางขวาจริง ๆ Spatial Blend ที่ 0 แปลว่าเป็น 2D เต็มตัว: ดังเท่าเดิมและไม่ pan เลยไม่ว่า AudioListener จะอยู่ตรงไหน ซึ่งปกติแล้วเป็นสิ่งที่คุณต้องการสำหรับเพลงกับเสียง UI
Play() จากสคริปต์ด้วย คลิปจะเริ่มเล่นสองครั้ง -- ครั้งหนึ่งอัตโนมัติ อีกครั้งจากโค้ดของคุณ -- แล้วคุณจะได้ยินเสียงเดียวกันซ้อนกันสองชุดที่เหลื่อมเฟสกันนิดหน่อยการเรียก source.Play() ครั้งที่สองในขณะที่ source กำลังเล่นอะไรบางอย่างอยู่แล้วจะ แทนที่ สิ่งที่กำลังเล่นอยู่ -- มันจะตัดเสียงเก่าทิ้งแล้วเริ่มเสียงใหม่ นั่นผิดสำหรับอะไรอย่างเสียงปืน ที่คุณต้องการให้แต่ละนัดใหม่ซ้อนทับกับนัดก่อนหน้าแทนที่จะตัดมันทิ้ง ทางแก้ของ Unity คือ PlayOneShot: มันเล่นคลิปหนึ่งครั้ง โดยไม่รบกวนสิ่งที่ source กำลังทำอยู่แล้ว
using UnityEngine;
public class Gun : MonoBehaviour
{
public AudioSource source;
public AudioClip shotClip;
public void Fire()
{
// PlayOneShot layers a new, independent playback of shotClip
// on top of anything this source is already playing.
source.PlayOneShot(shotClip);
}
}
Worked trace: สมมติว่า Fire() ถูกเรียกสามครั้ง ที่ t=0.00s, t=0.05s, และ t=0.10s จากอาวุธยิงเร็ว แต่ละครั้งจะเริ่ม playback ของ shotClip เป็นอิสระของตัวมันเอง ซ้อนทับกับตัวอื่น ๆ:
ข้อจำกัดหนึ่ง: เพราะ PlayOneShot ไม่ได้ return reference ของเสียงที่มันเริ่มเล่นกลับมา คุณจึงไม่สามารถ stop, pause, หรือปรับ pitch ของ one-shot ตัวใดตัวหนึ่งได้หลังจากมันเริ่มเล่นไปแล้ว -- มีแค่ source.Stop() ที่หยุดทุกอย่างที่ source นั้นกำลังเล่นอยู่ พร้อมกันทั้งหมดได้ เวลาที่คุณต้องควบคุมเสียงตัวใดตัวหนึ่งหลังจากมันเริ่มเล่นแล้ว (เช่น เสียงเครื่องยนต์ที่ loop ที่ pitch เปลี่ยนตามความเร็ว) ให้ใช้ AudioSource เฉพาะทางกับ Play() แทน ซึ่งเป็นสิ่งที่หัวข้อถัดไปทำพอดี
ฟิลด์ของ AudioSource สามตัวที่ครอบคลุมการควบคุมทั่วไปส่วนใหญ่: loop (bool -- เริ่มเล่นใหม่อัตโนมัติเมื่อคลิปจบ), volume (0 ถึง 1 แบบ linear), และ pitch (ตัวคูณความเร็ว -- 1 คือปกติ 2 คือเร็วเป็นสองเท่าและสูงขึ้นหนึ่ง octave 0.5 คือช้าลงครึ่งหนึ่งและต่ำลงหนึ่ง octave) การใช้งานที่พบบ่อยคือผูก pitch ของเสียงเครื่องยนต์หรือเสียงลมเข้ากับค่าเกมเพลย์บางอย่าง:
using UnityEngine;
public class EngineSound : MonoBehaviour
{
public AudioSource source;
public float minPitch = 0.8f;
public float maxPitch = 2.0f;
void Start()
{
source.loop = true;
source.volume = 0.7f;
source.Play();
}
void Update()
{
float speedFraction = GetCarSpeedFraction(); // 0 (stopped) to 1 (top speed)
source.pitch = Mathf.Lerp(minPitch, maxPitch, speedFraction);
}
float GetCarSpeedFraction()
{
return 0.5f; // placeholder -- a real script reads this from the car's Rigidbody
}
}
Worked trace: ถ้า speedFraction ถูกกำหนดตายตัวไว้ที่ 0.5 สำหรับตัวอย่างนี้ Mathf.Lerp(0.8, 2.0, 0.5) จะได้ 1.4 -- เสียง loop เครื่องยนต์จะเล่นเร็วขึ้น 40% และ pitch สูงขึ้นอย่างชัดเจนเทียบกับ pitch ที่บันทึกไว้ และเพราะ loop เป็น true มันจะเริ่มเล่นใหม่แบบไร้รอยต่อทุกครั้งที่ไปถึงจุดจบของคลิป ตราบใดที่ object นั้นยังมีอยู่
loop = true แปลว่าตลอดไป ไม่ใช่ "จนกว่า object จะดูเหมือนจบแล้ว" AudioSource ที่ loop อยู่บน object ที่ถูก pool หรือ deactivate ไว้จะยังคงเล่นต่อ (หรือคงสถานะพร้อม resume ไว้) เว้นแต่คุณจะเรียก Stop() อย่างชัดเจนก่อน disable หรือคืนมันกลับเข้า pool บั๊กที่พบบ่อยมาก: ศัตรูตาย GameObject ของมันถูก deactivate เพื่อนำกลับมาใช้ใหม่ แต่เสียง "alert" ที่ loop อยู่ไม่เคยถูกสั่งหยุด -- มันจะแอบกิน voice ต่อไปเงียบ ๆ (ดูหัวข้อ 11) หรือกลับมาเห่าใหม่ทันทีที่ object ถูก reactivate ที่ไหนสักแห่งเกมหนึ่งเกมสามารถมี AudioSource เป็นร้อยตัวที่ active อยู่ใน scene ที่วุ่นวายได้อย่างง่ายดาย การควบคุม volume ของแต่ละตัวทีละตัวไม่ scale -- ถ้าผู้เล่นอยากลดเสียงเพลงโดยไม่แตะเสียง effect คุณคงไม่อยากไล่หา AudioSource ที่เกี่ยวกับเพลงทุกตัวใน scene AudioMixer ของ Unity แก้ปัญหานี้ด้วย group (หรือเรียกว่า bus ในเครื่องมืออื่น ๆ): node ที่มีชื่อเรียงกันเป็นโครงสร้างต้นไม้ (tree) AudioSource แต่ละตัวถูก route ไปที่ group เดียวเท่านั้น และ volume ของแต่ละ group จะมีผลกับทุกอย่างที่ route เข้ามาที่มัน หรือเข้าไปที่ลูกของมันด้วย
โครงสร้าง tree นี้ก็เป็นจุดที่คุณติด effect ที่ควรใช้กับทั้งหมวดหมู่พร้อมกันด้วย -- compressor กับ limiter บน Master เพื่อไม่ให้มิกซ์รวมโดน clip, low-pass filter บน Ambience เพื่อให้เสียงที่อยู่ไกลรู้สึกอู้อี้ ฯลฯ -- แทนที่จะต้องเพิ่ม effect component เดียวกันให้กับ AudioSource แต่ละตัวด้วยมือ
ในการเปลี่ยน volume ของ group จากสคริปต์ -- เช่น slider ใน settings -- คุณต้องเรียก AudioMixer.SetFloat บน parameter ที่ถูก expose ไว้แล้ว (คลิกขวาที่ฟิลด์ Volume ของ group ในหน้าต่าง Mixer แล้วเลือก "Expose to script" -- parameter ที่ไม่ได้ expose จะเข้าถึงจากโค้ดไม่ได้) กับดักสำคัญคือ: parameter volume ของ mixer เป็นหน่วย decibel (dB) ซึ่งเป็นหน่วยแบบ logarithmic ไม่ใช่ช่วง 0-ถึง-1 แบบ linear ธรรมดาที่ slider UI ให้มาโดยธรรมชาติ
using UnityEngine;
using UnityEngine.Audio;
public class MixerVolumeSlider : MonoBehaviour
{
public AudioMixer mixer;
// Called from a UI slider with a value from 0 (silent) to 1 (full volume).
public void SetMusicVolume(float linear01)
{
// AudioMixer parameters are in decibels, not 0..1.
// 1.0 linear -> 0 dB (unchanged). 0.0 linear -> -80 dB (effectively silent).
float dB = (linear01 <= 0.0001f) ? -80f : Mathf.Log10(linear01) * 20f;
mixer.SetFloat("MusicVolume", dB);
Debug.Log("Slider " + linear01 + " -> " + dB + " dB");
}
}
Worked trace: การเรียก SetMusicVolume(0.5f) จะคำนวณ Mathf.Log10(0.5) * 20 Log10(0.5) มีค่าประมาณ -0.301 คูณด้วย 20 ได้ประมาณ -6.02:
Slider 0.5 -> -6.02 dB
สเกลแบบ logarithmic ไม่ใช่ตัวเลือกที่เลือกมาแบบสุ่ม ๆ -- การได้ยินของมนุษย์เองรับรู้ความดังแบบ logarithmic โดยประมาณ ดังนั้นการ fade แบบ linear 0-ถึง-1 ตรง ๆ จะฟังดูเหมือนเสียงลดลงเกือบทั้งหมดในช่วง 10% สุดท้ายของ slider ในขณะที่การ fade แบบอิง dB จะฟังดูนุ่มนวลและสม่ำเสมอตลอดทั้งช่วง การอ่านค่ากลับก็ทำแบบเดียวกันในทิศตรงข้าม ด้วย mixer.GetFloat("MusicVolume", out float currentDB) -- ใช้ในโค้ด ducking ในหัวข้อถัดไป
SetFloat ด้วยชื่อ parameter ที่ไม่เคยถูก expose ไว้ในหน้าต่าง Mixer มันไม่ throw exception -- มันจะ return false เงียบ ๆ แล้วไม่ทำอะไรเลย ซึ่งเป็นความล้มเหลวแบบเงียบที่งงมากตอนคุณเจอครั้งแรกDucking หมายถึงการลด volume ของ bus หนึ่งอัตโนมัติในขณะที่ bus อีกตัวกำลัง active อยู่ -- กรณีคลาสสิกคือดึง bus Music ลงตอนที่บทสนทนา (Voice) กำลังเล่น เพื่อให้ผู้เล่นฟังบทสนทนารู้เรื่องจริง ๆ แล้วดันเพลงกลับขึ้นมาเมื่อบทสนทนาจบ
เวอร์ชันที่เขียนเองจะ fade parameter mixer "MusicVolume" จากหัวข้อ 6 ไปสู่เป้าหมายที่เบาลงในเวลาสั้น ๆ โดยใช้ coroutine เพื่อให้การเปลี่ยนแปลงนุ่มนวลแทนที่จะกระโดดทันที:
using System.Collections;
using UnityEngine;
using UnityEngine.Audio;
public class DialogueDucking : MonoBehaviour
{
public AudioMixer mixer;
public float duckedDB = -12f; // how quiet music gets while someone talks
public float normalDB = 0f; // music at full volume
public float fadeTime = 0.3f; // seconds to fade in or out
public void OnDialogueStart()
{
StopAllCoroutines();
StartCoroutine(FadeMusicTo(duckedDB));
}
public void OnDialogueEnd()
{
StopAllCoroutines();
StartCoroutine(FadeMusicTo(normalDB));
}
private IEnumerator FadeMusicTo(float targetDB)
{
mixer.GetFloat("MusicVolume", out float startDB);
float t = 0f;
while (t < fadeTime)
{
t += Time.deltaTime;
float current = Mathf.Lerp(startDB, targetDB, t / fadeTime);
mixer.SetFloat("MusicVolume", current);
yield return null;
}
mixer.SetFloat("MusicVolume", targetDB);
}
}
Worked trace: ระบบบทสนทนาเรียก OnDialogueStart() ที่ t=10.0s ในช่วง 0.3 วินาทีถัดมา FadeMusicTo จะรันครั้งละหนึ่งเฟรม ค่อย ๆ เลื่อน MusicVolume อย่างนุ่มนวลจาก 0 dB ไปทาง -12 dB โดยใช้ Lerp ซึ่งเป็น interpolation แบบเดียวกับที่คุณใช้กับการเคลื่อนที่ในบทก่อน ๆ -- แค่เอามาใช้กับค่า decibel แทนตำแหน่ง เมื่อบทสนทนาจบและ OnDialogueEnd() ถูกเรียก coroutine pattern เดียวกันจะ fade มันกลับขึ้นไปที่ 0 dB
AudioMixer ของ Unity ยังมีทางเลือกในตัวสำหรับเรื่องนี้โดยเฉพาะด้วย: Snapshot (ชุดค่าของทุก parameter ที่บันทึกไว้พร้อมกัน) ที่คุณ transition ไปมาได้ด้วย mixer.TransitionToSnapshots(...) ทำให้คุณสร้าง snapshot "Dialogue" (เพลงเบาลง อาจมี low-pass filter บน Ambience ด้วย) ด้วยภาพในหน้าต่าง Mixer แทนที่จะเขียนสคริปต์ทุก parameter เอง Wwise กับ FMOD ต่างก็มีไอเดียแบบเดียวกันนี้ให้ใช้ทันที ปกติเรียกว่า ducking rule หรือ sidechain: "เมื่อไหร่ก็ตามที่มีเสียงบน bus X เล่นอยู่ ให้ลด volume ของ bus Y ลงอัตโนมัติ N dB" -- ตั้งค่าครั้งเดียวโดย sound designer โดยไม่ต้องเขียนโค้ดต่อบรรทัดจากโปรแกรมเมอร์เลย
ทุกอย่างที่ผ่านมา -- one-shot, loop, mixer group, การ duck ด้วยสคริปต์ -- ใช้งานได้ แต่มันก็ไม่ scale เกินเกมเล็ก ๆ เกมที่ shipped ออกมาส่วนใหญ่มักมีเสียงเฉพาะเป็นหลายร้อยหรือหลายพันแบบ: เสียงเดินหลายแบบต่อพื้นผิวหนึ่งชนิด เสียงเครื่องยนต์ที่ขับด้วย RTPC, occlusion (เสียงอู้อี้ตอนอยู่หลังกำแพง), เสียงร้องของอาวุธที่สุ่ม, ambience หลายชั้นที่ crossfade ตามสภาพอากาศและเวลาในเกม การเขียนโค้ดทั้งหมดนั้นด้วยมือใน C# หมายความว่า:
Audio middleware (Wwise จาก Audiokinetic และ FMOD จาก Firelight Technologies เป็นสองเจ้าหลักในอุตสาหกรรม) แบ่งงานออกเป็นสองส่วน sound designer ทำงานในแอปสำหรับสร้างเสียงแยกต่างหาก อยู่นอก Unity หรือ Unreal ไปเลย ที่ซึ่งพวกเขาสร้าง event ที่มีชื่อ เชื่อมต่อการสุ่มและความหลากหลาย ผูกค่าจากเกมเข้ากับ audio parameter และตั้ง voice limit กับ priority -- แล้ว export งานนั้นออกมาเป็น soundbank (มัดรวม event, คลิป, และ setting ที่ถูกแพ็คไว้) ที่ ship ไปพร้อมกับเกม งานทั้งหมดของโปรแกรมเมอร์เหลือแค่การเรียกสองอย่าง: "เล่น event นี้โดยใช้ชื่อ" กับ "ตั้งค่า parameter นี้เป็นค่านี้" ไม่มีใครฝั่งโปรแกรมมิ่งต้องรู้เลยว่ามีเสียงเดินกี่แบบ หรือ curve ไหนที่ map RPM เครื่องยนต์ไปเป็น pitch -- ความรู้นั้นอยู่ฝั่ง audio ทั้งหมด อยู่ในเครื่องมือ
นี่คือการแบ่งแบบเดียวกับที่คุณเคยเห็นมาแล้วในที่อื่น ๆ ของหลักสูตรนี้ ระหว่าง data กับ logic -- "อะไร" (เสียงไหน มันแปรผันยังไง ดังแค่ไหน) ถูกเขียนขึ้นเป็น data โดยผู้เชี่ยวชาญเฉพาะทาง และ "เมื่อไหร่" (trigger สิ่งนี้ตอนนี้) ยังคงเป็นการเรียกง่าย ๆ ครั้งเดียวใน game code
สามไอเดียนี้รวมกันแล้วเป็นเกือบทั้งหมดที่คุณต้องรู้เกี่ยวกับวิธีที่ middleware อย่าง Wwise หรือ FMOD ถูกใช้จริงจากโค้ด
event คือ trigger ที่มีชื่อ ที่ถูกสร้างขึ้นทั้งหมดข้างในเครื่องมือ audio -- ตัวอย่างเช่น "Play_Footstep_Grass" การ post event นั้นด้วยชื่อจากโค้ดจะทำให้สิ่งที่ sound designer ตั้งค่าไว้เกิดขึ้น: เล่นเสียง หยุดเสียง เริ่มท่อนเพลง ปรับ mixing rule โปรแกรมเมอร์ไม่จำเป็นต้องรู้เลยว่าข้างใน event มีอะไรจริง ๆ รู้แค่ชื่อของมันก็พอ
container จัดกลุ่มความหลากหลายของเสียงหลายแบบไว้ภายใต้ event เดียว แล้วตัดสินใจว่าจะเลือกยังไงในบรรดาแบบเหล่านั้น:
RTPC คือค่าตัวเลขต่อเนื่องที่ส่งจากเกมไปยัง audio engine ทุกเฟรม (หรือทุกครั้งที่มันเปลี่ยน) ซึ่ง sound designer map เข้ากับคุณสมบัติของเสียงบางอย่างโดยใช้ curve ที่พวกเขาวาดไว้ในเครื่องมือ -- "Speed" จาก 0 ถึง 10 map เข้ากับ curve ของ pitch หรือ "Health" จาก 100 ถึง 0 map เข้ากับ low-pass filter ที่ทำให้เสียงทั้งหมดอู้อี้ขึ้นเมื่อผู้เล่นใกล้ตาย โปรแกรมเมอร์แค่ส่งตัวเลขไปเรื่อย ๆ ส่วน curve ที่ map และสิ่งที่มันควบคุมเป็นการตัดสินใจของ sound designer ทั้งหมด
ในโค้ด สิ่งนี้ดูเกือบเหมือนกับระบบ event ที่เขียนเองในหัวข้อ 13 ทุกประการ เพียงแต่มีเครื่องมือสร้างเสียงที่ใหญ่กว่ามากอยู่ข้างหลัง แทนที่จะเป็น C# dictionary ตัวอย่างเช่น การ integrate Wwise กับ Unity ใช้ static class ที่ชื่อ AkSoundEngine:
using UnityEngine;
public class FootstepEmitter : MonoBehaviour
{
// Called from an animation event on the footstep frame.
// This code has no idea there are 4 variations, or that they
// are picked randomly -- that lives entirely inside Wwise.
public void OnFootstepAnimEvent()
{
AkSoundEngine.PostEvent("Play_Footstep_Grass", gameObject);
}
void Update()
{
float speed = GetCurrentSpeed(); // 0..10, from movement code
AkSoundEngine.SetRTPCValue("Speed", speed, gameObject);
}
float GetCurrentSpeed()
{
return 3.5f; // placeholder -- a real script reads this from the CharacterController
}
}
Worked trace: การเรียก PostEvent("Play_Footstep_Grass", gameObject) แต่ละครั้งจะ trigger สิ่งที่ sound designer สร้างไว้สำหรับ event นั้น -- สมมติว่า random container เลือกแบบที่ 3 จาก 4 แบบ นั้น แบบนั้นก็จะเล่นโดยมี pitch ที่เลื่อนตามค่า RTPC "Speed" ปัจจุบัน (3.5) ตาม curve ที่ sound designer วาดไว้ในเครื่องมือ โดยไม่มีโค้ดสุ่ม pitch หรือ map ความเร็วสักบรรทัดเดียวอยู่ในสคริปต์นี้เลย
Wwise_IDs) ที่สร้างขึ้นอัตโนมัติจากโปรเจกต์ แล้ว post integer ID แทนที่จะเป็น string -- ไอเดียเดียวกัน แต่ค้นหาได้เร็วกว่า และได้ compile error แทนที่จะเป็น typo เงียบ ๆ ถ้าชื่อ event เปลี่ยนไปในภายหลังข้อมูลของ AudioClip ต้องอยู่ที่ไหนสักแห่ง Unity, Wwise, และ FMOD ต่างก็มีสองทางเลือกพื้นฐานแบบเดียวกัน:
ปัจจัยตัดสินใจแทบทุกครั้งคือความยาวกับความถี่ในการเล่น และเหตุผลมาจากตัวเลขตรง ๆ โปรแกรม C# สั้น ๆ ทำให้ต้นทุนหน่วยความจำนี้เห็นภาพชัด:
using System;
class AudioBudget
{
static void Main()
{
int sampleRate = 44100; // samples per second
int bytesPerSample = 2; // 16-bit audio = 2 bytes per sample
int channels = 2; // stereo
int seconds = 180; // a 3-minute music track
long bytes = (long)sampleRate * bytesPerSample * channels * seconds;
double megabytes = bytes / (1024.0 * 1024.0);
Console.WriteLine("Raw PCM size: " + bytes + " bytes");
Console.WriteLine("Raw PCM size: " + megabytes.ToString("F1") + " MB");
}
}
Expected output:
Raw PCM size: 31752000 bytes
Raw PCM size: 30.3 MB
เพลงประกอบแบบ uncompressed หนึ่งเพลง ยาวสามนาที stereo ตัวเดียวก็กิน RAM ไปประมาณ 30 MB แล้ว เกมที่มีเพลง ambience และบทสนทนาที่มีเสียงพากย์ในปริมาณปานกลาง อาจมีเสียงรวมกันหลายชั่วโมงได้อย่างง่ายดาย -- เป็นไปไม่ได้ที่จะเก็บทั้งหมดไว้ใน memory พร้อมกันบนคอนโซลหรือมือถือที่มี RAM แค่ไม่กี่กิกะไบต์ที่ต้องแชร์กับ texture, mesh, และอย่างอื่นทั้งหมด นี่คือเหตุผลที่เนื้อหาที่ยาวและไม่ค่อยเล่นซ้ำ (เพลง, ambience bed, บทสนทนา) แทบจะถูก stream เสมอ ในขณะที่เนื้อหาสั้น ๆ ที่ถูก trigger บ่อย (เสียงเดิน, เสียงคลิก UI, เสียงปืน) จะถูกโหลดไว้ใน memory ซึ่งขนาดที่เล็กมาก ๆ ของมัน (คลิปเสียงเดินครึ่งวินาทีมีขนาดประมาณ 86 KB ตามสูตรเดียวกัน) ไม่ใช่ปัญหาเลย และการเล่นทันทีสำคัญกว่าการประหยัด kilobyte ไม่กี่ตัวมาก
voice คือเสียงหนึ่งเสียงที่กำลัง active อยู่ กำลังถูกมิกซ์จริง ๆ การมิกซ์มีต้นทุน CPU ต่อ voice และเมื่อเกินจำนวนหนึ่งไป การมี voice พร้อมกันมากขึ้นก็แค่ทำให้เสียงฟังดูเละ -- ผู้เล่นแยกเสียงแต่ละอันออกจากกำแพงเสียงไม่ได้ ทุก engine ตั้งเพดานที่ใช้งานได้จริงว่า voice เล่นพร้อมกันได้กี่ตัว และต้องมีกฎว่าจะเกิดอะไรขึ้นเมื่อเสียงใหม่อยากเล่นแต่เพดานเต็มแล้ว
ระบบเสียงในตัวของ Unity (ไม่ใช่ middleware) เปิดให้ใช้เวอร์ชันง่าย ๆ ของเรื่องนี้ผ่าน AudioSource.priority ตัวเลขตั้งแต่ 0 (สำคัญที่สุด) ถึง 256 (สำคัญน้อยที่สุด ค่า default คือ 128):
using UnityEngine;
public class ExplosionSFX : MonoBehaviour
{
public AudioSource source;
void Start()
{
// 0 = highest priority. Give big, rare, important sounds a low
// number so they win over small, common sounds when the engine
// has to decide what to cut.
source.priority = 0;
}
}
Worked trace: โปรเจกต์หนึ่งตั้งค่า real voice count ไว้ที่ 32 (Edit > Project Settings > Audio) การยิงต่อสู้กำลังเข้มข้น: เสียงเดิน 20 เสียง เสียงนกร้อง ambient 15 เสียง เสียงปืน one-shot 8 เสียง และเสียงระเบิด 1 เสียง อยากเล่นพร้อมกันในจังหวะเดียวกัน -- 44 voice ที่ขอเทียบกับเพดาน 32 Unity จะเก็บ 32 เสียงที่มี priority สูงสุดไว้เป็น voice จริงที่ได้ยิน แล้ว virtualize ที่เหลือ: มันยังคงติดตามตำแหน่ง playback ของเสียงเหล่านั้นไว้ภายใน โดยไม่ได้มิกซ์มันจริง ๆ (ดังนั้นมันแทบไม่กิน CPU และไม่มีเสียงออกมา) และสามารถเลื่อน virtual sound กลับมาเป็น voice จริงได้ในภายหลัง ถ้าเช่น กล้องเคลื่อนเข้าใกล้มันมากขึ้น เพราะเสียงระเบิดถูกให้ priority 0 มันจึงถูกการันตีว่าจะเป็นหนึ่งใน 32 ที่ถูกเก็บไว้เป็นเสียงจริง แม้ว่านกร้องพื้นหลังสิบกว่าตัวจะถูก virtualize แทนก็ตาม
Middleware ให้การควบคุมที่ละเอียดกว่าสำหรับไอเดียเดียวกันนี้: จำนวน instance สูงสุดต่อ event (ไม่เกิน 4 ชุดของเสียงปืนนัดนี้พร้อมกัน ไม่ว่า voice limit รวมจะเป็นเท่าไหร่ก็ตาม), priority ที่ตัวมันเองก็เป็น curve ที่ขับด้วย RTPC ได้ (เสียงที่อยู่ใกล้กว่าจะมี priority สูงกว่าเสียงที่อยู่ไกลโดยอัตโนมัติ) และกฎที่ชัดเจนว่าจะเกิดอะไรขึ้นเมื่อถึงเพดาน -- ฆ่า instance ที่เก่าที่สุด ฆ่าตัวที่เบาที่สุด หรือปฏิเสธไม่ให้เสียงใหม่เล่นเลย
วิธีที่ AudioClip ถูกเก็บบนดิสก์ (และบางทีก็ใน memory ด้วย) จะแลกกันระหว่างสามสิ่ง: ขนาดไฟล์ ต้นทุน CPU ในการ decode ตอนเล่น และคุณภาพเสียง
PCM (Pulse-Code Modulation ข้อมูล sample ดิบแบบ uncompressed ที่อธิบายไว้ในหัวข้อ 1) ไม่มีต้นทุนในการ decode เลย เพราะไม่มีอะไรให้ decode -- ตัวเลขก็คือตัวเลขที่จะส่งไปให้ลำโพงอยู่แล้ว นั่นทำให้มันเป็นตัวเลือกที่ดีสำหรับ effect สั้น ๆ ที่เล่นบ่อยมาก ๆ ที่ CPU ที่ใช้ decompress จะแพงกว่า memory ที่ประหยัดได้ ADPCM (Adaptive Differential PCM) เก็บผลต่างระหว่างแต่ละ sample กับ sample ก่อนหน้าแทนที่จะเก็บค่าดิบ ซึ่งบีบอัดได้ดีพอสมควรด้วยต้นทุน decode ที่ต่ำและคาดเดาได้ -- จุดกึ่งกลางที่พบบ่อยสำหรับ sound effect ความยาวปานกลาง Vorbis กับ MP3 เป็นรูปแบบบีบอัดแบบ lossy (ทิ้งรายละเอียดเสียงที่ผู้ฟังไม่น่าจะสังเกตเห็น) ที่ลดขนาดไฟล์ลงอย่างมากด้วยต้นทุน CPU จริง ๆ ในการ decompress ทุกเฟรมของการเล่น -- การแลกที่ถูกต้องสำหรับเนื้อหา stream ที่ยาว ที่ซึ่งการประหยัดขนาดไฟล์สำคัญกว่าต้นทุน CPU ที่พอประมาณมาก
Unity เปิดให้ตั้งค่าเรื่องนี้เป็นสอง import setting ต่อคลิปที่แยกจากกันเกือบเป็นอิสระ: Load Type (Decompress On Load, Compressed In Memory, หรือ Streaming -- นี่คือการตัดสินใจของหัวข้อ 10) และ Compression Format (PCM, ADPCM, หรือ Vorbis -- การตัดสินใจของหัวข้อนี้) คลิปเสียงเดินสั้น ๆ ที่เล่นบ่อยปกติจะต้องการ Decompress On Load บวก PCM หรือ ADPCM ส่วนเพลงยาวปกติจะต้องการ Streaming บวก Vorbis
คุณไม่จำเป็นต้องมี Wwise หรือ FMOD เพื่อใช้ pattern แบบ event จากหัวข้อ 9 -- คุณสร้างเวอร์ชันเล็ก ๆ ของมันเองได้ด้วย Unity AudioSource ธรรมดา ซึ่งเป็นวิธีที่มีประโยชน์ในการรู้สึกได้ชัด ๆ ว่าทำไมเครื่องมือใหญ่ ๆ ถึงมีอยู่ เมื่อรายการเสียงของคุณโตเกินจำนวนหยิบมือหนึ่ง
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Audio;
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
[System.Serializable]
public struct Sound
{
public string id;
public AudioClip clip;
public AudioMixerGroup group;
[Range(0f, 1f)] public float volume;
}
public Sound[] sounds;
private Dictionary<string, Sound> lookup;
private AudioSource oneShotSource;
void Awake()
{
Instance = this;
lookup = new Dictionary<string, Sound>();
foreach (Sound s in sounds)
{
lookup[s.id] = s;
}
oneShotSource = gameObject.AddComponent<AudioSource>();
}
// The rest of the game calls this ONE method by name -- the same
// shape as posting an event to Wwise or FMOD. Nothing else in the
// codebase touches AudioSource, AudioClip, or AudioMixer directly.
public void Play(string id)
{
if (!lookup.TryGetValue(id, out Sound s))
{
Debug.LogWarning("No sound registered with id: " + id);
return;
}
oneShotSource.outputAudioMixerGroup = s.group;
oneShotSource.PlayOneShot(s.clip, s.volume);
}
}
เรียกจากที่ไหนก็ได้ในเกม เหมือนกับการ post event ทุกประการ:
AudioManager.Instance.Play("footstep_grass");
Worked trace: Play("footstep_grass") จะค้นหา entry ที่ตรงกัน route output ของ one-shot AudioSource ที่ใช้ร่วมกันไปยัง AudioMixerGroup ที่ถูกกำหนดไว้ใน Inspector (สมมติว่า SFX > Footsteps) แล้วเล่นคลิปที่ volume ที่ตั้งค่าไว้ player controller, animation event, สคริปต์ AI ของศัตรู -- ไม่มีตัวไหนต้องรู้เลยว่า AudioClip หรือ AudioMixer มีอยู่ พวกมันแค่เรียก Play(string id) เท่านั้น
นี่คือช่องว่างที่ Wwise กับ FMOD มาเติมเต็มพอดีในระดับใหญ่: manager ที่เขียนเองตัวนี้ไม่มี random container ไม่มี RTPC ไม่มีการควบคุม streaming ไม่มี voice limit และไม่มี ducking rule -- ทุกอย่างในนั้นจะต้องเป็น C# ที่ต้องเขียนและดูแลเพิ่มเติมด้วยมือ เมื่อโปรเจกต์ต้องการเสียงเป็นร้อยที่มีพฤติกรรมระดับนั้น การซื้อ license และเรียนรู้เครื่องมือเฉพาะทางที่แก้ปัญหาทั้งหมดนั้นไว้แล้วปกติจะถูกกว่าการขยายไฟล์นี้ต่อไปเรื่อย ๆ
(a) ใช้ bytes = sampleRate x bytesPerSample x channels x seconds:
bytes = 48000 x 2 x 1 x 12 = 1,152,000 bytes
KB = 1,152,000 / 1024 =~ 1,125 KB (about 1.1 MB)
(b) หนึ่งบรรทัดที่ประมาณ 1.1 MB ไม่ได้ใหญ่มากด้วยตัวมันเอง แต่ปัจจัยตัดสินใจจากหัวข้อ 10 คือความยาวกับความถี่รวมกัน ไม่ใช่แค่ขนาดของไฟล์เดียว 8,000 บรรทัดที่ประมาณ 1.1 MB ต่อบรรทัด จะรวมกันได้ประมาณ 8.8 GB ถ้าโหลดเข้า memory ทั้งหมดพร้อมกัน -- เกินกว่าที่แพลตฟอร์มไหนจะเหลือให้บทสนทนาอย่างเดียวได้มาก บทสนทนาแต่ละบรรทัดยังถูกเล่นน้อยครั้งด้วย (ครั้งเดียว อาจสองครั้ง ในจังหวะเนื้อเรื่องที่เฉพาะเจาะจง) แทนที่จะถูก trigger ซ้ำตลอดเวลาแบบเสียงเดิน ดังนั้นประโยชน์ของการเล่นแบบไม่มีดีเลย์จากการ preload จึงสำคัญน้อยกว่า memory ที่มันจะต้องเสียไปมาก บทสนทนาควรถูก stream โดยโหลดแค่ไม่กี่บรรทัดที่กำลังจะเล่น ไม่ใช่ library ทั้ง 8,000 บรรทัด
AudioManager.Play(string id) จากหัวข้อ 13 เล่นคลิปเดิมทุกครั้งด้วย pitch และ volume เดิมทุกครั้งที่ถูกเรียก ซึ่งจะฟังดูซ้ำซากมากสำหรับอะไรอย่างเสียงเดินที่ถูก trigger หลายสิบครั้งต่อนาที แก้ไข Play ให้แต่ละครั้งที่เรียกใช้ pitch สุ่มเล็กน้อยระหว่าง 0.95 ถึง 1.05 และ volume สุ่มเล็กน้อยระหว่าง 90% ถึง 100% ของ volume ที่ตั้งค่าไว้ ก่อนจะเล่น แนวคิดไหนจากหัวข้อ 9 ที่สิ่งนี้เลียนแบบ แม้ว่ามันจะไม่ใช่ random container จริง ๆ (มันไม่ได้เลือกระหว่างเสียงบันทึกที่ต่างกัน)?public void Play(string id)
{
if (!lookup.TryGetValue(id, out Sound s))
{
Debug.LogWarning("No sound registered with id: " + id);
return;
}
oneShotSource.outputAudioMixerGroup = s.group;
oneShotSource.pitch = Random.Range(0.95f, 1.05f);
float volumeJitter = Random.Range(0.9f, 1.0f);
oneShotSource.PlayOneShot(s.clip, s.volume * volumeJitter);
}
สิ่งนี้เลียนแบบ random container (หัวข้อ 9) แต่เป็นเวอร์ชันตื้น ๆ: random container จริง ๆ จะเลือกระหว่างเสียงบันทึกที่ ต่างกันจริง หลายอัน ซึ่งทำลายความซ้ำซากได้ดีกว่าการสั่น pitch/volume บนเสียงบันทึกเดียวมาก การสั่นเพียงอย่างเดียวช่วยได้ก็จริง แต่เสียงเดินสิบก้าวติดกันก็ยังฟังดูเหมือนเป็นความแปรผันของเทคเดียวอยู่ดี ไม่เหมือนเสียงเท้าลงพื้นสิบครั้งที่ต่างกันจริง ๆ -- ซึ่งเป็นสิ่งที่ sound designer จะแก้โดยการบันทึกและเชื่อมต่อความหลากหลายจริงสี่ห้าแบบใน Wwise หรือ FMOD แทนที่จะให้โปรแกรมเมอร์ปลอมมันด้วยคณิตศาสตร์สุ่ม
DialogueDucking จากหัวข้อ 7 มีบั๊ก: ถ้าบทสนทนาบรรทัดที่สองเริ่มเล่นในขณะที่บรรทัดแรกยังเล่นอยู่ การเรียก OnDialogueEnd() ของมัน (จากบรรทัดแรกที่จบ) จะ fade เพลงกลับขึ้นมาทั้งที่บรรทัดที่สองยังพูดอยู่ แก้ไข DialogueDucking ให้เพลงจะ un-duck ก็ต่อเมื่อบทสนทนาที่กำลังเล่นอยู่ทุกบรรทัดจบแล้วเท่านั้น ไม่ใช่แค่บรรทัดล่าสุดusing System.Collections;
using UnityEngine;
using UnityEngine.Audio;
public class DialogueDucking : MonoBehaviour
{
public AudioMixer mixer;
public float duckedDB = -12f;
public float normalDB = 0f;
public float fadeTime = 0.3f;
private int activeDialogueCount = 0;
private Coroutine fadeRoutine;
public void OnDialogueStart()
{
activeDialogueCount++;
if (activeDialogueCount == 1)
{
// Only the FIRST overlapping line should trigger a duck --
// if we are already ducked, there is nothing new to do.
if (fadeRoutine != null) StopCoroutine(fadeRoutine);
fadeRoutine = StartCoroutine(FadeMusicTo(duckedDB));
}
}
public void OnDialogueEnd()
{
activeDialogueCount--;
if (activeDialogueCount <= 0)
{
activeDialogueCount = 0;
// Only un-duck once EVERY overlapping line has finished.
if (fadeRoutine != null) StopCoroutine(fadeRoutine);
fadeRoutine = StartCoroutine(FadeMusicTo(normalDB));
}
}
private IEnumerator FadeMusicTo(float targetDB)
{
mixer.GetFloat("MusicVolume", out float startDB);
float t = 0f;
while (t < fadeTime)
{
t += Time.deltaTime;
float current = Mathf.Lerp(startDB, targetDB, t / fadeTime);
mixer.SetFloat("MusicVolume", current);
yield return null;
}
mixer.SetFloat("MusicVolume", targetDB);
}
}
ทางแก้แทนที่ boolean "บทสนทนากำลังเล่นอยู่ไหม" ด้วยตัวนับ activeDialogueCount การ duck จะเริ่มเฉพาะตอนเปลี่ยนจาก 0 ไป 1 (บรรทัดแรกของการซ้อนกัน) และการ un-duck จะเกิดเฉพาะตอนเปลี่ยนจาก 1 ไป 0 (บรรทัดสุดท้ายที่จบ) -- ดังนั้นบรรทัดที่สองที่เริ่มขณะบรรทัดแรกยัง active อยู่จะเพิ่มตัวนับเป็น 2 โดยไม่แตะ mixer เลย และ OnDialogueEnd() ของบรรทัดแรกจะแค่ลดมันกลับมาเป็น 1 ซึ่งไม่ใช่ศูนย์ ดังนั้นเพลงจะยังคง duck อยู่อย่างถูกต้องจนกว่าบรรทัดที่สองจะจบด้วย