查看 PDF [1065 kb]
本文的目标受众是谁?
本文主要面向希望将 Steam*VR 集成至场景之中的 Unity* 开发人员。我将假设读者已安装 HTC Vive* 且能够正常运行。如果没有,请访问 SteamVR 网站查看具体说明。
我为何创建 Pirate Cove VR 场景?
我的工作重点随时变化,需要逐渐倾斜至虚拟现实 (VR),使其融于 Unity 环境之中。我想将 SteamVR 集成至 Unity 之中、设计场景,并支持远距传动以便在场景之中灵活移动。
本文主要介绍我在 VR 集成过程中所积累的经验,并展示如何实现 VR 和场景的有机结合。本文的重点不是如何设计场景以及如何使用 Unity 实现一切正常运行,而是介绍如何将 VR 集成至场景之中。
我的创建目标是什么?
我尝试创建虚拟现实视觉体验。尽管使用 Unity 来创建体验,但它不是游戏本身。我创建的视觉体验模拟以海盗为主题的小型热带岛屿。我选择对我有利的事物;毕竟我生活在雨水充沛的太平洋西北地区。所以我希望我的体验带有一些热带风情。
我使用的工具有哪些?
我使用 Unity* 5.6。我从 Unity 资产商店购买了一些资产。资产的选择均围绕如何设置生活着大量海盗的热带岛屿:
- Pirates Island
- SteamVR
- Hessburg Navel Cutter
- Aquas
还有一些其他的免费零星物品。
我为创建该项目积累了哪些知识?
我曾使用过 Unity,以及英特尔® 实感™ 技术。我们的 SDK 包含英特尔实感 Unity 插件,而且我编写过插件,并创建了有关该插件的培训示例。
到目前为止我没有真正尝试过在 Unity 中设计第一人称类型的游戏,也从不担心性能、每秒帧数 (FPS) 等相关问题。使用英特尔实感技术和其他简单支持工具时编写过脚本。但没有设计过大型项目或场景,也没有处理过与此相关的问题。
该项目的终极目标是什么?
我希望通过项目创建更好地了解 Unity 以及如何打造 VR 场景。我希望了解正常运行 VR 的难度。如果成功将会有哪些收获?性能是否可接受?如果存在帧速率低的问题,我是否能够保持稳定,继续创建该项目?
以及从中获得乐趣。我希望从该项目创建过程中获得乐趣,并积累经验,这也是我为何选择创建以海盗为主题的热带岛屿的原因。我个人对从前的加勒比海盗时代非常着迷。
我对此存在哪些误解?
如前所述,在使用 Unity 方面,经验并不丰富。
第一个误解是关于渲染对象和渲染时机。这是什么意思?由于某种原因,我假设地形中包含譬如 3D 模型(比如巨大的悬崖),如果仅将悬崖的一半放在地形之上,Unity 不会尝试渲染不可见部分。我认为是某种类型的渲染算法阻止了 Unity 渲染处于地形之下的对象。显然不是这样。Unity 仍然渲染整个 3D 悬崖模型。
这种相同的概念适用于两个不同的 3D 悬崖模型。例如,如果有两个悬崖游戏对象,我假设将其中一座悬崖推入另一座悬崖之中,以创造出一座巨大悬崖的幻象,那么任何不可见的几何体或纹理将不会被渲染。同样不是这么回事。
显然,如果有几何体和纹理,无论是否隐藏在其他对象之中,Unity 都会将其渲染。我们需要考虑到这一点。这对我的工作没有多大影响,也没有找到解决办法,我只是在正常使用 Unity 的过程中发现了这一问题。
性能
性能是我积累最多经验的一个方面。使用 Unity 的地形工具来设计场景非常简单。添加资产也非常简单。并不是说能够非常简单地设计出色的场景,只是说你可以轻松地明白如何设计场景中的对象。尽管我自己认为 Pirates Cove场景十分漂亮,但其他人可能会嗤之以鼻。但这是我第一次设计场景,而且我并不是尝试创建第一人称射击游戏。它是一种虚拟的视觉体验。
FPS:与他人探讨 VR 时我了解到适用于 VR 的 FPS 是 90。我一开始尝试使用 Unity Stats 窗口。在 Unity 论坛上与他人交谈时我发现该窗口并不是能够达到真正 FPS 性能的最佳工具。别人建议我使用 FPS Display 脚本。显然这种方法比较准确。
遮挡剔除:这种情况十分有趣。我正尝试解决一个完全不相关的问题时,一个同事过来帮忙。我们谈到 FPS 和可以帮助解决渲染问题的技巧,他介绍我使用遮挡剔除。他向我展示了一种人工操作方法:如何确定盒子的大小和形状。我必须承认,我只是简单地打开 Unity 的遮挡剔除窗口,让它自己算出遮蔽范围。这似乎帮助我解决了性能问题。
植被:我没有意识到为地形添加草地会对场景产生巨大的影响。我看过在其他场景中大片草丛在风中摇曳。因此我非常兴奋;将 FPS 将至 0,Unity 因此超载。我没有解决这一问题,而是简单地移除草丛,并使用三叶草纹理让场景看起来比较美观,但没有任何绘制调用。
如何让 Vive* 在场景中运行
我在文章开头提过,假设读者的工作站设置并运行 Vive。这是我找到的现有文章的浓缩版 HTC Vive Tutorial for Unity。我不打算深入介绍控制器条目,因为本文将介绍重点远距传动。我修改了远距传动版本,改动较小,因为我认为适度更好,更确切地说是在操作的过程中有所发现。
在进行 HTC Vive* 开发之前,必须下载和导入 SteamVR 插件。
导入插件后,项目中将出现以下文件结构:
在项目中,我在名为 VR 的层级结构中创建了根文件夹。在这里我拷贝了 SteamVR 和 CameraRig 预制件。你无需创建 VR 文件夹;我这里只是为了便于项目整理。
只需将 SteamVR 插件添加至项目层级结构,无需进行额外操作;看 CameraRig,
我将 CameraRig 放在场景中我希望一开始启动的地方。
放置 SteamVR CamerRig 预制件后,我需要删除主摄像头,以避免冲突。这时我可以启动场景,四处观察。我不能移动,但可以从静止的点四处观察,可以看到手中的控制器。不能随意走动,但至少可以看到任何地方。
获得追踪对象
追踪对象包含手部控制器和耳机。有了这个代码示例,我完全不担心耳机;但需要从手部控制器获得输入。这对追踪按钮点击等事物来说非常有必要。
首先我们必须获得带有脚本的追踪对象实例。这里追踪对象是控制器;可通过 Awake 函数获得实例。
void Awake( ) { _trackedController = GetComponent( ); }
然后,如果想测试是否能从手部控制器获得输入,可以通过以下 Get 函数选择特定控制器。它使用该脚本连接的追踪对象(手部控制器):
private SteamVR_Controller.Device HandController { get { return SteamVR_Controller.Input( ( int )_trackedObj.index ); } }
创建远距传动系统
现在我们想添加在场景中四处走动的功能。为此,我们必须创建脚本,知道如何读取手部控制器的输入。我创建了一个新的 Unity C# 脚本,名为 TeleportSystem.cs。
我们不仅需要脚本,还需要激光指示器,在本特定项目中为十字线。十字线不是强制性的,只是为了增添场景中的辨别力,因为用户可将该十字线用作视觉反馈工具。我只创建了一个带骷髅头的简单圆圈。
创建激光
将立方体扔在场景中即可创建激光;激光在场景中足够高,不会干扰场景中的其他资产。从这里将其坐标设为:x = 0.005,y = 0.005,z = 1。这样形成一个细长立方体形状的激光。
激光创建完成后,以预制件的形式保存并删除场景中的原始立方体,因为已不再需要。
创建十字线
我希望激光的末端带有一个定制的十字线,这点不是必需的,只是比较酷而已。我创建了一个预制件,即上面带贴花的圆形网格。
设置层级
这一步骤非常重要。你需要告诉激光/十字线哪些可以远距传动,哪些不可以。例如,可能不支持用户在水上远距传动,或者不希望支持他们在悬崖边上远距传动。你可以使用层级,将他们限制在场景中特定的区域。我创建了两个层级 — Teleportable 和 NotTeleportable。
我将可以远距传动的事物,比如地形本身、草棚和楼梯,放在 Teleportable 层。用户无法远距传动的事物,比如场景中的悬崖或其他事物,放在 NotTeleportable 层。
定义变量时,我确定了双层掩码。一个掩码包含所有层级。另一个是非远距传动掩码,表示这些层级无法远距传动。
// Layer mask to filter the areas on which teleports are allowed public LayerMask _teleportMask; // Layer mask specifies which layers we can NOT teleport to. public LayerMask _nonTeleportMask;
确定公共层级掩码时,它们均包含在脚本中。包含多个下拉列表,你可以从中选择不允许远距传动的层级。
设置层级与 LayerMatchTest 函数搭配使用。
/// <summary> /// Checks to see if a GameObject is on a layer in a LayerMask. /// </summary> /// <param name="layers">Layers we don't want to teleport to</param> /// <param name="objInQuestion">Object that the raytrace hit</param> /// <returns> true if the provided GameObject's Layer matches one of the Layers in the provided LayerMask.</returns> private static bool LayerMatchTest( LayerMask layers, GameObject objInQuestion ) { return( ( 1 << objInQuestion.layer ) & layers ) != 0; }
调用 LayerMatchTest()
时,将发送包含无法远距传动层级列表的层级掩码,以及 HitTest 检测到的游戏对象。测试将表明该对象是否在非远距传动层级列表中。
更新帧
void Update( ) { // If the touchpad is held down if ( HandController.GetPress( SteamVR_Controller.ButtonMask.Touchpad ) ) { _doTeleport = false; // Shoot a ray from controller.If it hits something make it store the point where it hit and show // the laser.Takes into account the layers which can be teleported onto if ( Physics.Raycast( _trackedController.transform.position, transform.forward, out _hitPoint, 100, _teleportMask ) ) { // Determine if we are pointing at something which is on an approved teleport layer. // Notice that we are sending in layers we DON'T want to teleport to. _doTeleport = !LayerMatchTest( _nonTeleportMask, _hitPoint.collider.gameObject ); if( _doTeleport ) { PointLaser( ); } else { DisplayLaser( false ); } } } else { // Hide _laser when player releases touchpad DisplayLaser( false ); } if( HandController.GetPressUp( SteamVR_Controller.ButtonMask.Touchpad ) && _doTeleport ) { TeleportToNewPosition(); ResetTeleporting( ); } }
更新时,代码将测试是否按下了控制器触控板按钮。如果按下了,将获得 Raycast 命中。请注意,我将发送包含一切事物的远距传动掩码。然后对命中点进行层级匹配测试。通过调用 LayerMatchTest 函数,确定是否命中了可远处传动或不可远距传动的事物。请注意,我将发送我不希望远距传动的层级列表。它将返回一个布尔值,之后该值用于确定我们能否远距传动。
如果可以远距传动,将使用 PointLaser 函数显示激光。在该函数中,我将告诉激光预制件看向 HitTest 的位置。接下来将激光预制件从控制器拉伸至 HitTest 位置。同时重新定位激光末端的十字线。
private void PointLaser( ) { DisplayLaser( true ); // Position laser between controller and point where raycast hits.Use Lerp because you can // give it two positions and the % it should travel.If you pass it .5f, which is 50% // you get the precise middle point. _laser.transform.position = Vector3.Lerp( _trackedController.transform.position, _hitPoint.point, .5f ); // Point the laser at position where raycast hits. _laser.transform.LookAt( _hitPoint.point ); // Scale the laser so it fits perfectly between the two positions _laser.transform.localScale = new Vector3( _laser.transform.localScale.x, _laser.transform.localScale.y, _hitPoint.distance ); _reticle.transform.position = _hitPoint.point + _VRReticleOffset; }
如果 HitTest 指向非远距传动层级,我将确保通过 DisplayLaser 函数关闭激光指示器。
在该函数的末端,如果触控板已按下,且 shouldTeleport 变量为真值,我调用 Teleport 函数将用户远距传动至新的位置。
private void TeleportToNewPosition( ) { // Calculate the difference between the positions of the camera's rig's center and players head. Vector3 difference = _VRCameraTransform.position - _VRHeadTransform.position; // Reset the y-position for the above difference to 0, because the calculation doesn’t consider the // vertical position of the player’s head difference.y = 0; _VRCameraTransform.position = _hitPoint.point + difference; }
总结
这就是我准备和运行场景的全过程。涉及从互联网获取资源、阅读他人发布的文章,以及测试和纠错。希望大家能够通过本文有所收获,随时欢迎大家与我联系。
出于完整性考虑,下面我提供完整的脚本:
using System.Collections; using System.Collections.Generic; using UnityEngine; /// <summary> /// Used to teleport the players location in the scene.Attach to SteamVR's ControllerRig/Controller left and right /// </summary> public class TeleportSystem :MonoBehaviour { // The controller itself private SteamVR_TrackedObject _trackedController; // SteamVR CameraRig transform public Transform _VRCameraTransform; // Reference to the laser prefab set in Inspecter public GameObject _VRLaserPrefab; // Ref to teleport reticle prefab set in Inspecter public GameObject _VRReticlePrefab; // Stores a reference to an instance of the laser private GameObject _laser; // Ref to instance of reticle private GameObject _reticle; // Ref to players head (the camera) public Transform _VRHeadTransform; // Reticle offset from the ground public Vector3 _VRReticleOffset; // Layer mask to filter the areas on which teleports are allowed public LayerMask _teleportMask; // Layer mask specifies which layers we can NOT teleport to. public LayerMask _nonTeleportMask; // True when a valid teleport location is found private bool _doTeleport; // Location where the user is pointing the hand held controller and releases the button private RaycastHit _hitPoint; /// <summary> /// Gets the tracked object.Can be either a controller or the head mount. /// But because this script will be on a hand controller, don't have to worry about /// knowing if it's a head or hand controller, this will only get the hand controller. /// </summary> void Awake( ) { _trackedController = GetComponent<SteamVR_TrackedObject>( ); } /// <summary> /// Initialize the two prefabs /// </summary> void Start( ) { // Spawn prefabs, init the classes _hitPoint _laser = Instantiate( _VRLaserPrefab ); _reticle = Instantiate( _VRReticlePrefab ); _hitPoint = new RaycastHit( ); } /// <summary> /// Checks to see if player holding down touchpad button, if so, are the trying to teleport to a legit location /// </summary> void Update( ) { // If the touchpad is held down if ( HandController.GetPress( SteamVR_Controller.ButtonMask.Touchpad ) ) { _doTeleport = false; // Shoot a ray from controller.If it hits something make it store the point where it hit and show // the laser.Takes into account the layers which can be teleported onto if ( Physics.Raycast( _trackedController.transform.position, transform.forward, out _hitPoint, 100, _teleportMask ) ) { // Determine if we are pointing at something which is on an approved teleport layer. // Notice that we are sending in layers we DON'T want to teleport to. _doTeleport = !LayerMatchTest( _nonTeleportMask, _hitPoint.collider.gameObject ); if( _doTeleport ) PointLaser( ); else DisplayLaser( false ); } } else { // Hide _laser when player releases touchpad DisplayLaser( false ); } if( HandController.GetPressUp( SteamVR_Controller.ButtonMask.Touchpad ) && _doTeleport ) { TeleportToNewPosition( ); DisplayLaser( false ); } } /// <summary> /// Gets the specific hand contoller this script is attached to, left or right controller /// </summary> private SteamVR_Controller.Device HandController { get { return SteamVR_Controller.Input( ( int )_trackedController.index ); } } /// <summary> /// Checks to see if a GameObject is on a layer in a LayerMask. /// </summary> /// <param name="layers">Layers we don't want to teleport to</param> /// <param name="objInQuestion">Object that the raytrace hit</param> /// <returns>true if the provided GameObject's Layer matches one of the Layers in the provided LayerMask.</returns> private static bool LayerMatchTest( LayerMask layers, GameObject objInQuestion ) { return( ( 1 << objInQuestion.layer ) & layers ) != 0; } /// <summary> /// Displays the lazer and reticle /// </summary> /// <param name="showIt">Flag </param> private void DisplayLaser( bool showIt ) { // Show _laser and reticle _laser.SetActive( showIt ); _reticle.SetActive( showIt ); } /// <summary> /// Displays the laser prefab, streteches it out as needed /// </summary> /// <param name="hit">Where the Raycast hit</param> private void PointLaser( ) { DisplayLaser( true ); // Position laser between controller and point where raycast hits.Use Lerp because you can // give it two positions and the % it should travel.If you pass it .5f, which is 50% // you get the precise middle point. _laser.transform.position = Vector3.Lerp( _trackedController.transform.position, _hitPoint.point, .5f ); // Point the laser at position where raycast hits. _laser.transform.LookAt( _hitPoint.point ); // Scale the laser so it fits perfectly between the two positions _laser.transform.localScale = new Vector3( _laser.transform.localScale.x, _laser.transform.localScale.y, _hitPoint.distance ); _reticle.transform.position = _hitPoint.point + _VRReticleOffset; } /// <summary> /// Calculates the difference between the cameraRig and head position.This ensures that /// the head ends up at the teleport spot, not just the cameraRig. /// </summary> /// <returns></returns> private void TeleportToNewPosition( ) { Vector3 difference = _VRCameraTransform.position - _VRHeadTransform.position; difference.y = 0; _VRCameraTransform.position = _hitPoint.point + difference; } }
关于作者
Rick Blacker 任职于英特尔® 软件和服务事业部,主要负责以 Primer VR 应用开发为重点的虚拟现实。