原文: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,然后在阅读本文。
在本教程中,你将必须具备下列条件:
如果你之前没有用过 HTC Vive,你可以去看我们之前的 HTC Vive tutorial,以了解如何在 Unity 中使用 HTC Vive。HTC Vive 是目前最好的头戴式显示器之一,它所支持的 room-scale 功能提供了精彩的沉浸式体验。
下载开始项目,解压缩,用 Unity 打开项目文件夹。
在项目窗口中看一下目录结构:
分别介绍如下:
打开 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 允许我们更加容易判断哪种种对象发生碰撞或者触象。
交互系统允许场景中的玩家和物理用一种灵活的、模块化的方式进行交互。替代为每个对象和控制器编写重复的代码,你将编写几个类给其它脚本进行继承。
第一个脚本是 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”。目前请忽略它,后面我们会创建这个类。
上面代码分别解释如下:
保存脚本,回到编辑器。
在 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; // 1
if (!gameObject.CompareTag("InteractionObject")) // 2
{
Debug.LogWarning("This InteractionObject does not have the correct tag, setting it now.", gameObject); // 3
gameObject.tag = "InteractionObject"; // 4
}
}
分别解释如下:
这个交互系统严重依赖于 InteractionObject 和控制器的 tag 来区分特殊对象和其它对象。忘记设置 tag 是很可能的,所以我们专门为这个编写了脚本。这是一种“失效保险”的设计。小心使得万年船。
最后,在 Awake() 方法后添加方法:
public bool IsFree() // 1
{
return currentController == null;
}
public virtual void OnDestroy() // 2
{
if (currentController)
{
OnTriggerWasReleased(currentController);
}
}
这些方法分别负责:
爆粗脚本,打开 RWVR_InteractionController。
现在它还是空的。我们马上会充实它!
控制器脚本是最重要的部分,因为它是玩家和游戏之间的直接联系。尽可能地接受输入并返回用户正确的反馈很重要。
首先,在类声明下面添加变量:
public Transform snapColliderOrigin; // 1
public GameObject ControllerModel; // 2
[HideInInspector]
public Vector3 velocity; // 3
[HideInInspector]
public Vector3 angularVelocity; // 4
private RWVR_InteractionObject objectBeingInteractedWith; // 5
private SteamVR_TrackedObject trackedObj; // 6
分段解释如下:
保存对手柄尖端的引用。后面我们会添加一个透明的球,表示你能够到触摸的位置以及距离你可以够到的地方有多远:
手柄的可见对象。上图中白色的部分。
继续在下面添加:
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();
}
代码解释如下:
然后是这个方法:
private void CheckForInteractionObject()
{
Collider[] overlappedColliders = Physics.OverlapSphere(snapColliderOrigin.position, snapColliderOrigin.lossyScale.x / 2f); // 1
foreach (Collider overlappedCollider in overlappedColliders) // 2
{
if (overlappedCollider.CompareTag("InteractionObject") && overlappedCollider.GetComponent().IsFree()) // 3
{
objectBeingInteractedWith = overlappedCollider.GetComponent(); // 4
objectBeingInteractedWith.OnTriggerWasPressed(this); // 5
return; // 6
}
}
}
这个方法从控制器的碰撞体的某个范围内查找 InteractionObject。一旦找到一个,就将赋给 objectBeingInteractedWith。
代码解释如下:
新增方法,调用刚刚的这个方法:
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;
}
}
}
代码非常简单:
这些检查确保玩家的所有输入都能被传递到正在和他们交互的 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; // 3
objectBeingInteractedWith.OnTriggerWasPressed(this); // 4
}
代码解释如下:
保存脚本,回到编辑器。为了让控制器按照我们的想法工作,还需要做一些调整。
在结构视图中选中两个控制器。它们都是[ 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(); // 1
rb = GetComponent(); // 2
}
然后是一些助手方法,用于将对象用 FixedJoint 附着在手柄上,或者从手柄上放开。
在 Awake() 方法后面添加:
private void AddFixedJointToController(RWVR_InteractionController controller) // 1
{
FixedJoint fx = controller.gameObject.AddComponent();
fx.breakForce = 20000;
fx.breakTorque = 20000;
fx.connectedBody = rb;
}
private void RemoveFixedJointFromController(RWVR_InteractionController controller) // 2
{
if (controller.gameObject.GetComponent())
{
FixedJoint fx = controller.gameObject.GetComponent();
fx.connectedBody = null;
Destroy(fx);
}
}
这两个方法分别用于:
写完这些方法,我们可以实现来自于基类的几个 OnTrigger 方法,以处理用户输入。首先添加 OnTriggerWasPressed() 方法:
public override void OnTriggerWasPressed(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasPressed(controller); // 2
if (hideControllerModelOnGrab) // 3
{
controller.HideControllerModel();
}
AddFixedJointToController(controller); // 4
}
这个方法在玩家按下扳机抓住一个对象时添加 FixedJoint 连接。代码分为几个阶段:
最后一步是添加 OnTriggerWasReleased() 方法:
public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasReleased(controller); //2
if (hideControllerModelOnGrab) // 3
{
controller.ShowControllerModel();
}
rb.velocity = controller.velocity; // 4
rb.angularVelocity = controller.angularVelocity;
RemoveFixedJointFromController(controller); // 5
}
这个方法移除参数指定的控制器的 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; // 3
private Rigidbody rb; // 4
然后增加方法:
public override void Awake()
{
base.Awake();
rb = GetComponent();
}
和 SimpleGrab 脚本一样,覆盖了基类的 Awake() 方法,然后保存刚体组件。
接下来是几个助手方法,这才算是这个脚本的肉戏。
添加如下方法:
private void ConnectToController(RWVR_InteractionController controller) // 1
{
cachedTransform.SetParent(controller.transform); // 2
cachedTransform.rotation = controller.transform.rotation; // 3
cachedTransform.Rotate(snapRotationOffset);
cachedTransform.position = controller.snapColliderOrigin.position; // 4
cachedTransform.Translate(snapPositionOffset, Space.Self);
rb.useGravity = false; // 5
rb.isKinematic = true; // 6
}
这个方法和 SimpleGrab 脚本中的方法不同,它不使用 FixedJoint 连接,而是将它自己作为控制器的子对象。也就是说控制器和所吸附的对象是无法被外力所打断的。在这个教程中,这种方式会很稳定,但在你自己的项目中你更应该采取 FixedJoint 连接。
代码解释如下:
现在来添加放开对象的方法:
private void ReleaseFromController(RWVR_InteractionController controller) // 1
{
cachedTransform.SetParent(null); // 2
rb.useGravity = true; // 3
rb.isKinematic = false;
rb.velocity = controller.velocity; // 4
rb.angularVelocity = controller.angularVelocity;
}
这个方法简单地将对象从父对象中解除,重置刚体并应用控制器的速度。详细解释一下:
覆盖如下方法以实现 snapping 操作:
public override void OnTriggerWasPressed(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasPressed(controller); // 2
if (hideControllerModel) // 3
{
controller.HideControllerModel();
}
ConnectToController(controller); // 4
}
代码非常简单:
然后是 release 方法:
public override void OnTriggerWasReleased(RWVR_InteractionController controller) // 1
{
base.OnTriggerWasReleased(controller); // 2
if (hideControllerModel) // 3
{
controller.ShowControllerModel();
}
ReleaseFromController(controller); // 4
}
同样十分简单:
保存脚本返回编辑器。从 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 个完整的游戏:
本书完全针对 Unity 初学者,将他们的 Unity 技能升级到专家水准。本书假设你有一定的编程经验(任何语言)。
感谢你阅读本教程!如果有任何意见和建议,请留言!