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

Lazy loading - vm part #238

Draft
wants to merge 9 commits into
base: develop
Choose a base branch
from
99 changes: 99 additions & 0 deletions src/engine/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ const defaultBlockPackages = {

const interpolate = require('./tw-interpolate');
const FrameLoop = require('./tw-frame-loop');
const LazySprite = require('../sprites/tw-lazy-sprite.js');
const CancellableMutex = require('../util/tw-cancellable-mutex.js');

const defaultExtensionColors = ['#0FBD8C', '#0DA57A', '#0B8E69'];

Expand Down Expand Up @@ -534,6 +536,18 @@ class Runtime extends EventEmitter {
* Total number of finished or errored scratch-storage load() requests since the runtime was created or cleared.
*/
this.finishedAssetRequests = 0;

/**
* Sprites with lazy-loading capabilities.
* @type {Array<LazySprite>}
*/
this.lazySprites = [];

/**
* All lazy sprite loading and unloading operations are gated behind this lock.
* @type {CancellableMutex<void>}
*/
this.lazySpritesLock = new CancellableMutex();
}

/**
Expand Down Expand Up @@ -925,6 +939,14 @@ class Runtime extends EventEmitter {
return 'PLATFORM_MISMATCH';
}

/**
* Event when lazily loaded sprites are loaded, before they have executed anything.
* Called with array of original targets.
*/
static get LAZY_SPRITES_LOADED () {
return 'LAZY_SPRITES_LOADED';
}

/**
* How rapidly we try to step threads by default, in ms.
*/
Expand Down Expand Up @@ -2276,6 +2298,14 @@ class Runtime extends EventEmitter {
if (target.isOriginal) target.deleteMonitors();
});

this.lazySpritesLock.cancel();
this.lazySprites.forEach(sprite => {
if (sprite.state === LazySprite.State.LOADED || sprite.state === LazySprite.State.LOADING) {
sprite.unload();
}
});
this.lazySprites = [];

this.targets.map(this.disposeTarget, this);
this.extensionStorage = {};
// tw: explicitly emit a MONITORS_UPDATE instead of relying on implicit behavior of _step()
Expand Down Expand Up @@ -3482,6 +3512,75 @@ class Runtime extends EventEmitter {

return callback().then(onSuccess, onError);
}

/**
* @param {string[]} spriteNames Assumed to contain no duplicate entries.
* @returns {Promise<RenderedTarget[]>} Resolves when all sprites have been loaded.
*/
loadLazySprites (spriteNames) {
return this.lazySpritesLock.do(async isCancelled => {
const lazySprites = [];
for (const name of spriteNames) {
const lazySprite = this.lazySprites.find(sprite => sprite.name === name);
if (lazySprite) {
if (lazySprite.state === LazySprite.State.UNLOADED) {
lazySprites.push(lazySprite);
} else if (lazySprite.state === LazySprite.State.ERROR) {
// TODO(lazy)
} else {
// Already loaded or loading. Nothing to do.
}
} else {
throw new Error(`Unknown lazy sprite: ${name}`);
}
}

const promises = lazySprites.map(sprite => sprite.load());
const allTargets = await Promise.all(promises);

if (isCancelled()) {
return;
}

// Ignore cancelled targets.
const loadedTargets = allTargets.filter(i => i);
for (const target of loadedTargets) {
target.updateAllDrawableProperties();
this.addTarget(target);
}

this.emit(Runtime.LAZY_SPRITES_LOADED, loadedTargets);
return loadedTargets;
});
}

