OpenGL Compound Zoom and Pan Effect

Hello. I am writing a 2D simulator for an machine learning algorithm and I’m trying to achieve panning and zooming. I know there are already many questions on this, but this is a bit more specific. I need to be able to zoom and pan like in Google Maps and Openstreetmap. I am also having trouble converting from window coordinates to world coordinates and vice versa.

Right now I am using glScalef and glTranslatef on the model view matrix to pan and zoom rather than changing the projection matrix because some elements in the simulator are GUI elements. Zooming/panning should not be applied to them. Panning works fine, but zooming only works on the first zoom point because after zooming, I cannot convert the window coordinates of the mouse to world coordinates properly. I noticed that even if the coordinates were correct, zooming in this way causes the whole world to jump to the new zoom point. Although I am trying to get it to zoom to one point, then the next.

My guess to apply this kind of zooming is keeping a list of zoom points and performing them in order, but this seems wasteful. I have also seen an answer on a Q&A site where code provided did not keep a list. Maybe there is a better way with a single glTranslatef and glScalef call?

Before I added zooming, I converted from window to world coordinates like this: window.x -= camPos.x, where camPos is used like this: glTranslatef(-camPos.x, -camPos.y, 0.0f). This was fine for just panning. For zooming (which is not working) I calculate the distance to the zoom point, then multiply by the zoom factor. Then I undo the panning effect like in the first example. However this seems to have no effect, and the coordinates are converted as if there was no zoom.

In case it is unclear, the camera transformations using glTranslatef and glScalef are used at the beginning of the render function before the first glPushMatrix call. GUI elements simply discard camera transformations by using glLoadIdentity. Before drawing every element, glPushMatrix is called, and of course popped after drawing with glPopMatrix.

One way to do it is:


static float pan_x, pan_y;
static float zoom;
void pan(float dx, float dy) {
    pan_x += dx;
    pan_y += dy;
}
void zoom(float factor) {
    zoom *= factor;
    pan_x *= factor;
    pan_y *= factor;
}
void viewport(int w, int h) {
    float aspect = 1.0f * h / w;
    glViewport(0, 0, w, h);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    gluOrtho2D(-w/2, w/2, -h/2, h/2);
}
void setup(void) {
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glTranslatef(pan_x, pan_y, 0.0f);
    glScalef(zoom, zoom, 1.0f);
}

With the viewport set this way, zooming will be about the centre of the window, and panning will pan by dx,dy pixels regardless of zoom factor. Object coordinates will be initially in pixels with the origin at the centre of the window, but this can be changed using glScale/glTranslate after the setup() (which should be called at the start of each redraw operation).

If you want to change the projection matrix so that the eye-space origin is in one corner, things get a bit more complex (without additional changes, zooming will be centred on that corner, which probably isn’t what you want).

In the above code, pan_x/pan_y are maintained in screen coordinates, so they have to be modified whenever you zoom (you want zooming to be centred on some point in the window, not on the world origin).

An alternative version maintains them in world space:


void pan(float dx, float dy) {
    pan_x += dx / zoom;
    pan_y += dy / zoom;
}
void zoom(float factor) {
    zoom *= factor;
}
void setup(void) {
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glScalef(zoom, zoom, 1.0f);
    glTranslatef(pan_x, pan_y, 0.0f);
}

Note that the order of glScale and glTranslate is swapped, and that rather than zooming affecting the pan offset, changes to the pan offset are affected by the current zoom factor.

But what about converting from window coordinates to world coordinates when there is no panning effect but when there is a zoom effect? And what about changing the zoom point when there is a zoom effect and the mouse moves from the original zoom point? Thanks for responding.

In the first case (screen-space pan coordinates), conversions are:


screen_x = world_x*zoom + pan_x + (w/2);
screen_y = world_y*zoom + pan_y + (h/2);

world_x = (screen_x - pan_x - (w/2)) / zoom;
world_y = (screen_y - pan_y - (h/2)) / zoom;

For the second case (world-space pan coordinates):


screen_x = (world_x + pan_x)*zoom + (w/2);
screen_y = (world_y + pan_y)*zoom + (h/2);

world_x = (screen_x - (w/2))/zoom - pan_x;
world_y = (screen_y - (h/2))/zoom - pan_y;

Where w and h are the window width and height passed to viewport().

