OpenGL Google maps style 2D camera / zoom to mouse cursor

c++, camera, geometry, opengl, zooming

Solution

- Get the world-space coordinates of the mouse cursor using the current zoom factor and model/proj/view matrices.

- Adjust zoom factor

- Get the world-space mouse coordinates again using the new zoom factor

- Shift the camera position by the difference in world-space mouse coordinates

- Redraw scene using new camera position and zoom factor

Something like this (in the `wheel()` callback):

#include <GL/freeglut.h>

#include <iostream>
using namespace std;

#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>


glm::dvec3 Unproject( const glm::dvec3& win )
{
    glm::ivec4 view;
    glm::dmat4 proj, model;
    glGetDoublev( GL_MODELVIEW_MATRIX, &model[0][0] );
    glGetDoublev( GL_PROJECTION_MATRIX, &proj[0][0] );
    glGetIntegerv( GL_VIEWPORT, &view[0] );

    glm::dvec3 world = glm::unProject( win, model, proj, view );
    return world;
}

// unprojects the given window point
// and finds the ray intersection with the Z=0 plane
glm::dvec2 PlaneUnproject( const glm::dvec2& win )
{
    glm::dvec3 world1 = Unproject( glm::dvec3( win, 0.01 ) );
    glm::dvec3 world2 = Unproject( glm::dvec3( win, 0.99 ) );

    // u is a value such that:
    // 0 = world1.z + u * ( world2.z - world1.z )
    double u = -world1.z / ( world2.z - world1.z );
    // clamp u to reasonable values
    if( u < 0 ) u = 0;
    if( u > 1 ) u = 1;

    return glm::dvec2( world1 + u * ( world2 - world1 ) );
}

// pixels per unit
const double ppu = 1.0;

glm::dvec2 center( 0 );
double scale = 1.0;
void ApplyCamera()
{
    glMatrixMode( GL_PROJECTION );
    glLoadIdentity();
    const double w = glutGet( GLUT_WINDOW_WIDTH ) / ppu;
    const double h = glutGet( GLUT_WINDOW_HEIGHT ) / ppu;
    glOrtho( -w/2, w/2, -h/2, h/2, -1, 1 );

    glMatrixMode( GL_MODELVIEW );
    glLoadIdentity();
    glScaled( scale, scale, 1.0 );
    glTranslated( -center[0], -center[1], 0 );
}

glm::dvec2 mPos;

glm::dvec2 centerStart( 0 );
int btn = -1;

void mouse( int button, int state, int x, int y )
{
    ApplyCamera();

    y = glutGet( GLUT_WINDOW_HEIGHT ) - y;
    mPos = glm::ivec2( x, y );

    btn = button;
    if( GLUT_LEFT_BUTTON == btn && GLUT_DOWN == state )
    {
        centerStart = PlaneUnproject( glm::dvec2( x, y ) );
    }
    if( GLUT_LEFT_BUTTON == btn && GLUT_UP == state )
    {
        btn = -1;
    }

    glutPostRedisplay();
}

void motion( int x, int y )
{
    y = glutGet( GLUT_WINDOW_HEIGHT ) - y;
    mPos = glm::ivec2( x, y );

    if( GLUT_LEFT_BUTTON == btn )
    {
        ApplyCamera();
        glm::dvec2 cur = PlaneUnproject( glm::dvec2( x, y ) );
        center += ( centerStart - cur );
    }

    glutPostRedisplay();
}

void passiveMotion( int x, int y )
{
    y = glutGet( GLUT_WINDOW_HEIGHT ) - y;
    mPos = glm::ivec2( x, y );
    glutPostRedisplay();
}

void wheel( int wheel, int direction, int x, int y )
{
    y = glutGet( GLUT_WINDOW_HEIGHT ) - y;
    mPos = glm::ivec2( x, y );

    ApplyCamera();
    glm::dvec2 beforeZoom = PlaneUnproject( glm::dvec2( x, y ) );

    const double scaleFactor = 0.90;
    if( direction == -1 )   scale *= scaleFactor;
    if( direction ==  1 )   scale /= scaleFactor;

    ApplyCamera();
    glm::dvec2 afterZoom = PlaneUnproject( glm::dvec2( x, y ) );

    center += ( beforeZoom - afterZoom );

    glutPostRedisplay();
}

