Wonderland Engine 1.0.0 JavaScript 迁移

David Peicho

Wonderland Engine 正在对其处理、交互和分发 JavaScript 代码的方式进行大幅改进。

这篇博文将指导您了解这些变化。此外,迁移部分将详细介绍每个新功能,为您的 pre-1.0.0 项目提供迁移步骤。

动机 

截至目前,Wonderland Engine 的默认打包器依赖于连接本地脚本。用户会创建脚本,编辑器会拾取它们并打包以创建最终的应用程序。需要预先打包任何第三方库,并将其放在项目的文件夹结构中。

或者,可以设置 NPM 项目,但设置是手动的,团队中的艺术设计师需要安装 NodeJS 并按照步骤安装依赖项等。

新系统有几个优点:

  • 默认支持 NPM 包依赖项
  • 更佳的 IDE 自动完成建议
  • 更容易与高级工具(如 TypeScript)集成
  • 与其他 WebAssembly 库兼容
  • 每个页面支持多个 Wonderland Engine 实例
  • 为非开发团队成员自动管理您的 NPM 项目

我们一直在开发一个新的 JavaScript 生态系统,以帮助您无缝地使用您喜爱的工具。

编辑器组件 

如果您以前是 NPM 用户,可能会遇到以下情况:

Wonderland Engine 中缺失组件警告的示例

1.0.0 版本开始,编辑器不再从应用程序包中拾取组件类型。除了修复上述错误之外,它还会列出可能在最终应用程序中未注册的更多组件。这将为高级用户在未来设置具有流式组件的复杂项目提供支持。

通过此更改,编辑器现在需要:

  • Views > Project Settings > JavaScript > sourcePaths 中列出组件或文件夹
  • 在根 package.json 文件中添加依赖

一个示例 package.json 文件,显示了暴露组件的库:

{
  "name": "my-wonderful-project",
  "version": "1.0.0",
  "description": "My Wonderland project",
  "dependencies": {
    "@wonderlandengine/components": "^1.0.0-rc.5"
  }
}

编辑器现在能够通过读取您的 package.json 来找到组件,以加快开发时间并提高可共享性。有关详细信息,请查看编写 JavaScript 库教程。

打包 

一个新的设置允许您修改打包过程:

Views > Project Settings > JavaScript > bundlingType

Wonderland Engine 中的打包类型设置

让我们看看每个选项:

esbuild 

你的脚本将使用 esbuild 打包器进行打包。

这是默认选择。出于性能原因,建议您尽可能坚持这个设置。

npm 

你的脚本将使用你自己的 npm 脚本进行打包。

示例 package.json 带有自定义 build 脚本:

{
  "name": "MyWonderfulProject",
  "version": "1.0.0",
  "description": "My Wonderland project",
  "type": "module",
  "module": "js/index.js",
  "scripts": {
    "build": "esbuild ./js/index.js --bundle --format=esm --outfile=\"deploy/MyWonderfulProject-bundle.js\""
  },
  "devDependencies": {
    "esbuild": "^0.15.18"
  }
}

npm 脚本名称可以在编辑器设置中设置:

Views > Project Settings > JavaScript > npmScript

如何选择 npm 脚本

此脚本可以运行任何命令,只要它生成您的最终应用程序包即可。

您可以使用您喜欢的打包器,例如 WebpackRollup。但是,我们建议用户使用诸如 esbuild 之类的工具来减少迭代时间。

应用程序入口点 

组件在运行时(即在浏览器中运行时)注册方式不同。

对于运行时,编辑器可以自动管理应用程序的入口点,即 index.js 文件。

编辑器使用的模板大致如下:

/* wle:auto-imports:start */
/* wle:auto-imports:end */

import {loadRuntime} from '@wonderlandengine/api';

/* wle:auto-constants:start */
/* wle:auto-constants:end */

const engine = await loadRuntime(RuntimeBaseName, {
    physx: WithPhysX,
    loader: WithLoader,
});

// ...

/* wle:auto-register:start */
/* wle:auto-register:end */

engine.scene.load(`${ProjectName}.bin`);

/* wle:auto-benchmark:start */
/* wle:auto-benchmark:end */

此模板会自动复制到新创建的和旧的 pre-1.0.0 项目中。

该模板包含以下标记:

  • wle:auto-imports: 分隔应编写导入语句的地方
  • wle:auto-register: 分隔应编写注册语句的地方
  • wle:auto-constants: 分隔编辑器将编写常量的地方,例如:
    • ProjectName: 项目.wlp文件中列出的名称
    • WithPhysX: 如果启用了物理引擎则启用的布尔值
    • WithLoader: 如果需要支持 glTF 的运行时加载,则启用的布尔值

