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

ESM Scripts #5764

Closed
wants to merge 146 commits into from
Closed

ESM Scripts #5764

wants to merge 146 commits into from

Conversation

marklundin
Copy link
Member

@marklundin marklundin commented Oct 18, 2023

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:

  1. Clean, familiar API with simple Class based syntax
  2. Removes any dependancy on older ES5 style globals - pc
  3. Supports nested attributes
  4. Solves issues with existing Scripts
  5. Provides more flexibility to use a custom base class with own logic (events, logging, analytics)
  6. Forwardly compatible with bundling and code splitting, not just string concatenation
  7. 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

  • Implement Script ESM Component system
  • Implement initialize / destroy / update methods

See #4767

I confirm I have read the contributing guidelines and signed the Contributor License Agreement.

@marklundin
Copy link
Member Author

marklundin commented Oct 18, 2023

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(){}
}

@Maksims
Copy link
Contributor

Maksims commented Oct 19, 2023

This is vastly different functionality from the current ScriptType.

Few things:

  1. Attributes are not editor-only, as they do provide better initialisation process and provide runtime events.
  2. 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.
  3. Events of a ScriptType are usefull and important, so not providing them by default might confuse at first.
  4. Default properties, such as entity, app, etc, are defined automatically, so it simplifies development for the developer.
  5. 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.

@marklundin
Copy link
Member Author

marklundin commented Oct 19, 2023

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?

@willeastcott
Copy link
Contributor

willeastcott commented Oct 19, 2023

@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.

@LeXXik
Copy link
Contributor

LeXXik commented Oct 19, 2023

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 pc could 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.

@Maksims
Copy link
Contributor

Maksims commented Oct 19, 2023

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.

@marklundin
Copy link
Member Author

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

@Maksims
Copy link
Contributor

Maksims commented Oct 21, 2023

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.

@marklundin
Copy link
Member Author

Thanks for all your input. We've outlined some more detailed design thoughts in this RFC #5770

@kungfooman
Copy link
Collaborator

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?

image

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.

@Maksims
Copy link
Contributor

Maksims commented Dec 17, 2023

A couple of questions:

  1. How to know when the script got disabled/enabled without events?
  2. 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?
  3. How to implement lazy-loading if the reference of the class is used instead of the name?
  4. Why not use entity.script.Test still? Isn't calling an extra function unnecessary here?
  5. object attribute type, this is same or different from json (current implementation)? The reason it is json, 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 to object?
  6. If new scripts don't extend some base class, what about basic functionality: enabling/disabling like any other Component?
  7. Entity, Texture, AppBase, Material, and many other classes have destroy method, that is called explicitly by the developer. ScriptType, Entity and few other classes have an event destroy of 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.
  8. If an entity is not enabled, will initialize still be called when a script is added to it?

@kungfooman
Copy link
Collaborator

kungfooman commented Dec 18, 2023

