How to create a smooth zoom for 2D games from scratch? (SFML)

Started by
10 comments, last by YesBox 1 year, 11 months ago

How would one go about creating a smooth 2D zoom from scratch?

I'm using SFML/C++ for my game and the built in zoom feature is awful. Unless you're zooming in multiples of 2, the pixels will pop in and out of existence, or be different sizes, and when you move the “view” around, the pixels will flicker and pop in and out of existence some more. It's headache inducing.

I cant stand it. SFML is otherwise perfect for my needs and I'll put in the work myself to work around this thorn. I know better zooms exist (though the different pixel sizes may be unavoidable, at least they wont disappear), so this is possible.

The way I am working around this limitation right now is by having a tile atlas PNG for every zoom level (multiples of sqrt(2)). Obviously this is a lot more work for me, though I created a script to generate each sprite sheet to the appropriate size for each zoom level and adjust as needed.

Any tips would be greatly appreciated.

Advertisement

The bad appearance you mention sounds like nearest neighbour scaling.

Don't do that, use proper interpolation; it's probably a simple matter of setting flags, for example Texture.setSmooth().

Omae Wa Mou Shindeiru

LorenzoGatti said:
The bad appearance you mention sounds like nearest neighbour scaling. Don't do that, use proper interpolation;

Interpolation isn't always welcome when using pixel art. Often you want too see individual blocky low res texels to cover multiple pixels of the high res framebuffer. With interpolation, the desired blocky texels would become a blurry mess.

The easy way to fix this is to upscale the assets without any filter, so each pixel becomes a 4x4 or 2x2 block of the same color for example. With such upscaled textures you can use interpolation and the desired pixel art look is preserved.
Another way to achieve this is to modify UVs in the pixel shader, so you get the same result without a need to upscale textures.

YesBox said:
The way I am working around this limitation right now is by having a tile atlas PNG for every zoom level (multiples of sqrt(2)). Obviously this is a lot more work for me, though I created a script to generate each sprite sheet to the appropriate size for each zoom level and adjust as needed.

You would need to upscale each zoom level texture as above, then you could use them as mip maps with a trilinear filter, so the GPU blends 2 levels automatically. Techneically you get a smooth interpolation then, without any discontinuities.
But if manually made images of different levels differ wildly, blending two of them may still not look great artistically.

It's hard to imagine your situation without seeing some screenshots, but upscaling + filter surely is worth a try.
When i tried SFML, i think i noticed it uses a virtual low res screen resolution like 640 x 400. That's maybe another obstacle where you need to experiment with different settings.

@LorenzoGatti Nearest neighbor scaling is what I need for pixel art, as JoeJ said. I've also already tried setting the setSmooth( true ) in the past and it doesn't solve the problem. It only blends the boarder pixels of the sprite. The interior pixels will still pop in/out of existence.

@JoeJ Coincidentally, I have two YouTube videos that I uploaded, one using the built in view.zoom() here:

https://www.youtube.com/watch?v=3tgZR9p-hQI&t=8s

Notice how the pixels flicker when I move around, and the road paint lines disappear.

And the other using a distinct tile atlas for each zoom level:

https://youtu.be/7q0l87hwmkI?t=274

(both links jump to the appropriate time stamp)

For a great pixel art zoom example, just place some buildings and zoom in this (free) web game: https://flori9.itch.io/the-final-earth-2

I'll try upscaling the pixel art, that makes sense. I'll report back with my findings after I implement it.

You would need to upscale each zoom level texture as above, then you could use them as mip maps with a trilinear filter, so the GPU blends 2 levels automatically. Techneically you get a smooth interpolation then, without any discontinuities.
But if manually made images of different levels differ wildly, blending two of them may still not look great artistically.

I'm trying to avoid having a tile atlas for each zoom level. The reason for the multiples of sqrt(2) was the best approach after multiples of 2 and doesn't drastically change the sprite sizes. However I'd really just like a smooth scaling zoom (much like how pinch zoom works on phones, or in the game I linked above)

YesBox said:
I'd really just like a smooth scaling zoom

That's what mip mapping solves, at least technically.
Because the videos change the zoom factor only in discrete steps, i can not see the issues i would expect form changing the scale gradually and continuously. With a gradual scale, you likely get some visible popping at the moment of switching the used textures.
Mip mapping + interpolation prevents those switches by always using two detail levels of texture and blending them.

However, it does not solve any problems on the art side. E.g. if you use black 1 pixel contours in your sprites, and the downscaled versions also have such 1px borders, blending both will not give sharp and blocky contours we'd expect from pixel art.

