การเก็บข้อมูลตำแหน่งของโน้ตมิวสิคเกม

อันนี้เป็นบทความเฉพาะทางมากๆไม่คิดว่าใครจะอ่านเข้าใจ แต่จดไว้อ่านทวนว่าตัวเองลองอะไรมาแล้วบ้างละกัน (หรือมีใครจะอยากทำมิวสิคเกมอีกมั้ย)

การเก็บข้อมูลตำแหน่งของโน้ตมิวสิคเกม

อันนี้เป็นบทความเฉพาะทางมากๆไม่คิดว่าใครจะอ่านเข้าใจ แต่จดไว้อ่านทวนว่าตัวเองลองอะไรมาแล้วบ้างละกัน (หรือมีใครจะอยากทำมิวสิคเกมอีกมั้ย…) ประเด็นอยู่ที่ว่าโน้ตแต่ละตัวจะเก็บไว้ยังไงดีว่ามันอยู่ที่ไหนของเพลง

เก็บเป็น float

เช่นโน้ต 4th เรียงกันที่อยู่ measure 3 ก็จะมีได้ 4 ตัวคือตำแหน่ง 3.00 3.25 3.50 3.75 ครับ เรียกได้ว่า resolution คือจุดทศนิยมที่นับไปทีละ 1/n เมื่อ n คือค่าความละเอียดของโน้ตตอนนั้น ถ้าโน้ตได้ละเอียดสุด 64th ก็จะไปทีละ 1/64

ข้อดี

  • สามารถเก็บโน้ตได้ละเอียดไม่จำกัดเพราะเป็น float
  • สามารถรู้ measure ของโน้ตได้ทันทีโดยการ floor ทศนิยมทิ้ง
  • เข้ากับโค้ดหลายๆจุดได้เนียนๆ เช่นโค้ดที่วาดของตาม measure แทบจะสามารถเอาข้อมูลที่อยู่โน้ตมาคิดตำแหน่งบนจอได้ทันที (เกิดการ optimize เพราะโค้ดพวกนี้จะ for loop integer เป็นเลข measure ที่ควรจะเห็นบนจอขณะนี้)
  • เข้ากับโค้ดคิดเวลาได้ดี เช่นถามว่าตรงนี้คือเวลาเท่าไหร่ตั้งแต่เริ่มเล่นมา (จะได้สั่งเล่นเพลงตรงเวลาที่ถูกต้องหลัง pause ได้ เป็นต้น) คำตอบคือตำแหน่ง float * 4 * (60 / BPM) เช่น​โน้ตที่ 3.25 ในเพลง 180 BPM แปลว่ามันวางอยู่​ ณ เวลา 4.33 วินาที (1 measure มี 4 beats คูณเพื่อให้คิดกับ BPM ได้)

