XR 房間規模

XR 專案的一大特色是能夠在大空間中自由走動。這個空間通常受限於玩家實際所在的房間,並在房間內設置追蹤感測器。然而,隨著「內向外」追蹤技術的出現,遊戲空間可以大幅擴展。

對開發者來說,這帶來了許多有趣的挑戰。本文件將探討你可能遇到的各種挑戰並概述一些解決方案。針對坐式 XR 遊戲的相關議題,會在另一份文件中說明。

備註

開發遊戲初期,開發者通常坐在桌前進行基礎開發。這種情況下,房間規模的相關問題往往到了較晚階段才顯現。因此建議盡早站起來走動測試,確保基礎功能穩固後,再選擇舒適地坐著開發。

在傳統第一人稱遊戲中,玩家通常由 CharacterBody3D 節點表示。這個節點透過控制器、滑鼠或鍵盤輸入來移動,相機則安裝在該節點上、約略對應玩家的頭部位置。

將這種模型套用到 XR 設定時,我們會在角色本體下新增 XROrigin3D 節點作為子節點,再在原點節點下新增 XRCamera3D 作為其子節點。表面上這看起來可行,但實際上這個模型並未考慮到 XR 裡有兩種移動方式:一種是控制器輸入造成的移動,另一種是玩家在現實空間中的實體移動。

因此,原點節點並不代表玩家的位置,而是代表追蹤空間的中心或起點,也就是玩家可實際移動的範圍。當玩家在房間內移動時,這些移動會透過頭戴裝置的追蹤反映在遊戲中,並相應更新相機節點的位置。就實作層面來說,我們追蹤的其實是一顆脫離身體的頭。除非有身體追蹤功能,否則我們無法得知玩家身體的位置與朝向。

../../_images/XRRoomCenterWalk.gif

這會導致第一個很明顯的問題:當玩家透過控制器移動時,我們雖然用跟普通遊戲一樣的方式讓玩家角色向前移動,但其實玩家實際在空間中的位置早就偏離預期,導致碰撞檢查會判斷錯誤的位置。

../../_images/XRRoomWalkOffCliff.gif

第二個問題在於玩家離開追蹤空間中心、並用控制器轉動時會出現。如果我們旋轉角色本體,玩家會以圓周方式在房間內移動。

../../_images/XRRoomRotateOrigin.gif

即使我們解決了上述問題,還會遇到第三個問題:當虛擬世界中玩家路徑被擋住時,玩家在現實中還是可以繼續往前走。

../../_images/XRRoomWalkWall.gif

我們將用兩種不同的解法處理前兩個問題,再討論第三個問題的應對方式。

以原點為中心的解決方案

首先我們介紹結構調整的解法,這也是 XR Tools 目前採用的方法。

../../_images/xr_room_scale_origin_body.webp

在這種架構下,將角色本體標記為頂層,讓它不會隨原點一起移動。

此外,我們會加入一個輔助節點,用於標示脖子關節相對於相機的位置,藉此推算身體中心點。

角色移動現在分為三個步驟處理。

備註

以原點為中心的移動範例 提供了下述技術更詳細的實作範例。

步驟 1

第一步是處理玩家的實體移動。我們會偵測玩家目前所在位置,並嘗試讓角色本體移動到該處。

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

步驟 2

第二步是根據使用者輸入處理玩家的旋轉。

由於專案需求不同,輸入方式也會不同,因此我們單純呼叫 _get_rotational_input 函式。這個函式應該取得必要的輸入,並回傳每秒弧度的旋轉速度。

備註

在範例中,我們會以簡化方式示範,不會加入如「瞬間轉向」或「視角暈染」等舒適性設計。建議正式開發時務必加入這類舒適性功能。

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)

備註

我們在物理流程中加入旋轉處理,但僅在玩家能完整移動時才執行。也就是說,若玩家移動到不該去的位置,我們就不再處理進一步移動。

步驟 3

第三步,也是最後一步,是根據玩家輸入讓角色前進、後退或側移。

和旋轉一樣,不同專案的移動輸入也會不同,因此我們只需呼叫 _get_movement_input 函式,讓它取得輸入,並傳回經速度縮放的方向向量。

備註

同樣地,這裡也只做簡單處理。實務上建議加上舒適性選項。

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

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

角色移動同樣以三步驟處理,但實作細節略有不同。

備註

以角色本體為中心的移動範例 提供了下述技術更完整的範例。

步驟 1

這種方法的精髓就在步驟 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。

步驟 2

這一步中,會根據控制器輸入套用旋轉,基本寫法就跟一般第一人稱遊戲一樣。

由於專案需求不同,輸入方式也會不同,因此我們單純呼叫 _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)

步驟 3

第三步同樣是根據控制器輸入執行移動,實作方式就和傳統第一人稱遊戲一樣。

和旋轉一樣,不同專案的移動輸入也會不同,因此我們只需呼叫 _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 的程式碼最後。

進一步改進程式碼的建議:

  • 只要這個距離尚小,仍允許控制器輸入動作,

  • 即便停用控制器輸入,也要讓玩家繼續受到重力影響。

備註

我們的範例專案中有展示玩家走進限制區時畫面漸黑的實作範例。

更多改進建議

以上兩種方法都很適合作為開發房間規模 XR 遊戲的起點。

還有幾項實作上值得注意的建議:

  • 可利用相機高度判斷玩家是站立、蹲下、跳躍還是躺下,並據此動態調整碰撞形狀的大小與朝向。若能針對頭部和身體各自設置更精確的碰撞形狀,效果會更好。

  • 場景剛載入時,玩家可能離追蹤空間中心很遠,導致一開始就出現在不同房間。此時遊戲會嘗試讓角色本體從起始點移動到玩家實際所在處,但多半失敗。因此應設計重置功能,主動調整原點,讓玩家從正確的起始位置開始。

上述這些改進都假設玩家已經站好準備,但實際上玩家可能還在戴頭戴裝置,因此無法保證隨時生效。

許多遊戲(包含 XR Tools)會設計一個起始或載入畫面,讓玩家在準備好時按下按鈕。通常這個起始區是一個很大的空間,玩家站在哪裡對所見畫面影響不大。當玩家按下按鈕時,即記錄下相機的位置與高度。