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

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

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

正文 · AI 翻译

前言

此次,我从2026年10月1日至11月13日
在Generosity株式会社进行了AR应用程序的开发。
在此介绍我的开发内容。

概要 (Abstract)

本次在约3周的开发期间内,我借助生成式AI的支持,挑战开发了用于吉他练习的AR应用程序。

開発概要

· 開発期間: 約3週間

· 开发目的:制作能够与AI一起练习吉他的AR应用程序

· 开发环境:Unity 6000.3.26f1

· 使用设备:Meta Quest 3

· 採用技術:

Meta XR SDK

Meta XR Interaction SDK

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 技术的吉他练习应用程序。

本次企划中,我们最重视的是让初学者也能轻松理解并持续练习。

由于我自己也是吉他初学者,因此从初学者的视角出发,思考了所需的功能。

具体来说,我们以以下两点作为企划的核心:

易于理解

・将文字说明控制在必要最小限度

・不一次性教授过多内容,而是分阶段进行讲解

・直感的に操作できる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 開発構成

开发的功能主要由以下要素构成。

虚拟琴颈的生成与调整:
生成虚拟琴颈以及品丝和琴弦的引导线,并调整其位置和大小。

演奏引导的生成:
基于乐曲数据,生成指示演奏位置的道(lane)和音符。

乐曲数据的管理:
管理BPM、拍号、音符长度等乐曲信息及音符信息。

乐曲编辑UI:
在应用内创建、编辑和保存乐曲数据。

演奏进度的控制:
根据BPM和拍号移动演奏引导,从而控制演奏的进度。

开发构成图(暂定)

1.3 Unity 上的对象构成

在 Unity 上,以负责管理整个指南的 GuideRoot 为中心,配置了用于显示指南的对象以及管理乐曲和演奏进度的对象。

对象构成图
image.png

主要对象的作用如下。

GuideRoot 下属

GuideRoot: 管理整个引导的位置、旋转和大小。
MenuBoard: 用于调整位置和开始/停止演奏的UI。
VirtualNeck: 对应实体吉他的虚拟坐标系。内部放置构成演奏引导的对象。
KeyLane: 指示品位位置和演奏时间的引导。
KeyNotes: 指示按压位置的音符标记。
StringsGuide: 对应六根琴弦的引导。
StringsGuide_Back1: 配置在靠后一侧的琴弦引导。
FretLines: 放置生成的品丝线的对象。
ReferencePoints: 生成品丝的基准点。

GuideRoot外的主要对象

SongEditorController: 管理乐曲编辑UI的生成与操作。
MultiMeasureKeyLaneGenerator: 管理以小节为单位的轨道生成。
MetronomeController: 执行与节拍器相关的控制。
OVRCameraRig: 管理Quest的视角与追踪。
PassthroughLayer: 管理用于显示现实空间的透视功能。
EventSystem: 管理UI的输入事件。

2. 应用运行说明

这个应用正如开发配置中所写的那样

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

下面按顺序逐一说明这些内容。

2.1 创建虚拟琴颈

首先制作吉他演奏所需的吉他琴颈部分。

Guitar_neck.png

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

品丝位置的计算

为了将虚拟指南叠加到真实吉他上,需要将品丝线之间的间距与实际吉他的间距相匹配。

由于吉他的品丝离琴枕越远间距越窄,因此无法通过固定间距排列的方式来匹配真实位置。

为此,我们在琴枕侧和12品侧的两端分别设置了基准点,并基于这些位置关系实现了生成品丝线的机制。(由于是原型,仅支持到12品)

使用的基准点如下。
・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)是表示音品间距按十二平均律逐渐变窄的计算。此外,开头的 2f 是一个系数,用于以从琴枕到第12品的距离为基准进行插值。

通过该计算,求得第1至第12品的位置。

琴弦线的生成

使用计算出的两点 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 进行生成。

  • 生成位置:两个基准点的中间
  • 旋转:对齐连接两点的方向
  • 长度:两点之间的距离
  • 粗细与深度:使用从 Inspector 设置的值
    由此可以生成符合基准点位置关系的品丝线。

image.png

フレットができた!

