7

We can do a slerp interpolation between two quaternions like this:

quat slerp(quat q1, quat q2, float t) {
    float angle = acos(dotProduct(q1, q2));
    float denom = sin(angle);
    //check if denom is zero
    return (q1*sin((1-t)*angle)+q2*sin(t*angle))/denom;
}

This will interpolate between the two quaternions the shortest way. However there's also a long way to interpolate between quaternions. As seen in the image below (source Maya).

enter image description here

How do we interpolate the long way?

Lenny White
  • 406
  • 4
  • 8
  • What happens if you take t<0.0 or t>1.0. (I don't claim to know, just curious) – Jeffrey Jul 16 '20 at 20:53
  • @Jeffrey I tested it out by slerping a vector (not quaternion) and purely visually the vector seems to indeed be interpolating along the long way when using values t<0.0 or t>1.0. – Lenny White Jul 16 '20 at 21:23
  • 2
    Ok, you just need someone with enough math to justify why 0.0 to -1.0 (?) would go the long way around. I lack the math :-) – Jeffrey Jul 16 '20 at 21:24
  • I suspect the key is not to use `acos()` with its half circle range, but `atan2()` with a full circle range. – chux - Reinstate Monica Jul 16 '20 at 23:26

3 Answers3

3

The nature of unit quaternions and the way they map to 3D rotations means they can describe each 3D rotation value in two ways - as q(r, v') and as q(-r, -v') (imagine them as axis-angle rotations - inverting both the axis and the angle leads to the same 3D rotation).

Quaternions are actually points on a 4D unit spherical surface, and these two values represent anti-podal points on that sphere.

