13
\$\begingroup\$

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.

\$\endgroup\$
  • \$\begingroup\$ "Is there a better term for that?" Aliasing? In that case Multisample anti-aliasing (MSAA) might be an alternate solution? \$\endgroup\$ – Yousef Amar Aug 29 '16 at 13:24
  • \$\begingroup\$ @Paraknight Sorry, I forgot to mention that I'm working on a pixelated game, where I need crisp graphics so anti-aliasing wouldn't work (AFAIK). I added that information to the question. Thanks though. \$\endgroup\$ – Joschua Aug 29 '16 at 17:57
  • \$\begingroup\$ Is there a reason to work at a higher resolution, otherwise work at the 1 pixel resolution and upscale the final result? \$\endgroup\$ – Felsir Sep 1 '16 at 19:08
  • \$\begingroup\$ @Felsir Yes, I move in screen pixels, so that the game feels as smooth as possible. Moving a whole texture pixel looks very jittery. \$\endgroup\$ – Joschua Sep 2 '16 at 8:08
21
+50
\$\begingroup\$

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.

\$\endgroup\$
  • 1
    \$\begingroup\$ First of all, thanks so much! This is a great visualization. Have an upvote for that alone. Sadly though, I can't find anything non-Integer in the provided code. The size of the mapSprite is 100x100, The playerSprite is set to a size of 1x1 and the viewport is set to a size of 32x20. Even changing everything to multiples of 16 doesn't seem to solve the problem. Any ideas? \$\endgroup\$ – Joschua Sep 3 '16 at 14:12
  • 2
    \$\begingroup\$ It's entirely possible to have integers everywhere and still get a non-integer ratio. Take the example of the 16 texel sprite being displayed on 17 screen pixels. Both values are integers, but their ratio isn't. I don't know much about libgdx, but in Unity I'd look at how many texels am I displaying per world unit (eg. resolution of sprite divided by world size of sprite), how many world units I'm displaying in my window (using the size of the camera), and how many screen pixels are on my window. Chaining those 3 values, we can work out the texel-to-pixel ratio for a given display window size. \$\endgroup\$ – DMGregory Sep 3 '16 at 14:28
  • \$\begingroup\$ "and the viewport is set to a size of 32x20" - unless your window's "client size" (renderable area) is an integer multiple of this, which I'd guess it's not, 1 unit in your viewport will be some non-integer number of pixels. \$\endgroup\$ – MaulingMonkey Sep 4 '16 at 16:00
  • \$\begingroup\$ Thanks. I've tried again. window size is 1000x1000 (pixels), WORLD_WIDTH x WORLD_HEIGHT is 100x100, FillViewport is 10x10, playerSprite is 1x1. Sadly, the problem still persists. \$\endgroup\$ – Joschua Sep 5 '16 at 13:42
  • \$\begingroup\$ These details are going to be a bit much to try to sort out in a comment thread. Would you like to start a Game Development Chat room, or direct message me on Twitter @D_M_Gregory? \$\endgroup\$ – DMGregory Sep 5 '16 at 13:49
1
\$\begingroup\$

here a small checklist:

  1. Seperate "current camera position" with "aim camera position". (like ndenarodev mentioned) If for example the "current camera position" is 1.1 - make the "aim camera position" 1.0

And only change the current camera position if the camera should move.
If the aim camera position is something else than transform.position - set transform.position (of your camera) to "aim camera position".

aim_camera_position = Mathf.Round(current_camera_position)

if (transform.position != aim_camera_position)
{
  transform.position = aim_camera_position;
}

this should solve your problem. If not: tell me. However you should consider doing those things too:

  1. set "camera projection" to "ortographic" (for your abnormally increasing y-problem) (from now on, you should only zoom with the "Field of View")

  2. set the camera rotation in 90° - steps (0° or 90° or 180°)

  3. do not generate mipmaps if you dont know if you should.

  4. use enough size for your sprites and dont use bilinear or trilinear filter

  5. 5.

If 1 unit is not a pixel-unit maybe its screen-resolution dependent. Do you have access to field of view? Depending on your field of view: Find out how much resolution is 1 unit.

float tan2Fov = tan(radians( Field of view / 2)); 
float xScrWidth = _CamNear*tan2Fov*_CamAspect * 2; 
float xScrHeight = _CamNear*tan2Fov * 2; 
float onePixel(width) = xWidth / screenResolutionX 
float onePixel(height) = yHeight / screenResolutionY 

^ And now if onePixel(width) and onePixel(height) are not 1.0000f , just move in those units to left and right with your camera.

\$\endgroup\$
  • \$\begingroup\$ Hey! Thanks for your answer. 1) I already store the actual camera position and the rounded one separately. 2) I use LibGDX's OrthographicCamera so that hopefully should work like that. (You're using Unity, right?) 3-5) I already do those in my game. \$\endgroup\$ – Joschua Sep 2 '16 at 9:10
  • \$\begingroup\$ Okay -then you need to find out how much units 1 pixel is. it could be screen resolution dependent. I edited "5." in my answer post. try this. \$\endgroup\$ – OC_RaizW Sep 2 '16 at 10:54
0
\$\begingroup\$

I'm not familiar with libgdx but have experience similar problems elsewhere. I think the problem lies in the fact that you are using lerp and potentially error in floating point values. I think a possible solution to your problem is to create your own camera location object. Use this object for your movement code and update it accordingly and then set the camera's position to the nearest wanted (integer?) value of your location object.

\$\endgroup\$
  • \$\begingroup\$ First of all, thanks for your insight. It really may have something to do with floating point errors. However the problem (of y increasing abnormally) was also present before I was using lerp. I actually added it yesterday so people could also see the other problem, where the size of sprites don't stay constant when the camera is approaching. \$\endgroup\$ – Joschua Sep 1 '16 at 9:01

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.