此外,生成的对象被命名为 GeneratedFret_。在重新生成时,系统仅删除以该名称开头的对象并重新创建,从而避免误删其他对象。

调整弦导引的长度与位置

生成琴弦线后,还需要使弦导引与实际吉他保持一致。

为此,

StringLengthAdjuster.cs

我们构建了一个机制,从用于生成琴弦线的四个基准点出发,调整弦导引的长度和位置。

首先,将琴枕侧和第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品侧的中央位置。

接下来,根据这两点之间的距离计算弦导板的长度。

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谱两种,
‘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";
    }
}

这样,音符的位置和长度不是以秒为单位,而是以四分音符为1的拍数进行管理。
例如,音符的长度可以表示如下。

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

为了支持跨越小节边界的音符,我们还保留了连音(tie)信息。
通过使用beatsPerMeasure和beatUnit等字段,我们设计了能够识别由同一连音连接的音符的结构。

并且,为了应对跨越小节边界的音符,还保留了拍(タイ)信息。
通过使用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运行时生成乐谱编辑器。通过从Prefab生成Canvas并将显示方式设置为World Space,使其能够放置在Quest的空间内。
创建了一个名为CreateCanvasAndUI()的方法来生成Canvas,
在此处理中,首先确认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品状态的按钮。
音符长度:选择从全音符到十六分音符长度的按钮。
辅助网格:切换时间轴上显示的四分、八分、十六分辅助线的开关。
操作按钮:执行添加音符、保存、加载、复制、移动、删除等操作的按钮。
时间轴:放置和编辑音符的区域。

各个UI元素通过调用CreateText()、CreateInput()、CreateButton()、CreateToggle()等方法生成。这些是用于统一处理UI元素生成的辅助方法。

此外,还为按钮和开关注册了Lambda表达式等处理逻辑。例如,按下状态选择按钮会更新stateSelection,从而刷新选中状态的颜色和屏幕显示。辅助网格的开关同理,在修改设定值后调用RefreshTimeline()以重绘时间轴。

这样,不仅UI的外观,对应各操作的处理逻辑也在BuildUI()中进行了注册。

时间轴部分由CreateTimelineViewport()独立于其他输入框和按钮进行生成。在此处,我们创建了带有Mask(遮罩)以限制显示范围的区域,以及在其内部放置的TimelineContent。

此外,还设置了横向滚动条和用于指针操作的TimelinePointerHandler。由于时间轴的绘制内容和音符的放置处理由其他方法负责,此处仅准备显示区域和接收操作的底层结构。

时间轴的绘制

接下来是精心打造的时间轴绘制部分。

画布内的时间轴是通过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:以四分音符为单位表示的一小节长度。
bars:显示的小节数量。
barWidth:每小节的宽度。
LabelWidth:左侧显示弦号等内容的区域宽度。
HeaderHeight:显示小节信息的标题高度。
RowHeight:每根弦所在行的高度。

使用这些参数计算整个时间轴的宽度和高度。宽度随小节数变化,高度由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:以四分音符为单位的线
showEighthGrid:以八分音符为单位的线
showSixteenthGrid:以十六分音符为单位的线

我们从每个节拍的位置计算小节内的宽度,并将其绘制为垂直线。此外,为了避免十六分网格和八分网格在同一位置重叠,我们根据条件省略了位于八分位置的线条。

小节边界线是独立于辅助网格生成的,从而明确区分各个小节的界限。

在这样生成背景和网格之后,我们依次取出保存在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 的手柄指定时间线上的添加位置。

将手柄指针对准时间线后,系统会获取该位置对应的小节号、拍号和弦号,并在界面上显示待添加的内容。此时点击时间线,即可根据当前选中的弦的状态和音符时长添加音符。

时间轴上的指针输入正在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();
}

如果追加位置已存在音符,则中止追加。如果没有问题,则生成NoteData,在其中设置所获取的拍位置与弦编号,再加上 UI 中所选弦的状态与音符长度,并添加到乐曲数据中。

弦的状态用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()中写入保存位置。

第二个参数的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的生成与复制

为了按小节管理使用乐曲数据创建的演奏指南,我们采用了为每个小节生成一条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();
}

