// ───────────────────────────────────────────────────────────────────────────── // 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()` 다 → 원본 카메라가 **계속 // 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 { /// Critter 픽셀 카메라 리그의 조립·복원. 정적 · 전용 러너 1개. 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 = ""; /// 지금 리그가 조립돼 있는가. public static bool IsAssembled { get { return s_rigRoot != null; } } /// 조립된 리그의 핵심 참조(프로브 전용). 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; } } /// 지금 회전 때문에 스냅이 꺼져 있는가(진단). 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; /// 룩 토글이 지금 리그를 강제로 켜고 있는가(진단). 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; } /// WL-814d — 룩 토글이 부른다. on 이면 이 값으로 즉시 조립, off 면 해체. 값이 그대로면 무동작. 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) : "조립 실패"; } // ───────────────────────────────────────────────────────────────────── // 틱 (러너가 매 프레임 부른다 · 에디트 모드에서는 프로브가 직접 부를 수 있다) // ───────────────────────────────────────────────────────────────────── /// 1프레임. (프로브가 별도 어셈블리에서 부르므로 public) 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) } } /// 맵 진입/이탈 경계 — 811q EffectWarmup · 811o CameraFramingOverride 와 같은 규칙. 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); } // ───────────────────────────────────────────────────────────────────── // 조립 // ───────────────────────────────────────────────────────────────────── /// 리그를 조립한다. 성공 시 true. 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(true); var view = s_rigRoot.GetComponentInChildren(true); s_canvas = s_rigRoot.GetComponentInChildren(true); if (s_mgr == null || view == null || s_canvas == null) { LastLog = "리그 프리팹 구조가 예상과 다르다 — 조립 취소"; Log(LastLog); Teardown("구조 불일치"); Faults++; return false; } s_gameCam = s_mgr.GetComponent(); s_viewCam = view.GetComponent(); 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(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(); 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(); 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; } /// SO 값으로 Critter 게임 카메라 줌(= 캔버스 반높이)을 계산한다. public static float GameZoomFor(WLPixelCameraSettings cfg) { if (cfg == null) return 5f; if (!cfg.compensateViewZoom) return OrthoOf(cfg); return OrthoOf(cfg) / CanvasOnScreenLimit(PxOf(cfg)); } /// Critter 가 캔버스 가장자리를 넘지 않으려고 쓰는 줌 한계 = 1 − 2/pixelHeight. 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; } // ───────────────────────────────────────────────────────────────────── // 복원 // ───────────────────────────────────────────────────────────────────── /// 리그를 걷고 원본 카메라를 스냅샷 그대로 되돌린다. 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); } } /// 이름으로 남은 리그·프록시 잔재를 지운다(도메인 리로드·에디트 모드 재실행 대비). static void DestroyStrays() { var all = Object.FindObjectsByType(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; } } // ───────────────────────────────────────────────────────────────────── // 프로브 진입점 // ───────────────────────────────────────────────────────────────────── /// 프로브·에디트 모드: 경계 폴링을 건너뛰고 지금 바로 조립한다. public static bool ProbeAssemble(Camera src) { var cfg = Cfg; if (cfg == null) return false; if (IsAssembled) Teardown("probe 재조립"); return Assemble(cfg, src); } /// 프로브: 지금 바로 복원한다. public static void ProbeTeardown() { Teardown("probe"); } /// 프로브: 회전 스냅 판정을 1회 돌린다(에디트 모드). public static void ProbeSnapTick() { var c = Cfg; if (c != null) UpdateSnapSuspension(c); } /// 프로브: 원본 카메라 스냅샷을 한 줄로. public static string SnapshotSummary() { return s_snap.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(); Object.DontDestroyOnLoad(s_runner); } internal static void OnRunnerQuit() { Teardown("quit"); } #if UNITY_EDITOR /// 에디터: Play 진입/이탈 경계에서 반드시 복원한다(에디트 모드 프로브 잔재 0). [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 } /// 매 프레임 리그 틱. Critter(기본 실행 순서 0)보다 먼저 돌아야 회전 스냅 해제가 그 프레임에 반영된다. [DefaultExecutionOrder(-100)] internal sealed class PixelCameraRigRunner : MonoBehaviour { void Update() { PixelCameraRig.Tick(); } void OnApplicationQuit() { PixelCameraRig.OnRunnerQuit(); } void OnDestroy() { PixelCameraRig.OnRunnerQuit(); } } /// MirrorScript 모드용 프록시. 원본 카메라 트랜스폼을 읽기만 한다(역방향 쓰기 0 = 피드백 루프 0). [DefaultExecutionOrder(-100)] public sealed class PixelCameraProxyMirror : MonoBehaviour { public Transform Source; void LateUpdate() { if (Source == null) return; transform.SetPositionAndRotation(Source.position, Source.rotation); } /// 프로브: 1회 미러(에디트 모드). public void Mirror() { LateUpdate(); } } /// 원본 Camera + URP 데이터의 되돌릴 값 전부. 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; } /// 비교용 한 줄 요약(프로브가 조립 전/후를 문자열로 대조한다). 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(); } } }