using System; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.Serialization; using UnityEngine.XR.Interaction.Toolkit.Locomotion; public class SceneLoadManager : MonoBehaviour { public static SceneLoadManager Instance { get; private set; } [SerializeField] private GameObject _loadingRoot; [SerializeField] private Camera _loadingCam; [SerializeField] private Transform _loadingCamTargetTransform; [SerializeField] private LoadingScreen _loadingScreen; // 씬(LocalManager)이 전용 스카이박스를 지정하지 않았을 때 쓰는 기본 스카이박스 // (FormerlySerializedAs: 프리팹에 _sceneSkybox로 저장돼 있던 연결을 유지) [FormerlySerializedAs("_sceneSkybox")] [SerializeField] private Material _defaultSceneSkybox; [SerializeField] private Material _loadingSkybox; [SerializeField, Min(0f)] private float _skyboxFadeTime = 1f; // 정상(밝음) 상태에서의 스카이박스 _Exposure 값. 페이드 인의 도착 지점. [SerializeField, Min(0f)] private float _skyboxNormalExposure = 1f; // 씬 전환(로딩) 동안 플레이어를 치워둘 허공 좌표 — 기존 씬 오브젝트가 시야에 안 들어오게 [SerializeField] private Vector3 _loadingPlayerPosition = new Vector3(10000f, 0f, 0f); private Material _runtimeSceneSkybox; private Material _runtimeLoadingSkybox; // 씬 전환 시퀀스 진행 중 여부 — 이때 적용되는 새 스카이박스는 검은 상태로 시작해야 한다 private bool _isChangingScene; private void Awake() { if (Instance == null) { Instance = this; // 만들어진 자신을 인스턴스로 설정 } else { Destroy(gameObject); // 이미 인스턴스가 있으면 자신을 파괴 } if (_loadingSkybox != null) _runtimeLoadingSkybox = new Material(_loadingSkybox); // 씬용 스카이박스는 OnSceneLoaded → SetSceneSkybox에서 적용된다 // (기본 스카이박스를 깔고, 씬에 LocalManager가 있으면 씬 전용으로 덮어씀) } private void Start() { SceneManager.sceneLoaded += OnSceneLoaded; OnSceneLoaded(SceneManager.GetActiveScene(), LoadSceneMode.Single); } private void OnDestroy() { if (Instance == this) { SceneManager.sceneLoaded -= OnSceneLoaded; if (_runtimeSceneSkybox != null) Destroy(_runtimeSceneSkybox); if (_runtimeLoadingSkybox != null) Destroy(_runtimeLoadingSkybox); } } private void Update() { if (_loadingRoot != null && _loadingCamTargetTransform != null) { _loadingRoot.transform.position = _loadingCamTargetTransform.position; } } // 씬이 로드되었을때 호출 private void OnSceneLoaded(Scene scene, LoadSceneMode mode) { // 우선 기본 스카이박스를 적용 — 씬 전용 스카이박스가 있으면 // 아래 루프에서 그 씬의 LocalManager가 SetSceneSkybox로 덮어쓴다 SetSceneSkybox(null); MonoBehaviour[] allObjs = FindObjectsByType(); foreach (var obj in allObjs) { if (obj is ISceneInitializable initializable) { // 씬에서 ISceneInitializable 인터페이스를 가진 오브젝트의 초기화 로직을 실행 initializable.OnSceneLoaded(); } } } // 현재 씬에서 쓸 스카이박스를 지정한다 (null이면 기본 스카이박스로). // 페이드가 머티리얼 에셋 원본의 _Exposure를 오염시키지 않도록 항상 복사본을 만들어 쓴다. public void SetSceneSkybox(Material skybox) { var source = skybox != null ? skybox : _defaultSceneSkybox; if (source == null) return; if (_runtimeSceneSkybox != null) Destroy(_runtimeSceneSkybox); _runtimeSceneSkybox = new Material(source); // 씬 전환 중이면 아직 어두워야 한다 — 마지막 페이드 인(0 → 기본 노출)이 밝혀준다 if (_isChangingScene && _runtimeSceneSkybox.HasFloat("_Exposure")) _runtimeSceneSkybox.SetFloat("_Exposure", 0f); RenderSettings.skybox = _runtimeSceneSkybox; DynamicGI.UpdateEnvironment(); } public async Awaitable FadeLoadingCanvas(bool isOut,float fadeTime) { float startAlpha = isOut ? 1f : 0f; float endAlpha = isOut ? 0f : 1f; float timer = 0; _loadingScreen.LoadingScreenCanvasGroup.alpha = startAlpha; while(timer < fadeTime) { timer += Time.deltaTime; _loadingScreen.LoadingScreenCanvasGroup.alpha = Mathf.Lerp(startAlpha, endAlpha, timer / fadeTime); await Awaitable.NextFrameAsync(this.destroyCancellationToken); } _loadingScreen.LoadingScreenCanvasGroup.alpha = endAlpha; } // 현재 RenderSettings.skybox의 _Exposure를 from→to로 보간 private async Awaitable FadeSkybox(float from, float to, float duration) { var sky = RenderSettings.skybox; if (sky == null || !sky.HasFloat("_Exposure")) return; float timer = 0f; while (timer < duration) { timer += Time.deltaTime; float k = Mathf.Clamp01(timer / duration); sky.SetFloat("_Exposure", Mathf.Lerp(from, to, k)); DynamicGI.UpdateEnvironment(); await Awaitable.NextFrameAsync(this.destroyCancellationToken); } sky.SetFloat("_Exposure", to); DynamicGI.UpdateEnvironment(); } public async Awaitable SetSceneLoadingActive(bool isActive,float alphaTime) { if (isActive) { _loadingRoot.SetActive(true); } if (alphaTime > 0f) { await FadeLoadingCanvas(!isActive,alphaTime); } if (!isActive) _loadingRoot.SetActive(false); } public void SetSceneLoadingActive(bool isActive) { _ = SetSceneLoadingActive(isActive, 0f); } public void SetSceneLoadingProgressValue(float value) { _loadingScreen.LoadingImage.fillAmount = value; _loadingScreen.LoadingTextMeshProUGUI.text = $"{(value * 100):F0}%"; } public void SetSceneLoadingProgressValue(float value,string loadingText) { _loadingScreen.LoadingImage.fillAmount = value; _loadingScreen.LoadingTextMeshProUGUI.text = loadingText; } // 플레이어(XR Origin 루트, Player 태그)를 로딩용 허공 좌표로 옮긴다. // 새 씬이 로드되면 새 씬의 플레이어 스폰 위치가 적용되므로 되돌릴 필요는 없다. private void MovePlayerToLoadingArea() { var player = GameObject.FindWithTag("Player"); if (player == null) { Debug.LogWarning("[SceneLoadManager] Player 태그 오브젝트를 찾지 못해 이동 생략"); return; } // 로딩 동안 이동/회전/중력을 전부 정지 — 허공에서 낙하하지 않게. // (XRI에서 중력도 LocomotionProvider다) 씬이 로드되면 리그가 새 씬 것으로 // 교체되므로 다시 켤 필요 없다. HMD 트래킹은 영향받지 않는다. foreach (var provider in player.GetComponentsInChildren()) provider.enabled = false; // CharacterController는 켜진 채로 위치를 바꾸면 이동이 씹힐 수 있어 잠깐 끄고 옮긴다 var cc = player.GetComponent(); if (cc != null) cc.enabled = false; player.transform.position = _loadingPlayerPosition; if (cc != null) cc.enabled = true; } // 로딩 룸(로딩바 포함)을 플레이어가 지금 보고 있는 방향으로 한 번 돌려놓는다. // Update에서는 위치만 따라가므로, 여기서 잡아둔 방향이 로딩 내내 유지된다. // 매 프레임 돌리면 UI가 얼굴에 붙어다녀 멀미가 나므로 전환 시점에 한 번만 맞춘다. private void AlignLoadingRootToPlayerView() { if (_loadingRoot == null || _loadingCamTargetTransform == null) return; // 고개를 들거나 숙인 각도는 버리고 좌우(yaw) 방향만 쓴다 var forward = Vector3.ProjectOnPlane(_loadingCamTargetTransform.forward, Vector3.up); // 정수리나 발끝을 보고 있으면 수평 성분이 0에 가까워진다 — 이때는 yaw 값을 직접 쓴다 if (forward.sqrMagnitude < 0.0001f) { _loadingRoot.transform.rotation = Quaternion.Euler(0f, _loadingCamTargetTransform.eulerAngles.y, 0f); return; } _loadingRoot.transform.rotation = Quaternion.LookRotation(forward, Vector3.up); } // 화면이 어두워진 뒤, HideDuringLoading 마커가 붙은 오브젝트를 전부 끈다 (로딩 중 노출/깜빡임 방지). // 글로벌 매니저라 씬 오브젝트를 직접 참조할 수 없으므로(씬 전환 시 참조가 끊김) 런타임에 씬에서 찾는다. private void DisableDarkObjects() { foreach (var marker in FindObjectsByType()) marker.gameObject.SetActive(false); } public void RequestSceneChange(string sceneName) { _ = SceneChange(sceneName); } private async Awaitable SceneChange(string sceneName) { try { _isChangingScene = true; //로딩바 수치 0으로 설정 SetSceneLoadingProgressValue(0f); // 시야(암전 구체) 완전히 어두워질 때까지 페이드 var pc = GameManager.Instance != null ? GameManager.Instance.PController : null; if (pc != null) await pc.FadeSight(false, 0.8f); //스카이 박스 페이드 아웃 await FadeSkybox(_skyboxNormalExposure, 0f, _skyboxFadeTime); // 검게 된 상태에서 머티리얼 교체 (교체 순간이 가려져 깜빡임 없음) RenderSettings.skybox = _runtimeLoadingSkybox; // 어두운 동안 플레이어를 허공으로 이동 — 로딩 중 기존 씬 오브젝트가 보이지 않게 // (로딩 룸은 카메라를 따라다니므로 함께 이동한다) MovePlayerToLoadingArea(); // 플레이어가 뒤를 보고 있어도 로딩바가 정면에 오도록 로딩 룸을 시선 방향으로 정렬 AlignLoadingRootToPlayerView(); // 어두워진 상태에서 지정 오브젝트들 끄기 (로딩 중 보이면 안 되는 것들) DisableDarkObjects(); // 로딩 룸에 도착했으니 시야를 다시 연다 (스카이박스 페이드 인과 동시 진행) if (pc != null) _ = pc.FadeSight(true, 0.8f); //스카이 박스 페이드 인 await FadeSkybox(0f, _skyboxNormalExposure, _skyboxFadeTime); //로딩창을 1초에 걸쳐 나타나게함 await SetSceneLoadingActive(true,1f); AsyncOperation op = SceneManager.LoadSceneAsync(sceneName); //자동 전환을 하고 싶지 않을 경우 해당값을 false로 두었다가 true로 바꾸면 그 때 전환됨 op.allowSceneActivation = false; //화면에 보여줄 로딩 수치 float displayProgress = 0f; //op.progress 0.9가 데이터 로딩이 끝난 기준 allowSceneActivation이 트루면 다음으로 넘어가면서 op.isDone이 true가 된다. while (op.progress < 0.9f) { //실제 로딩 수치 float realProgress = Mathf.Clamp01(op.progress / 0.9f); //보여줄 값을 실제값을 향해 부드럽게 이동 displayProgress = Mathf.MoveTowards(displayProgress, realProgress, Time.deltaTime * 0.5f); // 로딩바 UI에 값 적용 SetSceneLoadingProgressValue(displayProgress); //자기자신이 파괴될때 토큰에 취소요청을 보냄 await Awaitable.NextFrameAsync(this.destroyCancellationToken); } //로딩바 수치 1(100%)로 설정 (데이터 로딩은 이미 끝이기 때문에) SetSceneLoadingProgressValue(1); // 잠시 대기했다가 전환 await Awaitable.WaitForSecondsAsync(1.0f, this.destroyCancellationToken); // 로딩바가 1초에 걸쳐 사라짐 await SetSceneLoadingActive(false,1f); await FadeSkybox(_skyboxNormalExposure, 0f, _skyboxFadeTime); // 검게 된 상태에서 정상 스카이박스로 교체 RenderSettings.skybox = _runtimeSceneSkybox; // 다음씬으로 넘어가도 됨을 알림 op.allowSceneActivation = true; // 씬 활성화가 완전히 끝날 때까지 대기 // allowSceneActivation가 true가 되고 완전히 전환되기까지는 몇프레임 걸림. op.isDone 은 이 과정이 끝난 뒤에 true가 됨. while(!op.isDone) { await Awaitable.NextFrameAsync(this.destroyCancellationToken); } //VR용 로직 //트래킹이 중단되면 안되기 때문에 카메라를 유지해야 한다 _loadingCamTargetTransform = Camera.main.transform; // 새로운 씬의 메인카메라를 따라가게끔 설정 await FadeSkybox(0f, _skyboxNormalExposure, _skyboxFadeTime); } catch (OperationCanceledException) { Debug.Log("씬 전환 작업이 취소됨"); } finally { _isChangingScene = false; } } }