Smooth Parallax Scrolling for Low Res 2D Game

Is there a viable solution for this? Background parallax layers have a very small threshold for movement less than one pixel a frame before their scrolling becomes choppy (at 60fps). You can have the layer move 1px every 2, 3, or 4 frames and still sell the illusion of smooth movement, but any movement smaller than that (1px every 5 or more frames) and the illusion of smooth movement is broken. I’ve Google’d this pretty extensively and haven’t come up with much…

I don’t think there’s really a solution to your problem, because you can’t really move anything less than 1 px.

The only way is to give yourself more pixels you can move to: draw to a higher resolution and then scale it down.

The only thing I can think of is drawing a slightly “animated” layer. Think like different results of antialiasing, which could give the illusion of subpixel movement.

For example if you want a black pixel to move slowly, first you show a gray pixel to the left and a black pixel in the center, then just the black pixel in the center, then the black pixel in the center with a gray pixel to the right. The black pixel hasn’t moved, but there appears to be rightward motion.

Generally you can do subpixel movement by rendering a texture with linear filtering and using non-integer position values. I wasn’t sure if Spritebatch let’s you do that, so I did a quick search. It looks like it should be possible, because it worked in XNA, but there’s still an open bug that rounds positions to integers: https://github.com/MonoGame/MonoGame/issues/2978
Using a custom shader you can definitely do it though.

Thanks for the responses, guys! I’ll continue looking into this and, if I find a solid solution, I’ll update this thread!

What kind of Draw () are you using?
The one that takes a dstRectangle will round to pixels.
You 'd have to use the one that takes a Vector2 position.

I’m passing a Vector2 position into SpriteBatch. I’m also passing a transformation matrix into it. I think maybe the issue I’m facing is unavoidable. It’s possible I can’t scroll something smoothly when trying to move it 3px a second at 60fps (so 0.05px a frame). At some point the illusion of smooth movement just breaks.

You could maybe achieve that effect with double drawing and alpha blending based on the fractional portion of the position. If that makes sense i sort of did something like that for a fading smoky cloud effect in xna were images would blend in and out using spritebatch and it moved super slow but the effect was eerily haunting though i was using about 3 images.
Basically a per pixel level fade in or out.

Try if you can mix a 3D Quad as your Parallax Scrolling for the Background for smooth translation ^ _ ^ Y

What’s the problem exactly?
You don’t see the sprite drawn at fractions of a pixel or the sync is off?

I want to move far back parallax layers very slowly but still have it look smooth and not jerky or judder. But… I think it’s just the nature of things once movement is so slow when using 2d pixel art.