例如:

/* wle:auto-imports:start */
import {Forward} from './forward.js';
/* wle:auto-imports:end */

import {loadRuntime} from '@wonderlandengine/api';

/* wle:auto-constants:start */
const ProjectName = 'MyWonderland';
const RuntimeBaseName = 'WonderlandRuntime';
const WithPhysX = false;
const WithLoader = false;
/* wle:auto-constants:end */

const engine = await loadRuntime(RuntimeBaseName, {
  physx: WithPhysX,
  loader: WithLoader
});

// ...

/* wle:auto-register:start */
engine.registerComponent(Forward);
/* wle:auto-register:end */

engine.scene.load(`${ProjectName}.bin`);

// ...

此索引文件是为一个仅包含一个称为 Forward 的组件的项目自动生成,定义在 js/forward.js 中。重要的是要注意,编辑器仅导入和注册在场景中使用的组件,即附加到对象的组件。

如果您的应用程序仅在运行时使用组件,您将需要:

  • 将它们标记为依赖。在组件依赖部分了解更多信息
  • index.js 文件中手动导入它们

对于简单的应用程序,这个模板文件就足够了,不需要修改。对于更复杂的使用情况,您可以通过移除标记注释来自由创建和管理自己的 index.js 文件。

手动管理索引文件可以让您创建具有多个入口点的应用程序,并注册编辑器未知的组件

JavaScript 类 

Wonderland Engine 1.0.0 提供了一种声明组件的新方法:ES6 类

import {Component, Property} from '@wonderlandengine/api';

class Forward extends Component {
    /* 组件的注册名称。 */
    static TypeName = 'forward';
    /* 在编辑器中公开的属性。 */
    static Properties = {
        speed: Property.float(1.5)
    };

    _forward = new Float32Array(3);

    update(dt) {
        this.object.getForward(this._forward);
        this._forward[0] *= this.speed;
        this._forward[1] *= this.speed;
        this._forward[2] *= this.speed;
        this.object.translate(this._forward);
    }
}

有几点需要注意:

  • 不再使用全局 WL 符号,而是使用 @wonderlandengine/api 的 API
  • 我们创建了一个继承自 API Component 类的类
  • 组件的注册名称现在是一个静态属性
  • 属性现在设置在类中

JavaScript 属性 

对象字面量属性已被函子取代:

import {Component, Property} from '@wonderlandengine/api';

class MyComponent extends MyComponent {
    /* 组件的注册名称。 */
    static TypeName = 'forward';
    /* 在编辑器中公开的属性。 */
    static Properties = {
        myFloat: Property.float(1.0),
        myBool: Property.bool(true),
        myEnum: Property.enum(['first', 'second'], 'second'),
        myMesh: Property.mesh()
    };
}

组件依赖 

依赖是您的组件注册时自动注册的组件。

让我们在 Forward 示例中添加一个 Speed 组件:

import {Component, Type} from '@wonderlandengine/api';

class Speed extends Component {
    static TypeName = 'speed';
    static Properties = {
        value: Property.float(1.5)
    };
}

class Forward extends Component {
    static TypeName = 'forward';
    static Dependencies = [Speed];

    _forward = new Float32Array(3);

    start() {
      this._speed = this.object.addComponent(Speed);
    }

    update(dt) {
        this.object.getForward(this._forward);
        this._forward[0] *= this._speed.value;
        this._forward[1] *= this._speed.value;
        this._forward[2] *= this._speed.value;
        this.object.translate(this._forward);
    }
}

Forward 注册时,Speed 将因列为依赖而自动注册。

该行为由在 index.js 中创建的 WonderlandEngine 对象上的布尔值 autoRegisterDependencies 管理。

事件 

Wonderland Engine 曾经通过监听器数组处理事件,例如:

  • WL.onSceneLoaded
  • WL.onXRSessionStart
  • WL.scene.onPreRender
  • WL.scene.onPreRender

引擎现在附带一个 Emitter 类来简化事件交互:

engine.onXRSessionStart.add((session, mode) => {
    console.log(`Start session '${mode}'!`);
})

您可以使用标识符管理监听器:

engine.onXRSessionStart.add((session, mode) => {
    console.log(`Start session '${mode}'!`);
}, {id: 'my-listener'});

// 完成后,您可以简单地使用 `id` 将其移除。
engine.onXRSessionStart.remove('my-listener');

有关更多信息,请查看 [Emitter] API 文档。

Object 

Object 在整个重做中也未被忽视。它经过了更改,使 API 更加一致和更安全。

导出名称 

Object 类现在导出为 Object3D。此更改是为防止遮挡 JavaScript Object 构造函数

为了顺利迁移,Object 仍将被导出,但请务必使用

