Hi,
I've just transitioned to Godot 4 to prototype a new project. I'm doing a 3D project now for an idea I've had for a while, and I think I've went way over my head because apparently something I thought would be trivial is way harder than I thought, and it admittedly doesn't help that I don't have much experience with 3D transformations.

Basically, I'm trying to make it so that a first person controller is aligned with a slope when it's stepped on if the angle is above a certain threshold. Ideally the axis of looking would also be altered to work with this, i.e. if the character steps on a slope going up they will be corrected so they will be looking up that slope, and same for looking down. Such that the centered view is lower for going down and higher for going up. I've tried a bunch of methods, but can't seem to get it right.

There are two constraints on this project that absolutely are needed in order for this to work in my project. First, this is for a first person game, and mouselook is necessary. Mouselook in my prototype has the mouse changing the Y rotation of the player. This changes things dramatically from it were say a first person game. I have looked through lots of forum threads and such, and actually found one tutorial for what I'm doing. However, this tutorial can't be used because it's overwriting the entire transform of the object, and I also tried things with bases as well. These methods can not be used because this constricts the player's ability to look left and right with the mouse.

I also wanted the player's view to be constant with the slope when looking around it too. So if the player looks to the left or right while looking up, they will still be flush with the ramp, but will be slanted to an outside observer.

The final important thing is that the rotation must be lerp'd. The jerkiness of such clamping would definitely make people sick if this isn't accounted for.

I have a script that accomplishes some of this, but doesn't really work too well. For some reason it doesn't seem to take into account the player's rotation.

There's 4 ramps here.

Going up this one is fine

But I rotate downwards on this one, opposite of the first.

And the side ones don't work at all

Anyways, here is my script. I'm trying to use rotations on the X and Z axes to rotate it properly based on the normal for the floor. I have also tried using trigonometry to figure out the correct values based on the player's rotation, but couldn't really find something that worked. I think that's really the best method, does anyone have any ideas? Thanks


@onready var camera = $CollisionShape3D/Camera3D

const JumpVelocity = 16
const Acceleration = 0.2
const Deceleration = 1.5
const TopSpeed = 15.0
const SmoothingTime = 4.0

var lookSensitity : float = ProjectSettings.get_setting("player/look_sensitivity")
var gravity : float = ProjectSettings.get_setting("physics/3d/default_gravity")

var targetRotation : Vector3 = Vector3(0.0, 0.0, 0.0)
var floorNormal : Vector3 = Vector3()
var floorAngle : float = 0.0

func _physics_process(delta):
	var new_velocity : Vector3 = velocity

	if(is_on_floor()):
		if (Input.is_action_just_pressed("ui_accept")):
			new_velocity.y = JumpVelocity
		floorAngle = get_floor_angle()
		
		if (floorAngle > 0.4):
			floorNormal = get_floor_normal()
			
			targetRotation = rotation.lerp(Vector3(-floorNormal.x, rotation.y, -floorNormal.z), delta * SmoothingTime )
			
		else:
			targetRotation = Vector3(0.0, rotation.y, 0.0)
			floorNormal = Vector3.UP
			
	else:
		new_velocity.y -= gravity * delta

		targetRotation = Vector3(0.0, rotation.y, 0.0)
		floorNormal = Vector3.UP
		
		
	var inputDir : Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	var direction : Vector3 = (transform.basis * Vector3(inputDir.x, 0, inputDir.y)).normalized()
	if (direction != Vector3.ZERO):
		var xVelNew : float = new_velocity.x + direction.x * Acceleration
		var zVelNew : float = new_velocity.z + direction.z * Acceleration
		new_velocity.x = clamp(xVelNew, -TopSpeed, TopSpeed)
		new_velocity.z = clamp(zVelNew, -TopSpeed, TopSpeed)
	else:
		new_velocity.x = move_toward(velocity.x, 0, Deceleration)
		new_velocity.z = move_toward(velocity.z, 0, Deceleration)
			
	velocity = new_velocity
	move_and_slide()


	rotation = Vector3(targetRotation.x, rotation.y, targetRotation.z)
	
