diff --git a/examples/present_loop.cpp b/examples/present_loop.cpp index bc10f9c..275afe7 100644 --- a/examples/present_loop.cpp +++ b/examples/present_loop.cpp @@ -1,14 +1,17 @@ -// present_loop.cpp: how a Vulkan renderer drives the frame generation, as a sketch to adapt (it compiles against src/ps5_framegen.h -// but leaves the renderer's own parts, marked RENDERER, to you). docs/integration.md explains each step; RPCS3-PS5's own version is -// in its rpcs3/Emu/RSX/VK/VKPresent.cpp. +// present_loop.cpp: how a Vulkan renderer drives the frame generation, as a sketch to adapt. It compiles against src/ but leaves the +// renderer's own parts, marked RENDERER, to you. docs/integration.md explains each step. PS5SX2's own version is in its +// pcsx2/GS/Renderers/Vulkan/GSDeviceVK.cpp and GSRenderer.cpp, RPCS3-PS5's in rpcs3/Emu/RSX/VK/VKPresent.cpp. // +// Copyright (C) 2026 swordpdf // SPDX-License-Identifier: GPL-2.0-only #include "ps5_framegen.h" +#include "ps5_framegen_pacing.h" #include #include #include +#include #include namespace example @@ -19,114 +22,158 @@ namespace example VkPhysicalDevice physical; VkDevice device; VkSwapchainKHR swapchain; + VkFormat swapchain_format; // R8G8B8A8_UNORM or B8G8R8A8_UNORM std::uint32_t width, height; // the swapchain's size double display_hz; // the display's refresh rate (119.88, 59.94, ...) + double source_hz; // the clock that paces the game: an emulated machine's vblank (59.94, 50), or your game loop's - VkCommandBuffer begin_frame_commands(); // this frame's command buffer, recording + VkCommandBuffer begin_frame_commands(); // this frame's command buffer, recording void draw_game_frame_into(VkCommandBuffer cmd, VkImage target); // scaled and colour-processed exactly as it is shown void blit_to_swapchain(VkCommandBuffer cmd, VkImage source, std::uint32_t image_index); void draw_overlays(VkCommandBuffer cmd, std::uint32_t image_index); void submit_and_present(VkCommandBuffer cmd, VkSemaphore acquired, std::uint32_t image_index); - void present_game_frame(VkCommandBuffer cmd); // the usual present of the game's frame - void set_frame_limit(double fps); // 0: none + void present_game_frame(VkCommandBuffer cmd); // the usual present of the game's frame + void set_frame_limit(double fps); // 0: none + double emulation_speed_percent(); // 100 in a native port + bool limiter_nominal(); // false in turbo or slow motion VkSemaphore next_acquire_semaphore(); }; struct frame_generation { ps5::framegen::interpolator fg; - bool reset_next = true; + ps5::framegen::pacer pacing; + std::uint32_t multiplier = 2; + std::uint32_t presents_since_frame = 0; // presents since the last new frame was given to the pacer (`already`) bool backlog = false; - double interval_ms = 0.0; - std::uint32_t repeats = 1; - std::chrono::steady_clock::time_point last_flip{}; double held_at = -1.0; - bool start(renderer& r, std::uint32_t multiplier = 2) // 2: one generated frame between two of the game's, 3: two, 4: three + // 2: one generated frame between two of the game's, 3: two, 4: three (smoother, one refresh more lag for each). + bool start(renderer& r, std::uint32_t times = 2) { std::string error; - reset_next = true; - if (!fg.create(r.physical, r.device, r.width, r.height, error)) + multiplier = std::clamp(times, 2, 4); + // the frames in the swapchain's format, so that the renderer's own pipelines can draw into frame_image() + if (!fg.create(r.physical, r.device, r.width, r.height, error, r.swapchain_format)) + { + std::fprintf(stderr, "frame generation off: %s\n", error.c_str()); return false; - fg.set_generated(multiplier - 1); + } + pacing = {}; return true; } - // Once per game frame, instead of presenting the game's frame directly. `frame_limit`: the game's frame limit (0: none). - void flip(renderer& r, double frame_limit) + // A tick that brings no new frame of the game (an emulator's vblank repeating the last frame). While frames are + // generated it presents nothing: the generated frame took its refresh. + void repeat(renderer& r) { + if (pacing.state() == ps5::framegen::pacer_state::Generating) + return; + r.present_game_frame(r.begin_frame_commands()); + presents_since_frame++; + } + + // Once per new frame of the game, instead of presenting it directly. `ticks`: source ticks since the last new frame (1 at + // 60 fps on a 60 Hz source, 2 at 30). `frame_limit`: the game's frame limit (0: none). + void flip(renderer& r, std::uint32_t ticks, double frame_limit) + { + const double now = std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); + const ps5::framegen::pacer_decision d = pacing.frame(ticks, presents_since_frame, r.source_hz, r.display_hz, now, + r.emulation_speed_percent(), r.limiter_nominal()); + if (const auto e = pacing.take_event(); e != ps5::framegen::pacer_event::None) + std::printf("[framegen] safety net: event %d, state %d\n", static_cast(e), static_cast(pacing.state())); + VkCommandBuffer cmd = r.begin_frame_commands(); + if (!d.engaged) + { + // Uneven or too fast for the display, warming up, paused: the game's own frame. The interpolator gets a reset on + // the next engaged frame (d.reset), so skipping it here is fine. + r.present_game_frame(cmd); + presents_since_frame = 1; + if (held_at != frame_limit) // the user's own limit again + { + held_at = frame_limit; + r.set_frame_limit(frame_limit); + } + return; + } + // 1. this frame, exactly as it will be shown fg.prepare(cmd); r.draw_game_frame_into(cmd, fg.frame_image()); - // 2. optical flow (always) and the frame in between (unless reset) - const bool made = fg.record(cmd, reset_next); - reset_next = false; - - // 3. pacing: the generated frame for about half of the game's frame interval, in refreshes - const auto now = std::chrono::steady_clock::now(); - if (last_flip != std::chrono::steady_clock::time_point{}) + // 2. how many in-between frames: one shown d.presents times, or (multiplier > 2) up to multiplier - 1 shown once each, + // never more than the refreshes this frame's ticks are worth (an x4 game slower than 30 fps would bunch them) + std::uint32_t frames = d.presents > 0 ? 1 : 0; + std::uint32_t shown_each = d.presents; + if (multiplier > 2 && d.presents > 0) { - const double dt = std::chrono::duration(now - last_flip).count(); - if (dt > 4.0 && dt < 250.0) - interval_ms = interval_ms > 0.0 ? interval_ms * 0.85 + dt * 0.15 : dt; + const std::uint32_t fit = static_cast(ticks * r.display_hz / r.source_hz + 0.03); + const std::uint32_t left = fit > presents_since_frame ? fit - presents_since_frame : 0; + frames = std::min(multiplier - 1, left); + shown_each = 1; } - last_flip = now; + fg.set_generated(std::max(frames, 1)); - if (r.display_hz <= 90.0) - repeats = 1; - else if (interval_ms > 0.0) + // 3. optical flow (always) and the first frame in between (unless reset) + const bool made = fg.record(cmd, d.reset); + + // 4. latency: hold the game a hair under the display where the presents would fill every refresh + hold(r, frame_limit, frames > 1 ? frames + 1.0 : d.presents + 1.0); + + // 5. the generated frames (left out once when the last frame's first acquire found the queue full) + std::uint32_t presented = 0; + if (made && frames > 0 && !backlog) { - const double half = interval_ms * r.display_hz / 1000.0 / 2.0; - while (repeats < 3 && half >= repeats + 0.9) repeats++; - while (repeats > 1 && half < repeats - 0.2) repeats--; - } - - // 4. latency: where the presents fill every refresh, hold the game a hair under the display so the queue stays empty - const std::uint32_t generated = fg.generated(); - const double presents = generated > 1 ? generated + 1.0 : repeats + 1.0; - // (no limit at all counts as tight: V-Sync alone would hold the game at the display and keep the queue full) - const bool tight = frame_limit <= 0.0 || r.display_hz / frame_limit - presents < 0.25; - const double hold = tight ? (frame_limit > 0.0 ? std::min(r.display_hz / presents - 0.05, frame_limit) : r.display_hz / presents - 0.05) : frame_limit; - if (hold != held_at) - { - held_at = hold; - r.set_frame_limit(hold); - } - - // 5. the generated frame, `repeats` times, or the generated frames one refresh each (left out once when the last frame's - // first acquire found the queue full) - if (made) - { - const std::uint32_t shown = backlog ? 0 : generated > 1 ? generated : repeats; - backlog = false; - - for (std::uint32_t i = 0; i < shown; i++) + for (std::uint32_t i = 0; i < frames; i++) { - if (generated > 1 && i > 0) + if (i > 0) fg.interpolate_next(cmd, i); // the next moment, into output_image(), after the copy of the one before - const VkSemaphore acquired = r.next_acquire_semaphore(); - std::uint32_t index = 0; - const auto t0 = std::chrono::steady_clock::now(); - if (vkAcquireNextImageKHR(r.device, r.swapchain, 100000000ull, acquired, VK_NULL_HANDLE, &index) < 0) - break; - // the frame's first present found no image free before a refresh: every image was queued or on screen - if (i == 0 && std::chrono::duration(std::chrono::steady_clock::now() - t0).count() > 2.0) - backlog = true; + for (std::uint32_t k = 0; k < shown_each; k++) + { + const VkSemaphore acquired = r.next_acquire_semaphore(); + std::uint32_t index = 0; + const auto t0 = std::chrono::steady_clock::now(); + if (vkAcquireNextImageKHR(r.device, r.swapchain, 100000000ull, acquired, VK_NULL_HANDLE, &index) < 0) + break; + // the frame's first present found no image free before a refresh: every image was queued or on screen + if (presented == 0 && + std::chrono::duration(std::chrono::steady_clock::now() - t0).count() > 2.0) + backlog = true; - r.blit_to_swapchain(cmd, fg.output_image(), index); - r.draw_overlays(cmd, index); // or they blink - r.submit_and_present(cmd, acquired, index); // the submit must have reached the queue before the present waits on it - cmd = r.begin_frame_commands(); + r.blit_to_swapchain(cmd, fg.output_image(), index); + r.draw_overlays(cmd, index); // or they blink + r.submit_and_present(cmd, acquired, index); // submitted before the present waits on it + cmd = r.begin_frame_commands(); + presented++; + } } } + else + backlog = false; // 6. the game's frame as usual, then this frame becomes the previous one r.present_game_frame(cmd); + presents_since_frame = 1; fg.advance(); } + + private: + // Where a game frame brings as many presents as it has refreshes, nothing drains a FIFO queue that a game a hair faster + // than the display fills: hold it at display / presents - 0.05 (docs/integration.md#latency). No limit counts as tight: + // V-Sync alone would hold the game at the display and keep the queue full. + void hold(renderer& r, double frame_limit, double presents) + { + const bool tight = frame_limit <= 0.0 || r.display_hz / frame_limit - presents < 0.25; + const double cap = r.display_hz / presents - 0.05; + const double want = tight ? (frame_limit > 0.0 ? std::min(cap, frame_limit) : cap) : frame_limit; + if (want != held_at) + { + held_at = want; + r.set_frame_limit(want); + } + } }; -} +} // namespace example diff --git a/src/ps5_framegen.cpp b/src/ps5_framegen.cpp index ecda0aa..828d387 100644 --- a/src/ps5_framegen.cpp +++ b/src/ps5_framegen.cpp @@ -1,8 +1,9 @@ -// RPCS3-PS5 (m73): frame generation, the host side of FSR3's optical flow and frame interpolation (ps5_framegen.h). +// ps5-framegen: the host side of FSR3's optical flow and frame interpolation (ps5_framegen.h). // // The order of the passes, their sizes, the resources each binds and the constants are those of the FidelityFX SDK 2.3.0's // ffx_opticalflow.cpp (dispatch()) and ffx_frameinterpolation.cpp (ffxFrameInterpolationDispatch()), MIT, Advanced Micro Devices. // +// Copyright (C) 2026 swordpdf // SPDX-License-Identifier: GPL-2.0-only #include "ps5_framegen.h" @@ -277,7 +278,7 @@ namespace ps5::framegen img = {}; } - bool interpolator::create(VkPhysicalDevice physical, VkDevice device, u32 width, u32 height, std::string& error) + bool interpolator::create(VkPhysicalDevice physical, VkDevice device, u32 width, u32 height, std::string& error, VkFormat frame_format) { destroy(); @@ -307,7 +308,7 @@ namespace ps5::framegen for (image_t& c : m_color) { - if (!make_image(c, k_format, width, height, color_usage, error)) return fail(); + if (!make_image(c, frame_format, width, height, color_usage, error)) return fail(); } if (!make_image(m_output, k_format, width, height, work_usage | VK_IMAGE_USAGE_TRANSFER_SRC_BIT, error)) return fail(); @@ -457,9 +458,60 @@ namespace ps5::framegen pipeline_t& p = m_pipelines[i]; p.shader = i; + // Compact bindings: numbered 0..n-1 in the order of the shader's own numbers (0, 100, 200, 300, 405 ..., FSR3's HLSL + // registers), in the SPIR-V's Binding decorations, the set layout and the writes alike. ps5vk's descriptor tables run + // to a set's highest binding number and its compiler keeps a binding number in a byte: the sparse numbers made a + // 409-slot table and vkCreateComputePipelines failed ("internal error"). The order is kept, so the dynamic uniform + // blocks' offsets follow the bindings as before. Other drivers don't mind either way. + { + std::vector order(sh.binding_count); + for (u32 b = 0; b < sh.binding_count; b++) + order[b] = b; + std::sort(order.begin(), order.end(), [&sh](u32 a, u32 b) { return sh.bindings[a].binding < sh.bindings[b].binding; }); + p.slot.assign(sh.binding_count, 0); + for (u32 k = 0; k < sh.binding_count; k++) + p.slot[order[k]] = k; + } + + std::vector code(sh.code, sh.code + sh.words); + + for (std::size_t at = 5; at < code.size();) + { + const u32 op = code[at] & 0xffffu; + const u32 count = code[at] >> 16; + + if (count == 0 || at + count > code.size()) + { + error = std::string("framegen: the SPIR-V of ") + sh.name + " is cut short"; + return false; + } + + if (op == 71 && count >= 4 && code[at + 2] == 33) // OpDecorate Binding + { + bool found = false; + + for (u32 b = 0; b < sh.binding_count && !found; b++) + { + if (sh.bindings[b].binding == code[at + 3]) + { + code[at + 3] = p.slot[b]; + found = true; + } + } + + if (!found) + { + error = std::string("framegen: binding ") + std::to_string(code[at + 3]) + " of " + sh.name + " is not in its table"; + return false; + } + } + + at += count; + } + VkShaderModuleCreateInfo mod{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; - mod.codeSize = sh.words * 4; - mod.pCode = sh.code; + mod.codeSize = code.size() * 4; + mod.pCode = code.data(); if (vkCreateShaderModule(m_device, &mod, nullptr, &p.module) != VK_SUCCESS) { @@ -471,7 +523,7 @@ namespace ps5::framegen for (u32 b = 0; b < sh.binding_count; b++) { - bindings[b] = {sh.bindings[b].binding, sh.bindings[b].type, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr}; + bindings[b] = {p.slot[b], sh.bindings[b].type, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr}; } VkDescriptorSetLayoutCreateInfo set_info{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; @@ -769,7 +821,7 @@ namespace ps5::framegen VkWriteDescriptorSet& wr = writes[b]; wr = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; wr.dstSet = d.set[p]; - wr.dstBinding = fb.binding; + wr.dstBinding = m_pipelines[s.shader].slot[b]; // the compact number (create()) wr.descriptorCount = 1; wr.descriptorType = fb.type; diff --git a/src/ps5_framegen.h b/src/ps5_framegen.h index 513b02b..0ff8759 100644 --- a/src/ps5_framegen.h +++ b/src/ps5_framegen.h @@ -1,4 +1,4 @@ -// RPCS3-PS5 (m73): frame generation. AMD FSR3's frame interpolation (FidelityFX SDK 2.3.0, MIT) on plain Vulkan: from two frames +// ps5-framegen: frame generation. AMD FSR3's frame interpolation (FidelityFX SDK 2.3.0, MIT) on plain Vulkan: from two frames // of the game, the frame halfway between them, shown before the second one (a game held at 30 fps is shown at 60). // // A PS3 game gives no motion vectors or depth, so this is FSR3's optical flow path alone (FFX_FG_OF_ONLY in the shaders): @@ -15,13 +15,22 @@ // compiled to SPIR-V (tools/build-shaders.sh, sources in shaders/); this file and its .cpp are // the host side (the SDK's ffx_opticalflow.cpp and ffx_frameinterpolation.cpp redone on Vulkan, without the SDK's backend). // -// No RPCS3 dependency: the host tool tools/fg-test.cpp runs the same code on a software Vulkan device. +// No dependency on an emulator, the console's SDK or AMD's SDK backend: the host tool tools/fg-test.cpp runs the same code on a +// software Vulkan device. It came from RPCS3-PS5 (m74) and runs in PS5SX2 too, on both PS5 Vulkan drivers (RADV and ps5vk). +// +// Copyright (C) 2026 swordpdf // // SPDX-License-Identifier: GPL-2.0-only #pragma once +// Your own Vulkan header or loader (volk, a function table) instead of the Khronos one: -DPS5_FRAMEGEN_VULKAN_HEADER='"my_vk.h"'. +// It has to declare the vk* commands ps5_framegen.cpp calls under their usual names. +#ifdef PS5_FRAMEGEN_VULKAN_HEADER +#include PS5_FRAMEGEN_VULKAN_HEADER +#else #include +#endif #include #include @@ -29,23 +38,23 @@ namespace ps5::framegen { - // ps5.yml "Frame Generation" (also in a step, a test mode or a game's Title Steps): whether the game's frames get the frame - // between them shown too. The renderer (in RPCS3-PS5, rpcs3/Emu/RSX/VK/VKPresent.cpp) reads it at every flip. + // Settings shared between the app's settings code and its renderer (thread-safe; the renderer reads them at every flip). They + // only hold values: what they mean is up to the renderer (examples/present_loop.cpp). + // + // Whether the game's frames get the frame between them shown too (RPCS3-PS5: ps5.yml "Frame Generation"). void set_wanted(bool on); bool wanted(); - // m73b: ps5.yml "Frame Generation Limit": 0 (the default) leaves the game at its own frame rate (up to RPCS3's frame limit) and - // shows one generated frame after each of its frames; 30, 50 or 60 holds it at that rate (RPCS3's frame limit) while frame - // generation is on. 30 on a 120 Hz display is m73's even 30 -> 60. + // A frame rate to hold the game at while frame generation is on: 0 (the default) leaves it at its own rate; 30 on a 120 Hz + // display gives an even 30 -> 60 (RPCS3-PS5: "Frame Generation Limit"). void set_limit(std::uint32_t fps); std::uint32_t limit(); - // m73b: ps5.yml "Frame Generation Debug": the generated frames show the interpolation's decisions in colour instead - // (interpolator::set_debug_view). + // The generated frames show the interpolation's decisions in colour instead (interpolator::set_debug_view). void set_debug(bool on); bool debug(); - // m74: ps5.yml "Frame Generation Multiplier": 2 (the default) shows one generated frame between two of the game's, 3 two (at a + // The multiplier: 2 (the default) shows one generated frame between two of the game's, 3 two (at a // third and two thirds of the way: 40 fps shown at 120), 4 three (30 at 120). Each extra frame delays the game's own frame by // one more refresh, so 2 has the least input lag. void set_multiplier(std::uint32_t times); @@ -60,15 +69,18 @@ namespace ps5::framegen ~interpolator(); // The passes, images and buffers for frames of width x height (the size of the picture shown). false (and `error`) when - // the device lacks something or memory runs out. - bool create(VkPhysicalDevice physical, VkDevice device, std::uint32_t width, std::uint32_t height, std::string& error); + // the device lacks something or memory runs out. `frame_format` is the format of the game's frames (frame_image(), + // previous_image()): R8G8B8A8_UNORM, or B8G8R8A8_UNORM when the renderer's own pipelines draw into them in the swapchain's + // format (the passes only sample them). output_image() is always k_format. + bool create(VkPhysicalDevice physical, VkDevice device, std::uint32_t width, std::uint32_t height, std::string& error, + VkFormat frame_format = k_format); void destroy(); bool ready() const { return m_device != VK_NULL_HANDLE; } std::uint32_t width() const { return m_width; } std::uint32_t height() const { return m_height; } - // The frames are R8G8B8A8_UNORM images kept in VK_IMAGE_LAYOUT_GENERAL (usage: transfer source and destination, sampled, + // The frames are images of `frame_format` (create()) kept in VK_IMAGE_LAYOUT_GENERAL (usage: transfer source and destination, sampled, // colour attachment). Each frame the caller calls prepare() (it puts the images in GENERAL the first time), copies (or // draws) this frame's picture into frame_image(), then calls record(). static constexpr VkFormat k_format = VK_FORMAT_R8G8B8A8_UNORM; @@ -133,6 +145,8 @@ namespace ps5::framegen VkPipelineLayout layout = VK_NULL_HANDLE; VkPipeline pipeline = VK_NULL_HANDLE; std::uint32_t shader = 0; // index into the shader table + // The binding number each of the shader's bindings has here, in its table's order: 0..n-1 (create(), "compact bindings"). + std::vector slot; }; // One dispatch of a frame: its pipeline, its descriptor set (one for each frame parity), the uniform blocks it reads diff --git a/src/ps5_framegen_pacing.h b/src/ps5_framegen_pacing.h new file mode 100644 index 0000000..2596d00 --- /dev/null +++ b/src/ps5_framegen_pacing.h @@ -0,0 +1,317 @@ +// ps5-framegen: when to show generated frames and for how long (the pacing), counted in the source's own frame clock. +// Copyright (C) 2026 swordpdf +// SPDX-License-Identifier: GPL-2.0-only +// +// Header-only and independent of Vulkan, so tests/test_pacing.cpp runs it on a PC. It comes from PS5SX2 (builds vk-285-133 to +// 135), where it replaced a first pacing that timed the game's frames on the wall clock between presents. +// +// Why not the wall clock: at 60 Hz that fed itself. Once a generated frame had been shown (a slow first second was enough), it +// took a refresh of its own, so the game's frames came every two refreshes. That looked like a 30 fps game that should get a +// generated frame, and so on. Ratchet & Clank ran at 30 fps and half speed, its emulation waiting on the display. +// +// Here the interval is the number of ticks between two new frames of the game: the vblanks of the machine being emulated (a +// PS2's 59.94 or 50 Hz), or of whatever clock paces the game in a port. The presents can't change that count, and a frame's +// presents never exceed the display refreshes its ticks are worth: +// refreshes = ticks x display Hz / source Hz +// - The game's frame is one present. +// - Frames are generated only when the game's frames have two refreshes or more (the median of the last 8 intervals): a 60 fps +// game on a 120 Hz display, or a 30 fps one at 60 Hz. A 60 fps game at 60 Hz gets none. +// - The generated frame is presented for about half of the refreshes (once at 2, twice at 4, three times at 6). The game's frame +// stays up until the next one. +// - A frame never gets more presents than its own interval had refreshes, less the presents already made in it (the game's last +// frame, or a repeat the renderer presented). +// - While frames are generated, ticks that only repeat the game's last frame should present nothing: the generated frame used +// their refresh. +// - At the start, and after a load or a pause (more than 8 ticks without a new frame), 8 frames come before any is generated. +// +// It also generates only while the game's frame rate is steady: 7 of the last 8 intervals the same length to start, 6 to go on. +// In Ratchet & Clank, where frames take 1 or 2 vblanks (45-58 fps), generated frames felt worse than none. One generated frame +// before each of the game's frames of uneven length makes the motion speed up and slow down from refresh to refresh. That comes on +// top of the in-between frames' artifacts and the frame of lag. An even rate (60 to 120, 30 to 60) is what it doubles well, so +// uneven stretches show the game's own frames. +// +// The safety net is for an emulator whose speed the generated frames' GPU time can cost: +// - When the game runs below 95% of its target speed for 3 seconds while frames are generated, none are for 10 s. +// - If the game is back at full speed (97%+) in that pause, they were costing it, so the next pause is twice as long (up to 160 s; +// back to 10 s after 5 minutes without one). +// - If it is as slow without them, the game is slow by itself there. Frames are generated again, and for a minute that speed +// (less 3 points) is the floor. +// - Nothing is generated while the frame limiter isn't at normal speed (turbo, slow motion). +// A native port passes speed 100 and nominal true. +// +// Use (docs/integration.md#pacing): +// ps5::framegen::pacer pacing; +// // each new frame of the game: +// const auto d = pacing.frame(ticks, already, source_hz, display_hz, now_seconds, speed_percent, limiter_nominal); +// if (d.engaged) { fg.record(cmd, d.reset); present the generated frame d.presents times (none if 0); } +// else { the game's frame alone; the interpolator starts over (reset) on the next engaged frame } + +#pragma once + +#include +#include + +namespace ps5::framegen +{ + struct pacer_decision + { + bool engaged = false; // frames are generated for the game now: the interpolator takes every new frame + bool reset = false; // the interpolator's last frame is no neighbour of this one (a gap, a pause): it starts over + uint32_t presents = 0; // presents of the generated frame before the game's, for this frame (0: none this time) + }; + + // Where the pacing is, for the log. + enum class pacer_state + { + Warming, // the start, or a load or a pause: 8 frames first + NoRoom, // the game's frames have fewer than two refreshes each + Unsteady, // the game's frames aren't of one length (fewer than 7 of the last 8, or 6 once generating) + NotNominal, // the frame limiter isn't at normal speed + Paused, // the safety net's pause + Generating, + }; + + // The safety net's news, for the log. + enum class pacer_event + { + None, + Paused, // the game slowed while frames were generated: none for pause_seconds() + ResumedCostly, // the game was at full speed without them: the next pause is longer + ResumedSlowGame, // it was as slow without them: a lower floor for a minute + }; + + class pacer + { + public: + static constexpr uint32_t kGapTicks = 8; // a longer wait between two frames: a load, a pause, a still picture + static constexpr uint32_t kWindow = 8; // the intervals the median is taken over; as many frames after a gap + static constexpr double kEngage = 1.97; // two refreshes a frame, less rounding (an HD mode's 60 Hz on 119.88: 1.998) + static constexpr double kDisengage = 1.6; // ...kept down to here, each frame still capped by its own interval + static constexpr double kSlowSpeed = 95.0; // percent of the target speed + static constexpr double kFullSpeed = 97.0; + static constexpr int kSlowSeconds = 3; + static constexpr double kFirstPause = 10.0; // seconds + static constexpr double kMaxPause = 160.0; + static constexpr double kPauseForget = 300.0; // a pause this long ago no longer lengthens the next one + static constexpr double kSettle = 2.0; // a pause's first seconds aren't the game's speed without them yet + static constexpr double kFloorSeconds = 60.0; + static constexpr double kCostly = 3.0; // points of speed a pause has to win back to blame the generated frames + static constexpr uint32_t kSteadyStart = 7; // intervals of the window's usual length, to start generating... + static constexpr uint32_t kSteadyKeep = 6; // ...and to go on + + // One new frame of the game: `ticks` since the last new frame (1 at 60 fps, 2 at 30 on a 60 Hz source), `already` the + // presents made since the last one was given here (its own game frame and any repeat presented after it), the source's and + // the display's rates in Hz, `now` in seconds (any steady clock), the emulation's speed in percent of its target, and + // whether the limiter is at normal speed. + pacer_decision frame(uint32_t ticks, uint32_t already, double source_hz, double display_hz, double now, double speed, bool nominal) + { + pacer_decision d; + if (ticks == 0) + ticks = 1; + if (!(source_hz >= 20.0 && source_hz <= 100.0)) + source_hz = 59.94; + if (!(display_hz >= 20.0 && display_hz <= 250.0)) + display_hz = 59.94; + const double ratio = display_hz / source_hz; + + // A long wait: the window starts again, and so does the interpolator. + const bool gap = ticks > kGapTicks; + if (gap) + { + m_count = 0; + m_reset = true; + } + else + { + m_window[m_next] = ticks; + m_next = (m_next + 1) % kWindow; + m_count = m_count < kWindow ? m_count + 1 : kWindow; + } + m_median = median(); + m_steady = usual_count(); + m_refreshes = m_median * ratio; + const double now_refreshes = ticks * ratio; + + // The generated frame's presents: about half the refreshes a frame has, kept from flickering between two counts. + const double half = m_refreshes / 2.0; + while (m_repeats < 3 && half >= m_repeats + 0.9) + m_repeats++; + while (m_repeats > 1 && half < m_repeats - 0.2) + m_repeats--; + + second(now, speed); + + const bool warm = m_count >= kWindow; + m_room = warm && m_refreshes >= (m_room ? kDisengage : kEngage); + // steady enough, with the same hysteresis (the state just before says which threshold). + const bool was_generating = m_state == pacer_state::Generating || m_state == pacer_state::Paused || m_state == pacer_state::NotNominal; + const bool steady = warm && m_steady >= (was_generating ? kSteadyKeep : kSteadyStart); + m_state = !warm ? pacer_state::Warming : !m_room ? pacer_state::NoRoom : !steady ? pacer_state::Unsteady : !nominal ? pacer_state::NotNominal : + paused() ? pacer_state::Paused : pacer_state::Generating; + d.engaged = m_state == pacer_state::Generating; + if (!d.engaged) + { + m_reset = true; // the frames skipped meanwhile weren't given to the interpolator + return d; + } + + // This frame's presents: never more than the refreshes its own interval had, less the presents already made in it. + const uint32_t fit = static_cast(now_refreshes + 0.03); + const uint32_t left = fit > already ? fit - already : 0; + d.presents = std::min(m_repeats, left); + d.reset = m_reset; + m_reset = false; + if (d.presents > 0) + m_generated_this_second = true; + return d; + } + + pacer_event take_event() + { + const pacer_event e = m_event; + m_event = pacer_event::None; + return e; + } + + pacer_state state() const { return m_state; } + double median_ticks() const { return m_median; } + uint32_t steady_frames() const { return m_steady; } // the window's intervals of its usual length + double refreshes() const { return m_refreshes; } + uint32_t repeats() const { return m_repeats; } + uint32_t warm_frames() const { return m_count; } + bool paused() const { return m_paused_until > 0.0; } + double pause_seconds() const { return m_pause_length; } + double slow_speed() const { return m_fg_speed; } // the speed that started the last pause + double pause_floor() const { return m_pause_floor; } // the floor it fell below + double no_fg_speed() const { return m_nofg_speed; } // the speed during the last pause + double next_pause() const { return m_next_pause; } + double floor(double now) const { return now < m_floor_until ? m_floor : kSlowSpeed; } + + private: + double median() const + { + if (m_count == 0) + return 0.0; + uint32_t v[kWindow]; + for (uint32_t i = 0; i < m_count; i++) + v[i] = m_window[(m_next + kWindow - 1 - i) % kWindow]; + std::sort(v, v + m_count); + return (m_count & 1) ? v[m_count / 2] : (v[m_count / 2 - 1] + v[m_count / 2]) / 2.0; + } + + // how many of the window's intervals have its most common length. + uint32_t usual_count() const + { + uint32_t best = 0; + for (uint32_t i = 0; i < m_count; i++) + { + uint32_t same = 0; + for (uint32_t j = 0; j < m_count; j++) + same += m_window[(m_next + kWindow - 1 - j) % kWindow] == m_window[(m_next + kWindow - 1 - i) % kWindow] ? 1 : 0; + best = std::max(best, same); + } + return best; + } + + // Once a second: the speed against the floor while frames are generated, and the pauses. + void second(double now, double speed) + { + if (!m_second_started) + { + m_second_started = true; + m_second = now; + return; + } + if (now - m_second < 1.0) + return; + m_second = now; + const bool generated = m_generated_this_second; + m_generated_this_second = false; + + if (paused()) + { + if (now >= m_pause_start + kSettle) + { + m_nofg_sum += speed; + m_nofg_n++; + } + if (now < m_paused_until) + return; + m_paused_until = 0.0; + m_last_pause_end = now; + m_slow = 0; + m_slow_sum = 0.0; + m_nofg_speed = m_nofg_n > 0 ? m_nofg_sum / m_nofg_n : speed; + m_nofg_sum = 0.0; + m_nofg_n = 0; + if (m_nofg_speed >= kFullSpeed && m_nofg_speed >= m_fg_speed + kCostly) + { + m_next_pause = std::min(m_next_pause * 2.0, kMaxPause); + m_event = pacer_event::ResumedCostly; + } + else + { + m_floor = std::min(m_nofg_speed - kCostly, kSlowSpeed); + m_floor_until = now + kFloorSeconds; + m_next_pause = kFirstPause; + m_event = pacer_event::ResumedSlowGame; + } + m_reset = true; + return; + } + + if (m_last_pause_end > 0.0 && now - m_last_pause_end >= kPauseForget) + m_next_pause = kFirstPause; + if (generated && speed < floor(now)) + { + m_slow++; + m_slow_sum += speed; + } + else + { + m_slow = 0; + m_slow_sum = 0.0; + } + if (m_slow >= kSlowSeconds) + { + m_fg_speed = m_slow_sum / m_slow; + m_pause_floor = floor(now); + m_pause_length = m_next_pause; + m_pause_start = now; + m_paused_until = now + m_pause_length; + m_slow = 0; + m_slow_sum = 0.0; + m_event = pacer_event::Paused; + } + } + + uint32_t m_window[kWindow] = {}; + uint32_t m_next = 0, m_count = 0; + double m_median = 0.0; + uint32_t m_steady = 0; + double m_refreshes = 0.0; + uint32_t m_repeats = 1; + bool m_room = false; + bool m_reset = true; + bool m_generated_this_second = false; + pacer_state m_state = pacer_state::Warming; + pacer_event m_event = pacer_event::None; + + bool m_second_started = false; + double m_second = 0.0; + int m_slow = 0; + double m_slow_sum = 0.0; + double m_fg_speed = 0.0; + double m_pause_floor = kSlowSpeed; + double m_pause_start = 0.0; + double m_paused_until = 0.0; + double m_last_pause_end = 0.0; + double m_pause_length = kFirstPause; + double m_next_pause = kFirstPause; + double m_nofg_sum = 0.0; + int m_nofg_n = 0; + double m_nofg_speed = 0.0; + double m_floor = kSlowSpeed; + double m_floor_until = 0.0; + }; +} // namespace ps5::framegen diff --git a/tests/test_pacing.cpp b/tests/test_pacing.cpp new file mode 100644 index 0000000..c2f8d3c --- /dev/null +++ b/tests/test_pacing.cpp @@ -0,0 +1,401 @@ +// ps5-framegen: a check of the pacing (src/ps5_framegen_pacing.h) on a PC, with PS2 numbers: a 59.94 or 50 Hz source, 60 and +// 120 Hz displays. It comes from PS5SX2's tests/fgpacing. +// c++ -std=c++17 -Wall -Wextra -Isrc -o build/test_pacing tests/test_pacing.cpp && build/test_pacing +// Copyright (C) 2026 swordpdf +// SPDX-License-Identifier: GPL-2.0-only +#include "ps5_framegen_pacing.h" + +#include +#include +#include +#include + +using Decision = ps5::framegen::pacer_decision; +using Event = ps5::framegen::pacer_event; +using Pacing = ps5::framegen::pacer; +using State = ps5::framegen::pacer_state; + +static int s_failures = 0; +static int s_checks = 0; +#define CHECK(cond) \ + do \ + { \ + s_checks++; \ + if (!(cond)) \ + { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + s_failures++; \ + } \ + } while (0) + +constexpr double kNtsc = 59.94, kPal = 50.0, kHz60 = 59.94, kHz120 = 119.88; + +// A run of frames: each `vsyncs` apart on the PS2's clock, the emulation at `speed` percent (real time stretches to match), +// an emulator's presents modelled (PS5SX2's GSRenderer): the game's frame, the generated ones, and the repeats in between ("Skip duplicate +// frames" on, as by default: three skipped, the fourth presented). +struct Run +{ + Pacing p; + double now = 100.0; + double refreshes = 0.0; // the display's refreshes so far + double presents = 0.0; // the presents so far + uint32_t frames = 0, generated = 0, last_real = 0; + std::vector events; + bool over_budget = false; + + Decision Frame(uint32_t vsyncs, double ps2, double hz, double speed = 100.0, bool nominal = true) + { + const double clock = ps2 > 0.0 ? ps2 : 59.94, display = hz > 0.0 ? hz : 59.94; // what the pacing assumes then + now += vsyncs / clock * (100.0 / speed); + const uint32_t repeats = vsyncs > 0 ? (vsyncs - 1) / 4 : 0; + const uint32_t already = last_real + repeats; + const Decision d = p.frame(vsyncs, already, ps2, hz, now, speed, nominal); + const uint32_t fit = static_cast(vsyncs * display / clock + 0.03); + if (d.presents > 0 && already + d.presents > fit) + over_budget = true; + refreshes += vsyncs * display / clock; + presents += repeats + d.presents + 1; + last_real = 1; + frames++; + generated += d.presents; + if (const Event e = p.take_event(); e != Event::None) + events.push_back(e); + return d; + } +}; + +static bool Has(const std::vector& events, Event e) +{ + for (Event x : events) + if (x == e) + return true; + return false; +} + +// A steady game: every frame the same, after a few to settle. +static Decision Steady(uint32_t vsyncs, double ps2, double hz, uint32_t frames = 50) +{ + Run r; + Decision d; + for (uint32_t i = 0; i < frames; i++) + d = r.Frame(vsyncs, ps2, hz); + CHECK(!r.over_budget); + CHECK(r.presents <= r.refreshes + 1.0); + return d; +} + +int main() +{ + // The cases of the header's comment. + CHECK(Steady(1, kNtsc, kHz60).presents == 0); // R&C at 60 Hz: what the wall-clock pacing slowed to half speed + CHECK(!Steady(1, kNtsc, kHz60).engaged); + CHECK(Steady(1, kNtsc, kHz120).presents == 1); // 60 fps on a 120 Hz display + CHECK(Steady(2, kNtsc, kHz60).presents == 1); // 30 fps at 60 Hz + CHECK(Steady(2, kNtsc, kHz120).presents == 2); // 30 fps at 120 Hz: the generated frame twice + CHECK(Steady(3, kNtsc, kHz120).presents == 3); // 20 fps at 120 Hz: three times + CHECK(Steady(3, kNtsc, kHz60).presents == 1); // 20 fps at 60 Hz: once, the game's frame up for two refreshes + CHECK(Steady(4, kNtsc, kHz60).presents == 2); // 15 fps at 60 Hz + CHECK(Steady(1, kPal, kHz120).presents == 1); // PAL 50 fps on 120 Hz: 2.4 refreshes a frame + CHECK(Steady(2, kPal, kHz60).presents == 1); // PAL 25 fps at 60 Hz + CHECK(Steady(1, kPal, kHz60).presents == 0); // PAL 50 fps at 60 Hz: 1.2 refreshes, no room + CHECK(Steady(1, 60.0, kHz120).presents == 1); // an HD mode's 60.00 Hz on 119.88: 1.998 refreshes + CHECK(Steady(1, 59.83, kHz60).presents == 0); // NTSC progressive (59.83) at 59.94: one refresh + CHECK(Steady(1, 0.0, 0.0).presents == 0); // rates unknown: 59.94 both + CHECK(Steady(6, kNtsc, kHz60).presents == 3); // 10 fps at 60 Hz: a repeat presented in the interval, still in budget + + // The start: 8 frames first, then the first generating frame starts the interpolator over, the next ones don't. + { + Run r; + uint32_t first = 0; + Decision d; + for (uint32_t i = 1; i <= 20 && !first; i++) + { + d = r.Frame(1, kNtsc, kHz120); + if (d.engaged) + first = i; + } + CHECK(first == Pacing::kWindow); + CHECK(d.reset); + d = r.Frame(1, kNtsc, kHz120); + CHECK(d.engaged && !d.reset && d.presents == 1); + } + + // A gap (a load): nothing for 8 frames, then it starts over. + { + Run r; + for (int i = 0; i < 20; i++) + r.Frame(1, kNtsc, kHz120); + Decision d = r.Frame(30, kNtsc, kHz120); + CHECK(!d.engaged && d.presents == 0 && r.p.state() == State::Warming); + for (uint32_t i = 1; i < Pacing::kWindow; i++) + { + d = r.Frame(1, kNtsc, kHz120); + CHECK(!d.engaged); + } + d = r.Frame(1, kNtsc, kHz120); + CHECK(d.engaged && d.reset && d.presents == 1); + } + + // After a load, a first long interval doesn't switch it on for a 60 fps game at 60 Hz (the review's case). + { + Run r; + for (int i = 0; i < 20; i++) + r.Frame(1, kNtsc, kHz60); + r.Frame(12, kNtsc, kHz60); + r.Frame(8, kNtsc, kHz60); + uint32_t engaged = 0; + for (int i = 0; i < 40; i++) + engaged += r.Frame(1, kNtsc, kHz60).engaged ? 1 : 0; + CHECK(engaged == 0); + } + + // 60 fps games that drop a frame now and then, at 60 Hz: never a generated frame (the wall-clock pacing's trap). + { + Run r; + for (int i = 0; i < 600; i++) + r.Frame((i % 7) == 3 ? 2 : 1, kNtsc, kHz60); + CHECK(r.generated == 0); + CHECK(!r.over_budget); + } + + // A 30 fps game at 60 Hz with a short frame now and then: it stays on, and that frame gets no generated present. + { + Run r; + uint32_t short_with_presents = 0; + for (int i = 0; i < 600; i++) + { + const uint32_t v = (i % 9) == 4 ? 1 : 2; + const Decision d = r.Frame(v, kNtsc, kHz60); + if (v == 1 && d.presents > 0) + short_with_presents++; + } + CHECK(short_with_presents == 0); + CHECK(r.generated > 480); + CHECK(!r.over_budget); + CHECK(r.presents <= r.refreshes + 1.0); + } + + // Alternating 60 and 30 at 60 Hz: the median is 1.5 refreshes, no room. + { + Run r; + for (int i = 0; i < 300; i++) + r.Frame((i & 1) ? 2 : 1, kNtsc, kHz60); + CHECK(r.generated == 0); + } + + // Random frame patterns, both displays, both regions: never more presents than refreshes. + { + srand(1234); + for (int run = 0; run < 400; run++) + { + Run r; + const double ps2 = (run & 1) ? kPal : kNtsc; + const double hz = (run & 2) ? kHz120 : kHz60; + const int base = 1 + rand() % 3; + for (int i = 0; i < 300; i++) + { + uint32_t v = static_cast(base); + const int roll = rand() % 10; + if (roll == 0) + v = 1; + else if (roll == 1) + v = static_cast(base + 1); + else if (roll == 2 && (rand() % 20) == 0) + v = 12; // a load + r.Frame(v, ps2, hz); + } + CHECK(!r.over_budget); + CHECK(r.presents <= r.refreshes + 1.0); + } + } + + // Turbo, slow motion, the fast boot: nothing generated, and back when the limiter is. + { + Run r; + for (int i = 0; i < 20; i++) + r.Frame(1, kNtsc, kHz120); + Decision d = r.Frame(1, kNtsc, kHz120, 300.0, false); + CHECK(!d.engaged && d.presents == 0 && r.p.state() == State::NotNominal); + d = r.Frame(1, kNtsc, kHz120); + CHECK(d.engaged && d.reset && d.presents == 1 && r.p.state() == State::Generating); + } + + // The safety net. Generated frames that cost speed: a 10 s pause, full speed in it, so the next pause is 20 s. + { + Run r; + for (int i = 0; i < 120; i++) // two seconds at full speed + r.Frame(1, kNtsc, kHz120); + CHECK(!r.p.paused()); + int slow_frames = 0; + while (!r.p.paused() && slow_frames < 1000) + { + r.Frame(1, kNtsc, kHz120, 80.0); + slow_frames++; + } + CHECK(r.p.paused() && r.p.state() == State::Paused); + CHECK(Has(r.events, Event::Paused)); + CHECK(std::fabs(r.p.pause_seconds() - 10.0) < 1e-9); + CHECK(std::fabs(r.p.slow_speed() - 80.0) < 1e-9); + CHECK(std::fabs(r.p.pause_floor() - 95.0) < 1e-9); + CHECK(slow_frames >= 48 * 2 && slow_frames <= 48 * 4); // two to four seconds at 80% (48 frames a second) + const double pause_start = r.now; + uint32_t generated_in_pause = 0; + while (r.p.paused() && r.now - pause_start < 30.0) + { + const Decision in_pause = r.Frame(1, kNtsc, kHz120); + if (r.p.paused()) + generated_in_pause += in_pause.presents; + } + CHECK(generated_in_pause == 0); + CHECK(r.now - pause_start >= 10.0 && r.now - pause_start < 11.5); + CHECK(Has(r.events, Event::ResumedCostly)); + CHECK(r.p.no_fg_speed() > 99.0); + Decision d = r.Frame(1, kNtsc, kHz120); + CHECK(d.engaged && d.presents == 1); + r.events.clear(); + while (!r.p.paused() && slow_frames < 5000) + { + r.Frame(1, kNtsc, kHz120, 80.0); + slow_frames++; + } + CHECK(std::fabs(r.p.pause_seconds() - 20.0) < 1e-9); + // Then five minutes without a pause: the next one is 10 s again. + while (r.p.paused()) + r.Frame(1, kNtsc, kHz120); + const double calm_until = r.now + 310.0; + while (r.now < calm_until) + r.Frame(1, kNtsc, kHz120); + while (!r.p.paused() && r.frames < 200000) + r.Frame(1, kNtsc, kHz120, 80.0); + CHECK(std::fabs(r.p.pause_seconds() - 10.0) < 1e-9); + } + + // Better without them but not at full speed (80% with, 84% without): not blamed on them, the pause doesn't grow. + { + Run r; + for (int i = 0; i < 120; i++) + r.Frame(1, kNtsc, kHz120); + while (!r.p.paused() && r.frames < 2000) + r.Frame(1, kNtsc, kHz120, 80.0); + while (r.p.paused() && r.frames < 4000) + r.Frame(1, kNtsc, kHz120, 84.0); + CHECK(Has(r.events, Event::ResumedSlowGame)); + CHECK(std::fabs(r.p.next_pause() - 10.0) < 1e-9); + } + + // A game slow by itself: as slow in the pause, so frames are generated again and its speed (less 3) is the floor. + { + Run r; + for (int i = 0; i < 120; i++) + r.Frame(1, kNtsc, kHz120); + while (!r.p.paused() && r.frames < 2000) + r.Frame(1, kNtsc, kHz120, 85.0); + CHECK(r.p.paused()); + while (r.p.paused() && r.frames < 4000) + r.Frame(1, kNtsc, kHz120, 86.0); + CHECK(Has(r.events, Event::ResumedSlowGame)); + CHECK(std::fabs(r.p.floor(r.now) - 83.0) < 1e-9); + const double until = r.now + 30.0; + while (r.now < until) + r.Frame(1, kNtsc, kHz120, 85.0); + CHECK(!r.p.paused()); + while (!r.p.paused() && r.frames < 20000) + r.Frame(1, kNtsc, kHz120, 75.0); + CHECK(r.p.paused()); + CHECK(std::fabs(r.p.pause_seconds() - 10.0) < 1e-9); + CHECK(std::fabs(r.p.pause_floor() - 83.0) < 1e-9); + } + + // A slow game with nothing generated (60 fps at 60 Hz): no pause, nothing to blame. + { + Run r; + for (int i = 0; i < 2000; i++) + r.Frame(1, kNtsc, kHz60, 50.0); + CHECK(!r.p.paused()); + CHECK(r.events.empty()); + } + + // The pause's length tops out at 160 s. + { + Run r; + for (int round = 0; round < 7; round++) + { + while (!r.p.paused() && r.frames < 200000) + r.Frame(1, kNtsc, kHz120, 70.0); + while (r.p.paused() && r.frames < 400000) + r.Frame(1, kNtsc, kHz120, 100.0); + } + while (!r.p.paused() && r.frames < 600000) + r.Frame(1, kNtsc, kHz120, 70.0); + CHECK(std::fabs(r.p.pause_seconds() - 160.0) < 1e-9); + } + + // Only a steady frame rate gets generated frames. Ratchet & Clank's stretch of frames 1 or 2 vsyncs long + // (45-58 fps) at 120 Hz: none, the game's own frames. + { + Run r; + const uint32_t pattern[] = {1, 1, 2, 1, 2, 1, 1, 2, 1, 2, 2, 1}; + uint32_t generated = 0; + for (int i = 0; i < 600; i++) + { + const Decision d = r.Frame(pattern[i % 12], kNtsc, kHz120); + generated += d.presents; + } + CHECK(generated == 0); + CHECK(r.p.state() == State::Unsteady); + CHECK(r.p.steady_frames() < Pacing::kSteadyStart); + } + // A steady 60 fps game with a slow frame now and then (1 in 8): still generated, the long frame capped by its own refreshes. + { + Run r; + uint32_t generated = 0; + for (int i = 0; i < 400; i++) + { + const Decision d = r.Frame(i % 8 == 7 ? 2 : 1, kNtsc, kHz120); + if (i > 50) + generated += d.presents; + } + CHECK(generated > 300); + CHECK(r.p.state() == State::Generating); + CHECK(!r.over_budget); + } + // Generating, then the frames turn uneven: 2 of 8 different keeps it on, 3 of 8 stops it; 7 of 8 even again starts it. + { + Run r; + for (int i = 0; i < 60; i++) + r.Frame(1, kNtsc, kHz120); + CHECK(r.p.state() == State::Generating); + r.Frame(2, kNtsc, kHz120); + r.Frame(1, kNtsc, kHz120); + r.Frame(2, kNtsc, kHz120); + CHECK(r.p.steady_frames() == 6); + CHECK(r.p.state() == State::Generating); + r.Frame(2, kNtsc, kHz120); + CHECK(r.p.steady_frames() == 5); + CHECK(r.p.state() == State::Unsteady); + // Back to even: the three 2s leave the window one by one; 6 of 8 isn't enough to start again, 7 is. + int to_start = 0; + while (r.p.state() != State::Generating && to_start < 20) + { + const uint32_t before = r.p.steady_frames(); + r.Frame(1, kNtsc, kHz120); + to_start++; + if (r.p.state() == State::Generating) + CHECK(r.p.steady_frames() >= Pacing::kSteadyStart && before < Pacing::kSteadyStart + 1); + } + CHECK(r.p.state() == State::Generating); + CHECK(r.p.steady_frames() == 7); + } + // A steady 30 fps game at 60 Hz still doubles; one alternating between 30 and 20 fps (2, 3 vsyncs) doesn't. + { + CHECK(Steady(2, kNtsc, kHz60).engaged); + Run r; + bool any = false; + for (int i = 0; i < 400; i++) + any = r.Frame(i % 2 ? 3 : 2, kNtsc, kHz60).presents > 0 || any; + CHECK(!any); + } + + printf("%s: %d of %d checks passed (frame generation's pacing)\n", s_failures ? "FAIL" : "PASS", s_checks - s_failures, s_checks); + return s_failures ? 1 : 0; +} diff --git a/tools/fg-test.cpp b/tools/fg-test.cpp index 5d00f09..5a35cef 100644 --- a/tools/fg-test.cpp +++ b/tools/fg-test.cpp @@ -703,7 +703,13 @@ int main(int argc, char** argv) ps5::framegen::interpolator fg; std::string error; const auto t0 = std::chrono::steady_clock::now(); - if (!fg.create(g.physical, g.device, a.w, a.h, error)) die(("create: " + error).c_str()); + // FG_TEST_BGRA=1: the frames in B8G8R8A8_UNORM (create()'s frame_format, as a renderer drawing in a BGRA swapchain's format + // has them), uploaded with R and B swapped; the result must be the same as with R8G8B8A8. + const char* bgra_env = std::getenv("FG_TEST_BGRA"); + const bool bgra = bgra_env && bgra_env[0] == '1'; + if (!fg.create(g.physical, g.device, a.w, a.h, error, bgra ? VK_FORMAT_B8G8R8A8_UNORM : ps5::framegen::interpolator::k_format)) + die(("create: " + error).c_str()); + if (bgra) std::printf("frames in B8G8R8A8_UNORM\n"); const auto t1 = std::chrono::steady_clock::now(); std::printf("created for %dx%d in %.0f ms\n", a.w, a.h, std::chrono::duration(t1 - t0).count()); @@ -719,6 +725,11 @@ int main(int argc, char** argv) { const picture cur = frame_at(frame); std::memcpy(map, cur.rgba.data(), bytes); + if (bgra) + { + unsigned char* px = static_cast(map); + for (size_t i = 0; i < bytes; i += 4) std::swap(px[i], px[i + 2]); + } VkCommandBuffer cmd = g.begin(); fg.prepare(cmd); fg.set_debug_view(debug_view && frame == frames - 1);