import {Object3D} from '@wonderlandengine/api';

以便于未来的迁移。

变换 

变换 API 也进行了更新。引擎现在弃用使用变换的 getters 和 setters(访问器):

this.object.translationLocal;
this.object.translationWorld;
this.object.rotationLocal;
this.object.rotationWorld;
this.object.scalingLocal;
this.object.scalingWorld;
this.object.transformLocal;
this.object.transformWorld;

这些 getters / setters 有一些缺陷:

  • 一致性:没有遵循其他变换 API
  • 性能:每次调用时分配 Float32Array
  • 安全性:内存视图可能被其他组件更改
    • 可能产生 bug,当存储 Float32Array 引用以备后用时

如果您想了解新 API,请阅读 Object Transform 部分。

JavaScript 隔离 

对于使用内部打包器的用户,您可能已经看到类似以下的代码:

component-a.js

var componentAGlobal = {};

WL.registerComponent('component-a', {}, {
    init: function() {
        comonentAGlobal.init = true;
    },
});

component-b.js

WL.registerComponent('component-b', {}, {
    init: function() {
        if(componentAGlobal.init) {
              console.log('Component A has been initializaed before B!');
        }
    },
});

上述代码对 componentAGlobal 变量做出了一些假设。它期望 component-a 先行注册并在打包中被预置。

过去,这种方式工作是因为 Wonderland Engine 内部打包器不执行隔离

1.0.0 中,无论您使用 esbuild 还是 npm,这都将不再有效。打包器将无法将 component-a 中使用的 componentAGlobalcomponent-b 中使用的关联起来。

总之:在使用打包器时,把每个文件都视为隔离

迁移 

根据您的项目是否使用 npm,可能需要一些手动迁移步骤。

每个部分将描述根据您以前的设置所需的适当步骤。

编辑器组件 (#migration-editor-components) 

内部打包器 

对于以前使用内部打包器的用户,即 useInternalBundler 复选框处于激活状态:

Views > Project Settings > JavaScript > useInternalBundler

不需要进行任何进一步的步骤。

Npm 

对于 npm 用户,您需要确保您的脚本列在 sourcePaths 设置中。

如果您使用的是库,请确保它已按照 编写 JavaScript 库 教程迁移到 Wonderland Engine 1.0.0

如果您的某个依赖项没有更新,您可以在 sourcePaths 设置中添加 node_modules 文件夹的本地路径。示例:

Wonderland Engine 中缺失组件警告的示例

请始终记住,通过 npmesbuild 生成的打包文件将不再用于在编辑器中查找组件。它仅在运行您的应用程序时使用。

打包 

不需要进行任何进一步的步骤。项目应自动迁移。

JavaScript 类、属性和事件 

对于所有用户,无论您以前是否启用了 useInternalBundler,这一部分是相同的。

让我们来看一些比较旧方式与新方式的代码:

1.0.0 之前

WL.registerComponent('player-height', {
    height: {type: WL.Type.Float, default: 1.75}
}, {
    init: function() {
        WL.onXRSessionStart.push(this.onXRSessionStart.bind(this));
        WL.onXRSessionEnd.push(this.onXRSessionEnd.bind(this));
    },
    start: function() {
        this.object.resetTranslationRotation();
        this.object.translate([0.0, this.height, 0.0]);
    },
    onXRSessionStart: function() {
        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
            this.object.resetTranslationRotation();
        }
    },
    onXRSessionEnd: function() {
        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
            this.object.resetTranslationRotation();
            this.object.translate([0.0, this.height, 0.0]);
        }
    }
});

1.0.0 之后

/* 不要忘记我们现在使用 npm 依赖 */
import {Component, Property} from '@wonderlandengine/api';

export class PlayerHeight extends Component {
    static TypeName = 'player-height';
    static Properties = {
        height: Property.float(1.75)
    };

    init() {
        /* Wonderland Engine 1.0.0 移除全局实例。
         * 现在,您可以通过 `this.engine` 访问当前引擎实例。 */
        this.engine.onXRSessionStart.add(this.onXRSessionStart.bind(this));
        this.engine.onXRSessionEnd.add(this.onXRSessionEnd.bind(this));
    }
    start() {
        this.object.resetTranslationRotation();
        this.object.translate([0.0, this.height, 0.0]);
    }
    onXRSessionStart() {
        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
            this.object.resetTranslationRotation();
        }
    }
    onXRSessionEnd() {
        if(!['local', 'viewer'].includes(WebXR.refSpace)) {
            this.object.resetTranslationRotation();
            this.object.translate([0.0, this.height, 0.0]);
        }
    }
}

这两个示例是等效的,并将导致相同的结果。

