跳到正文
Qiita · Unity 开发社区· 来源· Eizi_dazo·· 2 小时前AI 评分38

基于 Unity 6 与 Meta Quest 3 的 AR 吉他练习应用开发实践

AR開発ミリしらでギター練習アプリをつくってみた

正文 · 原文

はじめに

この度、2026日10月1日から11月13日まで
株式会社Generosity様でARのアプリケーション開発をしてきました。
その開発内容を紹介させていただきます。

概要 (Abstract)

今回は約3週間という開発期間の中で、生成AIの支援を受けながらギター練習用のARアプリケーション開発に挑戦しました。

開発概要

· 開発期間: 約3週間

· 開発目的: AIと一緒にギターを練習できるARアプリケーションの制作

· 開発環境: Unity 6000.3.26f1

· 使用機材: Meta Quest 3

· 採用技術:

o Meta XR SDK

o Meta XR Interaction SDK

o Meta XR Interaction SDK Essential

· 開発支援: ChatGPT(GPT-6)

実現できたこと

· 実際のギターに仮想的な演奏ガイドを重ねることで、スムーズに演奏練習を進められる環境を構築

· 現実空間に仮想的な楽器や演奏ガイドを導入するための基礎知識を習得

開発を通して得られた結論

開発初期における最大の障壁は、Meta XR SDKをはじめとするXR関連SDKの理解と環境構築でした。しかし、一度SDKの基本的な仕組みや扱い方を把握してしまえば、その後の機能実装は比較的スムーズに進められることが分かりました。

開発背景

私は夏頃からVRを利用し始め、VRChatのワールド制作にも取り組むなど、XR技術への興味が徐々に高まっていました。

そんな中、10月のインターンシップをきっかけに、XR技術を活用したエンターテインメント系ソフトウェアの開発に挑戦する機会が訪れます。

新しいものを開発するなら、やはり自分の興味がある分野を選びたいものです。自分自身がターゲットユーザーを想像しやすく、利用者の視点から必要な機能を考えられるからです。

VRについては既に触れていたため、今回はARへ挑戦することにしました。

そして最終的に題材として選んだのが、以前から興味はありながらも年々後回しにしてきた「ギターの練習」です。

こうして、ギターについてほとんど知識のない私による「ミリしらAR開発」が始まったのでした。

既存のギターゲーム・練習ソフト

開発にあたって、まずは既存のギター関連ゲームや練習ソフトを調べてみました。

代表的なものとしては、専用コントローラーを使用する「Guitar Hero」や「GITADORA」、実際のギターを使用して演奏を判定する「Rocksmith」シリーズなどがあります。

それぞれ異なるアプローチでギター演奏をゲームとして楽しめるようにしていますが、今回特に参考にしたのが、Meta Quest向けの「IMMERROCK」です。

IMMERROCKは、実際のギターに仮想的な演奏ガイドを重ねて練習できるソフトウェアです。

今回の開発で目指している方向性にも近く、現実の楽器と仮想的なガイドを組み合わせることで、新しい練習体験を生み出せる可能性を感じました。

今回の企画方針

こうした既存製品を参考に、今回はARを活用したギター練習アプリケーションを作ることにしました。

今回の企画で大切にしたかったのは、初心者でも分かりやすく、継続しやすいことです。

私自身がギター初心者ということもあり、初心者の視点から必要な機能を考えていきました。

具体的には、次の2つを企画の軸としています。

分かりやすい

・言葉による説明は必要最小限にする

・一度に多くのことを教えず、段階的に説明する

・直感的に操作できるUIにする

継続しやすい

・目標が明確である

・目標に近づいていることを実感できる

・気が向いたときに気軽に遊べる

・楽しいから続けたいと思える体験を作る

これらを企画の軸として、実際の機能やUIを考えていくことにしました。

それでは、ここからは実際の開発について紹介していきます!

開発編

1.開発環境と基本構成

1.1 開発環境

本プロトタイプでは、以下の環境・技術を使用して開発を行いました。

開発エンジン: Unity 6(6000.3.26f1)
開発対象デバイス: Meta Quest 3
使用SDK・ライブラリ:
・Meta XR SDK
・Meta XR Interaction SDK
・Meta XR Interaction SDK Essential

開発では、完成版のアプリケーションを目指すのではなく、実物のギターに仮想ガイドを重ねて表示する機能と、楽曲データに応じてガイドを進行させる仕組みの実現を優先していきました。

1.2 開発構成

開発した機能は、大きく以下の要素で構成されています。

仮想ネックの生成・調整:
仮想ネックやフレット・弦のガイドを生成し、位置や大きさを調整する。

演奏ガイドの生成:
楽曲データを基に、演奏位置を示すレーンやノーツを生成する。

楽曲データの管理:
BPM・拍子・音符の長さなど、楽曲情報やノーツ情報を管理する。

楽曲編集UI:
アプリ内で楽曲データの作成・編集・保存を行う。

演奏進行の制御:
BPMや拍子に応じて演奏ガイドを移動させ、演奏の進行を制御する。

開発構成図(仮置き)

1.3 Unity上のオブジェクト構成

Unity上では、ガイド全体を管理するGuideRootを中心に、ガイド表示用のオブジェクトや、楽曲・演奏進行を管理するオブジェクトを配置しています。

オブジェクト構成図
image.png

主なオブジェクトの役割は以下の通り。

GuideRoot配下

GuideRoot: ガイド全体の位置・回転・大きさを管理する。
MenuBoard: 位置調整、演奏の開始・停止を行うためのUI。
VirtualNeck: 実物のギターに対応する仮想座標系。内部に演奏ガイドを構成するオブジェクトを配置する。
KeyLane: フレット位置と演奏時間を示すガイド。
KeyNotes: 押さえる位置を示すノーツ。
StringsGuide: 6本の弦に対応するガイド。
StringsGuide_Back1: 奥側に配置された弦のガイド。
FretLines: 生成したフレット線を配置するオブジェクト。
ReferencePoints: フレットの生成の基準点。

GuideRoot外の主なオブジェクト

SongEditorController: 楽曲編集UIの生成・操作を管理する。
MultiMeasureKeyLaneGenerator: 小節単位のレーン生成を管理する。
MetronomeController: メトロノームに関する制御を行う。
OVRCameraRig: Questの視点・トラッキングを管理する。
PassthroughLayer: 現実空間を表示するパススルー機能を管理する。
EventSystem: UIの入力イベントを管理する。

2.アプリの動作の説明

このアプリは開発構成に書いた通り

仮想のギターを作る → 楽曲データを読み込ませる → ガイドを表示する
↑
これを補助するシステム達

ここからはそれらを順を追って説明していきましょう

2.1 仮想のネックを作る

まずはギターの演奏で必要なギターのネック部分を作ります。

Guitar_neck.png

詳しいパーツの名称は調べてね!

フレット位置の計算

実物のギターに仮想ガイドを重ねるためには、フレット線の間隔を実際のギターに合わせる必要があります。

ギターのフレットは、ナットから離れるほど間隔が狭くなるため、一定間隔で配置する方法では実物の位置と一致しません。

そこで、ナット側と12フレット側の両端にそれぞれ基準点を設け、これらの位置関係からフレット線を生成する仕組みを実装しました。 (プロトタイプなので12フレットまで)

使用する基準点は以下の4つです。
・NutEdgeA、NutEdgeB:ナット側の両端
・Fret12EdgeA、Fret12EdgeB:12フレット側の両端
スクリーンショット 2026-11-11 164025.png
これらの基準点を利用してフレット位置を計算するのが、

FretPositionCalculator.cs ←こいつ

まず、基準点の位置をスクリプトが配置されているオブジェクトのローカル座標に変換します。

Vector3 nA = transform.InverseTransformPoint(nutEdgeA.position);
Vector3 nB = transform.InverseTransformPoint(nutEdgeB.position);
Vector3 fA = transform.InverseTransformPoint(fret12EdgeA.position);
Vector3 fB = transform.InverseTransformPoint(fret12EdgeB.position);

InverseTransformPoint()を使用することで、ワールド座標で取得した基準点を、フレット線を生成するオブジェクトのローカル座標に変換しています。


次に、各フレットの位置を計算します。

for (int fret = 1; fret <= 12; fret++)
{
    float t = 2f * (1f - Mathf.Pow(2f, -fret / 12f));

    Vector3 a = Vector3.Lerp(nA, fA, t);
    Vector3 b = Vector3.Lerp(nB, fB, t);
}

ここでは、フレット番号に応じて t を計算し、ナット側と12フレット側の基準点をそれぞれ補間しています。

Mathf.Pow(2f, -fret / 12f)は、フレット間隔が12平均律に基づいて狭くなることを表す計算です。また、先頭の 2f は、ナットから12フレットまでの距離を基準として補間できるようにするための係数です。

この計算によって、1~12フレットまでの位置を求めています。

フレット線の生成

計算した2点 a と b を使い、フレット線となるオブジェクトを生成します。

Vector3 direction = b - a;
float length = direction.magnitude;

GameObject line = GameObject.CreatePrimitive(PrimitiveType.Cube);
line.name = $"GeneratedFret_{fret:00}";
line.transform.SetParent(transform, false);
line.transform.localPosition = (a + b) * 0.5f;

if (length > 0.0001f)
{
    line.transform.localRotation =
        Quaternion.FromToRotation(Vector3.up, direction);
}

line.transform.localScale = new Vector3(
    lineThickness,
    length,
    lineDepth);

フレット線はCubeを利用して生成しています。

  • 生成位置:2つの基準点の中間
  • 回転:2点を結ぶ方向に合わせる
  • 長さ:2点間の距離
  • 太さ・奥行き:Inspectorから設定した値を使用
    これにより、基準点の位置関係に合わせたフレット線を生成できます。

image.png

フレットができた!

また、生成したオブジェクトには GeneratedFret_ という名前を付けています。再生成時には、この名前で始まるオブジェクトだけを削除してから作り直すため、他のオブジェクトを誤って削除しない構成にしています。

弦ガイドの長さと配置の調整

フレット線を生成した後は、弦ガイドも実物のギターに合わせる必要があります。

そこで、

StringLengthAdjuster.cs

を作成し、フレット線の生成に使用した4つの基準点から、弦ガイドの長さと配置を調整する仕組みを実装しました。

まず、ナット側と12フレット側の基準点をローカル座標に変換し、それぞれのX座標の平均を求めます。

Vector3 nutA = neck.InverseTransformPoint(nutEdgeA.position);
Vector3 nutB = neck.InverseTransformPoint(nutEdgeB.position);
Vector3 fret12A = neck.InverseTransformPoint(fret12EdgeA.position);
Vector3 fret12B = neck.InverseTransformPoint(fret12EdgeB.position);

float nutX = (nutA.x + nutB.x) * 0.5f;
float fret12X = (fret12A.x + fret12B.x) * 0.5f;

基準点の両端から平均位置を求めることで、ナット側と12フレット側の中央位置を取得しています。

次に、この2点間の距離から弦ガイドの長さを計算します。

float length = Mathf.Abs(fret12X - nutX);

float centerX = (nutX + fret12X) * 0.5f;

これにより、基準点間の距離を弦ガイドの長さとして使用します。

