[libGDX] Subpixel smooth spite movement

Hello Guys,

Help needed !
I tried to achieve smooth sub-pixel scrolling for my pixel game.
Below approach works greate:
https://code-disaster.com/2016/02/subpixel-perfect-smooth-scrolling.html

But when I add sprite to MellowGame.java code and start to move that sprite across tilemap it turned out that it moves only in pixel intervals.
(e.g. i can’t move that sprite in 1.5 pixels).

I am using tileRenderer’s batch to draw sprite:


sceneFrameBuffer.begin();

tiledMapRenderer.setView(sceneCamera);
tiledMapRenderer.render();
batch.begin();
batch.setProjectionMatrix(sceneCamera.combined);
playerSprite.draw(tiledMapRenderer.getBatch());
batch.end();

sceneFrameBuffer.end();

Maybe someone know how can smooth sub-pixel sprite movement can be achieved with that upscaled approach?

Thanks in advance for help !

Here is code I used (shaders were not modified):


package com.codedisaster.mellow;

import com.badlogic.gdx.*;
import com.badlogic.gdx.graphics.*;
import com.badlogic.gdx.graphics.g2d.*;
import com.badlogic.gdx.graphics.glutils.*;
import com.badlogic.gdx.maps.tiled.TiledMap;
import com.badlogic.gdx.maps.tiled.TmxMapLoader;
import com.badlogic.gdx.maps.tiled.renderers.OrthogonalTiledMapRenderer;
import com.badlogic.gdx.math.*;

import static com.badlogic.gdx.Input.Keys;

public class MellowGame extends ApplicationAdapter {

	private static final float PIXELS_IN_UNIT = 16.0f;
	private static final float PIXELS_IN_UNIT_INV = 1.0f / PIXELS_IN_UNIT;

	private static final float CAMERA_WIDTH_UNITS = 16f;
	private static final float CAMERA_HEIGHT_UNITS = 12f;

	private static final int FRAMEBUFFER_WIDTH_PXL = (int) (CAMERA_WIDTH_UNITS * PIXELS_IN_UNIT);
	private static final int FRAMEBUFFER_HEIGHT_PXL = (int) (CAMERA_HEIGHT_UNITS * PIXELS_IN_UNIT);

	private static final int UPSCALE = 4;

	public static final int SCREEN_WIDTH_PXL = FRAMEBUFFER_WIDTH_PXL * UPSCALE;
	public static final int SCREEN_HEIGHT_PXL = FRAMEBUFFER_HEIGHT_PXL * UPSCALE;

	/*
		Acceleration and dampen factors to make WASD movement non-linear.
	 */

	private SpriteBatch batch;
	private ShaderProgram mellowShader;

	private TiledMap tiledMap;
	private OrthogonalTiledMapRenderer tiledMapRenderer;

	private FrameBuffer sceneFrameBuffer;
	private OrthographicCamera sceneCamera;
	private Matrix4 screenProjectionMatrix = new Matrix4();

	private Vector2 cameraPosition = new Vector2();
	private Vector2 cameraDirection = new Vector2();

	private static final float SPEED = 0.05f;
	private static final float INV_SPEED = 1.0f - SPEED;

	private Vector2 playerPosition = new Vector2();
	private Vector2 playerPositionScaled = new Vector2();
	private Vector2 playerVelocity = new Vector2();
	private Sprite playerSprite;

	@Override
	public void create () {
		batch = new SpriteBatch();
		mellowShader = new ShaderProgram(Gdx.files.internal("shaders/mellow.vsh"), Gdx.files.internal("shaders/mellow.fsh"));
		tiledMap = new TmxMapLoader().load("maps/test2.tmx");
		tiledMapRenderer = new OrthogonalTiledMapRenderer(tiledMap, PIXELS_IN_UNIT_INV, batch);
		int mapWidth = tiledMap.getProperties().get("width", int.class);
		int mapHeight = tiledMap.getProperties().get("height", int.class);
		sceneFrameBuffer = new FrameBuffer(Pixmap.Format.RGBA8888, FRAMEBUFFER_WIDTH_PXL, FRAMEBUFFER_HEIGHT_PXL, false);
		sceneFrameBuffer.getColorBufferTexture().setFilter(Texture.TextureFilter.Nearest, Texture.TextureFilter.Nearest);

		// hero
		Texture t = new Texture("hero.png");
		playerPosition.set(0.5f * mapWidth, 0.5f * mapHeight);
		float width = t.getWidth() * PIXELS_IN_UNIT_INV;
		float height = t.getHeight() * PIXELS_IN_UNIT_INV;
		float x = playerPosition.x - width/2;
		float y = playerPosition.y - height/2;
		playerSprite = new Sprite(t);
		playerSprite.setBounds(x, y, width, height);

		// initial camera position at center of map
		cameraPosition.set(0.5f * mapWidth, 0.5f * mapHeight);

		sceneCamera = new OrthographicCamera(CAMERA_WIDTH_UNITS, CAMERA_HEIGHT_UNITS);
		sceneCamera.position.set(cameraPosition, 0f);
		sceneCamera.update();

		//Gdx.graphics.getWidth() - 1024, Gdx.graphics.getHeight() - 768
		screenProjectionMatrix.setToOrtho2D(0, 0, Gdx.graphics.getWidth(), Gdx.graphics.getHeight());

		// input
		Gdx.input.setInputProcessor(new Input());
	}