using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace SubpixelScrollingExample
{
    /// <summary>
    /// This is the main type for your game.
    /// </summary>
    public class Game1 : Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;
        Texture2D pixel;
        RenderTarget2D background;

        Vector2[] points = new Vector2[50];

        int width = 640;
        int height = 360;
        float offset = 0;

        int pxWidth = 16;
        int pxHeight = 16;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            graphics.PreferredBackBufferWidth = width;
            graphics.PreferredBackBufferHeight = height;
            graphics.SynchronizeWithVerticalRetrace = false;
            IsFixedTimeStep = true;
            TargetElapsedTime = new TimeSpan(166667); // 16.6667 ms
            Content.RootDirectory = "Content";
        }

        /// <summary>
        /// Allows the game to perform any initialization it needs to before starting to run.
        /// This is where it can query for any required services and load any non-graphic
        /// related content.  Calling base.Initialize will enumerate through any components
        /// and initialize them as well.
        /// </summary>
        protected override void Initialize()
        {
            var rand = new Random();

            for (int i = 0; i < points.Length; i++)
                points[i] = new Vector2((int)(rand.NextDouble() * (width - pxWidth) * 2), (int)(rand.NextDouble() * (height - pxHeight)));

            base.Initialize();
        }

        /// <summary>
        /// LoadContent will be called once per game and is the place to load
        /// all of your content.
        /// </summary>
        protected override void LoadContent()
        {
            // Create a new SpriteBatch, which can be used to draw textures.
            spriteBatch = new SpriteBatch(GraphicsDevice);

            pixel = new Texture2D(GraphicsDevice, 1, 1);
            pixel.SetData(new[] { Color.White });

            background = new RenderTarget2D(GraphicsDevice, graphics.PreferredBackBufferWidth + 1, graphics.PreferredBackBufferHeight);
        }

        /// <summary>
        /// UnloadContent will be called once per game and is the place to unload
        /// game-specific content.
        /// </summary>
        protected override void UnloadContent()
        {
            // TODO: Unload any non ContentManager content here
        }

        /// <summary>
        /// Allows the game to run logic such as updating the world,
        /// checking for collisions, gathering input, and playing audio.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            if (Keyboard.GetState().IsKeyDown(Keys.Right))
                offset += 0.0625f;

            if (Keyboard.GetState().IsKeyDown(Keys.Left))
                offset -= 0.0625f;

            base.Update(gameTime);
        }

        /// <summary>
        /// This is called when the game should draw itself.
        /// </summary>
        /// <param name="gameTime">Provides a snapshot of timing values.</param>
        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);

            if (Keyboard.GetState().IsKeyDown(Keys.X))
            {
                GraphicsDevice.SetRenderTarget(background);
                spriteBatch.Begin();
                for (int i = 0; i < points.Length; i++)
                {
                    spriteBatch.Draw(pixel, new Vector2(points[i].X - (float)Math.Floor(offset), points[i].Y), null, Color.White, 0, Vector2.Zero, new Vector2(pxWidth, pxHeight), SpriteEffects.None, 0);
                }
                spriteBatch.End();
                GraphicsDevice.SetRenderTarget(null);
                spriteBatch.Begin();
                spriteBatch.Draw(background, new Vector2(-(offset - (float)Math.Floor(offset)), 0), Color.White);
                spriteBatch.End();
            }
            else
            {
                spriteBatch.Begin();
                for (int i = 0; i < points.Length; i++)
                {
                    spriteBatch.Draw(pixel, new Vector2(points[i].X - offset, points[i].Y), null, Color.White, 0, Vector2.Zero, new Vector2(pxWidth, pxHeight), SpriteEffects.None, 0);
                }
                spriteBatch.End();
            }

            base.Draw(gameTime);
        }
    }
}

If I understand your problem correctly, then here’s one way to solve it. This code assumes integer position of all sprites drawn on the background layer. Left/right arrows to move the camera (really slowly) and hold X to show the smooth drawing method (without X it’s the jittery way). It was thrown together in under half an hour, so there may be some bugs, but the general method is simple - draw your background to a RenderTarget2D and offset that thing by scrollOffset % 1. Be sure to have a linear SamplerState in the second SpriteBatch.Draw(), otherwise bad things may happen (namely it probably won’t work). Also notice that the background render target is one wider than the viewport - this is intentional (and hopefully correct…).

I haven’t had a chance to try this yet… it appears you are truncating the point position by rounding down, rendering all points to render target, then shifting the render target by the movement decimal amount you truncated/discarded when moving the points. I’m not sure why this would be different from adjusting the point position by the non-truncated offset amount considering the two SpriteBatch calls have identical parameters outside of the Vector2 position parameter… meaning, you are not changing the SamplerState setting or something else that would affect how either the points or the rendertarget2d are drawn and the sub-pixel values are handled/accounted for…

I did a little test, and it turns out SpriteBatch works just fine for what you are trying to do. Not sure why you have problems with this. I can move a sprite so slowly, you can’t even tell anything is changing at all, just by looking at it.

You will loose “pixel-perfectness”, that means the sprite can get a bit more blurry whenever it’s between integer positions. Depending on your artwork that could be more or less apparent.

The Github issue I linked in my first post must have been resolved by now, or does not apply to DirectX.

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace Game1
{
    public class Game1 : Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;
        Texture2D tex;
        float x;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }

        protected override void LoadContent()
        {
            spriteBatch = new SpriteBatch(GraphicsDevice);
            tex = Content.Load<Texture2D>("test");
        }

        protected override void Update(GameTime gameTime)
        {
            if (Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            float speed = 1; // pixels per second
            x += (float)(gameTime.ElapsedGameTime.TotalSeconds) * speed;

            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            spriteBatch.Begin();
            spriteBatch.Draw(tex, new Vector2(x, 10), Color.White);
            spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}

1 Like

Right, so my issue is that I’m forcing my cam positions (I have a camera position for each parallax layer) to int via division. This was done in an effort to get rid of jittery movement of further back parallax layers under certain conditions/player movements, and it worked, but it now introduces this problem. So, I will just have to figure out something else or decide which I can live with more. Thanks for everyone’s input and suggestions!