godotengine / godot

Godot Engine – Multi-platform 2D and 3D game engine
https://godotengine.org
MIT License
86.95k stars 19.49k forks source link

After Node3D::set_quaternion(q), get_quaternion returns different quaternion than q #83846

Open thygrrr opened 9 months ago

thygrrr commented 9 months ago

Godot version

4.2.beta, 4.1.2.stable

System information

Windows 11

Issue description

When incrementally updating a Node3D's quaternion property, either by *= or direct assignment, an erroneous operation in set_quaternion or get_quaternion will cause the value to flip to the complementary rotation after around 3/4ths of Pi.

This is incorrect and makes interpolation between quaternions over time error-prone (e.g. when using an interpolator that also tracks or limits angular velocity)

Steps to reproduce

  1. Load repro product (in Godot 4.2 or 4.1)
  2. Press Play
  3. Examine Log Output (there is no visual output, only text)
  4. Observe: after 75% of the rotation, the quaternion's components suddenly change their sign, representing the complementary rotation
  5. Examine code in reproduction.gd: we're actually assigning the correct quaternion, but the rotation is corrupted by the conversion by the basis operation in set_quaternion or perhaps get_quaternion.

You'll see something like:

Rotation (% of 2pi)
5% (0, 0.156434, 0, 0.987688) == (0, 0.156434, 0, 0.987688)
10% (0, 0.309017, 0, 0.951056) == (0, 0.309017, 0, 0.951057)
15% (0, 0.453991, 0, 0.891007) == (0, 0.453991, 0, 0.891007)
20% (0, 0.587785, 0, 0.809017) == (0, 0.587785, 0, 0.809017)
25% (0, 0.707107, 0, 0.707107) == (0, 0.707107, 0, 0.707107)
30% (0, 0.809017, 0, 0.587785) == (0, 0.809017, 0, 0.587785)
35% (0, 0.891007, 0, 0.45399) == (0, 0.891007, 0, 0.453991)
40% (0, 0.951057, 0, 0.309017) == (0, 0.951057, 0, 0.309017)
45% (0, 0.987688, 0, 0.156434) == (0, 0.987689, 0, 0.156434)
50% (0, 1, 0, -0) == (0, 1, 0, -0)
55% (0, 0.987688, 0, -0.156435) == (0, 0.987689, 0, -0.156435)
60% (0, 0.951056, 0, -0.309017) == (0, 0.951057, 0, -0.309017)
65% (0, 0.891006, 0, -0.453991) == (0, 0.891007, 0, -0.453991)
70% (0, -0.809017, 0, 0.587785) != (0, 0.809017, 0, -0.587786) <<<<< ERROR
75% (0, -0.707107, 0, 0.707107) != (0, 0.707107, 0, -0.707107) <<<<< ERROR
80% (0, -0.587785, 0, 0.809017) != (0, 0.587785, 0, -0.809017) <<<<< ERROR
85% (0, -0.45399, 0, 0.891007) != (0, 0.45399, 0, -0.891007) <<<<< ERROR
90% (0, -0.309017, 0, 0.951057) != (0, 0.309017, 0, -0.951057) <<<<< ERROR
95% (0, -0.156434, 0, 0.987688) != (0, 0.156434, 0, -0.987689) <<<<< ERROR
100% (0, 0, 0, 1) != (0, -0, 0, -1) <<<<< ERROR

Minimal reproduction project

quaternion-wrap-bug.zip

thygrrr commented 9 months ago

The window is actually a bit larger. Changing the range in the reproduction.gd to 41 gives 2 full rotations, where the last 90° are ok again (and the first 270° are ok, too)

