diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index a18ee31..d515ed2 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -33,6 +33,7 @@ menu: texture_filter: "Filtre textura" render_info: "Render info" uptime: "Temps de joc" + internal_resolution: "Resolució interna" master_enable: "Àudio" master_volume: "Màster" music: "Música" diff --git a/source/core/rendering/menu.cpp b/source/core/rendering/menu.cpp index d9e5f14..8eca897 100644 --- a/source/core/rendering/menu.cpp +++ b/source/core/rendering/menu.cpp @@ -176,6 +176,11 @@ namespace Menu { ? Locale::get("menu.values.linear") : Locale::get("menu.values.nearest")); }, [](int dir) { Screen::get()->cycleTextureFilter(dir); }, nullptr, nullptr, nullptr}); + p.items.push_back({Locale::get("menu.items.internal_resolution"), ItemKind::IntRange, [] { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%dX", Options::video.internal_resolution); + return std::string(buf); }, [](int dir) { Screen::get()->changeInternalResolution(dir); }, nullptr, nullptr, nullptr}); + // Bloc shaders: no disponible a WASM (NO_SHADERS, sense SDL3 GPU a WebGL2) #ifndef __EMSCRIPTEN__ p.items.push_back({Locale::get("menu.items.shader"), ItemKind::Toggle, [] { return onOff(Options::video.shader_enabled); }, [](int) { Screen::get()->toggleShaders(); }, nullptr, nullptr, nullptr}); diff --git a/source/core/rendering/screen.cpp b/source/core/rendering/screen.cpp index 8e9096e..574d0ae 100644 --- a/source/core/rendering/screen.cpp +++ b/source/core/rendering/screen.cpp @@ -37,6 +37,13 @@ Screen::Screen() { if (zoom_ < 1) zoom_ = 1; if (zoom_ > max_zoom_) zoom_ = max_zoom_; + // Clamp de la resolució interna a [1, max_zoom_]. Llegir del YAML i + // ajustar aquí és l'únic moment en què es fa — el menú re-clampa cada + // canvi. Si la pantalla és més petita que el valor desat (p.ex. canvi + // de monitor), baixem al màxim suportat. + if (Options::video.internal_resolution < 1) Options::video.internal_resolution = 1; + if (Options::video.internal_resolution > max_zoom_) Options::video.internal_resolution = max_zoom_; + int w = GAME_WIDTH * zoom_; int h = Options::video.aspect_ratio_4_3 ? static_cast(GAME_HEIGHT * 1.2F) * zoom_ : GAME_HEIGHT * zoom_; @@ -66,6 +73,7 @@ Screen::~Screen() { shader_backend_.reset(); } + if (internal_texture_sdl_) SDL_DestroyTexture(internal_texture_sdl_); if (texture_) SDL_DestroyTexture(texture_); if (renderer_) SDL_DestroyRenderer(renderer_); if (window_) SDL_DestroyWindow(window_); @@ -108,6 +116,8 @@ void Screen::initShaders() { shader_backend_->setOversample(3); } + shader_backend_->setInternalResolution(Options::video.internal_resolution); + // Resol el shader actiu des del config if (Options::video.current_shader == "crtpi") { shader_backend_->setActiveShader(Rendering::ShaderType::CRTPI); @@ -164,6 +174,33 @@ void Screen::present(Uint32* pixel_data) { } else { // Fallback SDL_Renderer SDL_UpdateTexture(texture_, nullptr, pixel_data, GAME_WIDTH * sizeof(Uint32)); + + const int mult = Options::video.internal_resolution; + if (mult > 1) { + // Resolució interna: còpia NN de texture_ → internal_texture_sdl_ + // (la fa SDL/GPU, no CPU). Tota la presentació downstream llegirà + // d'aquesta textura més gran — el filtre LINEAR final parteix d'una + // font més fina i l'estirament a finestra queda menys difús. + ensureFallbackInternalTexture(); + if (internal_texture_sdl_ != nullptr) { + SDL_SetTextureScaleMode(texture_, SDL_SCALEMODE_NEAREST); + SDL_SetRenderTarget(renderer_, internal_texture_sdl_); + SDL_RenderClear(renderer_); + SDL_RenderTexture(renderer_, texture_, nullptr, nullptr); + SDL_SetRenderTarget(renderer_, nullptr); + // El filtre global (LINEAR/NEAREST) s'aplica a l'estirament final + // cap a la finestra; per això l'aplicam a la textura intermèdia. + SDL_ScaleMode final_scale = (Options::video.texture_filter == Options::TextureFilter::LINEAR) + ? SDL_SCALEMODE_LINEAR + : SDL_SCALEMODE_NEAREST; + SDL_SetTextureScaleMode(internal_texture_sdl_, final_scale); + SDL_RenderClear(renderer_); + SDL_RenderTexture(renderer_, internal_texture_sdl_, nullptr, nullptr); + SDL_RenderPresent(renderer_); + return; + } + // Si la creació de la textura ha fallat, caiem al path normal. + } SDL_RenderClear(renderer_); SDL_RenderTexture(renderer_, texture_, nullptr, nullptr); SDL_RenderPresent(renderer_); @@ -261,6 +298,22 @@ void Screen::cycleTextureFilter(int dir) { } } +void Screen::changeInternalResolution(int dir) { + int next = Options::video.internal_resolution + (dir >= 0 ? 1 : -1); + if (next < 1) next = 1; + if (next > max_zoom_) next = max_zoom_; + if (next == Options::video.internal_resolution) return; + Options::video.internal_resolution = next; + + // Propaga al backend actiu. Al fallback path, la textura es recrea al + // pròxim present via ensureFallbackInternalTexture. + if (shader_backend_) { + shader_backend_->setInternalResolution(next); + } else { + applyFallbackPresentation(); + } +} + auto Screen::nextShaderType() -> bool { if (!shader_backend_ || !shader_backend_->isHardwareAccelerated()) return false; if (!Options::video.shader_enabled) return false; @@ -442,7 +495,41 @@ void Screen::applyFallbackPresentation() { case Options::ScalingMode::INTEGER: mode = SDL_LOGICAL_PRESENTATION_INTEGER_SCALE; break; } } - SDL_SetRenderLogicalPresentation(renderer_, GAME_WIDTH, GAME_HEIGHT, mode); + // Amb resolució interna N > 1, la mida lògica creix proporcionalment + // perquè SDL scale des de 320·N × 200·N a la finestra — menys aggressive linear. + const int mult = Options::video.internal_resolution < 1 ? 1 : Options::video.internal_resolution; + SDL_SetRenderLogicalPresentation(renderer_, GAME_WIDTH * mult, GAME_HEIGHT * mult, mode); +} + +void Screen::ensureFallbackInternalTexture() { + const int mult = Options::video.internal_resolution; + if (mult <= 1 || renderer_ == nullptr) { + // No cal textura intermèdia. Si la teníem, la reciclem. + if (internal_texture_sdl_ != nullptr) { + SDL_DestroyTexture(internal_texture_sdl_); + internal_texture_sdl_ = nullptr; + internal_texture_mult_ = 0; + } + return; + } + if (internal_texture_sdl_ != nullptr && internal_texture_mult_ == mult) return; + + if (internal_texture_sdl_ != nullptr) { + SDL_DestroyTexture(internal_texture_sdl_); + internal_texture_sdl_ = nullptr; + } + internal_texture_sdl_ = SDL_CreateTexture(renderer_, + SDL_PIXELFORMAT_ABGR8888, + SDL_TEXTUREACCESS_TARGET, + GAME_WIDTH * mult, + GAME_HEIGHT * mult); + if (internal_texture_sdl_ == nullptr) { + std::cerr << "Screen: failed to create fallback internal texture (×" << mult << "): " + << SDL_GetError() << '\n'; + internal_texture_mult_ = 0; + return; + } + internal_texture_mult_ = mult; } void Screen::adjustWindowSize() { diff --git a/source/core/rendering/screen.hpp b/source/core/rendering/screen.hpp index 40cd48c..00464a9 100644 --- a/source/core/rendering/screen.hpp +++ b/source/core/rendering/screen.hpp @@ -33,6 +33,7 @@ class Screen { void cycleScalingMode(int dir); // Cicla DISABLED/STRETCH/LETTERBOX/OVERSCAN/INTEGER void toggleVSync(); void cycleTextureFilter(int dir); // Cicla NEAREST/LINEAR + void changeInternalResolution(int dir); // +/−1, clampat a [1, max_zoom_] auto nextShaderType() -> bool; // false si GPU off / shaders off auto prevShaderType() -> bool; // idem auto nextPreset() -> bool; // false si GPU off / shaders off @@ -45,6 +46,7 @@ class Screen { // Getters [[nodiscard]] auto isFullscreen() const -> bool { return fullscreen_; } [[nodiscard]] auto getZoom() const -> int { return zoom_; } + [[nodiscard]] auto getMaxZoom() const -> int { return max_zoom_; } [[nodiscard]] auto isHardwareAccelerated() const -> bool; [[nodiscard]] auto getActiveShaderName() const -> const char*; [[nodiscard]] auto getWindow() -> SDL_Window* { return window_; } @@ -58,12 +60,15 @@ class Screen { void calculateMaxZoom(); void initShaders(); void applyFallbackPresentation(); // Logical presentation + scale mode per al path SDL_Renderer + void ensureFallbackInternalTexture(); // Recrea internal_texture_sdl_ si cal (fallback path) static Screen* instance_; SDL_Window* window_{nullptr}; SDL_Renderer* renderer_{nullptr}; - SDL_Texture* texture_{nullptr}; // 320x200 streaming, ABGR8888 (fallback SDL_Renderer) + SDL_Texture* texture_{nullptr}; // 320x200 streaming, ABGR8888 (fallback SDL_Renderer) + SDL_Texture* internal_texture_sdl_{nullptr}; // 320·N x 200·N TARGET (fallback path, només si N>1) + int internal_texture_mult_{0}; // Multiplicador amb què es va crear internal_texture_sdl_ // Backend GPU (nullptr si no disponible o desactivat) std::unique_ptr shader_backend_; diff --git a/source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp b/source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp index 6e884ad..8482220 100644 --- a/source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp +++ b/source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp @@ -456,6 +456,11 @@ namespace Rendering { return false; } + // internal_texture_: si el multiplicador és > 1, es crea ací amb les + // dimensions game·N × game·N. No bloqueja si falla — només deixa la + // textura a nullptr i el pipeline ometrà la còpia. + recreateInternalTexture(); + // scaled_texture_ se creará en el primer render() una vez conocido el zoom de ventana ss_factor_ = 0; @@ -812,14 +817,50 @@ namespace Rendering { SDL_EndGPUCopyPass(copy); } - // ---- Upscale pass: scene_texture_ → scaled_texture_ ---- + // ---- Internal resolution NN upscale: scene_texture_ → internal_texture_ ---- + // Multiplicador enter. Si > 1, tot el pipeline downstream veu internal_texture_ + // com a "scene" (mida game·N × game·N) i els passos següents (SS, PostFX, + // Lanczos, letterbox) operen sobre aquesta font més gran. L'objectiu: quan el + // filtre final LINEAR estira a finestra, parteix d'una base més gran i es veu + // menys borrós. Amb internal_res_ == 1, s'omet el pas (zero overhead). + SDL_GPUTexture* source_texture = scene_texture_; + int source_width = game_width_; + int source_height = game_height_; + if (internal_res_ > 1 && internal_texture_ != nullptr && upscale_pipeline_ != nullptr) { + SDL_GPUColorTargetInfo internal_target = {}; + internal_target.texture = internal_texture_; + internal_target.load_op = SDL_GPU_LOADOP_DONT_CARE; + internal_target.store_op = SDL_GPU_STOREOP_STORE; + + SDL_GPURenderPass* ipass = SDL_BeginGPURenderPass(cmd, &internal_target, 1, nullptr); + if (ipass != nullptr) { + SDL_BindGPUGraphicsPipeline(ipass, upscale_pipeline_); + SDL_GPUTextureSamplerBinding ibinding = {}; + ibinding.texture = scene_texture_; + ibinding.sampler = sampler_; // sempre NEAREST per a la còpia de resolució interna + SDL_BindGPUFragmentSamplers(ipass, 0, &ibinding, 1); + SDL_DrawGPUPrimitives(ipass, 3, 1, 0, 0); + SDL_EndGPURenderPass(ipass); + } + source_texture = internal_texture_; + source_width = game_width_ * internal_res_; + source_height = game_height_ * internal_res_; + } + + // ---- Upscale pass: source_texture → scaled_texture_ ---- // Si 4:3 actiu, l'estirament s'aplica ací directament (320x200 → W*factor × H*factor*1.2) // El filtre s'aplica sempre (texture_filter_linear_), independent de 4:3. // L'effective_scene/height reflecteix la textura real que veuen els shaders. // Sense SS ni stretch: scene_texture_ a game_height_. // Amb SS o stretch: scaled_texture_ a l'alçada escalada (amb o sense 4:3). - SDL_GPUTexture* effective_scene = scene_texture_; + SDL_GPUTexture* effective_scene = source_texture; + // `effective_height` reflecteix l'alçada lògica del frame (per a + // scanlines i viewport), no la mida real de la textura. Es manté + // a `game_height_` encara que internal_res_ > 1 — el multiplicador + // només afecta la resolució física de la font, no l'aspect ni el + // nombre de scanlines visibles. int effective_height = game_height_; + (void)source_width; // només es fa servir com a context informatiu if (oversample_ > 1 && scaled_texture_ != nullptr && upscale_pipeline_ != nullptr) { SDL_GPUColorTargetInfo upscale_target = {}; @@ -834,7 +875,7 @@ namespace Rendering { if (upass != nullptr) { SDL_BindGPUGraphicsPipeline(upass, upscale_pipeline_); SDL_GPUTextureSamplerBinding ubinding = {}; - ubinding.texture = scene_texture_; + ubinding.texture = source_texture; ubinding.sampler = (use_linear && linear_sampler_ != nullptr) ? linear_sampler_ : sampler_; SDL_BindGPUFragmentSamplers(upass, 0, &ubinding, 1); SDL_DrawGPUPrimitives(upass, 3, 1, 0, 0); @@ -846,6 +887,7 @@ namespace Rendering { // Sense SS: el viewport s'encarrega de l'estirament geomètric effective_height = static_cast(static_cast(game_height_) * 1.2F); } + (void)source_height; // ---- Acquire swapchain texture ---- SDL_GPUTexture* swapchain = nullptr; @@ -1068,6 +1110,10 @@ namespace Rendering { SDL_ReleaseGPUTexture(device_, scene_texture_); scene_texture_ = nullptr; } + if (internal_texture_ != nullptr) { + SDL_ReleaseGPUTexture(device_, internal_texture_); + internal_texture_ = nullptr; + } if (scaled_texture_ != nullptr) { SDL_ReleaseGPUTexture(device_, scaled_texture_); scaled_texture_ = nullptr; @@ -1218,6 +1264,18 @@ namespace Rendering { scaling_mode_ = mode; } + // setInternalResolution — canvia el multiplicador de resolució interna. + // Recrea la textura intermèdia amb les noves dimensions (320·N × 200·N). + void SDL3GPUShader::setInternalResolution(int multiplier) { + const int NEW = std::max(1, multiplier); + if (NEW == internal_res_) return; + internal_res_ = NEW; + if (is_initialized_ && device_ != nullptr) { + SDL_WaitForGPUIdle(device_); + recreateInternalTexture(); + } + } + void SDL3GPUShader::setStretch4_3(bool enabled) { stretch_4_3_ = enabled; if (!is_initialized_ || device_ == nullptr) return; @@ -1263,6 +1321,10 @@ namespace Rendering { SDL_ReleaseGPUTexture(device_, scene_texture_); scene_texture_ = nullptr; } + if (internal_texture_ != nullptr) { + SDL_ReleaseGPUTexture(device_, internal_texture_); + internal_texture_ = nullptr; + } // scaled_texture_ se libera aquí; se recreará en el primer render() con el factor correcto if (scaled_texture_ != nullptr) { SDL_ReleaseGPUTexture(device_, scaled_texture_); @@ -1305,10 +1367,15 @@ namespace Rendering { return false; } - SDL_Log("SDL3GPUShader: reinit — scene %dx%d, SS %s (scaled se creará en render)", + // Recrea la textura interna si internal_res_ > 1 — manté coherència + // en canvis d'SS que passen per reinitTexturesAndBuffer(). + recreateInternalTexture(); + + SDL_Log("SDL3GPUShader: reinit — scene %dx%d, SS %s, internal ×%d (scaled se creará en render)", game_width_, game_height_, - oversample_ > 1 ? "on" : "off"); + oversample_ > 1 ? "on" : "off", + internal_res_); return true; } @@ -1379,4 +1446,39 @@ namespace Rendering { return true; } + // --------------------------------------------------------------------------- + // recreateInternalTexture — libera y recrea internal_texture_ para el + // multiplicador internal_res_ actual. Si val 1, allibera i queda a nullptr + // (el pipeline ometrà la còpia al següent render). + // --------------------------------------------------------------------------- + auto SDL3GPUShader::recreateInternalTexture() -> bool { + if (internal_texture_ != nullptr) { + SDL_ReleaseGPUTexture(device_, internal_texture_); + internal_texture_ = nullptr; + } + if (internal_res_ <= 1 || device_ == nullptr) return true; + + const int W = game_width_ * internal_res_; + const int H = game_height_ * internal_res_; + + SDL_GPUTextureCreateInfo info = {}; + info.type = SDL_GPU_TEXTURETYPE_2D; + info.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + info.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER | SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; + info.width = static_cast(W); + info.height = static_cast(H); + info.layer_count_or_depth = 1; + info.num_levels = 1; + + internal_texture_ = SDL_CreateGPUTexture(device_, &info); + if (internal_texture_ == nullptr) { + SDL_Log("SDL3GPUShader: failed to create internal texture %dx%d (×%d): %s", + W, H, internal_res_, SDL_GetError()); + return false; + } + + SDL_Log("SDL3GPUShader: internal texture %dx%d (×%d)", W, H, internal_res_); + return true; + } + } // namespace Rendering diff --git a/source/core/rendering/sdl3gpu/sdl3gpu_shader.hpp b/source/core/rendering/sdl3gpu/sdl3gpu_shader.hpp index 3c201ad..4e182b9 100644 --- a/source/core/rendering/sdl3gpu/sdl3gpu_shader.hpp +++ b/source/core/rendering/sdl3gpu/sdl3gpu_shader.hpp @@ -126,6 +126,9 @@ namespace Rendering { texture_filter_linear_ = (filter == Options::TextureFilter::LINEAR); } + // Multiplicador de resolució interna (1 = off). + void setInternalResolution(int multiplier) override; + private: static auto createShaderMSL(SDL_GPUDevice* device, const char* msl_source, @@ -146,6 +149,7 @@ namespace Rendering { auto createCrtPiPipeline() -> bool; // Pipeline dedicado para el shader CrtPi auto reinitTexturesAndBuffer() -> bool; // Recrea scene_texture_ y upload_buffer_ auto recreateScaledTexture(int factor) -> bool; // Recrea scaled_texture_ para factor dado + auto recreateInternalTexture() -> bool; // Recrea internal_texture_ (res interna × N) static auto calcSsFactor(float zoom) -> int; // Primer múltiplo de 3 >= zoom (mín 3) // Devuelve el mejor present mode disponible: IMMEDIATE > MAILBOX > VSYNC [[nodiscard]] auto bestPresentMode(bool vsync) const -> SDL_GPUPresentMode; @@ -158,6 +162,7 @@ namespace Rendering { SDL_GPUGraphicsPipeline* upscale_pipeline_ = nullptr; // Upscale pass (solo con SS) SDL_GPUGraphicsPipeline* downscale_pipeline_ = nullptr; // Lanczos downscale (solo con SS + algo > 0) SDL_GPUTexture* scene_texture_ = nullptr; // Canvas del joc (game_width_ × game_height_) + SDL_GPUTexture* internal_texture_ = nullptr; // Resolució interna ampliada (game·N × game·N), si N>1 SDL_GPUTexture* scaled_texture_ = nullptr; // Upscale target (game×factor, amb 4:3 si actiu) SDL_GPUTexture* postfx_texture_ = nullptr; // PostFX output a resolució escalada, sols amb Lanczos SDL_GPUTransferBuffer* upload_buffer_ = nullptr; @@ -173,6 +178,7 @@ namespace Rendering { int ss_factor_ = 0; // Factor SS activo (3, 6, 9...) o 0 si SS desactivado int oversample_ = 1; // SS on/off (1 = off, >1 = on) int downscale_algo_ = 1; // 0 = bilinear legacy, 1 = Lanczos2, 2 = Lanczos3 + int internal_res_ = 1; // Multiplicador de resolució interna (1 = off) std::string driver_name_; std::string preferred_driver_; // Driver preferido; vacío = auto (SDL elige) bool is_initialized_ = false; diff --git a/source/core/rendering/shader_backend.hpp b/source/core/rendering/shader_backend.hpp index 36ac31f..657cbae 100644 --- a/source/core/rendering/shader_backend.hpp +++ b/source/core/rendering/shader_backend.hpp @@ -177,6 +177,13 @@ namespace Rendering { * @brief Filtre de textura global per a l'upscale final (sempre aplicat). */ virtual void setTextureFilter(Options::TextureFilter /*filter*/) {} + + /** + * @brief Multiplicador enter de la "resolució interna": fa un NN upscale + * de scene (320×200) a 320·N × 200·N i la pipeline downstream + * parteix d'aquesta textura. 1 = off (sense còpia addicional). + */ + virtual void setInternalResolution(int /*multiplier*/) {} }; } // namespace Rendering diff --git a/source/game/defaults.hpp b/source/game/defaults.hpp index 7a9b783..17334d1 100644 --- a/source/game/defaults.hpp +++ b/source/game/defaults.hpp @@ -20,6 +20,7 @@ namespace Defaults::Video { constexpr bool VSYNC = true; constexpr bool ASPECT_RATIO_4_3 = false; // CRT original estira 200→240 constexpr int DOWNSCALE_ALGO = 1; // 0=bilinear, 1=Lanczos2, 2=Lanczos3 + constexpr int INTERNAL_RESOLUTION = 1; // Multiplicador enter de la textura font abans del pipeline // TextureFilter i ScalingMode viuen a Options (requereixen #include, evitem dependència circular). } // namespace Defaults::Video diff --git a/source/game/options.cpp b/source/game/options.cpp index 9e5b2a0..0524953 100644 --- a/source/game/options.cpp +++ b/source/game/options.cpp @@ -141,6 +141,10 @@ namespace Options { } if (node.contains("downscale_algo")) video.downscale_algo = node["downscale_algo"].get_value(); + if (node.contains("internal_resolution")) { + video.internal_resolution = node["internal_resolution"].get_value(); + if (video.internal_resolution < 1) video.internal_resolution = 1; + } if (node.contains("current_shader")) video.current_shader = node["current_shader"].get_value(); if (node.contains("current_postfx_preset")) @@ -298,6 +302,7 @@ namespace Options { file << " aspect_ratio_4_3: " << (video.aspect_ratio_4_3 ? "true" : "false") << "\n"; file << " texture_filter: " << (video.texture_filter == TextureFilter::LINEAR ? "linear" : "nearest") << " # nearest|linear\n"; file << " downscale_algo: " << video.downscale_algo << " # 0=bilinear, 1=Lanczos2, 2=Lanczos3\n"; + file << " internal_resolution: " << video.internal_resolution << " # multiplicador enter font, clampat a max_zoom\n"; file << " current_shader: " << video.current_shader << "\n"; file << " current_postfx_preset: " << video.current_postfx_preset << "\n"; file << " current_crtpi_preset: " << video.current_crtpi_preset << "\n"; diff --git a/source/game/options.hpp b/source/game/options.hpp index 4ce5fd5..f1a7aed 100644 --- a/source/game/options.hpp +++ b/source/game/options.hpp @@ -45,6 +45,7 @@ namespace Options { bool aspect_ratio_4_3{Defaults::Video::ASPECT_RATIO_4_3}; TextureFilter texture_filter{TextureFilter::NEAREST}; int downscale_algo{Defaults::Video::DOWNSCALE_ALGO}; + int internal_resolution{Defaults::Video::INTERNAL_RESOLUTION}; // Multiplicador enter ≥ 1, clampat a max_zoom std::string current_shader{"postfx"}; // "postfx" o "crtpi" std::string current_postfx_preset{"CRT"}; // Nom del preset PostFX actiu std::string current_crtpi_preset{"DEFAULT"}; // Nom del preset CrtPi actiu