Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions include/fast/Fast3dWindow.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "ship/controller/controldevice/controller/mapping/keyboard/KeyboardScancodes.h"
#include "FastMouseStateManager.h"
#include "fast/debug/GfxDebugger.h"
#include "fast/InterpolationPacer.h"

union Gfx;
#include "interpreter.h"
Expand Down Expand Up @@ -88,6 +89,9 @@ class Fast3dWindow : public Ship::Window {
/** @brief Returns the graphics debugger for this Fast3D window. */
std::shared_ptr<GfxDebugger> GetGfxDebugger() const;

/** @brief Returns the frame-interpolation pacer for this Fast3D window. */
std::shared_ptr<InterpolationPacer> GetInterpolationPacer() const;

protected:
static bool KeyDown(int32_t scancode);
static bool KeyUp(int32_t scancode);
Expand All @@ -101,5 +105,6 @@ class Fast3dWindow : public Ship::Window {
GfxWindowBackend* mWindowManagerApi;
std::shared_ptr<Interpreter> mInterpreter = nullptr;
std::shared_ptr<GfxDebugger> mGfxDebugger;
std::shared_ptr<InterpolationPacer> mInterpolationPacer;
};
} // namespace Fast
86 changes: 86 additions & 0 deletions include/fast/InterpolationPacer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#pragma once

#include <chrono>
#include <cstdint>

namespace Fast {

/**
* @brief Decides how many interpolated sub-frames to render between two
* fixed-rate game ticks, bounded by the measured render budget.
*
* Frame interpolation renders extra "tween" frames between a game's authentic,
* fixed-rate logic ticks. On a single-threaded port, rendering more tweens than
* the hardware can draw in a tick drags the whole loop -- and therefore the game
* logic -- into slow-motion. This bounds the sub-frame count to what the render
* budget can actually sustain, so a heavy scene falls back toward the native rate
* instead of stalling.
*
* The host owns the game-specific interpolation *recording*; this owns the pacing
* math and the render-cost model. Each tick the host reports how long game logic
* took (ReportLogicTime) and the cost of every rendered sub-frame
* (ReportSubframeCost), then asks Plan() how many sub-frames fit and at what
* present rate.
*/
class InterpolationPacer {
public:
/** @brief Per-tick inputs supplied by the host (game-specific state resolved). */
struct TickInputs {
// Required -- the host must supply these. There is no universal default:
// the native sim rate is game-specific (e.g. 30 Hz for BK/SM64, 20 Hz for
// OoT/MM) and can vary per tick (cutscene/pause), so the port computes it.
// The 0 defaults are an unset sentinel, not a usable rate.
int32_t targetFps = 0; ///< User-requested interpolation target (host-supplied).
int32_t nativeLogicFps = 0; ///< Authentic sim rate this tick, e.g. 60 / viPerTick (host-supplied).
// Optional, with safe defaults.
int32_t hardCapFps = 0; ///< Game-specific ceiling (e.g. cutscene); 0 = none.
bool bypassBudget = false; ///< Escape hatch: honor targetFps regardless of cost.
bool forceNative = false; ///< Replay/demo: never interpolate (one render/tick).
};

/** @brief The pacing decision for a tick. */
struct Pacing {
int32_t subframes = 1; ///< Renders to emit this tick (>= 1; the last is the keyframe).
int32_t paceFps = 30; ///< Present-pace fps to feed Window::SetTargetFps.
};

/** @brief Compute the pacing for the tick about to render. */
Pacing Plan(const TickInputs& inputs);

/** @brief Report the wall-time one rendered sub-frame took, in nanoseconds. */
void ReportSubframeCost(int64_t ns);

/**
* @brief Report the wall-time game logic took this tick before drawing began,
* in nanoseconds. Reserved from the budget so a busy tick can't overschedule.
*/
void ReportLogicTime(int64_t ns);

/**
* @brief Set how much of each tick's render budget to spend on interpolated
* frames, as a fraction in [0.5, 1.0]. Higher = smoother (more tweens, closer
* to the budget edge); lower = steadier (more headroom). The budget itself is
* always enforced -- this only tunes how aggressively it is filled.
*/
void SetSmoothness(float fraction);

/** @brief Forget past measurements (e.g. on game/scene load) so pacing starts fresh. */
void Reset();

private:
int32_t BudgetCappedFps(int32_t userTarget, int32_t nativeLogicFps);

double mEmaPerSubFrameUs = 0.0; // Average sub-frame draw time.
double mPeakPerSubFrameUs = 0.0; // Busiest recent sub-frame; the cap must fit the worst one.
double mEnvLogicUs = 0.0; // Reserved game-update time per tick.
int64_t mWinRunNs = 0; // Accumulators for the current measurement window.
int32_t mWinSubFrames = 0;
double mWinMaxUs = 0.0;
std::chrono::steady_clock::time_point mWinStart = std::chrono::steady_clock::now();
double mSafetySteady = 0.95; // Budget fraction spent once steady (set by SetSmoothness).
double mSafetyRising = 0.75; // Budget fraction while a scene is getting busier (cautious).
double mSafety = 0.95; // Current safety, oscillating between steady and rising.
int32_t mRisingHoldRemaining = 0; // Windows left to stay cautious after a load spike.
};

} // namespace Fast
5 changes: 5 additions & 0 deletions src/fast/Fast3dWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ void Fast3dWindow::Init() {
InitWindowManager();
mGfxDebugger = std::make_shared<GfxDebugger>();
mInterpreter->SetGfxDebugger(mGfxDebugger);
mInterpolationPacer = std::make_shared<InterpolationPacer>();
mInterpreter->Init(mWindowManagerApi, mRenderingApi, Ship::Context::GetRawInstance()->GetName().c_str(),
isFullscreen, width, height, posX, posY);
mWindowManagerApi->SetFullscreenChangedCallback(OnFullscreenChanged);
Expand Down Expand Up @@ -444,4 +445,8 @@ std::shared_ptr<GfxDebugger> Fast3dWindow::GetGfxDebugger() const {
return mGfxDebugger;
}

std::shared_ptr<InterpolationPacer> Fast3dWindow::GetInterpolationPacer() const {
return mInterpolationPacer;
}

} // namespace Fast
161 changes: 161 additions & 0 deletions src/fast/InterpolationPacer.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#include "fast/InterpolationPacer.h"