void display()
{
    glClearColor( 0, 0, 0, 1 );
    glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );

    ApplyCamera();

    glm::dvec2 cur = PlaneUnproject( mPos );
    cout << cur.x << " " << cur.y << " " << scale << endl;

    glPushMatrix();
    glScalef( 50, 50, 1 );
    glBegin( GL_QUADS );
    glColor3ub( 255, 255, 255 );
    glVertex2i( -1, -1 );
    glVertex2i(  1, -1 );
    glVertex2i(  1,  1 );
    glVertex2i( -1,  1 );
    glEnd();
    glPopMatrix();

    glutSwapBuffers();
}

int main( int argc, char **argv )
{
    glutInit( &argc, argv );
    glutInitDisplayMode( GLUT_RGBA | GLUT_DEPTH | GLUT_DOUBLE );
    glutInitWindowSize( 600, 600 );
    glutCreateWindow( "GLUT" );

    glutMouseFunc( mouse );
    glutMotionFunc( motion );
    glutMouseWheelFunc( wheel );
    glutDisplayFunc( display );
    glutPassiveMotionFunc( passiveMotion );

    glutMainLoop();
    return 0;
}

Problem

I am trying to implement a 2D camera in OpenGL that behaves like the Google maps camera. Specifically the "zoom to mouse point" functionality. So far I have been able to implement pan and zoom OK - but only if the zoom is locked to the center of the window/widget. If I try to zoom on the mouse location the view seems to "jump" and after the zoom level increases the item I zoomed in on is no longer under the mouse cursor. My camera class is below - quite a lot of code but I couldn't make it any smaller sorry! I call `Apply()` on the start of each frame, and I call `SetX/YPos` when the scene is panned, finally I call `SetScale` with the previous scale `+/- 0.1f` with the mouse position when the mouse wheel is scrolled. camera.h ``` class Camera { public: Camera(); void Apply(); void SetXPos(float xpos); void SetYPos(float ypos); void SetScale(float scaleFactor, float mx, float my); float XPos() const { return m_XPos; } float YPos() const { return m_YPos; } float Scale() const { return m_ScaleFactor; } void SetWindowSize(int w, int h); void DrawTestItems(); private: void init_matrix(); float m_XPos; float m_YPos; float m_ScaleFactor; float m_Width; float m_Height; float m_ZoomX; float m_ZoomY; }; ``` camera.cpp ``` Camera::Camera() : m_XPos(0.0f), m_YPos(0.0f), m_ScaleFactor(1.0f), m_ZoomX(0.0f), m_ZoomY(0.0f), m_Width(0.0f), m_Height(0.0f) { } // Called when window is created and when window is resized void Camera::SetWindowSize(int w, int h) { m_Width = (float)w; m_Height = (float)h; } void Camera::init_matrix() { glViewport(0, 0, m_Width, m_Height); glMatrixMode(GL_PROJECTION); glLoadIdentity(); float new_W = m_Width * m_ScaleFactor; float new_H = m_Height * m_ScaleFactor; // Point to zoom on float new_x = m_ZoomX; float new_y = m_ZoomY; glOrtho( -new_W/2+new_x, new_W/2+new_x, new_H/2+new_y, -new_H/2+new_y, -1,1); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); } void Camera::Apply() { // Zoom init_matrix(); // Pan glTranslatef( m_XPos, m_YPos, 1.0f ); DrawTestItems(); } void Camera::SetXPos(float xpos) { m_XPos = xpos; } void Camera::SetYPos(float ypos) { m_YPos = ypos; } // mx,my = window coords of mouse pos when wheel was scrolled // scale factor goes up or down by 0.1f void Camera::SetScale(float scaleFactor, float mx, float my) { m_ZoomX = (float)mx; m_ZoomY = (float)my; m_ScaleFactor = scaleFactor; } void Camera::DrawTestItems() { } ``` Update: I seem to have noticed 2 issues: - The mouse position in SetScale is incorrect - I don't know why. - No matter what I try glOrtho causes the centre of the screen to be the zoom point,I confirmed this setting the zoom point manually/hard coding it. In Google maps the screen won't "stick" to the centre like this. Update again: I'm also using Qt if this makes any difference, I just have a basic QGLWidget and I am using the mouse wheel event to perform the zoom. I take the delta of the wheel event and then either add or subtract 0.1f to the scale passing in the mouse position from the wheel event.

Original source