Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

XR 中的房间尺度

XR 项目的核心亮点之一,就是能够让玩家在广阔的空间里自由走动。不过,这个活动空间通常受限于玩家所在的现实房间,因为需要在房间里布置追踪传感器。但随着‘由内向外追踪(inside-out tracking)’技术的问世,如今我们能够实现比这大得多的游玩空间了。

对于开发者来说,这确实带来了一系列有趣的挑战。在这篇文档中,我们将探讨你可能会遇到的若干难题,并给出一些解决方案的框架。至于坐姿 XR 游戏(也就是坐着玩的 VR/AR 游戏)会遇到的那些问题和挑战,我们会放在另一篇文档里单独讨论。

备注

别光顾着坐着写代码,游戏是需要玩家动起来的,早点站起来亲身测试,才能避免后期出现晕动症或者空间设计不合理的大坑。

在传统的第一人称游戏中,玩家通常由一个 CharacterBody3D 节点来代表。这个节点是通过处理传统的手柄、鼠标或键盘输入来移动的。然后,一个摄像机节点会被挂载到这个角色节点上,位置大概就在玩家脑袋所在的地方。

如果把这个模型套用到 XR 设置中,我们会把 XROrigin3D 节点作为角色身体(character body)的子节点,然后再把 XRCamera3D 节点作为原点节点的子节点。乍一看,这样好像行得通。但仔细一琢磨就会发现,这个模型其实没有考虑到 XR 中存在两种完全不同的移动形式:一种是通过手柄输入产生的移动,另一种是玩家在现实世界中的物理移动。

因此,原点节点(origin node)并不代表玩家的实际位置。它代表的是追踪空间的中心(或者说起点),也就是玩家能在现实中进行物理移动的那个区域。当玩家在房间里走动时,这种移动是通过追踪玩家的头显来体现的。在游戏里,这就表现为摄像机节点(camera node)的位置被相应地更新。说白了,我们追踪的其实只是一个‘悬空的脑袋’。除非有专门的身体追踪设备,否则我们根本无从得知玩家身体的具体位置或朝向。

../../_images/XRRoomCenterWalk.gif

这带来的第一个问题其实挺明显的。当玩家通过手柄输入来移动时,我们本来可以像做普通游戏那样,让角色直接朝前方移动。但问题是,玩家的实际位置(在现实中)并不是我们以为的那个位置。所以当我们往前走的时候,程序其实是在一个错误的位置上检测碰撞的。

../../_images/XRRoomWalkOffCliff.gif

第二个问题在玩家走到追踪空间中心较远的地方,并使用手柄输入进行转身时,就会彻底暴露出来。因为一旦我们旋转角色身体(CharacterBody),玩家就会像被甩起来一样,绕着房间转一个大圈。

../../_images/XRRoomRotateOrigin.gif

如果我们解决了上面的问题,就会发现第三个问题:当玩家在虚拟世界里的行进路线被挡住时,玩家在现实中依然可以(不受阻碍地)继续向前走。

../../_images/XRRoomWalkWall.gif

我们将通过两种不同的方案,分别来解决前两个问题;然后再来讨论如何处理第三个问题。

以原点为中心的解决方案

来看看解决这个问题的第一种思路,我们需要调整一下(场景的)结构。这也是 XR Tools 目前所采用的方法。

../../_images/xr_room_scale_origin_body.webp

在这个设置中,我们将 CharacterBody(角色身体)标记为‘顶层(top level)’,这样它就不会随着原点(origin)一起移动了。

我们还有一个辅助节点,它能告诉我们脖子关节相对于摄像机的位置。我们就是利用这个信息,来确定身体中心点具体在哪里的。

角色移动的处理现在要分三步进行。

备注

Origin centric movement demo 包含了下面将要介绍的这种技术的一个更详尽、更复杂的示例。

第一步

第一步,我们要处理角色的物理移动。我们会先确定玩家当前所处的位置,然后尝试把我们的角色身体(CharacterBody)移动到那里。

func _process_on_physical_movement(delta):
  # Remember our current velocity, we'll apply that later
  var current_velocity = $CharacterBody3D.velocity

  # Remember where our player body currently is
  var org_player_body: Vector3 = $CharacterBody3D.global_transform.origin

  # Determine where our player body should be
  var player_body_location: Vector3 = $XRCamera3D.transform * $XRCamera3D/Neck.transform.origin
  player_body_location.y = 0.0
  player_body_location = global_transform * player_body_location

  # Attempt to move our character
  $CharacterBody3D.velocity = (player_body_location - org_player_body) / delta
  $CharacterBody3D.move_and_slide()

  # Set back to our current value
  $CharacterBody3D.velocity = current_velocity

  # Check if we managed to move all the way, ignoring height change
  var movement_left = player_body_location - $CharacterBody3D.global_transform.origin
  movement_left.y = 0.0
  if (movement_left).length() > 0.01:
    # We'll talk more about what we'll do here later on
    return true
  else:
    return false

