原文:Advanced VR Mechanics With Unity and the HTC Vive Part 1
作者:Eric Van de Kerckhove
译者:kmyhy
VR 从来没有这样时髦过,但是游戏不是那么好做的。为了提供真实的沉浸式体验,游戏内部机制和物理必须让人觉得非常、非常的真实,尤其当你在和游戏中的对象进行交互的时候。
在本教程的第一部分,你会学习如何创建一个可扩展的交互系统,并在系统中实现多种抓取虚拟物品的方式,并飞快地将它们扔出去。
学完本教程后,你可以拥有几个灵活的交互系统并可以用在你自己的 VR 项目中。
注意:本教程适合于高级读者,不会涉及如何添加组件、创建新的游戏对象脚本或者 C# 语法这样的东西。如果你需要提升自己的 Unity 技能,请先阅读我们的 getting started with Unity 和 introduction to Unity Scriptin,然后在阅读本文。
开始
在本教程中,你将必须具备下列条件:
- 安装好 Unity 5.6.0f3(或以上)
- 一套带手柄的、安装好、电源开启,准备就绪的 HTV View。
如果你之前没有用过 HTC Vive,你可以去看我们之前的 HTC Vive tutorial,以了解如何在 Unity 中使用 HTC Vive。HTC Vive 是目前最好的头戴式显示器之一,它所支持的 room-scale 功能提供了精彩的沉浸式体验。
下载开始项目,解压缩,用 Unity 打开项目文件夹。
在项目窗口中看一下目录结构:
分别介绍如下:
- Materials: 场景中用到的材质。
- Models: 本文用到的所有模型。
- Prefabs: 目前只有一个预制件,用于关卡中随处可见的柱子。
- Scenes:游戏画面和灯光数据。
- Scripts: 有几个现成的脚本;你自己的脚本也会放到这里。
- Sounds: 弓箭射出的声音。
- SteamVR: 放置 SteamVR 插件及其相关脚本、预制件和示例。
- Textures: 包含了几乎所有模型都共用的纹理(为了效率),以及 book 对象的纹理。
打开 Scenes 文件夹下的 Game 场景。
看一下 Game 视图,你会发现场景中缺少了相机:
在下一节,我们来解决这个问题,添加必要的东西,让 HTC Vive 能够工作。
场景设置
将 SteamVR\Prefabs 目录中将 [CameraRig] 和 [SteamVR] 预制件拖进结构视图。
摄像机现在应该是在地上,但要将它放在木塔上。将 [CameraRig] 的 position 修改为 (X:0, Y:3.35, Z:0) 。现在 Game 视图应该是这个样子:
保存场景,按 Play 按钮试一下是否顺利。四处逛逛,起码用一支手柄试试看能够看到游戏中的控制器。
如果手柄不工作,别担心!在写到此处的时候,最新版的 SteamVR 插件(版本 1.2.1)在 Unity 5.6 中有一个 bug,导致手柄的动作没有被注册。
要解决这个问题,选择 [CameraRig]/Camera (head) 下选择的 Camera (eye),然后为它添加一个 SteamVR_Update_Poses 组件:
这个脚本手动修改手柄的位置和角度。再次运行这个场景,问题解决了。
在编写任何脚本之前,看一下项目中的这几个 tag:
这几个 tag 允许我们更加容易判断哪种种对象发生碰撞或者触象。
交互系统:InteractionObject
交互系统允许场景中的玩家和物理用一种灵活的、模块化的方式进行交互。替代为每个对象和控制器编写重复的代码,你将编写几个类给其它脚本进行继承。
第一个脚本是 RWVR_InteractionObject 类;所有能够被交互的对象都应该从此类继承。这个基类中包含了几个基本的变量和方法。
注意:为了避免和 SteamVR 创建冲突或者便于搜索,本文中所有 VR 脚本都使用 RWVR 前缀。
新建文件夹 Scripts/RWVR。新建类 RWVR_InteractionObject。
打开这个脚本,删除 Start() 和 Update() 方法。
添加下列变量,就在类声明的下方:
protected Transform cachedTransform; // 1
[HideInInspector] // 2
public RWVR_InteractionController currentController; // 3
你可能会看到报错 “RWVR_InteractionController couldn’t be found”。目前请忽略它,后面我们会创建这个类。
上面代码分别解释如下:
- 为了改善性能,将 tranform 值缓存。
- 这个属性使下面的变量在检视器窗口中不可见,哪怕它是public 的。
- 当前对象正在交互的手柄。后面我们会用到这个手柄。
保存脚本,回到编辑器。
在 RWVR 下面新建一个 C# 文件 RWVR_InteractionController。打开它,删除 Start() 和 Update() 方法,保存。
打开 RWVR_InteractionObject ,之前的错误消失。
注意:如果错误仍然存在,关闭代码编辑器,点一下 Unity,然后再次打开脚本。
在刚刚添加的变量后面新增 3 个方法:
public virtual void OnTriggerWasPressed(RWVR_InteractionController controller)
{currentController = controller;
}public virtual void OnTriggerIsBeingPressed(RWVR_InteractionController controller)
{
}public virtual void OnTriggerWasReleased(RWVR_InteractionController controller)
{currentController = null;
}
这 3 个方法会在手柄的扳机按下、按住和放开时调用。当手柄被按下时,controller 被赋值,当它释放时,controller 被移除。
所有方法都是虚方法,它们将在更复杂的脚本中覆盖,以便它们能使用这些控制器回调方法。
在 OnTriggerWasReleased 方法后新增方法:
public virtual void Awake()
{cachedTransform = transform; // 1if (!gameObject.CompareTag("InteractionObject")) // 2{Debug.LogWarning("This InteractionObject does not have the correct tag, setting it now.", gameObject); // 3gameObject.tag = "InteractionObject"; // 4}
}
分别解释如下:
- 缓存 transform 以改善性能。
- 检查 InteractionObjet 是否有指定的 tag 值。如果没有,执行 if 后面的代码。
- 在检视器中输出一个警告,告诉开发者忘记设置 tag。
- 及时设置 tag,以便对象能够像我们期望的工作。
这个交互系统严重依赖于 InteractionObject 和控制器的 tag 来区分特殊对象和其它对象。忘记设置 tag 是很可能的,所以我们专门为这个编写了脚本。这是一种“失效保险”的设计。小心使得万年船。
最后,在 Awake() 方法后添加方法:
public bool IsFree() // 1
{return currentController == null;
}public virtual void OnDestroy() // 2
{if (currentController){OnTriggerWasReleased(currentController);}
}
这些方法分别负责:
- 一个公有的 Boolean 方法,表示当前对象是否正在被控制器所用。
- 当对象被销毁,将它从当前控制器(如果有的话)中释放。这有助于解决一些莫名其妙的问题。
爆粗脚本,打开 RWVR_InteractionController。
现在它还是空的。我们马上会充实它!
交互系统: Controller
控制器脚本是最重要的部分,因为它是玩家和游戏之间的直接联系。尽可能地接受输入并返回用户正确的反馈很重要。
首先,在类声明下面添加变量:
public Transform snapColliderOrigin; // 1
public GameObject ControllerModel; // 2[HideInInspector]
public Vector3 velocity; // 3
[HideInInspector]
public Vector3 angularVelocity; // 4private RWVR_InteractionObject objectBeingInteractedWith; // 5private SteamVR_TrackedObject trackedObj; // 6
分段解释如下:
保存对手柄尖端的引用。后面我们会添加一个透明的球,表示你能够到触摸的位置以及距离你可以够到的地方有多远:
手柄的可见对象。上图中白色的部分。
- 手柄的速度和方向。可以用于计算当你做抛掷时物体如何飞出。
- 手柄的角度,在抛掷时计算物体的移动也会用到它。
- 手柄当前正在交互的 InteractionObjecdt 对象。用它来向当前对象发送事件。
- 用于获得真实手柄的引用。
继续在下面添加:
private SteamVR_Controller.Device Controller // 1
{get { return SteamVR_Controller.Input((int)trackedObj.index); }
}public RWVR_InteractionObject InteractionObject // 2
{get { return objectBeingInteractedWith; }
}void Awake() // 3
{trackedObj = GetComponent<SteamVR_TrackedObject>();
}
代码解释如下:
- 这个变量通过 trackedObj 获得了一个对真实 SteamVR 手柄的引用。
- 返回和手柄进行交互的 InteractionObjecdt。对这个对象进行再次封装,是为了对其他类保持只读。
- 最后,保持一个和当前控制器相绑定的 TrackedObject 组件的引用,以便后面用到。
然后是这个方法:
private void CheckForInteractionObject()
{Collider[] overlappedColliders = Physics.OverlapSphere(snapColliderOrigin.position, snapColliderOrigin.lossyScale.x / 2f); // 1foreach (Collider overlappedCollider in overlappedColliders) // 2{if (overlappedCollider.CompareTag("InteractionObject") && overlappedCollider.GetComponent<RWVR_InteractionObject>().IsFree()) // 3{objectBeingInteractedWith = overlappedCollider.GetComponent<RWVR_InteractionObject>(); // 4objectBeingInteractedWith.OnTriggerWasPressed(this); // 5return; // 6}}
}
这个方法从控制器的碰撞体的某个范围内查找 InteractionObject。一旦找到一个,就将赋给 objectBeingInteractedWith。
代码解释如下:
- 创建一个碰撞体的数组,保存 OverlapSpherer() 方法找到的所有碰撞体,查找的位置和 scale 是 snapColliderOrigin,这是一个透明球体,如上图所示,我们后面会添加它。
- 遍历整个数组。
- 如果找到的碰撞体 tag 值等于 InteractionObject,同时它又是自由的,继续。
- 保存碰撞体的 RWVR_InteractionObject 在 objectBeingInteractedWidth。
- 调用 objectedBeingInteractedWith 的 OnTriggerWasPressed 方法,将当前控制器传递给它。
- 退出循环,完成查找。
新增方法,调用刚刚的这个方法:
void Update()
{if (Controller.GetHairTriggerDown()) // 1{CheckForInteractionObject();}if (Controller.GetHairTrigger()) // 2{if (objectBeingInteractedWith){objectBeingInteractedWith.OnTriggerIsBeingPressed(this);}}if (Controller.GetHairTriggerUp()) // 3{if (objectBeingInteractedWith){objectBeingInteractedWith.OnTriggerWasReleased(this);objectBeingInteractedWith = null;}}
}
代码非常简单:
- 当扳机被按下时,调用 CheckForInteractionObject() 方法,说明有可能发生了一次交互。
- 当扳机被按住时,同时有一个对象被抓住时,调用这个对象的 OnTriggerIsBeingPressed()。
- 当扳机被松开,同时有一个对象被抓住时,调用这个对象的 OnTriggerWasReleased() 方法,并停止交互。
这些检查确保玩家的所有输入都能被传递到正在和他们交互的 InteractionObject 对象。
添加两个方法,记录控制器的速度和角速度:
private void UpdateVelocity()
{velocity = Controller.velocity;angularVelocity = Controller.angularVelocity;
}void FixedUpdate()
{UpdateVelocity();
}
FixedUpdate() 以固定帧率调用 UpdateVelocity() ,后者更新 velocity 和 angularVelocity 变量。然后,你会将这两个值传递给一个刚体,以确保扔出去的东西能够更真实的移动。
有时候需要隐藏手柄,以确保体验更加浸入式,避免遮住视线。再添加两个方法:
public void HideControllerModel()
{ControllerModel.SetActive(false);
}public void ShowControllerModel()
{ControllerModel.SetActive(true);
}
这些方法简单地启用或禁用代表了控制器的 GameObject。
最后加入这两个方法:
public void Vibrate(ushort strength) // 1
{Controller.TriggerHapticPulse(strength);
}public void SwitchInteractionObjectTo(RWVR_InteractionObject interactionObject) // 2
{objectBeingInteractedWith = interactionObject; // 3objectBeingInteractedWith.OnTriggerWasPressed(this); // 4
}
代码解释如下:
- 这个方法造成了控制器中的压电线型驱动器(这个词不是我编造的)振动多次。它振动的时间越长,震动感就越强烈。它的强度是 1-3999。
- 这个方法将激活的 InteractionObject 换成参数指定的对象。
- 将指定的 InterationObject 变成激活状态。
- 在新的 InteractionObject 对象上调用 OnTriggerWasPressed() 方法,并传入当前控制器。
保存脚本,回到编辑器。为了让控制器按照我们的想法工作,还需要做一些调整。
在结构视图中选中两个控制器。它们都是[ CameraRig ]的子对象。
给它们各添加一个刚体。这允许它们使用固定连接,并和其它物体进行交互。
反选 Use Gravity,勾选 Is Kinematic。控制器不需要受物理的影响,因为在真实世界中,它们被你抓在手上。
将 RWVR_Interaction 控制器组件提交给两个手柄。我们待会要配置它。
展开 Controller(left),右键点击它,选择 3D Object > Sphere,为它添加一个球体。
选中球体,命名为 SnapOrigin,按 F 键让它在场景视图中居中。你会在地板中央看到一个巨大的白色半球体。
设置它的 Position 为 (X:0, Y:-0.045, Z:0.001) ,Scale 设为 (X:0.1, Y:0.1, Z:0.1)。这会将球放到控制器的前端。
删除 Sphere Collider 组件,因为物理检查通过代码进行。
最后,将它的 Mesh Renderer 修改为 Transparent 材质,让球体透明。
复制 SnapOrigin,将 SnapOrigin(1) 拖到 Controller(right)上,变成右手柄的子对象。命名为 SnapOrigin。
最后一步是创建控制器,使用它们的模型和 SnapOrigin。
选择并展开 Controller(left),将它的 SnapOrigin 子对象拖到 Snap Collider Origin 一栏中,将 Model 拖到 Controller Model 一栏。
在 Controller(right) 上重复同样的动作。
现在来放松一下!打开手柄电源,运行这个场景。
将手柄举到头盔前面,看看球体是否能够看见并和控制器粘在一起。
测试完后,保存场景,准备进入交互系统的使用!
用交互系统抓取物体
你可能看到附近有这些东西:
你只能看着它们,但无法把它们拿起来。你最好尽快解决这个问题,否则你怎么去读我们那本精彩的 Unity 教程呢?:]
为了和这些刚体进行交互,你需要创建一个新的 RWVR_InteractionObject 子类,用它来实现抓和扔的功能。
在 Scripts/RWVR 目录下创建新的 c# 脚本,名为 RWVR_SimpleGrab。
用代码编辑器打开它,删除里面的 Start() 和 Update() 方法。
将这一句:
public class RWVR_SimpleGrab : MonoBehaviour
修改为:
public class RWVR_SimpleGrab : RWVR_InteractionObject
这样这个类就继承了 RWVR_InteractionObject,后者提供了获得控制器输入的钩子,这样它就能对输入进行适当的处理。
在类声明下面声明几个变量:
public bool hideControllerModelOnGrab; // 1
private Rigidbody rb; // 2
很简单:
- 一个标志,用于表示控制器模型是否应该在该物体被拿起时隐藏。
- 为了性能和简单起见,缓存了刚体组件。
在变量声明之后添加方法:
public override void Awake()
{base.Awake(); // 1rb = GetComponent<Rigidbody>(); // 2
}
- 调用基类的 Awake() 方法。这会缓存对象的 Transform 组件并检查 InteractionObject 的 tag 是否赋值。
- 保存刚体组件,以便后面使用。
然后是一些助手方法,用于将对象用 FixedJoint 附着在手柄上,或者从手柄上放开。
在 Awake() 方法后面添加:
private void AddFixedJointToController(RWVR_InteractionController controller) // 1
{FixedJoint fx = controller.gameObject.AddComponent<FixedJoint>();fx.breakForce = 20000;fx.breakTorque = 20000;fx.connectedBody = rb;
}
private void RemoveFixedJointFromController(RWVR_InteractionController controller) // 2
{if (controller.gameObject.GetComponent<FixedJoint>()){FixedJoint fx = controller.gameObject.GetComponent<FixedJoint>();fx.connectedBody = null;Destroy(fx);}
}
这两个方法分别用于:
- 这个方法接收一个控制器作为参数,然后创建一个 FixedJoint 组件添加到手柄上,配置这个连接,使它不是那么容易掉,最后连接上当前的 InteractionObjecdt。在连接上添加一个力是为了防止用户将对象移过其他坚固的物体上,否则可能导致一些奇怪的物理问题。
- 将参数指定的控制器的 FixedJoint 组件(如果有的话)断开。所连接的对象将被删除,然后销毁 FixedJoint。
写完这些方法,我们可以实现来自于基类的几个 OnTrigger 方法,以处理用户输入。首先添加 OnTriggerWasPressed() 方法:
public override void OnTriggerWasPressed(RWVR_InteractionController controller) // 1
{base.OnTriggerWasPressed(controller); // 2if (hideControllerModelOnGrab) // 3{controller.HideControllerModel();}AddFixedJointToController(controller); // 4
}
这个方法在玩家按下扳机抓住一个对象时添加 FixedJoint 连接。代码分为几个阶段:
- 覆盖基类的 OnTriggerWasPressed() 方法。
- 如果 hideControllerModelOnGrab 标志为 true,隐藏控制器模型。
- 添加一个 FixedJoint 到控制器。
最后一步是添加 OnTriggerWasReleased() 方法:
public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{base.OnTriggerWasReleased(controller); //2if (hideControllerModelOnGrab) // 3{controller.ShowControllerModel();}rb.velocity = controller.velocity; // 4rb.angularVelocity = controller.angularVelocity;RemoveFixedJointFromController(controller); // 5
}
这个方法移除参数指定的控制器的 FixedJoint,将控制器的速度传递给刚体,以实现真实的抛掷效果。代码解释如下:
- 覆盖基类的 OnTriggerWasReleased() 方法。
- 调用基类方法解绑控制器。
- 如果 hideControllerModelOnGrab 标志为 true,再次显示控制器模型。
- 将控制器的速度和角速度传递给对象的刚体。这样当你放开对象时,对象会表现出真实的行为。例如,如果你扔出一个球,你会将手柄从后向前做一个抛物线动作。球应当获得旋转和向前的力,就像是在真实世界中你将动能传递给它一样。
- 删除 FixedJoint。
保存脚本,返回编辑器。
骰子和书在 Prefabs 文件夹中都有相应的预制件。在项目视图中打开这个文件夹:
选择 Book 和 Die 预制件,将 RWVR_Simple Grab 组件添加到二者。同时开启 Hide Controller Model。
保存场景运行游戏。尝试拿起几本书或骰子,扔到一边。
在下一节,我将介绍另一种抓取对象的方法:吸附。
拿起对象和吸附对象
在手柄所在的位置和角度拿起东西是可以的,但有时候将手柄吸附到物体的某个位置可能更有用。例如,如果用户看到一只枪,当他们拿起枪时会希望枪被指向右边。这就是 snapping (吸附)的意思。
为了吸附对象,你需要创建另外一个脚本。在 Scripts/RWVR 目录创建新的 C# 脚本,命名为 RWVR_SnapToController。用代码编辑器打开它,删除 Start() 和 Update() 方法。
将这句:
public class RWVR_SnapToController : MonoBehaviour
改成:
public class RWVR_SnapToController : RWVR_InteractionObject
这允许脚本具备所有 InteractionObject 的功能。
添加变量声明:
public bool hideControllerModel; // 1
public Vector3 snapPositionOffset; // 2
public Vector3 snapRotationOffset; // 3private Rigidbody rb; // 4
- 一个标志,表示手柄模型是否要在玩家抓住对象时隐藏。
- 当抓住对象时添加的位置。该对象默认会用这个位置吸附到手柄上。
- 同上,只是这个变量用于表示角度。
- 引用了对象的刚体组件。
然后增加方法:
public override void Awake()
{base.Awake();rb = GetComponent<Rigidbody>();
}
和 SimpleGrab 脚本一样,覆盖了基类的 Awake() 方法,然后保存刚体组件。
接下来是几个助手方法,这才算是这个脚本的肉戏。
添加如下方法:
private void ConnectToController(RWVR_InteractionController controller) // 1
{cachedTransform.SetParent(controller.transform); // 2cachedTransform.rotation = controller.transform.rotation; // 3cachedTransform.Rotate(snapRotationOffset);cachedTransform.position = controller.snapColliderOrigin.position; // 4cachedTransform.Translate(snapPositionOffset, Space.Self);rb.useGravity = false; // 5rb.isKinematic = true; // 6
}
这个方法和 SimpleGrab 脚本中的方法不同,它不使用 FixedJoint 连接,而是将它自己作为控制器的子对象。也就是说控制器和所吸附的对象是无法被外力所打断的。在这个教程中,这种方式会很稳定,但在你自己的项目中你更应该采取 FixedJoint 连接。
代码解释如下:
- 接收一个控制器参数,用于连接它。
- 将对象的 parent 设置为该控制器。
- 让对象的方向和控制器保持一定的偏移。
- 让对象的位置和控制器保持一定的偏移。
- 关闭重力,否则它会从你的手上掉落。
- 开启运动学特征。当附着到手柄上后,这个对象不会受福利引擎的影响。
现在来添加放开对象的方法:
private void ReleaseFromController(RWVR_InteractionController controller) // 1
{cachedTransform.SetParent(null); // 2rb.useGravity = true; // 3rb.isKinematic = false;rb.velocity = controller.velocity; // 4rb.angularVelocity = controller.angularVelocity;
}
这个方法简单地将对象从父对象中解除,重置刚体并应用控制器的速度。详细解释一下:
- 方法参数指定要松开对象的控制器。
- 将对象的父对象解开。
- 重新打开重力,并再次使对象再次变成非运动学的。
- 应用控制器的速度给对象。
覆盖如下方法以实现 snapping 操作:
public override void OnTriggerWasPressed(RWVR_InteractionController controller) // 1
{base.OnTriggerWasPressed(controller); // 2if (hideControllerModel) // 3{controller.HideControllerModel();}ConnectToController(controller); // 4
}
代码非常简单:
- 覆盖 OnTriggerWasPressed(),以添加吸附逻辑。
- 调用机类方法。
- 如果 hideControllerModel 标志为 true,隐藏控制器模型。
- 将对象连接到控制器。
然后是 release 方法:
public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{base.OnTriggerWasReleased(controller); // 2if (hideControllerModel) // 3{controller.ShowControllerModel();}ReleaseFromController(controller); // 4
}
同样十分简单:
- 覆盖 OnTriggerWasReleased() 方法。
- 调用基类的方法。
- 如果 hideControllerModel 标志为 true,重新显示手柄的模型。
- 将对象从控制器上放开。
保存脚本返回编辑器。从 Prefabs 目录中将 RealArrow 预制件拖到结构视图。
选择 arrow,设置它的 position 为 (X:0.5, Y:4.5, Z:-0.8)。它会悬浮在石板上方:
在结构视图中,将 RWVR_Snap To Controller 组件附加到箭支上,这样你就可以和它交互,同时将它的 Hide Controller Model 设为 true。最后点击检视器窗口上方的 Apply 按钮,将修改应用到该预制件。
对于这个对象,不需要修改 offset,默认它的握持部位就可以了。
保存并运行场景。抓住箭支,然后扔出去。唤醒你内心野兽吧!
注意,箭支握在手上的位置总是固定的,不管你如何拿起它。
本教程的内容就到此为止了,试玩一下游戏,感受一下交互中的变化。
结束
从此处下载最终项目。
在本教程中,你学习了如何创建可扩展的交互系统,你已经通过这个交互式系统找出了几种抓取物品的方法。
在第二部分的教程中,你将学习如何扩展这个系统,制作一套功能完备的弓和箭,以及一个功能完备的背包。
如果你想学习更多关于用 Unity 编写杀手游戏,请阅读我们的Unity Games By Tutorials。
在这本书中,你将创建 4 个完整的游戏:
- 一个 twin-stick 射击游戏
- 一个第一人称射击游戏
- 一个塔防游戏(带 VR 支持!)
- 一个 2D 平台游戏
学完这本书后,你将能够编写自己的游戏运行在 Windows、macOS、iOS及更多平台。
本书完全针对 Unity 初学者,将他们的 Unity 技能升级到专家水准。本书假设你有一定的编程经验(任何语言)。
感谢你阅读本教程!如果有任何意见和建议,请留言!