703 lines
35 KiB
C#
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
|
|
}
|
|
}
|