首页 > 解决方案 > 如何像在 3ds max 中一样实现 alt+MMB 相机旋转?

问题描述

先决条件


让我通过提供一些我们将使用的样板代码来开始这个问题:

mcve_framework.py:

from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *

import glm
from glm import cross, normalize, unProject, vec2, vec3, vec4


# -------- Camera --------
class BaseCamera():

    def __init__(
        self,
        eye=None, target=None, up=None,
        fov=None, near=0.1, far=100000,
        delta_zoom=10
    ):
        self.eye = eye or glm.vec3(0, 0, 1)
        self.target = target or glm.vec3(0, 0, 0)
        self.up = up or glm.vec3(0, 1, 0)
        self.original_up = glm.vec3(self.up)
        self.fov = fov or glm.radians(45)
        self.near = near
        self.far = far
        self.delta_zoom = delta_zoom

    def update(self, aspect):
        self.view = glm.lookAt(
            self.eye, self.target, self.up
        )
        self.projection = glm.perspective(
            self.fov, aspect, self.near, self.far
        )

    def move(self, dx, dy, dz, dt):
        if dt == 0:
            return

        forward = normalize(self.target - self.eye) * dt
        right = normalize(cross(forward, self.up)) * dt
        up = self.up * dt

        offset = right * dx
        self.eye += offset
        self.target += offset

        offset = up * dy
        self.eye += offset
        self.target += offset

        offset = forward * dz
        self.eye += offset
        self.target += offset

    def zoom(self, *args):
        x = args[2]
        y = args[3]
        v = glGetIntegerv(GL_VIEWPORT)
        viewport = vec4(float(v[0]), float(v[1]), float(v[2]), float(v[3]))
        height = viewport.w

        pt_wnd = vec3(x, height - y, 1.0)
        pt_world = unProject(pt_wnd, self.view, self.projection, viewport)
        ray_cursor = glm.normalize(pt_world - self.eye)

        delta = args[1] * self.delta_zoom
        self.eye = self.eye + ray_cursor * delta
        self.target = self.target + ray_cursor * delta

    def load_projection(self):
        width = glutGet(GLUT_WINDOW_WIDTH)
        height = glutGet(GLUT_WINDOW_HEIGHT)

        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()
        gluPerspective(glm.degrees(self.fov), width / height, self.near, self.far)

    def load_modelview(self):
        e = self.eye
        t = self.target
        u = self.up

        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()
        gluLookAt(e.x, e.y, e.z, t.x, t.y, t.z, u.x, u.y, u.z)


class GlutController():

    FPS = 0
    ORBIT = 1
    PAN = 2

    def __init__(self, camera, velocity=100, velocity_wheel=100):
        self.velocity = velocity
        self.velocity_wheel = velocity_wheel
        self.camera = camera

    def glut_mouse(self, button, state, x, y):
        self.mouse_last_pos = vec2(x, y)
        self.mouse_down_pos = vec2(x, y)

        if button == GLUT_LEFT_BUTTON:
            self.mode = self.FPS
        elif button == GLUT_RIGHT_BUTTON:
            self.mode = self.ORBIT
        else:
            self.mode = self.PAN

    def glut_motion(self, x, y):
        pos = vec2(x, y)
        move = self.mouse_last_pos - pos
        self.mouse_last_pos = pos

        if self.mode == self.FPS:
            self.camera.rotate_target(move * 0.005)
        elif self.mode == self.ORBIT:
            self.camera.rotate_around_origin(move * 0.005)

    def glut_mouse_wheel(self, *args):
        self.camera.zoom(*args)

    def process_inputs(self, keys, dt):
        dt *= 10 if keys[' '] else 1
        amount = self.velocity * dt

        if keys['w']:
            self.camera.move(0, 0, 1, amount)
        if keys['s']:
            self.camera.move(0, 0, -1, amount)
        if keys['d']:
            self.camera.move(1, 0, 0, amount)
        if keys['a']:
            self.camera.move(-1, 0, 0, amount)
        if keys['q']:
            self.camera.move(0, -1, 0, amount)
        if keys['e']:
            self.camera.move(0, 1, 0, amount)
        if keys['+']:
            self.camera.fov += radians(amount)
        if keys['-']:
            self.camera.fov -= radians(amount)


# -------- Mcve --------
class BaseWindow:

    def __init__(self, w, h, camera):
        self.width = w
        self.height = h

        glutInit()
        glutSetOption(GLUT_MULTISAMPLE, 16)
        glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH | GLUT_MULTISAMPLE)
        glutInitWindowSize(w, h)
        glutCreateWindow('OpenGL Window')

        self.keys = {chr(i): False for i in range(256)}

        self.startup()

        glutReshapeFunc(self.reshape)
        glutDisplayFunc(self.display)
        glutMouseFunc(self.controller.glut_mouse)
        glutMotionFunc(self.controller.glut_motion)
        glutMouseWheelFunc(self.controller.glut_mouse_wheel)
        glutKeyboardFunc(self.keyboard_func)
        glutKeyboardUpFunc(self.keyboard_up_func)
        glutIdleFunc(self.idle_func)

    def keyboard_func(self, *args):
        try:
            key = args[0].decode("utf8")

            if key == "\x1b":
                glutLeaveMainLoop()

            self.keys[key] = True
        except Exception as e:
            import traceback
            traceback.print_exc()

    def keyboard_up_func(self, *args):
        try:
            key = args[0].decode("utf8")
            self.keys[key] = False
        except Exception as e:
            pass

    def startup(self):
        raise NotImplementedError

    def display(self):
        raise NotImplementedError

    def run(self):
        glutMainLoop()

    def idle_func(self):
        glutPostRedisplay()

    def reshape(self, w, h):
        glViewport(0, 0, w, h)
        self.width = w
        self.height = h