func _input(event):
	if(event is InputEventMouseMotion):
		camera.rotate_x(-event.relative.y * lookSensitity)
		var x : float = clamp(camera.rotation.x, -PI / 2, PI / 2)
		camera.rotation = Vector3(x, camera.rotation.y, camera.rotation.z)
		rotate_y(-event.relative.x * lookSensitity)
  • xyz replied to this.

    PowerUpT Format your code properly using ``` tags

    What's the maximal slope? Does the gravity needs to change as well so it points down the slope normal?

      xyz Strange, I clicked the code markdown button and it formatted it incorrectly. I think that needs to be fixed.

      For my purposes, there is no maximal slope. If there is a curved surface that goes upside down, then the player should stick to the ceiling.

      The gravity would need to change, but I was currently just trying to get the player rotations working first. That is pretty important though.

      Thanks

      • xyz replied to this.

        PowerUpT Is there a maximal relative slope? Or the player can transition from a horizontal surface directly to an overhang (more than 90 deg) surface?

        So if I understood correctly, you want every surface to act as a horizontal floor regardless of its absolute incline?

          xyz Oh I see.
          I didn't think of that yet. What I have now is that there's a threshold for not snapping in an absolute sense (0.4 rad for now, so that stuff like stairs and slight differences in the ground don't affect it), but haven't considered a threshold.

          I'm thinking something like 25 degrees.

          • xyz replied to this.

            PowerUpT Ok. When dealing with orientation in space you should avoid using euler rotation angles. There are multiple reasons for that which I won't go into here. Work with quaternions or directly with basis vectors.

            So in order to orient the player node properly you need to construct its orthogonal basis i.e. figure out how the three basis vectors that define its local coordinate space are positioned relative to the global coordinate system.

            In practice you only need two of those vectors as, due to the basis orthogonality constraint, the third one can be calculated via the cross product of the other two (cross product of two vectors is always perpendicular to both of them)

            You already have one of those vectors explicitly defined - the surface normal vector. This is your basis up (or y) vector. The basis forward/back (or z) vector can be defined as the projection of the camera look direction onto the current "floor" plane. This is trivial to calculate from the normal vector and the camera look vector using Plane::project()

            Now you have two orthogonal basis vectors. Take their cross product to get the third vector. Those 3 vectors constitute your final orientation basis that can be assigned directly to the player node.

              xyz I have tried this already, and there is an issue. Is there a way to apply the quaternion without setting the entire transform of the object? I have done this by creating a Transform3D with this data and directly applying it to the player's transform, but the issue is with the script is that it constrains the ability for the player to rotate on the Y axis using the mouse.

              Is there a way around this so that the quaternion won't interfere with mouselook? This is the reason I've been trying to do it with rotations.

              • xyz replied to this.

                PowerUpT Use intermediary node(s). Or first rotate it and then apply the whole transform. If the transform is properly calculated it will retain the rotation.
                And btw the camera shouldn't be parented to a collider. Parent it to a character body or an intermediate Node3D

                PowerUpT Here's proper basis calculation code. It takes floor normal and camera look direction and returns the basis whose y axis is aligned with the normal and -z axis with floor projected look vector:

                func calc_basis(floor_normal: Vector3, camera_look: Vector3) -> Basis:
                	var b = Basis()
                	b.y = floor_normal
                	b.z = Plane(b.y).project(camera_look)
                	b.x = b.y.cross(b.z)
                	return b.orthonormalized()

                  xyz Sorry about the late reply, I was at work.

                  Thanks for the help so far. I'm not sure if I'm just not implementing the script right, but when I try to set the basis of the character's transform to the resulting basis I'm getting "Condition det == 0 is true" and "invert: Condition det == 0 is true" as errors every physics frame when move_and_slide runs.

                  All I did was add the function, created a basis object, set it to the return value of the function, then I set transform.basis to that object.

                  I actually did run across another post where you were helping someone with this error, do you have any clue as to what's causing it (Or if I did something wrong)?

                  • xyz replied to this.

                    PowerUpT You're probably not passing the right arguments in. Let's see the code.

                      xyz

                      
                      @onready var cameraRig = $CameraRig
                      @onready var camera = $CameraRig/Camera3D
                      
                      const JumpVelocity = 16
                      const Acceleration = 0.2
                      const Deceleration = 1.5
                      const TopSpeed = 15.0
                      const TempGravity = 55.0
                      const SmoothingTime = 4.0
                      
                      var lookSensitity : float = ProjectSettings.get_setting("player/look_sensitivity")
                      var gravity : float = ProjectSettings.get_setting("physics/3d/default_gravity")
                      
                      var floorNormal : Vector3 = Vector3()
                      var floorBasis : Basis = Basis()
                      var floorAngle : float = 0.0
                      
                      func _physics_process(delta):
                      	var new_velocity : Vector3 = velocity
                      
                      	if(is_on_floor()):
                      		if (Input.is_action_just_pressed("ui_accept")):
                      			new_velocity.y = JumpVelocity
                      		floorAngle = get_floor_angle()
                      		
                      		if (floorAngle > 0.4):
                      			floorNormal = get_floor_normal()
                      		else:
                      			floorNormal = Vector3.UP
                      	else:
                      		new_velocity.y -= TempGravity * delta
                      		floorNormal = Vector3.UP
                      		
                      	var inputDir : Vector2 = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
                      	var direction : Vector3 = (transform.basis * Vector3(inputDir.x, 0, inputDir.y)).normalized().rotated(Vector3.UP, cameraRig.rotation.y)
                      	if (direction != Vector3.ZERO):
                      		var xVelNew : float = new_velocity.x + direction.x * Acceleration
                      		var zVelNew : float = new_velocity.z + direction.z * Acceleration
                      		new_velocity.x = clamp(xVelNew, -TopSpeed, TopSpeed)
                      		new_velocity.z = clamp(zVelNew, -TopSpeed, TopSpeed)
                      	else:
                      		new_velocity.x = move_toward(velocity.x, 0, Deceleration)
                      		new_velocity.z = move_toward(velocity.z, 0, Deceleration)
                      			
                      	velocity = new_velocity
                      	
                      	floorBasis = calc_basis(get_floor_normal(), camera.rotation)
                      	
                      	transform.basis = floorBasis
                      	move_and_slide()
                      	
                      func _input(event):
                      	if(event is InputEventMouseMotion):
                      		cameraRig.rotate_y(-event.relative.x * lookSensitity)
                      		camera.rotate_x(-event.relative.y * lookSensitity)
                      		var x : float = clamp(camera.rotation.x, -PI / 2, PI / 2)
                      		camera.rotation = Vector3(x, camera.rotation.y, camera.rotation.z)
                      
                      func calc_basis(floor_normal: Vector3, camera_look: Vector3) -> Basis:
                      	var b = Basis()
                      	b.y = floor_normal
                      	b.z = Plane(b.y).project(camera_look)
                      	b.x = b.y.cross(b.z)
                      	return b.orthonormalized()

                      Here is my code.
                      I did fix the camera so that it's on its own rig instead of the collider, but that shouldn't affect things.

                      I don't think I put the wrong arguments in. I'm passing a vector that keeps the floor normal if the floor angle is past a certain point, and points up otherwise. I did verify this by just passing in get_floor_normal() with the same results. And as for camera_look I'm just passing in the camera's rotation.

                      • xyz replied to this.

                        PowerUpT The arguments are not correct.

                        Firstly get_floor_normal() will return the null vector if the body is not on floor. This will cause that error and the calculated basis will not be valid. It's likely that the body is not on floor in the first frame. And once you assign the invalid basis, your whole transformation system will fall apart from that point onward. If the body is not on floor, we want to keep the existing basis up vector so best to pass that.

                        And secondly, you need to pass a vector as the camera look, not scalar rotation value. The camera always looks down its local negative z axis so you need to pass that vector. Note that my example function expect the reverse look vector.

                        Both those vectors need to be in the same coordinate space. Since get_floor_normal() returns the normal in global space, the look vector needs to be in global space as well.

                        floorBasis = calc_basis(get_floor_normal() if is_on_floor() else global_basis.y, camera.global_basis.z)
                        global_basis = floorBasis

                        You'll also have problems with how you construct the velocity vector, but let's fix the basis part first.

                          xyz Alright, it seems to all be in order now, thanks so much!

                          There was an issue with this code however, making it the camera's global basis actually made the camera spin. I fixed it by making it the camera's local basis (camera.basis.z). Might have to do with the camera being encapsulated in a Node3D to fix the rotation issue.

                          Thanks so much!

                          • xyz replied to this.

                            PowerUpT No, it should be global camera basis. Local basis may work, depending on your rig but best to use global basis. If that's causing problems, it's because other parts of your code are not correct as well. You also don't really need that additional node if the basis is properly calculated.

                            Other things.

                            You need to change the up_direction property of the character body to coincide with the floor normal (or floorBasis.y) so that move_and_slide() works as expected when floor plane is changed.

                            You also cannot calculate gravity like that by altering the velocity's y component directly, because the basis up vector will not be collinear with the global y axis when the basis is rotated. The gravity (and the jump component of the velocity) need to act along the new basis y vector.

                            PowerUpT On the second though, if you allow for a large up/down look angle, the thing will be more stable if you do use a separate node to only handle y rotation (cameraRig) and pass its global_basis.z to be projected on the floor:

                            floorBasis = calc_basis(get_floor_normal() if is_on_floor() else global_basis.y, cameraRig.global_basis.z)

                            This will also allow for the transition between larger relative floor angles without glitches.