(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 👍

(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

@Maksims
Copy link
Contributor

Maksims commented Dec 18, 2023

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.

@marklundin
Copy link
Member Author

@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).

@marklundin
Copy link
Member Author

@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 🙏

@Maksims
Copy link
Contributor

Maksims commented Dec 19, 2023

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:

  1. Split in learning path for all users.
  2. Split in documentation, user manual, forum posts, tutorials etc of old / vs new system. This also worsens learning and accumulation of the community knowledge.
  3. Another system to support.
  4. Another system to deprecate and still support.
  5. 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.

@LeXXik
Copy link
Contributor

LeXXik commented Dec 19, 2023

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.

@Maksims
Copy link
Contributor

Maksims commented Dec 19, 2023

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.

@Maksims Maksims mentioned this pull request Dec 19, 2023
5 tasks
@marklundin
Copy link
Member Author

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

@Maksims
Copy link
Contributor

Maksims commented Dec 19, 2023

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.

@marklundin
Copy link
Member Author

marklundin commented Dec 20, 2023

Fully disagree. Invisibly changing the behaviour of a public member when it's explicitly declared is super weird.

class MyClass {
  // This attribute is changed to a copy???
  colorAttr = new Color()
}

@mvaligursky
Copy link
Contributor

If you do this in your code, this is not a copy, that creates a new member and assigns your vector:

class MyClass {
  colorAttr = new Color()
}

but the example Max added:

script.color = new Color()

I would expect the setter of the color would copy the color to its private property, to make sure the user cannot change its content without it being pushed through the setter, where this might need to be handled.

We had issues with this in many cases, where the user would initialize multiple bounding boxes with the same vectors, and when one changes, the other would change too. Also, they'd use Vec3.ZERO to assign it to a property, and later internally this would modify the ZERO static member, causing hard to find issues.

@Maksims
Copy link
Contributor

Maksims commented Dec 20, 2023

Also it is common to do this:

element.color = pc.Color.RED;

@kungfooman
Copy link
Collaborator

I'm very happy to see discussion around ownership details! Right now values like vectors aren't even cloned yet properly:

function cloneEntityAndFitStuff(entity) {
    const clone = entity.clone();
    const cloneScript = clone.esmscript.get("Test");
    cloneScript.a.scale(0.000001);
    cloneScript.b.scale(0.000001);
    cloneScript.c.scale(0.000001);
    return clone;
}
class Test {
  static attributes = {
    a: { type: 'vec3', default: [  1,  2,  3]},
    b: { type: 'vec3', default: [ 11, 22, 33]},
    c: { type: 'vec3', default: [111,222,333]},
  };
  initialize() {
    console.log("TEST", this.a, this.b, this.c);
  }
}
const entity = new pc.Entity('test');
demo.app.root.addChild(entity);
entity.addComponent('esmscript');
entity.esmscript.add(Test);
const clone = cloneEntityAndFitStuff(entity);
const {a, b, c} = entity.esmscript.get("Test");
console.log("Initial/uncloned entity values are now", JSON.stringify({a, b, c}, null, 2));
console.log("Test *should* be false", entity.esmscript.get("Test").a === clone.esmscript.get("Test").a);

Outputs:

image

Basically because this is true currently:

entity.esmscript.get("Test").a === clone.esmscript.get("Test").a

Now lets convert the same test script to make it use pc.ScriptType:

function cloneEntityAndFitStuff(entity) {
    const clone = entity.clone();
    const cloneScript = clone.script.Test;
    cloneScript.a.scale(0.000001);
    cloneScript.b.scale(0.000001);
    cloneScript.c.scale(0.000001);
    return clone;
}
class Test extends pc.ScriptType {
  static _ = (
    this.attributes.add('a', {type: 'vec3', default: [  1,  2,  3]}),
    this.attributes.add('b', {type: 'vec3', default: [ 11, 22, 33]}),
    this.attributes.add('c', {type: 'vec3', default: [111,222,333]})
  );
  initialize() {
    console.log("TEST", this.a, this.b, this.c);
  }
}
pc.registerScript(Test, 'Test');
const entity = new pc.Entity('test');
demo.app.root.addChild(entity);
entity.addComponent('script');
entity.script.create("Test");
const clone = cloneEntityAndFitStuff(entity);
const {a, b, c} = entity.script.Test;
console.log("Initial/uncloned entity values are now", JSON.stringify({a, b, c}, null, 2));
console.log("Test *should* be false", entity.script.Test.a === clone.script.Test.a);

Outputs:

image

Using ScriptType test from above:

image

I'm also happy that we don't inject a reference from somewhere else into the script.

Whereas this PR script system is replacing "internals":

image

We could kinda forget about the difference if we would write code more like this:

entity.script.Test.a.copy(d);

This also relates to this PR: #4925

One problem of flipping internals via loose ownership: if you assign a frozen object, the type is suddenly "read-only", but no one defined that type being read-only anywhere. So we ended up with more code testing that in different places (where possible):

Debug.assert(!Object.isFrozen(center), `The constructor of 'BoundingBox' does not accept a constant (frozen) object as a 'center' parameter`);
        Debug.assert(!Object.isFrozen(halfExtents), `The constructor of 'BoundingBox' does not accept a constant (frozen) object as a 'halfExtents' parameter`);

To sum up what I think:

  • we need a bunch of ownership unit tests that ensure clones are unentangled with source entity references
  • keep it unified, prefer hard ownership over a loose one

@marklundin
Copy link
Member Author

Thanks @kungfooman for pointing out the bug. Cloned entities shouldn't share attributes - I'll address this and add tests.

@Maksims @mvaligursky, my main issue isn't with the copying itself, but with how current scripts invisibly override property accessors and change their semantics. This is especially problematic in class syntax, as user-defined accessors get removed and overridden, like so:

class Script {
  set colorAttr(v) {
    // Overridden
  }
  get colorAttr() {
    // Also overridden
  }
}

This creates confusion and inconsistency for users. It's hard to predict when and why accessors are overridden, plus the behaviour varies by type (vectors are copied, curves cloned).

Assigning attribute via the editor/launcher should be efficient, but can be handled outside the class and shouldn't override user code or alter accessor semantics. If the engine needs to monitor attribute changes, we should consider using proxies. Users should explicitly handle attribute changes across scripts, even if it's more verbose but clearer.

@Maksims
Copy link
Contributor

Maksims commented Jan 2, 2024

Assigning attribute via the editor/launcher should be efficient, but can be handled outside the class and shouldn't override user code or alter accessor semantics. If the engine needs to monitor attribute changes, we should consider using proxies. Users should explicitly handle attribute changes across scripts, even if it's more verbose but clearer.

I think it should a balance, of convenience and stability. Copying has no runtime memory impact, and a small compute impact, while provides safety. While reference usage gives better init memory, faster execution, but can suffer from safety. Common devs without knowing the implications will end up in problematic situations more often, than they require very precise performance and efficiency in such non-impactful area. If a power user requires such micro-optimisation they are not limited to implement own accessors. Don't forget, we are power users, while majority don't know insides of the engine, and safety for them is more important.

@marklundin
Copy link
Member Author

marklundin commented Jan 12, 2024

This has now been updated based on feedback

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).

The scripting system should now be resistant to mangling, by requiring an explicit class identifier. I've also added tests to cover this. Also it seems that ScriptType has this problem

I also don't know how to enable/disable a script besides reimplementing pretty much pc.ScriptType

enabled is now a property of the script itself and can be used to enable or disable it in the standard way.

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.

Agree with this @kungfooman , although I don't like the idea of overriding a class's props. I'm less concerned with the issue of changing a types shape and more that if a user adds a class system{} it would override the components system prop. I wonder if something like entity.esm.scripts.Test would work, so it's all namespaced under a empty proxy object. Open to suggestions @mvaligursky @willeastcott

@Maksims
Copy link
Contributor

Maksims commented Jan 12, 2024

I still haven't seen a strong reason for another component system that does exactly the same. Instead of improving ScriptType and extending it. Basically allowing users to use same components as they had, and even attributes in Editor won't need to be changed and hierarchy. No need to parse new extra stuff, no need to support two separate systems!
There will be no possibility for gradual migration as two component systems will not provide same execution order.

@marklundin
Copy link
Member Author

marklundin commented Jan 12, 2024

Hey @Maksims, so these are friction points in the current ScriptType.

Overriding of class props

This becomes more confusing in class based syntax, but it's also inconsistent applied across types

class MyClass extends ScriptType {
   vec = new Vec3()
   curve = new Curve()
}

vec = script.MyClass.vec
vec.x = 10 // this works
script.MyClass.vec = myOtherVec
myOtherVec.x = 10 // does not work
vec.x = 10 // this works

curve = script.MyClass.curve
curve.x = 10 // this works
script.MyClass.curve = myOtherCurve
myOtherCurve.x = 10 // does not update class
curve.x = 10 // this does not work

This is not an argument against copying per-se, just that it should be clear and transparent to the user what is happening, and something they can opt in and out of. Performing invisible copies is hard to debug especially when they're inconsistent. In the engine code itself, this is fine.

Inflexible inheritance

Anecdotal feedback shows users prefer to choose their own base class. This provides more flexibility to the user. Conformance to an ESM Script api is done via an "interface", which will also work for TypeScript type checking class MyScript implements ESMScriptInterface.

Attributes can't be inherited

You can't extend a Script as it's attributes will not work. Attributes are tied to the file they're declared in.

Class name is susceptible to mangling

The __getStaticName function relies on the function name, which can change if a bundler uses Terser or similar.

Scripts override component props

pc.registerScript(class App {}, 'app') // Overrides app base
This goes for any prop of the script component class and any prop in the inheritance chain. This is problematic and can be easy to override, but also makes it difficult to add any new properties to the script component. Also it's not ideal from a JIT perspective as it changes the shape of hidden classes which can cause a deopt. It would be better to access the script instances via a vanilla object

Importing

Current scripts are loaded using a script tag which doesn't work in Node, Deno etc. Using ESM imports is now a standardised way of importing code across environments.

Concatenation + Hot reloading

Not a huge one but existing scripts are compiled using simple string concatenation. Modules would need to be properly bundled in order to retain correct module scope and provide better more modern bundling fearures. Additionally, arguably HMR code should be removed from production code.

Overall

Even though these could be individually solved. It would be non-trivial to iteratively update the current scripts without breaking existing projects.

Using a separate components system gives more flexibility to move forward in a safe way. Moreso, it allows us to logically isolate newer language features from older ones. If a user elects to use ESM modules, we can make assumptions and use newer language features. Similarly, if they want to use older scripts, they can do so without having to load newer features.

It also provides a clean an straightforward way to migrate users.

@marklundin marklundin changed the title Add ES Modules ESM Scripts Jan 12, 2024
@Maksims
Copy link
Contributor

Maksims commented Jan 12, 2024

class MyClass extends ScriptType {
   vec = new Vec3()
   curve = new Curve()
}

vec = script.MyClass.vec
vec.x = 10 // this works
script.MyClass.vec = myOtherVec
myOtherVec.x = 10 // does not work
vec.x = 10 // this works

curve = script.MyClass.curve
curve.x = 10 // this works
script.MyClass.curve = myOtherCurve
myOtherCurve.x = 10 // does not update class
curve.x = 10 // this does not work

