mirror of
https://github.com/scp-fs2open/fs2open.github.com.git
synced 2026-11-07 22:56:30 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
718ee3c038 | ||
|
|
6af6a0cc99 | ||
|
|
f444d1ecf7 | ||
|
|
26d10bfb35 | ||
|
|
b8dc89aed5 | ||
|
|
60170f1af3 | ||
|
|
803b573fea | ||
|
|
bd5b6459b2 | ||
|
|
00b01e3615 |
No files matched your search
+44
-9
@@ -1734,11 +1734,11 @@ void gr_screen_resize(int width, int height)
|
||||
|
||||
gr_setup_viewport();
|
||||
|
||||
// The offscreen targets that back the scene/post-processing pipeline were sized for the old
|
||||
// gr_screen; give the backend a chance to grow them before anything renders at the new size.
|
||||
// Backends that already handle this elsewhere (Vulkan, via recreateSwapChain()) leave it unset.
|
||||
if (gr_screen.gf_resize_render_targets) {
|
||||
gr_screen.gf_resize_render_targets();
|
||||
// Whatever the backend sized to the old gr_screen is now wrong; let it catch up before anything
|
||||
// renders at the new size. This can discard the frame in progress -- see the warning on the
|
||||
// declaration of this function.
|
||||
if (gr_screen.gf_viewport_size_changed) {
|
||||
gr_screen.gf_viewport_size_changed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2273,11 +2273,15 @@ bool gr_init(std::unique_ptr<os::GraphicsOperations>&& 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;
|
||||
}
|
||||
|
||||
@@ -3472,6 +3476,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);
|
||||
|
||||
+69
-5
@@ -817,6 +817,14 @@ typedef struct screen {
|
||||
// dumps the current screen to a html blob string
|
||||
std::function<SCP_string()> 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<bool(ubyte* out_rgba, int width, int height)> 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<void()> gf_end_offscreen_frame;
|
||||
|
||||
// transforms and dumps the current environment map to a file
|
||||
std::function<void(const char* filename)> gf_dump_envmap;
|
||||
|
||||
@@ -917,11 +925,15 @@ typedef struct screen {
|
||||
std::function<void()> gf_scene_texture_end;
|
||||
std::function<void()> gf_copy_effect_texture;
|
||||
|
||||
// Grow the offscreen render targets that back the scene/post-processing pipeline to cover the
|
||||
// current gr_screen, if they don't already. Called from gr_screen_resize(). Optional: the
|
||||
// Vulkan backend leaves this unset because VulkanRenderer::recreateSwapChain() already owns
|
||||
// resizing its extent-sized targets.
|
||||
std::function<void()> gf_resize_render_targets;
|
||||
// The viewport is now gr_screen.max_w x max_h; bring whatever the backend sized to the old one
|
||||
// into line. Called from gr_screen_resize(); see the precondition documented there, which is
|
||||
// stricter than it looks -- what a backend does here can include throwing away the frame in
|
||||
// progress. Optional: a backend with nothing sized to the viewport leaves it unset.
|
||||
//
|
||||
// OpenGL grows the scene/post-processing render targets. Vulkan rebuilds the swap chain and
|
||||
// everything sized to it, and restarts the frame; that is the only point at which it can notice
|
||||
// the window and the swap chain have diverged (see VulkanRenderer::syncToSurfaceExtent()).
|
||||
std::function<void()> gf_viewport_size_changed;
|
||||
|
||||
std::function<void(int zbias)> gf_zbias;
|
||||
|
||||
@@ -1042,6 +1054,12 @@ typedef struct screen {
|
||||
std::unique_ptr<os::Viewport> (*gf_create_viewport)(const os::ViewPortProperties& props);
|
||||
std::function<void(os::Viewport* view)> 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<void(os::Viewport* view)> gf_release_viewport;
|
||||
|
||||
std::function<void(uniform_block_type bind_point, size_t offset, size_t size, gr_buffer_handle buffer)>
|
||||
gf_bind_uniform_buffer;
|
||||
|
||||
@@ -1132,6 +1150,16 @@ extern GraphicsAPI gr_get_configured_render_api();
|
||||
// a real backend, such as GraphicsAPI::Stub.
|
||||
extern const char* gr_render_api_name(GraphicsAPI api);
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
@@ -1248,6 +1276,36 @@ gr_debug_stats gr_get_debug_stats();
|
||||
*/
|
||||
gr_memory_stats gr_get_memory_stats();
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
@@ -1557,6 +1615,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);
|
||||
|
||||
@@ -461,6 +461,42 @@ SCP_string gr_opengl_blob_screen()
|
||||
return "data:image/png;base64," + result;
|
||||
}
|
||||
|
||||
bool gr_opengl_read_render_target(ubyte* out_rgba, int width, int height)
|
||||
{
|
||||
const GLuint render_target = opengl_get_rtt_framebuffer();
|
||||
if (render_target == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The caller sized its buffer from the bitmap it bound, so a disagreement means it is reading
|
||||
// something other than what it thinks. Refuse rather than overrun or return a wrong-shaped image.
|
||||
if (width != gr_screen.max_w || height != gr_screen.max_h) {
|
||||
nprintf(("OpenGL", "gr_opengl_read_render_target: caller expected %dx%d but the bound target "
|
||||
"is %dx%d\n", width, height, gr_screen.max_w, gr_screen.max_h));
|
||||
return false;
|
||||
}
|
||||
|
||||
GL_state.PushFramebufferState();
|
||||
GL_state.BindFrameBuffer(render_target, GL_FRAMEBUFFER);
|
||||
glReadBuffer(GL_COLOR_ATTACHMENT0);
|
||||
|
||||
// Row 0 first, which for a render target FSO composed into is the top row -- matching the
|
||||
// top-down order gr_read_render_target() promises. Deliberately not the flip gr_blob_screen()
|
||||
// applies: that one exists to make the PNG come out upright, and there is no PNG here.
|
||||
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, out_rgba);
|
||||
glFlush();
|
||||
|
||||
GL_state.PopFramebufferState();
|
||||
|
||||
// Reported, not returned. glGetError drains one global queue, so an entry left by anything
|
||||
// earlier in the frame is not evidence about this readback -- and callers use the return value
|
||||
// to decide whether the frame's work has completed (gr_end_offscreen_frame()). Failing on
|
||||
// somebody else's error would silently skip that.
|
||||
opengl_check_for_errors("gr_opengl_read_render_target");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void gr_opengl_dump_envmap(const char* filename)
|
||||
{
|
||||
char tmp[MAX_PATH_LEN];
|
||||
@@ -1066,6 +1102,7 @@ void gr_opengl_init_function_pointers()
|
||||
|
||||
gr_screen.gf_print_screen = gr_opengl_print_screen;
|
||||
gr_screen.gf_blob_screen = gr_opengl_blob_screen;
|
||||
gr_screen.gf_read_render_target = gr_opengl_read_render_target;
|
||||
gr_screen.gf_dump_envmap = gr_opengl_dump_envmap;
|
||||
gr_screen.gf_calculate_irrmap = gr_opengl_calculate_irrmap;
|
||||
|
||||
@@ -1126,7 +1163,7 @@ void gr_opengl_init_function_pointers()
|
||||
gr_screen.gf_scene_texture_begin = gr_opengl_scene_texture_begin;
|
||||
gr_screen.gf_scene_texture_end = gr_opengl_scene_texture_end;
|
||||
gr_screen.gf_copy_effect_texture = gr_opengl_copy_effect_texture;
|
||||
gr_screen.gf_resize_render_targets = gr_opengl_resize_render_targets;
|
||||
gr_screen.gf_viewport_size_changed = gr_opengl_resize_render_targets;
|
||||
|
||||
gr_screen.gf_deferred_lighting_begin = gr_opengl_deferred_lighting_begin;
|
||||
gr_screen.gf_deferred_lighting_msaa = gr_opengl_deferred_lighting_msaa;
|
||||
|
||||
@@ -148,42 +148,78 @@ size_t VulkanBufferManager::bumpAllocate(size_t size)
|
||||
size_t alignedOffset = (alloc.cursor + m_uboAlignment - 1) & ~(static_cast<size_t>(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.
|
||||
auto* deletionQueue = getDeletionQueue();
|
||||
if (alloc.mappedPtr) {
|
||||
m_memoryManager->unmapMemory(alloc.allocation);
|
||||
}
|
||||
deletionQueue->queueBuffer(alloc.buffer, alloc.allocation);
|
||||
// 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;
|
||||
|
||||
// Create new buffer
|
||||
alloc = {};
|
||||
Verification(createFrameAllocBuffer(alloc, newCapacity), "Failed to grow Vulkan frame-allocator buffer");
|
||||
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 (oldMapped) {
|
||||
m_memoryManager->unmapMemory(oldAllocation);
|
||||
}
|
||||
if (oldBuffer) {
|
||||
deletionQueue->queueBuffer(oldBuffer, oldAllocation);
|
||||
}
|
||||
|
||||
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 ==========
|
||||
@@ -555,17 +592,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.
|
||||
@@ -620,15 +660,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);
|
||||
@@ -665,7 +706,7 @@ void* VulkanBufferManager::mapBuffer(gr_buffer_handle handle)
|
||||
}
|
||||
|
||||
if (bufferObj.isStreaming()) {
|
||||
Assert(bufferObj.frameAllocFrame == m_currentFrame);
|
||||
Assert(isFrameAllocCurrent(bufferObj));
|
||||
auto& alloc = m_frameAllocs[m_currentFrame];
|
||||
return static_cast<uint8_t*>(alloc.mappedPtr) + bufferObj.frameAllocOffset;
|
||||
}
|
||||
@@ -694,7 +735,7 @@ void VulkanBufferManager::flushMappedBuffer(gr_buffer_handle handle, size_t offs
|
||||
|
||||
if (bufferObj.isStreaming()) {
|
||||
// Adjust offset for current frame's allocation
|
||||
Assert(bufferObj.frameAllocFrame == m_currentFrame);
|
||||
Assert(isFrameAllocCurrent(bufferObj));
|
||||
auto& alloc = m_frameAllocs[m_currentFrame];
|
||||
m_memoryManager->flushMemory(alloc.allocation, bufferObj.frameAllocOffset + offset, size);
|
||||
} else {
|
||||
@@ -719,6 +760,30 @@ void VulkanBufferManager::bindUniformBuffer(uniform_block_type blockType, size_t
|
||||
|
||||
// ========== Buffer queries ==========
|
||||
|
||||
bool VulkanBufferManager::isFrameAllocCurrent(const VulkanBufferObject& bufferObj) const
|
||||
{
|
||||
if (!bufferObj.isStreaming()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return bufferObj.frameAllocFrame == m_currentFrame &&
|
||||
bufferObj.frameAllocGeneration == m_frameAllocs[m_currentFrame].generation;
|
||||
}
|
||||
|
||||
bool VulkanBufferManager::isFrameAllocCurrent(gr_buffer_handle handle) const
|
||||
{
|
||||
if (!isValidHandle(handle)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const VulkanBufferObject& bufferObj = m_buffers[handle.value()];
|
||||
if (!bufferObj.valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isFrameAllocCurrent(bufferObj);
|
||||
}
|
||||
|
||||
vk::Buffer VulkanBufferManager::getVkBuffer(gr_buffer_handle handle) const
|
||||
{
|
||||
if (!isValidHandle(handle)) {
|
||||
@@ -732,7 +797,12 @@ vk::Buffer VulkanBufferManager::getVkBuffer(gr_buffer_handle handle) const
|
||||
|
||||
if (bufferObj.isStreaming()) {
|
||||
// Streaming buffers return the frame allocator buffer they were uploaded to
|
||||
Assert(bufferObj.frameAllocFrame == m_currentFrame);
|
||||
Assertion(isFrameAllocCurrent(bufferObj),
|
||||
"Streaming buffer %d was allocated in frame %u (generation %u) but fetched in frame %u "
|
||||
"(generation %u) -- it must be uploaded again before it can be drawn with. A binding kept "
|
||||
"from an earlier frame belongs on getVkBufferForBinding().",
|
||||
handle.value(), bufferObj.frameAllocFrame, bufferObj.frameAllocGeneration, m_currentFrame,
|
||||
m_frameAllocs[m_currentFrame].generation);
|
||||
return bufferObj.frameAllocBuffer;
|
||||
} else {
|
||||
// Record that this frame (potentially) references the buffer -- consulted
|
||||
@@ -742,6 +812,21 @@ vk::Buffer VulkanBufferManager::getVkBuffer(gr_buffer_handle handle) const
|
||||
}
|
||||
}
|
||||
|
||||
vk::Buffer VulkanBufferManager::getVkBufferForBinding(gr_buffer_handle handle) const
|
||||
{
|
||||
if (!isFrameAllocCurrent(handle)) {
|
||||
// Either the handle no longer resolves at all, or it is a streaming buffer whose
|
||||
// sub-allocation belongs to a frame that has since been recycled. Both mean the caller
|
||||
// has to fall back; only getVkBuffer() treats the latter as a bug.
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Delegated rather than inlined: getVkBuffer()'s non-streaming branch stamps
|
||||
// lastUsedFrameNumber, which is what updateBufferData() consults to decide whether a rewrite
|
||||
// has to orphan. Duplicating the lookup here would mean remembering to keep that stamp.
|
||||
return getVkBuffer(handle);
|
||||
}
|
||||
|
||||
size_t VulkanBufferManager::getBufferSize(gr_buffer_handle handle) const
|
||||
{
|
||||
if (!isValidHandle(handle)) {
|
||||
@@ -783,11 +868,10 @@ size_t VulkanBufferManager::getFrameBaseOffset(gr_buffer_handle handle) const
|
||||
|
||||
if (bufferObj.isStreaming()) {
|
||||
// Return the bump allocator offset for the most recent upload this frame.
|
||||
// Stale handle detection: if frameAllocFrame != m_currentFrame, this buffer
|
||||
// was not uploaded this frame and the offset would be meaningless (the bump
|
||||
// allocator has been reset). This indicates a buffer marked Streaming/Dynamic
|
||||
// is being bound for rendering without being uploaded first.
|
||||
Assert(bufferObj.frameAllocFrame == m_currentFrame);
|
||||
// Stale handle detection: a sub-allocation that is not the live one has a meaningless
|
||||
// offset (the bump allocator has rewound since), which means a buffer marked
|
||||
// Streaming/Dynamic is being bound for rendering without being uploaded first.
|
||||
Assert(isFrameAllocCurrent(bufferObj));
|
||||
return bufferObj.frameAllocOffset;
|
||||
} else {
|
||||
return 0;
|
||||
|
||||
@@ -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 @@ public:
|
||||
* @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);
|
||||
|
||||
/**
|
||||
@@ -199,11 +212,40 @@ public:
|
||||
|
||||
/**
|
||||
* @brief Get the Vulkan buffer handle for the current frame
|
||||
*
|
||||
* For a streaming buffer this asserts that the buffer was uploaded into the frame
|
||||
* allocator as it stands right now: a caller reaching a buffer directly is uploading and
|
||||
* drawing within one frame, so a stale sub-allocation is a bug, not a state to tolerate.
|
||||
* Consumers of a binding recorded earlier want getVkBufferForBinding() instead.
|
||||
*
|
||||
* @param handle The buffer handle
|
||||
* @return The VkBuffer, or VK_NULL_HANDLE if invalid
|
||||
*/
|
||||
vk::Buffer getVkBuffer(gr_buffer_handle handle) const;
|
||||
|
||||
/**
|
||||
* @brief Resolve a buffer for a binding that was recorded before this frame
|
||||
*
|
||||
* Same lookup as getVkBuffer(), but a streaming buffer whose sub-allocation no longer
|
||||
* belongs to the current frame allocator resolves to VK_NULL_HANDLE rather than tripping
|
||||
* that function's assert. Bindings outlive the frame they were made in -- a uniform block
|
||||
* bound once for a whole pass stays bound, mirroring glBindBufferRange() -- while a
|
||||
* streaming sub-allocation does not, so the two can legitimately disagree and the caller
|
||||
* has to fall back to the placeholder buffer.
|
||||
*
|
||||
* @param handle The buffer handle
|
||||
* @return The VkBuffer, or VK_NULL_HANDLE if the binding no longer resolves this frame
|
||||
*/
|
||||
vk::Buffer getVkBufferForBinding(gr_buffer_handle handle) const;
|
||||
|
||||
/**
|
||||
* @brief Whether a streaming buffer's sub-allocation belongs to the current frame allocator
|
||||
*
|
||||
* False for a streaming buffer that has not been uploaded since the allocator last rewound.
|
||||
* Always true for a non-streaming buffer, which owns its VkBuffer outright.
|
||||
*/
|
||||
bool isFrameAllocCurrent(gr_buffer_handle handle) const;
|
||||
|
||||
/**
|
||||
* @brief Get buffer size
|
||||
* For streaming buffers, returns the current frame allocation size.
|
||||
@@ -292,6 +334,17 @@ private:
|
||||
VulkanBufferObject* getBufferObject(gr_buffer_handle handle);
|
||||
const VulkanBufferObject* getBufferObject(gr_buffer_handle handle) const;
|
||||
|
||||
/**
|
||||
* @brief Whether a streaming buffer object's sub-allocation is the one live right now
|
||||
*
|
||||
* The frame index alone is not enough: setCurrentFrame() rewinds the bump cursor and bumps
|
||||
* the generation, and endOffscreenFrame() calls it with the frame index deliberately
|
||||
* unchanged. A sub-allocation from before that call therefore matches on index while
|
||||
* pointing at memory that has since been handed out again, so the generation has to match
|
||||
* too. Same pair updateBufferData() compares before reusing a pre-allocation.
|
||||
*/
|
||||
bool isFrameAllocCurrent(const VulkanBufferObject& bufferObj) const;
|
||||
|
||||
// Frame bump allocator
|
||||
static constexpr size_t FRAME_ALLOC_INITIAL_SIZE = 4 * 1024 * 1024;
|
||||
|
||||
@@ -299,7 +352,7 @@ private:
|
||||
void initFrameAllocators();
|
||||
void shutdownFrameAllocators();
|
||||
size_t bumpAllocate(size_t size);
|
||||
void growFrameAllocator();
|
||||
void growFrameAllocator(size_t requiredEnd);
|
||||
|
||||
std::array<FrameBumpAllocator, MAX_FRAMES_IN_FLIGHT> m_frameAllocs;
|
||||
uint32_t m_uboAlignment = 256;
|
||||
|
||||
@@ -1003,11 +1003,13 @@ void vulkan_render_decals(decal_material* material_info,
|
||||
|
||||
stateTracker->bindPipeline(pipeline, pipelineManager->getPipelineLayout());
|
||||
|
||||
// Helper: get DescriptorBufferInfo from pending binding (null buffer = fallback)
|
||||
// Helper: get DescriptorBufferInfo from pending binding (null buffer = fallback).
|
||||
// getVkBufferForBinding() because a binding outlives the frame it was made in while a
|
||||
// streaming sub-allocation does not -- see bindPendingUBOs in VulkanDrawManager::applyMaterial.
|
||||
auto getPendingBufInfo = [&](size_t blockIdx) -> vk::DescriptorBufferInfo {
|
||||
const auto& pending = drawManager->getPendingUniformBinding(blockIdx);
|
||||
if (pending.valid) {
|
||||
vk::Buffer buf = bufferManager->getVkBuffer(pending.bufferHandle);
|
||||
vk::Buffer buf = bufferManager->getVkBufferForBinding(pending.bufferHandle);
|
||||
if (buf) {
|
||||
return {buf, pending.offset, pending.size};
|
||||
}
|
||||
@@ -1066,7 +1068,7 @@ void vulkan_render_decals(decal_material* material_info,
|
||||
writer.setBuffer(PerDrawBinding::DecalInfo,
|
||||
getPendingBufInfo(static_cast<size_t>(uniform_block_type::DecalInfo)));
|
||||
writer.flush();
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, globalSet, nullptr);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, globalSet);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Material,
|
||||
materialSet,
|
||||
writer.dynamicOffsets(DescriptorSetIndex::Material));
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace graphics::vulkan {
|
||||
static constexpr DescriptorBindingTemplate s_globalBindings[] = {
|
||||
{GlobalBinding::Lights, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment},
|
||||
{GlobalBinding::DeferredData, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment},
|
||||
{GlobalBinding::ShadowMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::e2DArray},
|
||||
{GlobalBinding::ShadowMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::e2DArray, /*depthCompare=*/true},
|
||||
{GlobalBinding::EnvMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::eCube},
|
||||
{GlobalBinding::IrradianceMap, vk::DescriptorType::eCombinedImageSampler, 1, vk::ShaderStageFlagBits::eFragment, vk::ImageViewType::eCube},
|
||||
{GlobalBinding::ShadowCascadeParams, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment},
|
||||
@@ -68,8 +68,17 @@ static constexpr VulkanDescriptorManager::UniformBindingEntry s_perDrawUBOs[] =
|
||||
|
||||
// ========== DescriptorFallbacks ==========
|
||||
|
||||
const vk::DescriptorImageInfo& DescriptorFallbacks::getImage(vk::ImageViewType t) const
|
||||
const vk::DescriptorImageInfo& DescriptorFallbacks::getImage(vk::ImageViewType t, bool depthCompare) const
|
||||
{
|
||||
if (depthCompare) {
|
||||
// A shadow sampler must get a depth view whatever its view type. Only the 2D-array
|
||||
// shape exists today (GlobalBinding::ShadowMap); assert rather than hand back a
|
||||
// color view, which is the exact validation error this fallback removes.
|
||||
Assertion(t == vk::ImageViewType::e2DArray,
|
||||
"DescriptorFallbacks::getImage: no depth fallback for ImageViewType %d", static_cast<int>(t));
|
||||
return shadowMap;
|
||||
}
|
||||
|
||||
switch (t) {
|
||||
case vk::ImageViewType::e2D: return texture2D;
|
||||
case vk::ImageViewType::e2DArray: return texture2DArray;
|
||||
@@ -113,6 +122,7 @@ void DescriptorWriter::writeSet(DescriptorSetIndex setIndex, vk::DescriptorSet s
|
||||
auto& slot = m_bindingSlots[b.binding];
|
||||
slot.count = b.count;
|
||||
slot.viewType = b.viewType;
|
||||
slot.depthCompare = b.depthCompare;
|
||||
|
||||
bool isImage = (b.type == vk::DescriptorType::eCombinedImageSampler);
|
||||
bool isAccelStruct = (b.type == vk::DescriptorType::eAccelerationStructureKHR);
|
||||
@@ -124,7 +134,7 @@ void DescriptorWriter::writeSet(DescriptorSetIndex setIndex, vk::DescriptorSet s
|
||||
if (isImage) {
|
||||
Assert(m_imageInfoCount + b.count <= MAX_IMAGE_INFOS);
|
||||
auto* dst = &m_imageInfos[m_imageInfoCount];
|
||||
const auto& fallbackImg = m_fallbacks->getImage(b.viewType);
|
||||
const auto& fallbackImg = m_fallbacks->getImage(b.viewType, b.depthCompare);
|
||||
for (uint32_t j = 0; j < b.count; ++j) {
|
||||
dst[j] = fallbackImg;
|
||||
}
|
||||
@@ -234,7 +244,7 @@ void DescriptorWriter::setImage(uint32_t binding, const vk::DescriptorImageInfo&
|
||||
if (info.imageView) {
|
||||
*slot.imageInfo = info;
|
||||
} else {
|
||||
*slot.imageInfo = m_fallbacks->getImage(slot.viewType);
|
||||
*slot.imageInfo = m_fallbacks->getImage(slot.viewType, slot.depthCompare);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +321,7 @@ void VulkanDescriptorManager::buildFallbacks(VulkanBufferManager* bufMgr, Vulkan
|
||||
m_fallbacks.texture2DArray = texMgr->getFallbackTextureInfo2DArray();
|
||||
m_fallbacks.textureCube = texMgr->getFallbackTextureInfoCube();
|
||||
m_fallbacks.texture3D = texMgr->getFallbackTextureInfo3D();
|
||||
m_fallbacks.shadowMap = texMgr->getFallbackShadowMapInfo();
|
||||
if (m_raytracingEnabled && rtMgr != nullptr) {
|
||||
// Seed with the permanent empty TLAS; setCurrentShadowTlas() takes over
|
||||
// once the first real TLAS is built.
|
||||
|
||||
@@ -22,11 +22,18 @@ struct DescriptorBindingTemplate {
|
||||
uint32_t count; // 1 for most, 16 for texture array
|
||||
vk::ShaderStageFlags stages;
|
||||
vk::ImageViewType viewType; // only meaningful for eCombinedImageSampler
|
||||
// True when the shader declares this binding as a shadow sampler (samplerXShadow) and
|
||||
// therefore samples it with depth comparison. Such a binding needs a depth-format
|
||||
// fallback view: a color view fails the format-feature check in
|
||||
// VUID-vkCmdDraw-None-06479 even on draws that never read the descriptor.
|
||||
bool depthCompare;
|
||||
|
||||
constexpr DescriptorBindingTemplate(uint32_t binding_, vk::DescriptorType type_,
|
||||
uint32_t count_, vk::ShaderStageFlags stages_,
|
||||
vk::ImageViewType viewType_ = vk::ImageViewType::e2D)
|
||||
: binding(binding_), type(type_), count(count_), stages(stages_), viewType(viewType_) {}
|
||||
vk::ImageViewType viewType_ = vk::ImageViewType::e2D,
|
||||
bool depthCompare_ = false)
|
||||
: binding(binding_), type(type_), count(count_), stages(stages_), viewType(viewType_),
|
||||
depthCompare(depthCompare_) {}
|
||||
};
|
||||
|
||||
struct DescriptorSetTemplate : ArrayView<DescriptorBindingTemplate> {
|
||||
@@ -40,6 +47,12 @@ struct DescriptorFallbacks {
|
||||
vk::DescriptorImageInfo textureCube;
|
||||
vk::DescriptorImageInfo texture3D;
|
||||
|
||||
// Depth-format (SHADOW_DEPTH_FORMAT) 1x1 2D-array view plus a compare-enabled sampler,
|
||||
// for bindings declared as samplerXShadow. Used whenever no real shadow map is bound:
|
||||
// shadows turned off entirely, and also every frame before VulkanShadowMap::init()
|
||||
// runs its lazy first-use setup.
|
||||
vk::DescriptorImageInfo shadowMap;
|
||||
|
||||
// Unlike the fields above (fixed dummy defaults, set once by buildFallbacks()),
|
||||
// this is refreshed every frame by VulkanDescriptorManager::setCurrentShadowTlas()
|
||||
// to the live shadow TLAS -- or the permanent 0-instance fallback before the
|
||||
@@ -49,7 +62,7 @@ struct DescriptorFallbacks {
|
||||
// up automatically, so callers never need to bind it explicitly.
|
||||
vk::AccelerationStructureKHR shadowTlas;
|
||||
|
||||
const vk::DescriptorImageInfo& getImage(vk::ImageViewType t) const;
|
||||
const vk::DescriptorImageInfo& getImage(vk::ImageViewType t, bool depthCompare) const;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -299,13 +312,13 @@ public:
|
||||
*
|
||||
* Only meaningful for a set this writer actually wrote; a set reused from a
|
||||
* memoization cache was not written here, so its caller owns the offsets.
|
||||
* Always sized to the set layout's dynamic descriptor count (offsets for
|
||||
* bindings left at their fallback stay 0). Points at stable per-set storage —
|
||||
* Covers the set layout's dynamic descriptor count with room to spare (offsets
|
||||
* for bindings left at their fallback stay 0). Views stable per-set storage —
|
||||
* valid until the next writeSet() of that set.
|
||||
*/
|
||||
const uint32_t* dynamicOffsets(DescriptorSetIndex setIndex) const
|
||||
ArrayView<uint32_t> dynamicOffsets(DescriptorSetIndex setIndex) const
|
||||
{
|
||||
return m_dynOffsets[static_cast<size_t>(setIndex)].data();
|
||||
return m_dynOffsets[static_cast<size_t>(setIndex)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,6 +348,7 @@ private:
|
||||
vk::DescriptorImageInfo* imageInfo = nullptr; // non-null for image bindings
|
||||
uint32_t count = 0; // descriptor count (1 or 16 for arrays)
|
||||
vk::ImageViewType viewType = vk::ImageViewType::e2D; // for fallback lookup
|
||||
bool depthCompare = false; // for fallback lookup
|
||||
int dynIndex = -1; // slot in m_dynOffsets, or -1 if not dynamic
|
||||
};
|
||||
|
||||
|
||||
@@ -759,7 +759,7 @@ void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle,
|
||||
// the fixed 3-tier layout alongside the batched-submodel transform buffer;
|
||||
// no material textures are needed for depth-only rendering.
|
||||
//
|
||||
// All three sets are memoized across the shadow pass (see m_cachedShadowSets).
|
||||
// All three sets are memoized across the shadow pass (see m_cachedShadow).
|
||||
// ShadowMapData is a dynamic binding, so the only thing that changes from one
|
||||
// shadow draw to the next -- its UBO offset -- rides in the dynamic-offset array
|
||||
// and leaves the set contents untouched. Without that this path allocated and
|
||||
@@ -772,7 +772,13 @@ void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle,
|
||||
shadowInputs.cascadeHandle = cascade.bufferHandle.value();
|
||||
shadowInputs.cascadeOffset = cascade.offset;
|
||||
shadowInputs.cascadeSize = cascade.size;
|
||||
shadowInputs.cascadeValid = cascade.valid;
|
||||
// Whether the binding resolves *this frame*, not merely whether one was ever made:
|
||||
// the block lives in a streaming buffer, so a binding from an earlier frame no longer
|
||||
// points at anything (see bindPendingUBOs in applyMaterial). Folded into the key so a
|
||||
// cached set written with the placeholder buffer cannot be reused once the real
|
||||
// binding lands -- the bump offset alone can repeat from one frame to the next.
|
||||
shadowInputs.cascadeValid =
|
||||
cascade.valid && bufferManager->isFrameAllocCurrent(cascade.bufferHandle);
|
||||
shadowInputs.shadowDataHandle = ubo_handle.value();
|
||||
shadowInputs.shadowDataSize = ubo_size; // offset is dynamic, so not part of the key
|
||||
auto& tf = g_transformBuffers[descManager->getCurrentFrame()];
|
||||
@@ -783,24 +789,27 @@ void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle,
|
||||
}
|
||||
}
|
||||
|
||||
if (!m_cachedShadowValid || shadowInputs != m_cachedShadowInputs) {
|
||||
if (!m_cachedShadow.hits(shadowInputs)) {
|
||||
DescriptorWriter writer;
|
||||
writer.reset(descManager->getDevice(), descManager->getFallbacks());
|
||||
|
||||
m_cachedShadowGlobalSet = descManager->allocateFrameSet(DescriptorSetIndex::Global);
|
||||
Assert(m_cachedShadowGlobalSet);
|
||||
writer.writeSet(DescriptorSetIndex::Global, m_cachedShadowGlobalSet);
|
||||
ShadowSets sets;
|
||||
|
||||
sets.global = descManager->allocateFrameSet(DescriptorSetIndex::Global);
|
||||
Assert(sets.global);
|
||||
writer.writeSet(DescriptorSetIndex::Global, sets.global);
|
||||
if (shadowInputs.cascadeValid) {
|
||||
vk::Buffer buf = bufferManager->getVkBuffer(gr_buffer_handle(shadowInputs.cascadeHandle));
|
||||
vk::Buffer buf =
|
||||
bufferManager->getVkBufferForBinding(gr_buffer_handle(shadowInputs.cascadeHandle));
|
||||
if (buf) {
|
||||
writer.setBuffer(GlobalBinding::ShadowCascadeParams,
|
||||
{buf, shadowInputs.cascadeOffset, shadowInputs.cascadeSize});
|
||||
}
|
||||
}
|
||||
|
||||
m_cachedShadowMaterialSet = descManager->allocateFrameSet(DescriptorSetIndex::Material);
|
||||
Assert(m_cachedShadowMaterialSet);
|
||||
writer.writeSet(DescriptorSetIndex::Material, m_cachedShadowMaterialSet);
|
||||
sets.material = descManager->allocateFrameSet(DescriptorSetIndex::Material);
|
||||
Assert(sets.material);
|
||||
writer.writeSet(DescriptorSetIndex::Material, sets.material);
|
||||
{
|
||||
vk::Buffer buf = bufferManager->getVkBuffer(ubo_handle);
|
||||
if (buf) {
|
||||
@@ -815,13 +824,12 @@ void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle,
|
||||
static_cast<vk::DeviceSize>(shadowInputs.transformSize)});
|
||||
}
|
||||
|
||||
m_cachedShadowPerDrawSet = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw);
|
||||
Assert(m_cachedShadowPerDrawSet);
|
||||
writer.writeSet(DescriptorSetIndex::PerDraw, m_cachedShadowPerDrawSet);
|
||||
sets.perDraw = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw);
|
||||
Assert(sets.perDraw);
|
||||
writer.writeSet(DescriptorSetIndex::PerDraw, sets.perDraw);
|
||||
writer.flush();
|
||||
|
||||
m_cachedShadowInputs = shadowInputs;
|
||||
m_cachedShadowValid = true;
|
||||
m_cachedShadow.store(sets, shadowInputs);
|
||||
}
|
||||
|
||||
// applyMaterial's caches deliberately are NOT invalidated here. They record
|
||||
@@ -839,9 +847,10 @@ void VulkanDrawManager::renderShadowDraw(gr_buffer_handle ubo_handle,
|
||||
|
||||
static constexpr uint32_t perDrawDynOffsets[PERDRAW_DYNAMIC_OFFSET_COUNT] = {};
|
||||
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, m_cachedShadowGlobalSet, nullptr);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, m_cachedShadowMaterialSet, materialDynOffsets);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, m_cachedShadowPerDrawSet, perDrawDynOffsets);
|
||||
const auto& sets = m_cachedShadow.payload();
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, sets.global);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, sets.material, materialDynOffsets);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, sets.perDraw, perDrawDynOffsets);
|
||||
}
|
||||
|
||||
vk::Buffer vbuffer = bufferManager->getVkBuffer(vert_src->Vbuffer_handle);
|
||||
@@ -949,9 +958,18 @@ void VulkanDrawManager::clearStates()
|
||||
stateTracker->setDepthBias(0.0f, 0.0f);
|
||||
stateTracker->setLineWidth(1.0f);
|
||||
|
||||
// Clear pending uniform bindings
|
||||
clearPendingUniformBindings();
|
||||
|
||||
// NOTE: Do NOT clear the pending uniform bindings here. gr_opengl_clear_states() leaves
|
||||
// glBindBufferRange() alone, so under OpenGL a uniform block stays bound across a
|
||||
// clear_states(); dropping them here made Vulkan diverge. It matters because
|
||||
// model_render_immediate() ends with gr_clear_states(), so anything bound once for a whole
|
||||
// pass -- ShadowCascadeParams above all, which nothing re-binds per model -- survived only
|
||||
// until the first model finished drawing. Every model after that fell back to the zeroed
|
||||
// placeholder UBO, which reads as rtShadowSampleCount == 0 and collapses
|
||||
// traceShadowRayCone() to a single hard ray. A binding that has outlived what it points at is
|
||||
// handled where it is consumed: the descriptor writers resolve it through
|
||||
// getVkBufferForBinding(), which hands back the zeroed placeholder buffer for a deleted buffer
|
||||
// or for a streaming sub-allocation from an earlier frame.
|
||||
//
|
||||
// NOTE: Do NOT call resetClip() here. OpenGL's gr_opengl_clear_states() does
|
||||
// not reset the clip region, and callers (e.g. model_render_immediate) rely on
|
||||
// the clip/offset state surviving through clear_states for subsequent 2D draws.
|
||||
@@ -979,16 +997,6 @@ void VulkanDrawManager::setPendingUniformBinding(uniform_block_type blockType, g
|
||||
}
|
||||
}
|
||||
|
||||
void VulkanDrawManager::clearPendingUniformBindings()
|
||||
{
|
||||
for (auto& binding : m_pendingUniformBindings) {
|
||||
binding.valid = false;
|
||||
binding.bufferHandle = gr_buffer_handle();
|
||||
binding.offset = 0;
|
||||
binding.size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void VulkanDrawManager::resetFrameStats()
|
||||
{
|
||||
m_frameStats = {};
|
||||
@@ -999,14 +1007,9 @@ void VulkanDrawManager::resetFrameStats()
|
||||
// the frame's first applyMaterial().
|
||||
m_cachedGlobalSet = nullptr;
|
||||
m_globalSetDirty = true;
|
||||
m_cachedMaterialSet = nullptr;
|
||||
m_cachedMaterialValid = false;
|
||||
m_cachedPerDrawSet = nullptr;
|
||||
m_cachedPerDrawValid = false;
|
||||
m_cachedShadowGlobalSet = nullptr;
|
||||
m_cachedShadowMaterialSet = nullptr;
|
||||
m_cachedShadowPerDrawSet = nullptr;
|
||||
m_cachedShadowValid = false;
|
||||
m_cachedMaterial.invalidate();
|
||||
m_cachedPerDraw.invalidate();
|
||||
m_cachedShadow.invalidate();
|
||||
}
|
||||
|
||||
void VulkanDrawManager::printFrameStats()
|
||||
@@ -1381,13 +1384,20 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v
|
||||
DescriptorWriter writer;
|
||||
writer.reset(descManager->getDevice(), descManager->getFallbacks());
|
||||
|
||||
// Bind pending UBOs for a given descriptor set
|
||||
// Bind pending UBOs for a given descriptor set.
|
||||
//
|
||||
// getVkBufferForBinding() rather than getVkBuffer(): a binding survives the frame it was
|
||||
// made in (clearStates() deliberately leaves it alone, see there), but a streaming
|
||||
// buffer's sub-allocation does not, so a block bound once per frame -- ShadowCascadeParams
|
||||
// -- is stale for every draw that precedes this frame's bind. Resolving to nullptr puts
|
||||
// the zeroed placeholder buffer in the descriptor, which is what those draws would have
|
||||
// got before anything bound the block at all.
|
||||
auto bindPendingUBOs = [&](DescriptorSetIndex targetSet) {
|
||||
for (const auto& entry : VulkanDescriptorManager::getUniformBindings(targetSet)) {
|
||||
vk::DescriptorBufferInfo bufInfo;
|
||||
const auto& pending = m_pendingUniformBindings[static_cast<size_t>(entry.blockType)];
|
||||
if (pending.valid) {
|
||||
vk::Buffer buf = bufferManager->getVkBuffer(pending.bufferHandle);
|
||||
vk::Buffer buf = bufferManager->getVkBufferForBinding(pending.bufferHandle);
|
||||
if (buf) {
|
||||
bufInfo = vk::DescriptorBufferInfo(buf, pending.offset, pending.size);
|
||||
}
|
||||
@@ -1402,7 +1412,7 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v
|
||||
// is reused from cache and never written.
|
||||
const auto dynOffsetOf = [&](uniform_block_type blockType) -> uint32_t {
|
||||
const auto& pending = m_pendingUniformBindings[static_cast<size_t>(blockType)];
|
||||
if (!pending.valid || !bufferManager->getVkBuffer(pending.bufferHandle)) {
|
||||
if (!pending.valid || !bufferManager->getVkBufferForBinding(pending.bufferHandle)) {
|
||||
return 0; // falls back to the dummy buffer, which is bound at offset 0
|
||||
}
|
||||
return static_cast<uint32_t>(pending.offset);
|
||||
@@ -1440,7 +1450,7 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v
|
||||
}
|
||||
vk::DescriptorSet globalSet = m_cachedGlobalSet;
|
||||
|
||||
// Set 1: Material (previous-set memoized — see m_cachedMaterialSet).
|
||||
// Set 1: Material (previous-set memoized — see m_cachedMaterial).
|
||||
// Snapshot every input; reuse the cached set only on an exact match.
|
||||
MaterialSetInputs matInputs;
|
||||
matInputs.texHandles[0] = mat->get_texture_map(TM_BASE_TYPE);
|
||||
@@ -1481,10 +1491,10 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v
|
||||
}
|
||||
|
||||
vk::DescriptorSet materialSet;
|
||||
if (m_cachedMaterialValid && m_cachedMaterialSet && matInputs == m_cachedMaterialInputs) {
|
||||
if (m_cachedMaterial.hits(matInputs)) {
|
||||
// Identical to the previous draw: reuse the set (skips allocation, the
|
||||
// template write, texture resolution/upload, and the override/UBO writes).
|
||||
materialSet = m_cachedMaterialSet;
|
||||
materialSet = m_cachedMaterial.payload();
|
||||
} else {
|
||||
materialSet = descManager->allocateFrameSet(DescriptorSetIndex::Material);
|
||||
Assert(materialSet);
|
||||
@@ -1500,12 +1510,10 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v
|
||||
writer.setImage(MaterialBinding::SceneColor, m_sceneColorInfo);
|
||||
writer.setImage(MaterialBinding::DistortionMap, m_distMapInfo);
|
||||
bindMaterialTextures(mat, &writer);
|
||||
m_cachedMaterialSet = materialSet;
|
||||
m_cachedMaterialInputs = matInputs;
|
||||
m_cachedMaterialValid = true;
|
||||
m_cachedMaterial.store(materialSet, matInputs);
|
||||
}
|
||||
|
||||
// Set 2: PerDraw (previous-set memoized — see m_cachedPerDrawSet)
|
||||
// Set 2: PerDraw (previous-set memoized — see m_cachedPerDraw)
|
||||
PerDrawSetInputs pdInputs;
|
||||
{
|
||||
struct PerDrawUbo {
|
||||
@@ -1531,19 +1539,17 @@ bool VulkanDrawManager::applyMaterial(material* mat, primitive_type prim_type, v
|
||||
}
|
||||
|
||||
vk::DescriptorSet perDrawSet;
|
||||
if (m_cachedPerDrawValid && m_cachedPerDrawSet && pdInputs == m_cachedPerDrawInputs) {
|
||||
perDrawSet = m_cachedPerDrawSet;
|
||||
if (m_cachedPerDraw.hits(pdInputs)) {
|
||||
perDrawSet = m_cachedPerDraw.payload();
|
||||
} else {
|
||||
perDrawSet = descManager->allocateFrameSet(DescriptorSetIndex::PerDraw);
|
||||
Assert(perDrawSet);
|
||||
writer.writeSet(DescriptorSetIndex::PerDraw, perDrawSet);
|
||||
bindPendingUBOs(DescriptorSetIndex::PerDraw);
|
||||
m_cachedPerDrawSet = perDrawSet;
|
||||
m_cachedPerDrawInputs = pdInputs;
|
||||
m_cachedPerDrawValid = true;
|
||||
m_cachedPerDraw.store(perDrawSet, pdInputs);
|
||||
}
|
||||
writer.flush();
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, globalSet, nullptr);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Global, globalSet);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::Material, materialSet, materialDynOffsets);
|
||||
stateTracker->bindDescriptorSet(DescriptorSetIndex::PerDraw, perDrawSet, perDrawDynOffsets);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,72 @@ struct PendingUniformBinding {
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief A descriptor set, or a fixed group of them, remembered alongside the inputs it was built from
|
||||
*
|
||||
* Writing and allocating a descriptor set per draw is the cost this exists to avoid: consecutive
|
||||
* draws very often want a set identical to the one before it. This is a *previous-only* cache --
|
||||
* any difference in the inputs rebuilds, so a hit is never stale -- and it holds for one frame,
|
||||
* because the sets are frame-pool allocated (see resetFrameStats()).
|
||||
*
|
||||
* @tparam Inputs everything the set's contents are derived from; needs operator==. Anything carried
|
||||
* as a dynamic offset must be left OUT of it, since an offset-only change rides in the
|
||||
* dynamic-offset array and leaves the set itself identical -- that is what makes the hit
|
||||
* rate worth having.
|
||||
* @tparam Payload what a hit hands back. A parameter because the shadow pass memoizes all three of
|
||||
* its sets against a single key, while applyMaterial() memoizes one set per key.
|
||||
*/
|
||||
template <typename Inputs, typename Payload = vk::DescriptorSet>
|
||||
class MemoizedDescriptorSet {
|
||||
public:
|
||||
/**
|
||||
* @brief Whether @p inputs matches what the stored payload was built from
|
||||
*
|
||||
* A true result means payload() can be rebound as-is, with no allocation or write.
|
||||
*/
|
||||
bool hits(const Inputs& inputs) const { return m_valid && m_inputs == inputs; }
|
||||
|
||||
/**
|
||||
* @brief The stored sets. Only meaningful after hits() returned true.
|
||||
*/
|
||||
const Payload& payload() const { return m_payload; }
|
||||
|
||||
void store(const Payload& payload, const Inputs& inputs)
|
||||
{
|
||||
// Never memoize an incomplete payload. allocateFrameSet() hands back a null handle when
|
||||
// the pool is exhausted, and the Assert() that catches it at the call site is compiled
|
||||
// out of release builds -- caching that would turn one failed allocation into every
|
||||
// later draw binding a null set, long after the pressure that caused it passed.
|
||||
if (!payload) {
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
m_payload = payload;
|
||||
m_inputs = inputs;
|
||||
m_valid = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Forget the stored sets
|
||||
*
|
||||
* Every caller has the same reason: the handles can no longer be assumed current, either
|
||||
* because the frame pool that owns them was reset or because something rebound descriptor
|
||||
* sets behind applyMaterial()'s back. Clearing the payload as well as the flag means a
|
||||
* missing hits() check binds a null set (which asserts) rather than a recycled one.
|
||||
*/
|
||||
void invalidate()
|
||||
{
|
||||
m_payload = Payload{};
|
||||
m_valid = false;
|
||||
}
|
||||
|
||||
private:
|
||||
Payload m_payload{};
|
||||
Inputs m_inputs;
|
||||
bool m_valid = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Handles Vulkan draw command recording
|
||||
*
|
||||
@@ -290,7 +356,7 @@ class VulkanDrawManager {
|
||||
* @brief Invalidate every memoized descriptor-set cache (Global + Material + PerDraw)
|
||||
*
|
||||
* These previous-set caches (see the class comments above m_cachedGlobalSet /
|
||||
* m_cachedMaterialSet / m_cachedPerDrawSet) assume nothing rebinds a *different*
|
||||
* m_cachedMaterial / m_cachedPerDraw) assume nothing rebinds a *different*
|
||||
* descriptor set on the command buffer between applyMaterial() calls behind their
|
||||
* back. Code that renders directly on the command buffer without going through
|
||||
* applyMaterial (currently: ImGui's Vulkan backend, drawing into the same active
|
||||
@@ -301,9 +367,9 @@ class VulkanDrawManager {
|
||||
void invalidateDrawStateCaches()
|
||||
{
|
||||
m_globalSetDirty = true;
|
||||
m_cachedMaterialValid = false;
|
||||
m_cachedPerDrawValid = false;
|
||||
m_cachedShadowValid = false;
|
||||
m_cachedMaterial.invalidate();
|
||||
m_cachedPerDraw.invalidate();
|
||||
m_cachedShadow.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,11 +399,6 @@ class VulkanDrawManager {
|
||||
vk::DeviceSize offset,
|
||||
vk::DeviceSize size);
|
||||
|
||||
/**
|
||||
* @brief Clear all pending uniform bindings
|
||||
*/
|
||||
void clearPendingUniformBindings();
|
||||
|
||||
/**
|
||||
* @brief Get a pending uniform binding by block type index
|
||||
*/
|
||||
@@ -538,11 +599,8 @@ class VulkanDrawManager {
|
||||
imgEq(depthInfo, o.depthInfo) && imgEq(sceneColorInfo, o.sceneColorInfo) &&
|
||||
imgEq(distMapInfo, o.distMapInfo);
|
||||
}
|
||||
bool operator!=(const MaterialSetInputs& o) const { return !(*this == o); }
|
||||
};
|
||||
vk::DescriptorSet m_cachedMaterialSet = nullptr;
|
||||
MaterialSetInputs m_cachedMaterialInputs;
|
||||
bool m_cachedMaterialValid = false;
|
||||
MemoizedDescriptorSet<MaterialSetInputs> m_cachedMaterial;
|
||||
|
||||
// ---- PerDraw (Set 2) previous-set memoization ----
|
||||
// PerDraw holds only the pending PerDraw UBO bindings (GenericData, Matrices,
|
||||
@@ -564,9 +622,7 @@ class VulkanDrawManager {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
vk::DescriptorSet m_cachedPerDrawSet = nullptr;
|
||||
PerDrawSetInputs m_cachedPerDrawInputs;
|
||||
bool m_cachedPerDrawValid = false;
|
||||
MemoizedDescriptorSet<PerDrawSetInputs> m_cachedPerDraw;
|
||||
|
||||
// ---- Shadow-pass descriptor memoization (renderShadowDraw) ----
|
||||
// The shadow pass doesn't go through applyMaterial, so it gets its own cache.
|
||||
@@ -592,16 +648,17 @@ class VulkanDrawManager {
|
||||
transformBuffer == o.transformBuffer && transformOffset == o.transformOffset &&
|
||||
transformSize == o.transformSize;
|
||||
}
|
||||
bool operator!=(const ShadowSetInputs& o) const
|
||||
{
|
||||
return !(*this == o);
|
||||
}
|
||||
};
|
||||
vk::DescriptorSet m_cachedShadowGlobalSet = nullptr;
|
||||
vk::DescriptorSet m_cachedShadowMaterialSet = nullptr;
|
||||
vk::DescriptorSet m_cachedShadowPerDrawSet = nullptr;
|
||||
ShadowSetInputs m_cachedShadowInputs;
|
||||
bool m_cachedShadowValid = false;
|
||||
// The whole set of three, memoized together: they share one key, so they are only ever all
|
||||
// valid or all rebuilt.
|
||||
struct ShadowSets {
|
||||
vk::DescriptorSet global;
|
||||
vk::DescriptorSet material;
|
||||
vk::DescriptorSet perDraw;
|
||||
|
||||
explicit operator bool() const { return global && material && perDraw; }
|
||||
};
|
||||
MemoizedDescriptorSet<ShadowSetInputs, ShadowSets> m_cachedShadow;
|
||||
|
||||
// Texture overrides for material bindings 4-6.
|
||||
vk::DescriptorImageInfo m_depthTextureInfo; // binding 4: depth/position for soft particles
|
||||
|
||||
@@ -214,9 +214,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 {
|
||||
|
||||
@@ -777,7 +777,10 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd)
|
||||
auto* drawManager = getDrawManager();
|
||||
const auto& pending = drawManager->getPendingUniformBinding(static_cast<size_t>(uniform_block_type::ShadowCascadeParams));
|
||||
if (pending.valid) {
|
||||
vk::Buffer buf = bufferMgr->getVkBuffer(pending.bufferHandle);
|
||||
// Resolved through getVkBufferForBinding() like applyMaterial's own pending UBOs: the
|
||||
// block lives in a streaming buffer, so a binding made in an earlier frame no longer
|
||||
// points at anything and has to fall back to the placeholder rather than assert.
|
||||
vk::Buffer buf = bufferMgr->getVkBufferForBinding(pending.bufferHandle);
|
||||
if (buf) {
|
||||
shadowCascadeParamsInfo = vk::DescriptorBufferInfo(buf, pending.offset, pending.size);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
|
||||
#include "VulkanPresentTarget.h"
|
||||
|
||||
#include "VulkanRenderer.h"
|
||||
|
||||
#include "cmdline/cmdline.h"
|
||||
#include "graphics/2d.h"
|
||||
#include "graphics/grinternal.h"
|
||||
#include "mod_table/mod_table.h"
|
||||
|
||||
namespace graphics::vulkan {
|
||||
|
||||
namespace {
|
||||
|
||||
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.
|
||||
//
|
||||
// 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) &&
|
||||
availableFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT) {
|
||||
nprintf(("vulkan", "Vulkan: Selected HDR10 surface (10-bit, ST.2084/BT.2020)\n"));
|
||||
return availableFormat;
|
||||
}
|
||||
}
|
||||
nprintf(("vulkan", "Vulkan: HDR requested but no HDR10 surface format available; falling back to SDR\n"));
|
||||
}
|
||||
|
||||
// Use a non-sRGB (UNORM) format to match OpenGL's default framebuffer behavior.
|
||||
// The FSO shaders handle gamma correction manually in the fragment shader and
|
||||
// post-processing pipeline, so hardware sRGB conversion would double-correct.
|
||||
for (const auto& availableFormat : values.surfaceFormats) {
|
||||
if (availableFormat.format == vk::Format::eB8G8R8A8Unorm &&
|
||||
availableFormat.colorSpace == vk::ColorSpaceKHR::eSrgbNonlinear) {
|
||||
return availableFormat;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no preferred format matched. Pick the first concrete format,
|
||||
// defensively skipping any eUndefined entry (the legacy "any format allowed"
|
||||
// sentinel), and log the actual choice so it's visible in the log.
|
||||
for (const auto& availableFormat : values.surfaceFormats) {
|
||||
if (availableFormat.format != vk::Format::eUndefined) {
|
||||
nprintf(("vulkan", "Vulkan: no preferred surface format available; falling back to format=%d colorSpace=%d\n",
|
||||
static_cast<int>(availableFormat.format), static_cast<int>(availableFormat.colorSpace)));
|
||||
return availableFormat;
|
||||
}
|
||||
}
|
||||
|
||||
// Degenerate list (all eUndefined) — return the front entry and warn.
|
||||
nprintf(("vulkan", "Vulkan: surface format list has no concrete entry; using front (format=%d)\n",
|
||||
static_cast<int>(values.surfaceFormats.front().format)));
|
||||
return values.surfaceFormats.front();
|
||||
}
|
||||
|
||||
vk::PresentModeKHR choosePresentMode(const PhysicalDeviceValues& values)
|
||||
{
|
||||
// With vsync requested, use FIFO: it is the only spec-guaranteed mode and
|
||||
// the only one that actually caps the frame rate to the display. Mailbox is
|
||||
// tear-free but uncapped ("fast vsync") and must not be silently substituted
|
||||
// for requested vsync. Without vsync prefer Immediate (true uncapped), then
|
||||
// Mailbox (uncapped but tear-free), then the guaranteed FIFO fallback.
|
||||
vk::PresentModeKHR chosen = vk::PresentModeKHR::eFifo;
|
||||
|
||||
if (!Gr_enable_vsync) {
|
||||
for (const auto& availablePresentMode : values.presentModes) {
|
||||
if (availablePresentMode == vk::PresentModeKHR::eImmediate) {
|
||||
chosen = availablePresentMode;
|
||||
break;
|
||||
}
|
||||
if (availablePresentMode == vk::PresentModeKHR::eMailbox) {
|
||||
chosen = availablePresentMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const char* name = "Unknown";
|
||||
switch (chosen) {
|
||||
case vk::PresentModeKHR::eImmediate: name = "Immediate"; break;
|
||||
case vk::PresentModeKHR::eMailbox: name = "Mailbox"; break;
|
||||
case vk::PresentModeKHR::eFifo: name = "FIFO (vsync)"; break;
|
||||
case vk::PresentModeKHR::eFifoRelaxed: name = "FIFO Relaxed"; break;
|
||||
default: break;
|
||||
}
|
||||
mprintf(("Vulkan: Present mode: %s (Gr_enable_vsync=%d)\n", name, Gr_enable_vsync ? 1 : 0));
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The size of the actual window, which is not the render resolution
|
||||
*
|
||||
* Only used where the surface declines to tell us its extent (Wayland reports
|
||||
* UINT32_MAX and lets the client pick). gr_screen.max_w/max_h is the wrong
|
||||
* answer there: with -window_res set -- which -vr forces -- the window is
|
||||
* smaller than the resolution the engine draws at.
|
||||
*
|
||||
* The target's own viewport answers first, so qtFRED's second window gets its own size rather
|
||||
* than the main one's.
|
||||
*/
|
||||
vk::Extent2D windowExtent(const VulkanPresentTarget& target)
|
||||
{
|
||||
if (target.viewport != nullptr) {
|
||||
const auto size = target.viewport->getSize();
|
||||
if (size.first > 0 && size.second > 0) {
|
||||
return {size.first, size.second};
|
||||
}
|
||||
}
|
||||
|
||||
if (Cmdline_window_res) {
|
||||
return {Cmdline_window_res->first, Cmdline_window_res->second};
|
||||
}
|
||||
|
||||
return {static_cast<uint32_t>(gr_screen.max_w), static_cast<uint32_t>(gr_screen.max_h)};
|
||||
}
|
||||
|
||||
vk::Extent2D chooseSwapChainExtent(const vk::SurfaceCapabilitiesKHR& capabilities, uint32_t width, uint32_t height)
|
||||
{
|
||||
if (capabilities.currentExtent.width != UINT32_MAX) {
|
||||
return capabilities.currentExtent;
|
||||
} else {
|
||||
VkExtent2D actualExtent = {width, height};
|
||||
|
||||
actualExtent.width = std::max(capabilities.minImageExtent.width,
|
||||
std::min(capabilities.maxImageExtent.width, actualExtent.width));
|
||||
actualExtent.height = std::max(capabilities.minImageExtent.height,
|
||||
std::min(capabilities.maxImageExtent.height, actualExtent.height));
|
||||
|
||||
return actualExtent;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool checkSwapChainSupport(PhysicalDeviceValues& values, vk::SurfaceKHR surface)
|
||||
{
|
||||
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);
|
||||
values.presentModes.assign(modes.begin(), modes.end());
|
||||
|
||||
return !values.surfaceFormats.empty() && !values.presentModes.empty();
|
||||
}
|
||||
|
||||
VulkanSurfaceHandle::VulkanSurfaceHandle(os::VulkanSurfaceProvider* provider,
|
||||
vk::Instance instance,
|
||||
vk::SurfaceKHR surface)
|
||||
: m_provider(provider), m_instance(instance), m_surface(surface)
|
||||
{
|
||||
}
|
||||
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;
|
||||
|
||||
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<VkInstance>(m_instance),
|
||||
os::vulkan_handle_value(static_cast<VkSurfaceKHR>(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<VkInstance>(*m_vkInstance));
|
||||
if (surface == 0) {
|
||||
nprintf(("vulkan", "Vulkan: failed to create a surface for this viewport.\n"));
|
||||
return false;
|
||||
}
|
||||
|
||||
target.surface = VulkanSurfaceHandle(vulkanSupport,
|
||||
*m_vkInstance,
|
||||
vk::SurfaceKHR(os::vulkan_handle_cast<VkSurfaceKHR>(surface)));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== Extent-sized resources ==========
|
||||
//
|
||||
// Every one of these is rebuilt whenever the swap chain is, which is why they live next to
|
||||
// it rather than with the renderer-wide setup: their size comes from the target's surface.
|
||||
|
||||
void VulkanRenderer::createCompositionResources(VulkanPresentTarget& target)
|
||||
{
|
||||
// Free any previous composition resources (swap chain recreation path)
|
||||
target.compositionImageViews.clear();
|
||||
target.compositionImages.clear();
|
||||
for (auto& alloc : target.compositionAllocations) {
|
||||
if (alloc.isValid()) {
|
||||
m_memoryManager->freeAllocation(alloc);
|
||||
}
|
||||
}
|
||||
target.compositionAllocations.clear();
|
||||
|
||||
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(target.renderExtent.width, target.renderExtent.height, 1);
|
||||
imageInfo.mipLevels = 1;
|
||||
imageInfo.arrayLayers = 1;
|
||||
imageInfo.samples = vk::SampleCountFlagBits::e1;
|
||||
imageInfo.tiling = vk::ImageTiling::eOptimal;
|
||||
imageInfo.usage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eSampled |
|
||||
vk::ImageUsageFlagBits::eTransferSrc;
|
||||
imageInfo.sharingMode = vk::SharingMode::eExclusive;
|
||||
imageInfo.initialLayout = vk::ImageLayout::eUndefined;
|
||||
|
||||
auto image = m_device->createImageUnique(imageInfo);
|
||||
|
||||
VulkanAllocation alloc{};
|
||||
m_memoryManager->allocateImageMemory(image.get(), MemoryUsage::GpuOnly, alloc, MemoryPurpose::RenderTarget);
|
||||
|
||||
vk::ImageViewCreateInfo viewInfo;
|
||||
viewInfo.image = image.get();
|
||||
viewInfo.viewType = vk::ImageViewType::e2D;
|
||||
viewInfo.format = HDR_COLOR_FORMAT;
|
||||
viewInfo.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1};
|
||||
auto view = m_device->createImageViewUnique(viewInfo);
|
||||
|
||||
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.
|
||||
if (!m_compositionSampler) {
|
||||
vk::SamplerCreateInfo sampInfo;
|
||||
sampInfo.magFilter = vk::Filter::eNearest;
|
||||
sampInfo.minFilter = vk::Filter::eNearest;
|
||||
sampInfo.mipmapMode = vk::SamplerMipmapMode::eNearest;
|
||||
sampInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge;
|
||||
sampInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge;
|
||||
sampInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge;
|
||||
m_compositionSampler = m_device->createSamplerUnique(sampInfo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void VulkanRenderer::createFrameBuffers(VulkanPresentTarget& target)
|
||||
{
|
||||
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.
|
||||
target.framebuffers.reserve(target.compositionImageViews.size());
|
||||
for (const auto& compView : target.compositionImageViews) {
|
||||
const vk::ImageView attachments[] = {
|
||||
compView.get(),
|
||||
target.depthImageView.get(),
|
||||
};
|
||||
|
||||
vk::FramebufferCreateInfo framebufferInfo;
|
||||
framebufferInfo.renderPass = m_renderPass.get();
|
||||
framebufferInfo.attachmentCount = 2;
|
||||
framebufferInfo.pAttachments = attachments;
|
||||
framebufferInfo.width = target.renderExtent.width;
|
||||
framebufferInfo.height = target.renderExtent.height;
|
||||
framebufferInfo.layers = 1;
|
||||
|
||||
target.framebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo));
|
||||
}
|
||||
|
||||
// Encode framebuffers: color = actual swap chain image.
|
||||
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 = target.extent.width;
|
||||
framebufferInfo.height = target.extent.height;
|
||||
framebufferInfo.layers = 1;
|
||||
|
||||
target.encodeFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
vk::Format VulkanRenderer::findDepthFormat()
|
||||
{
|
||||
// Prefer D32_SFLOAT for best precision, fall back to D32_SFLOAT_S8 or D24_UNORM_S8
|
||||
const vk::Format candidates[] = {
|
||||
vk::Format::eD32Sfloat,
|
||||
vk::Format::eD32SfloatS8Uint,
|
||||
vk::Format::eD24UnormS8Uint,
|
||||
};
|
||||
|
||||
for (auto format : candidates) {
|
||||
auto props = m_physicalDevice.getFormatProperties(format);
|
||||
if (props.optimalTilingFeatures & vk::FormatFeatureFlagBits::eDepthStencilAttachment) {
|
||||
return format;
|
||||
}
|
||||
}
|
||||
|
||||
// Should never happen on any real GPU
|
||||
Error(LOCATION, "Failed to find supported depth format!");
|
||||
return vk::Format::eD32Sfloat;
|
||||
}
|
||||
void VulkanRenderer::createDepthResources(VulkanPresentTarget& target)
|
||||
{
|
||||
const vk::Format depthFormat = findDepthFormat();
|
||||
// The render passes (m_renderPass, scene/G-buffer passes, ...) bake in the
|
||||
// depth format, and they are deliberately kept alive across swap chain
|
||||
// recreation. A driver changing its supported depth formats mid-session
|
||||
// would make them all incompatible with the new attachment.
|
||||
if (m_depthFormat != vk::Format::eUndefined && depthFormat != m_depthFormat) {
|
||||
Error(LOCATION, "Vulkan: depth format changed across swap chain recreation (%d -> %d)!",
|
||||
static_cast<int>(m_depthFormat), static_cast<int>(depthFormat));
|
||||
}
|
||||
m_depthFormat = depthFormat;
|
||||
|
||||
// Create depth image
|
||||
vk::ImageCreateInfo imageInfo;
|
||||
imageInfo.imageType = vk::ImageType::e2D;
|
||||
imageInfo.format = m_depthFormat;
|
||||
imageInfo.extent.width = target.renderExtent.width;
|
||||
imageInfo.extent.height = target.renderExtent.height;
|
||||
imageInfo.extent.depth = 1;
|
||||
imageInfo.mipLevels = 1;
|
||||
imageInfo.arrayLayers = 1;
|
||||
imageInfo.samples = vk::SampleCountFlagBits::e1;
|
||||
imageInfo.tiling = vk::ImageTiling::eOptimal;
|
||||
imageInfo.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment;
|
||||
imageInfo.sharingMode = vk::SharingMode::eExclusive;
|
||||
imageInfo.initialLayout = vk::ImageLayout::eUndefined;
|
||||
|
||||
target.depthImage = m_device->createImageUnique(imageInfo);
|
||||
|
||||
// Allocate GPU memory for the depth image
|
||||
m_memoryManager->allocateImageMemory(
|
||||
target.depthImage.get(), MemoryUsage::GpuOnly, target.depthImageMemory, MemoryPurpose::RenderTarget);
|
||||
|
||||
// Create depth image view
|
||||
vk::ImageViewCreateInfo viewInfo;
|
||||
viewInfo.image = target.depthImage.get();
|
||||
viewInfo.viewType = vk::ImageViewType::e2D;
|
||||
viewInfo.format = m_depthFormat;
|
||||
viewInfo.subresourceRange.aspectMask = imageAspectFromFormat(m_depthFormat);
|
||||
viewInfo.subresourceRange.baseMipLevel = 0;
|
||||
viewInfo.subresourceRange.levelCount = 1;
|
||||
viewInfo.subresourceRange.baseArrayLayer = 0;
|
||||
viewInfo.subresourceRange.layerCount = 1;
|
||||
|
||||
target.depthImageView = m_device->createImageViewUnique(viewInfo);
|
||||
|
||||
nprintf(("vulkan", "Vulkan: Created depth buffer (%dx%d, format %d)\n",
|
||||
target.renderExtent.width, target.renderExtent.height, static_cast<int>(m_depthFormat)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
void VulkanRenderer::createPresentSyncObjects(VulkanPresentTarget& target)
|
||||
{
|
||||
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) {
|
||||
target.frames[i] = std::make_unique<VulkanRenderFrame>(m_device.get(), target.swapChain.get(), m_graphicsQueue, m_presentQueue);
|
||||
}
|
||||
|
||||
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::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<int>(target.imageFormat), static_cast<int>(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;
|
||||
if (deviceValues.surfaceCapabilities.maxImageCount > 0 &&
|
||||
imageCount > deviceValues.surfaceCapabilities.maxImageCount) {
|
||||
imageCount = deviceValues.surfaceCapabilities.maxImageCount;
|
||||
}
|
||||
|
||||
const auto surfaceFormat = chooseSurfaceFormat(deviceValues);
|
||||
|
||||
const vk::Extent2D window = windowExtent(target);
|
||||
|
||||
vk::SwapchainCreateInfoKHR createInfo;
|
||||
createInfo.surface = target.surface.get();
|
||||
createInfo.minImageCount = imageCount;
|
||||
createInfo.imageFormat = surfaceFormat.format;
|
||||
createInfo.imageColorSpace = surfaceFormat.colorSpace;
|
||||
createInfo.imageExtent = chooseSwapChainExtent(deviceValues.surfaceCapabilities, window.width, window.height);
|
||||
createInfo.imageArrayLayers = 1;
|
||||
createInfo.imageUsage = vk::ImageUsageFlagBits::eColorAttachment
|
||||
| vk::ImageUsageFlagBits::eTransferSrc
|
||||
| vk::ImageUsageFlagBits::eTransferDst;
|
||||
|
||||
const uint32_t queueFamilyIndices[] = {deviceValues.graphicsQueueIndex.index, deviceValues.presentQueueIndex.index};
|
||||
if (deviceValues.graphicsQueueIndex.index != deviceValues.presentQueueIndex.index) {
|
||||
createInfo.imageSharingMode = vk::SharingMode::eConcurrent;
|
||||
createInfo.queueFamilyIndexCount = 2;
|
||||
createInfo.pQueueFamilyIndices = queueFamilyIndices;
|
||||
} else {
|
||||
createInfo.imageSharingMode = vk::SharingMode::eExclusive;
|
||||
}
|
||||
|
||||
// preTransform describes the transform already applied to the rendered content.
|
||||
// FSO does not pre-rotate it, so request identity whenever supported. If this
|
||||
// differs from currentTransform (e.g. landscape on an Android phone), the
|
||||
// presentation engine compensates during presentation. Passing currentTransform
|
||||
// instead would claim that FSO had already performed that rotation.
|
||||
auto supported = deviceValues.surfaceCapabilities.supportedTransforms;
|
||||
if (supported & vk::SurfaceTransformFlagBitsKHR::eIdentity) {
|
||||
createInfo.preTransform = vk::SurfaceTransformFlagBitsKHR::eIdentity;
|
||||
} else {
|
||||
createInfo.preTransform = deviceValues.surfaceCapabilities.currentTransform;
|
||||
}
|
||||
createInfo.compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque;
|
||||
createInfo.presentMode = choosePresentMode(deviceValues);
|
||||
createInfo.clipped = true;
|
||||
createInfo.oldSwapchain = oldSwapchain;
|
||||
|
||||
auto newSwapChain = m_device->createSwapchainKHRUnique(createInfo);
|
||||
|
||||
// Clear old resources before replacing the swap chain
|
||||
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;
|
||||
target.preTransform = createInfo.preTransform;
|
||||
|
||||
// Only the main target can draw at a size other than its window. Without -window_res the render
|
||||
// resolution simply is the window, so follow gr_screen (which gr_screen_resize() has already
|
||||
// updated for this recreation). With it, the two are deliberately independent -- the window is
|
||||
// whatever Cmdline_window_res says (SDLGraphicsOperations::createViewport forces it) while the
|
||||
// engine keeps drawing at gr_screen.max_w/max_h -- so the value captured in initialize() must
|
||||
// survive untouched. qtFRED's extra viewports have no such split: useViewport() resizes
|
||||
// gr_screen to whichever one is current, so they draw at their own swap chain extent.
|
||||
if (&target == m_mainTarget) {
|
||||
if (!Cmdline_window_res) {
|
||||
m_renderExtent =
|
||||
vk::Extent2D(static_cast<uint32_t>(gr_screen.max_w), static_cast<uint32_t>(gr_screen.max_h));
|
||||
}
|
||||
target.renderExtent = m_renderExtent;
|
||||
} else {
|
||||
target.renderExtent = target.extent;
|
||||
}
|
||||
|
||||
mprintf(("Vulkan: Swap chain output mode: %s\n", target.hdrActive ? "HDR10 (PQ/BT.2020)" : "SDR (sRGB)"));
|
||||
mprintf(("Vulkan: Render resolution %ux%u, window/swap chain %ux%u\n",
|
||||
target.renderExtent.width, target.renderExtent.height,
|
||||
target.extent.width, target.extent.height));
|
||||
|
||||
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 = target.imageFormat;
|
||||
|
||||
viewCreateInfo.components.r = vk::ComponentSwizzle::eIdentity;
|
||||
viewCreateInfo.components.g = vk::ComponentSwizzle::eIdentity;
|
||||
viewCreateInfo.components.b = vk::ComponentSwizzle::eIdentity;
|
||||
viewCreateInfo.components.a = vk::ComponentSwizzle::eIdentity;
|
||||
|
||||
viewCreateInfo.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
|
||||
viewCreateInfo.subresourceRange.baseMipLevel = 0;
|
||||
viewCreateInfo.subresourceRange.levelCount = 1;
|
||||
viewCreateInfo.subresourceRange.baseArrayLayer = 0;
|
||||
viewCreateInfo.subresourceRange.layerCount = 1;
|
||||
|
||||
target.imageViews.push_back(m_device->createImageViewUnique(viewCreateInfo));
|
||||
}
|
||||
|
||||
// No layout transition needed for the new images: the only pass that writes
|
||||
// them (m_encodeRenderPass) uses initialLayout=eUndefined with
|
||||
// loadOp=eDontCare, so their first use never reads prior contents.
|
||||
|
||||
// Advertise HDR10 mastering/content metadata to the compositor when active.
|
||||
if (target.hdrActive && m_hdrMetadataSupported) {
|
||||
vk::HdrMetadataEXT metadata;
|
||||
// BT.2020 display primaries and D65 white point
|
||||
metadata.displayPrimaryRed = vk::XYColorEXT{0.708f, 0.292f};
|
||||
metadata.displayPrimaryGreen = vk::XYColorEXT{0.170f, 0.797f};
|
||||
metadata.displayPrimaryBlue = vk::XYColorEXT{0.131f, 0.046f};
|
||||
metadata.whitePoint = vk::XYColorEXT{0.3127f, 0.3290f};
|
||||
metadata.maxLuminance = Gr_hdr_peak_nits;
|
||||
metadata.minLuminance = 0.0f;
|
||||
metadata.maxContentLightLevel = Gr_hdr_peak_nits;
|
||||
metadata.maxFrameAverageLightLevel = Gr_hdr_paperwhite_nits;
|
||||
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));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::shouldRecreateSwapChain(const VulkanPresentTarget& target, SwapChainStatus status) const
|
||||
{
|
||||
// An already requested rebuild also avoids redundant Android surface queries.
|
||||
if (target.needsRecreation || status == SwapChainStatus::eOutOfDate) {
|
||||
return true;
|
||||
}
|
||||
if (status != SwapChainStatus::eSuboptimal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
if (target.preTransform == vk::SurfaceTransformFlagBitsKHR::eIdentity) {
|
||||
vk::SurfaceCapabilitiesKHR capabilities;
|
||||
try {
|
||||
capabilities = m_physicalDevice.getSurfaceCapabilitiesKHR(target.surface.get());
|
||||
} catch (const vk::SystemError&) {
|
||||
// Let the normal recreation path handle a failed surface query.
|
||||
return true;
|
||||
}
|
||||
if (capabilities.currentTransform != target.preTransform &&
|
||||
(capabilities.supportedTransforms & target.preTransform)) {
|
||||
const auto window = windowExtent(target);
|
||||
const auto extent = chooseSwapChainExtent(capabilities, window.width, window.height);
|
||||
if (extent == target.extent) {
|
||||
// Android can report SUBOPTIMAL on every present while the compositor
|
||||
// compensates for our identity preTransform. Rebuilding with the same
|
||||
// extent and preTransform would just repeat that condition next frame.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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) {
|
||||
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.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
|
||||
const vk::Extent2D window = windowExtent(target);
|
||||
auto extent = chooseSwapChainExtent(freshValues.surfaceCapabilities, window.width, window.height);
|
||||
if (extent.width == 0 || extent.height == 0) {
|
||||
nprintf(("vulkan", "Vulkan: Surface extent is 0x0 (minimized), deferring swap chain recreation\n"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 = 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 (target.imageFormat != oldSwapChainFormat) {
|
||||
mprintf(("Vulkan: WARNING - swap chain surface format changed across recreation (%d -> %d); "
|
||||
"rendering may be broken until restart\n",
|
||||
static_cast<int>(oldSwapChainFormat), static_cast<int>(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(target);
|
||||
createDepthResources(target);
|
||||
|
||||
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(target.renderExtent)) {
|
||||
mprintf(("Vulkan: post-processor resize failed, disabling post-processing!\n"));
|
||||
setPostProcessor(nullptr);
|
||||
m_postProcessor->shutdown();
|
||||
m_postProcessor.reset();
|
||||
}
|
||||
|
||||
// Drop renderer-side cached state that may reference destroyed views
|
||||
if (m_drawManager) {
|
||||
m_drawManager->onResize();
|
||||
}
|
||||
m_sceneDepthCopiedThisFrame = false;
|
||||
|
||||
// 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
|
||||
target.imageRenderFrame.clear();
|
||||
target.imageRenderFrame.resize(target.images.size(), nullptr);
|
||||
target.previousImage = UINT32_MAX;
|
||||
|
||||
target.needsRecreation = false;
|
||||
|
||||
nprintf(("vulkan", "Vulkan: Swap chain recreated successfully (%ux%u, %zu images)\n",
|
||||
target.extent.width, target.extent.height, target.images.size()));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace graphics::vulkan
|
||||
@@ -0,0 +1,173 @@
|
||||
#pragma once
|
||||
|
||||
#include "osapi/osapi.h"
|
||||
#include "osapi/vulkan_surface.h"
|
||||
|
||||
#include "VulkanConstants.h"
|
||||
#include "VulkanMemory.h"
|
||||
#include "VulkanRenderFrame.h"
|
||||
|
||||
#include <vulkan/vulkan.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
|
||||
namespace graphics::vulkan {
|
||||
|
||||
// Filled in by checkSwapChainSupport() below; defined in VulkanRenderer.h, which owns
|
||||
// device selection. Only referenced here, so a declaration is enough.
|
||||
struct PhysicalDeviceValues;
|
||||
|
||||
/**
|
||||
* @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<bool>(m_surface); }
|
||||
|
||||
void reset();
|
||||
|
||||
private:
|
||||
os::VulkanSurfaceProvider* m_provider = nullptr;
|
||||
vk::Instance m_instance;
|
||||
vk::SurfaceKHR m_surface;
|
||||
};
|
||||
|
||||
/**
|
||||
* @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;
|
||||
// The transform the swap chain was made with. See VulkanRenderer::shouldRecreateSwapChain().
|
||||
vk::SurfaceTransformFlagBitsKHR preTransform = vk::SurfaceTransformFlagBitsKHR::eIdentity;
|
||||
|
||||
// The size everything before the output-encode pass is drawn at: the composition image, the
|
||||
// depth buffer and the post-processor's targets. Usually the same as extent, but the main
|
||||
// target keeps the two apart when -window_res is set (which -vr forces): the engine goes on
|
||||
// drawing at gr_screen.max_w/max_h while the window is smaller, and the encode pass scales one
|
||||
// into the other. See VulkanRenderer::m_renderExtent and createSwapChain().
|
||||
vk::Extent2D renderExtent;
|
||||
|
||||
SCP_vector<vk::Image> images;
|
||||
SCP_vector<vk::UniqueImageView> imageViews;
|
||||
SCP_vector<vk::UniqueFramebuffer> framebuffers;
|
||||
SCP_vector<VulkanRenderFrame*> 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<vk::UniqueImage> compositionImages;
|
||||
SCP_vector<vk::UniqueImageView> compositionImageViews;
|
||||
SCP_vector<VulkanAllocation> compositionAllocations;
|
||||
SCP_vector<vk::UniqueFramebuffer> encodeFramebuffers;
|
||||
|
||||
// Depth buffer
|
||||
vk::UniqueImage depthImage;
|
||||
vk::UniqueImageView depthImageView;
|
||||
VulkanAllocation depthImageMemory;
|
||||
|
||||
std::array<std::unique_ptr<VulkanRenderFrame>, 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<AcquireSemaphore> 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<vk::UniqueSemaphore> 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
|
||||
};
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
} // namespace graphics::vulkan
|
||||
@@ -6,10 +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_frameInFlightFence = device.createFenceUnique(fenceCreateInfo);
|
||||
}
|
||||
bool VulkanRenderFrame::waitForFinish(uint64_t timeoutNs)
|
||||
@@ -39,7 +37,7 @@ void VulkanRenderFrame::onFrameFinished(std::function<void()> 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.");
|
||||
|
||||
@@ -50,7 +48,7 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex
|
||||
try {
|
||||
res = m_device.acquireNextImageKHR(m_swapChain,
|
||||
std::numeric_limits<uint64_t>::max(),
|
||||
m_imageAvailableSemaphore.get(),
|
||||
imageAvailable,
|
||||
nullptr,
|
||||
&imageIndex);
|
||||
} catch (const vk::OutOfDateKHRError&) {
|
||||
@@ -73,7 +71,6 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex
|
||||
return SwapChainStatus::eOutOfDate;
|
||||
}
|
||||
|
||||
m_swapChainIdx = imageIndex;
|
||||
outImageIndex = imageIndex;
|
||||
|
||||
if (res == vk::Result::eSuboptimalKHR) {
|
||||
@@ -82,14 +79,18 @@ SwapChainStatus VulkanRenderFrame::acquireSwapchainImage(uint32_t& outImageIndex
|
||||
return SwapChainStatus::eSuccess;
|
||||
}
|
||||
SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector<vk::CommandBuffer>& cmdBuffers,
|
||||
vk::Semaphore renderingFinished)
|
||||
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<vk::PipelineStageFlags, 1> waitStages = {vk::PipelineStageFlagBits::eColorAttachmentOutput};
|
||||
const std::array<vk::Semaphore, 1> waitSemaphores = {m_imageAvailableSemaphore.get()};
|
||||
const std::array<vk::Semaphore, 1> waitSemaphores = {imageAvailable};
|
||||
|
||||
vk::SubmitInfo submitInfo;
|
||||
submitInfo.waitSemaphoreCount = 1;
|
||||
@@ -99,7 +100,7 @@ SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector<vk::Command
|
||||
submitInfo.commandBufferCount = static_cast<uint32_t>(cmdBuffers.size());
|
||||
submitInfo.pCommandBuffers = cmdBuffers.data();
|
||||
|
||||
const std::array<vk::Semaphore, 1> signalSemaphores = {renderingFinished};
|
||||
const std::array<vk::Semaphore, 1> signalSemaphores = {renderFinished};
|
||||
submitInfo.signalSemaphoreCount = 1;
|
||||
submitInfo.pSignalSemaphores = signalSemaphores.data();
|
||||
|
||||
@@ -115,7 +116,7 @@ SwapChainStatus VulkanRenderFrame::submitAndPresent(const SCP_vector<vk::Command
|
||||
const std::array<vk::SwapchainKHR, 1> swapChains = {m_swapChain};
|
||||
presentInfo.swapchainCount = 1;
|
||||
presentInfo.pSwapchains = swapChains.data();
|
||||
presentInfo.pImageIndices = &m_swapChainIdx;
|
||||
presentInfo.pImageIndices = &imageIndex;
|
||||
presentInfo.pResults = nullptr;
|
||||
|
||||
vk::Result res;
|
||||
@@ -141,12 +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);
|
||||
}
|
||||
|
||||
} // namespace graphics::vulkan
|
||||
@@ -26,22 +26,52 @@ class VulkanRenderFrame {
|
||||
*/
|
||||
bool waitForFinish(uint64_t timeoutNs = std::numeric_limits<uint64_t>::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<void()> finishFunc);
|
||||
|
||||
SwapChainStatus submitAndPresent(const SCP_vector<vk::CommandBuffer>& cmdBuffers, vk::Semaphore renderingFinished);
|
||||
|
||||
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<vk::CommandBuffer>& 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<uint64_t>::max();
|
||||
|
||||
void updateSwapChain(vk::SwapchainKHR swapChain);
|
||||
|
||||
private:
|
||||
vk::Device m_device;
|
||||
@@ -49,13 +79,12 @@ class VulkanRenderFrame {
|
||||
vk::Queue m_graphicsQueue;
|
||||
vk::Queue m_presentQueue;
|
||||
|
||||
vk::UniqueSemaphore m_imageAvailableSemaphore;
|
||||
vk::UniqueFence m_frameInFlightFence;
|
||||
SCP_vector<std::function<void()>> m_frameFinishedCallbacks;
|
||||
|
||||
bool m_inFlight = false;
|
||||
|
||||
uint32_t m_swapChainIdx = 0;
|
||||
uint64_t m_submittedFrameNumber = NEVER_SUBMITTED;
|
||||
};
|
||||
|
||||
} // namespace graphics::vulkan
|
||||
@@ -24,78 +24,20 @@ 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<os::GraphicsOperations> graphicsOps)
|
||||
: m_graphicsOps(std::move(graphicsOps))
|
||||
{
|
||||
}
|
||||
void VulkanRenderer::createCompositionResources()
|
||||
{
|
||||
// Free any previous composition resources (swap chain recreation path)
|
||||
m_compositionImageViews.clear();
|
||||
m_compositionImages.clear();
|
||||
for (auto& alloc : m_compositionAllocations) {
|
||||
if (alloc.isValid()) {
|
||||
m_memoryManager->freeAllocation(alloc);
|
||||
}
|
||||
}
|
||||
m_compositionAllocations.clear();
|
||||
|
||||
const size_t count = m_swapChainImageViews.size();
|
||||
m_compositionImages.reserve(count);
|
||||
m_compositionImageViews.reserve(count);
|
||||
m_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_renderExtent.width, m_renderExtent.height, 1);
|
||||
imageInfo.mipLevels = 1;
|
||||
imageInfo.arrayLayers = 1;
|
||||
imageInfo.samples = vk::SampleCountFlagBits::e1;
|
||||
imageInfo.tiling = vk::ImageTiling::eOptimal;
|
||||
imageInfo.usage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eSampled |
|
||||
vk::ImageUsageFlagBits::eTransferSrc;
|
||||
imageInfo.sharingMode = vk::SharingMode::eExclusive;
|
||||
imageInfo.initialLayout = vk::ImageLayout::eUndefined;
|
||||
|
||||
auto image = m_device->createImageUnique(imageInfo);
|
||||
|
||||
VulkanAllocation alloc{};
|
||||
m_memoryManager->allocateImageMemory(image.get(), MemoryUsage::GpuOnly, alloc, MemoryPurpose::RenderTarget);
|
||||
|
||||
vk::ImageViewCreateInfo viewInfo;
|
||||
viewInfo.image = image.get();
|
||||
viewInfo.viewType = vk::ImageViewType::e2D;
|
||||
viewInfo.format = HDR_COLOR_FORMAT;
|
||||
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));
|
||||
}
|
||||
|
||||
// Sampler used by the output-encode pass to read the composition image.
|
||||
if (!m_compositionSampler) {
|
||||
vk::SamplerCreateInfo sampInfo;
|
||||
sampInfo.magFilter = vk::Filter::eNearest;
|
||||
sampInfo.minFilter = vk::Filter::eNearest;
|
||||
sampInfo.mipmapMode = vk::SamplerMipmapMode::eNearest;
|
||||
sampInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge;
|
||||
sampInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge;
|
||||
sampInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge;
|
||||
m_compositionSampler = m_device->createSamplerUnique(sampInfo);
|
||||
}
|
||||
}
|
||||
|
||||
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,62 +79,21 @@ void VulkanRenderer::createEncodeRenderPass()
|
||||
m_encodeRenderPass = m_device->createRenderPassUnique(rpInfo);
|
||||
}
|
||||
|
||||
void VulkanRenderer::createFrameBuffers()
|
||||
{
|
||||
m_swapChainFramebuffers.clear();
|
||||
m_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) {
|
||||
const vk::ImageView attachments[] = {
|
||||
compView.get(),
|
||||
m_depthImageView.get(),
|
||||
};
|
||||
|
||||
vk::FramebufferCreateInfo framebufferInfo;
|
||||
framebufferInfo.renderPass = m_renderPass.get();
|
||||
framebufferInfo.attachmentCount = 2;
|
||||
framebufferInfo.pAttachments = attachments;
|
||||
framebufferInfo.width = m_renderExtent.width;
|
||||
framebufferInfo.height = m_renderExtent.height;
|
||||
framebufferInfo.layers = 1;
|
||||
|
||||
m_swapChainFramebuffers.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) {
|
||||
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.layers = 1;
|
||||
|
||||
m_encodeFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo));
|
||||
}
|
||||
}
|
||||
|
||||
vk::Image VulkanRenderer::getCurrentCompositionImage() const
|
||||
{
|
||||
if (m_currentSwapChainImage >= m_compositionImages.size()) {
|
||||
if (m_current == nullptr || m_current->currentImage >= m_current->compositionImages.size()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return m_compositionImages[m_currentSwapChainImage].get();
|
||||
return m_current->compositionImages[m_current->currentImage].get();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -200,13 +101,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,
|
||||
@@ -222,91 +123,12 @@ 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);
|
||||
}
|
||||
vk::Format VulkanRenderer::findDepthFormat()
|
||||
{
|
||||
// Prefer D32_SFLOAT for best precision, fall back to D32_SFLOAT_S8 or D24_UNORM_S8
|
||||
const vk::Format candidates[] = {
|
||||
vk::Format::eD32Sfloat,
|
||||
vk::Format::eD32SfloatS8Uint,
|
||||
vk::Format::eD24UnormS8Uint,
|
||||
};
|
||||
|
||||
for (auto format : candidates) {
|
||||
auto props = m_physicalDevice.getFormatProperties(format);
|
||||
if (props.optimalTilingFeatures & vk::FormatFeatureFlagBits::eDepthStencilAttachment) {
|
||||
return format;
|
||||
}
|
||||
}
|
||||
|
||||
// Should never happen on any real GPU
|
||||
Error(LOCATION, "Failed to find supported depth format!");
|
||||
return vk::Format::eD32Sfloat;
|
||||
}
|
||||
void VulkanRenderer::createDepthResources()
|
||||
{
|
||||
const vk::Format depthFormat = findDepthFormat();
|
||||
// The render passes (m_renderPass, scene/G-buffer passes, ...) bake in the
|
||||
// depth format, and they are deliberately kept alive across swap chain
|
||||
// recreation. A driver changing its supported depth formats mid-session
|
||||
// would make them all incompatible with the new attachment.
|
||||
if (m_depthFormat != vk::Format::eUndefined && depthFormat != m_depthFormat) {
|
||||
Error(LOCATION, "Vulkan: depth format changed across swap chain recreation (%d -> %d)!",
|
||||
static_cast<int>(m_depthFormat), static_cast<int>(depthFormat));
|
||||
}
|
||||
m_depthFormat = depthFormat;
|
||||
|
||||
// Create depth image
|
||||
vk::ImageCreateInfo imageInfo;
|
||||
imageInfo.imageType = vk::ImageType::e2D;
|
||||
imageInfo.format = m_depthFormat;
|
||||
imageInfo.extent.width = m_renderExtent.width;
|
||||
imageInfo.extent.height = m_renderExtent.height;
|
||||
imageInfo.extent.depth = 1;
|
||||
imageInfo.mipLevels = 1;
|
||||
imageInfo.arrayLayers = 1;
|
||||
imageInfo.samples = vk::SampleCountFlagBits::e1;
|
||||
imageInfo.tiling = vk::ImageTiling::eOptimal;
|
||||
imageInfo.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment;
|
||||
imageInfo.sharingMode = vk::SharingMode::eExclusive;
|
||||
imageInfo.initialLayout = vk::ImageLayout::eUndefined;
|
||||
|
||||
m_depthImage = m_device->createImageUnique(imageInfo);
|
||||
|
||||
// Allocate GPU memory for the depth image
|
||||
m_memoryManager->allocateImageMemory(
|
||||
m_depthImage.get(), MemoryUsage::GpuOnly, m_depthImageMemory, MemoryPurpose::RenderTarget);
|
||||
|
||||
// Create depth image view
|
||||
vk::ImageViewCreateInfo viewInfo;
|
||||
viewInfo.image = m_depthImage.get();
|
||||
viewInfo.viewType = vk::ImageViewType::e2D;
|
||||
viewInfo.format = m_depthFormat;
|
||||
viewInfo.subresourceRange.aspectMask = imageAspectFromFormat(m_depthFormat);
|
||||
viewInfo.subresourceRange.baseMipLevel = 0;
|
||||
viewInfo.subresourceRange.levelCount = 1;
|
||||
viewInfo.subresourceRange.baseArrayLayer = 0;
|
||||
viewInfo.subresourceRange.layerCount = 1;
|
||||
|
||||
m_depthImageView = m_device->createImageViewUnique(viewInfo);
|
||||
|
||||
nprintf(("vulkan", "Vulkan: Created depth buffer (%dx%d, format %d)\n",
|
||||
m_renderExtent.width, m_renderExtent.height, static_cast<int>(m_depthFormat)));
|
||||
}
|
||||
void VulkanRenderer::destroyDepthResources()
|
||||
{
|
||||
m_depthImageView.reset();
|
||||
m_depthImage.reset();
|
||||
if (m_memoryManager && m_depthImageMemory.isValid()) {
|
||||
m_memoryManager->freeAllocation(m_depthImageMemory);
|
||||
m_depthImageMemory = {};
|
||||
}
|
||||
}
|
||||
void VulkanRenderer::createRenderPass()
|
||||
{
|
||||
// Attachment 0: Color - clear each frame
|
||||
@@ -352,7 +174,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
|
||||
@@ -395,7 +217,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.
|
||||
@@ -424,31 +246,13 @@ void VulkanRenderer::createCommandPool(const PhysicalDeviceValues& values)
|
||||
|
||||
m_graphicsCommandPool = m_device->createCommandPoolUnique(poolCreate);
|
||||
}
|
||||
void VulkanRenderer::createPresentSyncObjects()
|
||||
{
|
||||
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) {
|
||||
m_frames[i] = std::make_unique<VulkanRenderFrame>(m_device.get(), m_swapChain.get(), m_graphicsQueue, m_presentQueue);
|
||||
}
|
||||
|
||||
m_swapChainImageRenderImage.resize(m_swapChainImages.size(), nullptr);
|
||||
createRenderingFinishedSemaphores();
|
||||
}
|
||||
void VulkanRenderer::createRenderingFinishedSemaphores()
|
||||
{
|
||||
constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo;
|
||||
m_renderingFinishedSemaphores.clear();
|
||||
for (size_t i = 0; i < m_swapChainImages.size(); ++i) {
|
||||
m_renderingFinishedSemaphores.push_back(m_device->createSemaphoreUnique(semaphoreCreateInfo));
|
||||
}
|
||||
}
|
||||
|
||||
bool VulkanRenderer::readbackFramebuffer(ubyte** outPixels, uint32_t* outWidth, uint32_t* outHeight)
|
||||
{
|
||||
*outPixels = nullptr;
|
||||
*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;
|
||||
}
|
||||
@@ -467,13 +271,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_renderExtent.width;
|
||||
uint32_t h = m_renderExtent.height;
|
||||
uint32_t w = m_current->renderExtent.width;
|
||||
uint32_t h = m_current->renderExtent.height;
|
||||
vk::DeviceSize bufferSize = static_cast<vk::DeviceSize>(w) * h * bytesPerSrcPixel;
|
||||
|
||||
// End the current render pass so we can record transfer commands
|
||||
@@ -771,8 +575,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.
|
||||
@@ -834,27 +642,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;
|
||||
|
||||
// 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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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<uint32_t>(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
|
||||
@@ -864,9 +704,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();
|
||||
@@ -942,20 +789,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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "osapi/osapi.h"
|
||||
#include "osapi/vulkan_surface.h"
|
||||
|
||||
#include "VulkanMemory.h"
|
||||
#include "VulkanBuffer.h"
|
||||
@@ -15,6 +16,7 @@
|
||||
#include "VulkanQuery.h"
|
||||
#include "VulkanRaytracing.h"
|
||||
#include "VulkanRenderFrame.h"
|
||||
#include "VulkanPresentTarget.h"
|
||||
|
||||
#include <vulkan/vulkan.hpp>
|
||||
|
||||
@@ -95,6 +97,64 @@ 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 End frame - ends render pass, submits, and presents
|
||||
* Called at the END of each frame after all draw calls
|
||||
@@ -145,22 +205,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
|
||||
@@ -184,9 +263,14 @@ class VulkanRenderer {
|
||||
uint32_t getGraphicsQueueFamilyIndex() const { return m_graphicsQueueFamilyIndex; }
|
||||
|
||||
/**
|
||||
* @brief Size of the actual window / swap chain images
|
||||
* @brief The extent the current target actually presents at, in device pixels
|
||||
*
|
||||
* The size of its window and of its swap chain images. This is also what the encode pass and
|
||||
* its 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 getSwapChainExtent() const { return m_swapChainExtent; }
|
||||
vk::Extent2D getSwapChainExtent() const { return m_current != nullptr ? m_current->extent : vk::Extent2D(); }
|
||||
|
||||
/**
|
||||
* @brief Size everything is rendered at, which is not always the window size
|
||||
@@ -197,13 +281,15 @@ class VulkanRenderer {
|
||||
* composition image, depth, and the post-processor's scene and G-buffer
|
||||
* targets -- is sized to this, and the encode pass scales it into the swap
|
||||
* chain image. OpenGL does the same thing with Back_framebuffer.
|
||||
*
|
||||
* Per target, because each one draws at its own size; see VulkanPresentTarget::renderExtent.
|
||||
*/
|
||||
vk::Extent2D getRenderExtent() const { return m_renderExtent; }
|
||||
vk::Extent2D getRenderExtent() const { return m_current != nullptr ? m_current->renderExtent : m_renderExtent; }
|
||||
|
||||
/**
|
||||
* @brief Get the composition image the frame is currently being rendered into
|
||||
*
|
||||
* This is the fp16 image behind m_swapChainFramebuffers -- the whole frame
|
||||
* This is the fp16 image behind the current target's framebuffers -- the whole frame
|
||||
* (3D scene, HUD and menus alike) lands here, and encodeToSwapChain() later
|
||||
* converts it into the actual swap chain image. Returns a null handle if
|
||||
* composition resources do not exist yet.
|
||||
@@ -426,37 +512,125 @@ 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 createRenderingFinishedSemaphores();
|
||||
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();
|
||||
bool shouldRecreateSwapChain(SwapChainStatus status) const;
|
||||
/**
|
||||
* @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);
|
||||
|
||||
bool shouldRecreateSwapChain(const VulkanPresentTarget& target, SwapChainStatus status) const;
|
||||
|
||||
void createImGuiDescriptorPool();
|
||||
void initImGui();
|
||||
@@ -468,20 +642,27 @@ 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<os::Viewport*, std::unique_ptr<VulkanPresentTarget>> 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;
|
||||
vk::SurfaceTransformFlagBitsKHR m_swapChainPreTransform = vk::SurfaceTransformFlagBitsKHR::eIdentity;
|
||||
|
||||
// The resolution the engine draws at (gr_screen.max_w/max_h), which is not
|
||||
// always the window size: when Cmdline_window_res is set,
|
||||
// SDLGraphicsOperations::createViewport sizes the window from that instead,
|
||||
@@ -489,43 +670,32 @@ class VulkanRenderer {
|
||||
// exactly that split (Cmdline_window_res 1000x1000 vs. the VR resolution
|
||||
// option), so treating the two as one is what put whole frames in the
|
||||
// upper-left corner of the screen.
|
||||
//
|
||||
// Captured in initialize() before the window exists, and only ever applied to the main target:
|
||||
// see createSwapChain(), which is where each target picks up its own render extent.
|
||||
vk::Extent2D m_renderExtent;
|
||||
SCP_vector<vk::Image> m_swapChainImages;
|
||||
SCP_vector<vk::UniqueImageView> m_swapChainImageViews;
|
||||
SCP_vector<vk::UniqueFramebuffer> m_swapChainFramebuffers;
|
||||
SCP_vector<VulkanRenderFrame*> m_swapChainImageRenderImage;
|
||||
// per image, not per frame: a frame's fence does not tell us when present has consumed the semaphore
|
||||
SCP_vector<vk::UniqueSemaphore> m_renderingFinishedSemaphores;
|
||||
|
||||
// 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<vk::UniqueImage> m_compositionImages;
|
||||
SCP_vector<vk::UniqueImageView> m_compositionImageViews;
|
||||
SCP_vector<VulkanAllocation> 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<vk::UniqueFramebuffer> 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<std::unique_ptr<VulkanRenderFrame>, MAX_FRAMES_IN_FLIGHT> m_frames;
|
||||
|
||||
vk::UniqueCommandPool m_graphicsCommandPool;
|
||||
|
||||
@@ -534,9 +704,6 @@ class VulkanRenderer {
|
||||
SCP_vector<vk::CommandBuffer> 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
|
||||
|
||||
@@ -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<VkDescriptorPool>(*m_imguiDescriptorPool);
|
||||
initInfo.MinImageCount = 2;
|
||||
initInfo.ImageCount = static_cast<uint32_t>(m_swapChainImages.size());
|
||||
initInfo.ImageCount = static_cast<uint32_t>(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<VkRenderPass>(*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"));
|
||||
}
|
||||
|
||||
@@ -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<uint32_t>(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) {
|
||||
@@ -81,18 +147,19 @@ void VulkanRenderer::acquireNextSwapChainImage()
|
||||
MAX_ACQUIRE_ATTEMPTS);
|
||||
}
|
||||
|
||||
if (shouldRecreateSwapChain(status)) {
|
||||
m_swapChainNeedsRecreation = true;
|
||||
if (shouldRecreateSwapChain(*m_current, status)) {
|
||||
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();
|
||||
@@ -152,14 +215,184 @@ void VulkanRenderer::setupFrame()
|
||||
|
||||
PassBeginDesc pass;
|
||||
pass.renderPass = m_renderPass.get();
|
||||
pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get();
|
||||
pass.extent = m_renderExtent;
|
||||
pass.framebuffer = m_current->framebuffers[m_current->currentImage].get();
|
||||
pass.extent = m_current->renderExtent;
|
||||
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<VulkanPresentTarget>();
|
||||
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) {
|
||||
@@ -189,9 +422,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;
|
||||
@@ -213,16 +446,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,
|
||||
m_renderingFinishedSemaphores[m_currentSwapChainImage].get());
|
||||
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 (shouldRecreateSwapChain(presentStatus)) {
|
||||
m_swapChainNeedsRecreation = true;
|
||||
if (shouldRecreateSwapChain(*m_current, presentStatus)) {
|
||||
m_current->needsRecreation = true;
|
||||
}
|
||||
|
||||
// Notify query manager that this frame's command buffer was submitted
|
||||
@@ -231,7 +469,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;
|
||||
@@ -254,6 +492,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()
|
||||
@@ -348,8 +598,8 @@ void VulkanRenderer::endSceneRendering()
|
||||
|
||||
PassBeginDesc pass;
|
||||
pass.renderPass = m_renderPassLoad.get();
|
||||
pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get();
|
||||
pass.extent = m_renderExtent;
|
||||
pass.framebuffer = m_current->framebuffers[m_current->currentImage].get();
|
||||
pass.extent = m_current->renderExtent;
|
||||
pass.clearValues = clearValues;
|
||||
pass.viewport = PassViewport::NoFlip;
|
||||
beginTrackedRenderPass(pass);
|
||||
@@ -359,9 +609,9 @@ void VulkanRenderer::endSceneRendering()
|
||||
|
||||
// Restore Y-flipped viewport for HUD rendering
|
||||
m_stateTracker->setViewport(0.0f,
|
||||
static_cast<float>(m_renderExtent.height),
|
||||
static_cast<float>(m_renderExtent.width),
|
||||
-static_cast<float>(m_renderExtent.height));
|
||||
static_cast<float>(m_current->renderExtent.height),
|
||||
static_cast<float>(m_current->renderExtent.width),
|
||||
-static_cast<float>(m_current->renderExtent.height));
|
||||
|
||||
m_sceneRendering = false;
|
||||
m_useGbufRenderPass = false;
|
||||
@@ -602,8 +852,8 @@ void VulkanRenderer::resumeSwapChainPass()
|
||||
|
||||
PassBeginDesc pass;
|
||||
pass.renderPass = m_renderPassLoad.get();
|
||||
pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get();
|
||||
pass.extent = m_renderExtent;
|
||||
pass.framebuffer = m_current->framebuffers[m_current->currentImage].get();
|
||||
pass.extent = m_current->renderExtent;
|
||||
pass.clearValues = clearValues;
|
||||
beginTrackedRenderPass(pass);
|
||||
}
|
||||
@@ -616,8 +866,8 @@ void VulkanRenderer::restartCompositionPass()
|
||||
|
||||
PassBeginDesc pass;
|
||||
pass.renderPass = m_renderPass.get();
|
||||
pass.framebuffer = m_swapChainFramebuffers[m_currentSwapChainImage].get();
|
||||
pass.extent = m_renderExtent;
|
||||
pass.framebuffer = m_current->framebuffers[m_current->currentImage].get();
|
||||
pass.extent = m_current->renderExtent;
|
||||
pass.clearValues = clearValues;
|
||||
beginTrackedRenderPass(pass);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
#include "libs/renderdoc/renderdoc.h"
|
||||
#include "mod_table/mod_table.h"
|
||||
|
||||
#include <SDL3/SDL_vulkan.h>
|
||||
#include <cstdint>
|
||||
|
||||
#include "graphics/openxr.h"
|
||||
@@ -90,18 +89,7 @@ bool checkDeviceExtensionSupport(PhysicalDeviceValues& values)
|
||||
return requiredExtensions.empty();
|
||||
}
|
||||
|
||||
bool checkSwapChainSupport(PhysicalDeviceValues& values, const vk::UniqueSurfaceKHR& surface)
|
||||
{
|
||||
values.surfaceCapabilities = values.device.getSurfaceCapabilitiesKHR(surface.get());
|
||||
auto fmts = values.device.getSurfaceFormatsKHR(surface.get());
|
||||
values.surfaceFormats.assign(fmts.begin(), fmts.end());
|
||||
auto modes = values.device.getSurfacePresentModesKHR(surface.get());
|
||||
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 &&
|
||||
@@ -125,7 +113,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;
|
||||
}
|
||||
@@ -224,124 +212,6 @@ void printPhysicalDevice(const PhysicalDeviceValues& values)
|
||||
scoreDevice(values)));
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const auto& availableFormat : values.surfaceFormats) {
|
||||
if ((availableFormat.format == vk::Format::eA2B10G10R10UnormPack32 ||
|
||||
availableFormat.format == vk::Format::eA2R10G10B10UnormPack32) &&
|
||||
availableFormat.colorSpace == vk::ColorSpaceKHR::eHdr10St2084EXT) {
|
||||
nprintf(("vulkan", "Vulkan: Selected HDR10 surface (10-bit, ST.2084/BT.2020)\n"));
|
||||
return availableFormat;
|
||||
}
|
||||
}
|
||||
nprintf(("vulkan", "Vulkan: HDR requested but no HDR10 surface format available; falling back to SDR\n"));
|
||||
}
|
||||
|
||||
// Use a non-sRGB (UNORM) format to match OpenGL's default framebuffer behavior.
|
||||
// The FSO shaders handle gamma correction manually in the fragment shader and
|
||||
// post-processing pipeline, so hardware sRGB conversion would double-correct.
|
||||
for (const auto& availableFormat : values.surfaceFormats) {
|
||||
if (availableFormat.format == vk::Format::eB8G8R8A8Unorm &&
|
||||
availableFormat.colorSpace == vk::ColorSpaceKHR::eSrgbNonlinear) {
|
||||
return availableFormat;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no preferred format matched. Pick the first concrete format,
|
||||
// defensively skipping any eUndefined entry (the legacy "any format allowed"
|
||||
// sentinel), and log the actual choice so it's visible in the log.
|
||||
for (const auto& availableFormat : values.surfaceFormats) {
|
||||
if (availableFormat.format != vk::Format::eUndefined) {
|
||||
nprintf(("vulkan", "Vulkan: no preferred surface format available; falling back to format=%d colorSpace=%d\n",
|
||||
static_cast<int>(availableFormat.format), static_cast<int>(availableFormat.colorSpace)));
|
||||
return availableFormat;
|
||||
}
|
||||
}
|
||||
|
||||
// Degenerate list (all eUndefined) — return the front entry and warn.
|
||||
nprintf(("vulkan", "Vulkan: surface format list has no concrete entry; using front (format=%d)\n",
|
||||
static_cast<int>(values.surfaceFormats.front().format)));
|
||||
return values.surfaceFormats.front();
|
||||
}
|
||||
|
||||
vk::PresentModeKHR choosePresentMode(const PhysicalDeviceValues& values)
|
||||
{
|
||||
// With vsync requested, use FIFO: it is the only spec-guaranteed mode and
|
||||
// the only one that actually caps the frame rate to the display. Mailbox is
|
||||
// tear-free but uncapped ("fast vsync") and must not be silently substituted
|
||||
// for requested vsync. Without vsync prefer Immediate (true uncapped), then
|
||||
// Mailbox (uncapped but tear-free), then the guaranteed FIFO fallback.
|
||||
vk::PresentModeKHR chosen = vk::PresentModeKHR::eFifo;
|
||||
|
||||
if (!Gr_enable_vsync) {
|
||||
for (const auto& availablePresentMode : values.presentModes) {
|
||||
if (availablePresentMode == vk::PresentModeKHR::eImmediate) {
|
||||
chosen = availablePresentMode;
|
||||
break;
|
||||
}
|
||||
if (availablePresentMode == vk::PresentModeKHR::eMailbox) {
|
||||
chosen = availablePresentMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const char* name = "Unknown";
|
||||
switch (chosen) {
|
||||
case vk::PresentModeKHR::eImmediate: name = "Immediate"; break;
|
||||
case vk::PresentModeKHR::eMailbox: name = "Mailbox"; break;
|
||||
case vk::PresentModeKHR::eFifo: name = "FIFO (vsync)"; break;
|
||||
case vk::PresentModeKHR::eFifoRelaxed: name = "FIFO Relaxed"; break;
|
||||
default: break;
|
||||
}
|
||||
mprintf(("Vulkan: Present mode: %s (Gr_enable_vsync=%d)\n", name, Gr_enable_vsync ? 1 : 0));
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The size of the actual window, which is not the render resolution
|
||||
*
|
||||
* Only used where the surface declines to tell us its extent (Wayland reports
|
||||
* UINT32_MAX and lets the client pick). gr_screen.max_w/max_h is the wrong
|
||||
* answer there: with -window_res set -- which -vr forces -- the window is
|
||||
* smaller than the resolution the engine draws at.
|
||||
*/
|
||||
vk::Extent2D windowExtent()
|
||||
{
|
||||
if (auto* viewport = os::getMainViewport()) {
|
||||
const auto size = viewport->getSize();
|
||||
if (size.first > 0 && size.second > 0) {
|
||||
return {size.first, size.second};
|
||||
}
|
||||
}
|
||||
|
||||
if (Cmdline_window_res) {
|
||||
return {Cmdline_window_res->first, Cmdline_window_res->second};
|
||||
}
|
||||
|
||||
return {static_cast<uint32_t>(gr_screen.max_w), static_cast<uint32_t>(gr_screen.max_h)};
|
||||
}
|
||||
|
||||
vk::Extent2D chooseSwapChainExtent(const vk::SurfaceCapabilitiesKHR& capabilities, uint32_t width, uint32_t height)
|
||||
{
|
||||
if (capabilities.currentExtent.width != UINT32_MAX) {
|
||||
return capabilities.currentExtent;
|
||||
} else {
|
||||
VkExtent2D actualExtent = {width, height};
|
||||
|
||||
actualExtent.width = std::max(capabilities.minImageExtent.width,
|
||||
std::min(capabilities.maxImageExtent.width, actualExtent.width));
|
||||
actualExtent.height = std::max(capabilities.minImageExtent.height,
|
||||
std::min(capabilities.maxImageExtent.height, actualExtent.height));
|
||||
|
||||
return actualExtent;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
bool VulkanRenderer::initialize()
|
||||
{
|
||||
@@ -364,6 +234,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<VulkanPresentTarget>();
|
||||
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"));
|
||||
@@ -374,7 +253,7 @@ bool VulkanRenderer::initialize()
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initializeSurface()) {
|
||||
if (!createTargetSurface(*m_mainTarget)) {
|
||||
nprintf(("vulkan", "Failed to create Vulkan surface!\n"));
|
||||
return false;
|
||||
}
|
||||
@@ -436,18 +315,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<VulkanTextureManager>();
|
||||
@@ -532,7 +413,7 @@ bool VulkanRenderer::initialize()
|
||||
// Initialize post-processing
|
||||
m_postProcessor = std::make_unique<VulkanPostProcessor>();
|
||||
if (!m_postProcessor->init(m_device.get(), m_physicalDevice, m_memoryManager.get(),
|
||||
m_renderExtent, m_depthFormat, m_hdrActive)) {
|
||||
m_mainTarget->renderExtent, m_depthFormat, m_mainTarget->hdrActive)) {
|
||||
mprintf(("Warning: Failed to initialize Vulkan post-processor, post-processing will be disabled\n"));
|
||||
m_postProcessor.reset();
|
||||
} else {
|
||||
@@ -635,29 +516,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<PFN_vkGetInstanceProcAddr>(SDL_Vulkan_GetVkGetInstanceProcAddr());
|
||||
reinterpret_cast<PFN_vkGetInstanceProcAddr>(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<SCP_string> windowExtensions;
|
||||
if (!vulkanSupport->getVulkanInstanceExtensions(windowExtensions)) {
|
||||
mprintf(("Vulkan: Could not determine the instance extensions required by the window system!\n"));
|
||||
return false;
|
||||
}
|
||||
|
||||
SCP_vector<const char*> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,23 +711,6 @@ bool VulkanRenderer::initializeInstance()
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::initializeSurface()
|
||||
{
|
||||
const auto window = os::getSDLMainWindow();
|
||||
|
||||
VkSurfaceKHR surface;
|
||||
if (!SDL_Vulkan_CreateSurface(window, static_cast<VkInstance>(*m_vkInstance), nullptr, &surface)) {
|
||||
nprintf(("vulkan", "Failed to create vulkan surface: %s\n", SDL_GetError()));
|
||||
return false;
|
||||
}
|
||||
|
||||
const vk::detail::ObjectDestroy<vk::Instance, VULKAN_HPP_DEFAULT_DISPATCHER_TYPE> deleter(*m_vkInstance,
|
||||
nullptr,
|
||||
VULKAN_HPP_DEFAULT_DISPATCHER);
|
||||
m_vkSurface = vk::UniqueSurfaceKHR(vk::SurfaceKHR(surface), deleter);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::pickPhysicalDevice(PhysicalDeviceValues& deviceValues)
|
||||
{
|
||||
const auto devices = m_vkInstance->enumeratePhysicalDevices();
|
||||
@@ -891,7 +766,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;
|
||||
@@ -1104,263 +979,4 @@ bool VulkanRenderer::createLogicalDevice(const PhysicalDeviceValues& deviceValue
|
||||
|
||||
return true;
|
||||
}
|
||||
bool VulkanRenderer::createSwapChain(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;
|
||||
if (deviceValues.surfaceCapabilities.maxImageCount > 0 &&
|
||||
imageCount > deviceValues.surfaceCapabilities.maxImageCount) {
|
||||
imageCount = deviceValues.surfaceCapabilities.maxImageCount;
|
||||
}
|
||||
|
||||
const auto surfaceFormat = chooseSurfaceFormat(deviceValues);
|
||||
|
||||
const vk::Extent2D window = windowExtent();
|
||||
|
||||
vk::SwapchainCreateInfoKHR createInfo;
|
||||
createInfo.surface = m_vkSurface.get();
|
||||
createInfo.minImageCount = imageCount;
|
||||
createInfo.imageFormat = surfaceFormat.format;
|
||||
createInfo.imageColorSpace = surfaceFormat.colorSpace;
|
||||
createInfo.imageExtent = chooseSwapChainExtent(deviceValues.surfaceCapabilities, window.width, window.height);
|
||||
createInfo.imageArrayLayers = 1;
|
||||
createInfo.imageUsage = vk::ImageUsageFlagBits::eColorAttachment
|
||||
| vk::ImageUsageFlagBits::eTransferSrc
|
||||
| vk::ImageUsageFlagBits::eTransferDst;
|
||||
|
||||
const uint32_t queueFamilyIndices[] = {deviceValues.graphicsQueueIndex.index, deviceValues.presentQueueIndex.index};
|
||||
if (deviceValues.graphicsQueueIndex.index != deviceValues.presentQueueIndex.index) {
|
||||
createInfo.imageSharingMode = vk::SharingMode::eConcurrent;
|
||||
createInfo.queueFamilyIndexCount = 2;
|
||||
createInfo.pQueueFamilyIndices = queueFamilyIndices;
|
||||
} else {
|
||||
createInfo.imageSharingMode = vk::SharingMode::eExclusive;
|
||||
}
|
||||
|
||||
// preTransform describes the transform already applied to the rendered content.
|
||||
// FSO does not pre-rotate it, so request identity whenever supported. If this
|
||||
// differs from currentTransform (e.g. landscape on an Android phone), the
|
||||
// presentation engine compensates during presentation. Passing currentTransform
|
||||
// instead would claim that FSO had already performed that rotation.
|
||||
auto supported = deviceValues.surfaceCapabilities.supportedTransforms;
|
||||
if (supported & vk::SurfaceTransformFlagBitsKHR::eIdentity) {
|
||||
createInfo.preTransform = vk::SurfaceTransformFlagBitsKHR::eIdentity;
|
||||
}
|
||||
else {
|
||||
createInfo.preTransform = deviceValues.surfaceCapabilities.currentTransform;
|
||||
}
|
||||
|
||||
createInfo.compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque;
|
||||
createInfo.presentMode = choosePresentMode(deviceValues);
|
||||
createInfo.clipped = true;
|
||||
createInfo.oldSwapchain = oldSwapchain;
|
||||
|
||||
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;
|
||||
m_swapChainPreTransform = createInfo.preTransform;
|
||||
|
||||
// Without -window_res the render resolution simply is the window, so follow
|
||||
// gr_screen (which gr_screen_resize() has already updated for this
|
||||
// recreation). With it, the two are deliberately independent -- the window
|
||||
// is whatever Cmdline_window_res says (SDLGraphicsOperations::createViewport
|
||||
// forces it) while the engine keeps drawing at gr_screen.max_w/max_h -- so
|
||||
// the value captured in initialize() must survive untouched.
|
||||
if (!Cmdline_window_res) {
|
||||
m_renderExtent = vk::Extent2D(static_cast<uint32_t>(gr_screen.max_w), static_cast<uint32_t>(gr_screen.max_h));
|
||||
}
|
||||
|
||||
mprintf(("Vulkan: Swap chain output mode: %s\n", m_hdrActive ? "HDR10 (PQ/BT.2020)" : "SDR (sRGB)"));
|
||||
mprintf(("Vulkan: Render resolution %ux%u, window/swap chain %ux%u\n",
|
||||
m_renderExtent.width, m_renderExtent.height,
|
||||
m_swapChainExtent.width, m_swapChainExtent.height));
|
||||
|
||||
m_swapChainImageViews.reserve(m_swapChainImages.size());
|
||||
for (const auto& image : m_swapChainImages) {
|
||||
vk::ImageViewCreateInfo viewCreateInfo;
|
||||
viewCreateInfo.image = image;
|
||||
viewCreateInfo.viewType = vk::ImageViewType::e2D;
|
||||
viewCreateInfo.format = m_swapChainImageFormat;
|
||||
|
||||
viewCreateInfo.components.r = vk::ComponentSwizzle::eIdentity;
|
||||
viewCreateInfo.components.g = vk::ComponentSwizzle::eIdentity;
|
||||
viewCreateInfo.components.b = vk::ComponentSwizzle::eIdentity;
|
||||
viewCreateInfo.components.a = vk::ComponentSwizzle::eIdentity;
|
||||
|
||||
viewCreateInfo.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
|
||||
viewCreateInfo.subresourceRange.baseMipLevel = 0;
|
||||
viewCreateInfo.subresourceRange.levelCount = 1;
|
||||
viewCreateInfo.subresourceRange.baseArrayLayer = 0;
|
||||
viewCreateInfo.subresourceRange.layerCount = 1;
|
||||
|
||||
m_swapChainImageViews.push_back(m_device->createImageViewUnique(viewCreateInfo));
|
||||
}
|
||||
|
||||
// No layout transition needed for the new images: the only pass that writes
|
||||
// them (m_encodeRenderPass) uses initialLayout=eUndefined with
|
||||
// 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) {
|
||||
vk::HdrMetadataEXT metadata;
|
||||
// BT.2020 display primaries and D65 white point
|
||||
metadata.displayPrimaryRed = vk::XYColorEXT{0.708f, 0.292f};
|
||||
metadata.displayPrimaryGreen = vk::XYColorEXT{0.170f, 0.797f};
|
||||
metadata.displayPrimaryBlue = vk::XYColorEXT{0.131f, 0.046f};
|
||||
metadata.whitePoint = vk::XYColorEXT{0.3127f, 0.3290f};
|
||||
metadata.maxLuminance = Gr_hdr_peak_nits;
|
||||
metadata.minLuminance = 0.0f;
|
||||
metadata.maxContentLightLevel = Gr_hdr_peak_nits;
|
||||
metadata.maxFrameAverageLightLevel = Gr_hdr_paperwhite_nits;
|
||||
m_device->setHdrMetadataEXT(m_swapChain.get(), metadata);
|
||||
mprintf(("Vulkan: HDR10 metadata set (peak %.0f nits, paper white %.0f nits)\n",
|
||||
Gr_hdr_peak_nits, Gr_hdr_paperwhite_nits));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::shouldRecreateSwapChain(SwapChainStatus status) const
|
||||
{
|
||||
// An already requested rebuild also avoids redundant Android surface queries.
|
||||
if (m_swapChainNeedsRecreation || status == SwapChainStatus::eOutOfDate) {
|
||||
return true;
|
||||
}
|
||||
if (status != SwapChainStatus::eSuboptimal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef __ANDROID__
|
||||
if (m_swapChainPreTransform == vk::SurfaceTransformFlagBitsKHR::eIdentity) {
|
||||
vk::SurfaceCapabilitiesKHR capabilities;
|
||||
try {
|
||||
capabilities = m_physicalDevice.getSurfaceCapabilitiesKHR(m_vkSurface.get());
|
||||
} catch (const vk::SystemError&) {
|
||||
// Let the normal recreation path handle a failed surface query.
|
||||
return true;
|
||||
}
|
||||
if (capabilities.currentTransform != m_swapChainPreTransform &&
|
||||
(capabilities.supportedTransforms & m_swapChainPreTransform)) {
|
||||
const auto window = windowExtent();
|
||||
const auto extent = chooseSwapChainExtent(capabilities, window.width, window.height);
|
||||
if (extent == m_swapChainExtent) {
|
||||
// Android can report SUBOPTIMAL on every present while the compositor
|
||||
// compensates for our identity preTransform. Rebuilding with the same
|
||||
// extent and preTransform would just repeat that condition next frame.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VulkanRenderer::recreateSwapChain()
|
||||
{
|
||||
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();
|
||||
}
|
||||
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};
|
||||
|
||||
// Check for 0x0 extent (minimized window) — caller should retry later
|
||||
const vk::Extent2D window = windowExtent();
|
||||
auto extent = chooseSwapChainExtent(freshValues.surfaceCapabilities, window.width, window.height);
|
||||
if (extent.width == 0 || extent.height == 0) {
|
||||
nprintf(("vulkan", "Vulkan: Surface extent is 0x0 (minimized), deferring swap chain recreation\n"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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());
|
||||
|
||||
// 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) {
|
||||
mprintf(("Vulkan: WARNING - swap chain surface format changed across recreation (%d -> %d); "
|
||||
"rendering may be broken until restart\n",
|
||||
static_cast<int>(oldSwapChainFormat), static_cast<int>(m_swapChainImageFormat)));
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
createCompositionResources();
|
||||
createFrameBuffers();
|
||||
|
||||
// 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_renderExtent)) {
|
||||
mprintf(("Vulkan: post-processor resize failed, disabling post-processing!\n"));
|
||||
setPostProcessor(nullptr);
|
||||
m_postProcessor->shutdown();
|
||||
m_postProcessor.reset();
|
||||
}
|
||||
|
||||
// Drop renderer-side cached state that may reference destroyed views
|
||||
if (m_drawManager) {
|
||||
m_drawManager->onResize();
|
||||
}
|
||||
m_sceneDepthCopiedThisFrame = false;
|
||||
m_sceneDepthSaved = 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();
|
||||
}
|
||||
|
||||
// Reset swap chain image tracking
|
||||
m_swapChainImageRenderImage.clear();
|
||||
m_swapChainImageRenderImage.resize(m_swapChainImages.size(), nullptr);
|
||||
createRenderingFinishedSemaphores();
|
||||
m_previousSwapChainImage = UINT32_MAX;
|
||||
|
||||
m_swapChainNeedsRecreation = false;
|
||||
|
||||
nprintf(("vulkan", "Vulkan: Swap chain recreated successfully (%ux%u, %zu images)\n",
|
||||
m_swapChainExtent.width, m_swapChainExtent.height, m_swapChainImages.size()));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace graphics::vulkan
|
||||
@@ -253,7 +253,7 @@ void VulkanStateTracker::bindPipeline(vk::Pipeline pipeline, vk::PipelineLayout
|
||||
|
||||
void VulkanStateTracker::bindDescriptorSet(DescriptorSetIndex setIndex,
|
||||
vk::DescriptorSet set,
|
||||
const uint32_t* dynamicOffsets)
|
||||
ArrayView<uint32_t> dynamicOffsets)
|
||||
{
|
||||
Assertion(m_cmdBuffer, "bindDescriptorSet called without active command buffer!");
|
||||
Assertion(m_currentPipelineLayout, "bindDescriptorSet called without bound pipeline layout!");
|
||||
@@ -261,10 +261,15 @@ void VulkanStateTracker::bindDescriptorSet(DescriptorSetIndex setIndex,
|
||||
|
||||
auto index = static_cast<uint32_t>(setIndex);
|
||||
const uint32_t dynCount = VulkanDescriptorManager::getDynamicOffsetCount(setIndex);
|
||||
Assertion(dynCount == 0 || dynamicOffsets != nullptr,
|
||||
"bindDescriptorSet: set %u declares %u dynamic descriptors but no offsets were supplied!",
|
||||
// vkCmdBindDescriptorSets reads exactly dynCount entries, so a caller that hands over
|
||||
// fewer would have it read past the end of their array. The view carries its own length,
|
||||
// so that is a check rather than a convention.
|
||||
Assertion(dynamicOffsets.size >= dynCount,
|
||||
"bindDescriptorSet: set %u declares %u dynamic descriptors but only " SIZE_T_ARG
|
||||
" offsets were supplied!",
|
||||
index,
|
||||
dynCount);
|
||||
dynCount,
|
||||
dynamicOffsets.size);
|
||||
|
||||
// The redundancy check has to cover the dynamic offsets too, not just the set
|
||||
// handle: with dynamic UBOs the common case is the *same* set rebound with a new
|
||||
@@ -273,7 +278,7 @@ void VulkanStateTracker::bindDescriptorSet(DescriptorSetIndex setIndex,
|
||||
auto& boundDyn = m_boundDynamicOffsets[index];
|
||||
bool offsetsChanged = false;
|
||||
for (uint32_t i = 0; i < dynCount; ++i) {
|
||||
if (boundDyn[i] != dynamicOffsets[i]) {
|
||||
if (boundDyn[i] != dynamicOffsets.data[i]) {
|
||||
offsetsChanged = true;
|
||||
break;
|
||||
}
|
||||
@@ -286,11 +291,11 @@ void VulkanStateTracker::bindDescriptorSet(DescriptorSetIndex setIndex,
|
||||
1,
|
||||
&set,
|
||||
dynCount,
|
||||
dynCount > 0 ? dynamicOffsets : nullptr);
|
||||
dynCount > 0 ? dynamicOffsets.data : nullptr);
|
||||
|
||||
m_boundDescriptorSets[index] = set;
|
||||
for (uint32_t i = 0; i < dynCount; ++i) {
|
||||
boundDyn[i] = dynamicOffsets[i];
|
||||
boundDyn[i] = dynamicOffsets.data[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,13 +136,16 @@ class VulkanStateTracker {
|
||||
* @brief Bind descriptor set
|
||||
*
|
||||
* @param dynamicOffsets Offsets for the set layout's eUniformBufferDynamic
|
||||
* bindings, ordered by binding number. Must point to at least
|
||||
* VulkanDescriptorManager::getDynamicOffsetCount(setIndex) entries
|
||||
* (DescriptorWriter::dynamicOffsets() produces exactly that); may be
|
||||
* null only for a set that declares none.
|
||||
* bindings, ordered by binding number. Must hold at least
|
||||
* VulkanDescriptorManager::getDynamicOffsetCount(setIndex) entries --
|
||||
* asserted here rather than trusted, which is why this is a view and not
|
||||
* a bare pointer. DescriptorWriter::dynamicOffsets() and the
|
||||
* *_DYNAMIC_OFFSET_COUNT-sized arrays at the call sites both convert
|
||||
* implicitly. May be empty only for a set that declares none.
|
||||
*/
|
||||
void
|
||||
bindDescriptorSet(DescriptorSetIndex setIndex, vk::DescriptorSet set, const uint32_t* dynamicOffsets);
|
||||
void bindDescriptorSet(DescriptorSetIndex setIndex,
|
||||
vk::DescriptorSet set,
|
||||
ArrayView<uint32_t> dynamicOffsets = {});
|
||||
|
||||
// ========== Buffer Binding ==========
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <algorithm>
|
||||
#include "VulkanBarrier.h"
|
||||
#include "VulkanBuffer.h"
|
||||
#include "VulkanConstants.h"
|
||||
#include "VulkanDeletionQueue.h"
|
||||
#include "VulkanRenderer.h"
|
||||
#include "gr_vulkan.h"
|
||||
@@ -247,6 +248,9 @@ bool VulkanTextureManager::init(vk::Device device, vk::PhysicalDevice physicalDe
|
||||
m_fallback3DView, ImageViewType::Volume3D, 1, false, vk::ImageType::e3D)) {
|
||||
return false;
|
||||
}
|
||||
if (!createFallbackShadowTexture()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check BCx and ETC2 Support
|
||||
auto features = m_physicalDevice.getFeatures();
|
||||
@@ -349,12 +353,27 @@ void VulkanTextureManager::shutdown()
|
||||
if (m_fallback2DArrayAllocation.isValid()) {
|
||||
m_memoryManager->freeAllocation(m_fallback2DArrayAllocation);
|
||||
}
|
||||
if (m_fallbackShadowView) {
|
||||
m_device.destroyImageView(m_fallbackShadowView);
|
||||
m_fallbackShadowView = nullptr;
|
||||
}
|
||||
if (m_fallbackShadowTexture) {
|
||||
m_device.destroyImage(m_fallbackShadowTexture);
|
||||
m_fallbackShadowTexture = nullptr;
|
||||
}
|
||||
if (m_fallbackShadowAllocation.isValid()) {
|
||||
m_memoryManager->freeAllocation(m_fallbackShadowAllocation);
|
||||
}
|
||||
|
||||
// Destroy samplers
|
||||
if (m_defaultSampler) {
|
||||
m_device.destroySampler(m_defaultSampler);
|
||||
m_defaultSampler = nullptr;
|
||||
}
|
||||
if (m_shadowCompareSampler) {
|
||||
m_device.destroySampler(m_shadowCompareSampler);
|
||||
m_shadowCompareSampler = nullptr;
|
||||
}
|
||||
|
||||
for (auto& pair : m_samplerCache) {
|
||||
m_device.destroySampler(pair.second);
|
||||
@@ -2225,6 +2244,12 @@ vk::DescriptorImageInfo VulkanTextureManager::getFallbackTextureInfo3D()
|
||||
vk::ImageLayout::eShaderReadOnlyOptimal};
|
||||
}
|
||||
|
||||
vk::DescriptorImageInfo VulkanTextureManager::getFallbackShadowMapInfo()
|
||||
{
|
||||
return {m_shadowCompareSampler, m_fallbackShadowView,
|
||||
vk::ImageLayout::eShaderReadOnlyOptimal};
|
||||
}
|
||||
|
||||
tcache_slot_vulkan* VulkanTextureManager::getTextureSlot(int handle)
|
||||
{
|
||||
(void)this;
|
||||
@@ -2576,6 +2601,74 @@ vk::ImageView VulkanTextureManager::createImageView(vk::Image image, vk::Format
|
||||
}
|
||||
}
|
||||
|
||||
bool VulkanTextureManager::createFallbackShadowTexture()
|
||||
{
|
||||
// One layer is enough: sampling clamps the array coordinate to [0, layerCount - 1],
|
||||
// so a cascade index past the end is well defined. The real cascade count is not
|
||||
// known this early anyway -- it follows Shadow_quality, which the renderer applies later.
|
||||
if (!createImage(1, 1, 1, SHADOW_DEPTH_FORMAT, vk::ImageTiling::eOptimal,
|
||||
vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled,
|
||||
MemoryUsage::GpuOnly, m_fallbackShadowTexture, m_fallbackShadowAllocation)) {
|
||||
nprintf(("vulkan", "Failed to create fallback shadow map image!\n"));
|
||||
return false;
|
||||
}
|
||||
|
||||
m_fallbackShadowView = createImageView(m_fallbackShadowTexture, SHADOW_DEPTH_FORMAT,
|
||||
vk::ImageAspectFlagBits::eDepth, 1, ImageViewType::Array2D);
|
||||
if (!m_fallbackShadowView) {
|
||||
nprintf(("vulkan", "Failed to create fallback shadow map view!\n"));
|
||||
m_device.destroyImage(m_fallbackShadowTexture);
|
||||
m_fallbackShadowTexture = nullptr;
|
||||
m_memoryManager->freeAllocation(m_fallbackShadowAllocation);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear to the far plane so a shader that does sample this reads "nothing occluding".
|
||||
// The clear also gets the image out of eUndefined, which the descriptor's declared
|
||||
// eShaderReadOnlyOptimal layout requires.
|
||||
transitionImageLayout(m_fallbackShadowTexture, SHADOW_DEPTH_FORMAT,
|
||||
vk::ImageLayout::eUndefined, vk::ImageLayout::eTransferDstOptimal);
|
||||
|
||||
{
|
||||
vk::ImageSubresourceRange range;
|
||||
range.aspectMask = vk::ImageAspectFlagBits::eDepth;
|
||||
range.baseMipLevel = 0;
|
||||
range.levelCount = 1;
|
||||
range.baseArrayLayer = 0;
|
||||
range.layerCount = 1;
|
||||
|
||||
vk::CommandBuffer cmd = beginSingleTimeCommands();
|
||||
cmd.clearDepthStencilImage(m_fallbackShadowTexture, vk::ImageLayout::eTransferDstOptimal,
|
||||
vk::ClearDepthStencilValue(1.0f, 0), range);
|
||||
endSingleTimeCommands(cmd);
|
||||
}
|
||||
|
||||
transitionImageLayout(m_fallbackShadowTexture, SHADOW_DEPTH_FORMAT,
|
||||
vk::ImageLayout::eTransferDstOptimal, vk::ImageLayout::eShaderReadOnlyOptimal);
|
||||
|
||||
// The renderer's own compare sampler belongs to VulkanShadowMap, which never
|
||||
// initializes while shadows are off. So this fallback needs its own.
|
||||
vk::SamplerCreateInfo samplerInfo;
|
||||
samplerInfo.magFilter = vk::Filter::eNearest;
|
||||
samplerInfo.minFilter = vk::Filter::eNearest;
|
||||
samplerInfo.mipmapMode = vk::SamplerMipmapMode::eNearest;
|
||||
samplerInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge;
|
||||
samplerInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge;
|
||||
samplerInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge;
|
||||
samplerInfo.compareEnable = VK_TRUE;
|
||||
samplerInfo.compareOp = vk::CompareOp::eLessOrEqual;
|
||||
samplerInfo.borderColor = vk::BorderColor::eFloatOpaqueWhite;
|
||||
|
||||
try {
|
||||
m_shadowCompareSampler = m_device.createSampler(samplerInfo);
|
||||
} catch (const vk::SystemError& e) {
|
||||
nprintf(("vulkan", "Failed to create fallback shadow compare sampler: %s\n", e.what()));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VulkanTextureManager::createFallbackTexture(vk::Image& outImage, VulkanAllocation& outAlloc,
|
||||
vk::ImageView& outView, ImageViewType viewType,
|
||||
uint32_t arrayLayers, bool cubemap,
|
||||
|
||||
@@ -187,6 +187,14 @@ public:
|
||||
*/
|
||||
vk::DescriptorImageInfo getFallbackTextureInfo3D();
|
||||
|
||||
/**
|
||||
* @brief Get a ready-to-use DescriptorImageInfo for the shadow map fallback
|
||||
*
|
||||
* A depth-format 2D array view plus a compare-enabled sampler, for the
|
||||
* sampler2DArrayShadow binding when no real shadow map is bound.
|
||||
*/
|
||||
vk::DescriptorImageInfo getFallbackShadowMapInfo();
|
||||
|
||||
// Texture access
|
||||
|
||||
/**
|
||||
@@ -267,6 +275,14 @@ private:
|
||||
/**
|
||||
* @brief Create a 1x1 white fallback texture (image + view + upload)
|
||||
*/
|
||||
/**
|
||||
* @brief Create the 1x1 depth fallback for shadow sampler bindings (image + view + clear)
|
||||
*
|
||||
* Cleared to depth 1.0 (the far plane), so a shader that does sample it reads
|
||||
* "nothing occluding" rather than undefined memory.
|
||||
*/
|
||||
bool createFallbackShadowTexture();
|
||||
|
||||
bool createFallbackTexture(vk::Image& outImage, VulkanAllocation& outAlloc,
|
||||
vk::ImageView& outView, ImageViewType viewType,
|
||||
uint32_t arrayLayers = 1, bool cubemap = false,
|
||||
@@ -413,6 +429,13 @@ private:
|
||||
VulkanAllocation m_fallbackCubeAllocation;
|
||||
|
||||
// Fallback 1x1x1 white 3D texture for unbound sampler3D slots
|
||||
// Depth fallback for the sampler2DArrayShadow binding. Sampled through
|
||||
// m_shadowCompareSampler, not m_defaultSampler.
|
||||
vk::Image m_fallbackShadowTexture;
|
||||
vk::ImageView m_fallbackShadowView; // 2D_ARRAY depth view (for sampler2DArrayShadow)
|
||||
VulkanAllocation m_fallbackShadowAllocation;
|
||||
vk::Sampler m_shadowCompareSampler;
|
||||
|
||||
vk::Image m_fallback3DTexture;
|
||||
vk::ImageView m_fallback3DView; // 3D view (for sampler3D)
|
||||
VulkanAllocation m_fallback3DAllocation;
|
||||
|
||||
@@ -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"
|
||||
@@ -42,7 +43,7 @@ std::unique_ptr<VulkanRenderer> renderer_instance;
|
||||
|
||||
// Sync object for tracking frame completion
|
||||
struct VulkanSyncObject {
|
||||
uint64_t frameNumber;
|
||||
FrameSyncPoint point;
|
||||
};
|
||||
|
||||
// ========== Renderer-level functions ==========
|
||||
@@ -53,6 +54,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();
|
||||
@@ -249,7 +266,7 @@ gr_sync vulkan_sync_fence()
|
||||
{
|
||||
auto* renderer = getRendererInstance();
|
||||
auto* sync = new VulkanSyncObject();
|
||||
sync->frameNumber = renderer->getCurrentFrameNumber();
|
||||
sync->point = renderer->captureSyncPoint();
|
||||
return static_cast<gr_sync>(sync);
|
||||
}
|
||||
|
||||
@@ -266,7 +283,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)
|
||||
@@ -363,6 +380,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<uint32_t>(width) && h == static_cast<uint32_t>(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<size_t>(w) * h * 4);
|
||||
}
|
||||
|
||||
vm_free(pixels);
|
||||
|
||||
return sizeMatches;
|
||||
}
|
||||
|
||||
SCP_string vulkan_blob_screen()
|
||||
{
|
||||
ubyte* pixels = nullptr;
|
||||
@@ -423,7 +505,26 @@ std::unique_ptr<os::Viewport> 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->getSwapChainExtent();
|
||||
if (extent.width > 0 && extent.height > 0) {
|
||||
gr_screen_resize(static_cast<int>(extent.width), static_cast<int>(extent.height));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Function pointer table ==========
|
||||
// Implementations are defined in their respective files:
|
||||
@@ -433,6 +534,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;
|
||||
|
||||
@@ -440,6 +542,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;
|
||||
@@ -559,7 +663,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;
|
||||
|
||||
|
||||
@@ -129,6 +129,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
|
||||
@@ -362,6 +367,19 @@ namespace os
|
||||
* @return The created viewport, may be @c nullptr if the viewport can't be created
|
||||
*/
|
||||
virtual std::unique_ptr<Viewport> 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; }
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include "globalincs/pstypes.h"
|
||||
|
||||
#include <type_traits>
|
||||
|
||||
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<SCP_string>& 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 <typename HandleType>
|
||||
inline HandleType vulkan_handle_cast(uint64_t handle)
|
||||
{
|
||||
if constexpr (std::is_pointer<HandleType>::value) {
|
||||
return reinterpret_cast<HandleType>(static_cast<uintptr_t>(handle));
|
||||
} else {
|
||||
return static_cast<HandleType>(handle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Converts a Vulkan handle to the transport type used by VulkanSurfaceProvider
|
||||
* @ingroup os_graphics_api
|
||||
*/
|
||||
template <typename HandleType>
|
||||
inline uint64_t vulkan_handle_value(HandleType handle)
|
||||
{
|
||||
if constexpr (std::is_pointer<HandleType>::value) {
|
||||
return static_cast<uint64_t>(reinterpret_cast<uintptr_t>(handle));
|
||||
} else {
|
||||
return static_cast<uint64_t>(handle);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace os
|
||||
@@ -630,6 +630,8 @@ if (FSO_BUILD_WITH_VULKAN)
|
||||
graphics/vulkan/VulkanPostProcessingMSAA.cpp
|
||||
graphics/vulkan/VulkanPostProcessingSMAA.cpp
|
||||
graphics/vulkan/VulkanPostProcessingShadow.cpp
|
||||
graphics/vulkan/VulkanPresentTarget.cpp
|
||||
graphics/vulkan/VulkanPresentTarget.h
|
||||
graphics/vulkan/VulkanQuery.cpp
|
||||
graphics/vulkan/VulkanQuery.h
|
||||
graphics/vulkan/VulkanRaytracing.cpp
|
||||
@@ -1171,6 +1173,7 @@ add_file_folder("OsApi"
|
||||
osapi/osregistry.cpp
|
||||
osapi/outwnd.h
|
||||
osapi/outwnd.cpp
|
||||
osapi/vulkan_surface.h
|
||||
)
|
||||
|
||||
# Parse files
|
||||
|
||||
@@ -1371,6 +1371,9 @@ static float sun_measured_disc_fraction(starfield_bitmap *bm)
|
||||
* tangent of the angular radius, so the penumbra widens with occluder distance the way a real
|
||||
* area light's would. 0 means a point source, i.e. hard shadows.
|
||||
*
|
||||
* Only a size that was *asked for* can be 0. A sun whose bitmap simply has no measurable disc
|
||||
* falls back to Sol instead -- see below.
|
||||
*
|
||||
* @param scale_x the mission's +Scale: for this sun instance
|
||||
* @param mission_angular_size this sun instance's +AngularSize: from the mission file
|
||||
*/
|
||||
@@ -1379,6 +1382,8 @@ static float sun_angular_radius_tangent(starfield_bitmap *bm, float scale_x, flo
|
||||
const float specified = sun_explicit_angular_size(bm, mission_angular_size);
|
||||
|
||||
if (specified >= 0.0f) {
|
||||
// Explicit, including an explicit 0: a mod that asks for a sizeless source gets the
|
||||
// hard edge it asked for.
|
||||
return sun_disc_tangent_from_diameter(specified);
|
||||
}
|
||||
|
||||
@@ -1387,7 +1392,20 @@ static float sun_angular_radius_tangent(starfield_bitmap *bm, float scale_x, flo
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
return sun_disc_tangent_from_fraction(sun_measured_disc_fraction(bm), scale_x);
|
||||
|
||||
const float measured = sun_disc_tangent_from_fraction(sun_measured_disc_fraction(bm), scale_x);
|
||||
if (measured > 0.0f) {
|
||||
return measured;
|
||||
}
|
||||
|
||||
// Nothing specified a size and the bitmap has no disc we could measure (a planet or
|
||||
// nebula bitmap used as a sun, or one we failed to read). Falling back to 0 here would
|
||||
// hand that sun a single hard ray, and in a multi-sun mission one such sun is enough to
|
||||
// lay a razor-sharp shadow over every soft one -- with no clue in the editor as to which
|
||||
// sun is responsible, since the control that would fix it sits on a different sun.
|
||||
// Sol is the same default the editors and the lab start from, so an unmeasurable sun now
|
||||
// behaves like an ordinary one; hard shadows remain available by asking for 0 outright.
|
||||
return sun_disc_tangent_from_diameter(SUN_ANGULAR_SIZE_SOL);
|
||||
}
|
||||
|
||||
// draw sun
|
||||
|
||||
@@ -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<void*>(SDL_Vulkan_GetVkGetInstanceProcAddr());
|
||||
if (procAddr == nullptr) {
|
||||
mprintf(("Failed to get vkGetInstanceProcAddr: %s\n", SDL_GetError()));
|
||||
}
|
||||
|
||||
return procAddr;
|
||||
}
|
||||
bool SDLGraphicsOperations::getVulkanInstanceExtensions(SCP_vector<SCP_string>& 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<VkSurfaceKHR>(0);
|
||||
if (!SDL_Vulkan_CreateSurface(view->toSDLWindow(), static_cast<VkInstance>(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>(vkInstance),
|
||||
os::vulkan_handle_cast<VkSurfaceKHR>(surface),
|
||||
nullptr);
|
||||
}
|
||||
std::unique_ptr<os::Viewport> SDLGraphicsOperations::createViewport(const os::ViewPortProperties& props)
|
||||
{
|
||||
uint32_t windowflags = 0;
|
||||
|
||||
@@ -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<os::Viewport> createViewport(const os::ViewPortProperties& props) override;
|
||||
|
||||
os::VulkanSurfaceProvider* getVulkanSupport() override { return this; }
|
||||
|
||||
void* getVulkanProcAddr() override;
|
||||
|
||||
bool getVulkanInstanceExtensions(SCP_vector<SCP_string>& 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
|
||||
+42
-30
@@ -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`.
|
||||
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`).
|
||||
|
||||
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*`.
|
||||
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.
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
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:
|
||||
**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.
|
||||
|
||||
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.
|
||||
`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
|
||||
|
||||
@@ -44,11 +44,20 @@ and the change takes effect immediately.</p>
|
||||
only - they are stored separately from the game's own graphics options and
|
||||
never change them.</p>
|
||||
|
||||
<h3>Renderer</h3>
|
||||
<ul>
|
||||
<li><strong>Backend</strong> - 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 <code>-vulkan</code> or
|
||||
<code>-opengl</code> command-line option always overrides this setting.
|
||||
<em>Requires a restart.</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Post-processing</h3>
|
||||
<p><strong>Enable post-processing in the viewport</strong> routes the viewport
|
||||
through the same HDR rendering pipeline the game uses, adding bloom,
|
||||
tonemapping, and lightshafts. It is off by default, so missions look the way
|
||||
they always have until you opt in. The same switch is available as
|
||||
tonemapping, lightshafts, and shadows. It is off by default, so missions look
|
||||
the way they always have until you opt in. The same switch is available as
|
||||
<strong>View › Enable Post Processing</strong>.</p>
|
||||
<p>Every other setting in this section only changes what you see while
|
||||
post-processing is on, because they are all consumed by that pipeline.</p>
|
||||
@@ -60,6 +69,10 @@ post-processing is on, because they are all consumed by that pipeline.</p>
|
||||
until you raise this above <em>Disabled</em>, so enabling post-processing
|
||||
alone will not produce any. <em>Requires a restart</em> - including the
|
||||
first time you turn it on.</li>
|
||||
<li><strong>Shadow method</strong> - 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.</li>
|
||||
<li><strong>Anti-aliasing</strong> - post-process anti-aliasing mode (FXAA or
|
||||
SMAA, in increasing quality). Applies immediately.</li>
|
||||
<li><strong>MSAA</strong> - multisample anti-aliasing for the 3D scene, off
|
||||
@@ -73,15 +86,19 @@ post-processing is on, because they are all consumed by that pipeline.</p>
|
||||
filtering. Left alone, QtFRED uses the same default the game does.
|
||||
<em>Requires a restart.</em></li>
|
||||
<li><strong>Anisotropic filtering</strong> - sharpens textures viewed at a
|
||||
steep angle. The list is limited to the levels your GPU reports, and the
|
||||
control is greyed out if it reports nothing worth offering. Left alone,
|
||||
QtFRED uses your hardware's maximum. <em>Requires a restart.</em></li>
|
||||
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. <em>Requires a restart.</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Gamma</h3>
|
||||
<p>Brightness of the viewport, from 0.10 to 5.00. QtFRED defaults this to 3.00,
|
||||
higher than the game, because the viewport is not tonemapped unless
|
||||
post-processing is enabled. Applies immediately.</p>
|
||||
<p>Brightness of the viewport, from 0.10 to 5.00, applied as
|
||||
<code>colour<sup>1/gamma</sup></code>. The default of 1.00 leaves the viewport
|
||||
untouched.</p>
|
||||
<p><em>This control currently only does anything when QtFRED is running on the
|
||||
Vulkan renderer.</em> 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.</p>
|
||||
|
||||
<h2>Grid</h2>
|
||||
<p>Configures the reference grid shown in the main viewport. You can set which
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "Editor.h"
|
||||
#include "EditorViewport.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include <globalincs/alphacolors.h>
|
||||
#include <mission/missiongrid.h>
|
||||
#include <globalincs/systemvars.h>
|
||||
@@ -983,6 +985,16 @@ void FredRenderer::render_models(int cur_object_index) {
|
||||
bool f = false;
|
||||
enable_htl();
|
||||
|
||||
// Mirrors obj_render_queue_all()'s bind right before scene.render_all(). The model shaders
|
||||
// read rtShadowSampleCount, the RTAO params and the per-cascade smoothness out of this block,
|
||||
// but that bind lives in obj_render_queue_all() and we render through obj_render_all(), so
|
||||
// nothing here binds it for the model pass. Without it the pass runs on the zero-filled
|
||||
// fallback UBO -- a sample count of 0, which collapses traceShadowRayCone() to a single hard
|
||||
// ray and makes shadows ignore the sun's angular size entirely.
|
||||
if (Shadow_quality != ShadowQuality::Disabled) {
|
||||
shadow_cascade_params_bind(Num_cockpit_shadow_cascades, Num_shadow_cascades);
|
||||
}
|
||||
|
||||
auto render_function = [&](object* objp) {
|
||||
if (!_viewport->isObjectVisibleInLayer(objp)) {
|
||||
return;
|
||||
@@ -1002,11 +1014,18 @@ 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);
|
||||
_devicePixelRatio = static_cast<float>(scale);
|
||||
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<uint32_t>(std::lround(logicalSize.first * scale));
|
||||
const auto height = static_cast<uint32_t>(std::lround(logicalSize.second * scale));
|
||||
|
||||
// Resize the rendering window in case the previous size was different
|
||||
gr_screen_resize(width, height);
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ namespace {
|
||||
|
||||
const char* SETTINGS_GROUP = "Preferences";
|
||||
|
||||
const char* KEY_BACKEND = "view_graphics_backend";
|
||||
const char* KEY_POST_PROCESSING = "view_enable_post_processing";
|
||||
const char* KEY_SHADOW_QUALITY = "view_graphics_shadow_quality";
|
||||
const char* KEY_SHADOW_METHOD = "view_graphics_shadow_method";
|
||||
const char* KEY_AA_MODE = "view_graphics_aa_mode";
|
||||
const char* KEY_MSAA_SAMPLES = "view_graphics_msaa_samples";
|
||||
const char* KEY_TEXTURE_FILTER = "view_graphics_texture_filter";
|
||||
@@ -37,9 +39,10 @@ T readEnum(const QSettings& settings, const char* key, T fallback, T highest)
|
||||
|
||||
bool GraphicsSettings::operator==(const GraphicsSettings& rhs) const
|
||||
{
|
||||
return enablePostProcessing == rhs.enablePostProcessing && shadowQuality == rhs.shadowQuality &&
|
||||
aaMode == rhs.aaMode && msaaSamples == rhs.msaaSamples && gamma == rhs.gamma &&
|
||||
textureFilter == rhs.textureFilter && anisotropy == rhs.anisotropy;
|
||||
return backend == rhs.backend && enablePostProcessing == rhs.enablePostProcessing &&
|
||||
shadowQuality == rhs.shadowQuality && shadowMethod == rhs.shadowMethod && aaMode == rhs.aaMode &&
|
||||
msaaSamples == rhs.msaaSamples && gamma == rhs.gamma && textureFilter == rhs.textureFilter &&
|
||||
anisotropy == rhs.anisotropy;
|
||||
}
|
||||
|
||||
SCP_vector<int> GraphicsSettings::validMsaaSampleCounts()
|
||||
@@ -54,8 +57,25 @@ GraphicsSettings GraphicsSettings::load()
|
||||
QSettings settings;
|
||||
settings.beginGroup(SETTINGS_GROUP);
|
||||
|
||||
if (settings.contains(KEY_BACKEND)) {
|
||||
// Only OpenGL and, on a build that has it, Vulkan are valid explicit choices. Stub, a Vulkan
|
||||
// choice this build can't honour, or a garbage value from a hand-edited file all keep
|
||||
// GraphicsAPI::Default, same as an absent key -- see the field comment for why that matters.
|
||||
const auto raw = static_cast<GraphicsAPI>(settings.value(KEY_BACKEND).toInt());
|
||||
if (raw == GraphicsAPI::OpenGL) {
|
||||
out.backend = GraphicsAPI::OpenGL;
|
||||
}
|
||||
#ifdef WITH_VULKAN
|
||||
else if (raw == GraphicsAPI::Vulkan) {
|
||||
out.backend = GraphicsAPI::Vulkan;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
out.enablePostProcessing = settings.value(KEY_POST_PROCESSING, out.enablePostProcessing).toBool();
|
||||
out.shadowQuality = readEnum(settings, KEY_SHADOW_QUALITY, out.shadowQuality, ShadowQuality::Ultra);
|
||||
out.shadowMethod =
|
||||
readEnum(settings, KEY_SHADOW_METHOD, out.shadowMethod, ShadowRenderMethod::Raytraced);
|
||||
out.aaMode = readEnum(settings, KEY_AA_MODE, out.aaMode, AntiAliasMode::SMAA_Ultra);
|
||||
out.gamma = settings.value(KEY_GAMMA, out.gamma).toFloat();
|
||||
|
||||
@@ -85,11 +105,16 @@ void GraphicsSettings::save() const
|
||||
|
||||
settings.setValue(KEY_POST_PROCESSING, enablePostProcessing);
|
||||
settings.setValue(KEY_SHADOW_QUALITY, static_cast<int>(shadowQuality));
|
||||
settings.setValue(KEY_SHADOW_METHOD, static_cast<int>(shadowMethod));
|
||||
settings.setValue(KEY_AA_MODE, static_cast<int>(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<int>(backend));
|
||||
}
|
||||
|
||||
if (textureFilter != NO_TEXTURE_FILTER_CHOICE) {
|
||||
settings.setValue(KEY_TEXTURE_FILTER, textureFilter);
|
||||
}
|
||||
@@ -105,12 +130,25 @@ void GraphicsSettings::applyLive() const
|
||||
{
|
||||
Gr_aa_mode = aaMode;
|
||||
gr_set_gamma(gamma);
|
||||
|
||||
// Same as Gr_aa_mode: this is what Graphics.ShadowRenderMethod's own .bind_to() does, and it is
|
||||
// harmless to set even when raytraced shadows are not actually available this session --
|
||||
// shadows_use_raytracing() re-checks shadows_raytracing_supported() before anything reads it.
|
||||
Shadow_render_method = shadowMethod;
|
||||
}
|
||||
|
||||
GraphicsSettings GraphicsSettings::applyBeforeGrInit()
|
||||
{
|
||||
const GraphicsSettings settings = load();
|
||||
|
||||
// Commandline always wins (see gr_init()'s own "Commandline ALWAYS wins" comment), so only fill
|
||||
// in the backend here if -vulkan/-opengl didn't already set it. If the user has never chosen one
|
||||
// in Preferences either, settings.backend is itself GraphicsAPI::Default, and this is a no-op --
|
||||
// gr_init() falls through to its own VideocardFs2open/OpenGL fallback exactly as it always has.
|
||||
if (Cmdline_graphics_api == GraphicsAPI::Default) {
|
||||
Cmdline_graphics_api = settings.backend;
|
||||
}
|
||||
|
||||
Cmdline_msaa_enabled = settings.msaaSamples;
|
||||
Shadow_quality = settings.shadowQuality;
|
||||
|
||||
|
||||
@@ -8,10 +8,17 @@ namespace fso::fred {
|
||||
/**
|
||||
* @brief qtFRED's Preferences > Graphics settings.
|
||||
*
|
||||
* Single owner of these values: the QSettings keys, their defaults, and the rules for when each
|
||||
* one can be applied all live here. Two very different callers need them -- management.cpp before
|
||||
* gr_init(), and EditorViewport once the editor is up -- and previously each read QSettings with
|
||||
* its own copy of the key strings and default values.
|
||||
* Single owner of the *rules*: the QSettings keys, the defaults, and when each value can be applied
|
||||
* all live here. Two very different callers need them -- management.cpp before gr_init(), and
|
||||
* EditorViewport once the editor is up -- and previously each read QSettings with its own copy of
|
||||
* the key strings and default values.
|
||||
*
|
||||
* The values themselves are ordinary fields and have more than one writer. Preferences edits a copy
|
||||
* and writes the whole struct back on apply (PreferencesDialogModel); View > Enable Post Processing
|
||||
* flips enablePostProcessing in place (FredView::syncViewOptions). Those cannot race -- the
|
||||
* Preferences dialog is modal, and the menu action re-reads the field on every viewIdle so its
|
||||
* check state follows an edit made in the dialog. A third writer, or a modeless Preferences, would
|
||||
* break that; route it through here rather than adding another direct one.
|
||||
*
|
||||
* The settings split by *when* they can take effect, which is what the two apply functions below
|
||||
* encode:
|
||||
@@ -27,8 +34,10 @@ namespace fso::fred {
|
||||
struct GraphicsSettings {
|
||||
// Texture filtering and anisotropy reach the engine through the options system, whose defaults
|
||||
// are better informed than anything qtFRED could hardcode -- the user's existing config for
|
||||
// one, the hardware maximum for the other. Both therefore carry a sentinel meaning "the user
|
||||
// has not chosen one", and applyBeforeGrInit() overrides only once there is a real choice.
|
||||
// one, the hardware maximum for the other. The backend has a comparable existing default: the
|
||||
// VideocardFs2open config entry gr_init() already reads. All three therefore carry a sentinel
|
||||
// meaning "the user has not chosen one", and applyBeforeGrInit() overrides only once there is a
|
||||
// real choice.
|
||||
static constexpr int NO_TEXTURE_FILTER_CHOICE = -1;
|
||||
static constexpr float NO_ANISOTROPY_CHOICE = 0.0f;
|
||||
|
||||
@@ -40,11 +49,67 @@ struct GraphicsSettings {
|
||||
*/
|
||||
bool enablePostProcessing = false;
|
||||
|
||||
/**
|
||||
* @brief The renderer backend, applied before gr_init() the same way Shadow_quality is.
|
||||
*
|
||||
* GraphicsAPI::Default means "the user has not chosen one in Preferences" -- gr_init() already
|
||||
* has a fallback for that (the VideocardFs2open config entry, then OpenGL), and qtFRED must not
|
||||
* get in front of it: a user relying on that entry to launch qtFRED into Vulkan without passing
|
||||
* -vulkan every time would otherwise lose that the moment they saved any other preference, since
|
||||
* save() would start persisting an explicit OpenGL nobody chose. So this is only ever written or
|
||||
* applied once Preferences actually sets it to OpenGL or Vulkan -- see save() and
|
||||
* applyBeforeGrInit(). `-vulkan`/`-opengl` on the command line still win over an explicit choice
|
||||
* here too: applyBeforeGrInit() only sets Cmdline_graphics_api from this when parse_cmdline() left
|
||||
* it at GraphicsAPI::Default, matching the "commandline always wins" rule gr_init() enforces.
|
||||
* load() also refuses to hand back GraphicsAPI::Vulkan on a build without WITH_VULKAN, so a
|
||||
* settings file left over from a Vulkan-enabled build can't make a later build without it hit the
|
||||
* Error() in gr_init_function_pointers().
|
||||
*/
|
||||
GraphicsAPI backend = GraphicsAPI::Default;
|
||||
|
||||
ShadowQuality shadowQuality = ShadowQuality::Disabled;
|
||||
|
||||
/**
|
||||
* @brief Whether shadows are cast via cascaded shadow maps or hardware raytracing.
|
||||
*
|
||||
* A live-apply setting: unlike ShadowQuality, the engine's own Graphics.ShadowRenderMethod
|
||||
* option just binds straight to Shadow_render_method with no initial-only change_listener,
|
||||
* so applyLive() can set it directly, same as aaMode.
|
||||
*
|
||||
* Raytraced only actually renders that way when shadows_use_raytracing() agrees, which
|
||||
* requires shadows_raytracing_supported() (Vulkan plus hardware ray-query support) --
|
||||
* exactly the same check Graphics.ShadowRenderMethod's own enumerator uses to decide
|
||||
* whether to offer the value at all. So this field can hold Raytraced on a build/session
|
||||
* that can't honour it with no effect on rendering, same as the engine option it mirrors.
|
||||
* Preferences populates its combo from that option (see PreferencesDialog::initializeUi()),
|
||||
* so it reads as greyed out whenever the choice would be inert, rather than qtFRED needing
|
||||
* its own Vulkan/hardware check.
|
||||
*
|
||||
* Graphics.RTShadowQuality (Low vs. High local-light coverage) is deliberately not exposed
|
||||
* here -- not useful enough in the editor to be worth a second control.
|
||||
*/
|
||||
ShadowRenderMethod shadowMethod = ShadowRenderMethod::ShadowMap;
|
||||
|
||||
AntiAliasMode aaMode = AntiAliasMode::None;
|
||||
|
||||
int msaaSamples = 0; //!< 0 (off), 4, or 8; see validMsaaSampleCounts()
|
||||
float gamma = 3.0f; //!< matches the brightness qtFRED has always launched with
|
||||
|
||||
/**
|
||||
* @brief Viewport brightness, as the exponent in pow(colour, 1/gamma).
|
||||
*
|
||||
* 1.0 (identity, and the engine's own default) is what qtFRED has always *rendered* with,
|
||||
* whatever this said. It used to default to 3.0, copied verbatim from retail FRED2
|
||||
* (fred2/management.cpp still has the same gr_set_gamma(3.0f)), where it has never had any
|
||||
* effect: OpenGL only applies Gr_gamma inside the `if (Cmdline_window_res)` branch of
|
||||
* gr_opengl_flip(), and gr_init() deliberately does not set Cmdline_window_res for FRED.
|
||||
*
|
||||
* The Vulkan backend has no such branch -- encodeToSwapChain() always runs the gamma encode --
|
||||
* so a 3.0 default made the Vulkan viewport render every frame through pow(colour, 1/3) while
|
||||
* OpenGL rendered it untouched. Keeping the identity here is what makes the two backends agree.
|
||||
*
|
||||
* Note the control is still inert under OpenGL in the editor; see qtfred/README.md.
|
||||
*/
|
||||
float gamma = 1.0f;
|
||||
|
||||
int textureFilter = NO_TEXTURE_FILTER_CHOICE; //!< 0 = bilinear, 1 = trilinear
|
||||
float anisotropy = NO_ANISOTROPY_CHOICE; //!< 1.0 = off, otherwise a power of two
|
||||
|
||||
@@ -959,6 +959,9 @@ void FredView::syncViewOptions() {
|
||||
|
||||
connectActionToViewSetting(ui->actionLighting_from_Suns, &_viewport->view.Lighting_on);
|
||||
connectActionToViewSetting(ui->actionRender_Full_Detail, &_viewport->view.FullDetail);
|
||||
// The one Preferences > Graphics setting that is also a View menu toggle. Safe to write directly:
|
||||
// nothing has to be pushed into the engine for it (render_frame() reads it each frame), and it
|
||||
// cannot fight the Preferences copy -- see the note on GraphicsSettings.
|
||||
connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.Graphics.enablePostProcessing);
|
||||
|
||||
connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances);
|
||||
|
||||
@@ -15,8 +15,45 @@
|
||||
|
||||
#include "FredApplication.h"
|
||||
|
||||
#include <SDL3/SDL_vulkan.h>
|
||||
|
||||
#if QTFRED_HAS_VULKAN
|
||||
#include <vulkan/vulkan_core.h>
|
||||
#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<void*>(SDL_Vulkan_GetVkGetInstanceProcAddr());
|
||||
if (procAddr == nullptr) {
|
||||
mprintf(("qtFRED: failed to get vkGetInstanceProcAddr: %s\n", SDL_GetError()));
|
||||
}
|
||||
|
||||
return procAddr;
|
||||
}
|
||||
|
||||
bool QtGraphicsOperations::getVulkanInstanceExtensions(SCP_vector<SCP_string>& 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<QtSurfaceViewport*>(view);
|
||||
if (qtView == nullptr) {
|
||||
mprintf(("qtFRED: cannot create a Vulkan surface for a non-Qt viewport.\n"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto* window = dynamic_cast<QWindow*>(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>(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<int>(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<os::OpenGLContext>
|
||||
QtGraphicsOperations::createOpenGLContext(os::Viewport* viewport, const os::OpenGLContextAttributes& gl_attrs) {
|
||||
auto qtPort = static_cast<QtViewport*>(viewport);
|
||||
@@ -114,8 +326,18 @@ QtViewport::~QtViewport() {
|
||||
}
|
||||
|
||||
std::unique_ptr<os::Viewport> 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<FredView> 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<os::Viewport>(new QtViewport(std::move(mw), props));
|
||||
@@ -141,6 +363,12 @@ std::pair<uint32_t, uint32_t> 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<QWindow*>(_viewportWindow->getRenderSurface());
|
||||
if (qSurf && qSurf->isExposed()) {
|
||||
QOpenGLContext::currentContext()->swapBuffers(qSurf);
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <osapi/osapi.h>
|
||||
#include <osapi/vulkan_surface.h>
|
||||
#include "mission/Editor.h"
|
||||
#include "FredView.h"
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <QtGui/qtguiglobal.h>
|
||||
#include <QtGui/QOpenGLContext>
|
||||
|
||||
// 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 <QtCore/QPointer>
|
||||
#include <QtGui/QVulkanInstance>
|
||||
#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<QOpenGLContext> _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<QVulkanInstance> _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<uint64_t, QPointer<QWindow>> _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<os::Viewport> createViewport(const os::ViewPortProperties& props) override;
|
||||
|
||||
os::VulkanSurfaceProvider* getVulkanSupport() override;
|
||||
|
||||
void* getVulkanProcAddr() override;
|
||||
|
||||
bool getVulkanInstanceExtensions(SCP_vector<SCP_string>& extensions) override;
|
||||
|
||||
uint64_t createVulkanSurface(os::Viewport* view, void* vkInstance) override;
|
||||
|
||||
void destroyVulkanSurface(void* vkInstance, uint64_t surface) override;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QKeyEvent>
|
||||
#include <QKeySequenceEdit>
|
||||
|
||||
#include "ui/QtGraphicsOperations.h"
|
||||
#include "ui/util/SignalBlockers.h"
|
||||
#include "ui/widgets/sexp_tree_view.h"
|
||||
|
||||
@@ -19,9 +20,8 @@ namespace {
|
||||
// gropengltexture.cpp). Reading the labels back out of those definitions keeps this dialog from
|
||||
// carrying a second, silently-diverging copy of them.
|
||||
//
|
||||
// The values are enumerated in declaration order, which for these three is enum order, so a combo
|
||||
// index is the enum value. Returns empty if the option isn't registered, in which case the caller
|
||||
// leaves the combo alone rather than showing a half-populated list.
|
||||
// Returns empty if the option isn't registered, in which case the caller leaves the combo alone
|
||||
// rather than showing a half-populated list.
|
||||
SCP_vector<options::ValueDescription> engineOptionValues(const char* configKey)
|
||||
{
|
||||
for (const auto& option : options::OptionsManager::instance()->getOptions()) {
|
||||
@@ -38,12 +38,45 @@ void populateFromEngineOption(QComboBox* combo, const char* configKey)
|
||||
const auto values = engineOptionValues(configKey);
|
||||
|
||||
for (const auto& value : values) {
|
||||
combo->addItem(QString::fromStdString(value.display));
|
||||
// Carry the value on the item rather than inferring it from the item's position. Enum- and
|
||||
// int-valued options serialize to their underlying integer (set_defaults() in
|
||||
// options/Option.h), so ValueDescription::serialized is the value itself.
|
||||
//
|
||||
// Position is emphatically not the same thing: the engine filters these lists at runtime --
|
||||
// shadows_remove_unsupported_options() already drops Graphics.ShadowRenderMethod's raytraced
|
||||
// entry on hardware that can't do it -- so the moment any of these gains a filtered entry,
|
||||
// index-as-value would silently start selecting the wrong thing.
|
||||
bool parsed = false;
|
||||
const int itemValue = QString::fromStdString(value.serialized).toInt(&parsed);
|
||||
if (!parsed) {
|
||||
mprintf(("PreferencesDialog: option %s has a non-integer value '%s'; skipping it.\n",
|
||||
configKey, value.serialized.c_str()));
|
||||
continue;
|
||||
}
|
||||
|
||||
combo->addItem(QString::fromStdString(value.display), itemValue);
|
||||
}
|
||||
|
||||
// Nothing to choose from means the option isn't registered in this build (a renderer compiled
|
||||
// out, say). Grey the control out rather than leaving an empty combo that looks broken.
|
||||
combo->setEnabled(!values.empty());
|
||||
combo->setEnabled(combo->count() > 0);
|
||||
}
|
||||
|
||||
// Select the item carrying @p value, leaving the combo alone if nothing does -- which is what a
|
||||
// setting the engine filtered out of this session's list looks like.
|
||||
void selectComboValue(QComboBox* combo, int value)
|
||||
{
|
||||
const int index = combo->findData(value);
|
||||
if (index >= 0) {
|
||||
combo->setCurrentIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
// The value the user picked, or @p fallback while the combo is empty or has no selection.
|
||||
int currentComboValue(const QComboBox* combo, int fallback)
|
||||
{
|
||||
const auto data = combo->currentData();
|
||||
return data.isValid() ? data.toInt() : fallback;
|
||||
}
|
||||
|
||||
// The model stores graphics settings as one struct, so an edit is read-modify-write. Doing it this
|
||||
@@ -145,20 +178,41 @@ void PreferencesDialog::initializeUi() {
|
||||
// (Items declared in the .ui file were added before the connections existed and so were safe.)
|
||||
util::SignalBlockers blockers(this);
|
||||
|
||||
// Every combo on this tab carries its value as item data, so nothing below depends on an item's
|
||||
// position -- see populateFromEngineOption().
|
||||
ui->backendCombo->addItem(tr("OpenGL"), static_cast<int>(GraphicsAPI::OpenGL));
|
||||
#if QTFRED_HAS_VULKAN
|
||||
ui->backendCombo->addItem(tr("Vulkan"), static_cast<int>(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<int>(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.
|
||||
_anisotropyLevels = gr_get_supported_anisotropy_levels();
|
||||
for (float level : _anisotropyLevels) {
|
||||
ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0));
|
||||
for (float level : gr_get_supported_anisotropy_levels()) {
|
||||
ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'f', 0), level);
|
||||
}
|
||||
ui->anisotropyCombo->setEnabled(!_anisotropyLevels.empty());
|
||||
ui->anisotropyCombo->setEnabled(ui->anisotropyCombo->count() > 0);
|
||||
|
||||
for (int samples : GraphicsSettings::validMsaaSampleCounts()) {
|
||||
ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples));
|
||||
ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples), samples);
|
||||
}
|
||||
|
||||
// Build the controls key-binding form dynamically from the registered bindings
|
||||
@@ -198,29 +252,28 @@ void PreferencesDialog::updateUi() {
|
||||
// Graphics
|
||||
const GraphicsSettings& graphics = _model->getGraphics();
|
||||
|
||||
// selectComboValue() leaves the combo alone when nothing carries the value, which covers both
|
||||
// "not chosen" sentinels and a choice this session can't offer (Vulkan on a build without it,
|
||||
// Raytraced on hardware without it). Each combo keeps its first item selected in that case --
|
||||
// and because updateUi() runs under a SignalBlockers guard, that display fallback never fires a
|
||||
// slot and never turns itself into an explicit choice. See GraphicsSettings::backend.
|
||||
selectComboValue(ui->backendCombo, static_cast<int>(graphics.backend));
|
||||
ui->enablePostProcessing->setChecked(graphics.enablePostProcessing);
|
||||
ui->shadowQualityCombo->setCurrentIndex(static_cast<int>(graphics.shadowQuality));
|
||||
ui->aaModeCombo->setCurrentIndex(static_cast<int>(graphics.aaMode));
|
||||
ui->gammaSpin->setValue(graphics.gamma);
|
||||
selectComboValue(ui->shadowQualityCombo, static_cast<int>(graphics.shadowQuality));
|
||||
selectComboValue(ui->shadowMethodCombo, static_cast<int>(graphics.shadowMethod));
|
||||
selectComboValue(ui->aaModeCombo, static_cast<int>(graphics.aaMode));
|
||||
selectComboValue(ui->msaaCombo, graphics.msaaSamples);
|
||||
|
||||
const auto msaaCounts = GraphicsSettings::validMsaaSampleCounts();
|
||||
ui->msaaCombo->setCurrentIndex(
|
||||
static_cast<int>(std::find(msaaCounts.begin(), msaaCounts.end(), graphics.msaaSamples) - msaaCounts.begin()));
|
||||
ui->gammaSpin->setValue(graphics.gamma);
|
||||
|
||||
// Both of these can be "not chosen", in which case the engine picks: trilinear, and the
|
||||
// hardware's maximum anisotropy. Show what will actually be used rather than a blank.
|
||||
ui->textureFilterCombo->setCurrentIndex(
|
||||
selectComboValue(ui->textureFilterCombo,
|
||||
graphics.textureFilter == GraphicsSettings::NO_TEXTURE_FILTER_CHOICE ? 1 : graphics.textureFilter);
|
||||
|
||||
if (!_anisotropyLevels.empty()) {
|
||||
int index = static_cast<int>(_anisotropyLevels.size()) - 1;
|
||||
for (size_t i = 0; i < _anisotropyLevels.size(); ++i) {
|
||||
if (_anisotropyLevels[i] == graphics.anisotropy) {
|
||||
index = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
ui->anisotropyCombo->setCurrentIndex(index);
|
||||
if (ui->anisotropyCombo->count() > 0) {
|
||||
const int level = ui->anisotropyCombo->findData(graphics.anisotropy);
|
||||
ui->anisotropyCombo->setCurrentIndex(level >= 0 ? level : ui->anisotropyCombo->count() - 1);
|
||||
}
|
||||
|
||||
ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents());
|
||||
@@ -305,37 +358,55 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) {
|
||||
_model->setThemeMode(themeModeFromIndex(index));
|
||||
}
|
||||
|
||||
// Every graphics combo carries its value as item data, so each slot reads currentData() rather than
|
||||
// mapping from the index. The fallbacks below are only reached if a slot somehow fires while the
|
||||
// combo has no selection, in which case leaving the setting as it was is the right answer.
|
||||
void PreferencesDialog::on_backendCombo_currentIndexChanged(int /*index*/) {
|
||||
const auto backend = static_cast<GraphicsAPI>(
|
||||
currentComboValue(ui->backendCombo, static_cast<int>(_model->getGraphics().backend)));
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.backend = backend; });
|
||||
}
|
||||
|
||||
void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) {
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.enablePostProcessing = checked; });
|
||||
}
|
||||
|
||||
void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) {
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = static_cast<ShadowQuality>(index); });
|
||||
void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int /*index*/) {
|
||||
const auto quality = static_cast<ShadowQuality>(
|
||||
currentComboValue(ui->shadowQualityCombo, static_cast<int>(_model->getGraphics().shadowQuality)));
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = quality; });
|
||||
}
|
||||
|
||||
void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) {
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.aaMode = static_cast<AntiAliasMode>(index); });
|
||||
void PreferencesDialog::on_shadowMethodCombo_currentIndexChanged(int /*index*/) {
|
||||
const auto method = static_cast<ShadowRenderMethod>(
|
||||
currentComboValue(ui->shadowMethodCombo, static_cast<int>(_model->getGraphics().shadowMethod)));
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowMethod = method; });
|
||||
}
|
||||
|
||||
void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) {
|
||||
const auto counts = GraphicsSettings::validMsaaSampleCounts();
|
||||
if (index < 0 || static_cast<size_t>(index) >= counts.size()) {
|
||||
void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int /*index*/) {
|
||||
const auto mode = static_cast<AntiAliasMode>(
|
||||
currentComboValue(ui->aaModeCombo, static_cast<int>(_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;
|
||||
}
|
||||
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.msaaSamples = counts[index]; });
|
||||
}
|
||||
|
||||
void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int index) {
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = index; });
|
||||
}
|
||||
|
||||
void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) {
|
||||
if (index < 0 || static_cast<size_t>(index) >= _anisotropyLevels.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = _anisotropyLevels[index]; });
|
||||
const float level = selected.toFloat();
|
||||
editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = level; });
|
||||
}
|
||||
|
||||
void PreferencesDialog::on_gammaSpin_valueChanged(double value) {
|
||||
|
||||
@@ -36,8 +36,10 @@ private slots:
|
||||
void on_labelFontScaleSpin_valueChanged(double value);
|
||||
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);
|
||||
@@ -74,8 +76,6 @@ private: // NOLINT(readability-redundant-access-specifiers)
|
||||
|
||||
std::unique_ptr<Ui::PreferencesDialog> ui;
|
||||
std::unique_ptr<PreferencesDialogModel> _model;
|
||||
//! Anisotropy levels backing the combo, in combo order. Queried once; hardware-dependent.
|
||||
SCP_vector<float> _anisotropyLevels;
|
||||
std::map<ControlAction, QKeySequenceEdit*> _controlEditors;
|
||||
FredView* _fredView = nullptr;
|
||||
EditorViewport* _viewport = nullptr;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
#include <QPaintEvent>
|
||||
#include <QOffscreenSurface>
|
||||
#include <QtGui/QOpenGLContext>
|
||||
#include <QtGui/QOpenGLFunctions>
|
||||
|
||||
#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();
|
||||
// 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 (context == nullptr) {
|
||||
if (!_loggedNoContext) {
|
||||
mprintf(("BriefingMapWidget: no current OpenGL context after gr_use_viewport().\n"));
|
||||
_loggedNoContext = true;
|
||||
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<int>(mainSize.first) * devicePixelRatio(),
|
||||
static_cast<int>(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<int>(std::lround(mainSize.first * devicePixelRatio())),
|
||||
static_cast<int>(std::lround(mainSize.second * devicePixelRatio())));
|
||||
g3_start_frame(0);
|
||||
g3_set_view_matrix(&_viewport->camera.eye_pos, &_viewport->camera.eye_orient, 0.5f);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,21 @@ public:
|
||||
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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -404,6 +404,29 @@
|
||||
<string>Graphics</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="graphicsLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="rendererGroup">
|
||||
<property name="title">
|
||||
<string>Renderer</string>
|
||||
</property>
|
||||
<layout class="QFormLayout" name="rendererLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="backendLabel">
|
||||
<property name="text">
|
||||
<string>Backend:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QComboBox" name="backendCombo">
|
||||
<property name="toolTip">
|
||||
<string>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.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="enablePostProcessing">
|
||||
<property name="toolTip">
|
||||
@@ -435,27 +458,41 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="shadowMethodLabel">
|
||||
<property name="text">
|
||||
<string>Shadow method:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QComboBox" name="shadowMethodCombo">
|
||||
<property name="toolTip">
|
||||
<string>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.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="aaModeLabel">
|
||||
<property name="text">
|
||||
<string>Anti-aliasing:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<item row="2" column="1">
|
||||
<widget class="QComboBox" name="aaModeCombo">
|
||||
<property name="toolTip">
|
||||
<string>Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled.</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="msaaLabel">
|
||||
<property name="text">
|
||||
<string>MSAA:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<item row="3" column="1">
|
||||
<widget class="QComboBox" name="msaaCombo">
|
||||
<property name="toolTip">
|
||||
<string>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.</string>
|
||||
@@ -518,7 +555,7 @@
|
||||
<item row="0" column="1">
|
||||
<widget class="QDoubleSpinBox" name="gammaSpin">
|
||||
<property name="toolTip">
|
||||
<string>Brightness of the viewport. qtFRED defaults this higher than the game since the viewport isn't normally tonemapped.</string>
|
||||
<string>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.</string>
|
||||
</property>
|
||||
<property name="decimals">
|
||||
<number>2</number>
|
||||
|
||||
Reference in new issue
Block a user