Hi all,

Extremely new to Godot and having some issues with rotating objects.

I am trying to create a first person camera that can pick up and drop objects in the environment. When I pick up the object I want to lock its current rotation along the Y axis relative to the camera. When I move the mouse, I want the object to rotate in global space to remain directly in front of the camera, but its rotation about the y-axis relative to the camera should remain the same, ie: the item should always remain upright but stay facing the camera at the same XZ orientation that it was picked up at.

My character is structured like:

  • Player
    -- PlayerPOV
    --- RayCast3D
    --- HeldItemPosition

The only times that the player.gd code interacts with the carryable object are:

  1. If the user left-clicks while not carrying anything, it gets the first collider along the RayCast3D and if it’s Carryable it calls pick_up() on the collider.
  2. If the user right-clicks while carrying something, it calls drop() on the carried object.
    	if Input.is_action_just_pressed("interact"):
    		if held_object == null:
    			var looking_at = raycast.get_collider()
    			if looking_at is Carryable:
    				held_object = looking_at
    				held_object.pick_up($PlayerPOV/HeldItemPosition)
    	if Input.is_action_just_pressed("drop"):
    		if held_object:
    			held_object.drop()
    			held_object = null

Other than that all of the logic is contained within the carryable scene. What I’m trying to do is:

  1. When the item is picked up disable all of its collision physics and get the initial angle in global space between the object’s and carrier’s rotation vectors on the XZ plane.
  2. When the item is dropped, restore all of its collision physics.
  3. On every physics processing tick, a) get the carrier’s global transform origin and store it as the object’s global transform origin, and b) get the camera’s global transform basis, set the y-vector to Vector3.UP to keep it upright, rotate by the angle recorded when the object was picked up, and store it as the object’s global transform basis.

Desired outcome:

  1. When moving the mouse in any direction, the carried object always remains upright. - WORKING AS DESIRED
  2. When moving the mouse left and right, the object should stay in the same rotation relative to the camera - WORKING AS DESIRED
  3. When first picking up the item it should retain its existing orientation relative to the camera - NOT WORKING
    Instead, the object snaps to a seemingly-arbitrary angle about the y axis upon pickup and I can’t figure out why. Here is the code for carryable.gd
extends RigidBody3D
class_name Carryable

var _carrier: Node3D
var _rotation_offset : float
var carried: bool = false
@onready var original_collision_layer = collision_layer
@onready var original_collision_mask = collision_mask

func pick_up(carrier: Node3D) -> void:
	# stop physics interactions while object is carried
	freeze = true
	collision_mask = 0
	collision_layer = 0

	# capture carrier information
	carried = true
	_carrier = carrier

	# get the angle between the direction of the camera and the object on the xz plane
	_rotation_offset = _get_xz_rotation(_carrier.global_rotation, global_rotation)
	
func _get_xz_rotation(a: Vector3, b: Vector3):
	# normalize a and b and project them onto the xz plane
	a = Plane.PLANE_XZ.project(a).normalized()
	b = Plane.PLANE_XZ.project(b).normalized()
	var theta = a.angle_to(b)
	return theta
	
func drop() -> void:
	# restore physics
	freeze = false
	collision_mask = original_collision_mask
	collision_layer = original_collision_layer

	carried = false
	_carrier = null
	
func _physics_process(delta: float):
	if carried:
		# get the carrier's position in global space and snap self to it
		var target_origin = _carrier.global_transform.origin
		global_transform.origin = target_origin
		
		# get the global rotation info for the carrier but maintain the y axis as
		# straight up to keep the object upright
		var target_basis: Basis = Basis(
			_carrier.global_transform.basis.x,
			Vector3.UP,
			_carrier.global_transform.basis.z).orthonormalized()

		# rotate the target basis by the initial angle between the object and the carrier
		target_basis = target_basis.rotated(Vector3.UP, _rotation_offset)

		# store as the new basis
		global_transform.basis = target_basis

Here's a video of what is happening:

As you can see when I pick up the item it initially rotates to a new orientation (bad) which it then maintains (good).

My guess is that _rotation_offset isn’t being calculated properly but I can’t quite figure out why not unless some function isn’t doing what I expect. I’m very new to godot so I might be missing something obvious.

