455 lines
21 KiB
C#
455 lines
21 KiB
C#
using System.Collections.Generic;
|
||
using BrewMonster.Scripts;
|
||
using BrewMonster.Scripts.Managers;
|
||
using UnityEngine;
|
||
|
||
namespace BrewMonster
|
||
{
|
||
/// <summary>
|
||
/// Renders the host player's portrait into a RenderTexture for the HUD.
|
||
///
|
||
/// Integrates with <see cref="PlayerModelPreview"/>:
|
||
/// • 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
|
||
/// </summary>
|
||
public class HostPlayerPortraitCapture : MonoSingleton<HostPlayerPortraitCapture>
|
||
{
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
// 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<Material> _portraitMaterialInstances = new();
|
||
private readonly List<RendererSharedMaterialsSnapshot> _previewMaterialRestore = new();
|
||
private readonly List<TransformLayerSnapshot> _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
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Attaches the portrait camera to the model matching <paramref name="roleId"/> inside
|
||
/// <see cref="PlayerModelPreview"/>.
|
||
///
|
||
/// Two paths:
|
||
/// 1. Model already loaded and active → attach immediately.
|
||
/// 2. Model still loading (async) → subscribe to <see cref="PlayerModelPreview.OnModelReady"/>
|
||
/// and attach automatically when the event fires for this roleId.
|
||
/// </summary>
|
||
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...");
|
||
}
|
||
|
||
/// <summary>Fallback: attach directly to any player hierarchy root.</summary>
|
||
public void SetHostPlayer(Transform hostPlayerRoot)
|
||
{
|
||
RestorePreviewMaterials();
|
||
RestorePreviewLayers();
|
||
DetachCamera();
|
||
if (hostPlayerRoot == null) return;
|
||
_modelTransform = hostPlayerRoot;
|
||
SetupPortrait();
|
||
}
|
||
|
||
/// <summary>Re-attach after equipment reload rebuilds the PlayerModelPreview model.</summary>
|
||
public void Refresh(int roleId) => AttachToPlayerModelPreview(roleId);
|
||
|
||
/// <summary>Detach camera and stop rendering. Call on logout / scene unload.</summary>
|
||
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
|
||
// ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Called by <see cref="PlayerModelPreview.OnModelReady"/> when a model becomes active.
|
||
/// Attaches only if the roleId matches the pending request.
|
||
/// </summary>
|
||
private void OnPreviewModelReady(int readyRoleId)
|
||
{
|
||
if (readyRoleId != _pendingRoleId) return;
|
||
|
||
UnsubscribeModelReady();
|
||
TryAttachFromPreview(readyRoleId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Tries to find and attach to the model for <paramref name="roleId"/> in PlayerModelPreview.
|
||
/// Returns true if the model was found (active or not) and attachment succeeded.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Restores <see cref="PlayerModelPreview"/> renderers to their original shared materials
|
||
/// and destroys portrait-only material instances.
|
||
/// </summary>
|
||
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<Renderer>(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<Transform>(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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the world-space direction that points OUT OF the character's face,
|
||
/// derived from the head bone's local coordinate axes according to <see cref="headFaceAxis"/>.
|
||
///
|
||
/// 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 <see cref="headFaceAxis"/>
|
||
/// and/or <see cref="flipFaceAxis"/> in the Inspector without changing code.
|
||
/// </summary>
|
||
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 }
|
||
}
|
||
}
|