From 99e05fa0936980a236de1c4a5ac4535f25a49e08 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 14:43:00 +0200 Subject: [PATCH 1/9] 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 4030dda7489..8bacd5831ee 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -863,6 +863,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 9bf045e8931..7024d17599e 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 d5c466fe4f604dedb486157be2d51b6240d51a06 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 18:51:09 +0200 Subject: [PATCH 2/9] 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 9375b353468..be217c97452 100644 --- a/code/graphics/opengl/gropengltexture.cpp +++ b/code/graphics/opengl/gropengltexture.cpp @@ -189,7 +189,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 f08284f9938309c881027ea05689d1539d6c03a1 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:07:16 +0200 Subject: [PATCH 3/9] 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 8bacd5831ee..48928047ba9 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -853,6 +853,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 7024d17599e..8ebb3d78008 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -1386,6 +1386,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 be217c97452..54c3718999d 100644 --- a/code/graphics/opengl/gropengltexture.cpp +++ b/code/graphics/opengl/gropengltexture.cpp @@ -89,28 +89,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) { @@ -134,7 +112,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) @@ -189,13 +167,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 4d3c614c60997995ca5b522b5f43c9aa3d23ea21 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:18:51 +0200 Subject: [PATCH 4/9] 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 48928047ba9..52058f283c9 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -886,9 +886,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}, @@ -1667,6 +1664,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(); + } } int gr_get_resolution_class(int width, int height) diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 8ebb3d78008..5c2a2c19493 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. @@ -873,6 +861,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 444515b797a..66938f670fa 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; @@ -1513,7 +1514,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 35d67419977..2b80207daa1 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() @@ -321,8 +323,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; { @@ -557,7 +563,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); @@ -653,6 +660,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 2985c0b6509..e353bf73e93 100644 --- a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp @@ -489,8 +489,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 0c756d287516df4379681882b547ff011dd46d42 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:20:42 +0200 Subject: [PATCH 5/9] 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 8bae43cc874b90dde2ec01702aa1c766f8f53160 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:41:59 +0200 Subject: [PATCH 6/9] 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 933c9ba04dac956ffca95b0df50c2183cec343f4 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:46:31 +0200 Subject: [PATCH 7/9] 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 52058f283c9..e01aec5f75e 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -857,11 +857,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

