• 3D
  • Quaternion Based Camera Controller - No Gimbal - No Euler

I'm trying to figure out how to make a 3D free look camera without Euler angles. I don't want to use a gimbal, and I'm getting lots of problems with smoothing and interpolation with Euler that I'm not sure I can solve easily. So I started writing code to do it fully with Quaternions. This is how far I got, it basically works except it doesn't clamp/limit on the pitch angle.

extends Camera

export(float, 0, 100) var move_speed = 50
export(float, 0, 100) var look_speed = 50
var move_scale = 0.1
var look_scale = 0.00001

func _process(delta):
	var move_dir = check_buttons()
	translate(move_dir * move_speed * move_scale * delta)
	
func _input(event):
	if event is InputEventMouseMotion:
		var mouse_delta = -event.relative * look_speed * look_scale
		var mouse_vector_horz = Vector3(0.0, mouse_delta.x, 0.0)
		var mouse_vector_vert = Vector3(mouse_delta.y, 0.0, 0.0)
		var look_quat_horz = Quat(transform.basis.xform_inv(mouse_vector_horz))
		var look_quat_vert = Quat(mouse_vector_vert)
		transform.basis *= Basis(look_quat_horz) * Basis(look_quat_vert)

func check_buttons():
	var dir = Vector3.ZERO
	if Input.is_action_pressed("forward"):
		dir += Vector3.FORWARD
	if Input.is_action_pressed("back"):
		dir += Vector3.BACK
	if Input.is_action_pressed("left"):
		dir += Vector3.LEFT
	if Input.is_action_pressed("right"):
		dir += Vector3.RIGHT
	if not dir.is_equal_approx(Vector3.ZERO):
		dir = dir.normalized()
	return dir

