Rotating CharacterBody3D beyond 90 degrees causes movement issues

4.4.1

I’m making a sonic like game, but when trying to implement wall running and loops, I’ve run into multiple issues related to player movement. Walking on slopes or walls at any angle between 0 and 89 degrees works without any issues, but as soon as the angle is equal to, or greater than 90 degrees, it seems like the character constantly fights to go back to a lower angle, sliding off surfaces (causing jittering, and flinging) and getting stuck on ceilings.
I’ve tinkered with settings like changing the up direction, slide_on_ceiling, wall_min_slide_angle, floor_stop_on_slope, floor_constant_speed, floor_block_on_wall, floor_max_angle, floor_snap_length, as well as disabling gravity, to no effect.
I’m calling move_and_slide() for movement.
How can I fix this?

You may have to change the character’s up_direction in code as it goes through a loop.

1 Like

I’ve tried that before, but I could have done it incorrectly. Do you know how I could make the character’s up_direction point the same way as the floor normal?

I would assume you can just do: up_direction = get_floor_normal().
There is a warning though for get_floor_normal()'s documentation stating:

Warning: The collision normal is not always the same as the surface normal.

Perhaps this causes an issue for you – I don’t know.

What about the movement vector that you are using for move_and_slide(); are you computing that on the basis of the ground normal to align the movement vector with the ground plane?

Let me know what you are thinking.

1 Like

I’m using a ray cast to try and specifically get the surface normal for where the character is standing. I tried setting that as the up_direction but the results were still the same.
The movement vector is based on the camera position, which is also being rotated when going up or down slopes, so I’m not sure if that’s what is causing the issue either.
Here’s what the ground movement code looks like:

if raw_input != Vector2(0,0) and is_on_floor():
	if move_speed < baseMoveSpeed:
    	move_speed = baseMoveSpeed
	velocity = move_direction * move_speed * delta
	lastMoveDirectionX = move_direction.x
	lastMoveDirectionZ = move_direction.z
	if move_speed < topSpeed:
    	move_speed = move_speed + 10
	elif move_speed > topSpeed:
    	if move_speed-5 < topSpeed:
        	move_speed = topSpeed
    	else:
        	move_speed = move_speed -5

Should I do something differently?

Hmm… you don’t seem to be showing all the related code for the snippet you’re showing.
A move_direction is being utilized but it’s definition is not being shown. I assume move_direction is, like you said, based on the camera rig’s position – but in what way?
In order to help you solve your problem, we need more explicit information; not just “it is based on the camera position”. It is often the case that mistakes are invisible to those who make them.

Leaving that aside, you may find it useful to read through a similar post concerned with orbital motion:

3D rigidbody movement around a sphere in Godot4 | Post #18

In the topic above, OP and I found a way to solve his problem of creating a character controller capable of walking on planets. While it is not your specific use-case, it should still apply.


Hopefully you can provide more information regarding your issue. Let me know if you have any questions regarding this specific problem or that of the linked topic above.

Sorry about that, here’s the full code for the player:

extends CharacterBody3D 

const MAX_SPEED = 15000
const TOP_SPEED = 10000
const BASE_MOVE_SPEED = 300
const GRAVITY = 50

var maxSpeed = MAX_SPEED
var topSpeed = TOP_SPEED
var baseMoveSpeed = BASE_MOVE_SPEED

var gravity = GRAVITY
var mouse_sensitivity := 0.18
var camera_input_direction := Vector2.ZERO
var last_movement_direction = Vector3.BACK

@onready var camera_pivot: Node3D = %Camera_Controller
@onready var camera: Camera3D = %Camera
@onready var model = %PlayerModel

var move_speed = BASE_MOVE_SPEED
var acceleration = 10.0
var rotation_speed = 30
var jump_impulse := 40
var hasJumped = false
var hasJustLeftSlope = false
var realVelocityYLastPosValue
var realVelocityYLastNegValue = 0
var lastRawInputVector:Vector2
var isStopping = false
var noInputDeceleration = 400

