Unity坐标转换指南 - 3D与屏幕UI坐标互转

快速查找表

需求场景 使用方法 关键API 注意事项
3D物体→屏幕位置 WorldToScreenPoint Camera.WorldToScreenPoint() 检查Z值是否>0
屏幕点击→3D位置 射线检测(推荐) Camera.ScreenPointToRay() + Physics.Raycast() 最精确的方法
屏幕坐标→3D坐标 ScreenToWorldPoint Camera.ScreenToWorldPoint() 必须指定Z深度
UI跟随3D物体 WorldToScreen + ScreenToLocal WorldToScreenPoint() + ScreenPointToLocalPointInRectangle() 使用LateUpdate
3D物体跟随UI UI坐标→屏幕→3D WorldToScreenPoint() + ScreenToWorldPoint() 指定深度值
判断物体是否可见 ViewportPoint检查 Camera.WorldToViewportPoint() x,y在0-1且z>0
UI点击检测 EventSystem EventSystem.current.IsPointerOverGameObject() 防止穿透到3D

坐标系统概述

Unity中存在多个坐标系统:

复制代码
世界坐标 (World Space)     → 3D场景中的绝对坐标
局部坐标 (Local Space)     → 相对于父物体的坐标
屏幕坐标 (Screen Space)    → 像素坐标,左下角(0,0),右上角(Screen.width, Screen.height)
视口坐标 (Viewport Space)  → 归一化坐标,左下角(0,0),右上角(1,1)
UI坐标 (UI Space)          → Canvas下的RectTransform本地坐标

坐标系统详解:

  1. 世界坐标 (World Space)

    • Transform.position - 物体在场景中的绝对位置
    • 不受父物体影响
    • 用于物理计算、射线检测
  2. 局部坐标 (Local Space)

    • Transform.localPosition - 相对于父物体的位置
    • 受父物体变换影响(位置、旋转、缩放)
    • UI元素通常使用局部坐标
  3. 屏幕坐标 (Screen Space)

    • 以像素为单位,原点在左下角
    • Input.mousePosition 返回屏幕坐标
    • 不同分辨率下数值不同
  4. 视口坐标 (Viewport Space)

    • 归一化坐标系,范围0-1
    • (0,0)=左下角,(1,1)=右上角
    • 与分辨率无关,便于判断可见性
  5. RectTransform坐标

    • anchoredPosition - 相对于锚点的位置
    • localPosition - 相对于父节点的位置
    • pivot - 自身旋转缩放中心点
    • anchor - 相对父节点的锚定位置

坐标系统转换关系图

graph TB subgraph "Unity坐标系统转换流程" W[世界坐标
World Space
Vector3] S[屏幕坐标
Screen Space
像素 0,0 到 width,height] V[视口坐标
Viewport Space
归一化 0,0 到 1,1] U[UI坐标
Canvas/UI Space
RectTransform] R[射线
Ray] W -->|WorldToScreenPoint| S S -->|ScreenToWorldPoint
⚠️需要Z深度| W W -->|WorldToViewportPoint| V V -->|ViewportToWorldPoint
⚠️需要Z深度| W S -->|ScreenPointToRay| R R -->|Raycast| W S -->|ScreenPointToLocalPointInRectangle
需传入Canvas| U U -->|RectTransformUtility
WorldToScreenPoint| S style W fill:#e1f5e1 style S fill:#e1e5f5 style V fill:#f5e1f5 style U fill:#f5f5e1 style R fill:#ffe1e1 end

转换要点:

  • 🔴 红色警告:屏幕坐标↔世界坐标转换时必须指定Z深度
  • 🟢 最佳实践:使用射线检测(Ray)获取精确3D坐标
  • 🔵 Canvas相关:不同RenderMode需要传入不同的Camera参数

RectTransform坐标详解

RectTransform是UI元素的核心组件,理解其坐标系统对UI开发至关重要。

关键属性

csharp 复制代码
// 1. anchoredPosition - 相对于锚点的位置
rectTransform.anchoredPosition = new Vector2(100, 50);

// 2. localPosition - 相对于父节点的位置(考虑pivot)
rectTransform.localPosition = new Vector3(100, 50, 0);

// 3. position - 世界坐标位置
rectTransform.position = new Vector3(500, 300, 0);

// 4. anchorMin/anchorMax - 锚点(相对于父节点的比例)
rectTransform.anchorMin = new Vector2(0, 0);  // 左下角
rectTransform.anchorMax = new Vector2(1, 1);  // 右上角

// 5. pivot - 自身旋转缩放中心(0-1)
rectTransform.pivot = new Vector2(0.5f, 0.5f);  // 中心点

// 6. sizeDelta - 相对于锚点的尺寸
rectTransform.sizeDelta = new Vector2(200, 100);

常用锚点预设

csharp 复制代码
// 中心锚点
anchorMin = anchorMax = new Vector2(0.5f, 0.5f);

// 左上角
anchorMin = anchorMax = new Vector2(0, 1);

// 右下角
anchorMin = anchorMax = new Vector2(1, 0);

// 拉伸填充父节点
anchorMin = new Vector2(0, 0);
anchorMax = new Vector2(1, 1);
sizeDelta = Vector2.zero;

坐标转换示例

csharp 复制代码
using UnityEngine;

public class RectTransformHelper : MonoBehaviour
{
    // 世界坐标转RectTransform本地坐标
    public static Vector2 WorldToRectTransformLocal(RectTransform rectTransform, Vector3 worldPos, Camera camera)
    {
        Vector2 screenPos = camera.WorldToScreenPoint(worldPos);
        Vector2 localPos;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            rectTransform, 
            screenPos, 
            camera, 
            out localPos
        );
        return localPos;
    }
    
    // RectTransform本地坐标转世界坐标
    public static Vector3 RectTransformLocalToWorld(RectTransform rectTransform, Vector2 localPos)
    {
        // 使用TransformPoint将本地坐标转换为世界坐标
        return rectTransform.TransformPoint(localPos);
    }
}

一、3D对象 → 屏幕坐标

1.1 WorldToScreenPoint

将世界坐标转换为屏幕像素坐标。

csharp 复制代码
public class WorldToScreenExample : MonoBehaviour
{
    public Transform target3D;
    public Camera mainCamera;
    
    void Update()
    {
        // 获取3D对象的屏幕坐标
        Vector3 screenPos = mainCamera.WorldToScreenPoint(target3D.position);
        
        Debug.Log($"屏幕坐标: X={screenPos.x}, Y={screenPos.y}, Z={screenPos.z}");
        // Z值表示距离相机的深度,负值表示在相机后方
    }
}

关键点:

  • Z值 > 0:对象在相机前方
  • Z值 < 0:对象在相机后方(屏幕外)
  • 坐标系原点在屏幕左下角

1.2 WorldToViewportPoint

转换为归一化视口坐标(0-1范围)。

csharp 复制代码
Vector3 viewportPos = mainCamera.WorldToViewportPoint(target3D.position);

// 判断是否在屏幕内
bool isVisible = viewportPos.x >= 0 && viewportPos.x <= 1 && 
                 viewportPos.y >= 0 && viewportPos.y <= 1 && 
                 viewportPos.z > 0;

二、屏幕坐标 → 3D世界坐标

2.1 ScreenToWorldPoint

将屏幕坐标转换为世界坐标,必须指定Z深度

csharp 复制代码
public class ScreenToWorldExample : MonoBehaviour
{
    public Camera mainCamera;
    
    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Vector3 mousePos = Input.mousePosition;
            
            // 方法1:指定距离相机的深度
            mousePos.z = 10f; // 距离相机10单位
            Vector3 worldPos = mainCamera.ScreenToWorldPoint(mousePos);
            
            // 方法2:基于某个3D对象的深度
            Vector3 targetScreenPos = mainCamera.WorldToScreenPoint(transform.position);
            mousePos.z = targetScreenPos.z;
            Vector3 worldPosAtTargetDepth = mainCamera.ScreenToWorldPoint(mousePos);
            
