[WL-814a] 도트 룩 1차 시안: URP 렌더 스케일·포인트 업스케일 런타임 스위치(인게임 한정) · 룩 캡처 4단 (#814)

This commit is contained in:
깃 관리자 2026-09-10 23:00:22 +09:00
parent 2a0625f11d
commit c3b3a16aa0
10 changed files with 1257 additions and 0 deletions

View File

@ -0,0 +1,29 @@
WL-814a 룩 캡처 · 2026-09-10 22:59:13
PASS E0 맵 배치 — WL_Nature
PC 위치(Portal_Start (115.00, 0.00, 9.98) 앞 6 m · 지면 스냅) = (115.00, 0.02, 15.98)
PASS E3 카메라 프리팹 — Main Camera
카메라 = (119.71, 4.60, 15.77) → look (115.00, 1.08, 15.98) · fov 60 · 811o(h 76 · d 3 · up 2.4 · yaw 233) · 화면 앞 방향 (-1.00, 0.00, 0.04)
PASS E1 PC 배치 — Ai01 @ (115.00, 0.02, 15.98)
PASS E2 잡몹 배치 — 3/3 (Batty_A/B/C · scale 1.5 · PC 앞 3~6 m)
🔴 인게임 베이스 카메라 cullingMask = 95 · UI 레이어(5) 포함? 아니오 → UI 는 반드시 Overlay UI 카메라가 그린다 = 같은 스택 = 같은 렌더 스케일
UI 경로(실기) = ScreenSpaceCamera 캔버스 + Overlay 카메라 스택 · 캡처 목업은 베이스 카메라가 보는 레이어 0 에 화면 픽셀 1:1 로 둔다
PASS E3b UI 목업(화면 픽셀 1:1 · 스킬카드 65px × 4 + HP 바) — 배치됨
캡처 look_1920.png · renderScale=1 · 색 238종(1 이면 빈 화면) · PC 661px(전체해상도) · 스킬버튼 65px · Screen 640×480 · uiCam px - · 캔버스 rect (1080.00, 1920.00)
PASS E4 look_1920.png — 332084 B
캡처 look_640.png · renderScale=0.3333 · 색 256종(1 이면 빈 화면) · PC 661px(전체해상도) · 스킬버튼 65px · Screen 640×480 · uiCam px - · 캔버스 rect (1080.00, 1920.00)
PASS E4 look_640.png — 108395 B
캡처 look_480.png · renderScale=0.25 · 색 245종(1 이면 빈 화면) · PC 661px(전체해상도) · 스킬버튼 65px · Screen 640×480 · uiCam px - · 캔버스 rect (1080.00, 1920.00)
PASS E4 look_480.png — 83676 B
캡처 look_360.png · renderScale=0.1875 · 색 241종(1 이면 빈 화면) · PC 661px(전체해상도) · 스킬버튼 65px · Screen 640×480 · uiCam px - · 캔버스 rect (1080.00, 1920.00)
PASS E4 look_360.png — 68957 B
| targetHeightPx | renderScale | 실제 그려진 해상도 | PC 키(px) | 잡몹 키(px) | 스킬버튼 한 변(px) |
|---|---|---|---|---|---|
| 1920 | 1 | 1080×1920 | 661 | 130 | 65 |
| 640 | 0.3333 | 360×640 | 220 | 43 | 22 |
| 480 | 0.25 | 270×480 | 165 | 32 | 16 |
| 360 | 0.1875 | 202×360 | 124 | 24 | 12 |
복원: renderScale=1 filter=Auto · dirty=False
RESULT PASS · 9/9 · FAIL 0

View File

@ -0,0 +1,60 @@
WL-814a 프로브 · 2026-09-10 22:58:23 · Unity 6000.3.23f1
ⓐ 실측 표
① m_PerPlatformDefaultQuality = {} (비어 있음 → 플랫폼별 지정 0 = 전 플랫폼이 m_CurrentQuality 레벨을 쓴다)
level 0 'High Fidelity' → URP-HighFidelity
level 1 'Balanced' ← m_CurrentQuality(현재) → URP-Balanced
level 2 'Performant' → URP-Performant
QualitySettings.renderPipeline(현재 레벨 오버라이드) = URP-Balanced
GraphicsSettings.defaultRenderPipeline = URP-Performant
🔴 GraphicsSettings.currentRenderPipeline(= 실제 활성) = URP-Balanced path=Assets/Settings/URP-Balanced.asset
PASS A1 활성 URP 에셋 존재 — URP-Balanced
② URP-Balanced renderScale=1 · upscalingFilter=Auto · MSAA=1 · HDR=1 · mainLightShadow=1 · shadowDist=50 ← 활성
② URP-HighFidelity renderScale=1 · upscalingFilter=Auto · MSAA=4 · HDR=1 · mainLightShadow=1 · shadowDist=150
② URP-Performant renderScale=1 · upscalingFilter=Auto · MSAA=1 · HDR=0 · mainLightShadow=0 · shadowDist=50
③ NewGameUI.prefab Canvas 1개
- NewGameUI renderMode=ScreenSpaceCamera worldCamera=Camera planeDistance=100 sortingOrder=0
CanvasScaler ref=(1920.00, 1080.00) mode=ScaleWithScreenSize match=Expand → 1080×1920 scaleFactor=0.5625
UI 카메라 'Camera' renderType=Overlay depth=6 clear=Nothing ortho=True cullingMask=32
PASS A3 UI 카메라 = URP Overlay(= 베이스 카메라 스택에 들어간다 → 렌더 스케일 영향 받음) — Overlay
PASS A3b 인게임 루트 캔버스 = ScreenSpaceCamera(Overlay 아님) — Overlay 캔버스 0
PASS A4 월드 공간 캔버스 0 — 0
스킬카드[0] sizeDelta=(115.00, 115.00) (유닛) → 1080×1920 화면 px = (64.69, 64.69)
스킬카드[1] sizeDelta=(115.00, 115.00) (유닛) → 1080×1920 화면 px = (64.69, 64.69)
스킬카드[0] sizeDelta=(115.00, 115.00) (유닛) → 1080×1920 화면 px = (64.69, 64.69)
스킬카드[1] sizeDelta=(115.00, 115.00) (유닛) → 1080×1920 화면 px = (64.69, 64.69)
스킬카드[0] sizeDelta=(115.00, 115.00) (유닛) → 1080×1920 화면 px = (64.69, 64.69)
스킬카드[1] sizeDelta=(115.00, 115.00) (유닛) → 1080×1920 화면 px = (64.69, 64.69)
④ HUDUI.prefab Canvas=0 · HUDDMGUI.prefab Canvas=0 (0 = 자체 캔버스 없음 = 부모 캔버스[NewGameUI]를 그대로 쓴다 · HUDBase 가 WorldToViewportPoint 로 anchoredPosition 만 옮긴다)
PASS A4b HP바/데미지숫자 = 스크린 공간(자체 캔버스 0) — HUDUI/HUDDMGUI 자체 Canvas 0
⑤ Main Camera.prefab renderType=Base · cameraStack(프리팹)=0 · postProcessing=True · antialiasing=None · fov=60 · ortho=False · rendererIndex(프리팹 직렬화 1 = 기본)
🔴 런타임 스택 = NewGameUI.Start() 가 `Camera.main…cameraStack.Add(UICamera)` (Assets/Script/UI/NewGameUI.cs:38) → Base 1 + Overlay 1
PASS A5 Main Camera = Base — Base
ⓑ 적용 / 복원 (에디트 모드 · 가짜 경계 = ApplyNow/RestoreNow 직접 호출)
PASS B0 SO 로드 — targetHeightPx=480 filter=Point applyOnlyInBattleMap=True restoreOnExit=True
전 : URP-Balanced renderScale=1 filter=Auto
후 : renderScale=0.25 filter=Point
PASS B1 적용됨 — renderScale=0.25 (기대 0.25 = 480/1920) · filter=Point
PASS B2 원래 값 기억 — orig=1/Auto
산식: targetHeightPx 1920 → renderScale 1 → 실제 그려지는 세로 픽셀 1920
산식: targetHeightPx 640 → renderScale 0.3333 → 실제 그려지는 세로 픽셀 640
산식: targetHeightPx 480 → renderScale 0.25 → 실제 그려지는 세로 픽셀 480
산식: targetHeightPx 360 → renderScale 0.1875 → 실제 그려지는 세로 픽셀 360
클램프: 96px/1920 = 0.05 → 0.1 (하한 0.1)
PASS B3 하한 0.1 클램프 — 0.1
복원: renderScale=1 filter=Auto
PASS B4 복원 프로브 — renderScale == 원래값 — 1 == 1
PASS B5 복원 프로브 — upscalingFilter == 원래값 — Auto == Auto
PASS B6 이중 복원 무해 — 두 번째 RestoreNow = false(무동작)
PASS B7 🔴 파이프라인 에셋 dirty 아님(= Save Project 해도 디스크에 안 써진다) — IsDirty=False
ⓒ C8 롤백
PASS C1 스위치 off → 적용 0 — applied=False renderScale=1
PASS C2 스위치 off → Enabled=false — False
PASS C3 에디트 모드 러너 오브젝트 0 — 0 → 0
ⓓ GC 0 (Tick × 200k · 에디트 모드라 PollBoundary 는 즉시 반환하는 경로)
PASS D1 Tick ×200k 할당 0 — monoΔ=0B · totalΔ=0B · gen0=0
RESULT PASS · 18/18 · FAIL 0

View File

@ -0,0 +1,623 @@
// WL-814a 프로브 — 도트 룩 스위치 검증 + 실측 표 + 룩 캡처 4단 (게임 코드 아님 · 에디트 모드 전용)
// run: unity command run_script --file AgentScripts/WL814a_Probe.cs --entry WL814a_Probe.RunAll
// cap: unity command run_script --file AgentScripts/WL814a_Probe.cs --entry WL814a_Probe.Capture
// 🔴 파이프라인 에셋에 SetDirty 를 부르지 않는다 · 임시 씬은 저장하지 않는다 · 에셋 파일 수정 0.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using WL.Look;
public static class WL814a_Probe
{
const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814a";
const string kTxt = "AgentScripts/WL814a_PROBE.txt";
const string kTxtCap = "AgentScripts/WL814a_CAPTURE.txt";
static StringBuilder s_sb;
static int s_pass, s_fail;
static Camera s_uiCam;
static RectTransform s_uiCanvas;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814a] " + s); }
static void Chk(string name, bool ok, string detail)
{
if (ok) s_pass++; else s_fail++;
L((ok ? " PASS " : " FAIL ") + name + " — " + detail);
}
// ═════════════════════════════════════════════════════════════════════════
// RunAll — 실측 표 + 적용/복원 + C8 + GC
// ═════════════════════════════════════════════════════════════════════════
public static void RunAll()
{
s_sb = new StringBuilder(); s_pass = 0; s_fail = 0;
L("WL-814a 프로브 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion);
try
{
A_Table();
B_ApplyRestore();
C_C8();
D_Gc();
}
catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); }
finally
{
// 🔴 무슨 일이 있어도 원래 값으로
PixelLook.RestoreNow("probe-final");
WLPixelLookSettings.RuntimeDisabled = false;
}
L("");
L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail);
Write();
}
static void Write() { Write(kTxt); }
static void Write(string path)
{
try
{
File.WriteAllText(path, s_sb.ToString(), new UTF8Encoding(false));
Directory.CreateDirectory(kOutDir);
File.WriteAllText(Path.Combine(kOutDir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"),
s_sb.ToString(), new UTF8Encoding(false));
}
catch (Exception ex) { Debug.Log("[WL814a] 파일 쓰기 실패 " + ex.Message); }
}
// ───────────────────────────────────────────── ⓐ 실측 표
static void A_Table()
{
L("");
L("ⓐ 실측 표");
// ① Quality → URP 에셋 (ProjectSettings/QualitySettings.asset 원문 파싱 · 읽기만)
string qsPath = "ProjectSettings/QualitySettings.asset";
string[] qs = File.Exists(qsPath) ? File.ReadAllLines(qsPath) : new string[0];
int perPlatCount = 0; bool perPlatEmpty = false;
var qNames = new List<string>(); var qGuids = new List<string>();
foreach (var raw in qs)
{
string t = raw.Trim();
if (t.StartsWith("m_PerPlatformDefaultQuality:"))
perPlatEmpty = t.EndsWith("{}");
else if (t.StartsWith("name: ") && raw.StartsWith(" name: ")) qNames.Add(t.Substring(6));
else if (t.StartsWith("customRenderPipeline:"))
{
int g = t.IndexOf("guid: ");
qGuids.Add(g >= 0 ? t.Substring(g + 6).TrimEnd(',', ' ') : "-");
}
else if (perPlatEmpty == false && t.StartsWith("- first:")) perPlatCount++;
}
L(" ① m_PerPlatformDefaultQuality = " + (perPlatEmpty ? "{} (비어 있음 → 플랫폼별 지정 0 = 전 플랫폼이 m_CurrentQuality 레벨을 쓴다)" : perPlatCount + "개"));
string[] names = QualitySettings.names;
int cur = QualitySettings.GetQualityLevel();
for (int i = 0; i < names.Length; i++)
{
string g = i < qGuids.Count ? qGuids[i] : "-";
string p = g != "-" ? AssetDatabase.GUIDToAssetPath(g) : "";
L(" level " + i + " '" + names[i] + "'" + (i == cur ? " ← m_CurrentQuality(현재)" : "") +
" → " + (string.IsNullOrEmpty(p) ? "(없음 → Graphics 기본)" : System.IO.Path.GetFileNameWithoutExtension(p)));
}
var qrp = QualitySettings.renderPipeline as UniversalRenderPipelineAsset;
L(" QualitySettings.renderPipeline(현재 레벨 오버라이드) = " + (qrp != null ? qrp.name : "(없음)"));
var def = GraphicsSettings.defaultRenderPipeline as UniversalRenderPipelineAsset;
var act = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset;
L(" GraphicsSettings.defaultRenderPipeline = " + (def != null ? def.name : "(없음)"));
L(" 🔴 GraphicsSettings.currentRenderPipeline(= 실제 활성) = " + (act != null ? act.name : "(없음)") +
" path=" + (act != null ? AssetDatabase.GetAssetPath(act) : "-"));
Chk("A1 활성 URP 에셋 존재", act != null, act != null ? act.name : "null");
// ② 각 URP 에셋 값
foreach (var g in AssetDatabase.FindAssets("t:UniversalRenderPipelineAsset", new[] { "Assets/Settings" }))
{
string p = AssetDatabase.GUIDToAssetPath(g);
var a = AssetDatabase.LoadAssetAtPath<UniversalRenderPipelineAsset>(p);
if (a == null) continue;
L(" ② " + a.name.PadRight(20) + " renderScale=" + a.renderScale +
" · upscalingFilter=" + a.upscalingFilter +
" · MSAA=" + a.msaaSampleCount +
" · HDR=" + (a.supportsHDR ? 1 : 0) +
" · mainLightShadow=" + (a.supportsMainLightShadows ? 1 : 0) +
" · shadowDist=" + a.shadowDistance +
(a == act ? " ← 활성" : ""));
}
// ③ 인게임 UI 캔버스 렌더 모드
var ui = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Res_Addr/MainUI/NewGameUI.prefab");
if (ui != null)
{
var canvases = ui.GetComponentsInChildren<Canvas>(true);
L(" ③ NewGameUI.prefab Canvas " + canvases.Length + "개");
foreach (var c in canvases)
L(" - " + HierPath(c.transform) + " renderMode=" + c.renderMode +
" worldCamera=" + (c.worldCamera != null ? c.worldCamera.name : "null") +
" planeDistance=" + c.planeDistance + " sortingOrder=" + c.sortingOrder);
var scaler = ui.GetComponentInChildren<CanvasScaler>(true);
if (scaler != null)
L(" CanvasScaler ref=" + scaler.referenceResolution + " mode=" + scaler.uiScaleMode +
" match=" + scaler.screenMatchMode + " → 1080×1920 scaleFactor=" +
Mathf.Min(1080f / scaler.referenceResolution.x, 1920f / scaler.referenceResolution.y).ToString("0.####"));
var uiCam = ui.GetComponentInChildren<Camera>(true);
if (uiCam != null)
{
var d = uiCam.GetUniversalAdditionalCameraData();
L(" UI 카메라 '" + uiCam.name + "' renderType=" + (d != null ? d.renderType.ToString() : "?") +
" depth=" + uiCam.depth + " clear=" + uiCam.clearFlags + " ortho=" + uiCam.orthographic +
" cullingMask=" + uiCam.cullingMask);
Chk("A3 UI 카메라 = URP Overlay(= 베이스 카메라 스택에 들어간다 → 렌더 스케일 영향 받음)",
d != null && d.renderType == CameraRenderType.Overlay, d != null ? d.renderType.ToString() : "?");
}
bool anyOverlayCanvas = false, anyWorld = false;
foreach (var c in canvases)
{
if (c.renderMode == RenderMode.ScreenSpaceOverlay) anyOverlayCanvas = true;
if (c.renderMode == RenderMode.WorldSpace) anyWorld = true;
}
Chk("A3b 인게임 루트 캔버스 = ScreenSpaceCamera(Overlay 아님)", !anyOverlayCanvas, "Overlay 캔버스 " + (anyOverlayCanvas ? "있음" : "0"));
Chk("A4 월드 공간 캔버스 0", !anyWorld, anyWorld ? "있음" : "0");
// 스킬 버튼(카드) 크기 — 캡처 표의 「스킬 버튼 영역」 근거
foreach (var mb in ui.GetComponentsInChildren<BattleUI>(true))
{
if (mb.skillCards == null) continue;
for (int i = 0; i < mb.skillCards.Length && i < 2; i++)
{
var rt = mb.skillCards[i] != null ? mb.skillCards[i].GetComponent<RectTransform>() : null;
if (rt != null) L(" 스킬카드[" + i + "] sizeDelta=" + rt.sizeDelta + " (유닛) → 1080×1920 화면 px = " +
(rt.sizeDelta * 0.5625f));
}
}
}
else Chk("A3 NewGameUI.prefab 로드", false, "없음");
// ④ 월드 UI(HP 바 · 데미지 숫자) 방식
var hud = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Res_Addr/Ingame/HUDUI.prefab");
var dmg = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Res_Addr/Ingame/HUDDMGUI.prefab");
L(" ④ HUDUI.prefab Canvas=" + (hud != null ? hud.GetComponentsInChildren<Canvas>(true).Length : -1) +
" · HUDDMGUI.prefab Canvas=" + (dmg != null ? dmg.GetComponentsInChildren<Canvas>(true).Length : -1) +
" (0 = 자체 캔버스 없음 = 부모 캔버스[NewGameUI]를 그대로 쓴다 · HUDBase 가 WorldToViewportPoint 로 anchoredPosition 만 옮긴다)");
Chk("A4b HP바/데미지숫자 = 스크린 공간(자체 캔버스 0)",
hud != null && dmg != null && hud.GetComponentsInChildren<Canvas>(true).Length == 0 && dmg.GetComponentsInChildren<Canvas>(true).Length == 0,
"HUDUI/HUDDMGUI 자체 Canvas 0");
// ⑤ 카메라 스택
var camPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/ResWork/Prefabs/Ingame/Main Camera.prefab");
if (camPrefab != null)
{
var c = camPrefab.GetComponentInChildren<Camera>(true);
var d = c.GetUniversalAdditionalCameraData();
L(" ⑤ Main Camera.prefab renderType=" + d.renderType + " · cameraStack(프리팹)=" + d.cameraStack.Count +
" · postProcessing=" + d.renderPostProcessing + " · antialiasing=" + d.antialiasing +
" · fov=" + c.fieldOfView + " · ortho=" + c.orthographic + " · rendererIndex(프리팹 직렬화 1 = 기본)");
L(" 🔴 런타임 스택 = NewGameUI.Start() 가 `Camera.main…cameraStack.Add(UICamera)` (Assets/Script/UI/NewGameUI.cs:38) → Base 1 + Overlay 1");
Chk("A5 Main Camera = Base", d.renderType == CameraRenderType.Base, d.renderType.ToString());
}
}
static string HierPath(Transform t)
{
string s = t.name;
while (t.parent != null) { t = t.parent; s = t.name + "/" + s; }
return s;
}
// ───────────────────────────────────────────── ⓑ 적용 / 복원
static void B_ApplyRestore()
{
L("");
L("ⓑ 적용 / 복원 (에디트 모드 · 가짜 경계 = ApplyNow/RestoreNow 직접 호출)");
WLPixelLookSettings.ClearCache();
var cfg = WLPixelLookSettings.Instance;
Chk("B0 SO 로드", cfg != null, cfg != null ? "targetHeightPx=" + cfg.targetHeightPx + " filter=" + cfg.filter +
" applyOnlyInBattleMap=" + cfg.applyOnlyInBattleMap + " restoreOnExit=" + cfg.restoreOnExit : "null");
if (cfg == null) return;
PixelLook.ResetForProbe();
var asset = PixelLook.CurrentAsset;
if (asset == null) { Chk("B1 활성 에셋", false, "null"); return; }
float before = asset.renderScale;
var beforeF = asset.upscalingFilter;
L(" 전 : " + asset.name + " renderScale=" + before + " filter=" + beforeF);
bool applied = PixelLook.ApplyNow(cfg.targetHeightPx, 1920, "probe");
L(" 후 : renderScale=" + asset.renderScale + " filter=" + asset.upscalingFilter);
Chk("B1 적용됨", applied && Mathf.Approximately(asset.renderScale, 0.25f) && asset.upscalingFilter == UpscalingFilterSelection.Point,
"renderScale=" + asset.renderScale + " (기대 0.25 = 480/1920) · filter=" + asset.upscalingFilter);
Chk("B2 원래 값 기억", Mathf.Approximately(PixelLook.OriginalRenderScale, before) && PixelLook.OriginalFilter == beforeF,
"orig=" + PixelLook.OriginalRenderScale + "/" + PixelLook.OriginalFilter);
// 4단 스케일 산식
foreach (int px in new[] { 1920, 640, 480, 360 })
L(" 산식: targetHeightPx " + px + " → renderScale " + PixelLook.ComputeRenderScale(px, 1920).ToString("0.####") +
" → 실제 그려지는 세로 픽셀 " + Mathf.RoundToInt(1920f * PixelLook.ComputeRenderScale(px, 1920)));
// 하한 클램프
L(" 클램프: 96px/1920 = " + (96f / 1920f).ToString("0.####") + " → " + PixelLook.ComputeRenderScale(96, 1920).ToString("0.####") + " (하한 0.1)");
Chk("B3 하한 0.1 클램프", Mathf.Approximately(PixelLook.ComputeRenderScale(96, 1920), 0.1f), PixelLook.ComputeRenderScale(96, 1920).ToString());
bool restored = PixelLook.RestoreNow("probe");
L(" 복원: renderScale=" + asset.renderScale + " filter=" + asset.upscalingFilter);
Chk("B4 복원 프로브 — renderScale == 원래값", restored && Mathf.Approximately(asset.renderScale, before),
asset.renderScale + " == " + before);
Chk("B5 복원 프로브 — upscalingFilter == 원래값", asset.upscalingFilter == beforeF,
asset.upscalingFilter + " == " + beforeF);
Chk("B6 이중 복원 무해", !PixelLook.RestoreNow("probe-again"), "두 번째 RestoreNow = false(무동작)");
// 🔴 디스크 오염 검사 — 에셋이 dirty 로 표시되지 않았는가
bool dirty = EditorUtility.IsDirty(asset);
Chk("B7 🔴 파이프라인 에셋 dirty 아님(= Save Project 해도 디스크에 안 써진다)", !dirty, "IsDirty=" + dirty);
}
// ───────────────────────────────────────────── ⓒ C8
static void C_C8()
{
L("");
L("ⓒ C8 롤백");
var cfg = WLPixelLookSettings.Instance;
var asset = PixelLook.CurrentAsset;
if (cfg == null || asset == null) return;
float before = asset.renderScale;
WLPixelLookSettings.RuntimeDisabled = true; // enabled=0 과 같은 경로(에셋 파일은 안 건드린다)
bool applied = PixelLook.ApplyNow(480, 1920, "c8");
Chk("C1 스위치 off → 적용 0", !applied && Mathf.Approximately(asset.renderScale, before),
"applied=" + applied + " renderScale=" + asset.renderScale);
Chk("C2 스위치 off → Enabled=false", !WLPixelLookSettings.Enabled, WLPixelLookSettings.Enabled.ToString());
WLPixelLookSettings.RuntimeDisabled = false;
// 러너: 에디트 모드에서는 EnsureRunner 가 오브젝트를 만들지 않는다
int before2 = CountRunners();
PixelLook.EnsureRunnerForProbe();
int after2 = CountRunners();
Chk("C3 에디트 모드 러너 오브젝트 0", before2 == 0 && after2 == 0, before2 + " → " + after2);
}
static int CountRunners()
{
int n = 0;
foreach (var go in Resources.FindObjectsOfTypeAll<GameObject>())
if (go.name == "[WL814a] PixelLookRunner") n++;
return n;
}
// ───────────────────────────────────────────── ⓓ GC
static void D_Gc()
{
L("");
L("ⓓ GC 0 (Tick × 200k · 에디트 모드라 PollBoundary 는 즉시 반환하는 경로)");
var cfg = WLPixelLookSettings.Instance;
if (cfg == null) return;
PixelLook.TickForProbe(); // 워밍
GC.Collect();
long m0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
long t0 = GC.GetTotalMemory(false);
int g0 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) PixelLook.TickForProbe();
long m1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong();
long t1 = GC.GetTotalMemory(false);
int g1 = GC.CollectionCount(0);
Chk("D1 Tick ×200k 할당 0", (m1 - m0) == 0 && (t1 - t0) == 0 && (g1 - g0) == 0,
"monoΔ=" + (m1 - m0) + "B · totalΔ=" + (t1 - t0) + "B · gen0=" + (g1 - g0));
}
// ═════════════════════════════════════════════════════════════════════════
// Capture — 임시 씬(저장 0) · 1080×1920 · 4단
// ═════════════════════════════════════════════════════════════════════════
public static void Capture()
{
s_sb = new StringBuilder(); s_pass = 0; s_fail = 0;
L("WL-814a 룩 캡처 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
Scene temp = default(Scene);
s_uiCam = null; s_uiCanvas = null;
Camera cam = null;
var asset = PixelLook.CurrentAsset;
float origScale = asset != null ? asset.renderScale : 1f;
var origFilter = asset != null ? asset.upscalingFilter : UpscalingFilterSelection.Auto;
try
{
Directory.CreateDirectory(kOutDir);
temp = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
// 기본 카메라 제거(우리 프리팹 카메라만 쓴다)
foreach (var go in temp.GetRootGameObjects())
if (go.GetComponent<Camera>() != null) UnityEngine.Object.DestroyImmediate(go);
// 맵
var map = Inst("Assets/Res_Addr/Map/WL_Nature.prefab", Vector3.zero);
Chk("E0 맵 배치", map != null, "WL_Nature");
// PC 위치 = 811o 와 같은 기준점(Portal_Start) 앞 · 지면 스냅
Vector3 anchor = Vector3.zero;
string anchorName = "(origin)";
if (map != null)
foreach (var t in map.GetComponentsInChildren<Transform>(true))
if (t.name == "Portal_Start") { anchor = t.position; anchorName = "Portal_Start"; break; }
Vector3 pcPos = anchor + new Vector3(0f, 0f, 6f); // 포탈 앞 6 m(존1 방향)
RaycastHit hit;
if (Physics.Raycast(pcPos + Vector3.up * 200f, Vector3.down, out hit, 500f)) pcPos = hit.point;
L(" PC 위치(" + anchorName + " " + anchor + " 앞 6 m · 지면 스냅) = " + pcPos);
// 카메라(811o 구도 h 76 · d 3 · up 2.4 · rotateAround 233 · m_LookAt (0,1.06,0))
var camGo = Inst("Assets/ResWork/Prefabs/Ingame/Main Camera.prefab", Vector3.zero);
cam = camGo != null ? camGo.GetComponentInChildren<Camera>(true) : null;
Chk("E3 카메라 프리팹", cam != null, camGo != null ? camGo.name : "null");
if (cam == null) return;
// 에디트 모드에서 원본 스크립트가 돌지 않게(Update 는 어차피 안 돈다) 카메라 스크립트만 비활성
foreach (var mb in camGo.GetComponentsInChildren<MonoBehaviour>(true))
if (mb != null && !(mb is UniversalAdditionalCameraData)) mb.enabled = false;
const float h = 76f, dAway = 3f, dUp = 2.4f, yaw = 233f;
Vector3 rotateVector = Quaternion.Euler(h, yaw, 0f) * Vector3.one;
Vector3 lookAt = new Vector3(0f, 1.06f, 0f);
cam.transform.position = pcPos + Vector3.up * dUp - rotateVector * dAway;
cam.transform.LookAt(pcPos + lookAt);
// 카메라가 보는 수평 방향 = 「PC 앞」
Vector3 fwd = pcPos - cam.transform.position; fwd.y = 0f; fwd.Normalize();
Vector3 right = Vector3.Cross(Vector3.up, fwd);
L(" 카메라 = " + cam.transform.position + " → look " + (pcPos + lookAt) +
" · fov " + cam.fieldOfView + " · 811o(h 76 · d 3 · up 2.4 · yaw 233) · 화면 앞 방향 " + fwd);
var pc = Inst("Assets/Res_Addr/PC/Ai01.prefab", pcPos);
if (pc != null) pc.transform.rotation = Quaternion.LookRotation(fwd, Vector3.up);
Chk("E1 PC 배치", pc != null, "Ai01 @ " + pcPos);
// 잡몹 3(존1 = MonsterList 101/102/103 → Mob/Batty_A·B·C · f_DefaultScale 1.5) — PC 앞(화면 안쪽)
string[] mobs = { "Batty_A", "Batty_B", "Batty_C" };
Vector2[] off = { new Vector2(3.2f, 1.6f), new Vector2(4.6f, -2.0f), new Vector2(6.4f, 0.6f) }; // (앞, 옆)
var mobGos = new List<GameObject>();
for (int i = 0; i < mobs.Length; i++)
{
Vector3 p = pcPos + fwd * off[i].x + right * off[i].y;
if (Physics.Raycast(p + Vector3.up * 50f, Vector3.down, out hit, 200f)) p = hit.point;
var m = Inst("Assets/Res_Addr/Mobs/Mob/" + mobs[i] + ".prefab", p);
if (m != null) { m.transform.localScale = Vector3.one * 1.5f; m.transform.rotation = Quaternion.LookRotation(-fwd, Vector3.up); mobGos.Add(m); }
}
Chk("E2 잡몹 배치", mobGos.Count == 3, mobGos.Count + "/3 (Batty_A/B/C · scale 1.5 · PC 앞 3~6 m)");
// T 포즈 방지 — 컨트롤러의 Idle 클립을 0.5 s 지점으로 샘플(에디트 모드)
Pose(pc); foreach (var m in mobGos) Pose(m);
// 🔴 UI 도 실제 게임과 같은 경로로 얹는다: Overlay UI 카메라 + ScreenSpaceCamera 캔버스(ref 1920×1080 Expand)
// = 렌더 스케일이 UI 까지 픽셀화하는지 눈으로 확인하기 위한 것(실측 표 ③ 결론의 그림 증거).
bool uiOk = BuildUiMock(cam);
Chk("E3b UI 목업(화면 픽셀 1:1 · 스킬카드 65px × 4 + HP 바)", uiOk, uiOk ? "배치됨" : "실패");
// 캡처 4단
int[] steps = { 1920, 640, 480, 360 };
var rows = new List<string>();
foreach (int px in steps)
{
float scale = px / 1920f;
if (asset != null) { asset.renderScale = Mathf.Clamp(scale, 0.1f, 1f); asset.upscalingFilter = UpscalingFilterSelection.Point; }
string file = System.IO.Path.Combine(kOutDir, "look_" + px + ".png");
string row = Shot(cam, pc, mobGos, file, px, asset != null ? asset.renderScale : 1f);
rows.Add(row);
Chk("E4 look_" + px + ".png", File.Exists(file), new FileInfo(file).Length + " B");
}
L("");
L(" | targetHeightPx | renderScale | 실제 그려진 해상도 | PC 키(px) | 잡몹 키(px) | 스킬버튼 한 변(px) |");
L(" |---|---|---|---|---|---|");
foreach (var r in rows) L(" " + r);
}
catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); }
finally
{
if (asset != null) { asset.renderScale = origScale; asset.upscalingFilter = origFilter; } // 🔴 복원 · SetDirty 0
L("");
L(" 복원: renderScale=" + (asset != null ? asset.renderScale.ToString() : "-") +
" filter=" + (asset != null ? asset.upscalingFilter.ToString() : "-") +
" · dirty=" + (asset != null ? EditorUtility.IsDirty(asset).ToString() : "-"));
// 임시 씬은 저장하지 않는다(커밋 0)
try { EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); } catch { }
}
L("");
L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail);
Write(kTxtCap);
}
/// <summary>Animator 컨트롤러의 Idle 계열 클립을 0.5 s 로 샘플해서 T 포즈를 없앤다(에디트 모드 · 애니메이터 상태 변경 0).</summary>
static void Pose(GameObject go)
{
if (go == null) return;
try
{
var an = go.GetComponentInChildren<Animator>(true);
if (an == null || an.runtimeAnimatorController == null) return;
var clips = an.runtimeAnimatorController.animationClips;
AnimationClip pick = null;
foreach (var c in clips)
{
if (c == null) continue;
if (c.name.ToLower().Contains("idle")) { pick = c; break; }
if (pick == null) pick = c;
}
if (pick != null) pick.SampleAnimation(an.gameObject, 0.5f);
}
catch { }
}
/// <summary>
/// UI 목업 — 화면 픽셀 1:1 로 배치한 캔버스.
/// 🔴 실제 게임 경로(URP Overlay 카메라 + ScreenSpaceCamera 캔버스)는 **에디트 모드 Camera.Render 에서 스택이 그려지지 않아**
/// 캡처에 안 나온다(이 함수 안에서 실측 · 로그로 남긴다). 그래서 캡처용으로만 캔버스를 베이스 카메라 앞
/// 월드 공간에 두고 화면 픽셀과 1:1 이 되게 스케일한다 — 같은 렌더 타겟(= 같은 renderScale)에 그려지므로
/// 「렌더 스케일이 UI 를 어떻게 만드는가」는 실제와 같다.
/// </summary>
static bool BuildUiMock(Camera baseCam)
{
try
{
int realUiLayer = LayerMask.NameToLayer("UI"); if (realUiLayer < 0) realUiLayer = 5;
bool baseSeesUi = (baseCam.cullingMask & (1 << realUiLayer)) != 0;
L(" 🔴 인게임 베이스 카메라 cullingMask = " + baseCam.cullingMask + " · UI 레이어(" + realUiLayer + ") 포함? " +
(baseSeesUi ? "예" : "아니오 → UI 는 반드시 Overlay UI 카메라가 그린다 = 같은 스택 = 같은 렌더 스케일"));
L(" UI 경로(실기) = ScreenSpaceCamera 캔버스 + Overlay 카메라 스택 · 캡처 목업은 베이스 카메라가 보는 레이어 0 에 화면 픽셀 1:1 로 둔다");
int uiLayer = 0; // 목업 전용(베이스 카메라 cullingMask 안)
// 화면 픽셀 1:1 — 카메라 앞 dist 에서 세로 1920 px 이 정확히 프러스텀 높이가 되게 스케일
const float dist = 2.0f;
float worldH = 2f * dist * Mathf.Tan(baseCam.fieldOfView * 0.5f * Mathf.Deg2Rad);
float unit = worldH / 1920f;
var canvasGo = new GameObject("UI_Mock(1080x1920)", typeof(RectTransform), typeof(Canvas));
canvasGo.layer = uiLayer;
var canvas = canvasGo.GetComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.worldCamera = baseCam;
var crt = canvasGo.GetComponent<RectTransform>();
crt.SetParent(baseCam.transform, false);
crt.sizeDelta = new Vector2(1080f, 1920f);
crt.localPosition = new Vector3(0f, 0f, dist);
crt.localRotation = Quaternion.identity;
crt.localScale = Vector3.one * unit;
// 스킬 카드 4개 — 실측 115 유닛 × NewGameUI Expand 계수 0.5625 = 화면 64.7 px
float card = 115f * 0.5625f;
for (int i = 0; i < 4; i++)
{
var c = Quad(canvasGo.transform, uiLayer, new Vector2(card, card),
new Vector2(1f, 0f), new Vector2(-40f - i * (card + 14f), 90f), new Color(0.12f, 0.14f, 0.20f, 0.95f));
Quad(c.transform, uiLayer, new Vector2(card - 9f, card - 9f), new Vector2(0.5f, 0.5f), Vector2.zero,
new Color(0.85f, 0.72f, 0.28f, 1f));
}
// 상단 HP 바(760×34 유닛 → 428×19 px)
var hp = Quad(canvasGo.transform, uiLayer, new Vector2(760f * 0.5625f, 34f * 0.5625f), new Vector2(0.5f, 1f),
new Vector2(0f, -60f), new Color(0.10f, 0.10f, 0.12f, 0.9f));
Quad(hp.transform, uiLayer, new Vector2(520f * 0.5625f, 22f * 0.5625f), new Vector2(0f, 0.5f),
new Vector2(70f, 0f), new Color(0.90f, 0.21f, 0.16f, 1f));
s_uiCanvas = crt;
return true;
}
catch (Exception ex) { L(" UI 목업 실패 " + ex.Message); return false; }
}
static GameObject Quad(Transform parent, int layer, Vector2 size, Vector2 anchor, Vector2 pos, Color col)
{
var go = new GameObject("q", typeof(RectTransform), typeof(Image));
go.layer = layer;
var rt = go.GetComponent<RectTransform>();
rt.SetParent(parent, false);
rt.anchorMin = rt.anchorMax = anchor;
rt.pivot = anchor;
rt.sizeDelta = size;
rt.anchoredPosition = pos;
var img = go.GetComponent<Image>();
img.sprite = null; img.color = col; img.raycastTarget = false;
return go;
}
static GameObject Inst(string path, Vector3 pos)
{
var src = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (src == null) { L(" 프리팹 없음 " + path); return null; }
var go = (GameObject)PrefabUtility.InstantiatePrefab(src);
go.transform.position = pos;
return go;
}
static string Shot(Camera cam, GameObject pc, List<GameObject> mobs, string file, int targetPx, float scale)
{
var rt = new RenderTexture(1080, 1920, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.antiAliasing = 1;
rt.Create();
var prev = cam.targetTexture;
cam.targetTexture = rt;
// 🔴 ScreenSpaceCamera 캔버스는 worldCamera.pixelRect 로 크기를 정한다 → 오버레이 카메라에도 같은 RT 를 물려
// 1080×1920 기준으로 배치시킨다(URP 는 오버레이의 targetTexture 를 무시하고 베이스의 타겟에 그린다).
if (s_uiCam != null) s_uiCam.targetTexture = rt;
Canvas.ForceUpdateCanvases();
cam.Render();
var active = RenderTexture.active;
RenderTexture.active = rt;
var tex = new Texture2D(1080, 1920, TextureFormat.RGB24, false);
tex.ReadPixels(new Rect(0, 0, 1080, 1920), 0, 0);
tex.Apply();
RenderTexture.active = active;
File.WriteAllBytes(file, tex.EncodeToPNG());
// 화면 픽셀 측정(전체 해상도 기준) → × renderScale = 실제 그려진 픽셀
float pcPx = ScreenHeightPx(cam, pc);
float mobPx = mobs.Count > 0 ? ScreenHeightPx(cam, mobs[0]) : 0f;
float skillPx = SkillButtonPx(); // 스킬 카드 실측 유닛 × 0.5625(NewGameUI Expand 계수)
// 색 분산(빈 화면 방지)
var px32 = tex.GetPixels32();
var seen = new HashSet<int>();
for (int i = 0; i < px32.Length; i += 977) seen.Add((px32[i].r << 16) | (px32[i].g << 8) | px32[i].b);
int distinct = seen.Count;
cam.targetTexture = prev;
if (s_uiCam != null) s_uiCam.targetTexture = null;
foreach (var c in UnityEngine.Object.FindObjectsByType<Camera>(FindObjectsInactive.Include, FindObjectsSortMode.None))
if (c != null && c.targetTexture == rt) c.targetTexture = null;
RenderTexture.active = null;
UnityEngine.Object.DestroyImmediate(tex);
rt.Release(); UnityEngine.Object.DestroyImmediate(rt);
L(" 캡처 " + System.IO.Path.GetFileName(file) + " · renderScale=" + scale.ToString("0.####") +
" · 색 " + distinct + "종(1 이면 빈 화면) · PC " + pcPx.ToString("0") + "px(전체해상도) · 스킬버튼 " + skillPx.ToString("0") + "px" +
" · Screen " + Screen.width + "×" + Screen.height +
" · uiCam px " + (s_uiCam != null ? s_uiCam.pixelWidth + "×" + s_uiCam.pixelHeight : "-") +
" · 캔버스 rect " + (s_uiCanvas != null ? s_uiCanvas.rect.size.ToString() : "-"));
return "| " + targetPx + " | " + scale.ToString("0.####") + " | " +
Mathf.RoundToInt(1080f * scale) + "×" + Mathf.RoundToInt(1920f * scale) + " | " +
Mathf.RoundToInt(pcPx * scale) + " | " + Mathf.RoundToInt(mobPx * scale) + " | " +
Mathf.RoundToInt(skillPx * scale) + " |";
}
/// <summary>렌더러 바운드를 화면에 투영한 세로 픽셀(1080×1920 전체 해상도 기준).</summary>
static float ScreenHeightPx(Camera cam, GameObject go)
{
if (go == null) return 0f;
var rs = go.GetComponentsInChildren<Renderer>();
if (rs.Length == 0) return 0f;
var b = rs[0].bounds;
foreach (var r in rs) b.Encapsulate(r.bounds);
float minY = float.MaxValue, maxY = float.MinValue;
for (int i = 0; i < 8; i++)
{
var c = new Vector3(((i & 1) == 0 ? b.min : b.max).x, ((i & 2) == 0 ? b.min : b.max).y, ((i & 4) == 0 ? b.min : b.max).z);
var s = cam.WorldToScreenPoint(c);
if (s.z <= 0f) continue;
minY = Mathf.Min(minY, s.y); maxY = Mathf.Max(maxY, s.y);
}
return maxY > minY ? maxY - minY : 0f;
}
/// <summary>스킬 카드 한 변의 1080×1920 화면 픽셀. NewGameUI(ref 1920×1080 · Expand) scaleFactor 0.5625 적용.</summary>
static float SkillButtonPx()
{
var ui = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Res_Addr/MainUI/NewGameUI.prefab");
if (ui == null) return 0f;
foreach (var b in ui.GetComponentsInChildren<BattleUI>(true))
{
if (b.skillCards == null || b.skillCards.Length == 0 || b.skillCards[0] == null) continue;
var rt = b.skillCards[0].GetComponent<RectTransform>();
if (rt != null) return Mathf.Max(rt.sizeDelta.x, rt.sizeDelta.y) * 0.5625f;
}
return 0f;
}
}

