(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:
- The current camera position is recorded in a new Vector3 object.
- 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.
- 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.
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()
{
}
}