Files
test/Assets/PerfectWorld/Scripts/UI/HostPlayerPortraitCapture.cs
2026-05-07 17:29:15 +07:00

455 lines
21 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.
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.51.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 }
}
}