mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-18 06:01:24 +02:00
Co-authored-by: Lydacious <81742991+lydacious@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3002 lines
147 KiB
C++
3002 lines
147 KiB
C++
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
|
// Tiled addressing follows Xenia's gpu/texture_address.h (BSD license).
|
|
#include <stdafx.h>
|
|
#include "presenter.h"
|
|
|
|
#include <kernel/vmem.h>
|
|
#include <kernel/dispatcher.h>
|
|
#include <hid/input.h>
|
|
#include <apu/audio.h>
|
|
#include <gpu/renderer.h>
|
|
#include <game/ultrawide.h>
|
|
#include "gpu_choice.h"
|
|
#include "picture_fit.h"
|
|
#include "frame_rate.h"
|
|
#include "swapchain_policy.h"
|
|
#include <cpu/guest_thread.h>
|
|
#include <cpu/guest_time.h>
|
|
#include "vblank_lock.h"
|
|
#include <ui/ui.h>
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
#include <platform/ios_ui.h>
|
|
#endif
|
|
#include <user/settings.h>
|
|
#include <report/report.h>
|
|
#include <platform/thermal.h>
|
|
#include <kernel/memory_status.h>
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <SDL3/SDL_vulkan.h>
|
|
|
|
#include <algorithm>
|
|
#include <condition_variable>
|
|
#include <deque>
|
|
#include <fstream>
|
|
#include <utility>
|
|
#include <vulkan/vulkan.h>
|
|
|
|
namespace video
|
|
{
|
|
namespace
|
|
{
|
|
constexpr uint32_t FRAMES_IN_FLIGHT = 2;
|
|
|
|
SDL_Window* s_window = nullptr;
|
|
VkInstance s_instance = VK_NULL_HANDLE;
|
|
VkSurfaceKHR s_surface = VK_NULL_HANDLE;
|
|
VkPhysicalDevice s_physical = VK_NULL_HANDLE;
|
|
VkDevice s_device = VK_NULL_HANDLE;
|
|
uint32_t s_queueFamily = 0;
|
|
VkQueue s_queue = VK_NULL_HANDLE;
|
|
|
|
VkSwapchainKHR s_swapchain = VK_NULL_HANDLE;
|
|
VkFormat s_swapFormat = VK_FORMAT_B8G8R8A8_UNORM;
|
|
VkExtent2D s_swapExtent{};
|
|
// The last swapchain's extent (width << 32 | height), for FrameRegion
|
|
// on other threads: the renderer's Auto internal resolution follows it.
|
|
std::atomic<uint64_t> s_regionExtent{ 0 };
|
|
std::vector<VkImage> s_swapImages;
|
|
std::vector<VkImageView> s_swapViews; // for the post-processing pass
|
|
bool s_swapReadable = false;
|
|
// The present scale (iOS): Settings > Display > Screen Resolution's
|
|
// 100% or 75%, or NFSMW_PRESENT_SCALE=<0.5..1> (an experiment for
|
|
// phones, which wins). The drawables at that fraction of the screen's
|
|
// pixels each way, which Core Animation scales up to the screen: the
|
|
// scale pass and the UI draw (and store) fewer pixels. That upscale's
|
|
// cost, if the GPU composites it, is outside this app's GPU timers,
|
|
// and the picture is resampled twice: judge it by late frames, fps
|
|
// and a look on the phone, not post-processing ms. A change applies
|
|
// live, as V-Sync's does: a new swapchain between two frames, with
|
|
// the view's scale set first (CreateSwapchain). Main thread.
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
const float s_envPresentScale = [] {
|
|
const char* v = std::getenv("NFSMW_PRESENT_SCALE");
|
|
float s = v ? std::strtof(v, nullptr) : 0.0f;
|
|
return s >= 0.5f && s <= 1.0f ? s : 0.0f;
|
|
}();
|
|
float s_presentScale = 1.0f;
|
|
// The view's scale was changed from the screen's: from then on each
|
|
// swapchain sets it, at 1 too (back to the screen's own).
|
|
bool s_viewScaled = false;
|
|
|
|
float WantedPresentScale()
|
|
{
|
|
return s_envPresentScale > 0.0f ? s_envPresentScale : float(settings::GetInt(settings::Id::PresentScale)) / 100.0f;
|
|
}
|
|
#else
|
|
const float s_presentScale = 1.0f;
|
|
#endif
|
|
|
|
VkCommandPool s_commandPool = VK_NULL_HANDLE;
|
|
struct Frame
|
|
{
|
|
VkCommandBuffer cmd = VK_NULL_HANDLE;
|
|
VkSemaphore imageAvailable = VK_NULL_HANDLE;
|
|
VkFence inFlight = VK_NULL_HANDLE;
|
|
VkBuffer staging = VK_NULL_HANDLE;
|
|
VkDeviceMemory stagingMemory = VK_NULL_HANDLE;
|
|
void* stagingMapped = nullptr;
|
|
VkDeviceSize stagingSize = 0;
|
|
VkImageView sourceView = VK_NULL_HANDLE; // this frame's view of the presented image
|
|
bool timed = false; // wrote its post-processing timestamps
|
|
} s_frames[FRAMES_IN_FLIGHT];
|
|
// NFSMW_GPU_TIMING=1: 3 timestamps per frame slot around the
|
|
// post-processing (before, after AA, after the scale and UI pass).
|
|
VkQueryPool s_timestamps = VK_NULL_HANDLE;
|
|
double s_timestampPeriod = 1.0;
|
|
std::atomic<uint64_t> s_postFrames{ 0 }, s_postAaNs{ 0 }, s_postNs{ 0 };
|
|
std::vector<VkSemaphore> s_renderDone; // one per swapchain image
|
|
std::mutex s_queueMutex;
|
|
VulkanContext s_context{};
|
|
bool s_contextReady = false;
|
|
uint32_t s_frameIndex = 0;
|
|
|
|
// The source image the front buffer is copied into before scaling.
|
|
VkImage s_source = VK_NULL_HANDLE;
|
|
VkDeviceMemory s_sourceMemory = VK_NULL_HANDLE;
|
|
uint32_t s_sourceWidth = 0, s_sourceHeight = 0;
|
|
|
|
// Latest front buffer from the command processor, as BGRA8.
|
|
std::mutex s_pendingMutex;
|
|
std::vector<uint32_t> s_pending;
|
|
uint32_t s_pendingWidth = 0, s_pendingHeight = 0;
|
|
uint32_t s_pendingAspectW = 0, s_pendingAspectH = 0; // the frame's shape (0: the image's)
|
|
bool s_pendingNew = false;
|
|
// Or a GPU image from the renderer (VK_IMAGE_LAYOUT_GENERAL, written by
|
|
// an earlier queue submission).
|
|
VkImage s_pendingImage = VK_NULL_HANDLE;
|
|
int64_t s_pendingVblank = 0; // the guest vblank the frame followed
|
|
std::atomic<uint64_t> s_dropped{ 0 }; // frames replaced here before they were shown
|
|
std::atomic<uint64_t> s_repeated{ 0 }, s_shown{ 0 }; // see PresentStats
|
|
std::atomic<int64_t> s_guestVblank{ 0 };
|
|
std::atomic<uint64_t> s_guestVblanks{ 0 };
|
|
|
|
// The last frame shown, drawn again under the UI while the game
|
|
// presents nothing new (null image: the CPU path's, in s_source).
|
|
// Main thread, under s_frontUseMutex.
|
|
VkImage s_lastImage = VK_NULL_HANDLE;
|
|
uint32_t s_lastWidth = 0, s_lastHeight = 0, s_lastAspectW = 0, s_lastAspectH = 0;
|
|
// Held from taking the renderer's image until the frame using it is
|
|
// submitted, so ReleaseFrontImage can make it safe to destroy.
|
|
std::mutex s_frontUseMutex;
|
|
std::chrono::steady_clock::time_point s_lastPresent{};
|
|
std::chrono::steady_clock::time_point s_lastGameFrame{};
|
|
|
|
bool s_uiReady = false;
|
|
// The view scales the last new frame was rendered with (Hor+ or Vert+;
|
|
// 1, 1: 16:9, letterboxed): a redraw keeps its picture's shape.
|
|
float s_lastScaleX = 1.0f, s_lastScaleY = 1.0f;
|
|
bool s_lastFreshImage = false; // the frame being drawn is a new GPU frame
|
|
std::string s_resolutionHint; // Game Mode below the monitor's resolution
|
|
std::atomic<bool> s_focused{ true };
|
|
std::atomic<bool> s_visible{ true }; // not minimized, hidden or covered
|
|
// The app is inactive or in the background (iOS, and other systems
|
|
// that send SDL's app events): no GPU work may start. Changed under
|
|
// s_queueMutex (WaitForeground waits on s_foreground for it); RunFrame
|
|
// reads it without the lock.
|
|
std::atomic<bool> s_background{ false };
|
|
std::condition_variable s_foreground;
|
|
bool SDLCALL OnAppEvent(void*, SDL_Event* e);
|
|
// Main thread: RunFrame stopped the controller's rumble for the
|
|
// suspension (hid::Suspend).
|
|
bool s_inputSuspended = false;
|
|
// When the swapchain is built again, and at what size (main thread;
|
|
// swapchain_policy.h): not while the app is inactive, the surface
|
|
// watched after a resume, a size event or a new swapchain,
|
|
// SUBOPTIMAL/OUT_OF_DATE followed, SDL's view fitted back to its
|
|
// window on iOS, and no portrait swapchain on a landscape iPhone.
|
|
SwapchainPolicy s_swapPolicy;
|
|
// What the next CreateSwapchain is for (its log line), and how many
|
|
// rebuilds at an unchanged size have been logged.
|
|
const char* s_swapReason = nullptr;
|
|
int s_sameSizeLogged = 0;
|
|
// Bug-report screenshots waiting for the next frame drawn (CaptureFrame).
|
|
std::mutex s_captureMutex;
|
|
std::vector<std::promise<CapturedFrame>> s_captureRequests;
|
|
// Window settings as applied (settings::Generation() tells when to look again).
|
|
uint64_t s_settingsGeneration = ~0ull;
|
|
bool s_fullscreen = false; // the window's state
|
|
bool s_fullscreenSetting = false; // the setting as last applied
|
|
// SteamOS Game Mode: gamescope gives the game a screen of the size
|
|
// Steam chooses and resizes only fullscreen windows to it, so the
|
|
// window is always fullscreen there (not saved: Desktop Mode keeps
|
|
// its own preference in the same settings.toml).
|
|
bool s_gameMode = false;
|
|
|
|
bool DetectGameMode()
|
|
{
|
|
if (const char* v = std::getenv("NFSMW_GAME_MODE"))
|
|
return v[0] == '1';
|
|
const char* wayland = std::getenv("GAMESCOPE_WAYLAND_DISPLAY");
|
|
const char* desktop = std::getenv("XDG_CURRENT_DESKTOP");
|
|
return (wayland && *wayland) || (desktop && strcmp(desktop, "gamescope") == 0);
|
|
}
|
|
|
|
// The connected monitor's preferred (native) mode, from the kernel:
|
|
// under gamescope, SDL only sees the size Steam gave the game.
|
|
// (The largest preferred mode of the connected, enabled displays,
|
|
// external ones first: a Steam Deck docked to a monitor has both.)
|
|
std::pair<int, int> NativeMonitorMode()
|
|
{
|
|
std::pair<int, int> best{ 0, 0 };
|
|
#ifdef __linux__
|
|
bool bestExternal = false;
|
|
std::error_code ec;
|
|
for (const auto& entry : std::filesystem::directory_iterator("/sys/class/drm", ec))
|
|
{
|
|
std::ifstream status(entry.path() / "status"), enabled(entry.path() / "enabled");
|
|
std::string state, on;
|
|
if (!(status >> state) || state != "connected" || !(enabled >> on) || on != "enabled")
|
|
continue;
|
|
std::ifstream modes(entry.path() / "modes");
|
|
std::string mode;
|
|
int w = 0, h = 0;
|
|
if (!(modes >> mode) || sscanf(mode.c_str(), "%dx%d", &w, &h) != 2)
|
|
continue;
|
|
bool external = entry.path().filename().string().find("eDP") == std::string::npos;
|
|
if ((external && !bestExternal) || (external == bestExternal && w * h > best.first * best.second))
|
|
{
|
|
best = { w, h };
|
|
bestExternal = external;
|
|
}
|
|
}
|
|
#endif
|
|
return best;
|
|
}
|
|
bool s_vsync = true;
|
|
|
|
// vkDeviceWaitIdle needs every queue externally synchronised; with one
|
|
// shared queue, wait on it under the queue mutex.
|
|
void WaitIdle()
|
|
{
|
|
std::lock_guard lock(s_queueMutex);
|
|
vkQueueWaitIdle(s_queue);
|
|
}
|
|
|
|
bool Check(VkResult r, const char* what)
|
|
{
|
|
if (r != VK_SUCCESS)
|
|
fprintf(stderr, "[video] %s failed: %d\n", what, int(r));
|
|
return r == VK_SUCCESS;
|
|
}
|
|
|
|
uint32_t FindMemoryType(uint32_t bits, VkMemoryPropertyFlags flags)
|
|
{
|
|
VkPhysicalDeviceMemoryProperties props;
|
|
vkGetPhysicalDeviceMemoryProperties(s_physical, &props);
|
|
for (uint32_t i = 0; i < props.memoryTypeCount; i++)
|
|
if ((bits & (1u << i)) && (props.memoryTypes[i].propertyFlags & flags) == flags)
|
|
return i;
|
|
return UINT32_MAX;
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Present timing. With VK_KHR_present_id and VK_KHR_present_wait a
|
|
// thread waits for each present to reach the screen and timestamps
|
|
// it. The times measure the display's refresh (RefreshFit), which the
|
|
// guest vblank locks to (GuestVblank), and show a game frame kept on
|
|
// screen for more refreshes than the game gave it vblanks (repeated).
|
|
// The waits run beside presents to the same swapchain, as the
|
|
// extension is used: the WSIs synchronise the two internally (Mesa's,
|
|
// MoltenVK's), though the spec leaves that to the caller. Destroying
|
|
// a swapchain waits until no wait is on it.
|
|
bool s_presentTiming = false;
|
|
PFN_vkWaitForPresentKHR s_waitForPresent = nullptr;
|
|
uint64_t s_presentId = 0; // main thread: the last present's
|
|
struct PresentTiming
|
|
{
|
|
uint64_t id;
|
|
VkSwapchainKHR swapchain;
|
|
int64_t presented; // vkQueuePresentKHR (ns, steady clock)
|
|
int64_t vblank; // a new game frame: the guest vblank it followed; 0: a redraw
|
|
};
|
|
// Never destroyed: the waiter is detached and blocks on them, and
|
|
// glibc's pthread_cond_destroy waits for blocked waiters, so an exit
|
|
// through main's return (no xex, a cancelled install) would hang.
|
|
std::mutex& s_timingMutex = *new std::mutex;
|
|
std::condition_variable& s_timingCv = *new std::condition_variable;
|
|
std::deque<PresentTiming>& s_timings = *new std::deque<PresentTiming>; // presented, not yet seen on screen
|
|
std::mutex s_waitSwapchainMutex; // held by the waiter while it waits
|
|
VkSwapchainKHR s_waitSwapchain = VK_NULL_HANDLE; // the one it may wait on
|
|
FILE* s_timingLog = nullptr; // NFSMW_VBLANK_LOG=<file>: one CSV line per present
|
|
|
|
int64_t NowNs()
|
|
{
|
|
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
// Waiter thread: a present was on screen at `onScreen`. `timed`: the
|
|
// wait was under way when it happened (not caught up on afterwards).
|
|
void OnScreen(const PresentTiming& t, int64_t onScreen, bool timed)
|
|
{
|
|
static RefreshFit& refresh = *new RefreshFit; // never destroyed either (the waiter may be in it at exit)
|
|
static int64_t lastOnScreen = 0, lastVblank = 0;
|
|
VblankLock& vblank = GuestVblank();
|
|
if (timed)
|
|
{
|
|
refresh.Add(onScreen);
|
|
vblank.SetDisplay(refresh.Estimate());
|
|
}
|
|
const RefreshEstimate& display = refresh.Estimate();
|
|
if (s_timingLog)
|
|
fprintf(s_timingLog, "%llu,%lld,%lld,%lld,%d,%d,%.1f,%lld,%.0f\n", (unsigned long long)t.id, (long long)t.vblank,
|
|
(long long)t.presented, (long long)onScreen, timed, display.valid, display.periodNs, (long long)display.gridNs,
|
|
display.rmsNs);
|
|
if (!t.vblank)
|
|
return;
|
|
s_shown++;
|
|
// A frame from before the last resume spent the suspension on
|
|
// screen: neither a repeat nor a latency to learn from.
|
|
if (!timed || t.vblank < guesttime::LastResumeNs())
|
|
{
|
|
lastOnScreen = 0;
|
|
return;
|
|
}
|
|
if (lastOnScreen)
|
|
{
|
|
// The last frame's time on screen, against the vblanks the
|
|
// game gave it (a gap of more than 0.5 s is a pause, not a repeat).
|
|
double refreshNs = display.valid ? display.periodNs : 1e9 / 60.0;
|
|
int64_t shownFor = std::llround(double(onScreen - lastOnScreen) / refreshNs);
|
|
int64_t vblanks = std::max<int64_t>(1, std::llround(double(t.vblank - lastVblank) / vblank.PeriodNs()));
|
|
if (shownFor > vblanks && shownFor <= 30)
|
|
s_repeated += uint64_t(shownFor - vblanks);
|
|
}
|
|
lastOnScreen = onScreen;
|
|
lastVblank = t.vblank;
|
|
vblank.Observe(t.vblank, t.presented, onScreen);
|
|
}
|
|
|
|
void PresentWaiter()
|
|
{
|
|
SetHostThreadName("nfsmw-present");
|
|
while (true)
|
|
{
|
|
PresentTiming t;
|
|
{
|
|
std::unique_lock lock(s_timingMutex);
|
|
s_timingCv.wait(lock, [] { return !s_timings.empty(); });
|
|
t = s_timings.front();
|
|
}
|
|
int64_t start = NowNs();
|
|
VkResult r = VK_ERROR_OUT_OF_DATE_KHR;
|
|
{
|
|
std::lock_guard lock(s_waitSwapchainMutex);
|
|
if (t.swapchain == s_waitSwapchain)
|
|
r = s_waitForPresent(s_device, t.swapchain, t.id, 50'000'000);
|
|
}
|
|
int64_t now = NowNs();
|
|
{
|
|
std::lock_guard lock(s_timingMutex);
|
|
// Not on screen yet: wait again. A hidden window may show
|
|
// nothing for a long time; past 8 waiting, drop the oldest.
|
|
if (r == VK_TIMEOUT && s_timings.size() <= 8)
|
|
continue;
|
|
if (!s_timings.empty() && s_timings.front().id == t.id)
|
|
s_timings.pop_front();
|
|
}
|
|
if (r == VK_SUCCESS || r == VK_SUBOPTIMAL_KHR)
|
|
OnScreen(t, now, now - start > 50'000);
|
|
}
|
|
}
|
|
|
|
// The surface's extent: Vulkan's current extent, else (Wayland: the
|
|
// swapchain decides) the window's pixels.
|
|
Extent SurfaceExtent(const VkSurfaceCapabilitiesKHR& caps)
|
|
{
|
|
if (caps.currentExtent.width != UINT32_MAX)
|
|
return { caps.currentExtent.width, caps.currentExtent.height };
|
|
int w = 0, h = 0;
|
|
SDL_GetWindowSizeInPixels(s_window, &w, &h);
|
|
return { uint32_t(std::max(w, 0)), uint32_t(std::max(h, 0)) };
|
|
}
|
|
|
|
Extent SurfaceExtent()
|
|
{
|
|
VkSurfaceCapabilitiesKHR caps{};
|
|
if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(s_physical, s_surface, &caps) != VK_SUCCESS)
|
|
return {};
|
|
return SurfaceExtent(caps);
|
|
}
|
|
|
|
// The interface's orientation and whether the app is landscape-only
|
|
// there: iOS's (an iPhone is), unknown and no elsewhere.
|
|
Orientation InterfaceNow()
|
|
{
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
return platform::ios::InterfaceOrientation(s_window);
|
|
#else
|
|
return Orientation::Unknown;
|
|
#endif
|
|
}
|
|
|
|
bool LandscapeOnlyHere()
|
|
{
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
return platform::ios::LandscapeOnly();
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
// iOS: the view, its window and the scene, for a log line ("" elsewhere).
|
|
std::string GeometryNote()
|
|
{
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
return ": " + platform::ios::DescribeGeometry(s_window);
|
|
#else
|
|
return {};
|
|
#endif
|
|
}
|
|
|
|
void UpdateDisplayItem();
|
|
|
|
void DestroySwapchain()
|
|
{
|
|
{
|
|
std::lock_guard wait(s_waitSwapchainMutex); // the waiter is off it after this
|
|
s_waitSwapchain = VK_NULL_HANDLE;
|
|
std::lock_guard lock(s_timingMutex);
|
|
std::erase_if(s_timings, [](const PresentTiming& t) { return t.swapchain == s_swapchain; });
|
|
}
|
|
for (VkSemaphore s : s_renderDone)
|
|
vkDestroySemaphore(s_device, s, nullptr);
|
|
s_renderDone.clear();
|
|
for (VkImageView v : s_swapViews)
|
|
vkDestroyImageView(s_device, v, nullptr);
|
|
s_swapViews.clear();
|
|
if (s_swapchain)
|
|
vkDestroySwapchainKHR(s_device, s_swapchain, nullptr);
|
|
s_swapchain = VK_NULL_HANDLE;
|
|
s_swapImages.clear();
|
|
}
|
|
|
|
bool CreateSwapchain()
|
|
{
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
// The present scale: the view's bounds may have changed since its
|
|
// scale was snapped, or the scale itself (before the surface's
|
|
// extent is read).
|
|
if ((s_presentScale < 1.0f || s_viewScaled) && platform::ios::SetDrawableScale(s_window, s_presentScale))
|
|
s_viewScaled = true;
|
|
#endif
|
|
VkSurfaceCapabilitiesKHR caps;
|
|
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(s_physical, s_surface, &caps);
|
|
const VkExtent2D previous = s_swapExtent;
|
|
// A landscape-only iPhone takes no portrait swapchain under a
|
|
// landscape interface (swapchain_policy.h): the same size turned,
|
|
// while the view is fitted back to its window.
|
|
const Extent surface = SurfaceExtent(caps);
|
|
const ExtentChoice choice = ChooseExtent(surface, InterfaceNow(), LandscapeOnlyHere());
|
|
s_swapExtent = { choice.extent.width, choice.extent.height };
|
|
if (s_swapExtent.width == 0 || s_swapExtent.height == 0)
|
|
return false; // minimised
|
|
if (choice.refused)
|
|
fprintf(stderr, "[video] the surface is %ux%u, portrait, under a landscape interface: the swapchain is %ux%u instead\n",
|
|
surface.width, surface.height, s_swapExtent.width, s_swapExtent.height);
|
|
s_regionExtent.store(uint64_t(s_swapExtent.width) << 32 | s_swapExtent.height, std::memory_order_relaxed);
|
|
// The UI lays out at and draws to the swapchain's size (the
|
|
// drawables' with iOS's present scale), not the window's pixels.
|
|
ui::SetFramebufferSize(s_swapExtent.width, s_swapExtent.height);
|
|
|
|
uint32_t count = 0;
|
|
vkGetPhysicalDeviceSurfaceFormatsKHR(s_physical, s_surface, &count, nullptr);
|
|
std::vector<VkSurfaceFormatKHR> formats(count);
|
|
vkGetPhysicalDeviceSurfaceFormatsKHR(s_physical, s_surface, &count, formats.data());
|
|
// B8G8R8A8_UNORM in plain sRGB: the picture is sRGB-encoded.
|
|
// MoltenVK lists each format in every color space it knows (asked
|
|
// for VK_EXT_swapchain_colorspace or not), and the last
|
|
// B8G8R8A8_UNORM entry is Display P3 *linear*: the layer showed
|
|
// the picture's values as linear light, washed out and pale
|
|
// (captures of the swapchain looked right: its values were).
|
|
auto pick = [&](VkFormat format) {
|
|
return std::find_if(formats.begin(), formats.end(), [&](const VkSurfaceFormatKHR& f) {
|
|
return (format == VK_FORMAT_UNDEFINED || f.format == format) && f.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR;
|
|
});
|
|
};
|
|
auto chosen = pick(VK_FORMAT_B8G8R8A8_UNORM);
|
|
if (chosen == formats.end())
|
|
chosen = pick(VK_FORMAT_UNDEFINED);
|
|
if (chosen == formats.end())
|
|
chosen = formats.begin();
|
|
s_swapFormat = chosen->format;
|
|
VkColorSpaceKHR colorSpace = chosen->colorSpace;
|
|
static bool formatLogged = false;
|
|
if (!std::exchange(formatLogged, true))
|
|
fprintf(stderr, "[video] swapchain format %d, color space %d (%zu offered)\n", int(s_swapFormat), int(colorSpace), formats.size());
|
|
|
|
VkSwapchainCreateInfoKHR info{ VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR };
|
|
info.surface = s_surface;
|
|
#ifdef __APPLE__
|
|
// MoltenVK takes the CAMetalLayer drawable inside vkQueuePresentKHR,
|
|
// which holds the queue mutex: with 2 drawables (its minimum) that
|
|
// waited for the display to free one, and the command processor
|
|
// waited behind it (on an iPhone 86% of its time, "queue lock" in
|
|
// NFSMW_LOG_LATENCY). Linux drivers ask for 3 themselves.
|
|
info.minImageCount = std::max(caps.minImageCount, 3u);
|
|
#else
|
|
info.minImageCount = std::max(caps.minImageCount, 2u);
|
|
#endif
|
|
if (caps.maxImageCount)
|
|
info.minImageCount = std::min(info.minImageCount, caps.maxImageCount);
|
|
info.imageFormat = s_swapFormat;
|
|
info.imageColorSpace = colorSpace;
|
|
info.imageExtent = s_swapExtent;
|
|
info.imageArrayLayers = 1;
|
|
info.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |
|
|
(caps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT); // NFSMW_CHECK_PRESENT_SEC captures
|
|
s_swapReadable = (caps.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT) != 0;
|
|
info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
|
info.preTransform = caps.currentTransform;
|
|
info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
|
|
// V-Sync off: mailbox (no tearing, newest frame) where there is
|
|
// one, else immediate.
|
|
info.presentMode = VK_PRESENT_MODE_FIFO_KHR;
|
|
if (!s_vsync)
|
|
{
|
|
uint32_t modeCount = 0;
|
|
vkGetPhysicalDeviceSurfacePresentModesKHR(s_physical, s_surface, &modeCount, nullptr);
|
|
std::vector<VkPresentModeKHR> modes(modeCount);
|
|
vkGetPhysicalDeviceSurfacePresentModesKHR(s_physical, s_surface, &modeCount, modes.data());
|
|
for (VkPresentModeKHR wanted : { VK_PRESENT_MODE_MAILBOX_KHR, VK_PRESENT_MODE_IMMEDIATE_KHR })
|
|
if (std::find(modes.begin(), modes.end(), wanted) != modes.end())
|
|
{
|
|
info.presentMode = wanted;
|
|
break;
|
|
}
|
|
}
|
|
info.clipped = VK_TRUE;
|
|
if (!Check(vkCreateSwapchainKHR(s_device, &info, nullptr, &s_swapchain), "vkCreateSwapchainKHR"))
|
|
return false;
|
|
{
|
|
// Once per change: the V-Sync setting's effect, in the log.
|
|
static int logged = -1;
|
|
if (logged != int(info.presentMode) * 2 + s_vsync)
|
|
{
|
|
logged = int(info.presentMode) * 2 + s_vsync;
|
|
const char* mode = info.presentMode == VK_PRESENT_MODE_FIFO_KHR ? "FIFO"
|
|
: info.presentMode == VK_PRESENT_MODE_MAILBOX_KHR ? "MAILBOX"
|
|
: "IMMEDIATE";
|
|
fprintf(stderr, "[video] present mode %s (V-Sync %s%s)\n", mode, s_vsync ? "on" : "off",
|
|
!s_vsync && info.presentMode == VK_PRESENT_MODE_FIFO_KHR ? "; the surface offers nothing else" : "");
|
|
}
|
|
}
|
|
{
|
|
std::lock_guard lock(s_waitSwapchainMutex);
|
|
s_waitSwapchain = s_swapchain;
|
|
}
|
|
if (s_uiReady)
|
|
ui::SetFormat(s_swapFormat);
|
|
s_swapPolicy.Created({ s_swapExtent.width, s_swapExtent.height });
|
|
static VkExtent2D logged{};
|
|
const char* reason = std::exchange(s_swapReason, nullptr);
|
|
if (logged.width != s_swapExtent.width || logged.height != s_swapExtent.height)
|
|
{
|
|
if (logged.width) // the first one is logged with the device
|
|
{
|
|
fprintf(stderr, "[video] swapchain %ux%u (was %ux%u%s%s)%s\n", s_swapExtent.width, s_swapExtent.height, logged.width,
|
|
logged.height, reason ? "; " : "", reason ? reason : "", GeometryNote().c_str());
|
|
UpdateDisplayItem();
|
|
}
|
|
logged = s_swapExtent;
|
|
}
|
|
else if (reason && previous.width && s_sameSizeLogged < 10)
|
|
{
|
|
s_sameSizeLogged++;
|
|
fprintf(stderr, "[video] swapchain %ux%u again (%s)%s\n", s_swapExtent.width, s_swapExtent.height, reason,
|
|
s_sameSizeLogged == 10 ? "; no more same-size rebuilds logged" : "");
|
|
}
|
|
|
|
vkGetSwapchainImagesKHR(s_device, s_swapchain, &count, nullptr);
|
|
s_swapImages.resize(count);
|
|
vkGetSwapchainImagesKHR(s_device, s_swapchain, &count, s_swapImages.data());
|
|
s_renderDone.resize(count);
|
|
VkSemaphoreCreateInfo si{ VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO };
|
|
for (auto& s : s_renderDone)
|
|
vkCreateSemaphore(s_device, &si, nullptr, &s);
|
|
s_swapViews.resize(count);
|
|
for (uint32_t i = 0; i < count; i++)
|
|
{
|
|
VkImageViewCreateInfo vci{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO };
|
|
vci.image = s_swapImages[i];
|
|
vci.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
|
vci.format = s_swapFormat;
|
|
vci.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
|
vkCreateImageView(s_device, &vci, nullptr, &s_swapViews[i]);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool EnsureSource(uint32_t width, uint32_t height)
|
|
{
|
|
if (s_source && s_sourceWidth == width && s_sourceHeight == height)
|
|
return true;
|
|
WaitIdle();
|
|
if (s_source)
|
|
{
|
|
vkDestroyImage(s_device, s_source, nullptr);
|
|
vkFreeMemory(s_device, s_sourceMemory, nullptr);
|
|
}
|
|
VkImageCreateInfo ii{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
|
|
ii.imageType = VK_IMAGE_TYPE_2D;
|
|
ii.format = VK_FORMAT_B8G8R8A8_UNORM;
|
|
ii.extent = { width, height, 1 };
|
|
ii.mipLevels = 1;
|
|
ii.arrayLayers = 1;
|
|
ii.samples = VK_SAMPLE_COUNT_1_BIT;
|
|
ii.tiling = VK_IMAGE_TILING_OPTIMAL;
|
|
ii.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
|
|
ii.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
|
|
if (!Check(vkCreateImage(s_device, &ii, nullptr, &s_source), "vkCreateImage"))
|
|
return false;
|
|
VkMemoryRequirements req;
|
|
vkGetImageMemoryRequirements(s_device, s_source, &req);
|
|
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
|
ai.allocationSize = req.size;
|
|
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
vkAllocateMemory(s_device, &ai, nullptr, &s_sourceMemory);
|
|
vkBindImageMemory(s_device, s_source, s_sourceMemory, 0);
|
|
s_sourceWidth = width;
|
|
s_sourceHeight = height;
|
|
return true;
|
|
}
|
|
|
|
bool EnsureStaging(Frame& f, VkDeviceSize size)
|
|
{
|
|
if (f.staging && f.stagingSize >= size)
|
|
return true;
|
|
if (f.staging)
|
|
{
|
|
vkDestroyBuffer(s_device, f.staging, nullptr);
|
|
vkFreeMemory(s_device, f.stagingMemory, nullptr);
|
|
}
|
|
VkBufferCreateInfo bi{ VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
|
|
bi.size = size;
|
|
bi.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
|
|
vkCreateBuffer(s_device, &bi, nullptr, &f.staging);
|
|
VkMemoryRequirements req;
|
|
vkGetBufferMemoryRequirements(s_device, f.staging, &req);
|
|
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
|
ai.allocationSize = req.size;
|
|
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits,
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
|
|
vkAllocateMemory(s_device, &ai, nullptr, &f.stagingMemory);
|
|
vkBindBufferMemory(s_device, f.staging, f.stagingMemory, 0);
|
|
vkMapMemory(s_device, f.stagingMemory, 0, size, 0, &f.stagingMapped);
|
|
f.stagingSize = size;
|
|
return true;
|
|
}
|
|
|
|
void Barrier(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkImageLayout to,
|
|
VkAccessFlags srcAccess, VkAccessFlags dstAccess, VkPipelineStageFlags srcStage, VkPipelineStageFlags dstStage)
|
|
{
|
|
VkImageMemoryBarrier b{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER };
|
|
b.oldLayout = from;
|
|
b.newLayout = to;
|
|
b.srcAccessMask = srcAccess;
|
|
b.dstAccessMask = dstAccess;
|
|
b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
|
b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
|
b.image = image;
|
|
b.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
|
vkCmdPipelineBarrier(cmd, srcStage, dstStage, 0, 0, nullptr, 0, nullptr, 1, &b);
|
|
}
|
|
|
|
// A copy of part of a swapchain image on its way to the host.
|
|
struct RegionCapture
|
|
{
|
|
VkBuffer buffer = VK_NULL_HANDLE;
|
|
VkDeviceMemory memory = VK_NULL_HANDLE;
|
|
uint32_t width = 0, height = 0;
|
|
};
|
|
|
|
// Records a copy of `rect` of `image` (in `layout`, last written at
|
|
// `stage` with `access`) into a host-visible buffer, and puts the
|
|
// image back in `layout` for the writes that follow.
|
|
RegionCapture RecordRegionCapture(VkCommandBuffer cmd, VkImage image, VkImageLayout layout, VkAccessFlags access,
|
|
VkPipelineStageFlags stage, VkRect2D rect)
|
|
{
|
|
RegionCapture c;
|
|
c.width = rect.extent.width;
|
|
c.height = rect.extent.height;
|
|
VkBufferCreateInfo bci{ VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
|
|
bci.size = VkDeviceSize(c.width) * c.height * 4;
|
|
bci.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
|
|
if (bci.size == 0 || vkCreateBuffer(s_device, &bci, nullptr, &c.buffer) != VK_SUCCESS)
|
|
return {};
|
|
VkMemoryRequirements req;
|
|
vkGetBufferMemoryRequirements(s_device, c.buffer, &req);
|
|
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
|
ai.allocationSize = req.size;
|
|
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
|
|
if (vkAllocateMemory(s_device, &ai, nullptr, &c.memory) != VK_SUCCESS)
|
|
{
|
|
vkDestroyBuffer(s_device, c.buffer, nullptr);
|
|
return {};
|
|
}
|
|
vkBindBufferMemory(s_device, c.buffer, c.memory, 0);
|
|
Barrier(cmd, image, layout, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, access, VK_ACCESS_TRANSFER_READ_BIT, stage,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
VkBufferImageCopy region{};
|
|
region.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
|
|
region.imageOffset = { rect.offset.x, rect.offset.y, 0 };
|
|
region.imageExtent = { c.width, c.height, 1 };
|
|
vkCmdCopyImageToBuffer(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, c.buffer, 1, ®ion);
|
|
VkBufferMemoryBarrier hostRead{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER };
|
|
hostRead.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
|
hostRead.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
|
|
hostRead.srcQueueFamilyIndex = hostRead.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
|
hostRead.buffer = c.buffer;
|
|
hostRead.size = VK_WHOLE_SIZE;
|
|
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &hostRead, 0, nullptr);
|
|
Barrier(cmd, image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, layout, VK_ACCESS_TRANSFER_READ_BIT, access,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT, stage);
|
|
return c;
|
|
}
|
|
|
|
// After the frame's fence: the pixels as RGB, then the buffer freed.
|
|
CapturedFrame FinishRegionCapture(RegionCapture& c)
|
|
{
|
|
CapturedFrame frame;
|
|
const bool bgr = s_swapFormat == VK_FORMAT_B8G8R8A8_UNORM || s_swapFormat == VK_FORMAT_B8G8R8A8_SRGB;
|
|
const bool rgb = s_swapFormat == VK_FORMAT_R8G8B8A8_UNORM || s_swapFormat == VK_FORMAT_R8G8B8A8_SRGB;
|
|
void* mapped = nullptr;
|
|
if ((bgr || rgb) && vkMapMemory(s_device, c.memory, 0, VK_WHOLE_SIZE, 0, &mapped) == VK_SUCCESS)
|
|
{
|
|
const uint8_t* px = static_cast<const uint8_t*>(mapped);
|
|
frame.width = c.width;
|
|
frame.height = c.height;
|
|
frame.rgb.resize(size_t(c.width) * c.height * 3);
|
|
for (size_t i = 0, n = size_t(c.width) * c.height; i < n; i++)
|
|
for (int k = 0; k < 3; k++)
|
|
frame.rgb[i * 3 + k] = px[i * 4 + (bgr ? 2 - k : k)];
|
|
vkUnmapMemory(s_device, c.memory);
|
|
}
|
|
vkDestroyBuffer(s_device, c.buffer, nullptr);
|
|
vkFreeMemory(s_device, c.memory, nullptr);
|
|
c = {};
|
|
return frame;
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Post-processing, when presenting: anti-aliasing at the game's
|
|
// resolution (compute, into s_aa), then scaling into the letterboxed
|
|
// part of the window (fragment). The 360 drew NFSMW with 2x MSAA; the
|
|
// renderer draws single-sampled, so edges get an FXAA-style pass
|
|
// (edge-directed, with a sub-pixel blend). Scaling is Catmull-Rom
|
|
// bicubic with optional sharpening, clamped to the 2x2 source texels
|
|
// around each sample so it can't ring.
|
|
// NFSMW_AA=0 turns the anti-aliasing off; NFSMW_SCALING=linear (or
|
|
// bicubic, the default); NFSMW_SHARPEN=<0..1> (default 0.3). If the
|
|
// pipelines can't be built, frames are blitted as before.
|
|
const char* kFullscreenVs = R"(#version 460
|
|
layout(location = 0) out vec2 uv;
|
|
void main()
|
|
{
|
|
vec2 p = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
|
uv = p;
|
|
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
|
}
|
|
)";
|
|
|
|
const char* kAaCs = R"(#version 460
|
|
layout(local_size_x = 8, local_size_y = 8) in;
|
|
layout(set = 0, binding = 0) uniform sampler2D src;
|
|
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D dst;
|
|
// band: the 16:9 band's columns x..y and rows z..w (video::BandOf), used
|
|
// when bandOn is 1 (video/band_filter.h).
|
|
layout(push_constant) uniform P { ivec2 size; float subpixel; uint bandOn; ivec4 band; } p;
|
|
|
|
const float kEdgeMin = 0.0312; // no AA on darker contrast than this
|
|
const float kEdgeRelative = 0.125; // ... or than this fraction of the local maximum
|
|
|
|
// This pixel's span of the band's three along each axis, as uv: every fetch
|
|
// stays between its first and last texel centres (band_filter.h's Clamp).
|
|
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0);
|
|
|
|
float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }
|
|
vec3 at(vec2 uv)
|
|
{
|
|
if (p.bandOn != 0u)
|
|
uv = clamp(uv, spanLo, spanHi);
|
|
return textureLod(src, uv, 0.0).rgb;
|
|
}
|
|
|
|
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t.
|
|
vec2 spanOf(float t, int lo, int hi, int size)
|
|
{
|
|
return t < float(lo) ? vec2(0.0, float(lo)) : t < float(hi) ? vec2(float(lo), float(hi)) : vec2(float(hi), float(size));
|
|
}
|
|
|
|
void main()
|
|
{
|
|
ivec2 pix = ivec2(gl_GlobalInvocationID.xy);
|
|
if (pix.x >= p.size.x || pix.y >= p.size.y)
|
|
return;
|
|
vec2 texel = 1.0 / vec2(p.size);
|
|
vec2 uv = (vec2(pix) + 0.5) * texel;
|
|
if (p.bandOn != 0u)
|
|
{
|
|
vec2 sx = spanOf(float(pix.x) + 0.5, p.band.x, p.band.y, p.size.x), sy = spanOf(float(pix.y) + 0.5, p.band.z, p.band.w, p.size.y);
|
|
spanLo = (vec2(sx.x, sy.x) + 0.5) * texel;
|
|
spanHi = (vec2(sx.y, sy.y) - 0.5) * texel;
|
|
}
|
|
vec3 center = at(uv);
|
|
float lM = luma(center);
|
|
float lN = luma(at(uv + vec2(0.0, -texel.y))), lS = luma(at(uv + vec2(0.0, texel.y)));
|
|
float lW = luma(at(uv + vec2(-texel.x, 0.0))), lE = luma(at(uv + vec2(texel.x, 0.0)));
|
|
float lMin = min(lM, min(min(lN, lS), min(lW, lE)));
|
|
float lMax = max(lM, max(max(lN, lS), max(lW, lE)));
|
|
float range = lMax - lMin;
|
|
if (range < max(kEdgeMin, lMax * kEdgeRelative))
|
|
{
|
|
imageStore(dst, pix, vec4(center, 1.0));
|
|
return;
|
|
}
|
|
float lNW = luma(at(uv - texel)), lSE = luma(at(uv + texel));
|
|
float lNE = luma(at(uv + vec2(texel.x, -texel.y))), lSW = luma(at(uv + vec2(-texel.x, texel.y)));
|
|
|
|
// The edge's orientation: where luma changes most (across rows for a
|
|
// horizontal edge).
|
|
float acrossRows = abs(lNW + lSW - 2.0 * lW) + 2.0 * abs(lN + lS - 2.0 * lM) + abs(lNE + lSE - 2.0 * lE);
|
|
float acrossColumns = abs(lNW + lNE - 2.0 * lN) + 2.0 * abs(lW + lE - 2.0 * lM) + abs(lSW + lSE - 2.0 * lS);
|
|
bool horizontal = acrossRows >= acrossColumns;
|
|
|
|
// Which side of this pixel the edge is on: the steeper neighbour.
|
|
float lBefore = horizontal ? lN : lW, lAfter = horizontal ? lS : lE;
|
|
float gBefore = abs(lBefore - lM), gAfter = abs(lAfter - lM);
|
|
bool before = gBefore >= gAfter;
|
|
float threshold = 0.25 * max(gBefore, gAfter);
|
|
float step = horizontal ? texel.y : texel.x;
|
|
float edgeLuma = 0.5 * ((before ? lBefore : lAfter) + lM);
|
|
if (before)
|
|
step = -step;
|
|
|
|
// Walk along the edge both ways until the luma leaves the edge's.
|
|
vec2 onEdge = uv;
|
|
if (horizontal) onEdge.y += 0.5 * step; else onEdge.x += 0.5 * step;
|
|
vec2 along = horizontal ? vec2(texel.x, 0.0) : vec2(0.0, texel.y);
|
|
vec2 uvA = onEdge - along, uvB = onEdge + along;
|
|
float dA = luma(at(uvA)) - edgeLuma, dB = luma(at(uvB)) - edgeLuma;
|
|
bool endA = abs(dA) >= threshold, endB = abs(dB) >= threshold;
|
|
const float strides[10] = float[](1.0, 1.0, 1.0, 1.5, 2.0, 2.0, 2.0, 2.0, 4.0, 8.0);
|
|
for (int i = 0; i < 10 && !(endA && endB); i++)
|
|
{
|
|
if (!endA) { uvA -= along * strides[i]; dA = luma(at(uvA)) - edgeLuma; endA = abs(dA) >= threshold; }
|
|
if (!endB) { uvB += along * strides[i]; dB = luma(at(uvB)) - edgeLuma; endB = abs(dB) >= threshold; }
|
|
}
|
|
float distA = horizontal ? uv.x - uvA.x : uv.y - uvA.y;
|
|
float distB = horizontal ? uvB.x - uv.x : uvB.y - uv.y;
|
|
bool nearerA = distA < distB;
|
|
float offset = 0.5 - min(distA, distB) / (distA + distB);
|
|
// Only blend from the end whose luma variation goes the way this
|
|
// pixel's does (otherwise this pixel is on the edge's far side).
|
|
if (((nearerA ? dA : dB) < 0.0) == (lM < edgeLuma))
|
|
offset = 0.0;
|
|
|
|
// Sub-pixel aliasing (thin lines, single-pixel features).
|
|
float average = (2.0 * (lN + lS + lW + lE) + lNW + lNE + lSW + lSE) / 12.0;
|
|
float sub = clamp(abs(average - lM) / range, 0.0, 1.0);
|
|
sub = (3.0 - 2.0 * sub) * sub * sub;
|
|
offset = max(offset, sub * sub * p.subpixel);
|
|
|
|
vec2 sampleUv = uv;
|
|
if (horizontal) sampleUv.y += offset * step; else sampleUv.x += offset * step;
|
|
imageStore(dst, pix, vec4(at(sampleUv), 1.0));
|
|
}
|
|
)";
|
|
|
|
const char* kScaleFs = R"(#version 460
|
|
layout(location = 0) in vec2 uv;
|
|
layout(location = 0) out vec4 color;
|
|
layout(set = 0, binding = 0) uniform sampler2D src;
|
|
// band: the 16:9 band's columns x..y and rows z..w in texels (video::BandOf),
|
|
// used when bandOn is 1 (video/band_filter.h).
|
|
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; vec4 band; uint bandOn; } p;
|
|
|
|
// The span of the band's three along each axis that this pixel's centre
|
|
// falls in, as uv: every tap stays between its first and last texel centres
|
|
// (band_filter.h's Clamp), so a strip's pixel reads only the strip.
|
|
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0);
|
|
|
|
vec3 at(vec2 t)
|
|
{
|
|
if (p.bandOn != 0u)
|
|
t = clamp(t, spanLo, spanHi);
|
|
return textureLod(src, t, 0.0).rgb;
|
|
}
|
|
|
|
// band_filter.h's SpanOf: [0, lo), [lo, hi) or [hi, size) holding t.
|
|
vec2 spanOf(float t, float lo, float hi, float size)
|
|
{
|
|
return t < lo ? vec2(0.0, lo) : t < hi ? vec2(lo, hi) : vec2(hi, size);
|
|
}
|
|
|
|
void setSpan()
|
|
{
|
|
if (p.bandOn == 0u)
|
|
return;
|
|
vec2 pos = uv * p.size;
|
|
vec2 sx = spanOf(pos.x, p.band.x, p.band.y, p.size.x), sy = spanOf(pos.y, p.band.z, p.band.w, p.size.y);
|
|
spanLo = (vec2(sx.x, sy.x) + 0.5) / p.size;
|
|
spanHi = (vec2(sx.y, sy.y) - 0.5) / p.size;
|
|
}
|
|
|
|
// Catmull-Rom bicubic from 9 bilinear taps: the middle two weights of each
|
|
// axis are folded into one bilinear fetch.
|
|
vec3 catmullRom(vec2 t, vec2 texel)
|
|
{
|
|
vec2 pos = t * p.size;
|
|
vec2 c = floor(pos - 0.5) + 0.5;
|
|
vec2 f = pos - c;
|
|
vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
|
vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
|
vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
|
vec2 w3 = f * f * (-0.5 + 0.5 * f);
|
|
vec2 w12 = w1 + w2;
|
|
vec2 t0 = (c - 1.0) * texel, t12 = (c + w2 / w12) * texel, t3 = (c + 2.0) * texel;
|
|
return at(vec2(t0.x, t0.y)) * w0.x * w0.y + at(vec2(t12.x, t0.y)) * w12.x * w0.y + at(vec2(t3.x, t0.y)) * w3.x * w0.y +
|
|
at(vec2(t0.x, t12.y)) * w0.x * w12.y + at(vec2(t12.x, t12.y)) * w12.x * w12.y + at(vec2(t3.x, t12.y)) * w3.x * w12.y +
|
|
at(vec2(t0.x, t3.y)) * w0.x * w3.y + at(vec2(t12.x, t3.y)) * w12.x * w3.y + at(vec2(t3.x, t3.y)) * w3.x * w3.y;
|
|
}
|
|
|
|
void main()
|
|
{
|
|
setSpan();
|
|
if (p.bicubic == 0u)
|
|
{
|
|
color = vec4(at(uv), 1.0);
|
|
return;
|
|
}
|
|
vec2 texel = 1.0 / p.size;
|
|
vec3 c = catmullRom(uv, texel);
|
|
// Sharpen against the bilinear sample, then clamp to the 2x2 texels
|
|
// around the sample: detail gets crisper without halos or ringing.
|
|
vec2 corner = (floor(uv * p.size - 0.5) + 0.5) * texel;
|
|
vec3 a = at(corner), b = at(corner + vec2(texel.x, 0.0)), d = at(corner + vec2(0.0, texel.y)), e = at(corner + texel);
|
|
vec3 lo = min(min(a, b), min(d, e)), hi = max(max(a, b), max(d, e));
|
|
c = clamp(c + p.sharpen * (c - at(uv)), lo, hi);
|
|
color = vec4(c, 1.0);
|
|
}
|
|
)";
|
|
|
|
// NFSMW_FAST_SCALE=1 (default on Apple, below; for phones, where this pass at
|
|
// the screen's size is most of the post-processing): the same
|
|
// scaling from 7 fetches a pixel instead of 14 (above). The 2x2
|
|
// texels around the sample come from one gather per channel, and the
|
|
// bilinear sample sharpened against and Catmull-Rom's middle tap are
|
|
// blends of those 4; the 4 corner taps (at most 1.6% of the weight,
|
|
// 1.5 texels away diagonally) are left out and the rest renormalised.
|
|
// The clamp is the same. (A real frame: at most 3 levels apart.)
|
|
const char* kScaleFastFs = R"(#version 460
|
|
layout(location = 0) in vec2 uv;
|
|
layout(location = 0) out vec4 color;
|
|
layout(set = 0, binding = 0) uniform sampler2D src;
|
|
// band, bandOn: as kScaleFs's.
|
|
layout(push_constant) uniform P { vec2 size; float sharpen; uint bicubic; vec4 band; uint bandOn; } p;
|
|
|
|
vec2 spanLo = vec2(0.0), spanHi = vec2(1.0); // as kScaleFs's
|
|
|
|
vec3 at(vec2 t)
|
|
{
|
|
if (p.bandOn != 0u)
|
|
t = clamp(t, spanLo, spanHi);
|
|
return textureLod(src, t, 0.0).rgb;
|
|
}
|
|
|
|
vec2 spanOf(float t, float lo, float hi, float size)
|
|
{
|
|
return t < lo ? vec2(0.0, lo) : t < hi ? vec2(lo, hi) : vec2(hi, size);
|
|
}
|
|
|
|
void main()
|
|
{
|
|
vec2 sx = vec2(0.0), sy = vec2(0.0);
|
|
if (p.bandOn != 0u)
|
|
{
|
|
vec2 pos = uv * p.size;
|
|
sx = spanOf(pos.x, p.band.x, p.band.y, p.size.x);
|
|
sy = spanOf(pos.y, p.band.z, p.band.w, p.size.y);
|
|
spanLo = (vec2(sx.x, sy.x) + 0.5) / p.size;
|
|
spanHi = (vec2(sx.y, sy.y) - 0.5) / p.size;
|
|
}
|
|
if (p.bicubic == 0u)
|
|
{
|
|
color = vec4(at(uv), 1.0);
|
|
return;
|
|
}
|
|
vec2 texel = 1.0 / p.size;
|
|
vec2 pos = uv * p.size;
|
|
vec2 c = floor(pos - 0.5) + 0.5;
|
|
vec2 f = pos - c;
|
|
vec2 w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
|
|
vec2 w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
|
|
vec2 w2 = f * (0.5 + f * (2.0 - 1.5 * f));
|
|
vec2 w3 = f * f * (-0.5 + 0.5 * f);
|
|
vec2 w12 = w1 + w2;
|
|
vec2 g = w2 / w12;
|
|
vec2 t0 = (c - 1.0) * texel, t12 = (c + g) * texel, t3 = (c + 2.0) * texel;
|
|
// Gathers return (x0,y1), (x1,y1), (x1,y0), (x0,y0).
|
|
vec2 mid = (c + 0.5) * texel;
|
|
vec4 r = textureGather(src, mid, 0), gr = textureGather(src, mid, 1), b = textureGather(src, mid, 2);
|
|
vec3 a = vec3(r.w, gr.w, b.w), e = vec3(r.z, gr.z, b.z), d = vec3(r.x, gr.x, b.x), h = vec3(r.y, gr.y, b.y);
|
|
if (p.bandOn != 0u)
|
|
{
|
|
// The gather's row or column past this pixel's span reads the
|
|
// span's edge one instead (band_filter.h's ClampTexel): rows a e
|
|
// (first) and d h, columns a d (first) and e h.
|
|
vec2 first = c - 0.5;
|
|
if (first.y < sy.x) { a = d; e = h; } else if (first.y + 1.0 >= sy.y) { d = a; h = e; }
|
|
if (first.x < sx.x) { a = e; d = h; } else if (first.x + 1.0 >= sx.y) { e = a; h = d; }
|
|
}
|
|
vec3 middle = mix(mix(a, e, g.x), mix(d, h, g.x), g.y);
|
|
vec3 bilinear = mix(mix(a, e, f.x), mix(d, h, f.x), f.y);
|
|
float wN = w12.x * w0.y, wW = w0.x * w12.y, wM = w12.x * w12.y, wE = w3.x * w12.y, wS = w12.x * w3.y;
|
|
vec3 cr = (at(vec2(t12.x, t0.y)) * wN + at(vec2(t0.x, t12.y)) * wW + middle * wM + at(vec2(t3.x, t12.y)) * wE +
|
|
at(vec2(t12.x, t3.y)) * wS) / (wN + wW + wM + wE + wS);
|
|
vec3 lo = min(min(a, e), min(d, h)), hi = max(max(a, e), max(d, h));
|
|
color = vec4(clamp(cr + p.sharpen * (cr - bilinear), lo, hi), 1.0);
|
|
}
|
|
)";
|
|
// NFSMW_FAST_SCALE: on by default on Apple (iPad: scale pass 1.26 ->
|
|
// 1.07 ms, the same picture within 3 levels) and arm64 Linux (Steam
|
|
// Frame: post-processing 0.47 -> 0.39 ms), off elsewhere until
|
|
// measured there; =0/=1 overrides.
|
|
#if defined(__APPLE__) || (defined(__linux__) && defined(__aarch64__))
|
|
constexpr bool kFastScaleByDefault = true;
|
|
#else
|
|
constexpr bool kFastScaleByDefault = false;
|
|
#endif
|
|
const bool s_fastScale = [] { const char* v = std::getenv("NFSMW_FAST_SCALE"); return v ? v[0] == '1' : kFastScaleByDefault; }();
|
|
|
|
struct PostState
|
|
{
|
|
bool tried = false, ready = false;
|
|
VkSampler sampler = VK_NULL_HANDLE;
|
|
VkDescriptorSetLayout aaSet = VK_NULL_HANDLE, scaleSet = VK_NULL_HANDLE;
|
|
VkPipelineLayout aaLayout = VK_NULL_HANDLE, scaleLayout = VK_NULL_HANDLE;
|
|
VkPipeline aa = VK_NULL_HANDLE, scale = VK_NULL_HANDLE;
|
|
VkFormat scaleFormat = VK_FORMAT_UNDEFINED;
|
|
VkShaderModule vs = VK_NULL_HANDLE, fs = VK_NULL_HANDLE;
|
|
PFN_vkCmdPushDescriptorSetKHR pushDescriptorSet = nullptr;
|
|
// The anti-aliased frame, at the game's resolution.
|
|
VkImage aaImage = VK_NULL_HANDLE;
|
|
VkDeviceMemory aaMemory = VK_NULL_HANDLE;
|
|
VkImageView aaView = VK_NULL_HANDLE;
|
|
uint32_t aaWidth = 0, aaHeight = 0;
|
|
} s_post;
|
|
// Read every frame: the settings menu changes them live.
|
|
bool AaEnabled() { return settings::GetBool(settings::Id::AntiAliasing); }
|
|
bool Bicubic() { return settings::GetInt(settings::Id::Scaling) == int32_t(settings::ScalingMode::Bicubic); }
|
|
float Sharpen() { return std::clamp(settings::GetFloat(settings::Id::Sharpening), 0.0f, 1.0f); }
|
|
|
|
VkShaderModule PostModule(const char* glsl, int stage)
|
|
{
|
|
std::string log;
|
|
std::vector<uint32_t> spirv = gpu::renderer::CompileShader(glsl, stage, log);
|
|
if (spirv.empty())
|
|
{
|
|
fprintf(stderr, "[video] post-processing shader failed to compile:\n%s\n", log.c_str());
|
|
return VK_NULL_HANDLE;
|
|
}
|
|
VkShaderModuleCreateInfo smci{ VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO };
|
|
smci.codeSize = spirv.size() * 4;
|
|
smci.pCode = spirv.data();
|
|
VkShaderModule module = VK_NULL_HANDLE;
|
|
vkCreateShaderModule(s_device, &smci, nullptr, &module);
|
|
return module;
|
|
}
|
|
|
|
// The scale pipeline for the swapchain's format (made again if it changes).
|
|
bool EnsureScalePipeline()
|
|
{
|
|
if (s_post.scale && s_post.scaleFormat == s_swapFormat)
|
|
return true;
|
|
if (s_post.scale)
|
|
vkDestroyPipeline(s_device, s_post.scale, nullptr);
|
|
s_post.scale = VK_NULL_HANDLE;
|
|
VkPipelineShaderStageCreateInfo stages[2] = {
|
|
{ VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_VERTEX_BIT, s_post.vs, "main", nullptr },
|
|
{ VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_FRAGMENT_BIT, s_post.fs, "main", nullptr },
|
|
};
|
|
VkPipelineVertexInputStateCreateInfo vi{ VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO };
|
|
VkPipelineInputAssemblyStateCreateInfo ia{ VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO };
|
|
ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
|
|
VkPipelineViewportStateCreateInfo vp{ VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO };
|
|
vp.viewportCount = 1;
|
|
vp.scissorCount = 1;
|
|
VkPipelineRasterizationStateCreateInfo rs{ VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO };
|
|
rs.polygonMode = VK_POLYGON_MODE_FILL;
|
|
rs.cullMode = VK_CULL_MODE_NONE;
|
|
rs.lineWidth = 1.0f;
|
|
VkPipelineMultisampleStateCreateInfo ms{ VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO };
|
|
ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
|
|
VkPipelineColorBlendAttachmentState att{};
|
|
att.colorWriteMask = 0xF;
|
|
VkPipelineColorBlendStateCreateInfo cb{ VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO };
|
|
cb.attachmentCount = 1;
|
|
cb.pAttachments = &att;
|
|
VkDynamicState dyn[] = { VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR };
|
|
VkPipelineDynamicStateCreateInfo ds{ VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO };
|
|
ds.dynamicStateCount = 2;
|
|
ds.pDynamicStates = dyn;
|
|
VkPipelineRenderingCreateInfoKHR rci{ VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO_KHR };
|
|
rci.colorAttachmentCount = 1;
|
|
rci.pColorAttachmentFormats = &s_swapFormat;
|
|
VkGraphicsPipelineCreateInfo gpci{ VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO };
|
|
gpci.pNext = &rci;
|
|
gpci.stageCount = 2;
|
|
gpci.pStages = stages;
|
|
gpci.pVertexInputState = &vi;
|
|
gpci.pInputAssemblyState = &ia;
|
|
gpci.pViewportState = &vp;
|
|
gpci.pRasterizationState = &rs;
|
|
gpci.pMultisampleState = &ms;
|
|
gpci.pColorBlendState = &cb;
|
|
gpci.pDynamicState = &ds;
|
|
gpci.layout = s_post.scaleLayout;
|
|
if (vkCreateGraphicsPipelines(s_device, VK_NULL_HANDLE, 1, &gpci, nullptr, &s_post.scale) != VK_SUCCESS)
|
|
return false;
|
|
s_post.scaleFormat = s_swapFormat;
|
|
return true;
|
|
}
|
|
|
|
bool EnsurePostProcessing()
|
|
{
|
|
if (s_post.tried)
|
|
return s_post.ready && EnsureScalePipeline();
|
|
s_post.tried = true;
|
|
s_post.pushDescriptorSet = reinterpret_cast<PFN_vkCmdPushDescriptorSetKHR>(
|
|
vkGetDeviceProcAddr(s_device, "vkCmdPushDescriptorSetKHR"));
|
|
if (!s_post.pushDescriptorSet || !s_context.cmdBeginRendering)
|
|
return false;
|
|
VkSamplerCreateInfo sci{ VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO };
|
|
sci.magFilter = VK_FILTER_LINEAR;
|
|
sci.minFilter = VK_FILTER_LINEAR;
|
|
sci.addressModeU = sci.addressModeV = sci.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
|
vkCreateSampler(s_device, &sci, nullptr, &s_post.sampler);
|
|
|
|
VkDescriptorSetLayoutBinding aaBindings[2] = {
|
|
{ 0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
|
|
{ 1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
|
|
};
|
|
VkDescriptorSetLayoutBinding scaleBinding = { 0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr };
|
|
VkDescriptorSetLayoutCreateInfo dslci{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO };
|
|
dslci.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_PUSH_DESCRIPTOR_BIT_KHR;
|
|
dslci.bindingCount = 2;
|
|
dslci.pBindings = aaBindings;
|
|
vkCreateDescriptorSetLayout(s_device, &dslci, nullptr, &s_post.aaSet);
|
|
dslci.bindingCount = 1;
|
|
dslci.pBindings = &scaleBinding;
|
|
vkCreateDescriptorSetLayout(s_device, &dslci, nullptr, &s_post.scaleSet);
|
|
VkPushConstantRange aaRange{ VK_SHADER_STAGE_COMPUTE_BIT, 0, 32 }, scaleRange{ VK_SHADER_STAGE_FRAGMENT_BIT, 0, 36 };
|
|
VkPipelineLayoutCreateInfo plci{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO };
|
|
plci.setLayoutCount = 1;
|
|
plci.pSetLayouts = &s_post.aaSet;
|
|
plci.pushConstantRangeCount = 1;
|
|
plci.pPushConstantRanges = &aaRange;
|
|
vkCreatePipelineLayout(s_device, &plci, nullptr, &s_post.aaLayout);
|
|
plci.pSetLayouts = &s_post.scaleSet;
|
|
plci.pPushConstantRanges = &scaleRange;
|
|
vkCreatePipelineLayout(s_device, &plci, nullptr, &s_post.scaleLayout);
|
|
|
|
VkShaderModule cs = PostModule(kAaCs, 2);
|
|
s_post.vs = PostModule(kFullscreenVs, 0);
|
|
s_post.fs = PostModule(s_fastScale ? kScaleFastFs : kScaleFs, 1);
|
|
if (!cs || !s_post.vs || !s_post.fs)
|
|
return false;
|
|
VkComputePipelineCreateInfo cpci{ VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO };
|
|
cpci.stage = { VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, VK_SHADER_STAGE_COMPUTE_BIT, cs, "main", nullptr };
|
|
cpci.layout = s_post.aaLayout;
|
|
if (vkCreateComputePipelines(s_device, VK_NULL_HANDLE, 1, &cpci, nullptr, &s_post.aa) != VK_SUCCESS)
|
|
return false;
|
|
if (!EnsureScalePipeline())
|
|
return false;
|
|
s_post.ready = true;
|
|
fprintf(stderr, "[video] post-processing: %s, %s scaling%s%s\n", AaEnabled() ? "anti-aliasing" : "no anti-aliasing",
|
|
Bicubic() ? "bicubic" : "linear", Bicubic() ? std::format(", sharpen {:.2f}", Sharpen()).c_str() : "",
|
|
s_fastScale ? " (NFSMW_FAST_SCALE: 7 fetches)" : "");
|
|
return true;
|
|
}
|
|
|
|
void EnsureAaImage(uint32_t width, uint32_t height)
|
|
{
|
|
if (s_post.aaImage && s_post.aaWidth == width && s_post.aaHeight == height)
|
|
return;
|
|
WaitIdle();
|
|
if (s_post.aaImage)
|
|
{
|
|
vkDestroyImageView(s_device, s_post.aaView, nullptr);
|
|
vkDestroyImage(s_device, s_post.aaImage, nullptr);
|
|
vkFreeMemory(s_device, s_post.aaMemory, nullptr);
|
|
}
|
|
VkImageCreateInfo ii{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
|
|
ii.imageType = VK_IMAGE_TYPE_2D;
|
|
ii.format = VK_FORMAT_R8G8B8A8_UNORM;
|
|
ii.extent = { width, height, 1 };
|
|
ii.mipLevels = 1;
|
|
ii.arrayLayers = 1;
|
|
ii.samples = VK_SAMPLE_COUNT_1_BIT;
|
|
ii.tiling = VK_IMAGE_TILING_OPTIMAL;
|
|
ii.usage = VK_IMAGE_USAGE_STORAGE_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
|
|
vkCreateImage(s_device, &ii, nullptr, &s_post.aaImage);
|
|
VkMemoryRequirements req;
|
|
vkGetImageMemoryRequirements(s_device, s_post.aaImage, &req);
|
|
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
|
ai.allocationSize = req.size;
|
|
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
vkAllocateMemory(s_device, &ai, nullptr, &s_post.aaMemory);
|
|
vkBindImageMemory(s_device, s_post.aaImage, s_post.aaMemory, 0);
|
|
VkImageViewCreateInfo vci{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO };
|
|
vci.image = s_post.aaImage;
|
|
vci.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
|
vci.format = VK_FORMAT_R8G8B8A8_UNORM;
|
|
vci.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
|
vkCreateImageView(s_device, &vci, nullptr, &s_post.aaView);
|
|
s_post.aaWidth = width;
|
|
s_post.aaHeight = height;
|
|
}
|
|
|
|
// Xenia's Tiled2D for 32x32-aligned pitches (texture_address.h).
|
|
uint32_t Tiled2D(uint32_t x, uint32_t y, uint32_t pitchAligned, uint32_t bppLog2)
|
|
{
|
|
uint32_t outer = (((y >> 5) * (pitchAligned >> 5)) + (x >> 5)) << 6;
|
|
uint32_t inner = (((y >> 1) & 7) << 3) | (x & 7);
|
|
uint32_t oi = (outer | inner) << bppLog2;
|
|
uint32_t bank = (y >> 4) & 1;
|
|
uint32_t pipe = ((x >> 3) & 3) ^ (((y >> 3) & 1) << 1);
|
|
return ((y & 1) << 4) | (pipe << 6) | (bank << 11) | (oi & 0xF) | (((oi >> 4) & 1) << 5) |
|
|
(((oi >> 5) & 7) << 8) | ((oi >> 8) << 12);
|
|
}
|
|
}
|
|
|
|
namespace
|
|
{
|
|
// Settings the window and swapchain follow live (main thread).
|
|
void ApplySettings()
|
|
{
|
|
uint64_t generation = settings::Generation();
|
|
if (generation == s_settingsGeneration)
|
|
return;
|
|
s_settingsGeneration = generation;
|
|
// Only when the setting itself changes: the window may have been
|
|
// toggled by the system since (and NFSMW_FULLSCREEN pins the setting).
|
|
bool fullscreen = settings::GetBool(settings::Id::Fullscreen);
|
|
if (fullscreen != s_fullscreenSetting && !FullscreenLocked())
|
|
{
|
|
s_fullscreenSetting = fullscreen;
|
|
if (fullscreen != s_fullscreen)
|
|
{
|
|
s_fullscreen = fullscreen;
|
|
bool ok = SDL_SetWindowFullscreen(s_window, fullscreen);
|
|
fprintf(stderr, "[video] fullscreen %s (setting)%s%s\n", fullscreen ? "on" : "off", ok ? "" : ": ",
|
|
ok ? "" : SDL_GetError());
|
|
}
|
|
}
|
|
bool rebuild = false;
|
|
bool vsync = settings::GetBool(settings::Id::VSync);
|
|
if (vsync != s_vsync)
|
|
{
|
|
s_vsync = vsync;
|
|
rebuild = true;
|
|
}
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
// Screen Resolution: CreateSwapchain sets the view's scale first.
|
|
if (float scale = WantedPresentScale(); scale != s_presentScale)
|
|
{
|
|
fprintf(stderr, "[video] present scale %.3f -> %.3f (Screen Resolution)\n", s_presentScale, scale);
|
|
s_presentScale = scale;
|
|
rebuild = true;
|
|
}
|
|
#endif
|
|
if (rebuild)
|
|
{
|
|
WaitIdle();
|
|
DestroySwapchain();
|
|
s_swapReason = "V-Sync or Screen Resolution";
|
|
CreateSwapchain();
|
|
s_swapReason = nullptr;
|
|
}
|
|
}
|
|
|
|
// The window's size for the next launch: only a window the player
|
|
// sized (not fullscreen, maximised, or filling the display, as under
|
|
// gamescope).
|
|
void RememberWindowSize()
|
|
{
|
|
// NFSMW_WINDOW sets this run's size; keep the player's.
|
|
if (s_gameMode || settings::EnvOverride(settings::Id::WindowWidth) || settings::EnvOverride(settings::Id::WindowHeight))
|
|
return;
|
|
if (SDL_GetWindowFlags(s_window) & (SDL_WINDOW_FULLSCREEN | SDL_WINDOW_MAXIMIZED | SDL_WINDOW_MINIMIZED))
|
|
return;
|
|
int w = 0, h = 0;
|
|
SDL_GetWindowSize(s_window, &w, &h);
|
|
if (const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window)))
|
|
if (w >= mode->w && h >= mode->h)
|
|
return;
|
|
if (w > 0 && h > 0)
|
|
{
|
|
settings::Set(settings::Id::WindowWidth, w);
|
|
settings::Set(settings::Id::WindowHeight, h);
|
|
}
|
|
}
|
|
|
|
// The device extensions the game can't start without: Initialize
|
|
// enables them, and ChooseDevice puts a device with all of them first.
|
|
constexpr const char* kNeededDeviceExtensions[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME,
|
|
VK_KHR_DYNAMIC_RENDERING_EXTENSION_NAME, VK_KHR_DEPTH_STENCIL_RESOLVE_EXTENSION_NAME,
|
|
VK_KHR_CREATE_RENDERPASS_2_EXTENSION_NAME, VK_KHR_MULTIVIEW_EXTENSION_NAME, VK_KHR_MAINTENANCE_2_EXTENSION_NAME,
|
|
VK_KHR_PUSH_DESCRIPTOR_EXTENSION_NAME };
|
|
|
|
// The Vulkan device to draw with (s_physical, s_queueFamily; left
|
|
// null when none can present to the window), and the log's one line
|
|
// listing every device the loader found, the one used and why
|
|
// (video/gpu_choice.h: a discrete GPU first, NFSMW_GPU=<index or part
|
|
// of a name> to pick another). Up to v0.1.1 it was the loader's first
|
|
// that could present, which on a laptop with two GPUs was usually the
|
|
// integrated one; where only one device can present it still is that
|
|
// one. A device's queue family is its first with graphics that can
|
|
// present to the window, as before.
|
|
void ChooseDevice(const std::vector<VkPhysicalDevice>& devices)
|
|
{
|
|
std::vector<GpuCandidate> candidates;
|
|
std::vector<uint32_t> families;
|
|
for (size_t i = 0; i < devices.size(); i++)
|
|
{
|
|
VkPhysicalDevice d = devices[i];
|
|
VkPhysicalDeviceProperties props;
|
|
vkGetPhysicalDeviceProperties(d, &props);
|
|
uint32_t qc = 0;
|
|
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, nullptr);
|
|
std::vector<VkQueueFamilyProperties> qs(qc);
|
|
vkGetPhysicalDeviceQueueFamilyProperties(d, &qc, qs.data());
|
|
uint32_t family = UINT32_MAX;
|
|
for (uint32_t f = 0; f < qc && family == UINT32_MAX; f++)
|
|
{
|
|
VkBool32 present = VK_FALSE;
|
|
vkGetPhysicalDeviceSurfaceSupportKHR(d, f, s_surface, &present);
|
|
if ((qs[f].queueFlags & VK_QUEUE_GRAPHICS_BIT) && present)
|
|
family = f;
|
|
}
|
|
VkPhysicalDeviceMemoryProperties memory;
|
|
vkGetPhysicalDeviceMemoryProperties(d, &memory);
|
|
uint64_t local = 0;
|
|
for (uint32_t h = 0; h < memory.memoryHeapCount; h++)
|
|
if (memory.memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)
|
|
local += memory.memoryHeaps[h].size;
|
|
uint32_t extCount = 0;
|
|
vkEnumerateDeviceExtensionProperties(d, nullptr, &extCount, nullptr);
|
|
std::vector<VkExtensionProperties> exts(extCount);
|
|
vkEnumerateDeviceExtensionProperties(d, nullptr, &extCount, exts.data());
|
|
std::string lacks;
|
|
for (const char* needed : kNeededDeviceExtensions)
|
|
if (lacks.empty() && std::none_of(exts.begin(), exts.end(),
|
|
[needed](const VkExtensionProperties& e) { return strcmp(e.extensionName, needed) == 0; }))
|
|
lacks = needed;
|
|
// Mesa's Dozen (Vulkan on Direct3D 12: Windows, WSL) lists GPUs
|
|
// a native driver may list too.
|
|
bool dozen = false;
|
|
if (props.apiVersion >= VK_API_VERSION_1_2)
|
|
{
|
|
VkPhysicalDeviceDriverProperties driver{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
|
|
VkPhysicalDeviceProperties2 props2{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2 };
|
|
props2.pNext = &driver;
|
|
vkGetPhysicalDeviceProperties2(d, &props2);
|
|
dozen = driver.driverID == VK_DRIVER_ID_MESA_DOZEN;
|
|
}
|
|
candidates.push_back({ i, int(props.deviceType), dozen, props.deviceName, local, family != UINT32_MAX, lacks });
|
|
families.push_back(family);
|
|
}
|
|
GpuChoice choice = ChooseGpu(candidates, std::getenv("NFSMW_GPU"));
|
|
fprintf(stderr, "[video] %s\n", GpuLogLine(candidates, choice).c_str());
|
|
if (choice.pick == kNoGpu)
|
|
return;
|
|
s_physical = devices[choice.pick];
|
|
s_queueFamily = families[choice.pick];
|
|
}
|
|
}
|
|
|
|
bool Initialize()
|
|
{
|
|
if (const char* h = std::getenv("NFSMW_HEADLESS"); h && h[0] == '1')
|
|
return false;
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
// MoltenVK encodes Metal at vkQueueSubmit on the submitting thread,
|
|
// the command processor: ~2 ms a frame on an iPad Pro M2 (127 ms/s
|
|
// in the scripted race), more in heavy scenes, and an iPhone has
|
|
// two performance cores for the CP and the game. Asynchronous
|
|
// submits encode on MoltenVK's own queue instead (1 ms/s left on the
|
|
// CP; the same frames, screenshots alike). Read when MoltenVK first
|
|
// loads its configuration, so before any Vulkan call;
|
|
// MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS=1 turns it back.
|
|
setenv("MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS", "0", 0);
|
|
#endif
|
|
#ifdef __APPLE__
|
|
// SpeedBreaker.app (scripts/macos/make_app.py) carries MoltenVK and
|
|
// its manifest: the Vulkan loader uses only that driver, not one a
|
|
// Homebrew install also has (two MoltenVKs, the system's chosen).
|
|
// VK_DRIVER_FILES or VK_ICD_FILENAMES set by hand still win.
|
|
if (!std::getenv("VK_DRIVER_FILES") && !std::getenv("VK_ICD_FILENAMES"))
|
|
if (const char* base = SDL_GetBasePath())
|
|
{
|
|
std::string manifest = std::string(base) + "vulkan/icd.d/MoltenVK_icd.json";
|
|
std::error_code ec;
|
|
if (std::filesystem::exists(manifest, ec))
|
|
setenv("VK_DRIVER_FILES", manifest.c_str(), 1);
|
|
}
|
|
#endif
|
|
if (!SDL_Init(SDL_INIT_VIDEO))
|
|
{
|
|
fprintf(stderr, "[video] SDL_Init: %s\n", SDL_GetError());
|
|
return false;
|
|
}
|
|
SDL_AddEventWatch(OnAppEvent, nullptr);
|
|
hid::Initialize();
|
|
// The size it had last time (NFSMW_WINDOW=<width>x<height> overrides).
|
|
int windowW = settings::GetInt(settings::Id::WindowWidth), windowH = settings::GetInt(settings::Id::WindowHeight);
|
|
s_gameMode = DetectGameMode();
|
|
s_fullscreenSetting = settings::GetBool(settings::Id::Fullscreen);
|
|
s_fullscreen = FullscreenLocked() || s_fullscreenSetting;
|
|
s_vsync = settings::GetBool(settings::Id::VSync);
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
// Always fullscreen there (FullscreenLocked), which hides the status
|
|
// bar over the picture. SDL then also defers every screen edge's
|
|
// system gestures, so going home would take two swipes: "0" keeps
|
|
// the one swipe the windowed app had. ("2" defers them, if touch
|
|
// players leave the game by accident.)
|
|
SDL_SetHint(SDL_HINT_IOS_HIDE_HOME_INDICATOR, "0");
|
|
// On iPhone, landscape only to SDL too: else it turns its view
|
|
// portrait when a keyboard shows or hides (ios_ui.h).
|
|
platform::ios::PrepareOrientation();
|
|
#endif
|
|
s_window = SDL_CreateWindow("SpeedBreaker", windowW, windowH,
|
|
SDL_WINDOW_VULKAN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | (s_fullscreen ? SDL_WINDOW_FULLSCREEN : 0));
|
|
if (!s_window)
|
|
{
|
|
fprintf(stderr, "[video] SDL_CreateWindow: %s\n", SDL_GetError());
|
|
return false;
|
|
}
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
platform::ios::WatchKeyboards(s_window);
|
|
#endif
|
|
|
|
Uint32 extCount = 0;
|
|
const char* const* sdlExts = SDL_Vulkan_GetInstanceExtensions(&extCount);
|
|
std::vector<const char*> exts(sdlExts, sdlExts + extCount);
|
|
exts.push_back(VK_KHR_GET_PHYSICAL_DEVICE_PROPERTIES_2_EXTENSION_NAME);
|
|
// Portability enumeration is the Vulkan loader's (it lists MoltenVK
|
|
// on macOS only when asked). iOS links MoltenVK itself, without a
|
|
// loader, and MoltenVK doesn't offer it: ask only when it's there.
|
|
uint32_t available = 0;
|
|
vkEnumerateInstanceExtensionProperties(nullptr, &available, nullptr);
|
|
std::vector<VkExtensionProperties> instanceExts(available);
|
|
vkEnumerateInstanceExtensionProperties(nullptr, &available, instanceExts.data());
|
|
bool portability = false;
|
|
for (const VkExtensionProperties& e : instanceExts)
|
|
portability |= strcmp(e.extensionName, VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) == 0;
|
|
if (portability)
|
|
exts.push_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME);
|
|
|
|
VkApplicationInfo app{ VK_STRUCTURE_TYPE_APPLICATION_INFO };
|
|
app.pApplicationName = "SpeedBreaker";
|
|
app.apiVersion = VK_API_VERSION_1_2;
|
|
VkInstanceCreateInfo ici{ VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO };
|
|
ici.flags = portability ? VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR : 0;
|
|
ici.pApplicationInfo = &app;
|
|
ici.enabledExtensionCount = uint32_t(exts.size());
|
|
ici.ppEnabledExtensionNames = exts.data();
|
|
if (!Check(vkCreateInstance(&ici, nullptr, &s_instance), "vkCreateInstance"))
|
|
return false;
|
|
if (!SDL_Vulkan_CreateSurface(s_window, s_instance, nullptr, &s_surface))
|
|
{
|
|
fprintf(stderr, "[video] SDL_Vulkan_CreateSurface: %s\n", SDL_GetError());
|
|
return false;
|
|
}
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
// Before the first swapchain: MoltenVK's surface extent follows the
|
|
// layer's contentsScale.
|
|
s_presentScale = WantedPresentScale();
|
|
if (s_presentScale < 1.0f)
|
|
{
|
|
if (platform::ios::SetDrawableScale(s_window, s_presentScale))
|
|
s_viewScaled = true;
|
|
else
|
|
fprintf(stderr, "[video] present scale: no Metal view to scale; full size\n");
|
|
}
|
|
#endif
|
|
|
|
uint32_t count = 0;
|
|
vkEnumeratePhysicalDevices(s_instance, &count, nullptr);
|
|
std::vector<VkPhysicalDevice> devices(count);
|
|
vkEnumeratePhysicalDevices(s_instance, &count, devices.data());
|
|
devices.resize(count);
|
|
ChooseDevice(devices);
|
|
if (!s_physical)
|
|
{
|
|
fprintf(stderr, "[video] no Vulkan device can present to the window\n");
|
|
return false;
|
|
}
|
|
VkPhysicalDeviceProperties props;
|
|
vkGetPhysicalDeviceProperties(s_physical, &props);
|
|
|
|
// The renderer needs dynamic rendering, and imports guest memory as
|
|
// a buffer where the device can (host-memory import; Turnip, the
|
|
// Steam Frame's driver, can't: the renderer's GPU copy instead).
|
|
std::vector<const char*> devExts(std::begin(kNeededDeviceExtensions), std::end(kNeededDeviceExtensions));
|
|
uint32_t extAvail = 0;
|
|
vkEnumerateDeviceExtensionProperties(s_physical, nullptr, &extAvail, nullptr);
|
|
std::vector<VkExtensionProperties> avail(extAvail);
|
|
vkEnumerateDeviceExtensionProperties(s_physical, nullptr, &extAvail, avail.data());
|
|
bool presentId = false, presentWait = false, hostImport = false, shadingRate = false;
|
|
for (auto& e : avail)
|
|
{
|
|
if (strcmp(e.extensionName, "VK_KHR_portability_subset") == 0)
|
|
devExts.push_back("VK_KHR_portability_subset");
|
|
hostImport |= strcmp(e.extensionName, VK_EXT_EXTERNAL_MEMORY_HOST_EXTENSION_NAME) == 0;
|
|
shadingRate |= strcmp(e.extensionName, VK_KHR_FRAGMENT_SHADING_RATE_EXTENSION_NAME) == 0;
|
|
presentId |= strcmp(e.extensionName, VK_KHR_PRESENT_ID_EXTENSION_NAME) == 0;
|
|
presentWait |= strcmp(e.extensionName, VK_KHR_PRESENT_WAIT_EXTENSION_NAME) == 0;
|
|
}
|
|
// Present timing (the guest vblank lock), where the device has it.
|
|
// NFSMW_PRESENT_TIMING=0 leaves it off, should a driver object to the
|
|
// waits beside presents: no lock, and no repeat counts.
|
|
const char* timing = std::getenv("NFSMW_PRESENT_TIMING");
|
|
const bool timingOff = timing && timing[0] == '0';
|
|
VkPhysicalDevicePresentIdFeaturesKHR presentIdFeatures{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_ID_FEATURES_KHR };
|
|
VkPhysicalDevicePresentWaitFeaturesKHR presentWaitFeatures{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_WAIT_FEATURES_KHR };
|
|
if (presentId && presentWait && !timingOff)
|
|
{
|
|
presentIdFeatures.pNext = &presentWaitFeatures;
|
|
VkPhysicalDeviceFeatures2 features2{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2 };
|
|
features2.pNext = &presentIdFeatures;
|
|
vkGetPhysicalDeviceFeatures2(s_physical, &features2);
|
|
s_presentTiming = presentIdFeatures.presentId && presentWaitFeatures.presentWait;
|
|
}
|
|
if (hostImport)
|
|
devExts.push_back(VK_EXT_EXTERNAL_MEMORY_HOST_EXTENSION_NAME);
|
|
// NFSMW_SHADING_RATE=<w>x<h>: an experiment (presenter.h).
|
|
uint32_t rateW = 0, rateH = 0;
|
|
VkPhysicalDeviceFragmentShadingRateFeaturesKHR rateFeatures{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_SHADING_RATE_FEATURES_KHR };
|
|
if (const char* v = std::getenv("NFSMW_SHADING_RATE"); v && shadingRate && sscanf(v, "%ux%u", &rateW, &rateH) == 2)
|
|
{
|
|
VkPhysicalDeviceFeatures2 features2{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2 };
|
|
features2.pNext = &rateFeatures;
|
|
vkGetPhysicalDeviceFeatures2(s_physical, &features2);
|
|
if (rateFeatures.pipelineFragmentShadingRate)
|
|
{
|
|
rateFeatures = { VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FRAGMENT_SHADING_RATE_FEATURES_KHR };
|
|
rateFeatures.pipelineFragmentShadingRate = VK_TRUE;
|
|
devExts.push_back(VK_KHR_FRAGMENT_SHADING_RATE_EXTENSION_NAME);
|
|
fprintf(stderr, "[video] shading rate %ux%u for the game's draws (NFSMW_SHADING_RATE, an experiment)\n", rateW, rateH);
|
|
}
|
|
else
|
|
rateW = rateH = 0;
|
|
}
|
|
else
|
|
rateW = rateH = 0;
|
|
if (s_presentTiming)
|
|
{
|
|
devExts.push_back(VK_KHR_PRESENT_ID_EXTENSION_NAME);
|
|
devExts.push_back(VK_KHR_PRESENT_WAIT_EXTENSION_NAME);
|
|
}
|
|
|
|
float priority = 1.0f;
|
|
VkDeviceQueueCreateInfo qci{ VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO };
|
|
qci.queueFamilyIndex = s_queueFamily;
|
|
qci.queueCount = 1;
|
|
qci.pQueuePriorities = &priority;
|
|
VkPhysicalDeviceFeatures supported;
|
|
vkGetPhysicalDeviceFeatures(s_physical, &supported);
|
|
VkPhysicalDeviceFeatures features{};
|
|
features.vertexPipelineStoresAndAtomics = supported.vertexPipelineStoresAndAtomics;
|
|
features.fragmentStoresAndAtomics = supported.fragmentStoresAndAtomics;
|
|
features.independentBlend = supported.independentBlend;
|
|
features.dualSrcBlend = supported.dualSrcBlend;
|
|
features.depthClamp = supported.depthClamp;
|
|
features.samplerAnisotropy = supported.samplerAnisotropy;
|
|
features.textureCompressionBC = supported.textureCompressionBC;
|
|
features.shaderInt16 = supported.shaderInt16;
|
|
VkPhysicalDeviceDynamicRenderingFeaturesKHR dynamicRendering{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DYNAMIC_RENDERING_FEATURES_KHR };
|
|
dynamicRendering.dynamicRendering = VK_TRUE;
|
|
if (s_presentTiming)
|
|
dynamicRendering.pNext = &presentIdFeatures; // and presentWaitFeatures
|
|
// NFSMW_OCCLUSION (=1 on, anything else off): count the game's
|
|
// occlusion queries on the GPU (presenter.h), which the sun's glare
|
|
// fades by. On by default where checked (MoltenVK on an M4 Max,
|
|
// RADV on the Steam Machine, Turnip on the Steam Frame: cost within
|
|
// noise); off elsewhere. Needs precise occlusion queries (counting,
|
|
// not boolean); host query reset (core 1.2) is optional. Off, the
|
|
// device is created exactly as without it.
|
|
#if defined(__APPLE__) || defined(__linux__)
|
|
constexpr bool kOcclusionByDefault = true;
|
|
#else
|
|
constexpr bool kOcclusionByDefault = false;
|
|
#endif
|
|
static const bool occlusion = [] { const char* v = std::getenv("NFSMW_OCCLUSION"); return v ? strcmp(v, "1") == 0 : kOcclusionByDefault; }();
|
|
bool occlusionCounting = false;
|
|
VkPhysicalDeviceHostQueryResetFeatures hostQueryReset{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_HOST_QUERY_RESET_FEATURES };
|
|
if (occlusion)
|
|
{
|
|
occlusionCounting = supported.occlusionQueryPrecise;
|
|
if (occlusionCounting && props.apiVersion >= VK_API_VERSION_1_2)
|
|
{
|
|
VkPhysicalDeviceFeatures2 features2{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2 };
|
|
features2.pNext = &hostQueryReset;
|
|
vkGetPhysicalDeviceFeatures2(s_physical, &features2);
|
|
}
|
|
hostQueryReset.pNext = nullptr;
|
|
hostQueryReset.hostQueryReset = occlusionCounting && hostQueryReset.hostQueryReset;
|
|
features.occlusionQueryPrecise = occlusionCounting ? VK_TRUE : VK_FALSE;
|
|
if (occlusionCounting)
|
|
fprintf(stderr, "[video] occlusion queries counted on the GPU (NFSMW_OCCLUSION=0 turns it off; host query reset: %s)\n",
|
|
hostQueryReset.hostQueryReset ? "yes" : "no, reset in command buffers");
|
|
else
|
|
fprintf(stderr, "[video] NFSMW_OCCLUSION=1, but this device has no precise occlusion queries: not counted (zero samples, as without it)\n");
|
|
}
|
|
|
|
VkDeviceCreateInfo dci{ VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO };
|
|
dci.pNext = &dynamicRendering;
|
|
if (rateW)
|
|
{
|
|
rateFeatures.pNext = &dynamicRendering;
|
|
dci.pNext = &rateFeatures;
|
|
}
|
|
if (hostQueryReset.hostQueryReset)
|
|
{
|
|
hostQueryReset.pNext = const_cast<void*>(dci.pNext);
|
|
dci.pNext = &hostQueryReset;
|
|
}
|
|
dci.pEnabledFeatures = &features;
|
|
dci.queueCreateInfoCount = 1;
|
|
dci.pQueueCreateInfos = &qci;
|
|
dci.enabledExtensionCount = uint32_t(devExts.size());
|
|
dci.ppEnabledExtensionNames = devExts.data();
|
|
if (!Check(vkCreateDevice(s_physical, &dci, nullptr, &s_device), "vkCreateDevice"))
|
|
return false;
|
|
vkGetDeviceQueue(s_device, s_queueFamily, 0, &s_queue);
|
|
if (s_presentTiming)
|
|
s_waitForPresent = (PFN_vkWaitForPresentKHR)vkGetDeviceProcAddr(s_device, "vkWaitForPresentKHR");
|
|
s_presentTiming = s_waitForPresent != nullptr;
|
|
if (s_presentTiming)
|
|
{
|
|
if (const char* path = std::getenv("NFSMW_VBLANK_LOG"))
|
|
if ((s_timingLog = fopen(path, "w")))
|
|
fprintf(s_timingLog, "id,vblank,presented,on_screen,timed,fit_valid,period_ns,grid_ns,rms_ns\n");
|
|
std::thread(PresentWaiter).detach();
|
|
}
|
|
fprintf(stderr, "[vblank] %s\n", s_presentTiming ? "presents are timed (VK_KHR_present_wait)"
|
|
: timingOff ? "presents aren't timed (NFSMW_PRESENT_TIMING=0): the guest vblank can't lock to the display"
|
|
: "no present timing (VK_KHR_present_wait) on this device: the guest vblank can't lock to the display");
|
|
|
|
VkCommandPoolCreateInfo pci{ VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO };
|
|
pci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
|
|
pci.queueFamilyIndex = s_queueFamily;
|
|
vkCreateCommandPool(s_device, &pci, nullptr, &s_commandPool);
|
|
for (Frame& f : s_frames)
|
|
{
|
|
VkCommandBufferAllocateInfo cai{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO };
|
|
cai.commandPool = s_commandPool;
|
|
cai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
|
cai.commandBufferCount = 1;
|
|
vkAllocateCommandBuffers(s_device, &cai, &f.cmd);
|
|
VkSemaphoreCreateInfo si{ VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO };
|
|
vkCreateSemaphore(s_device, &si, nullptr, &f.imageAvailable);
|
|
VkFenceCreateInfo fi{ VK_STRUCTURE_TYPE_FENCE_CREATE_INFO };
|
|
fi.flags = VK_FENCE_CREATE_SIGNALED_BIT;
|
|
vkCreateFence(s_device, &fi, nullptr, &f.inFlight);
|
|
}
|
|
if (const char* v = std::getenv("NFSMW_GPU_TIMING"); v && v[0] == '1')
|
|
{
|
|
uint32_t familyCount = 0;
|
|
vkGetPhysicalDeviceQueueFamilyProperties(s_physical, &familyCount, nullptr);
|
|
std::vector<VkQueueFamilyProperties> families(familyCount);
|
|
vkGetPhysicalDeviceQueueFamilyProperties(s_physical, &familyCount, families.data());
|
|
VkQueryPoolCreateInfo qpci{ VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO };
|
|
qpci.queryType = VK_QUERY_TYPE_TIMESTAMP;
|
|
qpci.queryCount = FRAMES_IN_FLIGHT * 3;
|
|
if (families[s_queueFamily].timestampValidBits == 0 ||
|
|
vkCreateQueryPool(s_device, &qpci, nullptr, &s_timestamps) != VK_SUCCESS)
|
|
s_timestamps = VK_NULL_HANDLE;
|
|
s_timestampPeriod = props.limits.timestampPeriod;
|
|
}
|
|
CreateSwapchain();
|
|
s_context = { s_instance, s_physical, s_device, s_queueFamily, s_queue, &s_queueMutex,
|
|
hostImport ? (PFN_vkGetMemoryHostPointerPropertiesEXT)vkGetDeviceProcAddr(s_device, "vkGetMemoryHostPointerPropertiesEXT") : nullptr,
|
|
(PFN_vkCmdBeginRenderingKHR)vkGetDeviceProcAddr(s_device, "vkCmdBeginRenderingKHR"),
|
|
(PFN_vkCmdEndRenderingKHR)vkGetDeviceProcAddr(s_device, "vkCmdEndRenderingKHR"), rateW, rateH, occlusionCounting,
|
|
hostQueryReset.hostQueryReset ? (PFN_vkResetQueryPool)vkGetDeviceProcAddr(s_device, "vkResetQueryPool") : nullptr };
|
|
s_contextReady = true;
|
|
s_settingsGeneration = settings::Generation();
|
|
fprintf(stderr, "[video] %s, swapchain %ux%u%s\n", props.deviceName, s_swapExtent.width, s_swapExtent.height, GeometryNote().c_str());
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
if (s_presentScale < 1.0f)
|
|
{
|
|
int pw = 0, ph = 0;
|
|
SDL_GetWindowSizeInPixels(s_window, &pw, &ph);
|
|
fprintf(stderr, "[video] present scale %.3f (%s): drawables %ux%u for a %dx%d screen\n", s_presentScale,
|
|
s_envPresentScale > 0.0f ? "NFSMW_PRESENT_SCALE" : "Screen Resolution", s_swapExtent.width, s_swapExtent.height, pw, ph);
|
|
}
|
|
#endif
|
|
{
|
|
// What the game was given, against the monitor (Game Mode: the
|
|
// screen is the size Steam's per-game Game Resolution sets).
|
|
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window));
|
|
auto [nativeW, nativeH] = NativeMonitorMode();
|
|
fprintf(stderr, "[video] screen %dx%d%s, monitor %dx%d, %s\n", mode ? mode->w : 0, mode ? mode->h : 0,
|
|
s_fullscreen ? " (fullscreen)" : "", nativeW, nativeH, s_gameMode ? "Game Mode (gamescope)" : "desktop");
|
|
// For the logs' system summary and bug reports (report/system_info.cpp).
|
|
{
|
|
const char* type = props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU ? "discrete"
|
|
: props.deviceType == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU ? "integrated"
|
|
: props.deviceType == VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU ? "virtual"
|
|
: props.deviceType == VK_PHYSICAL_DEVICE_TYPE_CPU ? "software" : "other";
|
|
std::string driver = std::format("driver version {:08X}", props.driverVersion);
|
|
if (props.apiVersion >= VK_API_VERSION_1_2)
|
|
{
|
|
VkPhysicalDeviceDriverProperties driverProps{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
|
|
VkPhysicalDeviceProperties2 props2{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2 };
|
|
props2.pNext = &driverProps;
|
|
vkGetPhysicalDeviceProperties2(s_physical, &props2);
|
|
driver = std::format("{} {}", driverProps.driverName, driverProps.driverInfo);
|
|
}
|
|
uint32_t loader = VK_API_VERSION_1_0;
|
|
vkEnumerateInstanceVersion(&loader);
|
|
report::SetSystemItem("GPU", std::format("{} ({}, {:04x}:{:04x}), {}, Vulkan {}.{}.{} (loader {}.{}.{})",
|
|
props.deviceName, type, props.vendorID, props.deviceID, driver, VK_API_VERSION_MAJOR(props.apiVersion),
|
|
VK_API_VERSION_MINOR(props.apiVersion), VK_API_VERSION_PATCH(props.apiVersion), VK_API_VERSION_MAJOR(loader),
|
|
VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader)));
|
|
UpdateDisplayItem();
|
|
}
|
|
if (s_gameMode && mode && nativeW > 0 && int64_t(mode->w) * mode->h < int64_t(nativeW) * nativeH)
|
|
s_resolutionHint = std::format("Steam gives this game {}x{} of your {}x{} display. For full sharpness, set this game's "
|
|
"Properties > General > Game Resolution to Native in Steam.", mode->w, mode->h, nativeW, nativeH);
|
|
}
|
|
s_uiReady = ui::Initialize(s_window, { s_instance, s_physical, s_device, s_queueFamily, s_queue, &s_queueMutex,
|
|
VK_API_VERSION_1_2, s_swapFormat });
|
|
if (!s_resolutionHint.empty())
|
|
{
|
|
fprintf(stderr, "[video] %s\n", s_resolutionHint.c_str());
|
|
ui::Toast(s_resolutionHint, 15.0);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
namespace
|
|
{
|
|
// The bug report's "Display" line: at start, and again with each
|
|
// swapchain of a new size (it said what the window was at launch).
|
|
void UpdateDisplayItem()
|
|
{
|
|
const SDL_DisplayMode* mode = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(s_window));
|
|
static const std::pair<int, int> native = NativeMonitorMode();
|
|
const auto [nativeW, nativeH] = native;
|
|
int windowW = 0, windowH = 0;
|
|
SDL_GetWindowSizeInPixels(s_window, &windowW, &windowH);
|
|
const char* name = SDL_GetDisplayName(SDL_GetDisplayForWindow(s_window));
|
|
report::SetSystemItem("Display", std::format("{} {}x{} at {:.2f} Hz{}; window {}x{} pixels{}, "
|
|
"swapchain {}x{}, V-Sync {}; SDL video driver {}; {}", name ? name : "?", mode ? mode->w : 0, mode ? mode->h : 0,
|
|
mode ? mode->refresh_rate : 0.0f, nativeW > 0 ? std::format(", monitor {}x{}", nativeW, nativeH) : std::string(),
|
|
windowW, windowH, s_fullscreen ? " (fullscreen)" : "",
|
|
s_swapExtent.width, s_swapExtent.height, s_vsync ? "on" : "off", SDL_GetCurrentVideoDriver(),
|
|
s_gameMode ? "Game Mode (gamescope)" : "desktop"));
|
|
}
|
|
}
|
|
|
|
void ReleaseFrontImage(VkImage image)
|
|
{
|
|
std::lock_guard use(s_frontUseMutex);
|
|
std::lock_guard lock(s_pendingMutex);
|
|
if (s_pendingImage == image)
|
|
{
|
|
s_pendingImage = VK_NULL_HANDLE;
|
|
s_pendingNew = !s_pending.empty();
|
|
}
|
|
if (s_lastImage == image)
|
|
{
|
|
s_lastImage = VK_NULL_HANDLE;
|
|
s_lastWidth = s_lastHeight = 0;
|
|
}
|
|
}
|
|
|
|
void SubmitFrontBuffer(const uint32_t fetch[6], uint32_t width, uint32_t height)
|
|
{
|
|
static bool logged = false;
|
|
if (!logged)
|
|
{
|
|
logged = true;
|
|
fprintf(stderr, "[video] first front buffer: %ux%u, fetch %08X %08X %08X %08X %08X %08X, window %s\n",
|
|
width, height, fetch[0], fetch[1], fetch[2], fetch[3], fetch[4], fetch[5], s_window ? "yes" : "no");
|
|
}
|
|
if (!s_window)
|
|
return;
|
|
bool tiled = (fetch[0] >> 31) & 1;
|
|
uint32_t pitch = ((fetch[0] >> 22) & 0x1FF) << 5; // texels, 32-aligned
|
|
uint32_t format = fetch[1] & 0x3F;
|
|
uint32_t endian = (fetch[1] >> 6) & 3;
|
|
uint32_t swizzle = (fetch[3] >> 1) & 0xFFF;
|
|
uint32_t base = fetch[1] & 0xFFFFF000; // physical
|
|
if (pitch == 0)
|
|
pitch = (width + 31) & ~31u;
|
|
|
|
const uint8_t* src = static_cast<const uint8_t*>(g_memory.Translate(vmem::Physical().Base() | (base & 0x1FFFFFFF)));
|
|
std::vector<uint32_t> pixels(size_t(width) * height);
|
|
for (uint32_t y = 0; y < height; y++)
|
|
{
|
|
for (uint32_t x = 0; x < width; x++)
|
|
{
|
|
uint32_t offset = tiled ? Tiled2D(x, y, pitch, 2) : (y * pitch + x) * 4;
|
|
uint32_t raw;
|
|
memcpy(&raw, src + offset, 4);
|
|
// The GPU reads the little-endian word, applies the fetch
|
|
// constant's endian swap, and x/y/z/w are then the low-to-high
|
|
// components; the fetch swizzle picks R, G and B from them.
|
|
uint32_t v = raw;
|
|
switch (endian)
|
|
{
|
|
case 1: v = ((v << 8) & 0xFF00FF00u) | ((v >> 8) & 0x00FF00FFu); break;
|
|
case 2: v = ByteSwap(v); break;
|
|
case 3: v = (v >> 16) | (v << 16); break;
|
|
default: break;
|
|
}
|
|
uint32_t comp[6];
|
|
if (format == 54 || format == 7) // 2_10_10_10 (optionally AS_16_16_16_16)
|
|
{
|
|
comp[0] = ((v >> 0) & 0x3FF) >> 2;
|
|
comp[1] = ((v >> 10) & 0x3FF) >> 2;
|
|
comp[2] = ((v >> 20) & 0x3FF) >> 2;
|
|
comp[3] = (v >> 30) * 85;
|
|
}
|
|
else // k_8_8_8_8
|
|
{
|
|
for (int c = 0; c < 4; c++)
|
|
comp[c] = (v >> (8 * c)) & 0xFF;
|
|
}
|
|
comp[4] = 0;
|
|
comp[5] = 255;
|
|
auto pick = [&](uint32_t sel) { return comp[std::min(sel & 7, 5u)]; };
|
|
uint32_t bgra = 0xFF000000u | (pick(swizzle) << 16) | (pick(swizzle >> 3) << 8) | pick(swizzle >> 6);
|
|
pixels[size_t(y) * width + x] = bgra;
|
|
}
|
|
}
|
|
// NFSMW_DUMP_FRAMES=<dir>: save every 60th front buffer as a PPM.
|
|
static const char* dumpDir = std::getenv("NFSMW_DUMP_FRAMES");
|
|
static uint32_t frameNumber = 0;
|
|
if (dumpDir && (frameNumber++ % 60) == 0)
|
|
{
|
|
char path[512];
|
|
snprintf(path, sizeof(path), "%s/frame_%05u.ppm", dumpDir, frameNumber - 1);
|
|
if (FILE* f = fopen(path, "wb"))
|
|
{
|
|
fprintf(f, "P6\n%u %u\n255\n", width, height);
|
|
for (uint32_t p : pixels)
|
|
{
|
|
uint8_t rgb[3] = { uint8_t(p >> 16), uint8_t(p >> 8), uint8_t(p) };
|
|
fwrite(rgb, 1, 3, f);
|
|
}
|
|
fclose(f);
|
|
}
|
|
}
|
|
|
|
std::lock_guard lock(s_pendingMutex);
|
|
if (s_pendingNew)
|
|
s_dropped++;
|
|
s_pending = std::move(pixels);
|
|
s_pendingWidth = width;
|
|
s_pendingHeight = height;
|
|
s_pendingAspectW = s_pendingAspectH = 0;
|
|
s_pendingNew = true;
|
|
s_pendingVblank = s_guestVblank.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
namespace
|
|
{
|
|
// NFSMW_WINDOW_AT=<s>:<w>x<h>,...: resize the window at those seconds
|
|
// (NFSMW_UI_KEYS's clock), to test what follows the window's size,
|
|
// such as the Auto internal resolution, without a person. A step of
|
|
// <s>:full or <s>:windowed toggles fullscreen there instead (on
|
|
// macOS a fullscreen Space, as the green button makes).
|
|
void ScriptedWindowSizes()
|
|
{
|
|
struct Step { double at; int w, h; }; // w -1: fullscreen, -2: windowed
|
|
static std::vector<Step> steps = [] {
|
|
std::vector<Step> out;
|
|
const char* v = std::getenv("NFSMW_WINDOW_AT");
|
|
for (const char* p = v; p && *p;)
|
|
{
|
|
Step step{};
|
|
char word[16] = {};
|
|
if (sscanf(p, "%lf:%dx%d", &step.at, &step.w, &step.h) == 3 && step.w > 0 && step.h > 0)
|
|
out.push_back(step);
|
|
else if (sscanf(p, "%lf:%15[a-z]", &step.at, word) == 2 && (!strcmp(word, "full") || !strcmp(word, "windowed")))
|
|
{
|
|
step.w = word[0] == 'f' ? -1 : -2;
|
|
out.push_back(step);
|
|
}
|
|
p = strchr(p, ',');
|
|
p = p ? p + 1 : nullptr;
|
|
}
|
|
return out;
|
|
}();
|
|
static const auto start = std::chrono::steady_clock::now();
|
|
static size_t next = 0;
|
|
if (next >= steps.size() ||
|
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count() < steps[next].at)
|
|
return;
|
|
const Step& step = steps[next++];
|
|
if (step.w < 0)
|
|
{
|
|
fprintf(stderr, "[video] NFSMW_WINDOW_AT: %s\n", step.w == -1 ? "fullscreen" : "windowed");
|
|
SDL_SetWindowFullscreen(s_window, step.w == -1);
|
|
return;
|
|
}
|
|
fprintf(stderr, "[video] NFSMW_WINDOW_AT: window %dx%d\n", step.w, step.h);
|
|
SDL_SetWindowSize(s_window, step.w, step.h);
|
|
}
|
|
}
|
|
|
|
namespace
|
|
{
|
|
// SDL calls this as the app event happens (on iOS, on the main
|
|
// thread inside SDL_PollEvent, in UIKit's scene callbacks): the GPU
|
|
// must be idle before the callback returns, not at the next frame.
|
|
// From "will resign active" (Control Center, a call, the app
|
|
// switcher) to "did become active", not just while in the background:
|
|
// the game stops as soon as it isn't the app in front. UIKit reports
|
|
// each transition twice, so every case here is idempotent.
|
|
bool SDLCALL OnAppEvent(void*, SDL_Event* e)
|
|
{
|
|
switch (e->type)
|
|
{
|
|
case SDL_EVENT_WILL_ENTER_BACKGROUND:
|
|
SuspendGame();
|
|
break;
|
|
case SDL_EVENT_DID_ENTER_BACKGROUND:
|
|
// Suspended already, unless "will resign active" never came:
|
|
// Metal must be idle in the background either way.
|
|
SuspendGame();
|
|
[[fallthrough]];
|
|
case SDL_EVENT_TERMINATING:
|
|
// The system may end a background app without another word.
|
|
if (settings::Dirty())
|
|
settings::Save();
|
|
report::FlushLog();
|
|
break;
|
|
case SDL_EVENT_DID_ENTER_FOREGROUND:
|
|
ResumeGame();
|
|
break;
|
|
default:
|
|
// SDL_EVENT_WILL_ENTER_FOREGROUND among them: the app is
|
|
// still inactive then.
|
|
break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// NFSMW_TEST_SUSPEND's app events, waiting for the main thread.
|
|
std::mutex s_testEventsMutex;
|
|
std::vector<uint32_t> s_testEvents;
|
|
std::atomic<bool> s_testEventsWaiting{ false }; // so a frame without any takes no lock
|
|
|
|
const char* AppEventName(uint32_t type)
|
|
{
|
|
switch (type)
|
|
{
|
|
case SDL_EVENT_WILL_ENTER_BACKGROUND:
|
|
return "SDL_EVENT_WILL_ENTER_BACKGROUND";
|
|
case SDL_EVENT_DID_ENTER_BACKGROUND:
|
|
return "SDL_EVENT_DID_ENTER_BACKGROUND";
|
|
case SDL_EVENT_WILL_ENTER_FOREGROUND:
|
|
return "SDL_EVENT_WILL_ENTER_FOREGROUND";
|
|
case SDL_EVENT_DID_ENTER_FOREGROUND:
|
|
return "SDL_EVENT_DID_ENTER_FOREGROUND";
|
|
case SDL_EVENT_TERMINATING:
|
|
return "SDL_EVENT_TERMINATING";
|
|
default:
|
|
return "an unknown event";
|
|
}
|
|
}
|
|
|
|
// First thing in a frame: the same handler, thread and place in the
|
|
// frame as on iOS (inside SDL_PollEvent, before any of the frame's
|
|
// work), and twice, as UIKit delivers them.
|
|
void DeliverTestAppEvents()
|
|
{
|
|
if (!s_testEventsWaiting.exchange(false, std::memory_order_acquire))
|
|
return;
|
|
std::vector<uint32_t> events;
|
|
{
|
|
std::lock_guard lock(s_testEventsMutex);
|
|
events.swap(s_testEvents);
|
|
}
|
|
static int delivered = 0;
|
|
for (uint32_t type : events)
|
|
for (int k = 0; k < 2; k++)
|
|
{
|
|
fprintf(stderr, "[test] NFSMW_TEST_SUSPEND: deliver %s (#%d)\n", AppEventName(type), ++delivered);
|
|
SDL_Event e{};
|
|
e.type = type;
|
|
OnAppEvent(nullptr, &e);
|
|
}
|
|
}
|
|
|
|
// What the game's clocks and counters stood at, for the [suspend]
|
|
// lines. Only the thread that suspends and resumes the game uses
|
|
// them (see SuspendGame).
|
|
struct SuspendMarks
|
|
{
|
|
double session = 0; // the log's clock (s)
|
|
int64_t host = 0; // steady clock (ns)
|
|
int64_t guest = 0, timebase = 0, system = 0;
|
|
uint64_t vblanks = 0, audioFrames = 0, timerFirings = 0;
|
|
};
|
|
SuspendMarks s_suspendedAt, s_resumedAt;
|
|
bool s_auditPending = false;
|
|
uint64_t s_auditEpoch = 0; // the suspension before the resume the audit follows
|
|
|
|
SuspendMarks TakeMarks()
|
|
{
|
|
SuspendMarks m;
|
|
m.session = report::SessionSeconds();
|
|
m.host = guesttime::RawNs();
|
|
m.guest = guesttime::NowNs();
|
|
m.timebase = guesttime::Timebase();
|
|
m.system = guesttime::SystemTime100ns();
|
|
m.vblanks = GuestVblanks();
|
|
m.audioFrames = apu::RenderedFrames();
|
|
m.timerFirings = dispatcher::TimerFirings();
|
|
return m;
|
|
}
|
|
|
|
// Guest seconds since the session began: the log's clock less the
|
|
// time spent suspended (guest = host - offset, or frozen).
|
|
double GuestSessionSeconds(const SuspendMarks& m)
|
|
{
|
|
return m.session - double(m.host - m.guest) / 1e9;
|
|
}
|
|
}
|
|
|
|
void WaitForeground(std::unique_lock<std::mutex>& queueLock)
|
|
{
|
|
s_foreground.wait(queueLock, [] { return !s_background.load(std::memory_order_relaxed); });
|
|
}
|
|
|
|
// NFSMW_TEST_SUSPEND_GPU_ONLY=1: hold only the GPU, as before the game's
|
|
// clocks could stop (7303475): scripts/test_suspend.sh's red baseline, in
|
|
// which the movie runs ahead after each resume.
|
|
static bool SuspendGpuOnly()
|
|
{
|
|
static const bool only = [] {
|
|
const char* v = std::getenv("NFSMW_TEST_SUSPEND_GPU_ONLY");
|
|
return v && v[0] == '1';
|
|
}();
|
|
return only;
|
|
}
|
|
|
|
void SuspendGame()
|
|
{
|
|
if (s_window)
|
|
s_swapPolicy.Suspended();
|
|
// 1. Every guest clock stops at one instant. The game's threads, its
|
|
// timers, the vblank and the audio cadence then park on their own:
|
|
// nothing here waits for them.
|
|
bool changed = !SuspendGpuOnly() && guesttime::Suspend();
|
|
if (changed)
|
|
{
|
|
s_suspendedAt = TakeMarks();
|
|
// 2. The output plays silence and keeps what is queued for the
|
|
// resume. Now, not after the drain: nothing renders from here, so
|
|
// a device still playing through it would empty the queue.
|
|
apu::SuspendOutput();
|
|
}
|
|
// 3. Then no GPU work may start (Metal refuses it from an inactive
|
|
// app). What was submitted finishes, its completions running their
|
|
// guest callbacks at the frozen time.
|
|
{
|
|
std::lock_guard lock(s_queueMutex);
|
|
if (!s_background.load(std::memory_order_relaxed) && s_queue)
|
|
{
|
|
s_background.store(true, std::memory_order_release);
|
|
vkQueueWaitIdle(s_queue);
|
|
if (SuspendGpuOnly())
|
|
{
|
|
s_visible.store(false, std::memory_order_relaxed); // as 7303475 did: the watchdog pauses
|
|
fprintf(stderr, "[suspend] GPU only: held at vblank %llu\n", (unsigned long long)GuestVblanks());
|
|
}
|
|
}
|
|
}
|
|
if (!changed)
|
|
return;
|
|
fprintf(stderr, "[suspend] suspended: the game's clocks stop at guest %.3f s (vblank %llu, audio frame %llu)\n",
|
|
GuestSessionSeconds(s_suspendedAt), (unsigned long long)s_suspendedAt.vblanks,
|
|
(unsigned long long)s_suspendedAt.audioFrames);
|
|
}
|
|
|
|
void ResumeGame()
|
|
{
|
|
// 1. GPU work may start: a submit parked in WaitForeground goes out,
|
|
// still at the frozen time.
|
|
bool held;
|
|
{
|
|
std::lock_guard lock(s_queueMutex);
|
|
held = s_background.exchange(false, std::memory_order_acq_rel);
|
|
}
|
|
if (s_window)
|
|
s_swapPolicy.Resumed(); // the swapchain is checked against the surface for a while
|
|
if (held)
|
|
{
|
|
s_foreground.notify_all();
|
|
if (SuspendGpuOnly())
|
|
{
|
|
s_visible.store(true, std::memory_order_relaxed);
|
|
fprintf(stderr, "[suspend] GPU only: released at vblank %llu\n", (unsigned long long)GuestVblanks());
|
|
}
|
|
}
|
|
// 2. Then guest time moves on. What the counters did meanwhile is
|
|
// read before, so nothing that runs once time moves counts.
|
|
uint64_t vblanks = GuestVblanks(), audioFrames = apu::RenderedFrames(), firings = dispatcher::TimerFirings();
|
|
if (SuspendGpuOnly() || !guesttime::Resume())
|
|
return;
|
|
s_resumedAt = TakeMarks();
|
|
// 3. The output plays again, what was queued at the freeze first.
|
|
apu::ResumeOutput();
|
|
const SuspendMarks& a = s_suspendedAt;
|
|
const SuspendMarks& b = s_resumedAt;
|
|
fprintf(stderr, "[suspend] resumed after %.3f s: guest time %+.6f s, mftb %+lld ticks, system time %+.6f s; "
|
|
"meanwhile %llu vblanks, %llu audio frames, %llu timer firings\n",
|
|
double(b.host - a.host) / 1e9, double(b.guest - a.guest) / 1e9, (long long)(b.timebase - a.timebase),
|
|
double(b.system - a.system) / 1e7, (unsigned long long)(vblanks - a.vblanks),
|
|
(unsigned long long)(audioFrames - a.audioFrames), (unsigned long long)(firings - a.timerFirings));
|
|
s_auditPending = true;
|
|
s_auditEpoch = guesttime::Epoch();
|
|
}
|
|
|
|
void SuspendAudit()
|
|
{
|
|
if (!s_auditPending || guesttime::RawNs() - s_resumedAt.host < 2'000'000'000)
|
|
return;
|
|
s_auditPending = false;
|
|
// Suspended again since: the numbers would span that suspension too.
|
|
if (guesttime::Epoch() != s_auditEpoch)
|
|
return;
|
|
SuspendMarks now = TakeMarks();
|
|
const SuspendMarks& r = s_resumedAt;
|
|
double host = double(now.host - r.host) / 1e9, guestNs = double(now.guest - r.guest);
|
|
// A guest vblank fires every GuestVblankDivider() display periods.
|
|
double vblankNs = GuestVblank().PeriodNs() * GuestVblankDivider();
|
|
fprintf(stderr, "[suspend] %.1f s after resuming: guest %.3f s, mftb %.3f s (host %.3f s), %llu vblanks (%.1f expected), "
|
|
"%llu audio frames (%.1f expected), audio queued %.1f ms\n",
|
|
host, guestNs / 1e9, double(now.timebase - r.timebase) / double(guesttime::kTimebaseHz), host,
|
|
(unsigned long long)(now.vblanks - r.vblanks), guestNs / vblankNs, (unsigned long long)(now.audioFrames - r.audioFrames),
|
|
guestNs / apu::kFrameNs, apu::QueuedMs());
|
|
}
|
|
|
|
void TestAppEvent(uint32_t type)
|
|
{
|
|
std::lock_guard lock(s_testEventsMutex);
|
|
s_testEvents.push_back(type);
|
|
s_testEventsWaiting.store(true, std::memory_order_release);
|
|
}
|
|
|
|
namespace
|
|
{
|
|
// Builds the swapchain again when the policy says so
|
|
// (swapchain_policy.h), or (iOS) fits SDL's view back to its window.
|
|
// Once a frame, before the acquire. Main thread.
|
|
void CheckSwapchain()
|
|
{
|
|
SurfaceState state;
|
|
state.active = !s_background.load(std::memory_order_acquire);
|
|
state.swapchain = s_swapchain ? Extent{ s_swapExtent.width, s_swapExtent.height } : Extent{};
|
|
if (s_swapPolicy.WantsSurface()) // active or not: the policy holds off while inactive
|
|
{
|
|
state.surfaceRead = true;
|
|
state.surface = SurfaceExtent();
|
|
state.interface = InterfaceNow();
|
|
state.landscapeOnly = LandscapeOnlyHere();
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
state.viewFitsWindow = platform::ios::ViewFitsWindow(s_window);
|
|
// Turning the phone over (landscape left and right) changes
|
|
// no size, so nothing else would say so.
|
|
static const char* lastScene = nullptr;
|
|
if (const char* scene = platform::ios::SceneOrientationName(s_window); scene != lastScene)
|
|
{
|
|
if (lastScene)
|
|
fprintf(stderr, "[video] the interface turned: %s -> %s (swapchain %ux%u, surface %ux%u)\n", lastScene, scene,
|
|
s_swapExtent.width, s_swapExtent.height, state.surface.width, state.surface.height);
|
|
lastScene = scene;
|
|
}
|
|
#endif
|
|
}
|
|
const SwapDecision d = s_swapPolicy.Decide(state);
|
|
if (d.settled)
|
|
fprintf(stderr, "[video] %u frames after the resume: swapchain %ux%u, surface %ux%u%s\n", s_swapPolicy.Rules().settleFrames,
|
|
s_swapExtent.width, s_swapExtent.height, state.surface.width, state.surface.height, GeometryNote().c_str());
|
|
if (d.action == SwapAction::RepairView)
|
|
{
|
|
fprintf(stderr, "[video] size check: %s (surface %ux%u, swapchain %ux%u, interface %s)\n", d.reason, state.surface.width,
|
|
state.surface.height, state.swapchain.width, state.swapchain.height, OrientationName(state.interface));
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
platform::ios::FitViewToWindow(s_window); // SDL's resize events follow; the next frames look again
|
|
#endif
|
|
}
|
|
else if (d.action == SwapAction::Recreate)
|
|
{
|
|
WaitIdle();
|
|
DestroySwapchain();
|
|
s_swapReason = d.reason;
|
|
CreateSwapchain(); // at ChooseExtent of the surface then (d.extent, unless it moved since)
|
|
s_swapReason = nullptr;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool RunFrame()
|
|
{
|
|
DeliverTestAppEvents();
|
|
ui::Tick();
|
|
platform::thermal::Poll(); // about once a second
|
|
memstatus::Poll(); // iOS: the memory footprint, once a minute
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
platform::ios::PollBattery(); // every 5 seconds
|
|
#endif
|
|
ScriptedWindowSizes();
|
|
SDL_Event e;
|
|
while (SDL_PollEvent(&e))
|
|
{
|
|
ui::ProcessEvent(e);
|
|
hid::HandleEvent(e);
|
|
if (e.type == SDL_EVENT_QUIT || e.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED)
|
|
return false;
|
|
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED || e.type == SDL_EVENT_WINDOW_RESIZED ||
|
|
e.type == SDL_EVENT_WINDOW_METAL_VIEW_RESIZED || e.type == SDL_EVENT_DISPLAY_ORIENTATION)
|
|
{
|
|
// Only a reason to look: CheckSwapchain compares the surface
|
|
// with the swapchain, once the app is active (a size seen
|
|
// while it isn't may be iOS's snapshot of it, or SDL's guess).
|
|
s_swapPolicy.SizeChanged();
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
if (e.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED)
|
|
fprintf(stderr, "[video] window pixels %dx%d%s%s\n", e.window.data1, e.window.data2,
|
|
s_background.load(std::memory_order_acquire) ? " (app inactive: checked after the resume)" : "", GeometryNote().c_str());
|
|
#endif
|
|
}
|
|
if (e.type == SDL_EVENT_WINDOW_RESIZED)
|
|
RememberWindowSize();
|
|
else if (e.type == SDL_EVENT_WINDOW_FOCUS_GAINED || e.type == SDL_EVENT_WINDOW_FOCUS_LOST)
|
|
s_focused.store(e.type == SDL_EVENT_WINDOW_FOCUS_GAINED, std::memory_order_relaxed);
|
|
else if (e.type == SDL_EVENT_WINDOW_MINIMIZED || e.type == SDL_EVENT_WINDOW_HIDDEN || e.type == SDL_EVENT_WINDOW_OCCLUDED)
|
|
s_visible.store(false, std::memory_order_relaxed);
|
|
else if (e.type == SDL_EVENT_WINDOW_RESTORED || e.type == SDL_EVENT_WINDOW_SHOWN || e.type == SDL_EVENT_WINDOW_EXPOSED ||
|
|
e.type == SDL_EVENT_WINDOW_MAXIMIZED)
|
|
s_visible.store(true, std::memory_order_relaxed);
|
|
else if (e.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN || e.type == SDL_EVENT_WINDOW_LEAVE_FULLSCREEN)
|
|
{
|
|
// Also when the system toggled it (the green button on macOS).
|
|
s_fullscreen = e.type == SDL_EVENT_WINDOW_ENTER_FULLSCREEN;
|
|
if (!FullscreenLocked() && !settings::EnvOverride(settings::Id::Fullscreen))
|
|
settings::Set(settings::Id::Fullscreen, s_fullscreen);
|
|
}
|
|
}
|
|
// Without the queue lock: the command processor's submits take it,
|
|
// and the main thread would hold it nearly all the suspension.
|
|
if (s_background.load(std::memory_order_acquire))
|
|
{
|
|
// A rumble the game asked for can last seconds: it stops with
|
|
// the game (here, not inside UIKit's callback).
|
|
if (!s_inputSuspended)
|
|
{
|
|
hid::Suspend();
|
|
s_inputSuspended = true;
|
|
}
|
|
SDL_Delay(16);
|
|
return true;
|
|
}
|
|
if (s_inputSuspended)
|
|
{
|
|
hid::Resume(); // the game's last rumble again
|
|
s_inputSuspended = false;
|
|
}
|
|
CheckSwapchain();
|
|
SuspendAudit();
|
|
hid::SetBlocked(ui::CapturingInput());
|
|
hid::Update();
|
|
ApplySettings();
|
|
if (!s_swapchain && !CreateSwapchain())
|
|
{
|
|
SDL_Delay(16);
|
|
return true;
|
|
}
|
|
|
|
// Held until the frame that uses the renderer's image is submitted
|
|
// (ReleaseFrontImage waits for it before that image is destroyed).
|
|
std::unique_lock frontLock(s_frontUseMutex);
|
|
std::vector<uint32_t> pixels;
|
|
uint32_t w = 0, h = 0, aspectW = 0, aspectH = 0;
|
|
VkImage gpuImage = VK_NULL_HANDLE;
|
|
int64_t frameVblank = 0;
|
|
{
|
|
std::lock_guard lock(s_pendingMutex);
|
|
if (s_pendingNew)
|
|
{
|
|
pixels.swap(s_pending);
|
|
gpuImage = s_pendingImage;
|
|
s_pendingImage = VK_NULL_HANDLE;
|
|
w = s_pendingWidth;
|
|
h = s_pendingHeight;
|
|
aspectW = s_pendingAspectW;
|
|
aspectH = s_pendingAspectH;
|
|
s_pendingNew = false;
|
|
frameVblank = s_pendingVblank;
|
|
}
|
|
}
|
|
const bool upload = !pixels.empty();
|
|
s_lastFreshImage = gpuImage != VK_NULL_HANDLE; // this frame is new (not a redraw)
|
|
if (!upload && !s_lastFreshImage)
|
|
frameVblank = 0;
|
|
if (upload || gpuImage)
|
|
{
|
|
s_lastImage = gpuImage; // null: the CPU path's frame stays in s_source
|
|
s_lastWidth = w;
|
|
s_lastHeight = h;
|
|
s_lastAspectW = aspectW;
|
|
s_lastAspectH = aspectH;
|
|
s_lastGameFrame = std::chrono::steady_clock::now();
|
|
ui::NoteGameFrame();
|
|
}
|
|
else
|
|
{
|
|
if (!ui::WantsFrames())
|
|
{
|
|
frontLock.unlock();
|
|
SDL_Delay(2);
|
|
return true;
|
|
}
|
|
// The game has stopped presenting (loading, or not started yet):
|
|
// keep the UI moving at about 60 Hz over the last frame. While
|
|
// the game presents, the UI rides its frames; a redraw between
|
|
// them would only delay the next one behind the vblank.
|
|
auto now = std::chrono::steady_clock::now();
|
|
if (now - s_lastGameFrame < std::chrono::milliseconds(50) || now - s_lastPresent < std::chrono::milliseconds(15))
|
|
{
|
|
frontLock.unlock();
|
|
SDL_Delay(1);
|
|
return true;
|
|
}
|
|
gpuImage = s_lastImage;
|
|
w = s_lastWidth;
|
|
h = s_lastHeight;
|
|
aspectW = s_lastAspectW;
|
|
aspectH = s_lastAspectH;
|
|
if (!gpuImage && !(s_source && s_sourceWidth == w && s_sourceHeight == h))
|
|
w = h = 0;
|
|
}
|
|
const bool haveSource = w && h;
|
|
const bool uiFrame = s_uiReady && ui::BeginFrame();
|
|
// Screenshots asked for (CaptureFrame): this frame's game image,
|
|
// taken before the UI is drawn over it. Without one, none.
|
|
std::vector<std::promise<CapturedFrame>> captures;
|
|
{
|
|
std::lock_guard lock(s_captureMutex);
|
|
captures.swap(s_captureRequests);
|
|
}
|
|
if (!captures.empty() && !(haveSource && s_swapReadable))
|
|
{
|
|
for (auto& promise : captures)
|
|
promise.set_value({});
|
|
captures.clear();
|
|
}
|
|
RegionCapture gameCapture;
|
|
|
|
Frame& f = s_frames[s_frameIndex];
|
|
vkWaitForFences(s_device, 1, &f.inFlight, VK_TRUE, UINT64_MAX);
|
|
if (f.timed)
|
|
{
|
|
uint64_t ts[3];
|
|
if (vkGetQueryPoolResults(s_device, s_timestamps, s_frameIndex * 3, 3, sizeof(ts), ts, sizeof(uint64_t),
|
|
VK_QUERY_RESULT_64_BIT) == VK_SUCCESS && ts[2] >= ts[1] && ts[1] >= ts[0])
|
|
{
|
|
s_postFrames++;
|
|
s_postAaNs += uint64_t(double(ts[1] - ts[0]) * s_timestampPeriod);
|
|
s_postNs += uint64_t(double(ts[2] - ts[0]) * s_timestampPeriod);
|
|
}
|
|
f.timed = false;
|
|
}
|
|
if (f.sourceView)
|
|
{
|
|
vkDestroyImageView(s_device, f.sourceView, nullptr); // the last use of this frame slot has finished
|
|
f.sourceView = VK_NULL_HANDLE;
|
|
}
|
|
uint32_t imageIndex;
|
|
VkResult acquire = vkAcquireNextImageKHR(s_device, s_swapchain, UINT64_MAX, f.imageAvailable, VK_NULL_HANDLE, &imageIndex);
|
|
// Either is for the next frame's CheckSwapchain, which runs before
|
|
// its acquire (one decision a frame: the policy counts in frames).
|
|
if (acquire == VK_SUBOPTIMAL_KHR)
|
|
s_swapPolicy.Reported(SurfaceResult::Suboptimal); // this frame is drawn and presented anyway
|
|
if (acquire == VK_ERROR_OUT_OF_DATE_KHR)
|
|
{
|
|
s_swapPolicy.Reported(SurfaceResult::OutOfDate);
|
|
// Screenshots wait for the next frame drawn.
|
|
std::lock_guard lock(s_captureMutex);
|
|
for (auto& promise : captures)
|
|
s_captureRequests.push_back(std::move(promise));
|
|
return true;
|
|
}
|
|
vkResetFences(s_device, 1, &f.inFlight);
|
|
|
|
if (upload)
|
|
{
|
|
EnsureSource(w, h);
|
|
EnsureStaging(f, VkDeviceSize(w) * h * 4);
|
|
memcpy(f.stagingMapped, pixels.data(), size_t(w) * h * 4);
|
|
}
|
|
|
|
VkCommandBuffer cmd = f.cmd;
|
|
vkResetCommandBuffer(cmd, 0);
|
|
VkCommandBufferBeginInfo bi{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO };
|
|
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
|
vkBeginCommandBuffer(cmd, &bi);
|
|
|
|
VkImage source = gpuImage;
|
|
VkImageLayout sourceLayout = VK_IMAGE_LAYOUT_GENERAL;
|
|
if (gpuImage)
|
|
{
|
|
// Written by the renderer's earlier submission: a memory barrier
|
|
// orders this read after it (and its next write after this read).
|
|
VkMemoryBarrier mb{ VK_STRUCTURE_TYPE_MEMORY_BARRIER };
|
|
mb.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT;
|
|
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
|
|
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &mb, 0, nullptr, 0, nullptr);
|
|
}
|
|
else if (haveSource)
|
|
{
|
|
// The CPU path's frame, uploaded now or (a redraw) still there
|
|
// from an earlier frame; it is left in TRANSFER_SRC between frames.
|
|
source = s_source;
|
|
sourceLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
|
|
if (upload)
|
|
{
|
|
// After the other frame in flight's reads (blit, AA, scaling).
|
|
Barrier(cmd, s_source, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0,
|
|
VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
VkBufferImageCopy copy{};
|
|
copy.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
|
|
copy.imageExtent = { w, h, 1 };
|
|
vkCmdCopyBufferToImage(cmd, f.staging, s_source, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©);
|
|
Barrier(cmd, s_source, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
|
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
}
|
|
}
|
|
|
|
VkImage dst = s_swapImages[imageIndex];
|
|
VkBuffer captureBuffer = VK_NULL_HANDLE;
|
|
VkDeviceMemory captureMemory = VK_NULL_HANDLE;
|
|
VkDeviceSize captureSize = 0;
|
|
int captureIndex = -1;
|
|
// Letterbox to the source aspect ratio; or, on a screen of another
|
|
// shape with Aspect Ratio on Auto, fill it (video/picture_fit): the
|
|
// game draws that frame with its view widened (a wider screen, Hor+)
|
|
// or made taller (a narrower one, Vert+) by the same factor
|
|
// (game/ultrawide). (The frame's shape, not the image's: at a
|
|
// non-uniform internal resolution such as 3x2 they differ.)
|
|
float frameW = float(aspectW ? aspectW : w), frameH = float(aspectH ? aspectH : h);
|
|
int32_t dw = int32_t(frameW), dh = int32_t(frameH);
|
|
float frameScaleX = 1.0f, frameScaleY = 1.0f; // the view scales this frame is shown for
|
|
if (haveSource)
|
|
{
|
|
// A new frame decides (and tells the game for the next ones); a
|
|
// redraw keeps the shape its frame was rendered with. Only with
|
|
// the hooks in the recompiled code (a stale ppc/ lacks them, or
|
|
// the Vert+ ones).
|
|
PictureFit fit;
|
|
if (upload || s_lastFreshImage)
|
|
{
|
|
const bool fillMode = settings::GetInt(settings::Id::AspectRatio) == int32_t(settings::AspectMode::Auto);
|
|
fit = FitPicture(s_swapExtent.width, s_swapExtent.height, frameW, frameH, fillMode && game::HooksActive(),
|
|
fillMode && game::TallHooksActive());
|
|
game::SetAspectScale(fit.scaleX, fit.scaleY);
|
|
s_lastScaleX = fit.scaleX;
|
|
s_lastScaleY = fit.scaleY;
|
|
}
|
|
else
|
|
fit = PlacePicture(s_swapExtent.width, s_swapExtent.height, frameW, frameH, s_lastScaleX, s_lastScaleY);
|
|
dw = fit.width;
|
|
dh = fit.height;
|
|
frameScaleX = fit.scaleX;
|
|
frameScaleY = fit.scaleY;
|
|
}
|
|
// Under Hor+ and Vert+ the post-processing keeps to each side of the
|
|
// 16:9 band's edge (video/band_filter.h): the frame's band and the
|
|
// strips past it are filtered as if each were the whole image.
|
|
// NFSMW_BAND_FILTER=0 filters across it as before (an A/B switch).
|
|
static const bool s_bandFilter = [] {
|
|
const char* v = std::getenv("NFSMW_BAND_FILTER");
|
|
const bool on = !(v && v[0] == '0');
|
|
if (!on)
|
|
fprintf(stderr, "[video] NFSMW_BAND_FILTER=0: anti-aliasing and scaling read across the 16:9 band's edge\n");
|
|
return on;
|
|
}();
|
|
const video::FrameBand band = video::BandOf(w, h, frameScaleX, frameScaleY);
|
|
const bool bandOn = s_bandFilter && (band.x0 > 0 || band.x1 < int32_t(w) || band.y0 > 0 || band.y1 < int32_t(h));
|
|
int32_t dx = (int32_t(s_swapExtent.width) - dw) / 2, dy = (int32_t(s_swapExtent.height) - dh) / 2;
|
|
// The picture without its letterbox bars, for a screenshot.
|
|
VkRect2D pictureRect{};
|
|
{
|
|
int32_t x0 = std::max(dx, 0), y0 = std::max(dy, 0);
|
|
int32_t x1 = std::min(dx + dw, int32_t(s_swapExtent.width)), y1 = std::min(dy + dh, int32_t(s_swapExtent.height));
|
|
if (x1 > x0 && y1 > y0)
|
|
pictureRect = { { x0, y0 }, { uint32_t(x1 - x0), uint32_t(y1 - y0) } };
|
|
}
|
|
VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
|
|
const bool post = haveSource && EnsurePostProcessing();
|
|
if (post || !haveSource)
|
|
{
|
|
// Draw into the swapchain image: the scaled game frame (if any),
|
|
// then the UI over it.
|
|
waitStage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
|
VkImageView scaleInput = VK_NULL_HANDLE;
|
|
if (post)
|
|
{
|
|
if (!gpuImage)
|
|
Barrier(cmd, s_source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL, VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
|
|
else
|
|
{
|
|
VkMemoryBarrier mb{ VK_STRUCTURE_TYPE_MEMORY_BARRIER };
|
|
mb.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT;
|
|
mb.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
|
|
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
|
0, 1, &mb, 0, nullptr, 0, nullptr);
|
|
}
|
|
VkImageViewCreateInfo vci{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO };
|
|
vci.image = source;
|
|
vci.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
|
vci.format = gpuImage ? VK_FORMAT_R8G8B8A8_UNORM : VK_FORMAT_B8G8R8A8_UNORM; // the renderer's front image / s_source
|
|
vci.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
|
vkCreateImageView(s_device, &vci, nullptr, &f.sourceView);
|
|
scaleInput = f.sourceView;
|
|
if (s_timestamps)
|
|
{
|
|
// From when the work before it is done (the renderer's frame).
|
|
vkCmdResetQueryPool(cmd, s_timestamps, s_frameIndex * 3, 3);
|
|
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, s_timestamps, s_frameIndex * 3);
|
|
f.timed = true;
|
|
}
|
|
|
|
if (AaEnabled())
|
|
{
|
|
EnsureAaImage(w, h);
|
|
Barrier(cmd, s_post.aaImage, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL, VK_ACCESS_SHADER_READ_BIT,
|
|
VK_ACCESS_SHADER_WRITE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
|
|
VkDescriptorImageInfo in{ s_post.sampler, f.sourceView, VK_IMAGE_LAYOUT_GENERAL };
|
|
VkDescriptorImageInfo out{ VK_NULL_HANDLE, s_post.aaView, VK_IMAGE_LAYOUT_GENERAL };
|
|
VkWriteDescriptorSet writes[2] = {
|
|
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr },
|
|
{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 1, 0, 1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, &out, nullptr, nullptr },
|
|
};
|
|
struct
|
|
{
|
|
int32_t width, height;
|
|
float subpixel;
|
|
uint32_t bandOn;
|
|
int32_t band[4];
|
|
} pc = { int32_t(w), int32_t(h), 0.5f, bandOn ? 1u : 0u, { band.x0, band.x1, band.y0, band.y1 } };
|
|
static_assert(sizeof(pc) == 32);
|
|
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_post.aa);
|
|
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s_post.aaLayout, 0, 2, writes);
|
|
vkCmdPushConstants(cmd, s_post.aaLayout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(pc), &pc);
|
|
vkCmdDispatch(cmd, (w + 7) / 8, (h + 7) / 8, 1);
|
|
Barrier(cmd, s_post.aaImage, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, VK_ACCESS_SHADER_WRITE_BIT,
|
|
VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT);
|
|
scaleInput = s_post.aaView;
|
|
}
|
|
if (f.timed)
|
|
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, s_timestamps, s_frameIndex * 3 + 1);
|
|
}
|
|
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, 0,
|
|
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT);
|
|
VkRenderingAttachmentInfoKHR color{ VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR };
|
|
color.imageView = s_swapViews[imageIndex];
|
|
color.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
|
color.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // black bars
|
|
color.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
|
if (!haveSource) // the installer, before the game: a dark backdrop
|
|
color.clearValue.color = { { 0.02f, 0.025f, 0.035f, 1.0f } };
|
|
VkRenderingInfoKHR ri{ VK_STRUCTURE_TYPE_RENDERING_INFO_KHR };
|
|
ri.renderArea = { { 0, 0 }, s_swapExtent };
|
|
ri.layerCount = 1;
|
|
ri.colorAttachmentCount = 1;
|
|
ri.pColorAttachments = &color;
|
|
s_context.cmdBeginRendering(cmd, &ri);
|
|
if (scaleInput)
|
|
{
|
|
VkViewport viewport{ float(dx), float(dy), float(dw), float(dh), 0.0f, 1.0f };
|
|
VkRect2D scissor{ { dx, dy }, { uint32_t(dw), uint32_t(dh) } };
|
|
vkCmdSetViewport(cmd, 0, 1, &viewport);
|
|
vkCmdSetScissor(cmd, 0, 1, &scissor);
|
|
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scale);
|
|
VkDescriptorImageInfo in{ s_post.sampler, scaleInput, VK_IMAGE_LAYOUT_GENERAL };
|
|
VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, VK_NULL_HANDLE, 0, 0, 1,
|
|
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, &in, nullptr, nullptr };
|
|
s_post.pushDescriptorSet(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, s_post.scaleLayout, 0, 1, &write);
|
|
bool bicubic = Bicubic();
|
|
struct
|
|
{
|
|
float width, height, sharpen;
|
|
uint32_t bicubic;
|
|
float band[4];
|
|
uint32_t bandOn;
|
|
} pc = { float(w), float(h), Sharpen(), bicubic ? 1u : 0u, { float(band.x0), float(band.x1), float(band.y0), float(band.y1) },
|
|
bandOn ? 1u : 0u };
|
|
static_assert(sizeof(pc) == 36);
|
|
vkCmdPushConstants(cmd, s_post.scaleLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(pc), &pc);
|
|
vkCmdDraw(cmd, 3, 1, 0, 0);
|
|
if (!captures.empty() && pictureRect.extent.width)
|
|
{
|
|
// The game's picture alone: pause the pass, copy it out,
|
|
// and carry on with the UI over it.
|
|
s_context.cmdEndRendering(cmd);
|
|
gameCapture = RecordRegionCapture(cmd, dst, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
|
|
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_READ_BIT,
|
|
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, pictureRect);
|
|
VkRenderingAttachmentInfoKHR resume = color;
|
|
resume.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
|
|
VkRenderingInfoKHR again = ri;
|
|
again.pColorAttachments = &resume;
|
|
s_context.cmdBeginRendering(cmd, &again);
|
|
}
|
|
}
|
|
if (uiFrame)
|
|
ui::Draw(cmd);
|
|
s_context.cmdEndRendering(cmd);
|
|
if (f.timed)
|
|
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, s_timestamps, s_frameIndex * 3 + 2);
|
|
// NFSMW_CHECK_PRESENT_SEC=<s>: from s seconds after the first
|
|
// frame, save 3 presented frames 30 apart as
|
|
// build/present_check_<k>.ppm (what the window shows, after
|
|
// post-processing and the UI). A list (2,6.5,30) saves one frame
|
|
// at each time instead.
|
|
static const std::vector<double> checkTimes = [] {
|
|
std::vector<double> times;
|
|
if (const char* v = std::getenv("NFSMW_CHECK_PRESENT_SEC"))
|
|
for (const char* p = v; *p;)
|
|
{
|
|
times.push_back(std::atof(p));
|
|
p = strchr(p, ',');
|
|
if (!p)
|
|
break;
|
|
p++;
|
|
}
|
|
return times;
|
|
}();
|
|
static const auto launch = std::chrono::steady_clock::now();
|
|
static int presents = 0, taken = 0, shots = 0;
|
|
presents++;
|
|
double sinceLaunch = std::chrono::duration<double>(std::chrono::steady_clock::now() - launch).count();
|
|
bool check = checkTimes.size() == 1 ? taken < 3 && presents % 30 == 0 && sinceLaunch >= checkTimes[0]
|
|
: size_t(taken) < checkTimes.size() && sinceLaunch >= checkTimes[taken];
|
|
// NFSMW_UI_KEYS "Shot": this frame, named apart from the timed ones
|
|
// (taken even when it can't be saved: the keys after it wait for it).
|
|
bool shot = !check && ui::TakeShot();
|
|
if (shot && !s_swapReadable)
|
|
fprintf(stderr, "[video] NFSMW_UI_KEYS Shot: this swapchain can't be read back\n");
|
|
if (check && !s_swapReadable)
|
|
{
|
|
fprintf(stderr, "[video] NFSMW_CHECK_PRESENT_SEC: this swapchain can't be read back\n");
|
|
taken = int(std::max<size_t>(checkTimes.size(), 3)); // once
|
|
}
|
|
if ((check || shot) && s_swapReadable)
|
|
{
|
|
captureSize = VkDeviceSize(s_swapExtent.width) * s_swapExtent.height * 4;
|
|
VkBufferCreateInfo bci{ VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
|
|
bci.size = captureSize;
|
|
bci.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
|
|
vkCreateBuffer(s_device, &bci, nullptr, &captureBuffer);
|
|
VkMemoryRequirements req;
|
|
vkGetBufferMemoryRequirements(s_device, captureBuffer, &req);
|
|
VkMemoryAllocateInfo ai{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
|
|
ai.allocationSize = req.size;
|
|
ai.memoryTypeIndex = FindMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
|
|
vkAllocateMemory(s_device, &ai, nullptr, &captureMemory);
|
|
vkBindBufferMemory(s_device, captureBuffer, captureMemory, 0);
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
|
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
VkBufferImageCopy region{};
|
|
region.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
|
|
region.imageExtent = { s_swapExtent.width, s_swapExtent.height, 1 };
|
|
vkCmdCopyImageToBuffer(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, captureBuffer, 1, ®ion);
|
|
VkBufferMemoryBarrier hostRead{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER };
|
|
hostRead.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
|
hostRead.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
|
|
hostRead.srcQueueFamilyIndex = hostRead.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
|
hostRead.buffer = captureBuffer;
|
|
hostRead.size = VK_WHOLE_SIZE;
|
|
vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, 1, &hostRead, 0, nullptr);
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
|
|
VK_ACCESS_TRANSFER_READ_BIT, 0, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT);
|
|
captureIndex = shot ? -2 - shots++ : taken++;
|
|
}
|
|
else
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
|
|
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, 0, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT);
|
|
if (post && !gpuImage)
|
|
Barrier(cmd, s_source, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_SHADER_READ_BIT,
|
|
VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
}
|
|
else
|
|
{
|
|
// No post-processing pipeline: a linear blit, then the UI in a pass
|
|
// of its own. (The transition waits at the acquire's wait stage.)
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0,
|
|
VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
|
|
VkClearColorValue black{};
|
|
VkImageSubresourceRange range{ VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
|
|
vkCmdClearColorImage(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &black, 1, &range);
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); // the blit after the clear
|
|
VkImageBlit blit{};
|
|
blit.srcSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
|
|
blit.srcOffsets[1] = { int32_t(w), int32_t(h), 1 };
|
|
blit.dstSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 };
|
|
blit.dstOffsets[0] = { dx, dy, 0 };
|
|
blit.dstOffsets[1] = { dx + dw, dy + dh, 1 };
|
|
vkCmdBlitImage(cmd, source, sourceLayout, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
|
1, &blit, VK_FILTER_LINEAR);
|
|
if (!captures.empty() && pictureRect.extent.width)
|
|
gameCapture = RecordRegionCapture(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
VK_PIPELINE_STAGE_TRANSFER_BIT, pictureRect);
|
|
if (uiFrame)
|
|
{
|
|
waitStage |= VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT,
|
|
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
|
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT);
|
|
VkRenderingAttachmentInfoKHR color{ VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR };
|
|
color.imageView = s_swapViews[imageIndex];
|
|
color.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
|
color.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD;
|
|
color.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
|
VkRenderingInfoKHR ri{ VK_STRUCTURE_TYPE_RENDERING_INFO_KHR };
|
|
ri.renderArea = { { 0, 0 }, s_swapExtent };
|
|
ri.layerCount = 1;
|
|
ri.colorAttachmentCount = 1;
|
|
ri.pColorAttachments = &color;
|
|
s_context.cmdBeginRendering(cmd, &ri);
|
|
ui::Draw(cmd);
|
|
s_context.cmdEndRendering(cmd);
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
|
|
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, 0, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT);
|
|
}
|
|
else
|
|
Barrier(cmd, dst, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
|
|
VK_ACCESS_TRANSFER_WRITE_BIT, 0, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT);
|
|
}
|
|
vkEndCommandBuffer(cmd);
|
|
|
|
VkSubmitInfo si{ VK_STRUCTURE_TYPE_SUBMIT_INFO };
|
|
si.waitSemaphoreCount = 1;
|
|
si.pWaitSemaphores = &f.imageAvailable;
|
|
si.pWaitDstStageMask = &waitStage;
|
|
si.commandBufferCount = 1;
|
|
si.pCommandBuffers = &cmd;
|
|
si.signalSemaphoreCount = 1;
|
|
si.pSignalSemaphores = &s_renderDone[imageIndex];
|
|
std::unique_lock queueLock(s_queueMutex);
|
|
vkQueueSubmit(s_queue, 1, &si, f.inFlight);
|
|
frontLock.unlock();
|
|
|
|
VkPresentInfoKHR pi{ VK_STRUCTURE_TYPE_PRESENT_INFO_KHR };
|
|
pi.waitSemaphoreCount = 1;
|
|
pi.pWaitSemaphores = &s_renderDone[imageIndex];
|
|
pi.swapchainCount = 1;
|
|
pi.pSwapchains = &s_swapchain;
|
|
pi.pImageIndices = &imageIndex;
|
|
VkPresentIdKHR presentId{ VK_STRUCTURE_TYPE_PRESENT_ID_KHR };
|
|
uint64_t id = 0;
|
|
if (s_presentTiming)
|
|
{
|
|
id = ++s_presentId;
|
|
presentId.swapchainCount = 1;
|
|
presentId.pPresentIds = &id;
|
|
pi.pNext = &presentId;
|
|
}
|
|
int64_t presented = NowNs();
|
|
VkResult present = vkQueuePresentKHR(s_queue, &pi);
|
|
queueLock.unlock();
|
|
if (s_presentTiming && (present == VK_SUCCESS || present == VK_SUBOPTIMAL_KHR))
|
|
{
|
|
std::lock_guard lock(s_timingMutex);
|
|
s_timings.push_back({ id, s_swapchain, presented, frameVblank });
|
|
s_timingCv.notify_one();
|
|
}
|
|
s_lastPresent = std::chrono::steady_clock::now();
|
|
if (!captures.empty())
|
|
{
|
|
CapturedFrame frame;
|
|
if (gameCapture.buffer)
|
|
{
|
|
vkWaitForFences(s_device, 1, &f.inFlight, VK_TRUE, UINT64_MAX);
|
|
frame = FinishRegionCapture(gameCapture);
|
|
}
|
|
for (auto& promise : captures)
|
|
promise.set_value(frame);
|
|
}
|
|
if (captureBuffer)
|
|
{
|
|
vkWaitForFences(s_device, 1, &f.inFlight, VK_TRUE, UINT64_MAX);
|
|
void* mapped = nullptr;
|
|
vkMapMemory(s_device, captureMemory, 0, captureSize, 0, &mapped);
|
|
std::string path = captureIndex >= 0 ? std::format("build/present_check_{}.ppm", captureIndex)
|
|
: std::format("build/ui_shot_{}.ppm", -2 - captureIndex);
|
|
if (FILE* out = fopen(path.c_str(), "wb"))
|
|
{
|
|
fprintf(out, "P6\n%u %u\n255\n", s_swapExtent.width, s_swapExtent.height);
|
|
const uint8_t* px = static_cast<const uint8_t*>(mapped);
|
|
std::vector<uint8_t> rgb(size_t(s_swapExtent.width) * s_swapExtent.height * 3);
|
|
bool bgr = s_swapFormat == VK_FORMAT_B8G8R8A8_UNORM || s_swapFormat == VK_FORMAT_B8G8R8A8_SRGB;
|
|
for (size_t i = 0; i < rgb.size() / 3; i++)
|
|
for (int k = 0; k < 3; k++)
|
|
rgb[i * 3 + k] = px[i * 4 + (bgr ? 2 - k : k)];
|
|
fwrite(rgb.data(), 1, rgb.size(), out);
|
|
fclose(out);
|
|
fprintf(stderr, "[video] presented frame saved to %s\n", path.c_str());
|
|
gpu::renderer::LogOcclusionCounts(path.c_str()); // NFSMW_OCCLUSION=1: the counts behind it
|
|
}
|
|
else
|
|
fprintf(stderr, "[video] can't save a presented frame to %s: %s\n", path.c_str(), strerror(errno));
|
|
vkUnmapMemory(s_device, captureMemory);
|
|
vkDestroyBuffer(s_device, captureBuffer, nullptr);
|
|
vkFreeMemory(s_device, captureMemory, nullptr);
|
|
}
|
|
// The next frame's CheckSwapchain builds again (before its acquire).
|
|
if (present == VK_ERROR_OUT_OF_DATE_KHR || present == VK_SUBOPTIMAL_KHR)
|
|
s_swapPolicy.Reported(present == VK_ERROR_OUT_OF_DATE_KHR ? SurfaceResult::OutOfDate : SurfaceResult::Suboptimal);
|
|
s_frameIndex = (s_frameIndex + 1) % FRAMES_IN_FLIGHT;
|
|
return true;
|
|
}
|
|
|
|
void SubmitFrontImage(VkImage image, uint32_t width, uint32_t height, uint32_t aspectWidth, uint32_t aspectHeight)
|
|
{
|
|
if (!s_window)
|
|
return;
|
|
std::lock_guard lock(s_pendingMutex);
|
|
if (s_pendingNew)
|
|
s_dropped++;
|
|
s_pending.clear();
|
|
s_pendingImage = image;
|
|
s_pendingWidth = width;
|
|
s_pendingHeight = height;
|
|
s_pendingAspectW = aspectWidth;
|
|
s_pendingAspectH = aspectHeight;
|
|
s_pendingNew = true;
|
|
s_pendingVblank = s_guestVblank.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
void WaitGpuIdle()
|
|
{
|
|
if (s_device)
|
|
WaitIdle();
|
|
}
|
|
|
|
PresentStats GetPresentStats()
|
|
{
|
|
return { s_dropped.load(std::memory_order_relaxed), s_repeated.load(std::memory_order_relaxed), s_shown.load(std::memory_order_relaxed) };
|
|
}
|
|
|
|
VblankLock& GuestVblank()
|
|
{
|
|
// Today's free-running period, 16667 us, when the display can't be
|
|
// measured. NFSMW_VBLANK_HZ=<30..240> fixes the rate instead (no
|
|
// lock); NFSMW_VBLANK_LOCK=0 keeps the free-running one.
|
|
static VblankLock lock = [] {
|
|
const char* hz = std::getenv("NFSMW_VBLANK_HZ");
|
|
double rate = hz ? std::atof(hz) : 0.0;
|
|
const char* on = std::getenv("NFSMW_VBLANK_LOCK");
|
|
bool fixed = rate >= 30.0 && rate <= 240.0;
|
|
if (fixed)
|
|
fprintf(stderr, "[vblank] fixed at %.4f Hz (NFSMW_VBLANK_HZ)\n", rate);
|
|
else if (on && on[0] == '0')
|
|
fprintf(stderr, "[vblank] lock off (NFSMW_VBLANK_LOCK=0): free-running every 16.667 ms\n");
|
|
return VblankLock(fixed ? int64_t(std::llround(1e9 / rate)) : 16'667'000, !fixed && !(on && on[0] == '0'));
|
|
}();
|
|
return lock;
|
|
}
|
|
|
|
void NoteGuestVblank(int64_t t)
|
|
{
|
|
s_guestVblank.store(t, std::memory_order_relaxed);
|
|
s_guestVblanks.fetch_add(1, std::memory_order_release);
|
|
}
|
|
|
|
uint64_t GuestVblanks()
|
|
{
|
|
return s_guestVblanks.load(std::memory_order_acquire);
|
|
}
|
|
|
|
float PresentScaleOverride()
|
|
{
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
return s_envPresentScale;
|
|
#else
|
|
return 0.0f;
|
|
#endif
|
|
}
|
|
|
|
int GuestVblankDivider()
|
|
{
|
|
return FrameRate() == 30 ? 2 : 1; // the setting's, or Auto's (video/frame_rate.h)
|
|
}
|
|
|
|
int64_t LastGuestVblank()
|
|
{
|
|
return s_guestVblank.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
PostTiming GetPostTiming()
|
|
{
|
|
return { s_postFrames.load(), s_postAaNs.load(), s_postNs.load() };
|
|
}
|
|
|
|
const VulkanContext* GetVulkan()
|
|
{
|
|
return s_contextReady ? &s_context : nullptr;
|
|
}
|
|
|
|
bool FullscreenLocked()
|
|
{
|
|
#if defined(__APPLE__) && TARGET_OS_IOS
|
|
// An iOS app has the whole screen: windowed, it only showed the
|
|
// status bar over the picture.
|
|
return true;
|
|
#else
|
|
return s_gameMode;
|
|
#endif
|
|
}
|
|
|
|
bool GameMode()
|
|
{
|
|
return s_gameMode;
|
|
}
|
|
|
|
void FrameRegion(uint32_t& width, uint32_t& height)
|
|
{
|
|
width = height = 0;
|
|
uint64_t extent = s_regionExtent.load(std::memory_order_relaxed);
|
|
uint32_t ew = uint32_t(extent >> 32), eh = uint32_t(extent);
|
|
if (!s_window || ew == 0 || eh == 0)
|
|
return;
|
|
// As the presenter fits a new frame, without asking for the hooks:
|
|
// the internal resolution doesn't change when they first run.
|
|
const bool fillMode = settings::GetInt(settings::Id::AspectRatio) == int32_t(settings::AspectMode::Auto);
|
|
const PictureFit fit = FitPicture(ew, eh, 1280.0f, 720.0f, fillMode, fillMode);
|
|
width = uint32_t(fit.width);
|
|
height = uint32_t(fit.height);
|
|
}
|
|
|
|
bool WindowFocused()
|
|
{
|
|
return s_focused.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
bool WindowVisible()
|
|
{
|
|
return s_visible.load(std::memory_order_relaxed);
|
|
}
|
|
|
|
std::future<CapturedFrame> CaptureFrame()
|
|
{
|
|
std::promise<CapturedFrame> promise;
|
|
std::future<CapturedFrame> future = promise.get_future();
|
|
if (!s_window)
|
|
{
|
|
promise.set_value({});
|
|
return future;
|
|
}
|
|
std::lock_guard lock(s_captureMutex);
|
|
s_captureRequests.push_back(std::move(promise));
|
|
return future;
|
|
}
|
|
|
|
void Shutdown()
|
|
{
|
|
if (s_device)
|
|
WaitIdle();
|
|
if (s_uiReady)
|
|
{
|
|
ui::Shutdown();
|
|
s_uiReady = false;
|
|
}
|
|
hid::Shutdown(); // a rumble under way would otherwise carry on
|
|
{
|
|
std::lock_guard wait(s_waitSwapchainMutex); // the present waiter is off the swapchain
|
|
s_waitSwapchain = VK_NULL_HANDLE;
|
|
}
|
|
if (s_window)
|
|
SDL_DestroyWindow(s_window);
|
|
// No SDL_Quit: the process exits next, and the game's audio thread
|
|
// may still be feeding its stream (SDL_Quit freed it under it).
|
|
}
|
|
}
|