弦ガイドの長さを求めた後は、各弦の位置を調整します。

まず、弦ガイド全体の中心位置をワールド座標に変換し、さらにStringsGuideのローカル座標に変換します。

Vector3 centerWorld =
    neck.TransformPoint(new Vector3(centerX, 0f, 0f));

Vector3 centerLocal =
    stringsGuide.InverseTransformPoint(centerWorld);

続いて、各弦の配置範囲を調整します。

endMarginを使用して、ナット側と12フレット側それぞれのY方向の両端に余白を設けます。

float nutMarginT =
    nutB.y == nutA.y
    ? 0f
    : Mathf.Clamp(
        endMargin / Mathf.Abs(nutB.y - nutA.y),
        0f,
        0.5f);

float fret12MarginT =
    fret12B.y == fret12A.y
    ? 0f
    : Mathf.Clamp(
        endMargin / Mathf.Abs(fret12B.y - fret12A.y),
        0f,
        0.5f);

endMarginを基準点間のY方向の幅で割ることで、余白を補間に使用する割合へ変換しています。

この割合を利用して、弦の配置範囲を両端から内側に狭めます。

float nutT = Mathf.Lerp(
    nutMarginT,
    1f - nutMarginT,
    t);

float fret12T = Mathf.Lerp(
    fret12MarginT,
    1f - fret12MarginT,
    t);

float nutY = Mathf.Lerp(nutA.y, nutB.y, nutT);
float fret12Y = Mathf.Lerp(fret12A.y, fret12B.y, fret12T);

こうして求めたナット側と12フレット側のY座標を基に、各弦の配置位置を計算します。

float targetY = Mathf.Lerp(nutY, fret12Y, 0.5f);

float centerY = (nutY + fret12Y) * 0.5f;

targetY =
    centerY + (targetY - centerY) * stringSpacingScale;

targetY += stringYOffset;

stringSpacingScaleで弦同士の間隔を調整し、stringYOffsetで全弦のY位置をまとめて移動できるようにしています。

最後に、計算した位置と長さを各弦ガイドに反映します。

stringObject.localPosition = localPosition;

Vector3 scale = stringObject.localScale;

float stringDiameter =
    Mathf.Lerp(0.0004f, 0.0010f, i / 5f);

scale.x = length;
scale.y = stringDiameter;

stringObject.localScale = scale;

弦ごとに太さも変更できるようにし、計算した長さをX方向のスケールに反映しています。

また、弦ガイドの色はStringColorPaletteを利用して適用します。
これはstringNumbersByChildIndexによって子オブジェクトの順番と弦番号を対応させ、各弦に色を設定しています。

colorPalette.ApplyColor(
    stringRenderer,
    stringNumbersByChildIndex[i]);

image.png

この色の適用処理はStart()から呼び出すようにしています。

さらに、複製した弦ガイドであるStringsGuide_Back1にも、元の弦ガイドの調整結果を反映する処理を追加しました。

target.localPosition = source.localPosition;
target.localScale = source.localScale;

元の弦ガイドの各子オブジェクトの位置とスケールを、複製側の対応する子オブジェクトに反映します。

親オブジェクトについては、元のStringsGuideの位置を基準に、Z方向へ指定した距離だけ移動させます。

Vector3 position = stringsGuide.position;
position.z += back1ZOffset;

stringsGuideBack1.position = position;

これにより、元の弦ガイドを調整した際に、複製側も同じ長さと配置へ更新できるようにしました。

image.png

合体!

ここにガイドが出るようになれば完璧です。
しかしそのガイドで表示するための楽曲データが必要です。
なので次の楽曲データについての説明に移っていきましょう。

2.2 楽曲データを作る

音楽と言えば楽譜ですよね。みなさん、あれ読むことできますか?
私は先月まで読めなかった人間です(^▽^)/

image.png

対応する音を入れて簡単に出すとこんな感じ..らしい

ギターには5線譜とtab譜の2種類があり、
弦の抑える位置を分かりやすくしたのが『tab譜』です。
今回はよく使われるTab譜から手打ちで楽曲データを作る方法を採用しました。

読まなくても良い余談

// C4はピアノの中央のドなんです。
// この画像の場合C4(ドの4)はtab譜に5弦の3フレットを抑えるように書き起こされていますが、
// しかし実際はtab譜の抑え方ではC3の音が出ます。
// 記入のしやすさ、ギターの音が回りの楽器より低い関係で1オクターブのズレがあるそうです。
// 音楽は不思議なルールがありますねぇ。

楽曲データとノーツデータの管理

楽曲全体の情報と、個々のノーツの情報を分けて管理するため、

SongData.cs

を作成。
内部にはpublic classとしてSnogDataとNoteDataを作成しました。

SongDataでは、曲名やBPM、拍子、小節数など、楽曲全体に関する情報を管理します。また、演奏するノーツの情報はNoteDataとして定義し、SongDataのnotesリストにまとめて保持します。

[Serializable]
public class SongData
{
    public string songName = "New Song";
    public float bpm = 120f;
    public int beatsPerMeasure = 4;
    public int beatUnit = 4;
    public int totalBars = 4;
    public float durationSeconds = 8f;
    public List<NoteData> notes = new List<NoteData>();
}

NoteDataでは、ノーツの開始位置や長さ、演奏する弦とフレットなどを管理します。

[Serializable]
public class NoteData
{
    // 4分音符を1とした曲先頭からの位置
    public float beatPosition;

    // 1~6弦(1弦が最も細い)
    public int stringNumber = 1;

    // 1~24=押弦、0=開放弦、-1=ミュート
    public int fretNumber = 1;

    public float durationBeats = 1f;

    // 小節境界を跨ぐノーツに付与するタイ情報。
    // durationBeats は曲全体での連続した長さを保持し、MR側では小節ごとに表示を分割する。
    public bool isTiedAcrossMeasure = false;
    public int nutSideOffset = 0;
    public string pickingDirection = "Down";

    // 同じタイで結ばれたノーツに共通するID。
    // タイで結ばれていない場合は空文字列。
    public string tieGroupId = "";

    public static string GetStateLabel(int fret)
    {
        if (fret == -1) return "ミュート";
        if (fret == 0) return "開放";
        return fret + "F";
    }
}

このように、ノーツの位置と長さは秒数ではなく、4分音符を1とした拍数で管理しています。
例えば、音符の長さは以下のように表現できます。

音符 durationBeats
全音符 4
2分音符 2
4分音符 1
8分音符 0.5
16分音符 0.25

拍数を基準にすることで、BPMや拍子が変わっても、ノーツの位置や長さを共通の形式で扱えます。
また、beatsPerMeasureとbeatUnitから1小節の長さを4分音符換算で求めています。例えば4/4拍子なら4拍、3/4拍子なら3拍となります。

そして、小節の境界を跨ぐノーツへ対応するために、タイ情報も保持しています。
isTiedAcrossMeasureやtieGroupIdなどを用いて、同じタイで結ばれたノーツを識別できる構成にしています。

ノーツの状態を文字列で表示する際には、GetStateLabel()を使用します。フレット番号に応じて「ミュート」「開放」「1F」などの表示を返します。

SongEditorによる楽曲データの作成・編集・保存・読み込み

ここが一番大変です。

SongEditorController.cs

このプロジェクトの中で色々詰め込んだ結果、2500行を超える化け物コードが生まれてしまいました。SongEditor関連をこの中に全て放り込んだ結果ですね。分割するように気を付けるべきでした。

というわけでこのスクリプトの中身を大まかに説明すると

  1. 楽曲データと編集状態の管理
  2. CanvasとUIの生成
  3. タイムラインの描画
  4. ノーツの追加・編集
  5. ノーツの重複・小節境界の確認
  6. 楽曲データの保存・読み込み
  7. UIの移動関連

が行われています。
5,7は割愛しつつ紹介していきましょう。

image.png

完成品はこんな感じ

楽曲データと編集状態の管理

SongEditorController.csでは、SongDataを保持し、楽曲情報や編集対象のノーツ、UIの選択状態などを管理しています。
最初に、必要なライブラリとクラス内で使用する変数を定義します。


using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;

public class SongEditorController : MonoBehaviour
{
    [Header("譜面")]
    public SongData song = new SongData();

    [Header("エディター")]
    public bool showEditor;
    public MultiMeasureKeyLaneGenerator laneGenerator;
    public GuideController guideController;

    [Min(1)] public int totalBars = 4;
    [Min(1)] public int editFret = 1;
    [Min(0.125f)] public float noteDuration = 1f;
//-----------------------------------------------------------
//割愛
//-----------------------------------------------------------
    private string status = "譜面を準備しています。";
    private string songNameText = "New Song";
    private string bpmText = "120";
    private string numeratorText = "4";
    private string denominatorText = "4";
    private string barsText = "4";

    // 0=ミュート、1=開放弦、2~25=1~24F
    private int stateSelection = 1;

    // 0=全音符、1=2分、2=4分、3=8分、4=16分
    private int durationSelection = 2;

    private NoteData selectedNote;
    private bool deleteMode;

    private bool showQuarterGrid = true;
    private bool showEighthGrid = true;
    private bool showSixteenthGrid;

    private float hoveredBeat = -1f;
    private int hoveredRow = -1;

    private bool hasAnyOverlapError;
    private string overlapErrorText = "";

    private readonly float[] durations = { 4f, 2f, 1f, 0.5f, 0.25f };

    private readonly string[] durationLabels =
    {
        "全音符", "2分音符", "4分音符", "8分音符", "16分音符"
    };

    [SerializeField] private TMP_FontAsset tmpFontAsset;

    private TMP_Text selectedText;
    private TMP_Text statusText;
//-----------------------------------------------------------
//割愛
//-----------------------------------------------------------

    private string SavePath =>
        Path.Combine(Application.persistentDataPath, "song.json");

楽曲データ:
songにSongDataを保持し、編集対象となる楽曲情報とノーツ情報を管理します。

演奏ガイドとの連携:
laneGeneratorとguideControllerを参照し、保存後のレーン再生成や演奏状態のリセットに使用します。

編集状態:
stateSelectionで追加する弦の状態を、durationSelectionで音符の長さを選択します。selectedNoteには現在選択中のノーツを保持します。

タイムラインの状態:
hoveredBeatとhoveredRowでポインターが示す拍と弦を管理します。また、補助グリッドの表示状態も保持します。

UI参照:
曲名やBPMなどの入力欄、ボタン、タイムラインの表示領域をそれぞれ変数として保持します。

保存先:
SavePathで定義しています。Application.persistentDataPathを基準にsong.jsonのパスを作成するため、端末上の保存領域を利用できます。

続いて、保持している変数に実際の楽曲データを設定する処理です。


private void Awake()
{
    try
    {
        if (File.Exists(SavePath) && TryLoadSong())
        {
            status = "保存済み譜面を読み込みました。";
        }
        else
        {
            song = new SongData();
            status = "新しい譜面です。";
        }

        InitializeFields();

        if (createCanvasOnStart)
        {
            if (editorCanvasPrefab != null)
            {
                CreateCanvasAndUI();
            }
            else
            {
                Debug.LogError(
                    "[SongEditor] editorCanvasPrefab が未設定です。",
                    this);
            }
        }

        SetEditorVisible(showEditor);
    }
    catch (Exception e)
    {
        Debug.LogError("[SongEditor] 初期化失敗: " + e, this);
    }
}

private void Start()
{
    SetEditorVisible(showEditor);
}

private void InitializeFields()
{
    if (song == null)
        song = new SongData();

    if (song.notes == null)
        song.notes = new List<NoteData>();

    songNameText = song.songName ?? "";
    bpmText = song.bpm.ToString(CultureInfo.InvariantCulture);
    numeratorText =
        song.beatsPerMeasure.ToString(CultureInfo.InvariantCulture);
    denominatorText =
        song.beatUnit.ToString(CultureInfo.InvariantCulture);

    totalBars = Mathf.Clamp(song.totalBars, 1, 32);
    barsText = totalBars.ToString(CultureInfo.InvariantCulture);
}

Awake()では、最初に保存ファイルの有無を確認します。保存ファイルが存在し、読み込みに成功した場合はそのデータを使用し、存在しない場合や読み込みに成功しなかった場合は、新しいSongDataを作成します。

その後、InitializeFields()で楽曲データの各項目を編集画面で使用する変数に反映します。例えば、BPMや拍子の情報を文字列に変換して保持し、入力欄へ表示できるようにしています。

最後に、createCanvasOnStartが有効であればCanvasとUIを生成し、showEditorの設定に合わせて表示状態を切り替えます。

このように、起動時に保存済みの楽曲データを読み込むか、新規データを作成するかを判断し、編集画面で扱える状態に整えています。

CanvasとUIの生成

SongEditorControllerでは、譜面エディターをUnityの実行時に生成しています。CanvasはPrefabから生成し、表示方式をWorld Spaceに設定することで、Questの空間内へ配置できるようにしています。
Canvasを生成するCreateCanvasAndUI()というメソッドを作成し、
この処理では、最初にInspectorで設定したeditorCanvasPrefabが存在するかを確認し、設定されていればPrefabを複製します。すでにCanvasが生成されている場合は、重複して生成しないようにしています。

生成したCanvasには、以下の設定を行っています。

Render Mode:World Spaceに設定し、3D空間内へ配置できるようにする。
Canvas Size:canvasSizeで指定したサイズを適用する。
Scale:0.001倍に縮小する。
Position / Rotation:メインカメラの位置と向きを基準に、canvasLocalOffsetで指定した位置へ配置する。
Canvas Scaler:ConstantPixelSizeを使用し、UIの描画サイズを調整する。

最後にBuildUI(canvasRect)を呼び出し、生成したCanvasの中にエディターの各UI要素を構築します。このメソッドでは、背景やウィンドウ移動用ヘッダーを作成した後、曲情報の入力欄、ノーツの設定ボタン、操作ボタン、タイムラインを順番に配置しています。

BuildUI()の中では、主に次のUIを生成しています。

曲設定:曲名、BPM、拍子、小節数を入力する欄。
状態選択:ミュート、開放弦、1~24フレットの状態を選択するボタン。
音符長:全音符から16分音符までの長さを選択するボタン。
補助グリッド:タイムラインに表示する4分・8分・16分の補助線を切り替えるトグル。
操作ボタン:ノーツの追加、保存、読込、複製、移動、削除などを行うボタン。
タイムライン:ノーツを配置・編集する領域。

各UI要素はCreateText()、CreateInput()、CreateButton()、CreateToggle()といったメソッドを呼び出して生成しています。これらはUI要素の生成処理を共通化した補助メソッドです。

また、ボタンやトグルにはラムダ式などで処理を登録しています。例えば、状態選択ボタンを押すとstateSelectionが更新され、選択状態の色と画面表示が更新されます。補助グリッドのトグルも同様に、設定値を変更した後でRefreshTimeline()を呼び出し、タイムラインを再描画します。

このように、UIの見た目だけでなく、各操作に対応する処理もBuildUI()の中で登録しています。

タイムライン部分は、他の入力欄やボタンとは別にCreateTimelineViewport()で生成しています。ここでは、表示範囲を制限するMask付きの領域と、その内側に配置するTimelineContentを作成しています。

さらに、横スクロールバーとポインター操作用のTimelinePointerHandlerを設定しています。タイムラインの描画内容やノーツの配置処理は別のメソッドで扱うため、ここでは表示領域と操作を受け付ける土台を用意しています。

タイムラインの描画

ここからはこだわったタイムラインの描画の部分ですね。

Canvas内のタイムラインは、RefreshTimeline()で生成しています。

このメソッドでは、既存の描画オブジェクトを削除してから、曲の設定に応じて背景・小節・弦ごとの行・補助グリッド・ノーツを再生成します。

まず、描画先となるtimelineContentを確認し、以前の描画内容を削除します。その後、拍子と小節数を取得し、タイムライン全体のサイズを決定します。

private void RefreshTimeline()
{
    if (timelineContent == null)
        return;

    // 以前の描画内容を削除
    for (int i = timelineContent.childCount - 1; i >= 0; i--)
        Destroy(timelineContent.GetChild(i).gameObject);

    hoverRowRect = null;
    hoverLineRect = null;

    if (!TryGetSettings(out float barLength, out int bars, out _))
    {
        timelineContent.sizeDelta = new Vector2(800, 336);
        return;
    }

    float contentWidth = LabelWidth + bars * barWidth;
    float contentHeight = HeaderHeight + 6 * RowHeight;

    timelineContent.sizeDelta =
        new Vector2(contentWidth, contentHeight);

    // 以下、背景・グリッド・ノーツを生成
}

ここでは、主に次の変数を使っています。

barLength:1小節を4分音符換算で表した長さ。
bars:表示する小節数。
barWidth:1小節あたりの横幅。
LabelWidth:弦番号などを表示する左側の領域幅。
HeaderHeight:小節情報を表示するヘッダーの高さ。
RowHeight:1弦あたりの行の高さ。

これらを使って、タイムライン全体の横幅と高さを計算しています。横幅は小節数に応じて変化し、高さは6弦分の行とヘッダーで構成されます。

続いて、タイムラインの背景と小節、各弦の行を生成します。

// 1. 背景・ヘッダー
CreateTimelineBox(
    timelineContent,
    "弦",
    new Rect(0, 0, LabelWidth, HeaderHeight),
    new Color(0.2f, 0.2f, 0.2f),
    16);

for (int bar = 0; bar < bars; bar++)
{
    float x = LabelWidth + bar * barWidth;

    CreateTimelineBox(
        timelineContent,
        "小節 " + (bar + 1),
        new Rect(x, 0, barWidth, 24),
        new Color(0.22f, 0.22f, 0.22f),
        16);

    CreateTimelineBox(
        timelineContent,
        "",
        new Rect(x, 24, barWidth, 24),
        new Color(0.16f, 0.16f, 0.16f),
        14);

    int beatCount = Mathf.CeilToInt(barLength);

    for (int q = 0; q < beatCount; q++)
    {
        float gx = x + q / barLength * barWidth;

        CreateTimelineBox(
            timelineContent,
            (q + 1).ToString(),
            new Rect(gx, 24, 34, 24),
            Color.clear,
            14);
    }
}

// 2. 弦ごとの背景
for (int s = 1; s <= 6; s++)
{
    float y = HeaderHeight + (s - 1) * RowHeight;

    CreateTimelineBox(
        timelineContent,
        s + "弦",
        new Rect(0, y, LabelWidth, RowHeight),
        new Color(0.2f, 0.2f, 0.2f),
        16);

    CreateTimelineBox(
        timelineContent,
        "",
        new Rect(
            LabelWidth,
            y,
            bars * barWidth,
            RowHeight),
        new Color(0.1f, 0.11f, 0.13f),
        14);
}

小節の配置では、barの値に応じて横方向の位置を計算しています。小節番号と拍番号を表示し、各小節の範囲を区別できるようにしています。

弦ごとの背景は1~6弦の6行を生成し、左側に弦番号、右側にノーツを配置する領域を設けています。

なお、各背景要素はCreateTimelineBox()という共通メソッドで生成しています。このメソッドでは、指定した位置とサイズにImageを配置し、ラベルが指定されている場合はその上にテキストを表示します。

次に、ノーツの配置位置を確認しやすくするため、拍に対応した補助グリッドを生成します。

補助グリッドは、エディター上部に設けたトグルの設定に応じて表示を切り替えます。

showQuarterGrid:4分音符単位の線
showEighthGrid:8分音符単位の線
showSixteenthGrid:16分音符単位の線

それぞれの拍の位置から小節内の横幅を計算し、縦線として描画しています。また、16分グリッドと8分グリッドが同じ位置に重複しないよう、8分位置に該当する線は条件によって省略しています。

小節境界線は補助グリッドとは別に生成し、各小節の区切りを明確にしています。

こうして背景とグリッドを生成した後、song.notesに保存されているノーツを順番に取り出して配置します。

// 5. ノーツ
foreach (NoteData note in song.notes)
{
    if (note == null ||
        note.stringNumber < 1 ||
        note.stringNumber > 6 ||
        note.beatPosition < 0)
        continue;

    int bar = Mathf.FloorToInt(note.beatPosition / barLength);

    if (bar >= bars)
        continue;

    float x =
        LabelWidth + note.beatPosition / barLength * barWidth;

    float width = Mathf.Max(
        12f,
        note.durationBeats / barLength * barWidth - 3f);

    float y =
        HeaderHeight + (note.stringNumber - 1) * RowHeight + 4f;

    width = Mathf.Min(width, contentWidth - x - 2f);

    Color color = HasOverlap(note)
        ? new Color(1f, 0.12f, 0.16f)
        : note == selectedNote
            ? new Color(1f, 0.82f, 0.05f)
            : note.fretNumber == -1
                ? new Color(1f, 0.38f, 0.52f)
                : note.fretNumber == 0
                    ? new Color(0.12f, 0.78f, 1f)
                    : new Color(0.25f, 0.92f, 0.28f);

    string label = note.fretNumber == -1
        ? "×"
        : note.fretNumber.ToString();

    CreateNoteBlock(
        note,
        new Rect(x + 1, y, width, RowHeight - 8),
        color,
        label);
}

ノーツの位置と大きさは、NoteDataに保存された値から計算しています
X座標:beatPositionを基準に、曲の先頭からの位置を横方向へ変換
Y座標:stringNumberに応じて、1~6弦の行へ配置
横幅:durationBeatsを基準に、ノーツの長さを表現

条件 表示色
他のノーツとの重複がある 赤
選択中 黄
ミュート ピンク
開放弦 水
押弦 緑

ノーツ本体の生成にはCreateNoteBlock()を使用しています。このメソッドでは、指定した位置とサイズにボタン付きのオブジェクトを生成し、クリックによる選択や削除、ドラッグによる移動を受け付けるようにしています。

最後に編集を助けるための補助機能を追加
CreateHoverOverlay()では、現在ポインターがある弦の行を薄く強調し、ノーツの配置位置を示す縦線を表示します。これにより、ノーツを追加する際に、対象となる弦と拍の位置を確認しやすくしています。

