Project_WL/Assets/WL/Look/PixelLook.cs

358 lines
19 KiB
C#
Raw Permalink 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>도트 룩 스위치. 정적 · 새 Manager/Singleton 0(러너는 숨김 GameObject 1개).</summary>
public static class PixelLook
{
// ───────────────────────────────────────── 설정
static WLPixelLookSettings Cfg { get { return WLPixelLookSettings.Instance; } }
/// <summary>기능이 살아 있는가 = 에셋 존재 · enabled · RuntimeDisabled off.</summary>
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;
// ───────────────────────────────────────── 진단(프로브가 읽는다)
/// <summary>맵 경계를 본 횟수 · 적용 횟수 · 복원 횟수 · 재계산(해상도 변화) 횟수.</summary>
public static int Boundaries, Applies, Restores, Rescales;
/// <summary>로비라서 건너뛴 횟수 · 활성 URP 에셋을 못 찾아 건너뛴 횟수.</summary>
public static int SkippedLobby, SkippedNoAsset;
/// <summary>지금 적용돼 있는가.</summary>
public static bool IsApplied { get { return s_applied; } }
/// <summary>적용 전 원래 renderScale(적용 중일 때만 의미 있음).</summary>
public static float OriginalRenderScale { get { return s_origRenderScale; } }
/// <summary>적용 전 원래 upscalingFilter(적용 중일 때만 의미 있음).</summary>
public static UpscalingFilterSelection OriginalFilter { get { return s_origFilter; } }
/// <summary>값을 바꾼 파이프라인 에셋 이름(없으면 빈 문자열).</summary>
public static string TargetAssetName { get { return s_asset != null ? s_asset.name : string.Empty; } }
public static string LastLog = "";
// ─────────────────────────────────────────────────────────────────────
// 현재 활성 파이프라인 에셋
// ─────────────────────────────────────────────────────────────────────
/// <summary>지금 실제로 쓰이는 URP 에셋(Quality 별 오버라이드가 있으면 그것 · 없으면 Graphics 기본).</summary>
public static UniversalRenderPipelineAsset CurrentAsset
{
get { return GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; }
}
/// <summary>목표 픽셀 높이와 화면 높이로 renderScale 을 구한다(SO 상·하한으로 클램프).</summary>
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;
}
}
// ─────────────────────────────────────────────────────────────────────
// 적용 / 복원 (프로브·캡처도 이 두 개만 쓴다)
// ─────────────────────────────────────────────────────────────────────
/// <summary>SO 값 그대로 적용. 이미 적용 중이면 해상도만 다시 맞춘다.</summary>
public static bool ApplyNow(string reason)
{
var c = Cfg;
if (c == null) return false;
return ApplyNow(c.targetHeightPx, Screen.height, reason);
}
/// <summary>목표 픽셀 높이·화면 높이를 지정해서 적용(캡처 4단이 이 경로를 쓴다).</summary>
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;
}
/// <summary>원래 값으로 되돌린다. 적용 중이 아니면 아무것도 하지 않는다(true = 실제로 되돌렸다).</summary>
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;
}
/// <summary>프로브·A/B 용: 경계 감시 상태를 처음으로 되돌린다(적용 중이면 먼저 복원한다).</summary>
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
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브 전용 공개 진입점(AgentScripts 는 별도 어셈블리라 internal 을 못 본다).</summary>
public static void TickForProbe() { Tick(); }
/// <summary>프로브 전용 공개 진입점 — 에디트 모드에서는 러너를 만들지 않는 것을 확인하는 데 쓴다.</summary>
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"); }
}
/// <summary>맵 진입/이탈 경계 — AutoCombatOnEnter 와 같은 규칙(메인 PC 교체 또는 맵 ID 변경).</summary>
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<PixelLookRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (WLPixelLookSettings.Enabled) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8)
}
}
/// <summary>도트 룩 스위치의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class PixelLookRunner : MonoBehaviour
{
void Update() { PixelLook.Tick(); }
void OnDisable() { PixelLook.RestoreNow("runner-disable"); }
void OnApplicationQuit() { PixelLook.RestoreNow("app-quit"); }
}
/// <summary>
/// 🔴 에디터 전용 안전망 — 도메인 리로드로 static 이 날아가도 파이프라인 에셋을 되돌린다.
/// 빌드에서는 세 함수 모두 빈 몸통이라 비용 0.
/// </summary>
internal static class EditorSafetyNet
{
#if UNITY_EDITOR
const string kKey = "WL814a.PixelLook.Pending"; // "<assetPath>|<renderScale>|<filter>"
/// <summary>플레이모드 상태 훅 등록 + 지난 세션에 남은 값이 있으면 지금 복원한다.</summary>
[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<UniversalRenderPipelineAsset>(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
}
}