            Debug.Log($"世界坐标: {worldPos}");
        }
    }
}

2.2 射线检测法(推荐)

使用射线获取精确的3D位置。

csharp 复制代码
void Update()
{
    if (Input.GetMouseButtonDown(0))
    {
        Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
        
        // 检测射线碰撞
        if (Physics.Raycast(ray, out RaycastHit hit))
        {
            Vector3 hitPoint = hit.point;
            Debug.Log($"点击位置: {hitPoint}");
            
            // 在点击位置生成物体
            Instantiate(prefab, hitPoint, Quaternion.identity);
        }
        
        // 或者指定平面检测
        Plane groundPlane = new Plane(Vector3.up, Vector3.zero);
        if (groundPlane.Raycast(ray, out float distance))
        {
            Vector3 hitPoint = ray.GetPoint(distance);
            Debug.Log($"地面点击位置: {hitPoint}");
        }
    }
}

三、UI坐标 ↔ 世界坐标

3.1 UI跟随3D对象

让UI元素(血条、名称)跟随3D角色移动。

csharp 复制代码
public class UIFollowWorld : MonoBehaviour
{
    public Transform target3D;           // 3D角色
    public RectTransform uiElement;      // UI元素
    public Canvas canvas;
    public Camera mainCamera;
    public Vector3 offset = Vector3.up * 2f; // 偏移量
    
    void LateUpdate()
    {
        // 计算3D对象的屏幕坐标
        Vector3 screenPos = mainCamera.WorldToScreenPoint(target3D.position + offset);
        
        // 判断是否在相机前方
        if (screenPos.z > 0)
        {
            // 转换为Canvas坐标
            Vector2 canvasPos;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvas.transform as RectTransform,
                new Vector2(screenPos.x, screenPos.y),
                canvas.worldCamera,
                out canvasPos
            );
            
            uiElement.anchoredPosition = canvasPos;
            uiElement.gameObject.SetActive(true);
        }
        else
        {
            // 在相机后方,隐藏UI
            uiElement.gameObject.SetActive(false);
        }
    }
}

3.2 3D对象跟随UI

让3D对象(预览模型)跟随UI拖拽。

csharp 复制代码
public class WorldFollowUI : MonoBehaviour
{
    public RectTransform uiElement;
    public Transform object3D;
    public Canvas canvas;
    public Camera mainCamera;
    public float depth = 5f; // 3D对象距离相机的深度
    
    void Update()
    {
        // 获取UI元素的屏幕坐标
        Vector2 screenPos = RectTransformUtility.WorldToScreenPoint(
            canvas.worldCamera,
            uiElement.position
        );
        
        // 转换为世界坐标
        Vector3 worldPos = mainCamera.ScreenToWorldPoint(
            new Vector3(screenPos.x, screenPos.y, depth)
        );
        
        object3D.position = worldPos;
    }
}

3.3 UI元素点击检测转3D

在UI上点击,在3D场景中执行操作。

csharp 复制代码
using UnityEngine;
using UnityEngine.EventSystems;

public class UIClick3DAction : MonoBehaviour, IPointerClickHandler
{
    public Camera mainCamera;
    public float rayDistance = 100f;
    
    public void OnPointerClick(PointerEventData eventData)
    {
        // 从UI点击位置发射射线
        Ray ray = mainCamera.ScreenPointToRay(eventData.position);
        
        if (Physics.Raycast(ray, out RaycastHit hit, rayDistance))
        {
            // 选中3D对象
            Debug.Log($"选中: {hit.collider.gameObject.name}");
            
            // 执行操作
            var selectable = hit.collider.GetComponent<ISelectable>();
            selectable?.OnSelected();
        }
    }
}

3.4 多相机场景处理

在复杂项目中,可能存在多个相机(主相机、UI相机、小地图相机等),需要正确处理坐标转换。

csharp 复制代码
using UnityEngine;
using System.Linq;

public class MultiCameraCoordinate : MonoBehaviour
{
    public Camera mainCamera;      // 主相机
    public Camera uiCamera;        // UI相机
    public Camera minimapCamera;   // 小地图相机
    
    // 获取鼠标位置对应的相机
    public Camera GetCameraUnderMouse(Vector2 mousePos)
    {
        // 按深度从大到小排序(深度越大越后渲染,优先级越高)
        Camera[] cameras = Camera.allCameras
            .OrderByDescending(c => c.depth)
            .ToArray();
        
        foreach (var cam in cameras)
        {
            // 检查相机是否启用
            if (!cam.enabled) continue;
            
            // 检查鼠标是否在相机视口内
            Rect pixelRect = cam.pixelRect;
            if (pixelRect.Contains(mousePos))
            {
                return cam;
            }
        }
        
        return Camera.main; // 默认返回主相机
    }
    
    // 示例:在正确的相机中进行射线检测
    void HandleClick()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Vector2 mousePos = Input.mousePosition;
            Camera targetCamera = GetCameraUnderMouse(mousePos);
            
            Ray ray = targetCamera.ScreenPointToRay(mousePos);
            if (Physics.Raycast(ray, out RaycastHit hit))
            {
                Debug.Log($"使用相机: {targetCamera.name}, 击中: {hit.collider.name}");
            }
        }
    }
}

多相机UI坐标转换:

csharp 复制代码
public class MultiCameraUIFollow : MonoBehaviour
{
    public Transform target3D;
    public RectTransform uiElement;
    public Canvas canvas;
    public Camera worldCamera;     // 3D世界相机
    public Camera uiCamera;        // UI相机(如果Canvas使用Camera模式)
    
    void LateUpdate()
    {
        // Step 1: 使用世界相机将3D坐标转为屏幕坐标
        Vector3 screenPos = worldCamera.WorldToScreenPoint(target3D.position);
        
        if (screenPos.z > 0)
        {
            // Step 2: 使用UI相机将屏幕坐标转为Canvas本地坐标
            Vector2 canvasPos;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvas.transform as RectTransform,
                new Vector2(screenPos.x, screenPos.y),
                canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : uiCamera,
                out canvasPos
            );
            
            uiElement.anchoredPosition = canvasPos;
            uiElement.gameObject.SetActive(true);
        }
        else
        {
            uiElement.gameObject.SetActive(false);
        }
    }
}

3.5 移动端触摸处理

移动设备需要使用触摸输入,并适配不同屏幕比例。

csharp 复制代码
using UnityEngine;

public class MobileTouchHandler : MonoBehaviour
{
    public Camera mainCamera;
    
    void Update()
    {
        Vector2 inputPosition = GetInputPosition();
        
        if (inputPosition != Vector2.zero)
        {
            HandleInput(inputPosition);
        }
    }
    
    // 兼容PC和移动端的输入获取
    Vector2 GetInputPosition()
    {
        #if UNITY_EDITOR || UNITY_STANDALONE
            // PC端:使用鼠标
            if (Input.GetMouseButton(0))
                return Input.mousePosition;
        #elif UNITY_IOS || UNITY_ANDROID
            // 移动端:使用触摸
            if (Input.touchCount > 0)
            {
                Touch touch = Input.GetTouch(0);
                return touch.position;
            }
        #endif
        return Vector2.zero;
    }
    
    // 处理输入
    void HandleInput(Vector2 screenPos)
    {
        Ray ray = mainCamera.ScreenPointToRay(screenPos);
        
        if (Physics.Raycast(ray, out RaycastHit hit))
        {
            Debug.Log($"点击: {hit.collider.name}");
            // 执行相应操作
        }
    }
}

多点触摸处理:

csharp 复制代码
public class MultiTouchHandler : MonoBehaviour
{
    public Camera mainCamera;
    
