From e2c24283cccc3d20811ac85959fc3c512f7ad0c5 Mon Sep 17 00:00:00 2001 From: CuongNV <> Date: Tue, 19 May 2026 17:28:14 +0700 Subject: [PATCH 1/2] fix pos name text of matter --- .../PerfectWorld/Scripts/Objet/CECMatter.cs | 124 ++++++++++++++++-- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs b/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs index 5776c5bd88..1a12a48c1a 100644 --- a/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs +++ b/Assets/PerfectWorld/Scripts/Objet/CECMatter.cs @@ -37,6 +37,10 @@ namespace PerfectWorld.Scripts public const uint MATTER_MONEY = 3; public const uint MATTER_TYPEMASK = 0xff; + private const float MatterNameExtraWorldYOffset = 0.05f; + private const float MatterNameFallbackLocalY = 0.6f; + private const string ItemNameTextChildName = "ItemNameText"; + // Constructor / Constructor public CECMatter() { @@ -179,11 +183,24 @@ namespace PerfectWorld.Scripts var collider = matterObject.AddComponent(); //this is a workaround to fix the collider size issue when load prefab go wrong at some point //TODO: remove this workaround after the prefab load issue is fixed - Vector3 size = matterObject.GetComponentInChildren().bounds.size; - if (size.x < 0.5f) size.x = 0.5f; - if (size.y < 0.5f) size.y = 0.5f; - if (size.z < 0.5f) size.z = 0.5f; - collider.size = size; + if (TryGetCombinedRendererBounds(matterObject.transform, null, out var combinedBounds)) + { + Vector3 size = combinedBounds.size; + if (size.x < 0.5f) size.x = 0.5f; + if (size.y < 0.5f) size.y = 0.5f; + if (size.z < 0.5f) size.z = 0.5f; + collider.size = size; + collider.center = matterObject.transform.InverseTransformPoint(combinedBounds.center); + } + else + { + var firstRenderer = matterObject.GetComponentInChildren(); + Vector3 size = firstRenderer != null ? firstRenderer.bounds.size : Vector3.one; + if (size.x < 0.5f) size.x = 0.5f; + if (size.y < 0.5f) size.y = 0.5f; + if (size.z < 0.5f) size.z = 0.5f; + collider.size = size; + } } // Create text object to display item name above the cube CreateItemNameText(matterObject, Info.tid); @@ -244,18 +261,109 @@ namespace PerfectWorld.Scripts return null; } + /// + /// Merge world-space bounds of all child Renderers (MeshRenderer + SkinnedMeshRenderer). + /// Reads sharedMesh.bounds (mesh local space) and manually converts to world space — + /// same approach as PlayerVisual/NPCVisual — to avoid stale renderer.bounds after SetActive. + /// 合并所有子 Renderer 的世界包围盒(MeshRenderer + SkinnedMeshRenderer)。 + /// 直接读 sharedMesh.bounds(网格本地空间)再手动转为世界坐标,避免 SetActive 后同帧 renderer.bounds 未刷新的问题。 + /// + private static bool TryGetCombinedRendererBounds(Transform matterRoot, Transform excludeSubtree, out Bounds combinedBounds) + { + combinedBounds = default; + if (matterRoot == null) + return false; + + var renderers = matterRoot.GetComponentsInChildren(true); + bool hasAny = false; + for (int i = 0; i < renderers.Length; i++) + { + var renderer = renderers[i]; + if (renderer == null) + continue; + if (excludeSubtree != null && renderer.transform.IsChildOf(excludeSubtree)) + continue; + + Mesh mesh = null; + if (renderer is SkinnedMeshRenderer smr) + { + mesh = smr.sharedMesh; + } + else if (renderer is MeshRenderer) + { + var mf = renderer.GetComponent(); + if (mf != null) + mesh = mf.sharedMesh; + } + + if (mesh == null) + continue; + + // Manually build world-space bounds from mesh-local bounds + transform, + // identical to PlayerVisual/NPCVisual — reliable even right after SetActive(true). + // 与 PlayerVisual/NPCVisual 相同:从网格本地包围盒手动计算世界包围盒,SetActive 后同帧可靠。 + var meshBounds = mesh.bounds; + var scale = renderer.transform.lossyScale; + var worldCenter = renderer.transform.TransformPoint(meshBounds.center); + var worldSize = new Vector3( + Mathf.Abs(meshBounds.size.x * scale.x), + Mathf.Abs(meshBounds.size.y * scale.y), + Mathf.Abs(meshBounds.size.z * scale.z)); + var currentBounds = new Bounds(worldCenter, worldSize); + + Debug.Log($"[Cuong] [CECMatter] renderer={renderer.name} meshLocalCenter={meshBounds.center} meshLocalSize={meshBounds.size} worldCenter={worldCenter} worldSize={worldSize} worldMaxY={currentBounds.max.y}"); + + if (!hasAny) + { + combinedBounds = currentBounds; + hasAny = true; + } + else + { + combinedBounds.Encapsulate(currentBounds); + } + } + + return hasAny; + } + + /// + /// Name anchor at top of tallest mesh: combinedBounds.max.y in world, converted to local. + /// 名牌锚点位于最高 mesh 顶部:世界坐标 combinedBounds.max.y,再转为本地坐标。 + /// + private static bool TryGetItemNameAnchorLocal(Transform matterRoot, out Vector3 localAnchor) + { + if (!TryGetCombinedRendererBounds(matterRoot, null, out var combinedBounds)) + { + localAnchor = new Vector3(0f, MatterNameFallbackLocalY, 0f); + return false; + } + + var worldAnchor = new Vector3( + combinedBounds.center.x, + combinedBounds.max.y + MatterNameExtraWorldYOffset, + combinedBounds.center.z); + localAnchor = matterRoot.InverseTransformPoint(worldAnchor); + return true; + } + private static void CreateItemNameText(GameObject matterObject, int tid) { if (matterObject == null) return; // Avoid duplicating if prefab already contains it (or Init called twice). - if (matterObject.transform.Find("ItemNameText") != null) + if (matterObject.transform.Find(ItemNameTextChildName) != null) return; - var textObject = new GameObject("ItemNameText"); + var textObject = new GameObject(ItemNameTextChildName); textObject.transform.SetParent(matterObject.transform, false); - textObject.transform.localPosition = new Vector3(0f, 0.6f, 0f); + if (!TryGetItemNameAnchorLocal(matterObject.transform, out var localAnchor)) + { + Debug.LogWarning( + $"[Cuong] [CECMatter] No renderer bounds for '{matterObject.name}'; using fallback Y={MatterNameFallbackLocalY}"); + } + textObject.transform.localPosition = localAnchor; var textMesh = textObject.AddComponent(); From 3e545f026d2624066df203d5343677235fb1a77b Mon Sep 17 00:00:00 2001 From: CuongNV <> Date: Wed, 20 May 2026 14:01:38 +0700 Subject: [PATCH 2/2] fix pos textname for NPC --- Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs | 53 +++++++++++++++++++ .../UI/GamePlay/NameplateWorldAnchor.cs | 37 +++++++++++++ .../PerfectWorld/Scripts/UI/GamePlay/UINPC.cs | 22 ++++++++ 3 files changed, 112 insertions(+) diff --git a/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs b/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs index 769a82f63a..6a544d802f 100644 --- a/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs +++ b/Assets/PerfectWorld/Scripts/NPC/NPCVisual.cs @@ -151,6 +151,59 @@ public class NPCVisual : MonoBehaviour } } + // Cached reference to the bone with the highest world-Y position at model-ready time. + // 模型就绪时世界Y最高的骨骼缓存引用。 + private Transform _cachedTopBone; + + /// + /// Scan all SkinnedMeshRenderer bones, find the one with the highest world-Y, and cache it. + /// Call once after the model and its first animation frame are ready (e.g. from UINPC coroutine). + /// 扫描所有SkinnedMeshRenderer骨骼,找到世界Y最高者并缓存。 + /// 在模型及首帧动画就绪后调用一次(例如从UINPC协程中调用)。 + /// + public void CacheTopBone() + { + var smrs = GetComponentsInChildren(true); + var seen = new HashSet(); + Transform highest = null; + float highestY = float.MinValue; + foreach (var smr in smrs) + { + if (smr == null || smr.bones == null) + continue; + foreach (var bone in smr.bones) + { + if (bone == null || !seen.Add(bone)) + continue; + float y = bone.position.y; + if (y > highestY) + { + highestY = y; + highest = bone; + } + } + } + _cachedTopBone = highest; + if (debugNamePlateBounds && _cachedTopBone != null) + Debug.Log($"[Cuong] [NPCVisual] CacheTopBone: topBone={_cachedTopBone.name} worldY={highestY:F3}"); + } + + /// + /// Return the world position of the cached top bone (set by ). + /// Returns false if no bone has been cached yet. + /// 返回缓存的最高骨骼世界坐标。若尚未缓存则返回false。 + /// + public bool TryGetTopBoneWorld(out Vector3 worldPos) + { + if (_cachedTopBone == null) + { + worldPos = default; + return false; + } + worldPos = _cachedTopBone.position; + return true; + } + /// /// Resolve world anchor from merged bounds of all SkinnedMeshRenderers. /// 从所有SkinnedMeshRenderer合并后的包围盒解析世界锚点。 diff --git a/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs b/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs index 8f244469a5..82e1f5b73a 100644 --- a/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs +++ b/Assets/PerfectWorld/Scripts/UI/GamePlay/NameplateWorldAnchor.cs @@ -26,6 +26,9 @@ namespace BrewMonster [SerializeField] private bool applyMaleHeadWorldOffsetForAabbOnly = true; [SerializeField] private float maleHeadWorldOffset = 0.1f; + [Header("NPC — skeleton hook")] + [SerializeField] private bool useNpcHeadHook = true; + [Header("NPC — SMR / root fallback")] [SerializeField] private bool preferVisualBoundsFallback = true; [SerializeField] private float fallbackHeightAboveRoot = 2f; @@ -55,6 +58,17 @@ namespace BrewMonster npcVisual = hostNpc.GetComponent(); } + /// + /// Trigger a scan of all NPC skeleton bones to cache the highest-Y bone. + /// Call from once the NPC model and its animation are ready. + /// 触发扫描NPC所有骨骼以缓存世界Y最高的骨骼。在NPC模型及动画就绪后由UINPC调用一次。 + /// + public void RefreshNpcTopBone() + { + CacheRefs(); + npcVisual?.CacheTopBone(); + } + /// /// Resolve world position for the nameplate canvas root. Call once at init or every frame if the plate should follow the host. /// 解析名牌Canvas根的世界坐标。仅在初始化时调用一次,或若名牌需跟随宿主则每帧调用。 @@ -119,6 +133,29 @@ namespace BrewMonster if (hostNpc == null) return false; + // Priority 1: skeleton hook (follows animation every frame — fixes floating/flying NPCs like 小星星). + // 优先级1:骨骼挂点(每帧跟随动画,修复小星星等浮空动画中名牌与模型重叠的问题)。 + if (useNpcHeadHook) + { + var headHook = hostNpc.GetHook(headHookName); + if (headHook != null) + { + worldPos = headHook.position + Vector3.up * (headHookWorldOffset + extraWorldYOffset); + usedSkinnedMeshMergedBounds = true; // treated as "precise anchor" so UINPC coroutine exits early + return true; + } + } + + // Priority 2: cached top bone — bone with highest world-Y found at model-ready time. + // Follows animation per-frame via UINPC.LateUpdate without needing a specific bone name. + // 优先级2:缓存的最高骨骼——模型就绪时世界Y最高的骨骼,每帧跟随动画,无需特定骨骼名。 + if (npcVisual != null && npcVisual.TryGetTopBoneWorld(out worldPos)) + { + usedSkinnedMeshMergedBounds = true; + worldPos.y += extraWorldYOffset; + return true; + } + if (preferVisualBoundsFallback && npcVisual != null && npcVisual.TryGetNamePlateAnchorWorld(out worldPos)) diff --git a/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs b/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs index eb6b7165b7..dbadd51e4e 100644 --- a/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs +++ b/Assets/PerfectWorld/Scripts/UI/GamePlay/UINPC.cs @@ -63,6 +63,22 @@ namespace BrewMonster _initialNameplateRoutine = StartCoroutine(CoApplyInitialNameplateDeferred(maxFrames: 120)); } + /// + /// Update nameplate position every frame so it follows skeleton animations (e.g. floating/flying NPCs or monsters). + /// Only active after the initial anchor coroutine has resolved (i.e. _initialNameplateRoutine == null). + /// 每帧更新名牌位置,使其跟随骨骼动画(如浮空/飞行的NPC或怪物,例如小星星)。 + /// 仅在初始锚点协程完成后(_initialNameplateRoutine == null)生效。 + /// + private void LateUpdate() + { + if (_initialNameplateRoutine != null) + return; + if (_canvasRoot == null || _nameplateAnchor == null) + return; + if (_nameplateAnchor.TryGetWorldPosition(out var worldPos)) + ApplyCanvasRootLocalPosition(worldPos); + } + /// /// Call after NPC model async load (e.g. ) so SMR bounds exist before anchoring. /// 在NPC模型异步加载完成后调用(如 ),以便SMR包围盒已存在再定位名牌。 @@ -107,12 +123,17 @@ namespace BrewMonster && fromSmr) { ApplyCanvasRootLocalPosition(worldPos); + // Model + SMR ready: scan bones and cache the highest one for per-frame LateUpdate tracking. + // 模型和SMR就绪:扫描骨骼并缓存最高骨骼,供LateUpdate每帧跟踪。 + _nameplateAnchor.RefreshNpcTopBone(); _initialNameplateRoutine = null; yield break; } yield return null; } + // Timeout: set position from whatever is available (hook / SMR / root fallback), then still cache top bone. + // 超时:使用当前可用方式设置位置(挂点/SMR/根节点回退),仍然缓存最高骨骼。 if (_nameplateAnchor.TryGetWorldPosition(out var finalPos, out _, logNpcRootFallback: true)) { ApplyCanvasRootLocalPosition(finalPos); @@ -125,6 +146,7 @@ namespace BrewMonster "NPC with SMR bounds: worldPos = (combinedBounds.center.x, combinedBounds.max.y, combinedBounds.center.z) + Vector3.up * extraWorldYOffset. " + "NPC fallback: worldPos = hostNpc.transform.position + Vector3.up * (fallbackHeightAboveRoot + extraWorldYOffset)."); } + _nameplateAnchor.RefreshNpcTopBone(); _initialNameplateRoutine = null; }