ข้อเสีย

  • เวลาอยากรู้ว่าโน้ตตัวนี้อยู่ความละเอียดลำดับที่เท่าไหร่ (เช่นถ้าเกมมี 4th 8th 12th 16th แล้วก็จะได้ว่าโน้ต 7.250 จะต้องมีความละเอียดลำดับ 2 คือ 8th) ต้องใช้ท่ายากคือเอาตำแหน่งที่เก็บไว้มาลอง modulo เทียบทีละอันเรียงจากหยาบไปละเอียดจนกว่าจะเจอ ว่าอันไหนจะได้ 0 ก่อนคืออันนั้น แล้วก็ modulo float กับ float จริงๆมันไม่มีในนิยาม แต่ถ้าทำแบบนั้นใน C# จะมีโอกาสเกิดผลเป็น 0 ไม่ก็ได้เท่าตัว modulo (คือเยื้องกันนิดเดียว) แล้วก็ เวลาเอามาเทียบกันว่าใช้ค่าที่ว่ามั้ยด้วยความเป็น float เราใช้ == ไม่ได้ ต้องใช้ฟังก์ชั่นหนักอย่าง Mathf.Approximately
  • เกิดบัคไม่คาดคิดเช่นอยากจะลบโน้ตแต่ลบไม่ได้ เพราะโค้ดหาโน้ตว่าลบตรงไหนกับตำแหน่งโน้ตคลาดกันนิดเดียว เช่น จะลบโน้ตที่ measure 1 ตำแหน่งแรกสุดแต่ลบไม่ได้ เพราะไปดูโน้ตแล้วดันอยู่ตำแหน่ง 0.99999999 (ได้ไง เกิดจากบัคอื่นๆที่ถ้าไม่มีบัคก็คงจะเวิค แต่ถ้าใช้ integer ก็คงไม่เกิดบัคนี้แต่แรกเพราะเช็ค equality ได้ตรงกันระดับ bit)
  • เกมผมมีตัวที่บอกว่าโน้ตตัวไหน link ไปตัวไหน ซึ่งเก็บเป็นสไตล์ linked list ในโปรแกรมแต่ว่า reference นี้เรา serialize ไม่ได้ ตอน serialize เลยต้องเปลี่ยนเป็นว่า โน้ตปลายทางอยู่ตำแหน่งไหนแทน แล้วค่อย link กันเป็น reference หลังอ่านไฟล์ แล้วพอตำแหน่งเป็น float บางทีเกิดบัคที่หาโน้ตปลายทางที่ว่าไม่เจอเพราะตำแหน่งคลาดกันนิดเดียว
    อันนี้แก้ได้อีกวิธีคือมี id ให้โน้ตทุกตัว แล้ว link กันด้วย id แทน แต่อาจจะลำบากตอนเพิ่มลบโน้ต แล้วต้องเลื่อน id ของโน้ตทุกตัวในเกมแล้วจะโปรแกรมลำบากอีกนิด แล้วก็จะเสีย shortcut ที่หาได้ทันทีว่าโน้ตปลายทางอยู่ตรงไหน (ต้องตาม id ไปดูก่อน) ซึ่งสามารถใช้หาอย่างความยาวโน้ตค้างอะไรงี้ได้ ทำให้รู้ว่าโน้ตค้างยาวเท่าไหร่ในตัวเอง มองย้อนไปแล้วจริงๆก็อยากใช้วิธี id มากกว่าเดี๋ยวต้องนั่งแก้ เพราะถ้าโน้ตทุกตัวมี id จะทำให้ทำระบบ record performance ได้ ซึ่งส่งผลให้ทำระบบ realtime ghost score แบบ DDR, IIDX ได้

เก็บเป็น integer

เราจะใช้หลักการตั้ง resolution ขึ้นมา ซึ่งเป็นหน่วยที่บอกว่าใน 1 measure มีตำแหน่งที่ valid อยู่กี่ตำแหน่ง

สมมติ resolution เป็น 4 โน้ตที่อยู่ตำแหน่ง 0 1 2 3 คือโน้ตใน measure แรกที่เป็น 4th note ทั้งหมด แต่ถ้า resolution เป็น 8 โน้ตตำแหน่ง 0 1 2 3 จะกลายเป็นโน้ต measure แรก 4th 8th 4th 8th เรียงกันจนถึงครึ่ง measure แทน ความหมายของ integer ตำแหน่งจะเปลี่ยนไปตาม resolution ที่ตั้งไว้ ถ้าเกิน resolution ก็มีความหมายว่าเป็น measure ถัดไป หรือใครจะมี int อีกตัวบอก measure แล้วตำแหน่งสุดแค่ resolution-1 ก็ได้ อาจจะมีประโยชน์ในโค้ด draw loop เวลาวาดเป็น measure ไปแล้วแต่ดีไซน์

สมมติว่าถ้าเกมเราอยากจะมีโน้ต 4th 8th 12th 16th 24th 32nd 48th 64th เท่านี้ ก็ควรใช้ตัวหารร่วมมากที่ represent ได้ทุกตัวพอดี ในที่นี้ใช้ 128 ไม่ได้เพราะ 48 หารไม่ลงตัว ต้องใช้ 192 ซึ่งเป็น GCD ของ 48 กับ 64 ได้ และที่เหลือทั้งหมด แบบนี้อย่าลืมว่าเราจะใช้โน้ต 128th ไม่ได้เพราหาร 192 ไม่ลงตัว ถ้าอยากใช้ก็คงต้องใช้ resolution 256 เป็นต้น

