xr_room_scale.rst 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. .. _doc_xr_room_scale:
  2. Room scale in XR
  3. ================
  4. One of the staples of XR projects is the ability to walk around freely in a large space.
  5. This space is often constrained by the room the player is physically in with tracking sensors placed within this space.
  6. With the advent of inside out tracking however ever larger play spaces are possible.
  7. As a developer this introduces a number of interesting challenges.
  8. In this document we will look at a number of the challenges you may face and outline some solutions.
  9. We'll discuss the issues and challenges for seated XR games in another document.
  10. .. note:
  11. Often developers sit behind their desk while building the foundation to their game.
  12. In this mode the issues with developing for room scale don't show themselves until it is too late.
  13. The advice here is to start testing while standing up and walking around as early as possible.
  14. Once you are happy your foundation is solid, you can develop in comfort while remaining seated.
  15. In traditional first person games a player is represented by a :ref:`CharacterBody3D <class_characterbody3d>` node.
  16. This node is moved by processing traditional controller, mouse or keyboard input.
  17. A camera is attached to this node at a location roughly where the player's head will be.
  18. Applying this model to the XR setup, we add an :ref:`XROrigin3D <class_xrorigin3d>` node as a child of the character body,
  19. and add a :ref:`XRCamera3D <class_xrcamera3d>` as a child of the origin node. At face value this seems to work.
  20. However, upon closer examination this model does not take into account that there are two forms of movement in XR.
  21. The movement through controller input, and the physical movement of the player in the real world.
  22. As a result, the origin node does not represent the position of the player.
  23. It represents the center, or start of, the tracking space in which the player can physically move.
  24. As the player moves around their room this movement is represented through the tracking of the players headset.
  25. In game this translates to the camera node's position being updated accordingly.
  26. For all intents and purposes, we are tracking a disembodied head.
  27. Unless body tracking is available, we have no knowledge of the position or orientation of the player's body.
  28. .. image:: img/XRRoomCenterWalk.gif
  29. The first problem this causes is fairly obvious.
  30. When the player moves with controller input, we can use the same approach in normal games and move the player in a forward direction.
  31. However the player isn't where we think they are and as we move forward we're checking collisions in the wrong location.
  32. .. image:: img/XRRoomWalkOffCliff.gif
  33. 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.
  34. If we rotate our character body, the player will be moved around the room in a circular fashion.
  35. .. image:: img/XRRoomRotateOrigin.gif
  36. If we fix the above issues, we will find a third issue.
  37. When the path for the player is blocked in the virtual world, the player can still physically move forward.
  38. .. image:: img/XRRoomWalkWall.gif
  39. We will look at solving the first two problem with two separate solutions, and then discuss dealing with the third.
  40. Origin centric solution
  41. -----------------------
  42. Looking at the first approach for solving this we are going to change our structure.
  43. This is the approach currently implemented in XR Tools.
  44. .. image:: img/xr_room_scale_origin_body.webp
  45. In this setup we mark the character body as top level so it does not move with the origin.
  46. We also have a helper node that tells us where our neck joint is in relation to our camera.
  47. We use this to determine where our body center is.
  48. Processing our character movement is now done in three steps.
  49. Step 1
  50. ------
  51. In the first step we're going to process the physical movement of the player.
  52. We determine where the player is right now, and attempt to move our character body there.
  53. .. code-block:: gdscript
  54. func _process_on_physical_movement(delta):
  55. # Remember our current velocity, we'll apply that later
  56. var current_velocity = $CharacterBody3D.velocity
  57. # Remember where our player body currently is
  58. var org_player_body: Vector3 = $CharacterBody3D.global_transform.origin
  59. # Determine where our player body should be
  60. var player_body_location: Vector3 = $XRCamera3D.transform * $XRCamera3D/Neck.transform.origin
  61. player_body_location.y = 0.0
  62. player_body_location = global_transform * player_body_location
  63. # Attempt to move our character
  64. $CharacterBody3D.velocity = (player_body_location - org_player_body) / delta
  65. $CharacterBody3D.move_and_slide()
  66. # Set back to our current value
  67. $CharacterBody3D.velocity = current_velocity
  68. # Check if we managed to move all the way, ignoring height change
  69. var movement_left = player_body_location - $CharacterBody3D.global_transform.origin
  70. movement_left.y = 0.0
  71. if (movement_left).length() > 0.01:
  72. # We'll talk more about what we'll do here later on
  73. return true
  74. else:
  75. return false
  76. func _physics_process(delta):
  77. var is_colliding = _process_on_physical_movement(delta)
  78. Note that we're returning ``true`` from our ``_process_on_physical_movement`` function when we couldn't move our player all the way.
  79. Step 2
  80. ------
  81. The second step is to handle rotation of the player as a result of user input.
  82. As the input used can differ based on your needs we are simply calling the function ``_get_rotational_input``.
  83. This function should obtain the necessary input and return the rotational speed in radians per second.
  84. .. note:
  85. For our example we are going to keep this simple and straight forward.
  86. We are not going to worry about comfort features such as snap turning and applying a vignette.
  87. We highly recommend implementing such comfort features.
  88. .. code-block:: gdscript
  89. func _get_rotational_input() -> float:
  90. # Implement this function to return rotation in radians per second.
  91. return 0.0
  92. func _copy_player_rotation_to_character_body():
  93. # We only copy our forward direction to our character body, we ignore tilt
  94. var camera_forward: Vector3 = -$XRCamera3D.global_transform.basis.z
  95. var body_forward: Vector3 = Vector3(camera_forward.x, 0.0, camera_forward.z)
  96. $CharacterBody3D.global_transform.basis = Basis.looking_at(body_forward, Vector3.UP)
  97. func _process_rotation_on_input(delta):
  98. var t1 := Transform3D()
  99. var t2 := Transform3D()
  100. var rot := Transform3D()
  101. # We are going to rotate the origin around the player
  102. var player_position = $CharacterBody3D.global_transform.origin - global_transform.origin
  103. t1.origin = -player_position
  104. t2.origin = player_position
  105. rot = rot.rotated(Vector3(0.0, 1.0, 0.0), _get_rotational_input() * delta)
  106. global_transform = (global_transform * t2 * rot * t1).orthonormalized()
  107. # Now ensure our player body is facing the correct way as well
  108. _copy_player_rotation_to_character_body()
  109. func _physics_process(delta):
  110. var is_colliding = _process_on_physical_movement(delta)
  111. if !is_colliding:
  112. _process_rotation_on_input(delta)
  113. .. note:
  114. 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.
  115. This means that if the player moves somewhere they shouldn't, we don't process further movement.
  116. Step 3
  117. ------
  118. The third and final step is moving the player forwards, backwards or sideways as a result of user input.
  119. Just like with the rotation the inputs differ from project to project so we are simply calling the function ``_get_movement_input``.
  120. This function should obtain the necessary input and return a directional vector scaled to the required velocity.
  121. .. note:
  122. Just like with rotation we're keeping it simple. Here too it is advisable to look at adding comfort settings.
  123. .. code-block:: gdscript
  124. var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
  125. func _get_movement_input() -> Vector2:
  126. # Implement this to return requested directional movement in meters per second.
  127. return Vector2()
  128. func _process_movement_on_input(delta):
  129. # Remember where our player body currently is
  130. var org_player_body: Vector3 = $CharacterBody3D.global_transform.origin
  131. # We start with applying gravity
  132. $CharacterBody3D.velocity.y -= gravity * delta
  133. # Now we add in our movement
  134. var input: Vector2 = _get_movement_input()
  135. var movement: Vector3 = ($CharacterBody3D.global_transform.basis * Vector3(input.x, 0, input.y))
  136. $CharacterBody3D.velocity.x = movement.x
  137. $CharacterBody3D.velocity.z = movement.z
  138. # Attempt to move our player
  139. $CharacterBody3D.move_and_slide()
  140. # And now apply the actual movement to our origin
  141. global_transform.origin += $CharacterBody3D.global_transform.origin - org_player_body
  142. func _physics_process(delta):
  143. var is_colliding = _process_on_physical_movement(delta)
  144. if !is_colliding:
  145. _process_rotation_on_input(delta)
  146. _process_movement_on_input(delta)
  147. Character body centric solution
  148. -------------------------------
  149. 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.
  150. .. image:: img/xr_room_scale_character_body.webp
  151. Here we have a standard character body with collision shape, and our XR origin node and camera as normal children.
  152. We also have our neck helper node.
  153. Processing our character movement is done in the same three steps but implemented slightly differently.
  154. Step 1
  155. ------
  156. In this approach step 1 is where all the magic happens.
  157. Just like with our previous approach we will be applying our physical movement to the character body,
  158. but we will counter that movement on the origin node.
  159. This will ensure that the players location stays in sync with the character body's location.
  160. .. code-block:: gdscript
  161. # Helper variables to keep our code readable
  162. @onready var origin_node = $XROrigin3D
  163. @onready var camera_node = $XROrigin3D/XRCamera3D
  164. @onready var neck_position_node = $XROrigin3D/XRCamera3D/Neck
  165. func _process_on_physical_movement(delta) -> bool:
  166. # Remember our current velocity, we'll apply that later
  167. var current_velocity = velocity
  168. # Start by rotating the player to face the same way our real player is
  169. var camera_basis: Basis = origin_node.transform.basis * camera_node.transform.basis
  170. var forward: Vector2 = Vector2(camera_basis.z.x, camera_basis.z.z)
  171. var angle: float = forward.angle_to(Vector2(0.0, 1.0))
  172. # Rotate our character body
  173. transform.basis = transform.basis.rotated(Vector3.UP, angle)
  174. # Reverse this rotation our origin node
  175. origin_node.transform = Transform3D().rotated(Vector3.UP, -angle) * origin_node.transform
  176. # Now apply movement, first move our player body to the right location
  177. var org_player_body: Vector3 = global_transform.origin
  178. var player_body_location: Vector3 = origin_node.transform * camera_node.transform * neck_position_node.transform.origin
  179. player_body_location.y = 0.0
  180. player_body_location = global_transform * player_body_location
  181. velocity = (player_body_location - org_player_body) / delta
  182. move_and_slide()
  183. # Now move our XROrigin back
  184. var delta_movement = global_transform.origin - org_player_body
  185. origin_node.global_transform.origin -= delta_movement
  186. # Return our value
  187. velocity = current_velocity
  188. if (player_body_location - global_transform.origin).length() > 0.01:
  189. # We'll talk more about what we'll do here later on
  190. return true
  191. else:
  192. return false
  193. func _physics_process(delta):
  194. var is_colliding = _process_on_physical_movement(delta)
  195. 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.
  196. The result is that the player stays centered above the character body.
  197. We start with applying the rotation.
  198. The character body should be facing where the player was looking the previous frame.
  199. We calculate our camera orientation in the space of the character body.
  200. We can now calculate the angle by which the player has rotated their head.
  201. We rotate our character body by the same amount so our character body faces the same direction as the player.
  202. And then we reverse the rotation on the origin node so the camera ends up aligned with the player again.
  203. For the movement we do much the same.
  204. The character body should be where the player was standing the previous frame.
  205. We calculate by how much the player has moved from this location.
  206. Then we attempt to move the character body to this location.
  207. 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.
  208. The player may thus move away from this location but that will be reflected in the positioning of the player.
  209. As with our previous solution we return true if this is the case.
  210. Step 2
  211. ------
  212. In this step we again apply the rotation based on controller input.
  213. However in this case the code is nearly identical to how one would implement this in a normal first person game.
  214. As the input used can differ based on your needs we are simply calling the function ``_get_rotational_input``.
  215. This function should obtain the necessary input and return the rotational speed in radians per second.
  216. .. code-block:: gdscript
  217. func _get_rotational_input() -> float:
  218. # Implement this function to return rotation in radians per second.
  219. return 0.0
  220. func _process_rotation_on_input(delta):
  221. rotation.y += _get_rotational_input() * delta
  222. func _physics_process(delta):
  223. var is_colliding = _process_on_physical_movement(delta)
  224. if !is_colliding:
  225. _process_rotation_on_input(delta)
  226. Step 3
  227. ------
  228. For step three we again apply the movement based on controller input.
  229. However just like at step 2, we can now implement this as we would in a normal first person game.
  230. Just like with the rotation the inputs differ from project to project so we are simply calling the function ``_get_movement_input``.
  231. This function should obtain the necessary input and return a directional vector scaled to the required velocity.
  232. .. code-block:: gdscript
  233. func _get_movement_input() -> Vector2:
  234. # Implement this to return requested directional movement in meters per second.
  235. return Vector2()
  236. func _process_movement_on_input(delta):
  237. var movement_input = _get_movement_input()
  238. var direction = global_transform.basis * Vector3(movement_input.x, 0, movement_input.y)
  239. if direction:
  240. velocity.x = direction.x
  241. velocity.z = direction.z
  242. else:
  243. velocity.x = move_toward(velocity.x, 0, delta)
  244. velocity.z = move_toward(velocity.z, 0, delta)
  245. move_and_slide()
  246. func _physics_process(delta):
  247. var is_colliding = _process_on_physical_movement(delta)
  248. if !is_colliding:
  249. _process_rotation_on_input(delta)
  250. _process_movement_on_input(delta)
  251. When the player walks to somewhere they shouldn't
  252. -------------------------------------------------
  253. Think of a situation where the player is outside a locked room.
  254. You don't want the player to go into that room until the door is unlocked.
  255. You also don't want the player to see what is in this room.
  256. The logic for moving the player through controller input nicely prevents this.
  257. The player encounters a static body, and the code prevents the player from moving into the room.
  258. However with XR, nothing is preventing the player from taking a real step forward.
  259. With both the approaches worked out up above we will prevent the character body from moving where the player can't go.
  260. As the player has physically moved to this location, the camera will now have moved into the room.
  261. The logical solution would be to prevent the movement altogether
  262. and adjust the placement of the XR origin point so the player stays outside of the room.
  263. The problem with this approach is that physical movement is now not replicated in the virtual space.
  264. This will cause nausea for the player.
  265. What many XR games do instead, is to measure the distance between where the player physically is,
  266. and where the players virtual body has been left behind.
  267. As this distance increases, usually to a distance of a few centimeters, the screen slowly blacks out.
  268. Our solutions up above would allow us to add this logic into the code at the end of step 1.
  269. Further improvements to the code presented could be:
  270. - allowing controller input as long as this distance is still small,
  271. - still applying gravity to the player even when controller input is disabled.
  272. Further suggestions for improvements
  273. ------------------------------------
  274. The above provides two good options as starting points for implementing room scale XR games.
  275. A few more things that are worth pointing out that you will likely want to implement:
  276. * The height of the camera can be used to detect whether the player is standing up, crouching, jumping or lying down.
  277. You can adjust the size and orientation of the collision shape accordingly.
  278. Extra bonus points for adding multiple collision shapes so the head and body have their own, more accurately sized, shapes.
  279. * When a scene first loads, the player may be far away from the center of the tracking space.
  280. This could result in the player spawning into a different room than our origin point.
  281. The game will now attempt, and fail, to move the player body from the starting point to where the player is standing.
  282. You should implement a reset function that moves the origin point so the player is in the correct starting position.
  283. Both of the above improvements require the player to be ready and standing up straight.
  284. There is no guarantee as the player may still be putting their headset on.
  285. 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.
  286. This starting environment is often a large location where the positioning of the player has little impact on what the player sees.
  287. When the player is ready, and presses the button, this is the moment you record the position and height of the camera.