18
votes

Short version:

Is there a general approach in OpenGL that will allow pixel-perfect 2D drawing of textures from an atlas when drawn at native size (including edge pixels), and good quality scaling when filtering to non-native size?

More detail on the problem:

Suppose you’re writing some kind of system for drawing sprites from a texture atlas, such that the user can specify the sprite, and the size and location at which it is to be drawn. Additionally, they may want only to draw a sub-rectangle of the sprite.

For instance, here is a 64x64 checkerboard:

checkerboard

...and alone in its texture atlas:

checkerboard in texture atlas

Note: We’ll assume that the viewport is set up to map 1:1 to device pixels.

Approaches

Edit for clarity: note that the first 2 approaches below do not give the desired result, and are there specifically to outline what doesn’t work.

1. To draw the sprite at native size, simply use GL_NEAREST

  • set OpenGL to use GL_NEAREST min/mag filtering
  • to draw at position (x,y), place vertices from (x,y) to (x+64,y+64)
  • use texture coordinates (0,0) -> (64/atlas_size,64/atlas_size)

This is fine for drawing at native size, but NEAREST filtering gives poor results when the user draws the object at a size other than 64x64. It also forces texels to align to the pixel grid, giving poor results when the object is drawn at non-integer pixel locations:

drawn with gl_nearest at pixel offset of 0.25drawn with gl_nearest at pixel offset of 0.5

2. Enable GL_LINEAR

  • With linear filtering, we need to shift our uv coords to the centre of the texels: (0.5/atlas_size,0.5/atlas_size) to (63.5/atlas_size,63.5/atlas_size). Otherwise, for the outermost pixels, linear filtering will sample the sprite’s neighbours in the atlas.
  • We then also need to modify our vertices, since continuing to use vertices from (0,0) to (64,64) will then stretch the texture by 1px in both directions, like so:

drawn with uv coords shifted in by 0.5 texels, resulting in stretching

  • we therefore need use vertices from (0.5,0.5) to (63.5,63.5). Generally, when the texture is drawn at a ratio of its native size (a,b) we will need to shift our vertices “inwards” by, I believe, (a/2,b/2). The gives the following result (on a purple background):

drawn with uv shifted in by 0.5 texels, and vertices by 0.5 pixels

Note that we get pixel accurate drawing, except for edge pixels, which are blended with the background, since the vertex boundary falls halfway between pixels.

Edit: also note, this behaviour also depends on whether antialiasing is enabled. If not, the previous approach does in fact give pixel perfect rendering, but doesn’t provide a good transition when the sprite moves from a pixel-aligned to a sub-pixel position. Plus antialiasing is a must in many cases.

3. Pad sprites in the texture atlas

The two obvious solutions to the edge pixel problem involve padding the edge of the sprite with a 1px border in the texture atlas. You could either:

  • pad with 1 layer of transparent pixels, and expand vertices by (0.5a,0.5b) rather than contracting them
  • pad with a second copy of the sprite's outermost pixels, and go back to sampling the texture from (0,0) to (64/atlas_size,64/atlas_size)

This basically gives us pixel accurate drawing with linear scaling. However, if we then allow the user to draw only a sub-rect of a sprite, either of these approaches fails because the sub-rect obviously does not have the required padding.

Have I missed anything? Is there a general solution to this problem?

1
Everyone I've talked to in-industry has always told me that padding is the best approach; I haven't heard of a more elegant solution.Matt Kline
I agree, padding is the best option. I also don't know how to deal with sub-rects though, but that has nothing to do with texture atlases.Andreas Haferburg
Thanks slavik & andreas, I thought as much. Andreas, no, you're quite right, am just thinking in terms of atlases here! Ofc, when dealing with drawing entire textures, you can just use CLAMP_TO_EDGE. The same problem arises with atlas’d sprites as with sub-rects, but in the former case, padding is available as a possible solution (whereas it clearly isn’t in the latter!)Benji XVI
Can you use array textures (opengl.org/wiki/Array_Texture)? If you're using shaders and all your sprites on a sheet are the same size they have the advantage that you can use GL_CLAMP_TO_EDGE (or GL_REPEAT) and sprites will never borrow texels from other sprites.GuyRT
Thanks Guy, Texture Arrays look like an interesting solution up to a reasonably large number of textures (Querying GL_MAX_ARRAY_TEXTURE_LAYERS returns 512 on this MBP). However, they aren’t available on OpenGL ES, which rules it out for my case. Padding would seem to be the necessary course here.Benji XVI

1 Answers

2
votes

It's difficult to know exactly what the "Right Thing" you're looking for is. If you have a 64x64 sprite, and you want to scale it to 65x65 or to 63x63, no amount of filtering is going to make it look good. When you throw antialiasing into the mix, remember that multisampling is not supersampling, and so your textures won't magically get softer interiors.


That said, really non-nearest filtering is supposed to work nicely out of the box with multisampling. I think your GL_LINEAR approach is on the right track, but I think you may have implementation issues.

In particular, linear filtering is supposed to filter when along texel boundaries. This can happen, for example, if you have two adjoining triangles. Point of fact, you should expect linear filtering along sprite edges, and this filtering is the Right Thing.

You shouldn't try to correct for this by adjusting texture coordinates (and therefore vertices), since this will incorrectly scale the texture coordinate across the texture. I'd instead recommend clamping texture coordinates to the desired range plus/minus 0.5/texture_res in your shader.

You'll find that this solves the problem for pixel-perfect results at native scaling and also good quality magnification. Minification will look okay, but I'd recommend trilinear (mipmap) filtering for best results.