#include <algorithm>

namespace Fast {

namespace {
using Clock = std::chrono::steady_clock;

// Cost-model tuning. The steady/rising budget fractions are configurable per
// pacer (SetSmoothness); the rest are fixed internals.
constexpr double kRisingThreshold = 1.20; // How much busier a window counts as "rising".
constexpr int kRisingHoldWindows = 3; // Measurement windows to stay cautious after a spike.
constexpr auto kSampleWindow = std::chrono::milliseconds(200);
constexpr double kEmaAlpha = 0.5; // Weight of the newest window average.
constexpr double kSpikeFactor = 1.25; // A sub-frame this much over peak counts as a spike.
constexpr double kSpikeBlend = 0.5; // How hard a spike pulls the peak up.
constexpr double kPeakDecay = 0.40; // How fast the peak eases down each window.
constexpr double kLogicRiseAlpha = 0.5; // Reserved-logic EMA: rise fast when logic gets heavier...
constexpr double kLogicFallAlpha = 0.1; // ...and fall slow when it calms.
} // namespace

InterpolationPacer::Pacing InterpolationPacer::Plan(const TickInputs& inputs) {
int32_t nativeLogicFps = inputs.nativeLogicFps >= 1 ? inputs.nativeLogicFps : 1;
int32_t target = inputs.targetFps;

// Bound the target to what the render budget can sustain, unless the host
// opted into the escape hatch (always honor the requested FPS).
if (!inputs.bypassBudget) {
target = BudgetCappedFps(target, nativeLogicFps);
}
// Game-specific ceiling (e.g. music-synced cutscenes capped at native).
if (inputs.hardCapFps > 0 && target > inputs.hardCapFps) {
target = inputs.hardCapFps;
}

// Integer sub-frames per tick: floor(target / native), at least one (the
// keyframe). Replay/demo never interpolates.
int32_t subframes = target / nativeLogicFps;
if (subframes < 1) {
subframes = 1;
}
if (inputs.forceNative) {
subframes = 1;
}

// paceFps drives the per-present wait so that subframes * 1/paceFps equals one
// tick of game time, keeping wall-clock aligned with the game's VI cadence.
Pacing out;
out.subframes = subframes;
out.paceFps = subframes * nativeLogicFps;
return out;
}

int32_t InterpolationPacer::BudgetCappedFps(int32_t userTarget, int32_t nativeLogicFps) {
// Base the cap on the busiest recent sub-frame, not the average: every frame
// we promise gets drawn, so the time has to fit the worst one.
double costUs = std::max(mPeakPerSubFrameUs, mEmaPerSubFrameUs);
if (costUs <= 0.0) {
return userTarget; // Nothing measured yet.
}
double tickBudgetUs = 1'000'000.0 / nativeLogicFps;
// Set aside the time game logic spent, then share what's left among the
// interpolated sub-frames so a busy tick can't overshoot its budget.
double renderBudgetUs = tickBudgetUs - mEnvLogicUs;
if (renderBudgetUs <= 0.0) {
return nativeLogicFps;
}
double maxSubPerTick = (renderBudgetUs * mSafety) / costUs;
if (maxSubPerTick < 1.0) {
return nativeLogicFps;
}
int32_t maxFps = (int32_t)(maxSubPerTick * nativeLogicFps);
if (maxFps < nativeLogicFps) {
maxFps = nativeLogicFps;
}
return std::min(userTarget, maxFps);
}

void InterpolationPacer::ReportLogicTime(int64_t ns) {
double us = (double)ns / 1000.0;
if (us < 0.0) {
return;
}
if (mEnvLogicUs == 0.0) {
mEnvLogicUs = us;
} else {
// Rise fast when logic gets heavier, fall slow when it calms.
double alpha = us > mEnvLogicUs ? kLogicRiseAlpha : kLogicFallAlpha;
mEnvLogicUs = alpha * us + (1.0 - alpha) * mEnvLogicUs;
}
}

void InterpolationPacer::ReportSubframeCost(int64_t ns) {
double sampleUs = (double)ns / 1000.0;

mWinRunNs += ns;
mWinSubFrames++;
if (sampleUs > mWinMaxUs) {
mWinMaxUs = sampleUs;
}

// A sudden spike bumps the peak up immediately and keeps us cautious, so the
// next cap already reflects it instead of waiting for the window to close.
if (mPeakPerSubFrameUs == 0.0) {
mPeakPerSubFrameUs = sampleUs;
mSafety = mSafetyRising;
mRisingHoldRemaining = kRisingHoldWindows;
} else if (sampleUs > mPeakPerSubFrameUs * kSpikeFactor) {
mPeakPerSubFrameUs = kSpikeBlend * sampleUs + (1.0 - kSpikeBlend) * mPeakPerSubFrameUs;
mSafety = mSafetyRising;
mRisingHoldRemaining = kRisingHoldWindows;
}

auto now = Clock::now();
if (now - mWinStart < kSampleWindow) {
return;
}

// End of a measurement window: refresh the average, decide whether the scene
// is still getting busier, and ease the peak back down.
double mean = (double)mWinRunNs / mWinSubFrames / 1000.0;
mEmaPerSubFrameUs = mEmaPerSubFrameUs == 0.0 ? mean : kEmaAlpha * mean + (1.0 - kEmaAlpha) * mEmaPerSubFrameUs;

bool rising = mPeakPerSubFrameUs > 0.0 && mWinMaxUs > mPeakPerSubFrameUs * kRisingThreshold;
if (rising) {
mRisingHoldRemaining = kRisingHoldWindows;
} else if (mRisingHoldRemaining > 0) {
mRisingHoldRemaining--;
}
mSafety = mRisingHoldRemaining > 0 ? mSafetyRising : mSafetySteady;

mPeakPerSubFrameUs = std::max(mWinMaxUs, mPeakPerSubFrameUs * (1.0 - kPeakDecay) + mWinMaxUs * kPeakDecay);

mWinRunNs = 0;
mWinSubFrames = 0;
mWinMaxUs = 0.0;
mWinStart = now;
}

void InterpolationPacer::SetSmoothness(float fraction) {
// Clamp to a sane range: below 0.5 wastes headroom, above 1.0 is impossible.
double steady = fraction < 0.5f ? 0.5 : (fraction > 1.0f ? 1.0 : (double)fraction);
mSafetySteady = steady;
// Keep the cautious (rising) safety a fixed margin below steady, floored at 0.5.
mSafetyRising = steady - 0.20 > 0.5 ? steady - 0.20 : 0.5;
}

void InterpolationPacer::Reset() {
mEmaPerSubFrameUs = 0.0;
mPeakPerSubFrameUs = 0.0;
mEnvLogicUs = 0.0;
mWinRunNs = 0;
mWinSubFrames = 0;
mWinMaxUs = 0.0;
mWinStart = Clock::now();
mSafety = mSafetySteady;
mRisingHoldRemaining = 0;
}

} // namespace Fast
Loading