func _physics_process(delta):
  var is_colliding = _process_on_physical_movement(delta)

需要注意的是,当我们的角色没能完全移动到目标位置时, _process_on_physical_movement 函数会返回 true

第二步

第二步,就是根据玩家的输入操作,来处理角色的旋转。

因为具体的输入方式会根据你的实际需求而有所不同,所以我们这里只是简单地调用了一个叫 _get_rotational_input 的函数。这个函数的作用,就是获取必要的输入信息,然后返回一个以‘弧度/秒’为单位的旋转速度。

备注

在我们的这个示例中,为了保持简单直观,我们不会去考虑诸如‘瞬移转向(snap turning)’和‘应用隧道视野遮罩(vignette)’这类舒适度功能。但我们强烈建议大家在实际开发中一定要实现这些舒适化功能。

func _get_rotational_input() -> float:
  # Implement this function to return rotation in radians per second.
  return 0.0

func _copy_player_rotation_to_character_body():
  # We only copy our forward direction to our character body, we ignore tilt
  var camera_forward: Vector3 = -$XRCamera3D.global_transform.basis.z
  var body_forward: Vector3 = Vector3(camera_forward.x, 0.0, camera_forward.z)

  $CharacterBody3D.global_transform.basis = Basis.looking_at(body_forward, Vector3.UP)

func _process_rotation_on_input(delta):
  var t1 := Transform3D()
  var t2 := Transform3D()
  var rot := Transform3D()

  # We are going to rotate the origin around the player
  var player_position = $CharacterBody3D.global_transform.origin - global_transform.origin

  t1.origin = -player_position
  t2.origin = player_position
  rot = rot.rotated(Vector3(0.0, 1.0, 0.0), _get_rotational_input() * delta)
  global_transform = (global_transform * t2 * rot * t1).orthonormalized()

  # Now ensure our player body is facing the correct way as well
  _copy_player_rotation_to_character_body()

func _physics_process(delta):
  var is_colliding = _process_on_physical_movement(delta)
  if !is_colliding:
    _process_rotation_on_input(delta)

备注

我们已经在物理处理(physics process)里加入了处理旋转的代码,但有个前提条件:只有当玩家能够完整地移动到位时,我们才会执行这段旋转逻辑。这就意味着,如果玩家试图移动到一个他们本不该去的地方(比如撞墙了),我们就不会再继续处理后续的移动了。

第三步

第三步,也是最后一步,就是根据用户的输入,让角色向前、向后或者向侧面移动。

就像处理旋转一样,每个项目的输入方式都各不相同,所以我们这里只是简单地调用了一个叫 _get_movement_input 的函数。这个函数的作用,就是获取必要的输入信息,然后返回一个已经按所需速度缩放好的方向向量。

备注

就像刚才处理旋转一样,我们这里也尽量保持简单。不过在这里,同样建议大家后续加上一些‘舒适度设置’(comfort settings)。

var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")

func _get_movement_input() -> Vector2:
  # Implement this to return requested directional movement in meters per second.
  return Vector2()

func _process_movement_on_input(delta):
  # Remember where our player body currently is
  var org_player_body: Vector3 = $CharacterBody3D.global_transform.origin

  # We start with applying gravity
  $CharacterBody3D.velocity.y -= gravity * delta

  # Now we add in our movement
  var input: Vector2 = _get_movement_input()
  var movement: Vector3 = ($CharacterBody3D.global_transform.basis * Vector3(input.x, 0, input.y))
  $CharacterBody3D.velocity.x = movement.x
  $CharacterBody3D.velocity.z = movement.z

  # Attempt to move our player
  $CharacterBody3D.move_and_slide()

  # And now apply the actual movement to our origin
  global_transform.origin += $CharacterBody3D.global_transform.origin - org_player_body

func _physics_process(delta):
  var is_colliding = _process_on_physical_movement(delta)
  if !is_colliding:
    _process_rotation_on_input(delta)
    _process_movement_on_input(delta)

以角色身体为中心的解决方案