您会注意到第 5 行的区别:

WL.onXRSessionStart.push(this.onXRSessionStart.bind(this));

与之比较

this.engine.onXRSessionStart.add(this.onXRSessionStart.bind(this));

因为我们现在迁移到了npm 依赖和标准打包,已经不再需要全局 WL 变量。

拥有一个全局暴露的引擎有两个限制:

  • 难以共享组件
  • 无法运行多个引擎实例

尽管第二个要点并不是一个常见的用例,但我们不想在可扩展性方面限制任何用户。

对象变换 

新API基于Wonderland Engine中常用的模式:

getValue(out) { ... }
setValue(v) { ... }

变换的平移旋转、和缩放组件现在也遵循这个模式:

const translation = this.object.getTranslationLocal();
this.object.setTranslationLocal([1, 2, 3]);

const rot = this.object.getRotationLocal();
this.object.setRotationLocal([1, 0, 0, 1]);

const scaling = this.object.getScalingLocal();
this.object.setScalingLocal([2, 2, 2]);

如 API 的其他部分一样,使用空 out 参数的getter会导致输出数组的创建。请注意,尽可能重用数组(出于性能原因)始终是更好的选择。

除了能够读取和写入对象的本地空间变换,您还可以直接操作世界空间:

const translation = this.object.getTranslationWorld();
this.object.setTranslationWorld([1, 2, 3]);

const rot = this.object.getRotationWorld();
this.object.setRotationWorld([1, 0, 0, 1]);

const scaling = this.object.getScalingWorld();
this.object.setScalingWorld([2, 2, 2]);

有关更多信息,请查看 [Object3D] API 文档。

JavaScript 隔离 

这一部分对于所有用户来说都是一致的,无论您是否启用了 useInternalBundler

有很多方法可以在组件之间共享数据,如何选择合适的做法取决于应用程序的开发者。

以下是一些不依赖全局变量的共享数据的示例。

组件中的状态 

组件可被视为数据的容器,可以通过其他组件访问。

因此,您可以创建一个组件来持有应用程序的状态。例如,如果您正在创建一个具有三种状态的游戏:

  • 进行中
  • 赢了
  • 输了

您可以创建一个单例组件,形状如下:

game-state.js

import {Component, Type} from '@wonderlandengine/api';

export class GameState extends Component {
  static TypeName = 'game-state';
  static Properties = {
    state: {
      type: Type.Enum,
      values: ['running', 'won', 'lost'],
      default: 0
    }
  };
}

然后可以将 GameState 组件添加到一个管理对象中。应由将改变游戏状态的组件引用此对象。

让我们创建一个组件,当玩家死亡时改变游戏状态:

player-health.js

import {Component, Type} from '@wonderlandengine/api';

import {GameState} from './game-state.js';

export class PlayerHealth extends Component {
  static TypeName = 'player-health';
  static Properties = {
    manager: {type: Type.Object},
    health: {type: Type.Float, default: 100.0}
  };
  update(dt) {
    /* 玩家死亡,改变状态。 */
    if(this.health <= 0.0) {
      const gameState = this.manager.getComponent(GameState);
      gameState.state = 2; // 我们将状态设置为 `lost`。
    }
  }
}

这是在你的 pre-1.0.0 应用程序中替换全局变量的一种方式示例。

导出 

还可以通过importexport共享变量。不过,请务必记住,同一包中的对象实例将始终相同。

我们可以用导出方式重述上面的示例:

game-state.js

export const GameState = {
  state: 'running'
};

player-health.js

import {Component, Type} from '@wonderlandengine/api';

import {GameState} from './game-state.js';

export class PlayerHealth extends Component {
  static TypeName = 'player-health';
  static Properties = {
    manager: {type: Type.Object},
    health: {type: Type.Float, default: 100.0}
  };
  update(dt) {
    if(this.health <= 0.0) {
      GameState.state = 'lost';
    }
  }
}

这种解决方案有效,但并不是万无一失的。

让我们看一个这种解决方案失效的例子。我们假设这个代码在一个名为 gamestate 的库中。

  • 您的应用依赖于 gamestate 版本 1.0.0
  • 您的应用依赖于库 A
  • A 依赖于 gamestate 版本 2.0.0

您的应用将会附带 gamestate 库的两个副本,因为两个版本在兼容性上不同。

当库 A 更新 GameState 对象时,它实际上是在改变其自身的导出实例。这是因为两个版本不兼容,您的应用程序因此捆绑了库的两个不同实例。

结尾 

通过本指南,您现在已准备好将您的项目迁移到 Wonderland Engine 1.0.0

如果您遇到任何问题,请通过 Discord 服务器 联系社区。

Last Update: April 14, 2023

保持更新。