Files
dhkatzandClaude Opus 5.5 065c7e9300
CI / build (push) Successful in 1m15s
Never make the client wait for drawing, or hold up its steps
The renderer process now takes each view's chunks on a thread of its own as
they come, and draws the newest frame on its main thread: the client no longer
waits for the previous frame to be drawn (on a VM, 22 of a frame's 25 ms).

The adapter records the client's steps (SceneViewer::step, by replacing its
entry in the exported IFrameListener vtable, after checking it), and the frame
pacer doesn't ask for a frame that wouldn't end before the next step is due: a
late step made the client catch up two at once, which showed as hitches. While
the renderer process covers the view, the client's renderer doesn't begin or
present frames, which could wait for the display.

The engine's stats now include the time in the client renderer's methods.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 21:30:46 -07:00

168 lines
5.6 KiB
C++

#include "frame_pacer.hpp"
#include <algorithm>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "log.hpp"
#include "scene_hooks.hpp"
namespace t5de::frame_pacer {
namespace {
// The client's own rate: its ticks already draw these frames.
constexpr DWORD clientRate = 30;
// The client is left idle at least this share of a frame's time between frames.
constexpr double idleShare = 1.0 / 3.0;
// The client's tick (SceneWindow.dll's millisecondsPerFrame).
constexpr double tickSeconds = 0.033;
// An extra frame must end at least this long before the next tick is due.
constexpr double tickMargin = 0.001;
// The user's frame rate, from the registry (unset or unreadable: the client's own).
DWORD read_rate()
{
DWORD rate = 0;
DWORD size = sizeof(rate);
if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\T5DE\\Graphics", L"FrameRate", RRF_RT_REG_DWORD, nullptr, &rate,
&size) != ERROR_SUCCESS) {
return clientRate;
}
return rate;
}
double seconds(std::int64_t counter)
{
static const double frequency = [] {
LARGE_INTEGER value;
QueryPerformanceFrequency(&value);
return static_cast<double>(value.QuadPart);
}();
return static_cast<double>(counter) / frequency;
}
double now_seconds()
{
LARGE_INTEGER counter;
QueryPerformanceCounter(&counter);
return seconds(counter.QuadPart);
}
class Pacer {
public:
static Pacer& get()
{
static Pacer pacer;
return pacer;
}
void add(HWND window)
{
const std::scoped_lock lock(mutex_);
windows_.push_back({.window = window});
if (!started_) {
started_ = true;
std::thread([this] { run(); }).detach(); // runs while the client does
}
}
void remove(HWND window)
{
const std::scoped_lock lock(mutex_);
std::erase_if(windows_, [window](const Window& w) { return w.window == window; });
}
void frameDrawn(HWND window, double seconds)
{
const std::scoped_lock lock(mutex_);
for (Window& w : windows_) {
if (w.window == window) {
w.pending = false;
w.frameTime = w.frameTime == 0 ? seconds : w.frameTime * 0.9 + seconds * 0.1;
w.drawnAt = now_seconds();
}
}
}
private:
struct Window {
HWND window;
bool pending = false; // a repaint asked for and not yet drawn
double frameTime = 0; // seconds a frame takes the client, averaged
double drawnAt = 0; // when its last frame was drawn
double askedAt = 0; // when a repaint was last asked for
};
// Re-reads the setting now and then; above the client's rate, asks each window for repaints at the rate, or as often
// as leaves the client its idle time.
void run()
{
HANDLE timer = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
double checked = 0;
DWORD rate = clientRate;
for (;;) {
const double now = now_seconds();
if (now - checked >= 0.5) {
checked = now;
const DWORD read = read_rate();
if (read != rate) {
rate = read;
log::info("frame pacer: frame rate {}", rate == 0 ? std::string("uncapped") : std::to_string(rate));
}
}
if (rate != 0 && rate <= clientRate) {
Sleep(100);
continue;
}
const double interval = rate == 0 ? 0 : 1.0 / rate;
// The next tick, if known (scene_hooks.hpp): extra frames mustn't hold it up, or the client catches up two
// steps at once (visible hitches).
const std::int64_t lastStep = scene_hooks::lastStep();
const double untilTick = lastStep != 0 ? seconds(lastStep) + tickSeconds - now : tickSeconds;
{
const std::scoped_lock lock(mutex_);
for (Window& w : windows_) {
if (w.pending && now - w.askedAt < 0.25) {
continue; // not drawn yet (asked again after a while: the client may have skipped it)
}
if (untilTick > -tickSeconds && untilTick < w.frameTime + tickMargin) {
continue; // the tick is due: it draws a frame of its own
}
// Since the last frame ended: the rest of the interval since it began, and the idle time.
if (now - w.drawnAt >= std::max(interval - w.frameTime, w.frameTime * idleShare)) {
w.pending = true;
w.askedAt = now;
InvalidateRect(w.window, nullptr, FALSE);
}
}
}
// Wake often enough for the rate (1 ms at most when uncapped).
const double wait = std::clamp(interval / 4, 0.0005, 0.004);
LARGE_INTEGER due{.QuadPart = -static_cast<LONGLONG>(wait * 10'000'000)}; // relative, 100 ns units
if (timer != nullptr && SetWaitableTimer(timer, &due, 0, nullptr, nullptr, FALSE)) {
WaitForSingleObject(timer, INFINITE);
} else {
Sleep(1);
}
}
}
std::mutex mutex_;
std::vector<Window> windows_;
bool started_ = false;
};
} // namespace
void add(HWND window) { Pacer::get().add(window); }
void remove(HWND window) { Pacer::get().remove(window); }
void frameDrawn(HWND window, double seconds) { Pacer::get().frameDrawn(window, seconds); }
} // namespace t5de::frame_pacer