var lastMoveDirectionX = 0
var lastMoveDirectionZ = 0
var lastMoveDirectionXSlide = 0
var lastMoveDirectionZSlide = 0
var isPlayerStomping = false
var isPlayerStompingFirst = false
var canPlayerFullStomp = false
var isPlayerBoosting = false
var moveSpeedY = 0
var SuperPeelOutTimer = 0
var SpindashTimer = 0
var AirdashTimer = 0
var speedPreSpindash

var extraDelta

var xform: Transform3D

func _ready():
	%SpringArm3D.add_excluded_object($".")
	%PlayerModelRotateXY.position = position
	floor_snap_length = 0.1
	floor_constant_speed = false
	slide_on_ceiling = false

func _input(event: InputEvent) -> void:
	if event.is_action_pressed("useLeftMouse"):
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
	elif event.is_action_pressed("useEsc"):
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE

func _unhandled_input(event: InputEvent) -> void:
	var is_camera_motion := (
		event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED
	)
	if is_camera_motion:
		camera_input_direction = event.screen_relative * mouse_sensitivity * 4
		%CameraTimer.start()


func _physics_process(delta):
	extraDelta = delta
	
	#Camera movement
	camera_pivot.rotation.x -= camera_input_direction.y * delta
	camera_pivot.rotation.x = clamp(camera_pivot.rotation.x, -PI/3.0, PI/3.0)
	camera_pivot.rotation.y -= camera_input_direction.x * delta
	
	camera_input_direction = Vector2.ZERO
	
	#Get player imput direction
	var raw_input := Input.get_vector("useA","useD","useW","useS")
	
	var forward = camera.global_basis.z
	var right = camera.global_basis.x
	
	#Get raycast collision normal
	var collisionNormal = %RayCast3D.get_collision_normal()
	GlobalVariables.overlayFloorNormal = collisionNormal
	GlobalVariables.overlayRealMoveSpeed = get_real_velocity()
	
	#Use camera and player imput to get move direction
	var move_direction = forward * raw_input.y + right * raw_input.x
	
	GlobalVariables.overlayMoveDirection = move_direction
	
	
	if AirdashTimer == 0:
		#move_direction.y = 0
		pass
	move_direction = move_direction.normalized()
	
	#Stop player
	if raw_input == lastRawInputVector*(-1) and lastRawInputVector != Vector2(0,0):
		isStopping = true
		noInputDeceleration = 700
	else:
		isStopping = false
		noInputDeceleration = 400
	if raw_input != Vector2(0,0) and isStopping == false:
		lastRawInputVector = raw_input
	
	var y_velocity := velocity.y
	
	velocity.y = 0
	
	#Determine if the player can go up walls
	var floorFrontAngle = snappedi(rad_to_deg(get_floor_angle()),1)
	if move_speed >= 10000:
		if floorFrontAngle+45 < 180:
			floor_max_angle = deg_to_rad(floorFrontAngle+45)
		else:
			floor_max_angle = deg_to_rad(180)
		slide_on_ceiling = false
		floor_block_on_wall = false
	elif move_speed < 10000:
		floor_max_angle = deg_to_rad(45)
		slide_on_ceiling = true
		floor_block_on_wall = true
	if floorFrontAngle >= 70 and is_on_floor():
		up_direction = collisionNormal
		set_up_direction(up_direction)
	else:
		up_direction = Vector3(0,1,0)
		set_up_direction(up_direction)
	
	if Input.is_action_pressed("useShift") and is_on_floor(): #SUPER PEEL OUT
		SuperPeelOutTimer += delta
		%CapsuleMeshInstance3D.visible = false
		%PeeloutMeshInstance3D.visible = true
		if move_speed > 800:
			velocity.x = lastMoveDirectionX * move_speed * delta
			velocity.z = lastMoveDirectionZ * move_speed * delta
			move_speed = move_speed - 800
		elif move_speed <= 800:
			move_speed = 0
	elif Input.is_action_just_released("useShift") and is_on_floor():
		%CapsuleMeshInstance3D.visible = true
		%PeeloutMeshInstance3D.visible = false
		if SuperPeelOutTimer > 3:
			SuperPeelOutTimer = 3
		move_speed = snappedi(((SuperPeelOutTimer*3.5) * 1000),1)
		SuperPeelOutTimer = 0
	
	if Input.is_action_pressed("useRightMouse") and is_on_floor(): #SPINDASH
		if SpindashTimer == 0:
			speedPreSpindash = move_speed
			if AirdashTimer == 0:
				lastMoveDirectionXSlide = lastMoveDirectionX
				lastMoveDirectionZSlide = lastMoveDirectionZ
		SpindashTimer += delta
		%CapsuleMeshInstance3D.visible = false
		%SphereMeshInstance3D.visible = true
		if move_speed > 0:
			lastMoveDirectionXSlide = lerp(lastMoveDirectionXSlide, move_direction.x, 1 * delta)
			lastMoveDirectionZSlide = lerp(lastMoveDirectionZSlide, move_direction.z, 1 * delta)
			velocity.x = lastMoveDirectionXSlide * move_speed * delta
			velocity.z = lastMoveDirectionZSlide * move_speed * delta
			move_speed = snappedi(speedPreSpindash/1.5,1)
	elif Input.is_action_just_released("useRightMouse") and is_on_floor():
		%CapsuleMeshInstance3D.visible = true
		%SphereMeshInstance3D.visible = false
		if SpindashTimer > 1:
			SpindashTimer = 1
		move_speed = speedPreSpindash + snappedi(((SpindashTimer) * 1000),1)
		SpindashTimer = 0
	
	if Input.is_action_pressed("useRightMouse") and !is_on_floor(): #AIRDASH
		if AirdashTimer == 0:
			speedPreSpindash = move_speed
			y_velocity = y_velocity / 3
			if SpindashTimer == 0:
				lastMoveDirectionXSlide = lastMoveDirectionX
				lastMoveDirectionZSlide = lastMoveDirectionZ
			gravity = 1
		AirdashTimer += delta
		%CapsuleMeshInstance3D.visible = false
		%SphereMeshInstance3D.visible = true
		if move_speed > 0:
			lastMoveDirectionXSlide = lerp(lastMoveDirectionXSlide, move_direction.x, 1 * delta)
			lastMoveDirectionZSlide = lerp(lastMoveDirectionZSlide, move_direction.z, 1 * delta)
			velocity.x = lastMoveDirectionXSlide * move_speed * delta
			velocity.z = lastMoveDirectionZSlide * move_speed * delta
			move_speed = snappedi(speedPreSpindash/3,1)
	elif Input.is_action_just_released("useRightMouse") and !is_on_floor():
		%CapsuleMeshInstance3D.visible = true
		%SphereMeshInstance3D.visible = false
		if AirdashTimer > 1:
			AirdashTimer = 1
		move_speed = speedPreSpindash + snappedi(((AirdashTimer) * 1000),1)
		y_velocity = (snappedi(((AirdashTimer*2) * 30),1) * move_direction.y)
		gravity = 1
		AirdashTimer = 0
	
	#Reset ability timers
	if !is_on_floor():
		if SuperPeelOutTimer != 0:
			%CapsuleMeshInstance3D.visible = true
			%PeeloutMeshInstance3D.visible = false
			SuperPeelOutTimer = 0
		if SpindashTimer != 0:
			%CapsuleMeshInstance3D.visible = true
			%SphereMeshInstance3D.visible = false
			SpindashTimer = 0
	elif is_on_floor():
		if AirdashTimer != 0:
			%CapsuleMeshInstance3D.visible = true
			%SphereMeshInstance3D.visible = false
			AirdashTimer = 0
	
	#Add speed when landing, based on how fast you were moving down
	if get_real_velocity().y < 0 and is_on_floor() and get_real_velocity().y < -2:
		move_speed += snappedi((get_real_velocity().y*-1)/2,1)
	
	#Player movement
	if isStopping == false and SpindashTimer == 0 and AirdashTimer == 0:
		if raw_input != Vector2(0,0) and is_on_floor(): #is moving and on floor
			%MoveTimer.start()
			if move_speed < baseMoveSpeed and SuperPeelOutTimer == 0 and SpindashTimer == 0:
				move_speed = baseMoveSpeed 
			velocity = move_direction * move_speed * delta
			lastMoveDirectionX = move_direction.x
			lastMoveDirectionZ = move_direction.z
			if move_speed < topSpeed:
				move_speed = move_speed + 10
			elif move_speed > topSpeed:
				if move_speed-5 < topSpeed:
					move_speed = topSpeed
				else:
					move_speed = move_speed -5 
			print(move_direction)
		elif raw_input == Vector2(0,0) and is_on_floor(): #is not moving and on floor
			if move_speed > baseMoveSpeed:
				velocity.x = lastMoveDirectionX * move_speed * delta
				velocity.z = lastMoveDirectionZ * move_speed * delta
				move_speed = move_speed - noInputDeceleration
			elif move_speed <= baseMoveSpeed:
				velocity.x = 0
				velocity.z = 0
				move_speed = 0
		elif raw_input != Vector2(0,0) and !is_on_floor(): #is moving and not on floor
			velocity = move_direction * move_speed * delta
			lastMoveDirectionX = move_direction.x
			lastMoveDirectionZ = move_direction.z
			if move_speed < 400:
				move_speed = move_speed + 10
			elif move_speed >= 400:
				move_speed = move_speed - 1
		elif raw_input == Vector2(0,0) and !is_on_floor(): #is not moving and not on floor
			if move_speed > 0:
				velocity.x = lastMoveDirectionX * move_speed * delta
				velocity.z = lastMoveDirectionZ * move_speed * delta
				move_speed = move_speed - 20
			elif move_speed <= 0:
				velocity.x = lastMoveDirectionX * move_speed * delta
				velocity.z = lastMoveDirectionZ * move_speed * delta
				move_speed = 0 
	elif isStopping == true:
		move_speed = move_speed - noInputDeceleration
		if move_speed < 0:
			move_speed = 0
		velocity.x = lastMoveDirectionX * move_speed * delta
		velocity.z = lastMoveDirectionZ * move_speed * delta
		if velocity.x == 0 and velocity.z == 0:
			isStopping = false
			lastRawInputVector = raw_input
		
	if move_speed > maxSpeed:
		move_speed = maxSpeed
	
	#Debug overlay variables
	GlobalVariables.overlayMoveSpeed = move_speed
	GlobalVariables.playerMoveSpeed = move_speed
	GlobalVariables.playerRealVelocity = get_real_velocity()
	GlobalVariables.playerIsOnFloor = is_on_floor()
	GlobalVariables.overlayVelocity = velocity
	GlobalVariables.overlayVelocity = is_on_floor()
	
	#Debug tool
	if Input.is_action_just_pressed("useE"):
		move_speed = 15000
	
	#ABILITIES
	
	#Boost
	if Input.is_action_just_pressed("useR"):
		if GlobalVariables.playerSpeedOMeter >= 3334:
			isPlayerBoosting = true
			Signals.emit_signal("playerBoost")
			move_speed = maxSpeed
	elif Input.is_action_pressed("useR"):
		if GlobalVariables.playerSpeedOMeter >= 1 and isPlayerBoosting == true:
			move_speed = maxSpeed
			Signals.emit_signal("playerBoosting")
			if GlobalVariables.playerSpeedOMeter <= 0:
				isPlayerBoosting = false
	elif Input.is_action_just_released("useR"):
		isPlayerBoosting = false
	
	if get_real_velocity().y < 0 and get_real_velocity().y < realVelocityYLastNegValue:
		realVelocityYLastNegValue = get_real_velocity().y
	
	#Stomp
	if Input.is_action_just_pressed("useCtrl") and !is_on_floor():
		if isPlayerStomping == false:
			canPlayerFullStomp = true
			isPlayerStomping = true
			isPlayerStompingFirst = true
			if get_real_velocity().y > 0:
				y_velocity = 0
				velocity.y = 0
			moveSpeedY = -500
			%CapsuleMeshInstance3D.visible = false
			%SphereMeshInstance3D.visible = true
		elif isPlayerStomping == true and canPlayerFullStomp == true:
			canPlayerFullStomp = false
			isPlayerStompingFirst = false
			move_speed = 0
			y_velocity = 0
			velocity.y = 0
			moveSpeedY = 0
			await get_tree().create_timer(0.05).timeout
			moveSpeedY = -1000
			%CapsuleMeshInstance3D.visible = false
			%SphereMeshInstance3D.visible = true
	elif Input.is_action_just_pressed("useCtrl") and is_on_floor():
		GlobalVariables.playerFallInWaterCTRL = true
		pass
	
	if isPlayerStomping == true and is_on_floor():
		isPlayerStomping = false
		if isPlayerStompingFirst == true and realVelocityYLastNegValue < -150:
			move_speed = move_speed + snappedi((-realVelocityYLastNegValue*2),1)
		isPlayerStompingFirst = false
		realVelocityYLastNegValue = 0
		moveSpeedY = 0
		%CapsuleMeshInstance3D.visible = true
		%SphereMeshInstance3D.visible = false
	
	#Gravity for different states
	if !is_on_floor():
		if GlobalVariables.playerInWater == false and get_real_velocity().y >= 0 and AirdashTimer == 0:
			gravity = 50
		elif GlobalVariables.playerInWater == false and get_real_velocity().y < 0 and AirdashTimer == 0:
			gravity = 70
		elif GlobalVariables.playerInWater == true and get_real_velocity().y >= 0 and AirdashTimer == 0:
			gravity = 20
		elif GlobalVariables.playerInWater == true and get_real_velocity().y < 0 and AirdashTimer == 0:
			gravity = 30
	elif is_on_floor():
		gravity = 0
		GlobalVariables.overlayFloorAngle = rad_to_deg($".".get_floor_angle())
	
	#Water movement
	if GlobalVariables.playerInWater == false:
		maxSpeed = MAX_SPEED
		topSpeed = TOP_SPEED
		baseMoveSpeed = BASE_MOVE_SPEED
	elif GlobalVariables.playerInWater == true:
		maxSpeed = MAX_SPEED * 0.8
		topSpeed = TOP_SPEED * 0.8
		baseMoveSpeed = BASE_MOVE_SPEED * 0.8
	
	if isPlayerStomping == false:
		velocity.y = y_velocity + -gravity * delta
	elif isPlayerStomping == true:
		velocity.y = y_velocity + moveSpeedY * delta
	
	#Change floor snap length based on speed
	if move_speed < 10000 and move_speed/200 > 0.1 and hasJumped == false:
		floor_snap_length = 0.1 * (move_speed/100)
	elif move_speed >= 10000 and move_speed/200 > 0.1 and hasJumped == false:
		floor_snap_length = 0.1 * (move_speed/300)
	else:
		floor_snap_length = 0.1
	
	#Jump
	if Input.is_action_just_pressed("useSpace") and %IsOnFloorTimer.time_left > 0 and hasJumped == false:
		hasJumped = true
		floor_snap_length = 0
		velocity.y += jump_impulse + get_real_velocity().y
	elif Input.is_action_just_released("useSpace") and !is_on_floor() and velocity.y > (jump_impulse/2):
		velocity.y = velocity.y - (jump_impulse/2)
	
	#Attempt at fixing loop de loop issue
	#up_direction.y = 1 - (get_floor_angle()/180)*2
	#set_up_direction(up_direction)
	
	move_and_slide()
	
	if get_real_velocity().y > 0:
		realVelocityYLastPosValue = get_real_velocity().y
	
	#Get a collision normal to align player even if raycast isn't touching something
	if %RayCast3D.is_colliding() == false and is_on_floor():
		collisionNormal = get_floor_normal()
		align_with_floor(collisionNormal)
		
	#Handle player rotation in different states
	if is_on_floor():
		%IsOnFloorTimer.start()
		hasJustLeftSlope = true
		hasJumped = false
		align_with_floor(collisionNormal)
		global_transform = global_transform.interpolate_with(xform, 10 * delta)
		if collisionNormal == Vector3(0,1,0):
			rotation = lerp(rotation, Vector3(0,0,0), 10 * delta)
	elif !is_on_floor():
		global_transform.basis.x = lerp(global_transform.basis.x, Vector3(1,0,0), 2 * delta)
		global_transform.basis.y = lerp(global_transform.basis.y, Vector3(0,1,0), 2 * delta)
		global_transform.basis.z = lerp(global_transform.basis.z, Vector3(0,0,1), 2 * delta)
		if hasJumped == false and hasJustLeftSlope == true: #simulate momentum from running off ramp
			velocity.y += realVelocityYLastPosValue
			hasJustLeftSlope = false
	GlobalVariables.overlayGravity = gravity
	%PlayerModelRotateXY.position = position
	%PlayerModelRotateXY.rotation = global_rotation
	GlobalVariables.overlayPlayerRotation = rotation
	
	if move_direction.length() > 0.2:
		last_movement_direction = move_direction
	var target_angle = Vector3.BACK.signed_angle_to(last_movement_direction,Vector3.UP)
	model.rotation.y = lerp_angle(model.rotation.y, target_angle, rotation_speed * delta)

