Hi!
As announced in #5016, here's a draft for supporting integer factor resource scaling. E.g., 1920x1080
can fit up to 3 OG screen by width, but only 2 by height, so the factor is 2 in this case. A resolution of 2560x1440
is 4x:3x
of the OG's 640x480
, so a useful scale factor is either 2 or 3 (for now the maximum is chosen automatically, but we may let user choose the factor freely as well). Higher resolutions with lower-than-maximum scale factors are likely the most useful combinations.
The change is focused on the rendering engine and tries to avoid touching the application code as much as possible — the vast majority of pixel offsets are hard-coded values in the game code, obtained by measuring the original game's resources, which would be infeasible to touch. ;)
The way this was made possible is by associating an integer "scale factor" value to each Image
instance (including Display
) and making the images "lie" about their dimensions: regardless of the scale factor, an image instance will always report the same width
and height
— its "logical dimensions". The actual physical dimensions are only used by the image manipulation and rendering routines, for the most part contained in a single file image.cpp
.
The resources are loaded with the default scale factor of 1
and on-demand can be resized and cached for higher scale factors (up to 4
). Resizing is done using our existing nearest neighbour algorithm, but this is where other algorithms may be plugged in later, if desired.
Once this is given, the next step is to always create Image
instances with the display's current scale factor, and, because the display is also reporting its width
/height
in the logical units, the calculations like centering a box on the screen continue to work "magically".
Edit: added example.
Consider the code for rendering a window of arbitrary size in the middle of the screen:
https://github.com/ihhub/fheroes2/blob/master/src/fheroes2/gui/ui_window.cpp#L33-L36
StandardWindow::StandardWindow( const int32_t width, const int32_t height, Image & output )
: _output( output )
, _activeArea( ( output.width() - width ) / 2, ( output.height() - height ) / 2, width, height )
, _windowArea( _activeArea.x - borderSize, _activeArea.y - borderSize, _activeArea.width + 2 * borderSize, _activeArea.height + 2 * borderSize )
...
It takes the dimensions of the inner active area width
×height
and adds a border of the hard-coded size of 16 logical pixels around it in all directions. Let's work out the coordinates for rendering such a window border on the display at resolution 1920x1080
with scale factor 2, for an inner area of 640x480
logical pixels.
As this is application code (not the rendering engine), my priority was to leave it untouched. The width()
and height()
methods used here return the logical dimensions, so for our display these values are 1920/2 = 960
and 1080/2 = 540
. The width
and height
parameters may either be hard-coded in the caller, or they may come from measuring the dimensions of a game resource, such as result of calling fheroes2::AGG::GetICN( ICN::SWAPWIN, 0 )
. In the original resources, this the window displayed when two heroes of the same player meet each other for exchange of armies and artifacts. It has the dimensions of 640x480
pixels. Because in this example our display's current scale factor is 2, the Image
instance returned by the GetICN()
function is pre-scaled 2×2, so its physical dimensions are actually 1280x960
. The logical dimensions, visible to the application code, are the same regardless of the current scale factor.
So the activeArea
works out, in logical pixels, to { (960 - 640)/2 = 160, (540 - 480)/2 = 30, 640, 480 }
. The broderSize
is hard-coded as 16, so windowArea
works out, again in logical pixels, to { 160 - 16 = 144, 30 - 16 = 14, 640 + 2*16 = 672, 480 + 2*16 = 512}
.
When the window border image is rendered on the display, the logical pixels are translated to physical ones, by simply multiplying them by the scale factor. This, again, happens in the rendering engine's functions such as Blit()
, so the application code doesn't need any changes for rendering to work correctly. The border area becomes, in physical pixels: { 144*2 = 288, 14*2 = 28, 1344, 512*2 = 1024 }
. The inner active window area — { 160*2 = 320, 30*2 = 60, 640*2 = 1280, 480*2 = 960 }
.

Finally, for correct controller operation, the mouse position should be also tracked taking the current scale factor into account.
The rendering code is always working with static pre-scaled resources (there is no real-time upscaling and it's not needed).
As I've mentioned in the original discussion, I observe no performance impact of the adjusted rendering procedure. :mx_claus: :champagne:
Edit: some notes on the implementation. It's not without hacks, some of which are quite ugly.
This has mostly to do with the fact that Display::instance()
is used as default parameter in a couple of places, such as default target for rendering buttons, etc. It would be better to add an abstraction layer of a "rendering context", so that direct rendering on the display is prohibited. That would also help to reduce code duplication where coordinates of the top-left corner are constantly need to be added manually to each X,Y screen offsets.
Not directly related to this, I think it may make sense to eventually rework the UI controls system to properly encapsulate the state/rendering and event handling. Right now it's all over the place and is simply copied over from one dialog code to the other, with slight changes. I don't know what are you exact plans for the Editor, but it feels like this is where even more dialogs and controls will be introduced. ;)
TODO:
- [ ] Make changing resolutions / scale factor 100% safe (there are some corner cases right now)
- [ ] Figure out the crash in the multi-language font generation
- [ ] Check how it works (or doesn't) on non-default build targets
- [ ] Explore advanced upscaling filters