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.

Escala de sala (Room scale) em XR

Um dos elementos principais dos projetos de XR é a capacidade de andar livremente em um grande espaço. Este espaço é frequentemente limitado pela sala em que o jogador está fisicamente, com sensores de rastreamento posicionados dentro deste espaço. Com o advento do rastreamento de dentro para fora (inside-out tracking), no entanto, espaços de jogo cada vez maiores são possíveis.

Como desenvolvedor, isso introduz uma série de desafios interessantes. Neste documento, examinaremos vários dos desafios que você pode enfrentar e delinearemos algumas soluções. Discutiremos os problemas e desafios para jogos de XR sentados em outro documento.

Nota

Muitas vezes, os desenvolvedores sentam-se atrás de suas mesas enquanto constroem a base de seu jogo. Neste modo, os problemas de desenvolvimento para a escala de sala (room scale) não se manifestam até que seja tarde demais. O conselho aqui é começar a testar em pé e andando o mais cedo possível. Uma vez que você esteja satisfeito que sua base está sólida, você pode desenvolver confortavelmente enquanto permanece sentado.

Em jogos tradicionais em primeira pessoa, um jogador é representado por um nó CharacterBody3D. Este nó é movido processando a entrada tradicional de controle, mouse ou teclado. Uma câmera é anexada a este nó em um local aproximadamente onde a cabeça do jogador estaria.

Aplicando este modelo à configuração de XR, adicionamos um nó XROrigin3D como filho do corpo do personagem e adicionamos uma XRCamera3D como filha do nó de origem. À primeira vista, isso parece funcionar. No entanto, após um exame mais detalhado, este modelo não leva em consideração que existem duas formas de movimento em XR: o movimento através da entrada do controle e o movimento físico do jogador no mundo real.

Como resultado, o nó de origem não representa a posição do jogador. Ele representa o centro, ou o início, do espaço de rastreamento no qual o jogador pode se mover fisicamente. À medida que o jogador se move pela sala, esse movimento é representado através do rastreamento do headset do jogador. No jogo, isso se traduz na atualização correspondente da posição do nó da câmera. Para todos os efeitos, estamos rastreando uma cabeça desincorporada. A menos que o rastreamento corporal esteja disponível, não temos conhecimento da posição ou orientação do corpo do jogador.

../../_images/XRRoomCenterWalk.gif

O primeiro problema que isso causa é bastante óbvio. Quando o jogador se move com a entrada do controle, podemos usar a mesma abordagem de jogos normais e mover o jogador em uma direção para a frente. No entanto, o jogador não está onde pensamos que está e, conforme avançamos, estamos verificando colisões no local errado.

../../_images/XRRoomWalkOffCliff.gif

O segundo problema realmente se manifesta quando o jogador se afasta do centro do espaço de rastreamento e usa a entrada do controle para girar. Se rotacionarmos o corpo do nosso personagem, o jogador será movido ao redor da sala de maneira circular.

../../_images/XRRoomRotateOrigin.gif

Se corrigirmos os problemas acima, encontraremos um terceiro problema. Quando o caminho para o jogador é bloqueado no mundo virtual, o jogador ainda pode se mover fisicamente para a frente.

../../_images/XRRoomWalkWall.gif

Examinaremos a resolução dos dois primeiros problemas com duas soluções separadas e, em seguida, discutiremos como lidar com o terceiro.

Solução centrada na origem

Olhando para a primeira abordagem para resolver isso, vamos mudar nossa estrutura. Esta é a abordagem atualmente implementada no XR Tools.

../../_images/xr_room_scale_origin_body.webp

Nesta configuração, marcamos o corpo do personagem como nível superior (top level) para que ele não se mova com a origem.

Também temos um nó auxiliar que nos diz onde está a articulação do nosso pescoço em relação à nossa câmera. Usamos isso para determinar onde está o centro do nosso corpo.

O processamento do movimento do nosso personagem agora é feito em três etapas.

Nota

O demo de movimento centrado na origem contém um exemplo mais elaborado da técnica descrita abaixo.

