diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr index 22a9053f2a1..1b4e4d255c5 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,8 @@ 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; + // 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 4030dda7489..37df6633687 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 {}; + } + + if (max <= 2.0f) { + return {}; + } + + 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; @@ -1641,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(); + + // 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(); + } } int gr_get_resolution_class(int width, int height) @@ -2123,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; } @@ -3266,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 9bf045e8931..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,6 +869,16 @@ typedef struct screen { std::function gf_scene_texture_end; std::function gf_copy_effect_texture; + // 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; std::function gf_set_fill_mode; @@ -972,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; @@ -1054,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); @@ -1133,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(); } @@ -1374,6 +1438,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); @@ -1417,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 444515b797a..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,6 +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_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; @@ -1513,7 +1551,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 63ac62ffe6b..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,10 +140,10 @@ void opengl_setup_scene_textures() return; } - // clamp size, if needed - Scene_texture_width = gr_screen.max_w; - Scene_texture_height = gr_screen.max_h; + Scene_texture_width = width; + Scene_texture_height = height; + // clamp size, if needed if ( Scene_texture_width > GL_max_renderbuffer_size ) { Scene_texture_width = GL_max_renderbuffer_size; } @@ -125,6 +152,13 @@ void opengl_setup_scene_textures() Scene_texture_height = GL_max_renderbuffer_size; } + 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, + GL_max_renderbuffer_size)); + // create framebuffer glGenFramebuffers(1, &Scene_framebuffer); GL_state.BindFrameBuffer(Scene_framebuffer); @@ -333,32 +367,15 @@ void opengl_setup_scene_textures() if ( opengl_check_framebuffer() ) { GL_state.BindFrameBuffer(0); - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; + opengl_delete_render_framebuffer(Scene_framebuffer); - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; - - 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; @@ -687,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; - } + // 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 (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; - } - - 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() @@ -776,20 +819,35 @@ 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; + // 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); + + // 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; + + 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); @@ -1236,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 ed9e14a995c..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_u_scale); + opengl_draw_full_screen_scene_texture(); } void opengl_post_pass_bloom() @@ -134,7 +137,10 @@ 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); + // 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 ------ @@ -293,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_u_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)); @@ -310,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_u_scale); + opengl_draw_full_screen_scene_texture(); opengl_shader_set_current(); } @@ -333,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_u_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_calculate_blending_weights() @@ -358,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_u_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_neighborhood_blending() @@ -381,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_u_scale); + opengl_draw_full_screen_scene_texture(); } void smaa_resolve() @@ -491,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_u_scale); + opengl_draw_full_screen_scene_texture(); GL_state.Blend(GL_FALSE); break; @@ -625,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_u_scale); + opengl_draw_full_screen_scene_texture(); //Shadow Map debug window //#define SHADOW_DEBUG @@ -1020,77 +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); - // clamp size, if needed - Post_texture_width = gr_screen.max_w; - Post_texture_height = gr_screen.max_h; + opengl_delete_render_texture(Smaa_blend_tex); + opengl_delete_render_framebuffer(Smaa_blending_weight_fb); - 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() @@ -1141,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/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp index 9375b353468..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,8 +167,15 @@ void opengl_tcache_init() // check what mipmap filter we should be using // 0 == Bilinear // 1 == Trilinear + // 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(); + } + if (GL_mipmap_filter > 1) { GL_mipmap_filter = 1; } 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/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/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/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/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 8a8ca2e3e84..cdcbda3b507 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,67 @@

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.