For a slerp (or nlerp) of two quaternions to follow the shortest path, the corresponding 4D points have to lie on the same hemisphere of the 4D sphere (this is also the reason why a weighted average of more than 2 quaternions doesn't have a unique solution). This maps to a non-negative dot product, and is usually something tested for in the interpolation code.

Simply negating one of the source quaternions will give you a point "on the opposite side of the 4D sphere", and lead to interpolation "the long way around" (and explains why negating the interpolation parameter leads to the same result).

Martin Prazak
  • 1,476
  • 12
  • 20
  • @iam, can you please be a little bit more specific? I have used this technique several times before, and it quite definitely does work :) – Martin Prazak Jan 06 '21 at 11:08
  • I plotted the rotation path from flipping signs as mentioned (but not very well clarified), and unfortunately although it was certainly a 'longer path' I don't think it's the longer path the OP intended (I thought it was that simple too but so far it seems not). I'm trying to get Mauricio's answer implemented to test for comparison. – iam Jan 06 '21 at 11:14
  • I will try and link my test case somewhere for you in a few hours - as I would certainly like it to work :-) – iam Jan 06 '21 at 11:16
  • It is the accepted answer, so I believe the OP was happy enough with that solution :) What did you use to plot the path? (i.e., are you trying to plot the 4D path, or Euler angles, or separate components of the quaternion? and what do you expect to see?) – Martin Prazak Jan 06 '21 at 11:17
  • Ok back at the PC now, no malice intended! Will add a short SDL/OpenGL example in another answer of what I mean - it rotates a point, which should rotate the long way around by negating one of the quaternions or the t parameter according to what you said right? – iam Jan 06 '21 at 14:44
  • I added an additional answer. I haven't discounted you are still right :P and that I confused myself! As it seems I did a bit, but what you said did make sense to me initially! Would love your input on my post below – iam Jan 06 '21 at 16:11
  • 2
    I've expanded my answer with additional functions that I think are now correct - which means you are correct and I apologise. Apparently I can't fix that down vote I used to get your attention, without you editing your answer though :( – iam Jan 06 '21 at 17:28
  • 2
    All good, don't worry! :) Yes, I can see your code - in addition to my very informal description, it also handles antipodality on inputs (which I have omitted completely above). It all makes sense now, and having your answer here with all the code is good. – Martin Prazak Jan 07 '21 at 15:32
1

This can be done by changing the angle in the spherical interpolation.

In the usual SLERP(q1, q2, t) you get q1 when t=0 and q2 when t=1. The geodesic distance traveled is actually the angle between q1 and q2, which we name theta.

What we want to do here is to travel the complement distance which is 2PI - theta, but in the opposite sense of rotation. We will call this the complement theta.

We want to find a quaternion-valued function Q(t) such that:

SLERP2(q1, q2, t) = q1 Q(t)

when t = 0

SLERP2(q1, q2, 0) = q1 Q(0) = q1

and when t=1

SLERP2(q1, q2, 1) = q1 Q(1) = q2.

So we know that Q(0) = 1 (identity quaternion) and Q(1) = (q1^-1 q2).

It turns out that we can define such a function Q(t) in terms of the exponential map and principal logarithm of quaternions:

Q(t) = Exp(t Log(q1^-1 q2)/2)

You can check that it works by giving values to t like t=0 and t=1.

So far ao good, but that Q(t) will lead us to have the regular SLERP, not the one that we want. Lets take a close look at the logarithm:

Log(q1^-1 q2) = theta V

Where V is a unit vector (actually pure unit quaternion) which is the axis of rotation of quaternion q1^-1 q2. And theta is basically the angle between q1 and q2.

We actually need to change that logarithm so that Q(t) will go the long way, which is the complement theta distance:

Q(t) = Exp(t CompTheta V/2)

Where CompTheta = theta - 2PI.

Recall that exponential function is:

Exp(t CompTheta V/2) = cos(t CompTheta/2) - sin(t CompTheta/2) V

Now, how do we find the Logarithm i.e., theta V?

When you multiply q1^-1 q2 you get a new quaternion, lets call it q12.

q12 = cos(theta/2) - sin(theta/2) V

q12 = w + V'

Where:

w = cos(theta/2)

V' = sin(theta/2) V

theta = 2 atan2(|V'|, w)

V = V'/|V'|

So finally your SLERP2(q1,q2, t) is equal to:

SLERP2(q1,q2, t) = q1 Q(t)

SLERP2(q1,q2, t) = q1 Exp(t CompTheta V/2)

Discraimler: I haven't tested this. If you can test it please comment here.

  • The accepted answer isn't correct. And I tried implementing yours but its giving me something in the same plane but that is wrong: float theta = acosf(Dot(q0, q1)); float CompTheta = theta - 2.0f * M_PI; Quaternion V = Normalise(Reciprocal(q0) * q1); return q1 * Exp(t * CompTheta * V * 0.5f); – iam Jan 06 '21 at 10:20
  • theta is not acosf(q0,q1), theta is 2 atan2(|V'|, w). V is not what you computed, it is (q12.x,q12.y,q12.z) /|(q12.x,q12.y,q12.z)|. V' = (q12.x,q12.y,q12.z) – Mauricio Cele Lopez Belon Jan 06 '21 at 10:38
  • Ok but I am not sure where you are getting w from as its dependent on theta: w = cos(theta/2)? I am not sure if you intend w = q12 - V' ? – iam Jan 06 '21 at 11:09
  • w is the fourth component of q12. So q12 = w + V' – Mauricio Cele Lopez Belon Jan 06 '21 at 12:21
  • Sorry I totally forgot that it was a binding notation used for quaternions with the plus sign like that! Ok I am going to add another answer soon with OpenGL/SDL code that tries to illustrate the accepted answer and your answer. So it can be critiqued and hopefully made correct. – iam Jan 06 '21 at 14:48
  • Ok still not working or I got something wrong: Quat FarSlerpMauricio(Quat q0, Quat q1, float t) { Quat q01 = Reciprocal(q0) * q1; Quat Vdash{ q01.x, q01.y, q01.z, 0.0f }; Quat V = Vdash * (1.0f / Length(Vdash)); float theta = 2.0f * atan2f(Length(Vdash), q01.w); float CompTheta = theta - 2.0f * M_PI; return q1 * Exp(t * CompTheta * V * 0.5f); } – iam Jan 06 '21 at 15:06
  • Ok I added an answer with code to try and help – iam Jan 06 '21 at 16:08
1

So in an effort to save some confusion from others who may go through this I knocked together some SDL/OpenGL code in an effort to get either Mauricio's or Martin's answer to work. I found Martin's answer as it stands a little bit nebulous when implementing even though it states the truth. I haven't managed to get Mauricio's answer to work even with his help unfortunately.

I made a few mistakes too, and the number of different slerp functions I tried from various places to sanity check things ended up causing me some confusion so I ended up implementing my own slerp (SlerpIam() in the code) from scratch without checking for the nearest path.

In the code Slerp1() & Slerp2() I think are broken for when the shortest path is not selected which was part of my confusion - as from the myriad of slerps I found I think they were erroneously modified to try and support longest paths but they don't. So I was originally trying to hack them as Martin mentioned but it went horribly wrong.

My test case shows a point being rotated/rolled around the Z axis to 270 degrees.

I compiled the code with SDL2 on Windows and you will need to include SDL headers and link etc:

#include <cmath>


constexpr float PI = 3.14159265358979323846;


struct Quat         { float x, y, z, w; };
struct Vec3         { float x, y, z; };
struct AxisAngle    { Vec3 axis; float angle; };


float ToRadian(float degree)    { return degree * PI / 180.0f; }

Quat operator+(Quat a, Quat b)  { return { a.x + b.x, a.y + b.y, a.z + b.z, a.w + b.w }; }
Quat operator*(Quat q, float s) { return { q.x * s, q.y * s, q.z * s, q.w * s }; }
Quat operator*(float s, Quat q) { return { q.x * s, q.y * s, q.z * s, q.w * s }; }
Quat operator*(Quat second, Quat first)
{
    return Quat
    {
        second.w*first.x + second.x*first.w + second.y*first.z - second.z*first.y,
        second.w*first.y - second.x*first.z + second.y*first.w + second.z*first.x,
        second.w*first.z + second.x*first.y - second.y*first.x + second.z*first.w,
        second.w*first.w - second.x*first.x - second.y*first.y - second.z*first.z
    };
}

float Dot(Quat a, Quat b)   { return a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w; }
float Length(Quat q)        { return sqrtf(Dot(q, q)); }
Quat Normalise(Quat q)      { return q * (1.0f / sqrtf(Dot(q, q))); }
Quat Conjugate(Quat q)      { return{ -q.x, -q.y, -q.z, q.w }; }
Quat Reciprocal(Quat q)     { return Conjugate(q) * (1.0f / Dot(q, q)); }
Vec3 Rotate(Quat q, Vec3 v) { Quat vr = q * Quat{ v.x, v.y, v.z, 0.0f } *Conjugate(q); return { vr.x, vr.y, vr.z }; }

Quat ToQuat(AxisAngle r)
{
    float halfAngle = 0.5f * r.angle;
    float sinHalfAngle = sinf(halfAngle);


    return{ r.axis.x * sinHalfAngle, r.axis.y * sinHalfAngle, r.axis.z * sinHalfAngle, cosf(halfAngle) };
}


AxisAngle ToAxisAngle(Quat q)
{
    float s = 1.0f / sqrtf(1.0f - q.w * q.w);


    return { { q.x * s, q.y * s, q.z * s }, acosf(q.w) * 2.0f };
}


Quat Exp(Quat q)
{
    double b = sqrt(q.x * q.x + q.y * q.y + q.z * q.z);


    if (fabs(b) <= 1.0e-14 * fabs(q.w))
        return { 0.0f, 0.0f, 0.0f, expf(q.w) };
    else
    {
        float e = expf(q.w);
        float f = sinf(b) / b;


        return { e * f * q.x, e * f * q.y,  e * f * q.z, e * cosf(b) };
    }
}



Quat SlerpIam(Quat a, Quat b, float t)
{
    float dotAB = Dot(a, b);
    float theta = acosf(dotAB);
    float sinTheta = sinf(theta);
    float af = sinf((1.0f - t) * theta) / sinTheta;
    float bf = sinf(t * theta) / sinTheta;


    return a * af + b * bf;
}


Quat Slerp1(Quat q0, Quat q1, float t, bool shortPath = true)
{
    float d = Dot(q0, q1);
    float s0, s1;
    float sd = shortPath ? (d > 0) - (d < 0) : 1.0f;


    d = fabs(d);

    if (d < 0.9995f)
    {
        float s = sqrtf(1 - d * d);    //   Sine of relative angle
        float a = atan2f(s, d);
        float c = cosf(t*a);


        s1 = sqrtf(1 - c * c) / s;
        s0 = c - d * s1;
    }
    else
    {
        s0 = 1.0f - t;
        s1 = t;
    }


    return q0 * s0 + q1 * sd * s1;
}


Quat Slerp2(Quat q0, Quat q1, float t, bool shortPath = true)
{
    float a = 1.0f - t;
    float b = t;
    float d = Dot(q0, q1);
    float c = fabsf(d);


    if (c < 0.9995f)
    {
        c = acosf(c);
        b = 1.0f / sinf(c);
        a = sinf(a * c) * b;
        b *= sinf(t * c);

        if (shortPath && d < 0)
            b = -b;
    }


    return q0 * a + q1 * b;
}


Quat FarSlerpMauricio(Quat q0, Quat q1, float t)
{
    Quat q01 = Reciprocal(q0) * q1;
    Quat Vdash{ q01.x, q01.y, q01.z, 0.0f };
    Quat V = Vdash * (1.0f / Length(Vdash));
    float theta = 2.0f * atan2f(Length(Vdash), q01.w);
    float CompTheta = theta - 2.0f * M_PI;


    return q1 * Exp(t * CompTheta * V * 0.5f);
}


void Draw()
{
    float t = float(SDL_GetTicks() % 6000) / 6000.0f;
    Quat id{ 0.0f, 0.0f, 0.0f, 1.0f};
    Quat target = ToQuat({{0.0f, 0.0f, 1.0f}, ToRadian(270.0f)});

    //Quat r = FarSlerpMauricio(id, target, t);
    Quat r = SlerpIam(id, target, t);
    //Quat r = Slerp1(id, target, t);
    //Quat r = Slerp2(id, target, t);

    Vec3 p = Rotate(r, { 1.0f, 0.0f, 0.0f });
    

    glClearColor(0.2f, 0.2f, 0.2f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    glBegin(GL_LINES);

    //  Floor grid
    glColor3f(0.13f, 0.13f, 0.13f);
    for (int i = 0; i < 8; ++i)
    {
        float f = 2.0f * float(i) / 7.0f - 1.0f;
        glVertex3f(-1.0f, 0.0f, f);
        glVertex3f(+1.0f, 0.0f, f);
        glVertex3f(f, 0.0f, -1.0f);
        glVertex3f(f, 0.0f, +1.0f);
    }

    //  Axii
    glColor3f(0.8f, 0.0f, 0.0f);
    glVertex3f(0.0f, 0.0f, 0.0f);
    glVertex3f(1.0f, 0.0f, 0.0f);

    glColor3f(0.0f, 0.8f, 0.0f);
    glVertex3f(0.0f, 0.0f, 0.0f);
    glVertex3f(0.0f, 1.0f, 0.0f);

    glColor3f(0.0f, 0.0f, 0.8f);
    glVertex3f(0.0f, 0.0f, 0.0f);
    glVertex3f(0.0f, 0.0f, 1.0f);

    //  Ray to path
    glColor3f(1.0f, 1.0f, 1.0f);
    glVertex3f(0.0f, 0.0f, 0.0f);
    glVertex3fv(&p.x);

    glEnd();
}


int main()
{
    SDL_GLContext openGL;
    SDL_Window* window;
    bool run = true;


    if (SDL_Init(SDL_INIT_VIDEO) < 0)
        return -1;
    
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
    SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 1);
    SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
    SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
    SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1);
    SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, 8);

    if (!(window = SDL_CreateWindow("slerp", 100, 100, 800, 800, SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN)))
        return -1;

    openGL = SDL_GL_CreateContext(window);

    glViewport(0, 0, 800, 800);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    glOrtho(-2.0f, 2.0f, -2.0f, 2.0f, -2.0f, 2.0f);
    glRotatef(45.0f, 1.0f, 0.0f, 0.0f);
    glRotatef(45.0f, 0.0f, 1.0f, 0.0f);
    
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();

    glEnable(GL_DEPTH_TEST);
    glDepthFunc(GL_LEQUAL);
    glClearDepth(1.0f);
        
    glDisable(GL_CULL_FACE);
    glCullFace(GL_BACK);
    glFrontFace(GL_CCW);
    
    while (run)
    {
        SDL_Event event;


        while (SDL_PollEvent(&event) != 0)
        {
            if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEMOTION)
                ;

            if (event.type == SDL_QUIT)
                run = false;
        }

        Draw();
        SDL_GL_SwapWindow(window);
    }

    SDL_GL_DeleteContext(openGL);
    SDL_DestroyWindow(window);


    return 0;
}