View File

@ -0,0 +1,24 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 814a00029c7b4d1e8f2a6c5b3d47e902, type: 3}
m_Name: WLPixelLookSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Look.WLPixelLookSettings
enabled: 1
verboseLog: 0
targetHeightPx: 480
filter: 0
minRenderScale: 0.1
maxRenderScale: 1
applyOnlyInBattleMap: 1
restoreOnExit: 1
pollSeconds: 0.25
trackScreenSize: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 814a00049c7b4d1e8f2a6c5b3d47e904
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

8
Assets/WL/Look.meta Normal file
View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 814a00019c7b4d1e8f2a6c5b3d47e901
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

357
Assets/WL/Look/PixelLook.cs Normal file
View File

@ -0,0 +1,357 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 814a00039c7b4d1e8f2a6c5b3d47e903
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,126 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLPixelLookSettings.cs — 도트(픽셀아트) 룩 스위치 값의 단일 출처 (SOT · C45)
//
// PD 지시 #814(「Critter 3D Pixel Art Toolkit 으로 우리 게임을 도트 게임처럼 보이게」)
// 발주서 WL-814a §1-2 (2026-09-10)
//
// ■ 왜 URP 기본 기능인가 (실측)
// 임포트된 것은 `Assets/Critter 3D Pixel Art Toolkit/README.pdf` **1개뿐**이다
// (번들 3종 = Critter 3D Pixel Camera · Environment · Volumetric Lighting 은 Asset Store 에서 별도 다운로드).
// → 에셋이 도착하기 전까지 URP 17.3 이 이미 가진 두 값만으로 「도트 룩」 1차 시안을 만든다:
// UniversalRenderPipelineAsset.renderScale … 낮은 해상도로 그린다(= 픽셀이 커진다)
// UniversalRenderPipelineAsset.upscalingFilter … Point(Nearest-Neighbor) 로 확대한다(= 뭉개지지 않는다)
// 이 둘은 **파이프라인 에셋의 런타임 프로퍼티**라 코드에서 켜고 끌 수 있다(셰이더·머티리얼·포스트 0).
//
// ■ 🔴 절대 규칙 — 디스크에 쓰지 않는다
// 파이프라인 에셋은 프로젝트 에셋이다. 에디터에서 그 오브젝트의 값을 바꾸면 **메모리에 남고**,
// PD 가 「Save Project」 를 누르면 `Assets/Settings/URP-*.asset` 에 그대로 기록된다.
// 그래서 이 기능은
// · `EditorUtility.SetDirty` 를 **부르지 않는다**(호출 0)
// · 맵 이탈 · `OnApplicationQuit` · 에디터 플레이모드 종료에서 **원래 값으로 되돌린다**
// · 되돌릴 값(에셋 경로 + renderScale + filter)을 에디터 `SessionState` 에도 적어 둬서
// 도메인 리로드로 static 이 날아가도 다음 로드에서 복원한다(§PixelLook.EditorSafetyNet)
//
// ■ C8 롤백
// `enabled = 0` → 러너 오브젝트가 아예 뜨지 않고 파이프라인 에셋을 한 번도 건드리지 않는다(원본 100 %).
//
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLPixelLookSettings.asset
// → Resources.Load<WLPixelLookSettings>("WL/WLPixelLookSettings") 1회 로드 후 캐시(기존 WL 설정 SO 관례).
//
// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(MyValue·InGameInfo 가 Assembly-CSharp 에 있다).
// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Look
{
/// <summary>낮은 해상도를 화면 크기로 확대할 때 쓰는 필터. URP UpscalingFilterSelection 으로 매핑된다.</summary>
public enum PixelUpscaleFilter
{
/// <summary>Nearest-Neighbor — 픽셀이 네모로 남는다(도트 룩의 핵심).</summary>
Point = 0,
/// <summary>Bilinear — 뭉개진다(비교용).</summary>
Linear = 1,
/// <summary>URP 자동 선택(렌더 스케일·해상도에 따라 Point 또는 Linear).</summary>
Auto = 2,
/// <summary>AMD FSR 1.0(선명하게 복원 = 도트 룩과 반대 방향 · 비교용).</summary>
FSR = 3,
}
[CreateAssetMenu(fileName = "WLPixelLookSettings", menuName = "WL/Pixel Look Settings", order = 324)]
public sealed class WLPixelLookSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLPixelLookSettings";
static WLPixelLookSettings s_cached;
static bool s_lookupDone;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null = 기능 전체 무동작.</summary>
public static WLPixelLookSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLPixelLookSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치(WLCameraFramingSettings.RuntimeDisabled 선례).</summary>
public static bool RuntimeDisabled;
/// <summary>기능이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off.</summary>
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치 (C8)")]
[Tooltip("0 이면 러너 오브젝트도 뜨지 않고 파이프라인 에셋을 한 번도 건드리지 않는다 = 원본 100 %.")]
public bool enabled = true;
[Tooltip("적용/복원 1줄 로그. 프레임당 로그는 없다(경계에서만).")]
public bool verboseLog = false;
// ─────────────────────────────────────────── 픽셀 크기
[Header("픽셀 크기")]
[Tooltip("세로로 몇 픽셀을 그릴 것인가. renderScale = targetHeightPx ÷ Screen.height.\n" +
"1080×1920 기준: 1920=원본 · 640≈0.333 · 480=0.25 · 360≈0.1875")]
[Range(64, 4096)]
public int targetHeightPx = 480;
[Tooltip("확대 필터. 도트 룩은 Point(Nearest-Neighbor).")]
public PixelUpscaleFilter filter = PixelUpscaleFilter.Point;
[Tooltip("renderScale 하한(URP 자체 하한도 0.1). 0.1 미만은 URP 가 어차피 0.1 로 잘라낸다.")]
[Range(0.05f, 1f)]
public float minRenderScale = 0.1f;
[Tooltip("renderScale 상한. 1 이면 「원본보다 크게 그리는 것」을 막는다(=슈퍼샘플링 방지).")]
[Range(0.5f, 3f)]
public float maxRenderScale = 1f;
// ─────────────────────────────────────────── 적용 범위
[Header("적용 범위 (인게임 한정)")]
[Tooltip("1 이면 전투 맵(Stage/Dungeon)에서만 켠다. 로비·타이틀·농장은 선명하게 둔다.")]
public bool applyOnlyInBattleMap = true;
[Tooltip("1 이면 맵 이탈(타이틀/로비 복귀) · 종료 · 플레이모드 종료에서 원래 값으로 되돌린다. 🔴 0 으로 두지 말 것.")]
public bool restoreOnExit = true;
[Tooltip("맵 진입/이탈 경계를 보는 주기(초). 프레임당 비용 0(시간 비교 1회).")]
[Range(0.05f, 2f)]
public float pollSeconds = 0.25f;
[Tooltip("1 이면 해상도가 바뀌었을 때(회전·창 크기) renderScale 을 다시 계산한다.")]
public bool trackScreenSize = true;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 814a00029c7b4d1e8f2a6c5b3d47e902
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: