Wednesday 15 November 2023

Godot - Rotating a Camera in 3d space

For the past couple of years I've been dabbling about in Unity, but due to their recent PR disaster, I've shifted over into dabbling with Godot instead.

The project I'm trying to create is a turn-based strategy game, which would include terrain. As such, units might be hidden behind terrain, so the user should be able to rotate the camera.

To this end I've been following the Godot tutorials, creating a 3d plane, and putting a box in one corner so that I can see how the whole thing would rotate (if it were a plain plane, I wouldn't be able to tell it rotated). I didn't bother putting in a player character yet (as the concept is quite different to the example in the tutorial), but I did follow the steps for implementing a camera.

I wanted the camera to rotate by increments of 90 degrees (so there would effectively be 4 positions). Unfortunately, most of the maths in the documentation is about radians, which doesn't seem useful to me for this case, as it makes the maths more complicated (and less precise due to pi having [as far as we know] infinite numbers after the decimal)

Eventually I found that I could use "rotation_degrees", giving me the exact precision I wanted and without requiring more complex calculations. I didn't want the transition to be instantaneous, so I used "lerp" to make the camera rotate over a series of frames (apparently this is an abbreviation for "linear interpolation", which makes sense) - I got this from one of the Godot samples (but it didn't have much annotation)

I also wanted to make it so that inputs are ignored during rotation, that the user can only do one rotation at a time. To this end, I wrote the code so that when it starts rotating it sets a flag, then when finished it resets it. If it receives another instruction to start, while that flag is set, that instruction is ignored.

Below is the code I had for that point, before things get particularly interesting.

extends Marker3D

const ROT_SPEED = 10
var rotating = false
var targetRotation = 0

func _ready():
	targetRotation = rotation_degrees.y

func rotate_left():
	targetRotation = targetRotation - 90
	if(targetRotation < -360):
		targetRotation = targetRotation + 360
	rotating = true
	print(targetRotation)
	
func rotate_right():
	targetRotation = targetRotation + 90
	if(targetRotation > 360):
		targetRotation = targetRotation - 360
	rotating = true
	print(targetRotation)
	
func _process(delta):
	if(!rotating):
		if(Input.is_action_pressed("camera_rotate_left")):
			rotate_left()
		if(Input.is_action_pressed("camera_rotate_right")):
			rotate_right()
	else:
		rotation_degrees = Vector3(rotation_degrees.x,lerp(rotation_degrees.y,targetRotation,ROT_SPEED*delta),rotation_degrees.z)
		if (rotation_degrees.y == targetRotation):
#Re-enable when finished rotating = false

However, this didn't work as expected - the rotation would stop, but not finish, before reaching a 90 degree change. Since the starting rotation was 45 degrees, if the target was then 135 (45 + 90), the rotation would stop at 134.999969482422 degrees (which changed to 135.000015258789 when I moved the window)

This meant that I had to round the current rotation value to compare to the target, and rather than an equals comparison use a "greater than or equal to", in case it rotated slightly too far.

As you can see, the "rotation_degrees" property is a Vector3, which is a structure that uses 3 floats as the values. But all number types (int, double, float etc) all have a maximum value (which differs for each type) - once that is reached, if you add 1 to that number, the result is then the minimum possible value (this is called "overflow"). What would happen when this number is reached for the Vector3? I have no idea, but with the code as above it is possible to just keep rotating until you reach that point. Since I don't know what would happen, and the numbers could be absurdly huge, it makes sense to restrict it to smaller numbers to avoid this and make debugging in future far easier.

The next step was to adjust the values so that if it goes below -360 degrees or above 360 is then adds or subtracts 360 to keep it within a reasonable boundry.

When testing this out, though, it looked like it was acting like a coiled spring - when the target value got above 360 (which would be 405, so it would then be set to 45) it would suddenly "spring" back rather than smoothly animating. Putting in debugging, it seemed to be rotating twice at once.



This is apparently from using the "Input.is_action_pressed" function, as it seems to keep executing the action repeatedly. Perhaps, despite the flag trying to prevent this, it was picking up the instructions across two frames? Maybe there is a race condition, and the function was executing asynchronously? Regardless, switching it to "Input.is_action_just_pressed" seems to restrict it to only one press at a time.

However, still the change is rather sudden, the final change not having any animation to it.



The ultimate solution was to allow it to overflow for the purposes of animation, and then to adjust the value after the rotation has finished. At least for rotating right, where the numbers are increasing. When decreasing, because of the check (greater than or equal to) it meant that the rotation was finishing early. So the comparison needed to change depending on a switch with 3 states - an enum rather than a boolean flag.



The final code for this is below.

extends Marker3D

enum RotationType {NONE, LEFT, RIGHT}
const ROT_SPEED = 10
var rotating = RotationType.NONE
var targetRotation = 0

func _ready():
	targetRotation = rotation_degrees.y
	print("Starting, current rotation is " + String.num(targetRotation))

func rotate_left():
	targetRotation = targetRotation - 90
	rotating = RotationType.LEFT
	print("Setting target rotation to " + String.num(targetRotation))
	
func rotate_right():
	targetRotation = targetRotation + 90
	rotating = RotationType.RIGHT
	print("Setting target rotation to " + String.num(targetRotation))
	
func _process(delta):
	if(rotating == RotationType.NONE):
		if(Input.is_action_just_pressed("camera_rotate_left")):
			rotate_left()
		if(Input.is_action_just_pressed("camera_rotate_right")):
			rotate_right()
	else:
		rotation_degrees = Vector3(rotation_degrees.x,lerp(rotation_degrees.y,targetRotation,ROT_SPEED*delta),rotation_degrees.z)
		var normalised_y = rotation_degrees.round().y
		if ((normalised_y >= targetRotation && rotating == RotationType.RIGHT) || (normalised_y <= targetRotation && rotating == RotationType.LEFT)):
			#Re-enable when finished
			if (normalised_y > 360):
				normalised_y = normalised_y - 360
			if(normalised_y < -360):
				normalised_y = normalised_y + 360
			rotation_degrees.y = normalised_y			
			targetRotation = normalised_y
			print("Finished rotating, target rotation = " + String.num(targetRotation))
			rotating = RotationType.NONE