From d490363b61265847aa11555d055d8fc05ed292f8 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 14:43:00 +0200 Subject: [PATCH 1/7] Size qtFRED's post-processing targets for its resizable viewport The offscreen render targets that back post-processing -- the scene textures and the post-processing surfaces -- are allocated once from gr_screen.max_w/max_h at renderer init and never resized. The game never notices, because its window size is fixed after gr_init(). qtFRED does: FredRenderer::render_frame() calls gr_screen_resize() every frame to match a dockable, resizable widget, so growing the viewport past the startup size clipped the render to the old, smaller texture and then stretched it back over the new, larger viewport. Add Gr_min_render_target_w/h as a floor those allocations respect, and have qtFRED set it to the largest size its viewport can reach (the biggest screen the window could be maximized onto, in device pixels) before calling gr_init(). Left at 0 for the game, which keeps sizing them purely from gr_screen. That makes Scene_texture_u_scale/v_scale meaningful outside the game for the first time, which turns up two latent bugs: - Seven post-processing passes passed Scene_texture_u_scale for both axes. Harmless while the scene texture matches the screen exactly (u == v), wrong the moment it doesn't. - The bloom bright pass hardcoded 1.0/1.0. Unlike the passes after it, it reads the scene texture directly rather than an already-cropped intermediate, so it has to confine itself to the sub-rectangle that was actually rendered into. Finally, expose the pipeline in qtFRED at all: a View menu toggle brackets the 3D scene in gr_scene_texture_begin/end, the same way game_render_frame() brackets its own. Off by default, and it covers only the 3D world content -- the 2D overlays stay outside it. Co-Authored-By: Claude Opus 5 --- code/graphics/2d.cpp | 3 + code/graphics/2d.h | 12 ++++ code/graphics/opengl/gropengldraw.cpp | 67 ++++++++++++++----- .../opengl/gropenglpostprocessing.cpp | 35 ++++++---- qtfred/src/mission/EditorViewport.h | 6 ++ qtfred/src/mission/FredRenderer.cpp | 13 ++++ qtfred/src/mission/management.cpp | 18 +++++ qtfred/src/ui/FredView.cpp | 1 + qtfred/ui/FredView.ui | 13 ++++ 9 files changed, 141 insertions(+), 27 deletions(-) diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 4dba605070f..5bdd8605afa 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -869,6 +869,9 @@ static void parse_post_processing_func() bool Gr_post_processing_enabled = true; +int Gr_min_render_target_w = 0; +int Gr_min_render_target_h = 0; + // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto PostProcessOption __UNUSED = options::OptionBuilder("Graphics.PostProcessing", std::pair{"Post processing", 1726}, diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 71f1f3663ea..689a6a66314 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -73,6 +73,18 @@ bool gr_is_smaa_mode(AntiAliasMode mode); extern bool Gr_post_processing_enabled; +// Floor, in pixels, for the offscreen render targets that back post-processing (the scene textures +// and the post-processing surfaces). Those are sized once, from gr_screen.max_w/max_h at renderer +// init, and never revisited; anything drawn while gr_screen is *larger* than them is silently +// clipped to their edge and then stretched back over the full viewport. The game never hits that +// -- its window size is fixed after gr_init() -- but qtFred resizes its 3D viewport at runtime, so +// it sets this to the largest size that viewport can ever reach before calling gr_init(). Leave at +// 0 (the default) to size the targets purely from gr_screen, which is what the game does. The +// targets are still clamped to the hardware's maximum renderbuffer size, so this is a request, not +// a guarantee. +extern int Gr_min_render_target_w; +extern int Gr_min_render_target_h; + extern bool Gr_enable_vsync; // HDR10 (PQ/ST.2084 + BT.2020) output. Currently only honored by the Vulkan renderer. diff --git a/code/graphics/opengl/gropengldraw.cpp b/code/graphics/opengl/gropengldraw.cpp index 63ac62ffe6b..8ecfc3e82ec 100644 --- a/code/graphics/opengl/gropengldraw.cpp +++ b/code/graphics/opengl/gropengldraw.cpp @@ -113,10 +113,14 @@ void opengl_setup_scene_textures() return; } - // clamp size, if needed - Scene_texture_width = gr_screen.max_w; - Scene_texture_height = gr_screen.max_h; + // These textures are allocated once and never resized, so they have to be big enough for the + // largest gr_screen this session will ever see, not just the current one -- see + // Gr_min_render_target_w/h (2d.h) for why anything drawn outside them is stretched. In the game + // the floor is 0 and this is just gr_screen. + Scene_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); + Scene_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + // clamp size, if needed if ( Scene_texture_width > GL_max_renderbuffer_size ) { Scene_texture_width = GL_max_renderbuffer_size; } @@ -125,6 +129,15 @@ void opengl_setup_scene_textures() Scene_texture_height = GL_max_renderbuffer_size; } + mprintf((" Scene textures: %dx%d (screen %dx%d, floor %dx%d, max renderbuffer %d)\n", + Scene_texture_width, + Scene_texture_height, + gr_screen.max_w, + gr_screen.max_h, + Gr_min_render_target_w, + Gr_min_render_target_h, + GL_max_renderbuffer_size)); + // create framebuffer glGenFramebuffers(1, &Scene_framebuffer); GL_state.BindFrameBuffer(Scene_framebuffer); @@ -776,20 +789,44 @@ void gr_opengl_scene_texture_begin() GL_state.PushFramebufferState(); GL_state.BindFrameBuffer(Scene_framebuffer); - if (GL_rendering_to_texture) - { - Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width); - Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height); - - CLAMP(Scene_texture_u_scale, 0.0f, 1.0f); - CLAMP(Scene_texture_v_scale, 0.0f, 1.0f); - } - else - { - Scene_texture_u_scale = 1.0f; - Scene_texture_v_scale = 1.0f; + // In the game Scene_texture_width/height always equals gr_screen.max_w/h (the + // scene texture is sized once from the same resolution at gr_init() and the + // game never resizes around it), so this ratio is always exactly 1.0 there. + // qtFred is the exception: its viewport is resized every frame + // (gr_screen_resize() in FredRenderer::render_frame()) to match a dockable + // widget that is smaller than the scene texture's fixed allocation (which + // Gr_min_render_target_w/h floors at the largest size that widget can reach), + // so the ratio keeps the render (and this end-of-frame blit) confined to the + // sub-rectangle of the texture that was actually drawn into, instead of + // stretching the whole texture -- most of it never touched this frame -- + // over the viewport-sized quad. + Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width); + Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height); + + // A ratio above 1.0 means the viewport outgrew the allocation after the fact -- the scene + // texture can't be resized, so all we can do is render the part that fits and let the blit + // stretch it back out, which misaligns everything by a different amount on each axis. The + // floor above is meant to make this unreachable; if it does happen (a display hotplug or + // resolution change after gr_init(), or an allocation clamped by GL_max_renderbuffer_size on + // low-end hardware) say so once rather than every frame. + if (Scene_texture_u_scale > 1.0f || Scene_texture_v_scale > 1.0f) { + static bool reported_undersized_scene_texture = false; + + if (!reported_undersized_scene_texture) { + reported_undersized_scene_texture = true; + nprintf(("OpenGL", + "Viewport (%dx%d) is larger than the scene texture backing it (%dx%d); " + "the post-processed image will be stretched to fit.\n", + gr_screen.max_w, + gr_screen.max_h, + Scene_texture_width, + Scene_texture_height)); + } } + CLAMP(Scene_texture_u_scale, 0.0f, 1.0f); + CLAMP(Scene_texture_v_scale, 0.0f, 1.0f); + if (!light_deferred_enabled()) { glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index ed9e14a995c..0f5056bc1d6 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -99,7 +99,7 @@ void opengl_post_pass_tonemap() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } void opengl_post_pass_bloom() @@ -134,7 +134,16 @@ void opengl_post_pass_bloom() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); + // Unlike every other pass below, this reads directly from the scene + // texture rather than from an already-cropped intermediate (Bloom_textures + // is filled by this very call), so it has to confine itself to the + // sub-rectangle that was actually rendered into -- see the scale + // variables' own comment in gr_opengl_scene_texture_begin(). Hardcoding + // 1.0/1.0 here (as this used to) is only correct when the scene texture + // exactly matches the screen, which is not true of qtFred's dynamically + // resized viewport: it would smear/misplace the bloom halo relative to + // the scene it's supposed to be blooming. + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } // ------ end bright pass ------ @@ -293,7 +302,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); // set and configure post shader .. opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_POST_PROCESS_FXAA, 0)); @@ -310,7 +319,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_luminance_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); opengl_shader_set_current(); } @@ -333,7 +342,7 @@ static void smaa_detect_edges() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } static void smaa_calculate_blending_weights() @@ -358,7 +367,7 @@ static void smaa_calculate_blending_weights() GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_area_tex); GL_state.Texture.Enable(2, GL_TEXTURE_2D, Smaa_search_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } static void smaa_neighborhood_blending() @@ -381,7 +390,7 @@ static void smaa_neighborhood_blending() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_blend_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } void smaa_resolve() @@ -491,7 +500,7 @@ void opengl_post_lightshafts() GL_state.Blend(GL_TRUE); GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); GL_state.Blend(GL_FALSE); break; @@ -625,7 +634,7 @@ void gr_opengl_post_process_end() // now render it to the screen ... GL_state.PopFramebufferState(); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); //Shadow Map debug window //#define SHADOW_DEBUG @@ -1042,10 +1051,12 @@ static bool opengl_post_init_framebuffer() { bool rval = false; - // clamp size, if needed - Post_texture_width = gr_screen.max_w; - Post_texture_height = gr_screen.max_h; + // Sized once and never resized, like the scene textures they consume, so they get the same + // floor -- see Gr_min_render_target_w/h (2d.h). + Post_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); + Post_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + // clamp size, if needed if (Post_texture_width > GL_max_renderbuffer_size) { Post_texture_width = GL_max_renderbuffer_size; } diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 26962561525..24ad6f92410 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -56,6 +56,12 @@ struct ViewSettings { bool Show_paths_fred = false; bool Lighting_on = false; bool FullDetail = false; + // Runs the viewport through the same HDR scene-texture + post-processing + // pipeline the game uses (bloom, tonemapping, lightshafts), instead of + // rendering straight to the default framebuffer. + // Off by default so existing missions keep looking exactly as they do today + // unless a FRED user opts in. + bool EnablePostProcessing = false; bool Show_waypoints = true; bool Show_props = true; bool Show_jump_nodes = true; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 5bd82493288..49d2ba750f3 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -994,6 +994,15 @@ void FredRenderer::render_frame(int cur_object_index, g3_set_view_matrix(&_viewport->camera.eye_pos, &_viewport->camera.eye_orient, 0.5f); + // Optionally run the 3D scene through the game's HDR post-processing pipeline + // (bloom, tonemapping, lightshafts) instead of drawing straight to the + // default framebuffer. Brackets only the 3D world content, + // the same way freespace.cpp's game_render_frame() brackets its own scene -- + // the 2D overlays below (distances, ship info, tooltips) stay outside it. + if (view().EnablePostProcessing) { + gr_scene_texture_begin(); + } + // Force max star detail so the editor always shows the full Num_stars count // regardless of the player's graphics quality setting (Detail.num_stars can be 0). int saved_detail_stars = Detail.num_stars; @@ -1019,6 +1028,10 @@ void FredRenderer::render_frame(int cur_object_index, render_models(cur_object_index); render_volumetric_overlay(); + if (view().EnablePostProcessing) { + gr_scene_texture_end(); + } + if (view().Show_distances) { display_distances(); } diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index cc8cf8efb8d..f9ff1a2cce7 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,8 @@ #include #include +#include +#include #include extern bool Xstr_inited; @@ -125,6 +128,21 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; + // Unlike the game, whose window size is fixed after gr_init(), qtFred resizes its 3D viewport at + // runtime: FredRenderer::render_frame() calls gr_screen_resize() every frame to match a dockable, + // resizable widget. The offscreen render targets that back post-processing are allocated once, + // from gr_screen as it is right now, and cannot grow afterwards -- so give them a floor big + // enough for any size that widget can reach, which is the largest screen the window could be + // maximized or fullscreened onto, in device pixels. Without this, growing the viewport past the + // startup size clips the render to the old, smaller texture and then stretches it back over the + // new, larger viewport, visibly misaligning sun sprites and bloom. + for (const QScreen* screen : QGuiApplication::screens()) { + const auto ratio = screen->devicePixelRatio(); + + Gr_min_render_target_w = MAX(Gr_min_render_target_w, qRound(screen->size().width() * ratio)); + Gr_min_render_target_h = MAX(Gr_min_render_target_h, qRound(screen->size().height() * ratio)); + } + std::unique_ptr graphicsOps(new QtGraphicsOperations(editor)); gr_init(std::move(graphicsOps)); gr_set_gamma(3.0f); diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp index 7c6aae8f482..29ffd80cbf7 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -942,6 +942,7 @@ void FredView::syncViewOptions() { connectActionToViewSetting(ui->actionLighting_from_Suns, &_viewport->view.Lighting_on); connectActionToViewSetting(ui->actionRender_Full_Detail, &_viewport->view.FullDetail); + connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.EnablePostProcessing); connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances); diff --git a/qtfred/ui/FredView.ui b/qtfred/ui/FredView.ui index 7d8cdc3e035..dd9d82c97b9 100644 --- a/qtfred/ui/FredView.ui +++ b/qtfred/ui/FredView.ui @@ -120,6 +120,8 @@ + + @@ -858,6 +860,17 @@ Render Full Detail + + + true + + + Enable Post Processing + + + Render the 3D viewport through the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) + + true From 15580f6845239dbcd36ab06d57d9c6384cffbd67 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 18:51:09 +0200 Subject: [PATCH 2/7] Add configurable graphics settings to Preferences dialog Introduce post-processing, shadow quality, anti-aliasing, anisotropic filtering, texture filtering, MSAA, and gamma controls to qtFRED's Preferences. Ensures real-time application of certain settings and requires restart for others. Adjust graphics initialization to support baked settings before `gr_init()`. Update documentation to reflect these additions. --- code/def_files/data/effects/fxaa-v.sdr | 13 +- code/graphics/opengl/gropengltexture.cpp | 9 +- .../doc/dialogs/PreferencesDialog.html | 23 ++ qtfred/src/mission/EditorViewport.cpp | 37 +++ qtfred/src/mission/EditorViewport.h | 21 ++ qtfred/src/mission/FredRenderer.cpp | 21 ++ .../dialogs/PreferencesDialogModel.cpp | 54 ++++ .../mission/dialogs/PreferencesDialogModel.h | 34 +++ qtfred/src/mission/management.cpp | 31 +++ qtfred/src/ui/dialogs/PreferencesDialog.cpp | 69 +++++ qtfred/src/ui/dialogs/PreferencesDialog.h | 8 + qtfred/ui/PreferencesDialog.ui | 243 ++++++++++++++++++ 12 files changed, 561 insertions(+), 2 deletions(-) diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr index 22a9053f2a1..d7134bb5972 100644 --- a/code/def_files/data/effects/fxaa-v.sdr +++ b/code/def_files/data/effects/fxaa-v.sdr @@ -8,6 +8,7 @@ void main() { } #else in vec4 vertPosition; +in vec4 vertTexCoord; out vec2 v_rcpFrame; noperspective out vec2 v_pos; @@ -20,6 +21,16 @@ layout (std140) uniform genericData { void main() { gl_Position = vertPosition; v_rcpFrame = vec2(1.0/rt_w, 1.0/rt_h); - v_pos = vertPosition.xy*0.5 + 0.5; + // Deriving this from vertPosition (clip-space -1..1 mapped to 0..1) instead of the actual + // texcoord attribute only samples the full [0,1] UV range, ignoring whatever sub-rectangle + // (u1,v1,u2,v2) the draw call actually asked for -- correct only when the source texture is + // the exact size of the destination viewport. qtFRED's post-processing targets are padded + // larger than its resizable viewport (see gr_opengl_scene_texture_begin()), so + // opengl_draw_full_screen_textured() is called with Scene_texture_u_scale/v_scale < 1 there; + // with the old formula FXAA re-samples that whole padded texture (valid content plus + // whatever's beyond it) squashed into the viewport, showing a shrunk duplicate of the scene + // in the corner matching the u/v scale. vertTexCoord already carries the correct (u1,v1).. + // (u2,v2) range, same as every other post-process pass's vertex shader (see post-v.sdr). + v_pos = vertTexCoord.xy; } #endif diff --git a/code/graphics/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp index d0711ecc192..abcb1eec388 100644 --- a/code/graphics/opengl/gropengltexture.cpp +++ b/code/graphics/opengl/gropengltexture.cpp @@ -190,7 +190,14 @@ void opengl_tcache_init() // check what mipmap filter we should be using // 0 == Bilinear // 1 == Trilinear - GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); + // Route through the option when in-game options are active, same as the anisotropy setting + // below, so that overriding "Graphics.TextureFilter" (e.g. via OptionsManager::setOverride()) + // actually has an effect instead of being silently bypassed by this direct config read. + if (Using_in_game_options) { + GL_mipmap_filter = TextureFilteringOption->getValue(); + } else { + GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); + } if (GL_mipmap_filter > 1) { GL_mipmap_filter = 1; diff --git a/qtfred/help-src/doc/dialogs/PreferencesDialog.html b/qtfred/help-src/doc/dialogs/PreferencesDialog.html index 88783286106..f4b2a5f043e 100644 --- a/qtfred/help-src/doc/dialogs/PreferencesDialog.html +++ b/qtfred/help-src/doc/dialogs/PreferencesDialog.html @@ -25,6 +25,29 @@

