Project_WL/AgentScripts/WL814a_Probe.cs

624 lines
34 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.

// 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;
}
}