From b1eaf69c8b40af0750432c14e7c3e647898ca6f6 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:46:31 +0200 Subject: [PATCH 8/9] Update Vulkan backend to support off-screen frame recycling This commit introduces `gr_end_offscreen_frame()` to manage per-frame state recycling for renders that skip `gr_flip()`. It adds generation-based sub-allocation tracking in `VulkanBuffer` to prevent stale memory access after allocator rewinds. Improves resource management, resolves memory growth issues in non-flipping workflows (e.g., qtFRED briefing map), and ensures consistent handling of uniform segments and descriptor pools. Integrate Vulkan render backend in qtFRED with updated rendering workflows, multi-target support, and enhanced resource lifecycle management. --- code/graphics/2d.cpp | 53 ++- code/graphics/2d.h | 74 +++- code/graphics/opengl/gropengl.cpp | 39 +- code/graphics/vulkan/VulkanBuffer.cpp | 85 +++- code/graphics/vulkan/VulkanBuffer.h | 15 +- code/graphics/vulkan/VulkanDrawAPI.cpp | 8 +- code/graphics/vulkan/VulkanRenderFrame.cpp | 29 +- code/graphics/vulkan/VulkanRenderFrame.h | 52 ++- code/graphics/vulkan/VulkanRenderer.cpp | 254 ++++++----- code/graphics/vulkan/VulkanRenderer.h | 401 +++++++++++++++--- code/graphics/vulkan/VulkanRendererImGui.cpp | 16 +- code/graphics/vulkan/VulkanRendererLoop.cpp | 309 ++++++++++++-- code/graphics/vulkan/VulkanRendererSetup.cpp | 366 ++++++++++++---- code/graphics/vulkan/gr_vulkan.cpp | 115 ++++- code/osapi/osapi.h | 18 + code/osapi/vulkan_surface.h | 92 ++++ code/source_groups.cmake | 1 + freespace2/SDLGraphicsOperations.cpp | 65 +++ freespace2/SDLGraphicsOperations.h | 16 +- qtfred/README.md | 80 ++-- .../doc/general/PreferencesDialog.html | 33 +- qtfred/src/mission/FredRenderer.cpp | 15 +- qtfred/src/mission/GraphicsSettings.cpp | 44 +- qtfred/src/mission/GraphicsSettings.h | 79 +++- qtfred/src/ui/FredView.cpp | 3 + qtfred/src/ui/QtGraphicsOperations.cpp | 230 +++++++++- qtfred/src/ui/QtGraphicsOperations.h | 65 ++- .../src/ui/dialogs/BriefingEditorDialog.cpp | 4 + qtfred/src/ui/dialogs/PreferencesDialog.cpp | 144 +++++-- qtfred/src/ui/dialogs/PreferencesDialog.h | 4 +- qtfred/src/ui/widgets/BriefingMapWidget.cpp | 81 +++- qtfred/src/ui/widgets/BriefingMapWidget.h | 15 + qtfred/src/ui/widgets/renderwidget.cpp | 8 +- qtfred/ui/PreferencesDialog.ui | 45 +- 34 files changed, 2396 insertions(+), 462 deletions(-) create mode 100644 code/osapi/vulkan_surface.h diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index e01aec5f75e..37df6633687 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -1665,11 +1665,11 @@ void gr_screen_resize(int width, int height) 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(); + // Whatever the backend sized to the old gr_screen is now wrong; let it catch up before anything + // renders at the new size. This can discard the frame in progress -- see the warning on the + // declaration of this function. + if (gr_screen.gf_viewport_size_changed) { + gr_screen.gf_viewport_size_changed(); } } @@ -2153,11 +2153,15 @@ bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI center_aspect_ratio = -1.0f; } - // FRED doesn't support Vulkan yet (see qtfred/README.md for what's needed to change that), so it always - // falls back to OpenGL regardless of what was requested. This must happen before gr_init_function_pointers() - // below, since that's what binds gr_screen's gf_* dispatch table to the chosen API; doing the override any + // Vulkan needs more from the windowing implementation than an OpenGL context does, and not every + // implementation can provide it -- the MFC editor can't, and neither can a qtFRED built against a + // Qt without Vulkan support or running on a platform plugin we have no surface extension for. + // Fall back rather than fail. This must happen before gr_init_function_pointers() below, since + // that's what binds gr_screen's gf_* dispatch table to the chosen API; doing the override any // later (e.g. in gr_init_sub()) would leave the dispatch table pointing at the wrong backend. - if (Fred_running) { + if (mode == GraphicsAPI::Vulkan && (graphicsOps == nullptr || graphicsOps->getVulkanSupport() == nullptr)) { + mprintf(("Vulkan was requested but this windowing implementation cannot present through it; " + "falling back to OpenGL.\n")); mode = GraphicsAPI::OpenGL; } @@ -3296,6 +3300,37 @@ static void uniform_buffer_managers_retire_buffers() UniformBufferManager->onFrameEnd(); } +bool gr_read_render_target(ubyte* out_rgba, int width, int height) +{ + if (out_rgba == nullptr || width <= 0 || height <= 0) { + return false; + } + + if (!gr_screen.gf_read_render_target) { + return false; + } + + return gr_screen.gf_read_render_target(out_rgba, width, height); +} + +void gr_end_offscreen_frame() +{ + if (gr_screen.mode == GraphicsAPI::Stub) { + return; + } + + // Same two things gr_flip() does for a presented frame, minus the presentation: retire the + // uniform segments so the next frame starts writing at offset 0 again, then let the backend + // recycle whatever per-frame pools it keeps. Order matters -- the backend rewinding its + // allocator while the engine still thinks it is part-way through a segment would just make + // the next allocation larger than the last. + uniform_buffer_managers_retire_buffers(); + + if (gr_screen.gf_end_offscreen_frame) { + gr_screen.gf_end_offscreen_frame(); + } +} + graphics::util::UniformBuffer gr_get_uniform_buffer(uniform_block_type type, size_t num_elements, size_t element_size_override) { return UniformBufferManager->getUniformBuffer(type, num_elements, element_size_override); diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 5c2a2c19493..6fc07d366de 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -761,6 +761,14 @@ typedef struct screen { // dumps the current screen to a html blob string std::function gf_blob_screen; + // reads the currently bound render target back into a caller-provided RGBA8 buffer. + // Optional: backends that can't read a render target back leave this unset. + std::function gf_read_render_target; + + // recycles per-frame backend state after an off-screen render that never reaches gr_flip(). + // Optional: backends that keep no per-frame pools leave this unset. + std::function gf_end_offscreen_frame; + // transforms and dumps the current environment map to a file std::function gf_dump_envmap; @@ -861,11 +869,15 @@ 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; + // The viewport is now gr_screen.max_w x max_h; bring whatever the backend sized to the old one + // into line. Called from gr_screen_resize(); see the precondition documented there, which is + // stricter than it looks -- what a backend does here can include throwing away the frame in + // progress. Optional: a backend with nothing sized to the viewport leaves it unset. + // + // OpenGL grows the scene/post-processing render targets. Vulkan rebuilds the swap chain and + // everything sized to it, and restarts the frame; that is the only point at which it can notice + // the window and the swap chain have diverged (see VulkanRenderer::syncToSurfaceExtent()). + std::function gf_viewport_size_changed; std::function gf_zbias; @@ -978,6 +990,12 @@ typedef struct screen { std::unique_ptr (*gf_create_viewport)(const os::ViewPortProperties& props); std::function gf_use_viewport; + //! Optional. Backends that keep per-viewport GPU resources (Vulkan holds a surface, swap chain + //! and everything sized to it) get told here that a viewport is about to be destroyed, while + //! the device and the viewport's window are both still alive. Left unset by backends with + //! nothing to release. + std::function gf_release_viewport; + std::function gf_bind_uniform_buffer; @@ -1060,6 +1078,16 @@ extern const char *Resolution_prefixes[GR_NUM_RESOLUTIONS]; extern bool gr_init(std::unique_ptr&& graphicsOps, GraphicsAPI d_mode = GraphicsAPI::Default, int d_width = GR_DEFAULT, int d_height = GR_DEFAULT, int d_depth = GR_DEFAULT); +/** + * @brief Tell the engine the viewport is now @p width x @p height. + * + * @warning Call this between frames, never once drawing has started. It runs + * gf_viewport_size_changed, and what a backend does there is not limited to reallocating: the + * Vulkan backend discards the frame in progress and restarts it at the new size, so anything + * already recorded into it is lost. OpenGL asserts rather than tear down a framebuffer it is + * rendering into. Both are fine at the top of a frame, which is where every caller sits today -- + * an SDL resize event, or qtFRED's per-frame viewport sync. + */ extern void gr_screen_resize(int width, int height); extern int gr_get_resolution_class(int width, int height); @@ -1139,6 +1167,36 @@ bool gr_is_screenshot_requested(); //#define gr_flip GR_CALL(gr_screen.gf_flip) void gr_flip(bool execute_scripting = true); +/** + * @brief Read the currently bound render target back into @p out_rgba. + * + * For callers that composed into a render target (bm_set_render_target()) and want the pixels + * rather than a file or a data URL -- qtFRED's briefing map. gr_blob_screen() reads the same source + * but PNG-encodes and base64-wraps it, which is pure overhead when the destination is a bitmap + * again. + * + * @param out_rgba Receives @p width * @p height * 4 bytes, RGBA order, rows top-down. Must be at + * least that large. + * @param width Expected width of the bound target, in pixels + * @param height Expected height of the bound target, in pixels + * @return false if no target is bound, if it isn't the size the caller expected, or if the backend + * can't read one back at all. @p out_rgba is untouched in that case. + */ +bool gr_read_render_target(ubyte* out_rgba, int width, int height); + +/** + * @brief End a frame's worth of rendering that never reaches gr_flip(). + * + * For off-screen renderers that compose into a render target and read the result back rather than + * presenting -- qtFRED's briefing map. gr_flip() is what retires the engine's per-frame uniform + * segments and what makes the backend recycle its per-frame pools; a renderer that never calls it + * accumulates both for as long as it runs. + * + * Only call this once the frame's GPU work has actually completed -- after a readback that + * host-waits, which is the case for gr_blob_screen() on a bound render target. + */ +void gr_end_offscreen_frame(); + inline void gr_setup_frame() { gr_screen.gf_setup_frame(); } @@ -1429,6 +1487,12 @@ inline void gr_use_viewport(os::Viewport* view) { gr_screen.gf_use_viewport(view); } +inline void gr_release_viewport(os::Viewport* view) +{ + if (gr_screen.gf_release_viewport) { + gr_screen.gf_release_viewport(view); + } +} inline void gr_set_viewport(int x, int y, int width, int height) { gr_screen.gf_set_viewport(x, y, width, height); diff --git a/code/graphics/opengl/gropengl.cpp b/code/graphics/opengl/gropengl.cpp index 66938f670fa..a699453c55a 100644 --- a/code/graphics/opengl/gropengl.cpp +++ b/code/graphics/opengl/gropengl.cpp @@ -461,6 +461,42 @@ SCP_string gr_opengl_blob_screen() return "data:image/png;base64," + result; } +bool gr_opengl_read_render_target(ubyte* out_rgba, int width, int height) +{ + const GLuint render_target = opengl_get_rtt_framebuffer(); + if (render_target == 0) { + return false; + } + + // The caller sized its buffer from the bitmap it bound, so a disagreement means it is reading + // something other than what it thinks. Refuse rather than overrun or return a wrong-shaped image. + if (width != gr_screen.max_w || height != gr_screen.max_h) { + nprintf(("OpenGL", "gr_opengl_read_render_target: caller expected %dx%d but the bound target " + "is %dx%d\n", width, height, gr_screen.max_w, gr_screen.max_h)); + return false; + } + + GL_state.PushFramebufferState(); + GL_state.BindFrameBuffer(render_target, GL_FRAMEBUFFER); + glReadBuffer(GL_COLOR_ATTACHMENT0); + + // Row 0 first, which for a render target FSO composed into is the top row -- matching the + // top-down order gr_read_render_target() promises. Deliberately not the flip gr_blob_screen() + // applies: that one exists to make the PNG come out upright, and there is no PNG here. + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, out_rgba); + glFlush(); + + GL_state.PopFramebufferState(); + + // Reported, not returned. glGetError drains one global queue, so an entry left by anything + // earlier in the frame is not evidence about this readback -- and callers use the return value + // to decide whether the frame's work has completed (gr_end_offscreen_frame()). Failing on + // somebody else's error would silently skip that. + opengl_check_for_errors("gr_opengl_read_render_target"); + + return true; +} + void gr_opengl_dump_envmap(const char* filename) { char tmp[MAX_PATH_LEN]; @@ -1066,6 +1102,7 @@ void gr_opengl_init_function_pointers() gr_screen.gf_print_screen = gr_opengl_print_screen; gr_screen.gf_blob_screen = gr_opengl_blob_screen; + gr_screen.gf_read_render_target = gr_opengl_read_render_target; gr_screen.gf_dump_envmap = gr_opengl_dump_envmap; gr_screen.gf_calculate_irrmap = gr_opengl_calculate_irrmap; @@ -1126,7 +1163,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_viewport_size_changed = 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; diff --git a/code/graphics/vulkan/VulkanBuffer.cpp b/code/graphics/vulkan/VulkanBuffer.cpp index 1fc4c1116e1..165184540de 100644 --- a/code/graphics/vulkan/VulkanBuffer.cpp +++ b/code/graphics/vulkan/VulkanBuffer.cpp @@ -148,42 +148,78 @@ size_t VulkanBufferManager::bumpAllocate(size_t size) size_t alignedOffset = (alloc.cursor + m_uboAlignment - 1) & ~(static_cast(m_uboAlignment) - 1); if (alignedOffset + size > alloc.capacity) { - growFrameAllocator(); - // After growth, cursor is 0 so alignedOffset is 0 - alignedOffset = 0; - Assertion(size <= alloc.capacity, "Frame allocator growth failed to provide enough capacity"); + // Growth preserves the cursor and copies the live contents across, so every offset handed + // out before the growth still addresses the same bytes -- alignedOffset stays valid. + growFrameAllocator(alignedOffset + size); + Assertion(alignedOffset + size <= alloc.capacity, + "Frame allocator growth failed to provide enough capacity"); } alloc.cursor = alignedOffset + size; return alignedOffset; } -void VulkanBufferManager::growFrameAllocator() +void VulkanBufferManager::growFrameAllocator(size_t requiredEnd) { auto& alloc = m_frameAllocs[m_currentFrame]; - // Double capacity until sufficient + // Double capacity until sufficient. requiredEnd covers the allocation that triggered the + // growth; the cursor is carried over, so the new buffer has to fit both. size_t newCapacity = alloc.capacity > 0 ? alloc.capacity * 2 : FRAME_ALLOC_INITIAL_SIZE; - // Ensure at least the current cursor position can fit (handles pathological single-alloc case) - while (newCapacity < alloc.cursor) { + while (newCapacity < requiredEnd) { newCapacity *= 2; } nprintf(("vulkan", "Growing frame allocator %u: %zuKB -> %zuKB\n", m_currentFrame, alloc.capacity / 1024, newCapacity / 1024)); - // Queue old buffer for deferred destruction - the deletion queue's FRAMES_TO_WAIT=2 - // ensures the old buffer survives through current frame's GPU execution. - // Existing handles with frameAllocBuffer pointing to the old buffer remain valid. + // Growth must not disturb allocations already handed out this frame. Callers keep a + // (frameAllocBuffer, frameAllocOffset) pair and go on writing through the *current* + // allocator's mapping while binding the buffer recorded at allocation time -- so if growth + // swapped the buffer out from under them, their writes would land in the new buffer while + // their draws kept reading the old one. Normally the next frame's index change forces a + // realloc before that can be observed, which is why it only shows up where the frame index + // stands still (qtFRED's briefing map renders outside flip()). + // + // So: carry the cursor over, copy the live bytes to the same offsets in the new buffer, and + // repoint every handle still referencing the old buffer. Offsets stay valid, contents stay + // intact, and writes and reads agree again. + const vk::Buffer oldBuffer = alloc.buffer; + VulkanAllocation oldAllocation = alloc.allocation; // unmapMemory takes a non-const reference + void* oldMapped = alloc.mappedPtr; + const size_t liveBytes = alloc.cursor; + + FrameBumpAllocator grown = {}; + Verification(createFrameAllocBuffer(grown, newCapacity), "Failed to grow Vulkan frame-allocator buffer"); + + if (liveBytes > 0 && oldMapped != nullptr && grown.mappedPtr != nullptr) { + memcpy(grown.mappedPtr, oldMapped, liveBytes); + m_memoryManager->flushMemory(grown.allocation, 0, liveBytes); + } + grown.cursor = liveBytes; + // Growth is not a rewind -- offsets survive it -- so the generation has to carry over too, or + // handles allocated before it would compare equal to a later generation and look fresh. + grown.generation = alloc.generation; + + // The old buffer stays alive through the deletion queue so draws already recorded against it + // keep reading intact data until it retires. auto* deletionQueue = getDeletionQueue(); - if (alloc.mappedPtr) { - m_memoryManager->unmapMemory(alloc.allocation); + if (oldMapped) { + m_memoryManager->unmapMemory(oldAllocation); + } + if (oldBuffer) { + deletionQueue->queueBuffer(oldBuffer, oldAllocation); } - deletionQueue->queueBuffer(alloc.buffer, alloc.allocation); - // Create new buffer - alloc = {}; - Verification(createFrameAllocBuffer(alloc, newCapacity), "Failed to grow Vulkan frame-allocator buffer"); + alloc = grown; + + if (oldBuffer) { + for (auto& bufferObj : m_buffers) { + if (bufferObj.valid && bufferObj.isStreaming() && bufferObj.frameAllocBuffer == oldBuffer) { + bufferObj.frameAllocBuffer = alloc.buffer; + } + } + } } // ========== Init / Shutdown ========== @@ -306,6 +342,7 @@ void VulkanBufferManager::setCurrentFrame(uint32_t frameIndex, uint64_t frameNum // Reset bump cursor — safe because the GPU fence for this frame-in-flight // was already waited on before setCurrentFrame is called. m_frameAllocs[m_currentFrame].cursor = 0; + ++m_frameAllocs[m_currentFrame].generation; } // ========== Buffer usage / memory helpers ========== @@ -539,17 +576,20 @@ void VulkanBufferManager::updateBufferData(gr_buffer_handle handle, size_t size, bufferObj.frameAllocOffset = offset; bufferObj.dataSize = size; bufferObj.frameAllocFrame = m_currentFrame; + bufferObj.frameAllocGeneration = alloc.generation; } else { // Pattern B: pre-alloc for offset writes (null data) - if (bufferObj.frameAllocFrame != m_currentFrame || size > bufferObj.dataSize) { - // First allocation this frame, or need more space + if (bufferObj.frameAllocFrame != m_currentFrame || + bufferObj.frameAllocGeneration != alloc.generation || size > bufferObj.dataSize) { + // First allocation this frame, the cursor was rewound under us, or need more space size_t offset = bumpAllocate(size); bufferObj.frameAllocBuffer = alloc.buffer; bufferObj.frameAllocOffset = offset; bufferObj.dataSize = size; bufferObj.frameAllocFrame = m_currentFrame; + bufferObj.frameAllocGeneration = alloc.generation; } - // Otherwise: same frame and size fits — keep current allocation + // Otherwise: same frame, same generation and size fits — keep current allocation } } else { // Static / PersistentMapping path. @@ -604,15 +644,16 @@ void VulkanBufferManager::updateBufferDataOffset(gr_buffer_handle handle, size_t // Auto-allocate if not yet allocated this frame. This happens when // the caller skips updateBufferData (e.g. gr_add_to_immediate_buffer // when the data fits the existing buffer size). - if (bufferObj.frameAllocFrame != m_currentFrame) { + auto& fa = m_frameAllocs[m_currentFrame]; + if (bufferObj.frameAllocFrame != m_currentFrame || bufferObj.frameAllocGeneration != fa.generation) { size_t allocSize = std::max(bufferObj.dataSize, offset + size); Assert(allocSize > 0); - auto& fa = m_frameAllocs[m_currentFrame]; size_t allocOffset = bumpAllocate(allocSize); bufferObj.frameAllocBuffer = fa.buffer; bufferObj.frameAllocOffset = allocOffset; bufferObj.dataSize = allocSize; bufferObj.frameAllocFrame = m_currentFrame; + bufferObj.frameAllocGeneration = fa.generation; } Assert(offset + size <= bufferObj.dataSize); diff --git a/code/graphics/vulkan/VulkanBuffer.h b/code/graphics/vulkan/VulkanBuffer.h index e21d899d0a3..822dfc0e182 100644 --- a/code/graphics/vulkan/VulkanBuffer.h +++ b/code/graphics/vulkan/VulkanBuffer.h @@ -23,6 +23,10 @@ struct FrameBumpAllocator { void* mappedPtr = nullptr; size_t capacity = 0; size_t cursor = 0; + // Bumped every time the cursor is rewound, so sub-allocations handed out before the rewind can + // be told apart from ones handed out after it. Growth deliberately does NOT bump this: it + // preserves offsets and repoints handles, so those allocations stay valid. + uint32_t generation = 0; }; /** @@ -64,6 +68,7 @@ struct VulkanBufferObject { vk::Buffer frameAllocBuffer; // VkBuffer at upload time (may be old allocator buffer after growth) size_t frameAllocOffset = 0; // Byte offset within the frame allocator buffer uint32_t frameAllocFrame = UINT32_MAX; // Frame index when last allocated + uint32_t frameAllocGeneration = UINT32_MAX; // Allocator generation when last allocated bool isStreaming() const { return usage == BufferUsageHint::Streaming || usage == BufferUsageHint::Dynamic; @@ -111,6 +116,14 @@ class VulkanBufferManager { * @param frameNumber The monotonic total frame number (for in-use tracking * of static buffers; see VulkanBufferObject::lastUsedFrameNumber) */ + /** + * @brief Point the manager at a frame-in-flight slot and rewind that slot's bump allocator. + * + * Passing the *current* index is legal and is what an off-screen frame end does: the cursor + * rewinds and the generation bumps, so sub-allocations from the frame just finished are + * invalidated rather than silently overlapped, while the swap-chain-tied index stays put. + * Only safe once the work referencing those sub-allocations has retired. + */ void setCurrentFrame(uint32_t frameIndex, uint64_t frameNumber); /** @@ -293,7 +306,7 @@ class VulkanBufferManager { void initFrameAllocators(); void shutdownFrameAllocators(); size_t bumpAllocate(size_t size); - void growFrameAllocator(); + void growFrameAllocator(size_t requiredEnd); std::array m_frameAllocs; uint32_t m_uboAlignment = 256; diff --git a/code/graphics/vulkan/VulkanDrawAPI.cpp b/code/graphics/vulkan/VulkanDrawAPI.cpp index f048919f09f..aa969f2693f 100644 --- a/code/graphics/vulkan/VulkanDrawAPI.cpp +++ b/code/graphics/vulkan/VulkanDrawAPI.cpp @@ -215,9 +215,13 @@ void vulkan_scene_texture_begin() auto* renderer = getRendererInstance(); - // Switch to HDR scene render pass when post-processing is enabled + // Switch to HDR scene render pass when post-processing is enabled. The post-processor's targets + // are sized for the main viewport's swap chain, so this stays off anywhere else -- qtFRED's + // briefing map renders through brief_render_map() and never opens a scene-texture scope, so + // nothing is lost by that today. auto* pp = getPostProcessor(); - if (pp && pp->isInitialized() && Gr_post_processing_enabled && !PostProcessing_override) { + if (pp && pp->isInitialized() && Gr_post_processing_enabled && !PostProcessing_override && + renderer->isMainTargetCurrent()) { renderer->beginSceneRendering(); High_dynamic_range = true; } else { diff --git a/code/graphics/vulkan/VulkanRenderFrame.cpp b/code/graphics/vulkan/VulkanRenderFrame.cpp index 73739d030df..fe2de612798 100644 --- a/code/graphics/vulkan/VulkanRenderFrame.cpp +++ b/code/graphics/vulkan/VulkanRenderFrame.cpp @@ -6,11 +6,8 @@ namespace graphics::vulkan { VulkanRenderFrame::VulkanRenderFrame(vk::Device device, vk::SwapchainKHR swapChain, vk::Queue graphicsQueue, vk::Queue presentQueue) : m_device(device), m_swapChain(swapChain), m_graphicsQueue(graphicsQueue), m_presentQueue(presentQueue) { - constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; constexpr vk::FenceCreateInfo fenceCreateInfo; - m_imageAvailableSemaphore = device.createSemaphoreUnique(semaphoreCreateInfo); - m_renderingFinishedSemaphore = device.createSemaphoreUnique(semaphoreCreateInfo); m_frameInFlightFence = device.createFenceUnique(fenceCreateInfo); } bool VulkanRenderFrame::waitForFinish(uint64_t timeoutNs) @@ -40,7 +37,7 @@ void VulkanRenderFrame::onFrameFinished(std::function finishFunc) { m_frameFinishedCallbacks.push_back(std::move(finishFunc)); } -SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex) +SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex, vk::Semaphore imageAvailable) { Assertion(!m_inFlight, "Cannot acquire swapchain image when frame is still in flight."); @@ -51,7 +48,7 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex try { res = m_device.acquireNextImageKHR(m_swapChain, std::numeric_limits::max(), - m_imageAvailableSemaphore.get(), + imageAvailable, nullptr, &imageIndex); } catch (const vk::OutOfDateKHRError&) { @@ -74,7 +71,6 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex return SwapChainStatus::eOutOfDate; } - m_swapChainIdx = imageIndex; outImageIndex = imageIndex; if (res == vk::Result::eSuboptimalKHR) { @@ -82,14 +78,19 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex } return SwapChainStatus::eSuccess; } -SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector& cmdBuffers) +SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector& cmdBuffers, + vk::Semaphore imageAvailable, vk::Semaphore renderFinished, uint32_t imageIndex, uint64_t frameNumber) { Assertion(!m_inFlight, "Cannot submit a frame for presentation when it is still in flight."); + // Record before the submit rather than after: from the moment the queue takes the work, the + // fence guards this frame number, and a sync point resolved in between must see that. + m_submittedFrameNumber = frameNumber; + // Wait at color attachment output stage — the first use of the swap chain image // is loadOp=eClear at the start of the render pass, which is a color attachment write. const std::array waitStages = {vk::PipelineStageFlagBits::eColorAttachmentOutput}; - const std::array waitSemaphores = {m_imageAvailableSemaphore.get()}; + const std::array waitSemaphores = {imageAvailable}; vk::SubmitInfo submitInfo; submitInfo.waitSemaphoreCount = 1; @@ -99,7 +100,7 @@ SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector(cmdBuffers.size()); submitInfo.pCommandBuffers = cmdBuffers.data(); - const std::array signalSemaphores = {m_renderingFinishedSemaphore.get()}; + const std::array signalSemaphores = {renderFinished}; submitInfo.signalSemaphoreCount = 1; submitInfo.pSignalSemaphores = signalSemaphores.data(); @@ -115,7 +116,7 @@ SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector swapChains = {m_swapChain}; presentInfo.swapchainCount = 1; presentInfo.pSwapchains = swapChains.data(); - presentInfo.pImageIndices = &m_swapChainIdx; + presentInfo.pImageIndices = &imageIndex; presentInfo.pResults = nullptr; vk::Result res; @@ -141,13 +142,5 @@ void VulkanRenderFrame::updateSwapChain(vk::SwapchainKHR swapChain) { m_swapChain = swapChain; } -void VulkanRenderFrame::recreateSyncObjects() -{ - Assertion(!m_inFlight, "Cannot recreate sync objects while the frame is in flight."); - - constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; - m_imageAvailableSemaphore = m_device.createSemaphoreUnique(semaphoreCreateInfo); - m_renderingFinishedSemaphore = m_device.createSemaphoreUnique(semaphoreCreateInfo); -} } // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanRenderFrame.h b/code/graphics/vulkan/VulkanRenderFrame.h index 8fe3e2e5009..fb9696ae03f 100644 --- a/code/graphics/vulkan/VulkanRenderFrame.h +++ b/code/graphics/vulkan/VulkanRenderFrame.h @@ -26,22 +26,52 @@ class VulkanRenderFrame { */ bool waitForFinish(uint64_t timeoutNs = std::numeric_limits::max()); - SwapChainStatus acquireSwapchainImage(uint32_t& outImageIndex); + /** + * @brief Acquire an image, signalling a semaphore the caller owns + * + * The semaphore belongs to the present target rather than to this frame: an acquire can outlive + * the frame slot that made it, because switching viewports mid-frame retains the acquired image + * and hands it back when that viewport becomes current again -- by which time the shared + * frame-in-flight cursor has moved on, and it cannot move back (the descriptor manager asserts + * that it only ever advances). + */ + SwapChainStatus acquireSwapchainImage(uint32_t& outImageIndex, vk::Semaphore imageAvailable); void onFrameFinished(std::function finishFunc); - SwapChainStatus submitAndPresent(const SCP_vector& cmdBuffers); - - void updateSwapChain(vk::SwapchainKHR swapChain); + /** + * @brief Submit this frame's work and present the acquired image + * + * Neither semaphore belongs to this frame. Both are owned by the present target: the acquire + * because it can outlive the frame slot that made it (see acquireSwapchainImage()), and the + * render-finished one because it is keyed on the swap chain image rather than on the frame + * slot -- the presentation engine keeps hold of it until that image is acquired again. + * + * @param imageAvailable the semaphore the acquire signalled + * @param renderFinished the semaphore for @p imageIndex; signalled by the submit, waited on by + * the present + * @param imageIndex the image that acquire returned + * @param frameNumber the monotonic frame number this submission covers; remembered so a + * sync point taken during that frame can tell whether this fence is + * still the one guarding its work (see getSubmittedFrameNumber()) + */ + SwapChainStatus submitAndPresent(const SCP_vector& cmdBuffers, + vk::Semaphore imageAvailable, vk::Semaphore renderFinished, uint32_t imageIndex, + uint64_t frameNumber); /** - * @brief Recreate the per-frame semaphores (frame must not be in flight) + * @brief The frame number of the submission this fence currently guards * - * Called during swap chain recreation: an acquire that succeeded against the - * old swap chain but was never consumed by a submit leaves the - * image-available semaphore signaled, which would corrupt the next acquire. + * NEVER_SUBMITTED until the first submit. A frame slot is reused every MAX_FRAMES_IN_FLIGHT + * frames, so the fence alone does not say *which* frame's work it covers -- this does, which is + * what lets VulkanRenderer::waitForSyncPoint() tell "still guarding the work I care about" from + * "already recycled by a later frame". */ - void recreateSyncObjects(); + uint64_t getSubmittedFrameNumber() const { return m_submittedFrameNumber; } + + static constexpr uint64_t NEVER_SUBMITTED = std::numeric_limits::max(); + + void updateSwapChain(vk::SwapchainKHR swapChain); private: vk::Device m_device; @@ -49,14 +79,12 @@ class VulkanRenderFrame { vk::Queue m_graphicsQueue; vk::Queue m_presentQueue; - vk::UniqueSemaphore m_imageAvailableSemaphore; - vk::UniqueSemaphore m_renderingFinishedSemaphore; vk::UniqueFence m_frameInFlightFence; SCP_vector> m_frameFinishedCallbacks; bool m_inFlight = false; - uint32_t m_swapChainIdx = 0; + uint64_t m_submittedFrameNumber = NEVER_SUBMITTED; }; } // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanRenderer.cpp b/code/graphics/vulkan/VulkanRenderer.cpp index a0e1f426051..b9e7fcc0237 100644 --- a/code/graphics/vulkan/VulkanRenderer.cpp +++ b/code/graphics/vulkan/VulkanRenderer.cpp @@ -24,33 +24,36 @@ extern float flFrametime; namespace graphics::vulkan { +// VulkanSurfaceHandle's implementation lives in VulkanRendererSetup.cpp, next to +// createTargetSurface()/createTargetResources() -- the rest of a target's surface/swap-chain +// lifecycle. VulkanRenderer::VulkanRenderer(std::unique_ptr graphicsOps) : m_graphicsOps(std::move(graphicsOps)) { } -void VulkanRenderer::createCompositionResources() +void VulkanRenderer::createCompositionResources(VulkanPresentTarget& target) { // Free any previous composition resources (swap chain recreation path) - m_compositionImageViews.clear(); - m_compositionImages.clear(); - for (auto& alloc : m_compositionAllocations) { + target.compositionImageViews.clear(); + target.compositionImages.clear(); + for (auto& alloc : target.compositionAllocations) { if (alloc.isValid()) { m_memoryManager->freeAllocation(alloc); } } - m_compositionAllocations.clear(); + target.compositionAllocations.clear(); - const size_t count = m_swapChainImageViews.size(); - m_compositionImages.reserve(count); - m_compositionImageViews.reserve(count); - m_compositionAllocations.reserve(count); + const size_t count = target.imageViews.size(); + target.compositionImages.reserve(count); + target.compositionImageViews.reserve(count); + target.compositionAllocations.reserve(count); for (size_t i = 0; i < count; ++i) { vk::ImageCreateInfo imageInfo; imageInfo.imageType = vk::ImageType::e2D; imageInfo.format = HDR_COLOR_FORMAT; - imageInfo.extent = vk::Extent3D(m_swapChainExtent.width, m_swapChainExtent.height, 1); + imageInfo.extent = vk::Extent3D(target.extent.width, target.extent.height, 1); imageInfo.mipLevels = 1; imageInfo.arrayLayers = 1; imageInfo.samples = vk::SampleCountFlagBits::e1; @@ -72,9 +75,9 @@ void VulkanRenderer::createCompositionResources() viewInfo.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1}; auto view = m_device->createImageViewUnique(viewInfo); - m_compositionImages.push_back(std::move(image)); - m_compositionAllocations.push_back(alloc); - m_compositionImageViews.push_back(std::move(view)); + target.compositionImages.push_back(std::move(image)); + target.compositionAllocations.push_back(alloc); + target.compositionImageViews.push_back(std::move(view)); } // Sampler used by the output-encode pass to read the composition image. @@ -90,12 +93,12 @@ void VulkanRenderer::createCompositionResources() } } -void VulkanRenderer::createEncodeRenderPass() +void VulkanRenderer::createEncodeRenderPass(vk::Format swapChainFormat) { // Color-only pass that writes the actual swap chain image. The fullscreen // encode draw overwrites the whole image, so the prior contents are discarded. vk::AttachmentDescription colorAttachment; - colorAttachment.format = m_swapChainImageFormat; + colorAttachment.format = swapChainFormat; colorAttachment.samples = vk::SampleCountFlagBits::e1; colorAttachment.loadOp = vk::AttachmentLoadOp::eDontCare; colorAttachment.storeOp = vk::AttachmentStoreOp::eStore; @@ -137,54 +140,54 @@ void VulkanRenderer::createEncodeRenderPass() m_encodeRenderPass = m_device->createRenderPassUnique(rpInfo); } -void VulkanRenderer::createFrameBuffers() +void VulkanRenderer::createFrameBuffers(VulkanPresentTarget& target) { - m_swapChainFramebuffers.clear(); - m_encodeFramebuffers.clear(); + target.framebuffers.clear(); + target.encodeFramebuffers.clear(); // Composition framebuffers: color = fp16 composition image, depth shared. // Indexed by swap chain image so each in-flight frame uses its own image. - m_swapChainFramebuffers.reserve(m_compositionImageViews.size()); - for (const auto& compView : m_compositionImageViews) { + target.framebuffers.reserve(target.compositionImageViews.size()); + for (const auto& compView : target.compositionImageViews) { const vk::ImageView attachments[] = { compView.get(), - m_depthImageView.get(), + target.depthImageView.get(), }; vk::FramebufferCreateInfo framebufferInfo; framebufferInfo.renderPass = m_renderPass.get(); framebufferInfo.attachmentCount = 2; framebufferInfo.pAttachments = attachments; - framebufferInfo.width = m_swapChainExtent.width; - framebufferInfo.height = m_swapChainExtent.height; + framebufferInfo.width = target.extent.width; + framebufferInfo.height = target.extent.height; framebufferInfo.layers = 1; - m_swapChainFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); + target.framebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); } // Encode framebuffers: color = actual swap chain image. - m_encodeFramebuffers.reserve(m_swapChainImageViews.size()); - for (const auto& scView : m_swapChainImageViews) { + target.encodeFramebuffers.reserve(target.imageViews.size()); + for (const auto& scView : target.imageViews) { const vk::ImageView attachments[] = { scView.get() }; vk::FramebufferCreateInfo framebufferInfo; framebufferInfo.renderPass = m_encodeRenderPass.get(); framebufferInfo.attachmentCount = 1; framebufferInfo.pAttachments = attachments; - framebufferInfo.width = m_swapChainExtent.width; - framebufferInfo.height = m_swapChainExtent.height; + framebufferInfo.width = target.extent.width; + framebufferInfo.height = target.extent.height; framebufferInfo.layers = 1; - m_encodeFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); + target.encodeFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo)); } } void VulkanRenderer::encodeToSwapChain() { - if (!m_postProcessor || m_currentSwapChainImage >= m_swapChainImages.size()) { + if (!m_postProcessor || m_current->currentImage >= m_current->images.size()) { return; } - if (m_currentSwapChainImage >= m_encodeFramebuffers.size()) { + if (m_current->currentImage >= m_current->encodeFramebuffers.size()) { return; } @@ -192,13 +195,13 @@ void VulkanRenderer::encodeToSwapChain() // HDR10: PQ/BT.2020 encode plus the user gamma slider, must run as a // shader (encodeOutput()). - if (m_hdrActive) { + if (m_current->hdrActive) { m_postProcessor->encodeOutput( m_currentCommandBuffer, m_encodeRenderPass.get(), - m_encodeFramebuffers[m_currentSwapChainImage].get(), - m_swapChainExtent, - m_compositionImageViews[m_currentSwapChainImage].get(), + m_current->encodeFramebuffers[m_current->currentImage].get(), + m_current->extent, + m_current->compositionImageViews[m_current->currentImage].get(), m_compositionSampler.get(), Gr_hdr_paperwhite_nits, Gr_hdr_peak_nits, @@ -214,9 +217,9 @@ void VulkanRenderer::encodeToSwapChain() m_postProcessor->encodeOutputSdr( m_currentCommandBuffer, m_encodeRenderPass.get(), - m_encodeFramebuffers[m_currentSwapChainImage].get(), - m_swapChainExtent, - m_compositionImageViews[m_currentSwapChainImage].get(), + m_current->encodeFramebuffers[m_current->currentImage].get(), + m_current->extent, + m_current->compositionImageViews[m_current->currentImage].get(), m_compositionSampler.get(), gamma); } @@ -240,7 +243,7 @@ vk::Format VulkanRenderer::findDepthFormat() Error(LOCATION, "Failed to find supported depth format!"); return vk::Format::eD32Sfloat; } -void VulkanRenderer::createDepthResources() +void VulkanRenderer::createDepthResources(VulkanPresentTarget& target) { const vk::Format depthFormat = findDepthFormat(); // The render passes (m_renderPass, scene/G-buffer passes, ...) bake in the @@ -257,8 +260,8 @@ void VulkanRenderer::createDepthResources() vk::ImageCreateInfo imageInfo; imageInfo.imageType = vk::ImageType::e2D; imageInfo.format = m_depthFormat; - imageInfo.extent.width = m_swapChainExtent.width; - imageInfo.extent.height = m_swapChainExtent.height; + imageInfo.extent.width = target.extent.width; + imageInfo.extent.height = target.extent.height; imageInfo.extent.depth = 1; imageInfo.mipLevels = 1; imageInfo.arrayLayers = 1; @@ -268,14 +271,14 @@ void VulkanRenderer::createDepthResources() imageInfo.sharingMode = vk::SharingMode::eExclusive; imageInfo.initialLayout = vk::ImageLayout::eUndefined; - m_depthImage = m_device->createImageUnique(imageInfo); + target.depthImage = m_device->createImageUnique(imageInfo); // Allocate GPU memory for the depth image - m_memoryManager->allocateImageMemory(m_depthImage.get(), MemoryUsage::GpuOnly, m_depthImageMemory); + m_memoryManager->allocateImageMemory(target.depthImage.get(), MemoryUsage::GpuOnly, target.depthImageMemory); // Create depth image view vk::ImageViewCreateInfo viewInfo; - viewInfo.image = m_depthImage.get(); + viewInfo.image = target.depthImage.get(); viewInfo.viewType = vk::ImageViewType::e2D; viewInfo.format = m_depthFormat; viewInfo.subresourceRange.aspectMask = imageAspectFromFormat(m_depthFormat); @@ -284,20 +287,15 @@ void VulkanRenderer::createDepthResources() viewInfo.subresourceRange.baseArrayLayer = 0; viewInfo.subresourceRange.layerCount = 1; - m_depthImageView = m_device->createImageViewUnique(viewInfo); + target.depthImageView = m_device->createImageViewUnique(viewInfo); nprintf(("vulkan", "Vulkan: Created depth buffer (%dx%d, format %d)\n", - m_swapChainExtent.width, m_swapChainExtent.height, static_cast(m_depthFormat))); -} -void VulkanRenderer::destroyDepthResources() -{ - m_depthImageView.reset(); - m_depthImage.reset(); - if (m_memoryManager && m_depthImageMemory.isValid()) { - m_memoryManager->freeAllocation(m_depthImageMemory); - m_depthImageMemory = {}; - } + target.extent.width, target.extent.height, static_cast(m_depthFormat))); } +// destroyDepthResources()/destroyTargetSwapChain()/releaseTargetMemory() are implemented in +// VulkanRendererSetup.cpp, next to createTargetResources() and createSwapChain() -- the create +// side of the same target lifecycle. + void VulkanRenderer::createRenderPass() { // Attachment 0: Color - clear each frame @@ -343,7 +341,7 @@ void VulkanRenderer::createRenderPass() // External dependency must make the PREVIOUS frame's accesses to these // attachments available/ordered before this frame writes them again. The - // depth buffer is a single shared image (one m_depthImage for every + // depth buffer is a single shared image (one m_current->depthImage for every // swap-chain framebuffer), so frame N+1's loadOp=eClear collides with frame // N's depth writes (WRITE_AFTER_WRITE) unless srcAccessMask lists the prior // depth write; likewise the composition color's store when a swap image @@ -386,7 +384,7 @@ void VulkanRenderer::createRenderPass() // Create a second render pass with loadOp=eLoad for resuming the composition // pass after post-processing. Render-pass compatibility (which is what lets a // pipeline built against m_renderPass bind under m_renderPassLoad, and lets - // both share m_swapChainFramebuffers) is defined ONLY by matching attachment + // both share m_current->framebuffers) is defined ONLY by matching attachment // formats/sample counts and subpass structure -- it deliberately ignores // load/store ops, layouts, AND subpass dependencies. So this variant reuses // the same renderPassInfo/dependency but only overrides the ops/layouts below. @@ -415,13 +413,38 @@ void VulkanRenderer::createCommandPool(const PhysicalDeviceValues& values) m_graphicsCommandPool = m_device->createCommandPoolUnique(poolCreate); } -void VulkanRenderer::createPresentSyncObjects() +void VulkanRenderer::createPresentSyncObjects(VulkanPresentTarget& target) { for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - m_frames[i] = std::make_unique(m_device.get(), m_swapChain.get(), m_graphicsQueue, m_presentQueue); + target.frames[i] = std::make_unique(m_device.get(), target.swapChain.get(), m_graphicsQueue, m_presentQueue); } - m_swapChainImageRenderImage.resize(m_swapChainImages.size(), nullptr); + target.imageRenderFrame.resize(target.images.size(), nullptr); + + // One more than the frames in flight: at any moment the in-flight frames can each be holding + // one, and a viewport switch can have retained one on top of that. + constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; + target.acquireSemaphores.clear(); + for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT + 1; ++i) { + VulkanPresentTarget::AcquireSemaphore entry; + entry.semaphore = m_device->createSemaphoreUnique(semaphoreCreateInfo); + target.acquireSemaphores.push_back(std::move(entry)); + } + target.nextAcquire = 0; + target.currentAcquire = 0; + target.hasRetainedAcquire = false; + + createRenderFinishedSemaphores(target); +} +void VulkanRenderer::createRenderFinishedSemaphores(VulkanPresentTarget& target) +{ + constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; + + target.renderFinishedSemaphores.clear(); + target.renderFinishedSemaphores.reserve(target.images.size()); + for (size_t i = 0; i < target.images.size(); ++i) { + target.renderFinishedSemaphores.push_back(m_device->createSemaphoreUnique(semaphoreCreateInfo)); + } } bool VulkanRenderer::readbackFramebuffer(ubyte** outPixels, uint32_t* outWidth, uint32_t* outHeight) @@ -430,7 +453,7 @@ bool VulkanRenderer::readbackFramebuffer(ubyte** outPixels, uint32_t* outWidth, *outWidth = 0; *outHeight = 0; - if (m_previousSwapChainImage == UINT32_MAX) { + if (m_current->previousImage == UINT32_MAX) { nprintf(("vulkan", "VulkanRenderer::readbackFramebuffer - no previous frame available\n")); return false; } @@ -449,13 +472,13 @@ bool VulkanRenderer::readbackFramebuffer(ubyte** outPixels, uint32_t* outWidth, // (1.0 == paper white) before the user gamma slider is applied -- the same // fidelity class as OpenGL's back-buffer read. Converted to BGRA8 on the // CPU below. - const vk::Image srcImage = m_compositionImages[m_previousSwapChainImage].get(); + const vk::Image srcImage = m_current->compositionImages[m_current->previousImage].get(); const vk::ImageLayout srcInitialLayout = vk::ImageLayout::eShaderReadOnlyOptimal; const vk::PipelineStageFlags2 srcStageMask = vk::PipelineStageFlagBits2::eFragmentShader; const vk::AccessFlags2 srcAccessMask = vk::AccessFlagBits2::eShaderSampledRead; const uint32_t bytesPerSrcPixel = 8; // fp16 RGBA = 4 x 2 bytes - uint32_t w = m_swapChainExtent.width; - uint32_t h = m_swapChainExtent.height; + uint32_t w = m_current->extent.width; + uint32_t h = m_current->extent.height; vk::DeviceSize bufferSize = static_cast(w) * h * bytesPerSrcPixel; // End the current render pass so we can record transfer commands @@ -753,8 +776,12 @@ bool VulkanRenderer::readbackRenderTarget(tcache_slot_vulkan* ts, ubyte** outPix // A fresh command buffer starts with no Vulkan state, so re-point the state tracker at // it and invalidate every cached binding/dynamic-state value. beginFrame() does exactly // this (and touches nothing else), so the next tracked draw re-binds pipeline, - // descriptors, viewport, scissor, etc. Deliberately NOT resetting the descriptor pool - // mid-frame (segment 2 keeps allocating past segment 1's sets). + // descriptors, viewport, scissor, etc. + // NOTE: the fence wait above leaves the device provably idle here, which makes this the only + // point in an off-screen capture where frame-scoped pools could be recycled. That is + // deliberately NOT done unconditionally -- this path is shared with gr.screenToBlob(), which + // mods call mid-frame with plenty of live allocations still to come. Callers that have + // genuinely finished a frame's worth of work opt in via gr_end_offscreen_frame() instead. m_stateTracker->beginFrame(m_currentCommandBuffer); // Resume drawing into the target (loadOp=eLoad) so content survives the flush. @@ -816,27 +843,59 @@ void VulkanRenderer::waitIdle() } } -bool VulkanRenderer::waitForFrame(uint64_t frameNumber, uint64_t timeoutNs) +void VulkanRenderer::endOffscreenFrame() { - // Fast path: if enough frames have elapsed, the work is definitely done -- - // the frame's fence was waited before its slot was reused (see - // acquireNextSwapChainImage). - if (m_frameNumber >= frameNumber + MAX_FRAMES_IN_FLIGHT) { - return true; + ++m_frameNumber; + + // Same call flip() makes, with the frame-in-flight index deliberately unchanged -- it rewinds + // the bump cursor and bumps its generation so sub-allocations from the frame just finished are + // invalidated rather than silently overlapped. + if (m_bufferManager) { + m_bufferManager->setCurrentFrame(m_currentFrame, m_frameNumber); } +} + +FrameSyncPoint VulkanRenderer::captureSyncPoint() const +{ + FrameSyncPoint point; + point.viewport = m_current != nullptr ? m_current->viewport : nullptr; + point.slot = m_currentFrame; + point.frameNumber = m_frameNumber; + return point; +} - // Not submitted yet: flip() advances m_frameNumber only after submission, - // so frameNumber >= m_frameNumber means the fence was taken during the - // frame currently being recorded. Its work cannot be complete, and blocking - // here would deadlock (submission happens on this thread). - if (frameNumber >= m_frameNumber) { +bool VulkanRenderer::waitForSyncPoint(const FrameSyncPoint& point, uint64_t timeoutNs) +{ + // Taken during the frame still being recorded: both flip() and endOffscreenFrame() advance + // m_frameNumber only once the frame is closed out, so this means nothing has been submitted for + // it yet. Its work cannot be complete, and blocking here would deadlock -- submission happens on + // this thread. + if (point.frameNumber >= m_frameNumber) { return false; } - // Remaining case: frameNumber < m_frameNumber < frameNumber + MAX_FRAMES_IN_FLIGHT, - // so the slot still belongs to exactly that frame -- wait on its fence. - auto frameIndex = static_cast(frameNumber % MAX_FRAMES_IN_FLIGHT); - return m_frames[frameIndex]->waitForFinish(timeoutNs); + // The target went away with its viewport. releaseViewport() drains every frame it owned before + // letting it go, so all of its work has retired. + const auto entry = m_targets.find(point.viewport); + if (entry == m_targets.end()) { + return true; + } + + const auto& frame = entry->second->frames[point.slot]; + + // The frame has been closed out (checked above) but this slot's fence is not the one guarding + // it. Three ways to get here, all of them meaning the work has retired: + // - the slot was recycled by a later frame, which waitForFrameSlot() only allows once this + // fence has been waited on; + // - the frame never went through a submit at all, which is the off-screen path -- and + // gr_end_offscreen_frame()'s precondition is that its GPU work had already completed; + // - the swap chain was rebuilt, which waits for every frame before replacing them. + // Waiting on the fence now would be waiting for whatever holds the slot today, not for this. + if (!frame || frame->getSubmittedFrameNumber() != point.frameNumber) { + return true; + } + + return frame->waitForFinish(timeoutNs); } VkCommandBuffer VulkanRenderer::getVkCurrentCommandBuffer() const @@ -846,9 +905,16 @@ VkCommandBuffer VulkanRenderer::getVkCurrentCommandBuffer() const void VulkanRenderer::shutdown() { - // Wait for all frames to complete to ensure no drawing is in progress when we destroy the device - for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - m_frames[i]->waitForFinish(); + // Wait for all frames to complete to ensure no drawing is in progress when we destroy the + // device. Every target has its own sync objects, and a target that has not been presented to + // for a while can still have work in flight, so all of them have to be drained -- not just the + // one that happens to be current. + for (auto& entry : m_targets) { + for (auto& frame : entry.second->frames) { + if (frame) { + frame->waitForFinish(); + } + } } // For good measure, also wait until the device is idle m_device->waitIdle(); @@ -924,20 +990,14 @@ void VulkanRenderer::shutdown() m_bufferManager.reset(); } - // Destroy depth resources before memory manager - destroyDepthResources(); - - // Destroy composition resources before memory manager - m_compositionImageViews.clear(); - m_compositionImages.clear(); - if (m_memoryManager) { - for (auto& alloc : m_compositionAllocations) { - if (alloc.isValid()) { - m_memoryManager->freeAllocation(alloc); - } - } + // Depth and composition images are backed by the memory manager, so every target has to give + // them up before it shuts down. The rest of a target (swap chain, views, framebuffers) is + // vk::Unique* and goes when m_targets does. + for (auto& entry : m_targets) { + releaseTargetMemory(*entry.second); + destroyTargetSwapChain(*entry.second); + entry.second->surface.reset(); } - m_compositionAllocations.clear(); // Deletion queue must be flushed before memory manager shutdown if (m_deletionQueue) { diff --git a/code/graphics/vulkan/VulkanRenderer.h b/code/graphics/vulkan/VulkanRenderer.h index 3d890b18a84..91b332b1e64 100644 --- a/code/graphics/vulkan/VulkanRenderer.h +++ b/code/graphics/vulkan/VulkanRenderer.h @@ -1,6 +1,7 @@ #pragma once #include "osapi/osapi.h" +#include "osapi/vulkan_surface.h" #include "VulkanMemory.h" #include "VulkanBuffer.h" @@ -26,6 +27,37 @@ struct QueueIndex { uint32_t index = 0; }; +/** + * @brief Owns the VkSurfaceKHR the windowing system handed us + * + * Not a vk::UniqueSurfaceKHR, because the surface is not ours to destroy with vkDestroySurfaceKHR: + * os::VulkanSurfaceProvider created it and only it knows how to get rid of it (a Qt-backed + * implementation, for instance, hands out a surface owned by QVulkanInstance). Destruction order is + * still the usual one -- declare this after the instance and before the swap chain, so the swap + * chain goes first, then the surface, then the instance. + */ +class VulkanSurfaceHandle { + public: + VulkanSurfaceHandle() = default; + VulkanSurfaceHandle(os::VulkanSurfaceProvider* provider, vk::Instance instance, vk::SurfaceKHR surface); + ~VulkanSurfaceHandle(); + + VulkanSurfaceHandle(const VulkanSurfaceHandle&) = delete; + VulkanSurfaceHandle& operator=(const VulkanSurfaceHandle&) = delete; + VulkanSurfaceHandle(VulkanSurfaceHandle&& other) noexcept; + VulkanSurfaceHandle& operator=(VulkanSurfaceHandle&& other) noexcept; + + vk::SurfaceKHR get() const { return m_surface; } + explicit operator bool() const { return static_cast(m_surface); } + + void reset(); + + private: + os::VulkanSurfaceProvider* m_provider = nullptr; + vk::Instance m_instance; + vk::SurfaceKHR m_surface; +}; + /** * @brief Viewport handling when beginning a tracked render pass */ @@ -83,6 +115,107 @@ struct PhysicalDeviceValues { QueueIndex presentQueueIndex; }; +/** + * @brief One presentable surface and everything sized to it + * + * The renderer used to hold exactly one of each of these, which is all the game ever needs. qtFRED + * presents through two independent windows -- its main viewport and the briefing map, which renders + * on its own timer -- and switches between them with gr_use_viewport(), so each needs its own + * surface, swap chain and extent-sized resources. + * + * What is *not* here is as deliberate as what is: the render passes, the post-processor and the + * frame-in-flight cursor stay on the renderer. See the comments on those members. + * + * The sync objects are per-target but indexed by the renderer's shared m_currentFrame, so a target + * that has not been drawn to for a while still has its slot waited on before reuse. + */ +struct VulkanPresentTarget { + os::Viewport* viewport = nullptr; + + VulkanSurfaceHandle surface; + + vk::UniqueSwapchainKHR swapChain; + vk::Format imageFormat = vk::Format::eUndefined; + vk::ColorSpaceKHR colorSpace = vk::ColorSpaceKHR::eSrgbNonlinear; + bool hdrActive = false; // True when an HDR10 (PQ/BT.2020) swap chain was negotiated + vk::Extent2D extent; + + SCP_vector images; + SCP_vector imageViews; + SCP_vector framebuffers; + SCP_vector imageRenderFrame; + + // HDR composition pipeline: the whole frame is rendered into these fp16 images (via + // m_renderPass / framebuffers) instead of directly into the swap chain image. + // encodeToSwapChain() converts composition -> swap chain: a direct blit (or + // encodeOutputPassthrough() as a fallback) for SDR, or encodeOutput() (m_encodeRenderPass + + // encodeFramebuffers) for the HDR10 PQ/BT.2020 transfer. + SCP_vector compositionImages; + SCP_vector compositionImageViews; + SCP_vector compositionAllocations; + SCP_vector encodeFramebuffers; + + // Depth buffer + vk::UniqueImage depthImage; + vk::UniqueImageView depthImageView; + VulkanAllocation depthImageMemory; + + std::array, MAX_FRAMES_IN_FLIGHT> frames; + + // Acquire semaphores live here rather than in the frames because an acquire can outlive the + // frame slot that made it -- see the retained acquire below. Each remembers the frame whose + // submit waits on it, so it is not handed out again until that frame has completed. + struct AcquireSemaphore { + vk::UniqueSemaphore semaphore; + VulkanRenderFrame* consumer = nullptr; + }; + SCP_vector acquireSemaphores; + uint32_t nextAcquire = 0; // round-robin cursor into acquireSemaphores + uint32_t currentAcquire = 0; // the one the in-progress frame will present with + + // One per swap chain image, indexed by image index -- not per frame-in-flight. A binary + // semaphore handed to vkQueuePresentKHR stays in use by the presentation engine until that + // image is acquired again, so the only safe moment to signal it once more is after an acquire + // has returned that same image. Keying it on the image is what makes that automatic; keying it + // on the frame slot (2 of them against 4 images) meant a submit could re-signal a semaphore the + // presentation engine still held (VUID-vkQueueSubmit-pSignalSemaphores-00067). That normally + // resolves itself once the image comes round again -- but a target that stops presenting (the + // main viewport, once qtFRED's briefing map is driving the render loop) never re-acquires it, + // and the pending question hangs the validation layer's state tracking for good. + SCP_vector renderFinishedSemaphores; + + // vkAcquireNextImageKHR hands out an image that only a present gives back, so a viewport switch + // cannot simply walk away from one: doing that leaks an image per switch and wedges the swap + // chain within a few frames. The acquire is kept here instead and reused when this target + // becomes current again. + bool hasRetainedAcquire = false; + uint32_t retainedAcquire = 0; + uint32_t retainedImage = 0; + + uint32_t currentImage = 0; + uint32_t previousImage = UINT32_MAX; // For saveScreen() readback of previous frame + + bool needsRecreation = false; +}; + +/** + * @brief Identifies the exact fence a gr_sync_fence() was taken against + * + * All three fields are needed to find it back. The frame number says which frame's work is meant, + * but it does not locate the fence: those live on the frame-in-flight slots of a *target*, and both + * of the other two can have moved on by the time the wait happens -- a viewport switch changes the + * target, and the slot cycles every MAX_FRAMES_IN_FLIGHT frames. + */ +struct FrameSyncPoint { + // The target's viewport rather than the target itself, deliberately: a target is destroyed when + // its viewport closes, and a sync point can outlive it. Looking the viewport up in m_targets + // answers "is that target still around?" instead of dereferencing a dangling pointer. + os::Viewport* viewport = nullptr; + + uint32_t slot = 0; // index into VulkanPresentTarget::frames + uint64_t frameNumber = 0; // VulkanRenderer::m_frameNumber at the time +}; + class VulkanRenderer { public: explicit VulkanRenderer(std::unique_ptr graphicsOps); @@ -95,6 +228,73 @@ class VulkanRenderer { */ void setupFrame(); + /** + * @brief Rebuild the swap chain if the window no longer matches it, restarting the frame + * + * The swap chain's extent is sampled at the end of the previous flip(), but gr_screen is set at + * the start of the current frame's drawing, so anything that resizes the window between those + * two points leaves the frame rendering into a swap chain of the wrong size: gr_setup_viewport() + * sets the viewport from gr_screen while the render pass area comes from the swap chain, and the + * difference shows up as a clipped image with unpainted bars around it. That gap is invisible in + * the game, where a resize is a window event outside rendering, but qtFRED calls + * gr_screen_resize() every single frame from its (freely resizable) viewport widget. + * + * Hooked up as gf_viewport_size_changed so gr_screen_resize() calls it, which is the moment both + * sizes can be sampled together. If the surface really has changed size, the in-progress frame + * is discarded (nothing has been drawn into it yet at that point), the swap chain and every + * extent-sized resource is rebuilt, and a fresh frame is started at the new size. + * + * The comparison is surface-against-swap-chain rather than gr_screen-against-swap-chain, on + * purpose: both of those come from the surface, so they agree exactly and this cannot thrash. + * gr_screen is computed independently by the caller and can be a pixel off from rounding. + * + * @return true if the swap chain was rebuilt + */ + bool syncToSurfaceExtent(); + + /** + * @brief Make a viewport's surface the one subsequent drawing presents to + * + * Backs gr_use_viewport(). Creating the target on first use is deliberate: qtFRED's briefing map + * appears and disappears with its dialog, so there is no point in the session at which the full + * set of viewports is known. + * + * A frame is always already open when this is called -- gr_flip() ends with gr_setup_frame() -- + * and it belongs to the outgoing target, which has also already acquired an image there. That + * frame is discarded rather than presented; see discardFrame(). + * + * @return false if the viewport cannot be presented to, in which case the current target is left + * alone and the caller keeps drawing where it was + */ + bool useViewport(os::Viewport* viewport); + + /** + * @brief Drop the target belonging to a viewport that is going away + * + * Must be called while the renderer is still alive and before the viewport's window is + * destroyed. qtFRED's briefing editor is opened with WA_DeleteOnClose, so this happens against a + * live device every time the user closes the dialog. + */ + void releaseViewport(os::Viewport* viewport); + + /** + * @brief Whether the current target is the main one + * + * The post-processor is sized for and bound to the main target, so the scene-texture path stays + * off anywhere else. Nothing is lost by that today: qtFRED's briefing map renders through + * brief_render_map() and never opens a ScenePostProcessing scope. + */ + bool isMainTargetCurrent() const { return m_current == m_mainTarget; } + + /** + * @brief The extent the current target actually presents at, in device pixels + * + * This is what the render pass area and the framebuffers are sized to, so it is what gr_screen + * has to agree with. Callers must not compute it themselves from a window's logical size and a + * scale factor -- that rounds differently from the way the surface was sized. + */ + vk::Extent2D getCurrentTargetExtent() const { return m_current != nullptr ? m_current->extent : vk::Extent2D(); } + /** * @brief End frame - ends render pass, submits, and presents * Called at the END of each frame after all draw calls @@ -145,22 +345,41 @@ class VulkanRenderer { uint32_t getMinUniformBufferOffsetAlignment() const; /** - * @brief Get the current frame number (total frames rendered) + * @brief Close out a frame's worth of work that completed without going through flip(). + * + * Advances the monotonic frame counter that sync objects are stamped with, and rewinds the + * frame-scoped bump allocator. Deliberately does NOT touch m_currentFrame: that index selects + * the swap-chain sync objects, and the image for this index has already been acquired for the + * flip that will eventually present. + * + * Without this, an off-screen renderer leaves m_frameNumber frozen, and waitForSyncPoint() + * reports every fence taken since as "still recording" -- which is what makes + * UniformBufferManager's segment rotation give up and Error() out. + * + * Only valid once the work in question has actually retired; see gr_end_offscreen_frame(). */ - uint64_t getCurrentFrameNumber() const { return m_frameNumber; } + void endOffscreenFrame(); /** - * @brief Wait for a specific frame's GPU work to complete + * @brief Stamp the frame currently being recorded, so it can be waited on later * - * Waits on that frame's fence rather than stalling the entire device. + * Backs gr_sync_fence(). Records *which* fence, not just when: the frame number alone cannot + * find it back, because the fences are per-target while the frame number is global, and because + * endOffscreenFrame() advances the frame number without moving the frame-in-flight cursor. + */ + FrameSyncPoint captureSyncPoint() const; + + /** + * @brief Wait for the GPU work recorded during the frame @p point was taken in + * + * Waits on that frame's own fence rather than stalling the entire device. * * @param timeoutNs Maximum wait in nanoseconds (0 = poll) - * @return true if the frame is known complete. false if the timeout expired, - * or if the frame has not been submitted yet (a fence taken during - * the currently-recording frame cannot complete until flip(); - * waiting here would deadlock, so it reports "not complete"). + * @return true if the work is known complete. false if the timeout expired, or if the sync + * point was taken during the frame still being recorded (submission happens on this + * thread, so waiting here would deadlock -- it reports "not complete" instead). */ - bool waitForFrame(uint64_t frameNumber, uint64_t timeoutNs = UINT64_MAX); + bool waitForSyncPoint(const FrameSyncPoint& point, uint64_t timeoutNs = UINT64_MAX); /** * @brief Wait for all GPU work to complete @@ -340,35 +559,123 @@ class VulkanRenderer { bool initializeInstance(); - bool initializeSurface(); + /** + * @brief Ask the windowing system for @p target's viewport surface and take ownership of it + */ + bool createTargetSurface(VulkanPresentTarget& target); bool pickPhysicalDevice(PhysicalDeviceValues& deviceValues); bool createLogicalDevice(const PhysicalDeviceValues& deviceValues); - bool createSwapChain(const PhysicalDeviceValues& deviceValues, vk::SwapchainKHR oldSwapchain = nullptr); + // Everything a target owns is built by one of these. They take the target explicitly rather than + // working on m_current: the setup path builds targets that are not current yet (and, when + // creation fails part-way, never become current), so "the current target" is the wrong answer + // there -- and an implicit one is impossible to check at the call site. + bool createSwapChain(VulkanPresentTarget& target, + const PhysicalDeviceValues& deviceValues, + vk::SwapchainKHR oldSwapchain = nullptr); void createRenderPass(); - void createFrameBuffers(); + void createFrameBuffers(VulkanPresentTarget& target); // HDR composition + output-encode resources - void createCompositionResources(); - void createEncodeRenderPass(); + void createCompositionResources(VulkanPresentTarget& target); + + /** + * @brief Build the shared output-encode render pass for @p swapChainFormat + * + * Takes the format rather than a target because, unlike its neighbours here, it does not build + * into one: m_encodeRenderPass is shared by every target. That is also the constraint + * createTargetResources() has to check -- a target whose surface negotiates a different format + * cannot use this pass. + */ + void createEncodeRenderPass(vk::Format swapChainFormat); void encodeToSwapChain(); - void createDepthResources(); - void destroyDepthResources(); + void createDepthResources(VulkanPresentTarget& target); + void destroyDepthResources(VulkanPresentTarget& target); + + /** + * @brief Give back everything in a target that the memory manager owns + * + * The depth and composition images are the only parts of a target backed by VulkanMemoryManager + * allocations, so they have to be released before it shuts down; everything else is vk::Unique* + * and can wait for the target's own destructor. + */ + void releaseTargetMemory(VulkanPresentTarget& target); + + /** + * @brief Tear down everything a target derived from its surface, in the order Vulkan requires + * + * A VkSurfaceKHR must outlive every swap chain made from it. Relying on member-declaration order + * to get that right is too subtle to be safe here, because the surface is not destroyed by us at + * all: it belongs to the windowing system, and under Qt it goes when the window does. So the + * swap chain and everything holding its images are released explicitly first. + */ + static void destroyTargetSwapChain(VulkanPresentTarget& target); vk::Format findDepthFormat(); void createCommandPool(const PhysicalDeviceValues& values); - void createPresentSyncObjects(); + void createPresentSyncObjects(VulkanPresentTarget& target); + + /** + * @brief Build a target's per-image render-finished semaphores + * + * Sized to the swap chain's image count and indexed by image index, so it has to be rebuilt + * whenever the swap chain is, alongside the images themselves. + */ + void createRenderFinishedSemaphores(VulkanPresentTarget& target); + + /** + * @brief Build a target's surface, swap chain and everything sized to it + * + * Leaves @p target untouched by the renderer's notion of "current": it is only safe to present + * to once this has returned true, and useViewport() switches to it then. + * + * @return false if the surface could not be created, or if it negotiated a format the shared + * encode render pass was not built for + */ + bool createTargetResources(VulkanPresentTarget& target); void acquireNextSwapChainImage(); - bool recreateSwapChain(); + /** + * @brief Wait until every target has finished the work it put in a frame-in-flight slot + * + * The slot indexes per-target sync objects but also the shared command pool and descriptor + * pools, so it cannot be recycled until all targets are done with it. + */ + void waitForFrameSlot(uint32_t slot); + + /** + * @brief Wait for a frame, naming it in the log if the wait is not a normal one + * + * Every wait on a fence in the present path goes through here. A legitimate wait is one frame + * time; anything beyond a fraction of a second means the queue is wedged, and blocking forever + * on it just produces an editor that stops responding and gets killed before any long timeout + * could say which wait it was. + * + * @param what description of the wait, for the log -- caller-built so the message names the + * target and slot involved + */ + static void waitOrReport(VulkanRenderFrame& frame, const char* what); + + /** + * @brief Throw away the in-progress frame without submitting or presenting it + * + * Ends the open render pass and command buffer and frees it -- safe to free immediately, since + * nothing was ever submitted and so no GPU work can reference it. The swap chain image this + * frame acquired is left unconsumed, which leaves its image-available semaphore signaled; the + * caller must therefore recreate the swap chain (which recreates every frame's sync objects) + * before the next acquire. + */ + void discardFrame(); + + bool recreateSwapChain(VulkanPresentTarget& target); void createImGuiDescriptorPool(); void initImGui(); @@ -380,53 +687,48 @@ class VulkanRenderer { vk::UniqueDebugReportCallbackEXT m_debugReport; // legacy fallback (no VK_EXT_debug_utils) vk::UniqueDebugUtilsMessengerEXT m_debugMessenger; // preferred debug callback - vk::UniqueSurfaceKHR m_vkSurface; - vk::UniqueDevice m_device; vk::Queue m_graphicsQueue; vk::Queue m_presentQueue; - vk::UniqueSwapchainKHR m_swapChain; - vk::Format m_swapChainImageFormat; - vk::ColorSpaceKHR m_swapChainColorSpace = vk::ColorSpaceKHR::eSrgbNonlinear; - bool m_hdrActive = false; // True when an HDR10 (PQ/BT.2020) swap chain was negotiated + // Everything downstream of a surface lives in the target it belongs to. There is exactly one in + // the game; qtFRED presents through two (its main viewport and the briefing map). + // + // m_current means only "where drawing goes right now" -- the frame loop reads it, the setup path + // does not. Everything that builds or tears down a target takes it as a parameter, so a target + // can be built before it is ever current and abandoned if that fails. + // + // Keyed by os::Viewport* rather than by index into os::viewports, deliberately: qtFRED's + // briefing map hands its viewport straight to gr_use_viewport() and never registers it with + // os::addViewport(), so that list does not contain every viewport we present to. + SCP_unordered_map> m_targets; + VulkanPresentTarget* m_mainTarget = nullptr; + VulkanPresentTarget* m_current = nullptr; + bool m_hdrMetadataSupported = false; // VK_EXT_hdr_metadata device extension enabled - vk::Extent2D m_swapChainExtent; - SCP_vector m_swapChainImages; - SCP_vector m_swapChainImageViews; - SCP_vector m_swapChainFramebuffers; - SCP_vector m_swapChainImageRenderImage; - - // HDR composition pipeline: the whole frame is rendered into these fp16 - // images (via m_renderPass / m_swapChainFramebuffers) instead of directly - // into the swap chain image. encodeToSwapChain() converts composition -> - // swap chain: a direct blit (or encodeOutputPassthrough() as a fallback) - // for SDR, or encodeOutput() (m_encodeRenderPass + m_encodeFramebuffers) - // for the HDR10 PQ/BT.2020 transfer. - SCP_vector m_compositionImages; - SCP_vector m_compositionImageViews; - SCP_vector m_compositionAllocations; + + // Shared by every target, and deliberately so: the render passes bake in the composition (fp16) + // and depth formats, which are the same everywhere, so keeping one set keeps every cached + // pipeline valid across a target switch. m_encodeRenderPass is the exception -- it bakes in the + // *swap chain* format, so a target whose surface negotiates a different one cannot use it; see + // createTargetResources(). vk::UniqueSampler m_compositionSampler; - SCP_vector m_encodeFramebuffers; vk::UniqueRenderPass m_encodeRenderPass; - uint32_t m_currentSwapChainImage = 0; - uint32_t m_previousSwapChainImage = UINT32_MAX; // For saveScreen() readback of previous frame - - // Depth buffer - vk::UniqueImage m_depthImage; - vk::UniqueImageView m_depthImageView; - VulkanAllocation m_depthImageMemory; vk::Format m_depthFormat = vk::Format::eUndefined; vk::UniqueRenderPass m_renderPass; // Swap chain pass with loadOp=eClear vk::UniqueRenderPass m_renderPassLoad; // Swap chain pass with loadOp=eLoad (resumed after post-processing) vk::UniqueDescriptorPool m_imguiDescriptorPool; + bool m_imguiInitialized = false; // false in the editors, which have no ImGui context at all + // The frame-in-flight cursor stays global rather than moving into the target: the buffer and + // descriptor managers keep one ring keyed off it (setCurrentFrame() below), so a per-target + // cursor would hand them conflicting indices and corrupt descriptors a few frames later. Each + // target instead keeps its own sync objects and indexes them with this shared cursor. uint32_t m_currentFrame = 0; uint64_t m_frameNumber = 0; // Total frames rendered (for sync tracking) - std::array, MAX_FRAMES_IN_FLIGHT> m_frames; vk::UniqueCommandPool m_graphicsCommandPool; @@ -435,9 +737,6 @@ class VulkanRenderer { SCP_vector m_currentCommandBuffers; // For cleanup bool m_frameInProgress = false; - // Swap chain recreation - bool m_swapChainNeedsRecreation = false; - // Physical device info (needed for memory manager) vk::PhysicalDevice m_physicalDevice; // Cached once at device selection: the limit/feature getters below are diff --git a/code/graphics/vulkan/VulkanRendererImGui.cpp b/code/graphics/vulkan/VulkanRendererImGui.cpp index 53271af071b..b6fccb145f4 100644 --- a/code/graphics/vulkan/VulkanRendererImGui.cpp +++ b/code/graphics/vulkan/VulkanRendererImGui.cpp @@ -25,6 +25,14 @@ void VulkanRenderer::createImGuiDescriptorPool() void VulkanRenderer::initImGui() { + // Only freespace2 creates an ImGui context (game_init()); the editors never do, and they don't + // open the debug window that would draw through it. Without that context ImGui_ImplVulkan_Init() + // asserts inside ImGui::GetIO(), so there is nothing to set up here. + if (ImGui::GetCurrentContext() == nullptr) { + nprintf(("vulkan", "Vulkan: no ImGui context exists, skipping the ImGui backend\n")); + return; + } + createImGuiDescriptorPool(); // Load Vulkan function pointers for imgui (required with VK_NO_PROTOTYPES) @@ -43,7 +51,7 @@ void VulkanRenderer::initImGui() initInfo.PipelineCache = VK_NULL_HANDLE; initInfo.DescriptorPool = static_cast(*m_imguiDescriptorPool); initInfo.MinImageCount = 2; - initInfo.ImageCount = static_cast(m_swapChainImages.size()); + initInfo.ImageCount = static_cast(m_mainTarget->images.size()); initInfo.Allocator = nullptr; initInfo.CheckVkResultFn = nullptr; initInfo.PipelineInfoMain.Subpass = 0; @@ -51,13 +59,19 @@ void VulkanRenderer::initImGui() initInfo.PipelineInfoMain.RenderPass = static_cast(*m_renderPass); ImGui_ImplVulkan_Init(&initInfo); + m_imguiInitialized = true; nprintf(("vulkan", "Vulkan: ImGui backend initialized successfully\n")); } void VulkanRenderer::shutdownImGui() { + if (!m_imguiInitialized) { + return; + } + ImGui_ImplVulkan_Shutdown(); + m_imguiInitialized = false; m_imguiDescriptorPool.reset(); nprintf(("vulkan", "Vulkan: ImGui backend shut down\n")); } diff --git a/code/graphics/vulkan/VulkanRendererLoop.cpp b/code/graphics/vulkan/VulkanRendererLoop.cpp index 601dad04485..e907e5e2ef2 100644 --- a/code/graphics/vulkan/VulkanRendererLoop.cpp +++ b/code/graphics/vulkan/VulkanRendererLoop.cpp @@ -48,13 +48,67 @@ void VulkanRenderer::beginTrackedRenderPass(const PassBeginDesc& desc) } } +void VulkanRenderer::waitForFrameSlot(uint32_t slot) +{ + // Every target has its own sync objects, but the command pool and the descriptor manager's + // pools are shared and keyed on this slot alone. So recycling the slot is only safe once the + // work *every* target put in it has completed -- waiting on the target that happens to be + // current is not enough. Getting this wrong let a viewport switch reset a descriptor pool and + // free command buffers that the other target's still-pending submit was using + // (VUID-vkResetDescriptorPool-descriptorPool-00313, + // VUID-vkFreeCommandBuffers-pCommandBuffers-00047), which wedged the queue. + for (auto& entry : m_targets) { + auto& frame = entry.second->frames[slot]; + if (!frame) { + continue; + } + + SCP_string what; + sprintf(what, "frame slot %u of the %s target (slot wait)", slot, + entry.second.get() == m_mainTarget ? "main" : "secondary"); + waitOrReport(*frame, what.c_str()); + } +} + +void VulkanRenderer::waitOrReport(VulkanRenderFrame& frame, const char* what) +{ + constexpr uint64_t PROBE_NS = 200000000ULL; // 0.2s -- far longer than any real frame + constexpr uint64_t GIVE_UP_NS = 2000000000ULL; // 2s more before calling it wedged + + if (frame.waitForFinish(PROBE_NS)) { + return; + } + + mprintf(("Vulkan: still waiting on %s after 0.2s; the queue looks wedged.\n", what)); + + if (!frame.waitForFinish(GIVE_UP_NS)) { + Error(LOCATION, "Vulkan: %s never completed. This is a renderer synchronisation bug, not bad data.", + what); + } +} + void VulkanRenderer::acquireNextSwapChainImage() { - m_frames[m_currentFrame]->waitForFinish(); + waitForFrameSlot(m_currentFrame); // Acquire an image, recreating the swap chain as often as needed (bounded). // The frame must never proceed without an acquired image: its image-available // semaphore would never be signaled and the submit would deadlock/corrupt. + // A viewport switch retains this target's acquired image rather than abandoning it, since only a + // present hands one back. Reuse it instead of acquiring a second one. + if (m_current->hasRetainedAcquire && !m_current->needsRecreation) { + m_current->currentAcquire = m_current->retainedAcquire; + m_current->currentImage = m_current->retainedImage; + m_current->hasRetainedAcquire = false; + + if (m_current->imageRenderFrame[m_current->currentImage]) { + waitOrReport(*m_current->imageRenderFrame[m_current->currentImage], + "the frame still rendering into a retained image"); + } + m_current->imageRenderFrame[m_current->currentImage] = m_current->frames[m_currentFrame].get(); + return; + } + constexpr int MAX_ACQUIRE_ATTEMPTS = 5; uint32_t imageIndex = 0; SwapChainStatus status = SwapChainStatus::eOutOfDate; @@ -62,18 +116,30 @@ void VulkanRenderer::acquireNextSwapChainImage() for (int attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; ++attempt) { // Recreate if flagged (from a previous frame, a failed acquire below, or // a suboptimal present). Waits out a minimized window (0x0 extent). - if (m_swapChainNeedsRecreation) { - while (!recreateSwapChain()) { + if (m_current->needsRecreation) { + while (!recreateSwapChain(*m_current)) { os_sleep(100); SDL_PumpEvents(); } } - status = m_frames[m_currentFrame]->acquireSwapchainImage(imageIndex); + // Take the next acquire semaphore round-robin, making sure whatever last presented with it + // has finished before it is signalled again. + auto& acquire = m_current->acquireSemaphores[m_current->nextAcquire]; + if (acquire.consumer != nullptr) { + waitOrReport(*acquire.consumer, "the frame that last presented with this acquire semaphore"); + acquire.consumer = nullptr; + } + m_current->currentAcquire = m_current->nextAcquire; + m_current->nextAcquire = (m_current->nextAcquire + 1) % + static_cast(m_current->acquireSemaphores.size()); + + status = m_current->frames[m_currentFrame]->acquireSwapchainImage(imageIndex, + acquire.semaphore.get()); if (status != SwapChainStatus::eOutOfDate) { break; } - m_swapChainNeedsRecreation = true; + m_current->needsRecreation = true; } if (status == SwapChainStatus::eOutOfDate) { @@ -82,17 +148,18 @@ void VulkanRenderer::acquireNextSwapChainImage() } if (status == SwapChainStatus::eSuboptimal) { - m_swapChainNeedsRecreation = true; + m_current->needsRecreation = true; } - m_currentSwapChainImage = imageIndex; + m_current->currentImage = imageIndex; // Ensure that this image is no longer in use - if (m_swapChainImageRenderImage[m_currentSwapChainImage]) { - m_swapChainImageRenderImage[m_currentSwapChainImage]->waitForFinish(); + if (m_current->imageRenderFrame[m_current->currentImage]) { + waitOrReport(*m_current->imageRenderFrame[m_current->currentImage], + "the frame still rendering into the newly acquired image"); } // Reserve the image as in use - m_swapChainImageRenderImage[m_currentSwapChainImage] = m_frames[m_currentFrame].get(); + m_current->imageRenderFrame[m_current->currentImage] = m_current->frames[m_currentFrame].get(); } void VulkanRenderer::setupFrame() { @@ -101,10 +168,6 @@ void VulkanRenderer::setupFrame() return; } - // Free completed texture upload command buffers - Assertion(m_textureManager, "Vulkan TextureManager not initialized in setupFrame!"); - m_textureManager->frameStart(); - // Allocate command buffer for this frame vk::CommandBufferAllocateInfo cmdBufferAlloc; cmdBufferAlloc.commandPool = m_graphicsCommandPool.get(); @@ -151,14 +214,184 @@ void VulkanRenderer::setupFrame() PassBeginDesc pass; pass.renderPass = m_renderPass.get(); - pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get(); - pass.extent = m_swapChainExtent; + pass.framebuffer = m_current->framebuffers[m_current->currentImage].get(); + pass.extent = m_current->extent; pass.clearValues = clearValues; beginTrackedRenderPass(pass); m_frameInProgress = true; } +void VulkanRenderer::discardFrame() +{ + if (!m_frameInProgress) { + return; + } + + // Whatever pass is open (composition, or a post-processing/shadow pass if this is ever called + // from somewhere less tidy) has to be closed before the command buffer can be ended. + if (m_stateTracker->getCurrentRenderPass()) { + m_currentCommandBuffer.endRenderPass(); + m_stateTracker->setRenderPass(vk::RenderPass()); + } + + m_currentCommandBuffer.end(); + m_device->freeCommandBuffers(m_graphicsCommandPool.get(), m_currentCommandBuffers); + + m_currentCommandBuffer = nullptr; + m_currentCommandBuffers.clear(); + m_frameInProgress = false; + + // This frame never submitted, so it does not own the image any more. + if (m_current->currentImage < m_current->imageRenderFrame.size()) { + m_current->imageRenderFrame[m_current->currentImage] = nullptr; + } + + // Keep the acquire rather than dropping it. vkAcquireNextImageKHR hands out an image that only + // vkQueuePresentKHR gives back, so abandoning one here would leak an image on this target every + // time a viewport switch passed through -- and with a handful of images per swap chain, that + // wedges the next acquire within a few frames. The semaphore it signalled is owned by the + // target, so it survives the frame slot moving on. + m_current->hasRetainedAcquire = true; + m_current->retainedAcquire = m_current->currentAcquire; + m_current->retainedImage = m_current->currentImage; +} + +bool VulkanRenderer::useViewport(os::Viewport* viewport) +{ + if (viewport == nullptr) { + return false; + } + + if (m_current != nullptr && m_current->viewport == viewport) { + return true; + } + + // Resolve the target -- building it on first use -- before disturbing anything. Creating one + // touches only its own resources, so a failure here leaves the renderer exactly as it was: the + // frame in progress is still the outgoing target's, and the caller keeps drawing where it was. + auto entry = m_targets.find(viewport); + if (entry == m_targets.end()) { + auto created = std::make_unique(); + created->viewport = viewport; + + if (!createTargetResources(*created)) { + mprintf(("Vulkan: could not present to this viewport; staying on the previous one.\n")); + releaseTargetMemory(*created); + return false; + } + + entry = m_targets.emplace(viewport, std::move(created)).first; + } + + // A frame is always open here: gr_flip() ends with gr_setup_frame(), so the outgoing target has + // both an open command buffer and an already-acquired image. Neither survives the switch. + const bool restartFrame = m_frameInProgress; + discardFrame(); + + m_current = entry->second.get(); + + if (restartFrame) { + acquireNextSwapChainImage(); + setupFrame(); + } + + return true; +} + +void VulkanRenderer::releaseViewport(os::Viewport* viewport) +{ + auto entry = m_targets.find(viewport); + if (entry == m_targets.end()) { + return; + } + + if (entry->second.get() == m_mainTarget) { + // The main target lives as long as the renderer does; letting it go here would leave + // nothing to fall back to. + return; + } + + // Anything still reading this target's images has to be done before they are destroyed. The + // frames are per-target, so this waits only on that target's work -- but the surface is about to + // go with the window, so be blunt about it and drain the device too. + for (auto& frame : entry->second->frames) { + if (frame) { + frame->waitForFinish(); + } + } + m_device->waitIdle(); + + if (m_current == entry->second.get()) { + // Whatever was set up against this target cannot be presented now. + discardFrame(); + m_current = m_mainTarget; + acquireNextSwapChainImage(); + setupFrame(); + } + + // Order matters and is not left to member destruction: the swap chain has to go before the + // surface, and the surface goes as soon as the handle releases it back to the window system. + releaseTargetMemory(*entry->second); + destroyTargetSwapChain(*entry->second); + entry->second->surface.reset(); + + m_targets.erase(entry); +} + +bool VulkanRenderer::syncToSurfaceExtent() +{ + if (!m_current->surface || !m_current->swapChain) { + return false; + } + + const auto capabilities = m_physicalDevice.getSurfaceCapabilitiesKHR(m_current->surface.get()); + + // 0xFFFFFFFF means "the surface takes its size from the swap chain", so there is nothing to + // follow. A 0x0 extent is a minimized window; leave the swap chain alone and let the regular + // acquire path wait it out rather than recreating into an unusable size here. + if (capabilities.currentExtent.width == UINT32_MAX || + capabilities.currentExtent.width == 0 || capabilities.currentExtent.height == 0) { + return false; + } + + if (capabilities.currentExtent == m_current->extent) { + return false; + } + + // Rebuilding means discarding the frame in progress, which is only free while nothing has been + // drawn into it -- the top of a frame, where every gr_screen_resize() caller sits today. + // + // This catches only the narrowest case of getting that wrong: a resize from inside an open + // scene-texture scope, which would throw away a composed scene. It is deliberately not the + // equivalent of OpenGL's Scene_framebuffer_in_frame check, which is broader -- + // beginSceneRendering() never runs at all when post-processing is off, and off is qtFRED's + // default, so this is false for the whole frame in the common case. Widening it would mean + // tracking "has anything been recorded into this command buffer", which nothing needs yet. + if (m_sceneRendering) { + Assertion(false, "Tried to resize the Vulkan swap chain to %ux%u while a scene was being " + "rendered into it! The resize has been deferred to the next frame.", + capabilities.currentExtent.width, capabilities.currentExtent.height); + return false; + } + + nprintf(("vulkan", "Vulkan: window is %ux%u but the swap chain is %ux%u, rebuilding\n", + capabilities.currentExtent.width, capabilities.currentExtent.height, + m_current->extent.width, m_current->extent.height)); + + const bool restartFrame = m_frameInProgress; + discardFrame(); + + m_current->needsRecreation = true; + acquireNextSwapChainImage(); + + if (restartFrame) { + setupFrame(); + } + + return true; +} + void VulkanRenderer::flip() { if (!m_frameInProgress) { @@ -186,9 +419,9 @@ void VulkanRenderer::flip() // unreliable (tearing/garbage in the composition image once the encode pass // samples it). RE-TEST on macOS hardware; if MoltenVK now honors the subpass // dependency, this explicit barrier can be removed. - if (m_currentSwapChainImage < m_compositionImages.size()) { + if (m_current->currentImage < m_current->compositionImages.size()) { ImageBarrier2 compositionBarrier; - compositionBarrier.image = m_compositionImages[m_currentSwapChainImage].get(); + compositionBarrier.image = m_current->compositionImages[m_current->currentImage].get(); compositionBarrier.levelCount = 1; compositionBarrier.layerCount = 1; compositionBarrier.oldLayout = vk::ImageLayout::eShaderReadOnlyOptimal; @@ -210,15 +443,21 @@ void VulkanRenderer::flip() // Set up cleanup callback for command buffers auto buffersToFree = m_currentCommandBuffers; - m_frames[m_currentFrame]->onFrameFinished([this, buffersToFree]() mutable { + m_current->frames[m_currentFrame]->onFrameFinished([this, buffersToFree]() mutable { m_device->freeCommandBuffers(m_graphicsCommandPool.get(), buffersToFree); }); // Submit and present - auto presentStatus = m_frames[m_currentFrame]->submitAndPresent(m_currentCommandBuffers); + auto& acquire = m_current->acquireSemaphores[m_current->currentAcquire]; + acquire.consumer = m_current->frames[m_currentFrame].get(); + auto presentStatus = m_current->frames[m_currentFrame]->submitAndPresent(m_currentCommandBuffers, + acquire.semaphore.get(), + m_current->renderFinishedSemaphores[m_current->currentImage].get(), + m_current->currentImage, + m_frameNumber); if (presentStatus == SwapChainStatus::eSuboptimal || presentStatus == SwapChainStatus::eOutOfDate) { - m_swapChainNeedsRecreation = true; + m_current->needsRecreation = true; } // Notify query manager that this frame's command buffer was submitted @@ -227,7 +466,7 @@ void VulkanRenderer::flip() } // Track which swap chain image was just presented so saveScreen() can read it - m_previousSwapChainImage = m_currentSwapChainImage; + m_current->previousImage = m_current->currentImage; // Clear current command buffer reference m_currentCommandBuffer = nullptr; @@ -250,6 +489,18 @@ void VulkanRenderer::flip() // acquireNextSwapChainImage, so we know the previous frame's commands // (including async upload CBs) have completed before destroying resources. m_deletionQueue->processDestructions(); + + // Retire finished texture upload command buffers. This belongs here rather than in + // setupFrame(): the texture manager frees them on a countdown of FRAMES_TO_WAIT calls, which + // only means "the GPU has moved on" if each call corresponds to a frame that actually + // completed. setupFrame() stopped being that the moment viewports could be switched -- + // useViewport() sets a frame up on the incoming target without ever flipping it, so with + // qtFRED's briefing map running there are about three setupFrame() calls per completed frame. + // The countdown then ran out while uploads were still executing and freed command buffers in + // the pending state (VUID-vkFreeCommandBuffers-pCommandBuffers-00047). Ticking it here, next + // to the deletion queue and after the fence wait above, ties it back to real frame completion. + Assertion(m_textureManager, "Vulkan TextureManager not initialized in flip!"); + m_textureManager->frameStart(); } void VulkanRenderer::beginSceneRendering() @@ -344,8 +595,8 @@ void VulkanRenderer::endSceneRendering() PassBeginDesc pass; pass.renderPass = m_renderPassLoad.get(); - pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get(); - pass.extent = m_swapChainExtent; + pass.framebuffer = m_current->framebuffers[m_current->currentImage].get(); + pass.extent = m_current->extent; pass.clearValues = clearValues; pass.viewport = PassViewport::NoFlip; beginTrackedRenderPass(pass); @@ -355,9 +606,9 @@ void VulkanRenderer::endSceneRendering() // Restore Y-flipped viewport for HUD rendering m_stateTracker->setViewport(0.0f, - static_cast(m_swapChainExtent.height), - static_cast(m_swapChainExtent.width), - -static_cast(m_swapChainExtent.height)); + static_cast(m_current->extent.height), + static_cast(m_current->extent.width), + -static_cast(m_current->extent.height)); m_sceneRendering = false; m_useGbufRenderPass = false; @@ -538,8 +789,8 @@ void VulkanRenderer::resumeSwapChainPass() PassBeginDesc pass; pass.renderPass = m_renderPassLoad.get(); - pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get(); - pass.extent = m_swapChainExtent; + pass.framebuffer = m_current->framebuffers[m_current->currentImage].get(); + pass.extent = m_current->extent; pass.clearValues = clearValues; beginTrackedRenderPass(pass); } diff --git a/code/graphics/vulkan/VulkanRendererSetup.cpp b/code/graphics/vulkan/VulkanRendererSetup.cpp index 90ebc51b567..c01e128c991 100644 --- a/code/graphics/vulkan/VulkanRendererSetup.cpp +++ b/code/graphics/vulkan/VulkanRendererSetup.cpp @@ -87,18 +87,27 @@ bool checkDeviceExtensionSupport(PhysicalDeviceValues& values) return requiredExtensions.empty(); } -bool checkSwapChainSupport(PhysicalDeviceValues& values, const vk::UniqueSurfaceKHR& surface) +/** + * @brief Fill in the parts of @p values that depend on a particular surface + * + * Every one of these can differ per surface, so this has to be re-run for each one rather than + * carried over from the surface the device was picked against -- see createTargetResources(). + * + * @return false if the surface reports no usable formats or present modes, i.e. cannot be presented + * to at all + */ +bool checkSwapChainSupport(PhysicalDeviceValues& values, vk::SurfaceKHR surface) { - values.surfaceCapabilities = values.device.getSurfaceCapabilitiesKHR(surface.get()); - auto fmts = values.device.getSurfaceFormatsKHR(surface.get()); + values.surfaceCapabilities = values.device.getSurfaceCapabilitiesKHR(surface); + auto fmts = values.device.getSurfaceFormatsKHR(surface); values.surfaceFormats.assign(fmts.begin(), fmts.end()); - auto modes = values.device.getSurfacePresentModesKHR(surface.get()); + auto modes = values.device.getSurfacePresentModesKHR(surface); values.presentModes.assign(modes.begin(), modes.end()); return !values.surfaceFormats.empty() && !values.presentModes.empty(); } -bool isDeviceUnsuitable(PhysicalDeviceValues& values, const vk::UniqueSurfaceKHR& surface) +bool isDeviceUnsuitable(PhysicalDeviceValues& values, vk::SurfaceKHR surface) { // We need a GPU. Reject CPU or "other" types. if (values.properties.deviceType != vk::PhysicalDeviceType::eDiscreteGpu && @@ -122,7 +131,7 @@ bool isDeviceUnsuitable(PhysicalDeviceValues& values, const vk::UniqueSurfaceKHR // queue (which implicitly supports transfer). Async transfer on a separate // queue is future work and must be reintroduced end-to-end, including // queue-family ownership transfers -- not half-wired. - if (!values.presentQueueIndex.initialized && values.device.getSurfaceSupportKHR(i, surface.get())) { + if (!values.presentQueueIndex.initialized && values.device.getSurfaceSupportKHR(i, surface)) { values.presentQueueIndex.initialized = true; values.presentQueueIndex.index = i; } @@ -226,7 +235,12 @@ vk::SurfaceFormatKHR chooseSurfaceFormat(const PhysicalDeviceValues& values) // When HDR output is requested, prefer a 10-bit HDR10 (PQ / ST.2084) surface // using BT.2020 primaries. The final output-encode pass writes PQ-encoded // BT.2020 values into this surface. - if (Gr_enable_hdr) { + // + // Never in the editor: its surface is a window embedded in a desktop-composited + // application, so what an HDR10 swap chain would actually look like there is not + // something we can verify. It would also drag in the format-change limitation + // recreateSwapChain() documents. + if (Gr_enable_hdr && !Fred_running) { for (const auto& availableFormat : values.surfaceFormats) { if ((availableFormat.format == vk::Format::eA2B10G10R10UnormPack32 || availableFormat.format == vk::Format::eA2R10G10B10UnormPack32) && @@ -330,6 +344,15 @@ bool VulkanRenderer::initialize() return false; } + // Everything from the surface down hangs off a target, so the main one has to exist before + // createTargetSurface() has anywhere to put its handle. The game only ever has this one; qtFRED + // adds a second when the briefing map first asks to be rendered into. + auto mainTarget = std::make_unique(); + mainTarget->viewport = os::getMainViewport(); + m_mainTarget = mainTarget.get(); + m_current = m_mainTarget; + m_targets.emplace(mainTarget->viewport, std::move(mainTarget)); + try { if (!initializeInstance()) { mprintf(("Failed to create Vulkan instance!\n")); @@ -340,7 +363,7 @@ bool VulkanRenderer::initialize() return false; } - if (!initializeSurface()) { + if (!createTargetSurface(*m_mainTarget)) { nprintf(("vulkan", "Failed to create Vulkan surface!\n")); return false; } @@ -402,18 +425,20 @@ bool VulkanRenderer::initialize() createCommandPool(deviceValues); - if (!createSwapChain(deviceValues)) { + if (!createSwapChain(*m_mainTarget, deviceValues)) { nprintf(("vulkan", "Failed to create swap chain.\n")); return false; } - createDepthResources(); - createCompositionResources(); - createEncodeRenderPass(); + createDepthResources(*m_mainTarget); + createCompositionResources(*m_mainTarget); + // Shared by every target, and built here from the main target's negotiated format. Any later + // target has to match it -- see createTargetResources(). + createEncodeRenderPass(m_mainTarget->imageFormat); createRenderPass(); - createFrameBuffers(); + createFrameBuffers(*m_mainTarget); - createPresentSyncObjects(); + createPresentSyncObjects(*m_mainTarget); // Initialize texture manager (needs command pool for uploads) m_textureManager = std::make_unique(); @@ -495,7 +520,7 @@ bool VulkanRenderer::initialize() // Initialize post-processing m_postProcessor = std::make_unique(); if (!m_postProcessor->init(m_device.get(), m_physicalDevice, m_memoryManager.get(), - m_swapChainExtent, m_depthFormat, m_hdrActive)) { + m_mainTarget->extent, m_depthFormat, m_mainTarget->hdrActive)) { mprintf(("Warning: Failed to initialize Vulkan post-processor, post-processing will be disabled\n")); m_postProcessor.reset(); } else { @@ -584,29 +609,40 @@ bool VulkanRenderer::initDisplayDevice() const } bool VulkanRenderer::initializeInstance() { + auto* vulkanSupport = m_graphicsOps->getVulkanSupport(); + if (vulkanSupport == nullptr) { + mprintf(("Vulkan: The windowing implementation in use cannot present through Vulkan!\n")); + return false; + } + const auto vkGetInstanceProcAddr = - reinterpret_cast(SDL_Vulkan_GetVkGetInstanceProcAddr()); + reinterpret_cast(vulkanSupport->getVulkanProcAddr()); + if (vkGetInstanceProcAddr == nullptr) { + mprintf(("Vulkan: Could not get vkGetInstanceProcAddr from the windowing system!\n")); + return false; + } VULKAN_HPP_DEFAULT_DISPATCHER.init(vkGetInstanceProcAddr); VkInstanceCreateFlags createFlags = 0; - uint32_t count = 0; - auto extPtr = SDL_Vulkan_GetInstanceExtensions(&count); - - if ( !extPtr ) { - mprintf(("Error in SDL_Vulkan_GetInstanceExtensions: %s\n", SDL_GetError())); + // The windowing system only knows about the extensions its own surfaces need; everything else + // (debug utils, swap chain color space, portability) is decided below against what the driver + // actually supports. This must outlive `extensions`, which only holds views into it. + SCP_vector windowExtensions; + if (!vulkanSupport->getVulkanInstanceExtensions(windowExtensions)) { + mprintf(("Vulkan: Could not determine the instance extensions required by the window system!\n")); return false; } SCP_vector extensions; - extensions.reserve(count); + extensions.reserve(windowExtensions.size()); - for (uint32_t i = 0; i < count; ++i) { + for (const auto& windowExtension : windowExtensions) { // SDL 3.2 will include portability enueration extension even if it's not // supported, so make sure not to add it blindly, and check for it later - if (SDL_strcmp(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME, extPtr[i])) { - extensions.push_back(extPtr[i]); + if (stricmp(windowExtension.c_str(), VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) != 0) { + extensions.push_back(windowExtension.c_str()); } } @@ -762,20 +798,65 @@ bool VulkanRenderer::initializeInstance() return true; } -bool VulkanRenderer::initializeSurface() +VulkanSurfaceHandle::VulkanSurfaceHandle(os::VulkanSurfaceProvider* provider, + vk::Instance instance, + vk::SurfaceKHR surface) + : m_provider(provider), m_instance(instance), m_surface(surface) { - const auto window = os::getSDLMainWindow(); +} +VulkanSurfaceHandle::~VulkanSurfaceHandle() +{ + reset(); +} +VulkanSurfaceHandle::VulkanSurfaceHandle(VulkanSurfaceHandle&& other) noexcept + : m_provider(other.m_provider), m_instance(other.m_instance), m_surface(other.m_surface) +{ + other.m_provider = nullptr; + other.m_instance = vk::Instance(); + other.m_surface = vk::SurfaceKHR(); +} +VulkanSurfaceHandle& VulkanSurfaceHandle::operator=(VulkanSurfaceHandle&& other) noexcept +{ + if (this != &other) { + reset(); + + m_provider = other.m_provider; + m_instance = other.m_instance; + m_surface = other.m_surface; - VkSurfaceKHR surface; - if (!SDL_Vulkan_CreateSurface(window, static_cast(*m_vkInstance), nullptr, &surface)) { - nprintf(("vulkan", "Failed to create vulkan surface: %s\n", SDL_GetError())); + other.m_provider = nullptr; + other.m_instance = vk::Instance(); + other.m_surface = vk::SurfaceKHR(); + } + return *this; +} +void VulkanSurfaceHandle::reset() +{ + if (m_provider != nullptr && m_surface) { + m_provider->destroyVulkanSurface(static_cast(m_instance), + os::vulkan_handle_value(static_cast(m_surface))); + } + + m_provider = nullptr; + m_instance = vk::Instance(); + m_surface = vk::SurfaceKHR(); +} + +bool VulkanRenderer::createTargetSurface(VulkanPresentTarget& target) +{ + auto* vulkanSupport = m_graphicsOps->getVulkanSupport(); + Assertion(vulkanSupport != nullptr, "initializeInstance() should have rejected this already!"); + + const auto surface = + vulkanSupport->createVulkanSurface(target.viewport, static_cast(*m_vkInstance)); + if (surface == 0) { + nprintf(("vulkan", "Vulkan: failed to create a surface for this viewport.\n")); return false; } - const vk::detail::ObjectDestroy deleter(*m_vkInstance, - nullptr, - VULKAN_HPP_DEFAULT_DISPATCHER); - m_vkSurface = vk::UniqueSurfaceKHR(vk::SurfaceKHR(surface), deleter); + target.surface = VulkanSurfaceHandle(vulkanSupport, + *m_vkInstance, + vk::SurfaceKHR(os::vulkan_handle_cast(surface))); return true; } @@ -823,7 +904,7 @@ bool VulkanRenderer::pickPhysicalDevice(PhysicalDeviceValues& deviceValues) // Remove devices that do not have the features we need values.erase(std::remove_if(values.begin(), values.end(), - [this](PhysicalDeviceValues& value) { return isDeviceUnsuitable(value, m_vkSurface); }), + [this](PhysicalDeviceValues& value) { return isDeviceUnsuitable(value, m_mainTarget->surface.get()); }), values.end()); if (values.empty()) { return false; @@ -1023,7 +1104,107 @@ bool VulkanRenderer::createLogicalDevice(const PhysicalDeviceValues& deviceValue return true; } -bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, vk::SwapchainKHR oldSwapchain) +bool VulkanRenderer::createTargetResources(VulkanPresentTarget& target) +{ + if (!createTargetSurface(target)) { + return false; + } + + // The device was already chosen against the main surface, so only the parts that are per-surface + // get re-queried here. The present queue is checked rather than assumed: a device is allowed to + // support presentation to one surface and not another. + PhysicalDeviceValues values; + values.device = m_physicalDevice; + values.graphicsQueueIndex = {true, m_graphicsQueueFamilyIndex}; + values.presentQueueIndex = {true, m_presentQueueFamilyIndex}; + + if (!m_physicalDevice.getSurfaceSupportKHR(m_presentQueueFamilyIndex, target.surface.get())) { + mprintf(("Vulkan: the present queue cannot present to this viewport's surface.\n")); + return false; + } + + if (!checkSwapChainSupport(values, target.surface.get())) { + mprintf(("Vulkan: this viewport's surface reports no usable formats or present modes.\n")); + return false; + } + + if (!createSwapChain(target, values)) { + mprintf(("Vulkan: failed to create a swap chain for this viewport.\n")); + return false; + } + + // m_encodeRenderPass is shared and bakes in the swap chain format it was built for, so a target + // that negotiates a different one cannot present through it. In practice every surface here is + // the same device, driver and window system and they agree -- which is exactly why this is + // checked rather than assumed, since a mismatch would otherwise be silent and only appear on + // somebody else's hardware. Fail the target instead; the caller falls back. + if (target.imageFormat != m_mainTarget->imageFormat) { + mprintf(("Vulkan: this viewport's surface negotiated format %d but the output-encode pass was " + "built for %d; cannot present to it.\n", + static_cast(target.imageFormat), static_cast(m_mainTarget->imageFormat))); + return false; + } + + createDepthResources(target); + createCompositionResources(target); + createFrameBuffers(target); + createPresentSyncObjects(target); + + nprintf(("vulkan", "Vulkan: created a present target for a second viewport (%ux%u, %zu images)\n", + target.extent.width, target.extent.height, target.images.size())); + + return true; +} + +void VulkanRenderer::destroyDepthResources(VulkanPresentTarget& target) +{ + target.depthImageView.reset(); + target.depthImage.reset(); + if (m_memoryManager && target.depthImageMemory.isValid()) { + m_memoryManager->freeAllocation(target.depthImageMemory); + target.depthImageMemory = {}; + } +} + +void VulkanRenderer::destroyTargetSwapChain(VulkanPresentTarget& target) +{ + // Framebuffers and views reference the swap chain images, and the frames hold the swap chain + // handle for their acquires and presents, so all of them go first. + target.framebuffers.clear(); + target.encodeFramebuffers.clear(); + target.imageViews.clear(); + target.images.clear(); + target.imageRenderFrame.clear(); + + for (auto& frame : target.frames) { + frame.reset(); + } + target.acquireSemaphores.clear(); + target.renderFinishedSemaphores.clear(); + target.hasRetainedAcquire = false; + + target.swapChain.reset(); +} + +void VulkanRenderer::releaseTargetMemory(VulkanPresentTarget& target) +{ + destroyDepthResources(target); + + target.compositionImageViews.clear(); + target.compositionImages.clear(); + if (m_memoryManager) { + for (auto& alloc : target.compositionAllocations) { + if (alloc.isValid()) { + m_memoryManager->freeAllocation(alloc); + } + } + } + target.compositionAllocations.clear(); +} + +bool VulkanRenderer::createSwapChain(VulkanPresentTarget& target, + const PhysicalDeviceValues& deviceValues, + vk::SwapchainKHR oldSwapchain) { // Choose one more than the minimum to avoid driver synchronization if it is not done with a thread yet uint32_t imageCount = deviceValues.surfaceCapabilities.minImageCount + 1; @@ -1035,7 +1216,7 @@ bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, v const auto surfaceFormat = chooseSurfaceFormat(deviceValues); vk::SwapchainCreateInfoKHR createInfo; - createInfo.surface = m_vkSurface.get(); + createInfo.surface = target.surface.get(); createInfo.minImageCount = imageCount; createInfo.imageFormat = surfaceFormat.format; createInfo.imageColorSpace = surfaceFormat.colorSpace; @@ -1063,26 +1244,26 @@ bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, v auto newSwapChain = m_device->createSwapchainKHRUnique(createInfo); // Clear old resources before replacing the swap chain - m_swapChainFramebuffers.clear(); - m_swapChainImageViews.clear(); - - m_swapChain = std::move(newSwapChain); - - auto swapChainImages = m_device->getSwapchainImagesKHR(m_swapChain.get()); - m_swapChainImages.assign(swapChainImages.begin(), swapChainImages.end()); - m_swapChainImageFormat = surfaceFormat.format; - m_swapChainColorSpace = surfaceFormat.colorSpace; - m_hdrActive = (surfaceFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT); - Gr_hdr_output_active = m_hdrActive; - m_swapChainExtent = createInfo.imageExtent; - mprintf(("Vulkan: Swap chain output mode: %s\n", m_hdrActive ? "HDR10 (PQ/BT.2020)" : "SDR (sRGB)")); - - m_swapChainImageViews.reserve(m_swapChainImages.size()); - for (const auto& image : m_swapChainImages) { + target.framebuffers.clear(); + target.imageViews.clear(); + + target.swapChain = std::move(newSwapChain); + + auto swapChainImages = m_device->getSwapchainImagesKHR(target.swapChain.get()); + target.images.assign(swapChainImages.begin(), swapChainImages.end()); + target.imageFormat = surfaceFormat.format; + target.colorSpace = surfaceFormat.colorSpace; + target.hdrActive = (surfaceFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT); + Gr_hdr_output_active = target.hdrActive; + target.extent = createInfo.imageExtent; + mprintf(("Vulkan: Swap chain output mode: %s\n", target.hdrActive ? "HDR10 (PQ/BT.2020)" : "SDR (sRGB)")); + + target.imageViews.reserve(target.images.size()); + for (const auto& image : target.images) { vk::ImageViewCreateInfo viewCreateInfo; viewCreateInfo.image = image; viewCreateInfo.viewType = vk::ImageViewType::e2D; - viewCreateInfo.format = m_swapChainImageFormat; + viewCreateInfo.format = target.imageFormat; viewCreateInfo.components.r = vk::ComponentSwizzle::eIdentity; viewCreateInfo.components.g = vk::ComponentSwizzle::eIdentity; @@ -1095,7 +1276,7 @@ bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, v viewCreateInfo.subresourceRange.baseArrayLayer = 0; viewCreateInfo.subresourceRange.layerCount = 1; - m_swapChainImageViews.push_back(m_device->createImageViewUnique(viewCreateInfo)); + target.imageViews.push_back(m_device->createImageViewUnique(viewCreateInfo)); } // No layout transition needed for the new images: the only pass that writes @@ -1103,7 +1284,7 @@ bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, v // loadOp=eDontCare, so their first use never reads prior contents. // Advertise HDR10 mastering/content metadata to the compositor when active. - if (m_hdrActive && m_hdrMetadataSupported) { + if (target.hdrActive && m_hdrMetadataSupported) { vk::HdrMetadataEXT metadata; // BT.2020 display primaries and D65 white point metadata.displayPrimaryRed = vk::XYColorEXT{0.708f, 0.292f}; @@ -1114,7 +1295,7 @@ bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, v metadata.minLuminance = 0.0f; metadata.maxContentLightLevel = Gr_hdr_peak_nits; metadata.maxFrameAverageLightLevel = Gr_hdr_paperwhite_nits; - m_device->setHdrMetadataEXT(m_swapChain.get(), metadata); + m_device->setHdrMetadataEXT(target.swapChain.get(), metadata); mprintf(("Vulkan: HDR10 metadata set (peak %.0f nits, paper white %.0f nits)\n", Gr_hdr_peak_nits, Gr_hdr_paperwhite_nits)); } @@ -1122,27 +1303,28 @@ bool VulkanRenderer::createSwapChain(const PhysicalDeviceValues& deviceValues, v return true; } -bool VulkanRenderer::recreateSwapChain() +bool VulkanRenderer::recreateSwapChain(VulkanPresentTarget& target) { nprintf(("vulkan", "Vulkan: Recreating swap chain...\n")); // Wait for all frames to finish so no resources are in use for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) { - m_frames[i]->waitForFinish(); + target.frames[i]->waitForFinish(); } m_device->waitIdle(); // Re-query surface state (may have changed due to resize/compositor) PhysicalDeviceValues freshValues; freshValues.device = m_physicalDevice; - freshValues.surfaceCapabilities = m_physicalDevice.getSurfaceCapabilitiesKHR(m_vkSurface.get()); - auto fmts = m_physicalDevice.getSurfaceFormatsKHR(m_vkSurface.get()); - freshValues.surfaceFormats.assign(fmts.begin(), fmts.end()); - auto modes = m_physicalDevice.getSurfacePresentModesKHR(m_vkSurface.get()); - freshValues.presentModes.assign(modes.begin(), modes.end()); freshValues.graphicsQueueIndex = {true, m_graphicsQueueFamilyIndex}; freshValues.presentQueueIndex = {true, m_presentQueueFamilyIndex}; + if (!checkSwapChainSupport(freshValues, target.surface.get())) { + nprintf(("vulkan", "Vulkan: surface no longer reports usable formats or present modes, " + "deferring swap chain recreation\n")); + return false; + } + // Check for 0x0 extent (minimized window) — caller should retry later auto extent = chooseSwapChainExtent(freshValues, gr_screen.max_w, gr_screen.max_h); if (extent.width == 0 || extent.height == 0) { @@ -1153,32 +1335,32 @@ bool VulkanRenderer::recreateSwapChain() // Recreate all size-dependent resources. The render passes (including // m_encodeRenderPass) are intentionally NOT recreated so cached pipelines // remain valid; only images, views, and framebuffers are rebuilt. - const vk::Format oldSwapChainFormat = m_swapChainImageFormat; - createSwapChain(freshValues, m_swapChain.get()); + const vk::Format oldSwapChainFormat = target.imageFormat; + createSwapChain(target, freshValues, target.swapChain.get()); // Known limitation: if the surface format changes across recreation (e.g. // the window moves to a display that flips HDR10 availability), // m_encodeRenderPass and the post-processor's LDR format would need a full // rebuild, which we don't support yet. Log it loudly. - if (m_swapChainImageFormat != oldSwapChainFormat) { + if (target.imageFormat != oldSwapChainFormat) { mprintf(("Vulkan: WARNING - swap chain surface format changed across recreation (%d -> %d); " "rendering may be broken until restart\n", - static_cast(oldSwapChainFormat), static_cast(m_swapChainImageFormat))); + static_cast(oldSwapChainFormat), static_cast(target.imageFormat))); } // The depth buffer is extent-sized; recreate it before the framebuffers // that attach its view. createDepthResources() verifies the format is stable // (the kept render passes bake it in). - destroyDepthResources(); - createDepthResources(); + destroyDepthResources(target); + createDepthResources(target); - createCompositionResources(); - createFrameBuffers(); + createCompositionResources(target); + createFrameBuffers(target); // Recreate the post-processor's extent-sized targets (scene color/depth, // G-buffer, bloom chains, LDR/SMAA targets, ...). Its render passes and // samplers are extent-independent and stay alive, keeping pipelines valid. - if (m_postProcessor && !m_postProcessor->resize(m_swapChainExtent)) { + if (m_postProcessor && !m_postProcessor->resize(target.extent)) { mprintf(("Vulkan: post-processor resize failed, disabling post-processing!\n")); setPostProcessor(nullptr); m_postProcessor->shutdown(); @@ -1191,25 +1373,37 @@ bool VulkanRenderer::recreateSwapChain() } m_sceneDepthCopiedThisFrame = false; - // Update VulkanRenderFrame handles to point to the new swap chain, and - // recreate their semaphores: an acquire that succeeded against the old swap - // chain but was never consumed by a submit leaves the image-available - // semaphore signaled. All frames are idle here (waited above), so - // recreating is safe and unambiguous. - for (auto& frame : m_frames) { - frame->updateSwapChain(m_swapChain.get()); - frame->recreateSyncObjects(); + // Update VulkanRenderFrame handles to point to the new swap chain. + for (auto& frame : target.frames) { + frame->updateSwapChain(target.swapChain.get()); + } + + // The render-finished semaphores are keyed on swap chain image, and the image count can change + // across recreation, so they go with the images they belonged to. Any of them still held by the + // presentation engine belonged to the old swap chain, which is retired here. All frames are + // idle (waited above), so nothing is still signalling one. + createRenderFinishedSemaphores(target); + + // The acquire semaphores were signalled against the swap chain that just went away, and any + // image a viewport switch was holding on to belonged to it too. Start both over. + constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo; + for (auto& acquire : target.acquireSemaphores) { + acquire.semaphore = m_device->createSemaphoreUnique(semaphoreCreateInfo); + acquire.consumer = nullptr; } + target.nextAcquire = 0; + target.currentAcquire = 0; + target.hasRetainedAcquire = false; // Reset swap chain image tracking - m_swapChainImageRenderImage.clear(); - m_swapChainImageRenderImage.resize(m_swapChainImages.size(), nullptr); - m_previousSwapChainImage = UINT32_MAX; + target.imageRenderFrame.clear(); + target.imageRenderFrame.resize(target.images.size(), nullptr); + target.previousImage = UINT32_MAX; - m_swapChainNeedsRecreation = false; + target.needsRecreation = false; nprintf(("vulkan", "Vulkan: Swap chain recreated successfully (%ux%u, %zu images)\n", - m_swapChainExtent.width, m_swapChainExtent.height, m_swapChainImages.size())); + target.extent.width, target.extent.height, target.images.size())); return true; } diff --git a/code/graphics/vulkan/gr_vulkan.cpp b/code/graphics/vulkan/gr_vulkan.cpp index 4d5d5768ff8..bf96fd9d146 100644 --- a/code/graphics/vulkan/gr_vulkan.cpp +++ b/code/graphics/vulkan/gr_vulkan.cpp @@ -5,6 +5,7 @@ #include "VulkanTexture.h" #include "VulkanShader.h" #include "VulkanDescriptorManager.h" +#include "VulkanDeletionQueue.h" #include "VulkanPipeline.h" #include "VulkanQuery.h" #include "VulkanState.h" @@ -41,7 +42,7 @@ std::unique_ptr renderer_instance; // Sync object for tracking frame completion struct VulkanSyncObject { - uint64_t frameNumber; + FrameSyncPoint point; }; // ========== Renderer-level functions ========== @@ -52,6 +53,22 @@ void vulkan_setup_frame() renderer->setupFrame(); } +void vulkan_viewport_size_changed() +{ + auto* renderer = getRendererInstance(); + if (renderer != nullptr) { + renderer->syncToSurfaceExtent(); + } +} + +void vulkan_release_viewport(os::Viewport* view) +{ + auto* renderer = getRendererInstance(); + if (renderer != nullptr && view != nullptr) { + renderer->releaseViewport(view); + } +} + void vulkan_flip() { renderer_instance->flip(); @@ -228,7 +245,7 @@ gr_sync vulkan_sync_fence() { auto* renderer = getRendererInstance(); auto* sync = new VulkanSyncObject(); - sync->frameNumber = renderer->getCurrentFrameNumber(); + sync->point = renderer->captureSyncPoint(); return static_cast(sync); } @@ -245,7 +262,7 @@ bool vulkan_sync_wait(gr_sync sync, uint64_t timeoutns) // timeout or when the fence was taken during the still-recording frame -- // callers (e.g. UniformBufferManager's segment fences) depend on this to // know whether the GPU is done with a resource. - return renderer->waitForFrame(syncObj->frameNumber, timeoutns); + return renderer->waitForSyncPoint(syncObj->point, timeoutns); } void vulkan_sync_delete(gr_sync sync) @@ -342,6 +359,71 @@ void vulkan_print_screen(const char* filename) vm_free(pixels); } +void vulkan_end_offscreen_frame() +{ + // Everything frame-scoped that setupFrame()/flip() would recycle. An off-screen renderer never + // reaches either, so without this the descriptor pool chain grows a chunk every few frames, the + // deletion queue's retirement clock never ticks, and the bump allocator climbs until it doubles + // -- and a mid-frame doubling used to hand draws stale uniforms (the qtFRED briefing icon + // flicker). Minutes with the briefing editor open reached multiple GB. + // + // Safe only because gr_end_offscreen_frame()'s contract is that the frame's GPU work has + // already completed: the readback that produced the image host-waits on a fence, and queue + // submissions execute in order, so every submission up to that point has retired. + if (auto* renderer = getRendererInstance()) { + // Advances the sync frame counter and rewinds the bump allocator. The counter matters as + // much as the memory: sync objects are stamped with it, and UniformBufferManager's segment + // fences can only ever resolve if it moves. + renderer->endOffscreenFrame(); + } + + if (auto* descriptorManager = getDescriptorManager()) { + descriptorManager->beginFrame(); + } + + if (auto* deletionQueue = getDeletionQueue()) { + deletionQueue->processDestructions(); + } +} + +bool vulkan_read_render_target(ubyte* out_rgba, int width, int height) +{ + auto* texManager = getTextureManager(); + const int rtHandle = texManager ? texManager->getCurrentRenderTarget() : -1; + if (rtHandle < 0) { + return false; + } + + auto* ts = texManager->getTextureSlot(rtHandle); + if (ts == nullptr) { + return false; + } + + ubyte* pixels = nullptr; + uint32_t w = 0; + uint32_t h = 0; + if (!renderer_instance->readbackRenderTarget(ts, &pixels, &w, &h)) { + return false; + } + + // The caller sized its buffer from the bitmap it bound, so a disagreement means it is reading + // something other than what it thinks. Refuse rather than overrun. + const bool sizeMatches = w == static_cast(width) && h == static_cast(height); + if (!sizeMatches) { + nprintf(("vulkan", "vulkan_read_render_target: caller expected %dx%d but the bound target is " + "%ux%u\n", width, height, w, h)); + } else { + // R8G8B8A8_UNORM, already RGBA order with real alpha, and row 0 is the top row -- which is + // the top-down order gr_read_render_target() promises. Deliberately not the flip + // vulkan_blob_screen() applies below: that one exists only to make its PNG match OpenGL's. + memcpy(out_rgba, pixels, static_cast(w) * h * 4); + } + + vm_free(pixels); + + return sizeMatches; +} + SCP_string vulkan_blob_screen() { ubyte* pixels = nullptr; @@ -402,7 +484,26 @@ std::unique_ptr stub_create_viewport(const os::ViewPortProperties& { return {}; } -void stub_use_viewport(os::Viewport* /*view*/) {} +void vulkan_use_viewport(os::Viewport* view) +{ + auto* renderer = getRendererInstance(); + if (renderer == nullptr || view == nullptr) { + return; + } + + if (!renderer->useViewport(view)) { + return; + } + + // Match gr_opengl_use_viewport(): the engine's idea of the screen follows whichever surface is + // being drawn to now. The swap chain extent is used rather than the viewport's own getSize(), + // because that reports logical pixels and the surface was sized in device pixels -- scaling one + // into the other by hand is what leaves gr_screen disagreeing with what is being presented. + const auto extent = renderer->getCurrentTargetExtent(); + if (extent.width > 0 && extent.height > 0) { + gr_screen_resize(static_cast(extent.width), static_cast(extent.height)); + } +} SCP_vector stub_openxr_get_extensions() { return {}; } bool stub_openxr_test_capabilities() { return false; } bool stub_openxr_create_session() { return false; } @@ -418,6 +519,7 @@ void init_function_pointers() { // function pointers... gr_screen.gf_setup_frame = vulkan_setup_frame; + gr_screen.gf_viewport_size_changed = vulkan_viewport_size_changed; gr_screen.gf_set_clip = vulkan_set_clip; gr_screen.gf_reset_clip = vulkan_reset_clip; @@ -425,6 +527,8 @@ void init_function_pointers() gr_screen.gf_print_screen = vulkan_print_screen; gr_screen.gf_blob_screen = vulkan_blob_screen; + gr_screen.gf_read_render_target = vulkan_read_render_target; + gr_screen.gf_end_offscreen_frame = vulkan_end_offscreen_frame; gr_screen.gf_zbuffer_get = vulkan_zbuffer_get; gr_screen.gf_zbuffer_set = vulkan_zbuffer_set; @@ -542,7 +646,8 @@ void init_function_pointers() gr_screen.gf_delete_query_object = vulkan_delete_query_object; gr_screen.gf_create_viewport = stub_create_viewport; - gr_screen.gf_use_viewport = stub_use_viewport; + gr_screen.gf_use_viewport = vulkan_use_viewport; + gr_screen.gf_release_viewport = vulkan_release_viewport; gr_screen.gf_bind_uniform_buffer = vulkan_bind_uniform_buffer; diff --git a/code/osapi/osapi.h b/code/osapi/osapi.h index a81810e0c9a..9394d8d7a55 100644 --- a/code/osapi/osapi.h +++ b/code/osapi/osapi.h @@ -98,6 +98,11 @@ namespace os * @ingroup osapi */ + // Declared in osapi/vulkan_surface.h, which is only included by the few files that actually + // deal with Vulkan -- osapi.h is included nearly everywhere and has no business pulling in the + // Vulkan headers. + class VulkanSurfaceProvider; + /** * @brief Flags for OpenGL context creation * @ingroup os_graphics_api @@ -331,6 +336,19 @@ namespace os * @return The created viewport, may be @c nullptr if the viewport can't be created */ virtual std::unique_ptr createViewport(const ViewPortProperties& props) = 0; + + /** + * @brief Gets the Vulkan support of this implementation + * + * Vulkan needs more from the windowing system than an OpenGL context does (loader, instance + * extensions, surface creation), so it gets its own interface. Implementations that can't + * present through Vulkan return @c nullptr here, which makes @ref gr_init fall back to + * OpenGL instead of failing. + * + * @return The Vulkan support interface, or @c nullptr if this implementation has none. The + * returned pointer is owned by the graphics operations and stays valid for their lifetime. + */ + virtual VulkanSurfaceProvider* getVulkanSupport() { return nullptr; } }; /** diff --git a/code/osapi/vulkan_surface.h b/code/osapi/vulkan_surface.h new file mode 100644 index 00000000000..b9ecda219d8 --- /dev/null +++ b/code/osapi/vulkan_surface.h @@ -0,0 +1,92 @@ +#pragma once + +#include "globalincs/pstypes.h" + +#include + +namespace os { + +class Viewport; + +/** + * @brief The windowing-system half of Vulkan initialization + * @ingroup os_graphics_api + * + * Three things the Vulkan renderer cannot work out for itself, because all three depend on which + * windowing toolkit created the window: where the Vulkan loader is, which instance extensions that + * toolkit's surfaces need, and how to turn one of its windows into a VkSurfaceKHR. + * + * Handles are passed as @c void* (VkInstance, always a pointer) and @c uint64_t (VkSurfaceKHR, a + * pointer on 64-bit targets but a plain integer on 32-bit ones) so this header stays free of the + * Vulkan headers -- it has to compile in the @c FSO_BUILD_WITH_VULKAN=OFF configuration too. Use + * vulkan_handle_cast() / vulkan_handle_value() to convert at the ends. + */ +class VulkanSurfaceProvider { + public: + virtual ~VulkanSurfaceProvider() = default; + + /** + * @brief Loads the Vulkan loader and returns @c vkGetInstanceProcAddr + * + * @return The function pointer, or @c nullptr if the loader is unavailable + */ + virtual void* getVulkanProcAddr() = 0; + + /** + * @brief The instance extensions this windowing system's surfaces require + * + * These are merged into the extension list the renderer builds; the renderer still adds its own + * (debug utils, swap chain color space, ...) on top. + * + * @param[out] extensions Receives the extension names + * @return @c true on success + */ + virtual bool getVulkanInstanceExtensions(SCP_vector& extensions) = 0; + + /** + * @brief Creates a Vulkan surface for a viewport + * + * @param view The viewport to create the surface for + * @param vkInstance The @c VkInstance the surface belongs to + * @return The @c VkSurfaceKHR handle, or 0 on failure + */ + virtual uint64_t createVulkanSurface(Viewport* view, void* vkInstance) = 0; + + /** + * @brief Destroys a surface previously returned by createVulkanSurface() + * + * @note The renderer must always go through this rather than calling @c vkDestroySurfaceKHR + * itself: an implementation may not own the surface it handed out. + */ + virtual void destroyVulkanSurface(void* vkInstance, uint64_t surface) = 0; +}; + +/** + * @brief Converts a surface handle from its transport type back to the Vulkan handle type + * @ingroup os_graphics_api + */ +template +inline HandleType vulkan_handle_cast(uint64_t handle) +{ + if constexpr (std::is_pointer::value) { + return reinterpret_cast(static_cast(handle)); + } else { + return static_cast(handle); + } +} + +/** + * @brief Converts a Vulkan handle to the transport type used by VulkanSurfaceProvider + * @ingroup os_graphics_api + */ +template +inline uint64_t vulkan_handle_value(HandleType handle) +{ + if constexpr (std::is_pointer::value) { + return static_cast(reinterpret_cast(handle)); + } else { + return static_cast(handle); + } +} + +} // namespace os diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 70dd601d39a..85665bde03a 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -1162,6 +1162,7 @@ add_file_folder("OsApi" osapi/osregistry.cpp osapi/outwnd.h osapi/outwnd.cpp + osapi/vulkan_surface.h ) # Parse files diff --git a/freespace2/SDLGraphicsOperations.cpp b/freespace2/SDLGraphicsOperations.cpp index 32702c0fa3c..1240b00909c 100644 --- a/freespace2/SDLGraphicsOperations.cpp +++ b/freespace2/SDLGraphicsOperations.cpp @@ -204,8 +204,73 @@ SDLGraphicsOperations::~SDLGraphicsOperations() { } } + if (_vulkanLibraryLoaded) { + SDL_Vulkan_UnloadLibrary(); + _vulkanLibraryLoaded = false; + } + SDL_QuitSubSystem(SDL_INIT_VIDEO); } +void* SDLGraphicsOperations::getVulkanProcAddr() +{ + // Creating a window with SDL_WINDOW_VULKAN already loads the loader, but this must also work + // before any such window exists, and SDL refcounts the load so the extra call is harmless. + if (!_vulkanLibraryLoaded) { + if (!SDL_Vulkan_LoadLibrary(nullptr)) { + mprintf(("Failed to load the Vulkan library: %s\n", SDL_GetError())); + return nullptr; + } + _vulkanLibraryLoaded = true; + } + + auto procAddr = reinterpret_cast(SDL_Vulkan_GetVkGetInstanceProcAddr()); + if (procAddr == nullptr) { + mprintf(("Failed to get vkGetInstanceProcAddr: %s\n", SDL_GetError())); + } + + return procAddr; +} +bool SDLGraphicsOperations::getVulkanInstanceExtensions(SCP_vector& extensions) +{ + uint32_t count = 0; + auto extPtr = SDL_Vulkan_GetInstanceExtensions(&count); + + if (extPtr == nullptr) { + mprintf(("Error in SDL_Vulkan_GetInstanceExtensions: %s\n", SDL_GetError())); + return false; + } + + extensions.reserve(extensions.size() + count); + for (uint32_t i = 0; i < count; ++i) { + extensions.emplace_back(extPtr[i]); + } + + return true; +} +uint64_t SDLGraphicsOperations::createVulkanSurface(os::Viewport* view, void* vkInstance) +{ + Assertion(view != nullptr, "Invalid viewport specified!"); + + // Not VK_NULL_HANDLE: this file also compiles without the Vulkan headers, where the handle type + // comes from SDL_vulkan.h and that macro doesn't exist. + auto surface = os::vulkan_handle_cast(0); + if (!SDL_Vulkan_CreateSurface(view->toSDLWindow(), static_cast(vkInstance), nullptr, &surface)) { + mprintf(("Failed to create Vulkan surface: %s\n", SDL_GetError())); + return 0; + } + + return os::vulkan_handle_value(surface); +} +void SDLGraphicsOperations::destroyVulkanSurface(void* vkInstance, uint64_t surface) +{ + if (surface == 0) { + return; + } + + SDL_Vulkan_DestroySurface(static_cast(vkInstance), + os::vulkan_handle_cast(surface), + nullptr); +} std::unique_ptr SDLGraphicsOperations::createViewport(const os::ViewPortProperties& props) { uint32_t windowflags = 0; diff --git a/freespace2/SDLGraphicsOperations.h b/freespace2/SDLGraphicsOperations.h index 7c15c034b75..1e087388262 100644 --- a/freespace2/SDLGraphicsOperations.h +++ b/freespace2/SDLGraphicsOperations.h @@ -4,8 +4,9 @@ #pragma once #include "osapi/osapi.h" +#include "osapi/vulkan_surface.h" -class SDLGraphicsOperations: public os::GraphicsOperations { +class SDLGraphicsOperations: public os::GraphicsOperations, public os::VulkanSurfaceProvider { public: SDLGraphicsOperations(); ~SDLGraphicsOperations() override; @@ -16,6 +17,19 @@ class SDLGraphicsOperations: public os::GraphicsOperations { void makeOpenGLContextCurrent(os::Viewport* view, os::OpenGLContext* ctx) override; std::unique_ptr createViewport(const os::ViewPortProperties& props) override; + + os::VulkanSurfaceProvider* getVulkanSupport() override { return this; } + + void* getVulkanProcAddr() override; + + bool getVulkanInstanceExtensions(SCP_vector& extensions) override; + + uint64_t createVulkanSurface(os::Viewport* view, void* vkInstance) override; + + void destroyVulkanSurface(void* vkInstance, uint64_t surface) override; + + private: + bool _vulkanLibraryLoaded = false; }; #endif // _SDL_GRAPHICS_OPERATIONS diff --git a/qtfred/README.md b/qtfred/README.md index 19372dc5d81..2e1a694af3a 100644 --- a/qtfred/README.md +++ b/qtfred/README.md @@ -42,44 +42,56 @@ functionality the DLLs will also be copied to the correct paths in the destinati Rendering backend (OpenGL / Vulkan) ------------------------------------ -qtFRED currently always initializes the OpenGL renderer, even if `-vulkan` is passed on the command line -(`Fred_running` forces `mode = GraphicsAPI::OpenGL` in `gr_init()`, `code/graphics/2d.cpp`). This is intentional: -qtFRED's windowing (`QtGraphicsOperations`/`QtViewport` in `qtfred/src/ui/QtGraphicsOperations.cpp`) only knows how -to create a Qt-native (`QOpenGLWidget`-backed) render surface. Unlike the retail engine's `SDLGraphicsOperations`, -it never creates a real `SDL_Window`, and `QtViewport::toSDLWindow()` unconditionally returns `nullptr`. - -The Vulkan backend (`code/graphics/vulkan/`), however, was written exclusively against `SDLGraphicsOperations`'s -windowing: -- `VulkanRenderer::initializeInstance()` (`VulkanRendererSetup.cpp`) obtains `vkGetInstanceProcAddr` via - `SDL_Vulkan_GetVkGetInstanceProcAddr()`, which only returns a valid pointer once SDL has loaded the Vulkan - loader — which normally happens automatically when a window is created with the `SDL_WINDOW_VULKAN` flag - (see `freespace2/SDLGraphicsOperations.cpp`). -- `VulkanRenderer::initializeSurface()` creates the `VkSurfaceKHR` via `SDL_Vulkan_CreateSurface(window, ...)`, - which likewise needs a real `SDL_Window*`. - -Since qtFRED never creates an SDL window, both calls fail: the first call aborts immediately -(`VULKAN_HPP_DEFAULT_DISPATCHER.init()` is handed a null function pointer), and even if that were papered over, -surface creation would fail right after. - -If you removed the `Fred_running` OpenGL override in `gr_init()` to experiment with Vulkan in the editor, this is -the crash you'll hit. To make Vulkan actually work in qtFRED, `QtGraphicsOperations` needs a real Vulkan surface -path, e.g. one of: - -1. Create a genuine (possibly hidden/embedded) `SDL_Window` with `SDL_WINDOW_VULKAN` set purely so the existing - SDL-based Vulkan plumbing (loader + instance extensions + surface creation) keeps working unmodified, while - still presenting through Qt. -2. Bypass SDL for Vulkan entirely: load the Vulkan loader directly (`SDL_Vulkan_LoadLibrary(nullptr)` works - without any window), and create the `VkSurfaceKHR` from the Qt window's native handle - (`QWindow::winId()`/native handle APIs) using the appropriate platform extension - (`VK_KHR_win32_surface`, `VK_KHR_xcb_surface`, `VK_KHR_wayland_surface`, etc.) instead of - `SDL_Vulkan_CreateSurface`. - -Either approach is a real chunk of implementation work, not a quick fix — plan for it as its own task rather than -bundling it with unrelated changes. +qtFRED can render through either backend. OpenGL is still the default; pass `-vulkan` on the +command line, or choose it in Preferences > Graphics, to use Vulkan instead. If the windowing +implementation can't present through Vulkan (a Qt build without `QT_CONFIG(vulkan)`, or a platform +plugin with no known surface extension), `gr_init()` logs it and falls back to OpenGL rather than +failing (`code/graphics/2d.cpp`). + +Vulkan presents through Qt's own Vulkan integration (`QWindow::setSurfaceType(QSurface::VulkanSurface)` +plus `QVulkanInstance`, adopting the engine's `VkInstance`) rather than through SDL. +`QtGraphicsOperations` implements `os::VulkanSurfaceProvider` (`code/osapi/vulkan_surface.h`), the +interface `VulkanRenderer` uses for everything that depends on the windowing toolkit — loading the +Vulkan loader, the required instance extensions, and `VkSurfaceKHR` creation for a given viewport. +`SDLGraphicsOperations` implements the same interface for the game. `fred2`'s `MFCGraphicsOperations` +does not, so `getVulkanSupport()` falls back to the `os::GraphicsOperations` base class default of +`nullptr` there, and `-vulkan` always falls back to OpenGL in the MFC editor. + +qtFRED presents to two independent surfaces: the main viewport, and the briefing map's own window, +which renders on its own timer and switches between them with `gr_use_viewport()`. `VulkanRenderer` +keeps a `VulkanPresentTarget` (surface, swap chain, framebuffers, sync objects) per viewport it has +been asked to present to, created the first time that viewport is used and torn down when the +viewport goes away — e.g. when the briefing editor dialog is closed, which happens against a live +device since `BriefingEditorDialog` is built with `Qt::WA_DeleteOnClose`. + +**Known limitation:** the main viewport does not present while the briefing map's timer is driving +the render loop (it sits on an already-acquired swap chain image the whole time). Not a regression +and doesn't block using the backend; see that document's *Follow-up work* section. + +`fred2/` (the Windows MFC editor) is out of scope and stays OpenGL-only. Known issues ------------ +### The Gamma preference does nothing on OpenGL +Preferences > Graphics > Gamma sets `Gr_gamma`, but OpenGL only applies it inside the +`if (Cmdline_window_res)` branch of `gr_opengl_flip()`, and `gr_init()` deliberately does not set +`Cmdline_window_res` when `Fred_running` — so in the editor the value is stored and then ignored. +The Vulkan backend has no such branch and does apply it. + +This was invisible for as long as nothing looked at the value: qtFRED inherited +`gr_set_gamma(3.0f)` verbatim from retail FRED2 (`fred2/management.cpp` still has it), where it was +equally dead. Once the Vulkan backend started honouring it, that 3.0 became a whole-frame +`pow(colour, 1/3)` and the viewport was visibly wrong. The default is now 1.0, which is both the +engine's own default and what the editor has always actually rendered with. + +Note that a default is only a default: anyone who ran an earlier build of this branch has +`view_graphics_gamma=3` written into `qtFRED.conf` (or `%APPDATA%` on Windows) and needs to change +it in Preferences — the stored value wins. + +Fixing the OpenGL side means giving FRED the intermediate buffer the gamma pass reads from, which +is exactly the `Cmdline_window_res` FRED integration work `gr_init()` already flags as unfinished. + ### Blank/empty render viewport under Wayland On Linux, qtFRED's main 3D viewport can render as a completely blank panel — no starfield, no grid, no models, nothing — when running under a native Wayland session (confirmed on Arch Linux diff --git a/qtfred/help-src/doc/general/PreferencesDialog.html b/qtfred/help-src/doc/general/PreferencesDialog.html index cb4bfca04ba..cdcbda3b507 100644 --- a/qtfred/help-src/doc/general/PreferencesDialog.html +++ b/qtfred/help-src/doc/general/PreferencesDialog.html @@ -37,11 +37,20 @@

