diff --git a/CHANGES.md b/CHANGES.md
index fc914d1a426..564a1b50f09 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,17 @@
# Change Log
+### 1.123 - 2024-11-01
+
+#### @cesium/engine
+
+##### Additions :tada:
+
+- Use rendering rate to adjust zoom rate so zoom speed appears consistent on high or low refresh rate browsers. Use `zoomFactor` to adjust zoomRate. Added `averageFramesPerSecond` and `averageFrameRateWindow` to `FrameRateMonitor` class for getting the frame rendering rate.
+
+##### Fixes :wrench:
+
+##### Deprecated :hourglass_flowing_sand:
+
### 1.122 - 2024-10-01
#### @cesium/engine
diff --git a/packages/engine/Source/Scene/FrameRateMonitor.js b/packages/engine/Source/Scene/FrameRateMonitor.js
index 3e7f3ae5053..516478b8d77 100644
--- a/packages/engine/Source/Scene/FrameRateMonitor.js
+++ b/packages/engine/Source/Scene/FrameRateMonitor.js
@@ -17,7 +17,8 @@ import TimeConstants from "../Core/TimeConstants.js";
*
* @param {object} [options] Object with the following properties:
* @param {Scene} options.scene The Scene instance for which to monitor performance.
- * @param {number} [options.samplingWindow=5.0] The length of the sliding window over which to compute the average frame rate, in seconds.
+ * @param {number} [options.samplingWindow=5.0] The length of the sliding window over which to compute the average frame rate, in seconds,
+ * for the purpose of calculating if the frame rate is lower or higher than the minimum frame rate threshold.
* @param {number} [options.quietPeriod=2.0] The length of time to wait at startup and each time the page becomes visible (i.e. when the user
* switches back to the tab) before starting to measure performance, in seconds.
* @param {number} [options.warmupPeriod=5.0] The length of the warmup period, in seconds. During the warmup period, a separate
@@ -28,6 +29,8 @@ import TimeConstants from "../Core/TimeConstants.js";
* @param {number} [options.minimumFrameRateAfterWarmup=8] The minimum frames-per-second that are required for acceptable performance after
* the end of the warmup period. If the frame rate averages less than this during any samplingWindow after the warmupPeriod, the
* lowFrameRate event will be raised and the page will redirect to the redirectOnLowFrameRateUrl, if any.
+ * @param {number} [options.averageFrameRateWindow=10] The length of the sliding window over which to compute the average frame rate, in
+ * number of frames, for the purpose of calculating and displaying averageFramesPerSecond
.
*/
function FrameRateMonitor(options) {
//>>includeStart('debug', pragmas.debug);
@@ -39,7 +42,8 @@ function FrameRateMonitor(options) {
this._scene = options.scene;
/**
- * Gets or sets the length of the sliding window over which to compute the average frame rate, in seconds.
+ * Gets or sets the length of the sliding window over which to compute the average frame rate, in seconds, for the
+ * purpose of calculating if the frame rate is lower or higher than the minimum frame rate threshold.
* @type {number}
*/
this.samplingWindow = defaultValue(
@@ -47,6 +51,16 @@ function FrameRateMonitor(options) {
FrameRateMonitor.defaultSettings.samplingWindow,
);
+ /**
+ * Gets or sets the length of the sliding window over which to compute the average frame rate, in number of frames,
+ * for the purpose of calculating and displaying averageFramesPerSecond
.
+ * @type {number}
+ */
+ this.averageFrameRateWindow = defaultValue(
+ options.averageFrameRateWindow,
+ FrameRateMonitor.defaultSettings.averageFrameRateWindow,
+ );
+
/**
* Gets or sets the length of time to wait at startup and each time the page becomes visible (i.e. when the user
* switches back to the tab) before starting to measure performance, in seconds.
@@ -93,6 +107,7 @@ function FrameRateMonitor(options) {
this._nominalFrameRate = new Event();
this._frameTimes = [];
+ this._averageFrameRateTimes = [];
this._needsQuietPeriod = true;
this._quietPeriodEndTime = 0.0;
this._warmupPeriodEndTime = 0.0;
@@ -165,6 +180,7 @@ FrameRateMonitor.defaultSettings = {
warmupPeriod: 5.0,
minimumFrameRateDuringWarmup: 4,
minimumFrameRateAfterWarmup: 8,
+ averageFrameRateWindow: 10,
};
/**
@@ -233,6 +249,9 @@ Object.defineProperties(FrameRateMonitor.prototype, {
/**
* Gets the most recently computed average frames-per-second over the last samplingWindow
.
+ * The samplingWindow
begins after the expiration of the quietPeriod
, and restarts
+ * each time the minimumFrameRateDuringWarmup
or minimumFrameRateAfterWarmup
threshold
+ * is crossed, or pause
is invoked.
* This property may be undefined if the frame rate has not been computed.
* @memberof FrameRateMonitor.prototype
* @type {number}
@@ -242,6 +261,31 @@ Object.defineProperties(FrameRateMonitor.prototype, {
return this._lastFramesPerSecond;
},
},
+
+ /**
+ * Gets the most recently computed average frames-per-second over the last number of frames specified
+ * in averageFrameRateWindow
.
+ * This property may be undefined when the scene first loads until averageFrameRateWindow
+ * has passed. After averageFrameRateWindow
has passed the property will be continuously
+ * available, the limitations specified for lastFramesPerSecond
do not apply to it.
+ * @memberof FrameRateMonitor.prototype
+ * @type {number|undefined}
+ */
+ averageFramesPerSecond: {
+ get: function () {
+ if (this._averageFrameRateTimes.length !== this.averageFrameRateWindow) {
+ return undefined;
+ }
+ const windowSize = this.averageFrameRateWindow - 1;
+ const averageTimePerFrame =
+ (this._averageFrameRateTimes[0] -
+ this._averageFrameRateTimes[windowSize]) /
+ windowSize;
+ const fps =
+ 1.0 / (averageTimePerFrame * TimeConstants.SECONDS_PER_MILLISECOND);
+ return fps;
+ },
+ },
});
/**
@@ -310,12 +354,17 @@ FrameRateMonitor.prototype.destroy = function () {
};
function update(monitor, time) {
+ const timeStamp = getTimestamp();
+
+ monitor._averageFrameRateTimes.unshift(timeStamp);
+ if (monitor._averageFrameRateTimes.length > monitor.averageFrameRateWindow) {
+ monitor._averageFrameRateTimes.pop();
+ }
+
if (monitor._pauseCount > 0) {
return;
}
- const timeStamp = getTimestamp();
-
if (monitor._needsQuietPeriod) {
monitor._needsQuietPeriod = false;
monitor._frameTimes.length = 0;
diff --git a/packages/engine/Source/Scene/ScreenSpaceCameraController.js b/packages/engine/Source/Scene/ScreenSpaceCameraController.js
index 54e7aee3e11..8785a84f7d4 100644
--- a/packages/engine/Source/Scene/ScreenSpaceCameraController.js
+++ b/packages/engine/Source/Scene/ScreenSpaceCameraController.js
@@ -25,6 +25,7 @@ import MapMode2D from "./MapMode2D.js";
import SceneMode from "./SceneMode.js";
import SceneTransforms from "./SceneTransforms.js";
import TweenCollection from "./TweenCollection.js";
+import FrameRateMonitor from "./FrameRateMonitor.js";
/**
* Modifies the camera position and orientation based on mouse input to a canvas.
@@ -145,6 +146,8 @@ function ScreenSpaceCameraController(scene) {
*/
this.zoomFactor = 5.0;
+ this.frameRateMonitor = FrameRateMonitor.fromScene(scene);
+
/**
* The input that allows the user to pan around the map. This only applies in 2D and Columbus view modes.
*
@@ -573,7 +576,16 @@ function handleZoom( const maxHeight = object.maximumZoomDistance; const minDistance = distanceMeasure - minHeight; - let zoomRate = zoomFactor * minDistance; + + let fpsMultiplier = 1.0; + const fps = object.frameRateMonitor.averageFramesPerSecond; + // we set the ideal browser refresh rate to 60hz + if (defined(fps) && fps > 0) { + fpsMultiplier = 60.0 / fps; + } + + let zoomRate = zoomFactor * minDistance * fpsMultiplier; + zoomRate = CesiumMath.clamp( zoomRate, object._minimumZoomRate, diff --git a/packages/engine/Specs/Scene/FrameRateMonitorSpec.js b/packages/engine/Specs/Scene/FrameRateMonitorSpec.js index 3de2f8a5b8d..67f61b4d1c1 100644 --- a/packages/engine/Specs/Scene/FrameRateMonitorSpec.js +++ b/packages/engine/Specs/Scene/FrameRateMonitorSpec.js @@ -58,6 +58,7 @@ describe( monitor = new FrameRateMonitor({ scene: scene, samplingWindow: 3.0, + averageFrameRateWindow: 20, quietPeriod: 1.0, warmupPeriod: 6.0, minimumFrameRateDuringWarmup: 1, @@ -65,6 +66,7 @@ describe( }); expect(monitor.samplingWindow).toBe(3.0); + expect(monitor.averageFrameRateWindow).toBe(20); expect(monitor.quietPeriod).toBe(1.0); expect(monitor.warmupPeriod).toBe(6.0); expect(monitor.minimumFrameRateDuringWarmup).toBe(1); @@ -251,6 +253,43 @@ describe( expect(monitor.lastFramesPerSecond).toBeGreaterThanOrEqual(10); expect(nominalListener).toHaveBeenCalled(); }); + + it("returns averageFrameRate, irrespective of quiet period and pauses", function () { + monitor = new FrameRateMonitor({ + scene: scene, + quietPeriod: 3, + warmupPeriod: 1, + samplingWindow: 1, + averageFrameRateWindow: 20, + minimumFrameRateDuringWarmup: 10, + minimumFrameRateAfterWarmup: 10, + }); + + // Rendering once starts the quiet period + scene.render(); + + // Render 10 frames at 100 millisecond interval + let numFramesRendered = 0; + while (numFramesRendered < 10) { + scene.render(); + spinWait(100); + numFramesRendered++; + } + + monitor.pause(); + + // Render 15 more frames at 100 millisecond interval + while (numFramesRendered < 25) { + scene.render(); + spinWait(100); + numFramesRendered++; + } + + monitor.unpause(); + + expect(getTimestamp()).toBeLessThan(monitor._quietPeriodEndTime); + expect(monitor.averageFramesPerSecond).toBeCloseTo(10, 1); + }); }, "WebGL", ); diff --git a/packages/engine/Specs/Scene/ScreenSpaceCameraControllerSpec.js b/packages/engine/Specs/Scene/ScreenSpaceCameraControllerSpec.js index 593ca54b10f..28da0f1e570 100644 --- a/packages/engine/Specs/Scene/ScreenSpaceCameraControllerSpec.js +++ b/packages/engine/Specs/Scene/ScreenSpaceCameraControllerSpec.js @@ -22,6 +22,7 @@ import { import createCamera from "../../../../Specs/createCamera.js"; import createCanvas from "../../../../Specs/createCanvas.js"; import DomEventSimulator from "../../../../Specs/DomEventSimulator.js"; +import Event from "../../Source/Core/Event.js"; describe("Scene/ScreenSpaceCameraController", function () { let usePointerEvents; @@ -41,6 +42,7 @@ describe("Scene/ScreenSpaceCameraController", function () { this.screenSpaceCameraController = undefined; this.cameraUnderground = false; this.globeHeight = 0.0; + this.preUpdate = new Event(); } function MockGlobe(ellipsoid) {