feat: polish VR gameplay and sync tools

This commit is contained in:
jongjae0305
2026-05-28 19:01:20 +09:00
parent ee34d79a66
commit 03105a4f85
50 changed files with 4986 additions and 328 deletions
+4 -1
View File
@@ -36,6 +36,8 @@ public class BeatSageNote
public static class BeatSageConverter
{
private static readonly bool LogConversions = false;
public static List<NoteData> Convert(string rawJson, float bpm)
{
var result = new List<NoteData>();
@@ -62,7 +64,8 @@ public static class BeatSageConverter
});
}
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
if (LogConversions)
Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
return result;
}
+53 -2
View File
@@ -8,7 +8,8 @@ public class DownloadManager : MonoBehaviour
{
[SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";
private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
private static string CacheRoot => Path.Combine(Application.persistentDataPath, "beatsaber");
private static string LegacyCacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");
// ── Public API ───────────────────────────────────────────
@@ -38,20 +39,35 @@ public class DownloadManager : MonoBehaviour
Directory.Delete(dir, recursive: true);
Debug.Log($"[DownloadManager] 삭제: {songId}");
}
string legacyDir = LegacySongDir(songId);
if (Directory.Exists(legacyDir))
Directory.Delete(legacyDir, recursive: true);
}
public void DeleteDifficulty(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
if (path != null && File.Exists(path))
File.Delete(path);
string songDir = SongDir(song.id);
if (Directory.Exists(songDir) && Directory.GetFileSystemEntries(songDir).Length == 0)
Directory.Delete(songDir);
}
public bool IsSongDownloaded(string songId)
=> File.Exists(AudioPath(songId));
{
TryMigrateLegacySong(songId);
return File.Exists(AudioPath(songId));
}
public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
{
TryMigrateLegacySong(song.id);
string path = MapPath(song, difficulty);
return path != null && File.Exists(path);
}
@@ -73,6 +89,8 @@ public class DownloadManager : MonoBehaviour
private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
Action<float> onProgress, Action onComplete, Action<string> onError)
{
TryMigrateLegacySong(song.id);
string songDir = Path.GetFullPath(SongDir(song.id));
Directory.CreateDirectory(songDir);
@@ -157,4 +175,37 @@ public class DownloadManager : MonoBehaviour
private static string SongDir(string songId)
=> Path.Combine(CacheRoot, songId);
private static string LegacySongDir(string songId)
=> Path.Combine(LegacyCacheRoot, songId);
private static void TryMigrateLegacySong(string songId)
{
string sourceDir = LegacySongDir(songId);
string targetDir = SongDir(songId);
if (Directory.Exists(targetDir) || !Directory.Exists(sourceDir))
return;
CopyDirectory(sourceDir, targetDir);
Directory.Delete(sourceDir, recursive: true);
Debug.Log($"[DownloadManager] 기존 캐시를 영구 저장소로 이동: {songId}");
}
private static void CopyDirectory(string sourceDir, string targetDir)
{
Directory.CreateDirectory(targetDir);
foreach (string file in Directory.GetFiles(sourceDir))
{
string targetFile = Path.Combine(targetDir, Path.GetFileName(file));
File.Copy(file, targetFile, overwrite: true);
}
foreach (string dir in Directory.GetDirectories(sourceDir))
{
string targetSubDir = Path.Combine(targetDir, Path.GetFileName(dir));
CopyDirectory(dir, targetSubDir);
}
}
}
+130
View File
@@ -0,0 +1,130 @@
using UnityEngine;
using UnityEngine.Video;
public class Game360VideoBackground : MonoBehaviour
{
[SerializeField] private VideoClip videoClip;
[SerializeField] private int renderTextureSize = 2048;
[SerializeField] private bool muteVideoAudio = true;
[SerializeField, Range(0f, 360f)] private float skyboxRotationDegrees = 0f;
[SerializeField, Range(0f, 8f)] private float skyboxExposure = 1f;
private GameObject videoPlayerObject;
private Material skyboxMaterial;
private Material previousSkybox;
private RenderTexture renderTexture;
private VideoPlayer videoPlayer;
private void Awake()
{
if (videoClip == null)
{
Debug.LogWarning("[Game360VideoBackground] videoClip is not assigned.");
return;
}
CreateSkyboxMaterial();
CreateVideoPlayer();
}
private void OnDestroy()
{
if (videoPlayer != null)
{
videoPlayer.prepareCompleted -= OnVideoPrepared;
videoPlayer.errorReceived -= OnVideoError;
}
if (renderTexture != null)
{
renderTexture.Release();
Destroy(renderTexture);
}
RenderSettings.skybox = previousSkybox;
DynamicGI.UpdateEnvironment();
if (skyboxMaterial != null)
Destroy(skyboxMaterial);
if (videoPlayerObject != null)
Destroy(videoPlayerObject);
}
private void CreateSkyboxMaterial()
{
renderTexture = new RenderTexture(renderTextureSize, renderTextureSize / 2, 0, RenderTextureFormat.ARGB32)
{
name = "Game360VideoRenderTexture",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear,
};
renderTexture.Create();
previousSkybox = RenderSettings.skybox;
skyboxMaterial = new Material(ResolveSkyboxShader())
{
name = "Game360VideoMaterial",
};
skyboxMaterial.SetTexture("_MainTex", renderTexture);
skyboxMaterial.SetFloat("_ImageType", 0f);
skyboxMaterial.SetFloat("_Mapping", 0f);
skyboxMaterial.SetFloat("_Layout", 0f);
ApplySkyboxSettings();
RenderSettings.skybox = skyboxMaterial;
DynamicGI.UpdateEnvironment();
}
private void CreateVideoPlayer()
{
videoPlayerObject = new GameObject("[360 Video Skybox Player]");
videoPlayerObject.transform.SetParent(transform, false);
videoPlayer = videoPlayerObject.AddComponent<VideoPlayer>();
videoPlayer.playOnAwake = false;
videoPlayer.isLooping = true;
videoPlayer.waitForFirstFrame = true;
videoPlayer.renderMode = VideoRenderMode.RenderTexture;
videoPlayer.targetTexture = renderTexture;
videoPlayer.clip = videoClip;
videoPlayer.audioOutputMode = muteVideoAudio
? VideoAudioOutputMode.None
: VideoAudioOutputMode.Direct;
videoPlayer.prepareCompleted += OnVideoPrepared;
videoPlayer.errorReceived += OnVideoError;
videoPlayer.Prepare();
}
private static Shader ResolveSkyboxShader()
{
return Shader.Find("Skybox/Panoramic")
?? Shader.Find("Skybox/6 Sided")
?? Shader.Find("Standard");
}
private void OnVideoPrepared(VideoPlayer source)
{
source.Play();
}
private void OnValidate()
{
ApplySkyboxSettings();
}
private void ApplySkyboxSettings()
{
if (skyboxMaterial == null)
return;
skyboxMaterial.SetFloat("_Exposure", skyboxExposure);
skyboxMaterial.SetFloat("_Rotation", skyboxRotationDegrees);
DynamicGI.UpdateEnvironment();
}
private static void OnVideoError(VideoPlayer source, string message)
{
Debug.LogWarning($"[Game360VideoBackground] VideoPlayer error: {message}");
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3e381cd99de84f67b9f83c19a032dc24
+24
View File
@@ -0,0 +1,24 @@
using UnityEngine;
public static class GlobalSyncSettings
{
private const string AudioOffsetMsKey = "VRBeats.GlobalAudioOffsetMs";
public static float AudioOffsetMs
{
get => PlayerPrefs.GetFloat(AudioOffsetMsKey, 0.0f);
set
{
PlayerPrefs.SetFloat(AudioOffsetMsKey, Mathf.Clamp(value, -300.0f, 300.0f));
PlayerPrefs.Save();
}
}
public static float AudioOffsetSeconds => AudioOffsetMs / 1000.0f;
public static void Reset()
{
PlayerPrefs.DeleteKey(AudioOffsetMsKey);
PlayerPrefs.Save();
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a2e8c518ec2f4a03a6d820774b475ce0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+93
View File
@@ -0,0 +1,93 @@
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class MenuSyncButtonInjector : MonoBehaviour
{
private const string MenuSceneName = "Menu";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void AutoCreate()
{
if (FindFirstObjectByType<MenuSyncButtonInjector>() != null)
return;
GameObject go = new GameObject("[MenuSyncButtonInjector]");
DontDestroyOnLoad(go);
go.AddComponent<MenuSyncButtonInjector>();
}
private void Awake()
{
SceneManager.sceneLoaded += OnSceneLoaded;
StartCoroutine(InjectAfterFrame());
}
private void OnDestroy()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
StartCoroutine(InjectAfterFrame());
}
private IEnumerator InjectAfterFrame()
{
yield return null;
if (SceneManager.GetActiveScene().name != MenuSceneName)
yield break;
if (GameObject.Find("SyncButton") != null)
yield break;
Button sourceButton = FindButtonByText("CreateSong");
if (sourceButton == null)
sourceButton = FindButtonByText("음악만들기");
if (sourceButton == null)
yield break;
Button syncButton = Instantiate(sourceButton, sourceButton.transform.parent);
syncButton.gameObject.name = "SyncButton";
foreach (VRBeats.LoadSceneButton loader in syncButton.GetComponents<VRBeats.LoadSceneButton>())
{
loader.enabled = false;
Destroy(loader);
}
syncButton.onClick.RemoveAllListeners();
syncButton.onClick.AddListener(SyncCalibrationOverlay.Open);
RectTransform sourceRect = sourceButton.GetComponent<RectTransform>();
RectTransform syncRect = syncButton.GetComponent<RectTransform>();
syncRect.anchoredPosition = sourceRect.anchoredPosition + new Vector2(0.0f, -22.0f);
TextMeshProUGUI tmp = syncButton.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null)
tmp.text = "SYNC";
Text text = syncButton.GetComponentInChildren<Text>(true);
if (text != null)
text.text = "SYNC";
}
private static Button FindButtonByText(string text)
{
foreach (Button button in FindObjectsByType<Button>(FindObjectsSortMode.None))
{
TextMeshProUGUI tmp = button.GetComponentInChildren<TextMeshProUGUI>(true);
if (tmp != null && tmp.text.Trim() == text)
return button;
Text legacyText = button.GetComponentInChildren<Text>(true);
if (legacyText != null && legacyText.text.Trim() == text)
return button;
}
return null;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: efc5a20a7b4749bfb60d95ac0f0b2180
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+93 -20
View File
@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Networking;
@@ -25,14 +26,20 @@ public class NasPublisher : MonoBehaviour
private void LoadConfig()
{
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
if (!File.Exists(path))
{
NormalizeSettings();
return;
}
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
if (cfg == null) return;
_password = cfg.password ?? "";
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim();
if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim();
if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim();
if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim();
NormalizeSettings();
}
[Serializable] private class NasConfig
@@ -52,6 +59,8 @@ public class NasPublisher : MonoBehaviour
Action onComplete,
Action<string> onError)
{
NormalizeSettings();
bool failed = false;
void OnErr(string e) { onError?.Invoke(e); failed = true; }
@@ -92,31 +101,53 @@ public class NasPublisher : MonoBehaviour
private IEnumerator Login(Action<string> onError)
{
if (string.IsNullOrWhiteSpace(_password))
{
onError?.Invoke("NAS password missing. Create Assets/StreamingAssets/nas_config.json with host, account, rootPath, staticUrl, and password.");
yield break;
}
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=6&method=login" +
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
$"&session=FileStation&format=sid&enable_syno_token=yes";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
UnityWebRequest req;
try
{
onError?.Invoke($"DSM login failed: {req.error}");
req = UnityWebRequest.Get(url);
}
catch (UriFormatException e)
{
onError?.Invoke($"DSM login URL invalid: '{nasBaseUrl}' — {e.Message}");
yield break;
}
string resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
string resp;
using (req)
{
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
{
onError?.Invoke($"DSM login failed: {req.error}");
yield break;
}
resp = req.downloadHandler.text;
_sid = ParseJsonString(resp, "sid");
_synoToken = ParseJsonString(resp, "synotoken");
}
if (string.IsNullOrEmpty(_sid))
onError?.Invoke("DSM sid parse failed — check credentials.");
onError?.Invoke($"DSM sid parse failed. Check NAS account/password/permissions. Response: {Shorten(resp)}");
}
private IEnumerator Logout()
{
NormalizeSettings();
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
using var req = UnityWebRequest.Get(url);
@@ -133,6 +164,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator UploadBytes(byte[] bytes, string fileName,
string nasFolder, Action<string> onError)
{
NormalizeSettings();
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
@@ -179,6 +212,8 @@ public class NasPublisher : MonoBehaviour
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
{
NormalizeSettings();
SongsList list = null;
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
@@ -201,12 +236,16 @@ public class NasPublisher : MonoBehaviour
private static string ParseJsonString(string json, string key)
{
string search = $"\"{key}\":\"";
int start = json.IndexOf(search, StringComparison.Ordinal);
if (start < 0) return null;
start += search.Length;
int end = json.IndexOf('"', start);
return end > start ? json.Substring(start, end - start) : null;
if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key))
return null;
Match match = Regex.Match(
json,
$"\"{Regex.Escape(key)}\"\\s*:\\s*\"(?<value>(?:\\\\.|[^\"])*)\"");
return match.Success
? Regex.Unescape(match.Groups["value"].Value)
: null;
}
private static void AssignMapFile(SongInfo song, string diff, string fileName)
@@ -214,4 +253,38 @@ public class NasPublisher : MonoBehaviour
var info = song.difficulties.Get(diff);
if (info != null) info.mapFile = $"maps/{fileName}";
}
private void OnValidate()
{
NormalizeSettings();
}
private void NormalizeSettings()
{
nasBaseUrl = NormalizeBaseUrl(nasBaseUrl);
staticBaseUrl = NormalizeBaseUrl(staticBaseUrl);
nasAccount = nasAccount?.Trim() ?? "";
nasRootPath = NormalizeRootPath(nasRootPath);
}
private static string NormalizeBaseUrl(string value)
{
return (value ?? "").Trim().TrimEnd('/');
}
private static string NormalizeRootPath(string value)
{
value = (value ?? "").Trim().Replace('\\', '/');
if (string.IsNullOrEmpty(value))
return "/";
return value.StartsWith("/") ? value.TrimEnd('/') : "/" + value.TrimEnd('/');
}
private static string Shorten(string value, int maxLength = 240)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value ?? "";
return value.Substring(0, maxLength) + "...";
}
}
+23 -7
View File
@@ -19,16 +19,25 @@ public class SongController : MonoBehaviour
private const float VerticalCenter = 1f;
private AudioManager _audio;
private ScoreManager _scoreManager;
private float _clipLength;
private static string CacheRoot =>
Path.Combine(Application.temporaryCachePath, "beatsaber");
Path.Combine(Application.persistentDataPath, "beatsaber");
private void Start()
{
_audio = FindFirstObjectByType<AudioManager>();
_scoreManager = FindFirstObjectByType<ScoreManager>();
StartCoroutine(LoadAndPlay());
}
private void Update()
{
if (_audio != null && _scoreManager != null && _clipLength > 0.0f)
_scoreManager.SetSongProgress(_audio.CurrentTime, _clipLength);
}
private IEnumerator LoadAndPlay()
{
SongInfo song = GameSession.SelectedSong;
@@ -53,6 +62,7 @@ public class SongController : MonoBehaviour
}
clip = DownloadHandlerAudioClip.GetContent(req);
}
_clipLength = clip.length;
// Load and parse map
DifficultyInfo diffInfo = song.difficulties.Get(diff);
@@ -74,13 +84,14 @@ public class SongController : MonoBehaviour
yield break;
}
map.target.Sort(CompareNotes);
_scoreManager?.SetTotalNotes(map.target.Count);
yield return StartCoroutine(Countdown());
_audio.PlayClip(clip);
StartCoroutine(SpawnRoutine(map.target));
yield return StartCoroutine(WaitForCompletion(clip.length));
yield return StartCoroutine(WaitForCompletion(clip.length, map.target));
}
private IEnumerator Countdown()
@@ -106,7 +117,8 @@ public class SongController : MonoBehaviour
foreach (NoteData note in notes)
{
float spawnAt = Mathf.Max(0f, note.time - travelTime);
float adjustedNoteTime = note.time + GlobalSyncSettings.AudioOffsetSeconds;
float spawnAt = Mathf.Max(0f, adjustedNoteTime - travelTime);
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
SpawnNote(note);
}
@@ -118,7 +130,7 @@ public class SongController : MonoBehaviour
float y = MapLayerY(note.lineLayer);
// 스폰 시점의 실제 남은 시간으로 역산 → 동시 노트가 프레임 차이 나도 같은 타이밍에 도착
float remaining = note.time - _audio.CurrentTime;
float remaining = note.time + GlobalSyncSettings.AudioOffsetSeconds - _audio.CurrentTime;
float travelTime = Mathf.Max(0.05f, remaining);
var info = new SpawnEventInfo
@@ -126,7 +138,7 @@ public class SongController : MonoBehaviour
position = new Vector3(x, y, 0f),
colorSide = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
hitDirection = MapCutDirection(note.cutDirection),
useSpark = true,
useSpark = false,
speed = 2f,
travelTimeOverride = travelTime,
};
@@ -177,9 +189,13 @@ public class SongController : MonoBehaviour
private static Direction MapCutDirection(int cut)
=> (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
private IEnumerator WaitForCompletion(float clipLength)
private IEnumerator WaitForCompletion(float clipLength, List<NoteData> notes)
{
yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
float lastNoteTime = notes.Count > 0 ? notes[notes.Count - 1].time : 0.0f;
float resultTime = Mathf.Min(clipLength, lastNoteTime + GlobalSyncSettings.AudioOffsetSeconds + 0.35f);
yield return new WaitUntil(() => _audio.CurrentTime >= resultTime);
yield return new WaitForSeconds(0.35f);
_scoreManager?.CompleteSong();
onLevelComplete?.Invoke();
}
}
+341
View File
@@ -0,0 +1,341 @@
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using UnityEngine.XR;
using XRCommonUsages = UnityEngine.XR.CommonUsages;
using XRInputDevice = UnityEngine.XR.InputDevice;
public class SyncCalibrationOverlay : MonoBehaviour
{
private const float TickInterval = 1.0f;
private const int MaxSamples = 8;
private static Scene pendingReturnScene;
private readonly List<float> samples = new();
private readonly List<(Canvas canvas, bool enabled)> hiddenCanvases = new();
private AudioSource audioSource = null;
private AudioClip tickClip = null;
private RectTransform sweepDot = null;
private RectTransform visualPulse = null;
private TextMeshProUGUI offsetText = null;
private TextMeshProUGUI sampleText = null;
private TextMeshProUGUI guideText = null;
private float lastTickTime = 0.0f;
private float nextTickTime = 0.0f;
private float pendingVisualPulseTime = -1.0f;
private float visualPulseTimer = 0.0f;
private bool previousPrimary = false;
private bool previousSecondary = false;
private Scene returnScene;
private Scene syncScene;
private bool canvasesRestored = false;
public static void Open()
{
if (FindFirstObjectByType<SyncCalibrationOverlay>() != null)
return;
pendingReturnScene = SceneManager.GetActiveScene();
Scene calibrationScene = SceneManager.CreateScene("SyncCalibration");
SceneManager.SetActiveScene(calibrationScene);
GameObject overlay = new GameObject("[SyncCalibrationOverlay]");
overlay.AddComponent<SyncCalibrationOverlay>();
}
private void Awake()
{
syncScene = gameObject.scene;
returnScene = pendingReturnScene;
HideExistingCanvases();
BuildView();
tickClip = CreateTickClip();
audioSource = gameObject.AddComponent<AudioSource>();
audioSource.playOnAwake = false;
audioSource.spatialBlend = 0.0f;
float now = Time.unscaledTime;
lastTickTime = now;
nextTickTime = now + 0.8f;
UpdateTexts();
}
private void OnDestroy()
{
RestoreCanvases();
}
private void Update()
{
float now = Time.unscaledTime;
if (now >= nextTickTime)
{
lastTickTime = nextTickTime;
nextTickTime += TickInterval;
audioSource.PlayOneShot(tickClip);
pendingVisualPulseTime = lastTickTime + GlobalSyncSettings.AudioOffsetSeconds;
}
if (pendingVisualPulseTime > 0.0f && now >= pendingVisualPulseTime)
{
visualPulseTimer = 0.18f;
pendingVisualPulseTime = -1.0f;
}
UpdateMetronomeVisual(now);
HandleInput(now);
}
private void HandleInput(float now)
{
bool primary = GetRightButton(XRCommonUsages.primaryButton) || IsKeyboardPressed(Key.Space);
bool secondary = GetRightButton(XRCommonUsages.secondaryButton) || IsKeyboardPressed(Key.Escape);
if (primary && !previousPrimary)
CaptureSample(now);
if (secondary && !previousSecondary)
Close();
previousPrimary = primary;
previousSecondary = secondary;
}
private void CaptureSample(float now)
{
float nearestTick = Mathf.Abs(now - lastTickTime) <= Mathf.Abs(now - nextTickTime)
? lastTickTime
: nextTickTime;
float offsetMs = Mathf.Clamp((now - nearestTick) * 1000.0f, -300.0f, 300.0f);
samples.Add(offsetMs);
if (samples.Count > MaxSamples)
samples.RemoveAt(0);
float sum = 0.0f;
for (int i = 0; i < samples.Count; i++)
sum += samples[i];
GlobalSyncSettings.AudioOffsetMs = sum / samples.Count;
UpdateTexts();
}
private void AdjustOffset(float deltaMs)
{
GlobalSyncSettings.AudioOffsetMs += deltaMs;
samples.Clear();
UpdateTexts();
}
private void ResetOffset()
{
GlobalSyncSettings.Reset();
samples.Clear();
UpdateTexts();
}
private void Close()
{
RestoreCanvases();
if (returnScene.IsValid() && returnScene.isLoaded)
SceneManager.SetActiveScene(returnScene);
if (syncScene.IsValid() && syncScene.isLoaded)
SceneManager.UnloadSceneAsync(syncScene);
else
Destroy(gameObject);
}
private void BuildView()
{
Camera camera = Camera.main ?? FindFirstObjectByType<Camera>();
GameObject canvasObject = new GameObject("SyncCalibrationCanvas");
canvasObject.transform.SetParent(transform, false);
Canvas canvas = canvasObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.sortingOrder = 400;
canvas.worldCamera = camera;
canvasObject.AddComponent<GraphicRaycaster>();
RectTransform canvasRect = canvasObject.GetComponent<RectTransform>();
canvasRect.sizeDelta = new Vector2(1080.0f, 660.0f);
canvasObject.transform.localScale = Vector3.one * 0.0028f;
if (camera != null)
{
Transform camTransform = camera.transform;
canvasObject.transform.position = camTransform.position + camTransform.forward * 1.9f;
canvasObject.transform.rotation = Quaternion.LookRotation(canvasObject.transform.position - camTransform.position, camTransform.up);
}
Image background = CreateImage("Panel", canvasRect, new Vector2(0, 0), new Vector2(1020, 620), new Color(0.02f, 0.06f, 0.09f, 0.92f));
background.raycastTarget = false;
TextMeshProUGUI title = CreateText("Title", canvasRect, "SYNC CALIBRATION", new Vector2(0, 260), new Vector2(920, 58), 42, Color.white, TextAlignmentOptions.Center);
title.fontStyle = FontStyles.Bold;
guideText = CreateText("Guide", canvasRect, "", new Vector2(0, 180), new Vector2(920, 92), 26, new Color(0.77f, 0.9f, 1.0f, 1.0f), TextAlignmentOptions.Center);
Image bar = CreateImage("BeatBar", canvasRect, new Vector2(0, 82), new Vector2(760, 10), new Color(0.25f, 0.85f, 1.0f, 0.28f));
bar.raycastTarget = false;
sweepDot = CreateImage("BeatDot", canvasRect, new Vector2(-380, 82), new Vector2(34, 34), new Color(0.35f, 0.95f, 1.0f, 1.0f)).rectTransform;
visualPulse = CreateImage("VisualPulse", canvasRect, new Vector2(0, 82), new Vector2(140, 140), new Color(0.35f, 0.95f, 1.0f, 0.0f)).rectTransform;
offsetText = CreateText("Offset", canvasRect, "", new Vector2(0, 0), new Vector2(720, 74), 54, Color.white, TextAlignmentOptions.Center);
offsetText.fontStyle = FontStyles.Bold;
sampleText = CreateText("Samples", canvasRect, "", new Vector2(0, -72), new Vector2(760, 44), 24, new Color(0.65f, 0.78f, 0.84f, 1.0f), TextAlignmentOptions.Center);
CreateButton(canvasRect, "-10ms", new Vector2(-300, -165), () => AdjustOffset(-10.0f));
CreateButton(canvasRect, "+10ms", new Vector2(-100, -165), () => AdjustOffset(10.0f));
CreateButton(canvasRect, "RESET", new Vector2(100, -165), ResetOffset);
CreateButton(canvasRect, "BACK", new Vector2(300, -165), Close);
CreateText("Footer", canvasRect, "A / Space: capture beat B / Esc: back", new Vector2(0, -270), new Vector2(860, 40), 22, new Color(0.58f, 0.7f, 0.75f, 1.0f), TextAlignmentOptions.Center);
}
private void UpdateMetronomeVisual(float now)
{
float phase = Mathf.InverseLerp(lastTickTime, nextTickTime, now);
if (sweepDot != null)
sweepDot.anchoredPosition = new Vector2(Mathf.Lerp(-380.0f, 380.0f, phase), 82.0f);
if (visualPulse == null)
return;
visualPulseTimer = Mathf.Max(0.0f, visualPulseTimer - Time.unscaledDeltaTime);
float alpha = visualPulseTimer / 0.18f;
visualPulse.sizeDelta = Vector2.one * Mathf.Lerp(190.0f, 80.0f, alpha);
Image image = visualPulse.GetComponent<Image>();
if (image != null)
image.color = new Color(0.35f, 0.95f, 1.0f, alpha * 0.52f);
}
private void UpdateTexts()
{
float offset = GlobalSyncSettings.AudioOffsetMs;
if (offsetText != null)
offsetText.text = $"{offset:+0;-0;0} ms";
if (sampleText != null)
sampleText.text = $"samples {samples.Count}/{MaxSamples} global offset saved";
if (guideText != null)
guideText.text = "Tick 소리가 들리는 순간 A / Space를 누르세요.\n파란 원이 박자와 겹치면 보정이 맞습니다.";
}
private void HideExistingCanvases()
{
hiddenCanvases.Clear();
foreach (Canvas canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
{
hiddenCanvases.Add((canvas, canvas.enabled));
canvas.enabled = false;
}
}
private void RestoreCanvases()
{
if (canvasesRestored)
return;
canvasesRestored = true;
foreach ((Canvas canvas, bool enabled) in hiddenCanvases)
{
if (canvas != null)
canvas.enabled = enabled;
}
}
private static TextMeshProUGUI CreateText(string name, RectTransform parent, string value, Vector2 position, Vector2 size, int fontSize, Color color, TextAlignmentOptions alignment)
{
GameObject go = new GameObject(name);
go.transform.SetParent(parent, false);
RectTransform rect = go.AddComponent<RectTransform>();
rect.anchoredPosition = position;
rect.sizeDelta = size;
TextMeshProUGUI text = go.AddComponent<TextMeshProUGUI>();
text.text = value;
text.fontSize = fontSize;
text.color = color;
text.alignment = alignment;
text.textWrappingMode = TextWrappingModes.Normal;
text.overflowMode = TextOverflowModes.Overflow;
text.raycastTarget = false;
return text;
}
private static Image CreateImage(string name, RectTransform parent, Vector2 position, Vector2 size, Color color)
{
GameObject go = new GameObject(name);
go.transform.SetParent(parent, false);
RectTransform rect = go.AddComponent<RectTransform>();
rect.anchoredPosition = position;
rect.sizeDelta = size;
Image image = go.AddComponent<Image>();
image.color = color;
return image;
}
private static void CreateButton(RectTransform parent, string label, Vector2 position, UnityEngine.Events.UnityAction action)
{
Image image = CreateImage(label + "Button", parent, position, new Vector2(168, 58), new Color(0.07f, 0.18f, 0.24f, 0.96f));
Button button = image.gameObject.AddComponent<Button>();
button.onClick.AddListener(action);
ColorBlock colors = button.colors;
colors.normalColor = new Color(0.07f, 0.18f, 0.24f, 0.96f);
colors.highlightedColor = new Color(0.13f, 0.38f, 0.48f, 1.0f);
colors.pressedColor = new Color(0.08f, 0.72f, 0.85f, 1.0f);
colors.selectedColor = colors.highlightedColor;
button.colors = colors;
TextMeshProUGUI labelText = CreateText(label + "Text", image.rectTransform, label, Vector2.zero, new Vector2(154, 48), 24, Color.white, TextAlignmentOptions.Center);
labelText.fontStyle = FontStyles.Bold;
}
private static bool GetRightButton(InputFeatureUsage<bool> usage)
{
var devices = new List<XRInputDevice>();
InputDevices.GetDevicesWithCharacteristics(InputDeviceCharacteristics.Controller | InputDeviceCharacteristics.Right, devices);
if (devices.Count == 0)
return false;
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private static bool IsKeyboardPressed(Key key)
{
Keyboard keyboard = Keyboard.current;
return keyboard != null && keyboard[key].isPressed;
}
private static AudioClip CreateTickClip()
{
const int sampleRate = 48000;
const float duration = 0.055f;
int sampleCount = Mathf.CeilToInt(sampleRate * duration);
float[] data = new float[sampleCount];
for (int i = 0; i < sampleCount; i++)
{
float t = (float)i / sampleRate;
float envelope = Mathf.Exp(-t * 62.0f);
float high = Mathf.Sin(2.0f * Mathf.PI * 1760.0f * t);
float click = i < 80 ? 1.0f - (float)i / 80.0f : 0.0f;
data[i] = Mathf.Clamp((high * 0.75f + click * 0.35f) * envelope, -1.0f, 1.0f);
}
AudioClip clip = AudioClip.Create("SyncTick", sampleCount, 1, sampleRate, false);
clip.SetData(data, 0);
return clip;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 90f8d18d467240d8bb84178048a5aa91
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+258 -24
View File
@@ -11,10 +11,20 @@ namespace VRBeats
{
[SerializeField] private bool isRightHand = true;
[SerializeField] private float maxDistance = 50f;
[SerializeField] private bool debugLogging = false;
[SerializeField] private float scrollSpeed = 2.4f;
[SerializeField] private float scrollDeadZone = 0.15f;
[SerializeField] private float dragScrollSpeed = 1.25f;
[SerializeField] private float dragClickThreshold = 0.025f;
private LineRenderer _line;
private bool _prevTrigger;
private Selectable _currentHover;
private ScrollRect _dragScrollRect;
private Selectable _triggerPressSelectable;
private Vector2 _dragStartLocalPoint;
private float _dragStartNormalizedPosition;
private float _dragMaxNormalizedDelta;
private static readonly Color NormalColor = new Color(1f, 1f, 1f, 0.8f);
private static readonly Color HoverColor = new Color(0.3f, 0.8f, 1f, 1f);
@@ -40,7 +50,8 @@ namespace VRBeats
private void Start()
{
// Awake 이후 reflection으로 isRightHand가 설정되므로 Start에서 로그
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
if (debugLogging)
Debug.Log($"[VRPointer] Start — {gameObject.name} / isRightHand={isRightHand}");
}
private void OnEnable()
@@ -56,11 +67,14 @@ namespace VRBeats
private void Update()
{
// 3초마다 연결된 디바이스 목록 출력
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
if (debugLogging)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
_deviceLogTimer += Time.deltaTime;
if (_deviceLogTimer >= 3f)
{
_deviceLogTimer = 0f;
LogConnectedDevices();
}
}
bool trigger = GetButton(CommonUsages.triggerButton);
@@ -70,31 +84,41 @@ namespace VRBeats
bool thumbstick = GetButton(CommonUsages.primary2DAxisClick);
bool triggerDown = trigger && !_prevTrigger;
bool triggerUp = !trigger && _prevTrigger;
bool gripDown = grip && !_prevGrip;
bool primaryDown = primary && !_prevPrimary;
bool secondaryDown = secondary && !_prevSecondary;
bool thumbstickDown = thumbstick && !_prevThumbstick;
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
string hand = isRightHand ? "R" : "L";
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown)Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
if (debugLogging)
{
if (triggerDown) Debug.Log($"[VRPointer:{hand}] 검지 트리거 눌림");
if (gripDown) Debug.Log($"[VRPointer:{hand}] 그립(중지) 눌림");
if (primaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "A" : "X")} 버튼 눌림");
if (secondaryDown) Debug.Log($"[VRPointer:{hand}] {(isRightHand ? "B" : "Y")} 버튼 눌림");
if (thumbstickDown) Debug.Log($"[VRPointer:{hand}] 조이스틱 클릭 눌림");
}
Ray ray = new Ray(transform.position, transform.forward);
float hitDist = maxDistance;
float selectableHitDist = maxDistance;
float scrollHitDist = maxDistance;
Selectable hit = FindSelectableUnderRay(ray, ref hitDist);
Selectable hit = FindSelectableUnderRay(ray, ref selectableHitDist);
ScrollRect scrollRect = FindScrollRectUnderRay(ray, ref scrollHitDist);
float hitDist = Mathf.Min(selectableHitDist, scrollHitDist);
bool beganScrollDrag = false;
if (triggerDown)
beganScrollDrag = TryBeginScrollDrag(scrollRect, hit, ray);
if (_dragScrollRect != null && trigger)
UpdateScrollDrag(ray);
else if (_dragScrollRect == null)
HandleScroll(scrollRect);
// 호버 변화 로그
if (hit != _currentHover)
if (debugLogging && hit != _currentHover)
{
Debug.Log(hit != null
? $"[VRPointer] HOVER → {hit.gameObject.name}"
@@ -103,16 +127,21 @@ namespace VRBeats
UpdateHoverState(hit);
// 검지 트리거 또는 A/X 버튼으로 클릭
if (triggerDown || primaryDown)
if (triggerUp && _dragScrollRect != null)
EndScrollDrag(hand, ray);
// 검지 트리거 또는 A/X 버튼으로 클릭.
// ScrollRect 위의 검지 트리거는 드래그/클릭 판별을 위해 release 시점에 처리한다.
if ((triggerDown && !beganScrollDrag) || primaryDown)
{
if (_currentHover != null)
{
string btn = triggerDown ? "검지 트리거" : (isRightHand ? "A" : "X");
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
string btn = triggerDown && !beganScrollDrag ? "검지 트리거" : (isRightHand ? "A" : "X");
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [{btn}] → {_currentHover.gameObject.name}");
Click(_currentHover);
}
else
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
@@ -121,6 +150,12 @@ namespace VRBeats
}
DrawLine(hitDist);
_prevTrigger = trigger;
_prevGrip = grip;
_prevPrimary = primary;
_prevSecondary = secondary;
_prevThumbstick = thumbstick;
}
private void LogConnectedDevices()
@@ -222,6 +257,7 @@ namespace VRBeats
foreach (Selectable sel in Selectable.allSelectablesArray)
{
if (!sel.gameObject.activeInHierarchy || !sel.interactable) continue;
if (!IsOnEnabledCanvas(sel)) continue;
var rt = sel.GetComponent<RectTransform>();
if (rt == null) continue;
@@ -260,6 +296,189 @@ namespace VRBeats
return r >= 0f && r <= 1f && u >= 0f && u <= 1f;
}
private static ScrollRect FindScrollRectUnderRay(Ray ray, ref float maxDist)
{
ScrollRect closest = null;
float closestDist = maxDist;
var all = Object.FindObjectsByType<ScrollRect>(FindObjectsSortMode.None);
foreach (ScrollRect scroll in all)
{
if (!scroll.isActiveAndEnabled) continue;
if (!IsOnEnabledCanvas(scroll)) continue;
RectTransform rt = scroll.viewport != null
? scroll.viewport
: scroll.GetComponent<RectTransform>();
if (rt == null) continue;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist)) continue;
if (dist >= closestDist || dist <= 0f) continue;
if (!IsPointInRect(ray.GetPoint(dist), corners)) continue;
closestDist = dist;
closest = scroll;
}
if (closest != null)
maxDist = closestDist;
return closest;
}
private static bool IsOnEnabledCanvas(Component component)
{
Canvas[] canvases = component.GetComponentsInParent<Canvas>(true);
if (canvases.Length == 0)
return true;
for (int i = 0; i < canvases.Length; i++)
{
Canvas canvas = canvases[i];
if (canvas == null)
continue;
if (!canvas.enabled || !canvas.gameObject.activeInHierarchy)
return false;
}
return true;
}
private void HandleScroll(ScrollRect scrollRect)
{
if (!CanScrollVertically(scrollRect))
return;
Vector2 axis = GetAxis(CommonUsages.primary2DAxis);
if (Mathf.Abs(axis.y) < scrollDeadZone)
return;
scrollRect.verticalNormalizedPosition = Mathf.Clamp01(
scrollRect.verticalNormalizedPosition + axis.y * scrollSpeed * Time.deltaTime);
}
private bool TryBeginScrollDrag(ScrollRect scrollRect, Selectable pressSelectable, Ray ray)
{
if (!CanScrollVertically(scrollRect))
return false;
if (!TryGetScrollLocalPoint(scrollRect, ray, out Vector2 localPoint, out _))
return false;
_dragScrollRect = scrollRect;
_triggerPressSelectable = pressSelectable;
_dragStartLocalPoint = localPoint;
_dragStartNormalizedPosition = scrollRect.verticalNormalizedPosition;
_dragMaxNormalizedDelta = 0f;
return true;
}
private void UpdateScrollDrag(Ray ray)
{
if (_dragScrollRect == null)
return;
if (!TryGetScrollLocalPoint(_dragScrollRect, ray, out Vector2 localPoint, out float viewportHeight))
return;
float deltaY = localPoint.y - _dragStartLocalPoint.y;
float normalizedDelta = deltaY / viewportHeight * dragScrollSpeed;
_dragMaxNormalizedDelta = Mathf.Max(_dragMaxNormalizedDelta, Mathf.Abs(normalizedDelta));
_dragScrollRect.verticalNormalizedPosition = Mathf.Clamp01(
_dragStartNormalizedPosition - normalizedDelta);
}
private void EndScrollDrag(string hand, Ray ray)
{
bool shouldClick = _dragMaxNormalizedDelta < dragClickThreshold;
ScrollRect scrollRect = _dragScrollRect;
Selectable pressSelectable = _triggerPressSelectable;
float startNormalizedPosition = _dragStartNormalizedPosition;
ClearScrollDrag();
if (!shouldClick)
return;
if (scrollRect != null)
scrollRect.verticalNormalizedPosition = startNormalizedPosition;
if (pressSelectable != null && pressSelectable.isActiveAndEnabled && pressSelectable.interactable)
{
if (debugLogging)
Debug.Log($"[VRPointer:{hand}] CLICK [검지 트리거] → {pressSelectable.gameObject.name}");
Click(pressSelectable);
}
else if (debugLogging)
{
Debug.Log($"[VRPointer:{hand}] CLICK — 레이 아래 버튼 없음 " +
$"pos={transform.position:F2} fwd={transform.forward:F2}");
DebugRaycastAttempt(ray);
}
}
private void ClearScrollDrag()
{
_dragScrollRect = null;
_triggerPressSelectable = null;
_dragStartLocalPoint = Vector2.zero;
_dragStartNormalizedPosition = 0f;
_dragMaxNormalizedDelta = 0f;
}
private static bool CanScrollVertically(ScrollRect scrollRect)
{
if (scrollRect == null || !scrollRect.vertical)
return false;
RectTransform viewport = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (viewport == null || scrollRect.content == null)
return true;
return scrollRect.content.rect.height > viewport.rect.height + 1f;
}
private static bool TryGetScrollLocalPoint(ScrollRect scrollRect, Ray ray, out Vector2 localPoint, out float viewportHeight)
{
localPoint = Vector2.zero;
viewportHeight = 1f;
RectTransform rt = scrollRect.viewport != null
? scrollRect.viewport
: scrollRect.GetComponent<RectTransform>();
if (rt == null)
return false;
Vector3[] corners = new Vector3[4];
rt.GetWorldCorners(corners);
Vector3 normal = rt.forward;
if (Vector3.Dot(normal, ray.direction) >= 0f)
normal = -normal;
Plane plane = new Plane(normal, corners[0]);
if (!plane.Raycast(ray, out float dist) || dist <= 0f)
return false;
Vector3 local = rt.InverseTransformPoint(ray.GetPoint(dist));
localPoint = new Vector2(local.x, local.y);
viewportHeight = Mathf.Max(1f, rt.rect.height);
return true;
}
private bool GetButton(InputFeatureUsage<bool> usage)
{
var chars = InputDeviceCharacteristics.Controller |
@@ -274,5 +493,20 @@ namespace VRBeats
devices[0].TryGetFeatureValue(usage, out bool pressed);
return pressed;
}
private Vector2 GetAxis(InputFeatureUsage<Vector2> usage)
{
var chars = InputDeviceCharacteristics.Controller |
(isRightHand
? InputDeviceCharacteristics.Right
: InputDeviceCharacteristics.Left);
var devices = new List<InputDevice>();
InputDevices.GetDevicesWithCharacteristics(chars, devices);
if (devices.Count == 0) return Vector2.zero;
devices[0].TryGetFeatureValue(usage, out Vector2 axis);
return axis;
}
}
}
+14 -1
View File
@@ -80,6 +80,9 @@ namespace VRBeats
if (!isRight && !isLeft) continue;
if (!name.Contains("Controller") && !name.Contains("Hand")) continue;
if (go.GetComponent<LineRenderer>() == null) continue;
DisableToolkitPointerComponents(go);
if (go.GetComponent<VRPointerController>() != null) continue;
var pointer = go.AddComponent<VRPointerController>();
@@ -94,8 +97,18 @@ namespace VRBeats
if (disabledByDefault)
pointer.enabled = false;
Debug.Log($"[VRPointerSetup] {(isRight ? "Right" : "Left")} pointer 추가: {go.name} (enabled={!disabledByDefault})");
}
}
private static void DisableToolkitPointerComponents(GameObject go)
{
var rayInteractor = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.XRRayInteractor>();
if (rayInteractor != null)
rayInteractor.enabled = false;
var lineVisual = go.GetComponent<UnityEngine.XR.Interaction.Toolkit.Interactors.Visuals.XRInteractorLineVisual>();
if (lineVisual != null)
lineVisual.enabled = false;
}
}
}