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.
Checking the stable version of the documentation...
Scala di radice 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.
Nota
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.
Nei tradizionali giochi in prima persona, il giocatore è rappresentato da un nodo CharacterBody3D. Questo nodo viene mosso elaborando l'input proveniente da un tradizionale controller, mouse o tastiera. Una telecamera è legata a questo nodo in una posizione che più o meno corrisponde a quella della testa del giocatore.
Applying this model to the XR setup, we add an XROrigin3D node as a child of the character body, and add an 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.
Pertanto, il nodo di origine non rappresenta la posizione del giocatore. Rappresenta il centro, o l'inizio, dello spazio di tracciamento in cui il giocatore può muoversi fisicamente. Man mano che il giocatore si muove nella sua stanza, questo movimento è rappresentato tramite il tracciamento del visore. Nel gioco, ciò fa aggiornare adeguatamente la posizione del nodo della telecamera. A tutti gli effetti, stiamo tracciando una testa senza corpo. A meno che non sia disponibile il tracciamento del corpo, non abbiamo alcuna informazione sulla posizione o sull'orientamento del corpo del giocatore.
Il primo problema che ne deriva è piuttosto ovvio. Quando il giocatore si muove tramite l'input del controller, possiamo usare lo stesso approccio dei giochi normali e muovere il giocatore in avanti. Tuttavia, il giocatore non si trova dove pensiamo che sia e, mentre lo muoviamo in avanti, verifichiamo le collisioni nel posto sbagliato.
Il secondo problema si manifesta soprattutto quando il giocatore si allontana dal centro dello spazio di tracciamento e utilizza l'input del controller per girare. Se ruotiamo il corpo del personaggio, il giocatore si muoverà attorno la stanza in maniera circolare.
Se risolviamo i problemi descritti prima, ne troveremo un terzo. Quando il percorso per il giocatore è bloccato nel mondo virtuale, il giocatore può comunque muoversi fisicamente in avanti.
Cercheremo di risolvere i primi due problemi con due soluzioni distinte, e poi discuteremo come affrontare il terzo.
Origin centric solution
Esaminando il primo approccio per risolvere il tutto, modificheremo la nostra struttura. Questo è l'approccio attualmente implementato in XR Tools.
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.
L'elaborazione dei movimenti del nostro personaggio avviene ora in tre fasi.
Nota
La demo Origin centric movement contiene un esempio più elaborato della tecnica descritta di seguito.
Fase 1
Nella prima fase elaboreremo il movimento fisico del giocatore. Determiniamo dove si trova attualmente il giocatore e tentiamo di muovere il nostro corpo di personaggio 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)
Nota che la nostra funzione _process_on_physical_movement restituisce true quando non riusciamo a muovere completamente il nostro giocatore.
Fase 2
La seconda fase consiste nel gestire la rotazione del giocatore come risultato dell'input dell'utente.
Poiché l'input utilizzato può variare in base alle esigenze, ci limitiamo a chiamare la funzione _get_rotational_input. Questa funzione dovrebbe ricavare l'input necessario e restituire la velocità di rotazione in radianti al secondo.
Nota
Per il nostro esempio, manterremo il tutto semplice e diretto. Non ci preoccuperemo di funzionalità di comfort come la rotazione a scatto e l'applicazione di una vignettatura. Consigliamo vivamente di implementare tali funzionalità di comfort.
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
Abbiamo aggiunto la chiamata per elaborare la rotazione al nostro processo di fisica, ma la eseguiamo solo se siamo riusciti a muovere completamente il nostro giocatore. Ciò significa che se il giocatore si muove dove non dovrebbe, non elaboriamo ulteriori movimenti.
Fase 3
La terza e ultima fase consiste nel muovere il giocatore in avanti, indietro o lateralmente a seconda dell'input dell'utente.
Come per la rotazione, gli input variano da progetto a progetto, quindi ci limitiamo a chiamare la funzione _get_movement_input. Questa funzione dovrebbe ottenere l'input necessario e restituire un vettore direzionale scalato alla velocità richiesta.
Nota
Come per la rotazione, lo manteniamo semplice. Anche qui è consigliabile pensare di aggiungere di impostazioni di comfort.
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.
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.
L'elaborazione del movimento del nostro personaggio avviene nelle stesse tre fasi, ma con un'implementazione leggermente diversa.
Nota
La demo Character centric movement contiene un esempio più elaborato della tecnica descritta di seguito.
Fase 1
In questo approccio, la fase 1 è quella in cui avviene tutta la magia. Proprio come nel nostro approccio precedente, applicheremo il movimento fisico al corpo di personaggio, ma contrasteremo tale movimento sul nodo di origine.
Ciò garantirà che la posizione del giocatore rimanga sincronizzata con la posizione del corpo di personaggio.
# 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 sostanza, il codice sopra riportato sposterà il corpo di personaggio nella posizione del giocatore, e poi riporterà indietro il nodo di origine di quantità uguali. Il risultato è che il giocatore rimarrà centrato sopra il corpo di personaggio.
Cominciamo applicando la rotazione. Il corpo di personaggio dovrebbe essere rivolto nella direzione in cui il giocatore stava guardando nel frame precedente. Calcoliamo l'orientamento della telecamera nello spazio del corpo del personaggio. Ora possiamo calcolare l'angolo di rotazione della testa del giocatore. Ruotiamo il corpo di personaggio della stessa quantità così che sia rivolto nella stessa direzione del giocatore. Infine, invertiamo la rotazione sul nodo di origine così che la telecamera risulti nuovamente allineata con il giocatore.
Per il movimento facciamo qualcosa di simile. Il corpo di personaggio dovrebbe trovarsi nella posizione in cui si trovava il giocatore nel frame precedente. Calcoliamo di quanto il giocatore si è mosso da questa posizione. Poi proviamo a muovere il corpo di personaggio in quella posizione.
Poiché il giocatore potrebbe urtare un corpo di collisione e fermarsi, riportiamo indietro il punto di origine solo della quantità di cui è stato effettivamente mosso il corpo di personaggio. Il giocatore potrebbe quindi allontanarsi da questa posizione, ma ciò si rifletterà nel suo posizionamento.
Come nella nostra soluzione precedente, restituiamo true se questo è il caso.
Fase 2
In questa fase applichiamo nuovamente la rotazione in base all'input del controller. Tuttavia, in questo caso il codice è quasi identico a come si implementerebbe in un normale gioco in prima persona.
Poiché l'input utilizzato può variare in base alle esigenze, ci limitiamo a chiamare la funzione _get_rotational_input. Questa funzione dovrebbe ricavare l'input necessario e restituire la velocità di rotazione in radianti al secondo.
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)
Fase 3
Per la terza fase applichiamo nuovamente il movimento in base all'input del controller. Tuttavia, proprio come nella fase 2, ora possiamo implementarlo come faremmo in un normale gioco in prima persona.
Come per la rotazione, gli input variano da progetto a progetto, quindi ci limitiamo a chiamare la funzione _get_movement_input. Questa funzione dovrebbe ottenere l'input necessario e restituire un vettore direzionale scalato alla velocità richiesta.
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 il giocatore cammina dove non dovrebbe
Immagina una situazione in cui il giocatore si trova fuori da una stanza chiusa a chiave. Non vuoi che il giocatore entri nella stanza finché la porta non viene sbloccata. Inoltre, non vuoi che il giocatore veda cosa c'è dentro.
La logica per muovere il giocatore tramite l'input del controller lo impedisce bene. Il giocatore incontra un corpo statico e il codice impedisce al giocatore di muoversi all'interno della stanza.
Tuttavia, con l'XR, nulla impedisce al giocatore di fare un vero passo avanti.
Grazie ai due approcci descritti sopra, impediremo al corpo di personaggio di muoversi dove il giocatore non può andare. Poiché il giocatore si è fisicamente spostato in quella posizione, la telecamera si sarà spostata all'interno della stanza.
La soluzione logica sarebbe quella di impedire completamente il movimento e regolare la posizione del punto di origine XR in modo che il giocatore rimanga fuori dalla stanza.
Il problema con questo approccio è che il movimento fisico non viene più replicato nello spazio virtuale. Ciò porterà nausea al giocatore.
Invece, quello che fanno molti giochi XR è misurare la distanza tra la posizione fisica del giocatore e la posizione in cui è stato lasciato il suo corpo virtuale. Man mano che questa distanza aumenta, solitamente fino a pochi centimetri, lo schermo si oscura gradualmente.
Le soluzioni presentate sopra ci permetterebbero di aggiungere questa logica al codice alla fine della fase 1.
Ulteriori miglioramenti al codice presentato potrebbero essere:
consentire l'input del controller finché questa distanza è ancora piccola,
applicare comunque la gravità al giocatore anche quando l'input del controller è disabilitato.
Nota
Le demo di movimento presenti nel nostro repository contengono un esempio di oscuramento dello schermo quando un utente entra in zone riservate.
Ulteriori suggerimenti per miglioramenti
Quanto sopra offre due valide opzioni come punti di partenza per implementare giochi XR room-scale.
Ecco alcune altre cose che vale la pena indicare che probabilmente vorrai implementare:
L'altezza della telecamera può essere utilizzata per rilevare se il giocatore è in piedi, accovacciato, sta saltando o è sdraiato. È possibile regolare di conseguenza le dimensioni e l'orientamento della forma di collisione. Punti bonus in più per l'aggiunta di più forme di collisione, così che la testa e il corpo abbiano forme proprie e di dimensioni più accurate.
Quando una scena viene caricata per la prima volta, il giocatore potrebbe trovarsi lontano dal centro dello spazio di tracciamento. Ciò potrebbe far comparire il giocatore in una stanza diversa dal punto di origine. Il gioco tenterà quindi, invano, di spostare il corpo di giocatore dal punto di partenza a dove si trova il giocatore. È necessario implementare una funzione di ripristino che sposti il punto di origine, così che il giocatore si trovi nella posizione di partenza corretta.
Entrambi i miglioramenti descritti richiedono che il giocatore sia pronto e in piedi dritto. Non vi è alcuna garanzia, poiché il giocatore potrebbe stare ancora mettendo il visore.
Molti giochi, incluso XR Tools, risolvono questo problema introducendo una schermata introduttiva o di caricamento in cui il giocatore deve premere un pulsante quando è pronto. Questo ambiente iniziale è spesso un luogo ampio in cui il posizionamento del giocatore ha un impatto minimo su ciò che vede. Quando il giocatore è pronto e preme il pulsante, è in quel momento che vengono registrate la posizione e l'altezza della telecamera.