0

I want to make something like a simple 3D viewer (maybe editor). So one of my goals is to learn how to rotate 3D objects for example using mouse.

I took "3D Rotating Monkey Head" example and changed some code in the main.py file.

I used functions of converting the Euler angles into quaternions and back - so I achieved the closest result.

So the app works almost as it should (demo gif on imgur)

But there is one annoying problem - an unwanted rotation along the z axis (tilt?). You can see this here (demo gif on imgur)

it is obvious that this should not be so.

Is there a way to get rid of this tilt?

gl and quaternions are new topics for me. Maybe I did something wrong.

My code is here (only main.py)

from kivy.app import App
from kivy.clock import Clock
from kivy.core.window import Window
from kivy.uix.widget import Widget
from kivy.resources import resource_find
from kivy.graphics.transformation import Matrix
from kivy.graphics.opengl import *
from kivy.graphics import *
from objloader import ObjFile



#============== quat =========================================================================

import numpy as np
from math import atan2, asin, pi, cos, sin, radians, degrees


def q2e(qua):
    L = (qua[0]**2 + qua[1]**2 + qua[2]**2 + qua[3]**2)**0.5
    w = qua[0] / L
    x = qua[1] / L
    y = qua[2] / L
    z = qua[3] / L
    Roll = atan2(2 * (w * x + y * z), 1 - 2 * (x**2 + y**2))
    if Roll < 0:
        Roll += 2 * pi


    temp = w * y - z * x
    if temp >= 0.5:
        temp = 0.5
    elif temp <= -0.5:
        temp = -0.5

    Pitch = asin(2 * temp)
    Yaw = atan2(2 * (w * z + x * y), 1 - 2 * (y**2 + z**2))
    if Yaw < 0:
        Yaw += 2 * pi
    return [Yaw,Pitch,Roll]



def e2q(ypr):
    y,p,r = ypr
    roll = r / 2
    pitch = p / 2
    yaw = y / 2

    w = cos(roll) * cos(pitch) * cos(yaw) + \
        sin(roll) * sin(pitch) * sin(yaw)
    x = sin(roll) * cos(pitch) * cos(yaw) - \
        cos(roll) * sin(pitch) * sin(yaw)
    y = cos(roll) * sin(pitch) * cos(yaw) + \
        sin(roll) * cos(pitch) * sin(yaw)
    z = cos(roll) * cos(pitch) * sin(yaw) + \
        sin(roll) * sin(pitch) * cos(yaw)
    qua = [w, x, y, z]
    return qua




def mult(q1, q2):

    w1, x1, y1, z1 = q1
    w2, x2, y2, z2 = q2
    w = w1*w2 - x1*x2 - y1*y2 - z1*z2
    x = w1*x2 + x1*w2 + y1*z2 - z1*y2
    y = w1*y2 + y1*w2 + z1*x2 - x1*z2
    z = w1*z2 + z1*w2 + x1*y2 - y1*x2
    return np.array([w, x, y, z])


def list2deg(l):
    return [degrees(i) for i in l]

#=====================================================================================================



class Renderer(Widget):
    def __init__(self, **kwargs):

        self.last = (0,0)

        self.canvas = RenderContext(compute_normal_mat=True)
        self.canvas.shader.source = resource_find('simple.glsl')
        self.scene = ObjFile(resource_find("monkey.obj"))
        super(Renderer, self).__init__(**kwargs)
        with self.canvas:
            self.cb = Callback(self.setup_gl_context)
            PushMatrix()
            self.setup_scene()
            PopMatrix()
            self.cb = Callback(self.reset_gl_context)
        Clock.schedule_interval(self.update_glsl, 1 / 60.)

    def setup_gl_context(self, *args):
        glEnable(GL_DEPTH_TEST)

    def reset_gl_context(self, *args):
        glDisable(GL_DEPTH_TEST)


    def on_touch_down(self, touch):
        super(Renderer, self).on_touch_down(touch)
        self.on_touch_move(touch)


    def on_touch_move(self, touch):

        new_quat = e2q([0.01*touch.dx,0.01*touch.dy,0])

        self.quat = mult(self.quat, new_quat)

        euler_radians = q2e(self.quat)

        self.roll.angle, self.pitch.angle, self.yaw.angle = list2deg(euler_radians)

        print self.roll.angle, self.pitch.angle, self.yaw.angle



    def update_glsl(self, delta):
        asp = self.width / float(self.height)
        proj = Matrix().view_clip(-asp, asp, -1, 1, 1, 100, 1)
        self.canvas['projection_mat'] = proj
        self.canvas['diffuse_light'] = (1.0, 1.0, 0.8)
        self.canvas['ambient_light'] = (0.1, 0.1, 0.1)


    def setup_scene(self):
        Color(1, 1, 1, 1)
        PushMatrix()
        Translate(0, 0, -3)

        self.yaw = Rotate(0, 0, 0, 1)
        self.pitch = Rotate(0, -1, 0, 0)
        self.roll = Rotate(0, 0, 1, 0)


        self.quat = e2q([0,0,0])

        m = list(self.scene.objects.values())[0]
        UpdateNormalMatrix()
        self.mesh = Mesh(
            vertices=m.vertices,
            indices=m.indices,
            fmt=m.vertex_format,
            mode='triangles',
        )
        PopMatrix()


