From 76e7c228833a1284d48ce8daa7f9d8314a9e6d19 Mon Sep 17 00:00:00 2001 From: Hector Bailey Date: Fri, 11 Sep 2026 21:47:28 +0100 Subject: [PATCH] A game pixel can be drawn as more than one screen pixel The original ran at 640x480 and, stretched across a modern display, shows each of its pixels about two screen pixels wide. RWE drew one to one, so its one-pixel wireframe and selection box, drawn exactly as the original draws them, read as too thin beside it. Making the lines thicker would make them wrong at one to one; the fix is to scale the whole picture. An rwe.cfg key, screen-scale, 1 to 4 and 1 by default. Above 1 the scenes see a viewport of the window's size divided by the scale and draw the frame, interface and cursor included, into a buffer of that size, which is then blown up onto the window with nearest-neighbour sampling so every game pixel is a square block of screen pixels. The world's own supersample and the building halo are resolved inside that frame, so they look as they did, only larger. Mouse events, the cursor and the scene's own mouse reads are mapped back through the same factor, so a click lands where the cursor is drawn. The scenes do not know any of this. A scene that binds a buffer of its own unbinds back to the frame's buffer rather than to the window, which is what the graphics context's presentation target is for. The screenshot moves after the blow-up, since the window has nothing on it until then, and so now has the cursor in it. Issue: #39 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PJCbwC9MGZnM6erMDKptun --- src/main.cpp | 1 + src/rwe/CursorService.cpp | 10 +++- src/rwe/CursorService.h | 6 +++ src/rwe/GlobalConfig.h | 14 ++++++ src/rwe/game/GameScene_commands.cpp | 4 +- src/rwe/render/GraphicsContext.cpp | 13 +++++ src/rwe/render/GraphicsContext.h | 19 +++++++ src/rwe/scene/SceneManager.cpp | 78 ++++++++++++++++++++++------- src/rwe/scene/SceneManager.h | 14 ++++++ 9 files changed, 138 insertions(+), 21 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 6907390c3..d409ad639 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 740c56bd1..e46119eac 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 656891fc3..a2ccc4641 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 31e8288a5..01b1da9e9 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 80f42f543..82a83cce4 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 82731bc53..cc7115c69 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 f8db22d6e..6e757e422 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 4ed0b0e2e..e15a15a62 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 8059eda21..d9c1de9ff 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;