โดยที่เราจะทำเป็น integer แค่ข้อมูลโน้ตปลายทางสุดท้ายเท่านั้น เมื่อเป็นข้อมูลที่อื่นเช่นหัวอ่าน (ตำแหน่งของ receptor ปัจจุบัน) เราจะเก็บเป็นหน่วยเดียวกันกับโน้ต (เทียบกันได้ว่าอันไหนมากกว่า อันไหนน้อยกว่า) แต่ว่าเป็น float แทน เช่น โน้ตอยู่ตำแหน่ง measure 47 ไปอีกครึ่งห้อง โน้ตอยู่ตำแหน่ง 9024 ใน resolution 192 แต่ว่าหัวอ่านอาจจะอยู่ที่ 9023.446 ก็ได้ เพราะคิดมาจากหน่วยพื้นฐานที่สุดคือเวลาปัจจุบันเป็นวินาทีของหัวอ่าน (เป็น float ละเอียดได้ไม่จำกัด เหมือนเวลาในโลกจริง) อีกอย่างถ้ามันเลื่อนเป็น int เกมคงดูกระตุกๆน่าดู

ในเมื่อเป็น int ก็แน่นอนว่าเก็บได้มากที่สุด 2 พันล้านซึ่งก็มากเกินพอครับ ถ้าคิดว่าเก็บได้กี่ measure ที่ความละเอียด 192 ก็ได้ตั้ง 1 ล้าน measure แน่ะ

อันนี้รู้สึกจะหลักการเหมือน BMS รึเปล่าไม่แน่ใจ?

ข้อดี

  • ทำให้ไม่งงกับหน่วยเวลา เพราะคนละ type กัน (int กับ float) บางที compiler ก็เตือนเราก่อนเราจะงงได้ แต่ก็เฉพาะตรงที่เทียบกับตัวโน้ตนะ
  • อันนี้สำคัญที่สุด คือใช้ == ได้แม่นยำ กับหา multiple of ได้แม่นยำ จุดที่ต้องใช้เช่นจะหาว่าโน้ตตัวนี้เป็นโน้ตความละเอียดไหนจากตำแหน่งโน้ตที่เก็บไว้สามารถ modulo ได้เป๊ะๆ ตอน == เช่นตอนคิดว่าเป็นโน้ตคู่หรือไม่ แล้วก็ใช้ตอนหาโน้ตที่ link กันได้แม่นยำ
  • พอดีใช้ protobuf แล้วขนาด int สามารถเล็กกว่า float ได้ เพราะมัน encode เป็น varint ที่ถ้าเลขเล็กแล้วจะกินที่น้อยลงโดยอัตโนมัติ ทำให้ไฟล์เล็ก อ่านไวขึ้นด้วย

ข้อเสีย

  • เกิดการหาร/modulo เพื่อเปลี่ยนเป็นตำแหน่งอิงกับ measure ให้เข้ากับโค้ดอื่นๆ เช่นโน้ตตำแหน่ง 240 อยู่ที่ measure ไหนแล้วอยู่ตรงไหนใน measure นั้น ต้องหารกับ resolution คือ 192 จะรู้ว่าอยู่ measure 1 แล้วตัวเศษที่เหลือคือตำแหน่งใน measure นั้น อันนี้คือ 48 ก็จะได้ว่าอยู่ที่ snap ที่ตรงกับ 4th note ตัวที่ 2 ของ measure แต่เราสามารถ cache เก็บไว้ได้หรือจะหารเก็บไว้คู่กับตำแหน่งเลยก็ได้ถ้าไม่อยากหารตอน runtime
  • ถ้าเปลี่ยนเลข resolution แล้วตำแหน่ง integer ของโน้ตที่จำไว้ทั้งหมดจะผิดความหมายทันที ดังนั้นคิดให้ดีว่าเกมจะมีความละเอียดสูงสุดเท่าไหร่
  • คิดในใจยากว่าตรงนี้คือตรงไหน เช่นบอกว่า อยากจะไป measure 47 ตรงกลางก็เลยเซ็ตตำแหน่งหัวอ่านไปที่ 47.50 แต่ถ้าเป็น int ต้องเซ็ตตำแหน่งไปที่ 9024 (ถ้า resolution คือ 192) อันนี้มีผลกับตอนเขียนเทสที่ต้องคิดในหัว แก้ได้โดยการทำ method ตัวแปลงขึ้นมารับ แล้วเขียนเทสแบบ floating ไปเลย
  • ตอนทำงงมาก เช่นงงว่า 48 นี่คือ 48th beat หรือ 4th beat (ตำแหน่ง 48 ใน resolution 192 คือ 4th beat)

สรุปแล้วคือ

มาเก็บเป็น integer กับ resolution กันดีกว่า!