Rotation (% of 2pi)
0% (0, 0, 0, 1) == (0, 0, 0, 1)
5% (0, 0.156434, 0, 0.987688) == (0, 0.156434, 0, 0.987688)
10% (0, 0.309017, 0, 0.951056) == (0, 0.309017, 0, 0.951057)
15% (0, 0.453991, 0, 0.891007) == (0, 0.453991, 0, 0.891007)
20% (0, 0.587785, 0, 0.809017) == (0, 0.587785, 0, 0.809017)
25% (0, 0.707107, 0, 0.707107) == (0, 0.707107, 0, 0.707107)
30% (0, 0.809017, 0, 0.587785) == (0, 0.809017, 0, 0.587785)
35% (0, 0.891007, 0, 0.45399) == (0, 0.891007, 0, 0.453991)
40% (0, 0.951057, 0, 0.309017) == (0, 0.951057, 0, 0.309017)
45% (0, 0.987688, 0, 0.156434) == (0, 0.987689, 0, 0.156434)
50% (0, 1, 0, -0) == (0, 1, 0, -0)
55% (0, 0.987688, 0, -0.156435) == (0, 0.987689, 0, -0.156435)
60% (0, 0.951056, 0, -0.309017) == (0, 0.951057, 0, -0.309017)
65% (0, 0.891006, 0, -0.453991) == (0, 0.891007, 0, -0.453991)
70% (0, -0.809017, 0, 0.587785) != (0, 0.809017, 0, -0.587786) <<<<< ERROR
75% (0, -0.707107, 0, 0.707107) != (0, 0.707107, 0, -0.707107) <<<<< ERROR
80% (0, -0.587785, 0, 0.809017) != (0, 0.587785, 0, -0.809017) <<<<< ERROR
85% (0, -0.45399, 0, 0.891007) != (0, 0.45399, 0, -0.891007) <<<<< ERROR
90% (0, -0.309017, 0, 0.951057) != (0, 0.309017, 0, -0.951057) <<<<< ERROR
95% (0, -0.156434, 0, 0.987688) != (0, 0.156434, 0, -0.987689) <<<<< ERROR
100% (0, 0, 0, 1) != (0, -0, 0, -1) <<<<< ERROR
105% (0, 0.156435, 0, 0.987688) != (0, -0.156435, 0, -0.987689) <<<<< ERROR
110% (0, 0.309017, 0, 0.951056) != (0, -0.309017, 0, -0.951057) <<<<< ERROR
115% (0, 0.453991, 0, 0.891006) != (0, -0.453991, 0, -0.891007) <<<<< ERROR
120% (0, 0.587785, 0, 0.809017) != (0, -0.587786, 0, -0.809017) <<<<< ERROR
125% (0, 0.707107, 0, 0.707107) != (0, -0.707107, 0, -0.707107) <<<<< ERROR
130% (0, 0.809017, 0, 0.587785) != (0, -0.809018, 0, -0.587785) <<<<< ERROR
135% (0, 0.891007, 0, 0.45399) != (0, -0.891007, 0, -0.453991) <<<<< ERROR
140% (0, 0.951057, 0, 0.309017) != (0, -0.951057, 0, -0.309017) <<<<< ERROR
145% (0, 0.987688, 0, 0.156434) != (0, -0.987689, 0, -0.156434) <<<<< ERROR
150% (0, 1, 0, -0) != (0, -1.000001, 0, 0) <<<<< ERROR
155% (0, 0.987688, 0, -0.156435) != (0, -0.987689, 0, 0.156435) <<<<< ERROR
160% (0, 0.951056, 0, -0.309017) != (0, -0.951057, 0, 0.309017) <<<<< ERROR
165% (0, 0.891006, 0, -0.453991) != (0, -0.891007, 0, 0.453991) <<<<< ERROR
170% (0, -0.809017, 0, 0.587785) == (0, -0.809017, 0, 0.587786)
175% (0, -0.707107, 0, 0.707107) == (0, -0.707107, 0, 0.707107)
180% (0, -0.587785, 0, 0.809017) == (0, -0.587785, 0, 0.809018)
185% (0, -0.45399, 0, 0.891007) == (0, -0.453991, 0, 0.891007)
190% (0, -0.309017, 0, 0.951057) == (0, -0.309017, 0, 0.951057)
195% (0, -0.156434, 0, 0.987688) == (0, -0.156434, 0, 0.987689)
200% (0, 0, 0, 1) == (0, 0, 0, 1.000001)
lawnjelly commented 9 months ago

My quick guess is that the problem you are seeing is Node3D only stores the rotation as a basis, and you are conceptually thinking it is storing it as a quaternion. Afaik any rotation has 2 possible quaternion versions, so depending on the conversion code, maybe sometimes you will get flips if you try to retrieve a quat from this stored basis.

One solution if you wish to interpolate quaternion manually may be to manage the quaternion entirely in user code and update it every tick / frame, and not rely on getting it from the basis stored in the Node3D.

thygrrr commented 9 months ago

This is my understanding, too, I was implementing various interpolators and realized that quaternion interpolation causes a reverse turn after 270° around the respective axis.

I don't think it should operate this way; in particular, setting the quaternion property should carry with it a reasonable expectation of getting the same quaternion back afterwards.

It's also notable that most other engines also encode their transforms in a basis, and don't show this behaviour when working with quaternions.

When creating camera interpolation with some inertia, you usually end up with an angular velocity in angle-axis representation. https://theorangeduck.com/page/spring-roll-call#quaternionspring (not a big fan of slerping euler angles - it's just not the same thing when doing XR or 6DOF stuff like flight sims or space games)

thygrrr commented 9 months ago

A workaround on user-side is to manually account for double cover.

Appending this line to the end of the loop in the demo script will reformulate our q in a way that it works with what get_quaternion returns, even if that call resulted in "flipping" the rotation. This is a deceptively expensive operation internally, as getting the quaternion isn't just a read.

q *= 1 if q.dot(quaternion) > 0 else -1

full code:

extends Node3D