如果你想使用上面的代码,你只需要安装 pyopengl 和 pygml。之后,您可以创建自己的BaseWindow子类,覆盖startup并且render您应该有一个非常基本的 glut 窗口,具有简单的功能,例如相机旋转/缩放以及一些渲染点/三角形/四边形和 indexed_triangles/indexed_quads 的方法。

做了什么


mcve_camera_arcball.py

import time

from OpenGL.GL import *
from OpenGL.GLU import *
from OpenGL.GLUT import *

import glm
from mcve_framework import BaseCamera, BaseWindow, GlutController


def line(p0, p1, color=None):
    c = color or glm.vec3(1, 1, 1)
    glColor3f(c.x, c.y, c.z)
    glVertex3f(p0.x, p0.y, p0.z)
    glVertex3f(p1.x, p1.y, p1.z)


def grid(segment_count=10, spacing=1, yup=True):
    size = segment_count * spacing
    right = glm.vec3(1, 0, 0)
    forward = glm.vec3(0, 0, 1) if yup else glm.vec3(0, 1, 0)
    x_axis = right * size
    z_axis = forward * size

    i = -segment_count

    glBegin(GL_LINES)
    while i <= segment_count:
        p0 = -x_axis + forward * i * spacing
        p1 = x_axis + forward * i * spacing
        line(p0, p1)
        p0 = -z_axis + right * i * spacing
        p1 = z_axis + right * i * spacing
        line(p0, p1)
        i += 1
    glEnd()


def axis(size=1.0, yup=True):
    right = glm.vec3(1, 0, 0)
    forward = glm.vec3(0, 0, 1) if yup else glm.vec3(0, 1, 0)
    x_axis = right * size
    z_axis = forward * size
    y_axis = glm.cross(forward, right) * size
    glBegin(GL_LINES)
    line(x_axis, glm.vec3(0, 0, 0), glm.vec3(1, 0, 0))
    line(y_axis, glm.vec3(0, 0, 0), glm.vec3(0, 1, 0))
    line(z_axis, glm.vec3(0, 0, 0), glm.vec3(0, 0, 1))
    glEnd()


class Camera(BaseCamera):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def rotate_target(self, delta):
        right = glm.normalize(glm.cross(self.target - self.eye, self.up))
        M = glm.mat4(1)
        M = glm.translate(M, self.eye)
        M = glm.rotate(M, delta.y, right)
        M = glm.rotate(M, delta.x, self.up)
        M = glm.translate(M, -self.eye)
        self.target = glm.vec3(M * glm.vec4(self.target, 1.0))

    def rotate_around_target(self, target, delta):
        right = glm.normalize(glm.cross(self.target - self.eye, self.up))
        amount = (right * delta.y + self.up * delta.x)
        M = glm.mat4(1)
        M = glm.rotate(M, amount.z, glm.vec3(0, 0, 1))
        M = glm.rotate(M, amount.y, glm.vec3(0, 1, 0))
        M = glm.rotate(M, amount.x, glm.vec3(1, 0, 0))
        self.eye = glm.vec3(M * glm.vec4(self.eye, 1.0))
        self.target = target
        self.up = self.original_up

    def rotate_around_origin(self, delta):
        return self.rotate_around_target(glm.vec3(0), delta)


class McveCamera(BaseWindow):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def startup(self):
        glEnable(GL_DEPTH_TEST)

        self.start_time = time.time()
        self.camera = Camera(
            eye=glm.vec3(200, 200, 200),
            target=glm.vec3(0, 0, 0),
            up=glm.vec3(0, 1, 0),
            delta_zoom=30
        )
        self.model = glm.mat4(1)
        self.controller = GlutController(self.camera)
        glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)

    def display(self):
        self.controller.process_inputs(self.keys, 0.005)
        self.camera.update(self.width / self.height)

        glClearColor(0.2, 0.3, 0.3, 1.0)
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)

        self.camera.load_projection()
        self.camera.load_modelview()

        glLineWidth(5)
        axis(size=70, yup=True)
        glLineWidth(1)
        grid(segment_count=7, spacing=10, yup=True)

        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()
        glOrtho(-1, 1, -1, 1, -1, 1)
        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()

        glutSwapBuffers()


if __name__ == '__main__':
    window = McveCamera(800, 600, Camera())
    window.run()

去做


