从 Unity 迁移
如果你曾在 Unity 中开发项目,PlayCanvas 的许多内容都会让你感到熟悉。PlayCanvas 编辑器同样具有层级面板、检查器、资源面板和 3D 视口。场景是实体构成的树,实体由组件组成,行为通过包含 initialize、update 等生命周期方法的脚本编写。
本指南将你熟悉的 Unity 知识对应到 PlayCanvas:编辑器面板和快捷键、常用概念的名称、C# API 对应的 JavaScript 写法,以及二者工作方式不同之处。各节均提供对应主题的完整文档链接。
主要差异
- **一切都运行在浏览器中。**编辑器是 Web 应用,项目存储在线上,发布的也是 Web 应用。无需等待 WebGL 构建:应用与编辑器视口使用同一个引擎。
- **脚本使用 JavaScript。**你编写的是 JavaScript 模块,而非 C# 类,无需编译,保存脚本后即可运行。
- **多个脚本共用一个组件。**Unity 的每个 MonoBehaviour 都是独立组件;PlayCanvas 的实体只有一个 Script 组件,用于容纳该实体的所有脚本。
- **每个实体同类型组件最多一个。**实体最多具有一个相机、一个灯光、一个碰撞组件等,同一个脚本也只能添加一次。如果需要两个同类对象,例如两个碰撞体,请使用子实体。
- **使用右手坐标系。**前方是 -Z,而非 +Z。参见坐标系与单位。
- **没有 Play Mode。**Launch 会在独立浏览器标签页中运行场景,并保持与编辑器的连接。
- **内置协作和版本控制。**多人可同时编辑同一个场景,检查点、分支和合并都是编辑器内置功能。
编辑器
编辑器布局与 Unity 默认布局非常相似:

