Unity 精准 Mesh 点击检测:穿透遮挡 + 单击双击识别

在 Unity 开发中,常规的射线检测(Physics.Raycast)只能检测到碰撞体层级,无法精准识别 Mesh 三角面,也难以区分重叠 / 遮挡的模型。本文将分享一套精准 Mesh 点击检测方案,实现穿透遮挡选中目标模型、区分单击 / 双击操作,适用于 3D 模型交互、工业可视化、虚拟仿真等场景。

一、核心功能说明

最终实现效果:

  • ✅ 精准命中 Mesh 三角面,而非仅检测碰撞体
  • ✅ 穿透遮挡物,选中射线路径上第一个真正被点击的模型
  • ✅ 自动区分单击 / 双击操作,可自定义双击时间阈值
  • ✅ 批量标记工具,快速为模型添加识别标识

二、完整代码实现

1. Mesh 识别标记组件(MeshIdentifier.cs)

用于标记模型名称,作为点击识别的唯一标识,挂载到需要检测的子模型上。

cs 复制代码
using UnityEngine;

/// <summary>
/// 标记子网格的名称,用于点击识别
/// </summary>
[DisallowMultipleComponent] // 防止重复挂载
public class MeshIdentifier : MonoBehaviour
{
    // 子网格的名称(默认取物体名,可手动修改)
    [Tooltip("点击识别的模型名称,默认使用物体名")]
    public string meshName;

    private void Awake()
    {
        // 初始化:默认使用物体名称作为标识
        if (string.IsNullOrEmpty(meshName))
        {
            meshName = gameObject.name;
        }
    }

    // 编辑器下自动初始化,避免运行时才赋值
    private void OnValidate()
    {
        if (string.IsNullOrEmpty(meshName))
        {
            meshName = gameObject.name;
        }
    }
}

2. 批量添加标识工具(BatchAddMeshIdentifier.cs)

编辑器脚本,快速为选中物体的所有子物体批量添加MeshIdentifier组件(需放在Editor文件夹下)。

cs 复制代码
// 编辑器脚本(必须放在 Editor 文件夹)
using UnityEditor;
using UnityEngine;

public class BatchAddMeshIdentifier : Editor
{
    [MenuItem("Tools/批量添加 MeshIdentifier", false, 100)]
    public static void AddIdentifierToAllChildren()
    {
        // 检测是否选中物体
        if (Selection.activeGameObject == null)
        {
            EditorUtility.DisplayDialog("提示", "请先选中需要批量处理的父物体!", "确定");
            return;
        }

        int addCount = 0; // 统计添加数量
        Transform[] allChildren = Selection.activeGameObject.GetComponentsInChildren<Transform>(true);

        foreach (Transform child in allChildren)
        {
            // 跳过父物体自身
            if (child == Selection.activeGameObject.transform) continue;
            
            // 只给有MeshFilter的物体添加(避免无效挂载)
            if (child.GetComponent<MeshFilter>() == null) continue;

            if (child.GetComponent<MeshIdentifier>() == null)
            {
                child.gameObject.AddComponent<MeshIdentifier>();
                addCount++;
            }
        }

        // 友好提示
        EditorUtility.DisplayDialog("完成", $"批量添加完成!共为 {addCount} 个子物体添加了 MeshIdentifier 组件", "确定");
        Debug.Log($"[批量添加工具] 共添加 {addCount} 个 MeshIdentifier 组件");
    }
}

3. 核心点击检测逻辑(PreciseMeshClick.cs)

实现精准射线检测、单击 / 双击判断、UI 穿透过滤等核心功能。

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

/// <summary>
/// 穿透遮挡检测 + 单击/双击判断
/// </summary>
[RequireComponent(typeof(Camera))] // 建议挂载在相机上
public class PreciseMeshClick : MonoBehaviour
{
    [Header("基础检测配置")]
    [Tooltip("仅检测指定层的模型,避免误触其他物体")]
    public LayerMask detectLayer;
    [Tooltip("射线最大检测距离,根据场景需求调整")]
    public float rayMaxDistance = 1000f;

    [Header("双击配置")]
    [Tooltip("双击时间阈值(秒),默认0.3秒")]
    public float doubleClickTime = 0.3f;

    // 双击判断的临时变量
    private float lastClickTime; // 上一次点击的时间
    private string lastHitMeshName; // 上一次命中的模型名称
    private bool isWaitingForDoubleClick; // 是否等待第二次点击

    private Camera mainCamera;