    void Update()
    {
        // 处理所有触摸点
        for (int i = 0; i < Input.touchCount; i++)
        {
            Touch touch = Input.GetTouch(i);
            
            switch (touch.phase)
            {
                case TouchPhase.Began:
                    OnTouchBegan(touch.position);
                    break;
                    
                case TouchPhase.Moved:
                    OnTouchMoved(touch.position, touch.deltaPosition);
                    break;
                    
                case TouchPhase.Ended:
                    OnTouchEnded(touch.position);
                    break;
            }
        }
    }
    
    void OnTouchBegan(Vector2 screenPos)
    {
        Ray ray = mainCamera.ScreenPointToRay(screenPos);
        if (Physics.Raycast(ray, out RaycastHit hit))
        {
            Debug.Log($"触摸开始: {hit.collider.name}");
        }
    }
    
    void OnTouchMoved(Vector2 screenPos, Vector2 deltaPosition)
    {
        // 处理拖拽
        Debug.Log($"拖拽偏移: {deltaPosition}");
    }
    
    void OnTouchEnded(Vector2 screenPos)
    {
        Debug.Log("触摸结束");
    }
}

3.6 UI边界限制

防止UI元素超出屏幕边界,保持在可视区域内。

csharp 复制代码
public class UIBoundaryClamp : MonoBehaviour
{
    public RectTransform uiElement;
    public Canvas canvas;
    public float padding = 10f; // 边距(像素)
    
    void LateUpdate()
    {
        ClampToScreen();
    }
    
    void ClampToScreen()
    {
        Vector3 pos = uiElement.localPosition;
        RectTransform canvasRect = canvas.transform as RectTransform;
        
        // 获取UI元素和Canvas的尺寸
        Vector2 elementSize = uiElement.sizeDelta;
        Vector2 canvasSize = canvasRect.sizeDelta;
        
        // 考虑Canvas缩放
        float canvasScale = canvas.transform.localScale.x;
        
        // 计算边界(本地坐标)
        float minX = -canvasSize.x / 2 + elementSize.x / 2 + padding / canvasScale;
        float maxX = canvasSize.x / 2 - elementSize.x / 2 - padding / canvasScale;
        float minY = -canvasSize.y / 2 + elementSize.y / 2 + padding / canvasScale;
        float maxY = canvasSize.y / 2 - elementSize.y / 2 - padding / canvasScale;
        
        // 限制在边界内
        pos.x = Mathf.Clamp(pos.x, minX, maxX);
        pos.y = Mathf.Clamp(pos.y, minY, maxY);
        
        uiElement.localPosition = pos;
    }
    
    // 检查UI是否在屏幕内
    public bool IsUIInScreen()
    {
        Vector3[] corners = new Vector3[4];
        uiElement.GetWorldCorners(corners);
        
        Rect screenRect = new Rect(0, 0, Screen.width, Screen.height);
        
        foreach (Vector3 corner in corners)
        {
            if (!screenRect.Contains(corner))
                return false;
        }
        
        return true;
    }
}

智能位置调整(避免遮挡):

csharp 复制代码
public class SmartUIPositioning : MonoBehaviour
{
    public RectTransform tooltip;
    public Canvas canvas;
    public Vector2 offset = new Vector2(10, 10);
    
    public void ShowTooltip(Vector2 screenPos)
    {
        Vector2 canvasPos;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            canvas.transform as RectTransform,
            screenPos,
            canvas.worldCamera,
            out canvasPos
        );
        
        // 智能调整位置避免超出屏幕
        canvasPos = AdjustPositionToFitScreen(canvasPos);
        tooltip.localPosition = canvasPos;
        tooltip.gameObject.SetActive(true);
    }
    
    Vector2 AdjustPositionToFitScreen(Vector2 position)
    {
        RectTransform canvasRect = canvas.transform as RectTransform;
        Vector2 tooltipSize = tooltip.sizeDelta;
        Vector2 canvasSize = canvasRect.sizeDelta;
        
        Vector2 adjustedPos = position + offset;
        
        // 右边界检查
        if (adjustedPos.x + tooltipSize.x / 2 > canvasSize.x / 2)
        {
            adjustedPos.x = position.x - offset.x - tooltipSize.x;
        }
        
        // 上边界检查
        if (adjustedPos.y + tooltipSize.y / 2 > canvasSize.y / 2)
        {
            adjustedPos.y = position.y - offset.y - tooltipSize.y;
        }
        
        // 左边界检查
        if (adjustedPos.x - tooltipSize.x / 2 < -canvasSize.x / 2)
        {
            adjustedPos.x = -canvasSize.x / 2 + tooltipSize.x / 2;
        }
        
        // 下边界检查
        if (adjustedPos.y - tooltipSize.y / 2 < -canvasSize.y / 2)
        {
            adjustedPos.y = -canvasSize.y / 2 + tooltipSize.y / 2;
        }
        
        return adjustedPos;
    }
}

四、Canvas坐标系统详解

4.1 三种渲染模式

csharp 复制代码
// Screen Space - Overlay: 直接覆盖在屏幕上
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
// camera参数传null

// Screen Space - Camera: 固定距离相机
canvas.renderMode = RenderMode.ScreenSpaceCamera;
canvas.worldCamera = mainCamera;
// camera参数传worldCamera

// World Space: 3D世界中的Canvas
canvas.renderMode = RenderMode.WorldSpace;
// camera参数传场景相机

五、调试与可视化工具

5.1 坐标调试器

实时显示各种坐标信息,方便调试。

csharp 复制代码
using UnityEngine;

public class CoordinateDebugger : MonoBehaviour
{
    public Camera mainCamera;
    public Transform target;
    public bool showDebugInfo = true;
    public bool drawGizmos = true;
    
    private GUIStyle labelStyle;
    
    void Start()
    {
        // 设置GUI样式
        labelStyle = new GUIStyle();
        labelStyle.fontSize = 14;
        labelStyle.normal.textColor = Color.white;
        labelStyle.alignment = TextAnchor.UpperLeft;
    }
    
    void OnDrawGizmos()
    {
        if (!drawGizmos || !target || !mainCamera) return;
        
        // 绘制世界坐标点
        Gizmos.color = Color.red;
        Gizmos.DrawSphere(target.position, 0.2f);
        
        // 绘制从相机到物体的射线
        Gizmos.color = Color.yellow;
        Gizmos.DrawLine(mainCamera.transform.position, target.position);
        
        // 绘制坐标轴
        Gizmos.color = Color.red;
        Gizmos.DrawRay(target.position, Vector3.right);
        Gizmos.color = Color.green;
        Gizmos.DrawRay(target.position, Vector3.up);
        Gizmos.color = Color.blue;
        Gizmos.DrawRay(target.position, Vector3.forward);
    }
    
    void OnGUI()
    {
        if (!showDebugInfo || !target || !mainCamera) return;
        
        // 计算各种坐标
        Vector3 worldPos = target.position;
        Vector3 screenPos = mainCamera.WorldToScreenPoint(worldPos);
        Vector3 viewportPos = mainCamera.WorldToViewportPoint(worldPos);
        
        // 判断可见性
        bool isVisible = viewportPos.z > 0 && 
                        viewportPos.x >= 0 && viewportPos.x <= 1 &&
                        viewportPos.y >= 0 && viewportPos.y <= 1;
        
        // 显示信息
        GUILayout.BeginArea(new Rect(10, 10, 400, 300));
        GUILayout.BeginVertical("box");
        
        GUILayout.Label("=== 坐标调试信息 ===", labelStyle);
        GUILayout.Space(10);
        
        GUILayout.Label($"世界坐标: {worldPos}", labelStyle);
        GUILayout.Label($"屏幕坐标: X={screenPos.x:F1}, Y={screenPos.y:F1}, Z={screenPos.z:F1}", labelStyle);
        GUILayout.Label($"视口坐标: X={viewportPos.x:F3}, Y={viewportPos.y:F3}, Z={viewportPos.z:F3}", labelStyle);
        GUILayout.Space(5);
        
        GUILayout.Label($"是否可见: {(isVisible ? "✓ 是" : "✗ 否")}", labelStyle);
        GUILayout.Label($"距相机距离: {Vector3.Distance(mainCamera.transform.position, worldPos):F2}m", labelStyle);
        
        // 鼠标信息
        GUILayout.Space(10);
        Vector3 mousePos = Input.mousePosition;
        GUILayout.Label($"鼠标屏幕坐标: ({mousePos.x:F0}, {mousePos.y:F0})", labelStyle);
        
        Vector3 mouseViewport = mainCamera.ScreenToViewportPoint(mousePos);
        GUILayout.Label($"鼠标视口坐标: ({mouseViewport.x:F3}, {mouseViewport.y:F3})", labelStyle);
        
        GUILayout.EndVertical();
        GUILayout.EndArea();
    }
}