There are just the expansions of (glViewport * gluOrtho2d * glTranslate * glScale) in the first case and (glViewport * gluOrtho2d * glScale * glTranslate) in the second case.

I don’t understand what you mean.

I’m using the first method you described for pan/zoom and converting the coordinates. Converting from window to world causes consecutive zooms to move away from the zoom point. Since the zoom point is can change, I don’t think dividing by the zoom is sufficient for converting. I think somehow the zoom point needs to be incorporated into converting the coordinates.

For the second part, I simply mean the Google Maps style zooming. You can zoom into one location, then zoom into another without “jumping” from one point to the next. It is as if two different scale operations are performed. However I can’t think of an efficient way to solve this.

If you want to zoom about an arbitrary point in screen coordinates, then use


void zoom_at(float cx, float cy, float factor) {
    pan(-cx,-cy);
    zoom(factor);
    pan( cx, cy);
}

In this case, it’s probably simpler to put the eye-space origin at the corner of the screen rather than the centre, e.g.


    gluOrtho2D(0, w, 0, h);

to place it at the lower-left corner (mouse coordinates will probably be relative to the upper-right, so you’ll need to invert the Y coordinate: y = h - 1 - y).
This also makes conversion between screen and world coordinates simpler:


screen_x = world_x * zoom + pan_x;
screen_y = world_y * zoom + pan_y;

world_x = (screen_x - pan_x) / zoom;
world_y = (screen_y - pan_y) / zoom;

[QUOTE=GClements;1262080]If you want to zoom about an arbitrary point in screen coordinates, then use
In this case, it’s probably simpler to put the eye-space origin at the corner of the screen rather than the centre, e.g.


    gluOrtho2D(0, w, 0, h);

This also makes conversion between screen and world coordinates simpler:


screen_x = world_x * zoom + pan_x;
screen_y = world_y * zoom + pan_y;

world_x = (screen_x - pan_x) / zoom;
world_y = (screen_y - pan_y) / zoom;

[/QUOTE]

This is actually what I am doing, I did not explicitly mention it because the concepts were similar. I just figured out that the zooms moving away from the original zoom point was due to using world rather then window coordinates (my fault). However, when moving the mouse to a new point to select a new zoom point, the coordinates still do not convert correctly.

Something I don’t understand is that trying to convert the zoom point using the world conversion does not seem to work. If we zoom into point (x, y) given (pan_x = and pan_y = 0), the world and window coordinates should not change. However using the world conversion will not yield the same value when zoom != 1.

void zoom_at(float cx, float cy, float factor) {
pan(-cx,-cy);
zoom(factor);
pan( cx, cy);
}

Does this eliminate the need for wraping the glScalef call with glTranslatef(zoomPoint.x, zoomPoint.y, 0) and glTranslatef(-zoomPoint.x, -zoomPoint.y, 0). Or should both be used?

It appears that one of us is misunderstanding the other. To try to clear things up, here is a complete program. Is this the behaviour you are after? If not, what are you after?

Usage: arrow keys to pan, left-click to zoom in, right-click to zoom out.


#include <stdio.h>
#include <stdlib.h>

#include <GL/gl.h>
#include <GL/glu.h>
#include <GL/glut.h>

double step = 10;
double scale = 1.2;

double win_w, win_h;

double pan_x = 0.0;
double pan_y = 0.0;
double zoom_k = 200.0;

