How to improve zoom and panning functionality OpenGL-ES 2

Hi, I have a map that displays real world property boundaries for a district on android. Like a basic GIS/GPS system.

The zoom and panning are working up to a certain extent, but when it gets to a certain level, lets say about 500m from ground level the panning starts to get noticeably jumpy, this gets worse the closer you zoom. The panning is via touch screen and zoom is via pinching. I need to get the smooth panning down to a level of about 1-2m. The bounding area is 65km wide and 125km height, just to give an idea of how much zoom is required, this may range depending on districts.

The zoom is done through scaling the model and translating to the correct spot. The panning is done through a translate. I have my data in VBO’s and keep all my shifts and scale factor in double precision variables for accuracy, casting them to floats last second for OpenGL-ES2. When the screen loads for the first time it displays the map to its extents via Matrix.frustumM().

I know the reason this is happening, unfortunately I’m stuck for solutions.

The reason is when you zoom in close, the floating point data types cannot handle the small shifts in movement. I found this by comparing the double and float values, and the difference between them.

Before scaling and translating the model, I tried manipulating the projection matrix to provide zoom and the eye to pan. This logically seemed to have a more naturally feel to it, however I ran into the exact same issue.

I have exhausted my knowledge on how to improve this functionality. Is there someone out there that can provide some possible solution/advice?
Kind regards Hank

You might like to read this on precision of floating point numbers floating point - What range of numbers can be represented in a 16-, 32- and 64-bit IEEE-754 systems? - Stack Overflow. It will help explain the limits you can measure too. Numbers close to zero have more precision than those further from it. If precision is your problem you can trade speed for accuracy by dynamically changing your origin. For example if you use the camera as your origin, things close to your camera can be drawn more accurately since their coordinates are now close to zero. Of course this means each object has to have its own local coordinates and is translated to its location relative to the new origin every frame. If you have a large map this may mean holding the map as a set of smaller maps stitched together.

Hi tonyo_au,

Thank you for the link, it will come in handy.

So my map is in decimal degrees, mbrMinX:152.073393 mbrMaxX:152.75667 mbrMinY:-27.570736999700003 mbrMaxY:-26.452339000100004
mLeft:-0.3522513384566926 mRight:0.3522513384566926 mBottom:-0.5591989997999995 mTop:0.5591989997999995
The center or where I have my eye currently is eyeX:152.4150315 eyeY:-27.011537999900003

Just so I understand this, I reset my eyeX and eyeY to zero (the origin (0,0)). I translate my map (model) so the center is at zero also. So the map in my case will get translated by -152.4150315, 27.011537999900003 or there abouts in float fashion.
From there, when I need to pan I translate the model, however much, around the origin. But I’m thinking my logic can’t be quite right as I translate the model it would just end up being -152.4150315 + shift which would lead to it jumping again. Definitely getting myself confused, could you perhaps explain a bit further.

Maybe I could put it into better context by referring to the code in the following replies:

Code from my renderer

// Position the eye behind the origin.
public double eyeX = default_settings.mbrMinX + ((default_settings.mbrMaxX - default_settings.mbrMinX)/2);
public double eyeY = default_settings.mbrMinY + ((default_settings.mbrMaxY - default_settings.mbrMinY)/2);
public float eyeZ = 1.5f;

// We are looking toward the distance
public double lookX = eyeX;
public double lookY = eyeY;
public float lookZ = 0.0f;
	
// Set our up vector. This is where our head would be pointing were we holding the camera.
public float upX = 0.0f;
public float upY = 1.0f;
public float upZ = 0.0f;

public double mScaleFactor = 1;
public double modelXShift = 0;
public double modelYShift = 0;
	
double realWorldCtrPosX = eyeX;
double realWorldCtrPosY = eyeY;
	
public void setEye(double x, double y){
		
    modelXShift = modelXShift + (x / screen_vs_map_horz_ratio);
    modelYShift = modelYShift - (y / screen_vs_map_vert_ratio);

    realWorldCtrPosX -= ((x / screen_vs_map_horz_ratio));
    realWorldCtrPosY += ((y / screen_vs_map_horz_ratio));
}

