回転とズーム

クラス AppBase が制御するウィンドウ上でのマウスイベントに応じて、シーンのズームや回転を動的に実現するためのクラス群の定義を行うモジュールを作る。本稿では、そのモジュールの実装について記す。

クラス AppBase 階層側から受け取ったマウスの動きを捕捉して、ズーム量や回転量を半ば無理矢理算出し、返送する。

方針

  • マウスモーションによるビューの変換を実装する。

  • マウスジェスチャーを 2 種類実装するという見方ができるので、一連のクラス階層を実装する。抽象基底クラスを AbstractViewNavigation とし、差し当たりその派生クラスを二つ、ViewRotateViewZoom を定義する。

    • クラス AppBase にそれらのクラスのオブジェクトを保持させて、マウスイベントを delegate させる。

  • モデルの回転に関して、次の点を考慮する:

    • とりあえず左クリックドラッグ時に動的にビューが回転するようにする。

    • マウスジェスチャーで回転を加算的に表現するのに便利な四元数オブジェクトを導入する。

  • カメラのズームに関して、次の点を考慮する:

    • とりあえず右クリックドラッグ時に、動的にカメラのズームを機能させる。

    • 射影変換が透視変換なので、fovy の値を上下によりズーム率の変更とする。

クラス AbstractViewNavigation

    """The abstract base class of for classes that control view navigation."""

    def __init__(self, app, button):
        """Initialize an instance of class AbstractViewNavigation."""

        self.app = app
        self.button = button
        self.width = 0
        self.height = 0
        self.first_mouse_position = None

    def mouse(self, button, state, x, y):
        """The mouse callback function."""

        if button == self.button:
            if state == GLUT.GLUT_DOWN:
                self.capture_mouse(x, y)
            else:
                self.release_mouse()

    def motion(self, x, y):
        """The motion callback function."""

        if self.first_mouse_position:
            self.update_mouse_position(x, y)

    def capture_mouse(self, x, y):
        """Capture mouse drag event."""

        self.width = GLUT.glutGet(GLUT.GLUT_WINDOW_WIDTH)
        self.height = GLUT.glutGet(GLUT.GLUT_WINDOW_HEIGHT)
        self.first_mouse_position = nds_coord(x, y, self.width, self.height)

    def release_mouse(self):
        """Release mouse drag event."""
        self.first_mouse_position = None

    @abstractmethod
    def update_mouse_position(self, x, y):
        """Handle mouse motion event."""
        pass

このクラスはマウスドラッグイベントを表現・記録する機能をサブクラスに提供するためだけにある。

  • メンバーデータ self.app でデモクラスの参照を保持する。

  • メンバーデータ self.width および self.height でドラッグ中のビューポートのサイズを保持する。

  • メンバーデータ self.first_mouse_position に、ドラッグ開始地点のマウス位置を正規化座標系に変換して保持する。後述。

  • 「マウスドラッグ中」イベントを処理するためのメソッド mouse および motion を提供する。後述するサブクラスはメソッド update_mouse_position を必ずオーバーライドし、そこで変換行列を決定する。

Todo

クラス AppBase とのコラボレーションを図示したい。

クラス ViewRotate

回転に関しては四元数を用いた計算をする。感覚的には、回転以外の同次座標系表現によるアフィン変換のコードより一手間多く手順がかかっている気がする。

メソッド capture_mouse

        """Capture mouse."""

        super(ViewRotate, self).capture_mouse(x, y)
        self.last_quat_arg = trackball_space(x, y, self.width, self.height)

マウスドラッグ開始時のマウスポインターのウィンドウ座標的なものを、トラックボール(仮想半球)上の点の座標に読み替える。

メソッド update_mouse_position

        """Handle mouse motion event."""

        # Compute position on hemisphere.
        cur_quat_arg = trackball_space(x, y, self.width, self.height)

        # Compute the change in position the hemisphere.
        diff = cur_quat_arg - self.last_quat_arg
        if (abs(diff) < 1e-2).all():
            return

        last_quat = Quat(np.resize(self.last_quat_arg, 4))
        cur_quat = Quat(np.resize(cur_quat_arg, 4))

        self.last_quat_arg = cur_quat_arg
        self.quat = self.quat * last_quat * cur_quat
        self.app.update_rotation(self.quat)

        GLUT.glutPostRedisplay()

  • マウスの動きが微小なときには、メソッドが False を返してウィンドウの再描画をさせない。

  • 直前のマウス位置、現在のマウス位置にそれぞれ対応する仮想半球上の変位の回転量を計算する。詳しくは後述する。

    • 自作関数 trackball_space の戻り値は三成分の np.array なので、四成分になるよう拡大する。

    • それをそのまま Quat オブジェクト化する。

  • self.quat を更新し、なおかつメソッド self.app.update_rotation に渡す。呼び出し先のメソッドでは、OpenGL のコンテキストバージョンに応じた方式で、現在の変換行列を更新する。

  • ウィンドウの再描画をさせる。

クラス ViewZoom

メソッド update_mouse_position

メソッド update_mouse_position だけを解説する。

        """Handle mouse motion event."""

        cur_pos = nds_coord(x, y, self.width, self.height)
        factor = np.exp((cur_pos[1] - self.first_mouse_position[1]) * -0.25)
        fovy = max(min(self.app.fovy * factor, 125), 25)
        self.app.update_projection(fovy, self.width, self.height)
        GLUT.glutPostRedisplay()

  • マウスがドラッグ開始地点から上方向に動いていればズームイン(拡大)、下方向ならばスームアウト(縮小)というふうに振る舞う。

  • 個人的な好みにより、fovy 値を 25 度から 125 度に制限している。

  • 更新後の fovy をメソッド self.app.update_projection に渡す。呼び出し先のメソッドでは、OpenGL のコンテキストバージョンに応じた方式で、現在の変換行列を更新する。

  • 最後にシーンの再描画をさせる。

補助関数群を定義する

マウスカーソル位置のウィンドウ座標からある種の座標系に変換するための関数群を用意する。マウス位置、およびその変位をウィンドウの形状に依らずに取り扱いたいので、こういうものが要るのだ。

    """Convert SCS to NDS."""
    return (2 * x - width) / width, (height - 2 * y) / height

def trackball_space(x, y, width, height):
    """Project orthographically the mouse cursor to a hemisphere."""

    v = np.zeros(4)
    v[0], v[1] = nds_coord(x, y, width, height)
    d = np.dot(v, v) # length-squared

    if d < 1:
        v[2] = np.cos(d * np.pi / 2)
    else:
        v[2] = 0

    return v / norm(v)

関数 nds_coord

関数 nds_coord はスクリーン座標から正規化座標系 \({[-1, 1]} \times {[-1, 1]}\) への写像を求める。ただし y 座標は上方向を正とする。ウィンドウのサイズが 0 でない任意の大きさであっても、変換後は座標成分の絶対値が 1 以下になる。

関数 trackball_space

関数 trackball_space はスクリーン座標から仮想的な半球上への写像を求める。 OpenGL: A Primer Second Edition 読書ノート 4/4 等を参考。