From a5a744072f385845fb39e19359a6b9741d040cd6 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 9 Nov 2026 13:57:32 +0100 Subject: [PATCH] feat(vk): frame generation Auto: a frame for each of the display's refreshes The Insert menu's Frame generation gets Auto (its value 1, the step it skipped so far). Instead of a whole multiplier, each game frame is given the display's refreshes that fall in its interval (on the game's timeline, a refresh period apart), and each of them a frame made at its place between the two game frames: 45 fps on a 60 Hz display makes one frame for every three of the game's (60 shown a second, at 3/4, 1/2 and 1/4), on 120 Hz 120; at 60 fps on 60 Hz none are made. The display thread shows each at its refresh's time, one game frame later. The sets' frames are made once for the most a game frame may be given (the refresh over 30 fps, 2 to 4). The fixed multipliers are unchanged. The refresh is the window's display's: on Linux SDL's (SDL_GetDisplayForWindow and SDL_GetCurrentDisplayMode; SDL2's SDL_GetWindowDisplayIndex), asked on the main thread once a second and handed over in the window bridge's new refresh_mhz (MKWVK_WINDOW_VERSION 2; a version 1 bridge still starts); on Windows the window's monitor's (EnumDisplaySettingsW). An unknown refresh counts as 60 Hz. tests/test_framegen_auto.py runs the real functions over refresh rates and frame rates; tests/test_framegen_gate.py now counts Auto as asking for interpolation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013mj4MdWTJo5ht4ShZX8Vpp --- gpu/vk/vk_api.h | 4 +- gpu/vk/vk_framegen.cpp | 108 ++++++++++++++++++++++++++++---- gpu/vk/vk_main.cpp | 31 ++++++++- gpu/vk/vk_menu.cpp | 8 +-- gpu/vk/vk_menu.h | 2 +- host/hos_sdl.c | 20 +++++- host/hos_sdl.h | 1 + host/posix/display_sdl.c | 11 +++- tests/test_framegen_auto.py | 121 ++++++++++++++++++++++++++++++++++++ tests/test_framegen_gate.py | 4 +- 10 files changed, 287 insertions(+), 23 deletions(-) create mode 100644 tests/test_framegen_auto.py diff --git a/gpu/vk/vk_api.h b/gpu/vk/vk_api.h index 32a2a6cc..915747fb 100644 --- a/gpu/vk/vk_api.h +++ b/gpu/vk/vk_api.h @@ -160,12 +160,14 @@ __attribute__((visibility("default"))) const MkwVkRenderer *mkwvk_init(const Mkw #ifndef _WIN32 /* (linux) what start() is given in place of a window handle (host/posix/display_sdl.c): the window makes the surface (SDL_Vulkan_CreateSurface: X11 or Wayland) and says its size in pixels */ -#define MKWVK_WINDOW_VERSION 1 +#define MKWVK_WINDOW_VERSION 2 typedef struct MkwVkWindow { uint32_t version; /* MKWVK_WINDOW_VERSION */ int (*create_surface)(void *window, void *instance, void *surface_out); /* (VkInstance, VkSurfaceKHR *): 1 if made */ void (*drawable_size)(void *window, int *width, int *height); void *window; + /* (version 2) the refresh rate of the display the window is on, in mHz; 0 when not known (frame generation's Auto) */ + int (*refresh_mhz)(void *window); } MkwVkWindow; #endif diff --git a/gpu/vk/vk_framegen.cpp b/gpu/vk/vk_framegen.cpp index bf2fd982..2ecb3c08 100644 --- a/gpu/vk/vk_framegen.cpp +++ b/gpu/vk/vk_framegen.cpp @@ -16,6 +16,8 @@ #include #include +#include + #include "vk_framegen.h" #include "vk_menu.h" #include "vk_texture.h" @@ -29,6 +31,7 @@ #include "vk_work.h" // (after the others: counts the commands that do GPU work, for full_barrier) namespace mkwvk { +int display_refresh_mhz(); // (vk_main.cpp: the window's display, mHz; outside the unnamed namespace, where it is defined) extern std::atomic present_kind; // (vk_replay.cpp: the frame log's) namespace { @@ -43,6 +46,11 @@ struct OutSet { Img frame[kMaxMult]; int made = 0; // (frame[0..made) are made frames; frame[made] is the game's) bool busy = false; + // (Auto: frame[0..shown) each at its own time, due[k] (steady clock), game[k] the game's own frame; shown < 0: the + // fixed multipliers' frames, made + 1 of them, shown evenly) + int shown = -1; + int64_t due[kMaxMult] = {}; + bool game[kMaxMult] = {}; }; struct Job { OutSet* set; @@ -72,6 +80,8 @@ struct State { OutSet* recorded = nullptr; // (the set the last framegen_record filled, for framegen_queue) int64_t recorded_at = 0; int mult = 0; // (the multiplier the sets' frames were made for) + int64_t game_ns = 0; // (Auto: the game's frame time, smoothed: the delay its frames are shown at) + int64_t slot_next = 0; // (Auto: the display's next refresh to be given a frame, on the game's timeline) } fg; // Used only by present_image, whose lock serializes direct and interpolated presents. @@ -87,7 +97,31 @@ int64_t now_ns() { return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); } -int multiplier() { return std::clamp(opts.framegen, 1, kMaxMult); } // (the Insert menu; 1: the game's frames alone) +// (hookshot) Frame generation's Auto (the menu's 1): a frame for each refresh of the display, no more. Each game frame +// is given the display's refreshes that fall between it and the game's frame before (on the game's own timeline, a +// refresh period apart), and each of them a frame made at its place between the two: 45 fps on a 60 Hz display makes one +// frame for every three of the game's (3 of 4 shown are made, at 3/4, 1/2, 1/4), on 120 Hz two or three a frame; at +// 60 fps on 60 Hz none, and faster than the display a game frame may be given no refresh and is not shown. Shown one +// game frame time later, each at its refresh's time. The sets' frames are made once for the most a game frame may be +// given (the refresh over 30 fps, 2 to 4). An unknown refresh counts as 60 Hz. +int auto_cap() { + const int hz = (display_refresh_mhz() + 500) / 1000; + return std::clamp(hz > 0 ? (hz + 29) / 30 : 2, 2, 4); +} +int multiplier() { // (the Insert menu; 1 Auto (its most); the game's frames alone at 0) + return opts.framegen == 1 ? auto_cap() : std::clamp(opts.framegen, 1, kMaxMult); +} +/// Auto: the display's refreshes in the game frame's interval (last, now], next the first not given a frame yet (moved +/// past them). Each one's place between the two game frames in t (1: this one) and its time in at; most of them at the +/// most, those past it skipped. A timeline outside the interval (the first frame, a hitch) starts again at now. +int auto_slots(int64_t last, int64_t now, int64_t period, int64_t& next, float* t, int64_t* at, int most) { + if (now <= last || period <= 0) return 0; + if (next <= last || next > now + period) next = now; + int n = 0; + for (; next <= now; next += period) + if (n < most) t[n] = (float)(next - last) / (float)(now - last), at[n++] = next; + return n; +} /// A compute pipeline over bindings of these types (push descriptors), push constants of push bytes bool make_pipe(Pipe& p, const uint32_t* code, size_t bytes, std::initializer_list types, uint32_t push, @@ -145,7 +179,11 @@ bool init() { si.magFilter = si.minFilter = VK_FILTER_LINEAR; vkCreateSampler(dev.device, &si, nullptr, &fg.linear); fg.failed = false; - logf("vk: frame interpolation ready: %dx the game's frames (made from its last two, shown evenly)", multiplier()); + if (opts.framegen == 1) + logf("vk: frame interpolation ready: Auto, a frame for each of the display's refreshes (%d Hz; up to %d a game frame)", + (display_refresh_mhz() + 500) / 1000, multiplier()); + else + logf("vk: frame interpolation ready: %dx the game's frames (made from its last two, shown evenly)", multiplier()); return true; } @@ -304,21 +342,25 @@ void display_main() { // apart, a frame early waiting for it: never more than one game frame behind what the game handed over, and a line // that has fallen behind what came starts again from now. RX_VK_PACE=0: as they come. const bool pace = opts.pace != 0; - const int shown = j.set->made + 1; - const int64_t step = interval / shown; - for (int k = 0; k <= j.set->made; k++) { - int64_t due = pace ? next_show : 0; + const bool timed = j.set->shown >= 0; // (Auto: each frame at its own refresh's time) + const int shown = timed ? j.set->shown : j.set->made + 1; + const int64_t step = interval / std::max(shown, 1); + for (int k = 0; k < shown; k++) { + int64_t due = timed ? j.set->due[k] : pace ? next_show : 0; const int64_t now = now_ns(); if (due < now) due = now; - if (due > j.at + interval + step) due = j.at + interval + step; + if (!timed && due > j.at + interval + step) due = j.at + interval + step; + if (timed && due > now + 100000000) due = now; // (a game frame time gone wrong: not held back) if (due - now > 1000000) { std::this_thread::sleep_for(std::chrono::nanoseconds(due - now - 1000000)); } while (now_ns() < due) SwitchToThread(); const int64_t t = now_ns(); - present_kind.store(k < j.set->made ? 1 : 0); + present_kind.store(timed ? (j.set->game[k] ? 0 : 1) : k < j.set->made ? 1 : 0); present_image(j.set->frame[k].image, fg.w, fg.h, nullptr, j.set->frame[k].view); - menu_note_frame(k == j.set->made); + // (the overlay's game rate: once a game frame, with its last refresh. Auto may show no frame of the game's own; + // a game frame given no refresh (the game faster than the display) is not counted) + menu_note_frame(timed ? k == shown - 1 : k == j.set->made); if (shown_at) { const int64_t d = t - shown_at; hist[d < 7000000 ? 0 : d < 9000000 ? 1 : d < 12000000 ? 2 : d < 17000000 ? 3 : d < 25000000 ? 4 : 5]++; @@ -339,7 +381,7 @@ void display_main() { // reduced it to luma (framegen_record), then a second thread showed it. Open areas paid for that on a full GPU and // gained no made frames. 2x and up is the interpolation; RX_VK_FRAMEGEN=0 keeps it off entirely. bool framegen_wanted(int menu_value, int allow) { - return allow && menu_value >= 2; + return allow && menu_value >= 1; // (1: Auto) } bool framegen_on() { @@ -424,8 +466,10 @@ bool framegen_record(VkCommandBuffer cmd, VkImageView src, uint32_t sw, uint32_t fg.thread_started = true; std::thread(display_main).detach(); } - const int64_t now = now_ns(); + const int64_t now = now_ns(), before = fg.last_at; const bool gap = fg.last_at && now - fg.last_at > 100000000; // (a loading hitch: nothing to blend) + if (fg.last_at && !gap) + fg.game_ns = fg.game_ns ? (fg.game_ns * 7 + (now - fg.last_at)) / 8 : now - fg.last_at; fg.last_at = now; OutSet* set; { @@ -444,6 +488,48 @@ bool framegen_record(VkCommandBuffer cmd, VkImageView src, uint32_t sw, uint32_t pass(cmd, fg.copy, {{src, fg.nearest}, {fg.frames[cur].view, VK_NULL_HANDLE}}, uw, uh, pc, sizeof pc); pass(cmd, fg.luma, {{fg.frames[cur].view, fg.nearest}, {fg.lumas[cur].view, VK_NULL_HANDLE}}, qw, qh); set->made = 0; + set->shown = -1; + if (opts.framegen == 1) { // (Auto: a frame for each of the display's refreshes, at its own time) + const int mhz = display_refresh_mhz(); + const int64_t period = (int64_t)(1e12 / (mhz > 0 ? mhz : 60000)); + float t[kMaxMult]; + int64_t at[kMaxMult]; + int n; + if (blend) { + n = auto_slots(before, now, period, fg.slot_next, t, at, std::min(fg.mult, kMaxMult)); + } else { // (nothing to blend from: the game's frame, and the timeline starts at it) + n = 1, t[0] = 1.0f, at[0] = now, fg.slot_next = now + period; + } + bool flow = false; + for (int k = 0; k < n; k++) { + set->game[k] = t[k] > 0.999f; + set->due[k] = at[k] + fg.game_ns; + if (set->game[k]) { + const VkImageCopy c{{VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, {0, 0, 0}, {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}, + {0, 0, 0}, {uw, uh, 1}}; + vkCmdCopyImage(cmd, fg.frames[cur].image, VK_IMAGE_LAYOUT_GENERAL, set->frame[k].image, + VK_IMAGE_LAYOUT_GENERAL, 1, &c); + continue; + } + if (!flow) { + flow = true; + pass(cmd, fg.flow, {{fg.lumas[prev].view, fg.nearest}, {fg.lumas[cur].view, fg.nearest}, + {fg.flows[0].view, VK_NULL_HANDLE}}, cw, ch); + pass(cmd, fg.median, {{fg.flows[0].view, fg.nearest}, {fg.flows[1].view, VK_NULL_HANDLE}}, cw, ch); + } + pass(cmd, fg.synth, {{fg.frames[prev].view, fg.linear}, {fg.frames[cur].view, fg.linear}, + {fg.flows[1].view, fg.nearest}, {set->frame[k].view, VK_NULL_HANDLE}}, + uw, uh, &t[k], 4); + } + set->shown = n; + set->made = n > 0 && !set->game[n - 1] ? n : std::max(0, n - 1); // (for framegen_made_image: any made) + full_barrier(cmd); + fg.have_prev = true; + fg.cur = prev; + fg.recorded = set; + fg.recorded_at = now; + return true; + } if (blend && m > 1) { pass(cmd, fg.flow, {{fg.lumas[prev].view, fg.nearest}, {fg.lumas[cur].view, fg.nearest}, {fg.flows[0].view, VK_NULL_HANDLE}}, cw, ch); diff --git a/gpu/vk/vk_main.cpp b/gpu/vk/vk_main.cpp index d3e5a09d..b41cd769 100644 --- a/gpu/vk/vk_main.cpp +++ b/gpu/vk/vk_main.cpp @@ -4,6 +4,8 @@ #include "vk_paths.h" #include "vk_loader.h" +#include +#include #include #include #include @@ -613,7 +615,7 @@ int start(void* hwnd) { if (!create_swapchain((uint32_t)(r.right - r.left), (uint32_t)(r.bottom - r.top))) return 0; #else const MkwVkWindow* w = (const MkwVkWindow*)hwnd; - if (w->version != MKWVK_WINDOW_VERSION) { + if (w->version < 1 || w->version > MKWVK_WINDOW_VERSION) { // (1: no refresh_mhz) logf("vk: unsupported SDL window bridge version %u (expected %u)", w->version, MKWVK_WINDOW_VERSION); return 0; } @@ -707,4 +709,31 @@ const MkwVkRenderer* mkwvk_init(const MkwVkHost* h) { namespace mkwvk { HWND app_window() { return main_window; } // (hookshot: the Insert menu's Fullscreen row) + +/// (hookshot) The refresh rate of the display the window is on, in mHz; 0 when not known (frame generation's Auto). Asked +/// again once a second at most: the window may move to another display, and Windows' answer costs a few system calls +int display_refresh_mhz() { + static std::atomic cached{0}; + static std::atomic asked_at{0}; + const int64_t now = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + const int64_t last = asked_at.load(std::memory_order_relaxed); + if (last && now - last < 1000) return cached.load(std::memory_order_relaxed); + asked_at.store(now, std::memory_order_relaxed); + int mhz = 0; +#ifdef _WIN32 + MONITORINFOEXW mi{}; + mi.cbSize = sizeof mi; + DEVMODEW dm{}; + dm.dmSize = sizeof dm; + if (main_window && GetMonitorInfoW(MonitorFromWindow(main_window, MONITOR_DEFAULTTONEAREST), &mi) && + EnumDisplaySettingsW(mi.szDevice, ENUM_CURRENT_SETTINGS, &dm) && dm.dmDisplayFrequency > 1) // (0 and 1: the default) + mhz = (int)dm.dmDisplayFrequency * 1000; +#else + const MkwVkWindow* w = (const MkwVkWindow*)main_window; + if (w && w->version >= 2 && w->refresh_mhz) mhz = w->refresh_mhz(w->window); +#endif + cached.store(mhz, std::memory_order_relaxed); + return mhz; +} } // namespace mkwvk diff --git a/gpu/vk/vk_menu.cpp b/gpu/vk/vk_menu.cpp index 6d10a766..a4920772 100644 --- a/gpu/vk/vk_menu.cpp +++ b/gpu/vk/vk_menu.cpp @@ -49,7 +49,7 @@ struct Row { const char* help; // (shown under the list while the row is selected) }; const char* tx_framegen(int v) { - static const char* t[] = {"Off", "", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; + static const char* t[] = {"Off", "Auto", "2x", "3x", "4x", "5x", "6x", "7x", "8x"}; return t[v]; } const char* tx_onoff(int v) { return v ? "On" : "Off"; } @@ -176,7 +176,7 @@ Row rows[] = { {TAB_GRAPHICS, "GPU device", &opts.gpu_device, 0, 8, 1, tx_gpu, "Selects which graphics card Vulkan uses. Auto picks the best dedicated GPU. Applies after restart."}, {TAB_GRAPHICS, "Frame generation", &opts.framegen, 0, 8, 1, tx_framegen, - "Makes extra frames between the game's own (2x up to 8x), for smoother motion. Adds a little input delay, and fast motion or camera cuts " + "Makes extra frames between the game's own (2x up to 8x, or Auto: as many as fill the display's refresh), for smoother motion. Adds a little input delay, and fast motion or camera cuts " "can show artifacts."}, {TAB_GRAPHICS, "Frame pacing", &opts.pace, 0, 1, 1, tx_onoff, "Shows each frame at an even interval instead of as soon as it is ready. Less stutter, at up to one frame of delay."}, @@ -444,7 +444,6 @@ void save() { /// A row's new value v, as the arrows set it (dir: 1 up, -1 down, for the rows that skip or wrap), saved unless !keep /// (a slider's drag: saved once it is let go) void set_row(Row& r, int v, int dir, bool keep = true) { - if (r.value == &opts.framegen && v == 1) v = dir > 0 ? 2 : 0; // (off, 2x ... 8x) if (r.value == &opts.mouse_sens) v = std::min(std::max(v, r.lo), r.hi); // (a scale: it stops at its ends) if (r.value == &opts.pad_layout) { if (v > 2) v = dir > 0 ? 0 : 2; @@ -1102,7 +1101,6 @@ void menu_init() { opts.sharpness = 0; } if (opts.preset > 0) sync_preset_budgets(opts.preset); - if (opts.framegen == 1) opts.framegen = 2; if (const char* e = getenv("RX_VK_FRAMEGEN_MULT")) opts.framegen = std::min(std::max(atoi(e), 2), 8); if (const char* e = getenv("RX_VK_PACE")) opts.pace = atoi(e) != 0; if (const char* e = getenv("RX_VK_UPSCALE")) @@ -1630,7 +1628,7 @@ void menu_tick() { double shown = 0, game = 0; if (fps_take(now, shown_frames.load(), game_frames.load(), &next, &last_shown, &last_game, &shown, &game)) { snprintf(fps_text, sizeof fps_text, "%.0f", shown); - if (opts.framegen >= 2) snprintf(fps_sub, sizeof fps_sub, "game %.0f", game); + if (opts.framegen >= 1) snprintf(fps_sub, sizeof fps_sub, "game %.0f", game); else fps_sub[0] = 0; fps_bmp.keyed = true; gdi_draw(fps_bmp, 120u * scale, 54u * scale, paint_fps, scale); diff --git a/gpu/vk/vk_menu.h b/gpu/vk/vk_menu.h index 75aa3d38..bb3bd1f2 100644 --- a/gpu/vk/vk_menu.h +++ b/gpu/vk/vk_menu.h @@ -7,7 +7,7 @@ namespace mkwvk { // (out of the box every feature is off: what the game does on its own. The ini beside the executable keeps a player's choices) struct Options { - int framegen = 0; // made frames: 0 off, or 2, 3, 4 times the game's frames + int framegen = 0; // made frames: 0 off, 1 Auto (to the display's refresh), or 2 to 8 times the game's frames int pace = 0; // frames shown on an even line (vk_framegen.cpp) int resolution = 0; // the 3D's: 0 Auto (the game's dynamic resolution), else 100%, 75%, 67%, 50% of its full scale (nx2/oot_patches.py) int output_res = 0; // the game's whole frame, docked: 0 its 2048x1152, 1 2560x1440, 2 3840x2160; read once, at the start (host/nn_applet.c) diff --git a/host/hos_sdl.c b/host/hos_sdl.c index 96471897..3614c424 100644 --- a/host/hos_sdl.c +++ b/host/hos_sdl.c @@ -15,7 +15,7 @@ static struct { /* window */ void *CreateWindow, *SetWindowPosition, *SetWindowFullscreen, *SetRelativeMouseMode, *GetPrimaryDisplay, *GetDesktopDisplayMode, *Vulkan_CreateSurface, *PixelSize, *GetKeyboardState, *GetMouseState, *WaitEventTimeout, - *PollEvent, *GetWindowSize, *LoadPNG, *SetWindowIcon, *DestroySurface; + *PollEvent, *GetWindowSize, *LoadPNG, *SetWindowIcon, *DestroySurface, *GetWindowDisplay, *GetCurrentDisplayMode; /* sound */ void *OpenAudioDevice, *PauseAudioDevice, *OpenAudioDeviceStream, *ResumeAudioStreamDevice, *PutAudioStreamData; /* controllers */ @@ -70,7 +70,11 @@ static int get_all(HMODULE m, int three) { *(FARPROC *)&f.LoadPNG = GetProcAddress(m, "SDL_LoadPNG"); /* (3.4 on: the icon, when there) */ *(FARPROC *)&f.SetWindowIcon = GetProcAddress(m, "SDL_SetWindowIcon"); *(FARPROC *)&f.DestroySurface = GetProcAddress(m, "SDL_DestroySurface"); + *(FARPROC *)&f.GetWindowDisplay = GetProcAddress(m, "SDL_GetDisplayForWindow"); /* (the refresh: hos_sdl_refresh_mhz) */ + *(FARPROC *)&f.GetCurrentDisplayMode = GetProcAddress(m, "SDL_GetCurrentDisplayMode"); } else { + *(FARPROC *)&f.GetWindowDisplay = GetProcAddress(m, "SDL_GetWindowDisplayIndex"); /* (the refresh: hos_sdl_refresh_mhz) */ + *(FARPROC *)&f.GetCurrentDisplayMode = GetProcAddress(m, "SDL_GetCurrentDisplayMode"); GET(OpenAudioDevice, "SDL_OpenAudioDevice"); GET(PauseAudioDevice, "SDL_PauseAudioDevice"); GET(NumJoysticks, "SDL_NumJoysticks"); @@ -162,6 +166,20 @@ int hos_sdl_desktop_size(int *width, int *height) { *width = m.w, *height = m.h; return 1; } +int hos_sdl_refresh_mhz(void *window) { + if (!window || !f.GetWindowDisplay || !f.GetCurrentDisplayMode) return 0; + float hz = 0; + if (version == 3) { /* (SDL_DisplayMode: refresh_rate, a float, at 20) */ + const uint32_t id = CALL(uint32_t (*)(void *), GetWindowDisplay)(window); + const uint8_t *m = id ? CALL(const uint8_t *(*)(uint32_t), GetCurrentDisplayMode)(id) : NULL; + if (m) memcpy(&hz, m + 20, 4); + } else { + const int index = CALL(int (*)(void *), GetWindowDisplay)(window); + struct { uint32_t format; int w, h, refresh_rate; void *driverdata; } m; + if (index >= 0 && CALL(int (*)(int, void *), GetCurrentDisplayMode)(index, &m) == 0) hz = (float)m.refresh_rate; + } + return hz > 1 && hz < 1000 ? (int)(hz * 1000 + 0.5f) : 0; +} int hos_sdl_vulkan_surface(void *window, void *instance, void *surface_out) { return version == 3 ? CALL(_Bool (*)(void *, void *, const void *, void *), Vulkan_CreateSurface)(window, instance, NULL, surface_out) : CALL(int (*)(void *, void *, void *), Vulkan_CreateSurface)(window, instance, surface_out) != 0; diff --git a/host/hos_sdl.h b/host/hos_sdl.h index 20e82e02..6fcfe68b 100644 --- a/host/hos_sdl.h +++ b/host/hos_sdl.h @@ -21,6 +21,7 @@ void *hos_sdl_window(const char *title, int width, int height); void hos_sdl_fullscreen(void *window, int on); /* (the desktop's mode, borderless) */ void hos_sdl_relative_mouse(void *window, int on); int hos_sdl_desktop_size(int *width, int *height); +int hos_sdl_refresh_mhz(void *window); /* the refresh rate of the window's display in mHz, 0 when not known (the main thread) */ int hos_sdl_vulkan_surface(void *window, void *instance, void *surface_out); /* (VkInstance, VkSurfaceKHR *): 1 if made */ void hos_sdl_pixel_size(void *window, int *width, int *height); const uint8_t *hos_sdl_keys(int *count); /* the keyboard by scancode (USB HID usage), 1 a key down */ diff --git a/host/posix/display_sdl.c b/host/posix/display_sdl.c index fe4e3a2f..22a9c694 100644 --- a/host/posix/display_sdl.c +++ b/host/posix/display_sdl.c @@ -103,7 +103,10 @@ static int fullscreen; static int create_surface(void *w, void *instance, void *surface) { return hos_sdl_vulkan_surface(w, instance, surface); } static void drawable_size(void *w, int *width, int *height) { hos_sdl_pixel_size(w, width, height); } -static MkwVkWindow vk_window = {MKWVK_WINDOW_VERSION, create_surface, drawable_size, NULL}; +/* (SDL's display calls belong on this thread: the loop asks every second, the renderer reads what it found) */ +static volatile int refresh_now; +static int refresh_mhz(void *w) { (void)w; return __atomic_load_n(&refresh_now, __ATOMIC_RELAXED); } +static MkwVkWindow vk_window = {MKWVK_WINDOW_VERSION, create_surface, drawable_size, NULL, refresh_mhz}; static void mouse_capture(int on) { if (on == (int)mouse_captured) return; @@ -237,9 +240,15 @@ void hos_display_loop(void) { hos_sdl_icon(window, png); } if (getenv("HOS_FULLSCREEN") && atoi(getenv("HOS_FULLSCREEN"))) set_fullscreen(1); + __atomic_store_n(&refresh_now, hos_sdl_refresh_mhz(window), __ATOMIC_RELAXED); CloseHandle(CreateThread(NULL, 0, renderer_main, NULL, 0, NULL)); + DWORD refresh_asked = GetTickCount(); for (;;) { hos_sdl_pads_pump(); + if (GetTickCount() - refresh_asked >= 1000) { /* (the window may have moved to another display) */ + refresh_asked = GetTickCount(); + __atomic_store_n(&refresh_now, hos_sdl_refresh_mhz(window), __ATOMIC_RELAXED); + } if (hos_sdl_wait_event(&e, 4)) { do { switch (e.type) { diff --git a/tests/test_framegen_auto.py b/tests/test_framegen_auto.py new file mode 100644 index 00000000..fdda9304 --- /dev/null +++ b/tests/test_framegen_auto.py @@ -0,0 +1,121 @@ +"""Frame generation's Auto: a frame for each of the display's refreshes, at its place between the game's frames. + +The real auto_cap, multiplier and auto_slots of gpu/vk/vk_framegen.cpp, with the display's refresh set by the fixture: +45 fps on 60 Hz gives one refresh to two game frames in three and two to the third (60 shown a second, three of four +made, at 3/4, 1/2 and 1/4), 60 fps on 60 Hz one refresh a frame at the game's own frame, faster than the display some +game frames none, 45 fps on 120 Hz 120 a second; the most a game frame may be given is the refresh over 30 fps (2 to +4), a timeline outside the interval starts again at the game's frame, and display_refresh_mhz is declared where +vk_main.cpp defines it (in the unnamed namespace the shared library linked with it undefined). +python -I -B tests/test_framegen_auto.py [--cxx g++] +""" +import argparse +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def block(text, marker): + start = text.index(marker) + end = text.index("{", start) + 1 + depth = 1 + while depth: + depth += (text[end] == "{") - (text[end] == "}") + end += 1 + return text[start:end] + + +PREFIX = r'''#include +#include +#include +#include +#include +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL:%d %s\n", __LINE__, #x); std::exit(1); } } while (0) +constexpr int kMaxMult = 8; +struct { int framegen = 1; } opts; +static int refresh_mhz = 60000; +int display_refresh_mhz() { return refresh_mhz; } +''' + +MAIN = r''' +/* a second of game frames at fps on a display at hz: the refreshes given, and the made ones' places */ +struct Run { int shown = 0, made = 0, most = 0, none = 0; float min_t = 1, max_t = 0; }; +static Run second(double fps, int hz) { + refresh_mhz = hz * 1000; + const int64_t period = (int64_t)(1e12 / refresh_mhz), frame = (int64_t)(1e9 / fps); + int64_t next = 0, last = 1000000000; + Run r; + for (int i = 0; i < (int)fps; i++) { + const int64_t now = last + frame; + float t[kMaxMult]; + int64_t at[kMaxMult]; + const int n = auto_slots(last, now, period, next, t, at, auto_cap()); + for (int k = 0; k < n; k++) { + CHECK(t[k] > 0 && t[k] <= 1.0001f); + CHECK(at[k] > last && at[k] <= now); + if (k) CHECK(at[k] - at[k - 1] == period); /* a refresh apart */ + if (t[k] <= 0.999f) r.made++, r.min_t = std::min(r.min_t, t[k]), r.max_t = std::max(r.max_t, t[k]); + } + r.shown += n, r.most = std::max(r.most, n), r.none += n == 0; + last = now; + } + return r; +} +int main() { + refresh_mhz = 60000; + CHECK(auto_cap() == 2 && multiplier() == 2); + Run r = second(45, 60); + CHECK(r.shown >= 59 && r.shown <= 61); /* the display's 60, not 2x's 90 */ + CHECK(r.most == 2 && r.none == 0); + CHECK(r.made >= 43 && r.made <= 46); /* three of four made */ + CHECK(r.min_t > 0.2f && r.max_t < 0.8f); /* at 1/4, 1/2, 3/4 */ + r = second(60, 60); + CHECK(r.shown >= 59 && r.shown <= 61 && r.most == 1); /* none made, or one a frame at most */ + r = second(75, 60); + CHECK(r.shown >= 59 && r.shown <= 61 && r.none > 0); /* faster than the display: some frames given none */ + refresh_mhz = 120000; + CHECK(auto_cap() == 4); + r = second(45, 120); + CHECK(r.shown >= 117 && r.shown <= 121 && r.most == 3); /* (the first frame starts the timeline: one) */ + refresh_mhz = 240000; + CHECK(auto_cap() == 4); /* the most is 4, whatever the refresh */ + r = second(30, 240); + CHECK(r.shown >= 117 && r.shown <= 121 && r.most == 4); /* capped: 4 of the 8 refreshes a frame */ + refresh_mhz = 0; + CHECK(auto_cap() == 2); /* unknown: as 60 Hz */ + + int64_t next = 5; /* a timeline behind the interval (a hitch) */ + float t[kMaxMult]; + int64_t at[kMaxMult]; + CHECK(auto_slots(1000, 2000, 300, next, t, at, 4) == 1 && at[0] == 2000 && t[0] == 1.0f && next == 2300); + CHECK(auto_slots(2000, 2000, 300, next, t, at, 4) == 0); /* no interval */ + + opts.framegen = 3; + CHECK(multiplier() == 3); /* a fixed setting is not Auto's */ + opts.framegen = 0; + CHECK(multiplier() == 1); + std::printf("PASS: Auto gives each of the display's refreshes a frame, and no more\n"); +} +''' + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--cxx", default="g++") + a = p.parse_args() + source = (ROOT / "gpu" / "vk" / "vk_framegen.cpp").read_text(encoding="utf-8") + decl = source.index("int display_refresh_mhz();") + if source.find("namespace {") < decl: + raise SystemExit("display_refresh_mhz is declared in the unnamed namespace: vk_main.cpp's is another function") + fixture = PREFIX + "\n".join(block(source, m) for m in ("int auto_cap()", "int multiplier()", "int auto_slots(")) + MAIN + with tempfile.TemporaryDirectory(prefix="framegen-auto-") as tmp: + cpp = Path(tmp) / "fixture.cpp" + exe = Path(tmp) / "fixture" + cpp.write_text(fixture, encoding="utf-8") + subprocess.run([a.cxx, "-std=c++20", "-Wall", "-Wextra", "-Werror", str(cpp), "-o", str(exe)], check=True) + subprocess.run([str(exe)], check=True) + + +if __name__ == "__main__": + main() diff --git a/tests/test_framegen_gate.py b/tests/test_framegen_gate.py index 2facd5a2..e59ebeac 100644 --- a/tests/test_framegen_gate.py +++ b/tests/test_framegen_gate.py @@ -39,11 +39,11 @@ def main(): ''' + fn + r''' int main() { CHECK(!framegen_wanted(0, 1)); /* menu Off: no copy, no luma */ - CHECK(!framegen_wanted(1, 1)); /* the blank step between Off and 2x */ + CHECK(framegen_wanted(1, 1)); /* Auto: as many as fill the display (tests/test_framegen_auto.py) */ CHECK(framegen_wanted(2, 1)); CHECK(framegen_wanted(8, 1)); CHECK(!framegen_wanted(2, 0)); /* RX_VK_FRAMEGEN=0 */ - std::printf("PASS: interpolation runs only from 2x up\n"); + std::printf("PASS: interpolation runs only when the menu asks for it (Auto, 2x up)\n"); } ''' with tempfile.TemporaryDirectory(prefix="framegen-gate-") as tmp: