mirror of
https://github.com/Toastconcern/FrameStudio.git
synced 2026-10-28 21:02:20 +01:00
Frame Studio - Gaussian splats (PLY, SPZ v2-v4), PLY vertex colours, panorama skies - Copy, cut, paste and duplicate for objects, splats and world items - Walkable-area rebake moved off the UI thread; viewport one frame sooner; refresh-locked pacing (FS_PACE=timer for the old one) - Fixed: bundle thumbnail use-after-free crash, auto-save deleting environments, saves into scratch copies, edits lost on non-ASCII paths, unopenable manifests with inf/nan, bundle save data loss, opening a home marking it changed, gizmo NaN, long paths, many malformed-file crashes - Updater verifies sha256, journals the swap and never leaves the exe missing Frame Home - Seats turn you to face their way - Draw batching (Hand Tutorials up to ~20% less GPU time), cut-out EQUAL pass, late head-pose read before submit - Proper queue-family hand-over to the compositor layer; layer descriptor generations and seqlocked slots; structural SPIR-V check - Fixed: hangs/crashes on damaged .fenv files and shaders, render-pass validation error, sync hazard in the foveation smoothing pass, unverified update downloads, half-done installs Tests: tests_loco/env_share/spv_check wired into CMake (FRAME_TESTS) and CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsaXGU27gsrXjDoFtyFJP9
212 lines
11 KiB
C++
212 lines
11 KiB
C++
#pragma once
|
|
// Frame pacing for Frame Studio's window: one frame per display refresh while the window is in use, a few per
|
|
// second when it's in the background, and none at all once nothing on screen can change (no input for a moment,
|
|
// nothing animating, loading or saving) until the next input arrives.
|
|
//
|
|
// Without it the UI loop drew as fast as the GPU allowed (MAILBOX presents never block): ~75% of an RTX 3060 for a
|
|
// home sitting still on a 164 Hz monitor.
|
|
//
|
|
// The wait happens before the frame reads input, so pacing adds no input latency: the frame starts as late as it can.
|
|
// On Windows the frames are locked to the display's refreshes (DWM's vblank clock): each starts its own measured
|
|
// cost (plus a millisecond to spare) before a refresh, so it's on screen at that refresh. A plain timer at the
|
|
// refresh rate drifts against the display: some refreshes got two frames and the next none (a hitch every few
|
|
// seconds), and input waited up to a whole refresh for no reason. Only while DWM's clock keeps to a steady grid
|
|
// (a virtual or idle display's doesn't): the plain timer otherwise, or always with FS_PACE=timer.
|
|
// The window code (Win32 or GLFW) supplies what the pacer can't know: the display's rate, whether the window has
|
|
// focus, and how to sleep until input arrives.
|
|
#ifdef _WIN32
|
|
#include <windows.h>
|
|
#include <dwmapi.h>
|
|
#endif
|
|
|
|
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <functional>
|
|
#include <thread>
|
|
|
|
class FramePacer {
|
|
public:
|
|
using Clock = std::chrono::steady_clock;
|
|
// Frames per second while in the background (another window has focus).
|
|
static constexpr double kBackgroundFps = 30.0;
|
|
// After the last input, keep drawing at full rate this long (ImGui hover effects, tooltips and drags settle).
|
|
static constexpr double kSettleSeconds = 1.0;
|
|
// Idle waits wake at least this often, so background work's progress (saving, uploading) still shows.
|
|
static constexpr double kIdleWakeSeconds = 0.25;
|
|
// Locked to the refresh: a frame starts this much before the refresh it's for, beyond what frames have cost lately.
|
|
static constexpr double kSpareMs = 1.0;
|
|
|
|
// isFocused: the window has the keyboard. waitForInput(seconds): sleep until input arrives or the time is up.
|
|
FramePacer(std::function<bool()> isFocused, std::function<void(double)> waitForInput)
|
|
: isFocused_(std::move(isFocused)), waitForInput_(std::move(waitForInput)) {
|
|
#ifdef _WIN32
|
|
// High-resolution waitable timer (Windows 10 1803+); a plain one (~1-15 ms granularity) otherwise.
|
|
timer_ = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
|
|
if (!timer_) timer_ = CreateWaitableTimerExW(nullptr, nullptr, 0, TIMER_ALL_ACCESS);
|
|
LARGE_INTEGER f;
|
|
QueryPerformanceFrequency(&f);
|
|
qpcHz_ = static_cast<double>(f.QuadPart);
|
|
LARGE_INTEGER q;
|
|
QueryPerformanceCounter(&q);
|
|
clockBase_ = Clock::now();
|
|
qpcBase_ = q.QuadPart;
|
|
const char* pace = std::getenv("FS_PACE");
|
|
lockToVblank_ = !(pace && std::strcmp(pace, "timer") == 0);
|
|
#endif
|
|
lastInput_ = nextFrame_ = Clock::now();
|
|
}
|
|
~FramePacer() {
|
|
#ifdef _WIN32
|
|
if (timer_) CloseHandle(timer_);
|
|
#endif
|
|
}
|
|
FramePacer(const FramePacer&) = delete;
|
|
FramePacer& operator=(const FramePacer&) = delete;
|
|
|
|
// FS_FPS=n overrides the display's rate (0: unlimited, the old behaviour, for comparisons).
|
|
void setFpsOverride(double fps) { fpsOverride_ = fps; }
|
|
|
|
// The refresh rate of the display the window is on (the window code tells it again when the window moves).
|
|
void setDisplayFps(double fps) { displayFps_ = fps > 1 ? fps : 60.0; }
|
|
double displayFps() const { return displayFps_; }
|
|
|
|
// Call for every input from the user (keyboard, mouse, resize, focus, file drop).
|
|
void noteInput() { lastInput_ = Clock::now(); }
|
|
|
|
// What the frame just drawn cost, from its start to its picture being done (CPU, then the GPU's part): how early
|
|
// the next one starts. The second-worst of the last 64 frames counts (one stall misses its refresh whatever the
|
|
// lead; a lead set by it would add latency to every frame after), leaving out frames longer than a refresh.
|
|
void noteFrameCost(double ms) {
|
|
if (!(ms > 0)) return;
|
|
costs_[costAt_++ % kCosts] = ms;
|
|
double top = 0, second = 0;
|
|
const double cap = 1000.0 / displayFps_;
|
|
for (double c : costs_) {
|
|
if (c >= cap) continue;
|
|
if (c > top) { second = top; top = c; } else if (c > second) second = c;
|
|
}
|
|
costMs_ = second > 0 ? second : top > 0 ? top : costMs_;
|
|
}
|
|
double frameCostMs() const { return costMs_; }
|
|
|
|
// The display's last refresh and its period, from DWM's clock (Windows): false when there's none to lock to.
|
|
bool vblank(Clock::time_point& last, double& periodMs) const {
|
|
#ifdef _WIN32
|
|
DWM_TIMING_INFO ti;
|
|
std::memset(&ti, 0, sizeof ti);
|
|
ti.cbSize = sizeof ti;
|
|
if (FAILED(DwmGetCompositionTimingInfo(nullptr, &ti)) || ti.qpcRefreshPeriod == 0 || ti.qpcVBlank == 0) return false;
|
|
LARGE_INTEGER q;
|
|
QueryPerformanceCounter(&q);
|
|
const double agoMs = (static_cast<double>(q.QuadPart) - static_cast<double>(ti.qpcVBlank)) * 1000.0 / qpcHz_;
|
|
// (from one fixed pairing of the two clocks: the same refresh is the same time_point every call)
|
|
const long long hz = static_cast<long long>(qpcHz_), c = static_cast<long long>(ti.qpcVBlank) - qpcBase_;
|
|
last = clockBase_ + std::chrono::duration_cast<Clock::duration>(std::chrono::nanoseconds(c / hz * 1000000000LL + c % hz * 1000000000LL / hz));
|
|
periodMs = static_cast<double>(ti.qpcRefreshPeriod) * 1000.0 / qpcHz_;
|
|
return periodMs > 2.0 && periodMs < 100.0 && agoMs > -periodMs && agoMs < 1000.0;
|
|
#else
|
|
(void)last; (void)periodMs;
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
// Blocks until the next frame is due. `sceneChanging`: something on screen moves by itself (animation playing,
|
|
// a home loading, a save's progress, a fly-through). An idle wait ends early when input arrives.
|
|
void waitForNextFrame(bool sceneChanging) {
|
|
const bool focused = isFocused_();
|
|
const bool settled = std::chrono::duration<double>(Clock::now() - lastInput_).count() > kSettleSeconds;
|
|
if (settled && !sceneChanging) {
|
|
// Nothing can change until the user does something: sleep until input.
|
|
waitForInput_(kIdleWakeSeconds);
|
|
nextFrame_ = Clock::now() - period(displayFps_); // the frame after waking starts at once
|
|
targetVblank_ = Clock::time_point();
|
|
return;
|
|
}
|
|
const double fps = fpsOverride_ >= 0 ? fpsOverride_ : focused ? displayFps_ : kBackgroundFps;
|
|
if (fps <= 0) return;
|
|
Clock::time_point last;
|
|
double periodMs = 0;
|
|
if (lockToVblank_ && fpsOverride_ < 0 && focused && vblank(last, periodMs) && std::fabs(1000.0 / periodMs - displayFps_) < displayFps_ * 0.25 &&
|
|
onGrid(last, periodMs)) {
|
|
// The first refresh this frame can make (its cost from now), and never the one the last frame was for.
|
|
const auto per = std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double, std::milli>(periodMs));
|
|
const auto lead = std::chrono::duration_cast<Clock::duration>(
|
|
std::chrono::duration<double, std::milli>(std::clamp(costMs_ + kSpareMs, 1.5, periodMs - 0.5)));
|
|
const Clock::time_point now = Clock::now();
|
|
Clock::time_point v = last + per;
|
|
while (v < now + lead) v += per;
|
|
if (targetVblank_ != Clock::time_point() && v < targetVblank_ + per / 2) v += per;
|
|
targetVblank_ = v;
|
|
nextFrame_ = v - lead;
|
|
sleepUntil(nextFrame_);
|
|
return;
|
|
}
|
|
targetVblank_ = Clock::time_point();
|
|
// A frame that ran late starts the next period from now instead of rushing to catch up.
|
|
nextFrame_ = std::max(nextFrame_ + period(fps), Clock::now());
|
|
// Input arriving meanwhile waits for the frame (at most one refresh): waking for each event would let a
|
|
// 1000 Hz mouse drive the frame rate.
|
|
sleepUntil(nextFrame_);
|
|
}
|
|
|
|
// The refresh the frame now starting is for (none when not locked to them).
|
|
Clock::time_point targetVblank() const { return targetVblank_; }
|
|
|
|
private:
|
|
static constexpr int kCosts = 64;
|
|
|
|
// Whether DWM's refreshes keep to a grid: each new one a whole number of periods (to 0.3 ms) after the one
|
|
// before, four in a row.
|
|
bool onGrid(Clock::time_point v, double periodMs) {
|
|
if (v == lastSeenVblank_) return gridRun_ >= 4;
|
|
if (lastSeenVblank_ != Clock::time_point()) {
|
|
const double d = std::chrono::duration<double, std::milli>(v - lastSeenVblank_).count(), k = std::round(d / periodMs);
|
|
gridRun_ = k >= 1 && std::fabs(d - k * periodMs) < 0.3 ? std::min(gridRun_ + 1, 8) : 0;
|
|
}
|
|
lastSeenVblank_ = v;
|
|
return gridRun_ >= 4;
|
|
}
|
|
|
|
static Clock::duration period(double fps) {
|
|
return std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(1.0 / fps));
|
|
}
|
|
|
|
void sleepUntil(Clock::time_point when) {
|
|
auto wait = when - Clock::now();
|
|
if (wait <= Clock::duration::zero()) return;
|
|
#ifdef _WIN32
|
|
// Windows' Sleep has ~1-15 ms granularity: a high-resolution timer to within a few tenths of a millisecond
|
|
// (it wakes up to ~0.5 ms late), then a short spin to the moment itself.
|
|
const auto early = std::chrono::microseconds(600);
|
|
if (timer_ && wait > early) {
|
|
LARGE_INTEGER due;
|
|
due.QuadPart = -static_cast<LONGLONG>(std::chrono::duration_cast<std::chrono::nanoseconds>(wait - early).count() / 100); // relative, 100 ns units
|
|
if (SetWaitableTimer(timer_, &due, 0, nullptr, nullptr, FALSE)) WaitForSingleObject(timer_, INFINITE);
|
|
}
|
|
while (Clock::now() < when) YieldProcessor();
|
|
return;
|
|
#endif
|
|
std::this_thread::sleep_until(when);
|
|
}
|
|
|
|
std::function<bool()> isFocused_;
|
|
std::function<void(double)> waitForInput_;
|
|
#ifdef _WIN32
|
|
HANDLE timer_ = nullptr;
|
|
double qpcHz_ = 1;
|
|
long long qpcBase_ = 0;
|
|
Clock::time_point clockBase_;
|
|
#endif
|
|
bool lockToVblank_ = false;
|
|
double displayFps_ = 60;
|
|
double fpsOverride_ = -1;
|
|
double costMs_ = 4.0; // (until frames say otherwise)
|
|
double costs_[kCosts] = {};
|
|
unsigned costAt_ = 0;
|
|
int gridRun_ = 0;
|
|
Clock::time_point lastInput_, nextFrame_, targetVblank_, lastSeenVblank_;
|
|
};
|