    private void Awake()
    {
        // 获取当前相机组件(推荐挂载在主相机上)
        mainCamera = GetComponent<Camera>();
        if (mainCamera == null)
        {
            mainCamera = Camera.main;
            Debug.LogWarning("未在当前物体找到相机组件,自动使用主相机!");
        }

        if (mainCamera == null)
        {
            Debug.LogError("未找到主相机!请确保场景中有 Tag 为 MainCamera 的相机");
        }
    }

    private void Update()
    {
        // 检测鼠标左键点击(忽略UI点击、相机为空的情况)
        if (Input.GetMouseButtonDown(0) && mainCamera != null && !IsPointerOverUI())
        {
            OnMouseClick();
        }
    }

    /// <summary>
    /// 处理鼠标点击,区分单击/双击
    /// </summary>
    private void OnMouseClick()
    {
        // 1. 获取本次点击精准命中的模型
        string currentHitMeshName = FindFirstHitMesh();
        if (string.IsNullOrEmpty(currentHitMeshName))
        {
            ResetDoubleClickState(); // 未命中,重置双击状态
            return;
        }

        // 2. 双击判断逻辑
        float currentTime = Time.time;
        float timeSinceLastClick = currentTime - lastClickTime;

        // 满足双击条件:时间阈值内 + 同一模型
        if (isWaitingForDoubleClick && timeSinceLastClick <= doubleClickTime && currentHitMeshName == lastHitMeshName)
        {
            OnMeshDoubleClick(currentHitMeshName);
            ResetDoubleClickState();
        }
        else
        {
            // 第一次点击,记录状态并等待第二次点击
            lastClickTime = currentTime;
            lastHitMeshName = currentHitMeshName;
            isWaitingForDoubleClick = true;

            // 延迟判定为单击(超时未触发双击则执行)
            Invoke(nameof(OnMeshSingleClick), doubleClickTime);
        }
    }

    /// <summary>
    /// 核心逻辑:找到射线路径上第一个精准命中的Mesh模型
    /// </summary>
    /// <returns>命中的模型名称,无则返回空字符串</returns>
    private string FindFirstHitMesh()
    {
        Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
        // 获取射线路径上所有命中的物体(按距离排序)
        RaycastHit[] allHits = Physics.RaycastAll(ray, rayMaxDistance, detectLayer);

        if (allHits.Length == 0) return "";

        // 遍历所有命中物体,找到第一个精准命中Mesh的模型
        foreach (RaycastHit hit in allHits)
        {
            // 优先获取碰撞体自身的Mesh标识和网格
            MeshIdentifier meshId = hit.collider.GetComponent<MeshIdentifier>();
            MeshFilter meshFilter = hit.collider.GetComponent<MeshFilter>();

            // 自身没有则查找子物体
            if (meshId == null || meshFilter == null)
            {
                meshId = hit.collider.GetComponentInChildren<MeshIdentifier>();
                meshFilter = hit.collider.GetComponentInChildren<MeshFilter>();
            }

            // 验证必要组件是否存在
            if (meshId == null || meshFilter == null || meshFilter.mesh == null)
            {
                continue;
            }

            // 精准检测射线是否命中Mesh三角面
            if (IsRayHitMesh(ray, meshFilter.mesh, hit.collider.transform))
            {
                return meshId.meshName;
            }
        }

        return "";
    }

    /// <summary>
    /// 精准检测射线是否命中Mesh的三角面(Möller-Trumbore 算法)
    /// </summary>
    private bool IsRayHitMesh(Ray worldRay, Mesh mesh, Transform meshTransform)
    {
        // 将世界空间射线转换为Mesh本地空间
        Ray localRay = new Ray(
            meshTransform.InverseTransformPoint(worldRay.origin),
            meshTransform.InverseTransformDirection(worldRay.direction).normalized
        );

        Vector3[] vertices = mesh.vertices;
        int[] triangles = mesh.triangles;

        // 遍历所有三角面
        for (int i = 0; i < triangles.Length; i += 3)
        {
            Vector3 v0 = vertices[triangles[i]];
            Vector3 v1 = vertices[triangles[i + 1]];
            Vector3 v2 = vertices[triangles[i + 2]];

            // 检测射线与单个三角面的交点
            if (RayIntersectsTriangle(localRay, v0, v1, v2))
            {
                return true;
            }
        }

        return false;
    }