应用指板范围后,关键轨道的显示宽度和位置会更新。因此,轨道之间的布局间距不是基于更改前的值,而是基于更新后的模板显示尺寸来获取。

将获取到的轨道长度设为laneLength,并由此求出轨道间距laneSpacing。使用此值来布置后续的关键轨道。

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方向偏移量。第一条车道偏移半个车道间距,第二条及之后的车道则依次按车道间距调整位置。

将成功生成的KeyLane添加到activeLanes中。通过此列表管理已生成的车道,以便在后续的移动处理和车道更新处理中获取目标对象。

通过上述处理,根据乐曲数据为每个小节生成多条KeyLane,并为每条KeyLane设置对应的指板范围和演奏数据。

拍线与小节线的生成

在生成和复制 KeyLane 时,为每条 Lane 配置拍号线和小节边界线。

这些线是通过 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 表示每小节的拍数。此外,可以通过 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;

在此处,以一小节的拍数作为 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坐标。

随后,根据一小节的拍数进行循环处理,求出各条线的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方向厚度设置为普通拍线的两倍,使其更粗。

例如在4/4拍的情况下,普通拍线有3条,小节界线位于起始位置和结束位置共2条,总计配置5条线。

网格的生成与渲染

确定线条的位置和尺寸后,将各个顶点和三角形的信息存储到数组中。

普通的拍号线和小节边界线分别由不同的数组管理,并传递给CreateMesh()进行网格化。

BeatLineGenerator.cs

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

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

网格的顶点和三角形是在AddBox()中生成的。将一条线表示为长方体,根据指定的中心位置和尺寸计算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中设置的四个基准点。

  • 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
);

首先,将四个基准点转换为 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 以求得结束边界。通过 Mathf.Max() 进行调整,以确保起始边界不小于0。

根据计算出的两个边界位置,计算 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获取乐曲数据,并根据拍号设置计算每小节的拍数。

KeyNoteSpawner.cs

SongData song = songEditor.song;

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

float measureStartBeat =
    (measureNumber - 1) * quarterNotesPerMeasure;

float measureEndBeat =
    measureStartBeat + quarterNotesPerMeasure;

quarterNotesPerMeasure 表示以四分音符为单位的一小节的长度。基于此,确定生成目标小节的位置和结束位置。

接下来,依次检查乐曲数据中注册的音符,并提取生成目标的音符。

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上的放置位置。

首先,求出每拍在轨道内的距离。

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方向排列音符。音符的长度通过显示区间的拍数乘以每拍的间距求得。

接下来,从品号和弦号获取音符的基准位置。

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;

由此,可以区分分割后的音符原本属于哪个音符,以及是否通过连音线相连。

不过,目前仅实现了保留连音线信息的功能。由于按小节分割并生成音符,视觉上也会在小节边界处断开。尚未实现能一目了然地显示连音连接的表示方式,视觉上的连续性是今后的改进点。

生成音符轮廓

最后,显示生成的立方体轮廓。

CreateNoteOutline() 中定义了立方体的8个顶点,并在 LineRenderer 中绘制了连接它们的12条边。

我们分别设置了普通轮廓和接触部分的轮廓的颜色与粗细。这样在保持音符形状清晰易读的同时,突出了接触部分。

另外,由于音符不使用物理碰撞,因此在生成时会删除 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求出一个小节的演奏时间,用该时间除车道长度,从而计算出每秒的移动距离。

最后,使用Vector3.back将车道沿-Z方向移动。

通过这一机制,BPM 加快时赛道的移动速度也会加快,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);

计算出的平面信息将传递给着色器的全局变量_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)的支持

支持在乐谱上连接相同高度的音符,使声音不间断地延长。

目前仅实现了保留连音信息的功能。但由于音符是按小节分割生成的机制,因此在显示上也会在小节边界处中断。

因此,尚未实现能够一目了然地掌握由连音连接的音符之间连续性的显示效果。

其他优化

除上述功能外,还计划修复开发中发现的缺陷,并改善用户界面的操作性和设置的易用性。

特别是,我们旨在简化在调整指南的位置和大小等操作时的步骤,以便更容易进行与真实吉他相匹配的设置。

这些内容将作为未来的改进项目保留下来。

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