Skip to content
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

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

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions src/blocks/scratch3_sensing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/compiler/irgen.js
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,10 @@ class ScriptTreeGenerator {
return {
kind: 'sensing.second'
};
case 'refreshtime':
return {
kind: 'sensing.refrehTime'
};
}
return {
kind: 'constant',
Expand Down
2 changes: 2 additions & 0 deletions src/compiler/jsgen.js
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
44 changes: 10 additions & 34 deletions src/engine/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -469,7 +471,6 @@ class Runtime extends EventEmitter {

this.debug = false;

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

this._defaultStoredSettings = this._generateAllProjectOptions();
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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 */);
Expand All @@ -2581,10 +2564,6 @@ class Runtime extends EventEmitter {
this.profiler.stop();
this.profiler.reportFrames();
}

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

/**
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion src/engine/sequencer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
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
};
Expand All @@ -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) {
Expand All @@ -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.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 () {
Expand All @@ -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();
}
}

Expand Down