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