Project_WL/AgentScripts/WL814c_Probe.cs

772 lines
43 KiB
C#
Raw Normal View History

// 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<string>();
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<string>(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<ScriptableRendererData>(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<int, string> Snapshot(GameObject root)
{
var d = new Dictionary<int, string>();
foreach (var r in root.GetComponentsInChildren<Renderer>(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<global::Environment.Instancing.MeshInstancesBehaviour>(true))
{
comps++;
var mf = mib.GetComponent<MeshFilter>();
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<MeshRenderer>();
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<global::Environment.Instancing.MeshInstancesBehaviour>(true))
if (mib.GetComponent<Collider>() != null) colliders++;
Chk("D5 렌더 전용(콜라이더·NavMesh 무관)", colliders == 0, "잔디 오브젝트의 콜라이더 " + colliders + "개");
EnvLook.RestoreNow("probe-d");
int left = map.GetComponentsInChildren<global::Environment.Instancing.MeshInstancesBehaviour>(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<Renderer>(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<Renderer>(true))
{
if (r.sharedMaterial != null && r.sharedMaterial.name == "Water") waterAfter++;
if (r.GetComponent<global::Environment.PixelWater.WaterReflectionCamera>() != 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<Renderer>(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<global::Environment.Instancing.MeshInstancesBehaviour>(true).Length;
var rd = AssetDatabase.LoadAssetAtPath<ScriptableRendererData>(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<GameObject>(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<ScriptableRendererData>(kRendererPath);
bool feature = false;
foreach (var f in rd.rendererFeatures) if (f is global::Environment.PixelOutlineSetupFeature) feature = f.isActive;
int grass = map.GetComponentsInChildren<global::Environment.Instancing.MeshInstancesBehaviour>(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<global::Environment.Instancing.MeshInstancesBehaviour>(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<global::Environment.Instancing.InstancingConfiguration> s_cfgs =
new List<global::Environment.Instancing.InstancingConfiguration>();
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<Camera>() != 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<Camera>();
var acd = camGo.AddComponent<UniversalAdditionalCameraData>();
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<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");
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);
}
/// <summary>피처 on/off + 렌더러 재생성 강제(SetDirty = isInvalidated · 에셋 파일에는 안 쓴다).</summary>
static void Feature(bool on)
{
EnvLook.SetFeature(on);
var rd = AssetDatabase.LoadAssetAtPath<ScriptableRendererData>(kRendererPath);
if (rd != null) rd.SetDirty();
}
/// <summary>「나무·수풀이 촘촘하고 물이 가까운」 곳 = 배경 아트를 보여줄 자리(실측 좌표를 보고서에 남긴다).</summary>
static Vector3 ScenicSpot(GameObject map, out string why)
{
var pts = new List<Vector3>();
var waters = new List<Bounds>();
foreach (var r in map.GetComponentsInChildren<Renderer>(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;
}
/// <summary>생성한 Toon 머티리얼의 구름 그림자만 토글(_Cloud_Strength · 원본 .mat 무관 · SaveAssets 0).</summary>
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<global::Environment.Instancing.MeshInstancesBehaviour>(true))
{
var mf = mib.GetComponent<MeshFilter>();
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<MeshRenderer>();
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<int>();
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<Camera>() != 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<Camera>();
camGo.AddComponent<UniversalAdditionalCameraData>();
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<ScriptableRendererData>(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<GameObject>(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<Animator>(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);
}
}
}