func align_with_floor(collisionNormal):
	xform = global_transform
	xform.basis.y = collisionNormal
	xform.basis.x = -xform.basis.z.cross(collisionNormal)
	xform.basis = xform.basis.orthonormalized()

It has a lot of code related to special abilities that probably aren’t related to the main issue, but I kept them in to help other parts of the code make more sense, and just in case they may be interfering with the movement code too.
I am also aware that basing the player movement on the direction the camera is pointing could cause unwanted issues when looking up or down, but I still haven’t fixed it to help test the wall running mechanic, although I am working on a patching it.

Thanks for providing more information on your implementation.

I have a couple of notes for you. Please read these carefully and think about them.

Issue #1: Incomprehensible complexity

As you’ve already stated, the script in question implements a variety of functionality that are not directly related to the main issue:

Being unable to move on walls and loops.

However, most of the “unrelated” functionality actually utilizes or modifies either the velocity, move_direction, or other miscellaneous CharacterBody3D parameters. The range of possible states is not immediately apparent to me as the script is somewhat hard to read from an outsider’s perspective.

I prefer not to open a discussion on system architecture right now – this doesn’t appear to precisely be your problem. I would instead urge you to – for the sake of finding the root cause of the issue – strip away all the functionality that does not contribute to the primary goal of being able to run on walls and loops.

Make a simpler version of this script whose sole purpose is to achieve your current goal. Once you’ve successfully implemented a system that works, you can add the additional functionality back in.

Issue #2: Camera-derived motion vector

Basing your character’s movement solely on that of the camera’s orientation is quite problematic for a grounded character. After all, the character should move along the ground – not towards where the player is looking. I suggest you read this post I made in the topic previously linked which explains this exact issue.


Please try to follow this advice, and please read the topic that was previously linked. I am confident that it will help you solve your problem. I would recommend you do the following:

  1. Reduce system complexity to find the root cause of your current problem.
  2. Compute the movement vector as outlined in the linked post.
  3. Read the linked topic in its entirety to evaluate how it may help you solve your problem.
  4. (BONUS) Formulate questions for the things you don’t yet understand so I can help you understand them.

Help me help you!