Sunday 2 September 2012

Tutorial: libgdx Orthographic Camera with Mouse Scroll

As part of my resolution to actually blog something useful and generally contribute to the world of programming, I've decided to reinstate this blog and make a relevant new post. So without further ado, this first outing will be about setting up a 2D orthographic camera with mouse scroll support.

(note: this article is based on an existing tutorial on the libgdx wiki that is classed as deprecated - please go there before reading on and download the "sc_map.png" image if you intend to run the code)

Setup 


While learning how to use libgdx I've seen multiple questions about viewports and stretching of textures with an orthographic camera. The forum contains many posts detailing how to do various solutions but for programmers like myself who just want an example, it's a little frustrating. Hopefully this article will clarify some points.

Firstly, in order to have sensible scrolling speed and a decoupling of world dimensions from rendering dimensions, you need a ratio of pixels to world unit. In this example we'll use a simple constant for this, along with a fixed world size.
public interface Constants
{
   float PIXELS_PER_METER = 32;
   float WORLD_WIDTH_METERS = 100;
   float WORLD_HEIGHT_METERS = 100;
}

You'll note that I'm using meters - this is what Box2D uses and it's a good enough world unit as any other.

Secondly we need to create the basic 'screen' that we'll be using to render. This is literally a Screen object and contains all the objects we need for rendering:
public class ScrollableScreen implements Screen, InputProcessor, Constants
{
   private final OrthographicCamera _camera;
   private final Vector3 _lastMouseWorldMovePos;
   private final Vector3 _lastMouseWorldDragPos;
   private final Vector3 _lastMouseWorldPressPos;
   private final Vector3 _lastMouseWorldReleasePos;
   private final Vector3 _lastMouseScreenPos;
   private final Texture _texture;
   private final SpriteBatch _batch;

   private int _mouseButtonDown = -1;

