mirror of
https://github.com/Swordpdf/ps5-framegen.git
synced 2026-10-23 10:01:52 +02:00
PS5SX2's changes: ps5vk's compact bindings, the frames' format, the tick-based pacer (AI-assisted)
What PS5SX2 2.00 added on top of the template's m74, made general: - create() renumbers each pass's bindings 0..n-1 in the SPIR-V, the set layout and the writes. ps5vk sizes a set's table to its highest binding number and keeps it in a byte, so FSR3's sparse numbers (up to 405) made vkCreateComputePipelines fail there. Output is bit-identical on SwiftShader and lavapipe. - create(..., frame_format): the game's frames may be B8G8R8A8_UNORM, for renderers that draw them in a BGRA swapchain's format (fg-test FG_TEST_BGRA=1 checks the picture is the same). - PS5_FRAMEGEN_VULKAN_HEADER: build against your own Vulkan header or loader. - src/ps5_framegen_pacing.h: the pacer, counted in the source's ticks (an emulated machine's vblanks, or a game loop's) instead of the wall clock, which fed itself at 60 Hz. It generates only with two refreshes a frame or more, at a steady frame rate, after 8 frames of warm-up, with the safety net that pauses generation when it costs an emulator its speed. tests/test_pacing.cpp: its 911 checks. - examples/present_loop.cpp drives the pacer, the multiplier from the ticks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaNprJCmpBhrkpzRHVPLXM
This commit is contained in:
1 parent
360995492b
commit
11937f654f
6 files changed
+932
-90
No files matched your search
+116
-69
@@ -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 <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
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<std::uint32_t>(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<double>(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<int>(e), static_cast<int>(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<double, std::milli>(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<std::uint32_t>(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<std::uint32_t>(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<double, std::milli>(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<double, std::milli>(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
|
||||
Reference in new issue
Block a user