Skip to content
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.refreshTime'
};
}
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.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 () {
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