5.2 射线可视化工具

可视化射线检测过程。

csharp 复制代码
using UnityEngine;

public class RaycastVisualizer : MonoBehaviour
{
    public Camera mainCamera;
    public float rayLength = 100f;
    public Color hitColor = Color.green;
    public Color missColor = Color.red;
    public float hitPointSize = 0.2f;
    
    private Vector3? lastHitPoint;
    private bool lastRaycastHit;
    
    void Update()
    {
        if (Input.GetMouseButton(0))
        {
            Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
            
            if (Physics.Raycast(ray, out RaycastHit hit, rayLength))
            {
                lastHitPoint = hit.point;
                lastRaycastHit = true;
                
                Debug.DrawRay(ray.origin, ray.direction * hit.distance, hitColor, 0.1f);
            }
            else
            {
                lastRaycastHit = false;
                lastHitPoint = null;
                
                Debug.DrawRay(ray.origin, ray.direction * rayLength, missColor, 0.1f);
            }
        }
    }
    
    void OnDrawGizmos()
    {
        if (lastHitPoint.HasValue && lastRaycastHit)
        {
            Gizmos.color = hitColor;
            Gizmos.DrawSphere(lastHitPoint.Value, hitPointSize);
            
            // 绘制法线
            Gizmos.color = Color.blue;
            Gizmos.DrawRay(lastHitPoint.Value, Vector3.up * 0.5f);
        }
    }
}

5.3 UI范围可视化

显示UI元素的边界和Canvas范围。

csharp 复制代码
using UnityEngine;
using UnityEngine.UI;

[ExecuteInEditMode]
public class UIBoundsVisualizer : MonoBehaviour
{
    public RectTransform uiElement;
    public Canvas canvas;
    public Color boundsColor = Color.cyan;
    public Color canvasColor = Color.yellow;
    
    void OnDrawGizmos()
    {
        if (!uiElement || !canvas) return;
        
        // 绘制Canvas边界
        DrawRectBounds(canvas.transform as RectTransform, canvasColor);
        
        // 绘制UI元素边界
        DrawRectBounds(uiElement, boundsColor);
    }
    
    void DrawRectBounds(RectTransform rectTransform, Color color)
    {
        if (rectTransform == null) return;
        
        Vector3[] corners = new Vector3[4];
        rectTransform.GetWorldCorners(corners);
        
        Gizmos.color = color;
        
        // 绘制边框
        for (int i = 0; i < 4; i++)
        {
            Gizmos.DrawLine(corners[i], corners[(i + 1) % 4]);
        }
        
        // 绘制对角线
        Gizmos.color = new Color(color.r, color.g, color.b, 0.3f);
        Gizmos.DrawLine(corners[0], corners[2]);
        Gizmos.DrawLine(corners[1], corners[3]);
    }
}

5.4 性能分析工具

六、常见陷阱与错误

在使用Unity坐标转换时,开发者经常会遇到一些隐蔽的陷阱。本章列举最常见的错误及其正确做法。

6.1 Canvas RenderMode参数错误 ⚠️

❌ 错误做法:

csharp 复制代码
// 在Overlay模式下传入相机
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
    rect, 
    screenPos, 
    mainCamera,  // ❌ 错误!Overlay模式应该传null
    out localPos
);

✅ 正确做法:

csharp 复制代码
// 根据Canvas模式传入正确的相机参数
Camera cam = canvas.renderMode == RenderMode.ScreenSpaceOverlay 
    ? null 
    : canvas.worldCamera;

RectTransformUtility.ScreenPointToLocalPointInRectangle(
    rect, 
    screenPos, 
    cam,  // ✓ 正确
    out localPos
);

6.2 忘记检查Z值 ⚠️

❌ 错误做法:

csharp 复制代码
Vector3 screenPos = camera.WorldToScreenPoint(worldPos);
// 没检查Z值,对象在背后也会显示UI
uiElement.position = screenPos;

✅ 正确做法:

csharp 复制代码
Vector3 screenPos = camera.WorldToScreenPoint(worldPos);

// 必须检查Z值
if (screenPos.z > 0)
{
    // 在相机前方,显示UI
    UpdateUIPosition(screenPos);
}
else
{
    // 在相机后方,隐藏UI
    uiElement.gameObject.SetActive(false);
}

6.3 每帧查找Camera.main ⚠️

❌ 错误做法:

csharp 复制代码
void Update()
{
    // Camera.main每次调用都会查找,性能很差!
    var pos = Camera.main.WorldToScreenPoint(transform.position);
}

✅ 正确做法:

csharp 复制代码
private Camera mainCamera;

void Start()
{
    // 缓存引用
    mainCamera = Camera.main;
}

void Update()
{
    var pos = mainCamera.WorldToScreenPoint(transform.position);
}

6.4 ScreenToWorldPoint缺少Z深度 ⚠️

❌ 错误做法:

csharp 复制代码
Vector3 mousePos = Input.mousePosition;
// 没有设置Z值,会使用0,导致结果错误
Vector3 worldPos = camera.ScreenToWorldPoint(mousePos);

✅ 正确做法:

csharp 复制代码
Vector3 mousePos = Input.mousePosition;
mousePos.z = 10f; // 必须指定Z深度
Vector3 worldPos = camera.ScreenToWorldPoint(mousePos);

// 或使用射线检测(更好的方法)
Ray ray = camera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
    Vector3 worldPos = hit.point;
}

6.5 混淆本地坐标和世界坐标 ⚠️

❌ 错误做法:

csharp 复制代码
// uiElement.position 是世界坐标
// 但ScreenPointToLocalPointInRectangle返回的是本地坐标
Vector2 localPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
    canvas.transform as RectTransform,
    screenPos,
    camera,
    out localPos
);

uiElement.position = localPos; // ❌ 类型不匹配!

✅ 正确做法:

csharp 复制代码
Vector2 localPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
    canvas.transform as RectTransform,
    screenPos,
    camera,
    out localPos
);

// 使用anchoredPosition而不是localPosition(最佳实践)
uiElement.anchoredPosition = localPos; // ✓ 正确

6.6 Update vs LateUpdate ⚠️

❌ 错误做法:

csharp 复制代码
// 在Update中更新UI位置
void Update()
{
    UpdateUIPosition();
}
// 可能出现抖动,因为3D对象可能在LateUpdate中移动

✅ 正确做法:

csharp 复制代码
// 使用LateUpdate确保在所有Update之后执行
void LateUpdate()
{
    UpdateUIPosition();
}

6.7 忽略Canvas Scaler影响 ⚠️

❌ 错误做法:

csharp 复制代码
// 直接使用屏幕像素
uiElement.anchoredPosition = new Vector2(100, 100);
// 在不同分辨率下位置会错乱

✅ 正确做法:

csharp 复制代码
// 使用RectTransformUtility考虑Canvas Scaler
Vector2 localPos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
    canvas.transform as RectTransform,
    new Vector2(100, 100),
    canvas.worldCamera,
    out localPos
);
uiElement.localPosition = localPos;