 private void CreateHoverOverlay(
     float barLength,
     int bars,
     float contentHeight)
 {
     if (hoveredBeat < 0 ||
         hoveredRow < 1 ||
         hoveredRow > 6)
         return;

     float x = LabelWidth + hoveredBeat / barLength * barWidth;
     float y = HeaderHeight + (hoveredRow - 1) * RowHeight;

     GameObject rowGo = new GameObject(
         "HoverRow",
         typeof(RectTransform),
         typeof(Image));

     rowGo.transform.SetParent(timelineContent, false);

     hoverRowRect = rowGo.GetComponent<RectTransform>();
     SetRect(
         hoverRowRect,
         new Rect(
             LabelWidth,
             y,
             bars * barWidth,
             RowHeight));

     Image rowImage = rowGo.GetComponent<Image>();
     rowImage.color = new Color(1f, 0.82f, 0.08f, 0.10f);
     rowImage.raycastTarget = false;

     GameObject lineGo = new GameObject(
         "HoverGridLine",
         typeof(RectTransform),
         typeof(Image));

     lineGo.transform.SetParent(timelineContent, false);

     hoverLineRect = lineGo.GetComponent<RectTransform>();
     SetRect(
         hoverLineRect,
         new Rect(
             x,
             0,
             2f,
             contentHeight));

     Image lineImage = lineGo.GetComponent<Image>();
     lineImage.color = new Color(1f, 0.78f, 0.05f, 1f);
     lineImage.raycastTarget = false;

     if (hoverRowRect != null)
         hoverRowRect.SetAsLastSibling();

     if (hoverLineRect != null)
         hoverLineRect.SetAsLastSibling();

     UpdateHoverOverlayPosition();
 }

また、タイムラインの横幅が表示領域を超える場合は、スクロールバーを使って横方向へ移動できるようにしています。

private void UpdateTimelineScrollbars()
{
    if (timelineContent == null ||
        timelineViewport == null)
    {
        return;
    }

    Canvas.ForceUpdateCanvases();

    float contentWidth = timelineContent.rect.width;
    float contentHeight = timelineContent.rect.height;

    float viewportWidth = timelineViewport.rect.width;
    float viewportHeight = timelineViewport.rect.height;

    float maxX = Mathf.Max(
        0f,
        contentWidth - viewportWidth);

    float maxY = Mathf.Max(
        0f,
        contentHeight - viewportHeight);

    // Contentの位置をスクロール可能範囲内に制限
    Vector2 contentPosition =
        timelineContent.anchoredPosition;

    contentPosition.x =
        Mathf.Clamp(contentPosition.x, -maxX, 0f);

    contentPosition.y =
        Mathf.Clamp(contentPosition.y, 0f, maxY);

    timelineContent.anchoredPosition = contentPosition;

    // 横スクロールバー
    if (horizontalScrollbar != null)
    {
        horizontalScrollbar.size =
            contentWidth > 0f
                ? Mathf.Clamp01(viewportWidth / contentWidth)
                : 1f;

        horizontalScrollbar.SetValueWithoutNotify(
            maxX > 0f
                ? Mathf.Clamp01(
                    -contentPosition.x / maxX)
                : 0f);

        horizontalScrollbar.interactable =
            maxX > 0f;
    }


    UpdateHoverOverlayPosition();
}

長くなりましたが、ここまででノーツを編集するための土台ができたというところです。残りも説明していきます。

ノーツの追加・編集

タイムライン上にノーツを配置する機能に加え、配置したノーツの選択、移動、複製、削除を実装しています。

ノーツの追加では、Meta Quest 3のコントローラーを使用してタイムライン上の追加位置を指定します。

コントローラーのポインターをタイムライン上に合わせると、その位置から小節・拍・弦番号を取得し、追加対象をUI上に表示します。その状態でタイムラインをクリックすると、選択中の弦の状態と音符の長さを使用してノーツが追加されます。

タイムラインへのポインター入力は、TimelinePointerHandlerで受け取っています。

public class TimelinePointerHandler :
    MonoBehaviour,
    IPointerMoveHandler,
    IPointerExitHandler,
    IPointerClickHandler
{
    public SongEditorController controller;
    public RectTransform timelineContent;
    public RectTransform viewport;

    public void OnPointerMove(PointerEventData eventData)
    {
        if (controller == null) return;

        controller.SetTimelinePointerFromScreen(
            eventData.position,
            eventData.enterEventCamera);
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (controller == null ||
            eventData.button != PointerEventData.InputButton.Left)
        {
            return;
        }

        UpdatePointerPosition(eventData);
        controller.AddNoteFromTimeline();
    }
}

OnPointerMove()では、ポインターの移動に合わせて位置情報を更新します。クリック時にはOnPointerClick()が呼び出され、左クリックの場合に現在のポインター位置を再取得してから、AddNoteFromTimeline()を実行します。

このクリックイベントはUnityのUIイベントとして処理されるため、Quest上ではコントローラーのRayによるUI操作を通じて実行されます。

取得したポインター位置は、SongEditorController側で拍位置と弦番号に変換します。

public void SetTimelinePointer(Vector2 local)
{
    if (!TryGetSettings(
            out float barLength,
            out int bars,
            out _))
        return;

    float contentX = local.x;
    float contentY = -local.y;

    if (contentX < LabelWidth ||
        contentY < HeaderHeight ||
        contentY >= HeaderHeight + 6 * RowHeight ||
        contentX >= LabelWidth + bars * barWidth)
    {
        hoveredBeat = -1f;
        hoveredRow = -1;

        UpdateTargetDisplay();
        UpdateHoverOverlayPosition();
        return;
    }

    float rawBeat =
        (contentX - LabelWidth) / barWidth * barLength;

    int row = Mathf.Clamp(
        Mathf.FloorToInt(
            (contentY - HeaderHeight) / RowHeight) + 1,
        1,
        6);

    hoveredBeat = SnapBeat(rawBeat, row);
    hoveredRow = row;

    UpdateTargetDisplay();
    UpdateHoverOverlayPosition();
}

タイムライン上の座標から拍位置と弦番号を計算し、SnapBeat()によってグリッドに合わせます。タイムラインの範囲外をポイントした場合は、追加対象を解除します。

追加処理はAddNote()で行います。

private void AddNote()
{
    if (!TryGetSettings(
            out float barLength,
            out int bars,
            out _))
    {
        status = "設定値を確認してください。";
        RefreshUI();
        return;
    }

    if (hoveredBeat < 0 ||
        hoveredRow < 1 ||
        hoveredRow > 6)
    {
        status = "タイムライン上の追加位置を選択してください。";
        RefreshUI();
        return;
    }

    float beat = SnapBeat(hoveredBeat, hoveredRow);

    beat = Mathf.Clamp(
        beat,
        0f,
        bars * barLength - 0.001f);

    if (FindNote(hoveredRow, beat) != null)
    {
        status = "同じ弦・同じ開始位置にノーツがあります。";
        RefreshUI();
        return;
    }

    NoteData note = new NoteData
    {
        beatPosition = beat,
        stringNumber = hoveredRow,
        fretNumber = SelectedFret(),
        durationBeats = durations[durationSelection]
    };

    song.notes.Add(note);
    selectedNote = note;

    status =
        $"{hoveredRow}弦に{NoteData.GetStateLabel(note.fretNumber)}を追加しました。";

    CheckWarnings(note, barLength, bars);
    RefreshUI();
}

追加位置に既存のノーツがある場合は追加を中止します。問題がなければ、取得した拍位置と弦番号に加えて、UIで選択されている弦の状態と音符の長さを設定したNoteDataを生成し、楽曲データに追加します。

弦の状態はSelectedFret()で数値に変換します。

private int SelectedFret()
{
    if (stateSelection == 0)
        return -1;

    if (stateSelection == 1)
        return 0;

    return stateSelection - 1;
}

楽曲データの保存・読み込み

SongEditorで作成・編集した楽曲データは、JSON形式のファイルとして保存するようにしています。

保存するデータには、曲名やBPM、拍子、小節数に加えて、各ノーツの弦番号、フレット番号、開始位置、音符の長さなどが含まれます。これらは前述したSongDataとNoteDataクラスで管理しています。
これらをちょろっと出ていた

private string SavePath =>
    Path.Combine(Application.persistentDataPath, "song.json");

こちらのパスに保存していきます。
Application.persistentDataPathは、アプリケーションがデータを保存するためのパスを取得するUnityのプロパティです。

このパスにsong.jsonを結合することで、楽曲データの保存先を指定しています。保存先を端末ごとの固定パスに直接指定する必要がなく、Meta Quest 3上でもアプリケーション用の保存領域を利用できます。

保存処理はSaveSong()で行います。保存前にValidateSong()を呼び出し、入力された設定値やノーツの内容に問題がないか確認しています。

private void SaveSong()
{
    if (!ValidateSong())
    {
        RefreshUI();
        return;
    }

    try
    {
        File.WriteAllText(
            SavePath,
            JsonUtility.ToJson(song, true));

        status = "譜面を保存しました。";

        if (guideController != null)
            guideController.ResetPracticeState();

        if (laneGenerator != null)
            laneGenerator.ReloadMeasureLanes();
        else
        {
            Debug.LogError(
                "SongEditorController:Lane Generatorが未設定です。",
                this);
        }
    }
    catch (Exception e)
    {
        status = "保存失敗:" + e.Message;
        Debug.LogException(e, this);
    }

    RefreshUI();
}

ValidateSong()で問題が見つかった場合は、保存処理を行わずに終了します。問題がなければ、JsonUtility.ToJson(song, true)でSongDataの内容をJSON文字列に変換し、File.WriteAllText()で保存先に書き込みます。

第2引数のtrueは、JSONを改行やインデントを含む読みやすい形式で出力するための指定です。

保存に成功した場合は、練習状態をリセットし、laneGenerator.ReloadMeasureLanes()で譜面データをもとにレーンを再読み込みします。これにより、保存した楽曲データを演奏ガイド側にも反映します。

______________________________________

補足として
ValidateSong()では、まず入力欄から曲名、BPM、拍子、小節数を取得し、TryGetSettings()で設定値が有効か確認します。

それらに続いて、各ノーツについて次の項目を検証しています。