public void setScaleFactor2(float scaleFactor, float gdx, float gdy){
	
    double diffX = gdx - (mWidth/2);
    double realWorldDiffX = diffX / screen_vs_map_horz_ratio;
    double realWorldPosX = realWorldCtrPosX + realWorldDiffX;

    double diffY = gdy - (mHeight/2);
    double realWorldDiffY = diffY / screen_vs_map_horz_ratio;
    double realWorldPosY = realWorldCtrPosY - realWorldDiffY;
		
    double newRealWorldPosX = realWorldPosX * scaleFactor;
    double newRealWorldPosY = realWorldPosY * scaleFactor;
		
    realWorldCtrPosX = newRealWorldPosX - realWorldDiffX;
    realWorldCtrPosY = newRealWorldPosY + realWorldDiffY;
		
    modelXShift = modelXShift - (newRealWorldPosX - realWorldPosX);
    modelYShift = modelYShift + (realWorldPosY - newRealWorldPosY);

    mScaleFactor *= scaleFactor;
}
	
//The following was the initial way panning and zooming were implemented
public void setEye1(double x, double y){
    eyeX -= (x / screen_vs_map_horz_ratio);
    lookX = eyeX;
    eyeY += (y / screen_vs_map_vert_ratio);
    lookY = eyeY;
    	
    // Set the camera position (View matrix)
    Matrix.setLookAtM(mViewMatrix, 0, (float)eyeX, (float)eyeY, eyeZ, (float)lookX, (float)lookY, lookZ, upX, upY, upZ);
}
	
public void setScaleFactor1(float scaleFactor, float gdx, float gdy){
    mScaleFactor *= scaleFactor;
    	
    mRight = mRight / scaleFactor;
    mLeft = -mRight;
    mTop = mTop / scaleFactor;
    mBottom = -mTop;

    //The eye shift is in pixels will get converted to screen ratio when sent to setEye().
    double eyeXShift = (((mWidth  / 2) - gdx) - (((mWidth  / 2) - gdx) / scaleFactor));
    double eyeYShift = (((mHeight / 2) - gdy) - (((mHeight / 2) - gdy) / scaleFactor));

    screen_vs_map_horz_ratio = (mWidth/(mRight-mLeft));
    screen_vs_map_vert_ratio = (mHeight/(mTop-mBottom));
    	
    eyeX -= (eyeXShift / screen_vs_map_horz_ratio);
    lookX = eyeX;
    eyeY += (eyeYShift / screen_vs_map_vert_ratio);
    lookY = eyeY;
    	
    // Set the scale (Projection matrix)
    Matrix.frustumM(mProjectionMatrix, 0, (float)mLeft, (float)mRight, (float)mBottom, (float)mTop, near, far);
}

@Override
public void onSurfaceCreated(GL10 unused, EGLConfig config) {
    	
    // Set the background frame color
    //White
    GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f);

// Set the view matrix. This matrix can be said to represent the camera position.
// NOTE: In OpenGL 1, a ModelView matrix is used, which is a combination of a model and
// view matrix. In OpenGL 2, we can keep track of these matrices separately if we choose.
Matrix.setLookAtM(mViewMatrix, 0, (float)eyeX, (float)eyeY, eyeZ, (float)lookX, (float)lookY, lookZ, upX, upY, upZ);
    	
//vertex/fragment shader code...
}

Hi tonyo_au,

Thank you for the link-ref, it will come in handy.

So my map is in decimal degrees, mbrMinX:152.073393 mbrMaxX:152.75667 mbrMinY:-27.570736999700003 mbrMaxY:-26.452339000100004
mLeft:-0.3522513384566926 mRight:0.3522513384566926 mBottom:-0.5591989997999995 mTop:0.5591989997999995
The center or where I have my eye currently is eyeX:152.4150315 eyeY:-27.011537999900003

http://i.stack.imgur.com/VJDtT.png is a screenshot