So the code is small and the results look much better than Euler so far. Plus the smoothing should work better (not implemented yet but I'm pretty sure it will work fine). The problem is that I need to detect when looking 90 degrees up or down and then clamp only the pitch (the x-axis of the camera). Detection somewhat works, as you can use angle_to(), however this is just the shortest angle. So it will tell you 0 if you are looking directly down, but if you are looking down and slightly forward it will say 10, but looking down and slightly back is also 10. Additionally, I need to clamp or limit the angle to 90 degrees and I can't seem to figure that out. Anyone have ideas?

You can clamp the angle in your vertical quaternion to the angle between current basis forward vector and up or down vector (depending on the rotation direction). It doesn't matter where the basis vector is actually pointing.

Perhaps the solution here is to work with both Eulers and Quats and convert from one to the other? I'm honestly not entirely sure either.

It's easy enough to be aware that Euler can result in gimbal lock and that quaternion is the likely way to go to avoid it but I haven't done anything with quats myself precisely because it's more involved. I'll have to double down and just wrap my head around this all myself one of these days...and I'm guessing this is part of the reason you are looking to do this experiment yourself as well.

Searching for and looking through some academic/white papers on the subject might be a way to go I suppose.

At least godot has some built-ins for the conversion tho, which is why my first instinct is to recommend looking at that.

Here's teh codez. I'll look if there's more elegant way to handle it

# delta angles
var dangle = -event.relative * look_speed * look_scale

# limit vertical delta angle
var dangle_y_max
var threshold = .000001
if dangle.y < 0:
	dangle_y_max = (-transform.basis.z).angle_to(Vector3.DOWN)
else:
	dangle_y_max = (-transform.basis.z).angle_to(Vector3.UP)
dangle.y = clamp(abs(dangle.y), 0, dangle_y_max-threshold) * sign(dangle.y)

# horizontal and vertical rotation axis in local space
var haxis = transform.basis.xform_inv(Vector3.UP)
var vaxis = Vector3.RIGHT

# xform
transform.basis *=  Basis(Quat(haxis, dangle.x) * Quat(vaxis, dangle.y))

I prefer constructing quaternions from axis+angle but you can still keep the way you're currently doing it from a single euler angle.

Here's purified version without ifs:

# delta angles
var dangle = -event.relative * look_speed * look_scale

# limit vertical delta angle
var threshold = .00001
var dangle_y_max = (-transform.basis.z).angle_to(Vector3.UP * sign(dangle.y))
dangle.y = clamp(abs(dangle.y), 0, dangle_y_max - threshold) * sign(dangle.y)

# horizontal and vertical rotation quaternions ( local axis + angle )
var hquat = Quat(transform.basis.xform_inv(Vector3.UP), dangle.x)
var vquat = Quat(Vector3.RIGHT, dangle.y)

# xform
transform.basis *= Basis(hquat * vquat)

Perfect, it works! I knew you'd be able to figure it out. I updated it to slerp the target angle, so the camera is smooth now. However, I found one bug. After about 10 seconds, if you look exactly forward, the camera gets locked in the identity matrix and won't move anymore. However, I just needed orthonormalize it and then it works fine (probably some sort of floating-point error, I guess). Here is the final working code.

extends Camera

export(float, 0, 100) var move_speed = 50
export(float, 0, 100) var look_speed = 50
export(float, 0, 100) var look_smooth = 50
var move_scale = 0.1
var look_scale = 0.00001
var smooth_scale = 0.001
var threshold = 0.00001
var target_basis = Basis.IDENTITY
var mouse_locked = false

func _process(delta):
	var move_dir = check_buttons()
	translate(move_dir * move_speed * move_scale * delta)
	transform.basis = transform.basis.slerp(target_basis, look_smooth * smooth_scale)
	
func _input(event):
	if event is InputEventMouseMotion and mouse_locked:
		var mouse_delta = -event.relative * look_speed * look_scale
		var delta_y_max = (-target_basis.z).angle_to(Vector3.UP * sign(mouse_delta.y) )
		mouse_delta.y = clamp(abs(mouse_delta.y), 0.0, delta_y_max - threshold) * sign(mouse_delta.y)
		var horz_quat = Quat(target_basis.xform_inv(Vector3.UP), mouse_delta.x)
		var vert_quat = Quat(Vector3.RIGHT, mouse_delta.y)
		target_basis *= Basis(horz_quat * vert_quat)
		target_basis = target_basis.orthonormalized()
	if event.is_action_pressed("click"):
		mouse_locked = not mouse_locked
		if mouse_locked:
			Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
		else:
			Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

func check_buttons():
	var dir = Vector3.ZERO
	if Input.is_action_pressed("forward"):
		dir += Vector3.FORWARD
	if Input.is_action_pressed("back"):
		dir += Vector3.BACK
	if Input.is_action_pressed("left"):
		dir += Vector3.LEFT
	if Input.is_action_pressed("right"):
		dir += Vector3.RIGHT
	if not dir.is_equal_approx(Vector3.ZERO):
		dir = dir.normalized()
	return dir

So we have it now, a camera with only Quaternions. Leonhard Euler can eat it.

Great! Yeah, you should normalize basis matrices from time to time if you do a lot of incremental multiplication. I think they even state so in the docs.

Poor Euler :( Mighty Quaternions dancing on his grave :D

@Megalomaniak said: Perhaps the solution here is to work with both Eulers and Quats and convert from one to the other? I'm honestly not entirely sure either.

Yeah, that was the first thing I tried, and I sort of got it working, but it introduced the same problems as before, negating the point of only using Quaternions. Also, it required a lot of code for the conversion, as I would have to extract and rebuild the axis, so it seemed more intensive than I wanted.

@Megalomaniak said: Searching for and looking through some academic/white papers on the subject might be a way to go I suppose.

Surprisingly I was not able to find a lot of material on this, at least in relation to game development. It seems everyone, at least people starting out, all use Eulers and Gimbals, and all the tutorials use that method. I'm sure some AAA games are based on Quaternions, but they probably don't share their code.

Note that Godot's editor camera behaves like this, Although it orbits around some pivot point instead of rotating, it's exactly the same problem regarding rotation axes and limits. Someone should peek into source and check if it's handled with eulers or quats.

@cybereality said:

Surprisingly I was not able to find a lot of material on this, at least in relation to game development. It seems everyone, at least people starting out, all use Eulers and Gimbals, and all the tutorials use that method. I'm sure some AAA games are based on Quaternions, but they probably don't share their code.

Yeah I did a quick search when I replied and didn't link anything since most of it was robotics related, but then again, I guess math is math, so might still be relevant.