Welcome to the start of a dev blog for ROBOT-SB, a retro game concept I’m currently working on. It’ll be a low-resolution game, which presents some challenges, and I thought I’d start by talking about setting up the display to support it.
One of the initial challenges is getting a “pixel-perfect” display across all of the target devices, given the wide variety of display resolutions (and thus wide variety of content scaling factors, using Corona SDK’s lingo). Internally, the game should deal with low-resolution “content” dimensions which are then scaled up (and aspect-adjusted, if necessary) to fill the device pixels.
That is, I don’t want high-resolution content dimensions, faking the pixel look with “blocky” high-resolution assets. Rather, I’d like native @1x pixel-art assets like this…
…to scale up as necessary and look like this on device…
…without having to think too much about it.
Well, for starters, a “pixel-perfect” display occurs when there is a integral (integer) scaling factor (or its reciprocal, depending on how you arrange the terms) between device pixels and content coordinates. For example, imagine a 320×480 pixel device with 160×240 content, giving a 2.0 (or 0.5) scaling ratio:
At this point, a developer might be tempted to just dive right in and start tweaking their “config.lua” file (the file used by Corona SDK to set content dimensions, et al) to achieve this effect. Simply set width=160, height=240 and done, right? Well, maybe – that would at least work on an actual 320×480 device, but may not work as expected on other device resolutions or aspect ratios… yet.
Because it’s not just the scale, but also the overall alignment of those two “grids”, and letterboxing and centering and other such things can get in the way of perfect alignment. (in Corona SDK you’d see negative values for screenOriginX|Y when this occurs) So, before we jump into config.lua, let’s think about alignment by pondering a few things worth noting in the diagrams above…
Device pixels are physical things, each having a non-zero area. The coordinate of a pixel, say the pixel at [0,0], represents the entire pixel “cell”. You can speak theoretically of fractional pixel coordinates, for example when discussing aliasing effects, but they don’t actually exist. For example, there is no discrete hardware pixel at the coordinate [0.5,0.5]. The diagram above shows the device pixel coordinates at the half-pixel location, to imply that the entire hardware pixel “cell” is represented by those integer coordinates.
Content coordinates are non-physical things, mathematical abstractions derived from the underlying OpenGL view model, and represent precise zero-area locations along each axis. Fractional content coordinates do exist, unlike fractional pixel coordinates. For example, it is entirely reasonable to talk about the content coordinate [0.25,0.25], which at the scale indicated would represent the center of device pixel [0,0]. The diagram above shows the content coordinates “on” the lines because that is how they function within OpenGL.
For example, given the scale indicated above, to draw a “perfect pixel” using a rectangle (we’ll keep it simple with “pure geometry” and put off a discussion of raster images and texture filtering until later) you could do the following:
local rect = display.newRect( 0.75, 0.75, 0.5, 0.5)
Remember that the scaling factor is 2:1, that’s why we create a rectangle with content dimensions 0.5×0.5 to get device dimensions of 1×1. Also remember that, by default in Corona SDK, display objects are positioned via their centers – content coordinate [0.75,0.75] is at the center of device pixel [1,1]. So, this will create a single “perfect pixel” at device coordinates [1,1].
Well then, what would have happened if we had instead created that rectangle centered on integer content coordinates? That’s the failure scenario, resulting in a non-pixel-perfect display, and it’s instructive to understand why:
local rect = display.newRect( 1, 1, 0.5, 0.5)
When the device’s GPU attempts to rasterize that geometry it’s going to find that it partially overlaps four hardware pixels, and (by default) will generate a filtered image, whereby a portion of the geometry contributes to each of those four pixels.
So now let’s get away from “diagrams” and present actual on-device results. Using a config.lua where width and height are not specified will give content dimensions that match the device – and this is a great place to start experimenting because it’s “easy” to then address specific device pixels with content coordinates and test for alignment/aliasing effects.
I used this code to compare properly and improperly aligned images against the device pixel grid:
local goodMusic = display.newImage("music.png", 20, 20)
local badMusic = display.newImage("music.png", 50.5, 20.5)
local goodSound = display.newImage("sound.png", 80, 20)
local badSound = display.newImage("sound.png", 110.5, 20.5)
Actual device output:
And scaled up 4X (without filtering) just to see better:
An important thing to note is that simply turning off bilinear filtering is not enough to completely solve the problem (note the left side of the right-most “badSound” image). What really matters is alignment. With proper alignment (as with “goodMusic” and “goodSound”) it doesn’t matter whether you’re using nearest-neighbor or bilinear texture filtering because the image texels and device pixels match perfectly, so no filtering is even needed!
The lesson to be learned is that if you render a non-aligned “pixel” for whatever reason, then you’re going to end up with some sort of unwanted artifacts. Fortunately, now that we’re armed with a bit of understanding about how “fractional pixels” arise in the first place, we can bake the solution (or at least most of it – accounting for the hardware side of things) into our “config.lua” file. More on that next time.