前言
在 Unity 3D 应用中,实现精确的物体点击检测是一个常见需求。当场景中存在大量可交互且不规则的物体时(如本文案例中的 5630 个物体),选择合适的检测方案对性能至关重要。本文将深入对比两种主流方案:GPU Picking(颜色拾取) 和 MeshCollider + Raycast(射线检测)。
一、方案概述
1.1 GPU Picking(颜色拾取)
核心原理:通过渲染每个物体为唯一颜色,读取点击位置的像素颜色来识别物体。
工作流程:
- 为每个物体分配唯一的 ID(1, 2, 3...)
- 将 ID 编码为 RGB 颜色(如 ID=1 → RGB(0,0,1))
- 使用专用摄像机渲染所有物体到 1x1 的 RenderTexture
- 读取点击位置的像素颜色
- 将颜色解码回 ID,查找对应的 GameObject
技术要点:
- 使用
CommandBuffer.DrawMesh批量绘制,不修改场景材质 AsyncGPUReadback异步读取像素,避免阻塞主线程- 支持 MeshRenderer 和 SkinnedMeshRenderer
1.2 MeshCollider + Raycast(射线检测)
核心原理:从摄像机发射射线,检测与 MeshCollider 的碰撞。
工作流程:
- 为每个需要检测的物体添加 MeshCollider
- 从摄像机位置向点击位置发射射线
- 使用
Physics.Raycast检测碰撞 - 返回碰撞的 Collider,获取对应的 GameObject
技术要点:
- 需要为每个物体创建 MeshCollider(消耗内存)
- 使用 Unity 物理系统进行碰撞检测
- 支持精确的碰撞点、法线等信息
二、性能对比(5630 个物体场景)
2.1 CPU 消耗
| 方案 | CPU 消耗 | 说明 |
|---|---|---|
| GPU Picking | 极低 | 主要工作在 GPU,CPU 仅负责: - 创建 CommandBuffer(~0.1ms) - 等待 GPU 完成(异步) - 读取 1 个像素(0.01ms)<br>**总计:0.2ms** |
| MeshCollider + Raycast | 较高 | CPU 需要: - 遍历所有 Collider(~5-10ms) - 计算射线与网格的精确交点 - 排序找到最近碰撞点 总计:~8-15ms |
结论 :GPU Picking 的 CPU 消耗约为 Raycast 的 1/40 到 1/75。
2.2 内存消耗
| 方案 | 内存消耗 | 说明 |
|---|---|---|
| GPU Picking | 极低 | - 1x1 RenderTexture(~4 bytes) - ID 映射字典(~50KB,5630 个对象) - 单个拾取材质实例 总计:~50KB |
| MeshCollider + Raycast | 较高 | - 每个物体需要 MeshCollider - 每个 MeshCollider 存储顶点、三角形数据 - 5630 个 MeshCollider ≈ 50-200MB(取决于网格复杂度) |
结论 :GPU Picking 的内存消耗约为 Raycast 的 1/1000 到 1/4000。
2.3 GPU 消耗
| 方案 | GPU 消耗 | 说明 |
|---|---|---|
| GPU Picking | 中等 | - 渲染 5630 个物体到 1x1 RT - 使用简单 Shader(无光照、无纹理) - 每帧:~2-5ms(仅点击时) |
| MeshCollider + Raycast | 无 | 不占用 GPU 资源 |
注意:GPU Picking 的 GPU 消耗仅在点击时发生,且使用最简化的渲染管线。
2.4 初始化开销
| 方案 | 初始化时间 | 说明 |
|---|---|---|
| GPU Picking | ~5-10ms | - 遍历所有 MeshRenderer - 创建 ID 映射字典 - 创建拾取材质 |
| MeshCollider + Raycast | ~500-2000ms | - 为 5630 个物体创建 MeshCollider - 构建碰撞网格(Cook Mesh) - 初始化物理系统 |
结论 :GPU Picking 的初始化时间约为 Raycast 的 1/50 到 1/200。
三、优缺点分析
3.1 GPU Picking 优缺点
✅ 优点
- CPU 消耗极低:主要工作在 GPU,不阻塞主线程
- 内存占用小:不需要为每个物体创建 Collider
- 初始化快:仅需创建 ID 映射,无需构建碰撞网格
- 精确度高:像素级精度,不受网格简化影响
- 支持复杂网格:不受 MeshCollider 的顶点数限制
- 易于扩展:添加新物体只需注册 ID,无需添加 Collider
❌ 缺点
- 需要额外摄像机:占用一个 Camera 资源
- GPU 占用:每次点击需要渲染一次(但仅 1x1 像素)
- 异步处理:需要等待 GPU 完成,有 1-2 帧延迟
- 不支持碰撞点信息:只能获取物体,无法获取精确碰撞点、法线
- 需要自定义 Shader:必须使用支持 ID 颜色的 Shader
3.2 MeshCollider + Raycast 优缺点
✅ 优点
- Unity 原生支持:无需额外实现,开箱即用
- 精确碰撞信息:可获取碰撞点、法线、距离等详细信息
- 支持复杂查询 :
RaycastAll、SphereCast等 - 无 GPU 占用:纯 CPU 计算
- 实时响应:同步执行,无延迟
❌ 缺点
- 内存消耗大:每个 MeshCollider 需要存储网格数据
- CPU 消耗高:需要遍历所有 Collider 并计算碰撞
- 初始化慢:创建大量 MeshCollider 耗时
- 顶点数限制:MeshCollider 对复杂网格有性能问题
- 维护成本高:添加新物体需要手动添加 Collider
四、适用场景
4.1 选择 GPU Picking 的场景
✅ 推荐使用:
- 场景中有大量可交互物体(>1000 个)
- 只需要识别物体,不需要碰撞点信息
- 内存受限(移动设备)
- 需要频繁添加/删除可交互物体
- 物体网格复杂(高顶点数)
典型应用:
- 3D 模型查看器(解剖模型、建筑模型)
- 大规模场景交互(如本文的 5630 个物体场景)
- 移动端应用
4.2 选择 MeshCollider + Raycast 的场景
✅ 推荐使用:
- 场景中物体数量较少(<100 个)
- 需要精确的碰撞点、法线信息
- 需要复杂的碰撞查询(SphereCast、BoxCast 等)
- 不需要考虑内存限制
- 物体网格简单(低顶点数)
典型应用:
- 第一人称射击游戏(需要精确的射击点)
- 物理交互游戏(需要碰撞点计算力)
- 编辑器工具(需要精确的编辑点)
五、总结
5.1 性能对比总结
| 维度 | GPU Picking | MeshCollider + Raycast | winner |
|---|---|---|---|
| CPU 消耗 | 极低(~0.2ms) | 较高(~8-15ms) | 🏆 GPU Picking |
| 内存消耗 | 极低(~50KB) | 较高(~80MB) | 🏆 GPU Picking |
| 初始化速度 | 快(~8ms) | 慢(~1200ms) | 🏆 GPU Picking |
| 响应延迟 | 1-2 帧(异步) | 实时(同步) | 🏆 Raycast |
| 信息丰富度 | 仅物体 | 物体+碰撞点+法线 | 🏆 Raycast |
| 实现复杂度 | 中等 | 简单 | 🏆 Raycast |
5.2 选择建议
对于大规模场景(>1000 个物体):
- ✅ 强烈推荐 GPU Picking
- 性能优势明显(CPU 快 40-75 倍,内存节省 99.9%)
- 适合移动端和内存受限场景
对于小规模场景(<100 个物体):
- ✅ 推荐 MeshCollider + Raycast
- 实现简单,Unity 原生支持
- 可获取详细碰撞信息
对于本文案例(5630 个物体):
- ✅ GPU Picking 是唯一可行方案
- MeshCollider 方案会导致内存溢出和严重卡顿
六、测试结果

