364 lines
19 KiB
C#
364 lines
19 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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 { }
|
||
}
|