Let’s now return to the discussion of “pixel perfect” displays. (You might want to first read part 1 if you missed it) This is a mainly discussion for developers that might be looking to set up a display for “retro” or “low-resolution pixelated” games, however there are aspects of it (avoiding aliasing) that might have wider application.
Here’s a sneak peak at where all of this is heading – actual on-device screenshot (click to see full-size at effective 5X resolution):
But first, the bad news: If you’re expecting a one-size-fits-all solution that’ll cover every possible setup, forget it. There are just too many “weird” display resolutions out there. Something, somewhere, has to be “flexible” in order to adapt to all those various conditions, but there are several of those “potentially flexible things”, and the one you choose might not match the one I chose.
But second, the good news: In the process of describing what worked for me, you ought to be able to pick up enough foundational insight to craft something that’ll work for you. Most of the techniques are pretty simple, but they’re most effective when you really understand what’s going on “underneath the hood”.
So, for this installment, I’m going to cover two general things that should apply no matter what specific config.lua is in use. These are things you might want to think about even if you’re not setting up a low-resolution display. Next time we’ll dive into config.lua itself.
To begin with, it will be necessary to disable bilinear filtering. I touched on this topic last time, but didn’t go into much detail or the “why” of it.
Since the goal here is to set up the content dimensions to be intentionally much smaller than the device resolution, we know that any images that are rendered will have to be scaled up. With OpenGL we have the choice of what sampling method is used to accomplish that resampling.
What we want to avoid is the typical “softening” that occurs when up-sampling an image with bilinear filtering. This is caused by interpolating along the x- and y-axes (thus the term bi-linear) to produce in-between colors for the missing pixels. For example, a black next to a white will produce an in-between gray, and so forth. You’ve probably seen this effect even when using Photoshop (or other image editing software), for example @10x magnification:
See https://en.wikipedia.org/wiki/Bilinear_filtering for specifics.
Nearest-neighbor sampling, on the other hand, simply grabs the existing color from the closest* pixel in the source image, without any interpolation. This is the sampling method needed in this case.
*note that if pixel alignment is off, nearest-neighbor sampling can still produce unwanted artifacts, because the “closest” pixel may not be the one you expect.
So at the very least we must disable (set to nearest-neighbor sampling) the magnification filter, the one that is used when enlarging textures. But common practice is to disable the minification filter also, the one used when reducing textures:
local badImage = display.newImage("image1.png", 20, 20)
local goodImage = display.newImage("image2.png", 40, 20)
Or grab the complete source for this example.
Because here’s what happens if you don’t, using a device resolution (640×960) that is 4 times the content resolution (160×240) as an example, on-device results:
Note that this example uses two copies of the same image in order to “trick” the texture cache mechanism into loading the first with bilinear filtering and the second without. The filter setting is recorded with the texture at load time, so if you attempt to alter the filter setting on an image that is still in the cache, it will appear to have no effect. (also note that this applies when loading image sheets as well)
Placing Images At Proper Coordinates
I wonder how many Corona SDK devs are aware that images with odd-numbered dimensions need to be positioned differently from images with even-numbered dimensions? (assuming the default center anchors)
For ROBOT-SB every pixel will matter, and I have a fairly balanced mix of even- and odd-sized rasters, so this is definitely something that affects me.
Why? Because the center of an image with odd-numbered dimensions falls at the half-pixel center of the centermost pixel, while the center of an image with even-numbered dimensions falls on the edge between two centermost pixels.
The condition depicted in the center scenario will badly alias when rendered, because every raster texel is misaligned with the hardware pixels. This is another scenario where using a 1:1 config.lua will help during experimenting, but this time we’ll specify an actual content dimension (the ‘standard’ 320×480) and simply require the demo to run on a device of the same dimensions.
Note that the same effect can occur with any content scale using fractional content coordinates (for example, an image positioned via physics, or during an arbitrary position transition), but it’s more intuitive to recreate the problem at 1:1 scale.
At 1:1 scale we shouldn’t, in theory, need to worry about filtering for this experiment – because a properly positioned image at 1:1 scale won’t need any filtering. In fact, we will intentionally leave bilinear filtering on in order to highlight the issue. (artifacts may still occur even with nearest neighbor sampling, as stated above and demonstrated in prior post, though they’ll be of a different character and less obvious)
local evenImage = display.newImage("even.png", 6, 6)
local badOddImage = display.newImage("odd.png", 12, 6)
local goodOddImage = display.newImage("odd.png", 18.5, 6.5)
Or grab the complete source for this example.
And here’s the on-device result:
Magnified 4X (without filtering) just to see it better:
Once we have a “proper” config.lua, with oversized content “pixels” (though remember: content dimensions are not really pixels, just coordinates), it’ll further be important to avoid placing display objects on fractional oversized-pixels as well. I’ll refer to these oversized content pixels as “retro pixels” – to indicate the supposed smallest addressable unit in the simulated low-resolution retro-display. For example, using the perfect 2:1 content scale from the former post, consider something like this:
local goodPixel = display.newImage("onegreenpixel.png", 0.5, 0.5)
local badPixel = display.newImage("oneredpixel.png", 2, 2)
Both will render fine, without artifacts, because they both properly align with hardware pixels. However, the red image violates the virtual low-resolution of the retro-display by addressing “retro-half-pixels” that should not be separately addressable.
That is, if we really were running at 160×240 hardware pixels then the red image could not be drawn centered at content coordinate [2,2] as shown without having aliasing artifacts. Proper “retro-pixels” occur only on the magenta lines in the illustration above.
And, again, note that for an image with odd dimensions (1×1), it was necessary to position it at half-pixel content coordinates, assuming default center anchoring. (as above)
That’s it for this installment. Next time I hope to get around to actually talking about config.lua.
Until then, you might want to study up on the approach Sergey Lerg presented here: https://github.com/Lerg/smartpixel-config-lua. I certainly don’t intend to claim that I’m the only who has ever tackled this topic! Sergey’s code is essentially intended to solve the same problem, just at a different resolution, so much of what is presented there will be applicable here. (I’m going to flip the problem around a bit though, essentially inverting the math, because it will better suit my particular needs.)