// WL-814j 프로브 — 팔레트 양자화 + Bayer 디더(저해상도 게임 카메라 한정) 실측 (에디트 모드 · Play 0 · 로그인 0) // probe: unity command run_script --file AgentScripts/WL814j_Probe.cs --entry WL814j_Probe.RunAll // cap: unity command run_script --file AgentScripts/WL814j_Probe.cs --entry WL814j_Probe.Capture // 산출물: AgentScripts/WL814j_PROBE.txt · WL814j_CAPTURE.txt · 캡처 PNG 4장 // 🔴 씬 저장 0 · 에셋 쓰기 0(AssetDatabase.SaveAssets 호출 0 = URP 에셋 재저장 방지) · 임시 오브젝트는 DontSave + DestroyImmediate. 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 WL.Look.Env; using WL.Look.Palette; using WL.Look.Toggle; using WL.PixelArt; public static class WL814j_Probe { const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814j"; const string kTxt = "AgentScripts/WL814j_PROBE.txt"; const string kTxtCap = "AgentScripts/WL814j_CAPTURE.txt"; const string kMapPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; const string kCamPrefab = "Assets/ResWork/Prefabs/Ingame/Main Camera.prefab"; const string kRendererPath = "Assets/Settings/URP-Balanced-Renderer.asset"; // 도트A 프리셋 = ortho 3.4 · pixelHeight 240 → 9:16 → 135×240 · 화면 1080×1920 = 정수 8배 const int kLowW = 135, kLowH = 240, kScale = 8; const int kHiW = kLowW * kScale, kHiH = kLowH * kScale; static StringBuilder s_sb; static int s_pass, s_fail; static void L(string s) { s_sb.AppendLine(s); } static void H(string s) { s_sb.AppendLine(); s_sb.AppendLine("── " + s); } static void Chk(string n, bool ok, string d) { if (ok) s_pass++; else s_fail++; L((ok ? " PASS " : " FAIL ") + n + " — " + d); } static void Write(string p) { File.WriteAllText(p, s_sb.ToString(), new UTF8Encoding(false)); Debug.Log("[WL814j] → " + p + " · " + (s_fail == 0 ? "PASS" : "FAIL") + " " + s_pass + "/" + (s_pass + s_fail)); } // ═════════════════════════════════════════════════════════════════════════ // RunAll // ═════════════════════════════════════════════════════════════════════════ public static void RunAll() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("# WL-814j 프로브 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)"); L("playMode=" + Application.isPlaying + " · colorSpace=" + QualitySettings.activeColorSpace); bool savedRtDisable = WLPaletteSettings.RuntimeDisabled; try { A_Setup(); B_TargetOnly(); C_DitherGrid(); D_Levels(); E_Gc(); F_C8(); G_ModeCycle(); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } finally { WLPaletteSettings.RuntimeDisabled = savedRtDisable; Z_Cleanup(); } L(""); L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " · " + s_pass + "/" + (s_pass + s_fail) + " · FAIL " + s_fail); Write(kTxt); } // ─────────────────────────────────────────── A. 전제 static void A_Setup() { H("A. 전제 — SO · 셰이더 · 렌더러 피처"); WLPaletteSettings.ClearCache(); var c = WLPaletteSettings.Instance; Chk("A1 SO", c != null, c != null ? "Resources/WL/WLPaletteSettings · enabled " + c.enabled_ + " · levels " + c.levels + " · dither " + c.ditherStrength : "없음"); var sh = Shader.Find(PaletteDitherFeature.ShaderName); Chk("A2 셰이더", sh != null && sh.isSupported, sh != null ? PaletteDitherFeature.ShaderName + " · isSupported " + sh.isSupported + " · passCount " + sh.passCount : "없음"); var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); Chk("A3 렌더러 에셋", rd != null, kRendererPath); if (rd != null) { int pal = 0; L(" 피처 " + rd.rendererFeatures.Count + "개(순서대로):"); for (int i = 0; i < rd.rendererFeatures.Count; i++) { var f = rd.rendererFeatures[i]; L(" [" + i + "] " + (f == null ? "(null)" : f.name + " [" + f.GetType().Name + "] active=" + f.isActive)); if (f is PaletteDitherFeature) pal++; } Chk("A4 팔레트 피처 1건만", pal == 1, "WLPaletteDither " + pal + "개"); } PaletteDither.ClearFeatureCache(); var feat = PaletteDither.SetFeature(true); Chk("A5 피처 찾기·켜기", feat && PaletteDither.FeatureActiveNow, "isActive=" + PaletteDither.FeatureActiveNow); var pf = PaletteDither.Feature as PaletteDitherFeature; Chk("A6 머티리얼 생성", pf != null && pf.ProbeMaterial != null, pf != null && pf.ProbeMaterial != null ? pf.ProbeMaterial.shader.name : "없음"); } // ─────────────────────────────────────────── B. 대상 카메라에서만 패스 static Camera _camA, _camB, _camPrev; static GameObject _goA, _goB, _goPrev; static void B_TargetOnly() { H("B. 🔴 대상 카메라에서만 패스 삽입 (뷰·UI·프리뷰 0)"); _goA = MakeCam("__WL814j_Target", out _camA); _goB = MakeCam("__WL814j_Other", out _camB); _goPrev = MakeCam("__WL814j_Preview", out _camPrev); _camPrev.cameraType = CameraType.Preview; PaletteDitherFeature.TargetCamera = _camA; PaletteDither.SetFeature(true); PaletteDitherFeature.ResetCounters(); RenderToLow(_camA, null); int a = PaletteDitherFeature.PassesAdded; Chk("B1 대상 카메라 = 패스 1건", a == 1, "PassesAdded " + a + " · 대상 '" + PaletteDitherFeature.LastTargetName + "' · 이벤트 " + PaletteDitherFeature.LastEvent); PaletteDitherFeature.ResetCounters(); RenderToLow(_camB, null); Chk("B2 다른 카메라 = 패스 0", PaletteDitherFeature.PassesAdded == 0, "PassesAdded " + PaletteDitherFeature.PassesAdded + " · skip=" + PaletteDitherFeature.LastSkipReason); PaletteDitherFeature.ResetCounters(); RenderToLow(_camPrev, null); Chk("B3 프리뷰 카메라 = 패스 0", PaletteDitherFeature.PassesAdded == 0, "PassesAdded " + PaletteDitherFeature.PassesAdded + " · skip=" + PaletteDitherFeature.LastSkipReason); // 대상 미지정 + 리그 미조립 → 어느 카메라에서도 0 PaletteDitherFeature.TargetCamera = null; PaletteDitherFeature.ResetCounters(); RenderToLow(_camA, null); Chk("B4 대상 없음 = 패스 0", PaletteDitherFeature.PassesAdded == 0, "리그조립 " + PixelCameraRig.IsAssembled + " · skip=" + PaletteDitherFeature.LastSkipReason); PaletteDitherFeature.TargetCamera = _camA; // 렌더 순서 실측 — 814c 외곽선 피처와의 상대 위치 var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); string order = ""; if (rd != null) foreach (var f in rd.rendererFeatures) if (f != null) order += (order.Length > 0 ? " → " : "") + f.name; var c = WLPaletteSettings.Instance; L(" 렌더러 피처 배열 순서 = " + order); L(" 팔레트 패스 이벤트 = " + PaletteDitherFeature.LastEvent + "(" + (int)PaletteDitherFeature.LastEvent + ")" + " · SO afterPostProcessing=" + (c != null && c.afterPostProcessing)); L(" 814c PixelOutlineSetup 이벤트 = AfterRenderingOpaques(" + (int)RenderPassEvent.AfterRenderingOpaques + ") — 빈 패스로 깊이·노멀·색 텍스처만 켠다."); L(" → 외곽선 자체는 Critter Toon 머티리얼 안에서 **불투명 지오메트리와 함께** 그려지므로 항상 이 패스보다 앞이다 = 외곽선까지 양자화된다."); Chk("B5 팔레트가 외곽선보다 뒤", (int)PaletteDitherFeature.LastEvent > (int)RenderPassEvent.AfterRenderingOpaques, (int)PaletteDitherFeature.LastEvent + " > " + (int)RenderPassEvent.AfterRenderingOpaques); } // ─────────────────────────────────────────── C. 디더 격자 = RT 픽셀 1:1 static void C_DitherGrid() { H("C. 🔴 Bayer 디더 격자가 RT 픽셀과 1:1 (단색 화면 · 4×4 주기 실측)"); var c = WLPaletteSettings.Instance; if (c == null || _camA == null) { Chk("C0", false, "전제 없음"); return; } float savedD = c.ditherStrength; bool saved8 = c.dither8x8; int savedL = c.levels; try { _camA.cullingMask = 0; _camA.clearFlags = CameraClearFlags.SolidColor; _camA.backgroundColor = new Color(0.52f, 0.31f, 0.66f, 1f); // 세 채널 모두 양자화 경계 근처 PaletteDitherFeature.TargetCamera = _camA; PaletteDither.SetFeature(true); // ① 디더 off = 완전 단색 c.levels = 6; c.ditherStrength = 0f; c.dither8x8 = false; var off = ReadLow(_camA); int offColors = Distinct(off); Chk("C1 디더 0 → 단색 1종", offColors == 1, "고유 색 " + offColors + "종 · " + Rgb(off[0])); // ② 디더 4×4 c.ditherStrength = 0.6f; var d4 = ReadLow(_camA); int d4Colors = Distinct(d4); int bad4x = Period(d4, 4, 0), bad4y = Period(d4, 0, 4); int bad2x = Period(d4, 2, 0), bad2y = Period(d4, 0, 2); Chk("C2 디더 4×4 → 색 2종 이상", d4Colors >= 2, "고유 색 " + d4Colors + "종"); Chk("C3 x 주기 4 정확", bad4x == 0, "p(x,y) != p(x+4,y) 인 픽셀 " + bad4x + "개 / " + ((kLowW - 4) * kLowH)); Chk("C4 y 주기 4 정확", bad4y == 0, "p(x,y) != p(x,y+4) 인 픽셀 " + bad4y + "개 / " + (kLowW * (kLowH - 4))); Chk("C5 주기 2 아님(= 진짜 4×4)", bad2x > 0 || bad2y > 0, "x " + bad2x + " · y " + bad2y + " 개 불일치"); // ③ 디더 8×8 c.dither8x8 = true; var d8 = ReadLow(_camA); int bad8x = Period(d8, 8, 0), bad8y = Period(d8, 0, 8); int bad84x = Period(d8, 4, 0); Chk("C6 8×8 x 주기 8 정확", bad8x == 0, "불일치 " + bad8x + "개"); Chk("C7 8×8 y 주기 8 정확", bad8y == 0, "불일치 " + bad8y + "개"); Chk("C8g 8×8 는 주기 4 아님", bad84x > 0, "주기 4 불일치 " + bad84x + "개"); L(" → 디더 점 1개 = 저해상도 RT 1픽셀(135×240). 화면(1080×1920)에서는 Point 확대로 8×8 화면픽셀 블록이 된다."); } finally { c.ditherStrength = savedD; c.dither8x8 = saved8; c.levels = savedL; _camA.cullingMask = -1; _camA.clearFlags = CameraClearFlags.Skybox; } } // ─────────────────────────────────────────── D. 레벨별 색 수 상한 static void D_Levels() { H("D. 채널 레벨별 색 수 (그라데이션 화면 · levels^3 상한 실측)"); var c = WLPaletteSettings.Instance; if (c == null || _camA == null) { Chk("D0", false, "전제 없음"); return; } int savedL = c.levels; float savedD = c.ditherStrength; GameObject quad = null; try { // 화면을 꽉 채우는 그라데이션 = 스카이박스(하늘 그라데이션)로 대신한다 — 색이 충분히 다양하다. _camA.cullingMask = 0; _camA.clearFlags = CameraClearFlags.Skybox; PaletteDitherFeature.TargetCamera = _camA; PaletteDither.SetFeature(false); var baseline = ReadLow(_camA); int n0 = Distinct(baseline); PaletteDither.SetFeature(true); L(""); L(" | levels | dither | 고유 색 수 | 상한 levels^3 |"); L(" |---|---|---|---|"); L(" | (패스 없음) | — | " + n0 + " | — |"); int[] ls = { 4, 6, 8 }; for (int i = 0; i < ls.Length; i++) { c.levels = ls[i]; c.ditherStrength = 0.6f; var px = ReadLow(_camA); int n = Distinct(px); int cap = ls[i] * ls[i] * ls[i]; L(" | " + ls[i] + " | 0.6 | " + n + " | " + cap + " |"); Chk("D" + (i + 1) + " levels " + ls[i] + " 상한 준수", n <= cap, n + " ≤ " + cap); } c.levels = 6; var q6 = ReadLow(_camA); Chk("D4 색 수 감소", Distinct(q6) < n0, "패스 없음 " + n0 + "종 → levels 6 " + Distinct(q6) + "종"); } finally { c.levels = savedL; c.ditherStrength = savedD; if (quad != null) UnityEngine.Object.DestroyImmediate(quad); _camA.cullingMask = -1; } } // ─────────────────────────────────────────── E. GC static void E_Gc() { H("E. GC — 프레임당 할당 0"); var pf = PaletteDither.Feature as PaletteDitherFeature; PaletteDither.Tick(); // 안정화 Gc("PaletteDither.Tick ×200k", delegate { PaletteDither.Tick(); }, 200000); Gc("PaletteDither.Want ×200k", delegate { PaletteDither.Want(); }, 200000); if (pf != null) Gc("피처 값 푸시(SO→머티리얼) ×200k", delegate { pf.ProbePushValues(); }, 200000); // 대조군 — 계기 감도 long b2 = GC.GetTotalMemory(false); var junk = new byte[16 * 1024 * 1024]; junk[0] = 1; long a2 = GC.GetTotalMemory(false); Chk("E9 대조군 16 MB 감지", a2 - b2 > 15000000, "Δ" + (a2 - b2) + " B = 계기 감도 확인"); junk = null; } static void Gc(string name, Action a, int n) { a(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long mono0 = GC.GetTotalMemory(false); long total0 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); int g0 = GC.CollectionCount(0); for (int i = 0; i < n; i++) a(); long mono1 = GC.GetTotalMemory(false); long total1 = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); Chk("E " + name, mono1 - mono0 == 0 && GC.CollectionCount(0) == g0, "mono Δ" + (mono1 - mono0) + " B · profiler Δ" + (total1 - total0) + " B · gen0 " + g0 + "→" + GC.CollectionCount(0)); } // ─────────────────────────────────────────── F. C8 static void F_C8() { H("F. C8 — SO off / 피처 off 면 저해상도 RT 가 원본과 픽셀 단위로 같다"); var c = WLPaletteSettings.Instance; if (c == null || _camA == null) { Chk("F0", false, "전제 없음"); return; } _camA.cullingMask = 0; _camA.clearFlags = CameraClearFlags.Skybox; PaletteDitherFeature.TargetCamera = _camA; PaletteDither.SetFeature(false); var orig = ReadLow(_camA); long hOrig = Hash(orig); // ① 피처 on + SO off PaletteDither.SetFeature(true); WLPaletteSettings.RuntimeDisabled = true; PaletteDitherFeature.ResetCounters(); var so_off = ReadLow(_camA); Chk("F1 SO off → 패스 0", PaletteDitherFeature.PassesAdded == 0, "PassesAdded " + PaletteDitherFeature.PassesAdded + " · skip=" + PaletteDitherFeature.LastSkipReason); Chk("F2 SO off → 픽셀 동일", Hash(so_off) == hOrig, "해시 " + Hash(so_off) + " vs " + hOrig); WLPaletteSettings.RuntimeDisabled = false; // ② 피처 off PaletteDither.SetFeature(false); PaletteDitherFeature.ResetCounters(); var f_off = ReadLow(_camA); Chk("F3 피처 off → AddRenderPasses 호출 0", PaletteDitherFeature.PassesAdded == 0 && PaletteDitherFeature.PassesSkipped == 0, "added " + PaletteDitherFeature.PassesAdded + " · skipped " + PaletteDitherFeature.PassesSkipped + "(URP 가 비활성 피처를 아예 안 부른다)"); Chk("F4 피처 off → 픽셀 동일", Hash(f_off) == hOrig, "해시 " + Hash(f_off) + " vs " + hOrig); // ③ 다시 켜면 달라진다(대조군) PaletteDither.SetFeature(true); var on = ReadLow(_camA); Chk("F5 대조군 — 켜면 달라진다", Hash(on) != hOrig, "해시 " + Hash(on) + " != " + hOrig); _camA.cullingMask = -1; } // ─────────────────────────────────────────── G. 도트 모드 전환 3회 왕복 static GameObject _mainCamGo, _infoGo; static Camera _srcCam; static void G_ModeCycle() { H("G. 도트 모드 전환 3회 왕복 — 예외 0 · 대상 카메라가 리그 게임 카메라로 따라온다"); PaletteDitherFeature.TargetCamera = null; // 🔴 리그 폴백 경로를 실제로 태운다 var pf = AssetDatabase.LoadAssetAtPath(kCamPrefab); if (pf == null) { Chk("G0 원본 카메라 프리팹", false, kCamPrefab); return; } _mainCamGo = UnityEngine.Object.Instantiate(pf); _mainCamGo.name = "__WL814j_MainCamera"; _mainCamGo.hideFlags = HideFlags.DontSave; _srcCam = _mainCamGo.GetComponent(); // 리그는 Camera.main 을 원본으로 쓴다. 임시 씬에 이미 MainCamera 태그 카메라가 있으면 그쪽이 뽑힐 수 있는데, // 검증 대상은 「도트 모드에서 리그 게임 카메라가 대상이 되는가」이므로 Camera.main 이 있기만 하면 된다. Chk("G1 Camera.main 존재", Camera.main != null, "Camera.main = " + (Camera.main != null ? Camera.main.name : "null") + " · 프로브가 넣은 것 = " + (_srcCam != null ? _srcCam.name : "null")); // 가짜 InGameInfo — 로비 판정만 인게임으로(에디트 모드 Awake 미실행 · Ins 는 순수 static) _infoGo = new GameObject("__WL814j_InGameInfo"); _infoGo.hideFlags = HideFlags.DontSave; var info = _infoGo.AddComponent(); InGameInfo.Ins = info; var fld = typeof(InGameInfo).GetField("m_GameMode", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); if (fld != null) fld.SetValue(info, eGameMode.Stage); Chk("G2 인게임 판정", !LookModeHub.InLobby, "InLobby=" + LookModeHub.InLobby); LookModeHub.ResetAll(); PaletteDither.ClearFeatureCache(); PaletteDither.ResetForProbe(); PaletteDither.EnsureSubscribed(); int ex = 0; L(""); L(" | 왕복 | 모드 | 리그조립 | 대상 카메라 | RT | 팔레트 on | 피처 active |"); L(" |---|---|---|---|---|---|---|"); for (int round = 1; round <= 3; round++) { for (int m = 1; m >= 0; m--) { try { LookModeHub.SetCameraMode(m); LookModeHub.Tick(); PaletteDither.Tick(); } catch (Exception e) { ex++; L(" EXCEPTION mode " + m + " — " + e.GetType().Name + " " + e.Message); } var t = PaletteDitherFeature.ResolvedTarget; var mgr = PixelCameraRig.Manager; string rt = mgr != null ? mgr.GameResolution.x + "×" + mgr.GameResolution.y : "—"; L(" | " + round + " | " + m + " | " + (PixelCameraRig.IsAssembled ? "O" : "-") + " | " + (t != null ? t.name : "(없음)") + " | " + rt + " | " + PaletteDither.IsOn + " | " + PaletteDither.FeatureActiveNow + " |"); if (m == 1) { Chk("G" + round + "a 도트 모드 → on", PaletteDither.IsOn && PaletteDither.FeatureActiveNow, "on=" + PaletteDither.IsOn + " 피처=" + PaletteDither.FeatureActiveNow); Chk("G" + round + "b 대상 = 리그 게임 카메라", t != null && ReferenceEquals(t, PixelCameraRig.GameCamera), t != null ? t.name : "(없음)"); } else { Chk("G" + round + "c 기본 모드 → off", !PaletteDither.IsOn && !PaletteDither.FeatureActiveNow, "on=" + PaletteDither.IsOn + " 피처=" + PaletteDither.FeatureActiveNow); } } } Chk("G9 예외 0", ex == 0, ex + "건"); L(" " + PaletteDither.Dump()); // 로비 강등 = off if (fld != null) fld.SetValue(info, eGameMode.Lobby); LookModeHub.SetCameraMode(1); LookModeHub.Tick(); PaletteDither.Tick(); Chk("G10 로비 강등 → off", !PaletteDither.IsOn, "AppliedMode " + LookModeHub.AppliedMode + " · on " + PaletteDither.IsOn); if (fld != null) fld.SetValue(info, eGameMode.Stage); } // ─────────────────────────────────────────── Z. 정리 static void Z_Cleanup() { H("Z. 정리"); try { PaletteDitherFeature.TargetCamera = null; PaletteDither.SetFeature(false); PaletteDither.ResetForProbe(); LookModeHub.ResetAll(); PixelCameraRig.Teardown("814j probe end"); InGameInfo.Ins = null; DestroyGo(ref _goA); DestroyGo(ref _goB); DestroyGo(ref _goPrev); DestroyGo(ref _mainCamGo); DestroyGo(ref _infoGo); _camA = _camB = _camPrev = _srcCam = null; var strays = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); L(" 남은 카메라 " + strays.Length + "개 · 리그조립 " + PixelCameraRig.IsAssembled + " · 피처 active " + PaletteDither.FeatureActiveNow); L(" AssetDatabase.SaveAssets 호출 0 · 씬 저장 0"); } catch (Exception e) { L(" 정리 중 예외 " + e.GetType().Name + " " + e.Message); } } // ═════════════════════════════════════════════════════════════════════════ // Capture — 임시 씬(저장 0) · 도트A(ortho 3.4 · px 240) · 135×240 → 1080×1920 Point(CPU 정수 8배) · 4장 // ═════════════════════════════════════════════════════════════════════════ public static void Capture() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("# WL-814j 팔레트 캡처 " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); L("도트A 기준 = ortho 3.4 · pixelHeight 240 → RT " + kLowW + "×" + kLowH + " → CPU 정수 " + kScale + "배 = " + kHiW + "×" + kHiH + " PNG"); var pc = WLPaletteSettings.Instance; var ec = WLEnvLookSettings.Instance; Scene temp = default(Scene); 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(kMapPrefab, Vector3.zero); Chk("K0 맵", map != null, "WL_Nature"); if (map == null) return; // 814c 와 같은 지점·같은 구도(811o h 76 · yaw 233) — 나무·물이 있는 곳 const float h = 76f, yaw = 233f, dUp = 2.4f, ortho = 3.4f, camBack = 25f, farClip = 120f; Vector3 dir = (Quaternion.Euler(h, yaw, 0f) * Vector3.one).normalized; Vector3 fwd0 = new Vector3(dir.x, 0f, dir.z).normalized; string aname; Vector3 scenic = ScenicSpot(map, out aname); Vector3 pcPos = scenic - fwd0 * 11f; RaycastHit hit; if (Physics.Raycast(pcPos + Vector3.up * 300f, Vector3.down, out hit, 700f)) pcPos = hit.point; L(" 기준점(" + aname + ") " + scenic.ToString("0.0") + " → PC " + pcPos.ToString("0.0")); var camGo = new GameObject("__WL814j_OrthoCam"); camGo.hideFlags = HideFlags.DontSave; var cam = camGo.AddComponent(); var acd = camGo.AddComponent(); acd.renderShadows = true; acd.renderPostProcessing = false; acd.antialiasing = AntialiasingMode.None; cam.orthographic = true; cam.orthographicSize = ortho; cam.nearClipPlane = 0.1f; cam.farClipPlane = farClip; cam.clearFlags = CameraClearFlags.Skybox; Vector3 lookAt = pcPos + new Vector3(0f, 1.06f, 0f) + fwd0 * 2f; cam.transform.position = pcPos + Vector3.up * dUp - dir * camBack; cam.transform.LookAt(lookAt); Vector3 fwd = pcPos - cam.transform.position; fwd.y = 0f; fwd.Normalize(); Vector3 right = Vector3.Cross(Vector3.up, fwd); L(" 카메라 직교 size " + ortho + " · pos " + cam.transform.position.ToString("0.0") + " · look " + lookAt.ToString("0.0") + " · near 0.1 / far " + farClip); var pcGo = Inst("Assets/Res_Addr/PC/Ai01.prefab", pcPos); if (pcGo != null) pcGo.transform.rotation = Quaternion.LookRotation(fwd, Vector3.up); string[] mobs = { "Batty_A", "Batty_B", "Batty_C" }; Vector2[] off = { new Vector2(2.4f, 1.2f), new Vector2(3.4f, -1.5f), new Vector2(4.4f, 0.5f) }; 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("K1 PC · 잡몹", pcGo != null && mobGos.Count == 3, "PC " + (pcGo != null) + " · 몹 " + mobGos.Count + "/3"); Pose(pcGo); foreach (var m in mobGos) Pose(m); // A안(814i) 상태 재현 = 배경 룩(Critter Toon · _Shades 3) 적용 — 도트A 프리셋은 envLookOn 1 이다 EnvLook.ResetForProbe(); EnvLook.BeginForProbe(); int swMap = EnvLook.SweepRoot(map.transform); EnvLook.SetFeature(true); L(" 배경 룩(814c/814i) 적용 — 맵 렌더러 " + swMap + "개 스왑 · 외곽선 피처 on · Toon _Shades = " + (ec != null ? ec.shades.ToString() : "?")); PaletteDither.ClearFeatureCache(); PaletteDitherFeature.TargetCamera = cam; int savedLevels = pc != null ? pc.levels : 6; float savedDither = pc != null ? pc.ditherStrength : 0.6f; bool saved8 = pc != null && pc.dither8x8; var rows = new List(); // ① 디더 off = A안만 (팔레트 패스 자체를 넣지 않는다) PaletteDither.SetFeature(false); rows.Add(Shot(cam, kOutDir + @"\1_dither_off.png", "① A안만(팔레트 패스 0)", "—", "—")); // ②~④ 팔레트 on PaletteDither.SetFeature(true); int[] ls = { 6, 4, 8 }; float[] ds = { 0.6f, 0.8f, 0.3f }; string[] files = { @"\2_levels6_dither0.6.png", @"\3_levels4_dither0.8.png", @"\4_levels8_dither0.3.png" }; string[] labels = { "② 권장 기본", "③ 더 도트답게", "④ 약하게" }; for (int i = 0; i < 3; i++) { if (pc != null) { pc.levels = ls[i]; pc.ditherStrength = ds[i]; pc.dither8x8 = false; } rows.Add(Shot(cam, kOutDir + files[i], labels[i], ls[i].ToString(), ds[i].ToString("0.0"))); } if (pc != null) { pc.levels = savedLevels; pc.ditherStrength = savedDither; pc.dither8x8 = saved8; } L(""); L(" | 파일 | 무엇 | levels | dither | 고유 색 수(전수) | 814c 방식 표본(1/331) | 크기 |"); L(" |---|---|---|---|---|---|---|"); foreach (var r in rows) L(" " + r); Chk("K9 캡처 4장", Directory.GetFiles(kOutDir, "*.png").Length >= 4, Directory.GetFiles(kOutDir, "*.png").Length + "장"); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } finally { try { PaletteDitherFeature.TargetCamera = null; PaletteDither.SetFeature(false); } catch { } try { EnvLook.SetFeature(false); EnvLook.RestoreNow("814j capture end"); } catch { } 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); } // ═════════════════════════════════════════════════════════════════════════ // 렌더 · 색 세기 // ═════════════════════════════════════════════════════════════════════════ /// 저해상도 RT 로 1프레임 렌더하고 RGB24 픽셀을 돌려준다(Blit 0 = 색 왜곡 0). static Color32[] ReadLow(Camera cam) { var low = new RenderTexture(kLowW, kLowH, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); low.filterMode = FilterMode.Point; low.antiAliasing = 1; low.Create(); var prev = cam.targetTexture; cam.targetTexture = low; cam.Render(); cam.targetTexture = prev; var active = RenderTexture.active; RenderTexture.active = low; var tex = new Texture2D(kLowW, kLowH, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, kLowW, kLowH), 0, 0); tex.Apply(); RenderTexture.active = active; var px = tex.GetPixels32(); UnityEngine.Object.DestroyImmediate(tex); low.Release(); UnityEngine.Object.DestroyImmediate(low); RenderTexture.active = null; return px; } static void RenderToLow(Camera cam, RenderTexture unused) { var low = new RenderTexture(kLowW, kLowH, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); low.filterMode = FilterMode.Point; low.antiAliasing = 1; low.Create(); var prev = cam.targetTexture; cam.targetTexture = low; cam.Render(); cam.targetTexture = prev; low.Release(); UnityEngine.Object.DestroyImmediate(low); } /// 렌더 → PNG(정수 8배 Point 확대 · CPU) + 고유 색 수. 표 1줄을 돌려준다. static string Shot(Camera cam, string file, string label, string levels, string dither) { var low = ReadLow(cam); int distinct = Distinct(low); // CPU 정수 배율 확대 = Point · 색 왜곡 0(Graphics.Blit 를 쓰면 sRGB 왕복에서 ±1 이 생길 수 있다) var hi = new Color32[kHiW * kHiH]; for (int y = 0; y < kHiH; y++) { int sy = y / kScale; int srow = sy * kLowW, drow = y * kHiW; for (int x = 0; x < kHiW; x++) hi[drow + x] = low[srow + (x / kScale)]; } var tex = new Texture2D(kHiW, kHiH, TextureFormat.RGB24, false); tex.SetPixels32(hi); tex.Apply(); File.WriteAllBytes(file, tex.EncodeToPNG()); // 814c 와 같은 방법(1080×1920 을 331 간격으로 표본) — 이전 보고서 숫자와 비교하려고 같이 센다 var seen = new HashSet(); for (int i = 0; i < hi.Length; i += 331) seen.Add((hi[i].r << 16) | (hi[i].g << 8) | hi[i].b); int sampled = seen.Count; UnityEngine.Object.DestroyImmediate(tex); var fi = new FileInfo(file); Chk("K " + Path.GetFileName(file), fi.Exists && fi.Length > 2048 && distinct > 2, label + " · 고유 색 " + distinct + "종(표본 " + sampled + ") · " + fi.Length + " B"); return "| `" + Path.GetFileName(file) + "` | " + label + " | " + levels + " | " + dither + " | **" + distinct + "** | " + sampled + " | " + (fi.Length / 1024) + " KB |"; } static int Distinct(Color32[] px) { var seen = new HashSet(); for (int i = 0; i < px.Length; i++) seen.Add((px[i].r << 16) | (px[i].g << 8) | px[i].b); return seen.Count; } static long Hash(Color32[] px) { long h = 1469598103934665603L; for (int i = 0; i < px.Length; i++) { h = (h ^ px[i].r) * 1099511628211L; h = (h ^ px[i].g) * 1099511628211L; h = (h ^ px[i].b) * 1099511628211L; } return h; } static string Rgb(Color32 c) { return "rgb(" + c.r + "," + c.g + "," + c.b + ")"; } /// p(x,y) 와 p(x+dx, y+dy) 가 다른 픽셀 수. static int Period(Color32[] px, int dx, int dy) { int bad = 0; for (int y = 0; y + dy < kLowH; y++) for (int x = 0; x + dx < kLowW; x++) { var a = px[y * kLowW + x]; var b = px[(y + dy) * kLowW + (x + dx)]; if (a.r != b.r || a.g != b.g || a.b != b.b) bad++; } return bad; } // ── 헬퍼 ───────────────────────────────────────────────────────────────── static GameObject MakeCam(string name, out Camera cam) { var go = new GameObject(name); go.hideFlags = HideFlags.DontSave; cam = go.AddComponent(); var acd = go.AddComponent(); acd.renderPostProcessing = false; acd.antialiasing = AntialiasingMode.None; cam.orthographic = true; cam.orthographicSize = 3.4f; cam.clearFlags = CameraClearFlags.Skybox; cam.nearClipPlane = 0.1f; cam.farClipPlane = 120f; cam.transform.position = new Vector3(0f, 5f, -10f); cam.transform.rotation = Quaternion.Euler(20f, 0f, 0f); return go; } static void DestroyGo(ref GameObject go) { if (go != null) { UnityEngine.Object.DestroyImmediate(go); go = null; } } 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 void Pose(GameObject go) { if (go == null) return; foreach (var an in go.GetComponentsInChildren(true)) { if (an.runtimeAnimatorController == null) continue; AnimationClip idle = null; foreach (var cl in an.runtimeAnimatorController.animationClips) { if (cl == null) continue; var n = cl.name.ToLowerInvariant(); if (n.Contains("idle")) { idle = cl; break; } if (idle == null) idle = cl; } if (idle != null) idle.SampleAnimation(an.gameObject, 0.5f); } } /// 814c 와 같은 「나무·수풀이 촘촘하고 물이 가까운」 지점 선정. static Vector3 ScenicSpot(GameObject map, out string why) { var pts = new List(); var waters = new List(); foreach (var r in map.GetComponentsInChildren(true)) { if (r == null || r is ParticleSystemRenderer) continue; var m = r.sharedMaterial; if (m == null) continue; if (m.name == "Trees" || m.name == "Vegetation" || m.name == "Trees_Toon" || m.name == "Vegetation_Toon") pts.Add(r.bounds.center); else if (m.name.IndexOf("Water", StringComparison.OrdinalIgnoreCase) >= 0) waters.Add(r.bounds); } if (pts.Count == 0) { why = "(나무 0 · 원점)"; return Vector3.zero; } int step = Mathf.Max(1, pts.Count / 300); Vector3 best = pts[0]; float bestScore = -1f; int bestN = 0; float bestWater = 9999f; for (int i = 0; i < pts.Count; i += step) { var p = pts[i]; int n = 0; for (int j = 0; j < pts.Count; j += step) if ((pts[j] - p).sqrMagnitude < 25f * 25f) n++; float wd = 9999f; for (int w = 0; w < waters.Count; w++) wd = Mathf.Min(wd, Vector3.Distance(waters[w].ClosestPoint(p), p)); float score = n + (wd < 40f ? 12f : 0f) + (wd < 20f ? 12f : 0f); if (score > bestScore) { bestScore = score; best = p; bestN = n; bestWater = wd; } } best.y = 0f; RaycastHit hit; if (Physics.Raycast(new Vector3(best.x, 400f, best.z), Vector3.down, out hit, 900f)) best = hit.point; why = "나무·수풀 군락 " + bestN + "그루/25 m · 물까지 " + (bestWater > 9000f ? "없음" : bestWater.ToString("0") + " m"); return best; } }