6.8 射线检测忽略UI层 ⚠️

❌ 错误做法:

csharp 复制代码
Ray ray = camera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
    // 点击UI时也会触发3D物体检测
    SelectObject(hit.collider.gameObject);
}

✅ 正确做法:

csharp 复制代码
using UnityEngine.EventSystems;

// 检查是否点击在UI上
if (EventSystem.current.IsPointerOverGameObject())
{
    return; // 点击在UI上,不处理3D对象
}

Ray ray = camera.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
    SelectObject(hit.collider.gameObject);
}

6.9 Vector3和Vector2混用 ⚠️

❌ 错误做法:

csharp 复制代码
Vector3 screenPos = camera.WorldToScreenPoint(worldPos);
// 隐式转换丢失了Z信息
Vector2 pos2D = screenPos;
// 后续使用pos2D会丢失深度信息

✅ 正确做法:

csharp 复制代码
Vector3 screenPos = camera.WorldToScreenPoint(worldPos);

// 先检查Z值
if (screenPos.z > 0)
{
    // 明确转换,了解丢失了什么
    Vector2 pos2D = new Vector2(screenPos.x, screenPos.y);
    // 使用pos2D
}

6.10 多相机场景使用错误的相机 ⚠️

❌ 错误做法:

csharp 复制代码
// 场景中有多个相机,使用了错误的相机进行转换
Vector3 screenPos = Camera.main.WorldToScreenPoint(worldPos);
// 如果worldPos是被另一个相机渲染的,结果会错误

✅ 正确做法:

csharp 复制代码
// 使用渲染该对象的相机
public Camera renderingCamera; // 在Inspector中指定正确的相机

Vector3 screenPos = renderingCamera.WorldToScreenPoint(worldPos);

6.11 坐标转换时机错误 ⚠️

❌ 错误做法:

csharp 复制代码
void Awake()
{
    // UI可能还未初始化完成
    UpdateUIPosition();
}

✅ 正确做法:

csharp 复制代码
void Start()
{
    // 确保所有组件都已初始化
    UpdateUIPosition();
}

// 或者使用协程等待一帧
IEnumerator Start()
{
    yield return null; // 等待下一帧
    UpdateUIPosition();
}

6.12 忘记处理屏幕旋转 ⚠️

❌ 错误做法:

csharp 复制代码
// 移动设备屏幕旋转后坐标系统变化,但代码未处理
void Update()
{
    Vector3 screenPos = camera.WorldToScreenPoint(worldPos);
    // 旋转后可能出现问题
}

✅ 正确做法:

csharp 复制代码
using UnityEngine;

private ScreenOrientation lastOrientation;

void Update()
{
    // 检测屏幕旋转
    if (Screen.orientation != lastOrientation)
    {
        lastOrientation = Screen.orientation;
        OnOrientationChanged();
    }
    
    UpdateCoordinates();
}

void OnOrientationChanged()
{
    // 重新计算Canvas Scaler等
    Debug.Log($"屏幕旋转: {Screen.orientation}");
}

6.13 常见错误检查清单

使用此清单避免常见陷阱:

csharp 复制代码
public class CoordinateConversionValidator : MonoBehaviour
{
    public static bool ValidateWorldToScreen(Camera camera, Vector3 worldPos, out Vector3 screenPos)
    {
        // ✓ 检查1: 相机是否存在
        if (camera == null)
        {
            Debug.LogError("相机为null!");
            screenPos = Vector3.zero;
            return false;
        }
        
        // ✓ 检查2: 相机是否启用
        if (!camera.enabled)
        {
            Debug.LogWarning("相机未启用!");
        }
        
        // 执行转换
        screenPos = camera.WorldToScreenPoint(worldPos);
        
        // ✓ 检查3: Z值验证
        if (screenPos.z <= 0)
        {
            Debug.LogWarning($"对象在相机后方!Z={screenPos.z}");
            return false;
        }
        
        // ✓ 检查4: 屏幕边界验证
        if (screenPos.x < 0 || screenPos.x > Screen.width ||
            screenPos.y < 0 || screenPos.y > Screen.height)
        {
            Debug.LogWarning($"坐标超出屏幕范围!({screenPos.x}, {screenPos.y})");
            return false;
        }
        
        return true;
    }
    
    public static bool ValidateUIConversion(Canvas canvas, Vector2 screenPos, out Vector2 localPos)
    {
        localPos = Vector2.zero;
        
        // ✓ 检查1: Canvas是否存在
        if (canvas == null)
        {
            Debug.LogError("Canvas为null!");
            return false;
        }
        
        // ✓ 检查2: RenderMode验证
        Camera cam = canvas.renderMode == RenderMode.ScreenSpaceOverlay 
            ? null 
            : canvas.worldCamera;
        
        if (canvas.renderMode != RenderMode.ScreenSpaceOverlay && cam == null)
        {
            Debug.LogError("Canvas设置了Camera模式但worldCamera为null!");
            return false;
        }
        
        // 执行转换
        RectTransform canvasRect = canvas.transform as RectTransform;
        return RectTransformUtility.ScreenPointToLocalPointInRectangle(
            canvasRect,
            screenPos,
            cam,
            out localPos
        );
    }
}

使用验证器:

csharp 复制代码
void UpdateUIPosition()
{
    if (CoordinateConversionValidator.ValidateWorldToScreen(
        mainCamera, 
        target.position, 
        out Vector3 screenPos))
    {
        if (CoordinateConversionValidator.ValidateUIConversion(
            canvas,
            screenPos,
            out Vector2 localPos))
        {
            uiElement.localPosition = localPos;
        }
    }
}
csharp 复制代码
using UnityEngine;
using System.Diagnostics;

public class CoordinatePerformanceProfiler : MonoBehaviour
{
    private Stopwatch stopwatch = new Stopwatch();
    private int frameCount = 0;
    private double totalTime = 0;
    
    void Update()
    {
        ProfileCoordinateConversions();
    }
    
    void ProfileCoordinateConversions()
    {
        Camera cam = Camera.main;
        Vector3 worldPos = transform.position;
        
        stopwatch.Restart();
        
        // 测试WorldToScreenPoint性能
        for (int i = 0; i < 100; i++)
        {
            Vector3 screenPos = cam.WorldToScreenPoint(worldPos);
        }
        
        stopwatch.Stop();
        totalTime += stopwatch.Elapsed.TotalMilliseconds;
        frameCount++;
        
        if (frameCount >= 60)
        {
            double avgTime = totalTime / frameCount;
            UnityEngine.Debug.Log($"平均每帧100次WorldToScreenPoint: {avgTime:F3}ms");
            
            frameCount = 0;
            totalTime = 0;
        }
    }
}

5.5 交互式调试面板

csharp 复制代码
using UnityEngine;
using UnityEngine.UI;

public class CoordinateDebugPanel : MonoBehaviour
{
    public Camera mainCamera;
    public Transform target;
    
    [Header("UI References")]
    public Text worldPosText;
    public Text screenPosText;
    public Text viewportPosText;
    public Text distanceText;
    public Toggle visibilityToggle;
    
    void Update()
    {
        if (!target || !mainCamera) return;
        
        UpdateDebugInfo();
    }
    
    void UpdateDebugInfo()
    {
        Vector3 worldPos = target.position;
        Vector3 screenPos = mainCamera.WorldToScreenPoint(worldPos);
        Vector3 viewportPos = mainCamera.WorldToViewportPoint(worldPos);
        
        // 更新文本
        if (worldPosText)
            worldPosText.text = $"世界: {worldPos}";
        
        if (screenPosText)
            screenPosText.text = $"屏幕: ({screenPos.x:F0}, {screenPos.y:F0}, {screenPos.z:F2})";
        
        if (viewportPosText)
            viewportPosText.text = $"视口: ({viewportPos.x:F3}, {viewportPos.y:F3})";
        
        if (distanceText)
        {
            float distance = Vector3.Distance(mainCamera.transform.position, worldPos);
            distanceText.text = $"距离: {distance:F2}m";
        }
        
        // 更新可见性
        if (visibilityToggle)
        {
            bool isVisible = viewportPos.z > 0 && 
                            viewportPos.x >= 0 && viewportPos.x <= 1 &&
                            viewportPos.y >= 0 && viewportPos.y <= 1;
            visibilityToggle.isOn = isVisible;
        }
    }
}

