Unity3D大规模点击检测:GPU Picking vs MeshCollider + Raycast

前言

在 Unity 3D 应用中,实现精确的物体点击检测是一个常见需求。当场景中存在大量可交互且不规则的物体时(如本文案例中的 5630 个物体),选择合适的检测方案对性能至关重要。本文将深入对比两种主流方案:GPU Picking(颜色拾取)MeshCollider + Raycast(射线检测)


一、方案概述

1.1 GPU Picking(颜色拾取)

核心原理:通过渲染每个物体为唯一颜色,读取点击位置的像素颜色来识别物体。

工作流程

  1. 为每个物体分配唯一的 ID(1, 2, 3...)
  2. 将 ID 编码为 RGB 颜色(如 ID=1 → RGB(0,0,1))
  3. 使用专用摄像机渲染所有物体到 1x1 的 RenderTexture
  4. 读取点击位置的像素颜色
  5. 将颜色解码回 ID,查找对应的 GameObject

技术要点

  • 使用 CommandBuffer.DrawMesh 批量绘制,不修改场景材质
  • AsyncGPUReadback 异步读取像素,避免阻塞主线程
  • 支持 MeshRenderer 和 SkinnedMeshRenderer

1.2 MeshCollider + Raycast(射线检测)

核心原理:从摄像机发射射线,检测与 MeshCollider 的碰撞。

工作流程

  1. 为每个需要检测的物体添加 MeshCollider
  2. 从摄像机位置向点击位置发射射线
  3. 使用 Physics.Raycast 检测碰撞
  4. 返回碰撞的 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 优缺点

✅ 优点
  1. CPU 消耗极低:主要工作在 GPU,不阻塞主线程
  2. 内存占用小:不需要为每个物体创建 Collider
  3. 初始化快:仅需创建 ID 映射,无需构建碰撞网格
  4. 精确度高:像素级精度,不受网格简化影响
  5. 支持复杂网格:不受 MeshCollider 的顶点数限制
  6. 易于扩展:添加新物体只需注册 ID,无需添加 Collider
❌ 缺点
  1. 需要额外摄像机:占用一个 Camera 资源
  2. GPU 占用:每次点击需要渲染一次(但仅 1x1 像素)
  3. 异步处理:需要等待 GPU 完成,有 1-2 帧延迟
  4. 不支持碰撞点信息:只能获取物体,无法获取精确碰撞点、法线
  5. 需要自定义 Shader:必须使用支持 ID 颜色的 Shader

3.2 MeshCollider + Raycast 优缺点

✅ 优点
  1. Unity 原生支持:无需额外实现,开箱即用
  2. 精确碰撞信息:可获取碰撞点、法线、距离等详细信息
  3. 支持复杂查询RaycastAllSphereCast
  4. 无 GPU 占用:纯 CPU 计算
  5. 实时响应:同步执行,无延迟
❌ 缺点
  1. 内存消耗大:每个 MeshCollider 需要存储网格数据
  2. CPU 消耗高:需要遍历所有 Collider 并计算碰撞
  3. 初始化慢:创建大量 MeshCollider 耗时
  4. 顶点数限制:MeshCollider 对复杂网格有性能问题
  5. 维护成本高:添加新物体需要手动添加 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
        }
    }
}
相关推荐
在路上看风景9 小时前
1.4 Unity运行时路径
unity·游戏引擎
在路上看风景1 天前
1.2 Unity资源分类
unity·游戏引擎
one named slash1 天前
BMFont在Unity中生成艺术字
unity·游戏引擎
郝学胜-神的一滴1 天前
图形学中的纹理映射问题:摩尔纹与毛刺的深度解析
c++·程序人生·unity·游戏引擎·图形渲染·unreal engine
在路上看风景1 天前
10. CPU-GPU协作渲染
unity
程序员agions1 天前
Unity 游戏开发邪修秘籍:从入门到被策划追杀的艺术
unity·cocoa·lucene
JIes__1 天前
Unity(一)——场景切换、退出游戏、鼠标隐藏锁定...
unity·游戏引擎
NIKITAshao2 天前
Unity URP Volume组件详解(笔记)
unity·游戏引擎
lingxiao168882 天前
WebApi详解+Unity注入--下篇:Unity注入
unity·c#·wpf