123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 |
- .. _doc_xr_room_scale:
- Room scale in XR
- ================
- One of the staples of XR projects is the ability to walk around freely in a large space.
- This space is often constrained by the room the player is physically in with tracking sensors placed within this space.
- With the advent of inside out tracking however ever larger play spaces are possible.
- As a developer this introduces a number of interesting challenges.
- In this document we will look at a number of the challenges you may face and outline some solutions.
- We'll discuss the issues and challenges for seated XR games in another document.
- .. note:
- 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.
- In traditional first person games a player is represented by a :ref:`CharacterBody3D <class_characterbody3d>` node.
- This node is moved by processing traditional controller, mouse or keyboard input.
- A camera is attached to this node at a location roughly where the player's head will be.
- Applying this model to the XR setup, we add an :ref:`XROrigin3D <class_xrorigin3d>` node as a child of the character body,
- and add a :ref:`XRCamera3D <class_xrcamera3d>` as a child of the origin node. At face value this seems to work.
- However, upon closer examination this model does not take into account that there are two forms of movement in XR.
- The movement through controller input, and the physical movement of the player in the real world.
- As a result, the origin node does not represent the position of the player.
- It represents the center, or start of, the tracking space in which the player can physically move.
- As the player moves around their room this movement is represented through the tracking of the players headset.
- In game this translates to the camera node's position being updated accordingly.
- For all intents and purposes, we are tracking a disembodied head.
- Unless body tracking is available, we have no knowledge of the position or orientation of the player's body.
- .. image:: img/XRRoomCenterWalk.gif
- The first problem this causes is fairly obvious.
- When the player moves with controller input, we can use the same approach in normal games and move the player in a forward direction.
- However the player isn't where we think they are and as we move forward we're checking collisions in the wrong location.
- .. image:: img/XRRoomWalkOffCliff.gif
- The second problem really shows itself when the player walks further away from the center of the tracking space and uses controller input to turn.
- If we rotate our character body, the player will be moved around the room in a circular fashion.
- .. image:: img/XRRoomRotateOrigin.gif
- If we fix the above issues, we will find a third issue.
- When the path for the player is blocked in the virtual world, the player can still physically move forward.
- .. image:: img/XRRoomWalkWall.gif
- We will look at solving the first two problem with two separate solutions, and then discuss dealing with the third.
- Origin centric solution
- -----------------------
- Looking at the first approach for solving this we are going to change our structure.
- This is the approach currently implemented in XR Tools.
- .. image:: img/xr_room_scale_origin_body.webp
- In this setup we mark the character body as top level so it does not move with the origin.
- We also have a helper node that tells us where our neck joint is in relation to our camera.
- We use this to determine where our body center is.
- Processing our character movement is now done in three steps.
- Step 1
- ------
- In the first step we're going to process the physical movement of the player.
- We determine where the player is right now, and attempt to move our character body there.
- .. code-block:: gdscript
- 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 that we're returning ``true`` from our ``_process_on_physical_movement`` function when we couldn't move our player all the way.
- Step 2
- ------
- The second step is to handle rotation of the player as a result of user input.
- As the input used can differ based on your needs we are simply calling the function ``_get_rotational_input``.
- This function should obtain the necessary input and return the rotational speed in radians per second.
- .. note:
- 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.
- .. code-block:: gdscript
- 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)
- .. note:
- 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.
- Step 3
- ------
- The third and final step is moving the player forwards, backwards or sideways as a result of user input.
- Just like with the rotation the inputs differ from project to project so we are simply calling the function ``_get_movement_input``.
- This function should obtain the necessary input and return a directional vector scaled to the required velocity.
- .. note:
- Just like with rotation we're keeping it simple. Here too it is advisable to look at adding comfort settings.
- .. code-block:: gdscript
- 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)
- Character body centric solution
- -------------------------------
- In this setup we are going to keep our character body as our root node and as such is easier to combine with traditional game mechanics.
- .. image:: img/xr_room_scale_character_body.webp
- Here we have a standard character body with collision shape, and our XR origin node and camera as normal children.
- We also have our neck helper node.
- Processing our character movement is done in the same three steps but implemented slightly differently.
- Step 1
- ------
- In this approach step 1 is where all the magic happens.
- Just like with our previous approach we will be applying our physical movement to the character body,
- but we will counter that movement on the origin node.
- This will ensure that the players location stays in sync with the character body's location.
- .. code-block:: gdscript
- # 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)
- In essence the code above will move the character body to where the player is, and then move the origin node back in equal amounts.
- The result is that the player stays centered above the character body.
- We start with applying the rotation.
- The character body should be facing where the player was looking the previous frame.
- We calculate our camera orientation in the space of the character body.
- We can now calculate the angle by which the player has rotated their head.
- We rotate our character body by the same amount so our character body faces the same direction as the player.
- And then we reverse the rotation on the origin node so the camera ends up aligned with the player again.
- For the movement we do much the same.
- The character body should be where the player was standing the previous frame.
- We calculate by how much the player has moved from this location.
- Then we attempt to move the character body to this location.
- As the player may hit a collision body and be stopped, we only move the origin point back by the amount we actually moved the character body.
- The player may thus move away from this location but that will be reflected in the positioning of the player.
- As with our previous solution we return true if this is the case.
- Step 2
- ------
- In this step we again apply the rotation based on controller input.
- However in this case the code is nearly identical to how one would implement this in a normal first person game.
- As the input used can differ based on your needs we are simply calling the function ``_get_rotational_input``.
- This function should obtain the necessary input and return the rotational speed in radians per second.
- .. code-block:: gdscript
- 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)
- Step 3
- ------
- For step three we again apply the movement based on controller input.
- However just like at step 2, we can now implement this as we would in a normal first person game.
- Just like with the rotation the inputs differ from project to project so we are simply calling the function ``_get_movement_input``.
- This function should obtain the necessary input and return a directional vector scaled to the required velocity.
- .. code-block:: gdscript
- 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)
- When the player walks to somewhere they shouldn't
- -------------------------------------------------
- Think of a situation where the player is outside a locked room.
- You don't want the player to go into that room until the door is unlocked.
- You also don't want the player to see what is in this room.
- The logic for moving the player through controller input nicely prevents this.
- The player encounters a static body, and the code prevents the player from moving into the room.
- However with XR, nothing is preventing the player from taking a real step forward.
- With both the approaches worked out up above we will prevent the character body from moving where the player can't go.
- As the player has physically moved to this location, the camera will now have moved into the room.
- The logical solution would be to prevent the movement altogether
- and adjust the placement of the XR origin point so the player stays outside of the room.
- The problem with this approach is that physical movement is now not replicated in the virtual space.
- This will cause nausea for the player.
- What many XR games do instead, is to measure the distance between where the player physically is,
- and where the players virtual body has been left behind.
- As this distance increases, usually to a distance of a few centimeters, the screen slowly blacks out.
- Our solutions up above would allow us to add this logic into the code at the end of step 1.
- Further improvements to the code presented could be:
- - allowing controller input as long as this distance is still small,
- - still applying gravity to the player even when controller input is disabled.
- Further suggestions for improvements
- ------------------------------------
- The above provides two good options as starting points for implementing room scale XR games.
- A few more things that are worth pointing out that you will likely want to implement:
- * The height of the camera can be used to detect whether the player is standing up, crouching, jumping or lying down.
- You can adjust the size and orientation of the collision shape accordingly.
- Extra bonus points for adding multiple collision shapes so the head and body have their own, more accurately sized, shapes.
- * When a scene first loads, the player may be far away from the center of the tracking space.
- This could result in the player spawning into a different room than our origin point.
- The game will now attempt, and fail, to move the player body from the starting point to where the player is standing.
- You should implement a reset function that moves the origin point so the player is in the correct starting position.
- Both of the above improvements require the player to be ready and standing up straight.
- There is no guarantee as the player may still be putting their headset on.
- Many games, including XR Tools, solve this by introducing an intro screen or loading screen where the player must press a button when they are ready.
- This starting environment is often a large location where the positioning of the player has little impact on what the player sees.
- When the player is ready, and presses the button, this is the moment you record the position and height of the camera.
|