4.2 Canvas Scaler详细配置

Canvas Scaler组件用于处理不同分辨率和屏幕比例的UI缩放。

csharp 复制代码
using UnityEngine;
using UnityEngine.UI;

public class CanvasScalerSetup : MonoBehaviour
{
    public Canvas canvas;
    public CanvasScaler canvasScaler;
    
    void Start()
    {
        SetupCanvasScaler();
    }
    
    void SetupCanvasScaler()
    {
        if (!canvasScaler)
        {
            canvasScaler = canvas.GetComponent<CanvasScaler>();
            if (!canvasScaler)
                canvasScaler = canvas.gameObject.AddComponent<CanvasScaler>();
        }
        
        // 设置缩放模式
        canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
        
        // 设置参考分辨率
        canvasScaler.referenceResolution = new Vector2(1920, 1080);
        
        // 根据屏幕比例智能调整
        float aspectRatio = (float)Screen.width / Screen.height;
        
        if (aspectRatio > 1.7f) // 宽屏 (如21:9, 2560x1080)
        {
            canvasScaler.matchWidthOrHeight = 1f; // 匹配高度
            Debug.Log("检测到宽屏,匹配高度");
        }
        else if (aspectRatio < 1.5f) // 窄屏或方形 (如4:3, iPad)
        {
            canvasScaler.matchWidthOrHeight = 0f; // 匹配宽度
            Debug.Log("检测到窄屏,匹配宽度");
        }
        else // 标准16:9
        {
            canvasScaler.matchWidthOrHeight = 0.5f; // 居中权重
            Debug.Log("标准16:9比例");
        }
        
        // 设置物理单位模式(可选)
        canvasScaler.physicalUnit = CanvasScaler.Unit.Points;
        canvasScaler.fallbackScreenDPI = 96f;
        canvasScaler.defaultSpriteDPI = 96f;
    }
    
    // 运行时动态调整
    void OnRectTransformDimensionsChange()
    {
        SetupCanvasScaler();
    }
}

三种缩放模式详解:

  1. Constant Pixel Size(恒定像素大小)
csharp 复制代码
canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize;
canvasScaler.scaleFactor = 1f; // 全局缩放因子
canvasScaler.referencePixelsPerUnit = 100f; // Sprite像素比
  • 适用场景:像素完美的游戏(如像素风格游戏)
  • 优点:UI大小固定,不会模糊
  • 缺点:在不同分辨率下显示大小不一致
  1. Scale With Screen Size(随屏幕大小缩放) ⭐ 推荐
csharp 复制代码
canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
canvasScaler.referenceResolution = new Vector2(1920, 1080);
canvasScaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
canvasScaler.matchWidthOrHeight = 0.5f; // 0=宽度, 1=高度, 0.5=平衡
  • 适用场景:大多数移动和PC游戏
  • 优点:自适应不同分辨率
  • 缺点:可能产生轻微缩放模糊
  1. Constant Physical Size(恒定物理大小)
csharp 复制代码
canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPhysicalSize;
canvasScaler.physicalUnit = CanvasScaler.Unit.Centimeters;
canvasScaler.fallbackScreenDPI = 96f;
  • 适用场景:需要真实物理尺寸的应用
  • 优点:跨设备物理大小一致
  • 缺点:依赖设备DPI信息

不同设备的最佳实践:

csharp 复制代码
public class AdaptiveCanvasScaler : MonoBehaviour
{
    public CanvasScaler canvasScaler;
    
    void Start()
    {
        SetupForCurrentDevice();
    }
    
    void SetupForCurrentDevice()
    {
        canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
        
        #if UNITY_ANDROID || UNITY_IOS
            // 移动设备设置
            SetupForMobile();
        #elif UNITY_STANDALONE
            // PC设置
            SetupForPC();
        #elif UNITY_WEBGL
            // WebGL设置
            SetupForWeb();
        #endif
    }
    
    void SetupForMobile()
    {
        // 移动设备通常是竖屏或横屏固定
        bool isPortrait = Screen.height > Screen.width;
        
        if (isPortrait)
        {
            canvasScaler.referenceResolution = new Vector2(1080, 1920);
            canvasScaler.matchWidthOrHeight = 0f; // 匹配宽度
        }
        else
        {
            canvasScaler.referenceResolution = new Vector2(1920, 1080);
            canvasScaler.matchWidthOrHeight = 1f; // 匹配高度
        }
        
        Debug.Log($"移动设备配置: {(isPortrait ? "竖屏" : "横屏")}");
    }
    
    void SetupForPC()
    {
        // PC通常是横屏,支持多种分辨率
        canvasScaler.referenceResolution = new Vector2(1920, 1080);
        
        float aspect = (float)Screen.width / Screen.height;
        
        // 超宽屏
        if (aspect >= 2.0f)
        {
            canvasScaler.matchWidthOrHeight = 1f;
        }
        // 标准宽屏
        else if (aspect >= 1.5f)
        {
            canvasScaler.matchWidthOrHeight = 0.5f;
        }
        // 接近方形
        else
        {
            canvasScaler.matchWidthOrHeight = 0f;
        }
    }
    
    void SetupForWeb()
    {
        // WebGL可能运行在各种浏览器窗口中
        canvasScaler.referenceResolution = new Vector2(1920, 1080);
        canvasScaler.matchWidthOrHeight = 0.5f; // 平衡模式
    }
}

常见分辨率适配表:

设备类型 分辨率 参考分辨率 matchWidthOrHeight
iPhone (竖屏) 1080x1920 1080x1920 0 (宽度)
iPhone (横屏) 1920x1080 1920x1080 1 (高度)
iPad 2048x2732 1536x2048 0.5 (平衡)
Android 手机 1080x2340 1080x1920 0 (宽度)
PC 1080p 1920x1080 1920x1080 0.5 (平衡)
PC 1440p 2560x1440 1920x1080 0.5 (平衡)
PC 4K 3840x2160 1920x1080 0.5 (平衡)
超宽屏 21:9 2560x1080 1920x1080 1 (高度)

4.3 ScreenPointToLocalPointInRectangle

将屏幕坐标转换为RectTransform的本地坐标。

csharp 复制代码
using UnityEngine;
using UnityEngine.EventSystems;

public class DragUI : MonoBehaviour, IDragHandler
{
    public Canvas canvas;
    private RectTransform rectTransform;
    
    void Start()
    {
        rectTransform = GetComponent<RectTransform>();
    }
    
    public void OnDrag(PointerEventData eventData)
    {
        Vector2 localPoint;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            canvas.transform as RectTransform,
            eventData.position,
            canvas.worldCamera,
            out localPoint
        );
        
        rectTransform.localPosition = localPoint;
    }
}

七、实战应用场景

6.1 小地图系统

csharp 复制代码
public class Minimap : MonoBehaviour
{
    public RectTransform minimapRect;
    public Transform player;
    public RectTransform playerIcon;
    public float mapScale = 0.1f; // 世界单位到UI像素的比例
    
    void Update()
    {
        // 玩家世界坐标转小地图坐标
        Vector2 mapPos = new Vector2(
            player.position.x * mapScale,
            player.position.z * mapScale
        );
        
        playerIcon.anchoredPosition = mapPos;
    }
}

6.2 3D物体提示框

csharp 复制代码
public class ObjectTooltip : MonoBehaviour
{
    public RectTransform tooltip;
    public Canvas canvas;
    public Camera mainCamera;
    