/**
* @param {string[]} spriteNames Assumed to contain no duplicate entries.
* @returns {Promise<void>} Resolves when all sprites have been unloaded.
*/
unloadLazySprites (spriteNames) {
return this.lazySpritesLock.do(() => {
const lazySprites = [];
for (const name of spriteNames) {
const lazySprite = this.lazySprites.find(sprite => sprite.name === name);
if (lazySprite) {
if (lazySprite.state === LazySprite.State.LOADED || lazySprite.state === LazySprite.State.LOADING) {
lazySprites.push(lazySprite);
} else if (lazySprite.state === LazySprite.State.ERROR) {
// TODO(lazy)
} else {
// Already unloaded. Nothing to do.
}
} else {
throw new Error(`Unknown lazy sprite: ${name}`);
}
}

for (const lazySprite of lazySprites) {
lazySprite.unload();
}
});
}
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/extension-support/extension-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ const defaultBuiltinExtensions = {
makeymakey: () => require('../extensions/scratch3_makeymakey'),
boost: () => require('../extensions/scratch3_boost'),
gdxfor: () => require('../extensions/scratch3_gdx_for'),
// tw: core extension
tw: () => require('../extensions/tw')
// tw: core extensions
tw: () => require('../extensions/tw'),
twlazy: () => require('../extensions/tw_lazy')
};

/**
Expand Down
134 changes: 134 additions & 0 deletions src/extensions/tw_lazy/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
const formatMessage = require('format-message');
const BlockType = require('../../extension-support/block-type');
const ArgumentType = require('../../extension-support/argument-type');
const Cast = require('../../util/cast');
const Runtime = require('../../engine/runtime');

class LazySprites {
constructor (runtime) {
/**
* The runtime instantiating this block package.
* @type {Runtime}
*/
this.runtime = runtime;

// This is implemented with an event rather than in the blocks below so that it works if
// some other extension loads sprites lazily.
this.runtime.on(Runtime.LAZY_SPRITES_LOADED, targets => {
for (const target of targets) {
this.runtime.startHats('twlazy_whenLoaded', null, target);
}
});
}

/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: 'twlazy',
name: 'Lazy Loading',
blocks: [
{
blockType: BlockType.EVENT,
opcode: 'whenLoaded',
isEdgeActivated: false,
text: formatMessage({
id: 'tw.lazy.whenLoaded',
default: 'when I load',
description: 'Hat block that runs when a sprite loads'
})
},
{
blockType: BlockType.COMMAND,
opcode: 'loadSprite',
text: formatMessage({
id: 'tw.lazy.loadSprite',
default: 'load sprite [SPRITE]',
description: 'Block that loads a sprite'
}),
arguments: {
SPRITE: {
type: ArgumentType.STRING,
menu: 'lazySprite'
}
}
},
{
blockType: BlockType.COMMAND,
opcode: 'unloadSprite',
text: formatMessage({
id: 'tw.lazy.unloadSprite',
default: 'unload sprite [SPRITE]',
description: 'Block that unloads a sprite'
}),
arguments: {
SPRITE: {
type: ArgumentType.STRING,
menu: 'lazySprite'
}
}
}
],
menus: {
lazySprite: {
acceptReporters: true,
items: 'getLazySpritesMenu'
},
lazyCostume: {
acceptReporters: true,
items: 'getLazyCostumesMenu'
},
lazySound: {
acceptReporters: true,
items: 'getLazySoundsMenu'
}
}
};
}

getLazySpritesMenu () {
if (this.runtime.lazySprites.length === 0) {
return [
{
text: formatMessage({
id: 'tw.lazy.noSprites',
default: 'No sprites',
description: 'Block menu in lazy loading extension when no lazy-loaded sprites exist'
}),
value: ''
}
];
}

return this.runtime.lazySprites.map(i => i.name);
}

getLazyCostumesMenu () {
// TODO(lazy)
return ['b'];
}

getLazySoundsMenu () {
// TODO(lazy)
return ['c'];
}

loadSprite (args) {
const name = Cast.toString(args.SPRITE);
return this.runtime.loadLazySprites([name])
.catch(() => {
// TODO(lazy): handle this...
});
}

unloadSprite (args) {
const name = Cast.toString(args.SPRITE);
return this.runtime.unloadLazySprites([name])
.catch(() => {
// TODO(lazy): handle this...
});
}
}

module.exports = LazySprites;
1 change: 1 addition & 0 deletions src/serialization/sb2.js
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ const sb2import = function (json, runtime, optForceSprite, zip) {
.then(reorderParsedTargets)
.then(targets => ({
targets,
lazySprites: [], // lazy loading not supported in sb2 projects
extensions
}));
};
Expand Down
Loading
Loading