    /// <summary>
    /// Möller-Trumbore 三角面射线相交检测算法(核心算法)
    /// </summary>
    private bool RayIntersectsTriangle(Ray ray, Vector3 v0, Vector3 v1, Vector3 v2)
    {
        const float epsilon = 1e-6f; // 精度阈值,避免浮点误差
        Vector3 edge1 = v1 - v0;
        Vector3 edge2 = v2 - v0;
        Vector3 h = Vector3.Cross(ray.direction, edge2);
        float a = Vector3.Dot(edge1, h);

        // 射线与三角面平行,无交点
        if (a > -epsilon && a < epsilon) return false;

        float f = 1 / a;
        Vector3 s = ray.origin - v0;
        float u = f * Vector3.Dot(s, h);

        // u不在0-1范围内,超出三角面
        if (u < 0 || u > 1) return false;

        Vector3 q = Vector3.Cross(s, edge1);
        float v = f * Vector3.Dot(ray.direction, q);

        // v不在0-1范围内 或 u+v>1,超出三角面
        if (v < 0 || u + v > 1) return false;

        // 计算射线到交点的距离
        float t = f * Vector3.Dot(edge2, q);
        // t>0表示交点在射线前进方向上
        return t > epsilon;
    }

    /// <summary>
    /// 检测鼠标是否在UI上(避免点击UI时触发模型检测)
    /// </summary>
    private bool IsPointerOverUI()
    {
        if (EventSystem.current == null) return false;
        // 检测鼠标是否在UI上(支持移动端触摸)
        return EventSystem.current.IsPointerOverGameObject() || 
               (Input.touchCount > 0 && EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId));
    }

    /// <summary>
    /// 单击回调(可根据业务需求修改)
    /// </summary>
    private void OnMeshSingleClick()
    {
        if (isWaitingForDoubleClick)
        {
            Debug.Log($"【单击】选中模型:{lastHitMeshName}");
            // 这里可以添加自定义逻辑,比如高亮模型、显示信息面板等
            ResetDoubleClickState();
        }
    }

    /// <summary>
    /// 双击回调(可根据业务需求修改)
    /// </summary>
    private void OnMeshDoubleClick(string meshName)
    {
        CancelInvoke(nameof(OnMeshSingleClick)); // 取消单击回调
        Debug.Log($"【双击】选中模型:{meshName}");
        // 这里可以添加自定义逻辑,比如聚焦模型、打开详情页等
    }

    /// <summary>
    /// 重置双击判断状态
    /// </summary>
    private void ResetDoubleClickState()
    {
        lastClickTime = 0;
        lastHitMeshName = "";
        isWaitingForDoubleClick = false;
    }

    // 调试用:在Scene视图绘制射线
    private void OnDrawGizmos()
    {
        if (mainCamera == null) return;
        
        if (Input.GetMouseButtonDown(0))
        {
            Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
            Gizmos.color = Color.red;
            Gizmos.DrawRay(ray.origin, ray.direction * rayMaxDistance);
        }
    }
}

三、使用步骤

1. 环境准备

  • 创建Editor文件夹(若不存在),将BatchAddMeshIdentifier.cs放入;
  • MeshIdentifier.csPreciseMeshClick.cs放入普通脚本文件夹;

2. 标记模型

  1. 在 Hierarchy 面板选中需要检测的父模型;
  2. 点击顶部菜单栏Tools -> 批量添加 MeshIdentifier,自动为所有子模型添加标识组件;
  3. (可选)手动修改MeshIdentifier组件的meshName字段,自定义识别名称;

3. 配置检测脚本

  1. PreciseMeshClick组件挂载到主相机(MainCamera)上;
  2. 在 Inspector 面板:
    • Detect Layer:选择需要检测的模型层(避免检测无关物体);
    • Ray Max Distance:根据场景大小调整射线最大距离;
    • Double Click Time:自定义双击时间阈值(默认 0.3 秒);

四、源码下载

https://csdnres.oss-cn-beijing.aliyuncs.com/MeshClick.zip

相关推荐
迪普阳光开朗很健康4 小时前
Unity中new() 和实例化有什么区别?
unity·游戏引擎
mxwin4 小时前
Unity Shader 极坐标特效 从数学原理到实战案例
unity·游戏引擎·shader·uv
魔士于安1 天前
unity 圆盘式 太空飞船
游戏·unity·游戏引擎·贴图·模型
陈言必行1 天前
Unity 之 Addressables 加载失败:路径变量未替换导致的 404 错误分析与解决
unity·游戏引擎
qq_170264751 天前
unity出安卓年龄分级的arr包问题
android·unity·游戏引擎
WMX10121 天前
Holoens2开发报错记录02_unity项目常见错误
unity
魔士于安1 天前
宇宙版地球模拟器
游戏·unity·游戏引擎·贴图·模型
魔士于安1 天前
氛围感游戏场景,天空盒,带地形,附赠一个空要塞
游戏·unity·游戏引擎·贴图
ellis19701 天前
Unity程序集(assembly)笔记
unity