   public ScrollableScreen()
   {
      //background texture
      _texture = new Texture(Gdx.files.internal("sc_map_centered.png"));
      _camera = new OrthographicCamera();
      //batching
      _batch = new SpriteBatch();
      //controls
      _lastMouseWorldMovePos = new Vector3();
      _lastMouseWorldDragPos = new Vector3();
      _lastMouseWorldPressPos = new Vector3();
      _lastMouseWorldReleasePos = new Vector3();
      _lastMouseScreenPos = new Vector3();
   }

[...]

The bundle of Vector3 objects to do with mouse positioning is simply to record all the individual mouse operations that occur.

Rendering


The first method we'll implement as part of the Screen interface is render, which contains the following code:
@Overridepublic void render(float delta)
{
   _camera.update();
   Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
   _batch.setProjectionMatrix(_camera.combined);
   _batch.begin();
   //100mx100m texture in world coordinates
   _batch.draw(_texture, 
               -WORLD_WIDTH_METERS/2f, 
               -WORLD_HEIGHT_METERS/2f, 
               WORLD_WIDTH_METERS, 
               WORLD_HEIGHT_METERS);
   _batch.end();
}

The important part here is that our background image, _texture, is being drawn right in the middle of the world using the world dimensions. In this specific case the draw call is equivalent to:
_batch.draw(_texture, -50, -50, 100, 100);

Thanks to libgdx camera transforms the pixel position of the image is not something we need to worry about. What does this mean when we're using Box2D? It means we can simply pass the world position to the rendering call without worrying about any messy calculations. Awesome!

The last critical thing we need to implement is the resize method content. This is remarkably simple and, incidentally, is called before render, allowing us to dump all the dimension calculations in this method. This is what resize does:
@Override
public void resize(int width, int height)
{
   Vector3 oldpos = new Vector3(_camera.position);
   _camera.setToOrtho(false,
                      width/PIXELS_PER_METER,
                      height/PIXELS_PER_METER);
   _camera.translate(oldpos.x-_camera.position.x,oldpos.y-_camera.position.y);
}

Line by line:
  1. The current camera position is recorded in a new Vector3 object.
  2. The camera is set to orthographic mode for a viewport size matching that of the scaled world size. Remember that libgdx's camera handles the rendering transform for us, but in order to maintain this we must specify the camera in world coordinates as well. Given that we have a fixed pixel per meter ratio and we know the new width of the screen, it's simply the matter of calculating the number of meters the screen can now display and telling OpenGL to give us a window sized appropriately.
  3. The camera is moved back to where it was before the resize. This is necessary as setToOrtho will reset the camera's position to WIDTH/2 and HEIGHT/2, which isn't ideal. We recorded the old position of the camera on line 1 so the translation is the difference between this and the new position.
Now what we get when resizing the OpenGL window is more of the world being shown, rather than the same amount of the world being shown but stretched to the window resolution. The camera will remain focused on the same position as before to prevent disorientation of the player.

Scrolling

 

Now that rendering is resolution agnostic we can implement scrolling. This requires filling in the methods touchDown, touchUp, touchDragged and touchMoved:
@Override
public boolean touchDown(int x, int y, int pointer, int button)
{
   _mouseButtonDown = button;
   _lastMouseWorldPressPos.set(x, y, 0);
   _camera.unproject(_lastMouseWorldPressPos);
   return false;
}

@Override
public boolean touchUp(int x, int y, int pointer, int button)
{
   _mouseButtonDown = -1;
   _lastMouseWorldReleasePos.set(x, y, 0);
   _camera.unproject(_lastMouseWorldReleasePos);
   return false;
}

@Override
public boolean touchDragged(int x, int y, int pointer)
{
   if (_mouseButtonDown == Input.Buttons.RIGHT)
   {
      _camera.translate((x-_lastMouseScreenPos.x)/PIXELS_PER_METER,
                        (y-_lastMouseScreenPos.y)/-PIXELS_PER_METER);
   }
   _lastMouseWorldDragPos.set(x, y, 0);
   _camera.unproject(_lastMouseWorldDragPos);
   _lastMouseWorldMovePos.set(x,y,0);
   _camera.unproject(_lastMouseWorldMovePos);
   _lastMouseScreenPos.set(x,y,0);
   return false;
}
 @Override
public boolean touchMoved(int x, int y)
{
   _lastMouseWorldMovePos.set(x, y, 0);
   _camera.unproject(_lastMouseWorldMovePos);
   _lastMouseScreenPos.set(x,y,0);
   return false;
}

Amongst the code that sets various Vector3 objects with mouse down, up, move and drag locations is the code in touchDragged that instructs the camera to translate. There's no need to unproject for translation as we already have a suitable translation distance: the difference between the current and last mouse position and the scale of the world to the screen. By dividing the distance the mouse has dragged by the actual distance this represents in the world we can provide a sensible screen distance to move the camera.
Note that the Y parameter to the translate call uses the negative PIXELS_PER_METER value, as Y is up in OpenGL.

Notes

 

This probably isn't the most efficient code, nor the best way of providing this feature, but it's simple and easy to expand upon. I'll probably be using this as a base for further articles.

Source


The full source as it currently appears in my project. Feel free to use however you wish.
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.Vector3;
import com.gingermess.Constants;

public class ScrollableScreen2 implements Screen, InputProcessor, Constants
{
   private final OrthographicCamera _camera;
   private final Vector3 _lastMouseWorldMovePos;
   private final Vector3 _lastMouseWorldDragPos;
   private final Vector3 _lastMouseWorldPressPos;
   private final Vector3 _lastMouseWorldReleasePos;
   private final Vector3 _lastMouseScreenPos;

   private SpriteBatch _batch;

   private int _mouseButtonDown;
   private Texture _texture;
   private float _zoomingQuantity = 0f;

   public ScrollableScreen2()
   {
      //test texture that is 100m by 100m
      _texture = new Texture(Gdx.files.internal("sc_map_centered.png"));
      _camera = new OrthographicCamera();
      //batching
      _batch = new SpriteBatch();

      //controls
      _lastMouseWorldMovePos = new Vector3();
      _lastMouseWorldDragPos = new Vector3();
      _lastMouseWorldPressPos = new Vector3();
      _lastMouseWorldReleasePos = new Vector3();
      _lastMouseScreenPos = new Vector3();
   }

