diff --git a/src/blocks/scratch3_sensing.js b/src/blocks/scratch3_sensing.js index 15c071cb7f5..f70ea3532d0 100644 --- a/src/blocks/scratch3_sensing.js +++ b/src/blocks/scratch3_sensing.js @@ -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(); diff --git a/src/compiler/irgen.js b/src/compiler/irgen.js index b1b8ded5e38..f999b0d4dce 100644 --- a/src/compiler/irgen.js +++ b/src/compiler/irgen.js @@ -584,6 +584,10 @@ class ScriptTreeGenerator { return { kind: 'sensing.second' }; + case 'refreshtime': + return { + kind: 'sensing.refrehTime' + }; } return { kind: 'constant', diff --git a/src/compiler/jsgen.js b/src/compiler/jsgen.js index 914e5cc72b1..f4e542f42cf 100644 --- a/src/compiler/jsgen.js +++ b/src/compiler/jsgen.js @@ -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': diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 0f0c74a86f3..ed0b8f312cc 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -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; diff --git a/src/engine/sequencer.js b/src/engine/sequencer.js index 1f3bbc5ed63..b493694d066 100644 --- a/src/engine/sequencer.js +++ b/src/engine/sequencer.js @@ -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; diff --git a/src/engine/tw-frame-loop.js b/src/engine/tw-frame-loop.js index 45423739623..aa7054ac372 100644 --- a/src/engine/tw-frame-loop.js +++ b/src/engine/tw-frame-loop.js @@ -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.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(); } }