So working from my own SlerpIam() from scratch, I think my sanity is restored and Martin's answer is in essence correct. I get the following functions that I think are correct (Note they don't deal with small angle lerp fallback presently):

Quat SlerpNear(Quat a, Quat b, float t)
{
    float dotAB = Dot(a, b);

    if (dotAB < 0.0f)
    {
        dotAB = -dotAB;
        b = -b;
    }

    float theta = acosf(dotAB);
    float sinTheta = sinf(theta);
    float af = sinf((1.0f - t) * theta) / sinTheta;
    float bf = sinf(t * theta) / sinTheta;


    return a * af + b * bf;
}


Quat SlerpFar(Quat a, Quat b, float t)
{
    float dotAB = Dot(a, b);

    if (dotAB > 0.0f)
    {
        dotAB = -dotAB;
        b = -b;
    }

    float theta = acosf(dotAB);
    float sinTheta = sinf(theta);
    float af = sinf((1.0f - t) * theta) / sinTheta;
    float bf = sinf(t * theta) / sinTheta;


    return a * af + b * bf;
}
iam
  • 1,623
  • 1
  • 14
  • 28
  • FarSlerpMauricio is not quite correct. Try changing the return line to: return q0 * ToQuat(V, t * CompTheta); – Mauricio Cele Lopez Belon Jan 06 '21 at 17:21
  • That's actually now giving me the shortest path for some reason: Quat FarSlerpMauricio(Quat q0, Quat q1, float t) { Quat q01 = Reciprocal(q0) * q1; Quat Vdash{ q01.x, q01.y, q01.z, 0.0f }; Quat V = Vdash * (1.0f / Length(Vdash)); float theta = 2.0f * atan2f(Length(Vdash), q01.w); float CompTheta = theta - 2.0f * M_PI; return q0 * ToQuat({ {V.x, V.y, V.z}, t * CompTheta }); } – iam Jan 06 '21 at 17:35
  • Or if you meant to not use the axis-angle construction function I just tried Quat{V.x, V.y, V.z, t * CompTheta} and it gave something non unit length and weird – iam Jan 06 '21 at 17:37
  • I meant to use ToQuat. So it is interpolating the complement angle, just the sense of rotation seem to be wrong. So try please try: return q0 * ToQuat({ {-V.x, -V.y, -V.z}, t * CompTheta }); }; If that doesn't work then I would give up (my answer would not be correct). – Mauricio Cele Lopez Belon Jan 06 '21 at 17:47
  • Unfortunately that doesn't work, as it gives me a rotation that goes only 90 not 270 degrees - but its heading in the right CCW direction! – iam Jan 06 '21 at 18:01