Up to date

This page is up to date for Godot 4.2. If you still find outdated information, please open an issue.

根縮放

XR 專案的主要特點之一是能夠在大空間中自由走動。該空間通常受到玩家所在房間的限制,並且追蹤感測器放置在該空間內。然而,隨著由內向外追蹤的出現,更大的遊戲空間成為可能。

作為開發人員,這帶來了許多有趣的挑戰。在本文件中,我們將探討您可能面臨的一些挑戰並概述一些解決方案。我們將在另一份檔案中討論坐式 XR 遊戲的問題和挑戰。

備註

Often developers sit behind their desk while building the foundation to their game. In this mode the issues with developing for room scale don't show themselves until it is too late. The advice here is to start testing while standing up and walking around as early as possible. Once you are happy your foundation is solid, you can develop in comfort while remaining seated.

在傳統的第一人稱遊戲中,玩家由 CharacterBody3D <class_characterbody3d> 節點表示。此節點透過處理傳統控制器、滑鼠或鍵盤輸入來移動。相機連接到該節點,大致位於玩家頭部所在的位置。

將此模型應用於 XR 設定,我們新增 XROrigin3D <class_xrorigin3d>` 節點作為角色身體的子節點,並新增 XRCamera3D <class_xrcamera3d>` 作為原始節點的子節點。從表面上看,這似乎有效。然而,經過仔細檢查,該模型並未考慮到 XR 中有兩種運動形式。透過控制器輸入進行的運動,以及玩家在現實世界中的物理運動。

因此,原點節點並不代表玩家的位置。它代表玩家可以在其中物理移動的追蹤空間的中心或起點。當玩家在房間內移動時,這種移動是透過玩家耳機的追蹤來表示的。在遊戲中,這會相應地更新相機節點的位置。出於所有意圖和目的,我們正在追蹤一個脫離實體的頭部。除非身體追蹤可用,否則我們不知道玩家身體的位置或方向。

../../_images/XRRoomCenterWalk.gif

這導致的第一個問題是相當明顯的。當玩家透過控制器輸入移動時,我們可以使用與正常遊戲中相同的方法並將玩家向前移動。然而,玩家並不在我們認為的位置,當我們前進時,我們會檢查錯誤位置的碰撞。

../../_images/XRRoomWalkOffCliff.gif

當玩家遠離追蹤空間的中心並使用控制器輸入進行轉彎時,第二個問題才真正顯現出來。如果我們旋轉角色身體,玩家將以圓形方式在房間內移動。

../../_images/XRRoomRotateOrigin.gif

如果我們解決了上述問題,我們就會發現第三個問題。當玩家的路徑在虛擬世界中被阻擋時,玩家仍然可以物理前進。

../../_images/XRRoomWalkWall.gif

我們將著眼於用兩個單獨的解決方案來解決前兩個問題,然後討論第三個問題的處理。

以原點為中心的解決方案

看看解決這個問題的第一種方法,我們將改變我們的結構。這是 XR Tools 目前實施的方法。

../../_images/xr_room_scale_origin_body.webp

在此設定中,我們將角色身體標記為頂層,因此它不會隨原點移動。

我們還有一個輔助節點,它告訴我們頸部關節相對於相機的位置。我們用它來確定我們的身體中心在哪裡。

角色移動的處理現在要分三步進行。

備註

The Origin centric movement demo contains a more elaborate example of the technique described below.

逐步執行

第一步,我們將處理玩家的身體運動。我們確定玩家現在所在的位置,並嘗試將我們的角色身體移動到那裡。

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”。此函式應獲得必要的輸入並傳回以弧度每秒為單位的旋轉速度。

備註

For our example we are going to keep this simple and straight forward. We are not going to worry about comfort features such as snap turning and applying a vignette. We highly recommend implementing such comfort features.

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)

備註

We've added the call for processing our rotation to our physics process but we are only executing this if we were able to move our player fully. This means that if the player moves somewhere they shouldn't, we don't process further movement.

逐步執行