   @Override
   public void render(float delta)
   {
      _camera.zoom+=_zoomingQuantity;
      _camera.update();
      Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
      _batch.setProjectionMatrix(_camera.combined);
      _batch.begin();
      //100mx100m texture in world coordinates
      _batch.draw(_texture, -WORLD_WIDTH_METERS/2f, -WORLD_HEIGHT_METERS/2f,
WORLD_WIDTH_METERS, WORLD_HEIGHT_METERS);
      _batch.end();
   }

   @Override
   public void resize(int width, int height)
   {
      /*
      This resize code will ensure that the window is filled with world. The camera position will be maintained during
      the resize so that whatever it was looking at isn't suddenly displaced or off screen altogether.
      Zoom is conveniently handled by the camera internals so doesn't need to be taken into account here.
       */
      Vector3 pos = new Vector3(_camera.position);
      //enforce a fixed number of pixels per meter otherwise aspect ratio will skew in one dimension
      _camera.setToOrtho(false,width/PIXELS_PER_METER,height/PIXELS_PER_METER);
      //move the camera back to where it was - zoom hasn't changed so this is ok to do in screen coords.
      _camera.translate(pos.x-_camera.position.x,pos.y-_camera.position.y);
   }

   @Override
   public void dispose()
   {
      _texture.dispose();
   }

   @Override
   public boolean keyDown(int keycode)
   {
      if (Input.Keys.A == keycode)
         _zoomingQuantity+=0.02;
      if (Input.Keys.Q == keycode)
         _zoomingQuantity-=0.02;
      return false;
   }

   @Override
   public boolean keyUp(int keycode)
   {
      if (Input.Keys.A == keycode)
         _zoomingQuantity-=0.02;
      if (Input.Keys.Q == keycode)
         _zoomingQuantity+=0.02;
      return false;
   }

   @Override
   public boolean keyTyped(char character)
   {
      return false;
   }

   @Override
   public boolean touchDown(int x, int y, int pointer, int button)
   {
      _mouseButtonDown = button;
      _lastMouseWorldPressPos.set(x, y, 0);
      _camera.unproject(_lastMouseWorldPressPos);
      System.out.println("Mouse down at world: "+ _lastMouseWorldPressPos);
      return false;
   }

   @Override
   public boolean touchUp(int x, int y, int pointer, int button)
   {
      _mouseButtonDown = Constants.NONE;
      _lastMouseWorldReleasePos.set(x, y, 0);
      _camera.unproject(_lastMouseWorldReleasePos);
      System.out.println("Mouse up at world: "+_lastMouseWorldReleasePos);
      return false;
   }

   @Override
   public boolean touchDragged(int x, int y, int pointer)
   {
      if (_mouseButtonDown == Input.Buttons.RIGHT)
      {
         _camera.translate((x-_lastMouseScreenPos.x)/PIXELS_PER_METER,
                           (y-_lastMouseScreenPos.y)/-PIXELS_PER_METER);
      }
      _lastMouseWorldDragPos.set(x, y, 0);
      _camera.unproject(_lastMouseWorldDragPos);
      _lastMouseWorldMovePos.set(x,y,0);
      _camera.unproject(_lastMouseWorldMovePos);
      _lastMouseScreenPos.set(x,y,0);
      return false;
   }

   @Override
   public boolean touchMoved(int x, int y)
   {
      _lastMouseWorldMovePos.set(x, y, 0);
      _camera.unproject(_lastMouseWorldMovePos);
      _lastMouseScreenPos.set(x,y,0);
      return false;
   }

   @Override
   public boolean scrolled(int amount)
   {
      return false;
   }


   @Override
   public void show()
   {
   }

   @Override
   public void hide()
   {
   }

   @Override
   public void pause()
   {
   }

   @Override
   public void resume()
   {
   }
}

No comments:

Post a Comment