void error(const char *msg)
{
    fprintf(stderr, "%s
", msg);
    exit(1);
}

void load_tex(const char *filename)
{
    FILE *fp;
    int magic, w, h, maxval;
    int bytes;
    void *data;
    GLuint tex;

    fp = fopen(filename, "rb");
    if (!fp)
	error("unable to open image file");
    if (fscanf(fp, "P%d
", &magic) != 1 || magic != 6)
	error("invalid header");
    if (fscanf(fp, "%d %d
", &w, &h) != 2 || w < 1 || h < 1)
	error("invalid header");
    if (fscanf(fp, "%d
", &maxval) != 1 || maxval != 255)
	error("invalid header");

    bytes = w * h * 3;
    data = malloc(bytes);
    if (fread(data, 1, bytes, fp) != bytes)
	error("invalid header");

    fclose(fp);

    glGenTextures(1, &tex);
    glBindTexture(GL_TEXTURE_2D, tex);
    gluBuild2DMipmaps(GL_TEXTURE_2D, GL_RGB, w, h, GL_RGB, GL_UNSIGNED_BYTE, data);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    free(data);
}

void reset(void)
{
    pan_x = 0.0;
    pan_y = 0.0;
    zoom_k = 200.0;
}

void draw_object(void)
{
    glBegin(GL_QUADS);
    glTexCoord2f(0.0, 0.0);
    glVertex2f(0.0, 0.0);
    glTexCoord2f(1.0, 0.0);
    glVertex2f(1.0, 0.0);
    glTexCoord2f(1.0, 1.0);
    glVertex2f(1.0, 1.0);
    glTexCoord2f(.0, 1.0);
    glVertex2f(.0, 1.0);
    glEnd();
}

void zoom(double factor)
{
    zoom_k *= factor;
}

void pan(double dx, double dy)
{
    pan_x += dx / zoom_k;
    pan_y += dy / zoom_k;
}

void zoom_at(double x, double y, double factor)
{
    pan(-x, -y);
    zoom(factor);
    pan(x, y);
}

void key(unsigned char key, int x, int y)
{
    if (key == '\033')
        exit(0);
}

void special(int k, int x, int y)
{
    if (k == GLUT_KEY_HOME)
        reset();
    else if (k == GLUT_KEY_LEFT)
        pan(-step, 0);
    else if (k == GLUT_KEY_RIGHT)
        pan( step, 0);
    else if (k == GLUT_KEY_DOWN)
        pan(0, -step);
    else if (k == GLUT_KEY_UP)
        pan(0, step);

    glutPostRedisplay();
}

void mouse(int b, int s, int x, int y)
{
    if (s != GLUT_DOWN)
        return;

    y = win_h - 1 - y;

    if (b == GLUT_LEFT_BUTTON)
        zoom_at(x, y, scale);
    else if (b == GLUT_RIGHT_BUTTON)
        zoom_at(x, y, 1/scale);

    glutPostRedisplay();
}

void draw(void)
{
    glClearColor(0.5, 0.5, 1.0, 1);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glScalef(zoom_k, zoom_k, 1);
    glTranslatef(pan_x, pan_y, 0);

    draw_object();

    glutSwapBuffers();
}

void resize(int w, int h)
{
    if (h == 0)
        h = 1;

    win_w = w;
    win_h = h;

    glViewport(0, 0, w, h);

    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    gluOrtho2D(0, w, 0, h);
}

int main(int argc, char **argv)
{
    glutInit(&argc, argv);

    glutInitDisplayMode(GLUT_RGB | GLUT_DOUBLE);
    glutInitWindowSize(640, 512);

    glutCreateWindow("Zoom/Pan Test");

    glutDisplayFunc(draw);
    glutReshapeFunc(resize);
    glutKeyboardFunc(key);
    glutSpecialFunc(special);
    glutMouseFunc(mouse);

    glFrontFace(GL_CCW);
    glEnable(GL_CULL_FACE);
    glEnable(GL_TEXTURE_2D);
    load_tex("test.ppm");

    reset();

    glutMainLoop();

    return 0;
}

After drawing some pictures, I found this relation for converting world coordinates to window coordinates:


window = world - (zoomPoint - world)(zoom - 1)
//Or a little more simplified:
window = -(zoomPoint)(zoom) + zoomPoint + (world)(zoom)

Solving for the world coordinate yields:


world = zoomPoint + ((window - zoomPoint) / zoom)

With this conversion I am able to check collisions between the mouse and world objects, but changing the zoom point still eludes me. I think I may be converting correctly, but zooming to this new point may cause some errors, I guess?

EDIT: I found out this fails in some cases, seems to only work for zooming to the center of the screen.

Oops, didn’t see your post there. I am testing the program now.

Yes that is exactly the functionality I am trying to achieve. Thanks for including an entire program. I’ll see if I can study it to see where I am going wrong.

Thank you very much. I was able to get it working. The main problem was that I was using glTranslatef(-x, -y 0) and forgot to switch the zoom_at function from:


pan(-x, -y);
Zoom(factor);
pan(x, y);

To:


pan(x, y);
Zoom(factor);
pan(-x, -y);

Thanks, your help was invaluable.:smiley: