资讯详情 Unity 2D物体拖拽全攻略:核心原理、常见坑与通用组件
📅 2026/10/14 6:37:59
说实话刚开始做Unity 2D物体拖拽的时候我差点把键盘摔了。当时在做一个小游戏Demo需求很朴素玩家按住一个方块把它拖到另一个位置松手落下。我心想这么简单的交互Unity 不是自带一堆物理和输入处理嘛随便写写就完事了。结果真上手才发现光是“按下不跳变、拖动跟手、松手稳定”这三个看似基础的要求就踩了整整一下午的坑。如果你现在也在做Unity 2D物体拖拽效果并且遇到了物体乱飞、拖拽卡顿、或者物体被UI挡住点不到之类的问题这篇文章应该能帮你省下不少时间。我不打算只丢一套代码给你而是把拖拽背后的坐标转换原理、几种常见方案的取舍、以及实际项目中真正能落地的通用组件按我踩坑的顺序重新捋一遍。不管你是刚接触Unity的新手还是已经写过一些拖拽逻辑但想做得更稳的开发者这篇都值得你花十分钟看完。1. 先理清楚:2D拖拽到底有几条路可以选1.1 最容易上手但坑最多的OnMouse系列Unity的脚本里只要写了OnMouseDown()、OnMouseDrag()、OnMouseUp()配合一个Collider就能让物体响应鼠标。我第一次写拖拽就是用的这个三五行代码搞定当时觉得世界真美好。using UnityEngine; public class SimpleDrag : MonoBehaviour { private void OnMouseDrag() { Vector3 mousePos Camera.main.ScreenToWorldPoint(Input.mousePosition); mousePos.z 0f; transform.position mousePos; } }这段代码在场景很简单、只有一个小方块的时候运行起来是正常的。但你很快会遇到几个问题第一个是物体“瞬移”到鼠标位置。按下鼠标那一刻物体的中心会直接跳到鼠标指针所在的位置而不是保持你按住的相对位置。如果方块小、鼠标按在中间感觉不明显但凡你按的是方块边缘整个物体就会“啪”地跳一下体验非常生硬。第二个问题是Collider依赖。OnMouse系列本质上靠的是Unity的物理射线检测所以物体必须有Collider2D而且如果鼠标前面挡着别的碰撞体点到的可能就是那个遮挡物。再加上这套API只考虑了鼠标没有细粒度地处理移动端多点触控做到后面基本得推翻重来。1.2 更可控的屏幕坐标转世界坐标方案另一种思路是自己处理输入再用ScreenToWorldPoint把鼠标位置转换成世界坐标去控制物体的位置。相比OnMouse系列这种方案把“输入来源”和“物体跟随”完全解耦你想监听鼠标还是触摸都行想加偏移、平滑、范围限制也都方便。这也是我后面真正在项目里采用的方案。它并不复杂核心就是一套坐标转换鼠标在屏幕上有一个2D坐标(x, y)通过相机把它转换成3D世界坐标把世界坐标交给物体听起来很简单但里面藏着一个特别的坑跟相机的Z轴有关这个我放到第2部分细说。1.3 带物理反馈的刚体拖拽如果你的物体需要跟其他物体碰撞、需要重力、需要在拖拽时推挤其他东西那就不应该直接改transform.position而是用Rigidbody2D配合速度或MovePosition来做。这样Unity的物理引擎才会正确参与碰撞检测才不会出错。这种方案的代价是物体的移动不再是你给什么位置它就立刻到那儿而是有惯性、有延迟参数需要调。如果你只是做一个UI拼图或者抽屉式的拖放玩法用刚体可能会让你觉得“手感很肉”。但如果是做推箱子、物理益智、或者模拟类游戏刚体拖拽才是正路。1.4 移动端触摸输入的区别到了移动端鼠标那套逻辑不能直接照搬。好在Unity的Input.touches接口把触摸状态分得很清楚Began按下、Moved移动、Ended抬起。你要做的就是把鼠标按下的时机和触摸按下的时机对应起来。还有一点容易被忽略触摸有fingerId也就是第几根手指。如果你只处理第一根手指Input.GetTouch(0)那当玩家另一根手指也按下时逻辑就会乱。好在我们做单物体拖拽时一般只关心第一根手指就行但如果你做多指同时拖多个物体就必须按fingerId去区分了。方案类型依赖组件优点主要缺点适用场景OnMouse系列Collider2DAPI简单代码少隐藏坑多移动端弱极简单Demo、原型验证屏幕坐标转世界坐标Camera Collider2D完全可控易扩展需要自己写监听取状态正式项目、拼图、拖拽玩法刚体拖拽Rigidbody2D Collider2D保留物理碰撞手感偏重参数调起来费时物理益智、推箱子类触摸输入无原生支持多指、拇指操作需要自己处理手指编号发布到iOS/Android2. 核心机制:坐标转换才是拖拽手感好坏的胜负手2.1 屏幕坐标、世界坐标和相机到底怎么换算很多人写拖拽卡就卡在搞不清坐标系。Unity里你看到的画面是相机把3D世界坐标投影到屏幕上的结果。反过来要从鼠标的屏幕坐标得到世界坐标也就是做一次逆投影。Camera.main.ScreenToWorldPoint就是干这个的。注意这个方法的参数是一个Vector3前两个是屏幕坐标第三个是深度值代表你要转换到哪个垂直于相机Z轴的平面上。这里有一个初学者必踩的坑习惯性把z写成了0f。在2D游戏里相机一般在(0, 0, -10)或类似的位置如果你把z设为0那你转换出来的坐标根本不在Sprite所在的平面上结果就是物体要么跑到相机背后要么跟着鼠标在奇怪的方向上移动。正确做法是用物体当前屏幕坐标的z值作为转换深度Vector3 screenPoint cam.WorldToScreenPoint(transform.position); Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, screenPoint.z) );这句话解释起来也很直白我先看看物体现在被画在屏幕上的“深度”是多少然后让鼠标也换算到这一层。这样鼠标和物体就处于同一个平面上换算出来就是准确的世界坐标。2.2 跳变问题:为什么按下瞬间物体会“嗖”地飞过来原因很简单物体的移动是直接设置了transform.position 鼠标世界坐标等于把物体的中心点强行拉到了鼠标指针的位置。你按的是边缘中心自然要跳过去。解决方式也很基础在按下的时候算出一个偏移量拖动的过程中始终拿“鼠标坐标 偏移量”作为目标位置offset transform.position - mouseWorld;offset代表物体中心与按下点之间的相对位置。拖动时把偏移加回去物体就能老老实实地保持你按住的相对位置不会出现瞬移跳变。2.3 为什么直接赋值会抖动,平滑插值才是关键如果你的游戏帧率不稳定直接赋值的拖拽会显得很生硬物体一顿一顿地跟着鼠标。这是因为每一帧鼠标位置变化量是固定的但帧间隔却不一样导致物体位移速度忽快忽慢。想让拖拽更“丝滑”有两种做法Vector3.Lerp当前位置向目标位置插值Vector3.SmoothDamp带阻尼的平滑跟随手感更像“物体被拖着走”我在项目里更推荐SmoothDamp因为它自带一个速度参数可以控制物体跟手的快慢和柔韧性。你只需要在类里声明一个Vector3 velocity字段每次拖动时调用transform.position Vector3.SmoothDamp( transform.position, targetPos, ref velocity, smoothTime );smoothTime越小跟随越紧越大拖拽越“飘”。我通常用0.05到0.1之间具体手感根据你的游戏类型微调。2.4 一个让拖拽范围受限的常用技巧有时候你不想让物体飞出某个区域比如拼图游戏的碎片只能在拼图框内移动或者拖拽的杆子只能在一定范围内滑动。这种情况不要在Update里写一堆判断条件更稳妥的做法是在计算完目标位置后手动Clamp一下坐标float minX -3f, maxX 3f; float minY -2f, maxY 2f; targetPos.x Mathf.Clamp(targetPos.x, minX, maxX); targetPos.y Mathf.Clamp(targetPos.y, minY, maxY);如果范围不是规则的矩形也可以用Vector2.Distance限制到某个中心点的半径内。总之拖拽逻辑的核心是“先算目标再约束目标最后赋值”顺序不要颠倒。3. 实操:一个能直接用的通用2D拖拽组件3.1 第一步,搞定基础移动我们先从最简版开始。下面这个脚本挂在任何带Collider2D的物体上它就能被鼠标拖走。注意我在Awake里缓存了相机引用别在拖动时反复Camera.main这个属性内部有查找开销每帧调用会让GC和CPU都不太舒服。using UnityEngine; [RequireComponent(typeof(Collider2D))] public class BasicDrag2D : MonoBehaviour { private Camera cam; private Vector3 offset; private float targetZ; private void Awake() { cam Camera.main; } private void OnMouseDown() { Vector3 screenPoint cam.WorldToScreenPoint(transform.position); targetZ screenPoint.z; Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, targetZ) ); offset transform.position - mouseWorld; } private void OnMouseDrag() { Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, targetZ) ); Vector3 targetPos mouseWorld offset; targetPos.z transform.position.z; transform.position targetPos; } }到这一步拖拽已经能跟手了。但你会发现如果物体被别的Sprite挡住你可能根本点不到它。接下来处理层级问题。3.2 第二步,拖拽时自动把物体提到最上层2D游戏里所有元素都是靠SortingLayer和SortingOrder来决定渲染顺序的。你的Sprite默认SortingOrder是0另一个遮挡物是100那你就永远点不到那个被挡住的物体。临时处理方式是在按下时把SpriteRenderer的sortingOrder调大比如设成1000松手后再恢复原来的值。这样只要拖拽开始这个物体就会跑到所有普通物体前面视觉上也会觉得它“被拿起来了”。private SpriteRenderer spriteRenderer; private int defaultOrder; private void Awake() { cam Camera.main; spriteRenderer GetComponentSpriteRenderer(); if (spriteRenderer ! null) { defaultOrder spriteRenderer.sortingOrder; } } private void OnMouseDown() { // ... 前面的坐标与偏移计算 if (spriteRenderer ! null) { spriteRenderer.sortingOrder 1000; } } private void OnMouseUp() { if (spriteRenderer ! null) { spriteRenderer.sortingOrder defaultOrder; } }这里有一个小细节如果你拖拽的物体层级关系本身就很复杂比如背后还有别的动态物体那么“恢复默认order”可能不够最好自己维护一套排序规则。我项目里用的是一个全局的顶置排序层拖拽开始从100递增每次拖一个新物体就加1这样多个物体叠加时总能看到当前拖的那个。3.3 第三步,加入SmoothDamp平滑继续往同一个脚本里增加平滑逻辑public float smoothTime 0.08f; private Vector3 velocity Vector3.zero; private void OnMouseDrag() { Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, targetZ) ); Vector3 targetPos mouseWorld offset; targetPos.z transform.position.z; transform.position Vector3.SmoothDamp( transform.position, targetPos, ref velocity, smoothTime ); }注意SmoothDamp会引用一个velocity字段你需要把它声明成类的成员变量不能每次重新new否则平滑效果会失效。加完这一步拖拽手感会立刻柔和很多。尤其在帧率不稳的场景平滑插值比直接赋值要稳定得多。3.4 完整版代码,直接拷过去就能用把上面三部分拼在一起就是一个很通用的2D拖拽组件。using UnityEngine; [RequireComponent(typeof(Collider2D))] public class DragAny2DObject : MonoBehaviour { [Header(平滑参数)] public float smoothTime 0.08f; [Header(拖拽时自动置顶)] public bool bringToFront true; private Camera cam; private Vector3 velocity Vector3.zero; private Vector3 offset; private float targetZ; private SpriteRenderer spriteRenderer; private int defaultOrder; private void Awake() { cam Camera.main; spriteRenderer GetComponentSpriteRenderer(); if (spriteRenderer ! null) { defaultOrder spriteRenderer.sortingOrder; } } private void OnMouseDown() { Vector3 screenPoint cam.WorldToScreenPoint(transform.position); targetZ screenPoint.z; Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, targetZ) ); offset transform.position - mouseWorld; if (bringToFront spriteRenderer ! null) { spriteRenderer.sortingOrder 1000; } } private void OnMouseDrag() { Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, targetZ) ); Vector3 targetPos mouseWorld offset; targetPos.z transform.position.z; transform.position Vector3.SmoothDamp( transform.position, targetPos, ref velocity, smoothTime ); } private void OnMouseUp() { if (bringToFront spriteRenderer ! null) { spriteRenderer.sortingOrder defaultOrder; } } }这段代码的用法就三步创建空物体或Sprite物体 - 挂上脚本 - 确保它有Collider2D。不需要额外写输入管理不用考虑相机角度适用于绝大多数正交相机的2D游戏。4. 移动端触摸和历史物理拖拽的实用改造4.1 触摸版本:用Input.touches实现同样效果OnMouse系列在移动端也能勉强用但既然都已经决定自己控制了我建议直接写触摸版本这样你能处理更多细节比如多点触控、触摸刚开始时是否按在UI上。下面这段代码的思路和上面完全一样只是把输入源从鼠标换成了触摸。using UnityEngine; public class TouchDrag2D : MonoBehaviour { public float smoothTime 0.08f; private Camera cam; private Vector3 offset; private float targetZ; private Vector3 velocity Vector3.zero; private bool isDragging false; private int draggingFingerId -1; private void Awake() { cam Camera.main; } private void Update() { if (Input.touchCount 0) return; for (int i 0; i Input.touchCount; i) { Touch touch Input.GetTouch(i); if (touch.phase TouchPhase.Began) { Ray ray cam.ScreenPointToRay(touch.position); RaycastHit2D hit Physics2D.Raycast(ray.origin, ray.direction); if (hit.collider ! null hit.collider.gameObject gameObject) { isDragging true; draggingFingerId touch.fingerId; Vector3 screenPoint cam.WorldToScreenPoint(transform.position); targetZ screenPoint.z; Vector3 world cam.ScreenToWorldPoint( new Vector3(touch.position.x, touch.position.y, targetZ) ); offset transform.position - world; } } else if (touch.phase TouchPhase.Moved isDragging touch.fingerId draggingFingerId) { Vector3 world cam.ScreenToWorldPoint( new Vector3(touch.position.x, touch.position.y, targetZ) ); Vector3 targetPos world offset; targetPos.z transform.position.z; transform.position Vector3.SmoothDamp( transform.position, targetPos, ref velocity, smoothTime ); } else if (touch.phase TouchPhase.Ended touch.fingerId draggingFingerId) { isDragging false; draggingFingerId -1; } } } }draggingFingerId这个变量是关键。它保证了你只跟随最初按下物体的那根手指移动不会因为第二根手指按下去而抢走控制权。这个细节在桌面端看不到区别但放到真机上测试两指同时操作时就能感受到差距。4.2 刚体拖拽:保留物理碰撞的正确姿势如果你不想让物体穿过别的碰撞体那就要用Rigidbody2D。直接改transform.position会绕过物理引擎导致物体硬生生瞬移到目标点甚至和障碍物重叠。正确做法是修改刚体的速度或者调用Rigidbody2D.MovePosition。我个人更推荐速度方案因为它对物理碰撞的反应更自然。using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] public class PhysicsDrag2D : MonoBehaviour { public float dragSpeed 10f; private Camera cam; private Rigidbody2D rb; private Vector3 offset; private float targetZ; private float originalGravity; private void Awake() { cam Camera.main; rb GetComponentRigidbody2D(); originalGravity rb.gravityScale; } private void OnMouseDown() { Vector3 screenPoint cam.WorldToScreenPoint(transform.position); targetZ screenPoint.z; Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, targetZ) ); offset transform.position - mouseWorld; rb.velocity Vector2.zero; rb.gravityScale 0f; } private void OnMouseDrag() { Vector3 mouseWorld cam.ScreenToWorldPoint( new Vector3(Input.mousePosition.x, Input.mousePosition.y, targetZ) ); Vector3 targetPos mouseWorld offset; Vector2 moveDirection targetPos - transform.position; rb.velocity moveDirection * dragSpeed; } private void OnMouseUp() { rb.velocity Vector2.zero; rb.gravityScale originalGravity; } }把gravityScale在拖拽期间改成0是为了防止物体在拖到一半时继续受重力往下掉。如果你希望物体被拖起来之后依然有轻微下坠感也可以不设成0而是调小一点比如0.1f这个要看你具体想要的手感。dragSpeed这个值可以理解为“物体向目标点冲的强度”。调太大会导致物体猛烈撞向障碍物调太小又觉得拖不动。我的经验是在8到15之间调整配合Rigidbody2D的Linear Drag线性阻力一起调能找到一个比较跟手又不发飘的状态。4.3 鼠标和触摸逻辑复用的思路如果你做的是PC手机双端游戏不想写两套拖拽代码也有一个通用思路用Input.mousePosition模拟单点触摸或者反过来把触摸归一化成鼠标接口。比较省事的是在PC上继续用OnMouse系列在移动端用触摸脚本两者只是挂的组件不同。如果你想维护一套可以把输入封装成接口比如定义一个GetPressPosition()、GetRelease()之类的方法在Update里先判断平台是移动端还是桌面端再选择从Input.touches还是Input.mousePosition取数据。这样拖拽逻辑本身不用变变动的只有输入源头。不过对于大多数中小型项目来说写两个分量级不重反而更好维护。5. 大概率会踩的坑:症状、原因、解决5.1 物体一拖就乱飞,跑到相机后面这个我刚开始做的时候几乎必现。原因是ScreenToWorldPoint的第三个参数z写成了0f或者直接不传。如果相机在(0,0,-10)z0会落在相机前方的某个斜面上物体的移动轨迹会被拉得很奇怪。解决方式就是用物体自己的屏幕坐标z值作为深度参数也就是我在2.1里写的targetZ。养成这个习惯之后不管相机在什么位置、什么角度拖拽都不会乱飞。5.2 按下瞬间物体跳一下这就是典型的偏移量缺失。如果你的拖拽直接写的是“物体位置 鼠标位置”那无论如何都会跳。在OnMouseDown或TouchPhase.Began里用按下的瞬时鼠标世界坐标减去物体当前坐标算出offset之后再进入拖动就能彻底解决。5.3 拖拽一卡一卡,或者掉帧大多数情况出在频繁调用Camera.main和GetComponent上。这两个操作内部都有一些查找和分配的耗时如果在Update或OnMouseDrag里每帧调用会带来不必要的开销。优化方式在Awake或Start里把相机引用、SpriteRenderer、Rigidbody2D都缓存成字段。另外一个隐藏点是new Vector3本身并不算什么性能问题但如果你频繁创建GC压力也会随之增大尽量复用同一个变量或者直接构造。5.4 点击物体没反应最常见的原因是物体上没有Collider2D。OnMouse系列和Physics2D.Raycast都要求有碰撞体没有Collider2D那射线就打不到。其次是物体被别的Collider挡住射线先命中了前面的物体。另外如果物体在UI下面鼠标点上去被UI的Raycast拦截就不会传入物理系统。这就是UI和3D/2D物体的输入冲突问题了。5.5 拖拽时UI按钮也被触发这个很典型。你想拖一个Sprite但手指落下时恰好在一个UI按钮上Unity的EventSystem也会响应按钮也亮了。解决思路是在拖拽逻辑开头加一道“是否点在UI上”的判断。using UnityEngine.EventSystems; private bool IsPointerOverUI() { if (EventSystem.current null) return false; #if UNITY_EDITOR || UNITY_STANDALONE return EventSystem.current.IsPointerOverGameObject(); #else if (Input.touchCount 0) return false; return EventSystem.current.IsPointerOverGameObject(Input.GetTouch(0).fingerId); #endif }然后把返回值当作拦截条件一旦点在了UI上就不走拖拽逻辑。这个方法在PC上用的是IsPointerOverGameObject()在移动端要传入fingerId否则会判断失效。5.6 刚体拖拽时物体穿过其他墙体出现这个问题通常是你直接改动了transform.position把物理引擎的碰撞检测绕过去了。换个方式用rb.velocity或rb.MovePosition。如果你发现用了MovePosition还是穿透检查一下刚体的Collision Detection Mode把它从Discrete改成Continuous高速移动下碰撞检测会更可靠。症状常见原因快速解决物体乱飞或跑到相机后方ScreenToWorldPoint的z值不对用WorldToScreenPoint(transform.position).z作为深度按下瞬间跳一下没记录偏移量offset按下时计算offset 物体位置 - 鼠标世界位置拖拽卡顿掉帧每帧Camera.main、GetComponentAwake时缓存引用点击完全没反应缺少Collider2D或被遮挡加Collider2D、检查射线命中UI和物体同时响应EventSystem和物理同时接收用IsPointerOverGameObject()拦截刚体穿透墙体直接改了transform.position改用rb.velocity或MovePosition6. 几个能直接加进项目的小扩展6.1 拖拽结束时自动吸附拼图类游戏经常需要“松手后自动吸附到最近格子”这个在拖拽代码里加一个判断就行。在OnMouseUp或触摸Ended阶段检测目标位置与某个预设锚点的距离小于阈值就直接把物体移动到锚点位置并做一点插值动画效果会很自然。private void SnapToNearestSlot() { float nearestDist float.MaxValue; Vector2 nearestPoint transform.position; foreach (Vector2 slot in slots) { float dist Vector2.Distance(transform.position, slot); if (dist nearestDist) { nearestDist dist; nearestPoint slot; } } if (nearestDist snapThreshold) { transform.position nearestPoint; // 触发回调事件,例如音效、得分、下一个激活等 } }6.2 长按拖拽和误触判定的取舍有些游戏里玩家是点击和拖拽共存的或者操作比较精细误触率太高。一个折中方案是按下时不立刻开始拖拽而是等几帧或者移动距离超过一个阈值比如10像素后再进入拖动状态。这样即便玩家只是短暂点击也不会误拖物体。实现上可以记录按下时的屏幕坐标在Moved阶段判断当前触摸位置和初始位置的距离是否超过阈值。如果没超过就继续等待超过了就正式把isDragging置为true后续才更新物体位置。6.3 拖拽事件接口,把逻辑和表现解耦当你的项目越来越大我不建议在拖拽脚本里直接写音频、动画、任务系统之类的东西。更清晰的思路是定义一套事件接口让其他系统订阅拖拽的开始、进行中、结束。public interface IDragHandler2D { void OnDragStart(Vector2 startWorldPos); void OnDragUpdate(Vector2 currentWorldPos); void OnDragEnd(Vector2 endWorldPos); }拖拽组件在对应时机调用接口方法你的玩法逻辑只需要实现这个接口挂在同或物体上或者通过事件中心转发。好处是拖拽组件永远只关心“如何被拖”而“拖完之后干嘛”留给了业务层。这个抽象层级虽然初期多写了几个方法但等你要扩展回收站、计分、关卡逻辑时会舒服很多。6.4 拖拽范围限制的一个具体例子给物体加一个跟随范围的限制实际上只需要在targetPos赋值之后做一次Clamp。我做过一个抽屉类的玩法物体只能沿X轴移动Y轴保持不变Vector3 targetPos mouseWorld offset; targetPos.y transform.position.y; // 锁定Y轴 targetPos.x Mathf.Clamp(targetPos.x, minX, maxX);这种做法比在Update里反复判断“是否越界、是否回拉”要干净得多。记住拖拽逻辑的主线永远是“计算目标位置 - 修正目标位置 - 移动到目标位置”所有边界约束都应该放在第二步不要放在第三步之后。我个人在实际项目里的习惯是把基础拖拽写成一个通用组件然后业务逻辑全部通过事件往外抛。这样不管后续是接音频、做吸附、还是做物品栏反应都不需要再去改拖拽的核心代码。代码量看着多了点维护起来反而省心。如果你正被2D物体的拖拽折磨照着这套思路从最简单的版本开始搭就行先保证能拖再加上偏移和平滑最后按需处理层级和物理。等这三个层次都稳了你会发现所谓的“手感不好”绝大多数都是坐标转换和插值细节没做到位。