engine
engine copied to clipboard
ESM Scripts
ES Module Component System
Moving forward we intend to add first class support for ES Modules a Scripting interface for PlayCanvas. This PR add's native engine support for ESM Scripts as a separate component system.
ESM Script Structure
ESM Scripts need only to implement the required properties and methods it needs.
class MyModule {
static name = 'MyModule'; // Required identifier
static attributes = { // optional
speed: { type: 'number', default: 10 }
},
enabled = true // optional, true by default
initialize(){} // optional
update(dt){} // optional
postUpdate(dt){} // optional
destroy(){} // optional
}
New API
To register a Script cawith the engine you use entity.esmscript.add(Module, attributes). However for many users, this will be handled automatically in the editor.
import * as Rotate from './utils/rotate'
const script = entity.esmscript.add(Rotate, { speed: 10 } )
This provides:
- Clean, familiar API with simple Class based syntax
- Removes any dependancy on older ES5 style globals -
pc - Supports nested attributes
- Solves issues with existing Scripts
- Provides more flexibility to use a custom base class with own logic (events, logging, analytics)
- Forwardly compatible with bundling and code splitting, not just string concatenation
- Forwardly compatible with TypeScript
Why not use the old Script system
This interface aims to replicate the interface to the old scripting system as closely as possible, improve upon it, whilst also mitigating some of the issues it faces in a new, more modern ESM ecosystem. Some of these can be found below
- [x] Implement Script ESM Component system
- [x] Implement initialize / destroy / update methods
See #4767
I confirm I have read the contributing guidelines and signed the Contributor License Agreement.
Just personally reviewing this. I'm actually leaning more towards instance based prop attributes, because that's how it currently works, and it just makes so much more sense to think of this.value. Thoughts @willeastcott
export const attributes = {
aNumber: { name: 'String', default: 10, min: 0, max: 199 }
}
class MyModule {
aNumber = 10
constructor(entity, app){}
update(dt){}
destroy(){}
}
This is vastly different functionality from the current ScriptType.
Few things:
- Attributes are not editor-only, as they do provide better initialisation process and provide runtime events.
- Swap method is not a destroy/create, and is an optional, "transparent" hot-reload. In destroy/create developers sometimes handle a lot more than what swap needs: just a transfer of data from old instance to the new one.
- Events of a ScriptType are usefull and important, so not providing them by default might confuse at first.
- Default properties, such as entity, app, etc, are defined automatically, so it simplifies development for the developer.
- Simple boilerplate code of a ScriptType - is important, with minimum magical arguments or rules of special definitions.
Also, class redefenition - can be a blocker, in case of multi-application context, swap, and lazy-loading, using a class - can lead to blockers.
If there is a way to provide class alternative for a ScriptType, it would be great to not reduce the functionality, but actually improve it as well. Justifying only by syntaxis (class vs prototype) IMHO would be not a reasonable feature.
Thanks @Maksims. Great to get your input on this. There's still ongoing discussion around this, so we'll take this all into account as we go.
Also, class redefenition - can be a blocker, in case of multi-application context, swap, and lazy-loading, using a class - can lead to blockers.
Not sure I follow on this point, could you elaborate?
@marklundin If you don't declare the instance property, IntelliSense is gonna spit out a bunch of errors because aNumber with be undefined and the type will not be discernable. So yeah, the updated example is better IMHO.
Would it be possible to decorate the actual property declaration in the class and not have the const declaration at the top? Otherwise, it kinda just looks like duplication.
I am curious if it is a replacement to ScriptType, or a feature next to it.
Some notes:
- We use EventHandler events on entity scripts a lot. This allows not to pollute the event handler on application level with game related events. The shorter the event list, the faster the lookup is.
- The inherited entity and app properties are useful. We kind of assume they are always available for us and we use them often. What would be an alternative way to find the current entity the script is attached to and app instance? Are those ES module scripts even "attacheable"? A user could probably create a super class that would somehow find those, but this should be documented.
- We must have a multi-application support. We do it for heavy-weight apps to manage the loading times. Perhaps
pccould offer a factory, if import paths are not possible. Or somehow namespacing the parallel instances. - I tend to delete the generated boilerplate script contents, whenever I create a new script. At this point its more of a nuisance to me, than a convenience. I'd rather have it blank, honestly, but we can't do it, obviously. I wish I would be able to define my own template that is generated when a new script is created.
- I don't use prototype syntaxis, and avoid it when I have an option.
- We rarely use script attributes. Mostly to expose them to Editor for general application configuration. If Editor would have a support for custom panels that would affect the launched game (similar to native project settings), then we probably won't use attributes at all. Game scripts occasionally use them, but then again, only for Editor access.
- If some developers are using swap, then it should be at least supported in the current form, so we don't break their projects. None of our projects are using it, though, as the cost of maintenance is too high in its current form.
I wish this new script type, would be a sister to ScriptType. The way I see it is that if some or all of the ScriptType features are needed, one would simply create a ScriptType script, or somehow inherit all its features in a new type. If one doesn't need ScriptType features, he could create a new type, possibly inheriting EventHandler. It would be great to have paths supported, so a same module name could live in a different folder asset and we could do imports using paths.
Well, no one really stopping developers of doing their own classes for script systems already, the only thing you need a way to attach them from the Editor, which you probably can do by small classic ScriptType with json+array attributes.
If there is a change to the way ScripyTypes would be created, it should be a significantly better than a current system.
Lets first specify what ScriptType is not satisfying, and what are shortcomings are based on developer needs.
Thinking about this more, we probably should focus on the primary task here, which is to add support for ES Modules, anything outside this is secondary, and it doesn't actually require any significant change to the API. Any issues/pain points the existing ScriptType API we can tackle as a separate issue and shouldn't really block the main objective.
I'll spend some time thinking about this more
Thinking about this more, we probably should focus on the primary task here, which is to add support for ES Modules, anything outside this is secondary, and it doesn't actually require any significant change to the API. Any issues/pain points the existing ScriptType API we can tackle as a separate issue and shouldn't really block the main objective.
I'll spend some time thinking about this more
Well, if this will be replacement to a ScriptType, then it is a relevant conversation. If it is just an alternative, then it will lead to 2 ways of doing the same thing and code segregation between developers.
Thanks for all your input. We've outlined some more detailed design thoughts in this RFC https://github.com/playcanvas/engine/discussions/5770
How do you define a name of a script and attach it to the entity?
How do you define a name of a script and attach it to the entity?
Scripts will be identified by their path, so entity.esmscript.import('./path/to/esmscript.js'). I think this will make it easier to identify what script is being used vs a name
How do you define a name of a script and attach it to the entity?
Scripts will be identified by their path, so
entity.esmscript.import('./path/to/esmscript.js'). I think this will make it easier to identify what script is being used vs a name
How that would work in Editor and how that will work with defining scripts on Entities that are not yet loaded? Also, assets in Editor are all located in a flat way, and do not follow asset folder structures, as folder assets are purely Editor organizational tool.
Also how this will work with concatenation/bundling, and how it will react to re-organizing scripts around the folders?
assets in Editor are all located in a flat way, and do not follow asset folder structures, as folder assets are purely Editor organizational tool.
There is work to create an additional route that matches the directory structure of the asset registry and resolves asset ID's.
Also how this will work with concatenation/bundling, and how it will react to re-organizing scripts around the folders?
The import will be context aware and fetch the bundle if necessary and resolve the ESM script it export. It should also handle code splitting. If you're consuming a PlayCanvas project though as part of a bigger project, and you're manually moving stuff around, then import maps, but this would be a similar case if you moved around regular modules. It would be good, if you had some use-cases to test against.
How that would work in Editor and how that will work with defining scripts on Entities that are not yet loaded?
Could you clarify? Do you mean lazy loading of scripts here?
The import will be context aware and fetch the bundle if necessary and resolve the ESM script it export. It should also handle code splitting. If you're consuming a PlayCanvas project though as part of a bigger project, and you're manually moving stuff around, then import maps, but this would be a similar case if you moved around regular modules. It would be good, if you had some use-cases to test against.
Current implementation simplifies a need to track where scripts are located. So it reduces a chance to break references to scripts when you are moving them between folders or even renaming.
Also it is common use case when two script files implement scripts of the same name, and during development you don't load one, but load the other for swapping purposes.
Could you clarify? Do you mean lazy loading of scripts here?
Every asset in PlayCanvas is assumed to be asynchronous, this is also true for scripts. In large projects it is not viable to load all scripts straight away, but they are loaded in async manner depending on application logic. Most basic example of it: load menu related scripts on initial load (preload), then allow users to interact, then in background load the rest of scripts required by the gameplay.
This is similar to the way web is loading pages and their scripts when you navigate between pages.
Gotcha thanks!
The import will be context aware and fetch the bundle if necessary and resolve the ESM script it export. It should also handle code splitting. If you're consuming a PlayCanvas project though as part of a bigger project, and you're manually moving stuff around, then import maps, but this would be a similar case if you moved around regular modules. It would be good, if you had some use-cases to test against.
Current implementation simplifies a need to track where scripts are located. So it reduces a chance to break references to scripts when you are moving them between folders or even renaming.
Agreed, although this avoids collisions, which would be important if you start importing other code libraries that use the same name id. I think moving folders around is ok, as most people are familiar with having to update imports when they do, plus I really like the idea of having auto-complete for Monaco paths
Also it is common use case when two script files implement scripts of the same name, and during development you don't load one, but load the other for swapping purposes.
Could you clarify? Do you mean lazy loading of scripts here?
Every asset in PlayCanvas is assumed to be asynchronous, this is also true for scripts. In large projects it is not viable to load all scripts straight away, but they are loaded in async manner depending on application logic. Most basic example of it: load menu related scripts on initial load (preload), then allow users to interact, then in background load the rest of scripts required by the gameplay.
This is similar to the way web is loading pages and their scripts when you navigate between pages.
Yep this should work, so if modules are marked 'preload' or similar they are pre-fetched ahead of time, same as is now, either via bundle or not, and then subsequent imports do not trigger network request. If they are not marked preload, then the import is dynamic/lazy and request happens after load.
Also, in bundling, we will code split, so that code shared between entry point A and B (different scenes) can be split into a shared chunk, which minimizes loading of new scenes.
I'd like to see an example with more complex / nested / array attributes to see how it can be used / works.
I just tried to run the Misc/Hello-Script-Modules example and it errors:
I just tried to run the Misc/Hello-Script-Modules example and it errors:
Thanks @kungfooman, that's my oversight. It should be fixed now if you want to try again. I'm also in the midst of creating a set of tests as part of this PR which should help
@willeastcott @slimbuck @mvaligursky @kungfooman @LeXXik @Maksims Any more thoughts here?
class Test {
static attributes = {
layer: {
type: 'object',
default: 0,
title: 'Layer',
description: 'Which Layer to print the debug lines on'
}
};
}
const entity = new pc.Entity("test");
entity.addComponent('esmscript');
entity.esmscript.add(Test);
In the old system you could just set 0 to act as "default" value for type: 'object', but now that errors and the default value is simply dropped - I understand the "type error", but since it was possible before, we should at least be able to express the intention in a better way? Something like: optional: true or something?
These error messages should also contain the given "wrong type value" for reference, could just add { value } as last parameter to Debug.assert.
Another potential issue:
Before we would do something like entity.script.Test to access the script instance (name is our "own" choosen name), but now it would be entity.esmscript.get("Test"); - dependent on the classes name. Whenever a uglifier/minifier went over the code, we can say good-bye to the class names and script instances won't be returned any longer (no one wants broken minified release builds).
Last thing I just want to point out:
Since the esmscript classes are classes without inheriting from e.g. pc.ScriptType or pc.EventHandler, functionality like this.on('attr:color', ...) is dropped and has to be rewritten via pairs of getters/setters. I'm not sure about the consequence for Editor plugins, @LeXXik just mentioned that @leonidaspir depends on this in some of his tools/plugins. How exactly that would affect 3rd party editor plugins is beyond my imagination, but maybe others can share what they think about it. In the beginning it may be no problem since no one made the effort to switch to esmscript yet, but sooner or later developers will adopt and then it may show up.
A couple of questions:
- How to know when the script got disabled/enabled without events?
- If get/set is not specified for the class, but the attribute is, will the class have a property defined on initialization, and will it be set in realtime when changed in Editor UI?
- How to implement lazy-loading if the reference of the class is used instead of the name?
- Why not use
entity.script.Teststill? Isn't calling an extra function unnecessary here? objectattribute type, this is same or different fromjson(current implementation)? The reason it isjson, is that it communicates clearly that it is parseable JSON. Parsing from string and back to json is used by internal loaders as well as save systems that people build. Why change name toobject?- If new scripts don't extend some base class, what about basic functionality: enabling/disabling like any other Component?
- Entity, Texture, AppBase, Material, and many other classes have
destroymethod, that is called explicitly by the developer. ScriptType, Entity and few other classes have an eventdestroyof when it is destroyed. Is there a reason to go against common practice within the engine? This might confuse users and require extra adjustment from ScriptType. Less differences in naming/mechanics there are, easier it will be to switch for users, as well as easier it will be to read either code that can be easily used within both systems. - If an entity is not enabled, will
initializestill be called when a script is added to it?
(2) Just tested, yes, it is set then:
class Test {
static attributes = {
layer: { type: 'number', default: 123}
};
initialize() {
console.log("TEST", this.layer);
}
}
const entity = new pc.Entity("test");
demo.app.root.addChild(entity);
entity.addComponent('esmscript');
entity.esmscript.add(Test);
Outputs: TEST 123
I wonder about possible refactoring of something like (as in scripts/textmesh/text-mesh.js):
// Handle any and all attribute changes
this.on('attr', function (name, value, prev) {
if (value !== prev) {
if (name === 'font') {
if (this.font) {
this.fontData = opentype.parse(this.font.resource);
} else {
this.fontData = null;
this.destroyCharacters();
}
}
if (this.fontData) {
this.createText();
}
}
});
Sorry about (5), I realized it was my own mistake - I took @LeXXik's AMMO Debug Drawer some years ago as basis and tweaked it a lot for making it "standalone" and for making a repo to show how it works and just some testing between the two module types (legacy vs ScriptType):
https://github.com/kungfooman/playcanvas-test-es/blob/b3aa00e541797a594c6074f544d39be542fc6846/es6/example.js#L3-L14
I never realized before that I used it wrong, since there was no error and everything just worked. So testing this PR actually found a mistake in my own script :+1:
(6) I also don't know how to enable/disable a script besides reimplementing pretty much pc.ScriptType
(8) Just tested, it depends on the last argument of entity.esmscript.add and not on the entity enabledness:
class Test {
initialize() {
console.log("Test#initialize called");
}
}
const entity = new pc.Entity("test");
demo.app.root.addChild(entity);
//entity.enabled = false;
entity.addComponent('esmscript');
entity.esmscript.add(Test, null, true); // last arg called "enabled" is "true" = enabled -> call's inittialize
Regarding the whole of this PR, why not use classes notation with existing system and extend it, instead of creating a new one?
I just did this:
class TestClass extends pc.ScriptType {
initialize() {
console.log('initialize');
}
update(dt) {
this.entity.rotate(dt * 90, 0, 0);
}
}
And then:
entity.script.create(TestClass);
And it worked perfectly. With only a few adjustments to the existing pc.ScriptType, we can ensure that attributes are parsed from static property, and we do not lose any functionality, it works already. It will be easier to add Editor support, and you can do imports as you want. No two script systems. Same execution order. Easy to migrate. No need to deprecate anything.
@Maksims
I’ve opted for a separate system for a few reasons.
We’re planning to allow a global script priority, so users can specify an execution order to types of scripts. This is outlined in a separate issue but existing scripts specify a local script execution order, and so the semantics are different.
Also, I believe it’s important a script shouldn’t require a base class in order to work. It shouldn’t break, or worse partially work with wrong results if it doesn’t inherit from the right class. It need only implement the methods it uses and expose the attributes it requires.
Removing the dep on a super class gives much more flexibility for users, keeps the dependancy between a users scripts and the engine as loose as possible and we also don’t have to worry about maintaining a base class that is difficult to upgrade else it breaks users projects. A base class imported by the user can be versioned independently from the engine. Also attribute events etc are useful, but not everyone uses them, users should be able to use what they need and omit what they don’t.
Also, there’s a fair bit of conditional logic to handle legacy scripts, and I’m reluctant to add another layer for ESM Scripts which is likely needed as they’d need separate Handlers to load them and maybe in other places too.
Taken together, keeping the ESM Scripts as a logically separate component/system makes sense. It keeps things isolated, allows us to provided new functionality without breaking existing code, and is done in a way inline with current practice of upgrades (Model vs Render component).
@kungfooman I'm AFK at the moment, but thanks for reviewing this all. Will get back to you both when I can. Appreciate all the work 🙏
We’re planning to allow a global script priority, so users can specify an execution order to types of scripts. This is outlined in a separate issue but existing scripts specify a local script execution order, and so the semantics are different.
Such system can be implemented to work with an existing scripting system.
Also, I believe it’s important a script shouldn’t require a base class in order to work. It shouldn’t break, or worse partially work with wrong results if it doesn’t inherit from the right class. It need only implement the methods it uses and expose the attributes it requires.
On what basis this is a requirement? Is it a problem with an existing system? Is it a blocker for some commercial projects?
Pretty much every engine in different languages that implements scriptable components - do inherit some sort of a base class. e.g. Unity - MonoBehaviour.
Removing the dep on a super class gives much more flexibility for users, keeps the dependancy between a users scripts and the engine as loose as possible and we also don’t have to worry about maintaining a base class that is difficult to upgrade else it breaks users projects. A base class imported by the user can be versioned independently from the engine. Also attribute events etc are useful, but not everyone uses them, users should be able to use what they need and omit what they don’t.
Not having a base class will have "loose rules" over of how the class should be defined/used. Underlying system will have to forever support such rules. Having a base class will actually provide a stable versioning and migrations approach. That allows users to consciously migrate to another base class if required. Editor > Engine workflow heavily relies on quick iterations, that what makes real-time workflow a major feature of the Editor. And it heavily relies on attributes: using Editor UI to iterate on things and see in Launcher updated, as needed. As well as in runtime - we use attributes a lot, and pretty much in every project from small to large.
If some features are not desirable, e.g. attribute events, then a feature for the pc.createScript in form of options, or static property on a class - is a good way to control such features.
Also, there’s a fair bit of conditional logic to handle legacy scripts, and I’m reluctant to add another layer for ESM Scripts which is likely needed as they’d need separate Handlers to load them and maybe in other places too.
Yep, there are two systems already, Legacy Scripts, and Scripts 2.0. So adding a third system will not make it better here.
Taken together, keeping the ESM Scripts as a logically separate component/system makes sense. It keeps things isolated, allows us to provided new functionality without breaking existing code, and is done in a way inline with current practice of upgrades (Model vs Render component).
Every move of PlayCanvas to deprecate some component, e.g.: Model > Render, Audio > Sound, Legacy Scripts > Scripts 2.0, Animation > Anim.
Reason for a switch was due to a fundamental shift in underlying data and limitations of previous system, without ability to do graceful improvements of the system without breaking. There decisions had to be massively weighted with cons/pros, to consider them.
Introducing a full on new script component has a major impact:
- Split in learning path for all users.
- Split in documentation, user manual, forum posts, tutorials etc of old / vs new system. This also worsens learning and accumulation of the community knowledge.
- Another system to support.
- Another system to deprecate and still support.
- Different workflows of achieving essentially the same thing - bad for learning and using any tool/API.
I still have not seen a real problem with a current ScriptType that justified a full switch to a new component system. Please provide actual reasons backed by problems from commercial projects.
One of the lacking features for us with ScriptType is inability to add modules from NPM ecosystem. Given how modules are widespread today, some libraries don't even bother generating IIFE or ES5 variants, so it adds to our burden to bundle them in a way that would work in PlayCanvas. Importing modules should be as natural. It is a hassle at the moment.
I personally don't use TypeScript, but I'm aware of projects that wish they could choose if they want to use TypeScript or Javascript, similar to Babylon. I believe the ESM modules system would allow to make it happen.
One of the lacking features for us with ScriptType is inability to add modules from NPM ecosystem. Given how modules are widespread today, some libraries don't even bother generating IIFE or ES5 variants, so it adds to our burden to bundle them in a way that would work in PlayCanvas. Importing modules should be as natural. It is a hassle at the moment.
I personally don't use TypeScript, but I'm aware of projects that wish they could choose if they want to use TypeScript or Javascript, similar to Babylon. I believe the ESM modules system would allow to make it happen.
We can add ESM modules with existing ScriptType. ESM modules mostly require small improvements to ScriptType, and integration to Editor, it does not require whole new component system at all.
To be honest, this has caused a few headaches in the past...
const c = Color()
script.color = c
c.r = 1
(script.color.r === 1) // nope
To be honest, this has caused a few headaches in the past...
const c = Color() script.color = c c.r = 1 (script.color.r === 1) // nope
This is consistent with the rest of the engine: do not attach short-lived/reusable objects, but copy their values to avoid unintended edits later, e.g.:
rigidbody.angularFactor
rigidbody.angularVelocity
rigidbody.linearFactor
rigidbody.linearVelocity
element.alignment
element.color
element.outlineColor
// dozens more ...
Pretty much every property on components within PlayCanvas engine follow that practice, ScriptComponent attributes are not an exception.