General

theme. +

Graphics

+ + + + + + + + + +
SettingDescription
Enable post-processing in the viewportRoutes the 3D viewport through + the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) instead + of drawing straight to the screen. Off by default. Every other setting on this tab + only has a visible effect while this is on.
Shadow qualityQuality of the shadows cast by the mission's sun in the + viewport.
Anti-aliasingPost-process anti-aliasing mode (FXAA/SMAA) used in the + viewport.
MSAAMultisample anti-aliasing for the viewport's 3D scene. Requires + restarting QtFRED to take effect.
Texture filteringMipmap filtering used for textures (bilinear or + trilinear). Requires restarting QtFRED to take effect.
Anisotropic filteringAnisotropic texture filtering level, up to the + maximum the current GPU supports. Requires restarting QtFRED to take + effect.
GammaBrightness of the viewport. QtFRED defaults this higher than the + game, since the viewport isn't normally tonemapped unless post-processing is + on.
+

Grid

diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index e91fcb7d67f..28b58a13f26 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace { @@ -122,6 +123,7 @@ EditorViewport::EditorViewport(Editor* in_editor, std::unique_ptr& syncMissionLayerNames(); loadSettings(); + applyGraphicsSettings(); fredApp->runAfterInit([this]() { initialSetup(); }); } @@ -177,6 +179,21 @@ void EditorViewport::loadSettings() { view.Show_compass = settings.value("view_show_compass", view.Show_compass).toBool(); view.Highlight_selectable_subsys = settings.value("view_highlight_selectable_subsys", view.Highlight_selectable_subsys).toBool(); view.Outline_lod = settings.value("view_outline_lod", view.Outline_lod).toInt(); + view.EnablePostProcessing = settings.value("view_enable_post_processing", view.EnablePostProcessing).toBool(); + view.Graphics_shadow_quality = settings.value("view_graphics_shadow_quality", view.Graphics_shadow_quality).toInt(); + view.Graphics_aa_mode = settings.value("view_graphics_aa_mode", view.Graphics_aa_mode).toInt(); + view.Graphics_msaa_samples = settings.value("view_graphics_msaa_samples", view.Graphics_msaa_samples).toInt(); + view.Graphics_texture_filter = settings.value("view_graphics_texture_filter", view.Graphics_texture_filter).toInt(); + { + // Default to the hardware's max anisotropy the first time qtFRED runs, so the + // viewport looks the same as it always has until the user turns this control down. + float maxAnisotropy = 1.0f; + if (gr_get_property(gr_property::MAX_ANISOTROPY, &maxAnisotropy)) { + view.Graphics_anisotropy = maxAnisotropy; + } + view.Graphics_anisotropy = settings.value("view_graphics_anisotropy", view.Graphics_anisotropy).toFloat(); + } + view.Graphics_gamma = settings.value("view_graphics_gamma", view.Graphics_gamma).toFloat(); camera.setInvertOrbitX(settings.value("camera_invert_orbit_x", camera.getInvertOrbitX()).toBool()); camera.setInvertOrbitY(settings.value("camera_invert_orbit_y", camera.getInvertOrbitY()).toBool()); settings.endGroup(); @@ -225,10 +242,30 @@ void EditorViewport::saveSettings() const { settings.setValue("view_show_compass", view.Show_compass); settings.setValue("view_highlight_selectable_subsys", view.Highlight_selectable_subsys); settings.setValue("view_outline_lod", view.Outline_lod); + settings.setValue("view_enable_post_processing", view.EnablePostProcessing); + settings.setValue("view_graphics_shadow_quality", view.Graphics_shadow_quality); + settings.setValue("view_graphics_aa_mode", view.Graphics_aa_mode); + settings.setValue("view_graphics_msaa_samples", view.Graphics_msaa_samples); + settings.setValue("view_graphics_texture_filter", view.Graphics_texture_filter); + settings.setValue("view_graphics_anisotropy", view.Graphics_anisotropy); + settings.setValue("view_graphics_gamma", view.Graphics_gamma); settings.setValue("camera_invert_orbit_x", camera.getInvertOrbitX()); settings.setValue("camera_invert_orbit_y", camera.getInvertOrbitY()); settings.endGroup(); } + +void EditorViewport::applyGraphicsSettings() const { + // Shadow_quality is deliberately not set here. Turning shadows on requires allocating the + // shadow framebuffer and sizing the cascade-parameter buffers (shadow_cascade_params_init(), + // gropengltnl.cpp/gr_vulkan.cpp), which only happens once at gr_init() and only if + // Shadow_quality is already non-Disabled at that point -- flipping the global afterwards + // leaves those buffers empty/unsized and segfaults the next time a frame tries to render + // shadows. Applied before gr_init() instead (see management.cpp), same as MSAA samples, + // texture filter, and anisotropy; the Preferences UI notes it needs a restart. + Gr_aa_mode = static_cast(view.Graphics_aa_mode); + gr_set_gamma(view.Graphics_gamma); +} + void EditorViewport::needsUpdate() { _renderer->scheduleUpdate(); } diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 24ad6f92410..c50ef0f9ccd 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -69,6 +69,19 @@ struct ViewSettings { bool Highlight_selectable_subsys = false; int Outline_lod = 1; + // Graphics (Preferences > Graphics tab). All quality/effect settings below are only + // visible in the viewport while EnablePostProcessing is on, since they're consumed by + // the same HDR scene-texture + post-processing pipeline that flag gates. + int Graphics_shadow_quality = 0; // ShadowQuality: 0=Disabled, 1=Low, 2=Medium, 3=High, 4=Ultra + int Graphics_aa_mode = 0; // AntiAliasMode: 0=None .. 7=SMAA Ultra + // The following three are baked into GPU resources at gr_init() time and can only be + // changed by restarting qtFRED; qtFRED applies the saved value before gr_init() runs + // (see management.cpp), and the Preferences UI notes this in each control's tooltip. + int Graphics_msaa_samples = 0; // 0, 4, or 8 + int Graphics_texture_filter = 1; // 0=Bilinear, 1=Trilinear + float Graphics_anisotropy = 1.0f; // populated from the hardware max the first time qtFRED runs + float Graphics_gamma = 3.0f; // matches the brightness qtFRED has always launched with + ViewSettings(); }; @@ -249,6 +262,14 @@ class EditorViewport { void saveSettings() const; + // Pushes the live-appliable subset of view.Graphics_* (shadow quality, AA mode, gamma) + // into the engine globals that actually drive rendering. Called once at startup, after + // loadSettings(), and again from PreferencesDialogModel::apply() whenever the user + // changes one of those controls. Graphics_msaa_samples/Graphics_texture_filter/ + // Graphics_anisotropy are deliberately not included here -- those can only be applied + // before gr_init() (see management.cpp) and need a restart to change. + void applyGraphicsSettings() const; + Editor* editor = nullptr; FredRenderer* renderer = nullptr; IDialogProvider* dialogProvider = nullptr; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 49d2ba750f3..760de615b6a 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -1012,6 +1013,26 @@ void FredRenderer::render_frame(int cur_object_index, disable_htl(); Detail.num_stars = saved_detail_stars; + // Shadows piggyback on the same HDR scene-texture pipeline as post-processing above -- the + // shadow pass writes into deferred G-buffer surfaces that only exist while that's bound, so + // it can only run under EnablePostProcessing. Mirrors freespace.cpp's game_render_frame(), + // which calls this right after its own gr_scene_texture_begin()+stars_draw(). Eye_position/ + // Eye_matrix/Proj_fov were already set to FRED's camera by the g3_set_view_matrix() call + // above, but shadows_render_all() operates on the separate HTL proj/view matrix stack (the + // same one enable_htl()/disable_htl() push and pop for the starfield): it starts by ending + // whatever's currently active (Assert(modelview_matrix_depth == 2) in gr_end_view_matrix()), + // then restores it via gr_set_proj_matrix()/gr_set_view_matrix() when done. disable_htl() + // just popped that stack, so it has to be pushed again here first -- and popped again + // afterward, since FRED's grid/model rendering below never touches the HTL stack and the + // *next* frame's enable_htl() would itself assert if it were left open. + if (view().EnablePostProcessing) { + gr_set_proj_matrix(Proj_fov, gr_screen.clip_aspect, Min_draw_distance, Max_draw_distance); + gr_set_view_matrix(&Eye_position, &Eye_matrix); + shadows_render_all(Proj_fov, &Eye_matrix, &Eye_position, nullptr, nullptr, nullptr); + gr_end_proj_matrix(); + gr_end_view_matrix(); + } + if (view().Show_horizon) { gr_set_color(128, 128, 64); g3_draw_horizon_line(); diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp index c674f26744e..74ae5252400 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -4,6 +4,7 @@ #include "ui/Theme.h" #include "mission/missiongrid.h" #include "math/vecmat.h" +#include "graphics/2d.h" namespace fso::fred::dialogs { @@ -26,6 +27,13 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _dataMenuStyle(viewport->Data_menu_style) , _toolbarIconSize(viewport->toolbar_icon_size) , _outlineLod(viewport->view.Outline_lod) + , _enablePostProcessing(viewport->view.EnablePostProcessing) + , _shadowQuality(viewport->view.Graphics_shadow_quality) + , _aaMode(viewport->view.Graphics_aa_mode) + , _msaaSamples(viewport->view.Graphics_msaa_samples) + , _textureFilter(viewport->view.Graphics_texture_filter) + , _anisotropy(viewport->view.Graphics_anisotropy) + , _gamma(viewport->view.Graphics_gamma) , _invertOrbitX(viewport->camera.getInvertOrbitX()) , _invertOrbitY(viewport->camera.getInvertOrbitY()) , _gridCenterX(static_cast(viewport->The_grid->center.xyz.x)) @@ -68,6 +76,16 @@ bool PreferencesDialogModel::apply() { _viewport->Data_menu_style = _dataMenuStyle; _viewport->toolbar_icon_size = _toolbarIconSize; _viewport->view.Outline_lod = _outlineLod; + _viewport->view.EnablePostProcessing = _enablePostProcessing; + _viewport->view.Graphics_shadow_quality = _shadowQuality; + _viewport->view.Graphics_aa_mode = _aaMode; + _viewport->view.Graphics_msaa_samples = _msaaSamples; + _viewport->view.Graphics_texture_filter = _textureFilter; + _viewport->view.Graphics_anisotropy = _anisotropy; + _viewport->view.Graphics_gamma = _gamma; + // AA mode and gamma take effect immediately; shadow quality, MSAA samples, texture filter, + // and anisotropy are only applied at startup (see management.cpp) and need a restart. + _viewport->applyGraphicsSettings(); _viewport->camera.setInvertOrbitX(_invertOrbitX); _viewport->camera.setInvertOrbitY(_invertOrbitY); @@ -180,6 +198,42 @@ void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconS int PreferencesDialogModel::getOutlineLod() const { return _outlineLod; } void PreferencesDialogModel::setOutlineLod(int value) { modify(_outlineLod, value); } +bool PreferencesDialogModel::getEnablePostProcessing() const { return _enablePostProcessing; } +void PreferencesDialogModel::setEnablePostProcessing(bool value) { modify(_enablePostProcessing, value); } + +int PreferencesDialogModel::getShadowQuality() const { return _shadowQuality; } +void PreferencesDialogModel::setShadowQuality(int value) { modify(_shadowQuality, value); } + +int PreferencesDialogModel::getAAMode() const { return _aaMode; } +void PreferencesDialogModel::setAAMode(int value) { modify(_aaMode, value); } + +int PreferencesDialogModel::getMSAASamples() const { return _msaaSamples; } +void PreferencesDialogModel::setMSAASamples(int value) { modify(_msaaSamples, value); } + +int PreferencesDialogModel::getTextureFilter() const { return _textureFilter; } +void PreferencesDialogModel::setTextureFilter(int value) { modify(_textureFilter, value); } + +float PreferencesDialogModel::getAnisotropy() const { return _anisotropy; } +void PreferencesDialogModel::setAnisotropy(float value) { modify(_anisotropy, value); } + +SCP_vector PreferencesDialogModel::getAvailableAnisotropyLevels() const { + SCP_vector levels; + + float max = 1.0f; + if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max) || max <= 1.0f) { + levels.push_back(1.0f); + return levels; + } + + for (float level = 1.0f; level <= max; level *= 2.0f) { + levels.push_back(level); + } + return levels; +} + +float PreferencesDialogModel::getGamma() const { return _gamma; } +void PreferencesDialogModel::setGamma(float value) { modify(_gamma, value); } + QKeySequence PreferencesDialogModel::getControlKey(ControlAction action) const { auto it = _controlKeys.find(action); Assertion(it != _controlKeys.end(), "Unknown control action!"); diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.h b/qtfred/src/mission/dialogs/PreferencesDialogModel.h index 9fcc5b50884..c46d915ef36 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.h +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.h @@ -67,6 +67,31 @@ class PreferencesDialogModel : public AbstractDialogModel { int getOutlineLod() const; void setOutlineLod(int value); + // Graphics + bool getEnablePostProcessing() const; + void setEnablePostProcessing(bool value); + + int getShadowQuality() const; + void setShadowQuality(int value); + + int getAAMode() const; + void setAAMode(int value); + + int getMSAASamples() const; + void setMSAASamples(int value); + + int getTextureFilter() const; + void setTextureFilter(int value); + + float getAnisotropy() const; + void setAnisotropy(float value); + // Anisotropy levels the current hardware actually supports (1.0 = off), for populating the + // combo box: 1x, 2x, 4x, ... up to the hardware max. + SCP_vector getAvailableAnisotropyLevels() const; + + float getGamma() const; + void setGamma(float value); + // Controls QKeySequence getControlKey(ControlAction action) const; void setControlKey(ControlAction action, const QKeySequence& sequence); @@ -109,6 +134,15 @@ class PreferencesDialogModel : public AbstractDialogModel { int _toolbarIconSize; int _outlineLod; + // Graphics + bool _enablePostProcessing; + int _shadowQuality; + int _aaMode; + int _msaaSamples; + int _textureFilter; + float _anisotropy; + float _gamma; + // Controls std::map _controlKeys; bool _invertOrbitX; diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index f9ff1a2cce7..b10ee7a4d2a 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +44,7 @@ #include #include +#include #include extern bool Xstr_inited; @@ -143,6 +146,34 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, Gr_min_render_target_h = MAX(Gr_min_render_target_h, qRound(screen->size().height() * ratio)); } + // MSAA sample count, shadow quality, and the texture filter/anisotropy defaults are baked + // into GPU resources during gr_init() and can't be changed afterwards without recreating + // them, so qtFRED has to apply its saved Preferences > Graphics values here, before gr_init() + // runs, rather than from EditorViewport (which doesn't exist yet -- it's created after the + // first viewport widget is up). Shadow quality specifically: shadow_cascade_params_init() + // (gropengltnl.cpp/gr_vulkan.cpp) only runs during gr_init(), and only if Shadow_quality is + // already non-Disabled at that point -- setting it afterwards leaves the cascade buffers + // unsized and segfaults the next time a frame renders shadows. Read directly out of QSettings + // under the same "Preferences" group EditorViewport uses, using an in-memory-only + // OptionsManager override for the two settings the engine reads through an Option + // (Graphics.TextureFilter, Graphics.Anisotropy) so this never rewrites the registry/config + // file the actual game reads its own graphics settings from. + { + QSettings settings; + settings.beginGroup("Preferences"); + Cmdline_msaa_enabled = settings.value("view_graphics_msaa_samples", Cmdline_msaa_enabled).toInt(); + Shadow_quality = static_cast(settings.value("view_graphics_shadow_quality", 0).toInt()); + + const int textureFilter = settings.value("view_graphics_texture_filter", 1).toInt(); + options::OptionsManager::instance()->setOverride("Graphics.TextureFilter", std::to_string(textureFilter)); + + if (settings.contains("view_graphics_anisotropy")) { + const float anisotropy = settings.value("view_graphics_anisotropy").toFloat(); + options::OptionsManager::instance()->setOverride("Graphics.Anisotropy", std::to_string(anisotropy)); + } + settings.endGroup(); + } + std::unique_ptr graphicsOps(new QtGraphicsOperations(editor)); gr_init(std::move(graphicsOps)); gr_set_gamma(3.0f); diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index 07db9e06d92..fd765fa548f 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -94,6 +94,12 @@ void PreferencesDialog::applyChanges() { } void PreferencesDialog::initializeUi() { + // Populate the anisotropy combo with the levels the current hardware actually supports + // (1x/2x/4x/.../max), since that max varies by GPU. + for (float level : _model->getAvailableAnisotropyLevels()) { + ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0)); + } + // Build the controls key-binding form dynamically from the registered bindings auto* form = new QFormLayout(ui->controlsFormWidget); auto& bindings = ControlBindings::instance(); @@ -125,6 +131,37 @@ void PreferencesDialog::updateUi() { const int iconSize = _model->getToolbarIconSize(); ui->toolbarIconSizeCombo->setCurrentIndex(iconSize <= 16 ? 0 : iconSize >= 32 ? 2 : 1); ui->outlineLodCombo->setCurrentIndex(_model->getOutlineLod()); + + // Graphics + ui->enablePostProcessing->setChecked(_model->getEnablePostProcessing()); + ui->shadowQualityCombo->setCurrentIndex(_model->getShadowQuality()); + ui->aaModeCombo->setCurrentIndex(_model->getAAMode()); + { + static constexpr int msaaSamples[] = { 0, 4, 8 }; + const int samples = _model->getMSAASamples(); + int index = 0; + for (int i = 0; i < 3; ++i) { + if (msaaSamples[i] == samples) { + index = i; + break; + } + } + ui->msaaCombo->setCurrentIndex(index); + } + ui->textureFilterCombo->setCurrentIndex(_model->getTextureFilter()); + { + const auto levels = _model->getAvailableAnisotropyLevels(); + const float anisotropy = _model->getAnisotropy(); + int index = 0; + for (size_t i = 0; i < levels.size(); ++i) { + if (levels[i] <= anisotropy) { + index = static_cast(i); + } + } + ui->anisotropyCombo->setCurrentIndex(index); + } + ui->gammaSpin->setValue(_model->getGamma()); + ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents()); ui->showSexpHelpMissionGoals->setChecked(_model->getShowSexpHelpMissionGoals()); ui->showSexpHelpMissionCutscenes->setChecked(_model->getShowSexpHelpMissionCutscenes()); @@ -202,6 +239,38 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) { _model->setThemeMode(themeModeFromIndex(index)); } +void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) { + _model->setEnablePostProcessing(checked); +} + +void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) { + _model->setShadowQuality(index); +} + +void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) { + _model->setAAMode(index); +} + +void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) { + static constexpr int msaaSamples[] = { 0, 4, 8 }; + _model->setMSAASamples(msaaSamples[index]); +} + +void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int index) { + _model->setTextureFilter(index); +} + +void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) { + const auto levels = _model->getAvailableAnisotropyLevels(); + if (index >= 0 && static_cast(index) < levels.size()) { + _model->setAnisotropy(levels[index]); + } +} + +void PreferencesDialog::on_gammaSpin_valueChanged(double value) { + _model->setGamma(static_cast(value)); +} + void PreferencesDialog::on_dataMenuStyleCombo_currentIndexChanged(int index) { _model->setDataMenuStyle(static_cast(index)); } diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h index e4e8266866e..cc1a5a6214b 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -34,6 +34,14 @@ private slots: void on_toolbarIconSizeCombo_currentIndexChanged(int index); void on_outlineLodCombo_currentIndexChanged(int index); void on_themeCombo_currentIndexChanged(int index); + // Graphics + void on_enablePostProcessing_toggled(bool checked); + void on_shadowQualityCombo_currentIndexChanged(int index); + void on_aaModeCombo_currentIndexChanged(int index); + void on_msaaCombo_currentIndexChanged(int index); + void on_textureFilterCombo_currentIndexChanged(int index); + void on_anisotropyCombo_currentIndexChanged(int index); + void on_gammaSpin_valueChanged(double value); void on_dataMenuStyleCombo_currentIndexChanged(int index); void on_showSexpHelpMissionEvents_toggled(bool checked); void on_showSexpHelpMissionGoals_toggled(bool checked); diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index c45e1a9ecb4..68b55ea5b3b 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -360,6 +360,249 @@ + + + Graphics + + + + + + Route the 3D viewport through the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) instead of drawing straight to the screen. The other settings on this tab only have a visible effect while this is on. + + + Enable post-processing in the viewport + + + + + + + Shadows && Anti-Aliasing + + + + + + Shadow quality: + + + + + + + Quality of the shadows cast by the mission's sun in the viewport. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect. + + + + Disabled + + + + + Low (restart required) + + + + + Medium (restart required) + + + + + High (restart required) + + + + + Ultra (restart required) + + + + + + + + Anti-aliasing: + + + + + + + Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled. + + + + None + + + + + FXAA Low + + + + + FXAA Medium + + + + + FXAA High + + + + + SMAA Low + + + + + SMAA Medium + + + + + SMAA High + + + + + SMAA Ultra + + + + + + + + MSAA: + + + + + + + Multisample anti-aliasing used for the viewport's 3D scene. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect. + + + + Off + + + + + 4x (restart required) + + + + + 8x (restart required) + + + + + + + + + + + Textures + + + + + + Texture filtering: + + + + + + + Mipmap filtering used for textures. Restarting qtFRED is required for a change to take effect. + + + + Bilinear (restart required) + + + + + Trilinear (restart required) + + + + + + + + Anisotropic filtering: + + + + + + + Anisotropic texture filtering level. Restarting qtFRED is required for a change to take effect. + + + + + + + + + + Brightness + + + + + + Gamma: + + + + + + + Brightness of the viewport. qtFRED defaults this higher than the game since the viewport isn't normally tonemapped. + + + 2 + + + 0.10 + + + 5.00 + + + 0.05 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + Grid From 074c02add6905342c332b66be7b97df311ceb7e9 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:07:16 +0200 Subject: [PATCH 3/7] Fix texture filtering silently defaulting to bilinear opengl_tcache_init() read GL_mipmap_filter out of TextureFilteringOption before seeding it from the legacy config key. That option's default_func returns GL_mipmap_filter itself, so with no persisted "Graphics.TextureFilter" value getValue() fell through to a still-zero-initialized global -- bilinear, where the config default is trilinear. Since in-game options are on by default, that affected every player who had never explicitly set the option, and it ignored the legacy TextureFilter key existing installs were configured through. Seed from config first, then let the option override, matching the order the anisotropy setting below already uses (its default_func queries the hardware directly, which is why it was never affected). Also route the option's value enumerator through the new shared gr_get_supported_anisotropy_levels() rather than a private copy. Co-Authored-By: Claude Opus 5 --- code/graphics/2d.cpp | 23 ++++++++++++++++ code/graphics/2d.h | 6 +++++ code/graphics/opengl/gropengltexture.cpp | 34 +++++------------------- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 5bdd8605afa..f49fa370502 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -859,6 +859,29 @@ bool gr_is_smaa_mode(AntiAliasMode mode) { return mode == AntiAliasMode::SMAA_Low || mode == AntiAliasMode::SMAA_Medium || mode == AntiAliasMode::SMAA_High || mode == AntiAliasMode::SMAA_Ultra; } +SCP_vector gr_get_supported_anisotropy_levels() +{ + float max; + if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { + return SCP_vector(); + } + + if (max <= 2.0f) { + return SCP_vector(); + } + + SCP_vector out; + + // We assume here that the anisotropy levels are powers of two... + float current = 1.0f; + while (current <= max) { + out.push_back(current); + current *= 2.0f; + } + + return out; +} + static void parse_post_processing_func() { bool value; diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 689a6a66314..b6024ad7fc6 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -1506,6 +1506,12 @@ inline bool gr_get_property(gr_property property, void* destination) return gr_screen.gf_get_property(property, destination); } +// Anisotropic filtering levels the current hardware supports: 1.0 (off), then powers of two up to +// the reported maximum. Empty if anisotropy is unavailable or the hardware caps out below 4x, in +// which case there is nothing meaningful to offer. Backs both the in-game option's enumerator and +// qtFRED's Preferences combo, so the two can't drift. +SCP_vector gr_get_supported_anisotropy_levels(); + inline void gr_push_debug_group(const char* name) { gr_screen.gf_push_debug_group(name); diff --git a/code/graphics/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp index abcb1eec388..63bdbe5090b 100644 --- a/code/graphics/opengl/gropengltexture.cpp +++ b/code/graphics/opengl/gropengltexture.cpp @@ -90,28 +90,6 @@ static auto TextureFilteringOption __UNUSED = options::OptionBuilder("Graph .parser(parse_texture_filtering_func) .finish(); -static SCP_vector anisotropic_value_enumerator() -{ - float max; - if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { - return SCP_vector(); - } - - if (max <= 2.0f) { - return SCP_vector(); - } - - SCP_vector out; - - // We assume here that the anisotropy levels are powers of two... - float current = 1.0f; - while (current <= max) { - out.push_back(current); - current *= 2.0f; - } - - return out; -} static SCP_string anisotropic_display(float val) { if (val < 2.0f) { @@ -135,7 +113,7 @@ static float anisotropic_default() static auto AnisotropyOption = options::OptionBuilder("Graphics.Anisotropy", std::pair{"Anistropic filtering", 1736}, std::pair{"Controls the amount of anistropic filtering of the textures", 1737}) - .enumerator(anisotropic_value_enumerator) + .enumerator(gr_get_supported_anisotropy_levels) .category(std::make_pair("Graphics", 1825)) .display(anisotropic_display) .default_func(anisotropic_default) @@ -190,13 +168,13 @@ void opengl_tcache_init() // check what mipmap filter we should be using // 0 == Bilinear // 1 == Trilinear - // Route through the option when in-game options are active, same as the anisotropy setting - // below, so that overriding "Graphics.TextureFilter" (e.g. via OptionsManager::setOverride()) - // actually has an effect instead of being silently bypassed by this direct config read. + // Seed from the legacy config key first: TextureFilteringOption's default_func returns + // GL_mipmap_filter, so this read is what supplies that default. Only then let the option + // override it, the same order the anisotropy setting below uses. + GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); + if (Using_in_game_options) { GL_mipmap_filter = TextureFilteringOption->getValue(); - } else { - GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); } if (GL_mipmap_filter > 1) { From aa73fb65b8fdf31fac9fa02e243db7118db68c72 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:18:51 +0200 Subject: [PATCH 4/7] Grow the scene/post render targets when the viewport does The offscreen targets backing the scene texture and post-processing were allocated once from gr_screen at gr_init() and never revisited. Anything drawn while gr_screen was larger got clipped to their edge and stretched back over the viewport. The game hits this on the SDL window-resize path (osapi.cpp calls gr_screen_resize()); qtFRED hits it constantly, since its 3D viewport is a resizable dock widget. Add gf_resize_render_targets, called from gr_screen_resize(), and implement it for OpenGL: grow the targets to cover the new gr_screen, rebuilding only the resolution-dependent resources. The post-processing table, the compiled shaders and the SMAA lookup textures are resolution-independent and stay alive, which is what makes this cheap enough to run off a window drag. This mirrors what the Vulkan backend already does in VulkanPostProcessor::resize(); Vulkan leaves the hook unset because recreateSwapChain() owns it there. The allocation only ever grows: gr_screen_resize() is called every frame by qtFRED and repeatedly by the briefing map widget, so tracking the high-water mark avoids thrashing, and the shrunk state is already handled by Scene_texture_u_scale/v_scale. The size is also clamped to the hardware limit up front, so a viewport past that limit stops asking to be resized instead of rebuilding every frame. If the larger allocation fails outright -- most likely precisely when growing -- the resize stops before rebuilding the post-processing targets on top of scene textures that no longer exist. This replaces Gr_min_render_target_w/h, which sized the targets for the largest attached display up front -- on a 4K display at 2x scaling that was over a gigabyte of VRAM, allocated at launch, for a feature that is off by default and may never be switched on. Deletions now go through GL_state.Texture.Delete(): a freed texture name the state cache still holds would make a later Enable() of the recycled name a silent no-op. This mattered little when teardown only ran at shutdown. While there, the scene teardown now releases everything setup allocates -- it was leaking Scene_ldr/composite/luminance/Cockpit_depth and all six MSAA targets -- and post-processing shutdown releases the SMAA lookup textures. Separately, fix the sampling extents that the above makes reachable: - deferred-f.sdr turns gl_FragCoord into a G-buffer coordinate using invScreenWidth/Height, which described gr_screen rather than the G-buffer. Fixed in both backends; it is currently a no-op under Vulkan, where resize() keeps the extent equal to gr_screen, but states the requirement instead of relying on that. - the MSAA scene-colour copy, the MSAA resolve and the fog pass sampled the full [0,1] range of targets that are only filled to the u/v scale. - fxaa-v.sdr derived its texcoord from vertPosition, ignoring the sub-rectangle the draw call asked for. Those extents now come from opengl_draw_full_screen_scene_texture() rather than being open-coded, so a new pass cannot quietly reintroduce the bug. The volumetric nebula pass is deliberately left unscaled and commented: it uses fragTexCoord both to reconstruct a ray direction and to sample, which needs a shader change to separate. Co-Authored-By: Claude Opus 5 --- code/def_files/data/effects/fxaa-v.sdr | 12 +- code/graphics/2d.cpp | 10 +- code/graphics/2d.h | 18 +- code/graphics/opengl/gropengl.cpp | 3 +- code/graphics/opengl/gropengldeferred.cpp | 23 +- code/graphics/opengl/gropengldraw.cpp | 252 ++++++++++-------- code/graphics/opengl/gropengldraw.h | 14 +- .../opengl/gropenglpostprocessing.cpp | 145 +++++----- code/graphics/opengl/gropenglpostprocessing.h | 4 + .../vulkan/VulkanPostProcessingLighting.cpp | 8 +- qtfred/src/mission/management.cpp | 16 -- 11 files changed, 273 insertions(+), 232 deletions(-) diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr index d7134bb5972..1b4e4d255c5 100644 --- a/code/def_files/data/effects/fxaa-v.sdr +++ b/code/def_files/data/effects/fxaa-v.sdr @@ -21,16 +21,8 @@ layout (std140) uniform genericData { void main() { gl_Position = vertPosition; v_rcpFrame = vec2(1.0/rt_w, 1.0/rt_h); - // Deriving this from vertPosition (clip-space -1..1 mapped to 0..1) instead of the actual - // texcoord attribute only samples the full [0,1] UV range, ignoring whatever sub-rectangle - // (u1,v1,u2,v2) the draw call actually asked for -- correct only when the source texture is - // the exact size of the destination viewport. qtFRED's post-processing targets are padded - // larger than its resizable viewport (see gr_opengl_scene_texture_begin()), so - // opengl_draw_full_screen_textured() is called with Scene_texture_u_scale/v_scale < 1 there; - // with the old formula FXAA re-samples that whole padded texture (valid content plus - // whatever's beyond it) squashed into the viewport, showing a shrunk duplicate of the scene - // in the corner matching the u/v scale. vertTexCoord already carries the correct (u1,v1).. - // (u2,v2) range, same as every other post-process pass's vertex shader (see post-v.sdr). + // Use the real texcoord rather than deriving it from vertPosition: the draw call may ask for a + // sub-rectangle of the source texture, which the clip-space formula ignored. Matches post-v.sdr. v_pos = vertTexCoord.xy; } #endif diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index f49fa370502..5b66d206955 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -892,9 +892,6 @@ static void parse_post_processing_func() bool Gr_post_processing_enabled = true; -int Gr_min_render_target_w = 0; -int Gr_min_render_target_h = 0; - // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto PostProcessOption __UNUSED = options::OptionBuilder("Graphics.PostProcessing", std::pair{"Post processing", 1726}, @@ -1673,6 +1670,13 @@ void gr_screen_resize(int width, int height) gr_screen.save_max_h_unscaled_zoomed = gr_screen.max_h_unscaled_zoomed; gr_setup_viewport(); + + // The offscreen targets that back the scene/post-processing pipeline were sized for the old + // gr_screen; give the backend a chance to grow them before anything renders at the new size. + // Backends that already handle this elsewhere (Vulkan, via recreateSwapChain()) leave it unset. + if (gr_screen.gf_resize_render_targets) { + gr_screen.gf_resize_render_targets(); + } } void gr_window_to_render_pos(float& x, float& y) diff --git a/code/graphics/2d.h b/code/graphics/2d.h index b6024ad7fc6..c98ff258bd2 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -73,18 +73,6 @@ bool gr_is_smaa_mode(AntiAliasMode mode); extern bool Gr_post_processing_enabled; -// Floor, in pixels, for the offscreen render targets that back post-processing (the scene textures -// and the post-processing surfaces). Those are sized once, from gr_screen.max_w/max_h at renderer -// init, and never revisited; anything drawn while gr_screen is *larger* than them is silently -// clipped to their edge and then stretched back over the full viewport. The game never hits that -// -- its window size is fixed after gr_init() -- but qtFred resizes its 3D viewport at runtime, so -// it sets this to the largest size that viewport can ever reach before calling gr_init(). Leave at -// 0 (the default) to size the targets purely from gr_screen, which is what the game does. The -// targets are still clamped to the hardware's maximum renderbuffer size, so this is a request, not -// a guarantee. -extern int Gr_min_render_target_w; -extern int Gr_min_render_target_h; - extern bool Gr_enable_vsync; // HDR10 (PQ/ST.2084 + BT.2020) output. Currently only honored by the Vulkan renderer. @@ -929,6 +917,12 @@ typedef struct screen { std::function gf_scene_texture_end; std::function gf_copy_effect_texture; + // Grow the offscreen render targets that back the scene/post-processing pipeline to cover the + // current gr_screen, if they don't already. Called from gr_screen_resize(). Optional: the + // Vulkan backend leaves this unset because VulkanRenderer::recreateSwapChain() already owns + // resizing its extent-sized targets. + std::function gf_resize_render_targets; + std::function gf_zbias; std::function gf_set_fill_mode; diff --git a/code/graphics/opengl/gropengl.cpp b/code/graphics/opengl/gropengl.cpp index 0bd86bdd539..413c6638829 100644 --- a/code/graphics/opengl/gropengl.cpp +++ b/code/graphics/opengl/gropengl.cpp @@ -1126,6 +1126,7 @@ void gr_opengl_init_function_pointers() gr_screen.gf_scene_texture_begin = gr_opengl_scene_texture_begin; gr_screen.gf_scene_texture_end = gr_opengl_scene_texture_end; gr_screen.gf_copy_effect_texture = gr_opengl_copy_effect_texture; + gr_screen.gf_resize_render_targets = gr_opengl_resize_render_targets; gr_screen.gf_deferred_lighting_begin = gr_opengl_deferred_lighting_begin; gr_screen.gf_deferred_lighting_msaa = gr_opengl_deferred_lighting_msaa; @@ -1514,7 +1515,7 @@ bool gr_opengl_init(std::unique_ptr&& graphicsOps) opengl_shader_init(); // post processing effects, after shaders are initialized - opengl_setup_scene_textures(); + opengl_setup_scene_textures(gr_screen.max_w, gr_screen.max_h); opengl_post_process_init(); // must be called after extensions are setup diff --git a/code/graphics/opengl/gropengldeferred.cpp b/code/graphics/opengl/gropengldeferred.cpp index 5f97cb8cd99..f306a9451e5 100644 --- a/code/graphics/opengl/gropengldeferred.cpp +++ b/code/graphics/opengl/gropengldeferred.cpp @@ -86,7 +86,7 @@ void gr_opengl_deferred_lighting_begin(bool clearNonColorBufs) Current_shader->program->Uniforms.setTextureUniform("tex", 0); GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE); GL_state.SetZbufferType(ZBUFFER_TYPE_NONE); - opengl_draw_full_screen_textured(0, 0, 1, 1); + opengl_draw_full_screen_scene_texture(); } else { // Copy the existing color data into the emissive part of the G-buffer since everything that already existed is // treated as emissive @@ -159,7 +159,9 @@ void gr_opengl_deferred_lighting_msaa() }); GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE); GL_state.SetZbufferType(ZBUFFER_TYPE_WRITE); - opengl_draw_full_screen_textured(0, 0, 1, 1); + // msaa-f.sdr resolves via ivec2(textureSize(texColor) * fragTexCoord), so the texcoords have to + // stay inside the rendered sub-rectangle of the multisampled G-buffer. + opengl_draw_full_screen_scene_texture(); } void gr_opengl_deferred_lighting_end() @@ -325,8 +327,12 @@ void gr_opengl_deferred_lighting_finish() shadow_cascade_params_bind(offset, count); } - header->invScreenWidth = 1.0f / gr_screen.max_w; - header->invScreenHeight = 1.0f / gr_screen.max_h; + // deferred-f.sdr turns gl_FragCoord into a G-buffer texture coordinate with these, so they + // have to normalize against the G-buffer's own dimensions. Those only equal gr_screen while + // the viewport exactly fills the scene textures -- not after a shrink, and not when the + // allocation was clamped by GL_max_renderbuffer_size. + header->invScreenWidth = 1.0f / Scene_texture_width; + header->invScreenHeight = 1.0f / Scene_texture_height; header->nearPlane = gr_near_plane; { @@ -561,7 +567,8 @@ void gr_opengl_deferred_lighting_finish() data->clip_dist = Neb2_fog_clip_distance; }); - opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); + // fog-f.sdr samples the composite and depth targets straight off fragTexCoord. + opengl_draw_full_screen_scene_texture(); if (bDrawNebVolumetrics) { glReadBuffer(GL_COLOR_ATTACHMENT0); @@ -657,6 +664,12 @@ void gr_opengl_deferred_lighting_finish() { GR_DEBUG_SCOPE("Volumetric Nebulae Draw"); + // Deliberately unscaled. volumetric-f.sdr uses fragTexCoord for two incompatible + // things: reconstructing an eye-space ray direction, which needs the full 0..1 range + // across the viewport, and sampling composite/depth/emissive, which needs the + // rendered sub-rectangle. Scaling here would fix the sampling and skew every ray. + // Separating the two needs a second varying (or a scale uniform) in the shader; until + // then volumetrics are only correct while the targets exactly match the viewport. opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); } GL_state.Texture.Enable(Scene_emissive_texture); diff --git a/code/graphics/opengl/gropengldraw.cpp b/code/graphics/opengl/gropengldraw.cpp index 8ecfc3e82ec..ea32c2950b7 100644 --- a/code/graphics/opengl/gropengldraw.cpp +++ b/code/graphics/opengl/gropengldraw.cpp @@ -70,6 +70,33 @@ int Scene_texture_height; GLfloat Scene_texture_u_scale = 1.0f; GLfloat Scene_texture_v_scale = 1.0f; +// Render targets are torn down and rebuilt mid-session by gr_opengl_resize_render_targets(), not +// just at shutdown, so deletion has to go through the state cache: the driver is free to hand a +// freed name straight back out, and a cache entry still holding that name would make a later +// Enable() of the recycled texture a no-op. +void opengl_delete_render_texture(GLuint& tex) +{ + if ( !tex ) { + return; + } + + GL_state.Texture.Delete(tex); + glDeleteTextures(1, &tex); + tex = 0; +} + +// Callers must have bound something else first (the resize path binds 0); the framebuffer cache +// has no equivalent of Texture.Delete() to unbind through. +void opengl_delete_render_framebuffer(GLuint& fbo) +{ + if ( !fbo ) { + return; + } + + glDeleteFramebuffers(1, &fbo); + fbo = 0; +} + inline GLenum opengl_primitive_type(primitive_type prim_type) { switch ( prim_type ) { @@ -98,7 +125,7 @@ void gr_opengl_sphere(material* material_def, float /*rad*/) } extern int opengl_check_framebuffer(); -void opengl_setup_scene_textures() +void opengl_setup_scene_textures(int width, int height) { Scene_texture_initialized = 0; @@ -113,12 +140,8 @@ void opengl_setup_scene_textures() return; } - // These textures are allocated once and never resized, so they have to be big enough for the - // largest gr_screen this session will ever see, not just the current one -- see - // Gr_min_render_target_w/h (2d.h) for why anything drawn outside them is stretched. In the game - // the floor is 0 and this is just gr_screen. - Scene_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); - Scene_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + Scene_texture_width = width; + Scene_texture_height = height; // clamp size, if needed if ( Scene_texture_width > GL_max_renderbuffer_size ) { @@ -129,13 +152,11 @@ void opengl_setup_scene_textures() Scene_texture_height = GL_max_renderbuffer_size; } - mprintf((" Scene textures: %dx%d (screen %dx%d, floor %dx%d, max renderbuffer %d)\n", + mprintf((" Scene textures: %dx%d (screen %dx%d, max renderbuffer %d)\n", Scene_texture_width, Scene_texture_height, gr_screen.max_w, gr_screen.max_h, - Gr_min_render_target_w, - Gr_min_render_target_h, GL_max_renderbuffer_size)); // create framebuffer @@ -346,32 +367,15 @@ void opengl_setup_scene_textures() if ( opengl_check_framebuffer() ) { GL_state.BindFrameBuffer(0); - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; - - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; + opengl_delete_render_framebuffer(Scene_framebuffer); - glDeleteTextures(1, &Scene_normal_texture); - Scene_normal_texture = 0; - - glDeleteTextures(1, &Scene_specular_texture); - Scene_specular_texture = 0; - - glDeleteTextures(1, &Scene_emissive_texture); - Scene_emissive_texture = 0; - - glDeleteTextures(1, &Scene_depth_texture); - Scene_depth_texture = 0; - - glDeleteTextures(1, &Scene_luminance_texture); - Scene_luminance_texture = 0; - - //glDeleteTextures(1, &Scene_fxaa_output_texture); - //Scene_fxaa_output_texture = 0; + opengl_delete_render_texture(Scene_color_texture); + opengl_delete_render_texture(Scene_position_texture); + opengl_delete_render_texture(Scene_normal_texture); + opengl_delete_render_texture(Scene_specular_texture); + opengl_delete_render_texture(Scene_emissive_texture); + opengl_delete_render_texture(Scene_depth_texture); + opengl_delete_render_texture(Scene_luminance_texture); Gr_post_processing_enabled = false; Gr_enable_soft_particles = false; @@ -700,77 +704,103 @@ void opengl_scene_texture_shutdown() return; } - if ( Scene_color_texture ) { - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - } - - if ( Scene_position_texture ) { - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; - } - - if ( Scene_normal_texture ) { - glDeleteTextures(1, &Scene_normal_texture); - Scene_normal_texture = 0; - } - - if ( Scene_specular_texture ) { - glDeleteTextures(1, &Scene_specular_texture); - Scene_specular_texture = 0; - } - - if (Scene_emissive_texture) { - glDeleteTextures(1, &Scene_emissive_texture); - Scene_emissive_texture = 0; - } - - if ( Scene_depth_texture ) { - glDeleteTextures(1, &Scene_depth_texture); - Scene_depth_texture = 0; - } - - if ( Scene_framebuffer ) { - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; - } + // Everything opengl_setup_scene_textures() generated, in the same order. Note that + // GammaBlit_texture is 0 when the gamma pass is aliasing Scene_ldr_texture, so the shared + // texture is only released once. + opengl_delete_render_texture(Scene_color_texture); + opengl_delete_render_texture(Scene_ldr_texture); + opengl_delete_render_texture(Scene_position_texture); + opengl_delete_render_texture(Scene_normal_texture); + opengl_delete_render_texture(Scene_specular_texture); + opengl_delete_render_texture(Scene_emissive_texture); + opengl_delete_render_texture(Scene_composite_texture); + opengl_delete_render_texture(Scene_luminance_texture); + opengl_delete_render_texture(Cockpit_depth_texture); + opengl_delete_render_texture(Scene_depth_texture); + opengl_delete_render_framebuffer(Scene_framebuffer); + + opengl_delete_render_texture(Scene_color_texture_ms); + opengl_delete_render_texture(Scene_position_texture_ms); + opengl_delete_render_texture(Scene_normal_texture_ms); + opengl_delete_render_texture(Scene_specular_texture_ms); + opengl_delete_render_texture(Scene_emissive_texture_ms); + opengl_delete_render_texture(Scene_depth_texture_ms); + opengl_delete_render_framebuffer(Scene_framebuffer_ms); + + opengl_delete_render_texture(Back_texture); + opengl_delete_render_texture(Back_depth_texture); + opengl_delete_render_framebuffer(Back_framebuffer); + + opengl_delete_render_texture(GammaBlit_texture); + opengl_delete_render_framebuffer(GammaBlit_framebuffer); + + opengl_delete_render_texture(Distortion_texture[0]); + opengl_delete_render_texture(Distortion_texture[1]); + opengl_delete_render_framebuffer(Distortion_framebuffer); - if (Back_texture) { - glDeleteTextures(1, &Back_texture); - Back_texture = 0; - } - - if (Back_depth_texture) { - glDeleteTextures(1, &Back_depth_texture); - Back_depth_texture = 0; - } + Scene_texture_initialized = 0; + Scene_framebuffer_in_frame = false; +} - if (Back_framebuffer) { - glDeleteFramebuffers(1, &Back_framebuffer); - Back_framebuffer = 0; +void gr_opengl_resize_render_targets() +{ + // Nothing allocated yet (still inside gr_init()), or FBOs are unavailable entirely. + if ( !Scene_texture_initialized ) { + return; } - if (GammaBlit_texture) { - glDeleteTextures(1, &GammaBlit_texture); - GammaBlit_texture = 0; + // Grow only. Shrinking back would mean reallocating every G-buffer again the moment the window + // grew back, and the shrunk state is already handled correctly: Scene_texture_u_scale and + // _v_scale confine rendering to the sub-rectangle actually in use. The hardware limit is + // applied here rather than left to opengl_setup_scene_textures(), so that a viewport larger + // than anything the GPU can allocate compares equal below and stops asking. + const int new_width = MIN(MAX(gr_screen.max_w, Scene_texture_width), GL_max_renderbuffer_size); + const int new_height = MIN(MAX(gr_screen.max_h, Scene_texture_height), GL_max_renderbuffer_size); + + // The overwhelmingly common case: qtFred calls gr_screen_resize() every frame and the game + // calls it on every SDL resize event, almost always at a size the current targets already + // cover -- or, past the hardware limit, at one they never will. + if ( new_width == Scene_texture_width && new_height == Scene_texture_height ) { + return; } - if (GammaBlit_framebuffer) { - glDeleteFramebuffers(1, &GammaBlit_framebuffer); - GammaBlit_framebuffer = 0; + // Tearing down the framebuffer we are currently rendering into would corrupt the frame rather + // than fail cleanly, so refuse rather than trying to recover. Callers resize between frames. + // Scene_framebuffer_in_frame covers the post-processing passes too: they only ever run inside + // gr_scene_texture_begin()/end(), so it is set for the whole of Post_in_frame as well. + if ( Scene_framebuffer_in_frame ) { + Assertion(false, "Tried to resize the render targets to %dx%d while a scene was being " + "rendered into them! The resize has been skipped; the frame will be stretched.", + new_width, new_height); + return; } - glDeleteTextures(2, Distortion_texture); - Distortion_texture[0] = 0; - Distortion_texture[1] = 0; - - if ( Distortion_framebuffer ) { - glDeleteFramebuffers(1, &Distortion_framebuffer); - Distortion_framebuffer = 0; + mprintf(("Growing render targets from %dx%d to %dx%d to cover the new %dx%d viewport.\n", + Scene_texture_width, Scene_texture_height, new_width, new_height, + gr_screen.max_w, gr_screen.max_h)); + + // Leave the framebuffer cache pointing at a name that cannot be deleted out from under it. + GL_state.BindFrameBufferBoth(0, 0); + + // Only the size-dependent resources are touched. The post-processing table, the compiled + // shaders and the SMAA lookup textures are all resolution-independent and stay alive, which is + // what keeps this cheap enough to run off a window drag. The post-processing targets are + // rebuilt after the scene textures because they are sized to match them. + opengl_scene_texture_shutdown(); + opengl_setup_scene_textures(new_width, new_height); + + // Reallocating larger is exactly when running out of video memory is most likely, and + // opengl_setup_scene_textures() reports that by leaving the scene uninitialized (having + // already turned post-processing and soft particles off). Rebuilding the post-processing + // targets on top of scene textures that don't exist would only make it worse, so stop here; + // the renderer keeps drawing without the offscreen pipeline. + if ( !Scene_texture_initialized ) { + mprintf(("Failed to allocate %dx%d render targets! The offscreen rendering pipeline has " + "been disabled for the rest of this session.\n", new_width, new_height)); + return; } - Scene_texture_initialized = 0; - Scene_framebuffer_in_frame = false; + opengl_post_resize_render_targets(); } void gr_opengl_scene_texture_begin() @@ -789,26 +819,17 @@ void gr_opengl_scene_texture_begin() GL_state.PushFramebufferState(); GL_state.BindFrameBuffer(Scene_framebuffer); - // In the game Scene_texture_width/height always equals gr_screen.max_w/h (the - // scene texture is sized once from the same resolution at gr_init() and the - // game never resizes around it), so this ratio is always exactly 1.0 there. - // qtFred is the exception: its viewport is resized every frame - // (gr_screen_resize() in FredRenderer::render_frame()) to match a dockable - // widget that is smaller than the scene texture's fixed allocation (which - // Gr_min_render_target_w/h floors at the largest size that widget can reach), - // so the ratio keeps the render (and this end-of-frame blit) confined to the - // sub-rectangle of the texture that was actually drawn into, instead of - // stretching the whole texture -- most of it never touched this frame -- - // over the viewport-sized quad. + // The fraction of the scene textures this frame actually renders into. Normally 1.0 -- the + // targets are grown to cover gr_screen (gr_opengl_resize_render_targets()) -- but they are + // never shrunk back, so a viewport that got smaller leaves the rest of the allocation stale. + // Every pass that samples these textures has to stay inside this sub-rectangle; use + // opengl_draw_full_screen_scene_texture() rather than open-coding the extents. Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width); Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height); - // A ratio above 1.0 means the viewport outgrew the allocation after the fact -- the scene - // texture can't be resized, so all we can do is render the part that fits and let the blit - // stretch it back out, which misaligns everything by a different amount on each axis. The - // floor above is meant to make this unreachable; if it does happen (a display hotplug or - // resolution change after gr_init(), or an allocation clamped by GL_max_renderbuffer_size on - // low-end hardware) say so once rather than every frame. + // Above 1.0 means the viewport outgrew the allocation and the resize could not keep up -- only + // reachable when GL_max_renderbuffer_size capped the targets. Render what fits and let the + // blit stretch it; say so once rather than every frame. if (Scene_texture_u_scale > 1.0f || Scene_texture_v_scale > 1.0f) { static bool reported_undersized_scene_texture = false; @@ -1273,6 +1294,11 @@ void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloa opengl_render_primitives_immediate(PRIM_TYPE_TRIS, &vert_def, 3, glVertices, sizeof(glVertices)); } +void opengl_draw_full_screen_scene_texture() +{ + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); +} + void gr_opengl_render_decals(decal_material* material_info, primitive_type prim_type, vertex_layout* layout, diff --git a/code/graphics/opengl/gropengldraw.h b/code/graphics/opengl/gropengldraw.h index 3cfd3183c80..5bfa170b341 100644 --- a/code/graphics/opengl/gropengldraw.h +++ b/code/graphics/opengl/gropengldraw.h @@ -55,8 +55,14 @@ void gr_opengl_render_shield_impact(shield_material* material_info, gr_buffer_handle buffer_handle, int n_verts); -void opengl_setup_scene_textures(); +void opengl_setup_scene_textures(int width, int height); void opengl_scene_texture_shutdown(); +void gr_opengl_resize_render_targets(); + +// Release a render target, keeping the GL state cache in sync. See the definitions for why the +// cache matters now that these are rebuilt mid-session. +void opengl_delete_render_texture(GLuint& tex); +void opengl_delete_render_framebuffer(GLuint& fbo); void gr_opengl_scene_texture_begin(); void gr_opengl_scene_texture_end(); void gr_opengl_copy_effect_texture(); @@ -147,6 +153,12 @@ void opengl_draw_textured_quad(GLfloat x1, */ void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloat v2); +// Fullscreen pass over a source that is one of the scene/post-processing textures. Those are only +// filled out to Scene_texture_u_scale/v_scale of their allocation, so sampling them over the full +// [0,1] range would pull in whatever is beyond the rendered region. Prefer this over passing +// literal 1.0f extents whenever the bound texture came from that pipeline. +void opengl_draw_full_screen_scene_texture(); + inline GLenum opengl_primitive_type(primitive_type prim_type); void gr_opengl_start_decal_pass(); diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index 0f5056bc1d6..76dc1324709 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -28,6 +28,9 @@ #include "es_compatibility.h" #endif +static void opengl_post_setup_render_targets(); +static void opengl_post_shutdown_render_targets(); + extern bool PostProcessing_override; extern int opengl_check_framebuffer(); // Needed to track where the FXAA shaders are @@ -99,7 +102,7 @@ void opengl_post_pass_tonemap() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); } void opengl_post_pass_bloom() @@ -134,16 +137,10 @@ void opengl_post_pass_bloom() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - // Unlike every other pass below, this reads directly from the scene - // texture rather than from an already-cropped intermediate (Bloom_textures - // is filled by this very call), so it has to confine itself to the - // sub-rectangle that was actually rendered into -- see the scale - // variables' own comment in gr_opengl_scene_texture_begin(). Hardcoding - // 1.0/1.0 here (as this used to) is only correct when the scene texture - // exactly matches the screen, which is not true of qtFred's dynamically - // resized viewport: it would smear/misplace the bloom halo relative to - // the scene it's supposed to be blooming. - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + // Reads the scene texture directly rather than an already-cropped intermediate, so it is + // the scaled variant. The blur/composite passes below read Bloom_textures, which this pass + // fills edge to edge, so those stay unscaled. + opengl_draw_full_screen_scene_texture(); } // ------ end bright pass ------ @@ -302,7 +299,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); // set and configure post shader .. opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_POST_PROCESS_FXAA, 0)); @@ -319,7 +316,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_luminance_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); opengl_shader_set_current(); } @@ -342,7 +339,7 @@ static void smaa_detect_edges() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_calculate_blending_weights() @@ -367,7 +364,7 @@ static void smaa_calculate_blending_weights() GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_area_tex); GL_state.Texture.Enable(2, GL_TEXTURE_2D, Smaa_search_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_neighborhood_blending() @@ -390,7 +387,7 @@ static void smaa_neighborhood_blending() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_blend_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); } void smaa_resolve() @@ -500,7 +497,7 @@ void opengl_post_lightshafts() GL_state.Blend(GL_TRUE); GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); GL_state.Blend(GL_FALSE); break; @@ -634,7 +631,7 @@ void gr_opengl_post_process_end() // now render it to the screen ... GL_state.PopFramebufferState(); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + opengl_draw_full_screen_scene_texture(); //Shadow Map debug window //#define SHADOW_DEBUG @@ -1029,79 +1026,93 @@ static GLuint load_smaa_texture(GLsizei width, GLsizei height, GLenum format, co return tex; } -static void setup_smaa_resources() +// The SMAA area and search textures are fixed-size lookup tables baked into the binary, so unlike +// everything else here they survive a resolution change untouched. +static void setup_smaa_lookup_textures() { - GL_state.PushFramebufferState(); - Smaa_area_tex = load_smaa_texture(AREATEX_WIDTH, AREATEX_HEIGHT, GL_RG8, areaTexBytes, "SMAA Area Texture"); Smaa_search_tex = load_smaa_texture(SEARCHTEX_WIDTH, SEARCHTEX_HEIGHT, GL_R8, searchTexBytes, "SMAA Search Texture"); +} +static void setup_smaa_render_targets() +{ setup_smaa_edges_resources(); setup_smaa_blending_weight_resources(); setup_smaa_neighborhood_blending_resources(); - - GL_state.PopFramebufferState(); } -// generate and test the framebuffer and textures that we are going to use -static bool opengl_post_init_framebuffer() +static void shutdown_smaa_render_targets() { - bool rval = false; + opengl_delete_render_texture(Smaa_edges_tex); + opengl_delete_render_framebuffer(Smaa_edge_detection_fb); - // Sized once and never resized, like the scene textures they consume, so they get the same - // floor -- see Gr_min_render_target_w/h (2d.h). - Post_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); - Post_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + opengl_delete_render_texture(Smaa_blend_tex); + opengl_delete_render_framebuffer(Smaa_blending_weight_fb); - // clamp size, if needed - if (Post_texture_width > GL_max_renderbuffer_size) { - Post_texture_width = GL_max_renderbuffer_size; - } + opengl_delete_render_texture(Smaa_output_tex); + opengl_delete_render_framebuffer(Smaa_neighborhood_blending_fb); +} - if (Post_texture_height > GL_max_renderbuffer_size) { - Post_texture_height = GL_max_renderbuffer_size; - } +// Allocate every post-processing resource whose size follows the scene textures. Split out from +// opengl_post_process_init() so gr_opengl_resize_render_targets() can rebuild just these without +// re-parsing post_processing.tbl or recompiling shaders. +static void opengl_post_setup_render_targets() +{ + // These consume the scene textures pass by pass, so they have to match them exactly rather + // than being sized from gr_screen independently -- see gr_opengl_scene_texture_begin() for + // what the two sizes diverging would mean. + Post_texture_width = Scene_texture_width; + Post_texture_height = Scene_texture_height; + + GL_state.PushFramebufferState(); opengl_setup_bloom_textures(); // Always set up SMAA resources so the user can switch to an SMAA preset // at runtime even when starting with a non-SMAA AA mode, such as None. - //if (Gr_aa_mode != AntiAliasMode::None) { - setup_smaa_resources(); - //} + setup_smaa_render_targets(); - GL_state.BindFrameBuffer(0); + GL_state.PopFramebufferState(); - rval = true; + GL_state.BindFrameBuffer(0); +} - if ( opengl_check_for_errors("post_init_framebuffer()") ) { - rval = false; - } +void opengl_post_process_shutdown_bloom() +{ + opengl_delete_render_texture(Bloom_textures[0]); + opengl_delete_render_texture(Bloom_textures[1]); + opengl_delete_render_framebuffer(Bloom_framebuffer); +} - return rval; +static void opengl_post_shutdown_render_targets() +{ + opengl_post_process_shutdown_bloom(); + shutdown_smaa_render_targets(); } +void opengl_post_resize_render_targets() +{ + // Post-processing may have been disabled outright (no FBOs, missing shaders, or turned off in + // the table), in which case none of these resources exist and none should start existing now. + if ( !Post_initialized ) { + return; + } + opengl_post_shutdown_render_targets(); + opengl_post_setup_render_targets(); +} -void opengl_post_process_shutdown_bloom() +// generate and test the framebuffer and textures that we are going to use +static bool opengl_post_init_framebuffer() { - if ( Bloom_textures[0] ) { - glDeleteTextures(1, &Bloom_textures[0]); - Bloom_textures[0] = 0; - } + setup_smaa_lookup_textures(); - if ( Bloom_textures[1] ) { - glDeleteTextures(1, &Bloom_textures[1]); - Bloom_textures[1] = 0; - } + opengl_post_setup_render_targets(); - if ( Bloom_framebuffer > 0 ) { - glDeleteFramebuffers(1, &Bloom_framebuffer); - Bloom_framebuffer = 0; - } + return !opengl_check_for_errors("post_init_framebuffer()"); } void opengl_post_process_init() @@ -1152,20 +1163,16 @@ void opengl_post_process_shutdown() return; } - if (Post_framebuffer_id[0]) { - glDeleteFramebuffers(1, &Post_framebuffer_id[0]); - Post_framebuffer_id[0] = 0; - - if (Post_framebuffer_id[1]) { - glDeleteFramebuffers(1, &Post_framebuffer_id[1]); - Post_framebuffer_id[1] = 0; - } - } + opengl_delete_render_framebuffer(Post_framebuffer_id[0]); + opengl_delete_render_framebuffer(Post_framebuffer_id[1]); graphics::Post_processing_manager->clear(); graphics::Post_processing_manager = nullptr; - opengl_post_process_shutdown_bloom(); + opengl_post_shutdown_render_targets(); + + opengl_delete_render_texture(Smaa_area_tex); + opengl_delete_render_texture(Smaa_search_tex); Post_in_frame = false; Post_active_shader_index = 0; diff --git a/code/graphics/opengl/gropenglpostprocessing.h b/code/graphics/opengl/gropenglpostprocessing.h index d820222bb89..cdba2579d88 100644 --- a/code/graphics/opengl/gropenglpostprocessing.h +++ b/code/graphics/opengl/gropenglpostprocessing.h @@ -8,6 +8,10 @@ void opengl_post_process_init(); void opengl_post_process_shutdown(); +// Rebuild the resolution-dependent subset of the above for the current scene texture size, without +// re-parsing post_processing.tbl or recompiling shaders. No-op if post-processing isn't active. +void opengl_post_resize_render_targets(); + void gr_opengl_post_process_set_effect(const char *name, int x, const vec3d *rgb); void gr_opengl_post_process_set_defaults(); void gr_opengl_post_process_save_zbuffer(); diff --git a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp index 1e1fb3fc4fc..90517eb30d9 100644 --- a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp @@ -494,8 +494,12 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) { auto* header = reinterpret_cast(uboMapped); memset(header, 0, sizeof(graphics::deferred_global_data)); - header->invScreenWidth = 1.0f / gr_screen.max_w; - header->invScreenHeight = 1.0f / gr_screen.max_h; + // Same as the OpenGL backend: deferred-f.sdr normalizes gl_FragCoord against these to + // sample the G-buffer, so they must describe the G-buffer, not gr_screen. resize() keeps + // sceneExtent equal to gr_screen today, which is why deriving it from the extent is a + // no-op here -- but it states the actual requirement instead of relying on that. + header->invScreenWidth = 1.0f / static_cast(m_ctx->sceneExtent.width); + header->invScreenHeight = 1.0f / static_cast(m_ctx->sceneExtent.height); header->nearPlane = gr_near_plane; if (m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled) { diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index b10ee7a4d2a..75a78e52619 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -42,8 +42,6 @@ #include #include -#include -#include #include #include @@ -131,20 +129,6 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; - // Unlike the game, whose window size is fixed after gr_init(), qtFred resizes its 3D viewport at - // runtime: FredRenderer::render_frame() calls gr_screen_resize() every frame to match a dockable, - // resizable widget. The offscreen render targets that back post-processing are allocated once, - // from gr_screen as it is right now, and cannot grow afterwards -- so give them a floor big - // enough for any size that widget can reach, which is the largest screen the window could be - // maximized or fullscreened onto, in device pixels. Without this, growing the viewport past the - // startup size clips the render to the old, smaller texture and then stretches it back over the - // new, larger viewport, visibly misaligning sun sprites and bloom. - for (const QScreen* screen : QGuiApplication::screens()) { - const auto ratio = screen->devicePixelRatio(); - - Gr_min_render_target_w = MAX(Gr_min_render_target_w, qRound(screen->size().width() * ratio)); - Gr_min_render_target_h = MAX(Gr_min_render_target_h, qRound(screen->size().height() * ratio)); - } // MSAA sample count, shadow quality, and the texture filter/anisotropy defaults are baked // into GPU resources during gr_init() and can't be changed afterwards without recreating From 316a5c7c6917a7323882bb81dadc6eaa583322ee Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:20:42 +0200 Subject: [PATCH 5/7] Give qtFRED's graphics preferences a single owner The Preferences > Graphics settings were read from QSettings in two places -- management.cpp before gr_init(), and EditorViewport once the editor exists -- each with its own copy of the key strings and default values, so renaming a key in one would silently stop the other from working. The split between settings that apply live and settings that need a restart was recorded only in comments that had to stay in sync across five files. Move both into GraphicsSettings, which owns the keys, the defaults, and the two apply paths (applyLive() and applyBeforeGrInit()). ViewSettings holds one of these instead of seven loose fields. Behaviour fixes that fall out of having one reader: - Values are range-checked before being cast to ShadowQuality/AntiAliasMode and validated against the MSAA list, matching what the neighbouring DataMenuStyle load already did. A hand-edited settings file no longer produces an out-of-range enum. - The two OptionsManager overrides are now consistent. Texture filtering was overridden unconditionally with a hardcoded default while anisotropy was guarded, so a fresh install masked the engine's own texture-filter default for no reason. Both now use a sentinel for "the user has not chosen" and leave the engine option alone until there is a real choice. - gr_set_gamma(3.0f) is no longer duplicated as a literal next to the struct default. The dialog's value lists now come from the engine option definitions (Graphics.Shadows, Graphics.AAMode, Graphics.TextureFilter) instead of the hardcoded .ui items that duplicated them, and anisotropy uses the shared gr_get_supported_anisotropy_levels() rather than a near-verbatim copy of the engine's enumerator. Adding an AA mode upstream no longer silently desyncs qtFRED's dropdown. Note that the shadow-quality entries therefore lose the "(restart required)" suffix they carried in the .ui; the control's tooltip and the help page still say it. Populating combos now blocks signals: setupUi() has already run connectSlotsByName(), so filling them would otherwise fire the change slots and mark the model modified before the user touched anything. Finally, render_frame() gets its three correlated EnablePostProcessing branches replaced by a scoped ScenePostProcessing guard, and the shadow pass -- with the HTL matrix-stack dance it needs -- moves into its own named function. Co-Authored-By: Claude Opus 5 --- qtfred/AGENTS.md | 10 ++ qtfred/source_groups.cmake | 2 + qtfred/src/mission/EditorViewport.cpp | 39 +---- qtfred/src/mission/EditorViewport.h | 29 +--- qtfred/src/mission/FredRenderer.cpp | 72 ++++++---- qtfred/src/mission/GraphicsSettings.cpp | 132 +++++++++++++++++ qtfred/src/mission/GraphicsSettings.h | 81 +++++++++++ .../dialogs/PreferencesDialogModel.cpp | 59 +------- .../mission/dialogs/PreferencesDialogModel.h | 38 +---- qtfred/src/mission/management.cpp | 42 ++---- qtfred/src/ui/FredView.cpp | 2 +- qtfred/src/ui/dialogs/PreferencesDialog.cpp | 135 +++++++++++++----- qtfred/src/ui/dialogs/PreferencesDialog.h | 2 + qtfred/ui/PreferencesDialog.ui | 90 ------------ 14 files changed, 399 insertions(+), 334 deletions(-) create mode 100644 qtfred/AGENTS.md create mode 100644 qtfred/src/mission/GraphicsSettings.cpp create mode 100644 qtfred/src/mission/GraphicsSettings.h diff --git a/qtfred/AGENTS.md b/qtfred/AGENTS.md new file mode 100644 index 00000000000..c54ef11c87a --- /dev/null +++ b/qtfred/AGENTS.md @@ -0,0 +1,10 @@ +# Module: qtfred + +Entry-point guide for this module lives at: +**`documentation/modules/qtfred.md`** (from repo root). + +It covers the editor's purpose, build requirements, layout, and how it relates to +the shared engine code in `code/`. + +For the engine-wide architecture overview see `documentation/ARCHITECTURE.md`. +For build/test/style conventions see the root `AGENTS.md`. diff --git a/qtfred/source_groups.cmake b/qtfred/source_groups.cmake index 6718b68a21a..2bd6c19312c 100644 --- a/qtfred/source_groups.cmake +++ b/qtfred/source_groups.cmake @@ -28,6 +28,8 @@ add_file_folder("Source/Mission" src/mission/EditorViewport.h src/mission/FredRenderer.cpp src/mission/FredRenderer.h + src/mission/GraphicsSettings.cpp + src/mission/GraphicsSettings.h src/mission/IDialogProvider.h src/mission/management.cpp src/mission/management.h diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index 28b58a13f26..bc289f83833 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -16,7 +16,6 @@ #include #include #include -#include namespace { @@ -123,7 +122,7 @@ EditorViewport::EditorViewport(Editor* in_editor, std::unique_ptr& syncMissionLayerNames(); loadSettings(); - applyGraphicsSettings(); + view.Graphics.applyLive(); fredApp->runAfterInit([this]() { initialSetup(); }); } @@ -179,24 +178,11 @@ void EditorViewport::loadSettings() { view.Show_compass = settings.value("view_show_compass", view.Show_compass).toBool(); view.Highlight_selectable_subsys = settings.value("view_highlight_selectable_subsys", view.Highlight_selectable_subsys).toBool(); view.Outline_lod = settings.value("view_outline_lod", view.Outline_lod).toInt(); - view.EnablePostProcessing = settings.value("view_enable_post_processing", view.EnablePostProcessing).toBool(); - view.Graphics_shadow_quality = settings.value("view_graphics_shadow_quality", view.Graphics_shadow_quality).toInt(); - view.Graphics_aa_mode = settings.value("view_graphics_aa_mode", view.Graphics_aa_mode).toInt(); - view.Graphics_msaa_samples = settings.value("view_graphics_msaa_samples", view.Graphics_msaa_samples).toInt(); - view.Graphics_texture_filter = settings.value("view_graphics_texture_filter", view.Graphics_texture_filter).toInt(); - { - // Default to the hardware's max anisotropy the first time qtFRED runs, so the - // viewport looks the same as it always has until the user turns this control down. - float maxAnisotropy = 1.0f; - if (gr_get_property(gr_property::MAX_ANISOTROPY, &maxAnisotropy)) { - view.Graphics_anisotropy = maxAnisotropy; - } - view.Graphics_anisotropy = settings.value("view_graphics_anisotropy", view.Graphics_anisotropy).toFloat(); - } - view.Graphics_gamma = settings.value("view_graphics_gamma", view.Graphics_gamma).toFloat(); camera.setInvertOrbitX(settings.value("camera_invert_orbit_x", camera.getInvertOrbitX()).toBool()); camera.setInvertOrbitY(settings.value("camera_invert_orbit_y", camera.getInvertOrbitY()).toBool()); settings.endGroup(); + + view.Graphics = GraphicsSettings::load(); } void EditorViewport::saveSettings() const { @@ -242,28 +228,11 @@ void EditorViewport::saveSettings() const { settings.setValue("view_show_compass", view.Show_compass); settings.setValue("view_highlight_selectable_subsys", view.Highlight_selectable_subsys); settings.setValue("view_outline_lod", view.Outline_lod); - settings.setValue("view_enable_post_processing", view.EnablePostProcessing); - settings.setValue("view_graphics_shadow_quality", view.Graphics_shadow_quality); - settings.setValue("view_graphics_aa_mode", view.Graphics_aa_mode); - settings.setValue("view_graphics_msaa_samples", view.Graphics_msaa_samples); - settings.setValue("view_graphics_texture_filter", view.Graphics_texture_filter); - settings.setValue("view_graphics_anisotropy", view.Graphics_anisotropy); - settings.setValue("view_graphics_gamma", view.Graphics_gamma); settings.setValue("camera_invert_orbit_x", camera.getInvertOrbitX()); settings.setValue("camera_invert_orbit_y", camera.getInvertOrbitY()); settings.endGroup(); -} -void EditorViewport::applyGraphicsSettings() const { - // Shadow_quality is deliberately not set here. Turning shadows on requires allocating the - // shadow framebuffer and sizing the cascade-parameter buffers (shadow_cascade_params_init(), - // gropengltnl.cpp/gr_vulkan.cpp), which only happens once at gr_init() and only if - // Shadow_quality is already non-Disabled at that point -- flipping the global afterwards - // leaves those buffers empty/unsized and segfaults the next time a frame tries to render - // shadows. Applied before gr_init() instead (see management.cpp), same as MSAA samples, - // texture filter, and anisotropy; the Preferences UI notes it needs a restart. - Gr_aa_mode = static_cast(view.Graphics_aa_mode); - gr_set_gamma(view.Graphics_gamma); + view.Graphics.save(); } void EditorViewport::needsUpdate() { diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index c50ef0f9ccd..4004f97f6b7 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -4,6 +4,7 @@ #include "CameraController.h" #include "FredRenderer.h" #include "Editor.h" +#include "GraphicsSettings.h" #include "IDialogProvider.h" #include "ui/ThemeMode.h" @@ -56,12 +57,6 @@ struct ViewSettings { bool Show_paths_fred = false; bool Lighting_on = false; bool FullDetail = false; - // Runs the viewport through the same HDR scene-texture + post-processing - // pipeline the game uses (bloom, tonemapping, lightshafts), instead of - // rendering straight to the default framebuffer. - // Off by default so existing missions keep looking exactly as they do today - // unless a FRED user opts in. - bool EnablePostProcessing = false; bool Show_waypoints = true; bool Show_props = true; bool Show_jump_nodes = true; @@ -69,18 +64,8 @@ struct ViewSettings { bool Highlight_selectable_subsys = false; int Outline_lod = 1; - // Graphics (Preferences > Graphics tab). All quality/effect settings below are only - // visible in the viewport while EnablePostProcessing is on, since they're consumed by - // the same HDR scene-texture + post-processing pipeline that flag gates. - int Graphics_shadow_quality = 0; // ShadowQuality: 0=Disabled, 1=Low, 2=Medium, 3=High, 4=Ultra - int Graphics_aa_mode = 0; // AntiAliasMode: 0=None .. 7=SMAA Ultra - // The following three are baked into GPU resources at gr_init() time and can only be - // changed by restarting qtFRED; qtFRED applies the saved value before gr_init() runs - // (see management.cpp), and the Preferences UI notes this in each control's tooltip. - int Graphics_msaa_samples = 0; // 0, 4, or 8 - int Graphics_texture_filter = 1; // 0=Bilinear, 1=Trilinear - float Graphics_anisotropy = 1.0f; // populated from the hardware max the first time qtFRED runs - float Graphics_gamma = 3.0f; // matches the brightness qtFRED has always launched with + //! Preferences > Graphics. Owns its own persistence and apply rules; see GraphicsSettings. + GraphicsSettings Graphics; ViewSettings(); }; @@ -262,14 +247,6 @@ class EditorViewport { void saveSettings() const; - // Pushes the live-appliable subset of view.Graphics_* (shadow quality, AA mode, gamma) - // into the engine globals that actually drive rendering. Called once at startup, after - // loadSettings(), and again from PreferencesDialogModel::apply() whenever the user - // changes one of those controls. Graphics_msaa_samples/Graphics_texture_filter/ - // Graphics_anisotropy are deliberately not included here -- those can only be applied - // before gr_init() (see management.cpp) and need a restart to change. - void applyGraphicsSettings() const; - Editor* editor = nullptr; FredRenderer* renderer = nullptr; IDialogProvider* dialogProvider = nullptr; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 760de615b6a..9ee0517f3a2 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -29,6 +29,8 @@ #include #include +#include + #include "mission/object.h" #include "prop/prop.h" #include "weapon/weapon.h" @@ -57,6 +59,38 @@ void disable_htl() { gr_end_view_matrix(); } +//! Scoped gr_scene_texture_begin()/end(), so the two can't drift apart as render_frame() grows. +struct ScenePostProcessing { + ScenePostProcessing() { gr_scene_texture_begin(); } + ~ScenePostProcessing() { gr_scene_texture_end(); } + + ScenePostProcessing(const ScenePostProcessing&) = delete; + ScenePostProcessing& operator=(const ScenePostProcessing&) = delete; +}; + +/** + * @brief Render the shadow pass for the current camera. + * + * Only meaningful inside a ScenePostProcessing scope: the shadow pass writes into deferred + * G-buffer surfaces that only exist while the scene texture is bound. Mirrors game_render_frame(), + * which calls this right after its own gr_scene_texture_begin() + stars_draw(). + * + * shadows_render_all() works on the HTL proj/view matrix stack -- the same one enable_htl() and + * disable_htl() push and pop for the starfield -- and expects it to be open: it ends whatever is + * active (gr_end_view_matrix() asserts on modelview_matrix_depth) and restores it when done. The + * starfield's disable_htl() just popped that stack, hence the push here, and the matching pop + * afterwards so the next frame's enable_htl() doesn't assert on a stack left open. + */ +void render_shadows() { + gr_set_proj_matrix(Proj_fov, gr_screen.clip_aspect, Min_draw_distance, Max_draw_distance); + gr_set_view_matrix(&Eye_position, &Eye_matrix); + + shadows_render_all(Proj_fov, &Eye_matrix, &Eye_position, nullptr, nullptr, nullptr); + + gr_end_proj_matrix(); + gr_end_view_matrix(); +} + bool fred_colors_inited = false; color colour_white; color colour_green; @@ -995,13 +1029,13 @@ void FredRenderer::render_frame(int cur_object_index, g3_set_view_matrix(&_viewport->camera.eye_pos, &_viewport->camera.eye_orient, 0.5f); - // Optionally run the 3D scene through the game's HDR post-processing pipeline - // (bloom, tonemapping, lightshafts) instead of drawing straight to the - // default framebuffer. Brackets only the 3D world content, - // the same way freespace.cpp's game_render_frame() brackets its own scene -- - // the 2D overlays below (distances, ship info, tooltips) stay outside it. - if (view().EnablePostProcessing) { - gr_scene_texture_begin(); + // Optionally run the 3D world through the game's HDR post-processing pipeline (bloom, + // tonemapping, lightshafts, shadows) instead of drawing straight to the default framebuffer. + // Brackets only the 3D content, the same way game_render_frame() does; the 2D overlays further + // down (distances, ship info, tooltips) stay outside it. + std::optional postProcessing; + if (view().Graphics.enablePostProcessing) { + postProcessing.emplace(); } // Force max star detail so the editor always shows the full Num_stars count @@ -1013,24 +1047,8 @@ void FredRenderer::render_frame(int cur_object_index, disable_htl(); Detail.num_stars = saved_detail_stars; - // Shadows piggyback on the same HDR scene-texture pipeline as post-processing above -- the - // shadow pass writes into deferred G-buffer surfaces that only exist while that's bound, so - // it can only run under EnablePostProcessing. Mirrors freespace.cpp's game_render_frame(), - // which calls this right after its own gr_scene_texture_begin()+stars_draw(). Eye_position/ - // Eye_matrix/Proj_fov were already set to FRED's camera by the g3_set_view_matrix() call - // above, but shadows_render_all() operates on the separate HTL proj/view matrix stack (the - // same one enable_htl()/disable_htl() push and pop for the starfield): it starts by ending - // whatever's currently active (Assert(modelview_matrix_depth == 2) in gr_end_view_matrix()), - // then restores it via gr_set_proj_matrix()/gr_set_view_matrix() when done. disable_htl() - // just popped that stack, so it has to be pushed again here first -- and popped again - // afterward, since FRED's grid/model rendering below never touches the HTL stack and the - // *next* frame's enable_htl() would itself assert if it were left open. - if (view().EnablePostProcessing) { - gr_set_proj_matrix(Proj_fov, gr_screen.clip_aspect, Min_draw_distance, Max_draw_distance); - gr_set_view_matrix(&Eye_position, &Eye_matrix); - shadows_render_all(Proj_fov, &Eye_matrix, &Eye_position, nullptr, nullptr, nullptr); - gr_end_proj_matrix(); - gr_end_view_matrix(); + if (postProcessing) { + render_shadows(); } if (view().Show_horizon) { @@ -1049,9 +1067,7 @@ void FredRenderer::render_frame(int cur_object_index, render_models(cur_object_index); render_volumetric_overlay(); - if (view().EnablePostProcessing) { - gr_scene_texture_end(); - } + postProcessing.reset(); if (view().Show_distances) { display_distances(); diff --git a/qtfred/src/mission/GraphicsSettings.cpp b/qtfred/src/mission/GraphicsSettings.cpp new file mode 100644 index 00000000000..560c8f3b6dc --- /dev/null +++ b/qtfred/src/mission/GraphicsSettings.cpp @@ -0,0 +1,132 @@ +#include "mission/GraphicsSettings.h" + +#include +#include + +#include + +namespace fso::fred { + +namespace { + +const char* SETTINGS_GROUP = "Preferences"; + +const char* KEY_POST_PROCESSING = "view_enable_post_processing"; +const char* KEY_SHADOW_QUALITY = "view_graphics_shadow_quality"; +const char* KEY_AA_MODE = "view_graphics_aa_mode"; +const char* KEY_MSAA_SAMPLES = "view_graphics_msaa_samples"; +const char* KEY_TEXTURE_FILTER = "view_graphics_texture_filter"; +const char* KEY_ANISOTROPY = "view_graphics_anisotropy"; +const char* KEY_GAMMA = "view_graphics_gamma"; + +// A settings file can be hand-edited or left over from a build with a different set of values, so +// anything that ends up in an enum or a fixed value list gets checked rather than cast blindly. +template +T readEnum(const QSettings& settings, const char* key, T fallback, T highest) +{ + const int raw = settings.value(key, static_cast(fallback)).toInt(); + + if (raw < 0 || raw > static_cast(highest)) { + return fallback; + } + + return static_cast(raw); +} + +} // namespace + +bool GraphicsSettings::operator==(const GraphicsSettings& rhs) const +{ + return enablePostProcessing == rhs.enablePostProcessing && shadowQuality == rhs.shadowQuality && + aaMode == rhs.aaMode && msaaSamples == rhs.msaaSamples && gamma == rhs.gamma && + textureFilter == rhs.textureFilter && anisotropy == rhs.anisotropy; +} + +SCP_vector GraphicsSettings::validMsaaSampleCounts() +{ + return {0, 4, 8}; +} + +GraphicsSettings GraphicsSettings::load() +{ + GraphicsSettings out; + + QSettings settings; + settings.beginGroup(SETTINGS_GROUP); + + out.enablePostProcessing = settings.value(KEY_POST_PROCESSING, out.enablePostProcessing).toBool(); + out.shadowQuality = readEnum(settings, KEY_SHADOW_QUALITY, out.shadowQuality, ShadowQuality::Ultra); + out.aaMode = readEnum(settings, KEY_AA_MODE, out.aaMode, AntiAliasMode::SMAA_Ultra); + out.gamma = settings.value(KEY_GAMMA, out.gamma).toFloat(); + + if (settings.contains(KEY_TEXTURE_FILTER)) { + out.textureFilter = settings.value(KEY_TEXTURE_FILTER).toInt() != 0 ? 1 : 0; + } + + if (settings.contains(KEY_ANISOTROPY)) { + out.anisotropy = settings.value(KEY_ANISOTROPY).toFloat(); + } + + const int samples = settings.value(KEY_MSAA_SAMPLES, out.msaaSamples).toInt(); + const auto validSamples = validMsaaSampleCounts(); + if (std::find(validSamples.begin(), validSamples.end(), samples) != validSamples.end()) { + out.msaaSamples = samples; + } + + settings.endGroup(); + + return out; +} + +void GraphicsSettings::save() const +{ + QSettings settings; + settings.beginGroup(SETTINGS_GROUP); + + settings.setValue(KEY_POST_PROCESSING, enablePostProcessing); + settings.setValue(KEY_SHADOW_QUALITY, static_cast(shadowQuality)); + settings.setValue(KEY_AA_MODE, static_cast(aaMode)); + settings.setValue(KEY_MSAA_SAMPLES, msaaSamples); + settings.setValue(KEY_GAMMA, gamma); + + // Don't write the sentinels back out -- an absent key is what keeps the engine default in play. + if (textureFilter != NO_TEXTURE_FILTER_CHOICE) { + settings.setValue(KEY_TEXTURE_FILTER, textureFilter); + } + + if (anisotropy != NO_ANISOTROPY_CHOICE) { + settings.setValue(KEY_ANISOTROPY, anisotropy); + } + + settings.endGroup(); +} + +void GraphicsSettings::applyLive() const +{ + Gr_aa_mode = aaMode; + gr_set_gamma(gamma); +} + +GraphicsSettings GraphicsSettings::applyBeforeGrInit() +{ + const GraphicsSettings settings = load(); + + Cmdline_msaa_enabled = settings.msaaSamples; + Shadow_quality = settings.shadowQuality; + + auto* options = options::OptionsManager::instance(); + + // Overrides are in-memory only, so they steer this session's gr_init() without touching the + // config file the game reads its own graphics settings from. + if (settings.textureFilter != NO_TEXTURE_FILTER_CHOICE) { + options->setOverride("Graphics.TextureFilter", std::to_string(settings.textureFilter)); + } + + if (settings.anisotropy != NO_ANISOTROPY_CHOICE) { + options->setOverride("Graphics.Anisotropy", std::to_string(settings.anisotropy)); + } + + return settings; +} + +} // namespace fso::fred diff --git a/qtfred/src/mission/GraphicsSettings.h b/qtfred/src/mission/GraphicsSettings.h new file mode 100644 index 00000000000..8e85a368360 --- /dev/null +++ b/qtfred/src/mission/GraphicsSettings.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include + +namespace fso::fred { + +/** + * @brief qtFRED's Preferences > Graphics settings. + * + * Single owner of these values: the QSettings keys, their defaults, and the rules for when each + * one can be applied all live here. Two very different callers need them -- management.cpp before + * gr_init(), and EditorViewport once the editor is up -- and previously each read QSettings with + * its own copy of the key strings and default values. + * + * The settings split by *when* they can take effect, which is what the two apply functions below + * encode: + * + * - applyLive() settings can be changed at any time and are re-applied whenever the user hits + * Apply in Preferences. + * - applyBeforeGrInit() settings are baked into GPU resources during gr_init() and cannot be + * changed afterwards, so they are read straight out of QSettings before the editor exists. + * Shadow quality is the sharpest example: shadow_cascade_params_init() only runs during + * gr_init(), and only if Shadow_quality is already non-Disabled, so setting it later leaves the + * cascade buffers unsized and crashes the next frame that renders shadows. + */ +struct GraphicsSettings { + // Texture filtering and anisotropy reach the engine through the options system, whose defaults + // are better informed than anything qtFRED could hardcode -- the user's existing config for + // one, the hardware maximum for the other. Both therefore carry a sentinel meaning "the user + // has not chosen one", and applyBeforeGrInit() overrides only once there is a real choice. + static constexpr int NO_TEXTURE_FILTER_CHOICE = -1; + static constexpr float NO_ANISOTROPY_CHOICE = 0.0f; + + /** + * @brief Run the viewport through the game's HDR scene-texture + post-processing pipeline. + * + * Off by default so missions keep looking exactly as they do today unless a user opts in. + * Everything else on this struct is only visible in the viewport while this is on. + */ + bool enablePostProcessing = false; + + ShadowQuality shadowQuality = ShadowQuality::Disabled; + AntiAliasMode aaMode = AntiAliasMode::None; + + int msaaSamples = 0; //!< 0 (off), 4, or 8; see validMsaaSampleCounts() + float gamma = 3.0f; //!< matches the brightness qtFRED has always launched with + + int textureFilter = NO_TEXTURE_FILTER_CHOICE; //!< 0 = bilinear, 1 = trilinear + float anisotropy = NO_ANISOTROPY_CHOICE; //!< 1.0 = off, otherwise a power of two + + bool operator==(const GraphicsSettings& rhs) const; + bool operator!=(const GraphicsSettings& rhs) const { return !(*this == rhs); } + + /** + * @brief The MSAA sample counts the Preferences combo offers, in combo order. + */ + static SCP_vector validMsaaSampleCounts(); + + static GraphicsSettings load(); + void save() const; + + /** + * @brief Push the settings that can be changed without a restart into the engine. + */ + void applyLive() const; + + /** + * @brief Load and apply the settings that must be in place before gr_init() runs. + * + * The engine reads texture filtering and anisotropy through the options system, so those are + * pushed as in-memory-only overrides rather than written out -- qtFRED must never rewrite the + * config file the game reads its own graphics settings from. + * + * @return the settings that were loaded, so the caller can applyLive() them once gr_init() has + * returned and there is a renderer to apply them to. + */ + static GraphicsSettings applyBeforeGrInit(); +}; + +} // namespace fso::fred diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp index 74ae5252400..57681573958 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -4,7 +4,6 @@ #include "ui/Theme.h" #include "mission/missiongrid.h" #include "math/vecmat.h" -#include "graphics/2d.h" namespace fso::fred::dialogs { @@ -27,13 +26,7 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _dataMenuStyle(viewport->Data_menu_style) , _toolbarIconSize(viewport->toolbar_icon_size) , _outlineLod(viewport->view.Outline_lod) - , _enablePostProcessing(viewport->view.EnablePostProcessing) - , _shadowQuality(viewport->view.Graphics_shadow_quality) - , _aaMode(viewport->view.Graphics_aa_mode) - , _msaaSamples(viewport->view.Graphics_msaa_samples) - , _textureFilter(viewport->view.Graphics_texture_filter) - , _anisotropy(viewport->view.Graphics_anisotropy) - , _gamma(viewport->view.Graphics_gamma) + , _graphics(viewport->view.Graphics) , _invertOrbitX(viewport->camera.getInvertOrbitX()) , _invertOrbitY(viewport->camera.getInvertOrbitY()) , _gridCenterX(static_cast(viewport->The_grid->center.xyz.x)) @@ -76,16 +69,9 @@ bool PreferencesDialogModel::apply() { _viewport->Data_menu_style = _dataMenuStyle; _viewport->toolbar_icon_size = _toolbarIconSize; _viewport->view.Outline_lod = _outlineLod; - _viewport->view.EnablePostProcessing = _enablePostProcessing; - _viewport->view.Graphics_shadow_quality = _shadowQuality; - _viewport->view.Graphics_aa_mode = _aaMode; - _viewport->view.Graphics_msaa_samples = _msaaSamples; - _viewport->view.Graphics_texture_filter = _textureFilter; - _viewport->view.Graphics_anisotropy = _anisotropy; - _viewport->view.Graphics_gamma = _gamma; - // AA mode and gamma take effect immediately; shadow quality, MSAA samples, texture filter, - // and anisotropy are only applied at startup (see management.cpp) and need a restart. - _viewport->applyGraphicsSettings(); + _viewport->view.Graphics = _graphics; + // Only some of these can take effect now; the rest need a restart (see GraphicsSettings). + _viewport->view.Graphics.applyLive(); _viewport->camera.setInvertOrbitX(_invertOrbitX); _viewport->camera.setInvertOrbitY(_invertOrbitY); @@ -198,41 +184,8 @@ void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconS int PreferencesDialogModel::getOutlineLod() const { return _outlineLod; } void PreferencesDialogModel::setOutlineLod(int value) { modify(_outlineLod, value); } -bool PreferencesDialogModel::getEnablePostProcessing() const { return _enablePostProcessing; } -void PreferencesDialogModel::setEnablePostProcessing(bool value) { modify(_enablePostProcessing, value); } - -int PreferencesDialogModel::getShadowQuality() const { return _shadowQuality; } -void PreferencesDialogModel::setShadowQuality(int value) { modify(_shadowQuality, value); } - -int PreferencesDialogModel::getAAMode() const { return _aaMode; } -void PreferencesDialogModel::setAAMode(int value) { modify(_aaMode, value); } - -int PreferencesDialogModel::getMSAASamples() const { return _msaaSamples; } -void PreferencesDialogModel::setMSAASamples(int value) { modify(_msaaSamples, value); } - -int PreferencesDialogModel::getTextureFilter() const { return _textureFilter; } -void PreferencesDialogModel::setTextureFilter(int value) { modify(_textureFilter, value); } - -float PreferencesDialogModel::getAnisotropy() const { return _anisotropy; } -void PreferencesDialogModel::setAnisotropy(float value) { modify(_anisotropy, value); } - -SCP_vector PreferencesDialogModel::getAvailableAnisotropyLevels() const { - SCP_vector levels; - - float max = 1.0f; - if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max) || max <= 1.0f) { - levels.push_back(1.0f); - return levels; - } - - for (float level = 1.0f; level <= max; level *= 2.0f) { - levels.push_back(level); - } - return levels; -} - -float PreferencesDialogModel::getGamma() const { return _gamma; } -void PreferencesDialogModel::setGamma(float value) { modify(_gamma, value); } +const GraphicsSettings& PreferencesDialogModel::getGraphics() const { return _graphics; } +void PreferencesDialogModel::setGraphics(const GraphicsSettings& value) { modify(_graphics, value); } QKeySequence PreferencesDialogModel::getControlKey(ControlAction action) const { auto it = _controlKeys.find(action); diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.h b/qtfred/src/mission/dialogs/PreferencesDialogModel.h index c46d915ef36..0d12bb0dcfe 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.h +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.h @@ -1,5 +1,6 @@ #pragma once +#include "mission/GraphicsSettings.h" #include "mission/dialogs/AbstractDialogModel.h" #include "ui/ControlBindings.h" #include "ui/ThemeMode.h" @@ -67,30 +68,10 @@ class PreferencesDialogModel : public AbstractDialogModel { int getOutlineLod() const; void setOutlineLod(int value); - // Graphics - bool getEnablePostProcessing() const; - void setEnablePostProcessing(bool value); - - int getShadowQuality() const; - void setShadowQuality(int value); - - int getAAMode() const; - void setAAMode(int value); - - int getMSAASamples() const; - void setMSAASamples(int value); - - int getTextureFilter() const; - void setTextureFilter(int value); - - float getAnisotropy() const; - void setAnisotropy(float value); - // Anisotropy levels the current hardware actually supports (1.0 = off), for populating the - // combo box: 1x, 2x, 4x, ... up to the hardware max. - SCP_vector getAvailableAnisotropyLevels() const; - - float getGamma() const; - void setGamma(float value); + // Graphics. The dialog edits a whole GraphicsSettings rather than a field at a time; the + // setter exists so edits still route through modify() and mark the model dirty. + const GraphicsSettings& getGraphics() const; + void setGraphics(const GraphicsSettings& value); // Controls QKeySequence getControlKey(ControlAction action) const; @@ -134,14 +115,7 @@ class PreferencesDialogModel : public AbstractDialogModel { int _toolbarIconSize; int _outlineLod; - // Graphics - bool _enablePostProcessing; - int _shadowQuality; - int _aaMode; - int _msaaSamples; - int _textureFilter; - float _anisotropy; - float _gamma; + GraphicsSettings _graphics; // Controls std::map _controlKeys; diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index 75a78e52619..db32e78246d 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -3,14 +3,14 @@ #include "object.h" +#include "mission/GraphicsSettings.h" + #include "cmdline/cmdline.h" #include #include #include #include -#include -#include #include #include #include @@ -27,7 +27,6 @@ #include #include #include -#include #include #include #include @@ -42,7 +41,6 @@ #include #include -#include #include extern bool Xstr_inited; @@ -129,38 +127,16 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; - - // MSAA sample count, shadow quality, and the texture filter/anisotropy defaults are baked - // into GPU resources during gr_init() and can't be changed afterwards without recreating - // them, so qtFRED has to apply its saved Preferences > Graphics values here, before gr_init() - // runs, rather than from EditorViewport (which doesn't exist yet -- it's created after the - // first viewport widget is up). Shadow quality specifically: shadow_cascade_params_init() - // (gropengltnl.cpp/gr_vulkan.cpp) only runs during gr_init(), and only if Shadow_quality is - // already non-Disabled at that point -- setting it afterwards leaves the cascade buffers - // unsized and segfaults the next time a frame renders shadows. Read directly out of QSettings - // under the same "Preferences" group EditorViewport uses, using an in-memory-only - // OptionsManager override for the two settings the engine reads through an Option - // (Graphics.TextureFilter, Graphics.Anisotropy) so this never rewrites the registry/config - // file the actual game reads its own graphics settings from. - { - QSettings settings; - settings.beginGroup("Preferences"); - Cmdline_msaa_enabled = settings.value("view_graphics_msaa_samples", Cmdline_msaa_enabled).toInt(); - Shadow_quality = static_cast(settings.value("view_graphics_shadow_quality", 0).toInt()); - - const int textureFilter = settings.value("view_graphics_texture_filter", 1).toInt(); - options::OptionsManager::instance()->setOverride("Graphics.TextureFilter", std::to_string(textureFilter)); - - if (settings.contains("view_graphics_anisotropy")) { - const float anisotropy = settings.value("view_graphics_anisotropy").toFloat(); - options::OptionsManager::instance()->setOverride("Graphics.Anisotropy", std::to_string(anisotropy)); - } - settings.endGroup(); - } + // These have to be in place before gr_init() bakes them into GPU resources, which is well + // before EditorViewport (and its copy of the settings) exists. + const GraphicsSettings graphicsSettings = GraphicsSettings::applyBeforeGrInit(); std::unique_ptr graphicsOps(new QtGraphicsOperations(editor)); gr_init(std::move(graphicsOps)); - gr_set_gamma(3.0f); + + // The rest needs a live renderer. EditorViewport re-applies these once it exists, but the + // startup screens render before that. + graphicsSettings.applyLive(); io::mouse::CursorManager::get()->showCursor(false); diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp index 29ffd80cbf7..4bbe97d6e41 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -942,7 +942,7 @@ void FredView::syncViewOptions() { connectActionToViewSetting(ui->actionLighting_from_Suns, &_viewport->view.Lighting_on); connectActionToViewSetting(ui->actionRender_Full_Detail, &_viewport->view.FullDetail); - connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.EnablePostProcessing); + connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.Graphics.enablePostProcessing); connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances); diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index fd765fa548f..0067bb2b90c 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -8,9 +8,54 @@ #include "ui/util/SignalBlockers.h" #include "ui/widgets/sexp_tree_view.h" +#include +#include + namespace fso::fred::dialogs { namespace { +// The engine already describes each of these settings once, with its own value list and display +// names (Graphics.Shadows in shadows.cpp, Graphics.AAMode and Graphics.TextureFilter in 2d.cpp and +// gropengltexture.cpp). Reading the labels back out of those definitions keeps this dialog from +// carrying a second, silently-diverging copy of them. +// +// The values are enumerated in declaration order, which for these three is enum order, so a combo +// index is the enum value. Returns empty if the option isn't registered, in which case the caller +// leaves the combo alone rather than showing a half-populated list. +SCP_vector engineOptionValues(const char* configKey) +{ + for (const auto& option : options::OptionsManager::instance()->getOptions()) { + if (option->getConfigKey() == configKey && option->getType() == options::OptionType::Selection) { + return option->getValidValues(); + } + } + + return {}; +} + +void populateFromEngineOption(QComboBox* combo, const char* configKey) +{ + const auto values = engineOptionValues(configKey); + + for (const auto& value : values) { + combo->addItem(QString::fromStdString(value.display)); + } + + // Nothing to choose from means the option isn't registered in this build (a renderer compiled + // out, say). Grey the control out rather than leaving an empty combo that looks broken. + combo->setEnabled(!values.empty()); +} + +// The model stores graphics settings as one struct, so an edit is read-modify-write. Doing it this +// way keeps every control's slot to the one line that actually differs. +template +void editGraphics(PreferencesDialogModel* model, F&& mutate) +{ + GraphicsSettings graphics = model->getGraphics(); + mutate(graphics); + model->setGraphics(graphics); +} + int themeModeToIndex(ThemeMode mode) { switch (mode) { @@ -94,11 +139,26 @@ void PreferencesDialog::applyChanges() { } void PreferencesDialog::initializeUi() { - // Populate the anisotropy combo with the levels the current hardware actually supports - // (1x/2x/4x/.../max), since that max varies by GPU. - for (float level : _model->getAvailableAnisotropyLevels()) { + // setupUi() has already run connectSlotsByName(), so filling a combo here would fire its + // currentIndexChanged slot and mark the model modified before the user has touched anything. + // (Items declared in the .ui file were added before the connections existed and so were safe.) + util::SignalBlockers blockers(this); + + populateFromEngineOption(ui->shadowQualityCombo, "Graphics.Shadows"); + populateFromEngineOption(ui->aaModeCombo, "Graphics.AAMode"); + populateFromEngineOption(ui->textureFilterCombo, "Graphics.TextureFilter"); + + // Anisotropy levels are hardware-dependent, so ask the engine for the same list its own + // options screen offers. Empty means the hardware can't do it; leave the combo disabled. + _anisotropyLevels = gr_get_supported_anisotropy_levels(); + for (float level : _anisotropyLevels) { ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0)); } + ui->anisotropyCombo->setEnabled(!_anisotropyLevels.empty()); + + for (int samples : GraphicsSettings::validMsaaSampleCounts()) { + ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples)); + } // Build the controls key-binding form dynamically from the registered bindings auto* form = new QFormLayout(ui->controlsFormWidget); @@ -133,34 +193,32 @@ void PreferencesDialog::updateUi() { ui->outlineLodCombo->setCurrentIndex(_model->getOutlineLod()); // Graphics - ui->enablePostProcessing->setChecked(_model->getEnablePostProcessing()); - ui->shadowQualityCombo->setCurrentIndex(_model->getShadowQuality()); - ui->aaModeCombo->setCurrentIndex(_model->getAAMode()); - { - static constexpr int msaaSamples[] = { 0, 4, 8 }; - const int samples = _model->getMSAASamples(); - int index = 0; - for (int i = 0; i < 3; ++i) { - if (msaaSamples[i] == samples) { - index = i; - break; - } - } - ui->msaaCombo->setCurrentIndex(index); - } - ui->textureFilterCombo->setCurrentIndex(_model->getTextureFilter()); - { - const auto levels = _model->getAvailableAnisotropyLevels(); - const float anisotropy = _model->getAnisotropy(); - int index = 0; - for (size_t i = 0; i < levels.size(); ++i) { - if (levels[i] <= anisotropy) { + const GraphicsSettings& graphics = _model->getGraphics(); + + ui->enablePostProcessing->setChecked(graphics.enablePostProcessing); + ui->shadowQualityCombo->setCurrentIndex(static_cast(graphics.shadowQuality)); + ui->aaModeCombo->setCurrentIndex(static_cast(graphics.aaMode)); + ui->gammaSpin->setValue(graphics.gamma); + + const auto msaaCounts = GraphicsSettings::validMsaaSampleCounts(); + ui->msaaCombo->setCurrentIndex( + static_cast(std::find(msaaCounts.begin(), msaaCounts.end(), graphics.msaaSamples) - msaaCounts.begin())); + + // Both of these can be "not chosen", in which case the engine picks: trilinear, and the + // hardware's maximum anisotropy. Show what will actually be used rather than a blank. + ui->textureFilterCombo->setCurrentIndex( + graphics.textureFilter == GraphicsSettings::NO_TEXTURE_FILTER_CHOICE ? 1 : graphics.textureFilter); + + if (!_anisotropyLevels.empty()) { + int index = static_cast(_anisotropyLevels.size()) - 1; + for (size_t i = 0; i < _anisotropyLevels.size(); ++i) { + if (_anisotropyLevels[i] == graphics.anisotropy) { index = static_cast(i); + break; } } ui->anisotropyCombo->setCurrentIndex(index); } - ui->gammaSpin->setValue(_model->getGamma()); ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents()); ui->showSexpHelpMissionGoals->setChecked(_model->getShowSexpHelpMissionGoals()); @@ -240,35 +298,40 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) { } void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) { - _model->setEnablePostProcessing(checked); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.enablePostProcessing = checked; }); } void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) { - _model->setShadowQuality(index); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = static_cast(index); }); } void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) { - _model->setAAMode(index); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.aaMode = static_cast(index); }); } void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) { - static constexpr int msaaSamples[] = { 0, 4, 8 }; - _model->setMSAASamples(msaaSamples[index]); + const auto counts = GraphicsSettings::validMsaaSampleCounts(); + if (index < 0 || static_cast(index) >= counts.size()) { + return; + } + + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.msaaSamples = counts[index]; }); } void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int index) { - _model->setTextureFilter(index); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = index; }); } void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) { - const auto levels = _model->getAvailableAnisotropyLevels(); - if (index >= 0 && static_cast(index) < levels.size()) { - _model->setAnisotropy(levels[index]); + if (index < 0 || static_cast(index) >= _anisotropyLevels.size()) { + return; } + + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = _anisotropyLevels[index]; }); } void PreferencesDialog::on_gammaSpin_valueChanged(double value) { - _model->setGamma(static_cast(value)); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.gamma = static_cast(value); }); } void PreferencesDialog::on_dataMenuStyleCombo_currentIndexChanged(int index) { diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h index cc1a5a6214b..a4ed07e2614 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -71,6 +71,8 @@ private slots: std::unique_ptr ui; std::unique_ptr _model; + //! Anisotropy levels backing the combo, in combo order. Queried once; hardware-dependent. + SCP_vector _anisotropyLevels; std::map _controlEditors; FredView* _fredView = nullptr; EditorViewport* _viewport = nullptr; diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index 68b55ea5b3b..e06b97af410 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -393,31 +393,6 @@ Quality of the shadows cast by the mission's sun in the viewport. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect. - - - Disabled - - - - - Low (restart required) - - - - - Medium (restart required) - - - - - High (restart required) - - - - - Ultra (restart required) - - @@ -432,46 +407,6 @@ Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled. - - - None - - - - - FXAA Low - - - - - FXAA Medium - - - - - FXAA High - - - - - SMAA Low - - - - - SMAA Medium - - - - - SMAA High - - - - - SMAA Ultra - - @@ -486,21 +421,6 @@ Multisample anti-aliasing used for the viewport's 3D scene. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect. - - - Off - - - - - 4x (restart required) - - - - - 8x (restart required) - - @@ -524,16 +444,6 @@ Mipmap filtering used for textures. Restarting qtFRED is required for a change to take effect. - - - Bilinear (restart required) - - - - - Trilinear (restart required) - - From 9f2f3f1ca4e400a403b181293100070a4bc38ecc Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:41:59 +0200 Subject: [PATCH 6/7] Document the Graphics preferences in the help that ships The Graphics tab was documented in help-src/doc/dialogs/PreferencesDialog.html, which is not listed in doc/qtfred.qhp and so is never compiled into qtfred_help.qch. It is a stale duplicate of general/PreferencesDialog.html -- the page the table of contents, the Preferences keyword, and Viewport.html all point at -- and one of only two orphaned files under help-src/doc. None of the new documentation reached the Help viewer. Document the tab on the page that actually ships, and revert the edit to the orphan so nothing is left stranded there. The orphan itself predates this branch and is left alone; it should probably be deleted, but that is a separate question from this branch. The content is also corrected against what the settings now do: - Shadow quality requires a restart. The orphan did not say so, and the dropdown entries no longer carry a "(restart required)" suffix now that their labels come from the engine's own option definition. - Texture filtering and anisotropy default to the engine's own choice (the config default, and the hardware maximum) until the user picks one. - Anisotropy is disabled outright on hardware that does not support it. - Gamma documents its actual range and default. - The page's "changes take effect immediately" claim is qualified, since it is not true of the four restart-only settings. Also notes the View > Enable Post Processing menu equivalent, which had no documentation anywhere -- no help page covers the View menu's display toggles. Co-Authored-By: Claude Opus 5 --- .../doc/dialogs/PreferencesDialog.html | 23 ---------- .../doc/general/PreferencesDialog.html | 46 ++++++++++++++++++- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/qtfred/help-src/doc/dialogs/PreferencesDialog.html b/qtfred/help-src/doc/dialogs/PreferencesDialog.html index f4b2a5f043e..88783286106 100644 --- a/qtfred/help-src/doc/dialogs/PreferencesDialog.html +++ b/qtfred/help-src/doc/dialogs/PreferencesDialog.html @@ -25,29 +25,6 @@

General

theme.
SettingDescription
-

Graphics

- - - - - - - - - -
SettingDescription
Enable post-processing in the viewportRoutes the 3D viewport through - the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) instead - of drawing straight to the screen. Off by default. Every other setting on this tab - only has a visible effect while this is on.
Shadow qualityQuality of the shadows cast by the mission's sun in the - viewport.
Anti-aliasingPost-process anti-aliasing mode (FXAA/SMAA) used in the - viewport.
MSAAMultisample anti-aliasing for the viewport's 3D scene. Requires - restarting QtFRED to take effect.
Texture filteringMipmap filtering used for textures (bilinear or - trilinear). Requires restarting QtFRED to take effect.
Anisotropic filteringAnisotropic texture filtering level, up to the - maximum the current GPU supports. Requires restarting QtFRED to take - effect.
GammaBrightness of the viewport. QtFRED defaults this higher than the - game, since the viewport isn't normally tonemapped unless post-processing is - on.
-

Grid

diff --git a/qtfred/help-src/doc/general/PreferencesDialog.html b/qtfred/help-src/doc/general/PreferencesDialog.html index 8a8ca2e3e84..907ea9961a4 100644 --- a/qtfred/help-src/doc/general/PreferencesDialog.html +++ b/qtfred/help-src/doc/general/PreferencesDialog.html @@ -10,7 +10,9 @@

Preferences

Opens via File › Preferences.

Controls QtFRED editor settings. Changes take effect immediately; there is no -Apply button.

+Apply button. The exception is a handful of graphics settings that the renderer +can only read while it is starting up - those are marked below, and QtFRED +applies them the next time it is launched.

General

Miscellaneous editor options. Hover over each setting for a tooltip @@ -30,6 +32,48 @@

Autosave

being asked. +

Graphics

+

Controls how the 3D viewport is rendered. These settings affect the editor +only - they are stored separately from the game's own graphics options and +never change them.

+ +

Post-processing

+

Enable post-processing in the viewport routes the viewport +through the same HDR rendering pipeline the game uses, adding bloom, +tonemapping, lightshafts, and shadows. It is off by default, so missions look +the way they always have until you opt in. The same switch is available as +View › Enable Post Processing.

+

Every other setting in this section only changes what you see while +post-processing is on, because they are all consumed by that pipeline.

+ +

Shadows & anti-aliasing

+
    +
  • Shadow quality - detail level of the shadows cast by the + mission's sun. Higher settings cost more performance. Requires a + restart.
  • +
  • Anti-aliasing - post-process anti-aliasing mode (FXAA or + SMAA, in increasing quality). Applies immediately.
  • +
  • MSAA - multisample anti-aliasing for the 3D scene, off + or 4x/8x. More expensive than the post-process modes above but higher + quality. Requires a restart.
  • +
+ +

Textures

+
    +
  • Texture filtering - bilinear or trilinear mipmap + filtering. Left alone, QtFRED uses the same default the game does. + Requires a restart.
  • +
  • Anisotropic filtering - sharpens textures viewed at a + steep angle. The list is limited to the levels your GPU reports, and is + disabled entirely if it does not support the feature. Left alone, QtFRED + uses your hardware's maximum. Requires a restart.
  • +
+ +

Gamma

+

Brightness of the viewport, from 0.10 to 5.00. QtFRED defaults this to 3.00, +higher than the game, because the viewport is not tonemapped unless +post-processing is enabled. Applies immediately.

+

Grid

Configures the reference grid shown in the main viewport. You can set which world-space plane the grid lies on (XZ, XY, or YZ) and adjust the grid's center From ef7dabacdba06fba32ad9031715abc4aa85e72d3 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:46:31 +0200 Subject: [PATCH 7/7] Update the design notes and help for the resize rework documentation/qtfred-post-processing-viewport-resize.md still described the Gr_min_render_target_w/h floor, which no longer exists, and listed reallocate-on-resize as rejected -- which is now the implemented approach. Rewrite it around gf_resize_render_targets, and keep the two failed attempts as history, because the reason they failed is the reason the current one works. That document had already identified the prerequisite correctly: the scene teardown left FBOs attached to stale texture handles, and draws to an incomplete FBO go nowhere, hence the black viewport. Completing the teardown was what made reallocation viable. Also records the two properties that are easy to get wrong (clamp to the hardware limit before deciding whether to resize, or a viewport past that limit rebuilds every frame; never resize mid-frame), the sites fixed in the deferred and Vulkan paths, and why the volumetric nebula pass is deliberately left unscaled. Corrections to the Preferences help page from the same review: - Enabling post-processing does not by itself produce shadows. Shadow quality defaults to Disabled and needs a restart to change, so the page said the opposite of what a user will experience. - The anisotropy control also greys out when the GPU reports a maximum of 2x, not only when it lacks the feature outright. The help keyword index is left alone: keywords there are page-level, with multiple entries only ever used as synonyms for one page, so per-setting keywords would be inventing a convention rather than following one. Finally, note in the qtfred module guide that its viewport calls gr_screen_resize() every frame -- the assumption whose violation caused all of the above -- and where the graphics preferences now live. Co-Authored-By: Claude Opus 5 --- code/graphics/2d.cpp | 4 ++-- .../help-src/doc/general/PreferencesDialog.html | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 5b66d206955..30edc07c8d9 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -863,11 +863,11 @@ SCP_vector gr_get_supported_anisotropy_levels() { float max; if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { - return SCP_vector(); + return {}; } if (max <= 2.0f) { - return SCP_vector(); + return {}; } SCP_vector out; diff --git a/qtfred/help-src/doc/general/PreferencesDialog.html b/qtfred/help-src/doc/general/PreferencesDialog.html index 907ea9961a4..cb4bfca04ba 100644 --- a/qtfred/help-src/doc/general/PreferencesDialog.html +++ b/qtfred/help-src/doc/general/PreferencesDialog.html @@ -40,8 +40,8 @@

Graphics

Post-processing

Enable post-processing in the viewport routes the viewport through the same HDR rendering pipeline the game uses, adding bloom, -tonemapping, lightshafts, and shadows. It is off by default, so missions look -the way they always have until you opt in. The same switch is available as +tonemapping, and lightshafts. It is off by default, so missions look the way +they always have until you opt in. The same switch is available as View › Enable Post Processing.

Every other setting in this section only changes what you see while post-processing is on, because they are all consumed by that pipeline.

@@ -49,8 +49,10 @@

Post-processing

Shadows & anti-aliasing

  • Shadow quality - detail level of the shadows cast by the - mission's sun. Higher settings cost more performance. Requires a - restart.
  • + mission's sun. Higher settings cost more performance. Shadows are off + until you raise this above Disabled, so enabling post-processing + alone will not produce any. Requires a restart - including the + first time you turn it on.
  • Anti-aliasing - post-process anti-aliasing mode (FXAA or SMAA, in increasing quality). Applies immediately.
  • MSAA - multisample anti-aliasing for the 3D scene, off @@ -64,9 +66,9 @@

    Textures

    filtering. Left alone, QtFRED uses the same default the game does. Requires a restart.
  • Anisotropic filtering - sharpens textures viewed at a - steep angle. The list is limited to the levels your GPU reports, and is - disabled entirely if it does not support the feature. Left alone, QtFRED - uses your hardware's maximum. Requires a restart.
  • + steep angle. The list is limited to the levels your GPU reports, and the + control is greyed out if it reports nothing worth offering. Left alone, + QtFRED uses your hardware's maximum. Requires a restart.

Gamma

SettingDescription