Monday, February 3, 2020

libgdx - How do I move the camera in full pixel intervals?


Basically I want to stop the camera from moving in subpixels, as I think this leads to sprites visibly changing their dimensions if just ever so slightly. (Is there a better term for that?) Note that this is a pixel-art game where I want to have crisp pixelated graphics. Here's a gif that shows the problem:


enter image description here


Now what I tried was this: Move the camera, project the current position (so it's screen coordinates) and then round or cast to int. After that convert it back to world coordinates and use that as the new camera position. As far as I know, this should lock the camera to actual screen coordinates, not fractions thereof.


For some reason, however, the y value of the new position just explodes. In a matter of seconds it increases to something like 334756315000.



Here is a SSCCE (or an MCVE) based on the code in the LibGDX wiki:


import com.badlogic.gdx.ApplicationListener;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Sprite;

import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector3;
import com.badlogic.gdx.utils.viewport.ExtendViewport;
import com.badlogic.gdx.utils.viewport.Viewport;


public class PixelMoveCameraTest implements ApplicationListener {

static final int WORLD_WIDTH = 100;

static final int WORLD_HEIGHT = 100;

private OrthographicCamera cam;
private SpriteBatch batch;

private Sprite mapSprite;
private float rotationSpeed;

private Viewport viewport;
private Sprite playerSprite;

private Vector3 newCamPosition;

@Override
public void create() {
rotationSpeed = 0.5f;

playerSprite = new Sprite(new Texture("/path/to/dungeon_guy.png"));
playerSprite.setSize(1f, 1f);

mapSprite = new Sprite(new Texture("/path/to/sc_map.jpg"));

mapSprite.setPosition(0, 0);
mapSprite.setSize(WORLD_WIDTH, WORLD_HEIGHT);

float w = Gdx.graphics.getWidth();
float h = Gdx.graphics.getHeight();

// Constructs a new OrthographicCamera, using the given viewport width and height
// Height is multiplied by aspect ratio.
cam = new OrthographicCamera();


cam.position.set(0, 0, 0);
cam.update();
newCamPosition = cam.position.cpy();

viewport = new ExtendViewport(32, 20, cam);
batch = new SpriteBatch();
}


@Override

public void render() {
handleInput();
cam.update();
batch.setProjectionMatrix(cam.combined);

Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

batch.begin();
mapSprite.draw(batch);
playerSprite.draw(batch);

batch.end();
}

private static float MOVEMENT_SPEED = 0.2f;

private void handleInput() {
if (Gdx.input.isKeyPressed(Input.Keys.A)) {
cam.zoom += 0.02;
}
if (Gdx.input.isKeyPressed(Input.Keys.Q)) {

cam.zoom -= 0.02;
}
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
newCamPosition.add(-MOVEMENT_SPEED, 0, 0);
}
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
newCamPosition.add(MOVEMENT_SPEED, 0, 0);
}
if (Gdx.input.isKeyPressed(Input.Keys.DOWN)) {
newCamPosition.add(0, -MOVEMENT_SPEED, 0);

}
if (Gdx.input.isKeyPressed(Input.Keys.UP)) {
newCamPosition.add(0, MOVEMENT_SPEED, 0);
}
if (Gdx.input.isKeyPressed(Input.Keys.W)) {
cam.rotate(-rotationSpeed, 0, 0, 1);
}
if (Gdx.input.isKeyPressed(Input.Keys.E)) {
cam.rotate(rotationSpeed, 0, 0, 1);
}


cam.zoom = MathUtils.clamp(cam.zoom, 0.1f, 100 / cam.viewportWidth);

float effectiveViewportWidth = cam.viewportWidth * cam.zoom;
float effectiveViewportHeight = cam.viewportHeight * cam.zoom;

cam.position.lerp(newCamPosition, 0.02f);
cam.position.x = MathUtils.clamp(cam.position.x,
effectiveViewportWidth / 2f, 100 - effectiveViewportWidth / 2f);
cam.position.y = MathUtils.clamp(cam.position.y,

effectiveViewportHeight / 2f, 100 - effectiveViewportHeight / 2f);


// if this is false, the "bug" (y increasing a lot) doesn't appear
if (true) {
Vector3 v = viewport.project(cam.position.cpy());
System.out.println(v);
v = viewport.unproject(new Vector3((int) v.x, (int) v.y, v.z));
cam.position.set(v);
}

playerSprite.setPosition(newCamPosition.x, newCamPosition.y);
}

@Override
public void resize(int width, int height) {
viewport.update(width, height);
}

@Override
public void resume() {

}

@Override
public void dispose() {
mapSprite.getTexture().dispose();
batch.dispose();
}

@Override
public void pause() {

}

public static void main(String[] args) {
new Lwjgl3Application(new PixelMoveCameraTest(), new Lwjgl3ApplicationConfiguration());
}
}

and here's the sc_map.jpg and the dungeon_guy.png


I'd also be interested to learn about simpler and/or better ways to fix this problem.



Answer




Your problem isn't moving the camera in full-pixel increments. It's that your texel-to-pixel ratio is slightly non-integer. I'll borrow some examples from this similar question I answered on StackExchange.


Here's two copies of Mario - both are moving across the screen at the same rate (either by the sprites moving right in the world, or the camera moving left - it ends up equivalent), but only the top one shows these rippling artifacts and the bottom one doesn't:


Two Mario sprites


The reason is that I slightly scaled the top Mario by 1 factor of 1.01x - that extra tiny fraction means he no longer lines up with the screen's pixel grid.


A small translational misalignment isn't a problem - "Nearest" texture filtering will snap to the closest texel anyway without us doing anything special.


But a scale mismatch means this snapping isn't always in the same direction. In one spot a pixel looking up the nearest texel will pick one slightly to the right, while another pixel picks one slightly to the left - and now a column of texels has either been omitted or duplicated in-between, creating a ripple or shimmer that moves over the sprite as it travels across the screen.


Here's a closer look. I've animated a translucent mushroom sprite moving smoothly, as though we could render it with unlimited sub-pixel resolution. Then I've overlaid a pixel grid using nearest-neighbour sampling. The whole pixel changes colour to match the part of the sprite under the sampling point (the dot in the center).


1:1 Scaling


A properly scaled sprite moving with no ripple


Even though the sprite moves smoothly to sub-pixel coordinates, its rendered image still snaps correctly each time it travels a full pixel. We don't need to do anything special to make this work.



1:1.0625 Scaling


16x16 sprite rendered 17x17 on-screen, showing artifacts


In this example, the 16x16 texel mushroom sprite has been scaled up to 17x17 screen pixels, and so the snapping happens differently from one part of the image to another, creating the ripple or wave that stretches & squashes it as it moves.


So, the trick is to adjust your camera size/field of view so that your assets' source texels map to an integer number of screen pixels. It doesn't have to be 1:1 - any whole number works great:


1:3 Scaling


Zoom in on triple scale


You'll need to do this a bit differently depending on your target resolution - simply scaling up to fit the window will almost always result in a fractional scale. Some amount of padding at the edges of the screen may be needed for certain resolutions.


No comments:

Post a Comment

Simple past, Present perfect Past perfect

Can you tell me which form of the following sentences is the correct one please? Imagine two friends discussing the gym... I was in a good s...