1. 项目概述:为什么是时候告别 Input.GetTouch 了?
如果你是一个Unity移动端开发者,还在用Input.touchCount和Input.GetTouch(i)来吭哧吭哧地处理手势,那感觉就像是在智能手机时代还在用九宫格按键发短信。它能用,但效率低、代码臃肿、维护起来头疼,而且很难优雅地处理复杂的手势组合。今天要聊的,就是如何用Unity全新的Input System这套“现代化武器”,彻底重构移动端的核心手势交互:单指旋转和双指缩放。
Input System 不是简单的API替换,而是一套从底层重构的输入处理架构。它最大的价值在于将“原始输入数据”(如屏幕坐标)和“高层交互逻辑”(如“旋转”、“缩放”)解耦。对于移动端手势,这意味着你不再需要手动计算两帧之间的手指位移差来判断是拖拽还是滑动,也不再需要自己写逻辑去区分单指和双指状态,然后小心翼翼地处理手指抬起、按下的事件序列。Input System 通过可配置的Input Action Asset,让你能用可视化的方式定义“一个旋转手势应该由单指在屏幕上移动来触发”,然后直接在代码里响应一个干净的OnRotate回调事件,里面直接包含了旋转的角度增量。这带来的直接好处是代码可读性、可维护性和可扩展性的巨大提升,也是项目迈向更专业架构的必经之路。
这篇文章,我会从一个实际开发者的角度,带你从零开始,用 Input System 实现一套稳健、高性能且易于扩展的移动端手势交互系统。无论你是正在为新项目做技术选型,还是打算重构老项目的输入模块,这篇内容都能给你提供可直接“抄作业”的完整方案和避坑指南。
2. Input System 核心概念与项目配置
在动手写代码之前,我们必须先理解 Input System 的几个核心“积木”。这能让你明白我们不是在死记硬背步骤,而是在搭建一个合理的工程结构。
2.1 核心概念:Actions、Bindings 与 Processors
你可以把Input Action Asset想象成一个“输入动作清单”配置文件。在这个清单里,你定义各种“动作”(Actions),比如“移动”、“跳跃”、“开火”,以及我们今天要做的“旋转”和“缩放”。
每个“动作”需要关联到一个或多个绑定(Bindings)。绑定指明了这个动作由什么具体的物理输入触发。对于移动端,绑定的就是<Touchscreen>设备上的各种路径,比如<Touchscreen>/position(触摸位置)、<Touchscreen>/delta(触摸位移)、<Touchscreen>/primaryTouch(主触摸点)等。
处理器(Processors)是绑定的“后厨”,负责对原始输入数据进行加工。比如,触摸屏的delta(位移)是像素值,直接用来旋转物体可能太快或太慢。我们可以给它加一个Scale Vector2 Processor,乘上一个系数(如0.01),把像素位移转换成更合适的旋转速度。这个配置过程完全是可视化的,无需硬编码。
2.2 项目初始配置与包管理
首先,你需要确保项目已经安装了 Input System 包。打开 Unity,进入Window > Package Manager。在 Package Manager 窗口左上角,确保选择的是Unity Registry。在列表中找到Input System包,点击安装。安装过程中,Unity会提示你禁用旧的 Input Manager,选择“Yes”或“Disable and Replace”。这是关键一步,意味着项目将完全转向新的输入系统。
安装完成后,我们开始创建核心的配置文件。在 Project 窗口右键,选择Create > Input Actions,我将其命名为MobileTouchControls。双击这个文件,会打开Input Actions Editor窗口,这就是我们配置所有手势的“作战指挥中心”。
3. 手势交互的详细设计与动作映射
我们的目标是定义两个核心动作:Rotate(单指旋转)和Zoom(双指缩放)。它们的触发逻辑完全不同,需要在 Input Action Asset 中进行精心设计。
3.1 创建与配置 Rotate(单指旋转)动作
在 Input Actions Editor 中,点击+号创建一个新的 Action Map,可以命名为TouchGestures。Action Map 是一组相关动作的集合,方便我们整体启用或禁用。
在TouchGestures这个 Map 下,点击+创建第一个 Action,命名为Rotate。它的Action Type需要设置为Value。这是因为旋转是一个连续的过程,我们需要在每一帧(只要手指在移动)都获取到一个表示旋转方向和速度的二维向量值(Vector2)。
接下来,为Rotate动作添加绑定(Binding):
- 点击
Rotate动作下+号旁边的下拉箭头,选择Add Binding。 - 在右侧的Path中,点击搜索图标,找到
<Touchscreen>,然后选择<Touchscreen>/delta。delta代表的是触摸点从上一帧到当前帧的位移向量(以像素为单位)。 - 这个绑定默认是监听“主触摸点”(即第一个按下的手指)。这正是我们想要的单指旋转逻辑:用第一根手指的移动来驱动旋转。
但是,直接使用像素位移delta数值太大,旋转会非常快。我们需要添加处理器。在绑定列表中找到刚创建的<Touchscreen>/delta绑定,在它下方点击+号,选择Add Processor,然后选择Scale Vector2 Processor。在 Inspector 中,将 X 和 Y 的 Scale Factor 都设置为一个较小的值,比如0.2或0.3。这个值需要根据你的物体大小和摄像机距离来微调,它的作用是将像素位移“缩放”到一个合理的灵敏度。
注意:这里有一个关键点。
<Touchscreen>/delta在手指按住不动时,其值就是(0,0)。我们的Rotate动作是Value类型,这意味着只要绑定有输入(即使为0),它就会持续触发事件。这非常适合用来在Update循环中平滑地应用旋转。
3.2 创建与配置 Zoom(双指缩放)动作
缩放动作的逻辑更复杂一些,因为它需要计算两根手指之间的距离变化。Input System 没有直接提供“双指捏合”的原生绑定,但我们可以通过一个巧妙的组合来实现。
- 在
TouchGesturesAction Map 下,再创建一个新的 Action,命名为Zoom。它的Action Type同样设置为Value,因为缩放也是一个连续过程,我们需要一个表示缩放比例变化(float)的值。 - 为
Zoom动作添加绑定。这次,我们需要手动输入路径。点击添加绑定,在 Path 输入框中直接键入:<Touchscreen>/position。 - 这还没完。我们需要告诉 Input System,这个绑定需要读取两根手指的信息。在绑定列表中找到这个
<Touchscreen>/position绑定,在右侧 Inspector 中找到Interactions部分。虽然这里通常用于点击、长按等,但我们需要关注另一个属性:Processors和Bindings旁边的设置。 实际上,更标准的做法是使用“Vector2 Composite”。但针对双指缩放的特定算法,我推荐一种更直接、更易理解的自定义处理方式:我们创建一个Value动作,但通过 C# 脚本从 Input System 直接读取两个触摸点的位置,然后自己计算距离差。不过,为了保持 Action Asset 的整洁和逻辑统一,我们可以换一种思路:- 创建两个Value类型的动作:
PinchPositionA和PinchPositionB。 - 将
PinchPositionA绑定到<Touchscreen>/touch0/position(第一根手指)。 - 将
PinchPositionB绑定到<Touchscreen>/touch1/position(第二根手指)。 - 然后,在脚本中同时监听这两个动作,实时计算它们当前位置的距离,并与上一帧的距离比较,其差值(或比值)就构成了我们自定义的
Zoom数值。
- 创建两个Value类型的动作:
虽然第二种方法更底层,但第一种方法(在脚本中计算)对于理解手势本质更有帮助,也更容易调试。在本方案中,我将采用在脚本中实时计算双指距离的方式,这样我们可以更清晰地控制缩放的速度、平滑度和死区。因此,在MobileTouchControls这个 Asset 中,我们只需要确保能获取到多个触摸点的位置信息即可。我们可以创建一个名为MultiTouchPosition的Pass-Through类型的 Action,它不做任何处理,只是将所有触摸数据传递出来,供脚本解析。
4. 核心交互脚本的完整实现与解析
配置文件准备好后,就到了编写驱动逻辑的 C# 脚本环节。我们将创建一个名为TouchGestureController的 MonoBehaviour 脚本。
4.1 脚本初始化与 Input Action 引用
首先,我们需要引用刚才创建的 Input Action Asset,并声明各个动作的引用。
using UnityEngine; using UnityEngine.InputSystem; public class TouchGestureController : MonoBehaviour { // 引用我们创建的 Input Action Asset [SerializeField] private InputActionAsset touchControlsAsset; // 定义各个 Input Action private InputAction rotateAction; private InputAction pinchPositionAAction; private InputAction pinchPositionBAction; // 旋转控制的参数 [Header(“旋转设置”)] [SerializeField] private float rotationSpeed = 0.5f; [SerializeField] private Transform targetTransform; // 要旋转的物体 // 缩放控制的参数 [Header(“缩放设置”)] [SerializeField] private float zoomSpeed = 0.01f; [SerializeField] private float minZoomDistance = 2.0f; [SerializeField] private float maxZoomDistance = 20.0f; private Camera mainCamera; // 假设通过摄像机推拉实现缩放 private float initialPinchDistance = 0f; private bool isPinching = false; private void Awake() { // 从 Asset 中找到对应的 Action Map 和 Actions var touchMap = touchControlsAsset.FindActionMap(“TouchGestures”); rotateAction = touchMap.FindAction(“Rotate”); // 假设我们采用了第二种方案,定义了获取手指位置的动作 pinchPositionAAction = touchMap.FindAction(“PinchPositionA”); pinchPositionBAction = touchMap.FindAction(“PinchPositionB”); mainCamera = Camera.main; if (targetTransform == null) { targetTransform = transform; // 默认旋转自身 } } private void OnEnable() { // 启用 Action Map 和所有 Actions touchControlsAsset.FindActionMap(“TouchGestures”).Enable(); rotateAction.Enable(); pinchPositionAAction.Enable(); pinchPositionBAction.Enable(); } private void OnDisable() { // 禁用 Action Map 和所有 Actions rotateAction.Disable(); pinchPositionAAction.Disable(); pinchPositionBAction.Disable(); touchControlsAsset.FindActionMap(“TouchGestures”).Disable(); } }4.2 单指旋转逻辑的实现
旋转的逻辑相对直接。我们在Update循环中读取rotateAction的值,它是一个经过我们处理器缩放后的Vector2。
private void Update() { HandleRotation(); HandleZoom(); } private void HandleRotation() { // 读取旋转动作的输入值(经过Scale Processor处理后的delta) Vector2 rotationInput = rotateAction.ReadValue<Vector2>(); // 如果输入不为零(即手指在移动),则应用旋转 if (rotationInput != Vector2.zero) { // 典型的3D物体旋转:绕世界Y轴水平旋转,绕自身X轴垂直旋转 float horizontalRot = -rotationInput.x * rotationSpeed; // 注意方向,根据需求调整正负 float verticalRot = -rotationInput.y * rotationSpeed; targetTransform.Rotate(Vector3.up, horizontalRot, Space.World); targetTransform.Rotate(Vector3.right, verticalRot, Space.Self); } }实操心得:这里
rotationInput.x和rotationInput.y的正负方向可能与你的直觉不符。通常,手指向右滑动,我们希望物体向左转(像转动一个转盘)。所以这里用了负号-。这个方向感需要根据你的具体场景(是观察者旋转还是物体自转)来调整,务必在真机上测试手感。
4.3 双指缩放逻辑的完整实现
缩放逻辑是我们实现的重点和难点。我们需要实时追踪两根手指,计算它们每帧之间的距离变化。
private void HandleZoom() { // 读取两个手指的当前位置 Vector2 touchAPos = pinchPositionAAction.ReadValue<Vector2>(); Vector2 touchBPos = pinchPositionBAction.ReadValue<Vector2>(); // 检查是否至少有两根手指按下(位置不为零) bool hasTwoTouches = touchAPos != Vector2.zero && touchBPos != Vector2.zero; if (hasTwoTouches) { float currentDistance = Vector2.Distance(touchAPos, touchBPos); if (!isPinching) { // 刚开始捏合,记录初始距离,不进行缩放 initialPinchDistance = currentDistance; isPinching = true; } else { // 计算当前距离与初始距离的差值 float deltaDistance = currentDistance - initialPinchDistance; // 根据距离差计算缩放量 float zoomAmount = deltaDistance * zoomSpeed; // 应用缩放:这里以摄像机前后移动为例 ApplyCameraZoom(zoomAmount); // 更新初始距离为当前距离,为下一帧计算做准备 // 这种实现是“增量式”的,每一帧都基于上一帧的状态变化,操作更跟手。 initialPinchDistance = currentDistance; } } else { // 手指少于两根,重置捏合状态 isPinching = false; initialPinchDistance = 0f; } } private void ApplyCameraZoom(float zoomDelta) { if (mainCamera == null) return; // 假设是透视摄像机,通过改变位置来实现缩放 // 计算摄像机向前(朝向目标)移动的方向 Vector3 zoomDirection = mainCamera.transform.forward; // 计算新的位置 Vector3 newPosition = mainCamera.transform.position + zoomDirection * zoomDelta; // 计算新位置到目标点的距离(假设目标点在原点或某个固定点) float distanceToTarget = Vector3.Distance(newPosition, Vector3.zero); // 这里目标点简化了 // 钳制距离在最小和最大值之间 distanceToTarget = Mathf.Clamp(distanceToTarget, minZoomDistance, maxZoomDistance); // 重新计算摄像机位置,保持看向目标 // 这里需要一个更稳健的算法,例如让摄像机沿其与目标的连线移动 // 简化版:直接设置位置(效果可能不完美,适用于轨道摄像机) // 更好的做法是使用 Camera.transform.Translate 或修改 Field of View mainCamera.transform.position = mainCamera.transform.position + zoomDirection * zoomDelta; // 更推荐的做法:对于观察一个焦点的摄像机,使用以下逻辑 // Vector3 toCamera = mainCamera.transform.position - targetTransform.position; // float desiredDistance = toCamera.magnitude - zoomDelta; // desiredDistance = Mathf.Clamp(desiredDistance, minZoomDistance, maxZoomDistance); // mainCamera.transform.position = targetTransform.position + toCamera.normalized * desiredDistance; }核心细节解析:缩放算法这里提供了两种思路。一种是简单的每帧增量移动,操作直观但可能不够平滑,且容易超出边界。另一种是基于“摄像机与目标点距离”的计算,这是更常见的3D观察器缩放模式,能严格将距离限制在
[minZoomDistance, maxZoomDistance]范围内,体验更佳。具体选择取决于你的项目是2D UI缩放、3D模型查看器还是场景漫游。
5. 高级优化、问题排查与平台适配
实现基础功能只是第一步,要让手势交互真正“跟手”、“顺滑”且“稳定”,还需要一系列优化和问题处理。
5.1 手势冲突的解决与输入优先级管理
在一个复杂的UI界面中,你可能同时存在需要滚动的列表、可拖拽的卡片和我们的3D模型观察器。这就产生了手势冲突:一个在列表上的双指捏合,到底应该滚动列表还是缩放模型?
Input System 提供了强大的解决机制:输入动作的回调上下文(Callback Context)和交互(Interactions)。但对于手势冲突,更实用的策略是在逻辑层进行管理。
基于UI射线检测的屏蔽:在
HandleRotation和HandleZoom开始时,使用EventSystem.current.IsPointerOverGameObject()或GraphicRaycaster检测当前触摸点是否在需要优先响应的UI元素(如ScrollRect)上。如果是,则直接return,不执行模型的手势操作。private bool IsTouchingUI() { // 这是一个简化示例,实际中可能需要处理多个触摸点 PointerEventData eventData = new PointerEventData(EventSystem.current); eventData.position = Mouse.current.position.ReadValue(); // 对于触摸,需要用 Touchscreen 的位置 List<RaycastResult> results = new List<RaycastResult>(); EventSystem.current.RaycastAll(eventData, results); return results.Count > 0; } private void HandleRotation() { if (IsTouchingUI()) return; // 如果触摸点在UI上,不处理旋转 // ... 原有的旋转逻辑 }动作禁用/启用:你可以准备多个 Input Action Asset 或 Action Map,在不同的游戏状态(如在菜单中、在游戏中)启用不同的映射。当打开一个全屏UI时,直接禁用
TouchGestures这个 Action Map。
5.2 性能优化与平滑处理
移动设备性能敏感,输入处理必须高效。
- 避免在 Update 中频繁查找 Action:我们的代码在
Awake中已经缓存了InputAction引用,这是最佳实践。不要在Update里通过字符串名称去FindAction。 - 缩放平滑阻尼:直接使用每帧的距离差进行缩放,在低帧率或快速操作时可能会产生跳跃感。可以使用
Mathf.SmoothDamp对最终的zoomDelta或摄像机距离进行平滑插值。private float currentZoomVelocity; // 平滑阻尼的当前速度 private float smoothedZoomDelta; private void ApplyCameraZoom(float rawZoomDelta) { // 使用 SmoothDamp 平滑缩放输入 smoothedZoomDelta = Mathf.SmoothDamp(smoothedZoomDelta, rawZoomDelta, ref currentZoomVelocity, 0.05f); // 使用 smoothedZoomDelta 进行实际的缩放计算... } - 减少不必要的计算:在
HandleZoom中,当!hasTwoTouches时,我们及时重置了状态,避免了无效的状态维持和计算。
5.3 跨平台与真机调试要点
在编辑器中,我们可以用鼠标模拟触摸(Input System 默认支持),但真机表现才是关键。
- 编辑器模拟:在 Play 模式下,你可以用鼠标左键拖拽模拟单指触摸,按住
Ctrl(Windows) 或Cmd(Mac) 键的同时拖拽模拟第二指。这非常便于快速调试。 - 真机测试必做项:
- 帧率影响:在真机上,特别是中低端设备,帧率可能波动。测试你的旋转和缩放逻辑在帧率下降时是否依然平滑,会不会出现卡顿或跳变。
- 多点触控:测试三指及以上触摸时,你的逻辑是否健壮(我们的逻辑只处理前两指,是合理的)。确保不会因为第三指的落下或抬起导致状态错乱。
- 手势边界:快速、大幅度的滑动手势,以及非常缓慢的微操作,都要测试。检查旋转速度是否线性,缩放是否有迟滞或过冲。
- 与系统手势的冲突:在某些手机(特别是iOS和某些Android定制系统)上,从屏幕边缘滑动会触发返回或全局手势。你需要考虑是否要在你的应用内禁用这些手势,或者确保你的手势操作区域不会轻易触发它们。
6. 常见问题排查与实战技巧实录
即使按照步骤操作,你也可能会遇到一些“坑”。这里记录了几个我实战中遇到过的问题和解决方法。
问题1:旋转/缩放没有反应。
- 检查清单:
- Input Action Asset 是否赋值?在 Inspector 中确认
TouchGestureController脚本上的touchControlsAsset字段已经拖入了我们创建的MobileTouchControls文件。 - Action Map 和 Actions 是否启用?确保
OnEnable方法被正确调用(脚本所在的 GameObject 需要处于激活状态)。可以在OnEnable里加一句Debug.Log(“Touch controls enabled.”)来验证。 - 绑定路径是否正确?双击
MobileTouchControls文件,在 Input Actions Editor 中确认Rotate动作绑定到了<Touchscreen>/delta,并且处理器参数设置得当。 - 目标 Transform 或 Camera 是否赋值?检查
targetTransform和脚本获取的mainCamera是否有效。
- Input Action Asset 是否赋值?在 Inspector 中确认
问题2:旋转方向反了,或者缩放是反向的。
- 解决方案:这是最常见的问题。调整
HandleRotation中horizontalRot和verticalRot计算时的正负号。例如,把-rotationInput.x改成rotationInput.x。对于缩放,检查ApplyCameraZoom中zoomDelta的符号。通常,手指分开(距离变大)应导致摄像机拉近(Zoom In),这时zoomDelta应为负值,你可能需要zoomDirection * -zoomDelta或调整zoomSpeed的正负。
问题3:双指缩放时,如果先放下一根手指再按下,状态会错乱。
- 问题分析:我们的逻辑基于
touch0和touch1。如果用户先放下touch0(第一根手指),系统会将还在屏幕上的那根手指重新分配为touch0。而我们代码里pinchPositionAAction和pinchPositionBAction仍然固定绑定到touch0和touch1,这就对不上了。 - 优化方案:不要固定绑定到
touch0和touch1。改为在HandleZoom中,通过Touchscreen.current.touches这个控件数组,动态获取屏幕上所有有效的触摸点,然后选择其中两个(例如,最先按下的两个,或最近移动的两个)来计算距离。这需要更复杂的触摸点追踪管理,但能提供更稳健的多点触控体验。
问题4:在UI按钮上操作,仍然触发了模型旋转。
- 解决方案:这就是前面提到的冲突管理。确保你的
IsTouchingUI()方法能正确工作。对于触摸输入,获取位置需要使用Touchscreen.current.primaryTouch.position.ReadValue()或其他活跃触摸点的位置,而不是Mouse.current.position。同时,确保你的UI元素(如Button、ScrollRect)拥有GraphicRaycaster组件,并且Blocking Objects设置正确。
一个实用的调试技巧:在开发过程中,创建一个简单的调试UI,实时显示rotateAction.ReadValue<Vector2>()的数值,以及两根手指的位置和计算出的距离。这能让你直观地看到输入系统是否按预期工作,以及你的算法是否正确处理了这些数据。可以在OnGUI或使用 UGUI Text 组件来显示这些信息。
从古老的Input.GetTouch迁移到现代的 Input System,初期确实需要一点学习成本来理解其基于“动作”的哲学。但一旦掌握,你会发现它带来的代码清晰度、可维护性和跨平台一致性是巨大的。本文实现的单指旋转和双指缩放,是一个坚实的起点。基于这个框架,你可以轻松地扩展出更多手势,如长按、双击、双指旋转(扭转)等,只需要在 Input Action Asset 中定义新的动作和绑定,并在脚本中添加相应的处理逻辑即可。记住,好的输入系统应该让玩家感觉不到它的存在,却又无比跟手。