// WL-814c 프로브 + 룩 캡처 6장 (게임 코드 아님 · 에디트 모드 전용 · 임시 씬 저장 0 · 커밋 0) // run: unity command run_script --file AgentScripts/WL814c_Probe.cs --entry WL814c_Probe.RunAll // cap: unity command run_script --file AgentScripts/WL814c_Probe.cs --entry WL814c_Probe.Capture // 🔴 원본 .mat / 프리팹 / 씬 수정 0 · AssetDatabase.SaveAssets 호출 0(URP 에셋 재저장 방지). // 🔴 Critter 의 전역 네임스페이스 `Environment` 는 System.Environment 와 충돌 → global:: 로 명시. using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.Profiling; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.SceneManagement; using WL.Look.Env; public static class WL814c_Probe { const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814c"; const string kTxt = "AgentScripts/WL814c_PROBE.txt"; const string kTxtCap = "AgentScripts/WL814c_CAPTURE.txt"; const string kMapPrefab = "Assets/Res_Addr/Map/WL_Nature.prefab"; const string kRendererPath = "Assets/Settings/URP-Balanced-Renderer.asset"; const int kLowW = 270, kLowH = 480, kHiW = 1080, kHiH = 1920; static StringBuilder s_sb; static int s_pass, s_fail; static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814c] " + 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("[WL814c] → " + p); } // ═════════════════════════════════════════════════════════════════════════ // RunAll // ═════════════════════════════════════════════════════════════════════════ public static void RunAll() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c 프로브 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion); Scene temp = default(Scene); try { A_Settings(); B_Feature(); temp = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single); var map = Inst(kMapPrefab, Vector3.zero); C_SwapRestore(map); D_Grass(map); E_Water(map); F_Gc(); G_C8(map); H_ApplyNow(map); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } finally { try { EnvLook.RestoreNow("probe-final"); } catch { } WLEnvLookSettings.RuntimeDisabled = false; 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(kTxt); } // ── A. 설정 ────────────────────────────────────────────────────────────── static void A_Settings() { L(""); L("## A. 설정 SO · 스왑 표"); var c = WLEnvLookSettings.Instance; Chk("A1 SO 로드", c != null, c != null ? AssetDatabase.GetAssetPath(c) : "null"); if (c == null) return; int pairs = c.originals != null ? c.originals.Length : 0; int nullPairs = 0; var dstDirs = new HashSet(); for (int i = 0; i < pairs; i++) { if (c.originals[i] == null || c.toons == null || i >= c.toons.Length || c.toons[i] == null) { nullPairs++; continue; } dstDirs.Add(System.IO.Path.GetDirectoryName(AssetDatabase.GetAssetPath(c.toons[i])).Replace('\\', '/')); } Chk("A2 스왑 표", pairs > 0 && nullPairs == 0, pairs + "쌍 · null " + nullPairs); Chk("A3 생성물 위치", dstDirs.Count == 1 && dstDirs.Contains("Assets/WL/Look/Env/Materials"), string.Join(" ", new List(dstDirs).ToArray())); // 원본 .mat 무수정 확인 = 원본은 여전히 원래 셰이더다 int origToon = 0; for (int i = 0; i < pairs; i++) if (c.originals[i] != null && c.originals[i].shader != null && c.originals[i].shader.name.Contains("Shader Graphs/Toon")) origToon++; Chk("A4 원본 머티리얼 무수정(셰이더 유지)", origToon == 0, "원본이 Toon 이 된 것 " + origToon + "개"); int toonShader = 0, withMap = 0, cloudsOn = 0, outlineOn = 0, shadesOk = 0; for (int i = 0; i < pairs; i++) { var t = c.toons[i]; if (t == null) continue; if (t.shader != null && t.shader.name.Contains("Toon")) toonShader++; if (t.GetTexture("_BaseMap") != null) withMap++; if (t.GetFloat("_Cloud_Strength") > 0f) cloudsOn++; // 🔴 _CLOUDSENABLED 는 이 셰이더에 없는 구버전 잔재 if (t.IsKeywordEnabled("_OUTLINESENABLED") && t.GetFloat("_OUTLINESENABLED") > 0f) outlineOn++; if (Mathf.Approximately(t.GetFloat("_Shades"), c.shades)) shadesOk++; } Chk("A5 생성 머티리얼 셰이더", toonShader == pairs, toonShader + "/" + pairs + " Toon"); Chk("A6 구름(_Cloud_Strength>0)·외곽선(_OUTLINESENABLED)", cloudsOn == pairs && outlineOn > 0, "clouds " + cloudsOn + "/" + pairs + " · outlines " + outlineOn + "/" + pairs + " (캐릭터는 SO characterOutlines=" + c.characterOutlines + " 라 " + (c.characterOutlines ? "켜짐" : "꺼짐") + ")"); Chk("A6b 툰 램프 Shades = SO", shadesOk == pairs, shadesOk + "/" + pairs + " (SO " + c.shades + ")"); L(" BaseMap 이관된 머티리얼 " + withMap + "/" + pairs + " (나머지는 원본에 텍스처가 없어 색만 이관)"); Chk("A7 잔디·물 에셋 참조", c.grassMesh != null && c.grassMaterial != null && c.flowerMesh != null && c.flowerMaterial != null && c.waterMaterial != null, "grass " + (c.grassMesh != null) + " · flower " + (c.flowerMesh != null) + " · water " + (c.waterMaterial != null)); } // ── B. 렌더러 피처 ─────────────────────────────────────────────────────── static void B_Feature() { L(""); L("## B. 픽셀 외곽선 렌더러 피처"); var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); Chk("B1 렌더러 에셋", rd != null, kRendererPath); if (rd == null) return; ScriptableRendererFeature outline = null; foreach (var f in rd.rendererFeatures) { L(" - " + (f == null ? "(null)" : f.name + " [" + f.GetType().Name + "] active=" + f.isActive)); if (f is global::Environment.PixelOutlineSetupFeature) outline = f; } Chk("B2 피처 존재", outline != null, outline != null ? outline.name : "없음"); Chk("B3 null 피처 0", !rd.rendererFeatures.Contains(null), rd.rendererFeatures.Count + "개"); if (outline == null) return; bool before = outline.isActive; EnvLook.SetFeature(true); bool during = outline.isActive; EnvLook.SetFeature(false); bool after = outline.isActive; Chk("B4 디스크 기본값 off(타이틀·로비 비용 0)", before == false, "isActive=" + before); Chk("B5 SetActive 전/후", during == true && after == before, before + " → " + during + " → " + after); Chk("B6 에셋 dirty 0", !EditorUtility.IsDirty(rd) && !EditorUtility.IsDirty(outline), "rd=" + EditorUtility.IsDirty(rd) + " feature=" + EditorUtility.IsDirty(outline)); // 디스크 원문이 그대로인가(내가 손으로 넣은 최소 diff 유지 · 버전 업그레이드 0) string text = File.ReadAllText(kRendererPath); Chk("B7 디스크 m_AssetVersion 유지", text.Contains("m_AssetVersion: 2"), text.Contains("m_AssetVersion: 3") ? "3 으로 업그레이드됨" : "2"); Chk("B8 디스크 피처 항목", text.Contains("- {fileID: 3640211299048422604}") && text.Contains("m_Name: PixelOutlineSetup"), "OK"); } // ── C. 스왑 / 복원 ─────────────────────────────────────────────────────── static void C_SwapRestore(GameObject map) { L(""); L("## C. 런타임 스왑 / 원복"); Chk("C1 맵 배치", map != null, kMapPrefab); if (map == null) return; var before = Snapshot(map); EnvLook.ResetForProbe(); EnvLook.BeginForProbe(); int swapped = EnvLook.SweepRoot(map.transform); var after = Snapshot(map); Chk("C2 스왑된 렌더러", swapped > 0, swapped + "개 · 슬롯 " + EnvLook.SwappedSlots); int toonSlots = 0, keptSlots = 0; foreach (var kv in after) { if (kv.Value.Contains("Toon")) toonSlots++; else keptSlots++; } Chk("C3 Toon 으로 바뀐 슬롯", toonSlots > 0, "Toon " + toonSlots + " · 유지 " + keptSlots + "(파티클·투명·물)"); int restored = 0, mismatch = 0; EnvLook.RestoreNow("probe-c"); var back = Snapshot(map); foreach (var kv in before) { string now; if (!back.TryGetValue(kv.Key, out now)) continue; if (now == kv.Value) restored++; else mismatch++; } Chk("C4 원복 == 원본 참조 동일", mismatch == 0, "동일 " + restored + " · 불일치 " + mismatch); Chk("C5 이중 복원 무해", EnvLook.RestoreNow("probe-c2") == false, "두 번째 복원 = 무동작"); } static Dictionary Snapshot(GameObject root) { var d = new Dictionary(); foreach (var r in root.GetComponentsInChildren(true)) { var sb = new StringBuilder(); foreach (var m in r.sharedMaterials) sb.Append(m != null ? m.name : "(null)").Append('|'); d[r.GetInstanceID()] = sb.ToString(); } return d; } // ── D. 잔디 ────────────────────────────────────────────────────────────── static void D_Grass(GameObject map) { L(""); L("## D. 잔디 인스턴싱"); var c = WLEnvLookSettings.Instance; if (map == null || c == null) return; EnvLook.ResetForProbe(); EnvLook.BeginForProbe(); int made = EnvLook.AttachGrass(map.transform, c); Chk("D1 지면 부착", made > 0, made + "개 지면 · 인스턴스(계산) " + EnvLook.GrassInstances + " · 드로우콜 +" + EnvLook.GrassDrawCalls); Chk("D2 상한 준수", EnvLook.GrassInstances <= c.maxInstances, EnvLook.GrassInstances + " ≤ " + c.maxInstances); L(" | 지면 | 메시 면적(m²) | Density | 인스턴스 | 설정 |"); L(" |---|---|---|---|---|"); int comps = 0, navChanged = 0; foreach (var mib in map.GetComponentsInChildren(true)) { comps++; var mf = mib.GetComponent(); float area = mf != null && mf.sharedMesh != null ? global::Environment.Utilities.MeshUtilities.GetMeshArea(mf.sharedMesh) : 0f; int n = Mathf.CeilToInt(area / Mathf.Max(0.01f, mib.Density)); string st = ""; foreach (var s in mib.InstancingSettings) st += (s.Mesh != null ? s.Mesh.name : "-") + "/" + (s.Material != null ? s.Material.name : "-") + "(p" + s.Probability + " s" + s.Scale + ") "; L(" | " + mib.name + " | " + area.ToString("0") + " | " + mib.Density.ToString("0.00") + " | " + n + " | " + st.Trim() + " |"); var mr = mib.GetComponent(); if (mr != null && mr.enabled) navChanged++; } Chk("D3 컴포넌트 수", comps == made, comps + " / " + made); Chk("D4 대리 렌더러는 그리지 않는다(지면 이중 렌더 0)", navChanged == 0, "enabled=true 인 대리 렌더러 " + navChanged + "개"); int colliders = 0; foreach (var mib in map.GetComponentsInChildren(true)) if (mib.GetComponent() != null) colliders++; Chk("D5 렌더 전용(콜라이더·NavMesh 무관)", colliders == 0, "잔디 오브젝트의 콜라이더 " + colliders + "개"); EnvLook.RestoreNow("probe-d"); int left = map.GetComponentsInChildren(true).Length; Chk("D6 이탈 시 파괴", left == 0, "남은 컴포넌트 " + left); } // ── E. 물 ──────────────────────────────────────────────────────────────── static void E_Water(GameObject map) { L(""); L("## E. 물 스왑"); var c = WLEnvLookSettings.Instance; if (map == null || c == null) return; EnvLook.ResetForProbe(); EnvLook.BeginForProbe(); EnvLook.SweepRoot(map.transform); int waterBefore = 0; foreach (var r in map.GetComponentsInChildren(true)) if (r.sharedMaterial != null && r.sharedMaterial.name == "Water") waterBefore++; // SweepRoot 만으로는 물이 안 바뀐다 → ApplyNow 경로의 SwapWater 를 쓰는지 확인 Chk("E1 SweepRoot 는 물을 건드리지 않는다", waterBefore == 0, "Water 로 바뀐 렌더러 " + waterBefore); EnvLook.RestoreNow("probe-e0"); EnvLook.ResetForProbe(); EnvLook.BeginForProbe(); var mi = typeof(EnvLook).GetMethod("SwapWater", BindingFlags.NonPublic | BindingFlags.Static); if (mi != null) mi.Invoke(null, new object[] { map.transform, c }); int waterAfter = 0; var reflCams = 0; foreach (var r in map.GetComponentsInChildren(true)) { if (r.sharedMaterial != null && r.sharedMaterial.name == "Water") waterAfter++; if (r.GetComponent() != null) reflCams++; } Chk("E2 물 스왑", waterAfter > 0, waterAfter + "개 렌더러 → Critter Water · 카운터 " + EnvLook.WaterSwapped); Chk("E3 반사 카메라 미부착(성능)", reflCams == 0, "WaterReflectionCamera " + reflCams + "개"); EnvLook.RestoreNow("probe-e"); int back = 0; foreach (var r in map.GetComponentsInChildren(true)) if (r.sharedMaterial != null && r.sharedMaterial.name == "Water") back++; Chk("E4 물 원복", back == 0, "남은 Water " + back); } // ── F. GC ──────────────────────────────────────────────────────────────── static void F_Gc() { L(""); L("## F. GC 0"); EnvLook.ResetForProbe(); Gc("EnvLook.Tick(비적용 · 폴링 전)", () => EnvLook.TickForProbe(), 200000); EnvLook.BeginForProbe(); Gc("EnvLook.SweepNewSpawns(캐시 히트)", () => EnvLook.SweepNewSpawns(), 200000); EnvLook.RestoreNow("probe-f"); } static void Gc(string name, Action a, int n) { a(); a(); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long m0 = Profiler.GetMonoUsedSizeLong(); long t0 = GC.GetTotalMemory(false); int g0 = GC.CollectionCount(0); for (int i = 0; i < n; i++) a(); long m1 = Profiler.GetMonoUsedSizeLong(); long t1 = GC.GetTotalMemory(false); int g1 = GC.CollectionCount(0); Chk("F " + name + " ×" + n, (m1 - m0) == 0 && (t1 - t0) == 0 && (g1 - g0) == 0, "monoΔ " + (m1 - m0) + " B · totalΔ " + (t1 - t0) + " B · gen0 " + (g1 - g0)); } // ── G. C8 ──────────────────────────────────────────────────────────────── static void G_C8(GameObject map) { L(""); L("## G. C8 롤백 (enabled = 0)"); var c = WLEnvLookSettings.Instance; if (map == null || c == null) return; EnvLook.ResetForProbe(); WLEnvLookSettings.RuntimeDisabled = true; bool applied = EnvLook.ApplyNow("c8"); int swapped = EnvLook.SweepRoot(map.transform); int grass = map.GetComponentsInChildren(true).Length; var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); bool feature = false; foreach (var f in rd.rendererFeatures) if (f is global::Environment.PixelOutlineSetupFeature) feature = f.isActive; Chk("G1 enabled=0 → 적용 0", !applied, "ApplyNow=" + applied + " · Enabled=" + WLEnvLookSettings.Enabled); Chk("G2 enabled=0 → 스왑 0", swapped == 0, "스왑 렌더러 " + swapped); Chk("G3 enabled=0 → 잔디 0", grass == 0, "잔디 컴포넌트 " + grass); Chk("G4 enabled=0 → 피처 off", !feature, "isActive=" + feature); EnvLook.EnsureRunnerForProbe(); int runners = 0; foreach (var go in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) if (go.name.StartsWith("[WL814c]")) runners++; Chk("G5 에디트 모드 러너 오브젝트 0", runners == 0, runners + "개"); WLEnvLookSettings.RuntimeDisabled = false; } // ── H. 실제 진입점(ApplyNow) 왕복 ─────────────────────────────────────── // 🔴 InGameInfo.Ins 는 자동 생성되지 않는 순수 static 필드라(실측) 에디트 모드에서 null 이고, // MyValue.MyPC 도 null 이라 ApplyNow 는 맵·물·잔디·피처 경로만 타고 캐릭터 경로는 건너뛴다. static void H_ApplyNow(GameObject map) { L(""); L("## H. ApplyNow / RestoreNow 왕복 (게임이 실제로 부르는 진입점)"); if (map == null) return; var before = Snapshot(map); EnvLook.ResetForProbe(); bool ok = EnvLook.ApplyNow("probe-h"); var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); bool feature = false; foreach (var f in rd.rendererFeatures) if (f is global::Environment.PixelOutlineSetupFeature) feature = f.isActive; int grass = map.GetComponentsInChildren(true).Length; Chk("H1 ApplyNow", ok && EnvLook.SwappedRenderers > 0 && EnvLook.WaterSwapped > 0 && EnvLook.GrassObjects > 0, "렌더러 " + EnvLook.SwappedRenderers + " · 슬롯 " + EnvLook.SwappedSlots + " · 물 " + EnvLook.WaterSwapped + " · 잔디 " + EnvLook.GrassObjects + "(" + EnvLook.GrassInstances + "개 · +" + EnvLook.GrassDrawCalls + " 드로우콜)"); Chk("H2 피처 활성", feature, "isActive=" + feature); Chk("H3 잔디 컴포넌트", grass == EnvLook.GrassObjects && grass > 0, grass + "개"); Chk("H4 이중 적용 무해", EnvLook.ApplyNow("probe-h2") == false, "두 번째 ApplyNow = 무동작"); EnvLook.RestoreNow("probe-h"); var back = Snapshot(map); int mismatch = 0; foreach (var kv in before) { string now; if (back.TryGetValue(kv.Key, out now) && now != kv.Value) mismatch++; } bool feature2 = false; foreach (var f in rd.rendererFeatures) if (f is global::Environment.PixelOutlineSetupFeature) feature2 = f.isActive; int grass2 = map.GetComponentsInChildren(true).Length; Chk("H5 원복 == 원본", mismatch == 0, "불일치 " + mismatch + " / 렌더러 " + before.Count); Chk("H6 피처 원복", !feature2, "isActive=" + feature2); Chk("H7 잔디 파괴", grass2 == 0, grass2 + "개"); Chk("H8 에셋 dirty 0", !EditorUtility.IsDirty(rd), "renderer dirty=" + EditorUtility.IsDirty(rd)); } // ═════════════════════════════════════════════════════════════════════════ // Capture — 임시 씬(저장 0) · 직교 · 270×480 렌더 → 1080×1920 확대 · 6장 // ═════════════════════════════════════════════════════════════════════════ static readonly List s_cfgs = new List(); static Bounds s_grassBounds; public static void Capture() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c 룩 캡처(직교 픽셀 카메라 근사 " + kLowW + "×" + kLowH + " → " + kHiW + "×" + kHiH + ") · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); var c = 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("E0 맵", map != null, "WL_Nature"); if (map == null) return; // 🔴 직교 카메라(814b 대신 임시) · 811o 회전(h 76 · yaw 233) · size 3.8 // far 를 160 m 로 좁힌다 = 깊이 외곽선 임계(SO depthThreshold)가 실제 단차(m)에 대응하게 하려고. // 🔴 camBack 는 25 m — URP-Balanced 의 그림자 거리가 **50 m**(814a 실측)라 카메라가 더 멀면 // 피사체가 그림자 범위 밖으로 나가 그림자가 통째로 사라진다(직교라 거리는 구도에 영향 없음). const float h = 76f, yaw = 233f, dUp = 2.4f, ortho = 3.8f, 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; // 나무 군락이 PC 뒤 11 m(화면 위쪽 배경)에 오도록 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("[WL814c] OrthoCam"); 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") + " · 811o(h 76 · yaw 233) · near 0.1 / far " + farClip + " → 깊이 임계 " + (c != null ? c.depthThreshold : 0f) + " = 단차 " + ((c != null ? c.depthThreshold : 0f) * (farClip - 0.1f)).ToString("0.00") + " m"); 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"); 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("E2 잡몹", mobGos.Count == 3, mobGos.Count + "/3"); Pose(pc); foreach (var m in mobGos) Pose(m); EnvLook.ResetForProbe(); // ─ ⓐ 원본 (Lit · 같은 직교 · 외곽선 0 · 잔디 0) ─────────────────── Feature(false); Shot(cam, kOutDir + @"\look_1_original.png", "ⓐ 원본(URP/Lit)", false); // ─ ⓑ Toon + 1px 외곽선 (구름 off) ──────────────────────────────── EnvLook.BeginForProbe(); int swMap = EnvLook.SweepRoot(map.transform); Clouds(c, false); Feature(true); Shot(cam, kOutDir + @"\look_2_toon_outline.png", "ⓑ Toon 램프 + 1px 픽셀 외곽선(맵 " + swMap + " 렌더러 · 구름 off)", false); // ─ ⓒ + 잔디 ────────────────────────────────────────────────────── int made = EnvLook.AttachGrass(map.transform, c); BuildGrassDraw(map, c); Shot(cam, kOutDir + @"\look_3_grass.png", "ⓒ + 인스턴싱 잔디(지면 " + made + " · 인스턴스 " + EnvLook.GrassInstances + ")", true); // ─ ⓓ + 구름 그림자 ─────────────────────────────────────────────── Clouds(c, true); Shot(cam, kOutDir + @"\look_4_clouds.png", "ⓓ + 구름 그림자", true); // ─ ⓔ/ⓕ 캐릭터 전 / 후 (클로즈업) ───────────────────────────────── float keepSize = cam.orthographicSize; Vector3 keepPos = cam.transform.position; cam.orthographicSize = 1.5f; Vector3 focus = pcPos + Vector3.up * 0.9f; cam.transform.position = focus + Vector3.up * 0.8f - dir * 60f; cam.transform.LookAt(focus); Shot(cam, kOutDir + @"\look_5_char_before.png", "ⓔ 캐릭터 원본(MK/Toon) · 배경만 Critter Toon", true); int swPc = pc != null ? EnvLook.SweepRoot(pc.transform) : 0; int swMob = 0; foreach (var m in mobGos) swMob += EnvLook.SweepRoot(m.transform); Shot(cam, kOutDir + @"\look_6_char_after.png", "ⓕ 캐릭터 Critter Toon(PC " + swPc + " · 몹 " + swMob + " 렌더러)", true); cam.orthographicSize = keepSize; cam.transform.position = keepPos; Chk("E9 캡처 6장", Directory.GetFiles(kOutDir, "look_*.png").Length >= 6, Directory.GetFiles(kOutDir, "look_*.png").Length + "장"); } catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); } finally { FreeGrassDraw(); try { Clouds(WLEnvLookSettings.Instance, true); } catch { } try { Feature(false); } catch { } try { EnvLook.RestoreNow("capture-final"); } 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); } /// 피처 on/off + 렌더러 재생성 강제(SetDirty = isInvalidated · 에셋 파일에는 안 쓴다). static void Feature(bool on) { EnvLook.SetFeature(on); var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); if (rd != null) rd.SetDirty(); } /// 「나무·수풀이 촘촘하고 물이 가까운」 곳 = 배경 아트를 보여줄 자리(실측 좌표를 보고서에 남긴다). 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") { if (pts.Count % 1 == 0) 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; } // 25 m 이웃 수 + 물 근접 보너스 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; } /// 생성한 Toon 머티리얼의 구름 그림자만 토글(_Cloud_Strength · 원본 .mat 무관 · SaveAssets 0). static void Clouds(WLEnvLookSettings c, bool on) { if (c == null || c.toons == null) return; foreach (var m in c.toons) { if (m == null) continue; m.SetFloat("_Cloud_Strength", on ? c.cloudStrength : 0f); } } // ── 잔디를 에디트 모드에서 그리기 ──────────────────────────────────────── // MeshInstancesBehaviour 의 OnEnable/Update 는 [ExecuteAlways] 가 없어 에디트 모드에서 돌지 않는다. // 그래서 **같은 공개 API** 로 같은 데이터를 만들어 Camera.Render 직전에 직접 그린다. static void BuildGrassDraw(GameObject map, WLEnvLookSettings c) { FreeGrassDraw(); bool first = true; foreach (var mib in map.GetComponentsInChildren(true)) { var mf = mib.GetComponent(); if (mf == null || mf.sharedMesh == null || mib.InstancingSettings == null) continue; var data = global::Environment.Instancing.MeshInstancesBehaviour.RandomMeshInstanceData(mf.sharedMesh, mib.Density, mib.InstancingSettings); if (data == null) continue; var divided = global::Environment.Instancing.InstancesBehaviour.DivideInstanceData(data, mib.InstancingSettings); if (divided == null) continue; var mr = mib.GetComponent(); var lw = mib.transform.localToWorldMatrix; lw.m03 = 0; lw.m13 = 0; lw.m23 = 0; foreach (var kv in divided) { if (kv.Value.Count == 0) continue; var cfg = new global::Environment.Instancing.InstancingConfiguration(kv.Key, kv.Value, "_InstanceData"); cfg.MaterialPropertyBlock.SetMatrix("_LocalToWorld", lw); s_cfgs.Add(cfg); } var b = mr != null ? mr.bounds : new Bounds(mib.transform.position, Vector3.one * 100f); var diff = mib.transform.position - b.center; var abs = new Vector3(Mathf.Abs(diff.x), Mathf.Abs(diff.y), Mathf.Abs(diff.z)); var bb = new Bounds(mib.transform.position, (b.extents + abs) * 2f); if (first) { s_grassBounds = bb; first = false; } else s_grassBounds.Encapsulate(bb); } L(" 잔디 드로우 구성 " + s_cfgs.Count + "개(지면×{잔디,꽃}) · bounds " + s_grassBounds.size.ToString("0")); } static void DrawGrass() { for (int i = 0; i < s_cfgs.Count; i++) { var cfg = s_cfgs[i]; Graphics.DrawMeshInstancedIndirect(cfg.Mesh, 0, cfg.Material, s_grassBounds, cfg.CommandBuffer, 0, cfg.MaterialPropertyBlock, ShadowCastingMode.Off, false, 0); } } static void FreeGrassDraw() { for (int i = 0; i < s_cfgs.Count; i++) s_cfgs[i].FreeMemory(); s_cfgs.Clear(); } // ── 렌더 ───────────────────────────────────────────────────────────────── static void Shot(Camera cam, string file, string label, bool grass) { var low = new RenderTexture(kLowW, kLowH, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); low.filterMode = FilterMode.Point; low.antiAliasing = 1; low.Create(); var hi = new RenderTexture(kHiW, kHiH, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); hi.filterMode = FilterMode.Point; hi.Create(); var prev = cam.targetTexture; cam.targetTexture = low; if (grass) DrawGrass(); cam.Render(); cam.targetTexture = prev; Graphics.Blit(low, hi); // Point 확대 = 픽셀 카메라 근사 var active = RenderTexture.active; RenderTexture.active = hi; var tex = new Texture2D(kHiW, kHiH, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, kHiW, kHiH), 0, 0); tex.Apply(); RenderTexture.active = active; File.WriteAllBytes(file, tex.EncodeToPNG()); var px = tex.GetPixels32(); var seen = new HashSet(); long dark = 0; for (int i = 0; i < px.Length; i += 331) { seen.Add((px[i].r << 16) | (px[i].g << 8) | px[i].b); if (px[i].r < 24 && px[i].g < 24 && px[i].b < 24) dark++; } int distinct = seen.Count; int dc = UnityEditor.UnityStats.drawCalls, batches = UnityEditor.UnityStats.batches, tri = UnityEditor.UnityStats.triangles; UnityEngine.Object.DestroyImmediate(tex); low.Release(); UnityEngine.Object.DestroyImmediate(low); hi.Release(); UnityEngine.Object.DestroyImmediate(hi); RenderTexture.active = null; var fi = new FileInfo(file); Chk("E " + System.IO.Path.GetFileName(file), fi.Exists && fi.Length > 4096 && distinct > 4, label + " · " + fi.Length + " B · 색 " + distinct + "종 · 진한픽셀 " + dark + " · drawCalls " + dc + " · batches " + batches + " · tri " + tri); } // ═════════════════════════════════════════════════════════════════════════ // Diag — 외곽선 파라미터 스윕(임시 · 커밋 대상 아님 · 어떤 값에서 외곽선이 실제로 나오는가) // ═════════════════════════════════════════════════════════════════════════ public static void Diag() { s_sb = new StringBuilder(); s_pass = 0; s_fail = 0; L("WL-814c 외곽선 진단 · " + DateTime.Now.ToString("HH:mm:ss")); var c = WLEnvLookSettings.Instance; try { var 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); Vector3 dir = (Quaternion.Euler(76f, 233f, 0f) * Vector3.one).normalized; Vector3 fwd0 = new Vector3(dir.x, 0f, dir.z).normalized; string aname; Vector3 pcPos = ScenicSpot(map, out aname) - fwd0 * 11f; RaycastHit hit; if (Physics.Raycast(pcPos + Vector3.up * 300f, Vector3.down, out hit, 700f)) pcPos = hit.point; var camGo = new GameObject("[WL814c] Diag"); var cam = camGo.AddComponent(); camGo.AddComponent(); cam.orthographic = true; cam.orthographicSize = 3.8f; cam.transform.position = pcPos + Vector3.up * 2.4f - dir * 55f; cam.transform.LookAt(pcPos + new Vector3(0f, 1.06f, 0f) + fwd0 * 2f); var pc = Inst("Assets/Res_Addr/PC/Ai01.prefab", pcPos); Pose(pc); EnvLook.ResetForProbe(); EnvLook.BeginForProbe(); EnvLook.SweepRoot(map.transform); if (pc != null) EnvLook.SweepRoot(pc.transform); Clouds(c, false); var rd = AssetDatabase.LoadAssetAtPath(kRendererPath); EnvLook.SetFeature(true); if (rd != null) rd.SetDirty(); cam.nearClipPlane = 0.1f; cam.farClipPlane = 160f; // (Shades, Brightness, MinimumDarkness, AmbientStrength) float[][] sets = { new float[]{5, 0.25f, 0.20f, 0.10f}, // 현재(데모 Brightness/MinDark) new float[]{5, 0.10f, 0.35f, 0.10f}, new float[]{4, 0.05f, 0.45f, 0.05f}, new float[]{6, 0.00f, 0.55f, 0.05f}, new float[]{4, -0.10f,0.55f, 0.02f}, }; for (int i = 0; i < sets.Length; i++) { var s = sets[i]; foreach (var m in c.toons) { if (m == null) continue; m.SetFloat("_Shades", s[0]); m.SetFloat("_Brightness", s[1]); m.SetFloat("_MinimumDarkness", s[2]); if (m.HasProperty("_AmbientStrength")) m.SetFloat("_AmbientStrength", s[3]); } float dark = ShotStat(cam, kOutDir + @"\diag_" + i + ".png"); L(" [" + i + "] Shades=" + s[0] + " Brightness=" + s[1] + " MinDark=" + s[2] + " Ambient=" + s[3] + " → 어두운픽셀 " + (dark * 100f).ToString("0.000") + "%"); } EnvLook.SetFeature(false); foreach (var m in c.toons) { if (m == null) continue; m.SetFloat("_Shades", c.shades); m.SetFloat("_Brightness", c.brightness); m.SetFloat("_MinimumDarkness", c.minimumDarkness); if (m.HasProperty("_AmbientStrength")) m.SetFloat("_AmbientStrength", c.ambientStrength); } Clouds(c, true); EnvLook.RestoreNow("diag"); } catch (Exception ex) { L("EXCEPTION " + ex); } finally { try { EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); } catch { } } Write("AgentScripts/WL814c_DIAG.txt"); } static float ShotStat(Camera cam, string file) { var low = new RenderTexture(kLowW, kLowH, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); low.filterMode = FilterMode.Point; low.antiAliasing = 1; low.Create(); cam.targetTexture = low; cam.Render(); cam.targetTexture = null; 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; File.WriteAllBytes(file, tex.EncodeToPNG()); var px = tex.GetPixels32(); int dark = 0; for (int i = 0; i < px.Length; i++) if (px[i].r < 40 && px[i].g < 40 && px[i].b < 40) dark++; UnityEngine.Object.DestroyImmediate(tex); low.Release(); UnityEngine.Object.DestroyImmediate(low); RenderTexture.active = null; return dark / (float)px.Length; } // ── 헬퍼 ───────────────────────────────────────────────────────────────── 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); } } }