From 7925670f81593f64f434d24552c1eec41b241308 Mon Sep 17 00:00:00 2001 From: lawnjelly Date: Wed, 1 Sep 2021 15:47:12 +0100 Subject: [PATCH] Add frame delta smoothing option (4.x) 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. The delta_smooth_enabled setting can also be modified at runtime through OS::, and there is also now a command line setting to override the project setting. --- core/core_bind.cpp | 12 ++ core/core_bind.h | 3 + core/os/os.cpp | 8 + core/os/os.h | 4 + doc/classes/OS.xml | 3 + doc/classes/ProjectSettings.xml | 5 + main/main.cpp | 30 ++++ main/main_timer_sync.cpp | 257 ++++++++++++++++++++++++++++++++ main/main_timer_sync.h | 63 ++++++++ 9 files changed, 385 insertions(+) diff --git a/core/core_bind.cpp b/core/core_bind.cpp index 8fa7aad0acf..030ec442174 100644 --- a/core/core_bind.cpp +++ b/core/core_bind.cpp @@ -224,6 +224,14 @@ int OS::get_low_processor_usage_mode_sleep_usec() const { return ::OS::get_singleton()->get_low_processor_usage_mode_sleep_usec(); } +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(); +} + void OS::alert(const String &p_alert, const String &p_title) { ::OS::get_singleton()->alert(p_alert, p_title); } @@ -556,6 +564,9 @@ void OS::_bind_methods() { ClassDB::bind_method(D_METHOD("set_low_processor_usage_mode_sleep_usec", "usec"), &OS::set_low_processor_usage_mode_sleep_usec); ClassDB::bind_method(D_METHOD("get_low_processor_usage_mode_sleep_usec"), &OS::get_low_processor_usage_mode_sleep_usec); + 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("get_processor_count"), &OS::get_processor_count); ClassDB::bind_method(D_METHOD("get_processor_name"), &OS::get_processor_name); @@ -631,6 +642,7 @@ void OS::_bind_methods() { 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, "delta_smoothing"), "set_delta_smoothing", "is_delta_smoothing_enabled"); // Those default values need to be specified for the docs generator, // to avoid using values from the documentation writer's own OS instance. diff --git a/core/core_bind.h b/core/core_bind.h index be43ae2c9d2..38a681c7db6 100644 --- a/core/core_bind.h +++ b/core/core_bind.h @@ -141,6 +141,9 @@ public: void set_low_processor_usage_mode_sleep_usec(int p_usec); int get_low_processor_usage_mode_sleep_usec() const; + void set_delta_smoothing(bool p_enabled); + bool is_delta_smoothing_enabled() const; + void alert(const String &p_alert, const String &p_title = "ALERT!"); void crash(const String &p_message); diff --git a/core/os/os.cpp b/core/os/os.cpp index 4123a1d6025..025dcfe982b 100644 --- a/core/os/os.cpp +++ b/core/os/os.cpp @@ -151,6 +151,14 @@ int OS::get_low_processor_usage_mode_sleep_usec() const { return low_processor_usage_mode_sleep_usec; } +void OS::set_delta_smoothing(bool p_enabled) { + _delta_smoothing_enabled = p_enabled; +} + +bool OS::is_delta_smoothing_enabled() const { + return _delta_smoothing_enabled; +} + String OS::get_executable_path() const { return _execpath; } diff --git a/core/os/os.h b/core/os/os.h index 1652c1ed904..09ed31b9ceb 100644 --- a/core/os/os.h +++ b/core/os/os.h @@ -52,6 +52,7 @@ class OS { bool _keep_screen_on = true; // set default value to true, because this had been true before godot 2.0. bool low_processor_usage_mode = false; int low_processor_usage_mode_sleep_usec = 10000; + bool _delta_smoothing_enabled = false; bool _verbose_stdout = false; bool _debug_stdout = false; String _local_clipboard; @@ -154,6 +155,9 @@ public: virtual void set_low_processor_usage_mode_sleep_usec(int p_usec); virtual int get_low_processor_usage_mode_sleep_usec() const; + void set_delta_smoothing(bool p_enabled); + bool is_delta_smoothing_enabled() const; + virtual Vector get_system_fonts() const { return Vector(); }; virtual String get_system_font_path(const String &p_font_name, int p_weight = 400, int p_stretch = 100, bool p_italic = false) const { return String(); }; virtual Vector get_system_font_path_for_text(const String &p_font_name, const String &p_text, const String &p_locale = String(), const String &p_script = String(), int p_weight = 400, int p_stretch = 100, bool p_italic = false) const { return Vector(); }; diff --git a/doc/classes/OS.xml b/doc/classes/OS.xml index 3bf41775492..e64b409551e 100644 --- a/doc/classes/OS.xml +++ b/doc/classes/OS.xml @@ -670,6 +670,9 @@ + + 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. + If [code]true[/code], the engine optimizes for low processor usage by only refreshing the screen if needed. Can improve battery consumption on mobile. diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml index 818e402067a..ee3367c24c8 100644 --- a/doc/classes/ProjectSettings.xml +++ b/doc/classes/ProjectSettings.xml @@ -284,6 +284,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 DisplayServer.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/vsync_mode] is set to [code]enabled[/code], 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 @GlobalScope.push_error] and [method @GlobalScope.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 b9f2daf0205..29fee2f1557 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -468,6 +468,7 @@ void Main::print_help(const char *p_binary) { OS::get_singleton()->print(" --disable-render-loop Disable render loop so rendering only occurs when called explicitly from script.\n"); OS::get_singleton()->print(" --disable-crash-handler Disable crash handler when supported by the platform code.\n"); OS::get_singleton()->print(" --fixed-fps Force a fixed number of frames per second. This setting disables real-time synchronization.\n"); + OS::get_singleton()->print(" --delta-smoothing Enable or disable frame delta smoothing ['enable', 'disable'].\n"); OS::get_singleton()->print(" --print-fps Print the frames per second to the stdout.\n"); OS::get_singleton()->print("\n"); @@ -791,6 +792,7 @@ Error Main::setup(const char *execpath, int argc, char *argv[], bool p_second_ph Vector breakpoints; bool use_custom_res = true; bool force_res = false; + bool delta_smoothing_override = false; String default_renderer = ""; String default_renderer_mobile = ""; @@ -1000,6 +1002,29 @@ Error Main::setup(const char *execpath, int argc, char *argv[], bool p_second_ph OS::get_singleton()->print("Missing tablet driver argument, aborting.\n"); goto error; } + } else if (I->get() == "--delta-smoothing") { + if (I->next()) { + String string = I->next()->get(); + bool recognised = false; + if (string == "enable") { + OS::get_singleton()->set_delta_smoothing(true); + delta_smoothing_override = true; + recognised = true; + } + if (string == "disable") { + OS::get_singleton()->set_delta_smoothing(false); + delta_smoothing_override = false; + recognised = true; + } + if (!recognised) { + OS::get_singleton()->print("Delta-smoothing argument not recognised, aborting.\n"); + goto error; + } + N = I->next()->next(); + } else { + OS::get_singleton()->print("Missing delta-smoothing argument, aborting.\n"); + goto error; + } } else if (I->get() == "--single-window") { // force single window single_window = true; @@ -1908,6 +1933,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(PropertyInfo(Variant::INT, "application/run/low_processor_mode_sleep_usec", PROPERTY_HINT_RANGE, "0,33200,1,or_greater"), 6900)); // Roughly 144 FPS + 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/allow_high_refresh_rate", true); GLOBAL_DEF("display/window/ios/hide_home_indicator", true); GLOBAL_DEF("display/window/ios/hide_status_bar", true); diff --git a/main/main_timer_sync.cpp b/main/main_timer_sync.cpp index 6441a403f47..569930d4277 100644 --- a/main/main_timer_sync.cpp +++ b/main/main_timer_sync.cpp @@ -30,6 +30,9 @@ #include "main_timer_sync.h" +#include "core/os/os.h" +#include "servers/display_server.h" + void MainFrameTime::clamp_process_step(double min_process_step, double max_process_step) { if (process_step < min_process_step) { process_step = min_process_step; @@ -40,6 +43,258 @@ void MainFrameTime::clamp_process_step(double min_process_step, double max_proce ///////////////////////////////// +void MainTimerSync::DeltaSmoother::update_refresh_rate_estimator(int64_t 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(int64_t 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) { + double fps = 1000000.0 / time_passed; + double ratio = fps / (double)_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; +} + +int64_t MainTimerSync::DeltaSmoother::smooth_delta(int64_t 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. + // Also only try smoothing if vsync is enabled (classical vsync, not new types) .. + // This condition is currently checked before calling smooth_delta(). + if (!OS::get_singleton()->is_delta_smoothing_enabled() || Engine::get_singleton()->is_editor_hint()) { + return p_delta; + } + + // only attempt smoothing if vsync is selected + DisplayServer::VSyncMode vsync_mode = DisplayServer::get_singleton()->window_get_vsync_mode(DisplayServer::MAIN_WINDOW_ID); + if (vsync_mode != DisplayServer::VSYNC_ENABLED) { + return p_delta; + } + + // Very important, ignore long deltas and pass them back unmodified. + // This is to deal with resuming after suspend for long periods. + if (p_delta > 1000000) { + 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? + int64_t 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_physics_step 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 @@ -236,6 +491,8 @@ double MainTimerSync::get_cpu_process_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 47f05aba585..d8b5d4a02d6 100644 --- a/main/main_timer_sync.h +++ b/main/main_timer_sync.h @@ -33,6 +33,9 @@ #include "core/config/engine.h" +// Uncomment this define to get more debugging logs for the delta smoothing. +// #define GODOT_DEBUG_DELTA_SMOOTHER + struct MainFrameTime { double process_step; // delta time to advance during 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 + int64_t smooth_delta(int64_t p_delta); + + private: + void update_refresh_rate_estimator(int64_t p_delta); + bool fps_allows_smoothing(int64_t 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. + int32_t _estimated_fps = 0; + int32_t _hits_at_estimated = 0; + int32_t _hits_above_estimated = 0; + int32_t _hits_below_estimated = 0; + int32_t _hits_one_above_estimated = 0; + int32_t _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 + int64_t _estimator_total_delta = 0; + int32_t _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 = 0; uint64_t current_cpu_ticks_usec = 0;