Thanks in advance please let me know if any extra information is helpful.

  • xyz replied to this.
  • hlzachar You're overcomplicating this.
    When box is picked up, store the matrix that transforms from box's local space to player's local space:

    local_to_local = player.global_transform.inverse() * box.global_transform 

    Next, on each frame the box is carried, transform local_to_local matrix back into global space and assign that transform as box's global matrix:

    box.global_transform = player.global_transform * local_to_local 

    This will ensure that the box is glued to the player while carried, as it was parented to it at the moment of picking up.

    The thing that remains to be done is to adjust box's basis so it's always horizontal (also done on each frame it's carried):

    box.global_basis.y = Vector3.UP
    box.global_basis.z = box.global_basis.x.cross(box.global_basis.y)
    box.global_basis.x = box.global_basis.y.cross(box.global_basis.z)
    box.global_basis = box.global_basis.orthonormalized()

    That's all there is to it.

    hlzachar If you want orientation to stay the same when picked up, why would you need to add any offsets?

      xyz Without the offset it keeps its rotation relative to the world and appears to be spinning as i move it around (see video).

      What I want is it to keep its rotation relative to me/the camera (as in the video in the original post, minus the fact that it jumps to a new position when I initially pick it up.

      • xyz replied to this.

        hlzachar You're overcomplicating this.
        When box is picked up, store the matrix that transforms from box's local space to player's local space:

        local_to_local = player.global_transform.inverse() * box.global_transform 

        Next, on each frame the box is carried, transform local_to_local matrix back into global space and assign that transform as box's global matrix:

        box.global_transform = player.global_transform * local_to_local 

        This will ensure that the box is glued to the player while carried, as it was parented to it at the moment of picking up.

        The thing that remains to be done is to adjust box's basis so it's always horizontal (also done on each frame it's carried):

        box.global_basis.y = Vector3.UP
        box.global_basis.z = box.global_basis.x.cross(box.global_basis.y)
        box.global_basis.x = box.global_basis.y.cross(box.global_basis.z)
        box.global_basis = box.global_basis.orthonormalized()

        That's all there is to it.

        hlzachar Alternatively, if you find those first two lines confusing, you can just reparent the box to the player at the moment of picking up:

        box.reparent(player)

        Do the same basis calculation each frame while it's carried.
        And when it's released, just reparent it back from where it came from:

        box.reparent(back_to_where_it_came_from)

        Beautiful, thank you!

        I just want to make sure I understand why this works:

        If I understand correctly, player.global_transform.inverse() is the transformation that takes the player's transformation in global space and returns it to the origin in global space. Then if you apply that same inverse transformation to the box's current global transformation it gives you the transformation to get from the box's transformation to the player's - this is essentially what I was trying to do with the offset angle but obviously I wasn't doing it correctly.

        Then your second line is just applying it which is straightforward.

        Then I understand having to recalculate x and z so that they remain perpendicular to the overridden Y-axis so that the box doesn't get sheared. I also know that rotating along the axes is not commutative so the order matters. One thing I'm not sure is - how did you know that the proper order is YZX? Is that just a standard thing I should know or is there something specific about this scenario that requires that order?

        Thank you so much for your help!! I want to polish it a bit more by having the box's origin snap to the origin of the HeldItemPosition node when it gets picked up, and to have it remain upright relative to its orientation when I initially pick it up, rather than always pointing the box's local +Y straight up but this is miles ahead of where I was, thanks again!

        • xyz replied to this.

          hlzachar I just want to make sure I understand why this works:

          That's commendable. Most people just want to paste code 😃

          hlzachar If I understand correctly, player.global_transform.inverse() is the transformation that takes the player's transformation in global space and returns it to the origin in global space. Then if you apply that same inverse transformation to the box's current global transformation it gives you the transformation to get from the box's transformation to the player's - this is essentially what I was trying to do with the offset angle but obviously I wasn't doing it correctly.

          Yes, this transform stores "relation" between the player and the box at the time of picking the box up. Afterwards this is "stacked" onto player's current transform. The result is a matrix that always transforms in the same way relative to the player. This is then assigned to the box as its global transformation.

          hlzachar Then I understand having to recalculate x and z so that they remain perpendicular to the overridden Y-axis so that the box doesn't get sheared. I also know that rotating along the axes is not commutative so the order matters. One thing I'm not sure is - how did you know that the proper order is YZX? Is that just a standard thing I should know or is there something specific about this scenario that requires that order?

          We calculate the whole basis here. The basis is 3 vectors representing 3 coordinate axes. They unambiguously describe object's orientation in 3D space. The order of calculating those vectors is not important. Note that this has nothing to with euler rotation compounding and their ordering. In fact when dealing with orientation in 3D space it's best to forget about euler rotations altogether.

          So that last piece of code works like a custom look_at() function, only that we look in a certain direction instead of at certain point. And we use basis.y as the pointing vector instead of -basis.z used by look_at().

          Since we know that box's basis.y need to be aligned with world y axis (to keep the box "horizontal"), we simply assign that directly.
          Next we need to calculate the remaining two axes in relation to that. All 3 basis vectors need to be mutually perpendicular. To achieve that, we exploit a nice property of the cross product - the resulting vector is always perpendicular to both operands.

          Note that those remaining 2 basis vectors can be positioned in infinite number of ways while the basis still satisfies our main criteria (basis.y coinciding with world y). Imagine the whole basis rotating around basis.y vector. We typically want to pick one combination that will cause the least change from the current orientation. So we take one of the current basis vectors for the first cross product. After that first cross product, we have 2 perpendicular basis vectors. As the last step, we just calculate their cross product to get the third vector.

          The calculation could have been done in several ways giving the same result. But we always start with assigning the vector whose direction we know. In this case it's basis.y. For example:

          box.global_basis.y = Vector3.UP
          box.global_basis.x = Vector3.UP.cross(box.global_basis.z)
          box.global_basis.z = box.global_basis.x.cross(Vector3.UP)