// ───────────────────────────────────────────────────────────────────────────── // PixelLook.cs — 도트(픽셀아트) 룩 런타임 스위치 (인게임 맵 한정 · 원본 훅 0 · 에셋 파일 수정 0) // // PD 지시 #814 · 발주서 WL-814a §1-2 (2026-09-10) // // ■ 무엇을 하나 // 맵 진입 경계(= AutoCombatOnEnter / EffectWarmup 과 **같은 판정**: 메인 PC 교체 또는 맵 ID 변경)에서 // **활성 파이프라인 에셋**(GraphicsSettings.currentRenderPipeline → UniversalRenderPipelineAsset)의 // renderScale = targetHeightPx ÷ Screen.height (1080×1920 · 480px → 0.25) // upscalingFilter = Point (Nearest-Neighbor) // 로 바꾼다. 맵을 벗어나면(타이틀/로비 복귀) · 앱 종료 · 에디터 플레이모드 종료에서 **원래 값으로 되돌린다**. // // ■ 🔴 왜 「되돌리기」가 이 파일의 절반인가 // 파이프라인 에셋은 디스크 위의 프로젝트 에셋이다. 에디터 Play 중에 그 오브젝트의 프로퍼티를 바꾸면 // Play 를 멈춰도 **메모리에 그 값이 남고**, PD 가 「Save Project」(Ctrl+S) 를 누르는 순간 // `Assets/Settings/URP-*.asset` 에 renderScale 0.25 가 그대로 기록된다 = 프로젝트 오염. // 그래서 // · EditorUtility.SetDirty 호출 **0** // · 4중 복원: 맵 이탈 · 러너 OnDisable · OnApplicationQuit · EditorApplication.playModeStateChanged // · 복원할 값을 에디터 SessionState 에도 적어 둔다 → 도메인 리로드로 static 이 날아가도 // 다음 에디터 로드에서 EditorSafetyNet 이 마저 되돌린다. // // ■ 성능 // Tick 은 「지금이 폴링 시각인가」 float 비교 1회뿐이다(경계에서만 실제 작업). 프레임당 할당 0. // renderScale 자체의 비용은 URP 가 내부에서 처리한다(렌더 타겟이 작아지므로 오히려 GPU 가 가벼워진다). // // ■ 이 방식의 한계 (에셋 도착 전 1차 시안 · 보고서 §4) // 카메라가 픽셀 격자에 스냅되지 않으므로 카메라가 움직일 때 가장자리에서 **픽셀 크롤/지터**가 보인다. // 외곽선·팔레트 제한도 없다. 그 셋이 Critter 3D Pixel Camera 가 대체할 지점이다. // // ■ C8 롤백 // WLPixelLookSettings.enabled = 0 → 러너 오브젝트 0 · 파이프라인 에셋 접촉 0. // // 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것. // 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; namespace WL.Look { /// 도트 룩 스위치. 정적 · 새 Manager/Singleton 0(러너는 숨김 GameObject 1개). public static class PixelLook { // ───────────────────────────────────────── 설정 static WLPixelLookSettings Cfg { get { return WLPixelLookSettings.Instance; } } /// 기능이 살아 있는가 = 에셋 존재 · enabled · RuntimeDisabled off. public static bool Active { get { return WLPixelLookSettings.Enabled; } } // ───────────────────────────────────────── 상태(적용 중일 때만 의미가 있다) static UniversalRenderPipelineAsset s_asset; // 값을 바꾼 그 에셋(복원 대상) static float s_origRenderScale; static UpscalingFilterSelection s_origFilter; static bool s_applied; static int s_appliedScreenHeight; static int s_appliedTargetPx; // ───────────────────────────────────────── 경계 감시 static PCActor s_lastPc; static int s_lastMapId = int.MinValue; static float s_nextPoll; static bool s_faulted; // ───────────────────────────────────────── 진단(프로브가 읽는다) /// 맵 경계를 본 횟수 · 적용 횟수 · 복원 횟수 · 재계산(해상도 변화) 횟수. public static int Boundaries, Applies, Restores, Rescales; /// 로비라서 건너뛴 횟수 · 활성 URP 에셋을 못 찾아 건너뛴 횟수. public static int SkippedLobby, SkippedNoAsset; /// 지금 적용돼 있는가. public static bool IsApplied { get { return s_applied; } } /// 적용 전 원래 renderScale(적용 중일 때만 의미 있음). public static float OriginalRenderScale { get { return s_origRenderScale; } } /// 적용 전 원래 upscalingFilter(적용 중일 때만 의미 있음). public static UpscalingFilterSelection OriginalFilter { get { return s_origFilter; } } /// 값을 바꾼 파이프라인 에셋 이름(없으면 빈 문자열). public static string TargetAssetName { get { return s_asset != null ? s_asset.name : string.Empty; } } public static string LastLog = ""; // ───────────────────────────────────────────────────────────────────── // 현재 활성 파이프라인 에셋 // ───────────────────────────────────────────────────────────────────── /// 지금 실제로 쓰이는 URP 에셋(Quality 별 오버라이드가 있으면 그것 · 없으면 Graphics 기본). public static UniversalRenderPipelineAsset CurrentAsset { get { return GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; } } /// 목표 픽셀 높이와 화면 높이로 renderScale 을 구한다(SO 상·하한으로 클램프). public static float ComputeRenderScale(int targetHeightPx, int screenHeight) { var c = Cfg; float lo = c != null ? c.minRenderScale : 0.1f; float hi = c != null ? c.maxRenderScale : 1f; if (screenHeight <= 0) return hi; return Mathf.Clamp((float)targetHeightPx / screenHeight, lo, hi); } static UpscalingFilterSelection ToUrp(PixelUpscaleFilter f) { switch (f) { case PixelUpscaleFilter.Linear: return UpscalingFilterSelection.Linear; case PixelUpscaleFilter.Auto: return UpscalingFilterSelection.Auto; case PixelUpscaleFilter.FSR: return UpscalingFilterSelection.FSR; default: return UpscalingFilterSelection.Point; } } // ───────────────────────────────────────────────────────────────────── // 적용 / 복원 (프로브·캡처도 이 두 개만 쓴다) // ───────────────────────────────────────────────────────────────────── /// SO 값 그대로 적용. 이미 적용 중이면 해상도만 다시 맞춘다. public static bool ApplyNow(string reason) { var c = Cfg; if (c == null) return false; return ApplyNow(c.targetHeightPx, Screen.height, reason); } /// 목표 픽셀 높이·화면 높이를 지정해서 적용(캡처 4단이 이 경로를 쓴다). public static bool ApplyNow(int targetHeightPx, int screenHeight, string reason) { var c = Cfg; if (c == null || !WLPixelLookSettings.Enabled) return false; var asset = CurrentAsset; if (asset == null) { SkippedNoAsset++; LastLog = "활성 URP 에셋 없음 — 건너뜀"; return false; } // 🔴 다른 에셋으로 바뀌었으면(Quality 변경) 이전 에셋부터 되돌린다. if (s_applied && !ReferenceEquals(asset, s_asset)) RestoreNow("quality-changed"); if (!s_applied) { s_asset = asset; s_origRenderScale = asset.renderScale; s_origFilter = asset.upscalingFilter; EditorSafetyNet.Remember(asset, s_origRenderScale, s_origFilter); // 에디터 전용 · 빌드에서는 빈 함수 } float scale = ComputeRenderScale(targetHeightPx, screenHeight); var filter = ToUrp(c.filter); // 🔴 SetDirty 호출 0 — 프로퍼티 대입만 한다. asset.renderScale = scale; asset.upscalingFilter = filter; s_appliedScreenHeight = screenHeight; s_appliedTargetPx = targetHeightPx; if (!s_applied) { s_applied = true; Applies++; } else Rescales++; if (c.verboseLog) Debug.Log("[WL814a PixelLook] 적용(" + reason + ") " + asset.name + " renderScale " + s_origRenderScale.ToString("0.####") + " → " + scale.ToString("0.####") + " · filter " + s_origFilter + " → " + filter + " · " + targetHeightPx + "px / " + screenHeight + "px"); LastLog = "적용 " + reason; return true; } /// 원래 값으로 되돌린다. 적용 중이 아니면 아무것도 하지 않는다(true = 실제로 되돌렸다). public static bool RestoreNow(string reason) { if (!s_applied) return false; var asset = s_asset; s_applied = false; s_asset = null; if (asset != null) { asset.renderScale = s_origRenderScale; // 🔴 SetDirty 0 asset.upscalingFilter = s_origFilter; } EditorSafetyNet.Forget(); Restores++; var c = Cfg; if (c != null && c.verboseLog) Debug.Log("[WL814a PixelLook] 복원(" + reason + ") " + (asset != null ? asset.name : "?") + " renderScale → " + s_origRenderScale.ToString("0.####") + " · filter → " + s_origFilter); LastLog = "복원 " + reason; return true; } /// 프로브·A/B 용: 경계 감시 상태를 처음으로 되돌린다(적용 중이면 먼저 복원한다). public static void ResetForProbe() { RestoreNow("probe-reset"); s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = 0f; s_faulted = false; Boundaries = Applies = Restores = Rescales = SkippedLobby = SkippedNoAsset = 0; s_appliedScreenHeight = 0; s_appliedTargetPx = 0; LastLog = ""; } // ───────────────────────────────────────────────────────────────────── // 틱 (러너가 매 프레임 부른다) — 프레임당 할당 0 // ───────────────────────────────────────────────────────────────────── /// 프로브 전용 공개 진입점(AgentScripts 는 별도 어셈블리라 internal 을 못 본다). public static void TickForProbe() { Tick(); } /// 프로브 전용 공개 진입점 — 에디트 모드에서는 러너를 만들지 않는 것을 확인하는 데 쓴다. public static void EnsureRunnerForProbe() { EnsureRunner(); } internal static void Tick() { if (s_faulted || !WLPixelLookSettings.Enabled) return; var c = Cfg; float now = Time.unscaledTime; if (now < s_nextPoll) return; s_nextPoll = now + Mathf.Max(0.05f, c.pollSeconds); try { PollBoundary(c); } catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외) — " + ex.Message; RestoreNow("fault"); } } /// 맵 진입/이탈 경계 — AutoCombatOnEnter 와 같은 규칙(메인 PC 교체 또는 맵 ID 변경). static void PollBoundary(WLPixelLookSettings c) { if (!Application.isPlaying) return; // 에디트 모드 오염 0(프로브는 ApplyNow/RestoreNow 로 직접 들어온다) var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc)) { // PC 가 없다 = 타이틀·로딩·로비 복귀 = 맵 이탈 s_lastPc = null; s_lastMapId = int.MinValue; if (s_applied && c.restoreOnExit) RestoreNow("map-exit(no-pc)"); return; } var info = InGameInfo.Ins; if (DSUtil.CheckNull(info)) return; // 아직 준비 안 됨 — 경계를 소비하지 않는다 int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; bool pcChanged = !ReferenceEquals(pc, s_lastPc); bool mapChanged = map != s_lastMapId; if (!pcChanged && !mapChanged) { // 경계가 아니면: 해상도 변화만 따라간다(회전·창 크기). if (s_applied && c.trackScreenSize && Screen.height != s_appliedScreenHeight) ApplyNow(s_appliedTargetPx > 0 ? s_appliedTargetPx : c.targetHeightPx, Screen.height, "screen-resize"); return; } s_lastPc = pc; s_lastMapId = map; Boundaries++; bool lobby = info.Get_GameMode() == eGameMode.Lobby; if (c.applyOnlyInBattleMap && lobby) { SkippedLobby++; if (s_applied && c.restoreOnExit) RestoreNow("map-exit(lobby)"); return; } ApplyNow(c.targetHeightPx, Screen.height, "map-enter"); } // ───────────────────────────────────────────────────────────────────── // 러너 (숨김 GameObject 1개 · SO off 면 아예 만들지 않는다 = C8) // ───────────────────────────────────────────────────────────────────── static PixelLookRunner s_runner; internal static void EnsureRunner() { if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; var go = new GameObject("[WL814a] PixelLookRunner"); go.hideFlags = HideFlags.HideAndDontSave; s_runner = go.AddComponent(); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (WLPixelLookSettings.Enabled) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8) } } /// 도트 룩 스위치의 시간 축. 숨김 GameObject 1개 · 코루틴 0. internal sealed class PixelLookRunner : MonoBehaviour { void Update() { PixelLook.Tick(); } void OnDisable() { PixelLook.RestoreNow("runner-disable"); } void OnApplicationQuit() { PixelLook.RestoreNow("app-quit"); } } /// /// 🔴 에디터 전용 안전망 — 도메인 리로드로 static 이 날아가도 파이프라인 에셋을 되돌린다. /// 빌드에서는 세 함수 모두 빈 몸통이라 비용 0. /// internal static class EditorSafetyNet { #if UNITY_EDITOR const string kKey = "WL814a.PixelLook.Pending"; // "||" /// 플레이모드 상태 훅 등록 + 지난 세션에 남은 값이 있으면 지금 복원한다. [UnityEditor.InitializeOnLoadMethod] static void Hook() { UnityEditor.EditorApplication.playModeStateChanged -= OnPlayMode; UnityEditor.EditorApplication.playModeStateChanged += OnPlayMode; RestorePending(); } static void OnPlayMode(UnityEditor.PlayModeStateChange s) { // 🔴 ExitingPlayMode 는 도메인 리로드 **전**이라 여기서 되돌리면 그대로 끝난다. if (s == UnityEditor.PlayModeStateChange.ExitingPlayMode) PixelLook.RestoreNow("exit-playmode"); else if (s == UnityEditor.PlayModeStateChange.EnteredEditMode) RestorePending(); // 혹시 놓친 경우 } internal static void Remember(UniversalRenderPipelineAsset asset, float scale, UpscalingFilterSelection filter) { if (asset == null) return; string path = UnityEditor.AssetDatabase.GetAssetPath(asset); if (string.IsNullOrEmpty(path)) return; UnityEditor.SessionState.SetString(kKey, path + "|" + scale.ToString("R") + "|" + ((int)filter)); } internal static void Forget() { UnityEditor.SessionState.EraseString(kKey); } static void RestorePending() { string v = UnityEditor.SessionState.GetString(kKey, string.Empty); if (string.IsNullOrEmpty(v)) return; UnityEditor.SessionState.EraseString(kKey); int b = v.LastIndexOf('|'); if (b <= 0) return; int a = v.LastIndexOf('|', b - 1); if (a <= 0) return; string path = v.Substring(0, a); float scale; int filter; if (!float.TryParse(v.Substring(a + 1, b - a - 1), out scale)) return; if (!int.TryParse(v.Substring(b + 1), out filter)) return; var asset = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (asset == null) return; asset.renderScale = scale; // 🔴 SetDirty 0 asset.upscalingFilter = (UpscalingFilterSelection)filter; } #else internal static void Remember(UniversalRenderPipelineAsset asset, float scale, UpscalingFilterSelection filter) { } internal static void Forget() { } #endif } }