Just so I understand this, I reset my eyeX and eyeY to zero (the origin (0,0)). I translate my map (model) so the center is at zero also. So the map in my case will get translated by -152.4150315, 27.011537999900003 or there abouts in float fashion.
From there, when I need to pan I translate the model, however much, around the origin. But I’m thinking my logic can’t be quite right as I translate the model it would just end up being -152.4150315 + shift which would lead to it jumping again. Definitely getting myself confused, could you perhaps explain a bit further.

Maybe I could put it into better context by referring to the code:

import java.nio.FloatBuffer;
import java.util.ListIterator;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView;
import android.opengl.Matrix;
import android.util.Log;

public class customrenderer implements GLSurfaceView.Renderer{

    private float[] mModelMatrix = new float[16];
    private float[] mViewMatrix = new float[16];
    private float[] mProjectionMatrix = new float[16];
	
    private float[] mMVPMatrix = new float[16];
    private int mMVPMatrixHandle;
	
    private int mPositionHandle;
    private int mColorUniformLocation;

    private final int mBytesPerFloat = 4;	
    private final int mPositionOffset = 0;
    private final int mPositionDataSize = 3;
    private final int mPositionFloatStrideBytes = mPositionDataSize * mBytesPerFloat;
	
    // Position the eye behind the origin.
    public double eyeX = default_settings.mbrMinX + ((default_settings.mbrMaxX - default_settings.mbrMinX)/2);
    public double eyeY = default_settings.mbrMinY + ((default_settings.mbrMaxY - default_settings.mbrMinY)/2);
    public float eyeZ = 1.5f;

    // We are looking toward the distance
    public double lookX = eyeX;
    public double lookY = eyeY;
    public float lookZ = 0.0f;
	
    // Set our up vector. This is where our head would be pointing were we holding the camera.
    public float upX = 0.0f;
    public float upY = 1.0f;
    public float upZ = 0.0f;

    public double mScaleFactor = 1;
    public double modelXShift = 0;
    public double modelYShift = 0;
    
    public customrenderer() {}
	
    double realWorldCtrPosX = eyeX;
    double realWorldCtrPosY = eyeY;
	
    public void setEye(double x, double y){
		
        modelXShift = modelXShift + (x / screen_vs_map_horz_ratio);
        modelYShift = modelYShift - (y / screen_vs_map_vert_ratio);

        realWorldCtrPosX -= ((x / screen_vs_map_horz_ratio));
        realWorldCtrPosY += ((y / screen_vs_map_horz_ratio));
    }

    public void setScaleFactor2(float scaleFactor, float gdx, float gdy){
		
        double diffX = gdx - (mWidth/2);
        double realWorldDiffX = diffX / screen_vs_map_horz_ratio;
        double realWorldPosX = realWorldCtrPosX + realWorldDiffX;

        double diffY = gdy - (mHeight/2);
        double realWorldDiffY = diffY / screen_vs_map_horz_ratio;
        double realWorldPosY = realWorldCtrPosY - realWorldDiffY;
		
        double newRealWorldPosX = realWorldPosX * scaleFactor;
        double newRealWorldPosY = realWorldPosY * scaleFactor;
		
        realWorldCtrPosX = newRealWorldPosX - realWorldDiffX;
        realWorldCtrPosY = newRealWorldPosY + realWorldDiffY;
		
        modelXShift = modelXShift - (newRealWorldPosX - realWorldPosX);
        modelYShift = modelYShift + (realWorldPosY - newRealWorldPosY);

        mScaleFactor *= scaleFactor;
    }
	
    //The following was the initial way panning and zooming were implemented
    public void setEye1(double x, double y){
        eyeX -= (x / screen_vs_map_horz_ratio);
        lookX = eyeX;
        eyeY += (y / screen_vs_map_vert_ratio);
        lookY = eyeY;
    	
        // Set the camera position (View matrix)
        Matrix.setLookAtM(mViewMatrix, 0, (float)eyeX, (float)eyeY, eyeZ, (float)lookX, (float)lookY, lookZ, upX, upY, upZ);
    }
	