	@Override
	public void render () {

		float dT = Gdx.graphics.getDeltaTime();

		float dx = playerVelocity.x * dT * 10;
		float dy = playerVelocity.y * dT * 10;
		playerPosition.add(dx, dy);
		playerSprite.setPosition(playerPosition.x, playerPosition.y);

		playerPositionScaled.set(playerPosition.x, playerPosition.y);
		cameraPosition.scl(INV_SPEED);
		playerPositionScaled.scl(SPEED);
		cameraPosition.add(playerPositionScaled.x, playerPositionScaled.y);

		float sceneX = cameraPosition.x;
		float sceneY = cameraPosition.y;

		// snap camera position to full pixels, avoiding floating point error artifacts

		float sceneXPxl = MathUtils.floor(sceneX * PIXELS_IN_UNIT) / PIXELS_IN_UNIT;
		float sceneYPxl = MathUtils.floor(sceneY * PIXELS_IN_UNIT) / PIXELS_IN_UNIT;

		// set camera for rendering to snapped position

		sceneCamera.position.set(sceneXPxl, sceneYPxl, 0.0f);
		sceneCamera.update();

		// calculate displacement offset: for UPSCALE=4, this results in (integer) offsets in [0..3]

		float upscaleOffsetX = (sceneX - sceneXPxl) * PIXELS_IN_UNIT * UPSCALE;
		float upscaleOffsetY = (sceneY - sceneYPxl) * PIXELS_IN_UNIT * UPSCALE;

		// subpixel interpolation in [0..1]: basically the delta between two displacement offset values

		float subpixelX = upscaleOffsetX - MathUtils.floor(upscaleOffsetX);
		float subpixelY = upscaleOffsetY - MathUtils.floor(upscaleOffsetY);

		upscaleOffsetX -= subpixelX;
		upscaleOffsetY -= subpixelY;

		// render tilemap to framebuffer

		Gdx.gl20.glEnable(GL20.GL_SCISSOR_TEST); // re-enabled each frame because UI changes GL state
		HdpiUtils.glScissor(0, 0, SCREEN_WIDTH_PXL, SCREEN_HEIGHT_PXL);

		Gdx.gl.glClearColor(0.0f, 0.0f, 0.3f, 1.0f);
		Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);

		sceneFrameBuffer.begin();

		tiledMapRenderer.setView(sceneCamera);
		tiledMapRenderer.render();
		batch.begin();
		batch.setProjectionMatrix(sceneCamera.combined);
		playerSprite.draw(tiledMapRenderer.getBatch());
		batch.end();

		sceneFrameBuffer.end();

		// render upscaled framebuffer to backbuffer
		// viewport/scissor adjust for artifacts at right/top pixel columns/lines

		HdpiUtils.glViewport(UPSCALE / 2, UPSCALE / 2, SCREEN_WIDTH_PXL, SCREEN_HEIGHT_PXL);
		HdpiUtils.glScissor(UPSCALE / 2, UPSCALE / 2, SCREEN_WIDTH_PXL - UPSCALE, SCREEN_HEIGHT_PXL - UPSCALE);

