做Unity交互的都知道鼠标拖拽旋转模型是个高频需求。但如果你搜“Unity 旋转模型”拿到的多半是自由旋转——鼠标往哪拖模型就往哪转用来观察物体或者相机环绕实现很简单。今天我想聊的是另一类把模型当成时钟指针来拖。这类需求在项目里其实很常见仪表盘指针跟随鼠标调整角度、时钟界面拨动时针设闹铃、机械臂关节转动、音量旋钮在3D场景里旋转。表面上看不过是“拖动旋转”六个字但实际做起来一堆细节指针围绕的是固定轴而不是自由旋转、鼠标在透视相机下的世界坐标偏移、跨360度时的角度连续性、最大最小角限制。这些不处理好做出来的效果就是“拖起来很怪”。这篇文章我会从一个完整可跑的脚本出发把实现思路、关键代码、场景搭建、参数配置以及踩过的坑都过一遍。适合刚接触Unity 3D交互的同学也适合想优化现有旋转交互的中级开发者。你会发现真正难的不是旋转本身而是选对“鼠标位置到旋转角度的映射方式”。1. 需求分析与方案选型1.1 先分清你要的是哪种旋转动手前先明确一个根本问题你说的“拖动旋转”到底是哪一类第一种是自由视角拖转例如模型展示场景中按住鼠标左键拖动模型绕世界Y轴和X轴旋转方便观察。特征是旋转轴会随时切换鼠标移动向量直接映射成角度增量。这类需求网上源码一大把用一句Rotate(delta.x, delta.y, 0)就完事。第二种是单轴指针拖转模型或UI元素绕一个固定轴转动拖动它时指针应该精准地指向鼠标当前位置或者跟随鼠标移动产生平滑增量。时钟指针就是典型——它只能绕表盘中心转旋转轴固定。标题里的“类似时钟拖动指针”几乎可以确定是第二种。我在项目里见过有人把第一种代码直接搬去调仪器指针结果转动方向不对、鼠标漂移、指针乱转最后一查是根本没用对方案。1.2 两条技术路线确定了是单轴旋转接下来选实现。业内主流做法有两条方案核心思路优点缺点屏幕空间映射把鼠标位置投影到屏幕以物体中心为原点计算方向角实现简单正交相机下很稳透视相机下有视差误差物体偏离屏幕中心越远误差越大射线平面交点从相机向鼠标投射线与穿过旋转中心的平面求交任意相机、任意角度都准确代码稍多需要理解平面求交多数情况下我推荐第二条。它听起来复杂其实只是一次射线计算加一个向量投影。而且现代Unity的Plane.Raycast写起来非常干净。还有个小众做法是直接读取Input.mousePosition的横向位移量来旋转也就是把屏幕的像素位移换算成角度。这种办法在UI控件里偶尔用但在3D场景里基本不靠谱——因为同样的屏幕距离在不同观察角度下对应的旋转量完全不同。1.3 动手前确定三个参数不管选哪条路线有三个参数总是绕不开的我建议在写代码前就想清楚旋转轴指针绕哪个轴转是模型自身的局部轴还是世界坐标轴大多数情况下是局部轴比如指针模型绕着它的上方向转。零点方向角度为0时指针指向哪里通常就是指针模型的初始朝向比如一个长方体指针的尖端方向。这个是角度计算的基准丢了它旋转后你根本不知道当前转了多少度。角度范围要不要限制最大最小角度时钟是自由360度但仪表盘往往只允许0到270度。这个参数决定了角度累加时要不要做Clamp。这三个参数想明白代码基本就能一气呵成。磨刀不误砍柴工。2. 核心实现射线平面法2.1 射线平面法的原理先打个比方。假想你的指针旋转中心上立着一块无形的玻璃板这块玻璃板垂直于旋转轴。你从鼠标位置朝场景里射出一束光光线穿过屏幕后打到这块玻璃上留下一个光斑。我们要做的就是让指针旋转到“指向光斑”的方向。具体计算分四步根据旋转轴和旋转中心点构造一个无限平面。用鼠标位置向Camera发射一条射线与平面求交点。用交点减去旋转中心得到指针目标方向向量。计算该方向向量与零点参考方向之间的夹角旋转模型到这个角度。这里面最难理解的是第4步。三维空间里任何方向向量都能分解成“垂直于旋转轴的成分”和“沿旋转轴的成分”。我们只需要保留垂直于轴的部分然后在这个二维子空间里用Atan2求角度就能得到稳定的0~360度角度值。用代码表达就是float angle Mathf.Atan2( Vector3.Dot(dir, perp), Vector3.Dot(dir, refDir) ) * Mathf.Rad2Deg;其中refDir是零度参考方向perp是轴向量 × refDir得到的另一个正交方向。这样算出来的角度不受相机位置影响随便你怎么移动相机指针都能精准指向鼠标在玻璃板上的投影。2.2 完整脚本直接能跑的版本下面这个脚本是简化版你可以直接挂到指针模型上测试。我把几个关键开关都做成了可配置项省得每次改需求都要动代码。using UnityEngine; public class PointerDragRotator : MonoBehaviour { [Header(旋转轴)] [Tooltip(指针围绕哪个局部轴旋转)] public Vector3 rotateLocalAxis Vector3.up; [Tooltip(指针的零度参考方向通常是模型尖端朝向)] public Vector3 pointerLocalAxis Vector3.forward; [Header(交互模式)] [Tooltip(拖拽起始时是否立刻吸附到鼠标方向)] public bool snapOnGrab false; [Tooltip(是否点击场景任意位置都可以拖动否则只有命中指针才响应)] public bool dragFromAnywhere false; [Header(角度限制0表示不限制)] public float minAngle 0f; public float maxAngle 360f; private Camera mainCamera; private Vector3 worldAxis; private Vector3 zeroRefDir; private bool isDragging; private float lastMouseAngle; private float currentAngle; private void Start() { mainCamera Camera.main; Vector3 axisLocalToWorld transform.TransformDirection(rotateLocalAxis).normalized; worldAxis axisLocalToWorld; Vector3 refLocalToWorld transform.TransformDirection(pointerLocalAxis); Vector3 refOnPlane refLocalToWorld - Vector3.Project(refLocalToWorld, worldAxis); if (refOnPlane.sqrMagnitude 0.0001f) { Debug.LogError(pointerLocalAxis 与 rotateLocalAxis 平行无法确定零度方向, this); return; } zeroRefDir refOnPlane.normalized; currentAngle GetObjectAngleOnPlane(); } private void Update() { if (Input.GetMouseButtonDown(0)) { TryBeginDrag(); } else if (Input.GetMouseButtonUp(0)) { isDragging false; } if (isDragging) { DragTick(); } } private void TryBeginDrag() { if (!dragFromAnywhere !IsPointerHit()) return; if (!TryGetPlaneHit(out Vector3 hitPoint)) return; float mouseAngle GetAngleOnPlane(hitPoint - transform.position); if (snapOnGrab) { float delta Mathf.DeltaAngle(currentAngle, mouseAngle); ApplyDeltaAngle(delta); isDragging true; } else { lastMouseAngle mouseAngle; isDragging true; } } private void DragTick() { if (!TryGetPlaneHit(out Vector3 hitPoint)) return; float mouseAngle GetAngleOnPlane(hitPoint - transform.position); float delta Mathf.DeltaAngle(lastMouseAngle, mouseAngle); lastMouseAngle mouseAngle; ApplyDeltaAngle(delta); } private void ApplyDeltaAngle(float delta) { if (Mathf.Approximately(delta, 0f)) return; float nextAngle currentAngle delta; bool useAngleLimit maxAngle minAngle; if (useAngleLimit) { nextAngle Mathf.Clamp(nextAngle, minAngle, maxAngle); } float actualDelta nextAngle - currentAngle; if (Mathf.Approximately(actualDelta, 0f)) return; transform.Rotate(worldAxis, actualDelta, Space.World); currentAngle nextAngle; } private bool TryGetPlaneHit(out Vector3 hitPoint) { hitPoint Vector3.zero; Plane plane new Plane(worldAxis, transform.position); Ray ray mainCamera.ScreenPointToRay(Input.mousePosition); if (plane.Raycast(ray, out float enter)) { hitPoint ray.GetPoint(enter); return true; } return false; } private float GetAngleOnPlane(Vector3 dir) { if (dir.sqrMagnitude 0.0001f) return 0f; Vector3 dirOnPlane dir - Vector3.Project(dir, worldAxis); if (dirOnPlane.sqrMagnitude 0.0001f) return 0f; dirOnPlane.Normalize(); Vector3 perp Vector3.Cross(worldAxis, zeroRefDir).normalized; float angle Mathf.Atan2( Vector3.Dot(dirOnPlane, perp), Vector3.Dot(dirOnPlane, zeroRefDir) ) * Mathf.Rad2Deg; return angle 0f ? angle 360f : angle; } private float GetObjectAngleOnPlane() { Vector3 pointerDir transform.TransformDirection(pointerLocalAxis); return GetAngleOnPlane(pointerDir); } private bool IsPointerHit() { Ray ray mainCamera.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 1000f)) { return hit.transform transform || hit.transform.IsChildOf(transform); } return false; } }2.3 关键细节角度换算与360度跨越这个脚本里有几个细节值得单独拿出来讲。第一个是Mathf.DeltaAngle的用法。它返回的是两个角度之间最短的有符号差值范围在-180到180之间。举个例子假设上一帧鼠标角度是350度这一帧变成10度两者直接相减是-340度如果用这个去旋转指针会绕很大一圈。但用DeltaAngle(350, 10)得到的是20度指针只往正方向转20度就到了10度位置这才是拖动指针应该有的手感。第二个是我不推荐用transform.eulerAngles来读取当前角度。原因很简单欧拉角不仅会受到对象初始化旋转的影响还容易受Unity内部绕序限制一旦指针模型带有父物体旋转或者自身有其他方向的初始旋转读出来的数值会和实际绕轴角度对不上。脚本里改用自己的currentAngle变量来累加只在设置旋转时使用真实的四元数变换中间过程全部在角度值上运算。这套模式配合角度限位很舒服。第三个是零度参考方向要在Start里固定下来不能用当前的指针方向实时计算。如果你在运行时用transform.TransformDirection(pointerLocalAxis)作为参考会发现拖到一半指针会产生一个越来越偏的误差。因为旋转后指针方向变了参考方向也跟着变角度基准不断漂移。固定一次后面所有角度都相对初始状态计算逻辑才自洽。2.4 小优化累计角度与限位的配合上面代码里currentAngle的累加方式完美处理了限位需求。假设你希望指针只在0到90度之间活动当用户往正方向拖到90度时Mathf.Clamp会把目标角度压回90度实际旋转为0指针卡住不动。此时用户继续同向拖动nextAngle一直是91度、95度被clamp为90表现正常。反过来当用户拖到临界点后反向拖nextAngle会小于90clamp 不再干预指针流畅地反向转动不会出现“卡住后突然跳回”的怪异表现。这一点看似简单但如果你用transform.localRotation和欧拉角做限位很容易在跨0度或跨90度时出现回弹。3. 实操搭一个可拖动的仪表盘3.1 场景搭建与检查为了验证脚本效果我搭过一个小Demo一个仪表盘形状的圆盘加上一根指针。你也可以用 Unity 自带的基础几何体快速验证。创建空物体Pivot作为指针的旋转中心放在表盘中心位置。创建 Cube 或 Capsule 作为指针缩成长条状放在Pivot下面让它从中心向外延伸。表盘用一个 Cylinder 平放旋转90度让圆形面朝上或者朝前都可以看你的应用场景。挂上PointerDragRotator配置旋转轴和零度参考方向。这里有个容易忽略的点如果用dragFromAnywhere false指针模型必须有一个Collider否则Physics.Raycast根本打不中它。我见过太多人挂完脚本发现没反应排查半天是Cube没有Collider——其实Unity给Cube默认加了BoxCollider但如果你用的是Sprite或者从外部导入的模型Collider很可能没有生成记得手动加一个。3.2 参数配置与测试以“指针绕世界的Y轴旋转表盘平放在XZ平面”为例配置方式是这样的rotateLocalAxis设为(0, 1, 0)。如果指针模型在Pivot的子节点里那么这时用局部轴(0, 1, 0)会跟随父级旋转表盘如果自身有朝向修正也只需要调整父物体脚本不用改。pointerLocalAxis设为指针尖端的朝向。假设指针是沿局部坐标Z轴方向伸长的就写(0, 0, 1)。如果指针沿X方向伸长就写(1, 0, 0)。相机采用透视模式也没问题默认角度下拖起来应该像“磁吸”一样按下鼠标指针转到鼠标方向拖动指针跟着走。测试时建议先打开Scene视图把Gizmos开起来观察每次点击后交点的位置。我在调试时习惯在TryGetPlaneHit里加一行Debug.DrawLine(hitPoint, hitPoint Vector3.up * 0.2f, Color.red, 2f)这样能直观看到鼠标射线与虚拟平面的交点排查方向对不对非常有用。3.3 两个必踩的坑第一坑指针一开始就跳变。原因多半是零度参考方向选错了比如指针本来沿X方向却配成了Z方向或者Start里参考向量没有投影到旋转平面。我的排查建议是在Start里打印zeroRefDir和worldAxis确认参考向量垂直于旋转轴点积应接近0。第二坑透视相机下用屏幕坐标算角度导致漂移。有同学图省事直接用Camera.ScreenToWorldPoint把鼠标坐标转成世界坐标再和物体中心做向量夹角。结果物体在屏幕边缘时指针明明不在鼠标位置。这是因为ScreenToWorldPoint需要一个深度参数深度不同映射出来是完全不同的世界点。射线法完全避开这个坑因为它通过平面求交自动确定了深度的正确值。4. 增量拖拽、角度限位与UGUI适配4.1 增量拖拽不要吞掉用户的手感脚本默认情况下snapOnGrab false按下鼠标的瞬间不会让指针跳到鼠标方向而是记录当前鼠标角度作为基准随后拖动产生的角度差增量叠加到指针上。这就是“增量拖拽”。增量拖拽的手感很像拧一个真实存在的旋钮你捏住它它不会瞬间跳到你的手指位置而是随着你移动平滑转动。很多交互上更自然。那么“绝对吸附”snapOnGrab true什么时候用呢我通常用在设置类交互上比如点一个日期控件希望指针立刻指示鼠标点击的位置而不是从当前位置慢慢转过去。两个模式都在脚本里做了开关可以在Inspector里随时切换对比比改代码方便得多。4.2 角度限位的实现补充前面2.4里讲了累计角度配合clamp的做法。这里补充一种特殊情况如果minAngle和maxAngle的跨度跨过了0度边界比如 -30 到 30clamp 依然能正常工作。因为currentAngle delta是在连续数轴上运算的不是做成0-360的循环。但要注意初始currentAngle的取值范围要和 min/max 一致。如果你的应用场景要求角度值始终在0-360之间显示可以在外面包一层归一化float displayAngle currentAngle % 360f; if (displayAngle 0) displayAngle 360f;这只影响显示不影响实际旋转我在调试面板里用这种方式把角度可视化出来。4.3 UGUI里的旋钮控件如果需求是做一个2D UI旋钮多半不用射线平面法直接屏幕空间映射更简单高效。在UGUI中步骤如下给旋钮的 Image 添加一个EventTrigger或者直接写一个继承MonoBehaviour的类实现IDragHandler,IPointerDownHandler,IPointerUpHandler。使用RectTransformUtility.ScreenPointToLocalPointInRectangle把屏幕坐标转成UI局部坐标。用Atan2(localPoint.y, localPoint.x)计算出相对旋转中心的角度直接把localEulerAngles.z设置为该角度。下面是简化版代码using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; public class SliderKnob : MonoBehaviour, IDragHandler, IPointerDownHandler { private RectTransform rt; private void Awake() { rt transform as RectTransform; } public void OnPointerDown(PointerEventData eventData) { RotateToPointer(eventData); } public void OnDrag(PointerEventData eventData) { RotateToPointer(eventData); } private void RotateToPointer(PointerEventData eventData) { if (rt null) return; Vector2 localPoint; if (RectTransformUtility.ScreenPointToLocalPointInRectangle(rt, eventData.position, eventData.pressEventCamera, out localPoint)) { float angle Mathf.Atan2(localPoint.y, localPoint.x) * Mathf.Rad2Deg; rt.localEulerAngles new Vector3(0f, 0f, angle); } } }这个UI方案不再需要 Collider、Camera 等配置因为它直接基于屏幕坐标系。但是注意它的旋转轴永远垂直于UI平面也就是Z轴所以你只能做出“单轴平面旋转”的效果。用途正好是时钟指针、旋转按钮这类UI控件。还有个小细节如果旋钮图片上带有一条指示线需要让指示线的初始方位对齐X轴正方向这样Atan2返回的角度才是正确的显示角度。如果图片里的指针朝上Y轴正方向就需要angle angle 90f或者把localPoint的x和y交换。我通常直接在图片制作阶段就把刻度线画在水平朝右的方向省得在代码里做偏移。5. 常见问题排查速查表整理一下我在实际调试中遇到的高频问题做成速查表方便对照排查。现象可能原因解决方案按下鼠标指针无反应模型没有Collider或dragFromAnywherefalse时射线没打中检查指针是否有Collider临时把dragFromAnywhere设为true验证拖动方向反了rotateLocalAxis符号反了或相机观察方向与旋转轴方向相反反转轴向量或在计算perp时对调叉乘顺序指针拖动时乱跳零度参考方向没有固定或者参考方向与旋转轴平行确保pointerLocalAxis不与rotateLocalAxis平行参考向量只在Start算一次只能朝一个方向转反向卡死minAngle/maxAngle配置不合理且clamp把反向变更吞掉了检查限位设置确认currentAngle初值在限位范围内物体在屏幕边缘时指针偏离鼠标用了ScreenToWorldPoint而不是射线平面法换成射线与平面求交或使用上文中的TryGetPlaneHit旋转轴不是预期轴物体挂在有旋转的父节点下局部轴方向变了使用世界轴向量或者修改父节点旋转后在Scene里查看Gizmos松手后角度保存不正常外部代码在Update里重置了模型旋转交互过程中禁用其它旋转逻辑或者用currentAngle作为唯一角度来源排查问题有个通用技巧先把所有配置简化到最小比如dragFromAnywheretrue、minAngle0、maxAngle360、相机对准物体正面。逐步放宽条件每次都只改动一个变量很快能定位问题。越急着同时改好几个参数越容易把自己绕晕。尾巴一点个人经验做这类交互多了我最大的体会是不要一上来就写旋转代码先把“鼠标如何映射到角度”想明白。屏幕空间法适合UI射线平面法适合3D二者没有优劣选错才是最大的坑。用射线法时把Debug.DrawLine留在代码里调试效率至少提升一倍。另外建议把角度限制、吸附模式这些参数全部开放到Inspector策划和美术同事调起来会感谢你。我最初写死常量每次别人改需求都得找我改代码后来全参数化项目里的使用率反而比预想高很多。这个交互后续还可以往外扩展——比如加一个旋转加速度模拟惯性松手后指针继续减速旋转或者配合固定时间更新闹钟数值再或者做成双指旋转支持触屏。基础框架搭对了这些都只是叠加功能而已。希望这篇对你项目里那个“时钟指针”有帮助。