    public void setScaleFactor1(float scaleFactor, float gdx, float gdy){
        mScaleFactor *= scaleFactor;
    	
        mRight = mRight / scaleFactor;
        mLeft = -mRight;
        mTop = mTop / scaleFactor;
        mBottom = -mTop;

        //The eye shift is in pixels will get converted to screen ratio when sent to setEye().
        double eyeXShift = (((mWidth  / 2) - gdx) - (((mWidth  / 2) - gdx) / scaleFactor));
        double eyeYShift = (((mHeight / 2) - gdy) - (((mHeight / 2) - gdy) / scaleFactor));

        screen_vs_map_horz_ratio = (mWidth/(mRight-mLeft));
        screen_vs_map_vert_ratio = (mHeight/(mTop-mBottom));
    	
        eyeX -= (eyeXShift / screen_vs_map_horz_ratio);
        lookX = eyeX;
        eyeY += (eyeYShift / screen_vs_map_vert_ratio);
        lookY = eyeY;
    	
        // Set the scale (Projection matrix)
        Matrix.frustumM(mProjectionMatrix, 0, (float)mLeft, (float)mRight, (float)mBottom, (float)mTop, near, far);
    }
	
    @Override
    public void onSurfaceCreated(GL10 unused, EGLConfig config) {
    	
    	
        // Set the background frame color
        //White
        GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f);

        // Set the view matrix. This matrix can be said to represent the camera position.
        // NOTE: In OpenGL 1, a ModelView matrix is used, which is a combination of a model and
        // view matrix. In OpenGL 2, we can keep track of these matrices separately if we choose.
        Matrix.setLookAtM(mViewMatrix, 0, (float)eyeX, (float)eyeY, eyeZ, (float)lookX, (float)lookY, lookZ, upX, upY, upZ);
        
        //vertex and fragment shader code...
        
        // Set program handles. These will later be used to pass in values to the program.
        mMVPMatrixHandle = GLES20.glGetUniformLocation(programHandle, "u_MVPMatrix");
        mPositionHandle = GLES20.glGetAttribLocation(programHandle, "a_Position");
        mColorUniformLocation = GLES20.glGetUniformLocation(programHandle, "u_Color");
        