class RendererApp(App):
    def build(self):
        return Renderer()

if __name__ == "__main__":
    RendererApp().run()
me2 beats
  • 774
  • 1
  • 8
  • 24
  • 1
    Instead of computing a quaternion and converting it *back* to Euler angles, you should realize that `dx, dy` already correspond to changes in the Euler angles; specifically pitch and yaw respectively, but *not* roll - which is the cause of this unwanted rotation. – meowgoesthedog Sep 05 '18 at 10:00
  • Thanks for the answer. Did you watch the first GIF? I show there how the rotation should work. This can't be realized using Euler angles only because I need to change pitch, yaw and roll when rotating an object – me2 beats Sep 05 '18 at 13:08
  • 1
    I see what you mean now - but then the "unwanted" rotation is expected because at each stage you are *accumulating* the rotation, and unless you can move your cursor perfectly horizontally, it will always have a certain amount of error. – meowgoesthedog Sep 05 '18 at 13:16
  • There is an app for Android — Emb3D. This app hasn't this unwanted rotation. – me2 beats Sep 05 '18 at 16:06
  • It probably doesn't accumulate the rotation in the way you do. It could be accumulating only the `dx, dy` values for each touch "session" (time interval between the finger touching down on and lifting off from the screen), and computing the corresponding quaternion from the *net* displacement; only after the "session" ends does it append this quaternion permanently to the object's overall rotation. This way the rotational basis would not vary during the "session", so you would not see the unwanted roll effect. (Apologies for my amateurish terminology) – meowgoesthedog Sep 05 '18 at 16:11
  • And yes, it seems I accumulate errors each time when I move diagonally. If so, then maybe I can calculate this error and compensate it or something like this – me2 beats Sep 05 '18 at 16:18
  • 1
    Actually the major issue here is not any kind of "error" - it is because if you incrementally update the quaternion, its rotation basis (the effective axes around which the roll, yaw, pitch are applied) changes relative to the object, so changes in pitch and yaw can propagate to those in roll etc. I'll post an answer later because I fear my description isn't very clear. – meowgoesthedog Sep 05 '18 at 16:21

1 Answers1

2

Solution

  • In on_touch_down:
    1. Initialize two accumulator variables Dx, Dy = 0, 0
    2. Store the object's current quaternion
  • In on_touch_move:
    1. Increment Dx, Dy using touch.dx, touch.dy
    2. Compute the quaternion from Dx, Dy, not the touch deltas
    3. Set the object's rotation to this quaternion x the stored quaternion

Code:

# only changes are shown here
class Renderer(Widget):
    def __init__(self, **kwargs):
        # as before ...

        self.store_quat = None
        self.Dx = 0
        self.Dy = 0

    def on_touch_down(self, touch):
        super(Renderer, self).on_touch_down(touch)
        self.Dx, self.Dy = 0, 0
        self.store_quat = self.quat

    def on_touch_move(self, touch):
        self.Dx += touch.dx
        self.Dy += touch.dy

        new_quat = e2q([0.01 * self.Dx, 0.01 * self.Dy, 0])
        self.quat = mult(self.store_quat, new_quat)

        euler_radians = q2e(self.quat)
        self.roll.angle, self.pitch.angle, self.yaw.angle = list2deg(euler_radians)

Explanation

The above change might seem unnecessary and counter-intuitive. But first look at it mathematically.

Consider N update calls to on_touch_move, each with delta dx_i, dy_i. Call pitch matrices Rx(angle) and yaw matrices Ry(angle). The final net rotation change is given by:

  • Your method:

    [Ry(dy_N) * Rx(dx_N)] * ... * [Ry(dy_2) * Rx(dx_2)] * [Ry(dy_1) * Rx(dx_1)]
    
  • New method:

    [Ry(dy_N + ... + dy_2 + dy_1)] * [Rx(dx_N + ... + dx_2 + dx_1)]
    

Rotation matrices are non-commutative in general, so these expressions are different. Which one is correct?

Consider this simple example. Say you move your finger in a perfect square on the screen, returning to the starting point:

enter image description here

Each rotation is either horizontal or vertical, and (assumed to be) by 45 degrees. The touchscreen sampling rate is lowered such that each straight line represents one delta sample. One would expect the cube to look the same afterwards as before, right? So what really happens?

enter image description here

Oh dear.

On the contrary, it is obvious that the new code gives the correct result, since the accumulated Dx, Dy are zero. There may be a way to prove this more generally, but I think the above example suffices to illustrate the issue.

(This was for "clean" inputs too. Imagine a real stream of inputs - human hands are not great at drawing perfectly straight lines without some form of assistance, so the end result would be even more unpredictable.)

meowgoesthedog
  • 14,670
  • 4
  • 27
  • 40
  • thank you! it turned out so easy, and I was looking for a bug in quaternions for 2 days already. the only thing - let me edit your answer in one line. where multiplication, I changed its order, because I need this kind of rotation. – me2 beats Sep 05 '18 at 22:58
  • 1
    @me2beats ah ok I saw that your suggestion was rejected so I edited it in myself – meowgoesthedog Sep 06 '18 at 07:01