// ───────────────────────────────────────────────────────────────────────────── // PaletteDither.cs — 팔레트 양자화 + 디더 후처리를 **도트 모드에서만** 켜는 연결 코드 (WL-814j · #814) // // PD 지시 #814 「도트같은 느낌이 전혀 나지 않고 해상도만 저하된 느낌」 → B안 · 발주서 WL-814j §1-3 // // ■ 이 파일이 하는 것 — 원본 훅 0 · 새 Manager/Singleton 0(정적 + 러너 GameObject 1개) // ① 814d `LookModeHub.CameraModeChanged` 를 구독해 모드가 바뀌는 순간 즉시 반영한다. // ② 그리고 매 프레임 「지금 켜져 있어야 하는가」를 비교만 해서(참조·정수 비교 = GC 0) 맞춘다. // — 허브 이벤트는 **PD 가 고른 값이 바뀔 때만** 오고, 로비 강등·맵 이탈·리그 재조립은 알려 주지 않는다. // 리그가 다시 조립되면 대상 카메라 참조가 바뀌므로 폴링이 필요하다(814d 보고 §5 계약). // ③ 켜짐 = URP 렌더러의 PaletteDitherFeature 를 SetActive(true). 꺼짐 = 원래 값으로 되돌린다. // (814c EnvLook.SetFeature 와 같은 방식 — 디스크 에셋에는 쓰지 않는다 · SetDirty 0) // // ■ 켜지는 조건(전부 AND) // · WLPaletteSettings.Enabled (C8) // · LookModeHub.AppliedMode >= minCameraMode (기본 1) — 로비·기본 모드에서는 AppliedMode 가 0 이라 꺼진다 // · requirePixelRig 이면 814b 리그가 조립돼 있고 게임 카메라가 살아 있을 것 // // 🔴 814b/814d 파일은 **읽기·구독만** 한다(수정 0). // 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z · ErrorLogHookManager 팝업). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using UnityEngine.Rendering.Universal; using WL.Look.Toggle; using WL.PixelArt; namespace WL.Look.Palette { /// 팔레트 후처리 on/off. 정적 · 전용 러너 1개. public static class PaletteDither { // ── 진단(프로브가 읽는다 · 실측만) ──────────────────────────────────── public static int Ons, Offs, HubEvents, FeatureMisses; public static string LastLog = ""; /// 지금 후처리가 켜져 있는가. public static bool IsOn { get { return s_on; } } /// 찾아 둔 렌더러 피처(진단). 못 찾으면 null. public static ScriptableRendererFeature Feature { get { return s_feature; } } /// 피처가 지금 활성인가(진단). public static bool FeatureActiveNow { get { return s_feature != null && s_feature.isActive; } } // ── 내부 상태 ───────────────────────────────────────────────────────── static ScriptableRendererFeature s_feature; static int s_featureLookups; static bool s_featureOrig, s_featureChanged; static bool s_on; static bool s_subscribed; static GameObject s_runner; static WLPaletteSettings Cfg { get { return WLPaletteSettings.Instance; } } // ───────────────────────────────────────────────────────────────────── // 틱 · 판정 // ───────────────────────────────────────────────────────────────────── /// 러너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). 비교뿐 = GC 0. public static void Tick() { bool want = Want(); if (want != s_on) Apply(want); } /// 지금 켜져 있어야 하는가(참조·정수 비교만). public static bool Want() { if (!WLPaletteSettings.Enabled) return false; var c = Cfg; if (c == null) return false; if (LookModeHub.AppliedMode < c.minCameraMode) return false; if (c.requirePixelRig) { if (!PixelCameraRig.IsAssembled) return false; if (PixelCameraRig.GameCamera == null) return false; } return true; } /// on/off 를 실제로 적용한다. 같은 값이면 무동작. public static bool Apply(bool on) { if (on == s_on && (!on || FeatureActiveNow)) return s_on; s_on = on; if (on) Ons++; else Offs++; bool ok = SetFeature(on); var c = Cfg; if (c != null && c.verboseLog) LastLog = Log("팔레트 후처리 " + (on ? "ON" : "OFF") + " · 모드 " + LookModeHub.AppliedMode + " · 리그 " + PixelCameraRig.IsAssembled + " · 피처 " + ok); return s_on; } // ───────────────────────────────────────────────────────────────────── // 렌더러 피처 // ───────────────────────────────────────────────────────────────────── /// URP 렌더러의 팔레트 피처를 켜고 끈다. 없으면 아무 것도 하지 않는다(후처리만 안 걸린다). public static bool SetFeature(bool on) { var f = FindFeature(); if (f == null) { FeatureMisses++; return false; } if (on) { if (!s_featureChanged) { s_featureOrig = f.isActive; s_featureChanged = true; } if (!f.isActive) f.SetActive(true); // 🔴 SetDirty 0 (디스크 에셋 무변경) return true; } if (!s_featureChanged) return false; f.SetActive(s_featureOrig); s_featureChanged = false; return true; } const int kMaxLookups = 8; static ScriptableRendererFeature FindFeature() { if (s_feature != null) return s_feature; // 🔴 배열을 새로 만드는 호출이라 전환 때만 · 최대 kMaxLookups 회만 찾는다(프레임당 반복 호출 0). if (s_featureLookups >= kMaxLookups) return null; s_featureLookups++; var all = Resources.FindObjectsOfTypeAll(); if (all != null && all.Length > 0) s_feature = all[0]; return s_feature; } /// 프로브: 피처 캐시를 비운다(에디트 모드 재실행). public static void ClearFeatureCache() { s_feature = null; s_featureLookups = 0; } /// 프로브: 상태·카운터를 시작값으로 되돌리고 피처도 원복한다. public static string ResetForProbe() { SetFeature(false); s_on = false; Ons = Offs = HubEvents = FeatureMisses = 0; LastLog = ""; return "리셋"; } public static string Dump() { var c = Cfg; return "[Palette] on=" + s_on + " 피처활성=" + FeatureActiveNow + " 대상=" + (PaletteDitherFeature.ResolvedTarget != null ? PaletteDitherFeature.ResolvedTarget.name : "(없음)") + " · 모드 " + LookModeHub.AppliedMode + " 리그 " + PixelCameraRig.IsAssembled + " · levels " + (c != null ? c.levels : 0) + " dither " + (c != null ? c.ditherStrength : 0f) + " (" + (c != null && c.dither8x8 ? "8x8" : "4x4") + ")" + " · 패스 추가 " + PaletteDitherFeature.PassesAdded + " 건너뜀 " + PaletteDitherFeature.PassesSkipped + " · on " + Ons + " off " + Offs + " 허브이벤트 " + HubEvents; } static string Log(string msg) { LastLog = msg; var c = Cfg; if (c != null && c.verboseLog) Debug.Log("[WL814j] " + msg); // 🔴 LogError 금지(813z) return msg; } // ───────────────────────────────────────────────────────────────────── // 허브 구독 · 러너 // ───────────────────────────────────────────────────────────────────── static void OnCameraModeChanged(int mode) { HubEvents++; Tick(); // 모드 전환 프레임에 바로 반영(폴링 1프레임을 기다리지 않는다) } /// 프로브: 구독을 보장한다(에디트 모드에서도 부를 수 있다). public static void EnsureSubscribed() { if (s_subscribed) return; s_subscribed = true; LookModeHub.CameraModeChanged += OnCameraModeChanged; } static void Unsubscribe() { if (!s_subscribed) return; s_subscribed = false; LookModeHub.CameraModeChanged -= OnCameraModeChanged; // 허브는 구독자를 모른다 — 스스로 뗀다 } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (s_runner != null) return; if (!WLPaletteSettings.Enabled) return; // C8 — 러너 자체가 뜨지 않는다 EnsureSubscribed(); s_runner = new GameObject("[WL814j] PaletteDitherRunner"); s_runner.hideFlags = HideFlags.HideAndDontSave; s_runner.AddComponent(); Object.DontDestroyOnLoad(s_runner); } internal static void OnRunnerGone() { Unsubscribe(); Apply(false); PaletteDitherFeature.TargetCamera = null; } } /// 팔레트 후처리의 시간 축. GameObject 1개 · 코루틴 0. internal sealed class PaletteDitherRunner : MonoBehaviour { void Update() { PaletteDither.Tick(); } void OnApplicationQuit() { PaletteDither.OnRunnerGone(); } void OnDestroy() { PaletteDither.OnRunnerGone(); } } }