在这种布局里,我们会继续把角色身体(CharacterBody)作为根节点,这样一来,把它和传统的游戏机制结合起来就会容易得多。

../../_images/xr_room_scale_character_body.webp

在这里,我们有一个标准的角色身体(CharacterBody),上面带着碰撞形状(CollisionShape)。我们的 XR 原点节点和摄像机,也像往常一样作为它的子节点。此外,我们还设置了一个用于辅助定位颈部的节点(neck helper node)。

我们处理角色移动依然是按照同样的三个步骤来进行,不过具体的实现方式会稍微有所不同。

备注

Character centric movement demo 包含了下面将要介绍技巧的更详尽示例。

第一步

在这个方案中,第一步就是施展‘魔法’的关键所在。就像我们之前的方法一样,我们依然会将物理移动的效果施加在角色身体(character body)上,但同时,我们要在根节点(origin node)上抵消掉这部分移动。

这样就能确保玩家的位置和角色身体的位置始终保持同步。

# Helper variables to keep our code readable
@onready var origin_node = $XROrigin3D
@onready var camera_node = $XROrigin3D/XRCamera3D
@onready var neck_position_node = $XROrigin3D/XRCamera3D/Neck

func _process_on_physical_movement(delta) -> bool:
  # Remember our current velocity, we'll apply that later
  var current_velocity = velocity

  # Start by rotating the player to face the same way our real player is
  var camera_basis: Basis = origin_node.transform.basis * camera_node.transform.basis
  var forward: Vector2 = Vector2(camera_basis.z.x, camera_basis.z.z)
  var angle: float = forward.angle_to(Vector2(0.0, 1.0))

  # Rotate our character body
  transform.basis = transform.basis.rotated(Vector3.UP, angle)

  # Reverse this rotation our origin node
  origin_node.transform = Transform3D().rotated(Vector3.UP, -angle) * origin_node.transform

  # Now apply movement, first move our player body to the right location
  var org_player_body: Vector3 = global_transform.origin
  var player_body_location: Vector3 = origin_node.transform * camera_node.transform * neck_position_node.transform.origin
  player_body_location.y = 0.0
  player_body_location = global_transform * player_body_location

  velocity = (player_body_location - org_player_body) / delta
  move_and_slide()

  # Now move our XROrigin back
  var delta_movement = global_transform.origin - org_player_body
  origin_node.global_transform.origin -= delta_movement

  # Return our value
  velocity = current_velocity

  if (player_body_location - global_transform.origin).length() > 0.01:
    # We'll talk more about what we'll do here later on
    return true
  else:
    return false

func _physics_process(delta):
  var is_colliding = _process_on_physical_movement(delta)

简单来说,上面的代码会把角色身体移动到玩家所在的位置,然后再把原点节点向反方向移动同样的距离。这样一来,最终的效果就是玩家始终能稳稳地保持在角色身体的正上方(完美对齐)。

我们首先从处理旋转开始。角色的身体应该朝向玩家上一帧时视线所看的方向。我们先在角色身体的坐标系里,计算出当前摄像机的朝向。这样一来,我们就能算出玩家的头部到底转了多少角度。接着,我们让角色身体也转过相同的角度,这样角色的朝向就能和玩家保持一致了。最后,我们再对原点节点进行反向旋转,让摄像机最终重新与玩家对齐。

对于移动的处理,我们的做法也基本相同。角色的身体应该待在玩家上一帧时所站的位置。我们先计算出玩家从这个位置移动了多少距离,然后尝试将角色身体移动到(玩家当前的)这个新位置上。

因为玩家可能会撞上碰撞体而被挡住(无法继续前进),所以我们只会把原点向后移动‘角色身体实际移动的那段距离’。这样一来,玩家虽然可以在现实中从这个位置走开,但这种移动会如实反映在玩家(在游戏里)的位置上。

和我们之前的解决方案一样,如果出现了这种情况,我们就返回 true。

第二步

在这一步,我们再次根据控制器的输入来施加旋转效果。不过在这种情况下,代码几乎就和在普通第一人称游戏里实现转向时一模一样了。

因为具体的输入方式会根据你的实际需求而有所不同,所以我们这里只是简单地调用了一个叫 _get_rotational_input 的函数。这个函数的作用,就是获取必要的输入信息,然后返回一个以‘弧度/秒’为单位的旋转速度。

func _get_rotational_input() -> float:
  # Implement this function to return rotation in radians per second.
  return 0.0