เนื่องจากมีจังหวะที่ต้องเก็บเป็น int จังหวะที่ต้องเก็บเป็น float แต่เลขเดียวกับ int (ตอนจะใช้จริง) แล้วก็จังหวะที่เป็น float แบบเก่า เช่นตอนเขียนเทส อยากเขียนว่า 2.5 แปลว่า 2 measure ครึ่ง มากกว่าต้องมาคิดในใจแล้วเขียนเทสว่า 480

เทคนิคสร้างคลาสมาแทน int/float

อยากจะแค่ห่อ int กับ float นั่นแหละ แต่เนื่องจาก struct ของ C# ไม่สามารถ extend จาก Int32 หรือ Single ได้ ก็เลยต้องทำเป็นคลาส ซึ่งก็ extend ไม่ได้อีกนั่นแหละเพราะดันเป็น sealed ดังนั้นคลาสใหม่นี้เราต้องพยายาม override operator ต่างๆให้นิสัยออกมาคล้ายๆ int หรือ float เอง โดยดำเนินการกับตัวแปร int หรือ float ตัวจริงที่ยัดไว้ข้างในผ่าน operator overloading/implicit-explicit casting

แบ่งเป็น 3 คลาสดังนี้

AbsolutePosition : จำนวนอิง resolution ที่เป็น int อันนี้โน้ตที่เก็บไว้ในไฟล์จะใช้

FloatingPosition : จำนวนอิง resolution ที่เป็น float สามารถ cast จาก AbsolutePosition ได้ 100% แต่ cast กลับแล้วจุดหาย อันนี้เกมจะใช้

NaturalPosition : อันนี้แบบ measure เหมือนเดิม มีฟังก์ชั่นแปลงเป็น Floating ได้แบบแม่นยำหรือแปลงเป็น Absolute เลยก็ได้แต่เสียความแม่นยำ ทำไว้เพื่อใช้เขียนเทสกับใช้ทำ note editor ในส่วนติดต่อกับคนทำโน้ต (เช่น จะโดดไป measure 37.5 นะ จะได้บอกแบบนั้นได้เลย)

ผ่านไป 5 วันก็เปลี่ยนทั้งเกมเป็น 3 format นี้สำเร็จ ทำให้ compiler จับผิดโค้ดเราได้ดีขึ้นเพราถ้าเราใช้ int float float แล้วตั้งชื่อตัวแปรเอาเดี๋ยวจะงงซะเอง ในเมื่อเป็นคลาสแล้วเราก็ต้อง override operator กับเขียน implicit/explicit casting ให้เวลาใช้แล้วรู้สึกเหมือนมันเป็น int หรือ float ธรรมดาๆได้ แต่พอผิดกฏแล้ว compiler บอกได้ว่าตรงนี้ผิดนะ

เอาตัวอย่างโค้ดของ AbsolutePosition มาให้ดู เขียนไปทั้งหมดนี้เพื่อให้มันเกือบเหมือน int แต่ก็มีฟังก์ชั่นเพิ่มเติมเช่นถ้าคูณกับ float แล้วจะกลับมาเป็น int เอง (เอาไว้เวลาเลื่อนโน้ตแบบเป็นกี่เท่าก็ว่าไป) หรือตอนที่ตบกับ FloatingPosition แล้วกลับมาเป็น FloatingPosition เองเลย การคูณที่เห็นนี้ ถ้าวางไว้สลับด้านกันก็ error นะ แต่ก็ตั้งใจอยากให้เป็นแบบนั้นเพราะอารมณ์ scale มันน่าจะเอาเลขมาใส่ทางขวามากกว่าอะไรงี้

จะเห็นว่าเราไม่สามารถ .Value ของ FloatingPosition ได้เพราะเป็น private แต่ของ AbsolutePosition ได้แม้จะเป็น private เพราะ static ดันอยู่ในขอบเขต definition ของ AbsolutePosition ดังนั้นของ Floating ที่เป็นต่างถิ่นเราจะใช้ explicit cast เปลี่ยนมาเทียบแทน

ส่วนตรง cast ก็เช่นสามารถ cast explicit จาก FloatingPosition ได้เลย หรือจาก int ก็ได้ แต่จาก float ไม่ให้ (ถ้า float ต้อง cast explicit ไป FloatingPosition แทน) ส่วนไป Natural ให้เป็นเมธอด เพราะเรารู้สึกว่ามันเป็นกิริยามากกว่า อยากได้ความหนักแน่นตอนเรียก