		batch.begin();
		batch.setShader(mellowShader);
		batch.setProjectionMatrix(screenProjectionMatrix);
		mellowShader.setUniformf("u_textureSizes", FRAMEBUFFER_WIDTH_PXL, FRAMEBUFFER_HEIGHT_PXL, UPSCALE, 0.0f);
		mellowShader.setUniformf("u_sampleProperties", subpixelX, subpixelY, upscaleOffsetX, upscaleOffsetY);
		batch.draw(sceneFrameBuffer.getColorBufferTexture(), 0, SCREEN_HEIGHT_PXL, SCREEN_WIDTH_PXL, -SCREEN_HEIGHT_PXL);
		batch.end();

		// reset scissor

		batch.setShader(null);
		HdpiUtils.glScissor(0, 0, SCREEN_WIDTH_PXL, SCREEN_HEIGHT_PXL);
	}

	@Override
	public void dispose() {
		Gdx.input.setInputProcessor(null);
		tiledMapRenderer.dispose();
		tiledMap.dispose();
		mellowShader.dispose();
		batch.dispose();
	}

	private class Input extends InputAdapter {

		@Override
		public boolean keyDown(int keycode) {
			float value = 0.05f;
			switch (keycode) {
				case Keys.A:
					MellowGame.this.cameraDirection.x = -value;
					MellowGame.this.playerVelocity.set(-value, 0);
					return true;
				case Keys.D:
					MellowGame.this.cameraDirection.x = value;
					MellowGame.this.playerVelocity.set(value, 0);
					return true;
				case Keys.W:
					MellowGame.this.cameraDirection.y = value;
					MellowGame.this.playerVelocity.set(0, value);
					return true;
				case Keys.S:
					MellowGame.this.cameraDirection.y = -value;
					MellowGame.this.playerVelocity.set(0, -value);
					return true;
			}

			return false;
		}

		@Override
		public boolean keyUp(int keycode) {
			MellowGame.this.playerVelocity.set(0, 0);
			switch (keycode) {
				case Keys.A:
					MellowGame.this.cameraDirection.x = 0.0f;
					return true;
				case Keys.D:
					MellowGame.this.cameraDirection.x = 0.0f;
					return true;
				case Keys.W:
					MellowGame.this.cameraDirection.y = 0.0f;
					return true;
				case Keys.S:
					MellowGame.this.cameraDirection.y = 0.0f;
					return true;
			}

			return false;
		}
	}

}


Well, that doesn’t work because the upscaling/scrolling is done as a post-render effect, using the final frame rendered in low resolution.

I did a brief experiment with rendering “kind-a sub-pixel moving” sprites a while back. It’s… difficult. One approach I tried is to render moving sprites into a separate frame buffer, at higher (4x, 8x) resolution, and then blend it accordingly before (final scene composition) or during the upscale/smooth-scroll render pass. My results were… mixed. It looked quite promising, but at some point you are starting to fight with blending artifacts.

I believe the “easiest” method would be to render moving sprites at a scale matching the upscale factor. e.g. if the upscale shader does a x3 scaling, render the sprites into a frame buffer 3x the size of the primary render buffer. Then, in the upscale shader, mix/mask background with sprites, but before subsampling. That would not give you true “sub-pixel”, but just “1/3 pixel” movement for sprites.

Hello, thanks for reply and approach described!
For now, I will not use that upscale approach. On one hand it gives smooth camera scrolling, but on another it tricky to achieve sub-pixel sprite movement, which is important for my game, since some objects can move very slowly (and without sub-pixel movement it looks discrete).
As workaround I do the following:

  1. set width and height in units visible on screen (e.g. 10x6), 1 unit = 32 pixels (PPU).
  2. set game resolution - 320x192 (1032x632)
  3. set upscale factor - 4 (SCALE_FACTOR), so game window resolution will be 1280x768 (3204x1924)
  4. following code used for camera position calculation:

        Vector2 cameraPosition = cameraSystem.getCameraPosition();
        float sceneX = cameraPosition.x;
        float sceneY = cameraPosition.y;
        float sceneXPxl = MathUtils.round(sceneX * Const.PPU * SCALE_FACTOR) / (Const.PPU * SCALE_FACTOR);
        float sceneYPxl = MathUtils.round(sceneY * Const.PPU * SCALE_FACTOR) / (Const.PPU * SCALE_FACTOR);
        cameraSystem.getCamera().position.set(sceneXPxl, sceneYPxl, 0.0f);
        cameraSystem.getCamera().update();

It snap (rounds) camera position to game ‘window’ pixels, which is 1/4 of game pixels. On very slow camera movement it is looks discrete.
5) Sprites still can move in sub-pixel intervals since no render to texture done with subsequent upscale render.