func _process_rotation_on_input(delta):
  rotation.y += _get_rotational_input() * delta

func _physics_process(delta):
  var is_colliding = _process_on_physical_movement(delta)
  if !is_colliding:
    _process_rotation_on_input(delta)

第三步

到了第三步,我们再次根据控制器的输入来施加移动效果。不过,就像第二步一样,我们现在可以像开发普通第一人称游戏那样来实现它了。

就像处理旋转一样,每个项目的输入方式都各不相同,所以我们这里只是简单地调用了一个叫 _get_movement_input 的函数。这个函数的作用,就是获取必要的输入信息,然后返回一个已经按所需速度缩放好的方向向量。

func _get_movement_input() -> Vector2:
  # Implement this to return requested directional movement in meters per second.
  return Vector2()

func _process_movement_on_input(delta):
  var movement_input = _get_movement_input()
  var direction = global_transform.basis * Vector3(movement_input.x, 0, movement_input.y)
  if direction:
    velocity.x = direction.x
    velocity.z = direction.z
  else:
    velocity.x = move_toward(velocity.x, 0, delta)
    velocity.z = move_toward(velocity.z, 0, delta)

  move_and_slide()

func _physics_process(delta):
  var is_colliding = _process_on_physical_movement(delta)
  if !is_colliding:
    _process_rotation_on_input(delta)
    _process_movement_on_input(delta)

玩家走到不应该抵达的位置时

想象这样一种情况:玩家身处于一个上锁房间的外边。你不希望玩家在房间门解锁前进入房间,也不希望玩家看到房间内的物体。

用手柄输入控制玩家移动的逻辑可以防止这种情况发生。玩家将会撞上一个 static body,然后代码会阻止玩家走入房间。

然而,在 XR 游戏中,没有任何方法能阻止玩家在现实世界中向前行走。

通过上面探讨的这两种方法,我们就能成功阻止角色身体移动到玩家无法到达的地方。不过,由于玩家在现实中已经移动到了这个位置,(这会导致)摄像机现在也跟着跑进房间里了。

一个合乎逻辑的解决方案是:直接彻底禁止移动,并通过调整 XR 原点的位置,来确保玩家始终处于(虚拟)房间的外部。

这种方法的问题在于玩家的物理移动并没有在虚拟空间中重现。这可能会导致玩家晕眩。

而许多 XR 游戏采用的替代做法是:去测量玩家现实中所处的位置,与虚拟身体被‘甩在后面’的位置之间的距离。随着这个距离逐渐拉大(通常达到几厘米时),屏幕就会开始慢慢变黑。

利用我们上面提到的那些解决方案,我们就可以把这段逻辑添加到‘第1步’末尾的代码里。

对于上面展示的这段代码,还可以做进一步的改进,比如:

  • 只要这个距离仍然很小,就允许控制器输入,

  • 即使在控制器输入被禁用的情况下,依然会对玩家施加重力。

备注

在我们的演示仓库(demo repository)中,移动(movement)相关的演示项目里,包含了一个具体的例子,展示了当用户走进限制区域时如何将屏幕变黑。

进一步的改进建议

以上提供了两个不错的方案,可以作为开发房间规模(room scale)XR 游戏的起点。

还有几件值得特别提一下的事情,这些功能你大概率也是需要去实现的:

  • 你可以利用摄像机的高度来检测玩家当前是在站立、蹲下、跳跃还是趴在地上。你可以根据这些状态相应地调整碰撞体的大小和方向。如果能添加多个碰撞体,让头部和身体拥有各自独立且尺寸更精确的碰撞形状,那就更是锦上添花啦(可以拿到额外的加分哦)。

  • 当一个场景首次加载时,玩家可能离追踪空间的中心非常远。这可能会导致玩家直接‘刷’进了一个与我们设定原点完全不同的房间里。此时,游戏会尝试把玩家的身体从(游戏设定的)起始点移动到玩家实际站立的位置,但这通常会失败。因此,你应该实现一个重置(reset)功能,去移动原点的位置,从而确保玩家能处于正确的初始位置上。

上述两项改进都需要玩家已经准备就绪并且站直了。但并不能保证这一点,因为玩家可能还在戴头显的过程中。

很多游戏(包括 XR Tools)都是通过引入一个初始界面或加载界面来解决这个问题的,玩家必须按下按钮表示自己准备好了。这个初始环境通常是一个很大的场景,玩家站在哪里对视野的影响微乎其微。当玩家准备好并按下按钮时,就是记录摄像机位置和身高的最佳时机。