แค่ย้ายข้อมูลนี่ก็เกิดบัคพอสมควร เช่นการเปลี่ยนจาก floating ตอนย้ายโน้ต 12th/24th มาเป็น format integer แน่นอนข้อมูลเก่าเป็นจุดก็จะเกิดการ rounding แต่โน้ต 12th อย่าง 5.083333333333 (12th ตัวแรก ของ measure 5) มันดันใกล้เคียงกับโน้ต 64th ที่ตำแหน่งใกล้ๆกันมาก เช่น 5.078125 (64th ตัวที่ 5 ของ measure 5)

ถ้าโน้ต 64th ตัวนั้นมาเป็น format AbsolutePosition ก็จะได้ (192*5) + (3*5) = 975 ถ้าโน้ต 12th ตัวนั้น ก็จะได้ (192*5) + (16*1) = 976 จะเห็นว่าต่างกันแค่ 1 เท่านั้นเอง แต่ตอนจะแปลง float Natural เก่ามาเป็น AbsolutePosition ต้องแปลงโดย Natural*192 เมื่อคูณ 5.083333333333 * 192 = 975.9999999 แล้วดันเผลอปัดเศษทิ้ง (ก็คือเผลอใช้ (int) แคส) ก็เลยกลายเป็นโน้ต 64th ไปหมดเลย จริงๆต้อง round อย่าง Mathf.RoundToInt

กับบัคที่กินเวลาไปหลายวันอีกอันก็คือลืมแปลงนั่นแหละ โดยเฉพาะตรงที่อันเก่าเป็น Natural ในคราบ float แล้วดันลอยมาเข้า FloatingPosition ได้โดยไม่ตั้งใจ แก้ไขได้โดยการใช้ explicit casting ในการใช้ float เป็น FloatingPosition ทำให้เราต้องพิมพ์ (FloatingPosition)0.53f แบบนี้ถึงจะใช้ได้ แต่ถึงเติมแล้วก็ยังงงตัวเองอยู่ดีเพราะจริงๆมันเป็น Natural เอามาเป็น FloatingPosition เลยกลายเป็นตำแหน่งเล็กจิ้ดเดียวแทน (ก็คือจริงๆต้อง ((NaturalPosition)0.53f).ToFloating() แบบนี้ โค้ดเทสยืดยาวแบบนี้หมดทั้งโปรเจคเลย)

แล้วก็ในเมื่อแยกคลาสกันแล้ว สามารถทำเมธอดช่วยเช็คโค้ดได้อีก เช่นทางทฤษฎีเราสามารถแปลง AbsolutePosition หรือ FloatingPosition เป็นหน่วยวินาทีได้ทั้งคู่ แต่ทางคอนเซ็ปต์เกมเราคิดไว้ว่า AbsolutePosition จะเอาไว้เก็บอย่างเดียว ตอนเกมจะใช้ยังไงก็เป็น FloatingPosition อยู่แล้ว เราก็สามารถ implement เมธอดแปลงที่ว่าไว้แค่ที่ FloatingPosition ได้จะได้ล็อคไว้ไม่ให้เผลอใช้กับ AbsolutePosition

อันแรกเป็น static สามารถใช้เสก FloatingPosition ได้ อันที่สองใช้แปลงเป็นเวลาได้ถ้ารู้ bpm ปล. ใช้สี Monokai มานานพอเปลี่ยนสีบ้างแล้วรู้สึกมีแรงดีเหมือนกัน

Performance ล่ะ

มีผลเรื่อง performance ที่เปลี่ยนไป ตรงที่เราเปลี่ยนจาก value type อย่าง int, float มาเป็น class ที่เป็น reference type…

  • ทุกครั้งที่แปลงตัวแปรคลาส 3 อันที่ว่านี้ไปมาจะเกิด object ใหม่ อาจจะเป็นปัญหากับ garbage collector ได้ถ้าทำรัวๆเหมือนมันเป็น value type แต่ก็อยากให้เป็นแบบนี้มากกว่าแก้ค่า value ข้างใน instance เดิม อยากได้อารมณ์ functional กับความเหมือน int/float มากกว่า
  • แต่กลับกันทุกครั้งที่ส่งตัวแปรพวกนี้เข้าออกเมธอดก็ส่งเป็น reference ไม่เกิดการ copy ค่า อาจจะเซฟ performance ตรงนี้ได้

ดังนั้นเราควรจะออกแบบโค้ดให้มีอย่างแรกน้อยๆ อย่างหลังเยอะๆครับในเมื่อเราใช้ reference type แบบนี้แล้ว