        // Tell OpenGL to use this program when rendering.
        GLES20.glUseProgram(programHandle);
    }

    double mWidth = 0;
    double mHeight = 0;
    double mLeft = 0;
    double mRight = 0;
    double mTop = 0;
    double mBottom = 0;
    double mRatio = 0;
    double screen_width_height_ratio;
    double screen_height_width_ratio;
    float near = 1.5f;
    float far = 10.0f;
    double screen_vs_map_horz_ratio = 0;
    double screen_vs_map_vert_ratio = 0;
	
    @Override
    public void onSurfaceChanged(GL10 unused, int width, int height) {

        // Adjust the viewport based on geometry changes,
        // such as screen rotation
        // Set the OpenGL viewport to the same size as the surface.
        GLES20.glViewport(0, 0, width, height);

        screen_width_height_ratio = (double) width / height;
        screen_height_width_ratio = (double) height / width;

        //Initialize
        if (mRatio == 0){
            mWidth = (double) width;
            mHeight = (double) height;
			
            //map height to width ratio
            double map_extents_width = default_settings.mbrMaxX - default_settings.mbrMinX;
            double map_extents_height = default_settings.mbrMaxY - default_settings.mbrMinY;
            double map_width_height_ratio = map_extents_width/map_extents_height;
			
            if (screen_width_height_ratio > map_width_height_ratio){
                mRight = (screen_width_height_ratio * map_extents_height)/2;
                mLeft = -mRight;
                mTop = map_extents_height/2;
                mBottom = -mTop;
            }
            else{
                mRight = map_extents_width/2;
                mLeft = -mRight;
                mTop = (screen_height_width_ratio * map_extents_width)/2;
                mBottom = -mTop;
            }
            mRatio = screen_width_height_ratio;
        }
		
        if (screen_width_height_ratio != mRatio){
            final double wRatio = width/mWidth;
            final double oldWidth = mRight - mLeft;
            final double newWidth = wRatio * oldWidth;
            final double widthDiff = (newWidth - oldWidth)/2;
            mLeft = mLeft - widthDiff;
            mRight = mRight + widthDiff;
			
            final double hRatio = height/mHeight;
            final double oldHeight = mTop - mBottom;
            final double newHeight = hRatio * oldHeight;
            final double heightDiff = (newHeight - oldHeight)/2;
            mBottom = mBottom - heightDiff;
            mTop = mTop + heightDiff;
			
            mWidth = (double) width;
            mHeight = (double) height;
			
            mRatio = screen_width_height_ratio;
        }
        screen_vs_map_horz_ratio = (mWidth/(mRight-mLeft));
        screen_vs_map_vert_ratio = (mHeight/(mTop-mBottom));
    	
        Matrix.frustumM(mProjectionMatrix, 0, (float)mLeft, (float)mRight, (float)mBottom, (float)mTop, near, far);
    }

    ListIterator<mapLayer> orgNonAssetCatLayersList_it;
    ListIterator<FloatBuffer> mapLayerObjectList_it;
    ListIterator<Byte> mapLayerObjectTypeList_it;
    mapLayer MapLayer;
    
    @Override
    public void onDrawFrame(GL10 unused) {
    
        GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);

        drawPreset();
		
        orgNonAssetCatLayersList_it = default_settings.orgNonAssetCatMappableLayers.listIterator();
        while (orgNonAssetCatLayersList_it.hasNext()) {
            MapLayer = orgNonAssetCatLayersList_it.next();
            if (MapLayer.BatchedPointVBO != null){
            }
            if (MapLayer.BatchedLineVBO != null){
                drawLineString(MapLayer.BatchedLineVBO, MapLayer.lineStringObjColor);
            }
            if (MapLayer.BatchedPolygonVBO != null){
                drawPolygon(MapLayer.BatchedPolygonVBO, MapLayer.polygonObjColor);
            }
        }
    }
	
    private void drawPreset(){
        Matrix.setIdentityM(mModelMatrix, 0);
		
        Matrix.translateM(mModelMatrix, 0, (float)modelXShift, (float)modelYShift, 0f);
        Matrix.scaleM(mModelMatrix, 0, (float)mScaleFactor, (float)mScaleFactor, 1.0f);
		
        // This multiplies the view matrix by the model matrix, and stores the result in the MVP matrix
        // (which currently contains model * view).
        Matrix.multiplyMM(mMVPMatrix, 0, mViewMatrix, 0, mModelMatrix, 0);

        // This multiplies the modelview matrix by the projection matrix, and stores the result in the MVP matrix
        // (which now contains model * view * projection).
        Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mMVPMatrix, 0);

        GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix, 0);
    }
    
    private void drawLineString(final FloatBuffer geometryBuffer, final float[] colorArray){
        // Pass in the position information
        geometryBuffer.position(mPositionOffset);
        GLES20.glVertexAttribPointer(mPositionHandle, mPositionDataSize, GLES20.GL_FLOAT, false, mPositionFloatStrideBytes, geometryBuffer);

        GLES20.glEnableVertexAttribArray(mPositionHandle);

        GLES20.glUniform4f(mColorUniformLocation, colorArray[0], colorArray[1], colorArray[2], 1f);

        GLES20.glLineWidth(2.0f);
        GLES20.glDrawArrays(GLES20.GL_LINES, 0, geometryBuffer.capacity()/mPositionDataSize);
    }
    
    private void drawPolygon(final FloatBuffer geometryBuffer, final float[] colorArray){
        // Pass in the position information
        geometryBuffer.position(mPositionOffset);
        GLES20.glVertexAttribPointer(mPositionHandle, mPositionDataSize, GLES20.GL_FLOAT, false, mPositionFloatStrideBytes, geometryBuffer);

        GLES20.glEnableVertexAttribArray(mPositionHandle);

        GLES20.glUniform4f(mColorUniformLocation, colorArray[0], colorArray[1], colorArray[2], 1f);

        GLES20.glLineWidth(1.0f);
        GLES20.glDrawArrays(GLES20.GL_LINES, 0, geometryBuffer.capacity()/mPositionDataSize);
    }
}

