using System.Collections.Generic; using BrewMonster.Scripts; using BrewMonster.Scripts.Managers; using UnityEngine; namespace BrewMonster { /// /// Renders the host player's portrait into a RenderTexture for the HUD. /// /// Integrates with : /// • If the model is already loaded when AttachToPlayerModelPreview is called → attach immediately. /// • If the model is still loading (async) → subscribe to PlayerModelPreview.OnModelReady and /// attach automatically as soon as the model becomes active. /// /// Camera is parented directly to the "Bip01 Head" bone so it rides every head /// movement from idle animation without any world-space tracking code. /// Every LateUpdate the camera rotation is Slerp-interpolated toward the face. /// /// Public API: /// AttachToPlayerModelPreview(int roleId) — primary; handles async loading /// SetHostPlayer(Transform root) — fallback; any player root directly /// Refresh(int roleId) — call after equipment reload /// ClearPortrait() — detach camera, stop rendering /// OutputTexture — wire to RawImage.texture on HUD /// public class HostPlayerPortraitCapture : MonoSingleton { // ────────────────────────────────────────────────────────────────────────────── // Inspector // ────────────────────────────────────────────────────────────────────────────── [Header("Portrait Camera")] [SerializeField] private Camera portraitCamera; [Header("Render Output")] [SerializeField] private RenderTexture outputTexture; [Header("Portrait Layer")] [Tooltip("Layer index rendered ONLY by the portrait camera (Project Settings > Tags and Layers).")] [SerializeField] private int portraitLayer = 29; [Header("Head Bone Face Direction")] [Tooltip("Which local axis of Bip01 Head points OUT of the face (toward the camera).\n" + "In most Biped rigs exported from 3ds Max/Unity: Forward (+Z).\n" + "Switch to Back (-Z) or Up (+Y) if portrait faces wrong way.")] [SerializeField] private FaceAxis headFaceAxis = FaceAxis.Forward; [Tooltip("Flip the chosen face axis (e.g. if Forward gives back-of-head, enable this).")] [SerializeField] private bool flipFaceAxis; [Header("Framing")] [Tooltip("Distance the camera sits in front of the face (meters). ~0.5–1.0 for tight portrait.")] [SerializeField] private float cameraDistance = 0.7f; [Tooltip("Extra upward offset applied to the look-at focus point (moves portrait up toward eyes).")] [SerializeField] private float faceUpLift = 0.04f; [Tooltip("How fast the camera rotation interpolates toward the face each frame (higher = snappier).")] [SerializeField][Range(1f, 50f)] private float lookSmoothSpeed = 20f; [SerializeField][Range(10f, 60f)] private float fieldOfView = 40f; [Header("Materials")] [Tooltip("Clone each renderer material and switch copies to URP/Unlit for stable portrait lighting.")] [SerializeField] private bool usePortraitUnlitMaterials = true; // ────────────────────────────────────────────────────────────────────────────── // Runtime state // ────────────────────────────────────────────────────────────────────────────── private Transform _modelTransform; private Transform _headBone; private readonly List _portraitMaterialInstances = new(); private readonly List _previewMaterialRestore = new(); private readonly List _previewLayerRestore = new(); // Stored while waiting for async model load; -1 means no pending request private int _pendingRoleId = -1; private struct RendererSharedMaterialsSnapshot { public Renderer Renderer; public Material[] SharedMaterials; } private struct TransformLayerSnapshot { public Transform Transform; public int Layer; } public RenderTexture OutputTexture => outputTexture; // ────────────────────────────────────────────────────────────────────────────── // Lifecycle // ────────────────────────────────────────────────────────────────────────────── protected override void Initialize() { EnsureCamera(); EnsureRenderTexture(); } protected override void OnDestroy() { base.OnDestroy(); UnsubscribeModelReady(); ClearPortrait(); if (outputTexture != null && outputTexture.IsCreated()) outputTexture.Release(); } // ────────────────────────────────────────────────────────────────────────────── // Public API // ────────────────────────────────────────────────────────────────────────────── /// /// Attaches the portrait camera to the model matching inside /// . /// /// Two paths: /// 1. Model already loaded and active → attach immediately. /// 2. Model still loading (async) → subscribe to /// and attach automatically when the event fires for this roleId. /// public void AttachToPlayerModelPreview(int roleId) { UnsubscribeModelReady(); RestorePreviewMaterials(); RestorePreviewLayers(); DetachCamera(); if (PlayerModelPreview.Instance == null) { BMLogger.LogWarning("[HostPlayerPortraitCapture] PlayerModelPreview.Instance is null."); return; } // Path 1: model already in the list and active → attach now if (TryAttachFromPreview(roleId)) return; // Path 2: model not ready yet — wait for OnModelReady event _pendingRoleId = roleId; PlayerModelPreview.Instance.OnModelReady += OnPreviewModelReady; BMLogger.Log($"[HostPlayerPortraitCapture] Model for roleId={roleId} not ready, waiting for load..."); } /// Fallback: attach directly to any player hierarchy root. public void SetHostPlayer(Transform hostPlayerRoot) { RestorePreviewMaterials(); RestorePreviewLayers(); DetachCamera(); if (hostPlayerRoot == null) return; _modelTransform = hostPlayerRoot; SetupPortrait(); } /// Re-attach after equipment reload rebuilds the PlayerModelPreview model. public void Refresh(int roleId) => AttachToPlayerModelPreview(roleId); /// Detach camera and stop rendering. Call on logout / scene unload. public void ClearPortrait() { UnsubscribeModelReady(); RestorePreviewMaterials(); RestorePreviewLayers(); DetachCamera(); _modelTransform = null; _headBone = null; _pendingRoleId = -1; } // ────────────────────────────────────────────────────────────────────────────── // LateUpdate — smooth Slerp toward face every frame // ────────────────────────────────────────────────────────────────────────────── private void LateUpdate() { if (portraitCamera == null || !portraitCamera.enabled) return; if (_headBone == null) return; // Focus point: centre of face, nudged upward by faceUpLift Vector3 faceCenter = _headBone.position + Vector3.up * faceUpLift; // Direction from camera to face centre Vector3 toFace = faceCenter - portraitCamera.transform.position; if (toFace.sqrMagnitude < 0.0001f) return; // Slerp rotation smoothly toward the look-at orientation Quaternion targetRot = Quaternion.LookRotation(toFace); portraitCamera.transform.rotation = Quaternion.Slerp( portraitCamera.transform.rotation, targetRot, Time.deltaTime * lookSmoothSpeed); } // ────────────────────────────────────────────────────────────────────────────── // PlayerModelPreview integration helpers // ────────────────────────────────────────────────────────────────────────────── /// /// Called by when a model becomes active. /// Attaches only if the roleId matches the pending request. /// private void OnPreviewModelReady(int readyRoleId) { if (readyRoleId != _pendingRoleId) return; UnsubscribeModelReady(); TryAttachFromPreview(readyRoleId); } /// /// Tries to find and attach to the model for in PlayerModelPreview. /// Returns true if the model was found (active or not) and attachment succeeded. /// private bool TryAttachFromPreview(int roleId) { var preview = PlayerModelPreview.Instance; if (preview == null) return false; var models = preview.playerModels; var ids = preview.playerModelIds; int count = Mathf.Min(models.Count, ids.Count); for (int i = 0; i < count; i++) { if (ids[i] != roleId || models[i] == null) continue; // Ensure the model is active so the camera can render it if (!models[i].activeSelf) models[i].SetActive(true); _modelTransform = models[i].transform; SetupPortrait(); return true; } return false; } private void UnsubscribeModelReady() { if (PlayerModelPreview.Instance != null) PlayerModelPreview.Instance.OnModelReady -= OnPreviewModelReady; _pendingRoleId = -1; } // ────────────────────────────────────────────────────────────────────────────── // Internal // ────────────────────────────────────────────────────────────────────────────── private void SetupPortrait() { if (_modelTransform == null) return; if (usePortraitUnlitMaterials) { CaptureRendererMaterialsForRestore(_modelTransform.gameObject); PortraitCaptureUtils.ApplyClonedUrpUnlitMaterials( _modelTransform.gameObject, _portraitMaterialInstances, static msg => BMLogger.LogWarning(msg)); } // Move model to portrait layer so the dedicated camera can render it CaptureLayersForRestore(_modelTransform.gameObject); PortraitCaptureUtils.SetLayerRecursive(_modelTransform.gameObject, portraitLayer); // Find head bone — primary target for camera attachment _headBone = PortraitCaptureUtils.FindChildByName(_modelTransform, "Bip01 Head") ?? PortraitCaptureUtils.FindChildByName(_modelTransform, "Bip001 Head") ?? PortraitCaptureUtils.FindHeadBone(_modelTransform); if (_headBone == null) BMLogger.LogWarning("[HostPlayerPortraitCapture] Head bone not found — portrait skipped."); AttachCameraToHeadBone(); SetCameraEnabled(true); } /// /// Restores renderers to their original shared materials /// and destroys portrait-only material instances. /// private void RestorePreviewMaterials() { for (int i = 0; i < _previewMaterialRestore.Count; i++) { var snap = _previewMaterialRestore[i]; if (snap.Renderer != null && snap.SharedMaterials != null) snap.Renderer.sharedMaterials = snap.SharedMaterials; } _previewMaterialRestore.Clear(); for (int i = 0; i < _portraitMaterialInstances.Count; i++) { if (_portraitMaterialInstances[i] != null) Object.Destroy(_portraitMaterialInstances[i]); } _portraitMaterialInstances.Clear(); } private void CaptureRendererMaterialsForRestore(GameObject root) { if (root == null) return; var renderers = root.GetComponentsInChildren(true); for (int i = 0; i < renderers.Length; i++) { var r = renderers[i]; var shared = r.sharedMaterials; if (shared == null || shared.Length == 0) continue; var copy = new Material[shared.Length]; System.Array.Copy(shared, copy, shared.Length); _previewMaterialRestore.Add( new RendererSharedMaterialsSnapshot { Renderer = r, SharedMaterials = copy }); } } private void CaptureLayersForRestore(GameObject root) { if (root == null) return; var transforms = root.GetComponentsInChildren(true); for (int i = 0; i < transforms.Length; i++) { var t = transforms[i]; if (t == null) continue; _previewLayerRestore.Add(new TransformLayerSnapshot { Transform = t, Layer = t.gameObject.layer }); } } private void RestorePreviewLayers() { for (int i = 0; i < _previewLayerRestore.Count; i++) { var snap = _previewLayerRestore[i]; if (snap.Transform != null) snap.Transform.gameObject.layer = snap.Layer; } _previewLayerRestore.Clear(); } /// /// Parents the portrait camera to "Bip01 Head" and sets its initial position /// directly in front of the face. /// /// Coordinate math: /// faceDir = local axis of headBone that points OUT of the face (Inspector: headFaceAxis) /// camWorldPos = headBone.position + faceDir * cameraDistance /// /// After SetParent, Unity converts this world position into a localPosition relative /// to the head bone automatically. From that point on the camera rides the bone — /// no per-frame world-space override is needed. Only LookAt is refreshed in LateUpdate. /// private void AttachCameraToHeadBone() { if (portraitCamera == null) return; Transform anchor = _headBone ?? _modelTransform; // Face direction in world space — derived from head bone's own local axes Vector3 faceDir = GetFaceDirectionWorld(anchor); Vector3 headPos = anchor.position; Vector3 faceCenter = headPos + Vector3.up * faceUpLift; // Camera sits cameraDistance units in FRONT of the face (outside, looking back in) Vector3 camWorldPos = headPos + faceDir * cameraDistance; // Parent to head bone first with worldPositionStays:false (resets local to zero), // then assign world position — Unity stores the correct localPosition internally portraitCamera.transform.SetParent(anchor, worldPositionStays: false); portraitCamera.transform.position = camWorldPos; // Initial look-at so first frame is already correct before LateUpdate fires portraitCamera.transform.LookAt(faceCenter); } /// /// Returns the world-space direction that points OUT OF the character's face, /// derived from the head bone's local coordinate axes according to . /// /// Biped rig convention (3ds Max → Unity export): /// In most Perfect World rigs, the head bone's +Z (forward) points toward the face. /// If the portrait comes out upside-down or backwards, toggle /// and/or in the Inspector without changing code. /// private Vector3 GetFaceDirectionWorld(Transform bone) { Vector3 dir = headFaceAxis switch { FaceAxis.Forward => bone.forward, FaceAxis.Back => -bone.forward, FaceAxis.Up => bone.up, FaceAxis.Down => -bone.up, FaceAxis.Right => bone.right, FaceAxis.Left => -bone.right, _ => bone.forward }; return flipFaceAxis ? -dir : dir; } private void DetachCamera() { if (portraitCamera != null) { portraitCamera.transform.SetParent(transform, worldPositionStays: false); SetCameraEnabled(false); } } private void SetCameraEnabled(bool enabled) { if (portraitCamera != null) portraitCamera.enabled = enabled; } private void EnsureCamera() { if (portraitCamera != null) { portraitCamera.cullingMask = 1 << portraitLayer; portraitCamera.fieldOfView = fieldOfView; portraitCamera.enabled = false; return; } portraitCamera = PortraitCaptureUtils.CreatePortraitCamera( transform, "PortraitCamera_HostPlayer", portraitLayer, fieldOfView); } private void EnsureRenderTexture() { if (outputTexture != null) { if (portraitCamera != null) portraitCamera.targetTexture = outputTexture; return; } outputTexture = PortraitCaptureUtils.CreatePortraitRT("HostPlayerPortraitRT", portraitCamera); } // ────────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────────── public enum FaceAxis { Forward, Back, Up, Down, Right, Left } } }