这里的最终目标是弄清楚如何在按下 Alt+MMB 时模拟 3dsmax 使用的旋转。

现在使用当前代码,您可以使用 WASDQE 键四处移动(shift 加速),左/右键围绕它/场景的中心旋转相机或使用鼠标滚轮进行缩放。如您所见,偏移值是硬编码的,只需将它们调整为在您的盒子中平稳运行(我知道有适当的方法可以使相机动力学矢量独立于 cpu,这不是我的问题的重点)

参考

让我们尝试更多地剖析在 3dsmax2018 上按 alt+MMB 时相机的行为。

  1. 在“家”旋转(当您按下右上角 Gizmo 上的主页按钮时会发生家中的相机,这会将相机位置放置在固定位置并瞄准 (0,0,0)):

展示

  1. 平移和旋转:

展示

  1. 缩放/平移和旋转:

展示

  1. 用户界面

展示

问题:所以接下来将添加必要的位以在按下 alt+MMB 时实现轨迹球旋转...我说轨迹球旋转因为我假设 3ds max 在幕后使用该方法,但我不确定这是 max 使用的方法所以首先我想知道按 alt+MMB 时 3ds max 使用的确切数学是什么,然后只需将必要的代码添加到Camera中即可完成该任务

标签: pythonopenglcamerarotation3dsmax

解决方案


您必须将围绕 x 和 y 轴的旋转矩阵应用于视图矩阵。首先应用绕 y 轴(向上向量)的旋转矩阵,然后应用当前视图矩阵,最后应用 x 轴上的旋转:

view-matrix = rotate-X * view-matrix * rotate-Y

pivotWorld旋转的工作方式与“3ds max”中的完全一样,除了必须定义 旋转原点(枢轴 - )的正确位置。

一个合理的解决方案是,枢轴是相机目标 ( self.target)。
一开始,目标是 (0, 0, 0),它是世界的起源。只要视图的目标是世界的中心,围绕世界的原点旋转就是预期的行为。如果视图是平移的,那么目标仍然在视口的中心,因为它的移动方式与视点相同 -并且“self.eye平行self.target”移动。这会导致场景看起来仍然围绕视图中心的一个点(新目标)旋转,并且看起来与“3ds max”中的行为完全相同。

def rotate_around_target(self, target, delta):

    # get the view matrix
    view = glm.lookAt(self.eye, self.target, self.up)

    # pivot in world sapace and view space
    #pivotWorld = glm.vec3(0, 0, 0)
    pivotWorld = self.target

    pivotView = glm.vec3(view * glm.vec4(*pivotWorld, 1))  

    # rotation around the vies pace x axis
    rotViewX    = glm.rotate( glm.mat4(1), -delta.y, glm.vec3(1, 0, 0) )
    rotPivotViewX   = glm.translate(glm.mat4(1), pivotView) * rotViewX * glm.translate(glm.mat4(1), -pivotView)  

    # rotation around the world space up vector
    rotWorldUp  = glm.rotate( glm.mat4(1), -delta.x, glm.vec3(0, 1, 0) )
    rotPivotWorldUp = glm.translate(glm.mat4(1), pivotWorld) * rotWorldUp * glm.translate(glm.mat4(1), -pivotWorld)

    # update view matrix
    view = rotPivotViewX * view * rotPivotWorldUp

    # decode eye, target and up from view matrix
    C = glm.inverse(view)
    targetDist  = glm.length(self.target - self.eye)
    self.eye    = glm.vec3(C[3])
    self.target = self.eye - glm.vec3(C[2]) * targetDist 
    self.up     = glm.vec3(C[1])

但是仍然存在一个问题。如果场景被放大会怎样?
在当前实现中,从相机到目标点的距离保持不变。在缩放的情况下,这可能是不正确的,从视点 ( self.eye) 到目标 ( self.target) 的方向必须保持不变,但可能必须根据缩放改变到目标的距离。我建议对类
的方法进行以下更改:zoomBaseCamera

class BaseCamera():

    def zoom(self, *args):
        x = args[2]
        y = args[3]
        v = glGetIntegerv(GL_VIEWPORT)
        viewport = vec4(float(v[0]), float(v[1]), float(v[2]), float(v[3]))
        height = viewport.w

        pt_wnd = vec3(x, height - y, 1.0)
        pt_world = unProject(pt_wnd, self.view, self.projection, viewport)
        ray_cursor = glm.normalize(pt_world - self.eye)

        # calculate the "zoom" vector
        delta       = args[1] * self.delta_zoom
        zoom_vec    = ray_cursor * delta

        # get the direction of sight and the distance to the target 
        sight_vec   = self.target - self.eye
        target_dist = glm.length(sight_vec)
        sight_vec   = sight_vec / target_dist

        # modify the distance to the target
        delta_dist = glm.dot(sight_vec, zoom_vec)
        if (target_dist - delta_dist) > 0.01: # the direction has to kept in any case
            target_dist -= delta_dist

        # update the eye postion and the target
        self.eye    = self.eye + zoom_vec
        self.target = self.eye + sight_vec * target_dist


推荐阅读