ตลอดคอร์สที่ผ่านมา เพลงในเกมของเรามีความหมายแค่อย่างเดียว คือกด play ให้ AudioClip ตั้ง loop เป็น true แล้วปล่อยให้มันเล่นคลออยู่ใต้เกมไปเรื่อยๆ วิธีนี้โอเคสำหรับหน้า title screen แต่พังทันทีที่ตัวเกมเองไม่สามารถคาดเดาได้ — การต่อสู้ที่อาจใช้เวลาแค่ห้าวินาทีหรือยาวถึงห้านาที บอสที่อาจโผล่มาทันทีที่ผู้เล่นเดินผ่านประตู หรือผู้เล่นที่วิ่งหนีจากการต่อสู้แล้วเดินกลับไปกลับมาสามรอบก่อนจะสู้จริง ไม่มีคอมโพเซอร์คนไหนเขียนเพลงให้ตรงกับเหตุการณ์ที่ยังไม่เกิดขึ้นได้
บทนี้พูดถึง adaptive music (เรียกอีกชื่อว่า interactive music) คือเพลงที่ถูกออกแบบมาตั้งแต่ต้นให้เปลี่ยนแปลงตามสิ่งที่เกิดขึ้นในเกม แทนที่จะเล่นตาม arrangement (การเรียบเรียง) เดียวที่ตายตัว มีสองเทคนิคหลักที่ทำงานส่วนใหญ่จริงๆ คือ horizontal re-sequencing (การจัดลำดับใหม่แนวนอน) กับ vertical layering (การซ้อนชั้นแนวตั้ง) บวกกับอีกสองแนวคิดเสริมคือ stinger (เสียงแทรกสั้นๆ) และ quantized transition (การเปลี่ยนที่ล็อกจังหวะ) ซึ่งทำให้ทั้งสองเทคนิคฟังดูตั้งใจ ไม่ใช่ฟังดูพัง พอจบบทนี้คุณจะได้สร้าง beat-quantized segment switcher กับ layered-intensity mixer ด้วย C# และจะเข้าใจว่า audio middleware อย่าง Wwise หรือ FMOD ทำอะไรให้คุณบ้าง เวลาสตูดิโอมีงบให้ใช้เครื่องมือพวกนี้
คอมโพเซอร์หนังรู้เป๊ะว่าฮีโร่จะชักดาบตอนเฟรมไหน เขาเขียนเพลงหกสิบวินาทีให้ตรงกับจังหวะนั้นเป๊ะๆ ได้ แต่คอมโพเซอร์เกมไม่มีความหรูหราแบบนั้น — ผู้เล่นเป็นคนตัดสินใจว่าจะชักดาบตอนไหน อาจจะชักตอนกำลังฟัง dialogue อยู่ ตอนกระโดดกลางอากาศ หรือไม่ชักเลยอีกสิบนาที ระบบไหนก็ตามที่แค่เล่นเพลงตายตัวแล้วสลับไปอีกเพลงตายตัวทันทีที่มีอะไรเกิดขึ้น เกือบทุกครั้งจะสลับกลางประโยคเพลงพอดี
using UnityEngine;
public class NaiveMusicSwitcher : MonoBehaviour
{
public AudioSource musicSource;
public AudioClip exploreClip;
public AudioClip combatClip;
public void EnterCombat()
{
musicSource.clip = combatClip;
musicSource.Play(); // starts immediately, wherever exploreClip happened to be
}
}
ในโค้ดนี้ musicSource คือ AudioSource หนึ่งตัวที่ loop exploreClip อยู่ พอ EnterCombat() ถูกเรียก ไม่ว่า exploreClip กำลังเล่นอะไรอยู่ก็จะถูกตัดทิ้งทันที — กลางโน้ต กลางดรัมฟิล ตรงไหนก็ได้ที่จังหวะของผู้เล่นดันไปตกพอดี — แล้ว combatClip ก็เริ่มใหม่จาก beat หนึ่งของตัวเอง ด้วยจังหวะที่ต่างไปคนละเรื่องเลยระหว่างที่หูกำลังปรับตัวตาม
นี่คือปัญหาทั้งหมดที่บทนี้จะแก้ให้ ทุกอย่างต่อจากนี้จริงๆ แล้วคือคำถามเดียวที่ถูกถามในหลายรูปแบบ: จะเปลี่ยนสิ่งที่ผู้เล่นได้ยินยังไงให้มันไม่ฟังดูเหมือนความผิดพลาด
สองเทคนิคนี้ ถูกใช้แทบทุกเกมที่มี combat แบบ dynamic (รวมถึงเกม HoYoverse อย่าง Genshin Impact กับ Honkai: Star Rail) แก้ปัญหาด้วยวิธีต่างกัน:
ไม่มีเทคนิคไหน "ดีกว่า" — มันแก้ปัญหาคนละแบบ Horizontal re-sequencing เหมาะกับตอนที่ตัวเพลงเองต้องเปลี่ยนเป็นคนละชิ้นจริงๆ (เพลงหมู่บ้านไม่มีอะไรเหมือนเพลงดันเจี้ยนเลย) ส่วน vertical layering เหมาะกับตอนที่เพลงยังเป็นไอเดียเดียวกันอยู่ แค่เข้มข้นมากหรือน้อยกว่า (เพลงบอสตัวเดิม เบาบางตอน HP ยังเยอะ เต็มอิ่มตอน HP ตก) เกมจริงๆ ส่วนใหญ่ใช้ทั้งสองอย่างพร้อมกัน คือใช้ horizontal re-sequencing เพื่อย้ายระหว่าง state ใหญ่ๆ อย่างสำรวจ/ต่อสู้/บอส แล้วใช้ vertical layering ข้างในแต่ละ state เพื่อตอบสนองต่อการเปลี่ยนแปลงเล็กๆ อย่างเหลือศัตรูอีกกี่ตัว
ทั้งสองเทคนิคขึ้นอยู่กับไอเดียเดียวกัน คือการรู้ตำแหน่งที่แน่ชัดว่าเรากำลังอยู่ตรงไหนของเพลง วัดเป็นเวลาทางดนตรีแทนที่จะเป็นวินาทีเฉยๆ มีสามคำศัพท์ที่ใช้ตลอดบทนี้:
การแปลง BPM เป็นวินาทีจริงคือเลขคณิตที่ใช้ในทุกๆ ส่วนที่เหลือของบทนี้:
using UnityEngine;
public class TempoMath : MonoBehaviour
{
public float bpm = 120f;
public int beatsPerBar = 4;
void Start()
{
double secondsPerBeat = 60.0 / bpm;
double secondsPerBar = secondsPerBeat * beatsPerBar;
Debug.Log("One beat = " + secondsPerBeat + "s, one bar = " + secondsPerBar + "s");
}
}
One beat = 0.5s, one bar = 2s
ที่ 120 BPM หนึ่ง beat จะเท่ากับครึ่งวินาทีพอดี และหนึ่ง bar (4 beat) จะเท่ากับ 2 วินาทีพอดี นี่เป็นส่วนหนึ่งที่ทำให้ 120 BPM เป็น tempo ที่คอมโพเซอร์เกมนิยมเลือกใช้ — เพราะมันทำให้เลข seconds-per-bar ออกมาเป็นตัวเลขที่ลงตัวง่ายๆ
อีกเรื่องหนึ่ง: Time.time ของ Unity ที่อิงกับเฟรมไม่แม่นยำพอสำหรับงานนี้ Time.time อัปเดตแค่ครั้งเดียวต่อเฟรมที่ถูก render และเวลาต่อเฟรมนั้นก็สั่นได้ — เฟรมหนึ่งอาจใช้เวลา 16ms อีกเฟรมอาจ 19ms ฮาร์ดแวร์เสียงมี clock ของตัวเองที่นิ่งกว่ามาก ซึ่ง Unity เปิดให้ใช้ผ่าน AudioSettings.dspTime (เป็น double ที่บอกเวลาปัจจุบันของระบบเสียงเป็นวินาที ไม่เกี่ยวกับ frame rate) ทุกการคำนวณเรื่อง scheduling ในบทนี้ใช้ dspTime ไม่ใช้ Time.time เพราะแค่การเหลื่อมเล็กน้อยของเฟรมก็ทำให้ได้ยินว่าการเปลี่ยนเพลงมันเบี้ยวไปจากจังหวะนิดหน่อยแล้ว
Horizontal re-sequencing เริ่มจากการแยกเพลงออกเป็น segment — audio clip แยกกัน หนึ่งชิ้นต่อหนึ่ง game state ทุกชิ้นเขียนที่ BPM เดียวกันและความยาว bar เดียวกัน เพื่อให้ segment ไหนก็ตามสามารถเล่นต่อจากอีกอันได้ การตั้งค่าทั่วไปคือมี segment สลับหลายแบบต่อ state (เพื่อไม่ให้ผู้เล่นติดอยู่กับ Explore clip อันเดิมวนไปวนมา — รายละเอียดอยู่ใน section 11) ผูกกับ state graph เล็กๆ
public enum MusicState { Explore, Combat, Boss }
[System.Serializable]
public class MusicStateSegments
{
public MusicState state;
public AudioClip[] variants; // several clips per state, same tempo and bar length
}
มีสองเรื่องที่สำคัญเกี่ยวกับ graph นี้ อย่างแรก state มักจะแยกกันเด็ดขาด — เกมอยู่ใน state ใด state หนึ่งจาก Explore, Combat, หรือ Boss ครั้งละหนึ่งเดียวเสมอ ไม่มีการผสมสอง state (นั่นเป็นหน้าที่ของ vertical layering ที่อยู่ข้างใน state เดียว) อย่างที่สอง และนี่คือประเด็นหลักของ section ที่เหลือ: ทุกลูกศรถูก quantize ทั้งหมด คำขอให้ย้ายจาก Explore ไป Combat จะไม่ตัดทันทีที่ถูกขอ — มันจะถูกเข้าคิวไว้ แล้วสลับจริงๆ ก็ต่อเมื่อถึง bar line ถัดไปเท่านั้น กฎข้อเดียวนี้แหละที่ทำให้ horizontal re-sequencing ไม่ฟังดูเหมือน naive switcher ใน section 1
Quantization ในความหมายทางดนตรี คือการดันเหตุการณ์หนึ่งให้ไปตกที่จุดที่ใกล้ที่สุดบน grid ทางดนตรี — ปกติคือ beat หรือ bar ที่ใกล้ที่สุด — แทนที่จะปล่อยให้มันเกิดขึ้นตรงเวลาที่ถูกขอพอดีซึ่งเป็นเวลาสุ่มๆ Quantized transition คือการสลับ segment ที่ถูกดันไปให้ตรงกับ bar line แบบนี้
เหตุผลชัดเจนขึ้นทันทีที่เห็น diagram: ทุก segment ที่ใช้ BPM เดียวกันและความยาว bar เดียวกัน หมายความว่า beat หนึ่งของ bar ใดๆ ใน Combat จะตรงจังหวะกับ beat หนึ่งของ bar ใดๆ ใน Explore ถ้าการสลับเกิดขึ้นตรง bar line เสมอ การเปลี่ยนก็จะฟังดูเหมือนคอมโพเซอร์ตั้งใจเขียนไว้แบบนั้น — เพราะจริงๆ แล้วเขาก็ตั้งใจ: เขาเขียนทุก segment ให้เล่นต่อจาก segment ไหนก็ได้อย่างสมูท ตราบใดที่การสลับเคารพ beat grid
มันมีต้นทุนจริงอยู่: การ quantize เพิ่ม latency (ความหน่วง) ที่ 120 BPM กับ bar 4 beat คำขอที่มาถึงตอนที่ bar line เพิ่งเริ่มไปหมาดๆ ต้องรอเกือบเต็ม 2 วินาทีกว่าจะถึง bar line ถัดไป สำหรับเพลง ความหน่วงเล็กๆ นี้คุ้มค่า — การเปลี่ยนที่ช้าไป 2 วินาทีแต่สะอาด ฟังดูดีกว่าการเปลี่ยนทันทีแต่กระโดดชัดเจนมาก Section 9 จะกลับมาพูดถึง trade-off นี้อีกครั้งสำหรับ stinger ซึ่งการเลือกตรงข้ามกันมักจะถูกต้องกว่า
การเปลี่ยน diagram ของ section 5 ให้เป็นโค้ดต้องใช้ Unity feature สองอย่าง: AudioSource.PlayScheduled(double dspTime) เริ่มเล่น clip ที่จุดเวลาที่แน่นอนในอนาคตบน audio clock แทนที่จะเป็น "เร็วที่สุดเท่าที่จะทำได้" และ AudioSource.SetScheduledEndTime(double dspTime) หยุด clip ที่กำลังเล่นอยู่ที่จุดเวลาที่แน่นอนในอนาคต ทั้งสองใช้หน่วย dspTime เดียวกับ AudioSettings.dspTime ซึ่งนี่แหละที่ทำให้มันตรงกันเป๊ะ
using UnityEngine;
// Horizontal re-sequencer: only one segment plays at a time, but a
// requested switch always waits for the next bar line before it happens.
public class HorizontalMusicPlayer : MonoBehaviour
{
public AudioSource activeSource; // currently playing segment
public AudioSource standbySource; // holds the next segment, cued up in advance
public float bpm = 120f;
public int beatsPerBar = 4;
private double segmentStartDsp; // dspTime the active segment's beat 1 landed on
private AudioClip queuedClip;
private bool hasQueuedClip = false;
public void PlayFirstSegment(AudioClip clip)
{
segmentStartDsp = AudioSettings.dspTime + 0.1; // small safety offset
activeSource.clip = clip;
activeSource.PlayScheduled(segmentStartDsp);
}
// called by gameplay code: "switch to this segment, whenever the beat allows it"
public void RequestSegment(AudioClip clip)
{
queuedClip = clip;
hasQueuedClip = true;
}
void Update()
{
if (!hasQueuedClip) return;
double secondsPerBeat = 60.0 / bpm;
double secondsPerBar = secondsPerBeat * beatsPerBar;
double now = AudioSettings.dspTime;
double elapsed = now - segmentStartDsp;
double barsSoFar = System.Math.Ceiling(elapsed / secondsPerBar);
double nextBarDsp = segmentStartDsp + barsSoFar * secondsPerBar;
standbySource.clip = queuedClip;
standbySource.PlayScheduled(nextBarDsp);
activeSource.SetScheduledEndTime(nextBarDsp);
Debug.Log("Queued switch to " + queuedClip.name + " at dsp time " + nextBarDsp
+ " (in " + (nextBarDsp - now).ToString("F2") + "s)");
AudioSource temp = activeSource; // swap roles for the next request
activeSource = standbySource;
standbySource = temp;
segmentStartDsp = nextBarDsp;
hasQueuedClip = false;
}
}
activeSource กับ standbySource เป็น AudioSource สองตัวแยกกันที่ผลัดกันสวมบทบาทเดียวกัน — ไม่มีวันเล่นพร้อมกันสองตัว แต่พร้อมส่งไม้ต่อได้เสมอ ต้องใช้สองตัว ไม่ใช่ตัวเดียว เพราะคุณเปลี่ยน clip ของ source ที่กำลังเล่นอยู่กลางคันโดยไม่หยุดมันก่อนไม่ได้อย่างปลอดภัย segment ที่กำลังจะมาต้องถูกเตรียมไว้ล่วงหน้าบน source ตัวที่สอง กำหนดให้เริ่มตรงจุดที่ source ตัวเดิมถูกกำหนดให้จบพอดี
RequestSegment แค่บันทึกว่า clip ไหนถูกขอ แล้วตั้ง flag ไว้ — มันไม่คำนวณอะไรเองเลย เพราะคำขอสามารถมาถึงตอนไหนก็ได้แบบสุ่มๆ และไม่ควรบล็อกอะไรทั้งนั้น งานจริงเกิดขึ้นตอน Update() รันครั้งถัดไป: elapsed คือระยะเวลาที่ segment ที่กำลังเล่นอยู่เล่นไปแล้วกี่วินาที การหารด้วย secondsPerBar แล้วปัดขึ้นด้วย Math.Ceiling จะหาว่าต้องผ่านไปกี่ bar เต็มๆ ก่อนจะถึง bar line ที่ตรงกับหรือหลังจากตอนนี้ การคูณกลับด้วย secondsPerBar แล้วบวกกับ segmentStartDsp จะได้ dspTime ที่แน่นอนของ bar line นั้น
ลอง trace ด้วย bpm = 120, beatsPerBar = 4 (ดังนั้น secondsPerBar = 2.0) segment ที่เริ่มที่ segmentStartDsp = 10.0 และ RequestSegment ถูกเรียกตอนที่ Update() รันพอดีที่ dspTime = 11.3:
Queued switch to CombatA at dsp time 12 (in 0.70s)
elapsed ออกมาเป็น 1.3 วินาที เข้าไปใน bar ยาว 2 วินาที (bar หนึ่งอยู่ระหว่าง dsp 10.0 ถึง 12.0) Math.Ceiling(1.3 / 2.0) ปัด 0.65 ขึ้นเป็น 1 หมายความว่า "รอ bar boundary อีกอันหนึ่ง" ซึ่งตกที่ 10.0 + 1 * 2.0 = 12.0 — bar line ถัดไปพอดี 0.7 วินาทีหลังจากคำขอมาถึง ถ้าคำขอมาถึงตอน dsp 11.9 แทน ซึ่งห่างจาก bar line เดียวกันแค่ 0.1 วินาที การรอก็จะเหลือแค่ 0.1 วินาทีแทนที่จะเกือบเต็ม bar — ความยาวของการรอขึ้นอยู่กับว่าคำขอมาถึงใกล้ bar line ถัดไปแค่ไหนล้วนๆ ไม่มีทางนานเกินหนึ่ง bar เต็ม
elapsed โดยใช้ Time.time แทน AudioSettings.dspTime Time.time ผูกกับ frame rate ที่ไม่คงที่ ส่วน dspTime คือ clock ของฮาร์ดแวร์เสียงเอง การผสมทั้งสองเข้าด้วยกันทำให้ "bar line" ที่คุณคำนวณได้ค่อยๆ เคลื่อนออกจากตำแหน่งจริงของฮาร์ดแวร์เสียง และพอสลับไปหลายครั้งเข้า การเคลื่อนนี้จะกลายเป็นเสียงคลิกที่ได้ยิน หรือการสลับที่เร็ว/ช้าไปอย่างชัดเจน คำนวณ audio scheduling ทั้งหมดด้วย dspTime เท่านั้น แล้วอ่าน Time.time เฉพาะเรื่องที่ยอมให้คลาดเคลื่อนได้สักเฟรมสองเฟรม อย่างเช่น UIVertical layering เริ่มจากแนวคิดที่ต่างออกไป: แทนที่จะมีเพลงหลายชิ้นแยกกัน มันมีเพลงชิ้นเดียวที่เล่นต่อเนื่อง ถูก export ออกมาจาก session ของคอมโพเซอร์เป็น stem หลายชิ้น (ไฟล์เสียงแยกสำหรับแต่ละกลุ่มเครื่องดนตรี — กลอง เบส สาย/pad เมโลดี้ — ทุกไฟล์มีความยาวเท่ากันเป๊ะ tempo เดียวกันเป๊ะ ตั้งใจให้เล่นพร้อมกันทั้งหมด) ในทุกๆ ขณะ ทุก stem กำลังเล่นอยู่ สิ่งที่เปลี่ยนคือแต่ละอันดังแค่ไหน
กฎที่สำคัญที่สุดข้อเดียวของ vertical layering คือ: ห้ามหยุดหรือเริ่ม layer ใหม่เด็ดขาดหลังจากเพลงเริ่มไปแล้ว AudioSource ทั้งสี่ตัวเริ่มพร้อมกัน ในจังหวะเดียวกัน แล้วก็เล่นต่อเนื่องตลอดเวลาที่ผู้เล่นอยู่ใน state นั้น — บางทีนานเป็นนาทีๆ มีแค่ volume เท่านั้นที่เปลี่ยน สิ่งนี้รับประกันว่าทั้งสี่ layer จะไม่มีวันเคลื่อนออกจากกันทางจังหวะ เพราะในมุมมองของ audio engine มันไม่ใช่เพลงสี่ชิ้นแยกกันที่ถูกประสานเข้าด้วยกัน — มันคือเพลงชิ้นเดียวสี่สำเนา ที่อยู่ตำแหน่งการเล่นเดียวกันเป๊ะเสมอ แค่ถูกมิกซ์ด้วยความดังต่างกัน
เทียบกับ horizontal re-sequencing ตรงๆ: horizontal เปลี่ยน เมโลดี้ไหน ที่ผู้เล่นได้ยิน (Explore กับ Combat เป็นไอเดียดนตรีคนละอันกัน) ส่วน vertical เปลี่ยน เท่าไหร่ ของไอเดียดนตรีชิ้นเดียวที่ผู้เล่นได้ยิน (เพลงบอสตัวเดิม บางเบาตอนแรก เต็มดังตอนที่การต่อสู้ทวีความรุนแรง) ค่า float เดียว — ค่า intensity ตั้งแต่ 0 ถึง 1 — ก็มักจะพอสำหรับขับเคลื่อนมันแล้ว
using UnityEngine;
// All four layers share ONE clip length and ONE start time. This code
// never starts or stops a layer -- it only changes volume, so the
// layers can never drift out of sync with each other.
public class VerticalMusicMixer : MonoBehaviour
{
public AudioSource drums;
public AudioSource bass;
public AudioSource strings;
public AudioSource melody;
[Range(0f, 1f)] public float intensity = 0f;
public float fadeSpeed = 1.5f; // volume units per second
void Start()
{
double startDsp = AudioSettings.dspTime + 0.1;
drums.volume = 0f;
bass.volume = 0f;
strings.volume = 0.3f;
melody.volume = 1f;
drums.PlayScheduled(startDsp);
bass.PlayScheduled(startDsp);
strings.PlayScheduled(startDsp);
melody.PlayScheduled(startDsp);
}
void Update()
{
float drumsTarget = intensity >= 0.6f ? 1f : 0f;
float bassTarget = intensity >= 0.35f ? 1f : 0f;
float stringsTarget = Mathf.Lerp(0.3f, 0.7f, intensity);
float melodyTarget = 1f; // melody is always present, at full volume
drums.volume = Mathf.MoveTowards(drums.volume, drumsTarget, fadeSpeed * Time.deltaTime);
bass.volume = Mathf.MoveTowards(bass.volume, bassTarget, fadeSpeed * Time.deltaTime);
strings.volume = Mathf.MoveTowards(strings.volume, stringsTarget, fadeSpeed * Time.deltaTime);
melody.volume = Mathf.MoveTowards(melody.volume, melodyTarget, fadeSpeed * Time.deltaTime);
}
// called from gameplay: enemy distance, combat state, boss phase, etc.
public void SetIntensity(float value)
{
intensity = Mathf.Clamp01(value);
}
}
Start() กำหนดให้ layer ทั้งสี่เริ่มด้วย PlayScheduled ที่ startDsp เดียวกันเป๊ะ บวก offset เผื่อไว้ 0.1 วินาที (พอให้ Unity เตรียม clip เสร็จก่อนถึง deadline จริง) การกำหนดให้ทั้งสี่เริ่มจากค่า dspTime เดียวกัน ในเฟรมเดียวกัน คือสิ่งที่รับประกันว่ามันจะเริ่มที่ sample เดียวกันเป๊ะ — ถ้าเรียก Play() แยกกันสี่ครั้งข้ามสามสี่เฟรม แต่ละอันอาจตกต่างกันไม่กี่มิลลิวินาที ซึ่งไม่ใช่ช่องว่างใหญ่อะไร แต่นั่นแหละคือความไม่ประสานกันเล็กๆ (บางทีเรียกว่า phasing) ที่หูที่ฝึกมาแล้วจับได้หลังฟังไปสักไม่กี่นาที
Update() คือจุดเดียวที่การตัดสินใจมิกซ์เสียงจริงๆ เกิดขึ้น และมันไม่แตะฟีลด์ intensity โดยตรงเลย — โค้ดฝั่ง gameplay จะเรียกแค่ SetIntensity(value) แล้ว Update() ก็อ่านค่ามันทุกเฟรม แต่ละ layer จะได้ target volume ที่คำนวณจาก intensity: กลองกับเบสเป็น threshold on/off ง่ายๆ (กลองจะเปิดเฉพาะตอนเกิน 0.6, เบสเฉพาะตอนเกิน 0.35) สายไล่ระดับสมูทตลอดช่วงด้วย Mathf.Lerp ส่วนเมโลดี้อยู่ที่ full volume ตลอด เพราะเมโลดี้ที่หายไปตอน intensity ต่ำจะทำให้เพลงฟังดูไม่ครบมากกว่าฟังดูสงบ Mathf.MoveTowards ค่อยๆ ดัน volume จริงของแต่ละ layer เข้าหา target ด้วยความเร็วคงที่ (fadeSpeed หน่วยเป็น volume unit ต่อวินาที) แทนที่จะสแนปทันที เพื่อให้การเปลี่ยน intensity แบบฉับพลันค่อยๆ เฟดเข้ามาแทนที่จะกระโดด
ลอง trace สถานการณ์ด้วย fadeSpeed = 1.5: intensity เริ่มที่ 0 (นิ่งแล้ว) จากนั้น SetIntensity(0.5) ถูกเรียกที่ t = 2.0s และ SetIntensity(0.8) ถูกเรียกที่ t = 5.0s:
สังเกตว่าเบสไม่ต้องขยับเลยครั้งที่สอง (มันอยู่ที่ target 1 อยู่แล้ว) และกลองเพิ่งขยับตอน t = 5.0s เพราะ 0.5 ไม่เคยข้าม threshold 0.6 ของมัน แต่ 0.8 ข้ามได้ ไม่มีอะไรสแนปเลย — ทุกการเปลี่ยนแปลงเป็นการไล่ระดับสมูทด้วยความเร็วคงที่เดียวกัน นี่คือเหตุผลที่การกระโดดจาก intensity 0 ไป 0.8 ทันทียังฟังดูเป็นการเฟด ไม่ใช่การตัด
Mathf.Clamp01 ใน SetIntensity ถ้าโค้ดฝั่ง gameplay คำนวณ intensity มาจากอะไรที่ไม่มีขอบเขต อย่างเช่น (maxHp - currentHp) / someDivisor ก็เผลอส่งค่าอย่าง 1.4 หรือ -0.2 เข้ามาได้ง่ายๆ ทุก threshold และ Mathf.Lerp ด้านบนสมมติว่า intensity อยู่ระหว่าง 0 กับ 1 เสมอ นอกช่วงนี้ Lerp จะ extrapolate เกิน 0.7 ของสายไปอย่างไม่แคร์ และค่าติดลบก็ทำให้ทุกการเปรียบเทียบทำงานแบบที่ไม่มีใครเคยเทสต์มาก่อน Clamp แค่จุดเดียว ตรงทางเข้าเดียว แล้วทุกบรรทัดที่ตามมาก็เชื่อค่านั้นได้เลยStinger คือวลีดนตรีสั้นๆ ที่เล่นครั้งเดียว — เสียงแตรกระแทก คอรัสกระแทก เสียงไรเซอร์ — ที่ซ้อนทับอยู่บนเพลงที่กำลังเล่นอยู่ เพื่อเน้นเหตุการณ์เดียวๆ: บอสปรากฏตัว ผู้เล่น level up ไอเทมหายากดรอป ต่างจาก section 4-8 ตรงที่ stinger ไม่ได้แทน state ที่ดำเนินต่อเนื่อง มันเล่นครั้งเดียวแล้วจบ
using System.Collections;
using UnityEngine;
public class MusicStingerPlayer : MonoBehaviour
{
public AudioSource stingerSource; // separate channel, layered on top of the music
public AudioSource[] musicLayers; // duck these briefly while the stinger plays
public float duckAmount = 0.4f; // fraction to lower music volume by
public float duckTime = 0.15f;
public float minCooldown = 1.0f; // ignore repeats faster than this
private float lastPlayTime = -999f;
public void PlayStinger(AudioClip stingerClip)
{
if (Time.time - lastPlayTime < minCooldown)
{
Debug.Log("Stinger " + stingerClip.name + " suppressed (too soon)");
return;
}
lastPlayTime = Time.time;
stingerSource.PlayOneShot(stingerClip);
StartCoroutine(DuckMusic());
Debug.Log("Stinger: " + stingerClip.name);
}
private IEnumerator DuckMusic()
{
float[] original = new float[musicLayers.Length];
for (int i = 0; i < musicLayers.Length; i++)
original[i] = musicLayers[i].volume;
for (int i = 0; i < musicLayers.Length; i++)
musicLayers[i].volume *= (1f - duckAmount);
yield return new WaitForSeconds(duckTime);
for (int i = 0; i < musicLayers.Length; i++)
musicLayers[i].volume = original[i];
}
}
PlayOneShot เล่น stinger clip โดยไม่รบกวนอะไรที่ stingerSource อาจจะเล่นทีหลัง ซ้อนทับอยู่บนเพลงที่กำลังเล่นอยู่ DuckMusic จะ duck (ลดระดับเสียงของเสียงหนึ่งชั่วคราว เพื่อให้อีกเสียงหนึ่งได้ยินชัดเจนขึ้นบนนั้น) volume ของทุก layer ลง duckAmount เป็นเวลา duckTime วินาที แล้วคืน volume เดิมของแต่ละ layer กลับมาเป๊ะๆ เพื่อให้ stinger ตัวใหญ่ๆ ไม่ต้องแย่งความสนใจของผู้ฟังกับมิกซ์เต็มๆ
ลอง trace การเรียก PlayStinger สองครั้งด้วย clip เดียวกัน ห่างกัน 0.3 วินาที โดย minCooldown = 1.0:
Stinger: BossReveal
Stinger BossReveal suppressed (too soon)
Cooldown มีไว้เพราะเหตุการณ์ในเกมไม่ได้เว้นระยะห่างให้กันอย่างสุภาพ — ศัตรูสามตัวอาจตายภายในครึ่งวินาทีเดียวกัน แต่ละตัวก็อยากยิง stinger "ศัตรูถูกกำจัด" เดียวกัน ถ้าไม่มี cooldown สามชุดของวลีสั้นๆ อันเดียวกันที่ซ้อนกันจะกลายเป็นเสียงรบกวนแทนที่จะเป็น sting hit ที่ชัดเจนสามครั้ง
ต่างจากการเปลี่ยน segment ใน section 5 PlayStinger เล่นทันที ไม่ต้องรอ bar line เลย นี่คือการตัดสินใจโดยเจตนา: stinger มักแทนเหตุการณ์ที่เกิดขึ้นแบบฉับพลันน่าประหลาดใจ และ feedback ที่ทันทีสำคัญกว่าการเรียงจังหวะดนตรีตรงนี้ — ผู้เล่นควรได้ยินตอนที่บอสเปิดตัวทันทีที่มันเกิด ไม่ใช่รอไปได้ถึงสองวินาที บางเกมก็ quantize stinger บางตัวให้ตรงกับ beat ที่ใกล้ที่สุด เพื่อให้ได้ hit ที่หนักแน่นและ "ดนตรี" มากขึ้น จะเลือกทางไหนเป็นการตัดสินใจที่คอมโพเซอร์กับ gameplay designer ร่วมกันตัดสินเป็นรายเหตุการณ์
ทุกอย่างใน section 5 ถึง 8 เป็นโค้ดจริงที่ใช้งานได้ — และก็เป็นโค้ดแบบเดียวกับที่มีเครื่องมือเฉพาะทางสร้างมาให้ทำแทนคุณพอดี Audio middleware (เครื่องมือภายนอกที่ใช้คู่กับ Unity หรือ Unreal ที่พบบ่อยที่สุดคือ Audiokinetic Wwise กับ FMOD Studio) ให้คอมโพเซอร์หรือ sound designer สร้างระบบ adaptive music ทั้งระบบแบบ visual โดยไม่ต้องเขียน C# แล้วส่ง API เล็กๆ ให้โปรแกรมเมอร์เกมเรียกใช้
สามไอเดียที่ตรงกับสิ่งที่คุณสร้างไปแล้วโดยตรง:
MusicState ของเรา, Explore/Combat/Boss) ที่เกมตั้งค่าด้วยการเรียกครั้งเดียว เครื่องมือ authoring ของ middleware รู้อยู่แล้วว่า segment ไหนอยู่ใน state ไหน และมันเรียงต่อกันได้ยังไง — นี่คือ horizontal re-sequencing ที่ authored แบบ visual แทนที่จะเป็น switch statement ใน C#intensity ของเรา ที่เกมอัปเดตทุกเฟรม คอมโพเซอร์วาด curve ต่อ layer แมป parameter นั้นไปเป็น volume ของ layer นั้นภายในเครื่องมือ authoring — นี่คือ vertical layering ที่แทนที่คณิตศาสตร์ fadeSpeed/Mathf.Lerp จาก section 8 ด้วย curve ที่คอมโพเซอร์เห็นและปรับได้เองโดยไม่ต้องขอให้โปรแกรมเมอร์แก้โค้ดMath.Ceiling จาก section 6 ที่ถูกทำให้คุณแล้ว// Hand-rolled (sections 5-8 of this chapter): the game computes the
// next bar line itself and schedules the switch/fade by hand.
horizontalPlayer.RequestSegment(combatClip);
verticalMixer.SetIntensity(0.8f);
// Wwise-style: the game only announces WHAT changed. The sound designer
// already placed Bar/Beat/Exit Cue markers and wrote the transition
// rules inside the Wwise authoring tool -- no C# math needed for it.
AkSoundEngine.SetState("MusicState", "Combat");
// FMOD-style: a continuous parameter (an RTPC) drives the vertical
// layers, mixed by the composer in FMOD Studio instead of by our
// fadeSpeed/Mathf.Lerp code.
musicInstance.setParameterByName("Intensity", 0.8f);
สังเกตว่าฝั่ง C# เล็กลงแค่ไหน: SetState กับ setParameterByName เหลือแค่บรรทัดเดียว การ scheduling การ duck และคณิตศาสตร์ curve ทั้งหมดจากบทนี้ยังเกิดขึ้นอยู่ — แค่มันเกิดขึ้นข้างในเอนจินของ middleware เอง ขับเคลื่อนด้วยข้อมูลที่คอมโพเซอร์ authored ไว้ในเครื่องมือ visual แทนที่จะอยู่ข้างใน MonoBehaviour ของคุณ
Repetition fatigue คือสิ่งที่เกิดขึ้นเมื่อผู้เล่นสังเกตเห็น และเบื่อกับการได้ยิน loop, transition, หรือ stinger เดิมๆ ซ้ำไปซ้ำมา มันแทบจะเลี่ยงไม่ได้เลยในทางทฤษฎี — ผู้เล่นอาจใช้เวลายี่สิบนาทีในพื้นที่เดียวที่คอมโพเซอร์เขียนเพลงไว้แค่สามนาที — แต่มีนิสัยไม่กี่อย่างที่ช่วยไม่ให้มันเห็นได้ชัดเกินไป:
using UnityEngine;
public class SegmentPicker
{
private AudioClip[] variants;
private int lastIndex = -1;
public SegmentPicker(AudioClip[] variants)
{
this.variants = variants;
}
// picks a random variant, but never repeats the same one twice in a row
public AudioClip PickNext()
{
int index;
do
{
index = Random.Range(0, variants.Length);
}
while (variants.Length > 1 && index == lastIndex);
lastIndex = index;
return variants[index];
}
}
ลอง trace การเรียก PickNext() ห้าครั้ง ด้วยสามแบบ [E1, E2, E3] ผลลัพธ์ที่เป็นไปได้แบบหนึ่ง (ลำดับจริงๆ เป็นแบบสุ่ม แต่กฎไม่ใช่):
รอบของคุณเองมีโอกาสสูงมากที่จะได้ลำดับต่างออกไป เพราะ Random.Range สุ่มจริงๆ — สิ่งเดียวที่ loop do/while รับประกันคือ index จะไม่มีวันเท่ากับ lastIndex ตอนที่ loop จบ ดังนั้น segment เดียวกันสองอันจะไม่มีวันเล่นติดกันสองครั้ง
AudioSettings.dspTime — clock ของฮาร์ดแวร์เสียงของ Unity ที่นิ่งกว่า หน่วยเป็นวินาที ใช้สำหรับ scheduling ที่แม่นยำ แทนที่จะเป็น Time.time ที่อิงเฟรมPlayScheduled / SetScheduledEndTime — method ของ AudioSource ใน Unity ที่เริ่มหรือหยุด clip ที่ dspTime อนาคตที่แน่นอนHorizontalMusicPlayer (section 6) มี bpm = 100 และ beatsPerBar = 4 segment ที่กำลังเล่นอยู่เริ่มที่ segmentStartDsp = 20.0 RequestSegment ถูกเรียก และ Update() รันตอน AudioSettings.dspTime = 23.1
คำนวณ: (a) secondsPerBar, (b) elapsed, (c) nextBarDsp, (d) กี่วินาทีหลังจากคำขอที่การสลับจริงๆ เกิดขึ้น และ (e) segment ใหม่เริ่มที่ bar ไหน (นับ bar แรกของ segment เป็น bar 1)
(a) secondsPerBeat = 60 / 100 = 0.6, ดังนั้น secondsPerBar = 0.6 * 4 = 2.4
(b) elapsed = 23.1 - 20.0 = 3.1 วินาที
(c) barsSoFar = Math.Ceiling(3.1 / 2.4) = Math.Ceiling(1.291...) = 2 ดังนั้น nextBarDsp = 20.0 + 2 * 2.4 = 24.8
(d) การสลับเกิดขึ้น 24.8 - 23.1 = 1.7 วินาทีหลังจากคำขอ
(e) bar boundary ตกที่ dsp 20.0 (bar 1 เริ่ม), 22.4 (bar 2 เริ่ม), และ 24.8 (bar 3 เริ่ม) คำขอที่ dsp 23.1 ตกอยู่ข้างใน bar 2 (22.4 ถึง 24.8) ดังนั้นจุดสลับที่ถูกกฎที่เร็วที่สุดคือจุดเริ่มของ bar 3 และนั่นก็คือจุดที่ nextBarDsp = 24.8 ตกพอดี
VerticalMusicMixer (section 8) มี fadeSpeed = 2.0 intensity อยู่นิ่งที่ 0.2 มาสักพักแล้ว ดังนั้นทุก layer นิ่งอยู่ที่ target ของมันแล้ว: drums = 0, bass = 0, strings = 0.38, melody = 1 ที่ t = 0 พอดี SetIntensity(0.7) ถูกเรียกและไม่มีการเปลี่ยนอีกเลย
คำนวณ: (a) target volume ใหม่ของแต่ละ layer ทั้งสี่ และ (b) เวลาที่แน่นอน (เป็นวินาทีหลัง t = 0) ที่แต่ละ layer เฟดเสร็จถึง target ใหม่
(a) ด้วย intensity = 0.7: drumsTarget = 1 (0.7 >= 0.6), bassTarget = 1 (0.7 >= 0.35), stringsTarget = Lerp(0.3, 0.7, 0.7) = 0.3 + 0.4 * 0.7 = 0.58, melodyTarget = 1 (ไม่เปลี่ยน มันเป็น 1 อยู่แล้วเสมอ)
(b) drums: ระยะห่าง |1 - 0| = 1.0 ที่ความเร็ว 2.0 หน่วย/วินาที ใช้เวลา 0.5s เสร็จที่ t = 0.5s bass: ระยะห่างเท่ากัน เวลาเฟดเท่ากัน เสร็จที่ t = 0.5s strings: ระยะห่าง |0.58 - 0.38| = 0.20 ที่ความเร็ว 2.0 หน่วย/วินาที ใช้เวลา 0.1s เสร็จที่ t = 0.1s melody: อยู่ที่ target (1) อยู่แล้ว จึงไม่ต้องเฟดเลย — เท่ากับ t = 0s
(a) สำหรับแต่ละสถานการณ์ ระบุว่าอะไรเหมาะที่สุด — horizontal re-sequencing, vertical layering, หรือ stinger — พร้อมเหตุผลหนึ่งบรรทัด: (i) เมโลดี้ต้องเปลี่ยนไปทั้งหมดทันทีที่ผู้เล่นออกจากหมู่บ้านเข้าสู่ดันเจี้ยน (ii) เพลงการต่อสู้กับบอสควรเต็มขึ้นและดังขึ้นเมื่อ HP บอสลดลง โดยไม่เปลี่ยนเมโลดี้เลย (iii) ผู้เล่น dodge ได้แบบสมบูรณ์แบบ และเกมต้องการเสียงแทรกสั้นๆ ครั้งเดียวทันที
(b) ใน section 6 HorizontalMusicPlayer รอ bar line ถัดไปเสมอก่อนจะสลับ แม้ว่ามันอาจทำให้การสลับช้าไปเกือบเต็ม bar ทำไมไม่ข้ามการรอแล้วสลับทันที แบบที่ NaiveMusicSwitcher ใน section 1 ทำล่ะ จะมีอะไรพังบ้าง
(a) (i) Horizontal re-sequencing — หมู่บ้านกับดันเจี้ยนต้องการไอเดียดนตรีที่ต่างกันจริงๆ ไม่ใช่แค่เวอร์ชันดังหรือเบากว่าของไอเดียเดียวกัน (ii) Vertical layering — มันยังเป็นเพลงชิ้นเดียวที่เล่นต่อเนื่องตลอดการต่อสู้ แค่เปิด layer มากขึ้นเมื่อ intensity (HP บอสที่ลดลง) เพิ่มขึ้น (iii) Stinger — เสียงแทรกครั้งเดียวที่ผูกกับเหตุการณ์เดียว ไม่ใช่ state ที่ต่อเนื่อง คือสิ่งที่ stinger ถูกสร้างมาให้ทำพอดี
(b) การสลับทันทีมีโอกาสสูงมากที่จะตัด segment เดิมกลางประโยคเพลง ที่ beat ที่ไม่เกี่ยวข้องกับ beat หนึ่งของ segment ใหม่เลย — นี่คือปัญหาเดียวกันเป๊ะกับที่ NaiveMusicSwitcher มีใน section 1 ได้ยินความขัดแย้งแบบเดียวกัน ประเด็นทั้งหมดของ section 4-6 คือการเอาความขัดแย้งนั้นออกไป โดยสลับเฉพาะตรง bar line เท่านั้น ที่ segment ไหนก็เล่นต่อจาก segment ไหนก็ได้อย่างสะอาด การรอ (มากสุดหนึ่ง bar) เป็นการแลกเปลี่ยนเล็กๆ ที่คุ้มค่า เพื่อให้ได้การเปลี่ยนที่ฟังดูตั้งใจ ไม่ใช่ฟังดูพัง
นี่คือ adaptive music แบบครบวงจร: loop ตายตัวพังเพราะเกมไม่รู้จังหวะเวลาของตัวเองล่วงหน้า horizontal re-sequencing แก้ปัญหาด้วยการสลับระหว่าง segment ที่ authored ไว้บน bar line ที่ quantize แล้ว vertical layering แก้ปัญหาอีกแบบด้วยการมิกซ์ stem ที่เล่นต่อเนื่องตลอดด้วยค่า intensity เดียว stinger เน้นเหตุการณ์เดียวๆ ซ้อนทับบนทั้งสองอย่าง และ middleware อย่าง Wwise หรือ FMOD ให้คอมโพเซอร์ authored state, RTPC, และ sync point แบบ visual จนฝั่ง C# เหลือแค่ไม่กี่บรรทัด ไอเดียเดียวที่ร้อยทุกอย่างเข้าด้วยกันคือเวลาทางดนตรี — BPM, beat, และ bar วัดด้วย dspTime แทนที่จะเป็นเวลาเฟรม — เพราะทุกเทคนิคในบทนี้จริงๆ แล้วก็แค่วิธีต่างๆ ในการเคารพมันนั่นเอง