This article describes a method for achieving sub-pixel perfect smooth scrolling for 2D games featuring upscaled pixel art. The method delivers stable results at varying scroll speeds, at a affordable overhead on modern GPU hardware.
With the inspiration of blog posts and talks of other people, who are looking after ingenious ways to apply oldschool gamedev tricks on modern hardware (heya, Oliver!), I started to search for a method to achieve genuinely smooth scrolling for our next game at Robotality.
I seriously doubt I’m the first one to think of this method, but so far I didn’t find it described anywhere.
I’ve written a small libGDX demo application which allows to switch between different camera and sampling options. It uses free tilesets from OpenGameArt.org.
The demo is available > here <. Use
M to switch through different camera & sampling modes.
N toggles the two improved modes only, one with and one without subsampling.
The demo source code is available on my GitHub page.
In PC games, the term “smooth scrolling” made an appearance in the early 1990s with the release of Commander Keen by ID software. Though, in my very own washed-out memories, the PC gaming community definition of smooth seemed bold to me at the time - to me, proud owner of an Amiga 500, featuring special chips exclusively made to let sprites dance and scroll softly over the TV screen at 50 Hz.
In truth they probably didn’t dance that softly at all…
Fast forward 25 years. 2D games today use a wide range of tricks to achieve smooth scrolling. Many rely on rendering at a high and stable framerate, on scrolling at very precise speed, and/or on rendering at full screen resolution.
If not before, the “smoothness” is usually starting to suffer when variable scroll speeds are used.
A prominent example I tend to showcase as a reference is Nuclear Throne by Vlambeer. Powered by GameMaker Studio, it renders the game at a very low resolution, and upscales to a x6 resolution (I counted pixels) on my PC configuration. Different to many other games, the camera is in control of the player alone, who can look around rather freely with his mouse. There isn’t any form of interpolation, so the camera always snaps on this 6x6 pixel grid.
Nuclear Throne is a very fun game, but the not-so-smooth camera movement (plus the Vlambeer Screenshake (tm)) causes me headache.
The technique described assumes that the game renders its view into an off-screen framebuffer, at low resolution. Then, at a later stage, this framebuffer is
upscaled to the full resolution backbuffer. The effect works with any upscale factor, as long as it stays a whole number.
The vertex and fragment shaders used during upscaling must be modified to achieve the effect. Only OpenGL/GLES 2.0 level GPU instructions are used.
The camera position must be used to calculate a few more parameters, which are later passed to the shader program.
- The camera position for actually rendering into the framebuffer is snapped (rounded down) to full pixels. In most games, this step is probably done already to avoid rendering artifacts due to floating point rounding errors.
- Some UV
displacementvalues are calculated. These values are in [0..upscale-1]. They basically describe how many intermediate steps could be done if scrolling was done in full backbuffer resolution.
- Last, a
subsamplingvector with UV in [0..1] is found. Its values describe how much to subsample from one pixel to the next, also at full backbuffer resolution.
After calculation, the parameters meet the following formula, not taking transformation from/to banana units into account.
Applying shader magic
upscaling is done by rendering a fullscreen quad, with the framebuffer color attachment as source texture, and texture UV coordinates sampled in [0..1].
It’s possible to partially apply the effect by modifying UV coordinates on the CPU already. I went for the shader code instead, as the overhead is negligible with just four vertices to process.
displacement vector is used to translate UV coordinates by a few pixels. This is done in the vertex shader.
Then, a bilinear filter is applied in the fragment shader, using the
subsampling vector as interpolation value. In the case of upscaled pixel blocks, this filter results in a far better sampling quality than a conventional linear texture filter.
Filling (hiding) the gaps
As a result of the manipulation of UV texture coordinates, the texture sampler reads up to one pixel beyond the framebuffer texture content. This leads to noticable artifacts on the upper and right sides of the screen.
A very easy solution to hide these artifacts is to manipulate viewport and scissor settings. The viewport is simply displaced, and the scissor rectangle downsized by a few pixels:
Don’t forget to reset the viewport settings to their previous values afterwards.
This effect works arguably well at low framerates, e.g. in games locked at 30 fps. While it cannot do miracles, it still improves the player experience in my opinion.
By default, it does not work well with parallax scrolling, if the parallax layers are rendered into the same framebuffer as the main scene. While I did not try yet, I believe this can be mitigated by rendering each layer to its own framebuffer, sampling all of them with adjusted displacement/subsample vectors.