Skip to content

Support framerates higher than 250 for faster I/O event handling #230

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
1 change: 1 addition & 0 deletions src/blocks/scratch3_sensing.js
Original file line number Diff line number Diff line change
@@ -244,6 +244,7 @@ class Scratch3SensingBlocks {

current (args) {
const menuOption = Cast.toString(args.CURRENTMENU).toLowerCase();
if (menuOption === 'refreshtime') return (this.runtime.screenRefreshTime / 1000);
const date = new Date();
switch (menuOption) {
case 'year': return date.getFullYear();
4 changes: 4 additions & 0 deletions src/compiler/irgen.js
Original file line number Diff line number Diff line change
@@ -584,6 +584,10 @@ class ScriptTreeGenerator {
return {
kind: 'sensing.second'
};
case 'refreshtime':
return {
kind: 'sensing.refrehTime'
};
}
return {
kind: 'constant',
2 changes: 2 additions & 0 deletions src/compiler/jsgen.js
Original file line number Diff line number Diff line change
@@ -724,6 +724,8 @@ class JSGenerator {
}
case 'sensing.second':
return new TypedInput(`(new Date().getSeconds())`, TYPE_NUMBER);
case 'sensing.refreshTime':
return new TypedInput('(runtime.screenRefreshTime / 1000)', TYPE_NUMBER);
case 'sensing.touching':
return new TypedInput(`target.isTouchingObject(${this.descendInput(node.object).asUnknown()})`, TYPE_BOOLEAN);
case 'sensing.touchingColor':
44 changes: 10 additions & 34 deletions src/engine/runtime.js
Original file line number Diff line number Diff line change
@@ -193,12 +193,6 @@ let stepProfilerId = -1;
*/
let stepThreadsProfilerId = -1;

/**
* Numeric ID for RenderWebGL.draw in Profiler instances.
* @type {number}
*/
let rendererDrawProfilerId = -1;

/**
* Manages targets, scripts, and the sequencer.
* @constructor
@@ -445,6 +439,14 @@ class Runtime extends EventEmitter {
*/
this.platform = Object.assign({}, platform);

/**
* Screen refresh time speculated from screen refresh rate, in milliseconds.
* Indicates time passed between two screen refreshments.
* Based on site isolation status, the resolution could be ~0.1ms or lower.
* @type {!number}
*/
this.screenRefreshTime = 0;

this._initScratchLink();

this.resetRunId();
@@ -469,7 +471,6 @@ class Runtime extends EventEmitter {

this.debug = false;

this._lastStepTime = Date.now();
this.interpolationEnabled = false;

this._defaultStoredSettings = this._generateAllProjectOptions();
@@ -2476,8 +2477,8 @@ class Runtime extends EventEmitter {
}

_renderInterpolatedPositions () {
const frameStarted = this._lastStepTime;
const now = Date.now();
const frameStarted = this.frameLoop._lastStepTime;
const now = this.frameLoop.now();
const timeSinceStart = now - frameStarted;
const progressInFrame = Math.min(1, Math.max(0, timeSinceStart / this.currentStepTime));

@@ -2548,24 +2549,6 @@ class Runtime extends EventEmitter {
// Store threads that completed this iteration for testing and other
// internal purposes.
this._lastStepDoneThreads = doneThreads;
if (this.renderer) {
// @todo: Only render when this.redrawRequested or clones rendered.
if (this.profiler !== null) {
if (rendererDrawProfilerId === -1) {
rendererDrawProfilerId = this.profiler.idByName('RenderWebGL.draw');
}
this.profiler.start(rendererDrawProfilerId);
}
// tw: do not draw if document is hidden or a rAF loop is running
// Checking for the animation frame loop is more reliable than using
// interpolationEnabled in some edge cases
if (!document.hidden && !this.frameLoop._interpolationAnimation) {
this.renderer.draw();
}
if (this.profiler !== null) {
this.profiler.stop();
}
}

if (this._refreshTargets) {
this.emit(Runtime.TARGETS_UPDATE, false /* Don't emit project changed */);
@@ -2581,10 +2564,6 @@ class Runtime extends EventEmitter {
this.profiler.stop();
this.profiler.reportFrames();
}

if (this.interpolationEnabled) {
this._lastStepTime = Date.now();
}
}

/**
@@ -2643,9 +2622,6 @@ class Runtime extends EventEmitter {
* @param {number} framerate Target frames per second
*/
setFramerate (framerate) {
// Setting framerate to anything greater than this is unnecessary and can break the sequencer
// Additionally, the JS spec says intervals can't run more than once every 4ms (250/s) anyways
if (framerate > 250) framerate = 250;
// Convert negative framerates to 1FPS
// Note that 0 is a special value which means "matching device screen refresh rate"
if (framerate < 0) framerate = 1;
8 changes: 7 additions & 1 deletion src/engine/sequencer.js
Original file line number Diff line number Diff line change
@@ -82,13 +82,15 @@ class Sequencer {
// Whether `stepThreads` has run through a full single tick.
let ranFirstTick = false;
const doneThreads = [];

// tw: If this happens, the runtime is in initialization, do not execute any thread.
if (this.runtime.currentStepTime === 0) return [];
// Conditions for continuing to stepping threads:
// 1. We must have threads in the list, and some must be active.
// 2. Time elapsed must be less than WORK_TIME.
// 3. Either turbo mode, or no redraw has been requested by a primitive.
while (this.runtime.threads.length > 0 &&
numActiveThreads > 0 &&
this.timer.timeElapsed() < WORK_TIME &&
(this.runtime.turboMode || !this.runtime.redrawRequested)) {
if (this.runtime.profiler !== null) {
if (stepThreadsInnerProfilerId === -1) {
@@ -164,6 +166,10 @@ class Sequencer {
}
this.runtime.threads.length = nextActiveThread;
}

// tw: Detect timer here so the sequencer won't break when FPS is greater than 1000
// and performance.now() is not available.
if (this.timer.timeElapsed() >= WORK_TIME) break;
}

this.activeThread = null;
140 changes: 109 additions & 31 deletions src/engine/tw-frame-loop.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
// Due to the existence of features such as interpolation and "0 FPS" being treated as "screen refresh rate",
// The VM loop logic has become much more complex

/**
* Numeric ID for RenderWebGL.draw in Profiler instances.
* @type {number}
*/
let rendererDrawProfilerId = -1;

// Use setTimeout to polyfill requestAnimationFrame in Node.js environments
const _requestAnimationFrame = typeof requestAnimationFrame === 'function' ?
requestAnimationFrame :
(f => setTimeout(f, 1000 / 60));
const _cancelAnimationFrame = typeof requestAnimationFrame === 'function' ?
cancelAnimationFrame :
clearTimeout;
const _requestAnimationFrame =
typeof requestAnimationFrame === 'function' ?
requestAnimationFrame :
f => setTimeout(f, 1000 / 60);
const _cancelAnimationFrame =
typeof requestAnimationFrame === 'function' ?
cancelAnimationFrame :
clearTimeout;

const animationFrameWrapper = callback => {
const taskWrapper = (callback, requestFn, cancelFn, manualInterval) => {
let id;
let cancelled = false;
const handle = () => {
id = _requestAnimationFrame(handle);
if (manualInterval) id = requestFn(handle);
callback();
};
const cancel = () => _cancelAnimationFrame(id);
id = _requestAnimationFrame(handle);
const cancel = () => {
if (!cancelled) cancelFn(id);
cancelled = true;
};
id = requestFn(handle);
return {
cancel
};
@@ -28,13 +40,15 @@ class FrameLoop {
this.running = false;
this.setFramerate(30);
this.setInterpolation(false);

this.stepCallback = this.stepCallback.bind(this);
this.interpolationCallback = this.interpolationCallback.bind(this);
this._lastRenderTime = 0;
this._lastStepTime = 0;

this._stepInterval = null;
this._interpolationAnimation = null;
this._stepAnimation = null;
this._renderInterval = null;
}

now () {
return (performance || Date).now();
}

setFramerate (fps) {
@@ -49,10 +63,54 @@ class FrameLoop {

stepCallback () {
this.runtime._step();
this._lastStepTime = this.now();
}

stepImmediateCallback () {
if (this.now() - this._lastStepTime >= this.runtime.currentStepTime) {
this.runtime._step();
this._lastStepTime = this.now();
}
}

interpolationCallback () {
this.runtime._renderInterpolatedPositions();
renderCallback () {
if (this.runtime.renderer) {
const renderTime = this.now();
if (this.interpolation && this.framerate !== 0) {
if (!document.hidden) {
this.runtime._renderInterpolatedPositions();
}
this.runtime.screenRefreshTime = renderTime - this._lastRenderTime; // Screen refresh time (from rate)
this._lastRenderTime = renderTime;
} else if (
this.framerate === 0 ||
renderTime - this._lastRenderTime >=
this.runtime.currentStepTime
) {
// @todo: Only render when this.redrawRequested or clones rendered.
if (this.runtime.profiler !== null) {
if (rendererDrawProfilerId === -1) {
rendererDrawProfilerId =
this.runtime.profiler.idByName('RenderWebGL.draw');
}
this.runtime.profiler.start(rendererDrawProfilerId);
}
// tw: do not draw if document is hidden or a rAF loop is running
// Checking for the animation frame loop is more reliable than using
// interpolationEnabled in some edge cases
if (!document.hidden) {
this.runtime.renderer.draw();
}
if (this.runtime.profiler !== null) {
this.runtime.profiler.stop();
}
this.runtime.screenRefreshTime = renderTime - this._lastRenderTime; // Screen refresh time (from rate)
this._lastRenderTime = renderTime;
if (this.framerate === 0) {
this.runtime.currentStepTime = this.runtime.screenRefreshTime;
}
}
}
}

_restart () {
@@ -65,29 +123,49 @@ class FrameLoop {
start () {
this.running = true;
if (this.framerate === 0) {
this._stepAnimation = animationFrameWrapper(this.stepCallback);
this.runtime.currentStepTime = 1000 / 60;
this._stepInterval = this._renderInterval = taskWrapper(
(() => {
this.stepCallback();
this.renderCallback();
}),
_requestAnimationFrame,
_cancelAnimationFrame,
true
);
this.runtime.currentStepTime = 0;
} else {
// Interpolation should never be enabled when framerate === 0 as that's just redundant
if (this.interpolation) {
this._interpolationAnimation = animationFrameWrapper(this.interpolationCallback);
this._renderInterval = taskWrapper(
this.renderCallback.bind(this),
_requestAnimationFrame,
_cancelAnimationFrame,
true
);
if (this.framerate > 250 && global.setImmediate && global.clearImmediate) {
// High precision implementation via setImmediate (polyfilled)
// bug: very unfriendly to DevTools
this._stepInterval = taskWrapper(
this.stepImmediateCallback.bind(this),
global.setImmediate,
global.clearImmediate,
true
);
} else {
this._stepInterval = taskWrapper(
this.stepCallback.bind(this),
fn => setInterval(fn, 1000 / this.framerate),
clearInterval,
false
);
}
this._stepInterval = setInterval(this.stepCallback, 1000 / this.framerate);
this.runtime.currentStepTime = 1000 / this.framerate;
}
}

stop () {
this.running = false;
clearInterval(this._stepInterval);
if (this._interpolationAnimation) {
this._interpolationAnimation.cancel();
}
if (this._stepAnimation) {
this._stepAnimation.cancel();
}
this._interpolationAnimation = null;
this._stepAnimation = null;
this._renderInterval.cancel();
this._stepInterval.cancel();
}
}

Loading