  • 開始位置が0以上であること
  • 弦番号が1~6の範囲であること
  • フレット番号が-1~24の範囲であること(-1はミュート、0は開放弦)
  • 音符の長さが0より大きいこと
  • 開始位置や音符の長さが NaN や無限大ではないこと
  • 同じ弦・同じ開始位置にノーツが重複していないこと

さらに、同じ弦のノーツ同士で演奏時間が重なっていないかも確認します。重なりがある場合は保存を中止し、該当するノーツの位置をステータス表示に知らせます。

検証を通過した場合は、入力欄の値をsongに反映します。このとき、曲の長さを秒単位で表すdurationSecondsも計算しています。

_______________________________________

さて、保存出来たら最後は読み込みです。
読み込み処理はTryLoadSong()で行います。保存先にファイルが存在するか確認した後、JSON形式の文字列を SongDataに変換します。

private bool TryLoadSong()
{
    try
    {
        if (!File.Exists(SavePath))
        {
            status = "保存ファイルがありません。";
            return false;
        }

        SongData loaded =
            JsonUtility.FromJson<SongData>(
                File.ReadAllText(SavePath));

        if (loaded == null)
        {
            status = "譜面を読み込めませんでした。";
            return false;
        }

        if (loaded.notes == null)
            loaded.notes = new List<NoteData>();

        song = loaded;
        status = "譜面を読み込みました。";

        return true;
    }
    catch (Exception e)
    {
        status = "読み込み失敗:" + e.Message;
        Debug.LogException(e, this);

        return false;
    }
}

まずFile.ReadAllText()で保存ファイルの内容を読み取り、JsonUtility.FromJson<SongData>()でSongDataオブジェクトに変換しています。

読み込んだデータが正常に取得できた場合は、現在のsongに代入します。また、ノーツのリストがnullだった場合には、新しいリストを作成しています。

もしも読み込みに失敗した場合はfalseを返し、ステータス表示やログによってエラーを確認できるようにしています。

そして読み込んだデータをエディター画面に反映する処理は、LoadAndRefresh()で行います。

private void LoadAndRefresh()
{
    if (TryLoadSong())
    {
        InitializeFields();
        selectedNote = null;

        RefreshInputFields();
        status = "譜面を読み込みました。";
    }

    RefreshUI();
}

InitializeFields()で読み込んだ楽曲データを各入力欄に対応する変数へ反映し、RefreshInputFields()で画面上の入力欄も更新します。最後にRefreshUI()を呼び出すことで、タイムラインを含むエディター画面を再描画します。

なお、この読み込み処理はエディター上の読み込み操作だけでなく、起動時にも利用しています。Awake()で保存ファイルの有無を確認し、ファイルが存在して正常に読み込めた場合は、その楽曲データを初期状態として使用します。

2.3 ガイドを表示する

ここまでで仮想のギターネックと楽曲データを作りました。
あとは楽曲データからガイドを作成して練習できるように再生するだけです。
プロトタイプの完成も間近。張り切っていきましょう。

ガイドに必要なのは
・抑える位置を表示する『ノーツ』
・ノーツを配置して流れる『レーン』

これらを作っていきます。
先に土台から作りたいのでレーンを作成して、次にノーツの順番です。

スクリーンショット 2026-11-13 181710.png

KeyLaneの生成と複製

楽曲データで作成した演奏ガイドを小節ごとに管理するため、1小節につき1本のKeyLaneを生成する構成としました。仕組みとしては親のKeyLaneオブジェクトを作り、その子にレーンの本体のCubeや拍子の線(拍線)を作って動かしています。
image.png

まず、

MultiMeasureKeyLaneGenerator.cs

を作成し、KeyLaneの生成と管理を行います。
その他にもいくつかのスクリプトが走っているので先に紹介します。

________合図出す人________
GuideController.cs 演奏開始・停止・リセットの指示担当


_________レーンの管理者______________
MultiMeasureKeyLaneGenerator.cs レーンの生成・削除の管理担当

________複製されたレーンそれぞれに全員いる_____________
KeyLaneFretRangeController.cs レーンの横幅計算担当
BeatLineGenerator.cs レーンの長さの設定、拍子の線の生成担当
KeyLaneController.cs レーンの速度計算、移動・停止・リセット担当
KeyNotesSpowner.cs ノーツ生成担当

________描画の補助担当_______________
LaneClipController.cs レーンのクリッピング担当
LaneGeometryClipController.cs ノーツのクリッピング担当

これらが計算や処理を渡しあっています。

生成前のフレット範囲の計算

生成はMultiMeasureKeyLaneGenerator.csが担っていますが、
彼が生成するレーンに必要な情報を用意する必要があります。

まずKeyLaneを生成する前の、楽曲データを参照して各小節で使用するフレット範囲、つまりレーンの横幅をrangeController.CalculateFretRanges()で計算します。(後述)

計算した結果はKeyLaneFretRangeController.csで管理します。
このスクリプトでは小節ごとの使用フレット範囲を保持し、指定された小節番号に対応する範囲をKeyLaneへ反映します。

これにより、楽曲全体で使用するフレット範囲を一律に適用するのではなく、各小節の演奏内容に応じた表示範囲へ変更できます。

ひな形への設定とレーン間隔の取得

次に、複製元となるKeyLaneのひな形に設定を反映します。

まず、楽曲データから拍子を取得し、ひな形のBeatLineGeneratorに設定します。
その後、1小節目のフレット範囲をひな形に適用します。

MultiMeasureKeyLaneGenerator.cs

// 楽曲データの拍子を設定
templateBeat.beatsPerMeasure = songEditor.persistentData.beatsPerMeasure;
templateBeat.beatUnit = songEditor.persistentData.beatUnit;

// 1小節目のフレット範囲を適用
templateFretRange.measureNumber = 1;
templateFretRange.ApplyFretRange();

// フレット範囲の参照を設定
templateBeat.fretRangeController = templateFretRange;

// 拍線を再生成
if (generateBeatLines)
{
    templateBeat.GenerateBeatLines();
}

フレット範囲を適用すると、KeyLaneの表示幅や位置が更新されます。そのため、レーン同士の配置間隔は、設定変更前の値ではなく、更新後のひな形の表示サイズをもとに取得します。

取得したレーンの長さをlaneLengthとし、そこからレーン間隔laneSpacingを求めます。この値を使って、後続のKeyLaneを配置します。

KeyLaneの複製と小節ごとの設定

ひな形の設定が完了したら、元のひな形を非表示にし、複製したKeyLaneを生成します。

複製処理はCreateLane()で行います。複製したオブジェクトには、ひな形の位置・回転・スケールを引き継ぎ、引数で指定されたZ方向のオフセットを加えます。

複製したKeyLaneには、それぞれ異なる小節番号を設定します。また、複製元と同じ設定をそのまま使用するのではなく、各小節に対応する設定を改めて適用しています。

まず、複製したオブジェクト自身が持つBeatLineGeneratorを取得し、KeyLaneControllerに参照を設定します。これにより、各レーンが自身の拍線生成処理を管理できるようになっています。

続いてKeyLaneFretRangeControllerに小節番号や参照先を設定し、対象小節のフレット範囲を適用します。

KeyLaneFretRangeController.cs

KeyLaneFretRangeController fretRangeController =
    laneObject.GetComponent<KeyLaneFretRangeController>();

if (fretRangeController != null)
{
    fretRangeController.measureNumber = measureNumber;
    fretRangeController.fretCalculator = fretCalculator;
    fretRangeController.rangeController = rangeController;

    fretRangeController.ApplyFretRange();
}

この処理によって、複製されたKeyLaneにも小節ごとのフレット範囲が反映されます。

その後、拍線を生成する場合は、複製先のBeatLineGeneratorに対して生成済みメッシュの参照をリセットし、拍子やフレット範囲の参照を設定してから拍線を再生成します。

さらにKeyLaneControllerに移動設定を行いKeyNoteSpawnerに楽曲データや小節番号を渡して、その小節に対応するノーツを生成します。

このように、CreateLane()では、KeyLaneの複製だけでなく、複製後のレーンに必要な設定やノーツの生成まで行っています。

楽曲データに応じた複数レーンの生成

初期生成するレーンの本数はinitialLaneCountで指定します。GenerateMeasureLanes()では、この値をもとにループ処理を行い、CreateLane()を呼び出してKeyLaneを生成します。

MultiMeasureKeyLaneGenerator.cs

int count = Mathf.Max(1, initialLaneCount);

for (int i = 0; i < count; i++)
{
    // 最初のレーンを半分のレーン間隔だけずらす
    float firstLaneOffset = laneSpacing * 0.5f;

    KeyLaneController lane = CreateLane(
        basePosition,
        baseRotation,
        baseScale,
        firstLaneOffset + laneSpacing * i,
        nextMeasureNumber++);

    if (lane != null)
        activeLanes.Add(lane);
}

各レーンには、laneSpacingをもとにしたZ方向のオフセットを設定します。最初のレーンは半分のレーン間隔だけずらし、2本目以降はレーン間隔分ずつ配置位置を変更しています。

生成に成功したKeyLaneはactiveLanesに追加します。このリストで生成済みのレーンを管理することで、後続の移動処理やレーンの更新処理で対象を取得できるようにしています。

以上の処理により、楽曲データに応じた小節ごとのKeyLaneを複数生成し、それぞれに対応するフレット範囲や演奏データを設定しています。

拍線・小節線の生成

KeyLaneの生成と複製の間で、各レーンに拍線と小節境界線を配置します。

これらの線は BeatLineGenerator.cs で生成しており、楽曲データから取得した拍子と、KeyLaneの表示サイズをもとに位置や幅を決定します。

拍線の生成設定

まず、拍線の生成に必要な設定として、KeyLaneの長さや拍子、線の高さ・太さ、描画用マテリアルを用意しています。

BeatLineGenerator.cs

[Header("レーン設定")]
public Transform laneCube;
public KeyLaneFretRangeController fretRangeController;

[Min(0.01f)]
public float laneLengthZ = 0.6f;

[Header("拍子設定")]
[Min(1)] public int beatsPerMeasure = 4;
[Min(1)] public int beatUnit = 4;

[Header("拍線の表示設定")]
[Min(0.001f)] public float lineHeight = 0.005f;
[Min(0.001f)] public float lineThickness = 0.004f;

[Header("描画設定")]
public Material beatLineMaterial;
public Material measureLineMaterial;

laneLengthZ はKeyLaneのZ方向の長さ、beatsPerMeasure は1小節あたりの拍数を表します。また、lineHeight と lineThickness で線の高さとZ方向の太さを調整できます。

拍線と小節境界線にはそれぞれ別のマテリアルを設定し、通常の拍線と小節の区切りを見分けられるようにしています。

レーンサイズと線の配置間隔の計算

線の生成処理は GenerateBeatLines() で行います。

最初に、レーンのZ方向の長さを laneLengthZ に合わせます。その後、KeyLaneFretRangeController からフレット範囲適用後のレーン幅を取得します。

BeatLineGenerator.cs

// レーンのZ方向の長さを設定
Vector3 cubeScale = laneCube.localScale;
cubeScale.z = laneLengthZ;
laneCube.localScale = cubeScale;

// フレット範囲計算後のレーン幅を取得
float actualLineWidth =
    fretRangeController.laneVisual.localScale.x;

int totalBeats = beatsPerMeasure;
float spacing = laneLengthZ / totalBeats;

ここでは、1小節の拍数を totalBeats とし、レーンの長さを拍数で割ることで、拍線同士の間隔 spacing を算出しています。

また、線の幅には固定値を使用せず、laneVisual のX方向のスケールを参照しています。これにより、フレット範囲に応じてKeyLaneの幅が変化した場合も、拍線と小節境界線の幅をレーンに合わせられます。

拍線と小節境界線の配置

配置間隔を求めた後は、各線の中心位置を計算します。

BeatLineGenerator.cs

float startZ =
    laneCube.localPosition.z + laneLengthZ / 2f;

float lineY =
    laneCube.localPosition.y +
    cubeScale.y / 2f +
    lineHeight / 2f;

startZ はレーンの奥側を基準としたZ座標、lineY はレーン上面に線を配置するためのY座標です。

続いて、1小節の拍数に合わせてループ処理を行い、各線のZ座標を求めます。

BeatLineGenerator.cs

for (int i = 0; i <= totalBeats; i++)
{
    float lineZ =
        startZ - totalBeats * spacing + i * spacing;

    bool isMeasureLine =
        i == 0 || i == totalBeats;

    float thickness = isMeasureLine
        ? lineThickness * 2f
        : lineThickness;

    Vector3 center = new Vector3(0f, lineY, lineZ);

    Vector3 size = new Vector3(
        actualLineWidth,
        lineHeight,
        thickness);

    // 線の種類に応じてメッシュへ追加
}

ループ回数は totalBeats + 1 回となっており、各拍の境界に線を配置します。このうち、最初と最後の線を小節境界線、それ以外を通常の拍線として扱います。

小節境界線は通常の拍線よりも太くなるよう、Z方向の太さを2倍に設定しています。

例えば4/4拍子の場合、通常の拍線は3本、小節境界線は開始位置と終了位置の2本となり、合計5本の線が配置されます。

メッシュの生成と描画

線の位置とサイズが決まったら、それぞれの頂点と三角形の情報を配列に格納します。

通常の拍線と小節境界線は別々の配列で管理し、CreateMesh() に渡してメッシュ化します。

BeatLineGenerator.cs

Mesh beatMesh = CreateMesh(
    "GeneratedBeatLines",
    beatVertices,
    beatTriangles);

Mesh measureMesh = CreateMesh(
    "GeneratedMeasureLines",
    measureVertices,
    measureTriangles);

メッシュの頂点や三角形は、AddBox() で生成しています。1本の線を直方体として表現し、指定した中心位置とサイズから8個の頂点を計算します。その頂点を三角形で結ぶことで、立体的な線を構成しています。

生成した通常の拍線は、BeatLineGenerator 自身の MeshFilter と MeshRenderer に設定します。一方、小節境界線は GeneratedMeasureLines という子オブジェクトを新たに作成し、専用のメッシュとマテリアルを割り当てます。

BeatLineGenerator.cs

// 通常拍線
MeshFilter beatFilter = GetComponent<MeshFilter>();
MeshRenderer beatRenderer = GetComponent<MeshRenderer>();

beatFilter.sharedMesh = generatedBeatMesh;
beatRenderer.sharedMaterial = beatLineMaterial;

// 小節境界線オブジェクトを新規生成
GameObject measureObject =
    new GameObject("GeneratedMeasureLines");

Transform measureChild = measureObject.transform;
measureChild.SetParent(transform, false);

// 専用のMeshFilterとMeshRendererを追加
MeshFilter measureFilter =
    measureChild.gameObject.AddComponent<MeshFilter>();

MeshRenderer measureRenderer =
    measureChild.gameObject.AddComponent<MeshRenderer>();

measureFilter.sharedMesh = generatedMeasureMesh;
measureRenderer.sharedMaterial = measureLineMaterial;

このように、通常の拍線と小節境界線を別々のメッシュ・マテリアルで管理することで、線の太さや見た目を個別に調整できる構成にしています。

再生成時の古いメッシュの破棄

拍線を再生成する際には、以前に生成したメッシュや小節境界線のオブジェクトが残らないよう、古いデータを削除してから新しいものに置き換えます。

BeatLineGenerator.cs

// 以前の生成Meshを破棄
DestroyGeneratedMesh(generatedBeatMesh);
DestroyGeneratedMesh(generatedMeasureMesh);

// 既存の小節境界線オブジェクトを削除
Transform oldMeasureLines =
    transform.Find("GeneratedMeasureLines");

if (oldMeasureLines != null)
{
    DestroyGeneratedObject(oldMeasureLines.gameObject);
}

また、旧方式で生成された MeasureLine_ から始まる名前のオブジェクトも削除対象に含めています。

メッシュの破棄には DestroyGeneratedMesh()、オブジェクトの破棄には DestroyGeneratedObject() を使用し、実行中かエディター上かによって Destroy() と DestroyImmediate() を使い分けています。

以上の処理により、KeyLaneの長さや幅、拍数に応じた拍線と小節境界線を生成できます。

使用フレット範囲の計算

これまでのKeyLaneの生成や拍線・小節線の生成でも、各小節で使用するフレット範囲を参照していました。

ここでは、そのフレット範囲をどのように求め、KeyLaneに反映しているかを説明します。

フレット範囲の計算結果は MeasureFretRangeController.cs で管理し、KeyLaneFretRangeController.cs で実際のKeyLaneの位置と幅に反映しています。

フレット範囲の取得

まず、KeyLaneFretRangeController.cs では、フレット位置の計算に使用する FretPositionCalculator と、各小節のフレット範囲を管理する MeasureFretRangeController を参照します。

KeyLaneFretRangeController.cs

[Header("参照")]
public FretPositionCalculator fretCalculator;
public MeasureFretRangeController rangeController;
public Transform laneVisual;

[Header("対象小節")]
[Min(1)]
public int measureNumber = 1;

measureNumber は対象となる小節番号です。これを使って、MeasureFretRangeController が保持している計算結果から該当する小節のフレット範囲を取得します。

KeyLaneFretRangeController.cs

MeasureFretRangeController.MeasureResult result =
    rangeController.results.Find(
        r => r.measureNumber == measureNumber);

if (result == null)
{
    Debug.LogError(
        $"小節 {measureNumber} のフレット範囲がありません。先に範囲を計算してください。",
        this);
    return;
}

results の中から、小節番号が一致する MeasureResult を検索しています。該当する結果が見つからない場合はエラーを出し、処理を終了します。

この結果には、対象小節の開始フレットや表示するフレット数などが含まれており、後続の処理でKeyLaneの範囲を決めるために使用します。

フレット位置の取得と座標変換

次に、取得したフレット範囲を実際のギター上の位置に対応させます。

フレット位置の計算には、FretPositionCalculator に設定した4つの基準点を使用します。