Passo 1

Na primeira etapa, vamos processar o movimento físico do jogador. Determinamos onde o jogador está agora e tentamos mover o corpo do nosso personagem para lá.

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)

Note que estamos retornando true da nossa função _process_on_physical_movement quando não conseguimos mover nosso jogador por completo.

Passo 2

A segunda etapa é lidar com a rotação do jogador como resultado da entrada do usuário.

Como a entrada usada pode diferir com base nas suas necessidades, estamos simplesmente chamando a função _get_rotational_input. Esta função deve obter a entrada necessária e retornar a velocidade de rotação em radianos por segundo.

Nota

Para o nosso exemplo, vamos manter isso simples e direto. Não vamos nos preocupar com recursos de conforto, como rotação em estalos (snap turning) e aplicação de vinheta. Recomendamos fortemente a implementação de tais recursos de conforto.

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)

Nota

Adicionamos a chamada para processar nossa rotação ao nosso processo de física, mas só a executamos se fomos capazes de mover nosso jogador totalmente. Isso significa que se o jogador se mover para algum lugar onde não deveria, não processamos mais movimentos.

Passo 3

A terceira e última etapa é mover o jogador para a frente, para trás ou para os lados como resultado da entrada do usuário.

Assim como na rotação, as entradas diferem de projeto para projeto, então estamos simplesmente chamando a função _get_movement_input. Esta função deve obter a entrada necessária e retornar um vetor direcional escalonado para a velocidade exigida.

Nota

Assim como na rotação, estamos mantendo as coisas simples. Aqui também é aconselhável considerar a adição de configurações de conforto.

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)

Solução centrada no corpo do personagem

Nesta configuração, vamos manter o corpo do nosso personagem como nosso nó raiz e, como tal, é mais fácil de combinar com mecânicas de jogo tradicionais.

../../_images/xr_room_scale_character_body.webp

Aqui temos um corpo de personagem padrão com formato de colisão, e nosso nó de origem XR e câmera como filhos normais. Também temos nosso nó auxiliar de pescoço.

O processamento do movimento do nosso personagem é feito nas mesmas três etapas, mas implementado de forma ligeiramente diferente.

Nota

O demo de movimento centrado no personagem contém um exemplo mais elaborado da técnica descrita abaixo.

Passo 1

Nesta abordagem, a etapa 1 é onde toda a mágica acontece. Assim como na nossa abordagem anterior, aplicaremos nosso movimento físico ao corpo do personagem, mas compensaremos esse movimento no nó de origem.

Isso garantirá que a localização do jogador permaneça em sincronia com a localização do corpo do personagem.

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

Em essência, o código acima moverá o corpo do personagem para onde o jogador está e, em seguida, moverá o nó de origem de volta em quantidades iguais. O resultado é que o jogador permanece centralizado acima do corpo do personagem.

Começamos aplicando a rotação. O corpo do personagem deve estar voltado para onde o jogador estava olhando no frame anterior. Calculamos a orientação da nossa câmera no espaço do corpo do personagem. Podemos agora calcular o ângulo pelo qual o jogador rotacionou sua cabeça. Rotacionamos o corpo do nosso personagem na mesma quantidade para que o corpo do nosso personagem fique voltado para a mesma direção do jogador. E então revertemos a rotação no nó de origem para que a câmera termine alinhada com o jogador novamente.

Para o movimento, fazemos quase o mesmo. O corpo do personagem deve estar onde o jogador estava no frame anterior. Calculamos o quanto o jogador se moveu a partir deste local. Então, tentamos mover o corpo do personagem para este local.

Como o jogador pode atingir um corpo de colisão e ser parado, só movemos o ponto de origem de volta na quantidade que realmente movemos o corpo do personagem. O jogador pode, assim, se afastar deste local, mas isso será refletido no posicionamento do jogador.

Assim como em nossa solução anterior, retornamos verdadeiro se este for o caso.

Passo 2

