diff --git a/src/main.cpp b/src/main.cpp index 6907390c..d409ad63 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -148,6 +148,7 @@ int main(int argc, char* argv[]) config.musicEnabled = args.getString("music", "true") != "false"; config.shadows = args.getString("shadows", "true") != "false"; config.vehicleShadows = args.getString("vehicle-shadows", "true") != "false"; + config.screenScale = std::clamp(args.getUint("screen-scale", 1), 1u, 4u); config.scrollSpeed = std::clamp(args.getUint("scroll-speed", 100), 25u, 200u); config.soundMode = std::min(2u, args.getUint("sound-mode", 2)); config.unitSpeech = std::min(2u, args.getUint("unit-speech", 2)); diff --git a/src/rwe/CursorService.cpp b/src/rwe/CursorService.cpp index 740c56bd..e46119ea 100644 --- a/src/rwe/CursorService.cpp +++ b/src/rwe/CursorService.cpp @@ -1,4 +1,5 @@ #include "CursorService.h" +#include #include namespace rwe @@ -35,8 +36,8 @@ namespace rwe float fy; sdlContext->getMouseState(&fx, &fy); - int x = static_cast(fx); - int y = static_cast(fy); + int x = static_cast(fx / static_cast(screenScale)); + int y = static_cast(fy / static_cast(screenScale)); auto timeInMillis = timeService->getTicks(); const auto& frames = currentCursor->sprites; @@ -47,4 +48,9 @@ namespace rwe renderer.drawSprite(x, y, *(frames[frameIndex])); } + + void CursorService::setScreenScale(unsigned int scale) + { + screenScale = std::max(1u, scale); + } } diff --git a/src/rwe/CursorService.h b/src/rwe/CursorService.h index 656891fc..a2ccc464 100644 --- a/src/rwe/CursorService.h +++ b/src/rwe/CursorService.h @@ -63,5 +63,11 @@ namespace rwe std::shared_ptr getCursor(CursorType type) const; void render(UiRenderService& renderer) const; + + /** See GlobalConfig::screenScale: the mouse is in window pixels, the frame is not. */ + void setScreenScale(unsigned int scale); + + private: + unsigned int screenScale{1}; }; } diff --git a/src/rwe/GlobalConfig.h b/src/rwe/GlobalConfig.h index 31e8288a..01b1da9e 100644 --- a/src/rwe/GlobalConfig.h +++ b/src/rwe/GlobalConfig.h @@ -102,6 +102,20 @@ namespace rwe */ bool vehicleShadows{true}; + /** + * How many screen pixels one game pixel is drawn as, 1 to 4. The + * original ran at 640x480 and, stretched across a modern display, + * shows each of its pixels about two screen pixels wide; RWE draws + * one to one, so its one-pixel wireframe and selection box read as + * thinner than TA's. At 2 the whole frame -- world, interface and + * cursor -- is rendered at half the window size and blown up with + * nearest-neighbour sampling, and mouse input is mapped back + * through the same factor. The world's own 2x supersample and the + * building halo filter happen inside that frame, so they look the + * same as at 1, only larger. An rwe.cfg key, screen-scale. + */ + unsigned int screenScale{1}; + /** Screen scroll speed percentage, 25 to 200; 100 is the old fixed rate. */ unsigned int scrollSpeed{100}; diff --git a/src/rwe/game/GameScene_commands.cpp b/src/rwe/game/GameScene_commands.cpp index 80f42f54..82a83cce 100644 --- a/src/rwe/game/GameScene_commands.cpp +++ b/src/rwe/game/GameScene_commands.cpp @@ -119,7 +119,9 @@ namespace rwe float fx; float fy; sceneContext.sdl->getMouseState(&fx, &fy); - return Point(static_cast(fx), static_cast(fy)); + // Window pixels to frame pixels; see GlobalConfig::screenScale. + auto scale = static_cast(std::max(1u, sceneContext.globalConfig->screenScale)); + return Point(static_cast(fx / scale), static_cast(fy / scale)); } std::optional GameScene::getFirstCollidingUnit(const Ray3f& ray) const diff --git a/src/rwe/render/GraphicsContext.cpp b/src/rwe/render/GraphicsContext.cpp index 82731bc5..cc7115c6 100644 --- a/src/rwe/render/GraphicsContext.cpp +++ b/src/rwe/render/GraphicsContext.cpp @@ -632,6 +632,19 @@ namespace rwe void GraphicsContext::unbindFrameBuffer() { + glBindFramebuffer(GL_FRAMEBUFFER, presentationFrameBuffer); + } + + void GraphicsContext::setPresentationFrameBuffer(std::optional frameBuffer) + { + presentationFrameBuffer = frameBuffer ? frameBuffer->value : 0; + } + + void GraphicsContext::blitFrameBufferToWindow(FrameBufferIdentifier source, int sourceWidth, int sourceHeight, int windowWidth, int windowHeight) + { + glBindFramebuffer(GL_READ_FRAMEBUFFER, source.value); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glBlitFramebuffer(0, 0, sourceWidth, sourceHeight, 0, 0, windowWidth, windowHeight, GL_COLOR_BUFFER_BIT, GL_NEAREST); glBindFramebuffer(GL_FRAMEBUFFER, 0); } diff --git a/src/rwe/render/GraphicsContext.h b/src/rwe/render/GraphicsContext.h index f8db22d6..6e757e42 100644 --- a/src/rwe/render/GraphicsContext.h +++ b/src/rwe/render/GraphicsContext.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -225,6 +226,23 @@ namespace rwe void bindFrameBuffer(FrameBufferIdentifier frameBuffer); + /** + * The framebuffer that unbindFrameBuffer returns to: the window's + * own by default, or a presentation buffer while the frame is being + * drawn at a scale (see SceneManager). Scenes never need to know + * which, which is the point. + */ + void setPresentationFrameBuffer(std::optional frameBuffer); + + /** + * Copies the colour of `source`, `sourceWidth` by `sourceHeight` + * pixels, onto the window's own framebuffer stretched to + * `windowWidth` by `windowHeight`, sampling nearest so a whole-number + * scale keeps every pixel square. Leaves the window's framebuffer + * bound. + */ + void blitFrameBufferToWindow(FrameBufferIdentifier source, int sourceWidth, int sourceHeight, int windowWidth, int windowHeight); + void unbindFrameBuffer(); void enableBlending(); @@ -279,6 +297,7 @@ namespace rwe void setActiveTextureSlot3(); private: + unsigned int presentationFrameBuffer{0}; ShaderHandle compileShader(GLenum shaderType, const std::string& source); VboHandle genBuffer(); diff --git a/src/rwe/scene/SceneManager.cpp b/src/rwe/scene/SceneManager.cpp index 4ed0b0e2..e15a15a6 100644 --- a/src/rwe/scene/SceneManager.cpp +++ b/src/rwe/scene/SceneManager.cpp @@ -1,4 +1,5 @@ #include "SceneManager.h" +#include #include #include #include @@ -45,6 +46,14 @@ namespace rwe viewport(viewport), requestedExit(false) { + // At a scale above 1 the scenes see a viewport of the window's size + // divided by the scale, draw into a buffer of that size, and the + // buffer is blown up onto the window at the end of the frame. The + // mouse is mapped back through the same factor on the way in. + screenScale = std::clamp(globalConfig->screenScale, 1u, 4u); + sdl->getWindowSize(window, &windowWidth, &windowHeight); + viewport->setDimensions(windowWidth / static_cast(screenScale), windowHeight / static_cast(screenScale)); + cursorService->setScreenScale(screenScale); } void SceneManager::setNextScene(std::shared_ptr scene) @@ -52,7 +61,7 @@ namespace rwe nextScene = std::move(scene); } - void dispatchToScene(const SDL_Event& event, Scene& currentScene) + void dispatchToScene(const SDL_Event& event, Scene& currentScene, float screenScale) { switch (event.type) { @@ -70,7 +79,7 @@ namespace rwe break; } - MouseButtonEvent e(event.button.x, event.button.y, *button); + MouseButtonEvent e(static_cast(event.button.x / screenScale), static_cast(event.button.y / screenScale), *button); currentScene.onMouseDown(e); break; } @@ -82,13 +91,13 @@ namespace rwe break; } - MouseButtonEvent e(event.button.x, event.button.y, *button); + MouseButtonEvent e(static_cast(event.button.x / screenScale), static_cast(event.button.y / screenScale), *button); currentScene.onMouseUp(e); break; } case SDL_EVENT_MOUSE_MOTION: { - MouseMoveEvent e(event.motion.x, event.motion.y); + MouseMoveEvent e(static_cast(event.motion.x / screenScale), static_cast(event.motion.y / screenScale)); currentScene.onMouseMove(e); break; } @@ -164,17 +173,19 @@ namespace rwe if (event.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED && event.window.windowID == sdl->getWindowId(window)) { - viewport->setDimensions(event.window.data1, event.window.data2); + windowWidth = event.window.data1; + windowHeight = event.window.data2; + viewport->setDimensions(windowWidth / static_cast(screenScale), windowHeight / static_cast(screenScale)); // The GL viewport does not follow the window by itself, // and only the game scene ever sets it per frame -- the // menu and the movie player draw through the default one, // so without this a resized window kept rendering into a // corner sized like the old window. - graphics->setViewport(0, 0, event.window.data1, event.window.data2); + graphics->setViewport(0, 0, viewport->width(), viewport->height()); continue; } - dispatchToScene(event, *currentScene); + dispatchToScene(event, *currentScene, static_cast(screenScale)); } if (!headless) @@ -209,25 +220,62 @@ namespace rwe imGuiContext->render(); setCrashPhase(CrashPhase::Render); + if (screenScale > 1) + { + // The frame goes into a buffer of the scaled size. Scenes + // that bind buffers of their own unbind back to it rather + // than to the window, which is what the presentation target + // on the graphics context is for. + auto wantedWidth = static_cast(viewport->width()); + auto wantedHeight = static_cast(viewport->height()); + if (!presentationBuffer || presentationBufferWidth != wantedWidth || presentationBufferHeight != wantedHeight) + { + presentationBuffer = graphics->createFrameBuffer(wantedWidth, wantedHeight); + presentationBufferWidth = wantedWidth; + presentationBufferHeight = wantedHeight; + } + graphics->setPresentationFrameBuffer(presentationBuffer->frameBuffer.get()); + graphics->bindFrameBuffer(presentationBuffer->frameBuffer.get()); + } graphics->clear(); // The game scene points the GL viewport at its own buffers as // it works (the supersampled world among them), and the menu and // movie scenes draw through whatever is current: without this // reset, leaving a game left the front end rendering into a - // stale sub-rectangle while the mouse math used the window. + // corner sized like the old window. graphics->setViewport(0, 0, viewport->width(), viewport->height()); currentScene->render(); - // Taken here, after the scene and before the cursor and the debug + if (!imGuiContext->io->WantCaptureMouse) + { + sdl->hideCursor(); + cursorService->render(uiRenderService); + } + + if (screenScale > 1) + { + // Nearest-neighbour, so at a whole-number scale every game + // pixel is a square block of screen pixels. The world's own + // supersample and the building halo were resolved inside + // the frame, before this, as the original's own pixels + // would have been before a monitor stretched them. + graphics->setPresentationFrameBuffer(std::nullopt); + graphics->blitFrameBufferToWindow(presentationBuffer->frameBuffer.get(), presentationBufferWidth, presentationBufferHeight, windowWidth, windowHeight); + graphics->setViewport(0, 0, windowWidth, windowHeight); + } + + // Taken here, after the scene and the cursor and before the debug // windows, so the picture is the game with nothing of RWE's own on - // top of it. Whether the original's included its cursor is not - // decoded. + // top of it. It reads the window's back buffer, which at a scale + // above 1 has the frame on it only once it has been blown up. The + // cursor is in it now, where it was not before the scale work; the + // original's own screenshots are not decoded on that point. if (screenshotRequested) { screenshotRequested = false; if (auto dataPath = getLocalDataPath()) { - auto written = saveScreenshot(*dataPath / "screenshots", static_cast(viewport->width()), static_cast(viewport->height())); + auto written = saveScreenshot(*dataPath / "screenshots", static_cast(windowWidth), static_cast(windowHeight)); if (written) { LOG_INFO << "Screenshot saved to " << written->string(); @@ -239,12 +287,6 @@ namespace rwe } } - if (!imGuiContext->io->WantCaptureMouse) - { - sdl->hideCursor(); - cursorService->render(uiRenderService); - } - { RWE_RENDERPROF("imgui"); imGuiContext->renderDrawData(); diff --git a/src/rwe/scene/SceneManager.h b/src/rwe/scene/SceneManager.h index 8059eda2..d9c1de9f 100644 --- a/src/rwe/scene/SceneManager.h +++ b/src/rwe/scene/SceneManager.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -43,6 +44,19 @@ namespace rwe */ bool screenshotRequested{false}; + /** + * GlobalConfig::screenScale, and what it needs: the window's own + * size in pixels, which the viewport the scenes read no longer + * holds at a scale above 1, and the buffer the frame is drawn into + * before it is blown up onto the window. + */ + unsigned int screenScale{1}; + int windowWidth{0}; + int windowHeight{0}; + std::optional presentationBuffer; + int presentationBufferWidth{0}; + int presentationBufferHeight{0}; + unsigned int lastFrameStartTime{0}; std::array frameTimes;