  • nutEdgeA:ナットの一方の端
  • nutEdgeB:ナットのもう一方の端
  • fret12EdgeA:12フレットの一方の端
  • fret12EdgeB:12フレットのもう一方の端

これらの基準点から各フレットの位置を求め、KeyLaneの親オブジェクトのローカル座標へ変換します。

KeyLaneFretRangeController.cs

Vector3 nutA = fretCalculator.transform.InverseTransformPoint(
    fretCalculator.nutEdgeA.position
);

Vector3 nutB = fretCalculator.transform.InverseTransformPoint(
    fretCalculator.nutEdgeB.position
);

Vector3 fret12A = fretCalculator.transform.InverseTransformPoint(
    fretCalculator.fret12EdgeA.position
);

Vector3 fret12B = fretCalculator.transform.InverseTransformPoint(
    fretCalculator.fret12EdgeB.position
);

まず、4つの基準点を FretPositionCalculator のローカル座標へ変換します。これにより、ギター全体の位置や向きに依存せず、基準点同士の相対的な位置関係からフレット位置を計算できます。

フレットの位置は、ナットから12フレットまでの距離を基準に、以下の式で補間します。

KeyLaneFretRangeController.cs

float t = fretNumber == 0
    ? 0f
    : 2f * (1f - Mathf.Pow(2f, -fretNumber / 12f));

Vector3 pointA = Vector3.Lerp(nutA, fret12A, t);
Vector3 pointB = Vector3.Lerp(nutB, fret12B, t);

Vector3 fretCenterLocal = (pointA + pointB) * 0.5f;

t はナットから対象フレットまでの補間率です。フレット番号に応じて値を変化させることで、ナットから12フレットまでの距離を基準とした位置を求めています。

また、ギターの両端それぞれでフレット位置を補間した後、その中間点を求めています。これにより、フレットの中央位置を取得できます。

最後に、求めた位置をワールド座標へ変換した後、KeyLaneの親オブジェクトのローカル座標へ変換します。

KeyLaneFretRangeController.cs

Vector3 fretCenterWorld =
    fretCalculator.transform.TransformPoint(fretCenterLocal);

Vector3 fretCenterInLaneParent =
    laneParent.InverseTransformPoint(fretCenterWorld);

return fretCenterInLaneParent.x;

この処理によって、実際のギター上のフレット位置をKeyLaneの配置に使用できるX座標として取得できます。

使用範囲の幅と中心位置の計算

フレット位置を取得した後は、対象小節で使用する範囲の開始位置と終了位置を求めます。

KeyLaneFretRangeController.cs

// 開始境界と終了境界のフレット位置を取得する。
int startBoundary = Mathf.Max(0, result.adjustedBaseFret - 1);
int endBoundary = startBoundary + result.fretWidth;

float startX = GetFretXInLaneParent(startBoundary, laneParent);
float endX = GetFretXInLaneParent(endBoundary, laneParent);

float width = Mathf.Abs(endX - startX);
float centerX = (startX + endX) * 0.5f;

adjustedBaseFret から1を引いた値を開始境界とし、そこに fretWidth を加えて終了境界を求めています。開始境界が0未満にならないよう、Mathf.Max() で調整しています。

求めた2つの境界位置から、KeyLaneの幅と中心位置を計算します。