    void OnMouseEnter()
    {
        // 显示提示框
        tooltip.gameObject.SetActive(true);
        UpdateTooltipPosition();
    }
    
    void OnMouseExit()
    {
        tooltip.gameObject.SetActive(false);
    }
    
    void Update()
    {
        if (tooltip.gameObject.activeSelf)
        {
            UpdateTooltipPosition();
        }
    }
    
    void UpdateTooltipPosition()
    {
        Vector3 screenPos = mainCamera.WorldToScreenPoint(transform.position);
        
        Vector2 canvasPos;
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            canvas.transform as RectTransform,
            new Vector2(screenPos.x, screenPos.y),
            canvas.worldCamera,
            out canvasPos
        );
        
        // 添加偏移避免遮挡
        tooltip.localPosition = canvasPos + new Vector2(50, 50);
    }
}

6.3 拖拽生成3D物体

csharp 复制代码
using UnityEngine;
using UnityEngine.EventSystems;

public class DragSpawn3D : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    public GameObject prefab3D;
    public Camera mainCamera;
    public float spawnHeight = 0f;
    
    private GameObject previewObject;
    
    public void OnBeginDrag(PointerEventData eventData)
    {
        // 创建预览对象
        previewObject = Instantiate(prefab3D);
        UpdatePreviewPosition(eventData.position);
    }
    
    public void OnDrag(PointerEventData eventData)
    {
        UpdatePreviewPosition(eventData.position);
    }
    
    public void OnEndDrag(PointerEventData eventData)
    {
        // 确认放置位置
        Ray ray = mainCamera.ScreenPointToRay(eventData.position);
        Plane groundPlane = new Plane(Vector3.up, new Vector3(0, spawnHeight, 0));
        
        if (groundPlane.Raycast(ray, out float distance))
        {
            Vector3 spawnPos = ray.GetPoint(distance);
            previewObject.transform.position = spawnPos;
            // 最终确认生成
        }
        else
        {
            Destroy(previewObject);
        }
    }
    
    void UpdatePreviewPosition(Vector2 screenPos)
    {
        Ray ray = mainCamera.ScreenPointToRay(screenPos);
        Plane groundPlane = new Plane(Vector3.up, new Vector3(0, spawnHeight, 0));
        
        if (groundPlane.Raycast(ray, out float distance))
        {
            previewObject.transform.position = ray.GetPoint(distance);
        }
    }
}

八、常见问题与解决方案

7.1 UI坐标转换不准确

原因: Canvas设置不正确。

csharp 复制代码
// 确保正确设置
if (canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
    // Overlay模式,camera传null
    RectTransformUtility.ScreenPointToLocalPointInRectangle(
        parentRect, screenPos, null, out localPos);
}
else
{
    // Camera模式,传入worldCamera
    RectTransformUtility.ScreenPointToLocalPointInRectangle(
        parentRect, screenPos, canvas.worldCamera, out localPos);
}

7.2 物体在相机后方仍显示UI

解决: 检查Z值。

csharp 复制代码
Vector3 screenPos = camera.WorldToScreenPoint(worldPos);
if (screenPos.z > 0) // 必须检查Z值
{
    // 在相机前方,显示UI
}

7.3 高分辨率屏幕UI错位

解决: 使用Canvas Scaler。

csharp 复制代码
// Canvas Scaler设置
canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
canvasScaler.referenceResolution = new Vector2(1920, 1080);
canvasScaler.matchWidthOrHeight = 0.5f;

7.4 3D物体跟随UI有延迟

解决: 使用LateUpdate()。

csharp 复制代码
void LateUpdate() // 而非Update()
{
    // 在所有Update之后执行,保证同步
    UpdateUIPosition();
}

九、性能优化建议

8.1 基础优化技巧

csharp 复制代码
public class OptimizedUIFollow : MonoBehaviour
{
    private Camera mainCamera;
    private RectTransform canvasRect;
    private Vector3 cachedScreenPos;
    private Vector2 cachedCanvasPos;
    
    void Start()
    {
        // 缓存引用,避免每帧查找
        mainCamera = Camera.main;
        canvasRect = canvas.transform as RectTransform;
    }
    
    void LateUpdate()
    {
        // 仅在可见时更新
        if (!renderer.isVisible) return;
        
        // 减少GC:复用Vector3
        cachedScreenPos = mainCamera.WorldToScreenPoint(target.position);
        
        // 距离剔除:太远不显示UI
        if (cachedScreenPos.z > maxDistance) return;
        
        // 转换坐标
        UpdateUIPosition(cachedScreenPos);
    }
    
    void UpdateUIPosition(Vector3 screenPos)
    {
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            canvasRect,
            new Vector2(screenPos.x, screenPos.y),
            canvas.worldCamera,
            out cachedCanvasPos
        );
        
        uiElement.localPosition = cachedCanvasPos;
    }
}

8.2 对象池优化

当需要频繁创建和销毁UI元素时,使用对象池可以显著提升性能。

通用对象池实现:

csharp 复制代码
using UnityEngine;
using System.Collections.Generic;

public class ObjectPool<T> where T : Component
{
    private Queue<T> pool = new Queue<T>();
    private T prefab;
    private Transform parent;
    private int initialSize;
    
    public ObjectPool(T prefab, Transform parent, int initialSize = 10)
    {
        this.prefab = prefab;
        this.parent = parent;
        this.initialSize = initialSize;
        
        // 预创建对象
        for (int i = 0; i < initialSize; i++)
        {
            CreateNewObject();
        }
    }
    
    private T CreateNewObject()
    {
        T obj = Object.Instantiate(prefab, parent);
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
        return obj;
    }
    
    public T Get()
    {
        if (pool.Count == 0)
        {
            CreateNewObject();
        }
        
        T obj = pool.Dequeue();
        obj.gameObject.SetActive(true);
        return obj;
    }
    
    public void Release(T obj)
    {
        obj.gameObject.SetActive(false);
        pool.Enqueue(obj);
    }
    
    public void Clear()
    {
        while (pool.Count > 0)
        {
            T obj = pool.Dequeue();
            if (obj != null)
                Object.Destroy(obj.gameObject);
        }
    }
}

UI跟随3D对象的对象池版本:

csharp 复制代码
using UnityEngine;
using System.Collections.Generic;

public class UIFollowPoolManager : MonoBehaviour
{
    [Header("References")]
    public RectTransform uiPrefab;
    public Canvas canvas;
    public Camera mainCamera;
    public Transform uiContainer;
    
    [Header("Settings")]
    public int poolSize = 20;
    
    private ObjectPool<RectTransform> uiPool;
    private Dictionary<Transform, RectTransform> activeUIs = new Dictionary<Transform, RectTransform>();
    
    void Start()
    {
        // 初始化对象池
        uiPool = new ObjectPool<RectTransform>(uiPrefab, uiContainer, poolSize);
    }
    
    public void ShowUI(Transform target3D)
    {
        // 如果已经存在,直接返回
        if (activeUIs.ContainsKey(target3D))
            return;
        
        // 从对象池获取UI
        RectTransform ui = uiPool.Get();
        activeUIs[target3D] = ui;
        
        // 设置初始位置
        UpdateUIPosition(target3D, ui);
    }
    
    public void HideUI(Transform target3D)
    {
        if (activeUIs.TryGetValue(target3D, out RectTransform ui))
        {
            // 释放回对象池
            uiPool.Release(ui);
            activeUIs.Remove(target3D);
        }
    }
    
    void LateUpdate()
    {
        // 更新所有活动的UI位置
        foreach (var kvp in activeUIs)
        {
            UpdateUIPosition(kvp.Key, kvp.Value);
        }
    }
    