func _ready() -> void:
    print("Rotation (% of 2pi)")
    var q = quaternion
    for i in range(41): # Pretend an iterative rotation process that climbs up to 720° in 18° steps
        var percent = str(i*5)+"%"
        if quaternion.is_equal_approx(q):
            prints(percent, quaternion, "==", q)
        else:
            prints(percent, quaternion, "!=", q, "<<<<< ERROR")
            #assert(false, "Check output! Quaternions are not the same. Comparing the signs, notice that one is the complementary rotation. This doesn't work well for interpolation, and is mathematically incorrect.")

        q *= Quaternion.from_euler(Vector3(0, PI/10.0, 0))
        quaternion = q
        q *= 1 if q.dot(quaternion) > 0 else -1

Output:

Vulkan API 1.3.242 - Forward+ - Using Vulkan Device #0: NVIDIA - NVIDIA GeForce RTX 3080

Rotation (% of 2pi)
0% (0, 0, 0, 1) == (0, 0, 0, 1)
5% (0, 0.156435, 0, 0.987688) == (0, 0.156434, 0, 0.987688)
10% (0, 0.309017, 0, 0.951057) == (0, 0.309017, 0, 0.951057)
15% (0, 0.453991, 0, 0.891007) == (0, 0.453991, 0, 0.891006)
20% (0, 0.587785, 0, 0.809017) == (0, 0.587785, 0, 0.809017)
25% (0, 0.707107, 0, 0.707107) == (0, 0.707107, 0, 0.707107)
30% (0, 0.809017, 0, 0.587785) == (0, 0.809017, 0, 0.587785)
35% (0, 0.891007, 0, 0.45399) == (0, 0.891007, 0, 0.45399)
40% (0, 0.951057, 0, 0.309017) == (0, 0.951057, 0, 0.309017)
45% (0, 0.987688, 0, 0.156434) == (0, 0.987688, 0, 0.156434)
50% (0, 1, 0, -0) == (0, 1, 0, -0)
55% (0, 0.987688, 0, -0.156435) == (0, 0.987688, 0, -0.156435)
60% (0, 0.951056, 0, -0.309017) == (0, 0.951057, 0, -0.309017)
65% (0, 0.891006, 0, -0.453991) == (0, 0.891006, 0, -0.453991)
70% (0, -0.809017, 0, 0.587785) == (0, -0.809017, 0, 0.587785)
75% (0, -0.707107, 0, 0.707107) == (0, -0.707107, 0, 0.707107)
80% (0, -0.587785, 0, 0.809017) == (0, -0.587785, 0, 0.809017)
85% (0, -0.45399, 0, 0.891007) == (0, -0.45399, 0, 0.891007)
90% (0, -0.309017, 0, 0.951057) == (0, -0.309017, 0, 0.951057)
95% (0, -0.156434, 0, 0.987688) == (0, -0.156434, 0, 0.987688)
100% (0, 0, 0, 1) == (0, 0, 0, 1)
105% (0, 0.156435, 0, 0.987688) == (0, 0.156435, 0, 0.987688)
110% (0, 0.309017, 0, 0.951056) == (0, 0.309017, 0, 0.951056)
115% (0, 0.453991, 0, 0.891006) == (0, 0.453991, 0, 0.891007)
120% (0, 0.587785, 0, 0.809017) == (0, 0.587785, 0, 0.809017)
125% (0, 0.707107, 0, 0.707107) == (0, 0.707107, 0, 0.707107)
130% (0, 0.809017, 0, 0.587785) == (0, 0.809017, 0, 0.587785)
135% (0, 0.891007, 0, 0.45399) == (0, 0.891007, 0, 0.45399)
140% (0, 0.951057, 0, 0.309017) == (0, 0.951057, 0, 0.309017)
145% (0, 0.987688, 0, 0.156434) == (0, 0.987688, 0, 0.156434)
150% (0, 1, 0, -0) == (0, 1, 0, -0)
155% (0, 0.987688, 0, -0.156435) == (0, 0.987688, 0, -0.156435)
160% (0, 0.951057, 0, -0.309017) == (0, 0.951056, 0, -0.309017)
165% (0, 0.891006, 0, -0.453991) == (0, 0.891006, 0, -0.453991)
170% (0, -0.809017, 0, 0.587785) == (0, -0.809017, 0, 0.587785)
175% (0, -0.707107, 0, 0.707107) == (0, -0.707107, 0, 0.707107)
180% (0, -0.587785, 0, 0.809017) == (0, -0.587785, 0, 0.809017)
185% (0, -0.45399, 0, 0.891007) == (0, -0.45399, 0, 0.891007)
190% (0, -0.309017, 0, 0.951057) == (0, -0.309017, 0, 0.951057)
195% (0, -0.156434, 0, 0.987688) == (0, -0.156434, 0, 0.987688)
200% (0, 0, 0, 1) == (0, 0, 0, 1)
--- Debugging process stopped ---