[QUOTE=Hank Finley;1253573]So my map is in decimal degrees,
mbrMinX:152.073393
mbrMaxX:152.75667
mbrMinY:-27.570736999700003
mbrMaxY:-26.452339000100004
[/QUOTE]
Herein lies the problem. You’re wasting 8 bits of the X coordinate and 5 bits of the Y coordinate.

Single-precision floating-point has the equivalent of just under 7 decimal digits of precision at whatever scale. For the range of your X coordinates, you’re wasting just over 2 decimal digits on the leading “152.”.

To avoid this, you should offset the vertex data by (152.4150315, -27.011538), so that your X values are in the range -0.3416385 to +0.3416385 and your Y values are in the range -0.5592 to +0.5592. This will ensure that they use the full 24 bits of precision.

You need to offset any other coordinates used (e.g. the eye position) by the same amount so that the offset cancels out.

Hi GClements,
thank you I understand now.
Just following up on tonyo_au’s comments about the smaller maps stitched together. I don’t think I’ll need this straight away, however for future reference, I’m not sure how to implement this or the logic behind it helping, would appreciate some clarification.

In a static environment you find the centre of your world and subtract this from all your coordinates including the camera. This is often enough to solve your precision problems.

If is not, each frame you chose a new world centre; the best is to use the camera.

Lets think of how we would draw an object in this environment.

First we create the object and store it in a vertex buffer; but rather than storing the object with its world coordinates we store it with say its centre as the origin (this would be normal for say an .obj object)

When we draw this object, we move it to its place in the world by adding its world coordinate to each vertex. With a floating origin, we instead we add its place in the world minus the camera’s place in the world.

This works well for ordinary object but what about the map? If the map extents aren’t to big this will work for it as well but if the extents are very large, the precision at the extremes might be a problem. If this is the case, we could break it into
several smaller non-overlapping maps and draw each one separately.

Hi Guys, when I said before that I understood, I may have been a bit too fast.

From what I understood I set the eye to (0,0) initially with:
Matrix.setLookAtM(mViewMatrix, 0, 0, 0, 1.5f, 0, 0, 0, 0, 1, 0);
and translate the model to:
Matrix.translateM(mModelMatrix, 0, -152.4150315, 27.011538, 0f)

Now when I coding my zoom and panning I am unsure how to proceed. Should I be doing the zoom by:

  1. Changing the scale of the model, or
  2. Manipulating the projection so the area gets bigger or smaller

I will also need to shift either the model or eye as there is a zoom point that this should focus on.

What is recommended here?

I just tried the projection way and it still jumps. I checked at a very close zoom level, about 2m, if the x and y as float values were increasing and decreasing and they are. Cannot figure the issue, but still not sure if this is recommended way either.

[QUOTE=Hank Finley;1253599]Hi Guys, when I said before that I understood, I may have been a bit too fast.

From what I understood I set the eye to (0,0) initially with:
Matrix.setLookAtM(mViewMatrix, 0, 0, 0, 1.5f, 0, 0, 0, 0, 1, 0);
and translate the model to:
Matrix.translateM(mModelMatrix, 0, -152.4150315, 27.011538, 0f)[/QUOTE]
First, the (-152,27) translation needs to be applied to the vertex coordinates before they’re converted from double to float and uploaded to OpenGL (if you’re getting the data as “float” to start with, that’s going to be a significant problem).

Second, all intermediate values should use “double” rather than “float”; there’s no point in trying to save 4 bytes here and 8 bytes there. If your matrices use “float”, you need to apply the (-152,27) translation to the eye position while its stored as “double”, then convert the offset version to float and use that to construct a translation matrix. Trying to perform the offset using a matrix is just going to run into problems with the limited precision of a “float”.

IOW, as much of the calculation as possible needs to be done as “double”, and the parts which must be done as “float” need to have any constant offset removed first.

Hi guys, apologies for late reply! Your advice has been fantastic. Panning while zoomed right in works a treat, thank you.