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 @@
+
+
+
+
+ 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