七、代码示例
7.1 GPU Picking 完整实现
完整代码PickingManager +Shader
csharp
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Events;
using System.Collections;
using System.Collections.Generic;
using FDIM.Framework;
// 拾取请求数据结构
public class PickRequest
{
public Vector2 screenPosition;
public UnityAction<GameObject> onSelect;
public UnityAction onNoSelect;
public string sourceId; // 调用源标识(用于区分不同的调用源)
public PickRequest(Vector2 pos, UnityAction<GameObject> select, UnityAction noSelect, string source)
{
screenPosition = pos;
onSelect = select;
onNoSelect = noSelect;
sourceId = source;
}
}
public class PickingManager : SingletonMonoBase<PickingManager>
{
[Header("摄像机")]
public Camera mainCamera;
public Camera pickingCamera;
[Header("层级")]
public LayerMask targetLayer; // 例如 Palette 层
[Header("拾取范围")]
public Transform pickRoot; // 父节点,只注册该节点下的物体(为空则注册场景中所有符合条件的物体)
[Header("拾取 Shader")]
public Shader pickingShader; // 设置为 URP_ID_Picking.shader
private Dictionary<uint, GameObject> idToObjects = new Dictionary<uint, GameObject>();
private Dictionary<uint, Renderer> idToRenderers = new Dictionary<uint, Renderer>(); // ID 到 Renderer 的映射
private Material pickingMaterial; // 全局拾取材质(只创建一次)
// 队列机制相关字段(使用字典记录每个调用源的最新请求)
private Dictionary<string, PickRequest> pendingRequests = new Dictionary<string, PickRequest>(); // 每个调用源的最新请求
private Queue<string> processingOrder = new Queue<string>(); // 处理顺序队列(记录不同调用源的顺序)
private bool isProcessingQueue = false; // 是否正在处理队列
void Start()
{
// 创建全局拾取材质(只创建一次,所有物体共用)
if (pickingShader != null)
{
pickingMaterial = new Material(pickingShader);
}
else
{
Debug.LogError("pickingShader 未设置!");
}
// 强制初始化拾取摄像机,关闭所有特效
if (pickingCamera != null)
{
pickingCamera.enabled = false;
var cameraData = pickingCamera.GetUniversalAdditionalCameraData();
cameraData.renderPostProcessing = false;
cameraData.antialiasing = AntialiasingMode.None;
// 建议在 Inspector 手动将 MSAA 和 HDR 设为 Off
}
RefreshIDs();
}
public void RefreshIDs()
{
idToObjects.Clear();
idToRenderers.Clear();
// 找到所有 MeshRenderer
MeshRenderer[] renderers;
if (pickRoot != null)
{
// 只在父节点下查找
renderers = pickRoot.GetComponentsInChildren<MeshRenderer>(true);
}
else
{
// 查找场景中所有 MeshRenderer
renderers = Object.FindObjectsByType<MeshRenderer>(FindObjectsSortMode.None);
}
uint idCounter = 1;
foreach (var r in renderers)
{
if (((1 << r.gameObject.layer) & targetLayer) != 0)
{
idToObjects[idCounter] = r.gameObject;
idToRenderers[idCounter] = r; // 保存 Renderer 引用,用于 CommandBuffer 绘制
idCounter++;
}
}
Debug.Log($"<color=cyan>初始化成功: 已监控 {idToObjects.Count} 个可交互物体</color>");
}
//void Update()
//{
// // 鼠标点击或触摸时触发拾取
// if (Input.GetMouseButtonDown(0) || (Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began))
// {
// Vector2 pos = (Input.touchCount > 0) ? Input.GetTouch(0).position : (Vector2)Input.mousePosition;
// PickAtPosition(pos);
// }
//}
/// <summary>
/// 在指定屏幕位置执行拾取操作
/// </summary>
/// <param name="screenPosition">屏幕坐标位置</param>
/// <param name="select">选中物体时的回调</param>
/// <param name="noSelect">未选中物体时的回调</param>
/// <param name="sourceId">调用源标识(相同标识的连续调用只执行最后一个)</param>
public void PickAtPosition(Vector2 screenPosition, UnityAction<GameObject> select = null, UnityAction noSelect = null, string sourceId = "default")
{
// 创建新请求
PickRequest newRequest = new PickRequest(screenPosition, select, noSelect, sourceId);
// 如果该调用源已有请求,覆盖它(跳过中间的请求)
bool isNewSource = !pendingRequests.ContainsKey(sourceId);
pendingRequests[sourceId] = newRequest;
// 如果是新的调用源,加入处理顺序队列
if (isNewSource)
{
processingOrder.Enqueue(sourceId);
}
// 如果当前没有在处理队列,开始处理
if (!isProcessingQueue)
{
StartCoroutine(ProcessPickQueue());
}
}
/// <summary>
/// 处理拾取队列(按顺序处理所有请求,相同调用源只执行最新的)
/// </summary>
private IEnumerator ProcessPickQueue()
{
isProcessingQueue = true;
// 逐个处理不同调用源的请求
while (processingOrder.Count > 0)
{
string sourceId = processingOrder.Dequeue();
// 从字典中取出该调用源的最新请求(可能已经被后续请求覆盖)
if (pendingRequests.TryGetValue(sourceId, out PickRequest request))
{
// 移除已处理的请求
pendingRequests.Remove(sourceId);
// 执行拾取操作
yield return StartCoroutine(DoPick(request.screenPosition, request.onSelect, request.onNoSelect));
}
}
isProcessingQueue = false;
}
IEnumerator DoPick(Vector2 screenPos, UnityAction<GameObject> select = null, UnityAction noSelect = null)
{
// 1. 创建 1x1 的 RT,强制关闭抗锯齿
RenderTexture rt = RenderTexture.GetTemporary(1, 1, 24, RenderTextureFormat.ARGB32);
rt.antiAliasing = 1;
rt.filterMode = FilterMode.Point;
pickingCamera.targetTexture = rt;
// 2. 同步摄像机参数
pickingCamera.transform.SetPositionAndRotation(mainCamera.transform.position, mainCamera.transform.rotation);
pickingCamera.fieldOfView = mainCamera.fieldOfView;
pickingCamera.nearClipPlane = mainCamera.nearClipPlane;
pickingCamera.farClipPlane = mainCamera.farClipPlane;
// 3. 调整投影矩阵偏移 (将 1x1 像素精确对准点击位置)
Matrix4x4 proj = mainCamera.projectionMatrix;
float x = (screenPos.x / Screen.width) * 2 - 1;
float y = (screenPos.y / Screen.height) * 2 - 1;
pickingCamera.projectionMatrix = Matrix4x4.TRS(new Vector3(-x, -y, 0), Quaternion.identity, Vector3.one) * proj;
// 4. 使用 CommandBuffer 绘制,不修改场景材质(性能优化)
if (pickingMaterial == null)
{
Debug.LogError("pickingMaterial 未初始化!");
pickingCamera.targetTexture = null;
RenderTexture.ReleaseTemporary(rt);
noSelect?.Invoke();
yield break;
}
CommandBuffer cmd = CommandBufferPool.Get("GPU Picking");
cmd.SetRenderTarget(rt);
cmd.ClearRenderTarget(true, true, Color.black);
cmd.SetViewProjectionMatrices(pickingCamera.worldToCameraMatrix, pickingCamera.projectionMatrix);
// 使用 MaterialPropertyBlock 为每个物体设置 ID 颜色,避免创建大量材质实例
foreach (var kvp in idToRenderers)
{
var renderer = kvp.Value;
if (renderer == null || !renderer.gameObject.activeInHierarchy) continue;
// 获取 Mesh(支持 MeshRenderer 和 SkinnedMeshRenderer)
Mesh mesh = null;
if (renderer is MeshRenderer)
{
var meshFilter = renderer.GetComponent<MeshFilter>();
if (meshFilter != null) mesh = meshFilter.sharedMesh;
}
else if (renderer is SkinnedMeshRenderer skinnedRenderer)
{
mesh = skinnedRenderer.sharedMesh;
}
if (mesh == null) continue;
// 创建 MaterialPropertyBlock 设置 ID 颜色
MaterialPropertyBlock mpb = new MaterialPropertyBlock();
Color32 idColor = IDToColor(kvp.Key);
mpb.SetColor("_IDColor", new Color(idColor.r / 255f, idColor.g / 255f, idColor.b / 255f, 1f));
// 使用 DrawMesh 绘制,支持 MaterialPropertyBlock,不修改场景中的材质
// DrawMesh 参数:Mesh, Matrix4x4, Material, submeshIndex, shaderPass, MaterialPropertyBlock
Matrix4x4 matrix = renderer.localToWorldMatrix;
cmd.DrawMesh(mesh, matrix, pickingMaterial, 0, -1, mpb);
}
// 执行 CommandBuffer
Graphics.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
// 5. 异步读取像素数据
var request = AsyncGPUReadback.Request(rt);
yield return new WaitUntil(() => request.done);
// 6. 需要先解绑再释放,否则会报 Releasing RT 错误
pickingCamera.targetTexture = null;
RenderTexture.ReleaseTemporary(rt);
// 7. 处理结果
if (!request.hasError)
{
Color32 pixel = request.GetData<Color32>()[0];
uint id = ColorToID(pixel);
if (idToObjects.TryGetValue(id, out GameObject hitObj))
{
Debug.Log($"<color=green>成功识别物体: {hitObj.name}</color>");
select?.Invoke(hitObj);
}
else
{
Debug.Log($"<color=green>没有识别");
noSelect?.Invoke();
}
}
else
{
Debug.LogError("GPU Readback 错误");
noSelect?.Invoke();
}
}
Color32 IDToColor(uint id) => new Color32((byte)((id >> 16) & 0xFF), (byte)((id >> 8) & 0xFF), (byte)(id & 0xFF), 255);
uint ColorToID(Color32 c) => (uint)((c.r << 16) | (c.g << 8) | c.b);
}
对应的Shader文件.
*注意:这里小编是URP管线
csharp
Shader "Custom/URP_ID_Picking"
{
Properties {
_IDColor ("ID Color", Color) = (1,1,1,1)
}
SubShader
{
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" }
Pass
{
// 强制不接受光照、雾效
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes { float4 positionOS : POSITION; };
struct Varyings { float4 positionCS : SV_POSITION; };
float4 _IDColor;
Varyings vert(Attributes input) {
Varyings output;
output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
return output;
}
float4 frag(Varyings input) : SV_Target {
return _IDColor;
}
ENDHLSL
}
}
}