在 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.cs和PreciseMeshClick.cs放入普通脚本文件夹;
2. 标记模型
- 在 Hierarchy 面板选中需要检测的父模型;
- 点击顶部菜单栏
Tools -> 批量添加 MeshIdentifier,自动为所有子模型添加标识组件; - (可选)手动修改
MeshIdentifier组件的meshName字段,自定义识别名称;
3. 配置检测脚本
- 将
PreciseMeshClick组件挂载到主相机(MainCamera)上; - 在 Inspector 面板:
Detect Layer:选择需要检测的模型层(避免检测无关物体);Ray Max Distance:根据场景大小调整射线最大距离;Double Click Time:自定义双击时间阈值(默认 0.3 秒);