I'm sorry, but your code is incorrect. These assignments do work.
Properties work here as they work in your class definitions. Just like they do work currently with ScriptType.
Please test the code you've provided.

Current behavior is this:

class TestClass extends pc.ScriptType {
    static name = 'test';

    vec = new pc.Vec3();
    curve = new pc.Curve();
}

// ...

const script = entity.script.test;
const otherVec = new pc.Vec3();
script.vec = otherVec;
otherVec.x = 10;
script.vec.x === 10; // true

Inflexible inheritance

Anecdotal feedback shows users prefer to choose their own base class. This provides more flexibility to the user. Conformance to an ESM Script api is done via an "interface", which will also work for TypeScript type checking class MyScript implements ESMScriptInterface.

In JavaScript there is no interface, inheritance is implemented using extends. Which you can use with the current system:

class NetworkScript extends pc.ScriptType {
    // implements networking
}

class Barrel extends NetworkScript {
    static name = 'barrel';
}

So you can use inheritance the way you want. Also, this is common practice in the industry and across pretty much all engines - to inherit the base class. Can you please provide a poll or some source of PlayCanvas user preferences on this topic?

You can't extend a Script

We can tackle that issue for existing ScriptType which will solve it for prototype and class approaches.

attributes will not work. Attributes are tied to the file they're declared in.

This is not an engine issue, but the way the Editor loads and parses scripts. So it has no context of other scripts. Nothing to do with the engine. This will be same issue for your ESM Scripts implementation. Please either upvote the relevant ticket one Editor's repo or create a new Issue there.

Class name is susceptible to mangling

The same issue for the ESM Script implementation.

Current scripts are loaded using a script tag which doesn't work in Node, Deno etc. Using ESM imports is now a standardised way of importing code across environments.

You can load scripts in node.js: you can import and you can provide exports. We use PlayCanvas in node.js and have no problems with it.
If you want to load scripts with automatic loading, then this can be easily improved, just create a PR for the non-browser loading path.

Not a huge one but existing scripts are compiled using simple string concatenation. Modules would need to be properly bundled in order to retain correct module scope and provide better more modern bundling fearures. Additionally, arguably HMR code should be removed from production code.

Do you have a good stats on this? Haven't had a single issue or heard of one regarding concatenation process. It does what it supposed to: concatenates scripts, minifies them and ensures they work exactly same way as non-concatenated or minifed.
It also solves few important things: order of execution, same scoping, order of script regestration.
Do you have a better option for more efficient minifiers? If so, feel free to propose a better solution in Editor's repo, as it is Editor's task to concatenate and minify scripts.

As for concatenation or minifacation for engine-only users. They can already use whatever systems they want, and minify the way they want. There are no blockers.

Hot-reloading, is extremely slim part of the codebase, and people use it. We use it even on node.js back-end production with multiplayer projects, where we can update gameplay not only during development, but also hot-patch stuff in production. And it is extremely powerful. Please provide a strong argument and production cases where it caused any issue that it requires a removal.

Even though these could be individually solved. It would be non-trivial to iteratively update the current scripts without breaking existing projects.

I strongly disagree. The users and the engine have their ways and have been working pretty well. It can be improved - and progressive improvements are the way to go. Unless there is a major roadblock, which then requires an alternative (e.g. Audio > Sound component, or Model > Render).

This has to be done and can be done like everything before, updating the engine without breaking stuff. This is the way the engine has been developed since the beginning - it always works, and doesn't break older stuff. It is important for developers that trust the engine.

Using a separate components system gives more flexibility to move forward in a safe way. Moreso, it allows us to logically isolate newer language features from older ones. If a user elects to use ESM modules, we can make assumptions and use newer language features. Similarly, if they want to use older scripts, they can do so without having to load newer features.

I'm sorry, but I've provided a major list of issues with a new system, and it does not solve all the list you've provided regarding the current system. So the new system will suffer as much from the same issues. As they need to be tackled separately.

It also provides a clean an straightforward way to migrate users.

There is no need to migrate. That's the point.
We can improve existing ScriptType, to satisfy certain things and provide a very gradual alternative. Without the need to migrate. Implementing a separate scripting component will not be supported by Editor straight away, and actually will have issues of to be implemented with Editor. And will require a full migration by users, which is way more intrusive.

I might point out, that I've referring to the majority of users, which are Editor users. Engine-only users are important too, but the solution has to serve both environments.

Implementing a whole new scripting component system, that is almost identical to ScriptType, and it does not still solve the majority of issues you have listed here because they come from Editor and other places. I'm sorry, but I don't believe this should be part of the Engine.

@marklundin
Copy link
Member Author

We've opted to use the existing ScriptType to support ES Modules. Closing for now

@marklundin marklundin closed this Feb 29, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants