Project_WL/Assets/WL/PixelCamera/PixelCameraRig.cs

759 lines
40 KiB
C#
Raw Permalink 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.

// ─────────────────────────────────────────────────────────────────────────────
// PixelCameraRig.cs — Critter 3D Pixel Camera 를 인게임에서 런타임 조립/복원하는 WL 리그
//
// PD 지시 #814(「Critter 3D Pixel Art Toolkit 으로 도트 게임처럼」) · 발주서 WL-814b §1-3
//
// ■ 왜 이렇게 만들었나 (실측 근거 — 보고서 표 ①)
// · 원본 인게임 카메라는 `Assets/ResWork/Prefabs/Ingame/Main Camera.prefab`(tag MainCamera · fov 60 · 원근)이고
// `Assets/Script/Util/RealCamera.cs` 가 **LateUpdate 에서** 트랜스폼을 쓴다. CamStatus 1/2 는
// `Vector3.Lerp(transform.position, …)` = **현재 위치에서 보간**이라 누가 카메라 트랜스폼에 되쓰면
// 그 값이 다음 프레임의 입력이 된다(피드백 루프). → 리그는 카메라 트랜스폼에 **절대 쓰지 않는다**.
// · `RealCamera.Shaking()` 은 `transform.localPosition` 을 쓴다(811gh 셰이크). 원본 카메라를 **재부모화하면
// 셰이크 기준이 깨진다** → 재부모화 0. 대신 **원본 카메라의 자식**으로 빈 프록시를 하나 달아
// Critter 가 그것을 따라가게 한다(지연 0 · 프레임당 비용 0 · 셰이크도 그대로 미러된다).
// · `MyValue.m_RealCamera` 는 `Camera.main.GetComponent<RealCamera>()` 다 → 원본 카메라가 **계속
// MainCamera 태그를 달고 enabled 여야** 한다(`Camera.main` 은 활성 카메라만 돌려준다).
// · 인게임 UI 캔버스는 **Screen Space - Camera(UICamera)** 이고 `NewGameUI.cs:38` 이 그 UICamera 를
// **`Camera.main` 의 URP 카메라 스택에 Overlay 로** 넣는다 → 원본 카메라를 끄면 UI 가 사라진다.
// 그래서 원본 카메라는 화면 Base 로 남기고, Critter 뷰 카메라를 같은 스택의 **0번 Overlay** 로 끼운다.
// 스택 = [원본 Main Camera(Base · 월드 미렌더)] → [ViewCamera(Overlay · 픽셀 쿼드)] → [UICamera(Overlay · UI)]
// · `HUDBase.cs:28,55`(HP 바 · 데미지 숫자)와 `DSUtil.cs:859`·`InGameTouchInfo.cs:37` 은 전부
// `Camera.main` 의 투영을 쓴다 → 원본 카메라를 **직교로 바꿔** 픽셀 카메라와 같은 투영을 갖게 한다.
//
// ■ 리그 구조 (Assemble 이 만든다 · 디스크 에셋 수정 0)
// WL_PixelCameraRig = PixelCameraSystem.prefab 인스턴스(루트 이름만 바꿈)
// ├─ PixelCameraTransform 프리팹 기본 followed(ChildOfCamera 모드에서는 쓰지 않는다 · 삭제하면
// │ Critter 의 `parent.childCount` 권고에서 벗어나므로 그대로 둔다)
// └─ PixelCamera 직교 카메라 + PixelCameraManager · 저해상도 RT 로 월드를 그린다
// └─ UpscaledDisplay
// ├─ QuadCanvas RT 를 붙인 쿼드(UpscaledCanvas) · rigLayer
// └─ ViewCamera 직교 · CanvasViewCamera(서브픽셀 보정) · rigLayer 만 그린다
// Main Camera (원본 · 트랜스폼 무변경)
// └─ WL_PixelFollow 프록시(빈 오브젝트 · localPos 0 · localRot identity)
//
// ■ C8 롤백
// `WLPixelCameraSettings.enabled = 0`(또는 `RuntimeDisabled`) → 다음 틱에 Teardown → 원본 카메라 값
// (직교/사이즈/컬링/클리어/배경/near·far/fov/depth/포스트/스택)이 **스냅샷 그대로** 복원되고 리그 오브젝트 0 · RT 해제.
//
// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z · ErrorLogHookManager 팝업).
// 🔴 Assets/WL/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.Rendering.Universal;
using PixelCamera;
namespace WL.PixelArt
{
/// <summary>Critter 픽셀 카메라 리그의 조립·복원. 정적 · 전용 러너 1개.</summary>
public static class PixelCameraRig
{
public const string RigName = "WL_PixelCameraRig";
public const string ProxyName = "WL_PixelFollow";
// ───────────────────────────────────────── 진단(프로브가 읽는다)
public static int Boundaries, Assembled, Restored, SkippedLobby, SkippedNoCam, SkippedNoPrefab, Faults;
public static string LastReason = "", LastLog = "";
/// <summary>지금 리그가 조립돼 있는가.</summary>
public static bool IsAssembled { get { return s_rigRoot != null; } }
/// <summary>조립된 리그의 핵심 참조(프로브 전용).</summary>
public static GameObject RigRoot { get { return s_rigRoot; } }
public static Camera SourceCamera { get { return s_src; } }
public static Camera GameCamera { get { return s_gameCam; } }
public static Camera ViewCamera { get { return s_viewCam; } }
public static Transform Proxy { get { return s_proxy; } }
public static PixelCameraManager Manager { get { return s_mgr; } }
/// <summary>지금 회전 때문에 스냅이 꺼져 있는가(진단).</summary>
public static bool SnapSuspended { get { return s_snapSuspended; } }
// ───────────────────────────────────────── 내부 상태
static Camera s_src;
static GameObject s_rigRoot, s_proxyGo;
static Transform s_proxy;
static Camera s_gameCam, s_viewCam;
static PixelCameraManager s_mgr;
static UpscaledCanvas s_canvas;
static Material s_matInstance;
static MeshRenderer s_quadRenderer;
static Material s_quadSharedMat;
static CamSnapshot s_snap;
static bool s_faulted;
static PCActor s_lastPc;
static int s_lastMapId = int.MinValue;
static float s_nextPoll;
static bool s_snapSuspended;
static float s_snapResumeAt;
static Quaternion s_lastSrcRot = Quaternion.identity;
static bool s_hasLastRot;
static WLPixelCameraSettings Cfg { get { return WLPixelCameraSettings.Instance; } }
static bool Active { get { return WLPixelCameraSettings.Enabled; } }
// ── WL-814d 런타임 오버라이드 — 룩 토글이 SO `enabled 0`(F-23) 이어도 리그를 강제로 켠다(값도 SO 보다 우선).
// 아무도 부르지 않으면 s_rtOn = false 라 기존 동작과 완전히 같다.
static bool s_rtOn; static float s_rtOrtho = 3.8f; static int s_rtPx = 480;
/// <summary>룩 토글이 지금 리그를 강제로 켜고 있는가(진단).</summary>
public static bool RuntimeOverrideOn { get { return s_rtOn; } }
static float OrthoOf(WLPixelCameraSettings c) { return s_rtOn ? s_rtOrtho : c.orthoSize; }
static int PxOf(WLPixelCameraSettings c) { return s_rtOn ? s_rtPx : c.pixelHeight; }
/// <summary>WL-814d — 룩 토글이 부른다. on 이면 이 값으로 즉시 조립, off 면 해체. 값이 그대로면 무동작.</summary>
public static string SetRuntimeOverride(bool on, float orthoSize, int pixelHeight)
{
int px = Mathf.Max(3, pixelHeight);
if (on == s_rtOn && (!on || (orthoSize == s_rtOrtho && px == s_rtPx))) return "변화 없음(on=" + on + ")";
var cam = s_src != null ? s_src : Camera.main;
if (IsAssembled) Teardown("814d 전환");
s_rtOn = on; s_faulted = false; s_nextPoll = 0f;
if (on) { s_rtOrtho = orthoSize; s_rtPx = px; }
var cfg = Cfg;
if ((!on && !Active) || cfg == null) return "오버라이드 해제(on=" + on + ")";
if (Application.isPlaying) Boot(); // SO enabled 0 이면 러너가 없다 — 여기서 띄운다
if (cam == null) return "Camera.main 없음 — 다음 경계에서 조립";
return Assemble(cfg, cam) ? "조립 ortho=" + OrthoOf(cfg).ToString("0.###") + " px=" + PxOf(cfg) : "조립 실패";
}
// ─────────────────────────────────────────────────────────────────────
// 틱 (러너가 매 프레임 부른다 · 에디트 모드에서는 프로브가 직접 부를 수 있다)
// ─────────────────────────────────────────────────────────────────────
/// <summary>1프레임. (프로브가 별도 어셈블리에서 부르므로 public)</summary>
public static void Tick()
{
if (s_faulted) return;
// C8 — 기능이 꺼지면 즉시 원상복구.
if (!Active && !s_rtOn) { if (IsAssembled) Teardown("off"); return; } // WL-814d 오버라이드는 SO 를 넘는다
try
{
var cfg = Cfg;
UpdateSnapSuspension(cfg);
float now = Time.unscaledTime;
if (now < s_nextPoll) return;
s_nextPoll = now + Mathf.Max(0.02f, cfg.pollSeconds);
if (cfg.assembleOnMapEnter) PollBoundary(cfg);
}
catch (System.Exception ex)
{
s_faulted = true; Faults++;
LastLog = "중단(예외) — " + ex.Message;
Log(LastLog); // 🔴 LogError 금지(813z)
}
}
/// <summary>맵 진입/이탈 경계 — 811q EffectWarmup · 811o CameraFramingOverride 와 같은 규칙.</summary>
static void PollBoundary(WLPixelCameraSettings cfg)
{
if (!Application.isPlaying) return; // 에디트 모드 오염 0(프로브는 ProbeAssemble 로 들어온다)
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc))
{
// 맵 이탈 — PC 가 사라졌다. 리그를 걷는다.
if (s_lastPc != null && IsAssembled) Teardown("맵 이탈");
s_lastPc = null;
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;
bool lobby = info.Get_GameMode() == eGameMode.Lobby;
if (!cfg.assembleInLobby && lobby)
{
s_lastPc = pc; s_lastMapId = map;
Boundaries++; SkippedLobby++;
LastReason = "lobby";
if (IsAssembled) Teardown("로비 진입");
return;
}
var cam = Camera.main;
if (cam == null) { SkippedNoCam++; return; } // 경계를 소비하지 않는다 — 다음 폴링에서 다시 본다
s_lastPc = pc; s_lastMapId = map;
Boundaries++;
LastReason = pcChanged ? "pc" : ("map " + map);
if (IsAssembled) Teardown("경계 재조립");
Assemble(cfg, cam);
}
// ─────────────────────────────────────────────────────────────────────
// 조립
// ─────────────────────────────────────────────────────────────────────
/// <summary>리그를 조립한다. 성공 시 true.</summary>
public static bool Assemble(WLPixelCameraSettings cfg, Camera src)
{
if (cfg == null || src == null) return false;
if (IsAssembled) return true;
var prefab = cfg.pixelCameraSystemPrefab;
if (prefab == null)
{
SkippedNoPrefab++;
LastLog = "PixelCameraSystem 프리팹 미설정 — 조립 안 함(URP.unitypackage 해제 확인)";
Log(LastLog);
return false;
}
// 이전 잔재(에디트 모드 재실행 등) 제거 — Critter 의 FindAnyObjectByType 가 엉뚱한 걸 잡지 않게.
DestroyStrays();
s_src = src;
s_snap.Capture(src);
// ── 리그 인스턴스. 프리팹이 followed/뷰/캔버스를 이미 배선해 두었으므로 활성 상태로 만들어
// Critter Initialize() 의 LogError 경로(부모 없음 · followed null · 뷰/캔버스 못 찾음)를 전부 피한다.
// 값 보정은 같은 프레임 안에서 끝나므로(LateUpdate 이전) 잘못된 프레임이 그려지지 않는다.
s_rigRoot = Object.Instantiate(prefab);
s_rigRoot.name = RigName;
s_rigRoot.transform.SetPositionAndRotation(src.transform.position, src.transform.rotation);
s_mgr = s_rigRoot.GetComponentInChildren<PixelCameraManager>(true);
var view = s_rigRoot.GetComponentInChildren<CanvasViewCamera>(true);
s_canvas = s_rigRoot.GetComponentInChildren<UpscaledCanvas>(true);
if (s_mgr == null || view == null || s_canvas == null)
{
LastLog = "리그 프리팹 구조가 예상과 다르다 — 조립 취소";
Log(LastLog);
Teardown("구조 불일치");
Faults++;
return false;
}
s_gameCam = s_mgr.GetComponent<Camera>();
s_viewCam = view.GetComponent<Camera>();
if (s_gameCam == null || s_viewCam == null)
{
LastLog = "리그 카메라 컴포넌트 없음 — 조립 취소";
Log(LastLog);
Teardown("카메라 없음");
Faults++;
return false;
}
// 🔴 Critter 프리팹의 ViewCamera 에는 AudioListener 가 달려 있다(실측). 원본 Main Camera 의 것과
// 겹치면 Unity 가 "There are 2 audio listeners in the scene" 경고를 매 프레임 낸다 → 리그 것은 끈다.
var listeners = s_rigRoot.GetComponentsInChildren<AudioListener>(true);
for (int i = 0; i < listeners.Length; i++) if (listeners[i] != null) DestroyObj(listeners[i]);
int layer = Mathf.Clamp(cfg.rigLayer, 0, 31);
SetLayerRecursive(s_rigRoot.transform, layer);
s_rigRoot.tag = "Untagged";
s_gameCam.gameObject.tag = "Untagged";
s_viewCam.gameObject.tag = "Untagged";
// ── 쿼드 머티리얼을 런타임 인스턴스로 갈아 끼운다.
// Critter 의 UpscaledCanvas 는 sharedMaterial 에 _LowResTexture 를 직접 SetTexture 한다 →
// 그대로 두면 **디스크의 .mat 에셋이 더러워진다**(에디트 모드에서 특히). 인스턴스로 바꾸고
// OnEnable 을 다시 돌려 캐시를 갱신한다.
s_quadRenderer = s_canvas.GetComponent<MeshRenderer>();
if (s_quadRenderer != null && s_quadRenderer.sharedMaterial != null)
{
s_quadSharedMat = s_quadRenderer.sharedMaterial;
s_matInstance = new Material(s_quadSharedMat);
s_matInstance.name = s_quadSharedMat.name + " (WL rig runtime)";
s_quadRenderer.sharedMaterial = s_matInstance;
s_canvas.enabled = false; s_canvas.enabled = true; // OnEnable 재실행 → 인스턴스 캐시
}
// ── 프록시
if (cfg.proxyMode == PixelProxyMode.ChildOfCamera)
{
s_proxyGo = new GameObject(ProxyName);
s_proxyGo.layer = layer;
s_proxyGo.transform.SetParent(src.transform, false);
s_proxyGo.transform.localPosition = Vector3.zero;
s_proxyGo.transform.localRotation = Quaternion.identity;
s_proxyGo.transform.localScale = Vector3.one;
s_proxy = s_proxyGo.transform;
}
else
{
s_proxyGo = new GameObject(ProxyName);
s_proxyGo.layer = layer;
s_proxyGo.transform.SetPositionAndRotation(src.transform.position, src.transform.rotation);
var mirror = s_proxyGo.AddComponent<PixelCameraProxyMirror>();
mirror.Source = src.transform;
s_proxy = s_proxyGo.transform;
}
// ── 저해상도 게임 카메라 = 원본이 보던 월드를 그대로 본다(리그 레이어만 뺀다)
s_gameCam.orthographic = true;
s_gameCam.nearClipPlane = s_snap.near;
s_gameCam.farClipPlane = s_snap.far;
s_gameCam.cullingMask = s_snap.cullingMask & ~(1 << layer);
s_gameCam.clearFlags = s_snap.clearFlags;
s_gameCam.backgroundColor = s_snap.background;
s_gameCam.allowHDR = s_snap.allowHDR;
s_gameCam.allowMSAA = s_snap.allowMSAA;
s_gameCam.useOcclusionCulling = s_snap.occlusionCulling;
s_gameCam.depth = s_snap.depth - 1f; // 원본(화면 Base)보다 먼저 RT 를 채운다
s_gameCam.targetTexture = null; // Critter 가 만든다
var srcData = s_src.GetUniversalAdditionalCameraData();
var gameData = s_gameCam.GetUniversalAdditionalCameraData();
if (gameData != null)
{
gameData.renderType = CameraRenderType.Base;
if (gameData.cameraStack != null) gameData.cameraStack.Clear();
if (srcData != null)
{
gameData.renderShadows = srcData.renderShadows;
gameData.volumeLayerMask = srcData.volumeLayerMask;
gameData.volumeTrigger = srcData.volumeTrigger;
gameData.renderPostProcessing = cfg.copyPostProcessingToGameCamera && srcData.renderPostProcessing;
gameData.antialiasing = srcData.antialiasing;
gameData.antialiasingQuality = srcData.antialiasingQuality;
gameData.stopNaN = srcData.stopNaN;
gameData.dithering = srcData.dithering;
}
}
// ── Critter 값
float aspect = ScreenAspect();
int px = Mathf.Max(3, PxOf(cfg));
s_mgr.FollowedTransform = s_proxy;
s_mgr.VoxelGridMovement = cfg.voxelGridMovement;
s_mgr.SubpixelAdjustments = cfg.subpixelAdjustments;
s_mgr.FollowRotation = cfg.followRotation;
s_mgr.resolutionSynchronizationMode = ResolutionSynchronizationMode.SetHeight;
s_mgr.GameResolution = new Vector2Int(Mathf.Max(2, Mathf.RoundToInt(px * aspect)), px);
s_mgr.ControlGameZoom = cfg.controlGameZoom;
s_mgr.GameCameraZoom = GameZoomFor(cfg);
s_mgr.ViewCameraZoom = cfg.viewCameraZoom;
// ── 뷰 카메라 = 픽셀 쿼드만 그린다
s_viewCam.orthographic = true;
s_viewCam.cullingMask = 1 << layer;
s_viewCam.allowHDR = s_snap.allowHDR;
s_viewCam.allowMSAA = false;
s_viewCam.useOcclusionCulling = false;
var viewData = s_viewCam.GetUniversalAdditionalCameraData();
if (viewData != null)
{
viewData.renderPostProcessing = false;
viewData.renderShadows = false;
viewData.renderType = cfg.viewCameraInMainStack ? CameraRenderType.Overlay : CameraRenderType.Base;
if (viewData.cameraStack != null && viewData.renderType == CameraRenderType.Base) viewData.cameraStack.Clear();
}
if (!cfg.viewCameraInMainStack) s_viewCam.depth = s_snap.depth + 1f;
// ── 원본 카메라 = 화면 Base 로 남되 월드는 그리지 않는다(투영만 픽셀 카메라와 맞춘다)
s_src.orthographic = true;
s_src.orthographicSize = OrthoOf(cfg);
s_src.cullingMask = cfg.mainCameraCullingMask;
if (cfg.mainCameraSolidBlack)
{
s_src.clearFlags = CameraClearFlags.SolidColor;
s_src.backgroundColor = Color.black;
}
if (srcData != null)
{
if (cfg.mainCameraPostProcessingOff) srcData.renderPostProcessing = false;
srcData.renderShadows = false;
if (cfg.viewCameraInMainStack && srcData.cameraStack != null)
srcData.cameraStack.Insert(0, s_viewCam); // ViewCamera → UICamera 순서
}
Assembled++;
s_hasLastRot = false;
s_snapSuspended = false;
LastLog = "조립 — ortho " + OrthoOf(cfg).ToString("0.###")
+ " · RT " + s_mgr.GameResolution.x + "×" + s_mgr.GameResolution.y
+ " · zoom " + s_mgr.GameCameraZoom.ToString("0.####")
+ " · layer " + layer;
Log(LastLog);
return true;
}
/// <summary>SO 값으로 Critter 게임 카메라 줌(= 캔버스 반높이)을 계산한다.</summary>
public static float GameZoomFor(WLPixelCameraSettings cfg)
{
if (cfg == null) return 5f;
if (!cfg.compensateViewZoom) return OrthoOf(cfg);
return OrthoOf(cfg) / CanvasOnScreenLimit(PxOf(cfg));
}
/// <summary>Critter 가 캔버스 가장자리를 넘지 않으려고 쓰는 줌 한계 = 1 2/pixelHeight.</summary>
public static float CanvasOnScreenLimit(int pixelHeight)
{
int py = Mathf.Max(3, pixelHeight);
return 1f - (2f / py);
}
static float ScreenAspect()
{
float w = Screen.width, h = Screen.height;
if (w <= 0f || h <= 0f) return 9f / 16f;
return w / h;
}
// ─────────────────────────────────────────────────────────────────────
// 복원
// ─────────────────────────────────────────────────────────────────────
/// <summary>리그를 걷고 원본 카메라를 스냅샷 그대로 되돌린다.</summary>
public static void Teardown(string why)
{
bool had = s_rigRoot != null || s_proxyGo != null;
// 🔴 WL-814f — 무엇을 지우기 전에 Critter 컴포넌트를 **먼저 멈춘다**.
// `Object.Destroy` 는 프레임 끝에 처리되므로, 아래에서 RT·머티리얼 인스턴스를
// 지워도 같은 프레임에 `PixelCameraManager.LateUpdate` 가 한 번 더 돈다.
// 그러면 `targetTexture == null` 을 보고 RT 를 새로 만들어
// `UpscaledCanvas.SetCanvasRenderTexture` → 이미 지운 머티리얼에 `SetTexture` →
// **MissingReferenceException** 이 매 프레임 터진다(모드 전환 때 실측).
if (s_mgr != null) s_mgr.enabled = false;
if (s_canvas != null) s_canvas.enabled = false;
if (s_gameCam != null) s_gameCam.enabled = false;
if (s_viewCam != null) s_viewCam.enabled = false;
// 🔴 WL-814g — 그리고 리그를 **즉시 비활성**으로 내린다.
// Critter `PixelCameraManager.Initialize()`(PixelCameraManager.cs:123·132)는
// `viewCamera`/`upscaledCanvas` 를 **`FindAnyObjectByType` 로 찾는다**(두 필드는
// `[SerializeField]` 가 아니라 프리팹에 저장되지 않아 인스턴스마다 항상 null → 항상 Find).
// `Object.Destroy` 는 프레임 끝 처리라, 모드 전환에서 새 리그를 `Instantiate` 하는 순간
// **아직 살아 있는 이전 리그**의 뷰 카메라/캔버스를 새 매니저가 잡아 버리고,
// 프레임 끝에 그것들이 파괴되면서 `CanvasViewCamera.get_Aspect` 에서
// `MissingReferenceException` 이 매 프레임 터졌다(PD 실측 200건).
// `FindAnyObjectByType` 는 **비활성 오브젝트를 제외**하므로 여기서 내려두면 충돌이 사라진다.
if (s_rigRoot != null) s_rigRoot.SetActive(false);
// 원본 카메라 복원 — 스택에서 뷰 카메라부터 뺀다.
if (s_src != null)
{
var srcData = s_src.GetUniversalAdditionalCameraData();
if (srcData != null && srcData.cameraStack != null)
{
srcData.cameraStack.Clear();
if (s_snap.stack != null)
for (int i = 0; i < s_snap.stack.Length; i++)
if (s_snap.stack[i] != null) srcData.cameraStack.Add(s_snap.stack[i]);
}
s_snap.Restore(s_src);
}
// RT 해제 — Critter 가 만든 것을 우리가 반납한다.
if (s_gameCam != null)
{
var rt = s_gameCam.targetTexture;
s_gameCam.targetTexture = null;
if (rt != null) { rt.Release(); DestroyObj(rt); }
}
// 쿼드 머티리얼 인스턴스 반납 + 원본 sharedMaterial 복귀(디스크 에셋 무변경 보장)
if (s_quadRenderer != null && s_quadSharedMat != null) s_quadRenderer.sharedMaterial = s_quadSharedMat;
if (s_matInstance != null) { DestroyObj(s_matInstance); s_matInstance = null; }
if (s_rigRoot != null) { DestroyObj(s_rigRoot); s_rigRoot = null; }
if (s_proxyGo != null) { DestroyObj(s_proxyGo); s_proxyGo = null; }
DestroyStrays();
s_proxy = null; s_gameCam = null; s_viewCam = null; s_mgr = null; s_canvas = null;
s_quadRenderer = null; s_quadSharedMat = null;
s_snapSuspended = false; s_hasLastRot = false;
s_src = null;
if (had)
{
Restored++;
LastLog = "복원 — " + why;
Log(LastLog);
}
}
/// <summary>이름으로 남은 리그·프록시 잔재를 지운다(도메인 리로드·에디트 모드 재실행 대비).</summary>
static void DestroyStrays()
{
var all = Object.FindObjectsByType<PixelCameraManager>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < all.Length; i++)
{
if (all[i] == null) continue;
var root = all[i].transform.parent != null ? all[i].transform.parent.gameObject : all[i].gameObject;
if (root == s_rigRoot) continue;
// 🔴 WL-814g — 파괴는 프레임 끝이라, 먼저 비활성으로 내려 새 리그의
// `FindAnyObjectByType` 가 이 잔재를 잡지 못하게 한다(위 Teardown 주석 참조).
if (root.name == RigName) { root.SetActive(false); DestroyObj(root); }
}
// 프록시(원본 카메라의 자식)도 이름으로 정리
var cam = Camera.main;
if (cam != null)
{
for (int i = cam.transform.childCount - 1; i >= 0; i--)
{
var c = cam.transform.GetChild(i);
if (c != null && c.gameObject != s_proxyGo && c.name == ProxyName) DestroyObj(c.gameObject);
}
}
}
static void DestroyObj(Object o)
{
if (o == null) return;
if (Application.isPlaying) Object.Destroy(o);
else Object.DestroyImmediate(o);
}
static void SetLayerRecursive(Transform t, int layer)
{
t.gameObject.layer = layer;
for (int i = 0; i < t.childCount; i++) SetLayerRecursive(t.GetChild(i), layer);
}
// ─────────────────────────────────────────────────────────────────────
// 회전 중 스냅 해제 (Critter 문서 권고 · 811o 구도 전환 · 보스 연출)
// ─────────────────────────────────────────────────────────────────────
static void UpdateSnapSuspension(WLPixelCameraSettings cfg)
{
if (s_mgr == null || s_src == null) { s_hasLastRot = false; return; }
if (!cfg.disableSnapWhileRotating)
{
if (s_snapSuspended) { s_mgr.VoxelGridMovement = cfg.voxelGridMovement; s_mgr.SubpixelAdjustments = cfg.subpixelAdjustments; s_snapSuspended = false; }
return;
}
var rot = s_src.transform.rotation;
float dt = Mathf.Max(1e-4f, Time.unscaledDeltaTime);
if (!s_hasLastRot) { s_lastSrcRot = rot; s_hasLastRot = true; return; }
float degPerSec = Quaternion.Angle(s_lastSrcRot, rot) / dt;
s_lastSrcRot = rot;
float now = Time.unscaledTime;
if (degPerSec >= Mathf.Max(0f, cfg.rotationSnapOffDegPerSec))
{
s_snapResumeAt = now + Mathf.Max(0f, cfg.rotationSnapResumeSeconds);
if (!s_snapSuspended)
{
s_snapSuspended = true;
s_mgr.VoxelGridMovement = false;
s_mgr.SubpixelAdjustments = false;
}
}
else if (s_snapSuspended && now >= s_snapResumeAt)
{
s_snapSuspended = false;
s_mgr.VoxelGridMovement = cfg.voxelGridMovement;
s_mgr.SubpixelAdjustments = cfg.subpixelAdjustments;
}
}
// ─────────────────────────────────────────────────────────────────────
// 프로브 진입점
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브·에디트 모드: 경계 폴링을 건너뛰고 지금 바로 조립한다.</summary>
public static bool ProbeAssemble(Camera src)
{
var cfg = Cfg;
if (cfg == null) return false;
if (IsAssembled) Teardown("probe 재조립");
return Assemble(cfg, src);
}
/// <summary>프로브: 지금 바로 복원한다.</summary>
public static void ProbeTeardown() { Teardown("probe"); }
/// <summary>프로브: 회전 스냅 판정을 1회 돌린다(에디트 모드).</summary>
public static void ProbeSnapTick() { var c = Cfg; if (c != null) UpdateSnapSuspension(c); }
/// <summary>프로브: 원본 카메라 스냅샷을 한 줄로.</summary>
public static string SnapshotSummary() { return s_snap.Summary(); }
/// <summary>프로브: 지금 원본 카메라 상태를 스냅샷과 같은 형식으로.</summary>
public static string CurrentSummary(Camera cam) { var s = new CamSnapshot(); s.Capture(cam); return s.Summary(); }
public static void ResetAll()
{
Teardown("reset");
Boundaries = Assembled = Restored = SkippedLobby = SkippedNoCam = SkippedNoPrefab = Faults = 0;
LastReason = LastLog = "";
s_faulted = false; s_rtOn = false; // WL-814d 오버라이드도 함께 걷는다
s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = 0f;
}
static void Log(string msg)
{
var cfg = Cfg;
if (cfg != null && cfg.verboseLog) Debug.Log("[PixelCameraRig] " + msg);
}
// ─────────────────────────────────────────────────────────────────────
// 러너
// ─────────────────────────────────────────────────────────────────────
static GameObject s_runner;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (s_runner != null) return;
if (!Active && !s_rtOn) return; // C8 — 러너 자체가 뜨지 않는다(WL-814d 오버라이드는 예외)
s_runner = new GameObject("[WL814b] PixelCameraRigRunner");
s_runner.hideFlags = HideFlags.HideAndDontSave;
s_runner.AddComponent<PixelCameraRigRunner>();
Object.DontDestroyOnLoad(s_runner);
}
internal static void OnRunnerQuit() { Teardown("quit"); }
#if UNITY_EDITOR
/// <summary>에디터: Play 진입/이탈 경계에서 반드시 복원한다(에디트 모드 프로브 잔재 0).</summary>
[UnityEditor.InitializeOnLoadMethod]
static void EditorHook()
{
UnityEditor.EditorApplication.playModeStateChanged -= OnPlayModeChanged;
UnityEditor.EditorApplication.playModeStateChanged += OnPlayModeChanged;
}
static void OnPlayModeChanged(UnityEditor.PlayModeStateChange s)
{
if (s == UnityEditor.PlayModeStateChange.ExitingEditMode || s == UnityEditor.PlayModeStateChange.ExitingPlayMode)
Teardown("playmode " + s);
}
#endif
}
/// <summary>매 프레임 리그 틱. Critter(기본 실행 순서 0)보다 먼저 돌아야 회전 스냅 해제가 그 프레임에 반영된다.</summary>
[DefaultExecutionOrder(-100)]
internal sealed class PixelCameraRigRunner : MonoBehaviour
{
void Update() { PixelCameraRig.Tick(); }
void OnApplicationQuit() { PixelCameraRig.OnRunnerQuit(); }
void OnDestroy() { PixelCameraRig.OnRunnerQuit(); }
}
/// <summary>MirrorScript 모드용 프록시. 원본 카메라 트랜스폼을 읽기만 한다(역방향 쓰기 0 = 피드백 루프 0).</summary>
[DefaultExecutionOrder(-100)]
public sealed class PixelCameraProxyMirror : MonoBehaviour
{
public Transform Source;
void LateUpdate()
{
if (Source == null) return;
transform.SetPositionAndRotation(Source.position, Source.rotation);
}
/// <summary>프로브: 1회 미러(에디트 모드).</summary>
public void Mirror() { LateUpdate(); }
}
/// <summary>원본 Camera + URP 데이터의 되돌릴 값 전부.</summary>
internal struct CamSnapshot
{
public bool valid;
public bool ortho;
public float orthoSize, fov, near, far, depth;
public int cullingMask;
public CameraClearFlags clearFlags;
public Color background;
public bool allowHDR, allowMSAA, occlusionCulling;
public RenderTexture target;
public bool hasData;
public CameraRenderType renderType;
public bool renderPost, renderShadows;
public LayerMask volumeMask;
public Transform volumeTrigger;
public AntialiasingMode aa;
public AntialiasingQuality aaQuality;
public Camera[] stack;
public void Capture(Camera c)
{
valid = c != null;
if (!valid) return;
ortho = c.orthographic; orthoSize = c.orthographicSize; fov = c.fieldOfView;
near = c.nearClipPlane; far = c.farClipPlane; depth = c.depth;
cullingMask = c.cullingMask; clearFlags = c.clearFlags; background = c.backgroundColor;
allowHDR = c.allowHDR; allowMSAA = c.allowMSAA; occlusionCulling = c.useOcclusionCulling;
target = c.targetTexture;
var d = c.GetUniversalAdditionalCameraData();
hasData = d != null;
if (!hasData) { stack = null; return; }
renderType = d.renderType;
renderPost = d.renderPostProcessing;
renderShadows = d.renderShadows;
volumeMask = d.volumeLayerMask;
volumeTrigger = d.volumeTrigger;
aa = d.antialiasing;
aaQuality = d.antialiasingQuality;
var st = d.cameraStack;
if (st == null) { stack = null; return; }
stack = new Camera[st.Count];
for (int i = 0; i < st.Count; i++) stack[i] = st[i];
}
public void Restore(Camera c)
{
if (!valid || c == null) return;
c.orthographic = ortho; c.orthographicSize = orthoSize; c.fieldOfView = fov;
c.nearClipPlane = near; c.farClipPlane = far; c.depth = depth;
c.cullingMask = cullingMask; c.clearFlags = clearFlags; c.backgroundColor = background;
c.allowHDR = allowHDR; c.allowMSAA = allowMSAA; c.useOcclusionCulling = occlusionCulling;
c.targetTexture = target;
if (!hasData) return;
var d = c.GetUniversalAdditionalCameraData();
if (d == null) return;
d.renderType = renderType;
d.renderPostProcessing = renderPost;
d.renderShadows = renderShadows;
d.volumeLayerMask = volumeMask;
d.volumeTrigger = volumeTrigger;
d.antialiasing = aa;
d.antialiasingQuality = aaQuality;
}
/// <summary>비교용 한 줄 요약(프로브가 조립 전/후를 문자열로 대조한다).</summary>
public string Summary()
{
if (!valid) return "(none)";
var sb = new System.Text.StringBuilder(160);
sb.Append("ortho=").Append(ortho ? 1 : 0)
.Append(" size=").Append(orthoSize.ToString("0.####"))
.Append(" fov=").Append(fov.ToString("0.###"))
.Append(" near=").Append(near.ToString("0.###"))
.Append(" far=").Append(far.ToString("0.#"))
.Append(" depth=").Append(depth.ToString("0.##"))
.Append(" mask=").Append(cullingMask)
.Append(" clear=").Append((int)clearFlags)
.Append(" bg=").Append(background.r.ToString("0.###")).Append(',').Append(background.g.ToString("0.###")).Append(',').Append(background.b.ToString("0.###")).Append(',').Append(background.a.ToString("0.###"))
.Append(" hdr=").Append(allowHDR ? 1 : 0)
.Append(" msaa=").Append(allowMSAA ? 1 : 0)
.Append(" occ=").Append(occlusionCulling ? 1 : 0)
.Append(" rt=").Append(target == null ? 0 : 1);
if (hasData)
{
sb.Append(" type=").Append((int)renderType)
.Append(" post=").Append(renderPost ? 1 : 0)
.Append(" shadow=").Append(renderShadows ? 1 : 0)
.Append(" vmask=").Append(volumeMask.value)
.Append(" aa=").Append((int)aa)
.Append(" stack=").Append(stack == null ? 0 : stack.Length);
}
return sb.ToString();
}
}
}