Project_WL/AgentScripts/WL814c_Probe.cs

734 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// WL-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);
}
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;
}
// ═════════════════════════════════════════════════════════════════════════
// 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);
}
}
}