Graphics

only - they are stored separately from the game's own graphics options and never change them.

+

Renderer

+
    +
  • Backend - the graphics API the viewport renders with, + OpenGL or Vulkan. OpenGL is the default. Vulkan is only offered when + this build of QtFRED was compiled with it. A -vulkan or + -opengl command-line option always overrides this setting. + Requires a restart.
  • +
+

Post-processing

Enable post-processing in the viewport routes the viewport through the same HDR rendering pipeline the game uses, adding bloom, -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 +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.

@@ -53,6 +62,10 @@

Shadows & anti-aliasing

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. +
  • Shadow method - whether shadows are rendered with + cascaded shadow maps or hardware raytracing. Raytraced is only offered + on the Vulkan backend, and only when your GPU and driver support it; + the control is greyed out otherwise. Applies immediately.
  • Anti-aliasing - post-process anti-aliasing mode (FXAA or SMAA, in increasing quality). Applies immediately.
  • MSAA - multisample anti-aliasing for the 3D scene, off @@ -66,15 +79,19 @@

    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 the - control is greyed out if it reports nothing worth offering. Left alone, - QtFRED uses your hardware's maximum. Requires a restart.
  • + 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.

    +

    Brightness of the viewport, from 0.10 to 5.00, applied as +colour1/gamma. The default of 1.00 leaves the viewport +untouched.

    +

    This control currently only does anything when QtFRED is running on the +Vulkan renderer. On OpenGL the editor renders straight to the window +without the intermediate buffer the gamma pass needs, so the setting is stored +but has no visible effect.

    Grid

    Configures the reference grid shown in the main viewport. You can set which diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 9ee0517f3a2..afc86a9b43e 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -3,6 +3,8 @@ #include "Editor.h" #include "EditorViewport.h" +#include + #include #include #include @@ -1002,10 +1004,17 @@ void FredRenderer::render_frame(int cur_object_index, qreal scale) { - // Make sure our OpenGL context is used for rendering + // Make sure our render target is the one being drawn into gr_use_viewport(_targetView); - uint32_t width = _targetView->getSize().first * scale; - uint32_t height = _targetView->getSize().second * scale; + + // Round rather than truncate: getSize() is in logical pixels, and Qt rounds when it sizes the + // native surface from them. Truncating lands a pixel short of the real surface on fractional + // display scaling, which leaves gr_screen (and so the viewport gr_setup_viewport derives from + // it) disagreeing with the size the renderer is actually presenting at. + const auto logicalSize = _targetView->getSize(); + const auto width = static_cast(std::lround(logicalSize.first * scale)); + const auto height = static_cast(std::lround(logicalSize.second * scale)); + // Resize the rendering window in case the previous size was different gr_screen_resize(width, height); diff --git a/qtfred/src/mission/GraphicsSettings.cpp b/qtfred/src/mission/GraphicsSettings.cpp index 560c8f3b6dc..5013f9ce3b5 100644 --- a/qtfred/src/mission/GraphicsSettings.cpp +++ b/qtfred/src/mission/GraphicsSettings.cpp @@ -11,8 +11,10 @@ namespace { const char* SETTINGS_GROUP = "Preferences"; +const char* KEY_BACKEND = "view_graphics_backend"; const char* KEY_POST_PROCESSING = "view_enable_post_processing"; const char* KEY_SHADOW_QUALITY = "view_graphics_shadow_quality"; +const char* KEY_SHADOW_METHOD = "view_graphics_shadow_method"; 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"; @@ -37,9 +39,10 @@ T readEnum(const QSettings& settings, const char* key, T fallback, T highest) 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; + return backend == rhs.backend && enablePostProcessing == rhs.enablePostProcessing && + shadowQuality == rhs.shadowQuality && shadowMethod == rhs.shadowMethod && aaMode == rhs.aaMode && + msaaSamples == rhs.msaaSamples && gamma == rhs.gamma && textureFilter == rhs.textureFilter && + anisotropy == rhs.anisotropy; } SCP_vector GraphicsSettings::validMsaaSampleCounts() @@ -54,8 +57,25 @@ GraphicsSettings GraphicsSettings::load() QSettings settings; settings.beginGroup(SETTINGS_GROUP); + if (settings.contains(KEY_BACKEND)) { + // Only OpenGL and, on a build that has it, Vulkan are valid explicit choices. Stub, a Vulkan + // choice this build can't honour, or a garbage value from a hand-edited file all keep + // GraphicsAPI::Default, same as an absent key -- see the field comment for why that matters. + const auto raw = static_cast(settings.value(KEY_BACKEND).toInt()); + if (raw == GraphicsAPI::OpenGL) { + out.backend = GraphicsAPI::OpenGL; + } +#ifdef WITH_VULKAN + else if (raw == GraphicsAPI::Vulkan) { + out.backend = GraphicsAPI::Vulkan; + } +#endif + } + out.enablePostProcessing = settings.value(KEY_POST_PROCESSING, out.enablePostProcessing).toBool(); out.shadowQuality = readEnum(settings, KEY_SHADOW_QUALITY, out.shadowQuality, ShadowQuality::Ultra); + out.shadowMethod = + readEnum(settings, KEY_SHADOW_METHOD, out.shadowMethod, ShadowRenderMethod::Raytraced); out.aaMode = readEnum(settings, KEY_AA_MODE, out.aaMode, AntiAliasMode::SMAA_Ultra); out.gamma = settings.value(KEY_GAMMA, out.gamma).toFloat(); @@ -85,11 +105,16 @@ void GraphicsSettings::save() const settings.setValue(KEY_POST_PROCESSING, enablePostProcessing); settings.setValue(KEY_SHADOW_QUALITY, static_cast(shadowQuality)); + settings.setValue(KEY_SHADOW_METHOD, static_cast(shadowMethod)); 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 (backend != GraphicsAPI::Default) { + settings.setValue(KEY_BACKEND, static_cast(backend)); + } + if (textureFilter != NO_TEXTURE_FILTER_CHOICE) { settings.setValue(KEY_TEXTURE_FILTER, textureFilter); } @@ -105,12 +130,25 @@ void GraphicsSettings::applyLive() const { Gr_aa_mode = aaMode; gr_set_gamma(gamma); + + // Same as Gr_aa_mode: this is what Graphics.ShadowRenderMethod's own .bind_to() does, and it is + // harmless to set even when raytraced shadows are not actually available this session -- + // shadows_use_raytracing() re-checks shadows_raytracing_supported() before anything reads it. + Shadow_render_method = shadowMethod; } GraphicsSettings GraphicsSettings::applyBeforeGrInit() { const GraphicsSettings settings = load(); + // Commandline always wins (see gr_init()'s own "Commandline ALWAYS wins" comment), so only fill + // in the backend here if -vulkan/-opengl didn't already set it. If the user has never chosen one + // in Preferences either, settings.backend is itself GraphicsAPI::Default, and this is a no-op -- + // gr_init() falls through to its own VideocardFs2open/OpenGL fallback exactly as it always has. + if (Cmdline_graphics_api == GraphicsAPI::Default) { + Cmdline_graphics_api = settings.backend; + } + Cmdline_msaa_enabled = settings.msaaSamples; Shadow_quality = settings.shadowQuality; diff --git a/qtfred/src/mission/GraphicsSettings.h b/qtfred/src/mission/GraphicsSettings.h index 8e85a368360..8c97b20ef54 100644 --- a/qtfred/src/mission/GraphicsSettings.h +++ b/qtfred/src/mission/GraphicsSettings.h @@ -8,10 +8,17 @@ 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. + * Single owner of the *rules*: the QSettings keys, the defaults, and when each value 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 values themselves are ordinary fields and have more than one writer. Preferences edits a copy + * and writes the whole struct back on apply (PreferencesDialogModel); View > Enable Post Processing + * flips enablePostProcessing in place (FredView::syncViewOptions). Those cannot race -- the + * Preferences dialog is modal, and the menu action re-reads the field on every viewIdle so its + * check state follows an edit made in the dialog. A third writer, or a modeless Preferences, would + * break that; route it through here rather than adding another direct one. * * The settings split by *when* they can take effect, which is what the two apply functions below * encode: @@ -27,8 +34,10 @@ namespace fso::fred { 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. + // one, the hardware maximum for the other. The backend has a comparable existing default: the + // VideocardFs2open config entry gr_init() already reads. All three 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; @@ -40,11 +49,67 @@ struct GraphicsSettings { */ bool enablePostProcessing = false; + /** + * @brief The renderer backend, applied before gr_init() the same way Shadow_quality is. + * + * GraphicsAPI::Default means "the user has not chosen one in Preferences" -- gr_init() already + * has a fallback for that (the VideocardFs2open config entry, then OpenGL), and qtFRED must not + * get in front of it: a user relying on that entry to launch qtFRED into Vulkan without passing + * -vulkan every time would otherwise lose that the moment they saved any other preference, since + * save() would start persisting an explicit OpenGL nobody chose. So this is only ever written or + * applied once Preferences actually sets it to OpenGL or Vulkan -- see save() and + * applyBeforeGrInit(). `-vulkan`/`-opengl` on the command line still win over an explicit choice + * here too: applyBeforeGrInit() only sets Cmdline_graphics_api from this when parse_cmdline() left + * it at GraphicsAPI::Default, matching the "commandline always wins" rule gr_init() enforces. + * load() also refuses to hand back GraphicsAPI::Vulkan on a build without WITH_VULKAN, so a + * settings file left over from a Vulkan-enabled build can't make a later build without it hit the + * Error() in gr_init_function_pointers(). + */ + GraphicsAPI backend = GraphicsAPI::Default; + ShadowQuality shadowQuality = ShadowQuality::Disabled; + + /** + * @brief Whether shadows are cast via cascaded shadow maps or hardware raytracing. + * + * A live-apply setting: unlike ShadowQuality, the engine's own Graphics.ShadowRenderMethod + * option just binds straight to Shadow_render_method with no initial-only change_listener, + * so applyLive() can set it directly, same as aaMode. + * + * Raytraced only actually renders that way when shadows_use_raytracing() agrees, which + * requires shadows_raytracing_supported() (Vulkan plus hardware ray-query support) -- + * exactly the same check Graphics.ShadowRenderMethod's own enumerator uses to decide + * whether to offer the value at all. So this field can hold Raytraced on a build/session + * that can't honour it with no effect on rendering, same as the engine option it mirrors. + * Preferences populates its combo from that option (see PreferencesDialog::initializeUi()), + * so it reads as greyed out whenever the choice would be inert, rather than qtFRED needing + * its own Vulkan/hardware check. + * + * Graphics.RTShadowQuality (Low vs. High local-light coverage) is deliberately not exposed + * here -- not useful enough in the editor to be worth a second control. + */ + ShadowRenderMethod shadowMethod = ShadowRenderMethod::ShadowMap; + 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 + + /** + * @brief Viewport brightness, as the exponent in pow(colour, 1/gamma). + * + * 1.0 (identity, and the engine's own default) is what qtFRED has always *rendered* with, + * whatever this said. It used to default to 3.0, copied verbatim from retail FRED2 + * (fred2/management.cpp still has the same gr_set_gamma(3.0f)), where it has never had any + * effect: OpenGL only applies Gr_gamma inside the `if (Cmdline_window_res)` branch of + * gr_opengl_flip(), and gr_init() deliberately does not set Cmdline_window_res for FRED. + * + * The Vulkan backend has no such branch -- encodeToSwapChain() always runs the gamma encode -- + * so a 3.0 default made the Vulkan viewport render every frame through pow(colour, 1/3) while + * OpenGL rendered it untouched. Keeping the identity here is what makes the two backends agree. + * + * Note the control is still inert under OpenGL in the editor; see qtfred/README.md. + */ + float gamma = 1.0f; int textureFilter = NO_TEXTURE_FILTER_CHOICE; //!< 0 = bilinear, 1 = trilinear float anisotropy = NO_ANISOTROPY_CHOICE; //!< 1.0 = off, otherwise a power of two diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp index 4bbe97d6e41..ca4394d676f 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -942,6 +942,9 @@ void FredView::syncViewOptions() { connectActionToViewSetting(ui->actionLighting_from_Suns, &_viewport->view.Lighting_on); connectActionToViewSetting(ui->actionRender_Full_Detail, &_viewport->view.FullDetail); + // The one Preferences > Graphics setting that is also a View menu toggle. Safe to write directly: + // nothing has to be pushed into the engine for it (render_frame() reads it each frame), and it + // cannot fight the Preferences copy -- see the note on GraphicsSettings. connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.Graphics.enablePostProcessing); connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances); diff --git a/qtfred/src/ui/QtGraphicsOperations.cpp b/qtfred/src/ui/QtGraphicsOperations.cpp index 146d20fc292..ad16af6a972 100644 --- a/qtfred/src/ui/QtGraphicsOperations.cpp +++ b/qtfred/src/ui/QtGraphicsOperations.cpp @@ -15,8 +15,45 @@ #include "FredApplication.h" +#include + +#if QTFRED_HAS_VULKAN +#include +#endif + namespace { +#if QTFRED_HAS_VULKAN +/** + * @brief The surface extension the current Qt platform plugin's windows need + * + * Deliberately derived from Qt rather than from SDL_Vulkan_GetInstanceExtensions(): SDL reports + * what *its* video driver would need, and the two can disagree -- SDL happily picks Wayland while + * Qt runs under XCB, which is exactly what qtFRED's own XWayland workaround asks users to do. The + * surface comes from Qt, so the extension has to as well. + * + * @return The extension name, or nullptr if this platform has no known mapping + */ +const char* vulkanPlatformSurfaceExtension() { + const auto platform = QGuiApplication::platformName(); + + if (platform == QLatin1String("xcb")) { + return "VK_KHR_xcb_surface"; + } + if (platform.startsWith(QLatin1String("wayland"))) { + return "VK_KHR_wayland_surface"; + } + if (platform == QLatin1String("windows")) { + return "VK_KHR_win32_surface"; + } + if (platform == QLatin1String("cocoa")) { + return "VK_EXT_metal_surface"; + } + + return nullptr; +} +#endif + QSurfaceFormat getSurfaceFormat(const os::ViewPortProperties& viewProps, const os::OpenGLContextAttributes& glAttrs) { QSurfaceFormat format; @@ -63,6 +100,26 @@ QSurfaceFormat getSurfaceFormat(const os::ViewPortProperties& viewProps, const o namespace fso { namespace fred { +namespace { +bool g_vulkanSurfaces = false; +#if QTFRED_HAS_VULKAN +// Set once QtGraphicsOperations builds the instance, cleared when it goes. Windows need to reach it +// at construction time (see fredVulkanInstance()), which is well before they could be handed one. +QVulkanInstance* g_vulkanInstance = nullptr; +#endif +} // namespace + +bool fredUsingVulkanSurfaces() { + return g_vulkanSurfaces; +} + +#if QTFRED_HAS_VULKAN + +QVulkanInstance* fredVulkanInstance() { + return g_vulkanInstance; +} +#endif + QtGraphicsOperations::QtGraphicsOperations(Editor* editor) : _editor(editor) { if ( !SDL_InitSubSystem(SDL_INIT_VIDEO) ) { @@ -71,9 +128,164 @@ QtGraphicsOperations::QtGraphicsOperations(Editor* editor) : _editor(editor) { } } QtGraphicsOperations::~QtGraphicsOperations() { + if (_vulkanLibraryLoaded) { + SDL_Vulkan_UnloadLibrary(); + _vulkanLibraryLoaded = false; + } + SDL_QuitSubSystem(SDL_INIT_VIDEO); } +os::VulkanSurfaceProvider* QtGraphicsOperations::getVulkanSupport() { +#if QTFRED_HAS_VULKAN + // gr_init() asks this before the renderer exists in order to decide whether it can honour a + // Vulkan request, so the platform check has to live here rather than in a later step. The + // QApplication is constructed in main() long before initialize() runs, so platformName() is + // meaningful at this point. + const auto* extension = vulkanPlatformSurfaceExtension(); + if (extension == nullptr) { + mprintf(("qtFRED: no Vulkan surface extension known for Qt platform '%s'; Vulkan unavailable.\n", + QGuiApplication::platformName().toUtf8().constData())); + return nullptr; + } + + return this; +#else + return nullptr; +#endif +} + +void* QtGraphicsOperations::getVulkanProcAddr() { + // SDL is still the right thing to load the loader with: libvulkan is windowing-system + // agnostic, so nothing here depends on SDL's video driver matching Qt's platform plugin -- + // only the surface extension does, and that comes from Qt (see vulkanPlatformSurfaceExtension). + // SDL's per-platform library naming is well tested; ours would not be. + if (!_vulkanLibraryLoaded) { + if (!SDL_Vulkan_LoadLibrary(nullptr)) { + mprintf(("qtFRED: failed to load the Vulkan library: %s\n", SDL_GetError())); + return nullptr; + } + _vulkanLibraryLoaded = true; + } + + auto procAddr = reinterpret_cast(SDL_Vulkan_GetVkGetInstanceProcAddr()); + if (procAddr == nullptr) { + mprintf(("qtFRED: failed to get vkGetInstanceProcAddr: %s\n", SDL_GetError())); + } + + return procAddr; +} + +bool QtGraphicsOperations::getVulkanInstanceExtensions(SCP_vector& extensions) { +#if QTFRED_HAS_VULKAN + const auto* platformExtension = vulkanPlatformSurfaceExtension(); + if (platformExtension == nullptr) { + return false; + } + + extensions.emplace_back(VK_KHR_SURFACE_EXTENSION_NAME); + extensions.emplace_back(platformExtension); + + mprintf(("qtFRED: Vulkan surface extension for Qt platform '%s': %s\n", + QGuiApplication::platformName().toUtf8().constData(), + platformExtension)); + + return true; +#else + (void)extensions; + return false; +#endif +} + +uint64_t QtGraphicsOperations::createVulkanSurface(os::Viewport* view, void* vkInstance) { +#if QTFRED_HAS_VULKAN + auto* qtView = dynamic_cast(view); + if (qtView == nullptr) { + mprintf(("qtFRED: cannot create a Vulkan surface for a non-Qt viewport.\n")); + return 0; + } + + auto* window = dynamic_cast(qtView->getRenderSurface()); + if (window == nullptr) { + mprintf(("qtFRED: render surface is not a QWindow, cannot create a Vulkan surface.\n")); + return 0; + } + + if (!_vulkanInstance) { + _vulkanInstance.reset(new QVulkanInstance()); + _vulkanInstance->setVkInstance(static_cast(vkInstance)); + + if (!_vulkanInstance->create()) { + mprintf(("qtFRED: QVulkanInstance::create() failed with error %d.\n", + _vulkanInstance->errorCode())); + _vulkanInstance.reset(); + return 0; + } + + g_vulkanInstance = _vulkanInstance.get(); + } + + // A window inside QWidget::createWindowContainer() cannot be rebuilt here: destroy() takes the + // container's embedding with it and the window is never composited into the widget again, which + // presents as a permanently blank panel rather than as an error. Windows that can be presented + // to therefore set their surface type and instance in their own constructor (see + // fredVulkanInstance()); all that is left to do here is realize it. + if (window->surfaceType() != QSurface::VulkanSurface) { + mprintf(("qtFRED: window was built as surface type %d rather than VulkanSurface; it cannot be " + "presented to.\n", static_cast(window->surfaceType()))); + return 0; + } + + if (window->vulkanInstance() == nullptr) { + window->setVulkanInstance(_vulkanInstance.get()); + } + + if (window->handle() == nullptr) { + window->create(); + } + + const auto surface = QVulkanInstance::surfaceForWindow(window); + if (surface == VK_NULL_HANDLE) { + mprintf(("qtFRED: QVulkanInstance::surfaceForWindow() failed.\n")); + return 0; + } + + const auto handle = os::vulkan_handle_value(surface); + _vulkanSurfaceWindows[handle] = window; + + return handle; +#else + (void)view; + (void)vkInstance; + return 0; +#endif +} + +void QtGraphicsOperations::destroyVulkanSurface(void* vkInstance, uint64_t surface) { +#if QTFRED_HAS_VULKAN + (void)vkInstance; + + const auto entry = _vulkanSurfaceWindows.find(surface); + if (entry == _vulkanSurfaceWindows.end()) { + return; + } + + // Qt owns the surface -- it is destroyed along with the native window, and calling + // vkDestroySurfaceKHR on it ourselves would double-free. Destroying the native window here is + // what releases it, and it has to happen now: qtFRED's shutdown() runs gr_close() (which + // destroys the VkInstance) before os_cleanup() frees the viewports, so leaving it to the + // window's own destructor would tear the surface down against a dead instance. + if (entry->second != nullptr) { + entry->second->destroy(); + } + + _vulkanSurfaceWindows.erase(entry); +#else + (void)vkInstance; + (void)surface; +#endif +} + std::unique_ptr QtGraphicsOperations::createOpenGLContext(os::Viewport* viewport, const os::OpenGLContextAttributes& gl_attrs) { auto qtPort = static_cast(viewport); @@ -114,8 +326,18 @@ QtViewport::~QtViewport() { } std::unique_ptr QtGraphicsOperations::createViewport(const os::ViewPortProperties& props) { + // Has to be set before the first window is constructed: a presentable window picks its surface + // type in its own constructor and cannot change it afterwards. + g_vulkanSurfaces = props.enable_vulkan; + std::unique_ptr mw(new FredView()); - mw->getRenderWidget()->setSurfaceFormat(getSurfaceFormat(props, props.gl_attributes)); + + // Under Vulkan the window must not be realized yet: its surface type and QVulkanInstance have + // to be set first, and neither is known until the renderer has built the instance -- which + // happens after this call. createVulkanSurface() finishes the job. + if (!props.enable_vulkan) { + mw->getRenderWidget()->setSurfaceFormat(getSurfaceFormat(props, props.gl_attributes)); + } auto viewPtr = mw.get(); auto view = std::unique_ptr(new QtViewport(std::move(mw), props)); @@ -141,6 +363,12 @@ std::pair QtViewport::getSize() { return std::make_pair((uint32_t) size.width(), (uint32_t) size.height()); } void QtViewport::swapBuffers() { + if (_viewProps.enable_vulkan) { + // Presentation is the swap chain's job, inside VulkanRenderer::flip(). There is no current + // QOpenGLContext to ask, so this would be a null dereference rather than a no-op. + return; + } + auto qSurf = dynamic_cast(_viewportWindow->getRenderSurface()); if (qSurf && qSurf->isExposed()) { QOpenGLContext::currentContext()->swapBuffers(qSurf); diff --git a/qtfred/src/ui/QtGraphicsOperations.h b/qtfred/src/ui/QtGraphicsOperations.h index 83f34a41fb8..90033efff4f 100644 --- a/qtfred/src/ui/QtGraphicsOperations.h +++ b/qtfred/src/ui/QtGraphicsOperations.h @@ -1,15 +1,53 @@ #pragma once #include +#include #include "mission/Editor.h" #include "FredView.h" #define WIN32_LEAN_AND_MEAN +#include #include +// Vulkan needs both halves: the engine's backend has to be compiled in, and Qt has to have been +// built with Vulkan support (it is a compile-time feature there, so the header itself is absent +// otherwise). +#if defined(WITH_VULKAN) && QT_CONFIG(vulkan) +#define QTFRED_HAS_VULKAN 1 +#include +#include +#else +#define QTFRED_HAS_VULKAN 0 +#endif + namespace fso { namespace fred { +/** + * @brief Whether qtFRED's presentable windows must be built as Vulkan surfaces + * + * Set from ViewPortProperties::enable_vulkan when the renderer asks for its viewport, which is + * before any window that can be presented to is constructed. + */ +bool fredUsingVulkanSurfaces(); + +#if QTFRED_HAS_VULKAN +/** + * @brief The QVulkanInstance qtFRED's Vulkan surfaces are created against, or nullptr + * + * A QWindow has to know both its surface type and its instance before it is first realized, and a + * window living inside QWidget::createWindowContainer() cannot be un-realized and rebuilt later: + * destroy() takes the container's embedding with it and the window stops being composited into the + * widget at all (which looks exactly like a render path that draws nothing). So any window that + * might be presented to has to be constructed as a Vulkan surface from the start, which means + * reaching the instance from outside the graphics operations. + * + * Valid from the first surface creation -- which happens during gr_init(), long before any dialog + * can be opened -- until the graphics operations are destroyed. + */ +QVulkanInstance* fredVulkanInstance(); +#endif + class QtOpenGLContext: public os::OpenGLContext { std::unique_ptr _context; public: @@ -48,10 +86,25 @@ class QtViewport: public QtSurfaceViewport { FredView* getWindow(); }; -class QtGraphicsOperations: public os::GraphicsOperations { +class QtGraphicsOperations: public os::GraphicsOperations, public os::VulkanSurfaceProvider { Editor* _editor = nullptr; QtOpenGLContext* _lastContext = nullptr; + + bool _vulkanLibraryLoaded = false; + +#if QTFRED_HAS_VULKAN + // Adopts the engine's VkInstance rather than creating one, so Qt can hand out surfaces for its + // own windows while the renderer keeps the instance it built (with the debug-messenger and + // validation-feature structs chained into vkCreateInstance, which QVulkanInstance can't express). + std::unique_ptr _vulkanInstance; + + // Surfaces belong to the QWindow Qt created them for, and qtFRED tears the renderer down + // (gr_close) before the windows (os_cleanup), so we have to release them explicitly while the + // instance is still alive. QPointer so a window that somehow went first reads as null. + SCP_unordered_map> _vulkanSurfaceWindows; +#endif + public: QtGraphicsOperations(Editor* editor); ~QtGraphicsOperations() override; @@ -62,6 +115,16 @@ class QtGraphicsOperations: public os::GraphicsOperations { void makeOpenGLContextCurrent(os::Viewport* view, os::OpenGLContext* ctx) override; std::unique_ptr createViewport(const os::ViewPortProperties& props) override; + + os::VulkanSurfaceProvider* getVulkanSupport() override; + + void* getVulkanProcAddr() override; + + bool getVulkanInstanceExtensions(SCP_vector& extensions) override; + + uint64_t createVulkanSurface(os::Viewport* view, void* vkInstance) override; + + void destroyVulkanSurface(void* vkInstance, uint64_t surface) override; }; } diff --git a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp index 08b5c878021..ca6c31ba62d 100644 --- a/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BriefingEditorDialog.cpp @@ -110,6 +110,8 @@ void BriefingEditorDialog::accept() if (_model->apply()) { ui->defaultMusicWidget->stopPlayback(); ui->musicPackWidget->stopPlayback(); + // Must run before QDialog::accept() hides us -- see BriefingMapWidget::releaseRenderTarget(). + _mapWidget->releaseRenderTarget(); QDialog::accept(); _viewportLock.reset(); // unlock before restoring the grid so the viewport can process controls again create_default_grid(); // restore the grid back to the normal version @@ -125,6 +127,8 @@ void BriefingEditorDialog::reject() if (rejectOrCloseHandler(this, _model.get(), _viewport)) { ui->defaultMusicWidget->stopPlayback(); ui->musicPackWidget->stopPlayback(); + // Must run before QDialog::reject() hides us -- see BriefingMapWidget::releaseRenderTarget(). + _mapWidget->releaseRenderTarget(); QDialog::reject(); // actually close _viewportLock.reset(); // unlock before restoring the grid so the viewport can process controls again create_default_grid(); // restore the grid back to the normal version diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index 0067bb2b90c..ccb4e6341ff 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -5,6 +5,7 @@ #include #include +#include "ui/QtGraphicsOperations.h" #include "ui/util/SignalBlockers.h" #include "ui/widgets/sexp_tree_view.h" @@ -19,9 +20,8 @@ namespace { // 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. +// 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()) { @@ -38,12 +38,45 @@ void populateFromEngineOption(QComboBox* combo, const char* configKey) const auto values = engineOptionValues(configKey); for (const auto& value : values) { - combo->addItem(QString::fromStdString(value.display)); + // Carry the value on the item rather than inferring it from the item's position. Enum- and + // int-valued options serialize to their underlying integer (set_defaults() in + // options/Option.h), so ValueDescription::serialized is the value itself. + // + // Position is emphatically not the same thing: the engine filters these lists at runtime -- + // shadows_remove_unsupported_options() already drops Graphics.ShadowRenderMethod's raytraced + // entry on hardware that can't do it -- so the moment any of these gains a filtered entry, + // index-as-value would silently start selecting the wrong thing. + bool parsed = false; + const int itemValue = QString::fromStdString(value.serialized).toInt(&parsed); + if (!parsed) { + mprintf(("PreferencesDialog: option %s has a non-integer value '%s'; skipping it.\n", + configKey, value.serialized.c_str())); + continue; + } + + combo->addItem(QString::fromStdString(value.display), itemValue); } // 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()); + combo->setEnabled(combo->count() > 0); +} + +// Select the item carrying @p value, leaving the combo alone if nothing does -- which is what a +// setting the engine filtered out of this session's list looks like. +void selectComboValue(QComboBox* combo, int value) +{ + const int index = combo->findData(value); + if (index >= 0) { + combo->setCurrentIndex(index); + } +} + +// The value the user picked, or @p fallback while the combo is empty or has no selection. +int currentComboValue(const QComboBox* combo, int fallback) +{ + const auto data = combo->currentData(); + return data.isValid() ? data.toInt() : fallback; } // The model stores graphics settings as one struct, so an edit is read-modify-write. Doing it this @@ -144,20 +177,32 @@ void PreferencesDialog::initializeUi() { // (Items declared in the .ui file were added before the connections existed and so were safe.) util::SignalBlockers blockers(this); + // Every combo on this tab carries its value as item data, so nothing below depends on an item's + // position -- see populateFromEngineOption(). + ui->backendCombo->addItem(tr("OpenGL"), static_cast(GraphicsAPI::OpenGL)); +#if QTFRED_HAS_VULKAN + ui->backendCombo->addItem(tr("Vulkan"), static_cast(GraphicsAPI::Vulkan)); +#endif + populateFromEngineOption(ui->shadowQualityCombo, "Graphics.Shadows"); + // Only offers Raytraced on a Vulkan build/session with raytraced-shadow hardware support -- + // shadows_remove_unsupported_options() removes Graphics.ShadowRenderMethod entirely otherwise, + // so engineOptionValues() comes back empty and the combo greys itself out. That is also why + // there is no separate Vulkan-mode check here: this option already only exists when Vulkan (and + // the hardware) can actually honour it. + populateFromEngineOption(ui->shadowMethodCombo, "Graphics.ShadowRenderMethod"); 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)); + for (float level : gr_get_supported_anisotropy_levels()) { + ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0), level); } - ui->anisotropyCombo->setEnabled(!_anisotropyLevels.empty()); + ui->anisotropyCombo->setEnabled(ui->anisotropyCombo->count() > 0); for (int samples : GraphicsSettings::validMsaaSampleCounts()) { - ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples)); + ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples), samples); } // Build the controls key-binding form dynamically from the registered bindings @@ -195,29 +240,28 @@ void PreferencesDialog::updateUi() { // Graphics const GraphicsSettings& graphics = _model->getGraphics(); + // selectComboValue() leaves the combo alone when nothing carries the value, which covers both + // "not chosen" sentinels and a choice this session can't offer (Vulkan on a build without it, + // Raytraced on hardware without it). Each combo keeps its first item selected in that case -- + // and because updateUi() runs under a SignalBlockers guard, that display fallback never fires a + // slot and never turns itself into an explicit choice. See GraphicsSettings::backend. + selectComboValue(ui->backendCombo, static_cast(graphics.backend)); ui->enablePostProcessing->setChecked(graphics.enablePostProcessing); - ui->shadowQualityCombo->setCurrentIndex(static_cast(graphics.shadowQuality)); - ui->aaModeCombo->setCurrentIndex(static_cast(graphics.aaMode)); - ui->gammaSpin->setValue(graphics.gamma); + selectComboValue(ui->shadowQualityCombo, static_cast(graphics.shadowQuality)); + selectComboValue(ui->shadowMethodCombo, static_cast(graphics.shadowMethod)); + selectComboValue(ui->aaModeCombo, static_cast(graphics.aaMode)); + selectComboValue(ui->msaaCombo, graphics.msaaSamples); - const auto msaaCounts = GraphicsSettings::validMsaaSampleCounts(); - ui->msaaCombo->setCurrentIndex( - static_cast(std::find(msaaCounts.begin(), msaaCounts.end(), graphics.msaaSamples) - msaaCounts.begin())); + ui->gammaSpin->setValue(graphics.gamma); // 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( + selectComboValue(ui->textureFilterCombo, 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); + if (ui->anisotropyCombo->count() > 0) { + const int level = ui->anisotropyCombo->findData(graphics.anisotropy); + ui->anisotropyCombo->setCurrentIndex(level >= 0 ? level : ui->anisotropyCombo->count() - 1); } ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents()); @@ -297,37 +341,55 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) { _model->setThemeMode(themeModeFromIndex(index)); } +// Every graphics combo carries its value as item data, so each slot reads currentData() rather than +// mapping from the index. The fallbacks below are only reached if a slot somehow fires while the +// combo has no selection, in which case leaving the setting as it was is the right answer. +void PreferencesDialog::on_backendCombo_currentIndexChanged(int /*index*/) { + const auto backend = static_cast( + currentComboValue(ui->backendCombo, static_cast(_model->getGraphics().backend))); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.backend = backend; }); +} + void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) { editGraphics(_model.get(), [=](GraphicsSettings& g) { g.enablePostProcessing = checked; }); } -void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) { - editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = static_cast(index); }); +void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int /*index*/) { + const auto quality = static_cast( + currentComboValue(ui->shadowQualityCombo, static_cast(_model->getGraphics().shadowQuality))); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = quality; }); } -void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) { - editGraphics(_model.get(), [=](GraphicsSettings& g) { g.aaMode = static_cast(index); }); +void PreferencesDialog::on_shadowMethodCombo_currentIndexChanged(int /*index*/) { + const auto method = static_cast( + currentComboValue(ui->shadowMethodCombo, static_cast(_model->getGraphics().shadowMethod))); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowMethod = method; }); } -void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) { - const auto counts = GraphicsSettings::validMsaaSampleCounts(); - if (index < 0 || static_cast(index) >= counts.size()) { - return; - } +void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int /*index*/) { + const auto mode = static_cast( + currentComboValue(ui->aaModeCombo, static_cast(_model->getGraphics().aaMode))); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.aaMode = mode; }); +} - editGraphics(_model.get(), [=](GraphicsSettings& g) { g.msaaSamples = counts[index]; }); +void PreferencesDialog::on_msaaCombo_currentIndexChanged(int /*index*/) { + const int samples = currentComboValue(ui->msaaCombo, _model->getGraphics().msaaSamples); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.msaaSamples = samples; }); } -void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int index) { - editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = index; }); +void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int /*index*/) { + const int filter = currentComboValue(ui->textureFilterCombo, _model->getGraphics().textureFilter); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = filter; }); } -void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) { - if (index < 0 || static_cast(index) >= _anisotropyLevels.size()) { +void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int /*index*/) { + const auto selected = ui->anisotropyCombo->currentData(); + if (!selected.isValid()) { return; } - editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = _anisotropyLevels[index]; }); + const float level = selected.toFloat(); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = level; }); } void PreferencesDialog::on_gammaSpin_valueChanged(double value) { diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h index a4ed07e2614..1b9432068e1 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -35,8 +35,10 @@ private slots: void on_outlineLodCombo_currentIndexChanged(int index); void on_themeCombo_currentIndexChanged(int index); // Graphics + void on_backendCombo_currentIndexChanged(int index); void on_enablePostProcessing_toggled(bool checked); void on_shadowQualityCombo_currentIndexChanged(int index); + void on_shadowMethodCombo_currentIndexChanged(int index); void on_aaModeCombo_currentIndexChanged(int index); void on_msaaCombo_currentIndexChanged(int index); void on_textureFilterCombo_currentIndexChanged(int index); @@ -71,8 +73,6 @@ 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/src/ui/widgets/BriefingMapWidget.cpp b/qtfred/src/ui/widgets/BriefingMapWidget.cpp index 2f6389d79ad..f4ea5aa64d3 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.cpp +++ b/qtfred/src/ui/widgets/BriefingMapWidget.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include "FredApplication.h" #include "anim/animplay.h" @@ -107,11 +106,34 @@ BriefingMapWidget::BriefingMapWidget(QWidget* parent, } BriefingMapWidget::~BriefingMapWidget() { + // Ordinarily already done by BriefingEditorDialog::accept()/reject() -- see releaseRenderTarget(). + // This is the fallback for teardown paths that don't go through the dialog's accept()/reject() + // (e.g. the whole editor shutting down and destroying this widget directly). + releaseRenderTarget(); +} + +void BriefingMapWidget::releaseRenderTarget() { _renderTimer->stop(); + if (_renderTarget >= 0) { bm_release(_renderTarget); _renderTarget = -1; } + + // The briefing editor is opened with WA_DeleteOnClose, so this runs against a live renderer + // every time the user closes the dialog. Backends that keep GPU resources per viewport (Vulkan + // keeps a whole surface and swap chain) need to let go of them here -- harmless if this viewport + // never had one, which is the common case: renderFrame() bails out under Vulkan before ever + // calling gr_use_viewport() on it, so no Vulkan present target is normally created for it. This + // must still happen before Qt gets a chance to hide us, in case that ever changes: hiding a + // dialog can tear down an embedded native window's platform surface synchronously, well before + // ~BriefingMapWidget() runs, since WA_DeleteOnClose only posts the actual C++ destruction via + // deleteLater(). Calling this explicitly from BriefingEditorDialog::accept()/reject(), before the + // base class call that does the hiding, keeps our release strictly first. + if (_briefingViewport) { + gr_release_viewport(_briefingViewport.get()); + _briefingViewport.reset(); + } } void BriefingMapWidget::initBriefingMap() { @@ -499,18 +521,26 @@ void BriefingMapWidget::renderFrame() { _rendering = true; - // Make the engine's GL context current on our off-screen surface so we can render the briefing. - gr_use_viewport(_briefingViewport.get()); - auto* context = QOpenGLContext::currentContext(); - - if (context == nullptr) { - if (!_loggedNoContext) { - mprintf(("BriefingMapWidget: no current OpenGL context after gr_use_viewport().\n")); - _loggedNoContext = true; + // Drawing into the offscreen render target still needs a current GL context under OpenGL -- + // gr_use_viewport() is what calls makeOpenGLContextCurrent() for this off-screen surface rather + // than the main viewport's. Vulkan has no such requirement (command recording is never tied to a + // "current" surface), and _surface is a QOffscreenSurface rather than a QWindow, so + // gr_use_viewport() would only fail trying to build it a Vulkan present target; every other + // bm_set_render_target() call site in the engine (starfield env maps, ship glow textures, ...) + // skips it too. So restrict the call, and the context it produces, to OpenGL. Nothing below + // needs the context -- the readback goes through gr_read_render_target(). + if (gr_screen.mode == GraphicsAPI::OpenGL) { + gr_use_viewport(_briefingViewport.get()); + + if (QOpenGLContext::currentContext() == nullptr) { + if (!_loggedNoContext) { + mprintf(("BriefingMapWidget: no current OpenGL context after gr_use_viewport().\n")); + _loggedNoContext = true; + } + restoreMainViewportFrame(mainFrameWasActive); + _rendering = false; + return; } - restoreMainViewportFrame(mainFrameWasActive); - _rendering = false; - return; } // Reference resolution the briefing is composed at (see BriefingViewport::getSize). @@ -582,15 +612,30 @@ void BriefingMapWidget::renderFrame() { // Read the finished frame back while the render target is still bound. The briefing is an // opaque scene, so use RGBX (ignore the alpha byte) to avoid the background reading as - // transparent. FSO already renders the target top-down, so no vertical flip is needed. + // transparent; gr_read_render_target() hands back rows top-down, which is the order FSO + // composed them in, so no vertical flip is needed on either backend. + // + // Whether this succeeded also decides whether the frame may be ended below: + // gr_end_offscreen_frame()'s precondition is that the frame's GPU work has finished, and it + // is the readback's host-wait that guarantees that. QImage frame(resW, resH, QImage::Format_RGBX8888); - context->functions()->glReadPixels(0, 0, resW, resH, GL_RGBA, GL_UNSIGNED_BYTE, frame.bits()); - _frameImage = frame.copy(); + const bool frameCaptured = gr_read_render_target(frame.bits(), resW, resH); + if (frameCaptured) { + _frameImage = std::move(frame); + } Briefing = savedBriefing; bscreen = savedBscreen; bm_set_render_target(-1); + + // We just composed and read back a whole frame without ever reaching gr_flip(), so nothing + // that gr_flip() retires has been retired: the engine's uniform segments keep filling and + // the backend's per-frame pools keep growing for as long as this dialog is open. Tell the + // engine the frame is done -- but only if the capture actually host-waited (see above). + if (frameCaptured) { + gr_end_offscreen_frame(); + } } // Restore the main viewport's persistent frame so its mouse-interaction helpers keep working. @@ -609,8 +654,10 @@ void BriefingMapWidget::restoreMainViewportFrame(bool wasActive) { auto* mainView = _viewport->renderer->getTargetViewport(); auto mainSize = mainView->getSize(); gr_use_viewport(mainView); - gr_screen_resize(static_cast(mainSize.first) * devicePixelRatio(), - static_cast(mainSize.second) * devicePixelRatio()); + // Round rather than truncate: getSize() is in logical pixels, and Qt rounds when it sizes the + // native surface from them, same as FredRenderer::render_frame(). + gr_screen_resize(static_cast(std::lround(mainSize.first * devicePixelRatio())), + static_cast(std::lround(mainSize.second * devicePixelRatio()))); g3_start_frame(0); g3_set_view_matrix(&_viewport->camera.eye_pos, &_viewport->camera.eye_orient, 0.5f); } diff --git a/qtfred/src/ui/widgets/BriefingMapWidget.h b/qtfred/src/ui/widgets/BriefingMapWidget.h index 6f5af74895c..2522af3c966 100644 --- a/qtfred/src/ui/widgets/BriefingMapWidget.h +++ b/qtfred/src/ui/widgets/BriefingMapWidget.h @@ -53,6 +53,21 @@ class BriefingMapWidget : public QWidget { EditorViewport* viewport); ~BriefingMapWidget() override; + /** + * @brief Stop rendering and release the offscreen render target. Idempotent. + * + * Called explicitly from BriefingEditorDialog::accept()/reject(), right before the dialog + * closes, so the render timer and the render-target bitmap are let go promptly rather than + * waiting for ~BriefingMapWidget() -- WA_DeleteOnClose only posts the actual C++ destruction via + * deleteLater(), on a later event-loop turn. The destructor also calls this, as a fallback for + * teardown paths that don't go through accept()/reject(). + * + * Also releases a Vulkan present target for this viewport, if it somehow has one -- renderFrame() + * bails out under Vulkan before ever calling gr_use_viewport() on it (see there), so in practice + * it never does, but gr_release_viewport() is a safe no-op either way. + */ + void releaseRenderTarget(); + void setStage(int stageNum); int getCurrentStage() const; void notifyIconVisualsChanged(); diff --git a/qtfred/src/ui/widgets/renderwidget.cpp b/qtfred/src/ui/widgets/renderwidget.cpp index 10f5b22b9e4..8e0715ae24a 100644 --- a/qtfred/src/ui/widgets/renderwidget.cpp +++ b/qtfred/src/ui/widgets/renderwidget.cpp @@ -27,11 +27,17 @@ #include "mission/Editor.h" #include "FredApplication.h" #include "ui/FredView.h" +#include "ui/QtGraphicsOperations.h" namespace fso::fred { RenderWindow::RenderWindow(RenderWidget* renderWidget) : QWindow((QWindow*) nullptr), _renderWidget(renderWidget) { - setSurfaceType(QWindow::OpenGLSurface); + // A surface type cannot be changed once the window has been realized, and a window inside + // QWidget::createWindowContainer() cannot be un-realized and rebuilt without losing the + // container's embedding, so this is the only place it can be decided. The QVulkanInstance is + // attached later (it does not exist yet -- this window is created before the renderer builds it) + // but still before create(), which is what actually matters. + setSurfaceType(fredUsingVulkanSurfaces() ? QWindow::VulkanSurface : QWindow::OpenGLSurface); } void RenderWindow::initializeGL(const QSurfaceFormat& surfaceFmt) { diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index e06b97af410..7581b716cb7 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -365,6 +365,29 @@ Graphics + + + + Renderer + + + + + + Backend: + + + + + + + Graphics API the viewport renders with. Vulkan is only offered when this build was compiled with it and Qt's own Vulkan support. Restarting qtFRED is required for a change to take effect, and an explicit -vulkan/-opengl command-line option always overrides this. + + + + + + @@ -396,27 +419,41 @@ + + + Shadow method: + + + + + + + Whether shadows are rendered via cascaded shadow maps or hardware raytracing. Raytraced is only offered on a Vulkan build running on hardware with raytraced-shadow support; the control is greyed out otherwise. Requires post-processing to be enabled. + + + + Anti-aliasing: - + Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled. - + 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. @@ -479,7 +516,7 @@ - Brightness of the viewport. qtFRED defaults this higher than the game since the viewport isn't normally tonemapped. + Brightness of the viewport, applied as colour^(1/gamma). The default of 1.00 leaves it untouched. Currently only has an effect on the Vulkan backend; on OpenGL the editor renders without the intermediate buffer the gamma pass needs. 2 From e7bd559139bf8acb287754aa266355f39fafe12c Mon Sep 17 00:00:00 2001 From: the-e Date: Sat, 1 Aug 2026 19:45:37 +0200 Subject: [PATCH 9/9] fix bad string in anisotropy setting and make the shadow method combo always display _something_ --- qtfred/src/ui/dialogs/PreferencesDialog.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index ccb4e6341ff..147e95e8b80 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -191,13 +191,22 @@ void PreferencesDialog::initializeUi() { // there is no separate Vulkan-mode check here: this option already only exists when Vulkan (and // the hardware) can actually honour it. populateFromEngineOption(ui->shadowMethodCombo, "Graphics.ShadowRenderMethod"); + // Raytraced shadows need Vulkan ray-query hardware support; shadows_remove_unsupported_options() + // responds by dropping Graphics.ShadowRenderMethod from the options list entirely rather than + // just filtering its Raytraced value, so populateFromEngineOption() above leaves the combo with + // no items at all (e.g. whenever qtFRED is running under OpenGL). An empty, greyed-out combo + // reads as broken; show the one method that's actually in effect instead, locked in place. + if (ui->shadowMethodCombo->count() == 0) { + ui->shadowMethodCombo->addItem(tr("Shadow Maps"), static_cast(ShadowRenderMethod::ShadowMap)); + ui->shadowMethodCombo->setEnabled(false); + } 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. for (float level : gr_get_supported_anisotropy_levels()) { - ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0), level); + ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'f', 0), level); } ui->anisotropyCombo->setEnabled(ui->anisotropyCombo->count() > 0);

    SettingDescription