    void UpdateUIPosition(Transform target3D, RectTransform ui)
    {
        Vector3 screenPos = mainCamera.WorldToScreenPoint(target3D.position);
        
        if (screenPos.z > 0)
        {
            Vector2 canvasPos;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvas.transform as RectTransform,
                new Vector2(screenPos.x, screenPos.y),
                canvas.worldCamera,
                out canvasPos
            );
            
            ui.localPosition = canvasPos;
        }
    }
    
    void OnDestroy()
    {
        // 清理对象池
        uiPool?.Clear();
    }
}

使用示例:

csharp 复制代码
public class Enemy : MonoBehaviour
{
    private UIFollowPoolManager uiManager;
    
    void Start()
    {
        uiManager = FindObjectOfType<UIFollowPoolManager>();
    }
    
    void OnBecameVisible()
    {
        // 显示血条
        uiManager.ShowUI(transform);
    }
    
    void OnBecameInvisible()
    {
        // 隐藏血条
        uiManager.HideUI(transform);
    }
    
    void OnDestroy()
    {
        // 确保释放UI
        uiManager?.HideUI(transform);
    }
}

8.3 批量更新优化

当有大量UI需要跟随3D对象时,批量处理可以提升效率。

csharp 复制代码
using UnityEngine;
using System.Collections.Generic;

public class BatchUIUpdateManager : MonoBehaviour
{
    [System.Serializable]
    public class UIFollowPair
    {
        public Transform target3D;
        public RectTransform uiElement;
        [HideInInspector] public Vector3 cachedScreenPos;
        [HideInInspector] public Vector2 cachedCanvasPos;
    }
    
    public Canvas canvas;
    public Camera mainCamera;
    public List<UIFollowPair> pairs = new List<UIFollowPair>();
    
    private RectTransform canvasRect;
    
    void Start()
    {
        canvasRect = canvas.transform as RectTransform;
    }
    
    void LateUpdate()
    {
        // 批量处理所有UI
        int count = pairs.Count;
        for (int i = 0; i < count; i++)
        {
            UpdateSingleUI(pairs[i]);
        }
    }
    
    void UpdateSingleUI(UIFollowPair pair)
    {
        // 计算屏幕坐标
        pair.cachedScreenPos = mainCamera.WorldToScreenPoint(pair.target3D.position);
        
        // 检查可见性
        if (pair.cachedScreenPos.z <= 0)
        {
            pair.uiElement.gameObject.SetActive(false);
            return;
        }
        
        // 转换为Canvas坐标
        RectTransformUtility.ScreenPointToLocalPointInRectangle(
            canvasRect,
            new Vector2(pair.cachedScreenPos.x, pair.cachedScreenPos.y),
            canvas.worldCamera,
            out pair.cachedCanvasPos
        );
        
        pair.uiElement.localPosition = pair.cachedCanvasPos;
        pair.uiElement.gameObject.SetActive(true);
    }
    
    // 动态添加
    public void AddPair(Transform target, RectTransform ui)
    {
        pairs.Add(new UIFollowPair { target3D = target, uiElement = ui });
    }
    
    // 动态移除
    public void RemovePair(Transform target)
    {
        pairs.RemoveAll(p => p.target3D == target);
    }
}

8.4 LOD(细节层次)优化

根据距离调整UI更新频率。

csharp 复制代码
public class LODUIFollow : MonoBehaviour
{
    public Transform target3D;
    public RectTransform uiElement;
    public Camera mainCamera;
    
    [Header("LOD Settings")]
    public float nearDistance = 10f;    // 近距离
    public float farDistance = 50f;     // 远距离
    
    private float updateInterval;
    private float timer;
    
    void LateUpdate()
    {
        float distance = Vector3.Distance(mainCamera.transform.position, target3D.position);
        
        // 根据距离设置更新频率
        if (distance < nearDistance)
        {
            updateInterval = 0f; // 每帧更新
        }
        else if (distance < farDistance)
        {
            updateInterval = 0.1f; // 10帧更新一次
        }
        else
        {
            updateInterval = 0.5f; // 30帧更新一次
        }
        
        // 计时器控制
        timer += Time.deltaTime;
        if (timer >= updateInterval)
        {
            timer = 0f;
            UpdateUIPosition();
        }
    }
    
    void UpdateUIPosition()
    {
        Vector3 screenPos = mainCamera.WorldToScreenPoint(target3D.position);
        
        if (screenPos.z > 0)
        {
            Vector2 canvasPos;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(
                canvas.transform as RectTransform,
                new Vector2(screenPos.x, screenPos.y),
                canvas.worldCamera,
                out canvasPos
            );
            
            uiElement.anchoredPosition = canvasPos;
        }
    }
}

8.5 性能监控

csharp 复制代码
using UnityEngine;
using System.Diagnostics;

public class CoordinatePerformanceMonitor : MonoBehaviour
{
    private Stopwatch stopwatch = new Stopwatch();
    
    [Header("统计信息")]
    public int conversionsPerFrame = 0;
    public float averageTimeMs = 0f;
    public int totalConversions = 0;
    
    void Update()
    {
        conversionsPerFrame = 0;
    }
    
    public void MeasureConversion(System.Action conversionAction)
    {
        stopwatch.Restart();
        conversionAction.Invoke();
        stopwatch.Stop();
        
        conversionsPerFrame++;
        totalConversions++;
        
        // 计算平均时间
        averageTimeMs = (averageTimeMs * (totalConversions - 1) + 
                        (float)stopwatch.Elapsed.TotalMilliseconds) / totalConversions;
    }
    
    void OnGUI()
    {
        GUILayout.BeginArea(new Rect(10, 100, 300, 100));
        GUILayout.Label($"每帧转换次数: {conversionsPerFrame}");
        GUILayout.Label($"平均耗时: {averageTimeMs:F4}ms");
        GUILayout.Label($"总转换次数: {totalConversions}");
        GUILayout.EndArea();
    }
}

总结

核心API速查:

转换方向 方法 注意事项
世界→屏幕 Camera.WorldToScreenPoint() Z值表示深度
屏幕→世界 Camera.ScreenToWorldPoint() 必须指定Z深度
世界→视口 Camera.WorldToViewportPoint() 归一化0-1
视口→世界 Camera.ViewportToWorldPoint() 必须指定Z深度
屏幕→射线 Camera.ScreenPointToRay() 用于射线检测
屏幕→UI ScreenPointToLocalPointInRectangle() 需传入Canvas
UI→屏幕 WorldToScreenPoint() RectTransformUtility

最佳实践:

  1. 使用射线检测获取精确3D坐标
  2. UI跟随3D时检查Z值和可见性
  3. 缓存Camera和RectTransform引用
  4. 在LateUpdate()中处理跟随逻辑
  5. 根据Canvas模式选择正确的camera参数
相关推荐
weixin_424294673 小时前
在 Unity 游戏开发中,为视频选择 VP8 还是 H.264
unity·游戏引擎
TG:@yunlaoda360 云老大18 小时前
腾讯WAIC发布“1+3+N”AI全景图:混元3D世界模型开源,具身智能平台Tairos亮相
人工智能·3d·开源·腾讯云
心 爱心 爱19 小时前
Shape-Guided Dual-Memory Learning for 3D Anomaly Detection 论文精读
计算机视觉·3d·异常检测·工业异常检测·三维异常检测·多模态工业异常检测·二维异常检测
一步一个foot-print20 小时前
【Unity】Light Probe 替代点光源给环境动态物体加光照
unity·游戏引擎
@LYZY1 天前
Unity 中隐藏文件规则
unity·游戏引擎·游戏程序·vr
霜绛1 天前
C#知识补充(二)——命名空间、泛型、委托和事件
开发语言·学习·unity·c#
Sator11 天前
使用Unity ASE插件设置数值不会生效的问题
unity·游戏引擎
覆东流1 天前
Photoshop合成的核心知识
ui·photoshop
程序猿追1 天前
轻量级云原生体验:在OpenEuler 25.09上快速部署单节点K3s
人工智能·科技·机器学习·unity·游戏引擎