  • width:開始位置と終了位置の距離
  • centerX:開始位置と終了位置の中間座標

このように、対象小節で使用するフレット範囲を実際のギター上の位置に対応させることで、KeyLaneの表示範囲を決定しています。

KeyLaneへの範囲の反映

最後に、計算した幅と中心位置をKeyLaneへ反映します。

KeyLaneFretRangeController.cs

// レーンの中心を対象フレット範囲の中心に合わせる。
Vector3 lanePosition = transform.localPosition;
lanePosition.x = centerX;
transform.localPosition = lanePosition;

// レーン表示の幅だけを変更する。
Vector3 visualScale = laneVisual.localScale;
visualScale.x = width;
laneVisual.localScale = visualScale;

// 表示オブジェクトの中心をKeyLaneの中心に合わせる。
Vector3 visualPosition = laneVisual.localPosition;
visualPosition.x = 0f;
laneVisual.localPosition = visualPosition;

まず、KeyLane本体のX座標を対象範囲の中心位置に合わせます。続いて、laneVisual のX方向のスケールを変更し、計算したフレット範囲に応じて表示幅を調整しています。

また、表示オブジェクトのX座標を0に戻すことで、KeyLane本体を中心とした配置を維持しています。

以上の処理により、各小節で使用するフレット範囲に合わせてKeyLaneの位置と幅が変化し、実際のギター上のフレット位置に対応した演奏ガイドを表示できます。

ノーツの生成

ここまでで、寿司のシャリにあたるレーンと拍線を作ってきました。あとはネタとなるノーツを生成すれば、ようやく寿司が完成します。

ノーツの生成には KeyNoteSpawner.cs を使用します。

このスクリプトでは、楽曲データから対象小節のノーツを取得し、弦番号・フレット番号・拍位置・音の長さをもとに、KeyLane上へ配置していきます。

対象小節のノーツを抽出する

まず、SongEditorControllerから楽曲データを取得し、拍子設定をもとに1小節あたりの拍数を計算します。

KeyNoteSpawner.cs

SongData song = songEditor.song;

float quarterNotesPerMeasure =
    song.beatsPerMeasure * 4f / song.beatUnit;

float measureStartBeat =
    (measureNumber - 1) * quarterNotesPerMeasure;

float measureEndBeat =
    measureStartBeat + quarterNotesPerMeasure;

quarterNotesPerMeasure は、1小節を四分音符単位で表した長さです。これをもとに、生成対象となる小節の開始位置と終了位置を求めます。

次に、楽曲データに登録されているノーツを順番に確認し、生成対象のノーツを抽出します。

KeyNoteSpawner.cs

foreach (NoteData note in song.notes)
{
    if (note == null ||
        note.stringNumber < 1 ||
        note.stringNumber > 6 ||
        note.beatPosition < 0 ||
        note.durationBeats <= 0)
    {
        continue;
    }

    float noteStartBeat = note.beatPosition;
    float noteEndBeat =
        noteStartBeat + note.durationBeats;

    if (noteEndBeat <= measureStartBeat ||
        noteStartBeat >= currentMeasureEndBeat)
    {
        continue;
    }
}

弦番号が1~6の範囲外である場合や、拍位置・音の長さが不正な場合は生成対象から除外します。

また、ノーツの開始位置と終了位置を確認し、対象小節と重なっていないノーツも除外します。

ここで重要なのが、小節をまたぐノーツ(タイ)の扱いです。小節をまたぐ場合は、ノーツ全体を一度に生成するのではなく、対象小節内に収まる区間だけを取り出します。

KeyNoteSpawner.cs

float segmentStartBeat =
    Mathf.Max(noteStartBeat, measureStartBeat);

float segmentEndBeat =
    Mathf.Min(noteEndBeat, currentMeasureEndBeat);

float segmentDuration =
    segmentEndBeat - segmentStartBeat;

この処理によって、小節の境界でノーツを分割し、それぞれの小節レーンへ表示できるようにしています。

ノーツの配置位置と長さを計算する

対象となるノーツ区間を取得したら、次はKeyLane上の配置位置を計算します。

まず、1拍あたりのレーン内距離を求めます。

KeyNoteSpawner.cs

float spacing =
    laneLengthZ / song.beatsPerMeasure;

float localStartBeat =
    segmentStartBeat - measureStartBeat;

float segmentLength =
    segmentDuration * spacing;

この距離を使い、ノーツの開始位置と中心位置を計算します。

KeyNoteSpawner.cs

float laneStartZ =
    beatLineGenerator.laneCube.localPosition.z
    - laneLengthZ * 0.5f;

float startZ =
    laneStartZ + localStartBeat * spacing;

float centerZ =
    startZ + segmentLength * 0.5f;

レーンの-Z側を0拍目の位置とし、拍が進むにつれて+Z方向へノーツを配置します。ノーツの長さは、表示区間の拍数に1拍あたりの距離を掛けて求めています。

次に、フレット番号と弦番号からノーツの基準位置を取得します。

KeyNoteSpawner.cs

Vector3 noteBaseWorld =
    fretRangeController.GetNotePositionWorld(
        note.fretNumber,
        note.stringNumber);

Vector3 noteBaseLocal =
    transform.InverseTransformPoint(noteBaseWorld);

Vector3 noteLocalPosition = new Vector3(
    noteBaseLocal.x,
    noteBaseLocal.y,
    centerZ);

GetNotePositionWorld() は、フレット位置と弦ガイドの位置を組み合わせて、ノーツの配置位置を返すメソッドです。

フレット位置のX座標と、実際の弦ガイドのY・Z座標を使用することで、指定した弦とフレットに対応する位置を求めています。

KeyLaneFretRangeController.cs

Vector3 fretLocal =
    calculatorTransform.InverseTransformPoint(fretWorld);

Vector3 stringLocal =
    calculatorTransform.InverseTransformPoint(stringWorld);

Vector3 noteLocal = new Vector3(
    fretLocal.x,
    stringLocal.y,
    stringLocal.z
);

return calculatorTransform.TransformPoint(noteLocal);

このように、フレットと弦の位置をそれぞれ取得し、フレットのX座標と弦のY・Z座標を組み合わせています。

ノーツオブジェクトを生成する

配置位置と長さが決まったら、Cubeを使ってノーツを生成します。

KeyNoteSpawner.cs

GameObject noteObject =
    GameObject.CreatePrimitive(PrimitiveType.Cube);

noteObject.name =
    $"KeyNote_S{note.stringNumber}_F{note.fretNumber}" +
    $"_B{note.beatPosition}_M{measureNumber}";

noteObject.transform.SetParent(noteRoot, false);

noteObject.transform.localScale = new Vector3(
    noteWidth,
    noteHeight,
    segmentLength
);

ノーツの幅と高さはInspectorから調整でき、Z方向の長さには先ほど計算した表示区間の長さを設定します。

また、弦番号に応じたマテリアルを適用し、弦ごとにノーツを色分けできるようにしています。

KeyNoteSpawner.cs

if (noteRenderer != null && noteMaterialPalette != null)
{
    noteMaterialPalette.ApplyMaterial(
        noteRenderer,
        note.stringNumber
    );
}

生成したノーツには `KeyNoteSegment.cs` を追加し、元の楽曲データやタイに関する情報も保持させます。

KeyNoteSegment.cs

public string tieGroupId = "";

public float sourceBeatPosition;
public float sourceDurationBeats;

public int measureNumber;
public int stringNumber;
public int fretNumber;

public bool belongsToTiedNote;

これにより、分割されたノーツが元々どのノーツに属していたか、またタイで結ばれたノーツなのかを判別できるようにしています。

ただし、現状ではタイ情報を保持するところまでの実装です。小節ごとにノーツを分割して生成するため、見た目も小節境界で途切れてしまいます。タイで結ばれていることが一目で分かる表示にはなっておらず、視覚的な連続性については今後の改善点です。

ノーツの輪郭を生成する

最後に、生成したCubeの輪郭を表示します。

CreateNoteOutline() では、Cubeの8つの頂点を定義し、それらを結ぶ12本の辺を LineRenderer で描画しています。

通常の輪郭と接触部分の輪郭では、色と太さを別々に設定できるようにしました。これにより、ノーツの形状を見やすくしつつ、接触部分を強調しています。

なお、ノーツには物理衝突を使用しないため、生成時にCubeへ付属するColliderを削除しています。

以上の処理によって、楽曲データに登録されたノーツを、対象小節のKeyLane上へ配置できるようになりました。

レーンの移動・進行管理

ここまでで、レーン・拍線・ノーツの生成ができました。次は、これらを実際の演奏に合わせて動かしていきます。

レーンの移動には KeyLaneController.cs を使用します。また、練習全体の進行管理には GuideController.cs と MultiMeasureKeyLaneGenerator.cs を使用しています。

レーンの移動速度を計算する

まず、KeyLaneController.cs でレーンの移動を制御します。

このスクリプトでは、BPMとレーンの長さ、拍子から移動速度を計算しています。

KeyLaneController.cs

private void Update()
{
    if (!autoMove || bpm <= 0f || beatLineGenerator == null)
        return;

    // レーンの長さと拍子を取得
    float laneLength = beatLineGenerator.laneLengthZ;
    int beatsPerMeasure = beatLineGenerator.beatsPerMeasure;
    int beatUnit = beatLineGenerator.beatUnit;

    if (laneLength <= 0f || beatsPerMeasure <= 0 || beatUnit <= 0)
        return;

    // 1小節の4分音符換算数
    float quarterNotesPerMeasure =
        beatsPerMeasure * 4f / beatUnit;

    // 1小節の演奏時間(秒)
    float measureDuration =
        quarterNotesPerMeasure * 60f / bpm;

    // 1秒あたりの移動距離
    float speed = laneLength / measureDuration;

    transform.localPosition +=
        Vector3.back * speed * Time.deltaTime;
}

まず、laneLengthZ からレーンの長さを取得し、拍子を四分音符単位に換算します。

次に、BPMから1小節の演奏時間を求め、レーンの長さをその時間で割ることで、1秒あたりの移動距離を算出します。

最後に、Vector3.back を使用してレーンを-Z方向へ移動させています。

この仕組みにより、BPMが速くなるとレーンの移動速度も速くなり、遅くなると移動速度も遅くなります。

レーンの移動・停止・リセット

レーンの移動状態は autoMove で管理しています。

KeyLaneController.cs

public void StartMovement()
{
    // 参照や設定値の確認を省略

    autoMove = true;
}

public void StopMovement()
{
    autoMove = false;
}

public void ResetPosition()
{
    StopMovement();
    transform.localPosition = initialLocalPosition;
}

StartMovement() を呼び出すと移動が始まり、StopMovement() で停止します。

また、Awake() では起動時のローカル座標を initialLocalPosition に保存しています。ResetPosition() は移動を停止したうえで、この初期位置に戻す処理です。

このように、移動・停止・初期位置への復帰をそれぞれメソッドとして分けています。

練習開始とカウントインの管理

実際の練習開始は GuideController.cs から行います。

StartPractice() では譜面データやBPMなどの設定を確認し、再生対象となるノーツを拍位置順に並べます。その後、MultiMeasureKeyLaneGenerator にレーンの開始処理を依頼します。

GuideController.cs

public void StartPractice()
{
    if (IsBusy)
        return;

    // 譜面データなどの確認を省略

    playbackNotes.Clear();
    playbackNotes.AddRange(
        song.notes
            .Where(note => note != null)
            .OrderBy(note => note.beatPosition)
    );

    // 再生時間などを計算

    currentNoteIndex = -1;
    isPlaying = false;
    isCountingIn = true;

    ShowStatus("カウントイン");

    laneGenerator.StartPracticeLanes(
        BeginPracticeAfterCountIn);
}

レーンの開始処理を依頼すると、MultiMeasureKeyLaneGenerator 側でカウントインが始まります。

カウントインが終了すると、各レーンの移動を開始し、コールバックとして指定した BeginPracticeAfterCountIn() が呼び出されます。

GuideController.cs

private void BeginPracticeAfterCountIn()
{
    if (!isCountingIn)
        return;

    isCountingIn = false;
    currentNoteIndex = -1;
    practiceStartTime = Time.time;
    isPlaying = true;

    ShowStatus("練習開始");
}

これにより、レーンの移動開始と譜面上のノーツ情報を確認する処理を連動させています。

レーンの継続生成と削除

複数小節を連続して表示するため、MultiMeasureKeyLaneGenerator.cs ではレーンを複数生成し、通過したレーンを削除しながら次の小節を補充しています。

初期生成では、Inspectorで設定した initialLaneCount をもとにレーンを複製します。

MultiMeasureKeyLaneGenerator.cs

int count = Mathf.Max(1, initialLaneCount);

for (int i = 0; i < count; i++)
{
    float firstLaneOffset = laneSpacing * 0.5f;

    KeyLaneController lane = CreateLane(
        basePosition,
        baseRotation,
        baseScale,
        firstLaneOffset + laneSpacing * i,
        nextMeasureNumber++);

    if (lane != null)
        activeLanes.Add(lane);
}

レーンの間隔は、テンプレート配下のRendererから取得した実際の表示サイズをもとに決定しています。

生成したレーンは activeLanes と managedLanes で管理し、移動状態や通過判定に使用します。

次に、移動中のレーンがギター上の基準位置を通過したかを定期的に確認します。

MultiMeasureKeyLaneGenerator.cs

private IEnumerator CheckPassedLanes()
{
    var wait = new WaitForSecondsRealtime(
        Mathf.Max(0.01f, passCheckInterval));

    while (true)
    {
        CheckAndRemovePassedLanes();
        yield return wait;
    }
}

通過判定では、まず referencePoint の位置をレーンの親オブジェクトのローカル座標に変換します。

その後、各レーン配下のRendererから末端位置を取得し、基準位置を通過しているか確認します。

MultiMeasureKeyLaneGenerator.cs

if (TryGetTrailingEdgeZ(
        lane.transform,
        out float trailingEdgeZ) &&
    trailingEdgeZ <= referenceZ)
{
    passed.Add(lane);
}

通過したレーンは、管理リストから削除したうえでGameObject自体を破棄します。

MultiMeasureKeyLaneGenerator.cs

activeLanes.Remove(lane);
managedLanes.Remove(lane);

DestroyGeneratedObject(lane.gameObject);

さらに、譜面の最終小節でなければ、次の小節のレーンを生成します。

MultiMeasureKeyLaneGenerator.cs

float nextZ = GetNextSpawnZ();

var nextLane = CreateLane(
    new Vector3(basePosition.x, basePosition.y, nextZ),
    baseRotation,
    baseScale,
    0f,
    nextMeasureNumber++);

このように、通過したレーンを削除して次のレーンを補充することで、複数小節を途切れずに表示できるようにしています。

なお、最終小節を通過した場合は次のレーンを生成せず、3秒間の待機後に練習を停止します。

練習の停止と再読み込み

練習を一時停止する場合は、GuideController.cs の PausePractice() を使用します。

GuideController.cs

public void PausePractice()
{
    isCountingIn = false;
    isPlaying = false;

    if (laneGenerator != null)
        laneGenerator.StopPracticeLanes();

    ShowStatus("一時停止");
}

StopPracticeLanes() では、カウントインや通過判定などのコルーチンを停止し、移動中のレーンをすべて停止します。

一方、譜面を編集した後などに先頭からやり直す場合は、RestartPractice() から ReloadMeasureLanes() を呼び出します。

MultiMeasureKeyLaneGenerator.cs

public void ReloadMeasureLanes()
{
    StopPracticeLanes();

    foreach (var lane in activeLanes)
    {
        if (lane != null)
            DestroyGeneratedObject(lane.gameObject);
    }

    activeLanes.Clear();
    managedLanes.Clear();

    nextGenerationNumber = 1;
    nextMeasureNumber = 1;
    lanesInitialized = false;
    generationInProgress = false;

    // フレット範囲を再計算

    GenerateMeasureLanes();
}

生成済みレーンを削除し、管理情報や小節番号を初期化してから、再びレーンを生成します。

単に移動を止めるだけでなく、レーンそのものを作り直すことで、譜面の変更を反映できるようにしています。

シェーダーによる表示範囲の制御

レーンを移動させると、ギターのネックからはみ出した部分も表示されてしまいます。

そこで、LaneClipController.cs を使用し、StringsGuideの位置を境界としてシェーダーによるクリッピングを行います。

このスクリプトでは、VirtualNeckのローカルZ軸をもとに切り取り平面の法線を求め、StringsGuideの位置から平面の距離を計算します。

LaneClipController.cs

Vector3 normal =
    virtualNeck.worldToLocalMatrix.transpose
    .MultiplyVector(Vector3.forward)
    .normalized;

Vector3 boundaryPoint = stringsGuide.position;

float distance = -Vector3.Dot(normal, boundaryPoint);

求めた平面情報は、Shaderのグローバル変数 _LaneClipPlaneWS に渡します。

LaneClipController.cs

Shader.SetGlobalVector(
    ClipPlaneID,
    new Vector4(
        normal.x,
        normal.y,
        normal.z,
        distance
    )
);

これにより、対象シェーダー側で同じ平面情報を参照し、境界の外側を描画しないようにできます。

メッシュ・輪郭のクリッピング

シェーダーによる表示制御とは別に、拍線・小節線・ノーツの形状を実際に切り取る処理も用意しています。

これを担当するのが LaneGeometryClipController.cs です。

LaneClipController.cs と同様にVirtualNeckとStringsGuideから切り取り平面を求めますが、こちらはメッシュやLineRendererの頂点・線分を計算し、形状そのものを切り取ります。

対象となるのは、主に以下のオブジェクトです。

