Project_WL/Assets/WL/Look/Farm/WLSkyScope.cs

364 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// WLSkyScope.cs — 하늘(RenderSettings)을 「들어간 맵 동안만」 빌려 쓰고 나갈 때 돌려준다
// (WL-816h · #816)
//
// ■ 왜 필요한가 — 🔴 실측(816f · 816h)
// `RenderSettings`(앰비언트·스카이박스·안개·반사·sun)는 **씬마다 하나가 아니라
// 「활성 씬」 하나가 소유**한다. 이 게임은 `InGame` 을 활성으로 두고 섬·던전·전투 맵을
// 전부 **Additive** 로 붙인다(`SceneInfo.Load_AddScene` 실측). 그래서
// · 섬 씬(`Level01`)이 제 씬에 적어 둔 하늘(FarmingIsland Skybox)은 **절대 안 쓰이고**
// · 누가 `RenderSettings` 에 쓰면 그것은 **모든 맵에 남는다**(전투 맵·로비까지 오염).
// 816e/816f 의 `WLIslandLook.ApplyLighting` 은 값을 **쓰기만** 하고 되돌리지 않았다.
// 지금은 쓰는 값이 마침 `InGame` 원본과 같아서 오염이 안 보였을 뿐이다(816h 실측 = 차이 0).
//
// ■ 그래서 이렇게 고친다
// `skyScenes`(섬·던전)에 **처음 들어갈 때 활성 씬의 하늘을 통째로 스냅샷**해 두고
// 우리 값(= 아레나/데모와 같은 값 · SO 재사용)으로 바꾼다.
// 그 맵들이 **하나도 안 남았을 때** 스냅샷을 **글자 그대로** 되돌린다.
// → 섬·던전은 같은 하늘 · 전투 맵·로비는 원래대로.
//
// ■ C8 되돌리기 — `WLIslandLookSettings.skyScopeEnabled = 0` 이면 아무것도 하지 않는다.
// 씬·에셋 파일을 한 글자도 쓰지 않는다(전부 런타임 메모리).
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.SceneManagement;
namespace WL.Look.Farm
{
public static class WLSkyScope
{
public const string RunnerName = "~WLSkyScopeRunner";
// ── 진단(프로브가 읽는다) ────────────────────────────────────────
public static bool Held;
public static int Acquires, Applies, Restores;
public static string LastLog = "";
public static string HeldBy = "";
struct Snap
{
public bool valid;
public string ownerScene;
public AmbientMode mode;
public Color light, sky, equator, ground;
public float intensity;
public Material skybox;
public bool fog; public Color fogColor; public FogMode fogMode;
public float fogDensity, fogStart, fogEnd;
public DefaultReflectionMode reflMode; public float reflIntensity;
public Light sun;
}
static Snap s_snap;
static int s_snapHandle; // 스냅샷을 뜬 활성 씬
static bool s_installed;
static WLSkyScopeRunner s_runner;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
public static void Install()
{
if (s_installed) return;
var cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.skyScopeEnabled == 0) return;
s_installed = true;
SceneManager.sceneLoaded += OnSceneLoaded;
SceneManager.sceneUnloaded += OnSceneUnloaded;
SceneManager.activeSceneChanged += OnActiveSceneChanged;
#if UNITY_EDITOR
// 🔴 SSAO 는 렌더러 **에셋**의 값이라 섬에서 Play 를 끄면 「꺼진 채」 메모리에 남는다 → Play 를 나갈 때·편집 모드로 돌아올 때 반드시 되돌린다.
UnityEditor.EditorApplication.playModeStateChanged += st =>
{
if (st == UnityEditor.PlayModeStateChange.ExitingPlayMode || st == UnityEditor.PlayModeStateChange.EnteredEditMode) RestoreSsao();
};
// 도메인 리로드(Play 중 컴파일) 뒤 — 이전 도메인이 끈 채 남긴 SSAO 를 먼저 되돌리고, 아직 섬이면 아래에서 다시 끈다.
RestoreSsao();
#endif
// 🔴 아직 아무 맵도 안 붙은 지금이 「활성 씬의 원래 하늘」이다 — 여기서 한 번만 뜬다.
CaptureIfNeeded();
for (int i = 0; i < SceneManager.sceneCount; i++)
{
var sc = SceneManager.GetSceneAt(i);
if (!sc.isLoaded) continue;
if (IsSsaoScoped(cfg, sc.name)) AcquireSsao(cfg, sc.name);
if (IsScoped(cfg, sc.name)) Acquire(cfg, sc.name);
}
}
static void OnActiveSceneChanged(Scene from, Scene to)
{
// 활성 씬이 바뀌면 하늘의 주인이 바뀐 것 — 새 주인의 원래 값을 다시 뜬다.
s_snap.valid = false;
Held = false; HeldBy = "";
CaptureIfNeeded();
}
public static bool IsScoped(WLIslandLookSettings cfg, string sceneName)
{
if (cfg == null || cfg.skyScenes == null) return false;
for (int i = 0; i < cfg.skyScenes.Length; i++)
if (cfg.skyScenes[i] == sceneName) return true;
return false;
}
static void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
var cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.enabled_ == 0 || cfg.skyScopeEnabled == 0) return;
if (IsSsaoScoped(cfg, scene.name)) AcquireSsao(cfg, scene.name);
if (!IsScoped(cfg, scene.name)) return;
Acquire(cfg, scene.name);
}
static void OnSceneUnloaded(Scene scene)
{
var cfg = WLIslandLookSettings.Instance;
if (cfg == null || cfg.skyScopeEnabled == 0) return;
if (!Held && !SsaoHeld) return;
// 🔴 맵을 바꿀 때 `SceneInfo` 는 **같은 프레임에** 내리기와 올리기를 함께 시작한다.
// 그래서 바로 되돌리면 「섬 → 던전」 에서 잘못 되돌아간다. 두 프레임 기다렸다
// **그때도 범위 맵이 하나도 없을 때만** 되돌린다.
EnsureRunner();
if (s_runner != null) s_runner.StartCoroutine(Co_MaybeRelease(cfg));
else ReleaseIfEmpty(cfg);
}
static IEnumerator Co_MaybeRelease(WLIslandLookSettings cfg)
{
yield return null;
yield return null;
// 🔴 섬 → 던전처럼 범위 안에서 맵만 바뀔 때는 새 맵이 다 올라올 때까지 기다린다.
// (안 기다리면 반납했다가 다시 빌리면서 화면이 한 번 튄다 — 816h 실측)
float wait = cfg != null ? cfg.skyReleaseDelaySeconds : 1.5f;
if (wait > 0f) yield return new WaitForSeconds(wait);
ReleaseIfEmpty(cfg);
}
static void ReleaseIfEmpty(WLIslandLookSettings cfg)
{
bool sky = false, ssao = false;
for (int i = 0; i < SceneManager.sceneCount; i++)
{
var sc = SceneManager.GetSceneAt(i);
if (!sc.isLoaded) continue;
if (IsScoped(cfg, sc.name)) sky = true; // 아직 섬/던전에 있다
if (IsSsaoScoped(cfg, sc.name)) ssao = true; // 아직 섬에 있다
}
if (!ssao) RestoreSsao();
if (!sky) Release(cfg);
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-16 PD 「풀이 얼룩덜룩 — 원인을 더 찾아라」 A/B 실측(같은 카메라 · 풀밭 450×290 px):
// SSAO(렌더러 피처 · 반지름 0.25 m) 가 풀잎 6만 장마다 발밑 그늘을 만들어 얼룩이 됐다 — 끄면 국소 질감 0.70 → 0.07(1/10),
// 구름 강도 0/0.15 · 풀잎 세로 명암 0 · 카메라 각도 35/65 는 전부 ±0.05 안(원인 아님).
// 렌더러 에셋은 전투 맵과 공유라(파일 0줄) **섬 씬이 떠 있는 동안만** 피처를 끄고 나갈 때 켠다.
// ─────────────────────────────────────────────────────────────────
public static bool SsaoHeld;
public static int SsaoOffCount, SsaoRestores;
static readonly System.Collections.Generic.List<UnityEngine.Rendering.Universal.ScriptableRendererFeature> s_ssaoOff =
new System.Collections.Generic.List<UnityEngine.Rendering.Universal.ScriptableRendererFeature>(2);
public static bool IsSsaoScoped(WLIslandLookSettings cfg, string sceneName)
{
if (cfg == null || cfg.islandSsaoOff == 0 || cfg.ssaoOffScenes == null) return false;
for (int i = 0; i < cfg.ssaoOffScenes.Length; i++)
if (cfg.ssaoOffScenes[i] == sceneName) return true;
return false;
}
const string SsaoSessionKey = "WL_SSAO_OFF_BY_ISLAND"; // 에디터 도메인 리로드(Play 중 컴파일)에도 살아남는 표식 — 정적 목록은 리로드에 지워진다(2026-09-16 실측: 섬에서 리컴파일 → SSAO 가 꺼진 채 남음)
static bool IsSsaoFeature(UnityEngine.Rendering.Universal.ScriptableRendererFeature f)
{
return f != null && (f.GetType().Name == "ScreenSpaceAmbientOcclusion" || f.name == "SSAO");
}
/// <summary>섬에 들어갈 때 — 켜져 있는 SSAO 피처(이름 · 형식명으로 판별 · URP 클래스는 internal)를 끄고 기억한다.</summary>
public static void AcquireSsao(WLIslandLookSettings cfg, string sceneName)
{
if (cfg == null || cfg.islandSsaoOff == 0) return;
var feats = Resources.FindObjectsOfTypeAll<UnityEngine.Rendering.Universal.ScriptableRendererFeature>();
for (int i = 0; i < feats.Length; i++)
{
var f = feats[i];
if (!IsSsaoFeature(f) || !f.isActive) continue;
if (s_ssaoOff.Contains(f)) continue;
f.SetActive(false);
s_ssaoOff.Add(f);
SsaoOffCount++;
}
SsaoHeld = s_ssaoOff.Count > 0;
#if UNITY_EDITOR
if (SsaoHeld) UnityEditor.SessionState.SetBool(SsaoSessionKey, true);
#endif
if (SsaoHeld) cfg.Log("SSAO 끔 — " + sceneName + " 동안 · 피처 " + s_ssaoOff.Count + "개(나갈 때 복원)");
}
/// <summary>
/// 섬을 나갈 때(또는 에디터 Play 종료·편집 모드 복귀) — 우리가 끈 피처를 다시 켠다.
/// 정적 목록이 비어 있어도 세션 표식이 남아 있으면(도메인 리로드로 목록만 잃은 경우) SSAO 형식 피처를 전부 켠다(에셋 원래 값 = 켜짐).
/// </summary>
public static void RestoreSsao()
{
bool flagged = false;
#if UNITY_EDITOR
flagged = UnityEditor.SessionState.GetBool(SsaoSessionKey, false);
#endif
if (s_ssaoOff.Count == 0 && !flagged) { SsaoHeld = false; return; }
int n = 0;
if (s_ssaoOff.Count > 0)
{
for (int i = 0; i < s_ssaoOff.Count; i++) if (s_ssaoOff[i] != null && !s_ssaoOff[i].isActive) { s_ssaoOff[i].SetActive(true); n++; }
}
else
{
var feats = Resources.FindObjectsOfTypeAll<UnityEngine.Rendering.Universal.ScriptableRendererFeature>();
for (int i = 0; i < feats.Length; i++) if (IsSsaoFeature(feats[i]) && !feats[i].isActive) { feats[i].SetActive(true); n++; }
}
SsaoRestores += n;
s_ssaoOff.Clear();
SsaoHeld = false;
#if UNITY_EDITOR
UnityEditor.SessionState.EraseBool(SsaoSessionKey);
#endif
}
// ─────────────────────────────────────────────────────────────────
/// <summary>섬·던전에 들어갈 때 — 활성 씬의 하늘을 스냅샷하고 우리 값으로 바꾼다.</summary>
public static void Acquire(WLIslandLookSettings cfg, string sceneName)
{
if (cfg == null || cfg.skyScopeEnabled == 0) return;
// 🔴 스냅샷은 **활성 씬마다 딱 한 번**만 뜬다.
// 여기서 다시 뜨면, 이미 다른 시스템(던전 씬에 들어 있는 `WL_ReferenceLook`)이
// 앰비언트를 바꿔 놓은 뒤의 값을 「원래 값」으로 잘못 기억한다(816h 1차 실측에서 실제로 났다).
CaptureIfNeeded();
if (!Held)
{
Held = true;
HeldBy = sceneName;
Acquires++;
}
ApplySky(cfg);
}
/// <summary>섬·던전에서 나갈 때 — 스냅샷을 글자 그대로 되돌린다.</summary>
public static void Release(WLIslandLookSettings cfg)
{
if (!Held) return;
Restore();
Held = false; HeldBy = "";
Restores++;
if (cfg != null) cfg.Log("하늘 반납 — 활성 씬(" + s_snap.ownerScene + ") 원래대로");
LastLog = "released → " + Describe();
}
static void CaptureIfNeeded()
{
var act = SceneManager.GetActiveScene();
if (s_snap.valid && s_snapHandle == act.handle) return;
s_snapHandle = act.handle;
Capture();
}
static void Capture()
{
s_snap.valid = true;
s_snap.ownerScene = SceneManager.GetActiveScene().name;
s_snap.mode = RenderSettings.ambientMode;
s_snap.light = RenderSettings.ambientLight;
s_snap.sky = RenderSettings.ambientSkyColor;
s_snap.equator = RenderSettings.ambientEquatorColor;
s_snap.ground = RenderSettings.ambientGroundColor;
s_snap.intensity = RenderSettings.ambientIntensity;
s_snap.skybox = RenderSettings.skybox;
s_snap.fog = RenderSettings.fog;
s_snap.fogColor = RenderSettings.fogColor;
s_snap.fogMode = RenderSettings.fogMode;
s_snap.fogDensity = RenderSettings.fogDensity;
s_snap.fogStart = RenderSettings.fogStartDistance;
s_snap.fogEnd = RenderSettings.fogEndDistance;
s_snap.reflMode = RenderSettings.defaultReflectionMode;
s_snap.reflIntensity = RenderSettings.reflectionIntensity;
s_snap.sun = RenderSettings.sun;
}
static void Restore()
{
if (!s_snap.valid) return;
RenderSettings.ambientMode = s_snap.mode;
RenderSettings.ambientLight = s_snap.light;
RenderSettings.ambientSkyColor = s_snap.sky;
RenderSettings.ambientEquatorColor = s_snap.equator;
RenderSettings.ambientGroundColor = s_snap.ground;
RenderSettings.ambientIntensity = s_snap.intensity;
RenderSettings.skybox = s_snap.skybox;
RenderSettings.fog = s_snap.fog;
RenderSettings.fogColor = s_snap.fogColor;
RenderSettings.fogMode = s_snap.fogMode;
RenderSettings.fogDensity = s_snap.fogDensity;
RenderSettings.fogStartDistance = s_snap.fogStart;
RenderSettings.fogEndDistance = s_snap.fogEnd;
RenderSettings.defaultReflectionMode = s_snap.reflMode;
RenderSettings.reflectionIntensity = s_snap.reflIntensity;
RenderSettings.sun = s_snap.sun; // 내려간 씬의 라이트면 자동으로 null 이 된다
}
/// <summary>아레나/데모와 같은 하늘 — 값은 전부 SO 에서 온다(새 값 0).</summary>
static void ApplySky(WLIslandLookSettings cfg)
{
// 하늘 그 자체(스카이박스·안개·반사)는 언제나 우리 값으로.
if (cfg.fogOff != 0) RenderSettings.fog = false;
if (cfg.skybox != null) RenderSettings.skybox = cfg.skybox;
RenderSettings.defaultReflectionMode = DefaultReflectionMode.Skybox;
// 🔴 앰비언트(무드)는 `WLReferenceLook`(814t 아레나 룩)의 것이다.
// 던전 씬에는 `WL_ReferenceLook` 이 **씬에 들어 있어** 우리보다 먼저 켜진다.
// 그때 여기서 앰비언트를 덮어쓰면 던전의 아레나 무드가 죽는다 → 이미 걸렸으면 손대지 않는다.
// (섬에서는 우리가 먼저 → 이 값을 깔고, 그 위에 아레나 무드가 얹힌다 = 던전과 같은 결과)
if (!WL.Look.Arena.WLReferenceLook.IsApplied)
{
RenderSettings.ambientMode = AmbientMode.Skybox;
RenderSettings.ambientSkyColor = cfg.ambientSky;
RenderSettings.ambientEquatorColor = cfg.ambientEquator;
RenderSettings.ambientGroundColor = cfg.ambientGround;
RenderSettings.ambientLight = cfg.ambientSky;
RenderSettings.ambientIntensity = cfg.ambientIntensity;
}
Applies++;
LastLog = "held(" + HeldBy + ") → " + Describe();
}
public static string Describe()
{
return "amb=" + RenderSettings.ambientMode
+ " light=(" + RenderSettings.ambientLight.r.ToString("F3") + ","
+ RenderSettings.ambientLight.g.ToString("F3") + ","
+ RenderSettings.ambientLight.b.ToString("F3") + ")"
+ " skybox=" + (RenderSettings.skybox ? RenderSettings.skybox.name : "없음")
+ " fog=" + RenderSettings.fog
+ " refl=" + RenderSettings.defaultReflectionMode
+ " sun=" + (RenderSettings.sun ? RenderSettings.sun.name : "없음");
}
static void EnsureRunner()
{
if (s_runner != null) return;
var go = new GameObject(RunnerName);
go.hideFlags = HideFlags.DontSave;
Object.DontDestroyOnLoad(go);
s_runner = go.AddComponent<WLSkyScopeRunner>();
}
}
/// <summary>코루틴 한 개를 태우기 위한 빈 그릇.</summary>
public sealed class WLSkyScopeRunner : MonoBehaviour { }
}