做Unity交互功能,第一课几乎都是“屏幕点击”。无论是 RTS 里点击地面让单位移动,还是 RPG 里点击 NPC 对话,又或者是模拟经营里点击地块建造建筑,背后都躲不开同一个需求:把屏幕上那个二维像素坐标,换算成三维世界里的一个点或一个物体。我刚入行时第一次接到这类需求,以为就是把Input.mousePosition直接塞进某个 API 里,结果跑起来物体飞到天上、点到空白区域报空引用、俯视角相机下坐标永远对不上,各种问题轮着来。
实际上,Unity 里把屏幕点击位置转成世界坐标,主流路线就两条:ScreenPointToRay配合射线检测,以及ScreenToWorldPoint直接换算。很多教程把这两个 API 混着讲,好像怎么用都行,但我做了几个项目之后发现,这两条路背后的适用场景完全不同,选错了会非常痛苦。这篇文章就把两条路线的原理、代码、坑和选型标准一次性说清楚,适合刚接触坐标转换的新手,也适合已经写过但总被奇怪 Bug 困扰的开发者。
1. 先别急着写代码:两种需求决定两种方案
很多人在搜索引擎里找“屏幕坐标转世界坐标”,找到代码就复制粘贴,却忽略了代码背后的假设。我的经验是,动手之前先问自己一个问题:点击之后,我要的到底是一个“物体”,还是一个“坐标”?
1.1 点击交互的真实需求分成两类
第一类需求是“我点到了什么”。比如 FPS 里开枪打中敌人、策略游戏里选中一个单位、做编辑器工具时点击场景里的模型。这种需求的本质是“拾取”,我需要的是被点中的那个 GameObject,以及它身上的组件数据。这类需求里,一个纯粹的坐标点其实没有意义,因为场景里可能有多个物体叠在一起,我需要的是最前面的、或者特定 Layer 上的那个物体。
第二类需求是“我点在了哪里”。比如把建筑物放在网格上的指定格子里、在地面上画一条路径、把特效生成在鼠标指向的地面上。这种需求的本质是“定位”,我要的是一个准确的世界坐标。场景里有没有物体、有没有碰撞体都不重要,哪怕是一片空地,我也应该能得到坐标。
这两类需求看起来很像,但实现方案完全不一样。第一类你必须用射线检测,因为只有射线才能告诉你是哪个碰撞体被击中;第二类则通常用ScreenToWorldPoint或射线与数学平面的交点来实现,因为它们不依赖场景里是否有物体。
1.2 用一个判断标准快速定方案
我自己的判断标准很简单:目标位置是否一定落在某个“已知平面”上,以及场景中是否已有碰撞体。
如果你的点击目标是场景里真实存在的物体(地形、角色、墙壁),并且这些物体都有 Collider,那无脑选第一条路线:ScreenPointToRay + Raycast。这条路线最稳,性能和精确度都可以接受,而且能顺便拿到碰撞体信息。
如果你的点击目标是“逻辑平面”而不是真实物体,比如鼠标在屏幕中心前方 10 米处放置一个预览框、或者在地面 y=0 平面上画线,那可以考虑第二条路线:ScreenToWorldPoint。这条路线不依赖碰撞体,是纯数学换算,在特定相机角度下非常快。
当然,两条路线不是互斥的。后面你会看到,在高级玩法里我们经常把它们结合起来用。先掌握单条路线的原理,再谈组合。
2. 方法一:ScreenPointToRay + Raycast,点击拾取的首选方案
这条路线是 Unity 文档里最标准的做法,也是大多数教程里出现次数最多的写法。它做的事情用一句话概括就是:从相机位置出发,经过你点击的屏幕像素点,朝场景里射出一条射线,然后看这条射线撞到了什么。
2.1 一句话理解原理
屏幕上的每一个像素点,在三维空间里都对应着一条射线。射线起点是相机位置,方向是“相机位置指向屏幕该点对应的世界方向”。ScreenPointToRay就是帮你把二维屏幕坐标换算成这条射线,Physics.Raycast则是负责执行“射线撞到哪些碰撞体”的物理查询。
我习惯把这条射线想象成一根无限长的激光笔,从相机镜头中心射出去,经过鼠标点击的那块屏幕玻璃,继续往前射进游戏场景。场景里谁挡住了激光,谁就是被点击的物体。
2.2 最小可用代码示例(3D 场景)
下面这段代码是我项目里的标准模板,放在一个挂载了 Camera 管理脚本的物体上就可以跑:
using UnityEngine; public class ClickSelector : MonoBehaviour { [SerializeField] private Camera targetCamera; [SerializeField] private LayerMask hitLayer; private void Update() { if (!Input.GetMouseButtonDown(0)) return; // 生成从相机穿过鼠标位置的射线 Ray ray = targetCamera.ScreenPointToRay(Input.mousePosition); // 执行物理射线检测 if (Physics.Raycast(ray, out RaycastHit hit, 1000f, hitLayer)) { Debug.Log("点击到了物体:" + hit.collider.gameObject.name); Debug.Log("命中点世界坐标:" + hit.point); // 在这里处理你的逻辑,比如高亮、选中、伤害判定 } } }这里面有几个值得注意的点。targetCamera建议用 Inspector 手动指定,不要偷懒直接写Camera.main,因为Camera.main底层要做 Tag 查询,在 Update 里每帧调用会白白浪费性能。hitLayer用来过滤你想点击的物体,比如只点击地面、只点击敌人,这是非常实用的技巧,我后面会细讲。
2.3 五个高频参数配置,决定了你能不能射中
第一是 LayerMask。如果你不指定 LayerMask,射线会撞到场景里所有的碰撞体,包括 UI 对象、粒子碰撞体、甚至看不见的触发器。我见过一个案例,玩家点击地面时角色总是往奇怪的方向跑,排查了一下午,结果发现地面上有一个透明的水面触发器把射线拦截了。正确的做法是先给物体分好 Layer,比如 Ground、Enemy、Item,然后在Physics.Raycast里传入对应的 LayerMask。
LayerMask 的写法有两种。一种是在 Inspector 里拖拽赋值,另一种是代码里用LayerMask.GetMask("Ground")。如果你只想点击多个层,用按位或运算:LayerMask.GetMask("Ground") | LayerMask.GetMask("Enemy")。
第二是最大距离 maxDistance。射线不是无限长的(虽然大部分情况下你可以设一个很大的值),但保持一个合理的距离能避免很多奇怪的跨区域点击问题。比如你做一个地图编辑器,相机离地面 500 米,射线距离设成 100 米就永远点不到地面;反过来,如果你的场景很小,射线距离设成 10000 米,理论上没问题,但尾数精度会变差。我通常设成相机 farClipPlane 的值,或者根据场景尺度算一个“够用就行”的值。
第三是 QueryTriggerInteraction。默认情况下Physics.Raycast会忽略Collider.isTrigger = true的触发器。这通常是合理行为,但有时候你的点击目标是触发器,比如点击一个无形的空气墙区域触发剧情。这时候需要显式指定QueryTriggerInteraction.Collide。不过我个人建议,能用实心碰撞体解决的事情就别用触发器去承接点击逻辑,否则后面维护成本很高。
第四是 2D 项目的区别。如果你在做 2D 游戏,用的不是Physics.Raycast,而是Physics2D.Raycast。而且 2D 场景里的 Collider 是Collider2D,返回的RaycastHit2D结构体也和 3D 版本不同。很多从 3D 转 2D 的开发者会在这里踩坑,把Physics.Raycast硬套到 2D 项目里,结果射线撞不到任何东西。2D 的写法大致是这样:
Vector2 origin = Camera.main.ScreenToWorldPoint(Input.mousePosition); RaycastHit2D hit2D = Physics2D.Raycast(origin, Vector2.zero);注意 2D 里射线原点是屏幕坐标转世界坐标后的点,方向可以传Vector2.zero表示“原地探测”,这个习惯跟 3D 差异挺大。
第五是 UI 遮挡问题。这是新手最容易忽略的坑。当 UI 上有一个全屏半透明面板时,你点击屏幕,射线会穿透 UI 直接击中后面的 3D 物体,导致“我明明点在按钮上,角色却动了”。解决这个问题的标准姿势是用 EventSystem 判断:
if (EventSystem.current.IsPointerOverGameObject()) { return; // 鼠标在 UI 上,不处理场景点击 }这段代码放在Update里的射线检测之前,可以过滤掉绝大多数 UI 误触。移动端要多一步,IsPointerOverGameObject()要传入手指 id:
if (Input.touchCount > 0 && EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId)) { return; }2.4 实战心得:射线不是“点”,是“方向 + 长度”
我见过不少人把hit.point当成了“鼠标点击位置的精确世界坐标”,然后拿这个点去做放置、寻路、特效生成,结果发现物体总在地上半米或天上半米。这是因为hit.point是射线与碰撞体表面的交点,当碰撞体表面本身就是地面时,这个点才会贴合地面。
如果地面用了复杂地形(Terrain),hit.point也基本准确。但如果你点了立方体、球体这种粗糙碰撞体表面,hit.point是那个表面上的点,而不是“物体底部在地面的投影”。要拿到贴着地面的点,通常还得自己再往 y 方向做一次投影运算。
另外,射线检测的本质是物理查询,它依赖场景里必须有碰撞体。如果你希望点击一片空地也能得到坐标,比如在空白的地面上任意画线,纯射线方案会失效,这时候就要考虑第二种方法了。
3. 方法二:ScreenToWorldPoint,精确平面放置的利器
ScreenToWorldPoint是一条更“数学”的路线。它不需要碰撞体,不需要物理系统,直接把屏幕坐标换算成世界坐标。听起来很完美,但我第一次用的时候被坑得很惨,原因只有一个:z 值,也就是第三个参数,必须你自己填。
3.1 最大的坑:Z 值永远不能省
Input.mousePosition返回的是一个Vector3,但它的 z 永远是 0。很多初学者直接把它传给ScreenToWorldPoint:
Vector3 worldPos = Camera.main.ScreenToWorldPoint(Input.mousePosition);结果物体全跑到相机位置附近,或者缩成一个点。原因很简单:屏幕坐标只是“像素平面上的二维点”,没有深度信息。在三维世界里,一个二维点可以对应无穷多个三维点,从相机位置出发,沿着某个方向延伸任意距离都行。所以你必须告诉相机,“我要的是距离相机多远的那一个”。
那这个 z 到底填多少?官方定义是“物体到相机的距离,以世界单位衡量”,但这个距离是沿相机 forward 方向的,不是沿屏幕射线方向。这一点在后面的倾斜相机问题里会产生巨大的影响。
3.2 什么时候可以无脑用 ScreenToWorldPoint
有一种情况下ScreenToWorldPoint非常好用,那就是你的目标位置刚好在一个“垂直于相机视线”的平面上。
举例:FPS 游戏里,玩家面前有一块虚拟屏幕,鼠标指向哪,准星特效就出现在哪。这个特效的放置位置,就是相机前方固定距离处。代码可以这样写:
Vector3 screenPos = Input.mousePosition; screenPos.z = 10f; // 距离相机前方向量 10 米 Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos);在这个场景里,z=10 意味着所有屏幕点都被投影到相机前方 10 米、平行于相机近裁剪面的那个平面上,完全符合需求。
另一个常见场景是正交相机。正交相机没有透视效果,屏幕上的每个点对应一条“平行于相机 forward 的射线”,所以只要你把 z 设置成“目标平面到相机的垂直距离”,就能得到准确的世界坐标。很多经营游戏使用正交俯视相机,点击地面格子放置建筑时,形如酱紫的代码可以直接跑:
Vector3 screenPos = Input.mousePosition; screenPos.z = Mathf.Abs(transform.position.y - targetPlaneY); Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos); worldPos.y = targetPlaneY;正交相机下这套逻辑几乎没有误差,因为深度方向完全一致,屏幕像素点与地面格子的对应关系是一条直线平行线的关系。
3.3 透视相机下失效怎么办:深度点插值求交
如果你用的是透视相机(游戏行业 90% 的情况),并且相机不是垂直朝下,而是带了俯仰角,比如 45 度、60 度,那想用ScreenToWorldPoint精确得到地面坐标就没那么简单了。
原因是:把所有屏幕点放到同一个 z 值的时候,它们其实落在“相机前方一个平行于近裁剪面的平面”上,而不是地面上。当相机倾斜时,这个平面和地面是不重合的。
很多教程会让你“把 z 设成相机高度”,这在相机垂直朝下的时候是对的,但只要相机一倾斜,得到的点就会偏离实际点击位置。你点屏幕下方的地面,得到的点会偏近;点屏幕上方的地面,得到的点会偏远。
真正可靠的补救方案是:用ScreenToWorldPoint取出两个不同深度处的点,然后用线性插值方法计算出它们与目标平面的交点。实际操作时,等价于先构造一条射线,再让射线与数学平面求交。代码我不会直接调Physics.Raycast,而是用 Unity 的Plane结构体来算:
public static bool GetGroundPositionWithPlane(Camera cam, Vector3 mousePos, out Vector3 worldPos) { Ray ray = cam.ScreenPointToRay(mousePos); Plane groundPlane = new Plane(Vector3.up, Vector3.zero); // 地面在 y=0 if (groundPlane.Raycast(ray, out float enter)) { worldPos = ray.GetPoint(enter); return true; } worldPos = Vector3.zero; return false; }这个方案的好处是:不需要任何碰撞体,纯数学计算;相机任意角度都准确;性能比物理射线好得多。它虽然不是直接用ScreenToWorldPoint,但本质还是在解决“屏幕点击变世界坐标”的需求,可以算作方法二的一个增强版。
3.4 正交相机下的固定写法
正交相机是ScreenToWorldPoint的舒适区。固定写法长这样:
private Vector3 GetOrthographicGroundPoint(Camera cam) { Vector3 mousePos = Input.mousePosition; mousePos.z = cam.ScreenToWorldPoint(new Vector3(0, 0, 0)).y - cam.transform.position.y; // 简化写法:用相机高度近似 mousePos.z = -cam.transform.position.y; Vector3 world = cam.ScreenToWorldPoint(mousePos); world.y = 0f; return world; }注意这里我把 z 设成-cam.transform.position.y,因为正交相机的 forward 通常指向下?不,这里有个方向性问题。更保险的写法是用相机位置到目标平面的向量:
Camera cam = Camera.main; Vector3 screenPos = Input.mousePosition; float distance = Vector3.Distance(cam.transform.position, new Vector3(cam.transform.position.x, 0, cam.transform.position.z)); screenPos.z = distance; Vector3 worldPos = cam.ScreenToWorldPoint(screenPos); worldPos.y = 0f;但这段代码只适用于相机完全垂直朝下(forward 与 Vector3.down 平行)的情况。如果正交相机也带了倾角,那依然建议用Plane.Raycast,不要挣扎了。
4. 两种方法完整对照与选择策略
写到这里,两条路线各自的原理和坑都已经讲清楚了。接下来做个系统性的对照,帮助你在下一次需求来临时,10 秒钟内就能判断该用哪个。
4.1 核心差异对照表
| 对比维度 | ScreenPointToRay + Raycast | ScreenToWorldPoint |
|---|---|---|
| 依赖条件 | 场景中必须有 Collider | 不依赖场景物体,纯数学计算 |
| 返回内容 | 被击中的物体信息 + 命中点坐标 | 只有世界坐标 |
| 相机角度 | 任意角度都能准确 | 正交相机或目标面平行于相机视线时准确 |
| 性能消耗 | 物理查询,相对较重 | 纯数学运算,非常轻量 |
| 典型场景 | 点击拾取物体、射击判定、选中单位 | 放置物体、画线、网格高亮 |
| UI 遮挡处理 | 需要额外用 EventSystem 判断 | 同样需要额外判断 |
| 2D 游戏支持 | Physics2D.Raycast 单独写 | 需要结合正交相机 |
从表格能看出来,这两条路线的差异非常大,根本不是一个东西的两种写法。ScreenPointToRay适合“和场景物理交互”,ScreenToWorldPoint适合“和数学逻辑交互”。
4.2 性能与精度,到底选谁
性能方面,ScreenToWorldPoint是纯矩阵运算,开销可以忽略不计。Physics.Raycast则要遍历物理碰撞体、做粗测和精测,开销相对大很多,尤其在移动平台、场景里碰撞体数量多的情况下,频繁点击时会有肉眼可见的卡顿。但这不是说射线方案不能用,Unity 物理引擎做了大量优化,单次射线检测通常都在微秒级,大多数游戏里每帧执行几次完全没问题。
真正让我固执选择方案的关键矛盾点,是精度与语义的区别。射线方案拿到的是“真实物体表面的那个点”,它天然准确,但受限于碰撞体是否存在。ScreenToWorldPoint拿到的是“某个数学平面上的点”,它不依赖碰撞体,但在倾斜透视相机下,需要额外计算才能落到目标平面。
所以我的选型原则是这样的:能明确知道“点击目标一定在某个平面上”时,优先用 Plane 求交点;需要拾取具体物体时,用Physics.Raycast;只有正交相机或者目标平面平行于相机视线时,才直接用ScreenToWorldPoint。
4.3 混合使用的标准流程示例
用一个我实际做过的小功能来演示混合流程:制作一个即时战略游戏的“点击地面移动单位”操作。这个功能的要求是:点击地面时,单位走到点击位置;点击敌人时,单位攻击敌人;点击 UI 时,不做任何操作。
标准实现分三步:
第一步,判断是否点到 UI。用EventSystem.current.IsPointerOverGameObject(),点到了就直接 return。
第二步,生成一条射线:Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);先做一层Physics.Raycast,LayerMask 指定为 Enemy 层,看看是否点到敌人。如果点到敌人,进入攻击逻辑。
第三步,如果没有点到敌人,再对地面做处理。这里我没有继续物理射线,而是用Plane.Raycast来求地面交点,因为地面不一定要有真实碰撞体,而且Plane计算更快:
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); Plane ground = new Plane(Vector3.up, Vector3.zero); if (ground.Raycast(ray, out float enter)) { Vector3 destination = ray.GetPoint(enter); unit.MoveTo(destination); }这个流程把两种思路的优势都发挥了出来:敌人用物理射线拾取,地面用数学平面求交,不会互相干扰,也不会因为某个地面缺了碰撞体就点不动。
5. 实战问题记录与避坑清单
最后集中分享一些我在实际项目中踩过的坑和排查经验。这个清单我常年存档,每次接新项目都会拿出来过一遍。
5.1 高频问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 物体生成在相机附近 | ScreenToWorldPoint 的 z 值传了 0 或太小 | 手动设置 z 为合理距离 |
| 点击地面但射线打不中 | 地面没有 Collider,或 LayerMask 过滤掉 | 给地面加 Collider 或调整 LayerMask |
| 点击 UI 时底层物体也被点击 | 没有做 UI 判断 | 用 IsPointerOverGameObject 拦截 |
| 相机带倾斜角时坐标偏了 | 用 ScreenToWorldPoint 求地面交点 | 改用 Plane.Raycast 或物理射线 |
| 点击物体总差一点 | 相机物体位置、屏幕坐标原点不一致 | 确认屏幕坐标系以左下角为原点 |
| 2D 项目射线无效 | 用了 3D 的 Physics.Raycast | 改用 Physics2D.Raycast + RaycastHit2D |
5.2 三个我印象最深的坑
第一个坑是关于Camera.main。早期我写脚本很喜欢把Camera.main直接写在 Update 里,后来做了一款怪物数量很多的塔防游戏,每次点击屏幕都卡一下。用 Profiler 一看,Camera.main占了不少耗时,因为它本质是按 Tag 查找主相机。从那以后我养成了习惯:要么用 Inspector 缓存相机引用,要么在 Awake 里缓存Camera.main。
第二个坑是关于屏幕坐标系。Unity 的屏幕坐标系原点在左下角,x 向右、y 向上。但 UI 的 RectTransform 坐标系原点默认在中心,或者受锚点影响。有一回我做 “点击屏幕生成拖拽图标” 的功能,图标位置总是偏移半个屏幕,查了很久才发现是屏幕坐标换算成 UI 坐标时忘了把原点从中心挪到左下角。后来遇到坐标偏移,我第一反应就是先检查坐标系原点。
第三个坑是关于Screen.ToWorldPoint跟 Canvas 渲染模式。项目里的 UI 用的是 Screen Space - Overlay 渲染模式,我尝试用点击的屏幕坐标去定位 UI 上的元素,做了很多转换,后来发现 Overlay 模式下 UIToolkit 和 UGUI 的位置关系跟你想象的并不一致,直接读屏幕坐标是不可靠的。把 Canvas 混进坐标转换,复杂度会指数上升,能用 3D 世界坐标解决的就别让 UI 掺和进来。
5.3 一点建议
说实话,屏幕坐标转世界坐标,本质上就是一道投影几何题。新手期我总想找一个“万能函数”,希望能处理所有点击场景,但做了几个项目之后才明白,正确的姿势是“先明确需求,再选择工具”。
如果你现在的主战场是 3D 游戏,建议把ScreenPointToRay + Physics.Raycast练到肌肉记忆,再补上Plane.Raycast作为无碰撞体场景的救火队员。如果你做的是 2D 游戏,Camera.main.ScreenToWorldPoint(Input.mousePosition)是你每天都会见面的老朋友。
最后再分享一个小习惯:所有涉及点击坐标转换的代码,我都会在最外层做一个异常保护,避免相机为空、鼠标位置越界、射线打空等边界情况直接抛异常。一行if (Camera.main == null) return;看起来琐碎,但上线之后能帮你挡住不少线上报错。