From 6914d7c6e063b3bc57ae83497f0f1d9fc5d93137 Mon Sep 17 00:00:00 2001 From: lawnjelly Date: Fri, 30 Apr 2021 19:48:08 +0100 Subject: [PATCH] Add frame delta smoothing option Frame deltas are currently measured by querying the OS timer each frame. This is subject to random error. Frame delta smoothing instead filters the delta read from the OS by replacing it with the refresh rate delta wherever possible. This PR also contains code to estimate the refresh rate based on the input deltas, without reading the refresh rate from the host OS. --- core/bind/core_bind.cpp | 12 ++ core/bind/core_bind.h | 3 + core/os/os.cpp | 9 ++ core/os/os.h | 4 + doc/classes/OS.xml | 3 + doc/classes/ProjectSettings.xml | 5 + main/main.cpp | 14 ++ main/main_timer_sync.cpp | 243 ++++++++++++++++++++++++++++++++ main/main_timer_sync.h | 63 +++++++++ 9 files changed, 356 insertions(+) diff --git a/core/bind/core_bind.cpp b/core/bind/core_bind.cpp index 71fc171eee9..f3851291c0b 100644 --- a/core/bind/core_bind.cpp +++ b/core/bind/core_bind.cpp @@ -596,6 +596,14 @@ bool _OS::is_vsync_via_compositor_enabled() const { return OS::get_singleton()->is_vsync_via_compositor_enabled(); } +void _OS::set_delta_smoothing(bool p_enabled) { + OS::get_singleton()->set_delta_smoothing(p_enabled); +} + +bool _OS::is_delta_smoothing_enabled() const { + return OS::get_singleton()->is_delta_smoothing_enabled(); +} + _OS::PowerState _OS::get_power_state() { return _OS::PowerState(OS::get_singleton()->get_power_state()); } @@ -1369,6 +1377,9 @@ void _OS::_bind_methods() { ClassDB::bind_method(D_METHOD("set_vsync_via_compositor", "enable"), &_OS::set_vsync_via_compositor); ClassDB::bind_method(D_METHOD("is_vsync_via_compositor_enabled"), &_OS::is_vsync_via_compositor_enabled); + ClassDB::bind_method(D_METHOD("set_delta_smoothing", "delta_smoothing_enabled"), &_OS::set_delta_smoothing); + ClassDB::bind_method(D_METHOD("is_delta_smoothing_enabled"), &_OS::is_delta_smoothing_enabled); + ClassDB::bind_method(D_METHOD("has_feature", "tag_name"), &_OS::has_feature); ClassDB::bind_method(D_METHOD("get_power_state"), &_OS::get_power_state); @@ -1391,6 +1402,7 @@ void _OS::_bind_methods() { ADD_PROPERTY(PropertyInfo(Variant::INT, "exit_code"), "set_exit_code", "get_exit_code"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "vsync_enabled"), "set_use_vsync", "is_vsync_enabled"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "vsync_via_compositor"), "set_vsync_via_compositor", "is_vsync_via_compositor_enabled"); + ADD_PROPERTY(PropertyInfo(Variant::BOOL, "delta_smoothing"), "set_delta_smoothing", "is_delta_smoothing_enabled"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "low_processor_usage_mode"), "set_low_processor_usage_mode", "is_in_low_processor_usage_mode"); ADD_PROPERTY(PropertyInfo(Variant::INT, "low_processor_usage_mode_sleep_usec"), "set_low_processor_usage_mode_sleep_usec", "get_low_processor_usage_mode_sleep_usec"); ADD_PROPERTY(PropertyInfo(Variant::BOOL, "keep_screen_on"), "set_keep_screen_on", "is_keep_screen_on"); diff --git a/core/bind/core_bind.h b/core/bind/core_bind.h index e03b26df376..7e7c9734f97 100644 --- a/core/bind/core_bind.h +++ b/core/bind/core_bind.h @@ -374,6 +374,9 @@ public: void set_vsync_via_compositor(bool p_enable); bool is_vsync_via_compositor_enabled() const; + void set_delta_smoothing(bool p_enabled); + bool is_delta_smoothing_enabled() const; + PowerState get_power_state(); int get_power_seconds_left(); int get_power_percent_left(); diff --git a/core/os/os.cpp b/core/os/os.cpp index 423396778a9..8032b78edf3 100644 --- a/core/os/os.cpp +++ b/core/os/os.cpp @@ -587,6 +587,14 @@ bool OS::is_vsync_via_compositor_enabled() const { return _vsync_via_compositor; } +void OS::set_delta_smoothing(bool p_enabled) { + _delta_smoothing_enabled = p_enabled; +} + +bool OS::is_delta_smoothing_enabled() const { + return _delta_smoothing_enabled; +} + OS::PowerState OS::get_power_state() { return POWERSTATE_UNKNOWN; } @@ -798,6 +806,7 @@ OS::OS() { _no_window = false; _exit_code = 0; _orientation = SCREEN_LANDSCAPE; + _delta_smoothing_enabled = false; _render_thread_mode = RENDER_THREAD_SAFE; diff --git a/core/os/os.h b/core/os/os.h index 45a24ed5f0e..b144fbc6eae 100644 --- a/core/os/os.h +++ b/core/os/os.h @@ -62,6 +62,7 @@ class OS { bool _allow_layered; bool _use_vsync; bool _vsync_via_compositor; + bool _delta_smoothing_enabled; char *last_error; @@ -548,6 +549,9 @@ public: void set_vsync_via_compositor(bool p_enable); bool is_vsync_via_compositor_enabled() const; + void set_delta_smoothing(bool p_enabled); + bool is_delta_smoothing_enabled() const; + virtual OS::PowerState get_power_state(); virtual int get_power_seconds_left(); virtual int get_power_percent_left(); diff --git a/doc/classes/OS.xml b/doc/classes/OS.xml index 7e5fe622635..5ce0440aa94 100644 --- a/doc/classes/OS.xml +++ b/doc/classes/OS.xml @@ -1104,6 +1104,9 @@ The current screen index (starting from 0). + + If [code]true[/code], the engine filters the time delta measured between each frame, and attempts to compensate for random variation. This will only operate on systems where V-Sync is active. + The exit code passed to the OS when the main loop exits. By convention, an exit code of [code]0[/code] indicates success whereas a non-zero exit code indicates an error. For portability reasons, the exit code should be set between 0 and 125 (inclusive). [b]Note:[/b] This value will be ignored if using [method SceneTree.quit] with an [code]exit_code[/code] argument passed. diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index aebe194033b..bd39c48e61c 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -244,6 +244,11 @@ Icon set in [code].ico[/code] format used on Windows to set the game's icon. This is done automatically on start by calling [method OS.set_native_icon]. + + Time samples for frame deltas are subject to random variation introduced by the platform, even when frames are displayed at regular intervals thanks to V-Sync. This can lead to jitter. Delta smoothing can often give a better result by filtering the input deltas to correct for minor fluctuations from the refresh rate. + [b]Note:[/b] Delta smoothing is only attempted when [member display/window/vsync/use_vsync] is switched on, as it does not work well without V-Sync. + It may take several seconds at a stable frame rate before the smoothing is initially activated. It will only be active on machines where performance is adequate to render frames at the refresh rate. + If [code]true[/code], disables printing to standard error. If [code]true[/code], this also hides error and warning messages printed by [method @GDScript.push_error] and [method @GDScript.push_warning]. See also [member application/run/disable_stdout]. Changes to this setting will only be applied upon restarting the application. diff --git a/main/main.cpp b/main/main.cpp index 74e6ae85a8d..d78e271f371 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -267,6 +267,8 @@ void Main::print_help(const char *p_binary) { OS::get_singleton()->print(" --no-window Run with invisible window. Useful together with --script.\n"); OS::get_singleton()->print(" --enable-vsync-via-compositor When vsync is enabled, vsync via the OS' window compositor (Windows only).\n"); OS::get_singleton()->print(" --disable-vsync-via-compositor Disable vsync via the OS' window compositor (Windows only).\n"); + OS::get_singleton()->print(" --enable-delta-smoothing When vsync is enabled, enabled frame delta smoothing.\n"); + OS::get_singleton()->print(" --disable-delta-smoothing Disable frame delta smoothing.\n"); OS::get_singleton()->print(" --tablet-driver Tablet input driver ("); for (int i = 0; i < OS::get_singleton()->get_tablet_driver_count(); i++) { if (i != 0) { @@ -417,6 +419,7 @@ Error Main::setup(const char *execpath, int argc, char *argv[], bool p_second_ph bool use_custom_res = true; bool force_res = false; bool saw_vsync_via_compositor_override = false; + bool delta_smoothing_override = false; #ifdef TOOLS_ENABLED bool found_project = false; #endif @@ -637,6 +640,12 @@ Error Main::setup(const char *execpath, int argc, char *argv[], bool p_second_ph } else if (I->get() == "--disable-vsync-via-compositor") { video_mode.vsync_via_compositor = false; saw_vsync_via_compositor_override = true; + } else if (I->get() == "--enable-delta-smoothing") { + OS::get_singleton()->set_delta_smoothing(true); + delta_smoothing_override = true; + } else if (I->get() == "--disable-delta-smoothing") { + OS::get_singleton()->set_delta_smoothing(false); + delta_smoothing_override = true; #endif } else if (I->get() == "--profiling") { // enable profiling @@ -1191,6 +1200,11 @@ Error Main::setup(const char *execpath, int argc, char *argv[], bool p_second_ph OS::get_singleton()->set_low_processor_usage_mode_sleep_usec(GLOBAL_DEF("application/run/low_processor_mode_sleep_usec", 6900)); // Roughly 144 FPS ProjectSettings::get_singleton()->set_custom_property_info("application/run/low_processor_mode_sleep_usec", PropertyInfo(Variant::INT, "application/run/low_processor_mode_sleep_usec", PROPERTY_HINT_RANGE, "0,33200,1,or_greater")); // No negative numbers + GLOBAL_DEF("application/run/delta_smoothing", true); + if (!delta_smoothing_override) { + OS::get_singleton()->set_delta_smoothing(GLOBAL_GET("application/run/delta_smoothing")); + } + GLOBAL_DEF("display/window/ios/hide_home_indicator", true); GLOBAL_DEF("input_devices/pointing/ios/touch_delay", 0.150); diff --git a/main/main_timer_sync.cpp b/main/main_timer_sync.cpp index f3490f6aa64..9f37667774f 100644 --- a/main/main_timer_sync.cpp +++ b/main/main_timer_sync.cpp @@ -30,6 +30,9 @@ #include "main_timer_sync.h" +#include "core/math/math_funcs.h" +#include "core/os/os.h" + void MainFrameTime::clamp_idle(float min_idle_step, float max_idle_step) { if (idle_step < min_idle_step) { idle_step = min_idle_step; @@ -40,6 +43,244 @@ void MainFrameTime::clamp_idle(float min_idle_step, float max_idle_step) { ///////////////////////////////// +void MainTimerSync::DeltaSmoother::update_refresh_rate_estimator(int p_delta) { + // the calling code should prevent 0 or negative values of delta + // (preventing divide by zero) + + // note that if the estimate gets locked, and something external changes this + // (e.g. user changes to non-vsync in the OS), then the results may be less than ideal, + // but usually it will detect this via the FPS measurement and not attempt smoothing. + // This should be a rare occurrence anyway, and will be cured next time user restarts game. + if (_estimate_locked) { + return; + } + + // First average the delta over NUM_READINGS + _estimator_total_delta += p_delta; + _estimator_delta_readings++; + + const int NUM_READINGS = 60; + + if (_estimator_delta_readings < NUM_READINGS) { + return; + } + + // use average + p_delta = _estimator_total_delta / NUM_READINGS; + + // reset the averager for next time + _estimator_delta_readings = 0; + _estimator_total_delta = 0; + + /////////////////////////////// + + int fps = Math::round(1000000.0 / p_delta); + + // initial estimation, to speed up converging, special case we will estimate the refresh rate + // from the first average FPS reading + if (_estimated_fps == 0) { + // below 50 might be chugging loading stuff, or else + // dropping loads of frames, so the estimate will be inaccurate + if (fps >= 50) { + _estimated_fps = fps; +#ifdef GODOT_DEBUG_DELTA_SMOOTHER + print_line("initial guess (average measured) refresh rate: " + itos(fps)); +#endif + } else { + // can't get started until above 50 + return; + } + } + + // we hit our exact estimated refresh rate. + // increase our confidence in the estimate. + if (fps == _estimated_fps) { + // note that each hit is an average of NUM_READINGS frames + _hits_at_estimated++; + + if (_estimate_complete && _hits_at_estimated == 20) { + _estimate_locked = true; +#ifdef GODOT_DEBUG_DELTA_SMOOTHER + print_line("estimate LOCKED at " + itos(_estimated_fps) + " fps"); +#endif + return; + } + + // if we are getting pretty confident in this estimate, decide it is complete + // (it can still be increased later, and possibly lowered but only for a short time) + if ((!_estimate_complete) && (_hits_at_estimated > 2)) { + // when the estimate is complete we turn on smoothing + if (_estimated_fps) { + _estimate_complete = true; + _vsync_delta = 1000000 / _estimated_fps; + +#ifdef GODOT_DEBUG_DELTA_SMOOTHER + print_line("estimate complete. vsync_delta " + itos(_vsync_delta) + ", fps " + itos(_estimated_fps)); +#endif + } + } + +#ifdef GODOT_DEBUG_DELTA_SMOOTHER + if ((_hits_at_estimated % (400 / NUM_READINGS)) == 0) { + String sz = "hits at estimated : " + itos(_hits_at_estimated) + ", above : " + itos(_hits_above_estimated) + "( " + itos(_hits_one_above_estimated) + " ), below : " + itos(_hits_below_estimated) + " (" + itos(_hits_one_below_estimated) + " )"; + + print_line(sz); + } +#endif + + return; + } + + const int SIGNIFICANCE_UP = 1; + const int SIGNIFICANCE_DOWN = 2; + + // we are not usually interested in slowing the estimate + // but we may have overshot, so make it possible to reduce + if (fps < _estimated_fps) { + // micro changes + if (fps == (_estimated_fps - 1)) { + _hits_one_below_estimated++; + + if ((_hits_one_below_estimated > _hits_at_estimated) && (_hits_one_below_estimated > SIGNIFICANCE_DOWN)) { + _estimated_fps--; + made_new_estimate(); + } + + return; + } else { + _hits_below_estimated++; + + // don't allow large lowering if we are established at a refresh rate, as it will probably be dropped frames + bool established = _estimate_complete && (_hits_at_estimated > 10); + + // macro changes + // note there is a large barrier to macro lowering. That is because it is more likely to be dropped frames + // than mis-estimation of the refresh rate. + if (!established) { + if (((_hits_below_estimated / 8) > _hits_at_estimated) && (_hits_below_estimated > SIGNIFICANCE_DOWN)) { + // decrease the estimate + _estimated_fps--; + made_new_estimate(); + } + } + + return; + } + } + + // Changes increasing the estimate. + // micro changes + if (fps == (_estimated_fps + 1)) { + _hits_one_above_estimated++; + + if ((_hits_one_above_estimated > _hits_at_estimated) && (_hits_one_above_estimated > SIGNIFICANCE_UP)) { + _estimated_fps++; + made_new_estimate(); + } + return; + } else { + _hits_above_estimated++; + + // macro changes + if ((_hits_above_estimated > _hits_at_estimated) && (_hits_above_estimated > SIGNIFICANCE_UP)) { + // increase the estimate + int change = fps - _estimated_fps; + change /= 2; + change = MAX(1, change); + + _estimated_fps += change; + made_new_estimate(); + } + return; + } +} + +bool MainTimerSync::DeltaSmoother::fps_allows_smoothing(int p_delta) { + _measurement_time += p_delta; + _measurement_frame_count++; + + if (_measurement_frame_count == _measurement_end_frame) { + // only switch on or off if the estimate is complete + if (_estimate_complete) { + int64_t time_passed = _measurement_time - _measurement_start_time; + + // average delta + time_passed /= MEASURE_FPS_OVER_NUM_FRAMES; + + // estimate fps + if (time_passed) { + float fps = 1000000.0f / time_passed; + float ratio = fps / (float)_estimated_fps; + + //print_line("ratio : " + String(Variant(ratio))); + + if ((ratio > 0.95) && (ratio < 1.05)) { + _measurement_allows_smoothing = true; + } else { + _measurement_allows_smoothing = false; + } + } + } // estimate complete + + // new start time for next iteration + _measurement_start_time = _measurement_time; + _measurement_end_frame += MEASURE_FPS_OVER_NUM_FRAMES; + } + + return _measurement_allows_smoothing; +} + +int MainTimerSync::DeltaSmoother::smooth_delta(int p_delta) { + // Conditions to disable smoothing. + // Note that vsync is a request, it cannot be relied on, the OS may override this. + // If the OS turns vsync on without vsync in the app, smoothing will not be enabled. + // If the OS turns vsync off with sync enabled in the app, the smoothing must detect this + // via the error metric and switch off. + if (!OS::get_singleton()->is_delta_smoothing_enabled() || !OS::get_singleton()->is_vsync_enabled() || Engine::get_singleton()->is_editor_hint()) { + return p_delta; + } + + // keep a running guesstimate of the FPS, and turn off smoothing if + // conditions not close to the estimated FPS + if (!fps_allows_smoothing(p_delta)) { + return p_delta; + } + + // we can't cope with negative deltas .. OS bug on some hardware + // and also very small deltas caused by vsync being off. + // This could possibly be part of a hiccup, this value isn't fixed in stone... + if (p_delta < 1000) { + return p_delta; + } + + // note still some vsync off will still get through to this point... + // and we need to cope with it by not converging the estimator / and / or not smoothing + update_refresh_rate_estimator(p_delta); + + // no smoothing until we know what the refresh rate is + if (!_estimate_complete) { + return p_delta; + } + + // accumulate the time we have available to use + _leftover_time += p_delta; + + // how many vsyncs units can we fit? + int units = _leftover_time / _vsync_delta; + + // a delta must include minimum 1 vsync + // (if it is less than that, it is either random error or we are no longer running at the vsync rate, + // in which case we should switch off delta smoothing, or re-estimate the refresh rate) + units = MAX(units, 1); + + _leftover_time -= units * _vsync_delta; + // print_line("units " + itos(units) + ", leftover " + itos(_leftover_time/1000) + " ms"); + + return units * _vsync_delta; +} + +///////////////////////////////////// + // returns the fraction of p_frame_slice required for the timer to overshoot // before advance_core considers changing the physics_steps return from // the typical values as defined by typical_physics_steps @@ -195,6 +436,8 @@ float MainTimerSync::get_cpu_idle_step() { uint64_t cpu_ticks_elapsed = current_cpu_ticks_usec - last_cpu_ticks_usec; last_cpu_ticks_usec = current_cpu_ticks_usec; + cpu_ticks_elapsed = _delta_smoother.smooth_delta(cpu_ticks_elapsed); + return cpu_ticks_elapsed / 1000000.0; } diff --git a/main/main_timer_sync.h b/main/main_timer_sync.h index d6ef01129f2..3ae4e08cf19 100644 --- a/main/main_timer_sync.h +++ b/main/main_timer_sync.h @@ -33,6 +33,9 @@ #include "core/engine.h" +// define these to get more debugging logs for the delta smoothing +//#define GODOT_DEBUG_DELTA_SMOOTHER + struct MainFrameTime { float idle_step; // time to advance idles for (argument to process()) int physics_steps; // number of times to iterate the physics engine @@ -42,6 +45,66 @@ struct MainFrameTime { }; class MainTimerSync { + class DeltaSmoother { + public: + // pass the recorded delta, returns a smoothed delta + int smooth_delta(int p_delta); + + private: + void update_refresh_rate_estimator(int p_delta); + bool fps_allows_smoothing(int p_delta); + + // estimated vsync delta (monitor refresh rate) + int64_t _vsync_delta = 16666; + + // keep track of accumulated time so we know how many vsyncs to advance by + int64_t _leftover_time = 0; + + // keep a rough measurement of the FPS as we run. + // If this drifts a long way below or above the refresh rate, the machine + // is struggling to keep up, and we can switch off smoothing. This + // also deals with the case that the user has overridden the vsync in the GPU settings, + // in which case we don't want to try smoothing. + static const int MEASURE_FPS_OVER_NUM_FRAMES = 64; + + int64_t _measurement_time = 0; + int64_t _measurement_frame_count = 0; + int64_t _measurement_end_frame = MEASURE_FPS_OVER_NUM_FRAMES; + int64_t _measurement_start_time = 0; + bool _measurement_allows_smoothing = true; + + // we can estimate the fps by growing it on condition + // that a large proportion of frames are higher than the current estimate. + int _estimated_fps = 0; + int _hits_at_estimated = 0; + int _hits_above_estimated = 0; + int _hits_below_estimated = 0; + int _hits_one_above_estimated = 0; + int _hits_one_below_estimated = 0; + bool _estimate_complete = false; + bool _estimate_locked = false; + + // data for averaging the delta over a second or so + // to prevent spurious values + int _estimator_total_delta = 0; + int _estimator_delta_readings = 0; + + void made_new_estimate() { + _hits_above_estimated = 0; + _hits_at_estimated = 0; + _hits_below_estimated = 0; + _hits_one_above_estimated = 0; + _hits_one_below_estimated = 0; + + _estimate_complete = false; + +#ifdef GODOT_DEBUG_DELTA_SMOOTHER + print_line("estimated fps " + itos(_estimated_fps)); +#endif + } + + } _delta_smoother; + // wall clock time measured on the main thread uint64_t last_cpu_ticks_usec; uint64_t current_cpu_ticks_usec;