// 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(); var qGuids = new List(); 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(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("Assets/Res_Addr/MainUI/NewGameUI.prefab"); if (ui != null) { var canvases = ui.GetComponentsInChildren(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(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(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(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() : 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("Assets/Res_Addr/Ingame/HUDUI.prefab"); var dmg = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/Ingame/HUDDMGUI.prefab"); L(" ④ HUDUI.prefab Canvas=" + (hud != null ? hud.GetComponentsInChildren(true).Length : -1) + " · HUDDMGUI.prefab Canvas=" + (dmg != null ? dmg.GetComponentsInChildren(true).Length : -1) + " (0 = 자체 캔버스 없음 = 부모 캔버스[NewGameUI]를 그대로 쓴다 · HUDBase 가 WorldToViewportPoint 로 anchoredPosition 만 옮긴다)"); Chk("A4b HP바/데미지숫자 = 스크린 공간(자체 캔버스 0)", hud != null && dmg != null && hud.GetComponentsInChildren(true).Length == 0 && dmg.GetComponentsInChildren(true).Length == 0, "HUDUI/HUDDMGUI 자체 Canvas 0"); // ⑤ 카메라 스택 var camPrefab = AssetDatabase.LoadAssetAtPath("Assets/ResWork/Prefabs/Ingame/Main Camera.prefab"); if (camPrefab != null) { var c = camPrefab.GetComponentInChildren(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()) 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() != 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(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(true) : null; Chk("E3 카메라 프리팹", cam != null, camGo != null ? camGo.name : "null"); if (cam == null) return; // 에디트 모드에서 원본 스크립트가 돌지 않게(Update 는 어차피 안 돈다) 카메라 스크립트만 비활성 foreach (var mb in camGo.GetComponentsInChildren(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(); 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(); 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); } /// Animator 컨트롤러의 Idle 계열 클립을 0.5 s 로 샘플해서 T 포즈를 없앤다(에디트 모드 · 애니메이터 상태 변경 0). static void Pose(GameObject go) { if (go == null) return; try { var an = go.GetComponentInChildren(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 { } } /// /// UI 목업 — 화면 픽셀 1:1 로 배치한 캔버스. /// 🔴 실제 게임 경로(URP Overlay 카메라 + ScreenSpaceCamera 캔버스)는 **에디트 모드 Camera.Render 에서 스택이 그려지지 않아** /// 캡처에 안 나온다(이 함수 안에서 실측 · 로그로 남긴다). 그래서 캡처용으로만 캔버스를 베이스 카메라 앞 /// 월드 공간에 두고 화면 픽셀과 1:1 이 되게 스케일한다 — 같은 렌더 타겟(= 같은 renderScale)에 그려지므로 /// 「렌더 스케일이 UI 를 어떻게 만드는가」는 실제와 같다. /// 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.renderMode = RenderMode.WorldSpace; canvas.worldCamera = baseCam; var crt = canvasGo.GetComponent(); 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(); rt.SetParent(parent, false); rt.anchorMin = rt.anchorMax = anchor; rt.pivot = anchor; rt.sizeDelta = size; rt.anchoredPosition = pos; var img = go.GetComponent(); img.sprite = null; img.color = col; img.raycastTarget = false; return go; } static GameObject Inst(string path, Vector3 pos) { var src = AssetDatabase.LoadAssetAtPath(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 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(); 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(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) + " |"; } /// 렌더러 바운드를 화면에 투영한 세로 픽셀(1080×1920 전체 해상도 기준). static float ScreenHeightPx(Camera cam, GameObject go) { if (go == null) return 0f; var rs = go.GetComponentsInChildren(); 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; } /// 스킬 카드 한 변의 1080×1920 화면 픽셀. NewGameUI(ref 1920×1080 · Expand) scaleFactor 0.5625 적용. static float SkillButtonPx() { var ui = AssetDatabase.LoadAssetAtPath("Assets/Res_Addr/MainUI/NewGameUI.prefab"); if (ui == null) return 0f; foreach (var b in ui.GetComponentsInChildren(true)) { if (b.skillCards == null || b.skillCards.Length == 0 || b.skillCards[0] == null) continue; var rt = b.skillCards[0].GetComponent(); if (rt != null) return Mathf.Max(rt.sizeDelta.x, rt.sizeDelta.y) * 0.5625f; } return 0f; } }