본문 바로가기
자료

Flick 자료 - 2020 스마일게이트 챌린지 2

by 소리쿤 2021. 12. 1.
불의의 사고로 리포지토리 날라감...ㅜ

리듬에 맞는 노트 생성 (CSV 파싱)


1. 노래에 대한 정보를 Scriptable Object에 입력합니다. (BPM, 시작 딜레이, 앨범 커버 사진 등)

2. ReadCSV는 음원 MIDI 파일을 변환한 CSV 파일을 받아 NoteData를 만듭니다.
(이 때 위 Scriptable Object를 참조하여 디테일을 수정합니다.)

https://www.fourmilab.ch/webtools/midicsv/

 

MIDICSV: Convert MIDI File to and from CSV

This page describes, in Unix manual page style, programs available for downloading from this site which translate MIDI music files into a human- and computer-readable CSV (Comma-Separated Value) format, suitable for manipulation by spreadsheet, database, o

www.fourmilab.ch


3. CSV 파일은 다음과 같이 구성됩니다.

  • CSV 파일의 헤더엔 곡의 이름 / BPM / 음질 정보 등 곡에 대한 메타데이터가 작성돼있습니다.
  • CSV 파일의 바디엔 각 트랙이 연주되는 타이밍이 트랙 / 시간대 별로 나열돼있습니다.
  • 각 라인은 트랙 번호 / 연주되는 시간 / 음의 높이 등으로 구성돼있습니다.
2, 0, Note_on_c, 0, 66, 110 
2, 160, Note_on_c, 0, 66, 0 
2, 192, Note_on_c, 0, 66, 110 
2, 352, Note_on_c, 0, 66, 0


4. CSV를 파싱하여 노트를 생성하는 알고리즘은 다음과 같습니다.

  1. ReadSelectedMusic()이 호출되어 사용자가 선택한 곡의 이름 / 속도 등을 읽어온다.
  2. Init()이 호출되어 해당 곡의 Scriptable Object을 불러와 곡의 구체적인 정보를 담는다.
  3. ReadCSVFile()이 호출되어 CSV 파일을 TestAsset으로 변환하여 StringReader로 EOF를 만날 때까지 한 줄씩 읽는다.
  4. 파싱된 문자열엔 각 노트의 트랙 번호 / 연주되는 시간 / 음의 높이가 들어 있다.
  5. BasicGenerateMode()이 호출되어 파싱된 문자열을 통해 NoteData 리스트를 생성한다.
  6. 생성된 NoteData 리스트를 연주 시간 오름차순으로 정렬한다.
  7. 오브젝트 풀링하여 노트를 생성한다.
  8. 현재 시간과 노트의 연주 시간 정보, 판정선까지의 거리를 연산하여 적절한 시기에 노트를 활성화하여 사용한다.
  9. 음의 높이를 기준으로 일반 / 롱 노트를 생성하였다.
  10. 노트의 판정선 도착 시간은 +- 범위로 판정에 활용한다.

축약 소스 코드

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

[CreateAssetMenu(fileName = "New Song", menuName = "Song")]
public class Song : ScriptableObject
{
    public string MusicName;
    public string CSVName;
    public string composer;
    public AudioClip clip;
    public int bpm;                           // BPM
    public double beat;                       // 1박

    public int Level;                         // 레벨 난이도

    [Range(3, 10)]
    public float musicStartDelay;             // 음악 시작까지 딜레이 (속도 조절을 고려해 최소 5초 필요)
                                              // 가급적 시작시간만 결정하는 용도로 쓰고, 싱크는 noteSrartDelay로 조절을 권장

    [Range(0.0f, 5.0f)]
    public float noteStartDelay;              // 음악이 시작되고 첫 노트가 나올때까지'의 딜레이. 
                                              // 노트와 음악의 싱크를 맞추기 위해 미세하게 조정해야함

    public bool isRandomNote;                 // 노트를 무작위 트랙에 생성할 것인가
    public Sprite JacketImage;                // 앨범 커버 사진
}
using System.Collections;
using System.Collections.Generic;

public class NoteData
{
    public int startTime;
    public int endTime;
    public int track;
    public Note.NoteType noteType;

    //시작시간, 끝시간, 트랙번호, 노트유형
    public NoteData(int _startTime, int _endTime, int _track, Note.NoteType _noteType)
    {
        startTime = _startTime;
        endTime = _endTime;
        track = _track;
        noteType = _noteType;
    }
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using System;

public class ReadCSV : MonoBehaviour
{
    public static ReadCSV Instance;
    public Song currentSong;
    public int bpm;                           // BPM
    public double beat;                       // 1박
    public double CountperSec;                // 1분 BPM을 채우기 위해 1초에 카운트
    public float musicStartDelay;             // 첫 음악 시작까지 딜레이
    public float noteStartDelay;              // 첫 노트 생성까지 딜레이

    public List<NoteData> noteList = new List<NoteData>();
    public Queue<NoteData> note = new Queue<NoteData>();

    void Awake()
    {
        Instance = this;
    }

    void Start()
    {
        ReadSelectedMusic();
        Init();
        ReadCSVFile();
    }

    void ReadSelectedMusic()
    {
        var obj = GameObject.Find("SelectMusicScene");
        if (obj != null)
        {
            currentSong = obj.GetComponent<SelectMusicScene>().GetSelectedSong(); // 곡 지정
            var speed = obj.GetComponent<SelectMusicScene>().musicSpeed; // 속도 지정
            HighSpeed.Instance.MoveSpeed = 1 / speed + 0.5f;
        }
    }

    void Init()
    {
        bpm = currentSong.bpm;
        beat = currentSong.beat;
        musicStartDelay = currentSong.musicStartDelay;
        noteStartDelay = currentSong.noteStartDelay;
    }

    void ReadCSVFile()
    {
        // var CSVdir = (Application.dataPath + "/Resources/" + currentSong.CSVName + ".csv");
        // PC버전, 상대경로 지정

        FileReadWrite file = new FileReadWrite();
        TextAsset CSVdata = Resources.Load<TextAsset>(currentSong.CSVName);

        StringReader strReader = new StringReader(CSVdata.text);
        string currentTitle = "";
        bool endOfFile = false;
        bool endOfTrack = false;

        List<string> StreamList = new List<string>();

        while (!endOfFile)
        {
            string data_String = strReader.ReadLine();
            if (data_String == null)
            {
                endOfFile = true;
                break;
            }
            StreamList.Add(data_String);
        }

        for (int i = 0; i < StreamList.Count; i++)
        {
            string[] csvText = StreamList[i].Split(',');
            string[] csvNextLineText = csvText;

            if (i + 2 < StreamList.Count)
                csvNextLineText = StreamList[i + 2].Split(',');

            if (csvText[2].ToString() == " Title_t")
            {
                currentTitle = csvText[3].ToString();
                Debug.Log(currentTitle);
            }

            if (csvText[2] == " Start_track") endOfTrack = false;
            if (csvText[2] == " End_track") endOfTrack = true;

            if (endOfTrack == false)
            {
                BasicGenerateMode(currentTitle, csvText, csvNextLineText); // 정해진 트랙별로 노트를 배치
            }
        }
        CountperSec = bpm * beat / 60;

        Debug.Log("노트 개수 : " + noteList.Count);

        //리스트 정렬하기
        noteList.Sort(delegate (NoteData A, NoteData B)
        {
            if (A.startTime > B.startTime) { return 1; }
            else if (A.startTime < B.startTime) { return -1; }
            return 0;
        });

        //리스트 정렬 후 큐에 담기
        for (int i = 0; i < noteList.Count; i++)
        {
            note.Enqueue(noteList[i]);
        }
        ObjectPool.Instance.Initialize(noteList.Count);

        public int BasicGenerateMode(string title, string[] csvText, string[] csvNextLineText)
        {
            if (currentSong.isRandomNote == false)
            {
                for (int index = 0; index < 6; index++)
                {
                    if (title == " \"track_" + index + "\"")
                    {
                        if (csvText[2] == " Note_on_c" && csvText[5] != " 0")
                        {
                            var startTimeInt = Convert.ToInt32(csvText[1]);
                            var endTimeInt = Convert.ToInt32(csvNextLineText[1]);
                            //Debug.Log("시작: " + startTimeInt + "끝 : " + endTimeInt);
                            if (csvText[4] == " 71")
                                noteList.Add(new NoteData(startTimeInt, endTimeInt, index, Note.NoteType.NORMAL));
                            if (csvText[4] == " 72")
                                noteList.Add(new NoteData(startTimeInt, endTimeInt, index, Note.NoteType.LONG));
                            if (csvText[4] == " 73")
                                noteList.Add(new NoteData(startTimeInt, endTimeInt, index, Note.NoteType.FLICK_DOWN));
                        }
                    }
                }
            }
            return 1;
        }
  }

노트 오브젝트 풀링


사용 이유

 

  1. 노트 오브젝트를 대량으로 생성, 파괴시키는 과정에서 렉이 발견되었다.
  2. 오브젝트 파괴 후, 예상치 못한 타이밍에 일어난 가비지 컬렉션이 원인임을 알았다.


오브젝트 풀링 개념

 

  1. 오브젝트를 게임 로딩 과정에서 미리 충분히 생성하고, 풀을 만든다.
  2. 사용할 땐 큐에서 꺼내 사용한다. (큐가 비어 있다면 새로 생성한다.)
  3. 사용이 끝나면 큐로 반환한다.


1. 게임이 로드될 떄, Initialize()를 통해 노트를 미리 생성하여 큐에 삽입한다.
2. 노트 생성처에서 GetObject()를 통해 노트를 획득한다.
3. GetObject()는 큐가 충분하다면 큐에 있는 노트를 리턴하고, 충분하지 않다면 CreateNewObject()를 호출한다.
4. 사용이 완료되면 ReturnObject()로 큐에 반환한다.


축약 소스 코드

sing System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    public static ObjectPool Instance;
    [SerializeField] private GameObject poolingObjectPrefab;
    Queue<Note> poolingObjectQueue = new Queue<Note>();

    private void Awake()
    {
        Instance = this;
        Initialize(ReadCSV.Instance.noteList.Count);
    }

    public void Initialize(int initCount)
    {
        for (int i = 0; i < initCount; i++)
        {
            poolingObjectQueue.Enqueue(CreateNewObject());
        }
    }

    private Note CreateNewObject()
    {
        var newObj = Instantiate(poolingObjectPrefab).GetComponent<Note>();
        newObj.gameObject.SetActive(false);
        newObj.transform.SetParent(transform);
        return newObj;
    }

    public static Note GetObject()
    {
        if (Instance.poolingObjectQueue.Count > 0)
        {
            var obj = Instance.poolingObjectQueue.Dequeue();
            obj.transform.SetParent(Instance.transform);
            obj.gameObject.SetActive(true);
            return obj;
        }
        else
        {
            var newObj = Instance.CreateNewObject();
            newObj.gameObject.SetActive(true);
            newObj.transform.SetParent(Instance.transform);
            return newObj;
        }
    }
    public static void ReturnObject(Note obj)
    {
        obj.gameObject.SetActive(false);
        obj.transform.SetParent(Instance.transform);
        Instance.poolingObjectQueue.Enqueue(obj);
    }
}