YesBox said:
The reason for the multiples of sqrt(2) was the best approach after multiples of 2 and doesn't drastically change the sprite sizes.

Hardware mip mapping only supports multiplies of 2. If that's not enough, you'd need to implement your own interpolation in pixel shaders.
And i realize i do not know if mip map interpolation can be combined with nearest neighbor sampling? For general 3D rendering you'd never want that, but for scaled pixel art it could be useful eventually.

Scaling pixel art is just hard. Early solutions used in Monkey Island or Doom didn't look that good, and modern blending of discrete levels of detail does not magically solve it either.
Some kind of compromise is probably unavoidable, but for your artstyle i'm quite optimistic you arrive at something satisfying…

Upscaling has failed. I tried increasing the sprite sheet size by 400% and SFML just disappears 4x4 pixel blocks of pixels now, so nothing has changed. I also tried setting setSmooth( true ) again, which didn't change the situation.

@JoeJ I dont have OpenGL programming experience, so any resources/tutorials would be helpful regarding the topics mentioned. If I can find a solution then it will save me a lot of time in the future if I can use just one sprite sheet.

I just realized… This is only a problem when zooming out, which means I will need to use interpolation/blending. Darn. Sorry, new to graphics related coding. Too bad setSmooth() only works with the sprite boarders. I take back what I said about needing nearest neighbor. @LorenzoGatti was correct.

YesBox said:
any resources/tutorials would be helpful regarding the topics mentioned.

Here, but not sure if you have such low level access with SFML easily.
Looking up a bit, i guess setSmooth() only enables bilinear filter, but found nothing about mip mapping. Maybe because 2D games usually don't do a lot of scaling.

YesBox said:
I tried increasing the sprite sheet size by 400% and SFML just disappears 4x4 pixel blocks of pixels now, so nothing has changed.

I don't know what you mean. The difference would show only with setSmooth(), and with zooming in (magnification)
To make it work with zooming out (minification), you'd first need to make mip maps work.

@JoeJ Thanks! This looks great.

I don't know what you mean. The difference would show only with setSmooth(), and with zooming in (magnification)
To make it work with zooming out (minification), you'd first need to make mip maps work.

I mean if the road paint line is 1 pixel wide on my 32 x 32 sprite, which can disappear when zooming out and moving the view, upscaling it by 400% doesn't change that behavior. Which vaguely makes sense from the little I understand from what's going on under the hood.

YesBox said:
I mean if the road paint line is 1 pixel wide on my 32 x 32 sprite, which can disappear when zooming out and moving the view, upscaling it by 400% doesn't change that behavior. Which vaguely makes sense from the little I understand from what's going on under the hood.

Ah ok, yes - this stuff will still disappear. The upscaling proposal helps to preserve a pixel art look and enables ‘low res’ pixels on high res frame buffer with support for filtering to get smooth scaling or rotations.
But it does not handle extreme minification, because if the sampling frequency is much higher than your thin lines are wide, we will still miss many of them completely.

Basically we talk about an undersampling problem. There are generally two options to solve this:
1. More samples. We would make many samples inside the square of a frame buffer pixel (covering the whole area of the pixel) and average them. Some of those samples would hit the thin lines, so they would contribute to the average as desired.
2. Prefiltering the domain we want to sample. We would make a lower resolution image, e.g. by using the average of 2x2 texels of the original image to get one pixel for the new image at half resolution. After that, taking just one sample is good enough, because the thin features are already integrated in the texels of the low res image. That's exactly how mip maps work.

Option 1 is bad for us, becasue the more we zoom out, the more samples we need, and even a simple 2D game like yours would cause performance issues at some point.
So you want option 2, and you already tried that with drawing down scaled textures.
But for smooth scaling, you need to interpolate two of your discrete textures so there is no hard switch.
(just summing up what we already know)

The question now is how could you do such mip interpolation within SFML?
Two options:
1. Get hardware mip maps to work, which would be the least effort. After some searching, i found SFML has a function generateMipmap(). Maybe you only need to call this when loading the texture, and then setSmooth() also enables trilinear filter (bilinear interpolation of texels AND interpolation of two mips). This might just work.
2. Use pixel shaders and implement it yourself. I saw SFML has support for shaders, but i'd try the first option first.

EDIT:
Notice generateMipmap() would generate all texture detail levels by halfing resolutions for each level. So no more need to draw down scaled textures manually, but also no option of smaller sqrt(2) steps. But due to inerpolation, smaller steps should not be needed.

This topic is closed to new replies.

Advertisement