Texture magnification antialiasing
There are several uses for upscaling pixel art, here I consider the following cases:
- fitting a smaller game screen (e.g. 160x144) to a modern resolution (e.g. 1280x1080)
- preserving a "pixel art" look for textured 3D models
The main problem with upscaling pixel art is when the the upscaled size is not an integer multiple of the original size. If you use nearest filtering then your pixel sizes will be uneven; this can be particularly jarring for animations where e.g. a part of a sprite appears to change size as it moves across the screen.[TODO: Add images.] You can't use (bi)linear filtering either, because your pixels will be smeared out into something that no longer looks like pixel art at all (specifically, the problem is that pixel values are interpolated across the whole upscaled pixel rather than just on pixel borders).
One solution for case 1 above is to only upscale to the nearest integer ratio. This will add a border to the image, however, and depending on the actual ratio (and the actual sizes!) this may be highly undesirable (e.g. it wastes a lot of screen space or does not magnify the image sufficiently). For case 2 this doesn't work at all, since the scale factor is not uniform across the screen.
What we really want is a filtering algorithm that antialiases only the thin line between the (low-res) pixels. As far as I know, there is no magnification filter in OpenGL that can do this, but we can do it ourselves in a shader.
Fragment shader
The first thing we need to do is to calculate the size (in full-scale pixel units) of a single low-res pixel. For case 1 above, where pixel sizes are uniform, we can just use small_screen_size / big_screen_size. For case 2, we can use fwidth().[TODO: Explain exactly how this works.]
We now need to test if a fragment is within half a pixel of the border of its texel (so within half a pixel of the top, bottom, left, or right in texture space); in that case, we need to blend with the neighbouring texel. The blend factor will depend on how close we are. The reason for using half a pixel instead of one pixel is that the 1 pixel border is shared between two texels, so by checking half a pixel on the right in the left texel and half a pixel on the left in the right texel we effectively get a 1 pixel border when you put them next to each other.[TODO: Add illustration.]
// tex is the texture to sample
// uv is the texture coordinate ranging from 0 to small_screen_size
#if 0
vec2 border = .5 * fwidth(uv);
#else
vec2 border = .5 * small_screen_size / big_screen_size;
#endif
vec2 uvf = fract(uv);
// the main color of the texel, assuming we're not at a border
vec4 col = texelFetch(tex, ivec2(uv), 0);
vec4 xcol = col;
if (uvf.x < border.x)
xcol = mix(texelFetchOffset(tex, ivec2(uv), 0, ivec2(-1, 0)), col, uvf.x / border.x);
else if (1. - uvf.x < border.x)
xcol = mix(texelFetchOffset(tex, ivec2(uv), 0, ivec2(1, 0)), col, (1. - uvf.x) / border.x);
vec4 ycol = col;
if (uvf.y < border.y)
ycol = mix(texelFetchOffset(tex, ivec2(uv), 0, ivec2(0, -1)), col, uvf.y / border.y);
else if (1. - uvf.y < border.y)
ycol = mix(texelFetchOffet(tex, ivec2(uv), 0, ivec2(0, 1)), col, (1. - uvf.y) / border.y);
Finally, to account for those borders that sit at the boundary of 4 texels we just take the average of the two values we got in each direction:
output_color = mix(xcol, ycol, .5);
Note that the actual texture sampler must not use any hardware texture filtering! E.g. for GL_TEXTURE_MAG_FILTER it should be using GL_NEAREST; for GL_TEXTURE_MIN_FILTER it might be fine to use linear interpolation or even mipmapping.
See also
- https://en.wikipedia.org/wiki/Pixel-art_scaling_algorithms (Has many good pointers/references, but does not mention the algorithm above.)