Project_WL/Assets/WL/Look/Env/EnvLook.cs

703 lines
35 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// EnvLook.cs — Critter Environment 룩 런타임 스왑 (인게임 맵 한정 · 원본 훅 0 · 에셋 파일 수정 0)
//
// PD 지시 #814 · 발주서 WL-814c §1-3/4/5/6 (2026-09-10)
//
// ■ 무엇을 하나 (맵 진입 경계 = 814a PixelLook / 811q EffectWarmup 과 **같은 판정**)
// ① 스왑 : SO 표(원본 머티리얼 → Toon 머티리얼)에 **있는 것만** sharedMaterials 를 갈아끼운다.
// 표에 없는 파티클·이펙트·UI 머티리얼은 절대 건드리지 않는다.
// ② 새 스폰 : InGameInfo.tf_Objs 직속 자식 중 **처음 보는 인스턴스만** 훑어 같은 스왑을 적용(813m3 방식).
// ③ 외곽선 : URP 렌더러의 PixelOutlineSetupFeature 를 SetActive(true) — 깊이·노멀 텍스처를 켠다.
// Toon 셰이더가 그 텍스처로 오브젝트 안에서 1px 외곽선을 그린다.
// ④ 잔디 : 지면 메시에 Critter MeshInstancesBehaviour 를 런타임 부착(GPU 인스턴싱 · 렌더 전용).
// ⑤ 물 : 물 렌더러를 Critter Water 머티리얼로 스왑(반사 카메라는 붙이지 않는다 = 성능).
// 맵을 벗어나면 · enabled=0 · OnApplicationQuit · 에디터 플레이모드 종료에서 **전부 원복**.
// ■ 814d 허브 브리지 (WL-814e · SO `followLookModeHub` 기본 1) — 맵 진입 자동 적용을 허브
// `LookModeHub.EnvLookOn` 으로 게이트하고 `EnvLookChanged` 를 구독한다(구독 시 현재 값 1회 반영).
// `followLookModeHub 0` = 814c 원래 동작(허브 참조 0).
//
// ■ 🔴 왜 「되돌리기」가 중요한가
// 머티리얼·렌더러 피처는 디스크 위의 프로젝트 에셋이다. 값을 바꾼 채로 두면 PD 가 「Save Project」 를
// 누르는 순간 프로젝트가 오염된다. 그래서
// · 원본 .mat 파일에는 어떤 값도 쓰지 않는다(참조만 바꾼다)
// · EditorUtility.SetDirty 호출 0
// · 4중 복원: 맵 이탈 · 러너 OnDisable · OnApplicationQuit · playModeStateChanged
//
// ■ 성능 / GC
// Tick 은 「지금이 폴링 시각인가」 float 비교뿐이다(경계에서만 실제 작업). 새 스폰 스윕은 인스턴스 ID
// 캐시가 히트하면 할당 0(List·HashSet 재사용). 잔디는 Graphics.DrawMeshInstancedIndirect = 지면당 2 드로우콜.
//
// ■ C8 롤백
// WLEnvLookSettings.enabled = 0 → 러너 오브젝트 0 · 스왑 0 · 피처 0 · 잔디 0.
//
// 🔴 Assets/WL/Look/Env/ 에 .asmdef 를 만들지 말 것.
// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙).
// 🔴 Critter 는 전역 네임스페이스 `Environment` 를 쓴다(System.Environment 와 충돌) → global:: 로 명시한다.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using WL.Look.Toggle;
namespace WL.Look.Env
{
/// <summary>Critter 룩 스왑. 정적 · 새 Manager/Singleton 0(러너는 숨김 GameObject 1개).</summary>
public static class EnvLook
{
static WLEnvLookSettings Cfg { get { return WLEnvLookSettings.Instance; } }
public static bool Active { get { return WLEnvLookSettings.Enabled; } }
// ───────────────────────────────────────── 상태
struct Rec { public Renderer r; public Material[] orig; }
static readonly Dictionary<int, Material> s_table = new Dictionary<int, Material>(64);
static readonly List<Rec> s_recs = new List<Rec>(512);
static readonly HashSet<int> s_seenRenderers = new HashSet<int>();
static readonly HashSet<int> s_seenSpawns = new HashSet<int>();
static readonly List<Renderer> s_rbuf = new List<Renderer>(256);
static readonly List<GameObject> s_grass = new List<GameObject>(8);
static readonly List<MeshRenderer> s_groundBuf = new List<MeshRenderer>(64);
static bool s_applied;
static bool s_tableBuilt;
static ScriptableRendererFeature s_feature;
static bool s_featureOrig;
static bool s_featureChanged;
// ───────────────────────────────────────── 경계 감시
static PCActor s_lastPc;
static int s_lastMapId = int.MinValue;
static float s_nextPoll;
static float s_nextSpawnSweep;
static bool s_faulted;
// ───────────────────────────────────────── 진단(프로브가 읽는다)
public static int Boundaries, Applies, Restores, SkippedLobby;
public static int SwappedRenderers, SwappedSlots, RestoredRenderers;
public static int SpawnSweeps, SpawnSwapped;
public static int GrassObjects, GrassInstances, GrassDrawCalls;
public static int WaterSwapped;
public static bool IsApplied { get { return s_applied; } }
public static int TablePairs { get { return s_table.Count; } }
public static bool FeatureActiveNow { get { return s_feature != null && s_feature.isActive; } }
public static string FeatureName { get { return s_feature != null ? s_feature.name : ""; } }
public static string LastLog = "";
// ═════════════════════════════════════════════════════════════════════
// 표
// ═════════════════════════════════════════════════════════════════════
static void BuildTable(WLEnvLookSettings c)
{
if (s_tableBuilt) return;
s_tableBuilt = true;
s_table.Clear();
if (c.originals == null || c.toons == null) return;
int n = Mathf.Min(c.originals.Length, c.toons.Length);
for (int i = 0; i < n; i++)
{
var o = c.originals[i]; var t = c.toons[i];
if (o == null || t == null) continue;
s_table[o.GetInstanceID()] = t;
}
}
/// <summary>SO 를 바꿔치기했을 때 표를 다시 만들게 한다(프로브·에디터 스크립트).</summary>
public static void InvalidateTable() { s_tableBuilt = false; }
/// <summary>프로브·캡처 전용 — 맵 경계 판정 없이 표만 만들고 「적용 중」 상태로 들어간다.</summary>
public static void BeginForProbe()
{
var c = Cfg;
if (c == null) return;
BuildTable(c);
s_applied = true;
}
// ═════════════════════════════════════════════════════════════════════
// 적용 / 복원
// ═════════════════════════════════════════════════════════════════════
/// <summary>맵·캐릭터·잔디·물·외곽선 피처를 한 번에 적용한다. 이미 적용 중이면 아무 것도 하지 않는다.</summary>
public static bool ApplyNow(string reason)
{
var c = Cfg;
if (c == null || !WLEnvLookSettings.Enabled) return false;
if (s_applied) return false;
BuildTable(c);
s_applied = true;
Applies++;
Transform mapRoot = FindMapRoot();
// 🔴 물이 먼저다 — 맵 스윕이 「스왑할 것 없음」으로 표시한 렌더러는 다시 보지 않기 때문.
if (c.waterEnabled) SwapWater(mapRoot, c);
if (c.swapMap && mapRoot != null) SweepRoot(mapRoot);
if (c.swapCharacters) SweepCharacters();
if (c.outlineFeature) SetFeature(true);
if (c.grassEnabled) AttachGrass(mapRoot, c);
s_seenSpawns.Clear();
s_nextSpawnSweep = 0f;
if (c.verboseLog)
Debug.Log("[WL814c EnvLook] 적용(" + reason + ") 렌더러 " + SwappedRenderers + " · 슬롯 " + SwappedSlots +
" · 물 " + WaterSwapped + " · 잔디 " + GrassObjects + "(" + GrassInstances + "개) · 피처 " + FeatureActiveNow);
LastLog = "적용 " + reason;
return true;
}
/// <summary>원본 머티리얼로 되돌리고 잔디를 지우고 피처를 원래 상태로 돌린다.</summary>
public static bool RestoreNow(string reason)
{
if (!s_applied) return false;
s_applied = false;
for (int i = 0; i < s_recs.Count; i++)
{
var rec = s_recs[i];
if (rec.r == null) continue;
rec.r.sharedMaterials = rec.orig;
RestoredRenderers++;
}
s_recs.Clear();
s_seenRenderers.Clear();
s_seenSpawns.Clear();
SwappedRenderers = 0; SwappedSlots = 0; WaterSwapped = 0;
DetachGrass();
var cc = Cfg;
if (cc == null || cc.outlineFeatureOnlyInBattleMap) SetFeature(false); // 인게임 한정이면 끈다
Restores++;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[WL814c EnvLook] 복원(" + reason + ") 렌더러 " + RestoredRenderers);
LastLog = "복원 " + reason;
return true;
}
/// <summary>프로브·A/B 용: 처음 상태로 되돌린다.</summary>
public static void ResetForProbe()
{
UnhookHub(); HubEvents = HubGated = 0; // 🔴 814e — 구독을 남기지 않는다(누수 0)
RestoreNow("probe-reset");
// 🔴 RestoreNow 는 「적용 중이 아니면」 곧장 돌아가므로 캐시를 여기서 무조건 비운다.
// (안 비우면 이전에 훑어 「스왑할 것 없음」으로 표시된 렌더러가 영영 건너뛰어진다)
s_seenRenderers.Clear(); s_seenSpawns.Clear(); s_recs.Clear();
s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = 0f; s_nextSpawnSweep = 0f; s_faulted = false;
s_tableBuilt = false; s_table.Clear();
Boundaries = Applies = Restores = SkippedLobby = 0;
SwappedRenderers = SwappedSlots = RestoredRenderers = 0;
SpawnSweeps = SpawnSwapped = 0;
GrassObjects = GrassInstances = GrassDrawCalls = 0;
WaterSwapped = 0;
LastLog = "";
}
// ═════════════════════════════════════════════════════════════════════
// 스왑
// ═════════════════════════════════════════════════════════════════════
/// <summary>루트 아래 렌더러 전수 스왑(표에 있는 슬롯만). 프로브가 직접 부를 수 있다.</summary>
public static int SweepRoot(Transform root)
{
if (root == null) return 0;
s_rbuf.Clear();
root.GetComponentsInChildren<Renderer>(true, s_rbuf);
int n = 0;
for (int i = 0; i < s_rbuf.Count; i++) if (SwapRenderer(s_rbuf[i])) n++;
return n;
}
/// <summary>렌더러 하나. 표에 걸리는 슬롯이 하나도 없으면 아무 것도 하지 않는다(할당 0).</summary>
public static bool SwapRenderer(Renderer r)
{
if (r == null || r is ParticleSystemRenderer) return false;
int id = r.GetInstanceID();
if (s_seenRenderers.Contains(id)) return false;
s_seenRenderers.Add(id);
var cur = r.sharedMaterials;
bool any = false;
for (int i = 0; i < cur.Length; i++)
{
var m = cur[i];
if (m != null && s_table.ContainsKey(m.GetInstanceID())) { any = true; break; }
}
if (!any) return false;
var orig = new Material[cur.Length];
System.Array.Copy(cur, orig, cur.Length);
var next = new Material[cur.Length];
int slots = 0;
for (int i = 0; i < cur.Length; i++)
{
Material t;
if (cur[i] != null && s_table.TryGetValue(cur[i].GetInstanceID(), out t)) { next[i] = t; slots++; }
else next[i] = cur[i];
}
r.sharedMaterials = next;
s_recs.Add(new Rec { r = r, orig = orig });
SwappedRenderers++; SwappedSlots += slots;
return true;
}
static void SweepCharacters()
{
var pc = MyValue.MyPC;
if (!DSUtil.CheckNull(pc)) SweepRoot(pc.transform.root);
var objs = MobRoot();
if (objs != null) SweepRoot(objs);
}
static Transform MobRoot()
{
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) return null;
return info.tf_Objs;
}
/// <summary>맵 루트 = MapData 싱글턴의 게임오브젝트(LoadMapMgr 이 만든 go_map).</summary>
public static Transform FindMapRoot()
{
var md = Object.FindFirstObjectByType<MapData>(FindObjectsInactive.Include);
return md != null ? md.transform : null;
}
// ═════════════════════════════════════════════════════════════════════
// 새 스폰(813m3 풀 루트 스윕 방식) — 캐시 히트 시 할당 0
// ═════════════════════════════════════════════════════════════════════
/// <summary>몹 루트의 직속 자식 중 처음 보는 것만 스왑한다.</summary>
public static int SweepNewSpawns()
{
if (!s_applied) return 0;
var c = Cfg;
if (c == null || !c.swapCharacters) return 0;
var root = MobRoot();
if (root == null) return 0;
SpawnSweeps++;
int n = 0;
int cnt = root.childCount;
for (int i = 0; i < cnt; i++)
{
var ch = root.GetChild(i);
if (ch == null) continue;
int id = ch.GetInstanceID();
if (s_seenSpawns.Contains(id)) continue;
s_seenSpawns.Add(id);
n += SweepRoot(ch);
}
SpawnSwapped += n;
return n;
}
// ═════════════════════════════════════════════════════════════════════
// 물
// ═════════════════════════════════════════════════════════════════════
static void SwapWater(Transform mapRoot, WLEnvLookSettings c)
{
if (mapRoot == null || c.waterMaterial == null) return;
s_rbuf.Clear();
mapRoot.GetComponentsInChildren<Renderer>(true, s_rbuf);
for (int i = 0; i < s_rbuf.Count; i++)
{
var r = s_rbuf[i];
if (r == null || r is ParticleSystemRenderer) continue;
if (!IsWater(r)) continue;
int id = r.GetInstanceID();
if (s_seenRenderers.Contains(id)) continue;
s_seenRenderers.Add(id);
var cur = r.sharedMaterials;
var orig = new Material[cur.Length];
System.Array.Copy(cur, orig, cur.Length);
var next = new Material[cur.Length];
for (int k = 0; k < next.Length; k++) next[k] = c.waterMaterial;
r.sharedMaterials = next;
s_recs.Add(new Rec { r = r, orig = orig });
WaterSwapped++;
}
}
static bool IsWater(Renderer r)
{
if (r.name.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) >= 0) return true;
var m = r.sharedMaterial;
if (m != null && m.name.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) >= 0) return true;
if (m != null && m.shader != null && m.shader.name.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
// ═════════════════════════════════════════════════════════════════════
// 외곽선 렌더러 피처
// ═════════════════════════════════════════════════════════════════════
/// <summary>깊이·노멀 텍스처를 켜는 Critter 피처. 없으면 아무 것도 하지 않는다(외곽선만 안 나온다).</summary>
public static bool SetFeature(bool on)
{
var f = FindFeature();
if (f == null) return false;
if (on)
{
if (!s_featureChanged) { s_featureOrig = f.isActive; s_featureChanged = true; }
if (!f.isActive) f.SetActive(true); // 🔴 SetDirty 0
return true;
}
if (!s_featureChanged) return false;
f.SetActive(s_featureOrig);
s_featureChanged = false;
return true;
}
static ScriptableRendererFeature FindFeature()
{
if (s_feature != null) return s_feature;
var all = Resources.FindObjectsOfTypeAll<global::Environment.PixelOutlineSetupFeature>();
if (all != null && all.Length > 0) s_feature = all[0];
return s_feature;
}
// ═════════════════════════════════════════════════════════════════════
// 잔디 인스턴싱
// ═════════════════════════════════════════════════════════════════════
/// <summary>지면 메시(면적 큰 순)에 Critter MeshInstancesBehaviour 를 붙인다. 렌더 전용(NavMesh·충돌 무관).</summary>
public static int AttachGrass(Transform mapRoot, WLEnvLookSettings c)
{
if (mapRoot == null || c == null) return 0;
if (c.grassMesh == null || c.grassMaterial == null) return 0;
s_groundBuf.Clear();
mapRoot.GetComponentsInChildren<MeshRenderer>(true, s_groundBuf);
// 기준점 = 메인 PC(없으면 맵 원점). 잔디 예산을 「플레이어가 보는 지면」에 몰아준다.
Vector3 refPos = mapRoot.position;
var pcRef = MyValue.MyPC;
if (!DSUtil.CheckNull(pcRef)) refPos = pcRef.transform.position;
var cands = new List<MeshRenderer>();
for (int i = 0; i < s_groundBuf.Count; i++)
{
var mr = s_groundBuf[i];
if (mr == null || !mr.gameObject.activeInHierarchy) continue;
var mf = mr.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null) continue;
if (IsWater(mr)) continue; // 물 위에 잔디를 심지 않는다
var b = mr.bounds;
if (b.size.x * b.size.z < c.minGroundFootprint) continue;
if (b.size.y > c.maxGroundBoundsHeight) continue; // 배경 산·절벽 제외
if (b.size.y > c.groundFlatness * Mathf.Min(b.size.x, b.size.z)) continue; // 나무 수관·바위 제외(평평한 것만)
cands.Add(mr);
}
cands.Sort((a, b) => SqrTo(a.bounds, refPos).CompareTo(SqrTo(b.bounds, refPos)));
int made = 0;
int budget = c.maxInstances;
for (int i = 0; i < cands.Count && made < c.maxGroundMeshes && budget > 0; i++)
{
int used = AttachOne(cands[i], c, budget);
if (used > 0) { made++; budget -= used; }
}
GrassObjects = made;
GrassDrawCalls = made * 2; // 잔디 1 + 꽃 1 (DrawMeshInstancedIndirect)
return made;
}
static float SqrTo(Bounds b, Vector3 p) { return (b.ClosestPoint(p) - p).sqrMagnitude; }
/// <summary>목표 높이(m) ÷ 메시 높이. 메시가 없거나 목표가 0 이면 SO 의 원시 Scale.</summary>
public static float ScaleFor(Mesh m, WLEnvLookSettings c)
{
if (m == null || c == null || c.grassHeightMeters <= 0f) return c != null ? c.grassScale : 1f;
float h = m.bounds.size.y;
if (h <= 0.0001f) return c.grassScale;
return c.grassHeightMeters / h;
}
static int AttachOne(MeshRenderer ground, WLEnvLookSettings c, int budget)
{
var mf = ground.GetComponent<MeshFilter>();
var mesh = mf.sharedMesh;
Mesh surface = mesh != null && mesh.isReadable ? mesh : BuildProbeMesh(ground, c);
if (surface == null) return 0;
float area;
try { area = global::Environment.Utilities.MeshUtilities.GetMeshArea(surface); }
catch { return 0; }
if (area <= 0f) return 0;
// 🔴 성긴 면(나무 수관·바위)은 지면이 아니다 — 메시 면적이 수평 투영 면적에 못 미치면 건너뛴다.
var gb = ground.bounds;
float footprint = gb.size.x * gb.size.z;
if (footprint > 0f && area < c.groundAreaRatio * footprint) return 0;
float ratio = Mathf.Max(0.01f, c.grassDensity);
float density = Mathf.Max(0.01f, c.demoDensity / ratio); // Critter Density = 면적/인스턴스
int want = Mathf.CeilToInt(area / density);
if (want > budget) { density = area / Mathf.Max(1, budget); want = budget; }
if (want <= 0) return 0;
var go = new GameObject("[WL814c] Grass_" + ground.name);
go.SetActive(false); // 🔴 필드를 다 채운 뒤에 OnEnable 이 돌게 한다
go.transform.SetParent(ground.transform, false);
go.layer = 0; // Critter 인스턴싱은 Default 레이어로 그린다
var gmf = go.AddComponent<MeshFilter>();
gmf.sharedMesh = surface;
var gmr = go.AddComponent<MeshRenderer>();
gmr.sharedMaterial = ground.sharedMaterial; // 빈 슬롯 마젠타 방지(813m3)
gmr.enabled = false; // 지면을 두 번 그리지 않는다
gmr.shadowCastingMode = ShadowCastingMode.Off;
var mib = go.AddComponent<global::Environment.Instancing.MeshInstancesBehaviour>();
mib.UseSubMesh = false;
mib.Density = density;
var settings = new List<global::Environment.Instancing.InstancingSettings>(2);
settings.Add(new global::Environment.Instancing.InstancingSettings
{
Mesh = c.grassMesh,
Material = c.grassMaterial,
Probability = c.grassProbability,
Scale = ScaleFor(c.grassMesh, c),
NormalOffset = c.grassNormalOffset
});
if (c.flowerMesh != null && c.flowerMaterial != null)
settings.Add(new global::Environment.Instancing.InstancingSettings
{
Mesh = c.flowerMesh,
Material = c.flowerMaterial,
Probability = c.flowerProbability,
Scale = ScaleFor(c.flowerMesh, c),
NormalOffset = c.grassNormalOffset
});
mib.InstancingSettings = settings.ToArray();
go.SetActive(true); // 여기서 OnEnable → 인스턴스 생성
s_grass.Add(go);
GrassInstances += want;
return want;
}
/// <summary>지면 메시가 읽기 불가일 때: 바운드 위에서 아래로 레이캐스트해 읽기 가능한 격자 메시를 만든다.</summary>
static Mesh BuildProbeMesh(MeshRenderer ground, WLEnvLookSettings c)
{
int n = Mathf.Clamp(c.groundProbeGrid, 4, 128);
var b = ground.bounds;
float top = b.max.y + 20f, len = b.size.y + 60f;
var tr = ground.transform;
var verts = new Vector3[(n + 1) * (n + 1)];
var ok = new bool[(n + 1) * (n + 1)];
int groundId = ground.gameObject.GetInstanceID();
var hits = new RaycastHit[8];
for (int z = 0; z <= n; z++)
for (int x = 0; x <= n; x++)
{
float wx = Mathf.Lerp(b.min.x, b.max.x, x / (float)n);
float wz = Mathf.Lerp(b.min.z, b.max.z, z / (float)n);
int idx = z * (n + 1) + x;
int cnt = Physics.RaycastNonAlloc(new Vector3(wx, top, wz), Vector3.down, hits, len);
float best = float.MaxValue; bool found = false;
for (int h = 0; h < cnt; h++)
{
var col = hits[h].collider;
if (col == null) continue;
if (col.gameObject.GetInstanceID() != groundId && col.transform.root != tr.root) continue;
if (hits[h].distance < best) { best = hits[h].distance; found = true; }
}
if (!found && cnt > 0) { best = hits[0].distance; found = true; }
if (found) { verts[idx] = tr.InverseTransformPoint(new Vector3(wx, top - best, wz)); ok[idx] = true; }
else { verts[idx] = tr.InverseTransformPoint(new Vector3(wx, b.min.y, wz)); ok[idx] = false; }
}
var tris = new List<int>(n * n * 6);
for (int z = 0; z < n; z++)
for (int x = 0; x < n; x++)
{
int a = z * (n + 1) + x, bb = a + 1, cc = a + (n + 1), d = cc + 1;
if (!ok[a] || !ok[bb] || !ok[cc] || !ok[d]) continue;
tris.Add(a); tris.Add(cc); tris.Add(bb);
tris.Add(bb); tris.Add(cc); tris.Add(d);
}
if (tris.Count == 0) return null;
var m = new Mesh();
m.name = "[WL814c] GrassSurface_" + ground.name;
m.indexFormat = verts.Length > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16;
m.vertices = verts;
m.SetTriangles(tris, 0);
m.RecalculateNormals();
m.RecalculateBounds();
return m;
}
static void DetachGrass()
{
for (int i = 0; i < s_grass.Count; i++)
{
var go = s_grass[i];
if (go == null) continue;
var gmf = go.GetComponent<MeshFilter>();
if (gmf != null && gmf.sharedMesh != null && gmf.sharedMesh.name.StartsWith("[WL814c]"))
{
if (Application.isPlaying) Object.Destroy(gmf.sharedMesh); else Object.DestroyImmediate(gmf.sharedMesh);
}
if (Application.isPlaying) Object.Destroy(go); else Object.DestroyImmediate(go);
}
s_grass.Clear();
GrassObjects = 0; GrassInstances = 0; GrassDrawCalls = 0;
}
// ═════════════════════════════════════════════════════════════════════
// 틱 — 프레임당 할당 0
// ═════════════════════════════════════════════════════════════════════
public static void TickForProbe() { Tick(); }
public static void EnsureRunnerForProbe() { EnsureRunner(); }
internal static void Tick()
{
if (s_faulted || !WLEnvLookSettings.Enabled) return;
var c = Cfg;
float now = Time.unscaledTime;
if (now >= s_nextPoll)
{
s_nextPoll = now + Mathf.Max(0.05f, c.pollSeconds);
try { PollBoundary(c); }
catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외) — " + ex.Message; RestoreNow("fault"); return; }
}
if (s_applied && c.respawnSweepSeconds > 0f && now >= s_nextSpawnSweep)
{
s_nextSpawnSweep = now + Mathf.Max(0.1f, c.respawnSweepSeconds);
try { SweepNewSpawns(); }
catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외·스윕) — " + ex.Message; RestoreNow("fault"); }
}
}
/// <summary>맵 진입/이탈 경계 — AutoCombatOnEnter / 814a PixelLook 과 같은 규칙.</summary>
static void PollBoundary(WLEnvLookSettings c)
{
if (!Application.isPlaying) return;
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc))
{
s_lastPc = null; s_lastMapId = int.MinValue;
if (s_applied && c.restoreOnExit) RestoreNow("map-exit(no-pc)");
return;
}
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) return;
int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue;
bool pcChanged = !ReferenceEquals(pc, s_lastPc);
bool mapChanged = map != s_lastMapId;
if (!pcChanged && !mapChanged) return;
s_lastPc = pc; s_lastMapId = map;
Boundaries++;
bool lobby = info.Get_GameMode() == eGameMode.Lobby;
if (c.applyOnlyInBattleMap && lobby)
{
SkippedLobby++;
if (s_applied && c.restoreOnExit) RestoreNow("map-exit(lobby)");
return;
}
MapEnter();
}
// ── 814d 허브 브리지 (WL-814e) — SO followLookModeHub 가 1 일 때만 산다 ─────────
static bool s_hooked;
public static int HubEvents, HubGated;
public static bool HubHooked { get { return s_hooked; } }
/// <summary>허브 추종 스위치. 에셋이 없으면 false = 814c 원래 동작(허브 참조 0).</summary>
public static bool FollowHub { get { var c = Cfg; return c != null && c.followLookModeHub; } }
/// <summary>맵 진입 적용 — 추종 중이면 허브가 ON 일 때만(프로브가 부르는 경계와 같은 코드).</summary>
public static bool MapEnter() { if (FollowHub && !LookModeHub.EnvLookOn) { HubGated++; return false; } return ApplyNow("map-enter"); }
/// <summary>구독 + 현재 값 1회 반영(이벤트는 「변할 때만」 오므로). 중복 구독 0.</summary>
public static bool HookHub()
{
if (s_hooked || !FollowHub) return false;
s_hooked = true;
LookModeHub.EnvLookChanged -= OnHubEnvLook;
LookModeHub.EnvLookChanged += OnHubEnvLook;
OnHubEnvLook(LookModeHub.EnvLookOn);
return true;
}
/// <summary>구독 해제(누수 0). 이미 해제되어 있으면 무동작.</summary>
public static bool UnhookHub()
{
if (!s_hooked) return false;
s_hooked = false;
LookModeHub.EnvLookChanged -= OnHubEnvLook;
return true;
}
static void OnHubEnvLook(bool on)
{ HubEvents++; if (on) ApplyNow("814d 토글"); else RestoreNow("814d 토글"); }
// ═════════════════════════════════════════════════════════════════════
// 러너 (숨김 GameObject 1개 · SO off 면 아예 만들지 않는다 = C8)
// ═════════════════════════════════════════════════════════════════════
static EnvLookRunner s_runner;
internal static void EnsureRunner()
{
if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return;
var go = new GameObject("[WL814c] EnvLookRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent<EnvLookRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (!WLEnvLookSettings.Enabled) return;
EnsureRunner();
var c = Cfg;
if (c != null && c.outlineFeature && !c.outlineFeatureOnlyInBattleMap) SetFeature(true);
}
}
/// <summary>Critter 룩 스왑의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class EnvLookRunner : MonoBehaviour
{
void Update() { EnvLook.Tick(); }
void OnEnable() { EnvLook.HookHub(); }
void OnDisable() { EnvLook.UnhookHub(); EnvLook.RestoreNow("runner-disable"); }
void OnApplicationQuit() { EnvLook.RestoreNow("app-quit"); }
}
/// <summary>🔴 에디터 전용 안전망 — 플레이모드를 벗어날 때 반드시 원복한다.</summary>
internal static class EnvEditorSafetyNet
{
#if UNITY_EDITOR
[UnityEditor.InitializeOnLoadMethod]
static void Hook()
{
UnityEditor.EditorApplication.playModeStateChanged -= OnPlayMode;
UnityEditor.EditorApplication.playModeStateChanged += OnPlayMode;
}
static void OnPlayMode(UnityEditor.PlayModeStateChange s)
{
if (s == UnityEditor.PlayModeStateChange.ExitingPlayMode) EnvLook.RestoreNow("exit-playmode");
}
#endif
}
}