| PlayCanvas | Unity | |
|---|---|---|
| 1 | 工具栏 | 主菜单、变换工具和工具栏 |
| 2 | 层级面板 | Hierarchy 窗口 |
| 3 | 视口 | Scene 视图 |
| 4 | 检查器 | Inspector 窗口 |
| 5 | 资源面板 | Project 窗口 |
其他 Unity 窗口和菜单的对应项如下:
| Unity | PlayCanvas |
|---|---|
| Play 按钮和 Game 视图 | 视口右上角的 Launch 按钮 |
| Console 窗口 | 启动标签页中的浏览器开发者工具控制台。编辑器左下角自己的 CONSOLE 面板显示脚本解析错误等编辑器消息。 |
| Project Settings 和 Lighting 窗口 | 工具栏齿轮图标打开的设置 |
| Build Settings | 工具栏中的 Publish / Download |
| Animator 窗口 | 选择动画状态图资源时打开的动画状态图编辑器 |
| Asset Store | 资源面板右上角的 ASSET STORE,提供 PlayCanvas Store 和 Sketchfab 资源 |
| Visual Studio 或 Rider | 内置代码编辑器,或安装了 VS Code 扩展的 VS Code |
| Unity Version Control 或 Git | 内置版本控制 |
使用 Launch 代替 Play Mode
Unity 在编辑器内运行游戏。PlayCanvas 则在新的浏览器标签页,即运行页面中打开游戏,场景的运行方式与发布后相同。点击 Launch,或按 Ctrl+Enter 即可。
运行页面始终与编辑器连接。修改实体、组件、脚本属性、资源或场景设置时,运行中的应用会实时更新。这些修改与 Play Mode 中的修改不同,停止运行后不会丢失:编辑器会即时保存每项修改。可以将两个标签页并排放置。修改代码后通常需要重新加载,除非脚本支持热重载。
脚本不会在编辑器视口中运行,因此没有 Unity 的 [ExecuteInEditMode] 对应功能;脚本创建或修改的内容只会出现在已启动的应用中。
Launch 按钮的菜单提供测试选项:选择图形 API,打开 Profiler、MiniStats 或 Inspector,以及勾选 Debug,将引擎警告和错误输出到控制台。
视口导航
| 操作 | Unity | PlayCanvas |
|---|---|---|
| 环绕 | Alt + 鼠标左键拖动 | 鼠标左键拖动 |
| 环视 | 鼠标右键拖动 | 鼠标右键拖动 |
| 平移 | 鼠标中键拖动 | 鼠标中键拖动,或 Shift + 鼠标左键拖动 |
| 缩放 | 鼠标滚轮 | 鼠标滚轮 |
| 飞行 | 按住鼠标右键,再按 W、A、S、D、Q、E | 直接按 W、A、S、D、Q、E,无需按住鼠标按键;按住 Shift 可加速 |
| 聚焦所选对象 | F | F |
| 框选 | 在空白处按住鼠标左键拖动 | Ctrl + 鼠标左键拖动 |
| 增加或移除所选对象 | Shift+单击或 Ctrl+单击 | Ctrl+单击 |
键盘快捷键
| 操作 | Unity | PlayCanvas |
|---|---|---|
| 移动工具 | W | 1 |
| 旋转工具 | E | 2 |
| 缩放工具 | R | 3 |
| 矩形工具(UI 元素) | T | 4 |
| 切换操作器的局部和世界空间 | X | L |
| 聚焦所选对象 | F | F |
| 运行 | Ctrl+P | Ctrl+Enter(Launch) |
| 创建空子对象 | Alt+Shift+N | Ctrl+E |
| 复制对象 | Ctrl+D | Ctrl+D |
| 重命名 | F2 | F2 |
| 删除 | Delete | Delete |
| 撤销 / 重做 | Ctrl+Z / Ctrl+Y | Ctrl+Z / Ctrl+Y |
| 保存 | Ctrl+S | 无需手动保存,每次修改都会保存。Ctrl+S 会打开创建版本控制检查点的表单。 |
| 最大化视图 | Shift+Space | Space(隐藏层级、检查器和资源面板) |
| 前视图、右视图和顶视图 | 点击 Scene Gizmo 的坐标轴 | 数字键盘 1、3、7(加 Ctrl 查看相反方向),或点击视图立方体的坐标轴 |
| 切换正交和透视 | 点击 Scene Gizmo 中心 | 数字键盘 5 |
在 macOS 上,用 Cmd 代替 Ctrl。Unity 的 View 工具(Q)没有对应项,因为视口导航不需要专门的工具。完整快捷键列表见操作与键盘快捷键。
概念与术语
| Unity | PlayCanvas | 说明 |
|---|---|---|
| GameObject | 实体 | |
| Component | 组件 | 组件作为实体属性访问,例如 entity.camera、entity.light。 |
| Transform | 每个实体自身的一部分 | 实体本身具有位置、旋转和缩放。参见层级与变换。 |
| MonoBehaviour | 脚本 | 通过实体的 Script 组件附加。 |
| Prefab | 模板 | 参见预制体与模板。 |
| Scene | 场景 | |
| Tag | 标签 | 实体可以有任意数量的标签。 |
| Layer | 图层 | 仅用于渲染;物理使用碰撞组与掩码。 |
| Mesh Filter 和 Mesh Renderer | Render 组件 | 蒙皮网格同样使用 Render 组件。 |
| Material | 材质 | |
| Camera | Camera 组件 | |
| Light | Light 组件 | 点光源称为 omni light。 |
| Rigidbody | Rigid Body 组件 | |
| Collider | Collision 组件 | 没有刚体时,碰撞组件充当触发器。参见物理。 |
| Audio Source | Sound 组件 | 一个组件可以播放任意数量的声音,各声音通过槽位组织。 |
| Audio Listener | Audio Listener 组件 | |
| Particle System | Particle System 组件 | |
| Animator 和 Animator Controller | Anim 组件与动画状态图资源 | |
| Canvas | Screen 组件 | |
| RectTransform、Image 和 Text | Element 组件 | |
| Sprite Renderer | Sprite 组件 | |
| ScriptableObject | JSON 资源 | 也可以使用导出数据的 JavaScript 模块。 |
Resources 和 Addressables | 资源注册表 | 参见资源。 |
坐标系与单位
两个引擎都以 Y 轴为上方,距离使用米,角度使用度。区别在于坐标系的手性:Unity 使用左手坐标系,+Z 为前方;PlayCanvas 使用右手坐标系,-Z 为前方。相机朝向自身的负 Z 轴,entity.forward 返回负 Z 轴方向,lookAt 则将负 Z 轴指向目标。
迁移代码和内容时,需要注意以下影响:
- 向前移动就是沿 -Z 移动。
transform.Translate(0, 0, speed * Time.deltaTime)对应this.entity.translateLocal(0, 0, -this.speed * dt)。 - **正角度旋转的方向相反。**绕 Y 轴正向旋转会使实体向左转(从上方看为逆时针),绕 X 轴正向旋转则使实体向上抬头。Unity 中分别为右转和低头,因此在 PlayCanvas 中俯视场景的相机,其 X 轴旋转角度为负。
- **欧拉角的应用顺序不同。**PlayCanvas 先绕 X,再绕 Y,最后绕 Z;Unity 则先绕 Z,再绕 X,最后绕 Y。加上坐标轴方向差异,Unity Inspector 中读取的旋转值不能直接照搬。应通过导出文件迁移布局,让格式转换处理差异,参见迁移项目内容。
- **灯光沿 -Y 照射。**方向光和聚光灯朝向局部负 Y 轴,因此未旋转的灯光垂直向下照射;Unity 中则沿 +Z 照射。
- **模型通常朝向 +Z。**glTF 将 +Z 定义为模型前方,从 Unity 导出的模型仍朝向 +Z,在 PlayCanvas 中这相当于朝后。如果脚本沿
entity.forward移动角色,可将模型放到父实体下,绕 Y 轴旋转 180°,让模型面向父实体的前方。 - **默认视野角度更窄。**两者都使用垂直视野角度,但 PlayCanvas 新相机默认为 45°,而不是 60°。
脚本编程
PlayCanvas 脚本是继承 Script 的 JavaScript 类。每个脚本保存在资源面板中的 .mjs 文件内,为实体添加 Script 组件并选择脚本,即可将其附加到实体。完整指南见脚本编程。
脚本对照示例
下面的 Unity 脚本让所属 GameObject 旋转,并通过空格键切换灯光:
using UnityEngine;
public class Spinner : MonoBehaviour
{
// Degrees per second
[SerializeField] float speed = 90f;
[SerializeField] Light lamp;
void Start()
{
lamp.enabled = false;
}
void Update()
{
transform.Rotate(0f, speed * Time.deltaTime, 0f);
if (Input.GetKeyDown(KeyCode.Space))
{
lamp.enabled = !lamp.enabled;
Debug.Log($"Lamp on: {lamp.enabled}");
}
}
}
下面是对应的 PlayCanvas 脚本,文件名为 spinner.mjs:
import { Entity, KEY_SPACE, Script } from 'playcanvas';
export class Spinner extends Script {
static scriptName = 'spinner';
/**
* Degrees per second.
*
* @attribute
*/
speed = 90;
/**
* @attribute
* @type {Entity}
*/
lamp;
initialize() {
this.lamp.light.enabled = false;
}
update(dt) {
this.entity.rotateLocal(0, this.speed * dt, 0);
if (this.app.keyboard.wasPressed(KEY_SPACE)) {
this.lamp.light.enabled = !this.lamp.light.enabled;
console.log(`Lamp on: ${this.lamp.light.enabled}`);
}
}
}
需要注意的区别:
- **
scriptName指定脚本名称。**编辑器通过该名称列出脚本,其他脚本也通过它访问脚本实例,例如entity.script.spinner。 - **
@attribute代替[SerializeField]。**在 JSDoc 注释中标记了@attribute的类字段会出现在 Inspector 中,注释的第一句话作为字段提示。参见检查器字段。 - 实体属性引用实体,而非组件。
Entity类型的属性保存实体引用,需要通过实体访问组件,例如this.lamp.light。 this.entity同时对应 GameObject 及其变换。this.app代表应用,提供输入设备、场景层级(this.app.root)、资源注册表等功能。- **
dt对应Time.deltaTime。**引擎会将以秒为单位的帧间隔传给update。
生命周期方法
| Unity | PlayCanvas | 说明 |
|---|---|---|
Awake、Start | initialize() | 脚本与其实体首次同时启用时调用一次。不要使用类构造函数进行初始化配置。 |
依赖其他脚本 Awake 的 Start | postInitialize() | 所有脚本的 initialize 执行后调用,此时其他脚本已经就绪。 |
Update | update(dt) | |
LateUpdate | postUpdate(dt) | 所有脚本的 update 执行后调用。 |
FixedUpdate | 无 | 物理引擎自行按固定频率步进。参见物理。 |
OnEnable、OnDisable | enable 和 disable 事件 | 使用 this.on('enable', ...) 订阅。 |
OnDestroy | destroy 事件 | 使用 this.once('destroy', ...) 订阅,并在处理函数中取消其他事件订阅。 |
OnValidate | 属性 setter | 参见响应属性变化。 |
OnCollisionEnter、OnTriggerEnter 等 | 碰撞组件事件 | 参见物理。 |
详细说明见脚本生命周期。
检查器字段
脚本属性相当于 PlayCanvas 中的序列化字段。编辑器从代码中读取它们,因此添加或修改属性后,需要在脚本资源或 Script 组件上点击 Parse,更新 Inspector。
| Unity | PlayCanvas |
|---|---|
[SerializeField] float speed = 5f; | /** @attribute */ speed = 5; |
[Tooltip("...")] | 属性 JSDoc 注释的第一句话 |
[Range(0, 10)] | @range [0, 10] |
[Min(0)] | @range [0] |
[HideInInspector],或没有 [SerializeField] | 不带 @attribute 的字段 |
GameObject 或 Transform 字段 | @type {Entity} |
Texture2D、Material、AudioClip 字段 | @type {Asset},并添加 @resource texture、material 或 audio |
| Prefab 字段 | @type {Asset},并添加 @resource template |
Color、Vector3 字段 | color = new Color()、offset = new Vec3() |
AnimationCurve 字段 | @type {Curve} |
数组或 List<T> 字段 | 数组类型,例如 @type {Entity[]} |
enum 字段 | @enum 对象,或字面量联合类型,例如 @type {'low' | 'high'} |
[Serializable] 类字段 | @interface 类或 @typedef |
编辑器中设置的属性值会在 initialize 运行前应用。各个标签的说明见属性参考。
常用 API 对照
在这些表格中,entity 表示任意实体,例如脚本中的 this.entity;Vec3、KEY_W 等标识符从 playcanvas 导入。
实体与组件
| Unity | PlayCanvas |
|---|---|
new GameObject("Box") | const box = new Entity('Box'); this.app.root.addChild(box); |
Instantiate(original) | const copy = original.clone(); this.app.root.addChild(copy); |
Instantiate(prefab) | const instance = templateAsset.resource.instantiate(); this.app.root.addChild(instance); |
Destroy(gameObject) | entity.destroy() |
gameObject.SetActive(false) | entity.enabled = false |
gameObject.activeInHierarchy | entity.enabled,当祖先实体被禁用时读取为 false |
GetComponent<Light>() | entity.light |
GetComponent<Spinner>() | entity.script.spinner |
GetComponentInChildren<Light>() | entity.findComponent('light') |
GetComponentsInChildren<Light>() | entity.findComponents('light') |
AddComponent<Light>() | entity.addComponent('light', { type: 'omni' }) |
Destroy(GetComponent<Light>()) | entity.removeComponent('light') |
GameObject.Find("Player") | this.app.root.findByName('Player') |
GameObject.FindGameObjectsWithTag("Enemy") | this.app.root.findByTag('enemy') |
transform.Find("Arm/Hand") | entity.findByPath('Arm/Hand') |
CompareTag("Enemy") | entity.tags.has('enemy') |
clone() 会复制实体的组件和子实体,但脚本会从创建时的属性值重新开始。应用运行期间由脚本修改的值不会被复制,而 Unity 的 Instantiate 会复制序列化字段的当前值。
变换
| Unity | PlayCanvas |
|---|---|
transform.position | entity.getPosition(), entity.setPosition(x, y, z) |
transform.localPosition | entity.getLocalPosition(), entity.setLocalPosition(x, y, z) |
transform.rotation | entity.getRotation(), entity.setRotation(quat) |
transform.eulerAngles | entity.getEulerAngles(), entity.setEulerAngles(x, y, z) |
transform.localScale | entity.getLocalScale(), entity.setLocalScale(x, y, z) |
transform.Translate(x, y, z) | entity.translateLocal(x, y, z)(世界空间使用 translate) |
transform.Rotate(x, y, z) | entity.rotateLocal(x, y, z)(世界空间使用 rotate) |
transform.LookAt(target) | entity.lookAt(target.getPosition()) |
transform.forward、right、up | entity.forward、entity.right、entity.up(forward 为 -Z) |
transform.parent | entity.parent |
transform.SetParent(parent) | entity.reparent(parent),保留局部变换,因此实体会相对于新父实体改变位置 |
transform.GetChild(i), childCount | entity.children[i], entity.children.length |
时间、数学与调试
| Unity | PlayCanvas |
|---|---|
Time.deltaTime | update 的 dt 参数 |
Time.timeScale | this.app.timeScale |
Time.time | 无对应项;在 update 中累加 dt。 |
Vector3, Quaternion, Color | Vec3, Quat, Color |
Vector3.up、Vector3.forward | Vec3.UP、Vec3.FORWARD(值为 (0, 0, -1)) |
Quaternion.Euler(x, y, z) | new Quat().setFromEulerAngles(x, y, z) |
rotation * vector | rotation.transformVector(vector, result) |
Vector3.Distance(a, b) | a.distance(b) |
Vector3.Lerp(a, b, t) | result.lerp(a, b, math.clamp(t, 0, 1)),因为 lerp 不会限制 t 的范围 |
Mathf.Clamp, Mathf.Lerp | math.clamp, math.lerp |
Mathf.Deg2Rad | math.DEG_TO_RAD |
浮点数版 Random.Range(min, max) | math.random(min, max) |
整数版 Random.Range(min, max) | Math.floor(math.random(min, max)),因为 math.random 始终返回浮点数 |
Debug.Log, LogWarning, LogError | console.log, console.warn, console.error |
Debug.DrawLine(start, end, color) | this.app.drawLine(start, end, color) |
输入
| Unity | PlayCanvas |
|---|---|
Input.GetKey(KeyCode.W) | this.app.keyboard.isPressed(KEY_W) |
Input.GetKeyDown(KeyCode.W) | this.app.keyboard.wasPressed(KEY_W) |
Input.GetKeyUp(KeyCode.W) | this.app.keyboard.wasReleased(KEY_W) |
Input.GetMouseButton(0) | this.app.mouse.isPressed(MOUSEBUTTON_LEFT) |
Input.mousePosition | 鼠标事件的 x 和 y,从画布左上角而非左下角测量 |
Input.GetAxis("Mouse X") | mousemove 事件的 dx |
Cursor.lockState = CursorLockMode.Locked | this.app.mouse.enablePointerLock() |
Input.touches | this.app.touch 上的触摸事件 |
Physics.Raycast | this.app.systems.rigidbody.raycastFirst(start, end) |
没有内置输入轴或动作映射:直接读取设备,或在自定义脚本中将其映射为动作。浏览器只允许在响应点击或按键时启用指针锁定。
Web 平台
| Unity | PlayCanvas |
|---|---|
PlayerPrefs | 浏览器的 localStorage |
UnityWebRequest | fetch |
JsonUtility | JSON.parse 和 JSON.stringify |
Application.OpenURL | window.open |
向量是对象
Unity 的 Vector3 和 Quaternion 是结构体,赋值时会复制;PlayCanvas 的 Vec3 和 Quat 是类,赋值会共享同一个对象,JavaScript 也没有运算符重载。因此需要养成以下三个习惯:
- 将返回的向量视为只读。
entity.getPosition()等 getter 返回实体自身持有并复用的向量,其值会在实体移动时变化;直接修改它不能正确更新实体。需要保留数值时调用clone(),修改时使用setPosition()等 setter。 - 使用方法代替运算符。
add、sub、mulScalar等方法会修改调用方法的向量,并返回该向量。add2(a, b)等方法将结果写入调用方法的向量中。 - **避免在
update中分配对象。**每帧创建向量会增加垃圾回收负担,可能引起卡顿。应创建一次并重复使用。
import { Script, Vec3 } from 'playcanvas';
export class Mover extends Script {
static scriptName = 'mover';
/** @attribute */
speed = 5;
// Created once, and reused every frame
step = new Vec3();
update(dt) {
// Unity: transform.position += transform.forward * speed * Time.deltaTime;
this.step.copy(this.entity.forward).mulScalar(this.speed * dt);
this.entity.translate(this.step);
}
}
事件与消息
C# 事件、UnityEvent 和 SendMessage 都可对应到 PlayCanvas 事件。应用、实体和脚本均可触发和监听事件:
// Anywhere: fire an event on the application
this.app.fire('player:died', this.entity);
// In another script: listen for it, and stop listening when the script is destroyed
initialize() {
this.app.on('player:died', this.onPlayerDied, this);
this.once('destroy', () => {
this.app.off('player:died', this.onPlayerDied, this);
});
}
脚本销毁时,务必移除添加到其他对象上的监听器,否则它们会继续执行,并让脚本留在内存中。
协程与定时器
PlayCanvas 没有协程或 Invoke。游戏逻辑中的计时可以在 update 中倒计时:这样会遵循 app.timeScale,脚本禁用时暂停,实体销毁时停止。
// Unity: Invoke(nameof(Spawn), 2f);
initialize() {
this.spawnTimer = 2;
}
update(dt) {
if (this.spawnTimer > 0) {
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
this.spawn();
}
}
}
浏览器的 setTimeout、setInterval 和带有 await 的 async 函数适合加载等不依赖游戏时间的工作。但它们按真实时间运行,脚本销毁后仍会继续,因此应在 destroy 处理函数中取消。
执行顺序
同一实体上的脚本按 Script 组件中列出的顺序执行,可通过拖动调整。不要依赖实体之间的执行顺序(Unity 的 Script Execution Order 设置允许这样做),而应使用 postInitialize、postUpdate 或事件。
共享代码与使用包
脚本是 ES 模块,通过 import 和 export 共享代码。模块的顶层变量由所有导入它的脚本共享,因此适合承载 C# 中通常写成静态类和单例的内容。这些变量也会在场景切换后保留。
在编辑器中使用 npm 包时,通过导入映射将包名映射到 CDN URL。不使用编辑器构建的项目,则像其他 Web 项目一样通过 npm 安装包。
预制体与模板
模板相当于 PlayCanvas 的预制体,两者工作方式相近:
| Unity | PlayCanvas |
|---|---|
| 将 GameObject 拖入 Project 窗口 | 在层级面板中右键点击实体,选择 Template > New Template |
| 预制体实例 | 模板实例,在层级面板中用图标标记 |
| 覆盖项 | 覆盖项,在 Inspector 中显示,并提供 View Diff 按钮 |
| Overrides > Apply All / Revert All | 实例根实体 Inspector 中的 Apply All / Revert All |
| 嵌套预制体 | 嵌套模板 |
| Prefab Mode | 在场景中编辑任意实例,然后应用其覆盖项 |
| Unpack Prefab | Template > Unlink From Template |
Instantiate(prefab) | templateAsset.resource.instantiate() |
没有预制体变体;带覆盖项的模板实例是最接近的对应形式。导入模型会为其层级创建模板,类似于 Unity 创建模型预制体。
将覆盖项应用到模板资源后无法撤销。运行时实例化模板前,必须先加载模板资源:使用带有 @resource template 的脚本属性引用它,并将其标记为预加载,或先加载它。
场景
| Unity | PlayCanvas |
|---|---|
SceneManager.LoadScene("Level 2") | this.app.scenes.changeScene('Level 2') |
LoadSceneMode.Additive | this.app.scenes.loadSceneHierarchy('Level 2', callback) |
| 卸载叠加加载的场景 | 销毁 loadSceneHierarchy 传给回调的根实体 |
DontDestroyOnLoad | 无对应项,见下文。 |
| 随各场景保存的光照设置 | 场景设置,由 changeScene 或 loadSceneSettings 应用 |
changeScene 会先销毁应用根节点下的所有实体,包括脚本创建的实体,再添加新场景。若要让实体跨关卡保留,不要切换场景,而应叠加加载各个关卡,并销毁上一关的根实体。仅供脚本使用的数据可保存在模块中,不受场景切换影响。参见加载场景。
编辑器一次只打开一个场景,因此没有 Unity 多场景编辑的对应功能。叠加加载场景由应用在运行时完成。
资源
资源面板像 Project 窗口一样保存文件,但项目位于 PlayCanvas 服务器,而非本地磁盘目录,因此没有 .meta 文件。每个资源都有 ID,以及用于设置导入参数的独立 Inspector。
| Unity | PlayCanvas |
|---|---|
Resources.Load<Texture2D>("logo") | 先调用 this.app.assets.find('logo', 'texture'),再加载资源 |
| Addressables 和 AssetBundles | 不预加载、按需加载的资源,可通过名称或标签查找 |
| ScriptableObject | JSON 资源,其 resource 为解析后的对象 |
| 纹理导入设置 | 纹理检查器,包括纹理压缩 |
| 模型导入设置 | Asset Import 设置 |
标记为 Preload 的资源会在应用启动前下载。新资源默认预加载,除非在 Asset Import 设置中关闭 Assets default to preload。仅部分场景需要的大型资源应取消预加载。
渲染
PlayCanvas 只有一种渲染器,无需选择渲染管线。它使用 WebGPU 或 WebGL 2 渲染,并通过集群光照让包含大量点光源和聚光灯的场景仍保持高效。
材质与着色器
PlayCanvas 材质是基于物理的材质,类似 Unity 的 Standard 着色器和 URP 的 Lit 着色器,支持金属度和高光工作流:
| Unity | PlayCanvas |
|---|---|
| Albedo 或 Base Map | Diffuse(漫反射) |
| Metallic | Metalness(金属度) |
| Smoothness | Glossiness(两者都是值越大表面越光滑) |
| Normal Map | Normal(法线) |
| Occlusion | Ambient Occlusion(环境光遮蔽) |
| Emission | Emissive(自发光) |
| Rendering Mode: Cutout | Alpha Test(透明度测试) |
| Rendering Mode: Transparent 或 Fade | Blend Type(混合类型),结合 Opacity(不透明度) |
| Tiling 和 Offset | Tiling 和 Offset,可统一设置所有贴图,也可分别设置 |
| Shader Graph 和自定义着色器 | 使用 GLSL 和 WGSL 编写的着色器 |
Unity 的 Metallic 贴图将金属度存储在红色通道,将光滑度存储在 alpha 通道。PlayCanvas 中,可将同一纹理指定给 Metalness 贴图并选择 R 通道,同时指定给 Glossiness 贴图并选择 A 通道。请关闭光泽度的 Invert 选项,否则贴图会按粗糙度解释。
光照
| Unity | PlayCanvas |
|---|---|
| 方向光 | Directional light |
| 点光源 | Omni light |
| 聚光灯 | Spot light。Inner 和 Outer Cone Angle 从光源轴线开始测量,因此是 Unity Spot Angle 的一半。 |
| 面光源 | 在 Rendering Settings 中启用 Area Lights Enabled 后,为灯光选择 Rectangle、Disk 或 Sphere Shape |
| Lighting 窗口的环境设置和雾效 | 渲染设置中的天空盒、环境颜色、曝光和雾效 |
| 反射探针 | 为所有材质提供照明的天空盒,以及各材质独立设置的带盒投影的立方体贴图 |
| 光照探针 | 无对应项,使用光照贴图和环境照明。 |
| Progressive Lightmapper | 光照贴图烘焙器,可通过工具栏的 Bake 或在运行中的应用中执行 |
参见光照。
相机与图层
| Unity | PlayCanvas |
|---|---|
| Culling Mask | Layers(图层) |
| Depth 或 URP 相机堆叠 | Priority(值越小越先渲染) |
| Clear Flags | Clear Color Buffer 和 Clear Depth Buffer |
| Viewport Rect | Viewport(视口) |
| Target Texture | 通过脚本设置的 renderTarget |
| Orthographic Size | Ortho Height |
Unity 的 GameObject 只能属于一个图层,图层同时用于渲染、物理和射线检测。PlayCanvas 图层仅用于渲染,决定哪些相机绘制网格以及绘制顺序:每台相机按 Settings > Layers 中的顺序渲染自己列表中的图层,绘制网格的组件可以属于多个图层。物理筛选改用碰撞组与掩码。参见多相机。
后期处理
Unity 的后期处理 Volume 对应 CameraFrame,它为相机添加泛光、环境光遮蔽、景深、抗锯齿、颜色分级、暗角等效果。在编辑器中,为相机实体添加 CameraFrame 脚本。与其他脚本一样,它仅在已启动的应用中运行,因此效果不会出现在编辑器视口中。不使用它时,色调映射和伽马由 Camera 组件属性控制。
性能功能
| Unity | PlayCanvas |
|---|---|
| 静态和动态批处理 | 批处理组 |
| GPU 实例化 | 硬件实例化 |
| 遮挡剔除 | 无对应项。相机会剔除视野外的网格。 |
| LOD Group | 网格没有对应项 |
| Profiler | 参见调试与性能分析 |
物理
PlayCanvas 物理基于 ammo.js,即 Bullet 物理引擎的 WebAssembly 构建。物理需要主动启用:打开 Settings,展开 PHYSICS 并点击 IMPORT AMMO,将其加入项目。各场景的重力也在此设置。参见物理。
| Unity | PlayCanvas |
|---|---|
| Rigidbody | 类型为 Dynamic 的 Rigid Body 组件 |
| 勾选 Is Kinematic 的 Rigidbody | 类型为 Kinematic 的 Rigid Body |
| 不带 Rigidbody 的 Collider | Collision 组件加上类型为 Static 的 Rigid Body |
| 勾选 Is Trigger 的 Collider | 不带 Rigid Body 的 Collision 组件 |
AddForce(force) | entity.rigidbody.applyForce(force) |
AddForce(force, ForceMode.Impulse) | entity.rigidbody.applyImpulse(force) |
AddTorque | applyTorque, applyTorqueImpulse |
velocity(Unity 6 中为 linearVelocity) | linearVelocity |
| 设置动态刚体的位置 | entity.rigidbody.teleport(position) |
运动学刚体的 MovePosition | 设置实体位置:运动学刚体会跟随所属实体 |
| Constraints(冻结位置或旋转) | Linear Factor 和 Angular Factor |
| Physic Material | Rigid Body 上的 Friction 和 Restitution |
OnCollisionEnter、OnCollisionStay、OnCollisionExit | 碰撞组件上的 collisionstart、contact 和 collisionend 事件 |
OnTriggerEnter、OnTriggerExit | 触发器碰撞组件和进入它的刚体上的 triggerenter、triggerleave 事件 |
| 图层碰撞矩阵 | 通过脚本设置的碰撞组与掩码 |
Physics.Raycast | raycastFirst 和 raycastAll |
| Character Controller | 无对应项,通过脚本驱动动态刚体。 |
以下差异最容易让 Unity 开发者遇到问题:
- **静态碰撞体也需要刚体。**单独的碰撞组件是触发体积,只检测刚体,不阻挡运动。地板和墙壁需要类型为 Static 的 Rigid Body 组件。
- **基本几何碰撞形状忽略缩放。**无论怎样缩放实体,盒体、球体或胶囊体仍使用设置的 Half Extents 或 Radius。网格形状例外。参见缩放。
- **没有
FixedUpdate。**模拟按每秒 60 次的固定频率步进,在update中施加力。碰撞事件每个物理步触发一次。 - **动态刚体由物理系统控制。**每步之后,引擎都会将动态刚体的位置和旋转写回实体。因此应通过力、速度或
teleport移动动态刚体,而非直接设置实体变换。
用户界面
PlayCanvas 的画布内 UI 与 Unity UI(uGUI)的工作方式相似。参见用户界面。
| Unity | PlayCanvas |
|---|---|
| Screen Space - Overlay 模式的 Canvas | 启用 Screen Space 的 Screen 组件 |
| World Space 模式的 Canvas | 禁用 Screen Space 的 Screen 组件 |
| Canvas Scaler: Scale With Screen Size | Scale Mode 为 Blend,并配置 Ref Resolution 和 Scale Blend |
| RectTransform | Element 组件的锚点、轴心和边距 |
| Image、Raw Image | 类型为 Image 的 Element,显示纹理或精灵 |
| Text、TextMeshPro | 类型为 Text 的 Element,使用从 TTF 或 OTF 文件创建的字体资源 |
| Button | Button 组件 |
| Horizontal、Vertical 和 Grid Layout Group | Layout Group 组件 |
| Layout Element | Layout Child 组件 |
| Scroll Rect、Scrollbar | Scroll View 和 Scrollbar 组件 |
| Mask、Rect Mask 2D | 启用 Mask 的元素。参见遮罩。 |
| EventSystem 和 Graphic Raycaster | 在需要接收输入的元素上启用 Use Input |
| Toggle、Slider、Dropdown | 通过元素组合实现,参见常用控件。 |
| UI Toolkit | HTML 和 CSS |
应用运行在网页中,因此也可以用 HTML 和 CSS 构建任意界面部分,叠加在画布上。文本输入框和无障碍菜单通常更适合用这种方式实现。
动画
| Unity | PlayCanvas |
|---|---|
| Animator 组件 | Anim 组件 |
| Animator Controller | 动画状态图资源,包含状态、过渡、参数和图层 |
| Animation Clip | 导入模型时生成的动画资源 |
animator.SetFloat("Speed", 2f) | entity.anim.setFloat('Speed', 2) |
SetBool, SetInteger, SetTrigger | setBoolean, setInteger, setTrigger |
| Avatar Mask | 图层遮罩 |
| Blend Tree | 通过代码创建的状态图支持混合树(1D、2D 和直接混合);编辑器的状态图编辑器不提供此功能。 |
| Root Motion | 无对应项,通过脚本移动实体。 |
| Animation Events | 动画资源上的动画事件 |
| Animator Override Controller | 使用同一个动画状态图,在不同实体上为状态指定不同动画资源 |
PlayCanvas 没有人形动画重定向。动画按名称驱动骨骼,因此可以应用到骨骼名称相同的模型上,但不会自动适配骨架比例。应针对实际播放动画的骨架制作或烘焙动画。
音频
| Unity | PlayCanvas |
|---|---|
| Audio Source | Sound 组件中的槽位 |
audioSource.Play() | entity.sound.play('slot name') |
| Spatial Blend 设为 3D | Sound 组件上的 Positional |
| Min Distance 和 Max Distance | Ref Distance 和 Max Distance |
| Volume Rolloff | Distance Model,默认 Linear,而 Unity 默认为 Logarithmic |
| Audio Listener | Audio Listener 组件 |
| Audio Mixer | 无对应项,可使用 Web Audio API。 |
浏览器通常要求用户先与页面交互,例如点击或按键,才允许播放声音,因此应在用户首次点击后启动音乐和其他声音。
迁移项目内容
没有自动转换 Unity 项目的工具。资源通过标准文件格式迁移,代码则需要重写。
- **模型和动画。**使用 UnityGLTF 或 Unity glTFast 等 glTF 导出器导出 GLB,或使用 Unity FBX Exporter 导出 FBX。编辑器导入的是 glTF 的二进制
.glb格式,而非.gltf。将文件拖入资源面板:FBX 会转换为 GLB,每个模型会生成渲染、材质、纹理和动画资源,以及表示模型层级的模板。参见导入 3D 模型。 - **材质。**Unity 着色器无法直接迁移。glTF 导出器会将 Standard 和 URP Lit 材质转换为 glTF PBR 材质,导入后成为 PlayCanvas 材质。FBX 保留的信息较少,通常需要重新配置材质。
- **纹理、音频和字体。**上传源文件即可。参见支持的格式。
- **场景和预制体。**将场景的静态几何体导出为单个 GLB,保留布局,然后在编辑器中添加灯光、相机、物理、UI 和脚本。
- **地形。**导出前将地形转换为网格。
- **脚本和着色器。**分别用 JavaScript,以及 GLSL 或 WGSL 重写。AI 编程助手比较擅长转换 MonoBehaviour。若希望它处理编辑器项目的脚本,可通过 VS Code 扩展同步脚本,再通过 MCP 服务器连接编辑器,使其也能修改场景。
- **光照贴图。**通过 PlayCanvas 光照贴图烘焙器重新烘焙,或在 3D 建模工具中烘焙后随模型导入。
发布
Unity 为各个平台构建播放器;PlayCanvas 发布由 HTML、JavaScript 和资源组成的 Web 应用:
- Publish 将构建托管在 PlayCanvas 上,并生成可分享的 URL。参见 PlayCanvas 托管。
- Download 创建 ZIP 文件,供你自行托管。
- 若要发布到应用商店,可以为 Web 应用封装移动端或桌面端外壳,也可以发布为试玩广告。
调试与性能分析
应用运行在浏览器中,因此请在已启动的标签页中使用浏览器开发者工具调试,快捷键为 F12,macOS 上为 Cmd+Option+I。
| Unity | PlayCanvas |
|---|---|
| Console 窗口 | 开发者工具的 Console 标签页。参见控制台日志。 |
| 附加 Visual Studio 或 Rider | 开发者工具 Sources 标签页中的断点。参见浏览器开发者工具。 |
Debug.Break() | debugger; 语句,在开发者工具打开时暂停应用 |
| Profiler | Launch 菜单中的 Profiler 和 MiniStats,以及开发者工具的 Performance 标签页 |
| Frame Debugger | Launch 菜单中的 Inspector,用于查看每帧的渲染通道和绘制调用;也可使用原生 GPU 性能分析器 |
Debug.DrawLine 和 Gizmos | this.app.drawLine(),以及用于球体、盒体等形状的 WireRenderer |
版本控制与协作
Unity 项目是放在版本控制下的本地目录,PlayCanvas 项目则在线共享:团队成员打开场景后,可以实时共同编辑,即时看到彼此的修改。历史记录通过编辑器版本控制保存:像提交代码一样创建检查点,在分支上工作,并通过带冲突解决的合并整合修改。
没有内置对应项的功能
| Unity | PlayCanvas 中的替代方式 |
|---|---|
| Terrain | 将地形作为网格导入 |
| NavMesh 和 AI Navigation | 使用支持 PlayCanvas 的库,例如 recast-navigation-js |
| Character Controller | 通过脚本驱动动态刚体,或使用引擎的第一人称和第三人称控制器脚本 |
| 2D 物理 | 使用 3D 物理,通过 Linear Factor 和 Angular Factor 锁定第三个轴 |
| Timeline 和 Cinemachine | 使用脚本,通过曲线属性控制动画数值 |
| VFX Graph | Particle System 组件 |
| 光照探针和实时全局光照 | 光照贴图与环境照明 |
| DOTween 等补间动画包 | 从 CDN 导入 JavaScript 补间动画库 |