Масштаб помещения в XR
Одна из основных особенностей XR-проектов — возможность свободно перемещаться в большом пространстве. Это пространство часто ограничено комнатой, в которой физически находится игрок, и датчиками отслеживания, расположенными внутри этого пространства. Однако с появлением технологии отслеживания «изнутри наружу» стали возможны всё большие игровые пространства.
Для разработчика это создаёт ряд интересных сложностей. В этом документе мы рассмотрим ряд проблем, с которыми вы можете столкнуться, и предложим некоторые решения. Проблемы и сложности, связанные с играми XR, для которых нужно играть сидя, мы обсудим в другом документе.
Примечание
Разработчики часто сидят за столом, создавая основу для своей игры. В этом режиме проблемы разработки в масштабе комнаты проявляются лишь тогда, когда становится слишком поздно. Советую начать тестирование стоя и ходить как можно раньше. Как только вы убедитесь, что основа прочна, вы сможете комфортно разрабатывать, не выходя из дома.
В традиционных играх от первого лица игрок представлен узлом CharacterBody3D. Этот узел перемещается, обрабатывая традиционный ввод с контроллера, мыши или клавиатуры. Камера прикреплена к этому узлу примерно в том месте, где будет находиться голова игрока.
Применяя эту модель к конфигурации XR, мы добавляем узел XROrigin3D в качестве дочернего узла тела персонажа и добавляем узел XRCamera3D в качестве дочернего узла исходного узла. На первый взгляд, это работает. Однако при более внимательном рассмотрении эта модель не учитывает, что в XR существуют два вида движения: движение посредством ввода с контроллера и физическое движение игрока в реальном мире.
В результате узел начала координат не отображает положение игрока. Он представляет собой центр, или начало, пространства отслеживания, в котором игрок может физически перемещаться. Перемещение игрока по комнате отображается с помощью отслеживания его гарнитуры. В игре это соответствует соответствующему обновлению положения узла камеры. Фактически, мы отслеживаем голову без тела. Если отслеживание тела недоступно, мы не знаем положения или ориентации тела игрока.
Первая проблема, возникающая в результате этого, довольно очевидна. Когда игрок движется с помощью контроллера, мы можем использовать тот же подход в обычных играх и перемещать его вперёд. Однако игрок находится не там, где мы предполагаем, и при движении вперёд мы проверяем столкновения в неправильном месте.
Вторая проблема особенно ярко проявляется, когда игрок отходит дальше от центра пространства отслеживания и использует управление контроллером для поворота. Если мы вращаем тело персонажа, игрок будет двигаться по комнате по кругу.
Если мы исправим эти проблемы, то обнаружим третью: даже если путь игрока в виртуальном мире заблокирован, он всё равно может физически двигаться вперёд.
Мы рассмотрим решение первых двух проблем с помощью двух отдельных решений, а затем обсудим решение третьей.
Решение, ориентированное на происхождение
Рассматривая первый подход к решению этой проблемы, мы изменим нашу структуру. Именно этот подход в настоящее время реализован в XR Tools.
В этой настройке мы помечаем тело персонажа как верхний уровень, чтобы оно не перемещалось вместе с началом координат.
У нас также есть вспомогательный узел, который определяет положение шейного сустава относительно камеры. Мы используем его для определения центра тела.
Обработка движения нашего персонажа теперь выполняется в три этапа.
Примечание
Демонстрация движения, ориентированного на 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)
Решение, ориентированное на тело персонажа
В этой настройке мы собираемся сохранить тело нашего персонажа в качестве корневого узла, и поэтому его легче комбинировать с традиционной игровой механикой.
Здесь у нас есть стандартное тело персонажа с формой столкновения, а также узел начала 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
На третьем шаге мы снова применяем движение, основанное на действиях контроллера. Однако, как и на втором шаге, теперь мы можем реализовать это так же, как в обычной игре от первого лица.
Как и в случае с вращением, входные данные различаются от проекта к проекту, поэтому мы просто вызываем функцию _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, решают эту проблему, добавляя вступительный или загрузочный экран, где игрок должен нажать кнопку, когда будет готов. Эта стартовая среда часто представляет собой большую локацию, где положение игрока мало влияет на то, что он видит. Когда игрок готов и нажимает кнопку, вы фиксируете положение и высоту камеры.