+ +

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, 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. 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.
  • +
  • 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 + 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, 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 world-space plane the grid lies on (XZ, XY, or YZ) and adjust the grid's center 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 e91fcb7d67f..bc289f83833 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -122,6 +122,7 @@ EditorViewport::EditorViewport(Editor* in_editor, std::unique_ptr& syncMissionLayerNames(); loadSettings(); + view.Graphics.applyLive(); fredApp->runAfterInit([this]() { initialSetup(); }); } @@ -180,6 +181,8 @@ void EditorViewport::loadSettings() { 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 { @@ -228,7 +231,10 @@ void EditorViewport::saveSettings() const { settings.setValue("camera_invert_orbit_x", camera.getInvertOrbitX()); settings.setValue("camera_invert_orbit_y", camera.getInvertOrbitY()); settings.endGroup(); + + view.Graphics.save(); } + void EditorViewport::needsUpdate() { _renderer->scheduleUpdate(); } diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 26962561525..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" @@ -63,6 +64,9 @@ struct ViewSettings { bool Highlight_selectable_subsys = false; int Outline_lod = 1; + //! Preferences > Graphics. Owns its own persistence and apply rules; see GraphicsSettings. + GraphicsSettings Graphics; + ViewSettings(); }; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 5bd82493288..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 @@ -15,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +31,8 @@ #include #include +#include + #include "mission/object.h" #include "prop/prop.h" #include "weapon/weapon.h" @@ -56,6 +61,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; @@ -967,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); @@ -994,6 +1038,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 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 // regardless of the player's graphics quality setting (Detail.num_stars can be 0). int saved_detail_stars = Detail.num_stars; @@ -1003,6 +1056,10 @@ void FredRenderer::render_frame(int cur_object_index, disable_htl(); Detail.num_stars = saved_detail_stars; + if (postProcessing) { + render_shadows(); + } + if (view().Show_horizon) { gr_set_color(128, 128, 64); g3_draw_horizon_line(); @@ -1019,6 +1076,8 @@ void FredRenderer::render_frame(int cur_object_index, render_models(cur_object_index); render_volumetric_overlay(); + 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..5013f9ce3b5 --- /dev/null +++ b/qtfred/src/mission/GraphicsSettings.cpp @@ -0,0 +1,170 @@ +#include "mission/GraphicsSettings.h" + +#include +#include + +#include + +namespace fso::fred { + +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"; +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 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() +{ + return {0, 4, 8}; +} + +GraphicsSettings GraphicsSettings::load() +{ + GraphicsSettings out; + + 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(); + + 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_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); + } + + if (anisotropy != NO_ANISOTROPY_CHOICE) { + settings.setValue(KEY_ANISOTROPY, anisotropy); + } + + settings.endGroup(); +} + +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; + + 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..8c97b20ef54 --- /dev/null +++ b/qtfred/src/mission/GraphicsSettings.h @@ -0,0 +1,146 @@ +#pragma once + +#include +#include + +namespace fso::fred { + +/** + * @brief qtFRED's Preferences > Graphics settings. + * + * 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: + * + * - 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. 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; + + /** + * @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; + + /** + * @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() + + /** + * @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 + + 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 c674f26744e..57681573958 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -26,6 +26,7 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _dataMenuStyle(viewport->Data_menu_style) , _toolbarIconSize(viewport->toolbar_icon_size) , _outlineLod(viewport->view.Outline_lod) + , _graphics(viewport->view.Graphics) , _invertOrbitX(viewport->camera.getInvertOrbitX()) , _invertOrbitY(viewport->camera.getInvertOrbitY()) , _gridCenterX(static_cast(viewport->The_grid->center.xyz.x)) @@ -68,6 +69,9 @@ bool PreferencesDialogModel::apply() { _viewport->Data_menu_style = _dataMenuStyle; _viewport->toolbar_icon_size = _toolbarIconSize; _viewport->view.Outline_lod = _outlineLod; + _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); @@ -180,6 +184,9 @@ void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconS int PreferencesDialogModel::getOutlineLod() const { return _outlineLod; } void PreferencesDialogModel::setOutlineLod(int value) { modify(_outlineLod, 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); 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..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,6 +68,11 @@ class PreferencesDialogModel : public AbstractDialogModel { int getOutlineLod() const; void setOutlineLod(int 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; void setControlKey(ControlAction action, const QKeySequence& sequence); @@ -109,6 +115,8 @@ class PreferencesDialogModel : public AbstractDialogModel { int _toolbarIconSize; int _outlineLod; + GraphicsSettings _graphics; + // Controls std::map _controlKeys; bool _invertOrbitX; diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index cc8cf8efb8d..db32e78246d 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -3,6 +3,8 @@ #include "object.h" +#include "mission/GraphicsSettings.h" + #include "cmdline/cmdline.h" #include @@ -125,9 +127,16 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; + // 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 7c6aae8f482..ca4394d676f 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -942,6 +942,10 @@ 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 07db9e06d92..147e95e8b80 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -5,12 +5,90 @@ #include #include +#include "ui/QtGraphicsOperations.h" #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. +// +// 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) { + // 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(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 +// 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,6 +172,48 @@ void PreferencesDialog::applyChanges() { } void PreferencesDialog::initializeUi() { + // 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); + + // 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"); + // 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, 'f', 0), level); + } + ui->anisotropyCombo->setEnabled(ui->anisotropyCombo->count() > 0); + + for (int samples : GraphicsSettings::validMsaaSampleCounts()) { + ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples), samples); + } + // Build the controls key-binding form dynamically from the registered bindings auto* form = new QFormLayout(ui->controlsFormWidget); auto& bindings = ControlBindings::instance(); @@ -125,6 +245,34 @@ void PreferencesDialog::updateUi() { const int iconSize = _model->getToolbarIconSize(); ui->toolbarIconSizeCombo->setCurrentIndex(iconSize <= 16 ? 0 : iconSize >= 32 ? 2 : 1); ui->outlineLodCombo->setCurrentIndex(_model->getOutlineLod()); + + // 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); + 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); + + 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. + selectComboValue(ui->textureFilterCombo, + graphics.textureFilter == GraphicsSettings::NO_TEXTURE_FILTER_CHOICE ? 1 : graphics.textureFilter); + + 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()); ui->showSexpHelpMissionGoals->setChecked(_model->getShowSexpHelpMissionGoals()); ui->showSexpHelpMissionCutscenes->setChecked(_model->getShowSexpHelpMissionCutscenes()); @@ -202,6 +350,61 @@ 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*/) { + const auto quality = static_cast( + currentComboValue(ui->shadowQualityCombo, static_cast(_model->getGraphics().shadowQuality))); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = quality; }); +} + +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_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; }); +} + +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*/) { + const int filter = currentComboValue(ui->textureFilterCombo, _model->getGraphics().textureFilter); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = filter; }); +} + +void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int /*index*/) { + const auto selected = ui->anisotropyCombo->currentData(); + if (!selected.isValid()) { + return; + } + + const float level = selected.toFloat(); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = level; }); +} + +void PreferencesDialog::on_gammaSpin_valueChanged(double value) { + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.gamma = 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..1b9432068e1 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -34,6 +34,16 @@ private slots: void on_toolbarIconSizeCombo_currentIndexChanged(int index); 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); + 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/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/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 diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index c45e1a9ecb4..7581b716cb7 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -360,6 +360,196 @@ + + + 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. + + + + + + + + + + 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. + + + + + + + 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. + + + + + + + + + + Textures + + + + + + Texture filtering: + + + + + + + Mipmap filtering used for textures. Restarting qtFRED is required for a change to take effect. + + + + + + + Anisotropic filtering: + + + + + + + Anisotropic texture filtering level. Restarting qtFRED is required for a change to take effect. + + + + + + + + + + Brightness + + + + + + Gamma: + + + + + + + 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 + + + 0.10 + + + 5.00 + + + 0.05 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + Grid