  • BeatLineGenerator が生成した拍線のメッシュ
  • GeneratedMeasureLines 配下の小節線メッシュ
  • GeneratedKeyNotes 配下のノーツ本体と輪郭

メッシュの場合は、三角形を構成する頂点が切り取り平面のどちら側にあるかを判定します。平面をまたぐ場合は交点を補間し、残す側のポリゴンを再構成します。

LaneGeometryClipController.cs

float da = Vector3.Dot(planeNormal, worldA) + planeDistance;
float db = Vector3.Dot(planeNormal, worldB) + planeDistance;

bool insideA = da >= 0f;
bool insideB = db >= 0f;

if (insideA != insideB)
{
    float t = da / (da - db);
    result.Add(a.Lerp(b, t));
}

この処理では、頂点の位置だけでなく、法線・UV・色・タンジェントも補間しています。

LineRendererについても同様に、線分の両端が平面のどちら側にあるかを判定し、必要に応じて交点までに短縮します。

なお、元のメッシュや線分の情報はキャッシュに保持し、毎フレームの切り取り計算に使用しています。

LaneClipController.cs と LaneGeometryClipController.cs はそれぞれ異なる役割を持ち、前者がシェーダー向けの切り取り平面を設定し、後者が拍線・小節線・ノーツの形状を切り取ります。

これにより、レーンが移動してもStringsGuideの位置を境界として、ギターの表示範囲に合わせたガイド表示を維持できるようにしています。

3. 当初予定していた要素

今回のプロトタイプでは、ギターの演奏をより分かりやすく、継続して練習できるようにするため、いくつかの追加機能を予定していました。

しかし、インターンの開発期間に限りがあったことから、これらの機能は完成まで至りませんでした。

PickingLane

ピッキングする位置を示すためのガイドです。

KeyLaneと同様に、実際のギターに重ねて表示し、どの位置でピッキングすればよいかを視覚的に把握できるようにすることを想定していました。

PickingNotes

ピッキングする位置や方向を示すノーツです。

PickingLaneと組み合わせることで、弦を弾くタイミングだけでなく、どの位置をどの方向に弾くのかも確認できるようにする予定でした。

音声による判定

マイクで演奏音を取得し、正確に演奏できたかを判定する機能です。

音の検出や音程・タイミングの判定を行い、ユーザーの演奏が譜面と合っているかを確認できるようにすることを目指していました。

スコア

練習の結果を数値化し、継続的な練習のモチベーションにつなげる機能です。

演奏の正確さなどを評価し、練習結果を分かりやすく提示することを想定していました。

タイ(Tie)への対応

楽譜上で同じ高さの音符をつなぎ、音を途切れさせずに伸ばすタイへの対応です。

現状では、タイの情報を保持するところまで実装しています。ただし、ノーツは小節ごとに分割して生成する仕組みのため、表示上も小節の境界で途切れてしまいます。

そのため、タイで結ばれたノーツ同士が連続していることを一目で把握できる表示には至っていません。

その他の最適化

上記の機能以外にも、開発中に見つかった不具合の修正や、UIの操作性・設定のしやすさを改善する作業を予定していました。

特に、ガイドの位置や大きさなどを調整する際の操作を簡単にし、実際のギターに合わせた設定を行いやすくすることを目指していました。

これらは今後の改善項目として残っています。

4. 考察

未実装の要素の中で、最も大きな課題となったのは、やはり音声入力です。

過去の製品を調べてみても、音声入力は課題になりやすい部分だと感じました。例えば、Rocksmith 2014 Edition - RemasteredやIMMERROCKは、その仕組み上、専用ケーブルやオーディオインターフェースなどの外部機器を必要とします。一方、Rocksmith+のスマートフォン版では、スマートフォン本体のマイクを利用することで、追加の機器を必要としない仕組みが実現されています。

今回開発に使用したMeta Quest 3向けのIMMERROCKについても、内蔵マイクを利用できないことが課題として挙げられています。

実際にQuest上でマイク入力のテストを行ったところ、マイクの使用許可を取得し、Unityから音声データを読み込むこと自体はできました。しかし、入力された音声を確認すると、ノイズキャンセリングの影響を受けているように感じました。

今回のテストでは、音声の大きさをRMS(実効値)で計算し、一定のしきい値を超えたときに音の立ち上がりを検出する仕組みを実装しました。

MicrophoneTester.cs

private const int SampleCount = 1024;
private const int SampleRate = 44100;

private readonly float[] samples = new float[SampleCount];

音声データは44,100Hzで取得し、1,024サンプルごとに音量を計算しています。

MicrophoneTester.cs

float sum = 0f;
peak = 0f;

foreach (float sample in samples)
{
    sum += sample * sample;
    peak = Mathf.Max(peak, Mathf.Abs(sample));
}

rms = Mathf.Sqrt(sum / SampleCount);

このようにRMSを計算することで、瞬間的な音量の変化だけでなく、一定区間の音量をもとに音の立ち上がりを検出できます。ただし、今回実装したのはあくまで音の発生を検出する段階であり、音程や演奏タイミングの正確さを判定するところまでは至っていません。

ここで重要になるのが、Quest側のノイズキャンセリングです。もしこれを無効化し、ギターの音をそのままマイクで取得できるようになれば、音声入力のために必要な準備を大幅に減らせる可能性があります。専用機器を用意せずに練習を始められることは、特に初心者にとって大きなメリットになると考えています。

解決策として、スマートフォンで録音した音声データを無線でQuestに送信する方法も検討しました。しかし、実装経験がないことに加え、通信による遅延が演奏判定に影響する可能性も考慮し、今回は採用を見送りました。

また、Meta Quest向けのゲームで、会話以外の音声、特に楽器のような音をマイクで取得して利用する事例をあまり見かけなかったこともあり、この部分はまだ調査の余地があると感じています。

今後機会があれば、自宅にあるMeta Quest 3でも改めて検証し、ノイズキャンセリングの仕組みや、ギターの音を取得する方法について調べてみたいと思います。

5.まとめ

本記事では、Meta Quest 3のパススルー機能を利用し、実物のアコースティックギターに演奏ガイドを重ねて表示するMRアプリを開発しました。ギターに合わせたガイドの調整から、譜面の入力・表示、ノーツの進行まで、練習を支援するための基本的な仕組みを実装しています。

限られた時間の中で初めてMeta Quest 3向けの開発に取り組みましたが、SDK特有の機能や仕組みを理解してからは、普段のUnity開発と共通する部分も多く、想像していたよりもスムーズに進められました。また、開発を通してギターや音楽理論について学ぶ機会もあり、楽譜と実際の演奏の関係など、知れば知るほど奥が深く、興味深い分野だと感じました。

今回の開発で得た知見を活かし、今後も演奏ガイドの精度や操作性を改善していきたいと考えています。

来源:Qiita · Unity 开发社区 · qiita.com