Nesta etapa, aplicamos novamente a rotação com base na entrada do controle. No entanto, neste caso, o código é quase idêntico a como se implementaria isso em um jogo normal em primeira pessoa.

Como a entrada usada pode diferir com base nas suas necessidades, estamos simplesmente chamando a função _get_rotational_input. Esta função deve obter a entrada necessária e retornar a velocidade de rotação em radianos por segundo.

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)

Passo 3

Para a etapa três, aplicamos novamente o movimento com base na entrada do controle. No entanto, assim como na etapa 2, agora podemos implementar isso como faríamos em um jogo normal em primeira pessoa.

Assim como na rotação, as entradas diferem de projeto para projeto, então estamos simplesmente chamando a função _get_movement_input. Esta função deve obter a entrada necessária e retornar um vetor direcional escalonado para a velocidade exigida.

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)

Quando o jogador caminha para onde não deveria

Pense em uma situação em que o jogador está do lado fora de uma sala trancada. Você não quer que o jogador entre nessa sala até que a porta esteja destrancada. Você também não quer que o jogador veja o que está nesta sala.

A lógica para mover o jogador através da entrada do controle evita isso perfeitamente. O jogador encontra um corpo estático e o código impede que o jogador se mova para dentro da sala.

No entanto, com XR, nada impede o jogador de dar um passo real para a frente.

Com ambas as abordagens detalhadas acima, evitaremos que o corpo do personagem se mova para onde o jogador não pode ir. Como o jogador se moveu fisicamente para este local, a câmera agora terá se movido para dentro da sala.

A solução lógica seria impedir totalmente o movimento e ajustar o posicionamento do ponto de origem XR para que o jogador permaneça fora da sala.

O problema com essa abordagem é que o movimento físico agora não é replicado no espaço virtual. Isso causará náuseas ao jogador.

O que muitos jogos de XR fazem, em vez disso, é medir a distância entre onde o jogador está fisicamente e onde o corpo virtual do jogador foi deixado para trás. À medida que essa distância aumenta, geralmente para uma distância de alguns centímetros, a tela escurece lentamente (blackout).

Nossas soluções acima nos permitiriam adicionar essa lógica ao código no final da etapa 1.

Melhorias adicionais ao código apresentado poderiam ser:

  • permitir a entrada do controle desde que essa distância ainda seja pequena,

  • ainda aplicar gravidade ao jogador mesmo quando a entrada do controle estiver desativada.

Nota

As demonstrações de movimento em nosso repositório de demos contêm um exemplo de escurecimento da tela quando um usuário caminha em áreas restritas.

Sugestões adicionais para melhorias

O texto acima fornece duas boas opções como pontos de partida para a implementação de jogos de XR em escala de sala.

Mais algumas coisas que valem a pena apontar que você provavelmente desejará implementar:

  • A altura da câmera pode ser usada para detectar se o jogador está em pé, agachado, pulando ou deitado. Você pode ajustar o tamanho e a orientação do formato de colisão de acordo. Pontos de bônus extras por adicionar múltiplos formatos de colisão para que a cabeça e o corpo tenham seus próprios formatos, com tamanhos mais precisos.

  • Quando uma cena é carregada pela primeira vez, o jogador pode estar longe do centro do espaço de rastreamento. Isso pode fazer com que o jogador surja em uma sala diferente do nosso ponto de origem. O jogo agora tentará, e falhará, mover o corpo do jogador do ponto de partida para onde o jogador está em pé. Você deve implementar uma função de redefinição que mova o ponto de origem para que o jogador fique na posição inicial correta.

Ambas as melhorias acima exigem que o jogador esteja pronto e de pé. Não há garantia, pois o jogador ainda pode estar colocando o headset.

Muitos jogos, incluindo o XR Tools, resolvem isso introduzindo uma tela de introdução ou tela de carregamento onde o jogador deve pressionar um botão quando estiver pronto. Este ambiente inicial é frequentemente um local amplo onde o posicionamento do jogador tem pouco impacto no que ele vê. Quando o jogador está pronto e pressiona o botão, este é o momento em que você registra a posição e a altura da câmera.