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-проектів — можливість вільно пересуватися у великому просторі. Цей простір часто обмежений кімнатою, у якій фізично перебуває гравець, із датчиками відстеження, розміщеними в цьому просторі. З появою відстеження навиворіт, однак ігровий простір стає більшим.

Для розробника це створює ряд цікавих завдань. У цьому документі ми розглянемо ряд проблем, з якими ви можете зіткнутися, і окреслимо деякі рішення. Ми обговоримо проблеми та виклики сидячих ігор 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

У цьому налаштуванні ми позначаємо тіло персонажа як верхній рівень, щоб воно не рухалося разом з початком координат.

У нас також є допоміжний вузол, який повідомляє нам, де знаходиться наш шийний суглоб по відношенню до нашої камери. Ми використовуємо це, щоб визначити, де знаходиться центр нашого тіла.

Обробка руху нашого персонажа тепер виконується в три етапи.

Примітка

Демонстрація орієнтованого руху Origin містить детальніший приклад техніки, описаної нижче.

Крок 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)

Зверніть увагу, що ми повертаємо true з нашої функції _process_on_physical_movement, коли ми не можемо перемістити нашого гравця до кінця.

Крок 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

Тут ми маємо стандартне тіло персонажа з формою зіткнення, а також наш вихідний вузол XR і камеру як звичайні діти. У нас також є допоміжний вузол шиї.

Обробка руху нашого персонажа виконується в ті самі три кроки, але реалізується дещо по-іншому.

Примітка

Демонстрація руху персонажів містить детальніший приклад техніки, описаної нижче.

Крок 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

Для третього кроку ми знову застосовуємо рух на основі введення контролера. Однак, як і на кроці 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.

Подальші вдосконалення представленого коду можуть бути:

  • дозволяючи ввод контролера, поки ця відстань все ще мала,

  • все ще застосовуючи силу тяжіння до гравця, навіть якщо вхід контролера вимкнено.

Примітка

Демонстрації руху в нашому демонстраційному репозиторії містять приклад затемнення екрана, коли користувач заходить у зони обмеженого доступу.

Подальші пропозиції щодо покращення

Вище наведено два хороші варіанти як відправні точки для реалізації ігор XR масштабу кімнати.

Ще кілька речей, на які варто звернути увагу, які ви, ймовірно, захочете реалізувати:

  • За висотою камери можна визначити, чи гравець стоїть, присідає, стрибає чи лежить. Ви можете відповідно налаштувати розмір і орієнтацію форми зіткнення. Додаткові бонусні бали за додавання кількох форм зіткнень, щоб голова та тіло мали власні форми точнішого розміру.

  • Коли сцена завантажується вперше, гравець може бути далеко від центру простору відстеження. Це може призвести до появи гравця в кімнаті, відмінній від нашої вихідної точки. Тепер гра намагатиметься, але не вдасться, перемістити тіло гравця з початкової точки до місця, де гравець стоїть. Ви повинні застосувати функцію скидання, яка переміщує початкову точку, щоб гравець перебував у правильному початковому положенні.

Обидва вищезазначені вдосконалення вимагають, щоб гравець був готовий і стояв прямо. Гарантії немає, оскільки гравець може все ще одягати гарнітуру.

Багато ігор, у тому числі XR Tools, вирішують це, вводячи вступний екран або екран завантаження, де гравець повинен натиснути кнопку, коли він буде готовий. Це початкове середовище часто є великою локацією, де позиціонування гравця мало впливає на те, що гравець бачить. Коли гравець готовий і натискає кнопку, це момент, коли ви записуєте положення та висоту камери.