第三步也是最後一步是根據使用者輸入向前、向後或側向移動玩家。

就像旋轉一樣,專案與專案之間的輸入也不同,因此我們只需呼叫函式「_get_movement_input」。此函式應獲得必要的輸入並傳回縮放至所需速度的方向向量。

備註

Just like with rotation we're keeping it simple. Here too it is advisable to look at adding 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)

角色動畫

在此設定中,我們將保留角色身體作為根節點,因此更容易與傳統遊戲機制結合。

../../_images/xr_room_scale_character_body.webp

這裡我們有一個具有碰撞形狀的標準角色身體,以及我們的 XR 原點節點和相機作為普通子節點。我們還有頸部輔助節點。

處理我們的角色運動是透過相同的三個步驟完成的,但實施方式略有不同。

備註

The Character centric movement demo contains a more elaborate example of the technique described below.

逐步執行

在這個方法中,第 1 步是所有神奇發生的地方。就像我們之前的方法一樣,我們將把物理運動應用於角色身體,但我們將在原始節點上抵消該運動。

這將確保玩家位置與角色身體位置保持同步。

# 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)

逐步執行

對於第三步,我們再次應用基於控制器輸入的運動。然而,就像步驟 2 一樣,我們現在可以像在正常的第一人稱遊戲中一樣實作這一點。

就像旋轉一樣,專案與專案之間的輸入也不同,因此我們只需呼叫函式「_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)

將玩家移動到樹狀結構中其他地方。

想像一下玩家在上鎖的房間外面的狀況。在門解鎖之前,您不希望玩家進入該房間。您也不希望玩家看到這個房間裡有什麼。

透過控制器輸入移動玩家的邏輯很好地防止了這種情況。玩家遇到靜態物體,程式碼阻止玩家進入房間。

然而,有了 XR,沒有什麼可以阻止玩家向前邁出真正的一步。

透過上面提出的兩種方法,我們將防止角色身體移動到玩家無法到達的地方。當玩家實際移動到該位置時,攝影機現在也已移動到房間中。

合乎邏輯的解決方案是完全阻止移動並調整 XR 原點的位置,以便玩家留在房間外面。

這種方法的問題在於,實體運動現在無法在虛擬空間中複製。這會導致玩家感到噁心。

相反,許多 XR 遊戲所做的是測量玩家的實體位置與玩家的虛擬身體被拋在後面的位置之間的距離。隨著這個距離的增加,通常是幾公分的距離,螢幕會慢慢變黑。

上面的解決方案允許我們將此邏輯新增到步驟 1 末尾的程式碼中。

對所提供的程式碼的進一步改進可能是:

  • 只要這個距離仍然很小,就允許控制器輸入,

  • 即使控制器輸入被停用,仍然對玩家施加重力。

備註

The movement demos in our demo repository contain an example of blacking out the screen when a user walks into restricted areas.

進一步的改進建議

以上提供了兩個很好的選擇作為實施房間規模 XR 遊戲的起點。

您可能想要實施的還有一些值得指出的事情:

  • 相機的高度可以用來偵測玩家是站立、蹲伏、跳躍還是躺著。您可以相應地調整碰撞形狀的大小和方向。新增多個碰撞形狀的額外獎勵點,使頭部和身體有自己的、尺寸更準確的形狀。

  • 當場景首次加載時,玩家可能遠離追蹤空間的中心。這可能會導致玩家生成到與我們的原點不同的房間。遊戲現在將嘗試將玩家身體從起點移動到玩家站立的位置,但失敗了。您應該實作一個重設函式來移動原點,以便玩家處於正確的起始位置。

上述兩項改進都要求玩家做好準備並站直。無法保證,因為玩家可能仍然戴著耳機。

許多遊戲(包括 XR Tools)透過引入介紹畫面或載入畫面來解決此問題,玩家必須在準備好時按下按鈕。此起始環境通常是較大的位置,玩家的位置對玩家所看到的內容幾乎沒有影響。當玩家準備好並按下按鈕時,您就記錄了攝影機的位置和高度。