diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx
index 2453b0531d..40e5b3c5e8 100644
--- a/docs/en/core/clone.mdx
+++ b/docs/en/core/clone.mdx
@@ -8,24 +8,28 @@ label: Core
Node cloning is a common runtime feature, and node cloning also includes cloning its bound components. For example, during the initialization phase, dynamically create a certain number of identical entities based on configuration, and then place them in different positions in the scene according to logical rules. Here, the details of script cloning will be explained in detail.
## Entity Cloning
+
It's very simple, just call the entity's [clone()](/apis/design/#IClone-clone) method to complete the cloning of the entity and its attached components.
+
```typescript
const cloneEntity = entity.clone();
```
## Script Cloning
-Scripts are essentially components, so when we call the entity's [clone()](/apis/design/#IClone-clone) function, the engine will not only clone the built-in components but also clone custom scripts. The cloning rules for built-in components have been customized by the official team, and similarly, we have also opened up the cloning capabilities and rules for scripts to developers. The default cloning method for script fields is shallow copy. For example, if we modify the field values of the script and then clone it, the cloned script will retain the modified values without any additional coding. Below is an example of custom script cloning:
+
+Scripts are essentially components, so when we call the entity's [clone()](/apis/design/#IClone-clone) function, the engine will not only clone the built-in components but also clone custom scripts. The cloning rules for built-in components have been customized by the official team, and similarly, we have also opened up the cloning capabilities and rules for scripts to developers. Script fields are cloned automatically, and how each field value is cloned is resolved by the value's type (see the default rules below). For example, if we modify the field values of the script and then clone it, the cloned script will retain the modified values without any additional coding. Below is an example of custom script cloning:
+
```typescript
// define a custom script
-class CustomScript extends Script{
+class CustomScript extends Script {
/** boolean type.*/
- a:boolean = false;
-
+ a: boolean = false;
+
/** number type.*/
- b:number = 1;
-
+ b: number = 1;
+
/** class type.*/
- c:Vector3 = new Vector3(0,0,0);
+ c: Vector3 = new Vector3(0, 0, 0);
}
// Init entity and script
@@ -33,44 +37,99 @@ const entity = engine.createEntity();
const script = entity.addComponent(CustomScript);
script.a = true;
script.b = 2;
-script.c.set(1,1,1);
+script.c.set(1, 1, 1);
// Clone logic
const cloneEntity = entity.clone();
const cloneScript = cloneEntity.getComponent(CustomScript);
console.log(cloneScript.a); // output is true.
console.log(cloneScript.b); // output is 2.
-console.log(cloneScript.c); // output is (1,1,1).
+console.log(cloneScript.c); // output is (1,1,1), and it is an independent copy — Vector3 is a math value type and is deep cloned by default.
```
+
+### Default Cloning Rules
+
+When a field has no clone decorator, the engine resolves how to clone its value by the value's type:
+
+| Value Type | Default Cloning Behavior |
+| :-- | :-- |
+| Primitive types (`number`, `string`, `boolean`, ...) | Copy the value. |
+| Assets (`Texture`, `Mesh`, `Material`, `Sprite`, `Font` and other `ReferResource` types) | Share the reference — the clone uses the same asset as the source. |
+| `Entity` / `Component` references | Automatically remapped to the corresponding clone within the cloned subtree; references pointing outside the subtree keep the original reference. |
+| Math value types (`Vector2`, `Vector3`, `Vector4`, `Quaternion`, `Matrix`, `Color`, ...) and other value-semantic data | Deep cloned — the clone gets a fully independent copy. |
+| Containers (`Array`, `Map`, `Set`, TypedArray, `DataView`, plain objects) | Deep cloned — a fresh container is created, and each member (for `Map`, keys included) is cloned again according to its own type semantics. |
+| Runtime containers (internal transient state such as `UpdateFlagManager`) | Ignored — the clone keeps its own constructor-built value. |
+| Function values | The clone keeps its own constructor-built function; if it has none, the source's function is shared. |
+| Other objects | Share the reference (assignment). |
+
+Deep-by-default behavior is explicit rather than inferred from method names. A custom data class can opt in by extending [DataObject](/apis/core/#DataObject), whose own enumerable string-keyed properties are cloned field by field. Engine math value types are registered internally and copied through their `copyFrom` implementations; merely adding a method named `copyFrom` does not opt a custom class in. A type cloned this way must support argument-less construction when no compatible preset exists: the engine creates it with `new Type()` before populating it, and throws if bare construction fails.
+
+So without any decorator, plain data objects and arrays get independent deep copies, assets remain shared, and entity references are automatically remapped:
+
+```typescript
+// define a custom script
+class CustomScript extends Script {
+ /** Entity reference.*/
+ target: Entity;
+
+ /** Plain data object.*/
+ config = { speed: 1, offsets: [0, 1, 2] };
+
+ /** Asset.*/
+ texture: Texture2D;
+}
+
+// Init entity and script
+const entity = engine.createEntity();
+const child = entity.createChild("child");
+const script = entity.addComponent(CustomScript);
+script.target = child;
+script.texture = new Texture2D(engine, 128, 128);
+
+// Clone logic
+const cloneEntity = entity.clone();
+const cloneScript = cloneEntity.getComponent(CustomScript);
+console.log(cloneScript.target === cloneEntity.findByName("child")); // output is true, entity references are remapped to the corresponding clone in the cloned subtree.
+console.log(cloneScript.config === script.config); // output is false, plain data objects are deep cloned into independent copies.
+console.log(cloneScript.texture === script.texture); // output is true, assets are shared between the source and the clone.
+```
+
### Clone Decorators
-In addition to the default cloning method, the engine also provides "clone decorators" to customize the cloning method for script fields. The engine has four built-in clone decorators:
+
+In addition to the default type-driven rules, the engine also provides "clone decorators" to customize the cloning method for script fields. Field decorators have the highest priority — they override the value type's default cloning behavior and apply wherever the clone walks fields: at the component's top level and inside any object that is itself deep cloned (no field walk happens inside a value that stays shared, so no decorator is consulted there). The engine has three built-in clone decorators:
| Decorator Name | Decorator Description |
-| :--- | :--- |
-| [ignoreClone](/apis/core/#ignoreClone) | Ignore the field during cloning. |
-| [assignmentClone](/apis/core/#assignmentClone) | (Default value, equivalent to not adding any clone decorator) Assign the field during cloning. If it is a basic type, the value will be copied; if it is a reference type, the reference address will be copied. |
-| [shallowClone](/apis/core/#shallowClone) | Shallow clone the field during cloning. After cloning, it will maintain its own independent reference and clone all its internal fields by assignment (if the internal field is a basic type, the value will be copied; if the internal field is a reference type, the reference address will be copied). |
-| [deepClone](/apis/core/#deepClone) | Deep clone the field during cloning. After cloning, it will maintain its own independent reference, and all its internal deep fields will remain completely independent. |
+| :-- | :-- |
+| [ignoreClone](/apis/core/#ignoreClone) | Ignore the field during cloning; the clone keeps its own constructor-built value. |
+| [assignmentClone](/apis/core/#assignmentClone) | Assign the field during cloning. If it is a basic type, the value will be copied; if it is a reference type, the clone will share the reference with the source. |
+| [deepClone](/apis/core/#deepClone) | Deep clone supported values throughout the field's subtree. The deep intent carries into field-cloneable members, while assets stay shared, entity references remap, runtime state keeps the clone's own, and unregistered opaque members keep their default assignment behavior. If the decorated value itself cannot honor a deep clone — for example an `Entity` / `Component`, asset, function, runtime-state object, or opaque platform object — cloning throws. |
+
+
+ `@shallowClone` has been removed. Its old semantics — copy the container itself but share its members — no longer
+ exist. Migrate by intent: use `@deepClone` if the field should be independent, or `@assignmentClone` if it should be
+ shared.
+
+
+We slightly modify the above example and add different "clone decorators" to the four fields in `CustomScript`. Since arrays are already deep cloned by default, we use `assignmentClone` on field `c` to force sharing, and `deepClone` on field `d` to make the default behavior explicit, with additional print output for further explanation.
-We slightly modify the above example and add different "clone decorators" to the four fields in `CustomScript`. Since `shallowClone` and `deepClone` are more complex, we add additional print output to the fields `c` and `d` for further explanation.
```typescript
// define a custom script
-class CustomScript extends Script{
+class CustomScript extends Script {
/** boolean type.*/
@ignoreClone
- a:boolean = false;
-
+ a: boolean = false;
+
/** number type.*/
@assignmentClone
- b:number = 1;
-
+ b: number = 1;
+
/** class type.*/
- @shallowClone
- c:Vector3[] = [new Vector3(0,0,0)];
-
+ @assignmentClone
+ c: Vector3[] = [new Vector3(0, 0, 0)];
+
/** class type.*/
@deepClone
- d:Vector3[] = [new Vector3(0,0,0)];
+ d: Vector3[] = [new Vector3(0, 0, 0)];
}
// Init entity and script
@@ -78,26 +137,32 @@ const entity = engine.createEntity();
const script = entity.addComponent(CustomScript);
script.a = true;
script.b = 2;
-script.c[0].set(1,1,1);
-script.d[0].set(1,1,1);
+script.c[0].set(1, 1, 1);
+script.d[0].set(1, 1, 1);
// Clone logic
const cloneEntity = entity.clone();
const cloneScript = cloneEntity.getComponent(CustomScript);
-console.log(cloneScript.a); // output is false,ignoreClone will ignore the value.
+console.log(cloneScript.a); // output is false,ignoreClone keeps the clone's own constructor-built value.
console.log(cloneScript.b); // output is 2,assignmentClone is just assignment the origin value.
-console.log(cloneScript.c[0]); // output is Vector3(1,1,1),shallowClone clone the array shell,but use the same element.
+console.log(cloneScript.c[0]); // output is Vector3(1,1,1),assignmentClone shares the same array instance with the source.
console.log(cloneScript.d[0]); // output is Vector3(1,1,1),deepClone clone the array shell and also clone the element.
-cloneScript.c[0].set(2,2,2); // change the field c[0] value to (2,2,2).
-cloneScript.d[0].set(2,2,2); // change the field d[0] value to (2,2,2).
+cloneScript.c[0].set(2, 2, 2); // change the field c[0] value to (2,2,2).
+cloneScript.d[0].set(2, 2, 2); // change the field d[0] value to (2,2,2).
-console.log(script.c[0]); // output is (2,2,2). bacause shallowClone let c[0] use the same reference with cloneScript's c[0].
-console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use the different reference with cloneScript's d[0].
+console.log(script.c[0]); // output is (2,2,2), because assignmentClone lets c use the same array reference as cloneScript.c.
+console.log(script.d[0]); // output is (1,1,1), because deepClone gives d[0] a different reference from cloneScript.d[0].
```
+
- Note:
- - `shallowClone` and `deepClone` are usually used for *Object*, *Array*, and *Class* types.
- - `shallowClone` will maintain its own independent reference after cloning and clone all its internal fields by assignment (if the internal field is a basic type, the value will be copied; if the internal field is a reference type, the reference address will be copied).
- - `deepClone` is a deep clone that will recursively clone the properties deeply. How the sub-properties of the properties are cloned depends on the decorators of the sub-properties.
- - If the clone decorators do not meet the requirements, you can implement the [_cloneTo()](/apis/design/#IClone-cloneTo) method to add custom cloning.
+ - Field decorators have the highest priority and also apply to the fields of nested objects, wherever the clone walks fields.
+ - `deepClone` propagates through containers and field-cloneable members. An ordinary class instance without a deep default is copied by its fields and must support argument-less construction. Assets stay shared, entity references are remapped, runtime state keeps the clone's own, unregistered opaque members stay shared, and nested field decorators still win.
+ - Field cloning has a deliberately narrow contract: only an object's own enumerable string-keyed properties carry structural clone semantics. Private fields, non-enumerable properties, Symbol keys, and prototype/accessor state are not reconstructed. The engine does not attempt to recognize every possible form of opaque runtime state.
+ - `deepClone` cannot directly clone engine-bound or opaque values: `@deepClone` on an `Entity` / `Component` reference, an asset, engine runtime state, a function, or an unregistered platform object throws because the explicit intent can't be honored. Remove the decorator to fall back to the default, or use the value's own clone API for a real copy.
+ - If the clone decorators do not meet the requirements, implement [ICloneHook.\_onClone()](/apis/core/#ICloneHook-_onClone) to append type-specific behavior after automatic field cloning.
+
+## Cloning and Asset Reference Counting
+
+When a component's top-level field shares a ref-counted asset (a `ReferResource` such as `Texture`, `Mesh`, or `Material`) during cloning, the engine automatically adds one reference for the clone, so destroying the source never pulls the asset out from under it. Built-in components release their references when destroyed. An asset referenced by custom script fields is kept alive by its clones — destroy the asset itself via its `destroy()` when it is no longer needed.
diff --git a/docs/en/how-to-contribute.mdx b/docs/en/how-to-contribute.mdx
index 988d96a68e..b5f5e6319a 100644
--- a/docs/en/how-to-contribute.mdx
+++ b/docs/en/how-to-contribute.mdx
@@ -120,7 +120,7 @@ class CustomScript extends Script {
a:boolean = false;
@assignmentClone
b:number = 1;
- @shallowClone
+ @deepClone
c:Vector3[] = [new Vector3(0,0,0)];
}
```
@@ -133,7 +133,7 @@ class CustomScript extends Script{
a:boolean = false;
@assignmentClone
b:number = 1;
- @shallowClone
+ @deepClone
c:Vector3[] = [new Vector3(0,0,0)];
}
diff --git a/docs/zh/core/clone.mdx b/docs/zh/core/clone.mdx
index 4cf66c382d..aaa27f5c4b 100644
--- a/docs/zh/core/clone.mdx
+++ b/docs/zh/core/clone.mdx
@@ -5,28 +5,31 @@ type: 核心
label: Core
---
-
节点克隆是运行时的常用功能,同时节点克隆也会附带克隆其绑定的组件。例如在初始化阶段根据配置动态创建一定数量相同的实体,然后根据逻辑规则摆放到场景不同的位置。这里会对脚本的克隆细节进行详细讲解。
## 实体的克隆
-非常简单,直接调用实体的 [clone()](/apis/design/#IClone-clone) 方法即可完成实体以及附属组件的克隆。
+
+非常简单,直接调用实体的 [clone()](/apis/design/#IClone-clone) 方法即可完成实体以及附属组件的克隆。
+
```typescript
const cloneEntity = entity.clone();
```
## 脚本的克隆
-脚本的本质也是组件,所以当我们调用实体的 [clone()](/apis/design/#IClone-clone) 函数时,引擎不仅会对引擎内置组件进行克隆,还会对自定义脚本进行克隆。引擎内置组件的克隆规则官方已经完成定制,同样我们也将脚本的克隆能力和规则定制开放给了开发者。脚本字段默认的克隆方式为浅拷贝,例如我们对脚本的字段值进行修改后再克隆,克隆后的脚本将保持修改后的值,无需增加任何额外的编码。以下为自定义脚本的克隆案例:
+
+脚本的本质也是组件,所以当我们调用实体的 [clone()](/apis/design/#IClone-clone) 函数时,引擎不仅会对引擎内置组件进行克隆,还会对自定义脚本进行克隆。引擎内置组件的克隆规则官方已经完成定制,同样我们也将脚本的克隆能力和规则定制开放给了开发者。脚本字段会被自动克隆,每个字段值采用哪种克隆方式由值的类型决定(见下方默认克隆规则)。例如我们对脚本的字段值进行修改后再克隆,克隆后的脚本将保持修改后的值,无需增加任何额外的编码。以下为自定义脚本的克隆案例:
+
```typescript
// define a custom script
-class CustomScript extends Script{
+class CustomScript extends Script {
/** boolean type.*/
- a:boolean = false;
-
+ a: boolean = false;
+
/** number type.*/
- b:number = 1;
-
+ b: number = 1;
+
/** class type.*/
- c:Vector3 = new Vector3(0,0,0);
+ c: Vector3 = new Vector3(0, 0, 0);
}
// Init entity and script
@@ -34,44 +37,98 @@ const entity = engine.createEntity();
const script = entity.addComponent(CustomScript);
script.a = true;
script.b = 2;
-script.c.set(1,1,1);
+script.c.set(1, 1, 1);
// Clone logic
const cloneEntity = entity.clone();
const cloneScript = cloneEntity.getComponent(CustomScript);
console.log(cloneScript.a); // output is true.
console.log(cloneScript.b); // output is 2.
-console.log(cloneScript.c); // output is (1,1,1).
+console.log(cloneScript.c); // output is (1,1,1),且是一份独立拷贝 —— Vector3 属于数学值类型,默认深克隆。
```
+
+### 默认克隆规则
+
+当字段没有添加任何克隆装饰器时,引擎会根据字段值的类型决定克隆方式:
+
+| 值类型 | 默认克隆行为 |
+| :-- | :-- |
+| 基本类型(`number`、`string`、`boolean` 等) | 拷贝值。 |
+| 资产(`Texture`、`Mesh`、`Material`、`Sprite`、`Font` 等 `ReferResource` 类型) | 共享引用 —— 克隆体与源使用同一份资产。 |
+| `Entity` / `Component` 引用 | 自动重映射到克隆子树内对应的克隆对象;指向子树外的引用保持原引用不变。 |
+| 数学值类型(`Vector2`、`Vector3`、`Vector4`、`Quaternion`、`Matrix`、`Color` 等)及其他值语义数据 | 深克隆 —— 克隆体获得完全独立的拷贝。 |
+| 容器(`Array`、`Map`、`Set`、TypedArray、`DataView`、普通对象) | 深克隆 —— 创建全新的容器,每个成员(`Map` 连键一起)再按各自的类型语义继续克隆。 |
+| 运行时容器(`UpdateFlagManager` 等内部瞬态状态) | 忽略 —— 克隆体保留自身构造时的值。 |
+| 函数值 | 克隆体保留自身构造时的函数;没有则共享源的函数。 |
+| 其他对象 | 共享引用(赋值)。 |
+
+“默认深克隆”是一项显式能力,不会根据方法名推断。自定义数据类可通过继承 [DataObject](/apis/core/#DataObject) 加入:引擎会逐字段克隆其自身可枚举的字符串键属性。引擎数学值类型则由内部显式注册,并通过各自的 `copyFrom` 实现复制;自定义类仅仅声明一个名为 `copyFrom` 的方法并不会自动加入。此类对象在没有兼容预置实例时必须支持无参构造:引擎会先通过 `new Type()` 创建实例再填充,裸构造失败会直接报错。
+
+因此在不加装饰器的情况下,普通数据对象和数组默认会得到独立的深拷贝,资产依旧共享,实体引用则自动重映射:
+
+```typescript
+// define a custom script
+class CustomScript extends Script {
+ /** Entity reference.*/
+ target: Entity;
+
+ /** Plain data object.*/
+ config = { speed: 1, offsets: [0, 1, 2] };
+
+ /** Asset.*/
+ texture: Texture2D;
+}
+
+// Init entity and script
+const entity = engine.createEntity();
+const child = entity.createChild("child");
+const script = entity.addComponent(CustomScript);
+script.target = child;
+script.texture = new Texture2D(engine, 128, 128);
+
+// Clone logic
+const cloneEntity = entity.clone();
+const cloneScript = cloneEntity.getComponent(CustomScript);
+console.log(cloneScript.target === cloneEntity.findByName("child")); // output is true,实体引用被重映射到克隆子树内对应的克隆对象。
+console.log(cloneScript.config === script.config); // output is false,普通数据对象被深克隆为独立拷贝。
+console.log(cloneScript.texture === script.texture); // output is true,资产在源与克隆体之间共享。
+```
+
### 克隆装饰器
-除了默认的克隆方式外,引擎还提供了“克隆装饰器“对脚本字段的克隆方式进行定制。引擎内置四种克隆装饰:
+
+除了默认的类型驱动规则外,引擎还提供了“克隆装饰器“对脚本字段的克隆方式进行定制。字段装饰器优先级最高 —— 会覆盖值类型的默认克隆行为,并在克隆走字段遍历的任何地方生效:组件顶层,以及任何本身被深克隆的对象内部(默认共享的值不会被逐字段遍历,其内部的装饰器也就不会被查询)。引擎内置三种克隆装饰:
| 装饰器名称 | 装饰器释义 |
-| :--- | :--- |
-| [ignoreClone](/apis/core/#ignoreClone) | 克隆时对字段进行忽略。 |
-| [assignmentClone](/apis/core/#assignmentClone) | ( 默认值,和不添加任何克隆装饰器等效) 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型则会拷贝其引用地址。 |
-| [shallowClone](/apis/core/#shallowClone) | 克隆时对字段进行浅克隆。克隆后会保持自身引用独立,并使用赋值的方式克隆其内部所有字段(如果内部字段是基本类型则会拷贝值,如果内部字段是引用类型则会拷贝其引用地址)。|
-| [deepClone](/apis/core/#deepClone) | 克隆时对字段进行深克隆。克隆后会保持自身引用独立,并且其内部所有深层字段均保持完全独立。|
+| :-- | :-- |
+| [ignoreClone](/apis/core/#ignoreClone) | 克隆时对字段进行忽略,克隆体保留自身构造时的值。 |
+| [assignmentClone](/apis/core/#assignmentClone) | 克隆时对字段进行赋值。如果是基本类型则会拷贝值,如果是引用类型,克隆体将与源共享同一个引用。 |
+| [deepClone](/apis/core/#deepClone) | 深克隆字段子树中支持深克隆的值。深意图会传播进可按字段克隆的成员;资产保持共享、实体引用重映射、运行时状态保留克隆体自己的,未注册的不透明成员保持默认赋值行为。如果被装饰的值本身无法满足深克隆,例如 `Entity` / `Component`、资产、函数、运行时状态对象或不透明的平台对象,克隆会直接报错。 |
+
+
+ `@shallowClone` 已被移除。它旧有的“拷贝容器本身、共享内部成员”语义已不复存在。请按意图迁移:希望字段独立就使用
+ `@deepClone`,希望共享就使用 `@assignmentClone`。
+
+
+我们将上面的案例稍加修改,分别对 `CustomScript` 中的四个字段添加了不同的“克隆装饰器“。由于数组默认就会被深克隆,我们对字段 `c` 使用 `assignmentClone` 来强制共享,对字段 `d` 使用 `deepClone` 显式声明默认行为,并增加了额外的打印输出进行进一步讲解。
-我们将上面的案例稍加修改,分别对 `CustomScript` 中的四个字段添加了不同的“克隆装饰器“。由于 `shallowClone` 和 `deepCone` 较复杂,我们对字段 `c` 和 `d` 增加了额外的打印输出进行进一步讲解。
```typescript
// define a custom script
-class CustomScript extends Script{
+class CustomScript extends Script {
/** boolean type.*/
@ignoreClone
- a:boolean = false;
-
+ a: boolean = false;
+
/** number type.*/
@assignmentClone
- b:number = 1;
-
+ b: number = 1;
+
/** class type.*/
- @shallowClone
- c:Vector3[] = [new Vector3(0,0,0)];
-
+ @assignmentClone
+ c: Vector3[] = [new Vector3(0, 0, 0)];
+
/** class type.*/
@deepClone
- d:Vector3[] = [new Vector3(0,0,0)];
+ d: Vector3[] = [new Vector3(0, 0, 0)];
}
// Init entity and script
@@ -79,27 +136,32 @@ const entity = engine.createEntity();
const script = entity.addComponent(CustomScript);
script.a = true;
script.b = 2;
-script.c[0].set(1,1,1);
-script.d[0].set(1,1,1);
+script.c[0].set(1, 1, 1);
+script.d[0].set(1, 1, 1);
// Clone logic
const cloneEntity = entity.clone();
const cloneScript = cloneEntity.getComponent(CustomScript);
-console.log(cloneScript.a); // output is false,ignoreClone will ignore the value.
+console.log(cloneScript.a); // output is false,ignoreClone 使克隆体保留自身构造时的值。
console.log(cloneScript.b); // output is 2,assignmentClone is just assignment the origin value.
-console.log(cloneScript.c[0]); // output is Vector3(1,1,1),shallowClone clone the array shell,but use the same element.
+console.log(cloneScript.c[0]); // output is Vector3(1,1,1),assignmentClone 与源共享同一个数组实例。
console.log(cloneScript.d[0]); // output is Vector3(1,1,1),deepClone clone the array shell and also clone the element.
-cloneScript.c[0].set(2,2,2); // change the field c[0] value to (2,2,2).
-cloneScript.d[0].set(2,2,2); // change the field d[0] value to (2,2,2).
+cloneScript.c[0].set(2, 2, 2); // change the field c[0] value to (2,2,2).
+cloneScript.d[0].set(2, 2, 2); // change the field d[0] value to (2,2,2).
-console.log(script.c[0]); // output is (2,2,2). bacause shallowClone let c[0] use the same reference with cloneScript's c[0].
-console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use the different reference with cloneScript's d[0].
+console.log(script.c[0]); // output is (2,2,2),因为 assignmentClone 让 c 与 cloneScript.c 使用同一个数组引用。
+console.log(script.d[0]); // output is (1,1,1),因为 deepClone 让 d[0] 与 cloneScript.d[0] 使用不同引用。
```
-- 注意:
- - `shallowClone` 和 `deepClone` 通常用于 *Object*、*Array* 和 *Class* 类型。
- - `shallowClone` 克隆后会保持自身引用独立,并使用赋值的方式克隆其内部所有字段(如果内部字段是基本类型则会拷贝值,如果内部字段是引用类型则会拷贝其引用地址)。
- - `deepClone` 为深克隆,会对属性进行深度递归,至于属性的子属性如何克隆,取决于子属性的装饰器。
- - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。
+- 注意:
+
+ - 字段装饰器优先级最高,并且在克隆走字段遍历的任何地方对嵌套对象的字段同样生效。
+ - `deepClone` 会传播进容器和可按字段克隆的成员。没有深克隆默认的普通类实例会按字段复制,因此必须支持无参构造。资产保持共享,实体引用被重映射,运行时状态保留克隆体自己的,未注册的不透明成员保持共享,嵌套字段上的装饰器依然优先。
+ - 字段克隆采用刻意收窄的契约:只有对象自身可枚举的字符串键属性具有结构克隆语义。私有字段、不可枚举属性、Symbol 键以及原型或访问器状态不会被重建;引擎也不承诺识别运行时中所有可能的不透明状态。
+ - `deepClone` 无法直接克隆引擎绑定或不透明的值:对 `Entity` / `Component` 引用、资产、引擎运行时状态、函数或未注册的平台对象使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除装饰器以回退到默认行为,或使用该值自身的克隆 API 得到真正的拷贝。
+ - 如果克隆装饰器不能满足诉求,可实现 [ICloneHook.\_onClone()](/apis/core/#ICloneHook-_onClone),在字段自动克隆完成后追加类型特有的处理。
+
+## 克隆与资产引用计数
+克隆时,如果组件的顶层字段共享了一个引用计数资产(`ReferResource`,如 `Texture`、`Mesh`、`Material`),引擎会自动为克隆体增加一次引用计数,因此销毁源对象不会让资产在克隆体脚下被回收。内置组件会在销毁时释放自己持有的引用;被自定义脚本字段引用的资产则由克隆体一直保活 —— 不再需要时,调用资产自身的 `destroy()` 释放。
diff --git a/docs/zh/how-to-contribute.mdx b/docs/zh/how-to-contribute.mdx
index 6a995b5d89..e55d4c23c8 100644
--- a/docs/zh/how-to-contribute.mdx
+++ b/docs/zh/how-to-contribute.mdx
@@ -121,7 +121,7 @@ class CustomScript extends Script {
a:boolean = false;
@assignmentClone
b:number = 1;
- @shallowClone
+ @deepClone
c:Vector3[] = [new Vector3(0,0,0)];
}
```
@@ -135,7 +135,7 @@ class CustomScript extends Script{
a:boolean = false;
@assignmentClone
b:number = 1;
- @shallowClone
+ @deepClone
c:Vector3[] = [new Vector3(0,0,0)];
}
```
diff --git a/packages/core/src/2d/sprite/Sprite.ts b/packages/core/src/2d/sprite/Sprite.ts
index 5232001c9a..3021521f17 100644
--- a/packages/core/src/2d/sprite/Sprite.ts
+++ b/packages/core/src/2d/sprite/Sprite.ts
@@ -2,7 +2,7 @@ import { BoundingBox, MathUtil, Rect, Vector2, Vector4 } from "@galacean/engine-
import { Engine } from "../../Engine";
import { UpdateFlagManager } from "../../UpdateFlagManager";
import { ReferResource } from "../../asset/ReferResource";
-import { ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { Texture2D } from "../../texture/Texture2D";
import { SpriteAtlas } from "../atlas/SpriteAtlas";
import { SpriteModifyFlags } from "../enums/SpriteModifyFlags";
diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts
index 1f740081f9..d61eec80cc 100644
--- a/packages/core/src/2d/sprite/SpriteMask.ts
+++ b/packages/core/src/2d/sprite/SpriteMask.ts
@@ -6,7 +6,7 @@ import { RenderContext } from "../../RenderPipeline/RenderContext";
import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk";
import { RenderElement } from "../../RenderPipeline/RenderElement";
import { Renderer, RendererUpdateFlags } from "../../Renderer";
-import { assignmentClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer";
import { ShaderProperty } from "../../shader/ShaderProperty";
import { ISpriteRenderer } from "../assembler/ISpriteRenderer";
@@ -24,7 +24,6 @@ export class SpriteMask extends Renderer implements ISpriteRenderer {
static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff");
/** The mask layers the sprite mask influence to. */
- @assignmentClone
influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything;
/** @internal */
@ignoreClone
@@ -44,16 +43,11 @@ export class SpriteMask extends Renderer implements ISpriteRenderer {
private _automaticWidth: number = 0;
@ignoreClone
private _automaticHeight: number = 0;
- @assignmentClone
private _customWidth: number = undefined;
- @assignmentClone
private _customHeight: number = undefined;
- @assignmentClone
private _flipX: boolean = false;
- @assignmentClone
private _flipY: boolean = false;
- @assignmentClone
private _alphaCutoff: number = 0.5;
/**
@@ -191,10 +185,10 @@ export class SpriteMask extends Renderer implements ISpriteRenderer {
}
/**
- * @internal
+ * @inheritdoc
*/
- override _cloneTo(target: SpriteMask): void {
- super._cloneTo(target);
+ override _onClone(target: SpriteMask): void {
+ super._onClone(target);
target.sprite = this._sprite;
}
diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts
index 2a6986af6a..ad0321b8a4 100644
--- a/packages/core/src/2d/sprite/SpriteRenderer.ts
+++ b/packages/core/src/2d/sprite/SpriteRenderer.ts
@@ -6,7 +6,7 @@ import { RenderContext } from "../../RenderPipeline/RenderContext";
import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk";
import { RenderElement } from "../../RenderPipeline/RenderElement";
import { Renderer, RendererUpdateFlags } from "../../Renderer";
-import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderProperty } from "../../shader/ShaderProperty";
import { ISpriteAssembler } from "../assembler/ISpriteAssembler";
import { ISpriteRenderer } from "../assembler/ISpriteRenderer";
@@ -35,12 +35,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer {
private _drawMode: SpriteDrawMode;
@ignoreClone
private _assembler: ISpriteAssembler;
- @assignmentClone
private _tileMode: SpriteTileMode = SpriteTileMode.Continuous;
- @assignmentClone
private _tiledAdaptiveThreshold: number = 0.5;
- @deepClone
private _color: Color = new Color(1, 1, 1, 1);
@ignoreClone
private _sprite: Sprite = null;
@@ -49,13 +46,9 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer {
private _automaticWidth: number = 0;
@ignoreClone
private _automaticHeight: number = 0;
- @assignmentClone
private _customWidth: number = undefined;
- @assignmentClone
private _customHeight: number = undefined;
- @assignmentClone
private _flipX: boolean = false;
- @assignmentClone
private _flipY: boolean = false;
/**
@@ -284,10 +277,10 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer {
}
/**
- * @internal
+ * @inheritdoc
*/
- override _cloneTo(target: SpriteRenderer): void {
- super._cloneTo(target);
+ override _onClone(target: SpriteRenderer): void {
+ super._onClone(target);
target.sprite = this._sprite;
target.drawMode = this._drawMode;
}
diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts
index 3649180c79..b18f370037 100644
--- a/packages/core/src/2d/text/TextRenderer.ts
+++ b/packages/core/src/2d/text/TextRenderer.ts
@@ -8,7 +8,7 @@ import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk";
import { RenderElement } from "../../RenderPipeline/RenderElement";
import { Renderer } from "../../Renderer";
import { TransformModifyFlags } from "../../Transform";
-import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData, ShaderProperty } from "../../shader";
import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup";
import { Texture2D } from "../../texture";
@@ -36,38 +36,24 @@ export class TextRenderer extends Renderer implements ITextRenderer {
@ignoreClone
private _textChunks = Array();
/** @internal */
- @assignmentClone
_subFont: SubFont = null;
/** @internal */
@ignoreClone
_dirtyFlag = DirtyFlag.Font;
- @deepClone
private _color = new Color(1, 1, 1, 1);
- @assignmentClone
private _text = "";
- @assignmentClone
private _width = 0;
- @assignmentClone
private _height = 0;
@ignoreClone
private _localBounds = new BoundingBox();
- @assignmentClone
private _font: Font = null;
- @assignmentClone
private _fontSize = 24;
- @assignmentClone
private _fontStyle = FontStyle.None;
- @assignmentClone
private _lineSpacing = 0;
- @assignmentClone
private _characterSpacing = 0;
- @assignmentClone
private _horizontalAlignment = TextHorizontalAlignment.Center;
- @assignmentClone
private _verticalAlignment = TextVerticalAlignment.Center;
- @assignmentClone
private _enableWrapping = false;
- @assignmentClone
private _overflowMode = OverflowMode.Overflow;
/**
@@ -330,15 +316,6 @@ export class TextRenderer extends Renderer implements ITextRenderer {
this._subFont && (this._subFont = null);
}
- /**
- * @internal
- */
- override _cloneTo(target: TextRenderer): void {
- super._cloneTo(target);
- target.font = this._font;
- target._subFont = this._subFont;
- }
-
/**
* @internal
*/
diff --git a/packages/core/src/Camera.ts b/packages/core/src/Camera.ts
index 55d904f6c9..a8f6e28530 100644
--- a/packages/core/src/Camera.ts
+++ b/packages/core/src/Camera.ts
@@ -9,7 +9,7 @@ import { Transform } from "./Transform";
import { UpdateFlagManager } from "./UpdateFlagManager";
import { VirtualCamera } from "./VirtualCamera";
import { GLCapabilityType, Logger } from "./base";
-import { deepClone, ignoreClone } from "./clone/CloneManager";
+import { ignoreClone } from "./clone/CloneDecorators";
import { AntiAliasing } from "./enums/AntiAliasing";
import { CameraClearFlags } from "./enums/CameraClearFlags";
import { CameraModifyFlags } from "./enums/CameraModifyFlags";
@@ -116,13 +116,11 @@ export class Camera extends Component {
@ignoreClone
_globalShaderMacro: ShaderMacroCollection = new ShaderMacroCollection();
/** @internal */
- @deepClone
_frustum: BoundingFrustum = new BoundingFrustum();
/** @internal */
@ignoreClone
_renderPipeline: BasicRenderPipeline;
/** @internal */
- @deepClone
_virtualCamera: VirtualCamera = new VirtualCamera();
/** @internal */
_replacementShader: Shader = null;
@@ -148,25 +146,16 @@ export class Camera extends Component {
private _msaaSamples: MSAASamples;
private _renderTarget: RenderTarget = null;
- @ignoreClone
private _updateFlagManager: UpdateFlagManager;
- @ignoreClone
private _frustumChangeFlag: BoolUpdateFlag;
- @ignoreClone
private _isViewMatrixDirty: BoolUpdateFlag;
- @ignoreClone
private _isInvViewProjDirty: BoolUpdateFlag;
- @deepClone
private _shaderData: ShaderData = new ShaderData(ShaderDataGroup.Camera);
@ignoreClone
private _depthBufferParams: Vector4 = new Vector4();
- @deepClone
private _viewport: Vector4 = new Vector4(0, 0, 1, 1);
- @deepClone
private _pixelViewport: Rect = new Rect(0, 0, 0, 0);
- @deepClone
private _inverseProjectionMatrix: Matrix = new Matrix();
- @deepClone
private _invViewProjMat: Matrix = new Matrix();
/**
@@ -826,13 +815,6 @@ export class Camera extends Component {
this._updateFlagManager?.removeListener(onChange);
}
- /**
- * @internal
- */
- _cloneTo(target: Camera): void {
- this._renderTarget?._addReferCount(1);
- }
-
/**
* @internal
* @inheritdoc
diff --git a/packages/core/src/Component.ts b/packages/core/src/Component.ts
index 2ebafbcd05..1c9bb68a71 100644
--- a/packages/core/src/Component.ts
+++ b/packages/core/src/Component.ts
@@ -1,7 +1,6 @@
import { IReferable } from "./asset/IReferable";
import { EngineObject } from "./base";
-import { assignmentClone, ignoreClone } from "./clone/CloneManager";
-import { CloneUtils } from "./clone/CloneUtils";
+import { CloneMode, ignoreClone, registerDefaultCloneMode } from "./clone/CloneDecorators";
import { Entity } from "./Entity";
import { ActiveChangeFlag } from "./enums/ActiveChangeFlag";
import { Scene } from "./Scene";
@@ -23,7 +22,6 @@ export class Component extends EngineObject {
@ignoreClone
private _phasedActive: boolean = false;
- @assignmentClone
private _enabled: boolean = true;
/**
@@ -154,13 +152,6 @@ export class Component extends EngineObject {
}
}
- /**
- * @internal
- */
- _remap(srcRoot: Entity, targetRoot: Entity): T {
- return CloneUtils.remapComponent(srcRoot, targetRoot, this) as unknown as T;
- }
-
protected _addResourceReferCount(resource: IReferable, count: number): void {
this._entity._isTemplate || resource._addReferCount(count);
}
@@ -178,3 +169,5 @@ export class Component extends EngineObject {
}
}
}
+
+registerDefaultCloneMode(Component, CloneMode.Remap);
diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts
index eab8ce5aa2..c3cdbf43cf 100644
--- a/packages/core/src/Entity.ts
+++ b/packages/core/src/Entity.ts
@@ -10,7 +10,7 @@ import { Transform } from "./Transform";
import { UpdateFlagManager } from "./UpdateFlagManager";
import { ReferResource } from "./asset/ReferResource";
import { EngineObject } from "./base";
-import { CloneUtils } from "./clone/CloneUtils";
+import { CloneMode, registerDefaultCloneMode } from "./clone/CloneDecorators";
import { ComponentCloner } from "./clone/ComponentCloner";
import { ActiveChangeFlag } from "./enums/ActiveChangeFlag";
import { EntityModifyFlags } from "./enums/EntityModifyFlags";
@@ -421,8 +421,9 @@ export class Entity extends EngineObject {
* @returns Cloned entity
*/
clone(): Entity {
- const cloneEntity = this._createCloneEntity();
- this._parseCloneEntity(this, cloneEntity, this, cloneEntity, new Map());
+ const cloneMap = new Map();
+ const cloneEntity = this._createCloneEntity(cloneMap);
+ this._parseCloneEntity(this, cloneEntity, cloneMap);
return cloneEntity;
}
@@ -434,13 +435,6 @@ export class Entity extends EngineObject {
return this._updateFlagManager.createFlag(BoolUpdateFlag);
}
- /**
- * @internal
- */
- _remap(srcRoot: Entity, targetRoot: Entity): Entity {
- return CloneUtils.remapEntity(srcRoot, targetRoot, this);
- }
-
/**
* @internal
*/
@@ -449,7 +443,7 @@ export class Entity extends EngineObject {
this._templateResource = templateResource;
}
- private _createCloneEntity(): Entity {
+ private _createCloneEntity(cloneMap: Map): Entity {
const componentConstructors = Entity._tempComponentConstructors;
const components = this._components;
for (let i = 0, n = components.length; i < n; i++) {
@@ -457,6 +451,11 @@ export class Entity extends EngineObject {
}
const cloneEntity = new Entity(this.engine, this.name, ...componentConstructors);
componentConstructors.length = 0;
+ cloneMap.set(this, cloneEntity);
+ const targetComponents = cloneEntity._components;
+ for (let i = 0, n = components.length; i < n; i++) {
+ cloneMap.set(components[i], targetComponents[i]);
+ }
const templateResource = this._templateResource;
if (templateResource) {
cloneEntity._templateResource = templateResource;
@@ -467,27 +466,21 @@ export class Entity extends EngineObject {
cloneEntity._isActive = this._isActive;
const srcChildren = this._children;
for (let i = 0, n = srcChildren.length; i < n; i++) {
- cloneEntity.addChild(srcChildren[i]._createCloneEntity());
+ cloneEntity.addChild(srcChildren[i]._createCloneEntity(cloneMap));
}
return cloneEntity;
}
- private _parseCloneEntity(
- src: Entity,
- target: Entity,
- srcRoot: Entity,
- targetRoot: Entity,
- deepInstanceMap: Map
- ): void {
+ private _parseCloneEntity(src: Entity, target: Entity, cloneMap: Map): void {
const srcChildren = src._children;
const targetChildren = target._children;
for (let i = 0, n = srcChildren.length; i < n; i++) {
- this._parseCloneEntity(srcChildren[i], targetChildren[i], srcRoot, targetRoot, deepInstanceMap);
+ this._parseCloneEntity(srcChildren[i], targetChildren[i], cloneMap);
}
const components = src._components;
for (let i = 0, n = components.length; i < n; i++) {
- ComponentCloner.cloneComponent(components[i], target._components[i], srcRoot, targetRoot, deepInstanceMap);
+ ComponentCloner.cloneComponent(components[i], target._components[i], cloneMap);
}
}
@@ -787,6 +780,8 @@ export class Entity extends EngineObject {
}
}
+registerDefaultCloneMode(Entity, CloneMode.Remap);
+
export type ComponentArguments Component> = T extends new (
entity: Entity,
...args: infer P
diff --git a/packages/core/src/Renderer.ts b/packages/core/src/Renderer.ts
index 5af1e71750..a9f368a7cc 100644
--- a/packages/core/src/Renderer.ts
+++ b/packages/core/src/Renderer.ts
@@ -7,7 +7,8 @@ import { Entity } from "./Entity";
import { RenderContext } from "./RenderPipeline/RenderContext";
import { RenderElement } from "./RenderPipeline/RenderElement";
import { Transform, TransformModifyFlags } from "./Transform";
-import { assignmentClone, deepClone, ignoreClone } from "./clone/CloneManager";
+import { ignoreClone } from "./clone/CloneDecorators";
+import type { ICloneHook } from "./clone/ICloneHook";
import { SpriteMaskLayer } from "./enums/SpriteMaskLayer";
import { Material } from "./material";
import { ShaderMacro, ShaderProperty } from "./shader";
@@ -20,7 +21,7 @@ import { ShaderDataGroup } from "./shader/enums/ShaderDataGroup";
* @decorator `@dependentComponents(Transform, DependentMode.CheckOnly)`
*/
@dependentComponents(Transform, DependentMode.CheckOnly)
-export class Renderer extends Component {
+export class Renderer extends Component implements ICloneHook {
private static _tempVector0 = new Vector3();
/** @internal */
@@ -48,9 +49,7 @@ export class Renderer extends Component {
@ignoreClone
_renderFrameCount: number;
/** @internal */
- @assignmentClone
_maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None;
- @assignmentClone
_maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0;
@ignoreClone
@@ -65,7 +64,6 @@ export class Renderer extends Component {
protected _bounds: BoundingBox = new BoundingBox();
protected _transformEntity: Entity;
- @deepClone
private _shaderData: ShaderData = new ShaderData(ShaderDataGroup.Renderer);
@ignoreClone
private _mvMatrix: Matrix = new Matrix();
@@ -75,9 +73,7 @@ export class Renderer extends Component {
private _normalMatrix: Matrix = new Matrix();
@ignoreClone
private _materialsInstanced: boolean[] = [];
- @assignmentClone
private _priority: number = 0;
- @assignmentClone
private _receiveShadows: boolean = true;
/**
@@ -343,9 +339,9 @@ export class Renderer extends Component {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: Renderer): void {
+ _onClone(target: Renderer): void {
const materials = this._materials;
for (let i = 0, n = materials.length; i < n; i++) {
target._setMaterial(i, materials[i]);
diff --git a/packages/core/src/Script.ts b/packages/core/src/Script.ts
index 997901573d..4ee901e51a 100644
--- a/packages/core/src/Script.ts
+++ b/packages/core/src/Script.ts
@@ -1,6 +1,6 @@
import { Camera } from "./Camera";
import { Component } from "./Component";
-import { ignoreClone } from "./clone/CloneManager";
+import { ignoreClone } from "./clone/CloneDecorators";
import { PointerEventData } from "./input/pointer/PointerEventData";
import { ColliderShape } from "./physics";
import { Collision } from "./physics/Collision";
diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts
index e337e03d9d..ca7c9c91ec 100644
--- a/packages/core/src/Signal.ts
+++ b/packages/core/src/Signal.ts
@@ -1,14 +1,16 @@
+import { DataObject } from "./base/DataObject";
+import { ignoreClone } from "./clone/CloneDecorators";
+import type { ICloneHook } from "./clone/ICloneHook";
import { Component } from "./Component";
import { Entity } from "./Entity";
-import { CloneUtils } from "./clone/CloneUtils";
-import { ignoreClone } from "./clone/CloneManager";
import { SafeLoopArray } from "./utils/SafeLoopArray";
/**
* Signal is a typed event mechanism for Galacean Engine.
* @typeParam T - Tuple type of the signal arguments
*/
-export class Signal {
+export class Signal extends DataObject implements ICloneHook> {
+ // Rebuilt by `_onClone`; must survive even a propagated @deepClone.
@ignoreClone
private _listeners: SafeLoopArray> = new SafeLoopArray>();
@@ -125,36 +127,31 @@ export class Signal {
}
/**
- * @internal
- * Clone listeners to target signal, remapping entity/component references.
+ * @inheritdoc
*/
- _cloneTo(target: Signal, srcRoot: Entity, targetRoot: Entity): void {
+ _onClone(target: Signal, cloneMap: ReadonlyMap): void {
const listeners = this._listeners.getLoopArray();
for (let i = 0, n = listeners.length; i < n; i++) {
const listener = listeners[i];
if (listener.destroyed || !listener.methodName) continue;
- const clonedTarget = CloneUtils.remapComponent(srcRoot, targetRoot, listener.target);
- if (clonedTarget) {
- const clonedArgs = this._cloneArguments(listener.arguments, srcRoot, targetRoot);
- if (listener.once) {
- target.once(clonedTarget, listener.methodName, ...clonedArgs);
- } else {
- target.on(clonedTarget, listener.methodName, ...clonedArgs);
- }
+ const clonedTarget = (cloneMap.get(listener.target) ?? listener.target);
+ const clonedArgs = this._cloneArguments(listener.arguments, cloneMap);
+ if (listener.once) {
+ target.once(clonedTarget, listener.methodName, ...clonedArgs);
+ } else {
+ target.on(clonedTarget, listener.methodName, ...clonedArgs);
}
}
}
- private _cloneArguments(args: any[], srcRoot: Entity, targetRoot: Entity): any[] {
+ private _cloneArguments(args: any[], cloneMap: ReadonlyMap): any[] {
if (!args || args.length === 0) return [];
const len = args.length;
const clonedArgs = new Array(len);
for (let i = 0; i < len; i++) {
const arg = args[i];
- if (arg instanceof Entity) {
- clonedArgs[i] = CloneUtils.remapEntity(srcRoot, targetRoot, arg);
- } else if (arg instanceof Component) {
- clonedArgs[i] = CloneUtils.remapComponent(srcRoot, targetRoot, arg);
+ if (arg instanceof Entity || arg instanceof Component) {
+ clonedArgs[i] = cloneMap.get(arg) ?? arg;
} else {
clonedArgs[i] = arg;
}
diff --git a/packages/core/src/Transform.ts b/packages/core/src/Transform.ts
index 5f1235caf0..234b3db8b9 100644
--- a/packages/core/src/Transform.ts
+++ b/packages/core/src/Transform.ts
@@ -2,12 +2,13 @@ import { MathUtil, Matrix, Matrix3x3, Quaternion, Vector3 } from "@galacean/engi
import { BoolUpdateFlag } from "./BoolUpdateFlag";
import { Component } from "./Component";
import { Entity } from "./Entity";
-import { assignmentClone, ignoreClone } from "./clone/CloneManager";
+import { ignoreClone } from "./clone/CloneDecorators";
+import type { ICloneHook } from "./clone/ICloneHook";
/**
* Used to implement transformation related functions.
*/
-export class Transform extends Component {
+export class Transform extends Component implements ICloneHook {
private static _tempQuat0: Quaternion = new Quaternion();
private static _tempVec30: Vector3 = new Vector3();
private static _tempVec31: Vector3 = new Vector3();
@@ -26,7 +27,6 @@ export class Transform extends Component {
private _rotationQuaternion: Quaternion = new Quaternion();
@ignoreClone
private _scale: Vector3 = new Vector3(1, 1, 1);
- @assignmentClone
private _localUniformScaling: boolean = true;
@ignoreClone
private _worldPosition: Vector3 = new Vector3();
@@ -579,9 +579,9 @@ export class Transform extends Component {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: Transform): void {
+ _onClone(target: Transform): void {
const { _position: position, _rotation: rotation, _scale: scale } = target;
// @ts-ignore
diff --git a/packages/core/src/UpdateFlag.ts b/packages/core/src/UpdateFlag.ts
index d926b47b8c..dd0798a42e 100644
--- a/packages/core/src/UpdateFlag.ts
+++ b/packages/core/src/UpdateFlag.ts
@@ -1,3 +1,4 @@
+import { CloneMode, registerDefaultCloneMode } from "./clone/CloneDecorators";
import { UpdateFlagManager } from "./UpdateFlagManager";
import { Utils } from "./Utils";
@@ -13,7 +14,7 @@ export abstract class UpdateFlag {
* @param bit - Bit
* @param param - Parameter
*/
- abstract dispatch(bit?: number, param?: Object): void;
+ abstract dispatch(bit?: number, param?: unknown): void;
/**
* Clear.
@@ -38,3 +39,5 @@ export abstract class UpdateFlag {
}
}
}
+
+registerDefaultCloneMode(UpdateFlag, CloneMode.Ignore);
diff --git a/packages/core/src/UpdateFlagManager.ts b/packages/core/src/UpdateFlagManager.ts
index 0e1e98ec90..154e85d66f 100644
--- a/packages/core/src/UpdateFlagManager.ts
+++ b/packages/core/src/UpdateFlagManager.ts
@@ -1,3 +1,4 @@
+import { CloneMode, registerDefaultCloneMode } from "./clone/CloneDecorators";
import { UpdateFlag } from "./UpdateFlag";
import { Utils } from "./Utils";
@@ -10,7 +11,7 @@ export class UpdateFlagManager {
_updateFlags: UpdateFlag[] = [];
- private _listeners: ((type?: number, param?: Object) => void)[] = [];
+ private _listeners: ((type?: number, param?: unknown) => void)[] = [];
/**
* Create a UpdateFlag.
@@ -46,7 +47,7 @@ export class UpdateFlagManager {
* Add a listener.
* @param listener - The listener
*/
- addListener(listener: (type?: number, param?: Object) => void): void {
+ addListener(listener: (type?: number, param?: unknown) => void): void {
this._listeners.push(listener);
}
@@ -54,7 +55,7 @@ export class UpdateFlagManager {
* Remove a listener.
* @param listener - The listener
*/
- removeListener(listener: (type?: number, param?: Object) => void): void {
+ removeListener(listener: (type?: number, param?: unknown) => void): void {
Utils.removeFromArray(this._listeners, listener);
}
@@ -63,7 +64,7 @@ export class UpdateFlagManager {
* @param type - Event type, usually in the form of enumeration
* @param param - Event param
*/
- dispatch(type?: number, param?: Object): void {
+ dispatch(type?: number, param?: unknown): void {
this.version++;
const updateFlags = this._updateFlags;
@@ -77,3 +78,5 @@ export class UpdateFlagManager {
}
}
}
+
+registerDefaultCloneMode(UpdateFlagManager, CloneMode.Ignore);
diff --git a/packages/core/src/VirtualCamera.ts b/packages/core/src/VirtualCamera.ts
index 4c8598888e..564fe473a3 100644
--- a/packages/core/src/VirtualCamera.ts
+++ b/packages/core/src/VirtualCamera.ts
@@ -1,10 +1,11 @@
+import { DataObject } from "./base/DataObject";
import { Matrix, Vector3 } from "@galacean/engine-math";
-import { ignoreClone } from "./clone/CloneManager";
+import { ignoreClone } from "./clone/CloneDecorators";
/**
* @internal
*/
-export class VirtualCamera {
+export class VirtualCamera extends DataObject {
isOrthographic: boolean = false;
nearClipPlane: number = 0.1;
farClipPlane: number = 100;
diff --git a/packages/core/src/animation/AnimationClip.ts b/packages/core/src/animation/AnimationClip.ts
index ef51647577..9238ce782c 100644
--- a/packages/core/src/animation/AnimationClip.ts
+++ b/packages/core/src/animation/AnimationClip.ts
@@ -55,7 +55,7 @@ export class AnimationClip extends EngineObject {
* @param time - The time when the event be triggered
* @param parameter - The parameter that is stored in the event and will be sent to the function
*/
- addEvent(functionName: string, time: number, parameter: Object): void;
+ addEvent(functionName: string, time: number, parameter: object): void;
/**
* Adds an animation event to the clip.
@@ -63,7 +63,7 @@ export class AnimationClip extends EngineObject {
*/
addEvent(event: AnimationEvent): void;
- addEvent(param: AnimationEvent | string, time?: number, parameter?: Object): void {
+ addEvent(param: AnimationEvent | string, time?: number, parameter?: object): void {
let newEvent: AnimationEvent;
if (typeof param === "string") {
const event = new AnimationEvent();
diff --git a/packages/core/src/animation/Animator.ts b/packages/core/src/animation/Animator.ts
index f58589955c..28031bcaf0 100644
--- a/packages/core/src/animation/Animator.ts
+++ b/packages/core/src/animation/Animator.ts
@@ -5,7 +5,8 @@ import { Entity } from "../Entity";
import { Renderer } from "../Renderer";
import { Script } from "../Script";
import { Logger } from "../base/Logger";
-import { assignmentClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
+import type { ICloneHook } from "../clone/ICloneHook";
import { AnimatorController } from "./AnimatorController";
import { AnimatorControllerLayer } from "./AnimatorControllerLayer";
import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter";
@@ -30,17 +31,15 @@ import { AnimationCurveOwner } from "./internal/animationCurveOwner/AnimationCur
/**
* The controller of the animation system.
*/
-export class Animator extends Component {
+export class Animator extends Component implements ICloneHook {
private static _passedTriggerParameterNames = new Array();
private static _tempScripts: Script[] = [];
/** Culling mode of this Animator. */
cullingMode: AnimatorCullingMode = AnimatorCullingMode.None;
/** The playback speed of the Animator, 1.0 is normal playback speed. */
- @assignmentClone
speed = 1.0;
/** Whether the Animator sends AnimationEvent callbacks. */
- @assignmentClone
fireEvents = true;
/** @internal */
@@ -48,9 +47,7 @@ export class Animator extends Component {
/** @internal */
_onUpdateIndex = -1;
- @assignmentClone
protected _animatorController: AnimatorController;
- @ignoreClone
protected _controllerUpdateFlag: BoolUpdateFlag;
@ignoreClone
protected _updateMark = 0;
@@ -348,12 +345,11 @@ export class Animator extends Component {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: Animator): void {
+ _onClone(target: Animator): void {
const animatorController = target._animatorController;
if (animatorController) {
- target._addResourceReferCount(animatorController, 1);
target._controllerUpdateFlag = animatorController._registerChangeFlag();
}
}
@@ -442,7 +438,7 @@ export class Animator extends Component {
layerIndex: number
): void {
const { entity, _curveOwnerPool: curveOwnerPool } = this;
- let { mask } = this._animatorController.layers[layerIndex];
+ const { mask } = this._animatorController.layers[layerIndex];
const { curveLayerOwner } = animatorStateData;
const { _curveBindings: curves } = animatorState.clip;
diff --git a/packages/core/src/animation/AnimatorController.ts b/packages/core/src/animation/AnimatorController.ts
index 1f8e91a01c..cda119f6d0 100644
--- a/packages/core/src/animation/AnimatorController.ts
+++ b/packages/core/src/animation/AnimatorController.ts
@@ -85,7 +85,7 @@ export class AnimatorController extends ReferResource {
*/
clearParameters(): void {
this._parameters.length = 0;
- for (let name in this._parametersMap) {
+ for (const name in this._parametersMap) {
delete this._parametersMap[name];
}
}
@@ -140,7 +140,7 @@ export class AnimatorController extends ReferResource {
}
layers.length = 0;
- for (let name in this._layersMap) {
+ for (const name in this._layersMap) {
delete this._layersMap[name];
}
this._updateFlagManager.dispatch();
diff --git a/packages/core/src/asset/ReferResource.ts b/packages/core/src/asset/ReferResource.ts
index 1460d048e8..87d52bdc43 100644
--- a/packages/core/src/asset/ReferResource.ts
+++ b/packages/core/src/asset/ReferResource.ts
@@ -1,4 +1,5 @@
import { EngineObject } from "../base/EngineObject";
+import { CloneMode, registerDefaultCloneMode } from "../clone/CloneDecorators";
import { Engine } from "../Engine";
import { IReferable } from "./IReferable";
@@ -109,3 +110,5 @@ export abstract class ReferResource extends EngineObject implements IReferable {
}
}
}
+
+registerDefaultCloneMode(ReferResource, CloneMode.Assignment);
diff --git a/packages/core/src/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts
index b149e5609e..0157236a69 100644
--- a/packages/core/src/audio/AudioSource.ts
+++ b/packages/core/src/audio/AudioSource.ts
@@ -1,4 +1,4 @@
-import { assignmentClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { Component } from "../Component";
import { Entity } from "../Entity";
import { AudioClip } from "./AudioClip";
@@ -16,7 +16,6 @@ export class AudioSource extends Component {
@ignoreClone
private _pendingPlay = false;
- @assignmentClone
private _clip: AudioClip;
@ignoreClone
private _gainNode: GainNode;
@@ -28,13 +27,9 @@ export class AudioSource extends Component {
@ignoreClone
private _playTime = -1;
- @assignmentClone
private _volume = 1;
- @assignmentClone
private _lastVolume = 1;
- @assignmentClone
private _playbackRate = 1;
- @assignmentClone
private _loop = false;
/**
@@ -220,14 +215,6 @@ export class AudioSource extends Component {
}
}
- /**
- * @internal
- */
- _cloneTo(target: AudioSource): void {
- target._clip?._addReferCount(1);
- // _volume is field-cloned; its gain node is applied lazily on first play
- }
-
/**
* @internal
*/
diff --git a/packages/core/src/base/DataObject.ts b/packages/core/src/base/DataObject.ts
new file mode 100644
index 0000000000..ba545b1cda
--- /dev/null
+++ b/packages/core/src/base/DataObject.ts
@@ -0,0 +1,9 @@
+import { CloneMode, registerDefaultCloneMode } from "../clone/CloneDecorators";
+
+/**
+ * Base class for objects whose own enumerable string-keyed properties are structurally cloned by default.
+ * Subclasses must support argument-less construction when no compatible preset exists.
+ */
+export abstract class DataObject {}
+
+registerDefaultCloneMode(DataObject, CloneMode.Deep);
diff --git a/packages/core/src/base/EngineObject.ts b/packages/core/src/base/EngineObject.ts
index 4354f3d61b..2783d29617 100644
--- a/packages/core/src/base/EngineObject.ts
+++ b/packages/core/src/base/EngineObject.ts
@@ -1,4 +1,4 @@
-import { ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { Engine } from "../Engine";
/**
diff --git a/packages/core/src/base/index.ts b/packages/core/src/base/index.ts
index 74cd4ac148..2870931b65 100644
--- a/packages/core/src/base/index.ts
+++ b/packages/core/src/base/index.ts
@@ -1,6 +1,7 @@
export { EventDispatcher } from "./EventDispatcher";
export { Logger } from "./Logger";
export { Time } from "./Time";
+export { DataObject } from "./DataObject";
export { EngineObject } from "./EngineObject";
export * from "./Constant";
diff --git a/packages/core/src/clone/CloneDecorators.ts b/packages/core/src/clone/CloneDecorators.ts
new file mode 100644
index 0000000000..71c88c3e13
--- /dev/null
+++ b/packages/core/src/clone/CloneDecorators.ts
@@ -0,0 +1,60 @@
+/** @internal */
+export const defaultCloneModeKey = Symbol("defaultCloneMode");
+/** @internal */
+export const fieldCloneModesKey = Symbol("fieldCloneModes");
+
+/**
+ * @internal
+ */
+export const enum CloneMode {
+ Ignore,
+ Assignment,
+ Remap,
+ Copy,
+ Deep
+}
+
+/**
+ * Property decorator — deep clone this field's whole subtree, overriding the value type's default
+ * clone mode (field-level decorators have the highest priority). The deep intent carries into
+ * field-cloneable members; engine-bound and platform objects keep their defaults. A decorator is
+ * an explicit intent: if the decorated value itself can't be deep cloned (an entity reference,
+ * asset, function, or object with opaque internal state), cloning throws rather than falling back.
+ * Field-cloned classes reproduce only their own enumerable string-keyed properties.
+ */
+export function deepClone(target: object, propertyKey: string): void {
+ CloneMetadata.registerFieldMode(target, propertyKey, CloneMode.Deep);
+}
+
+/**
+ * Property decorator — assign (share the reference) this field, overriding the value type's default clone mode.
+ */
+export function assignmentClone(target: object, propertyKey: string): void {
+ CloneMetadata.registerFieldMode(target, propertyKey, CloneMode.Assignment);
+}
+
+/**
+ * Property decorator — ignore this field when cloning; keep the clone's own constructor-built value.
+ */
+export function ignoreClone(target: object, propertyKey: string): void {
+ CloneMetadata.registerFieldMode(target, propertyKey, CloneMode.Ignore);
+}
+
+/**
+ * @internal
+ */
+export function registerDefaultCloneMode(target: { prototype: object }, mode: CloneMode): void {
+ Object.defineProperty(target.prototype, defaultCloneModeKey, { value: mode });
+}
+
+class CloneMetadata {
+ static registerFieldMode(target: any, propertyKey: string, mode: CloneMode): void {
+ if (!Object.prototype.hasOwnProperty.call(target, fieldCloneModesKey)) {
+ Object.defineProperty(target, fieldCloneModesKey, {
+ value: Object.create(target[fieldCloneModesKey] ?? null),
+ configurable: true
+ });
+ }
+ target[fieldCloneModesKey][propertyKey] = mode;
+ }
+}
diff --git a/packages/core/src/clone/CloneManager.ts b/packages/core/src/clone/CloneManager.ts
deleted file mode 100644
index be46462982..0000000000
--- a/packages/core/src/clone/CloneManager.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { Entity } from "../Entity";
-import { TypedArray } from "../base/Constant";
-import { ICustomClone } from "./ComponentCloner";
-import { CloneMode } from "./enums/CloneMode";
-
-/**
- * Property decorator, ignore the property when cloning.
- */
-export function ignoreClone(target: Object, propertyKey: string): void {
- CloneManager.registerCloneMode(target, propertyKey, CloneMode.Ignore);
-}
-
-/**
- * Property decorator, assign value to the property when cloning.
- *
- * @remarks
- * If it's a primitive type, the value will be copied.
- * If it's a class type, the reference will be copied.
- */
-export function assignmentClone(target: Object, propertyKey: string): void {
- CloneManager.registerCloneMode(target, propertyKey, CloneMode.Assignment);
-}
-
-/**
- * Property decorator, shallow clone the property when cloning.
- * After cloning, it will keep its own reference independent, and use the method of assignment to clone all its internal properties.
- * if the internal property is a primitive type, the value will be copied, if the internal property is a reference type, its reference address will be copied.。
- *
- * @remarks
- * Applicable to Object, Array, TypedArray and Class types.
- */
-export function shallowClone(target: Object, propertyKey: string): void {
- CloneManager.registerCloneMode(target, propertyKey, CloneMode.Shallow);
-}
-
-/**
- * Property decorator, deep clone the property when cloning.
- * After cloning, it will maintain its own reference independence, and all its internal deep properties will remain completely independent.
- *
- * @remarks
- * Applicable to Object, Array, TypedArray and Class types.
- * If Class is encountered during the deep cloning process, the custom cloning function of the object will be called first.
- * Custom cloning requires the object to implement the IClone interface.
- */
-export function deepClone(target: Object, propertyKey: string): void {
- CloneManager.registerCloneMode(target, propertyKey, CloneMode.Deep);
-}
-
-/**
- * @internal
- * Clone manager.
- */
-export class CloneManager {
- /** @internal */
- static _subCloneModeMap = new Map();
- /** @internal */
- static _cloneModeMap = new Map();
-
- private static _objectType = Object.getPrototypeOf(Object);
-
- /**
- * Register clone mode.
- * @param target - Clone target
- * @param propertyKey - Clone property name
- * @param mode - Clone mode
- */
- static registerCloneMode(target: Object, propertyKey: string, mode: CloneMode): void {
- let targetMap = CloneManager._subCloneModeMap.get(target.constructor);
- if (!targetMap) {
- targetMap = Object.create(null);
- CloneManager._subCloneModeMap.set(target.constructor, targetMap);
- }
- targetMap[propertyKey] = mode;
- }
-
- /**
- * Get the clone mode according to the prototype chain.
- */
- static getCloneMode(type: Function): Object {
- let cloneModes = CloneManager._cloneModeMap.get(type);
- if (!cloneModes) {
- cloneModes = Object.create(null);
- CloneManager._cloneModeMap.set(type, cloneModes);
- const objectType = CloneManager._objectType;
- const cloneModeMap = CloneManager._subCloneModeMap;
- while (type !== objectType) {
- const subCloneModes = cloneModeMap.get(type);
- if (subCloneModes) {
- Object.assign(cloneModes, subCloneModes);
- }
- type = Object.getPrototypeOf(type);
- }
- }
- return cloneModes;
- }
-
- static cloneProperty(
- source: Object,
- target: Object,
- k: string | number,
- cloneMode: CloneMode,
- srcRoot: Entity,
- targetRoot: Entity,
- deepInstanceMap: Map
- ): void {
- const sourceProperty = source[k];
-
- // Remappable references (Entity/Component) are always remapped, regardless of clone decorator
- if (sourceProperty instanceof Object && (sourceProperty)._remap) {
- target[k] = (sourceProperty)._remap(srcRoot, targetRoot);
- return;
- }
-
- if (cloneMode === CloneMode.Ignore) return;
-
- // Primitives, undecorated, or @assignmentClone: direct assign
- if (!(sourceProperty instanceof Object) || cloneMode === undefined || cloneMode === CloneMode.Assignment) {
- target[k] = sourceProperty;
- return;
- }
-
- // @shallowClone / @deepClone: deep copy complex objects
- const type = sourceProperty.constructor;
- switch (type) {
- case Uint8Array:
- case Uint16Array:
- case Uint32Array:
- case Int8Array:
- case Int16Array:
- case Int32Array:
- case Float32Array:
- case Float64Array:
- let targetPropertyT = target[k];
- if (targetPropertyT == null || targetPropertyT.length !== (sourceProperty).length) {
- target[k] = (sourceProperty).slice();
- } else {
- targetPropertyT.set(sourceProperty);
- }
- break;
- case Array:
- let targetPropertyA = >target[k];
- const length = (>sourceProperty).length;
- if (targetPropertyA == null) {
- target[k] = targetPropertyA = new Array(length);
- } else {
- targetPropertyA.length = length;
- }
- for (let i = 0; i < length; i++) {
- CloneManager.cloneProperty(
- >sourceProperty,
- targetPropertyA,
- i,
- cloneMode,
- srcRoot,
- targetRoot,
- deepInstanceMap
- );
- }
- break;
- default:
- let targetProperty = target[k];
- // If the target property is undefined, create new instance and keep reference sharing like the source
- if (!targetProperty) {
- targetProperty = deepInstanceMap.get(sourceProperty);
- if (!targetProperty) {
- targetProperty = new sourceProperty.constructor();
- deepInstanceMap.set(sourceProperty, targetProperty);
- }
- target[k] = targetProperty;
- }
-
- if ((sourceProperty).copyFrom) {
- (targetProperty).copyFrom(sourceProperty);
- } else {
- const cloneModes = CloneManager.getCloneMode(sourceProperty.constructor);
- for (let k in sourceProperty) {
- CloneManager.cloneProperty(
- sourceProperty,
- targetProperty,
- k,
- cloneModes[k],
- srcRoot,
- targetRoot,
- deepInstanceMap
- );
- }
- (sourceProperty)._cloneTo?.(targetProperty, srcRoot, targetRoot);
- }
- break;
- }
- }
-
- static deepCloneObject(source: Object, target: Object, deepInstanceMap: Map): void {
- for (let k in source) {
- CloneManager.cloneProperty(source, target, k, CloneMode.Deep, null, null, deepInstanceMap);
- }
- }
-}
diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts
new file mode 100644
index 0000000000..2ab1b76942
--- /dev/null
+++ b/packages/core/src/clone/CloneUtil.ts
@@ -0,0 +1,375 @@
+import {
+ BoundingBox,
+ BoundingFrustum,
+ BoundingSphere,
+ Color,
+ Matrix,
+ Matrix3x3,
+ Plane,
+ Quaternion,
+ Ray,
+ Rect,
+ SphericalHarmonics3,
+ Vector2,
+ Vector3,
+ Vector4
+} from "@galacean/engine-math";
+import { IReferable } from "../asset/IReferable";
+import { ReferResource } from "../asset/ReferResource";
+import { TypedArray } from "../base/Constant";
+import { Logger } from "../base/Logger";
+import { Component } from "../Component";
+import { Entity } from "../Entity";
+import { UpdateFlag } from "../UpdateFlag";
+import { UpdateFlagManager } from "../UpdateFlagManager";
+import { CloneMode, defaultCloneModeKey, fieldCloneModesKey, registerDefaultCloneMode } from "./CloneDecorators";
+import type { ICloneHook } from "./ICloneHook";
+
+/**
+ * @internal
+ */
+export class CloneUtil {
+ /**
+ * @internal
+ */
+ static _cloneObjectFields(
+ source: any,
+ target: object,
+ cloneMap: Map,
+ deepCloneSubtree = false
+ ): void {
+ const fieldModes = source[fieldCloneModesKey];
+ const keys = Object.keys(source);
+ for (let i = 0, n = keys.length; i < n; i++) {
+ const k = keys[i];
+ const fieldMode = fieldModes?.[k];
+ if (fieldMode === CloneMode.Ignore) continue;
+ target[k] = CloneUtil._cloneFieldValue(source[k], target[k], cloneMap, fieldMode, deepCloneSubtree);
+ }
+ }
+
+ /**
+ * @internal
+ */
+ static _cloneFieldValue(
+ source: any,
+ preset: any,
+ cloneMap: Map,
+ fieldMode?: CloneMode,
+ deepCloneSubtree = false
+ ): any {
+ if (fieldMode === CloneMode.Assignment) return source;
+ if (fieldMode === CloneMode.Deep) return CloneUtil._cloneValueForDeepField(source, preset, cloneMap);
+ return CloneUtil._cloneValueByDefault(source, preset, cloneMap, deepCloneSubtree);
+ }
+
+ /**
+ * @internal
+ */
+ static _transferSlotOwnership(cloned: any, source: any, preset: any): void {
+ if (cloned === preset) return;
+ if (preset instanceof ReferResource) {
+ const presetRefCount = (<{ refCount?: number }>preset).refCount;
+ presetRefCount !== undefined &&
+ presetRefCount <= 0 &&
+ Logger.error(
+ `CloneUtil: the clone's preset ${preset.constructor.name} holds no owned reference; ` +
+ `a constructor presetting a ref-counted resource must acquire it (assign via its setter or an explicit +1).`
+ );
+ (preset)._addReferCount(-1);
+ }
+ if (cloned === source && cloned instanceof ReferResource) {
+ (cloned)._addReferCount(1);
+ }
+ }
+
+ private static _cloneValueForDeepField(source: any, preset: any, cloneMap: Map): any {
+ if (typeof source === "function") {
+ throw new Error(
+ `CloneUtil: @deepClone cannot deep clone a function — code is not a cloneable graph. ` +
+ `Remove @deepClone to keep the clone's own binding.`
+ );
+ }
+ if (source instanceof Entity || source instanceof Component) {
+ throw new Error(
+ `CloneUtil: @deepClone cannot deep clone "${CloneUtil._getTypeName(source)}" — Entity / Component ` +
+ `references are engine-bound. Remove @deepClone to remap the reference by default.`
+ );
+ }
+ if (source instanceof ReferResource) {
+ throw new Error(
+ `CloneUtil: @deepClone cannot deep clone "${CloneUtil._getTypeName(source)}" — assets are engine-bound ` +
+ `and shared by reference. Remove @deepClone to share it, or copy it via the asset's own clone() API.`
+ );
+ }
+ if (source instanceof UpdateFlagManager || source instanceof UpdateFlag) {
+ throw new Error(
+ `CloneUtil: @deepClone cannot deep clone "${CloneUtil._getTypeName(source)}" — a flag and its manager hold ` +
+ `each other, and a field copy resolves neither side, leaving the pair inconsistent. Remove ` +
+ `@deepClone to keep the clone's own.`
+ );
+ }
+ if (source === null || typeof source !== "object") return source;
+ switch (source[defaultCloneModeKey]) {
+ case CloneMode.Copy:
+ return CloneUtil._cloneCopyFromValue(source, preset, cloneMap);
+ case CloneMode.Deep:
+ return CloneUtil._cloneObjectByFields(source, preset, cloneMap, true);
+ }
+ if (ArrayBuffer.isView(source)) {
+ return CloneUtil._deepCloneArrayBufferView(source, preset, cloneMap);
+ }
+ if (Array.isArray(source)) {
+ return CloneUtil._cloneArray(source, preset, cloneMap, true);
+ }
+ if (source instanceof Map) {
+ return CloneUtil._cloneMap(source, preset, cloneMap, true);
+ }
+ if (source instanceof Set) {
+ return CloneUtil._cloneSet(source, preset, cloneMap, true);
+ }
+ if (!CloneUtil._hasOrdinaryObjectTag(source)) {
+ throw new Error(
+ `CloneUtil: @deepClone cannot deep clone "${CloneUtil._getTypeName(source)}" — its internal state ` +
+ `cannot be reproduced by cloning enumerable fields. Remove @deepClone to share it, or wrap the value ` +
+ `in a field-cloneable type.`
+ );
+ }
+ return CloneUtil._cloneObjectByFields(source, preset, cloneMap, true);
+ }
+
+ private static _cloneValueByDefault(
+ source: any,
+ preset: any,
+ cloneMap: Map,
+ deepCloneSubtree = false
+ ): any {
+ if (typeof source === "function") {
+ return deepCloneSubtree ? source : typeof preset === "function" ? preset : source;
+ }
+ if (source === null || typeof source !== "object") return source;
+
+ switch (source[defaultCloneModeKey]) {
+ case CloneMode.Ignore:
+ return preset;
+ case CloneMode.Assignment:
+ return source;
+ case CloneMode.Remap:
+ return cloneMap.get(source) ?? source;
+ case CloneMode.Copy:
+ return CloneUtil._cloneCopyFromValue(source, preset, cloneMap);
+ case CloneMode.Deep:
+ return CloneUtil._cloneObjectByFields(source, preset, cloneMap, deepCloneSubtree);
+ }
+
+ if (ArrayBuffer.isView(source)) {
+ return CloneUtil._deepCloneArrayBufferView(source, preset, cloneMap);
+ }
+ if (Array.isArray(source)) {
+ return CloneUtil._cloneArray(source, preset, cloneMap, deepCloneSubtree);
+ }
+ if (source instanceof Map) {
+ return CloneUtil._cloneMap(source, preset, cloneMap, deepCloneSubtree);
+ }
+ if (source instanceof Set) {
+ return CloneUtil._cloneSet(source, preset, cloneMap, deepCloneSubtree);
+ }
+ if (CloneUtil._isPlainObject(source) || (deepCloneSubtree && CloneUtil._hasOrdinaryObjectTag(source))) {
+ return CloneUtil._cloneObjectByFields(source, preset, cloneMap, deepCloneSubtree);
+ }
+ return source;
+ }
+
+ private static _cloneCopyFromValue(source: any, preset: any, cloneMap: Map): any {
+ const existing = cloneMap.get(source);
+ if (existing) return existing;
+ const dst = CloneUtil._createCloneTarget(source, preset, cloneMap);
+ dst.copyFrom(source);
+ (>source)._onClone?.(dst, cloneMap);
+ return dst;
+ }
+
+ private static _cloneObjectByFields(
+ source: any,
+ preset: any,
+ cloneMap: Map,
+ deepCloneSubtree = false
+ ): any {
+ const existing = cloneMap.get(source);
+ if (existing) return existing;
+ const dst = CloneUtil._createCloneTarget(source, preset, cloneMap);
+ CloneUtil._cloneObjectFields(source, dst, cloneMap, deepCloneSubtree);
+ (>source)._onClone?.(dst, cloneMap);
+ return dst;
+ }
+
+ private static _createCloneTarget(source: any, preset: any, cloneMap: Map): any {
+ const proto = Object.getPrototypeOf(source);
+ const reusable = preset && preset !== source && Object.getPrototypeOf(preset) === proto && preset;
+ let dst: any;
+ if (reusable) {
+ dst = reusable;
+ } else {
+ const ctor = proto?.constructor;
+ if (typeof ctor === "function") {
+ try {
+ dst = new ctor();
+ } catch (error) {
+ throw new Error(
+ `CloneUtil: failed to bare-construct "${ctor.name}" — a type cloned deep must support ` +
+ `argument-less construction (the gate creates preset-less instances bare, then populates fields). ` +
+ `Cause: ${error}`,
+ { cause: error }
+ );
+ }
+ } else {
+ dst = Object.create(proto);
+ }
+ }
+ cloneMap.set(source, dst);
+ return dst;
+ }
+
+ private static _deepCloneArrayBufferView(
+ source: ArrayBufferView,
+ preset: any,
+ cloneMap: Map
+ ): ArrayBufferView {
+ const existing = cloneMap.get(source);
+ if (existing) return existing;
+
+ let dst: ArrayBufferView;
+ if (source instanceof DataView) {
+ if (preset instanceof DataView && preset !== source && preset.byteLength === source.byteLength) {
+ new Uint8Array(preset.buffer, preset.byteOffset, preset.byteLength).set(
+ new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
+ );
+ dst = preset;
+ } else {
+ dst = new DataView(source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength));
+ }
+ } else {
+ const src = source;
+ if (
+ preset &&
+ preset !== source &&
+ preset.constructor === src.constructor &&
+ (preset).length === src.length
+ ) {
+ (preset).set(src);
+ dst = preset;
+ } else {
+ dst = src.slice();
+ }
+ }
+ cloneMap.set(source, dst);
+ return dst;
+ }
+
+ private static _cloneArray(
+ source: any[],
+ preset: any,
+ cloneMap: Map,
+ deepCloneSubtree = false
+ ): any[] {
+ const existing = cloneMap.get(source);
+ if (existing) return existing;
+
+ const dst =
+ preset !== source &&
+ Array.isArray(preset) &&
+ preset.constructor === source.constructor &&
+ preset.length === source.length
+ ? preset
+ : new Array(source.length);
+ cloneMap.set(source, dst);
+ for (let i = 0, n = source.length; i < n; i++) {
+ dst[i] = CloneUtil._cloneValueByDefault(source[i], undefined, cloneMap, deepCloneSubtree);
+ }
+ return dst;
+ }
+
+ private static _cloneMap(
+ source: Map,
+ preset: any,
+ cloneMap: Map,
+ deepCloneSubtree = false
+ ): Map {
+ const existing = cloneMap.get(source);
+ if (existing) return >existing;
+
+ let dst: Map;
+ if (preset instanceof Map && preset !== source && preset.constructor === source.constructor) {
+ preset.clear();
+ dst = preset;
+ } else {
+ dst = new Map();
+ }
+ cloneMap.set(source, dst);
+ for (const entry of source) {
+ dst.set(
+ CloneUtil._cloneValueByDefault(entry[0], undefined, cloneMap, deepCloneSubtree),
+ CloneUtil._cloneValueByDefault(entry[1], undefined, cloneMap, deepCloneSubtree)
+ );
+ }
+ return dst;
+ }
+
+ private static _cloneSet(
+ source: Set,
+ preset: any,
+ cloneMap: Map,
+ deepCloneSubtree = false
+ ): Set {
+ const existing = cloneMap.get(source);
+ if (existing) return >existing;
+
+ let dst: Set;
+ if (preset instanceof Set && preset !== source && preset.constructor === source.constructor) {
+ preset.clear();
+ dst = preset;
+ } else {
+ dst = new Set();
+ }
+ cloneMap.set(source, dst);
+ for (const v of source) {
+ dst.add(CloneUtil._cloneValueByDefault(v, undefined, cloneMap, deepCloneSubtree));
+ }
+ return dst;
+ }
+
+ private static _isPlainObject(value: object): boolean {
+ const proto = Object.getPrototypeOf(value);
+ if (proto === null || proto === Object.prototype) return true;
+ const ctor = Object.getPrototypeOf(proto) === null && proto.constructor;
+ return typeof ctor === "function" && ctor.name === "Object";
+ }
+
+ private static _hasOrdinaryObjectTag(value: object): boolean {
+ return Object.prototype.toString.call(value) === "[object Object]";
+ }
+
+ private static _getTypeName(value: object): string {
+ return Object.getPrototypeOf(value)?.constructor?.name ?? "Object";
+ }
+}
+
+const copyFromCloneTypes = [
+ BoundingBox,
+ BoundingFrustum,
+ BoundingSphere,
+ Color,
+ Matrix,
+ Matrix3x3,
+ Plane,
+ Quaternion,
+ Ray,
+ Rect,
+ SphericalHarmonics3,
+ Vector2,
+ Vector3,
+ Vector4
+];
+for (let i = 0, n = copyFromCloneTypes.length; i < n; i++) {
+ registerDefaultCloneMode(copyFromCloneTypes[i], CloneMode.Copy);
+}
diff --git a/packages/core/src/clone/CloneUtils.ts b/packages/core/src/clone/CloneUtils.ts
deleted file mode 100644
index bded5ed474..0000000000
--- a/packages/core/src/clone/CloneUtils.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Component } from "../Component";
-import { Entity } from "../Entity";
-
-/**
- * @internal
- * Utility functions for remapping Entity/Component references during cloning.
- */
-export class CloneUtils {
- private static _tempRemapPath: number[] = [];
-
- static remapEntity(srcRoot: Entity, targetRoot: Entity, entity: Entity): Entity {
- const path = CloneUtils._tempRemapPath;
- if (!CloneUtils._getEntityHierarchyPath(srcRoot, entity, path)) return entity;
- return CloneUtils._getEntityByHierarchyPath(targetRoot, path);
- }
-
- static remapComponent(srcRoot: Entity, targetRoot: Entity, component: T): T {
- const path = CloneUtils._tempRemapPath;
- const srcEntity = component.entity;
- if (!CloneUtils._getEntityHierarchyPath(srcRoot, srcEntity, path)) return component;
- return CloneUtils._getEntityByHierarchyPath(targetRoot, path)._components[
- srcEntity._components.indexOf(component)
- ] as T;
- }
-
- private static _getEntityHierarchyPath(rootEntity: Entity, searchEntity: Entity, inversePath: number[]): boolean {
- inversePath.length = 0;
- while (searchEntity !== rootEntity) {
- const parent = searchEntity.parent;
- if (!parent) {
- return false;
- }
- inversePath.push(searchEntity.siblingIndex);
- searchEntity = parent;
- }
- return true;
- }
-
- private static _getEntityByHierarchyPath(rootEntity: Entity, inversePath: number[]): Entity {
- let entity = rootEntity;
- for (let i = inversePath.length - 1; i >= 0; i--) {
- entity = entity.children[inversePath[i]];
- }
- return entity;
- }
-}
diff --git a/packages/core/src/clone/ComponentCloner.ts b/packages/core/src/clone/ComponentCloner.ts
index f4e1b11651..ac646f192a 100644
--- a/packages/core/src/clone/ComponentCloner.ts
+++ b/packages/core/src/clone/ComponentCloner.ts
@@ -1,42 +1,28 @@
import { Component } from "../Component";
-import { Entity } from "../Entity";
-import { CloneManager } from "./CloneManager";
+import { CloneUtil } from "./CloneUtil";
+import { CloneMode, fieldCloneModesKey } from "./CloneDecorators";
+import type { ICloneHook } from "./ICloneHook";
/**
- * Custom clone interface.
+ * @internal
+ * Clones component fields and runs the component's post-clone hook.
*/
-export interface ICustomClone {
- /**
- * @internal
- */
- _remap?(srcRoot: Entity, targetRoot: Entity): Object;
- /**
- * @internal
- */
- _cloneTo?(target: ICustomClone, srcRoot?: Entity, targetRoot?: Entity): void;
- /**
- * @internal
- */
- copyFrom?(source: ICustomClone): void;
-}
-
export class ComponentCloner {
/**
- * Clone component.
- * @param source - Clone source
- * @param target - Clone target
+ * @internal
*/
- static cloneComponent(
- source: Component,
- target: Component,
- srcRoot: Entity,
- targetRoot: Entity,
- deepInstanceMap: Map
- ): void {
- const cloneModes = CloneManager.getCloneMode(source.constructor);
- for (let k in source) {
- CloneManager.cloneProperty(source, target, k, cloneModes[k], srcRoot, targetRoot, deepInstanceMap);
+ static cloneComponent(source: Component, target: Component, cloneMap: Map): void {
+ const fieldModes = (source)[fieldCloneModesKey];
+ const keys = Object.keys(source);
+ for (let i = 0, n = keys.length; i < n; i++) {
+ const k = keys[i];
+ const fieldMode = fieldModes?.[k];
+ if (fieldMode === CloneMode.Ignore) continue;
+ const sourceValue = source[k];
+ const preset = target[k];
+ const cloned = (target[k] = CloneUtil._cloneFieldValue(sourceValue, preset, cloneMap, fieldMode));
+ CloneUtil._transferSlotOwnership(cloned, sourceValue, preset);
}
- ((source as unknown))._cloneTo?.(target, srcRoot, targetRoot);
+ (>>(source as unknown))._onClone?.(target, cloneMap);
}
}
diff --git a/packages/core/src/clone/ICloneHook.ts b/packages/core/src/clone/ICloneHook.ts
new file mode 100644
index 0000000000..c0173cdcba
--- /dev/null
+++ b/packages/core/src/clone/ICloneHook.ts
@@ -0,0 +1,12 @@
+/**
+ * Extends automatic field cloning with type-specific behavior.
+ * @typeParam T - Clone target type
+ */
+export interface ICloneHook {
+ /**
+ * Called on the source after its fields have been cloned to the target.
+ * @param target - Populated clone target
+ * @param cloneMap - Source-to-clone identity map for the cloned graph
+ */
+ _onClone(target: T, cloneMap: ReadonlyMap): void;
+}
diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts
deleted file mode 100644
index 77158ce3ce..0000000000
--- a/packages/core/src/clone/enums/CloneMode.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * Clone mode.
- */
-export enum CloneMode {
- /** Ignore clone. */
- Ignore,
- /** Assignment clone. */
- Assignment,
- /** Shallow clone. */
- Shallow,
- /** Deep clone. */
- Deep
-}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index dbb7b3efd9..e95af2aeba 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -69,8 +69,8 @@ export * from "./trail/index";
export * from "./env-probe/index";
export * from "./shader/index";
export * from "./Layer";
-export * from "./clone/CloneManager";
-export { CloneUtils } from "./clone/CloneUtils";
+export { deepClone, assignmentClone, ignoreClone } from "./clone/CloneDecorators";
+export type { ICloneHook } from "./clone/ICloneHook";
export * from "./renderingHardwareInterface/index";
export * from "./physics/index";
export * from "./Utils";
diff --git a/packages/core/src/lighting/DirectLight.ts b/packages/core/src/lighting/DirectLight.ts
index 2184474d75..f189bffd66 100644
--- a/packages/core/src/lighting/DirectLight.ts
+++ b/packages/core/src/lighting/DirectLight.ts
@@ -1,5 +1,5 @@
import { Vector3 } from "@galacean/engine-math";
-import { ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { ShaderData } from "../shader";
import { ShaderProperty } from "../shader/ShaderProperty";
import { Light } from "./Light";
diff --git a/packages/core/src/lighting/Light.ts b/packages/core/src/lighting/Light.ts
index 5406de6243..e15fa455e4 100644
--- a/packages/core/src/lighting/Light.ts
+++ b/packages/core/src/lighting/Light.ts
@@ -1,7 +1,7 @@
import { Color, MathUtil, Matrix } from "@galacean/engine-math";
import { Component } from "../Component";
import { Layer } from "../Layer";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { ShadowType } from "../shadow";
/**
@@ -32,7 +32,6 @@ export abstract class Light extends Component {
_lightIndex = -1;
private _shadowStrength = 1.0;
- @deepClone
private _color = new Color(1, 1, 1, 1);
@ignoreClone
private _viewMat: Matrix;
diff --git a/packages/core/src/lighting/SpotLight.ts b/packages/core/src/lighting/SpotLight.ts
index 26dd61545b..91c76a5ab9 100644
--- a/packages/core/src/lighting/SpotLight.ts
+++ b/packages/core/src/lighting/SpotLight.ts
@@ -2,7 +2,7 @@ import { Matrix, Vector3 } from "@galacean/engine-math";
import { ShaderData } from "../shader";
import { ShaderProperty } from "../shader/ShaderProperty";
import { Light } from "./Light";
-import { ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
/**
* Spot light.
diff --git a/packages/core/src/mesh/MeshRenderer.ts b/packages/core/src/mesh/MeshRenderer.ts
index 127eec663c..0c1c23a572 100644
--- a/packages/core/src/mesh/MeshRenderer.ts
+++ b/packages/core/src/mesh/MeshRenderer.ts
@@ -4,7 +4,7 @@ import { RenderContext } from "../RenderPipeline/RenderContext";
import { RenderElement } from "../RenderPipeline/RenderElement";
import { Renderer, RendererUpdateFlags } from "../Renderer";
import { Logger } from "../base/Logger";
-import { ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { Mesh, MeshModifyFlags } from "../graphic/Mesh";
import { ShaderMacro } from "../shader/ShaderMacro";
@@ -76,10 +76,10 @@ export class MeshRenderer extends Renderer {
}
/**
- * @internal
+ * @inheritdoc
*/
- override _cloneTo(target: MeshRenderer): void {
- super._cloneTo(target);
+ override _onClone(target: MeshRenderer): void {
+ super._onClone(target);
target.mesh = this._mesh;
}
diff --git a/packages/core/src/mesh/Skin.ts b/packages/core/src/mesh/Skin.ts
index 18489805c5..3011218bcb 100644
--- a/packages/core/src/mesh/Skin.ts
+++ b/packages/core/src/mesh/Skin.ts
@@ -2,27 +2,23 @@ import { Matrix } from "@galacean/engine-math";
import { Entity } from "../Entity";
import { UpdateFlagManager } from "../UpdateFlagManager";
import { Utils } from "../Utils";
-import { EngineObject } from "../base/EngineObject";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
+import { DataObject } from "../base/DataObject";
+import { ignoreClone } from "../clone/CloneDecorators";
import { SkinnedMeshRenderer } from "./SkinnedMeshRenderer";
/**
* Skin used for skinned mesh renderer.
*/
-export class Skin extends EngineObject {
+export class Skin extends DataObject {
/** Inverse bind matrices. */
- @deepClone
inverseBindMatrices = new Array();
/** @internal */
- @deepClone
_skinMatrices: Float32Array;
/** @internal */
- @ignoreClone
_updatedManager = new UpdateFlagManager();
private _rootBone: Entity;
- @deepClone
private _bones = new Array();
@ignoreClone
private _updateMark = -1;
@@ -65,7 +61,7 @@ export class Skin extends EngineObject {
}
constructor(public name: string) {
- super(null);
+ super();
}
/**
diff --git a/packages/core/src/mesh/SkinnedMeshRenderer.ts b/packages/core/src/mesh/SkinnedMeshRenderer.ts
index 4d7f726184..0077a8aa3d 100644
--- a/packages/core/src/mesh/SkinnedMeshRenderer.ts
+++ b/packages/core/src/mesh/SkinnedMeshRenderer.ts
@@ -4,7 +4,7 @@ import { RenderContext } from "../RenderPipeline/RenderContext";
import { RenderElement } from "../RenderPipeline/RenderElement";
import { RendererUpdateFlags } from "../Renderer";
import { Logger } from "../base/Logger";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { ShaderProperty } from "../shader";
import { Texture2D } from "../texture/Texture2D";
import { TextureFilterMode } from "../texture/enums/TextureFilterMode";
@@ -29,12 +29,10 @@ export class SkinnedMeshRenderer extends MeshRenderer {
@ignoreClone
_condensedBlendShapeWeights: Float32Array;
- @deepClone
private _localBounds: BoundingBox = new BoundingBox();
@ignoreClone
private _jointDataCreateCache: Vector2 = new Vector2(-1, -1);
- @ignoreClone
private _blendShapeWeights: Float32Array;
@ignoreClone
private _maxVertexUniformVectors: number;
@@ -42,7 +40,6 @@ export class SkinnedMeshRenderer extends MeshRenderer {
@ignoreClone
private _jointTexture: Texture2D;
- @deepClone
private _skin: Skin;
/**
@@ -142,16 +139,16 @@ export class SkinnedMeshRenderer extends MeshRenderer {
}
/**
- * @internal
+ * @inheritdoc
*/
- override _cloneTo(target: SkinnedMeshRenderer): void {
- super._cloneTo(target);
-
+ override _onClone(target: SkinnedMeshRenderer): void {
+ super._onClone(target);
+ if (this._jointTexture) {
+ target.shaderData.setTexture(SkinnedMeshRenderer._jointSamplerProperty, null);
+ }
if (this.skin) {
target._applySkin(null, target.skin);
}
-
- this._blendShapeWeights && (target._blendShapeWeights = this._blendShapeWeights.slice());
}
protected override _update(context: RenderContext): void {
@@ -256,7 +253,7 @@ export class SkinnedMeshRenderer extends MeshRenderer {
}
@ignoreClone
- private _onSkinUpdated(type: SkinUpdateFlag, value: Object): void {
+ private _onSkinUpdated(type: SkinUpdateFlag, value: number | Entity): void {
switch (type) {
case SkinUpdateFlag.BoneCountChanged:
const shaderData = this.shaderData;
diff --git a/packages/core/src/particle/ParticleGenerator.ts b/packages/core/src/particle/ParticleGenerator.ts
index b04f3f0130..fed2b05e38 100644
--- a/packages/core/src/particle/ParticleGenerator.ts
+++ b/packages/core/src/particle/ParticleGenerator.ts
@@ -1,6 +1,8 @@
+import { DataObject } from "../base/DataObject";
import { BoundingBox, Color, MathUtil, Matrix, Quaternion, Vector2, Vector3 } from "@galacean/engine-math";
import { Transform } from "../Transform";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
+import type { ICloneHook } from "../clone/ICloneHook";
import { Primitive } from "../graphic/Primitive";
import { SubMesh } from "../graphic/SubMesh";
import { SubPrimitive } from "../graphic/SubPrimitive";
@@ -42,7 +44,7 @@ import { SubEmittersModule } from "./modules/SubEmittersModule";
/**
* Particle Generator.
*/
-export class ParticleGenerator {
+export class ParticleGenerator extends DataObject implements ICloneHook {
private static _tempVector20 = new Vector2();
private static _tempVector21 = new Vector2();
private static _tempVector22 = new Vector2();
@@ -63,40 +65,28 @@ export class ParticleGenerator {
useAutoRandomSeed = true;
/** Main module. */
- @deepClone
readonly main: MainModule;
/** Emission module. */
- @deepClone
readonly emission = new EmissionModule(this);
/** Velocity over lifetime module. */
- @deepClone
readonly velocityOverLifetime: VelocityOverLifetimeModule;
/** Force over lifetime module. */
- @deepClone
readonly forceOverLifetime: ForceOverLifetimeModule;
/** Limit velocity over lifetime module. */
- @deepClone
readonly limitVelocityOverLifetime: LimitVelocityOverLifetimeModule;
/** Size over lifetime module. */
- @deepClone
readonly sizeOverLifetime: SizeOverLifetimeModule;
/** Rotation over lifetime module. */
- @deepClone
readonly rotationOverLifetime = new RotationOverLifetimeModule(this);
/** Color over lifetime module. */
- @deepClone
readonly colorOverLifetime = new ColorOverLifetimeModule(this);
/** Texture sheet animation module. */
- @deepClone
readonly textureSheetAnimation = new TextureSheetAnimationModule(this);
/** Noise module. */
- @deepClone
readonly noise: NoiseModule;
/** Sub emitters module. */
- @deepClone
readonly subEmitters: SubEmittersModule;
/** Custom data module. */
- @deepClone
readonly customData: CustomDataModule;
/** @internal */
@@ -210,6 +200,7 @@ export class ParticleGenerator {
* @internal
*/
constructor(renderer: ParticleRenderer) {
+ super();
this._renderer = renderer;
const subPrimitive = new SubPrimitive();
subPrimitive.start = 0;
@@ -751,9 +742,9 @@ export class ParticleGenerator {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: ParticleGenerator): void {
+ _onClone(target: ParticleGenerator): void {
target._setTransformFeedback();
}
diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts
index 47324c6e75..f2e23a31f5 100644
--- a/packages/core/src/particle/ParticleRenderer.ts
+++ b/packages/core/src/particle/ParticleRenderer.ts
@@ -5,7 +5,7 @@ import { Renderer, RendererUpdateFlags } from "../Renderer";
import { TransformModifyFlags } from "../Transform";
import { GLCapabilityType } from "../base/Constant";
import { Logger } from "../base/Logger";
-import { deepClone, ignoreClone, shallowClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { ModelMesh } from "../mesh/ModelMesh";
import { ShaderMacro } from "../shader/ShaderMacro";
import { ShaderProperty } from "../shader/ShaderProperty";
@@ -30,14 +30,12 @@ export class ParticleRenderer extends Renderer {
private static readonly _currentTime = ShaderProperty.getByName("renderer_CurrentTime");
/** Particle generator. */
- @deepClone
readonly generator: ParticleGenerator;
/** Specifies how much particles stretch depending on their velocity. */
velocityScale = 0;
/** How much are the particles stretched in their direction of motion, defined as the length of the particle compared to its width. */
lengthScale = 2;
/** The pivot of particle. */
- @shallowClone
pivot = new Vector3();
/** @internal */
@@ -270,10 +268,10 @@ export class ParticleRenderer extends Renderer {
}
/**
- * @internal
+ * @inheritdoc
*/
- override _cloneTo(target: ParticleRenderer): void {
- super._cloneTo(target);
+ override _onClone(target: ParticleRenderer): void {
+ super._onClone(target);
target.mesh = this._mesh;
target.renderMode = this._renderMode;
}
diff --git a/packages/core/src/particle/modules/Burst.ts b/packages/core/src/particle/modules/Burst.ts
index a444381dc2..b885ca12c4 100644
--- a/packages/core/src/particle/modules/Burst.ts
+++ b/packages/core/src/particle/modules/Burst.ts
@@ -1,12 +1,11 @@
-import { deepClone } from "../../clone/CloneManager";
+import { DataObject } from "../../base/DataObject";
import { ParticleCompositeCurve } from "./ParticleCompositeCurve";
/**
* A burst is a particle emission event, where a number of particles are all emitted at the same time
*/
-export class Burst {
+export class Burst extends DataObject {
public time: number;
- @deepClone
public count: ParticleCompositeCurve;
private _cycles: number;
@@ -49,6 +48,7 @@ export class Burst {
*/
constructor(time: number, count: ParticleCompositeCurve, cycles: number, repeatInterval: number);
constructor(time: number, count: ParticleCompositeCurve, cycles?: number, repeatInterval?: number) {
+ super();
this.time = time;
this.count = count;
this._cycles = Math.max(cycles ?? 1, 1);
diff --git a/packages/core/src/particle/modules/ColorOverLifetimeModule.ts b/packages/core/src/particle/modules/ColorOverLifetimeModule.ts
index c920fb3fb4..e2632237cb 100644
--- a/packages/core/src/particle/modules/ColorOverLifetimeModule.ts
+++ b/packages/core/src/particle/modules/ColorOverLifetimeModule.ts
@@ -1,5 +1,5 @@
import { Color, Rand, Vector4 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderMacro } from "../../shader/ShaderMacro";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -23,7 +23,6 @@ export class ColorOverLifetimeModule extends ParticleGeneratorModule {
static readonly _gradientKeysCount = ShaderProperty.getByName("renderer_COLGradientKeysMaxTime");
/** Color gradient over lifetime. */
- @deepClone
color = new ParticleCompositeGradient(
new ParticleGradient(
[new GradientColorKey(0.0, new Color(1, 1, 1)), new GradientColorKey(1.0, new Color(1, 1, 1))],
diff --git a/packages/core/src/particle/modules/CustomDataModule.ts b/packages/core/src/particle/modules/CustomDataModule.ts
index 4ad2fa6fc9..3b9ecb91ea 100644
--- a/packages/core/src/particle/modules/CustomDataModule.ts
+++ b/packages/core/src/particle/modules/CustomDataModule.ts
@@ -1,5 +1,4 @@
import { Color, Vector4 } from "@galacean/engine-math";
-import { CloneManager, ignoreClone } from "../../clone/CloneManager";
import { Logger } from "../../base/Logger";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -53,14 +52,10 @@ export class CustomDataModule extends ParticleGeneratorModule {
private static readonly _zeroColor = new Color(0, 0, 0, 0);
private static readonly _zeroVector4 = new Vector4(0, 0, 0, 0);
- @ignoreClone
private _curves: Map = new Map();
- @ignoreClone
private _gradients: Map = new Map();
- @ignoreClone
private _curveStreams: CurveStream[] = [];
- @ignoreClone
private _gradientStreams: GradientStream[] = [];
/**
@@ -186,24 +181,6 @@ export class CustomDataModule extends ParticleGeneratorModule {
this._gradients.delete(name);
}
- /**
- * @internal
- */
- _cloneTo(target: CustomDataModule): void {
- // Shared across both loops so cross-entry sub-object references stay shared in the clone.
- const deepInstanceMap = new Map();
- for (const [name, curve] of this._curves) {
- const clonedCurve = new ParticleCompositeCurve(0);
- CloneManager.deepCloneObject(curve, clonedCurve, deepInstanceMap);
- target.addCurve(name, clonedCurve);
- }
- for (const [name, gradient] of this._gradients) {
- const clonedGradient = new ParticleCompositeGradient(new Color());
- CloneManager.deepCloneObject(gradient, clonedGradient, deepInstanceMap);
- target.addGradient(name, clonedGradient);
- }
- }
-
/**
* @internal
*/
diff --git a/packages/core/src/particle/modules/EmissionModule.ts b/packages/core/src/particle/modules/EmissionModule.ts
index a2d303a7d3..e69b3f79c6 100644
--- a/packages/core/src/particle/modules/EmissionModule.ts
+++ b/packages/core/src/particle/modules/EmissionModule.ts
@@ -1,5 +1,5 @@
import { MathUtil, Rand, Vector3 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData, ShaderMacro } from "../../shader";
import { ParticleCurveMode } from "../enums/ParticleCurveMode";
import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds";
@@ -19,13 +19,10 @@ export class EmissionModule extends ParticleGeneratorModule {
private static _tempEmitPosition = new Vector3();
/** The rate of particle emission. */
- @deepClone
rateOverTime: ParticleCompositeCurve = new ParticleCompositeCurve(10);
/** The rate at which the emitter spawns new particles over distance. */
- @deepClone
rateOverDistance: ParticleCompositeCurve = new ParticleCompositeCurve(0);
- @deepClone
_shape: BaseShape;
/** @internal */
@ignoreClone
@@ -46,7 +43,6 @@ export class EmissionModule extends ParticleGeneratorModule {
@ignoreClone
private _hasLastEmitPosition = false;
- @deepClone
private _bursts: Burst[] = [];
private _currentBurstIndex = 0;
@@ -172,7 +168,11 @@ export class EmissionModule extends ParticleGeneratorModule {
* @internal
*/
_destroy(): void {
- this._shape?._unRegisterOnValueChanged(this._generator._renderer._onGeneratorParamsChanged);
+ const shape = this._shape;
+ if (shape) {
+ shape._unRegisterOnValueChanged(this._generator._renderer._onGeneratorParamsChanged);
+ shape._destroy();
+ }
}
private _emitByRateOverTime(playTime: number): void {
diff --git a/packages/core/src/particle/modules/ForceOverLifetimeModule.ts b/packages/core/src/particle/modules/ForceOverLifetimeModule.ts
index f96ce211c7..3e50c76dc4 100644
--- a/packages/core/src/particle/modules/ForceOverLifetimeModule.ts
+++ b/packages/core/src/particle/modules/ForceOverLifetimeModule.ts
@@ -1,5 +1,5 @@
import { Rand, Vector3 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader";
import { ParticleCurveMode } from "../enums/ParticleCurveMode";
import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds";
@@ -39,11 +39,8 @@ export class ForceOverLifetimeModule extends ParticleGeneratorModule {
@ignoreClone
private _randomModeMacro: ShaderMacro;
- @deepClone
private _forceX: ParticleCompositeCurve;
- @deepClone
private _forceY: ParticleCompositeCurve;
- @deepClone
private _forceZ: ParticleCompositeCurve;
private _space = ParticleSimulationSpace.Local;
diff --git a/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts
index 9d6a91bc54..2f783551be 100644
--- a/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts
+++ b/packages/core/src/particle/modules/LimitVelocityOverLifetimeModule.ts
@@ -1,5 +1,5 @@
import { Rand, Vector2, Vector3 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData, ShaderMacro } from "../../shader";
import { ShaderProperty } from "../../shader/ShaderProperty";
import { ParticleCurveMode } from "../enums/ParticleCurveMode";
@@ -70,14 +70,10 @@ export class LimitVelocityOverLifetimeModule extends ParticleGeneratorModule {
private _dragVelocityMacro: ShaderMacro;
private _separateAxes = false;
- @deepClone
private _speedX: ParticleCompositeCurve;
- @deepClone
private _speedY: ParticleCompositeCurve;
- @deepClone
private _speedZ: ParticleCompositeCurve;
private _dampen: number = 0;
- @deepClone
private _drag: ParticleCompositeCurve;
private _multiplyDragByParticleSize = false;
private _multiplyDragByParticleVelocity = false;
diff --git a/packages/core/src/particle/modules/MainModule.ts b/packages/core/src/particle/modules/MainModule.ts
index d17c156d9a..d27401fea4 100644
--- a/packages/core/src/particle/modules/MainModule.ts
+++ b/packages/core/src/particle/modules/MainModule.ts
@@ -1,7 +1,8 @@
+import { DataObject } from "../../base/DataObject";
import { Color, Rand, Vector3, Vector4 } from "@galacean/engine-math";
import { TransformModifyFlags } from "../../Transform";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
-import { ICustomClone } from "../../clone/ComponentCloner";
+import { ignoreClone } from "../../clone/CloneDecorators";
+import type { ICloneHook } from "../../clone/ICloneHook";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderProperty } from "../../shader/ShaderProperty";
import { ParticleGenerator } from "../ParticleGenerator";
@@ -11,7 +12,7 @@ import { ParticleSimulationSpace } from "../enums/ParticleSimulationSpace";
import { ParticleCompositeCurve } from "./ParticleCompositeCurve";
import { ParticleCompositeGradient } from "./ParticleCompositeGradient";
-export class MainModule implements ICustomClone {
+export class MainModule extends DataObject implements ICloneHook {
private _tempVector40 = new Vector4();
private static _vector3One = new Vector3(1, 1, 1);
@@ -30,25 +31,20 @@ export class MainModule implements ICustomClone {
isLoop = true;
/** Start delay in seconds. */
- @deepClone
startDelay = new ParticleCompositeCurve(0);
/** A flag to enable 3D particle rotation, when disabled, only `startRotationZ` is used. */
startRotation3D = false;
/** The initial rotation of particles around the x-axis when emitted, in degrees. */
- @deepClone
startRotationX = new ParticleCompositeCurve(0);
/** The initial rotation of particles around the y-axis when emitted, in degrees. */
- @deepClone
startRotationY = new ParticleCompositeCurve(0);
/** The initial rotation of particles around the z-axis when emitted, in degrees. */
- @deepClone
startRotationZ = new ParticleCompositeCurve(0);
/** Makes some particles spin in the opposite direction. */
flipRotation = 0;
/** The mode of start color */
- @deepClone
startColor = new ParticleCompositeGradient(new Color(1, 1, 1, 1));
/** A scale that this Particle Generator applies to gravity, defined by Physics.gravity. */
/** Override the default playback speed of the Particle Generator. */
@@ -59,7 +55,6 @@ export class MainModule implements ICustomClone {
playOnEnabled = true;
/** @internal */
- @ignoreClone
_maxParticleBuffer = 1000;
/** @internal */
@ignoreClone
@@ -83,18 +78,12 @@ export class MainModule implements ICustomClone {
@ignoreClone
readonly _gravityModifierRand = new Rand(0, ParticleRandomSubSeeds.GravityModifier);
- @deepClone
private _startLifetime: ParticleCompositeCurve;
- @deepClone
private _startSpeed: ParticleCompositeCurve;
private _startSize3D = false;
- @deepClone
private _startSizeX: ParticleCompositeCurve;
- @deepClone
private _startSizeY: ParticleCompositeCurve;
- @deepClone
private _startSizeZ: ParticleCompositeCurve;
- @deepClone
private _gravityModifier: ParticleCompositeCurve;
private _simulationSpace = ParticleSimulationSpace.Local;
@ignoreClone
@@ -250,6 +239,7 @@ export class MainModule implements ICustomClone {
* @internal
*/
constructor(generator: ParticleGenerator) {
+ super();
this._generator = generator;
this.startLifetime = new ParticleCompositeCurve(5);
@@ -329,11 +319,9 @@ export class MainModule implements ICustomClone {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: MainModule): void {
- target.maxParticles = this.maxParticles;
-
+ _onClone(target: MainModule): void {
if (target._simulationSpace === ParticleSimulationSpace.World) {
target._generator._generateTransformedBounds();
}
diff --git a/packages/core/src/particle/modules/NoiseModule.ts b/packages/core/src/particle/modules/NoiseModule.ts
index 153a40b5b9..8881ab81b3 100644
--- a/packages/core/src/particle/modules/NoiseModule.ts
+++ b/packages/core/src/particle/modules/NoiseModule.ts
@@ -1,5 +1,5 @@
import { Rand, Vector3, Vector4 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData, ShaderMacro, ShaderProperty } from "../../shader";
import { ParticleGenerator } from "../ParticleGenerator";
import { ParticleCurveMode } from "../enums/ParticleCurveMode";
@@ -47,11 +47,8 @@ export class NoiseModule extends ParticleGeneratorModule {
@ignoreClone
private _strengthMinConst = new Vector3();
- @deepClone
private _strengthX: ParticleCompositeCurve;
- @deepClone
private _strengthY: ParticleCompositeCurve;
- @deepClone
private _strengthZ: ParticleCompositeCurve;
private _scrollSpeed = 0;
private _separateAxes = false;
diff --git a/packages/core/src/particle/modules/ParticleCompositeCurve.ts b/packages/core/src/particle/modules/ParticleCompositeCurve.ts
index f84ef57fb3..cdfa5790ee 100644
--- a/packages/core/src/particle/modules/ParticleCompositeCurve.ts
+++ b/packages/core/src/particle/modules/ParticleCompositeCurve.ts
@@ -1,5 +1,6 @@
+import { DataObject } from "../../base/DataObject";
import { Vector2 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { UpdateFlagManager } from "../../UpdateFlagManager";
import { ParticleCurveMode } from "../enums/ParticleCurveMode";
import { CurveKey, ParticleCurve } from "./ParticleCurve";
@@ -7,17 +8,14 @@ import { CurveKey, ParticleCurve } from "./ParticleCurve";
/**
* Particle composite curve.
*/
-export class ParticleCompositeCurve {
+export class ParticleCompositeCurve extends DataObject {
private static _minMaxRange = new Vector2();
- @ignoreClone
private _updateManager = new UpdateFlagManager();
private _mode = ParticleCurveMode.Constant;
private _constantMin = 0;
private _constantMax = 0;
- @deepClone
private _curveMin: ParticleCurve;
- @deepClone
private _curveMax: ParticleCurve;
@ignoreClone
private _updateDispatch: () => void;
@@ -142,6 +140,7 @@ export class ParticleCompositeCurve {
constructor(curveMin: ParticleCurve, curveMax: ParticleCurve);
constructor(constantOrCurve: number | ParticleCurve, constantMaxOrCurveMax?: number | ParticleCurve) {
+ super();
this._updateDispatch = this._updateManager.dispatch.bind(this._updateManager);
if (typeof constantOrCurve === "number") {
if (constantMaxOrCurveMax) {
diff --git a/packages/core/src/particle/modules/ParticleCompositeGradient.ts b/packages/core/src/particle/modules/ParticleCompositeGradient.ts
index e2777bb86b..d2deb3789a 100644
--- a/packages/core/src/particle/modules/ParticleCompositeGradient.ts
+++ b/packages/core/src/particle/modules/ParticleCompositeGradient.ts
@@ -1,27 +1,23 @@
+import { DataObject } from "../../base/DataObject";
import { Color } from "@galacean/engine-math";
-import { deepClone } from "../../clone/CloneManager";
import { ParticleGradientMode } from "../enums/ParticleGradientMode";
import { ParticleGradient } from "./ParticleGradient";
/**
* Particle composite gradient.
*/
-export class ParticleCompositeGradient {
+export class ParticleCompositeGradient extends DataObject {
private static _tempColor = new Color();
/** The gradient mode. */
mode: ParticleGradientMode = ParticleGradientMode.Constant;
/* The min constant color used by the gradient if mode is set to `TwoConstants`. */
- @deepClone
constantMin: Color = new Color();
/* The max constant color used by the gradient if mode is set to `TwoConstants`. */
- @deepClone
constantMax: Color = new Color();
/** The min gradient used by the gradient if mode is set to `Gradient`. */
- @deepClone
gradientMin: ParticleGradient = new ParticleGradient();
/** The max gradient used by the gradient if mode is set to `Gradient`. */
- @deepClone
gradientMax: ParticleGradient = new ParticleGradient();
/**
@@ -46,6 +42,11 @@ export class ParticleCompositeGradient {
this.gradientMax = value;
}
+ /**
+ * Create a particle gradient in constant mode with the default color.
+ */
+ constructor();
+
/**
* Create a particle gradient that generates a constant color.
* @param constant - The constant color
@@ -72,7 +73,9 @@ export class ParticleCompositeGradient {
*/
constructor(gradientMin: ParticleGradient, gradientMax: ParticleGradient);
- constructor(constantOrGradient: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) {
+ constructor(constantOrGradient?: Color | ParticleGradient, constantMaxOrGradientMax?: Color | ParticleGradient) {
+ super();
+ if (!constantOrGradient) return;
if (constantOrGradient.constructor === Color) {
if (constantMaxOrGradientMax) {
this.constantMin.copyFrom(constantOrGradient);
diff --git a/packages/core/src/particle/modules/ParticleCurve.ts b/packages/core/src/particle/modules/ParticleCurve.ts
index fdba01cb62..5f34643ad9 100644
--- a/packages/core/src/particle/modules/ParticleCurve.ts
+++ b/packages/core/src/particle/modules/ParticleCurve.ts
@@ -1,13 +1,12 @@
+import { DataObject } from "../../base/DataObject";
import { UpdateFlagManager } from "../../UpdateFlagManager";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
/**
* Particle curve.
*/
-export class ParticleCurve {
- @ignoreClone
+export class ParticleCurve extends DataObject {
private _updateManager = new UpdateFlagManager();
- @deepClone
private _keys = new Array();
@ignoreClone
private _typeArray: Float32Array;
@@ -27,6 +26,7 @@ export class ParticleCurve {
* @param keys - The keys of the curve
*/
constructor(...keys: CurveKey[]) {
+ super();
this._updateDispatch = this._updateManager.dispatch.bind(this._updateManager);
for (let i = 0, n = keys.length; i < n; i++) {
@@ -195,8 +195,7 @@ export class ParticleCurve {
/**
* The key of the curve.
*/
-export class CurveKey {
- @ignoreClone
+export class CurveKey extends DataObject {
private _updateManager = new UpdateFlagManager();
private _time: number;
private _value: number;
@@ -233,6 +232,7 @@ export class CurveKey {
* Create a new key.
*/
constructor(time: number, value: number) {
+ super();
this._time = time;
this._value = value;
}
diff --git a/packages/core/src/particle/modules/ParticleGeneratorModule.ts b/packages/core/src/particle/modules/ParticleGeneratorModule.ts
index 87f0e7ebfa..fa4fa6b70a 100644
--- a/packages/core/src/particle/modules/ParticleGeneratorModule.ts
+++ b/packages/core/src/particle/modules/ParticleGeneratorModule.ts
@@ -1,4 +1,5 @@
-import { ignoreClone } from "../../clone/CloneManager";
+import { DataObject } from "../../base/DataObject";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData, ShaderMacro } from "../../shader";
import { ParticleGenerator } from "../ParticleGenerator";
import { ParticleCompositeCurve } from "./ParticleCompositeCurve";
@@ -6,7 +7,7 @@ import { ParticleCompositeCurve } from "./ParticleCompositeCurve";
/**
* Particle generator module.
*/
-export abstract class ParticleGeneratorModule {
+export abstract class ParticleGeneratorModule extends DataObject {
/** @internal */
@ignoreClone
_generator: ParticleGenerator;
@@ -28,6 +29,7 @@ export abstract class ParticleGeneratorModule {
* @internal
*/
constructor(generator: ParticleGenerator) {
+ super();
this._generator = generator;
}
diff --git a/packages/core/src/particle/modules/ParticleGradient.ts b/packages/core/src/particle/modules/ParticleGradient.ts
index 732f879f5c..0dd2902a3f 100644
--- a/packages/core/src/particle/modules/ParticleGradient.ts
+++ b/packages/core/src/particle/modules/ParticleGradient.ts
@@ -1,13 +1,12 @@
+import { DataObject } from "../../base/DataObject";
import { Color } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
/**
* Particle gradient.
*/
-export class ParticleGradient {
- @deepClone
+export class ParticleGradient extends DataObject {
private _colorKeys: GradientColorKey[] = [];
- @deepClone
private _alphaKeys: GradientAlphaKey[] = [];
@ignoreClone
private _colorTypeArray: Float32Array;
@@ -37,6 +36,7 @@ export class ParticleGradient {
* @param alphaKeys - The alpha keys of the gradient
*/
constructor(colorKeys: GradientColorKey[] = null, alphaKeys: GradientAlphaKey[] = null) {
+ super();
if (colorKeys) {
for (let i = 0, n = colorKeys.length; i < n; i++) {
const key = colorKeys[i];
@@ -286,7 +286,7 @@ export class ParticleGradient {
/**
* The color key of the particle gradient.
*/
-export class GradientColorKey {
+export class GradientColorKey extends DataObject {
/** @internal */
_onValueChanged: () => void = null;
@@ -324,6 +324,7 @@ export class GradientColorKey {
* @param color - The alpha component of the gradient colorKey
*/
constructor(time: number, color: Color) {
+ super();
this._time = time;
color && this._color.copyFrom(color);
// @ts-ignore
@@ -334,7 +335,7 @@ export class GradientColorKey {
/**
* The alpha key of the particle gradient.
*/
-export class GradientAlphaKey {
+export class GradientAlphaKey extends DataObject {
/** @internal */
_onValueChanged: () => void = null;
@@ -371,6 +372,7 @@ export class GradientAlphaKey {
* @param alpha - The alpha component of the gradient alpha key
*/
constructor(time: number, alpha: number) {
+ super();
this._time = time;
this._alpha = alpha;
}
diff --git a/packages/core/src/particle/modules/RotationOverLifetimeModule.ts b/packages/core/src/particle/modules/RotationOverLifetimeModule.ts
index 557f5b983b..0bf5327d8e 100644
--- a/packages/core/src/particle/modules/RotationOverLifetimeModule.ts
+++ b/packages/core/src/particle/modules/RotationOverLifetimeModule.ts
@@ -1,5 +1,5 @@
import { Rand, Vector3 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderMacro } from "../../shader/ShaderMacro";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -29,13 +29,10 @@ export class RotationOverLifetimeModule extends ParticleGeneratorModule {
/** Specifies whether the rotation is separate on each axis, when disabled, only `rotationZ` is used. */
separateAxes: boolean = false;
/** Rotation over lifetime for x axis, in degrees. */
- @deepClone
rotationX = new ParticleCompositeCurve(0);
/** Rotation over lifetime for y axis, in degrees. */
- @deepClone
rotationY = new ParticleCompositeCurve(0);
/** Rotation over lifetime for z axis, in degrees. */
- @deepClone
rotationZ = new ParticleCompositeCurve(45);
/** @internal */
diff --git a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts
index 2388bc247c..44557bcd68 100644
--- a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts
+++ b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts
@@ -1,5 +1,5 @@
import { Rand } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderMacro } from "../../shader/ShaderMacro";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -30,11 +30,8 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule {
_sizeRand = new Rand(0, ParticleRandomSubSeeds.SizeOverLifetime);
private _separateAxes = false;
- @deepClone
private _sizeX: ParticleCompositeCurve;
- @deepClone
private _sizeY: ParticleCompositeCurve;
- @deepClone
private _sizeZ: ParticleCompositeCurve;
@ignoreClone
diff --git a/packages/core/src/particle/modules/SubEmitter.ts b/packages/core/src/particle/modules/SubEmitter.ts
index c9c740207e..1c199d6dde 100644
--- a/packages/core/src/particle/modules/SubEmitter.ts
+++ b/packages/core/src/particle/modules/SubEmitter.ts
@@ -1,4 +1,5 @@
-import { ignoreClone } from "../../clone/CloneManager";
+import { DataObject } from "../../base/DataObject";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ParticleRenderer } from "../ParticleRenderer";
import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty";
import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType";
@@ -8,7 +9,7 @@ import type { SubEmittersModule } from "./SubEmittersModule";
* One slot in `SubEmittersModule.subEmitters`. Configures which sub-emitter
* fires, on which parent event, with what inheritance, probability, and count.
*/
-export class SubEmitter {
+export class SubEmitter extends DataObject {
/** Bitmask of properties inherited from the parent particle. */
inheritProperties: ParticleSubEmitterInheritProperty = ParticleSubEmitterInheritProperty.None;
@@ -19,7 +20,6 @@ export class SubEmitter {
emitCount: number = 1;
/** @internal */
- @ignoreClone
_module: SubEmittersModule = null;
private _emitter: ParticleRenderer = null;
diff --git a/packages/core/src/particle/modules/SubEmittersModule.ts b/packages/core/src/particle/modules/SubEmittersModule.ts
index 90d19e5feb..c63e7bc2df 100644
--- a/packages/core/src/particle/modules/SubEmittersModule.ts
+++ b/packages/core/src/particle/modules/SubEmittersModule.ts
@@ -1,5 +1,5 @@
import { Color, Rand, Vector3 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ParticleRandomSubSeeds } from "../enums/ParticleRandomSubSeeds";
import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty";
import { ParticleSubEmitterType } from "../enums/ParticleSubEmitterType";
@@ -44,7 +44,6 @@ export class SubEmittersModule extends ParticleGeneratorModule {
return found;
}
- @deepClone
private _subEmitters: SubEmitter[] = [];
/**
@@ -168,17 +167,6 @@ export class SubEmittersModule extends ParticleGeneratorModule {
return false;
}
- /**
- * @internal
- */
- _cloneTo(target: SubEmittersModule): void {
- // _module is @ignoreClone, so re-link each cloned slot back to its new module
- const subEmitters = target._subEmitters;
- for (let i = 0, n = subEmitters.length; i < n; i++) {
- subEmitters[i]._module = target;
- }
- }
-
/**
* @internal
*/
diff --git a/packages/core/src/particle/modules/TextureSheetAnimationModule.ts b/packages/core/src/particle/modules/TextureSheetAnimationModule.ts
index c75601c339..868e78bfa1 100644
--- a/packages/core/src/particle/modules/TextureSheetAnimationModule.ts
+++ b/packages/core/src/particle/modules/TextureSheetAnimationModule.ts
@@ -1,5 +1,5 @@
import { Rand, Vector2, Vector3 } from "@galacean/engine-math";
-import { deepClone, ignoreClone, shallowClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderMacro } from "../../shader/ShaderMacro";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -24,7 +24,6 @@ export class TextureSheetAnimationModule extends ParticleGeneratorModule {
private static readonly _tillingParamsProperty = ShaderProperty.getByName("renderer_TSATillingParams");
/** Frame over time curve of the texture sheet. */
- @deepClone
readonly frameOverTime = new ParticleCompositeCurve(new ParticleCurve(new CurveKey(0, 0), new CurveKey(1, 1)));
/** Texture sheet animation type. */
type = TextureSheetAnimationType.WholeSheet;
@@ -32,13 +31,11 @@ export class TextureSheetAnimationModule extends ParticleGeneratorModule {
cycleCount = 1;
/** @internal */
- @shallowClone
_tillingInfo = new Vector3(1, 1, 1); // x:subU, y:subV, z:tileCount
/** @internal */
@ignoreClone
_frameOverTimeRand = new Rand(0, ParticleRandomSubSeeds.TextureSheetAnimation);
- @deepClone
private _tiling = new Vector2(1, 1);
@ignoreClone
private _frameCurveMacro: ShaderMacro;
diff --git a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts
index 67cb113ba5..c9cab6bab6 100644
--- a/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts
+++ b/packages/core/src/particle/modules/VelocityOverLifetimeModule.ts
@@ -1,5 +1,5 @@
import { Rand, Vector3 } from "@galacean/engine-math";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { ShaderMacro } from "../../shader";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -74,21 +74,13 @@ export class VelocityOverLifetimeModule extends ParticleGeneratorModule {
@ignoreClone
private _radialRandomModeMacro: ShaderMacro;
- @deepClone
private _velocityX: ParticleCompositeCurve;
- @deepClone
private _velocityY: ParticleCompositeCurve;
- @deepClone
private _velocityZ: ParticleCompositeCurve;
- @deepClone
private _orbitalX: ParticleCompositeCurve;
- @deepClone
private _orbitalY: ParticleCompositeCurve;
- @deepClone
private _orbitalZ: ParticleCompositeCurve;
- @deepClone
private _radial: ParticleCompositeCurve;
- @deepClone
private _offset = new Vector3();
private _space = ParticleSimulationSpace.Local;
diff --git a/packages/core/src/particle/modules/shape/BaseShape.ts b/packages/core/src/particle/modules/shape/BaseShape.ts
index 9e00891b59..2be9908f1b 100644
--- a/packages/core/src/particle/modules/shape/BaseShape.ts
+++ b/packages/core/src/particle/modules/shape/BaseShape.ts
@@ -1,12 +1,13 @@
import { BoundingBox, MathUtil, Matrix, Quaternion, Rand, Vector2, Vector3 } from "@galacean/engine-math";
-import { ParticleShapeType } from "./enums/ParticleShapeType";
import { UpdateFlagManager } from "../../../UpdateFlagManager";
-import { deepClone, ignoreClone } from "../../../clone/CloneManager";
+import { DataObject } from "../../../base/DataObject";
+import { ignoreClone } from "../../../clone/CloneDecorators";
+import { ParticleShapeType } from "./enums/ParticleShapeType";
/**
* Base class for all particle shapes.
*/
-export abstract class BaseShape {
+export abstract class BaseShape extends DataObject {
/** @internal */
static _tempVector20 = new Vector2();
/** @internal */
@@ -18,18 +19,13 @@ export abstract class BaseShape {
private static _tempQuaternion = new Quaternion();
/** The type of shape to emit particles from. */
abstract readonly shapeType: ParticleShapeType;
-
- @ignoreClone
protected _updateManager = new UpdateFlagManager();
private _enabled = true;
private _randomDirectionAmount = 0;
- @deepClone
private _position = new Vector3(0, 0, 0);
- @deepClone
private _rotation = new Vector3(0, 0, 0);
- @deepClone
private _scale = new Vector3(1, 1, 1);
@ignoreClone
private _matrix = new Matrix();
@@ -106,6 +102,7 @@ export abstract class BaseShape {
}
constructor() {
+ super();
// @ts-ignore
this._position._onValueChanged = this._onTransformChanged;
// @ts-ignore
@@ -128,6 +125,11 @@ export abstract class BaseShape {
this._updateManager.removeListener(listener);
}
+ /**
+ * @internal
+ */
+ _destroy(): void {}
+
/**
* @internal
*/
diff --git a/packages/core/src/particle/modules/shape/BoxShape.ts b/packages/core/src/particle/modules/shape/BoxShape.ts
index 7ad762e2e8..e1a0796979 100644
--- a/packages/core/src/particle/modules/shape/BoxShape.ts
+++ b/packages/core/src/particle/modules/shape/BoxShape.ts
@@ -1,5 +1,4 @@
import { Rand, Vector3 } from "@galacean/engine-math";
-import { deepClone } from "../../../clone/CloneManager";
import { BaseShape } from "./BaseShape";
import { ShapeUtils } from "./ShapeUtils";
import { ParticleShapeType } from "./enums/ParticleShapeType";
@@ -10,7 +9,6 @@ import { ParticleShapeType } from "./enums/ParticleShapeType";
export class BoxShape extends BaseShape {
readonly shapeType = ParticleShapeType.Box;
- @deepClone
private _size = new Vector3(1, 1, 1);
/**
diff --git a/packages/core/src/particle/modules/shape/MeshShape.ts b/packages/core/src/particle/modules/shape/MeshShape.ts
index e425003704..7fd19e7b68 100644
--- a/packages/core/src/particle/modules/shape/MeshShape.ts
+++ b/packages/core/src/particle/modules/shape/MeshShape.ts
@@ -1,7 +1,7 @@
import { Rand, Vector3, Vector4 } from "@galacean/engine-math";
import { TypedArray } from "../../../base";
-import { ignoreClone } from "../../../clone/CloneManager";
-import { Entity } from "../../../Entity";
+import { ignoreClone } from "../../../clone/CloneDecorators";
+import type { ICloneHook } from "../../../clone/ICloneHook";
import { VertexElement } from "../../../graphic";
import { MeshModifyFlags } from "../../../graphic/Mesh";
import { ModelMesh, VertexAttribute } from "../../../mesh";
@@ -11,7 +11,7 @@ import { ParticleShapeType } from "./enums/ParticleShapeType";
/**
* Particle shape that emits particles from a mesh.
*/
-export class MeshShape extends BaseShape {
+export class MeshShape extends BaseShape implements ICloneHook {
readonly shapeType = ParticleShapeType.Mesh;
@ignoreClone
@@ -51,6 +51,13 @@ export class MeshShape extends BaseShape {
}
}
+ /**
+ * @internal
+ */
+ override _destroy(): void {
+ this.mesh = null;
+ }
+
/**
* @internal
*/
@@ -164,9 +171,9 @@ export class MeshShape extends BaseShape {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: MeshShape): void {
+ _onClone(target: MeshShape): void {
target.mesh = this._mesh;
}
}
diff --git a/packages/core/src/physics/CharacterController.ts b/packages/core/src/physics/CharacterController.ts
index f3c9e6f048..6bb6b3d272 100644
--- a/packages/core/src/physics/CharacterController.ts
+++ b/packages/core/src/physics/CharacterController.ts
@@ -5,7 +5,7 @@ import { Entity } from "../Entity";
import { Collider } from "./Collider";
import { ControllerNonWalkableMode } from "./enums/ControllerNonWalkableMode";
import { ColliderShape } from "./shape";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
/**
* The character controllers.
@@ -13,7 +13,6 @@ import { deepClone, ignoreClone } from "../clone/CloneManager";
export class CharacterController extends Collider {
private _stepOffset = 0.5;
private _nonWalkableMode: ControllerNonWalkableMode = ControllerNonWalkableMode.PreventClimbing;
- @deepClone
private _upDirection = new Vector3(0, 1, 0);
private _slopeLimit = 45;
diff --git a/packages/core/src/physics/Collider.ts b/packages/core/src/physics/Collider.ts
index a8a591fe56..f83aca0f84 100644
--- a/packages/core/src/physics/Collider.ts
+++ b/packages/core/src/physics/Collider.ts
@@ -1,7 +1,7 @@
import { ICollider, IStaticCollider } from "@galacean/engine-design";
import { BoolUpdateFlag } from "../BoolUpdateFlag";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
-import { ICustomClone } from "../clone/ComponentCloner";
+import { ignoreClone } from "../clone/CloneDecorators";
+import type { ICloneHook } from "../clone/ICloneHook";
import { Component } from "../Component";
import { DependentMode, dependentComponents } from "../ComponentsDependencies";
import { Entity } from "../Entity";
@@ -15,16 +15,14 @@ import { ColliderShapeChangeFlag } from "./enums/ColliderShapeChangeFlag";
* @decorator `@dependentComponents(Transform, DependentMode.CheckOnly)`
*/
@dependentComponents(Transform, DependentMode.CheckOnly)
-export class Collider extends Component implements ICustomClone {
+export class Collider extends Component implements ICloneHook {
/** @internal */
@ignoreClone
_index: number = -1;
/** @internal */
@ignoreClone
_nativeCollider: ICollider;
- @ignoreClone
protected _updateFlag: BoolUpdateFlag;
- @deepClone
protected _shapes: ColliderShape[] = [];
protected _collisionLayerIndex: number = 0;
@@ -73,8 +71,8 @@ export class Collider extends Component implements ICustomClone {
if (oldCollider) {
oldCollider.removeShape(shape);
}
- this._shapes.push(shape);
this._addNativeShape(shape);
+ this._shapes.push(shape);
this._handleShapesChanged(ColliderShapeChangeFlag.Count);
}
}
@@ -144,9 +142,9 @@ export class Collider extends Component implements ICustomClone {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: Collider): void {
+ _onClone(target: Collider): void {
target._syncNative();
}
@@ -159,6 +157,22 @@ export class Collider extends Component implements ICustomClone {
}
}
+ /**
+ * @internal
+ */
+ _setNativeShapeAttached(shape: ColliderShape, attached: boolean): void {
+ const nativeShape = shape._nativeShape;
+ if (nativeShape && shape._isShapeAttached !== attached) {
+ if (attached) {
+ nativeShape.setWorldScale(this.entity.transform.lossyWorldScale);
+ this._nativeCollider.addShape(nativeShape);
+ } else {
+ this._nativeCollider.removeShape(nativeShape);
+ }
+ shape._isShapeAttached = attached;
+ }
+ }
+
protected _syncNative(): void {
for (let i = 0, n = this.shapes.length; i < n; i++) {
this._addNativeShape(this.shapes[i]);
@@ -182,18 +196,13 @@ export class Collider extends Component implements ICustomClone {
}
protected _addNativeShape(shape: ColliderShape): void {
+ this._setNativeShapeAttached(shape, true);
shape._collider = this;
- if (shape._nativeShape) {
- shape._nativeShape.setWorldScale(this.entity.transform.lossyWorldScale);
- this._nativeCollider.addShape(shape._nativeShape);
- }
}
protected _removeNativeShape(shape: ColliderShape): void {
+ this._setNativeShapeAttached(shape, false);
shape._collider = null;
- if (shape._nativeShape) {
- this._nativeCollider.removeShape(shape._nativeShape);
- }
}
private _setCollisionLayer(): void {
diff --git a/packages/core/src/physics/DynamicCollider.ts b/packages/core/src/physics/DynamicCollider.ts
index 5c8725c604..dd958819f5 100644
--- a/packages/core/src/physics/DynamicCollider.ts
+++ b/packages/core/src/physics/DynamicCollider.ts
@@ -1,6 +1,6 @@
import { IDynamicCollider } from "@galacean/engine-design";
import { Quaternion, Vector3 } from "@galacean/engine-math";
-import { ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { Engine } from "../Engine";
import { Entity } from "../Entity";
import { Collider } from "./Collider";
@@ -462,14 +462,14 @@ export class DynamicCollider extends Collider {
}
/**
- * @internal
+ * @inheritdoc
*/
- override _cloneTo(target: DynamicCollider): void {
+ override _onClone(target: DynamicCollider): void {
target._linearVelocity.copyFrom(this.linearVelocity);
target._angularVelocity.copyFrom(this.angularVelocity);
target._centerOfMass.copyFrom(this.centerOfMass);
target._inertiaTensor.copyFrom(this.inertiaTensor);
- super._cloneTo(target);
+ super._onClone(target);
}
/**
@@ -484,6 +484,8 @@ export class DynamicCollider extends Collider {
}
protected override _syncNative(): void {
+ // Non-convex triangle meshes require a kinematic native actor before attachment
+ (this._nativeCollider).setIsKinematic(this._isKinematic);
super._syncNative();
(this._nativeCollider).setLinearDamping(this._linearDamping);
(this._nativeCollider).setAngularDamping(this._angularDamping);
@@ -501,7 +503,6 @@ export class DynamicCollider extends Collider {
(this._nativeCollider).setSleepThreshold(this._sleepThreshold);
(this._nativeCollider).setSolverIterations(this._solverIterations);
(this._nativeCollider).setUseGravity(this._useGravity);
- (this._nativeCollider).setIsKinematic(this._isKinematic);
(this._nativeCollider).setConstraints(this._constraints);
(this._nativeCollider).setCollisionDetectionMode(this._collisionDetectionMode);
}
diff --git a/packages/core/src/physics/joint/HingeJoint.ts b/packages/core/src/physics/joint/HingeJoint.ts
index acffbae4b6..bb46e1c9fe 100644
--- a/packages/core/src/physics/joint/HingeJoint.ts
+++ b/packages/core/src/physics/joint/HingeJoint.ts
@@ -6,20 +6,17 @@ import { HingeJointFlag } from "../enums/HingeJointFlag";
import { Joint } from "./Joint";
import { JointLimits } from "./JointLimits";
import { JointMotor } from "./JointMotor";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { Entity } from "../../Entity";
/**
* A joint which behaves in a similar way to a hinge or axle.
*/
export class HingeJoint extends Joint {
- @deepClone
private _axis = new Vector3(1, 0, 0);
private _hingeFlags = HingeJointFlag.None;
private _useSpring = false;
- @deepClone
private _jointMotor: JointMotor;
- @deepClone
private _limits: JointLimits;
private _angle = 0;
private _velocity = 0;
diff --git a/packages/core/src/physics/joint/Joint.ts b/packages/core/src/physics/joint/Joint.ts
index 8d9f8af178..5b60236a2d 100644
--- a/packages/core/src/physics/joint/Joint.ts
+++ b/packages/core/src/physics/joint/Joint.ts
@@ -1,10 +1,11 @@
+import { DataObject } from "../../base/DataObject";
import { IJoint } from "@galacean/engine-design";
import { Matrix, Quaternion, Vector3 } from "@galacean/engine-math";
import { Component } from "../../Component";
import { DependentMode, dependentComponents } from "../../ComponentsDependencies";
import { Entity } from "../../Entity";
import { TransformModifyFlags } from "../../Transform";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { Collider } from "../Collider";
import { DynamicCollider } from "../DynamicCollider";
@@ -18,9 +19,7 @@ export abstract class Joint extends Component {
private static _tempQuat = new Quaternion();
private static _tempMatrix = new Matrix();
- @deepClone
protected _colliderInfo = new JointColliderInfo();
- @deepClone
protected _connectedColliderInfo = new JointColliderInfo();
@ignoreClone
protected _nativeJoint: IJoint;
@@ -321,11 +320,9 @@ enum AnchorOwner {
/**
* @internal
*/
-class JointColliderInfo {
+class JointColliderInfo extends DataObject {
collider: Collider = null;
- @deepClone
anchor = new Vector3();
- @deepClone
actualAnchor = new Vector3();
massScale: number = 1;
inertiaScale: number = 1;
diff --git a/packages/core/src/physics/joint/JointLimits.ts b/packages/core/src/physics/joint/JointLimits.ts
index 3d7ff811be..4369abb48d 100644
--- a/packages/core/src/physics/joint/JointLimits.ts
+++ b/packages/core/src/physics/joint/JointLimits.ts
@@ -1,11 +1,10 @@
-import { deepClone } from "../../clone/CloneManager";
+import { DataObject } from "../../base/DataObject";
import { UpdateFlagManager } from "../../UpdateFlagManager";
/**
* JointLimits is used to limit the joints angle.
*/
-export class JointLimits {
- @deepClone
+export class JointLimits extends DataObject {
/** @internal */
_updateFlagManager = new UpdateFlagManager();
diff --git a/packages/core/src/physics/joint/JointMotor.ts b/packages/core/src/physics/joint/JointMotor.ts
index 0f381c1a6c..73024929fa 100644
--- a/packages/core/src/physics/joint/JointMotor.ts
+++ b/packages/core/src/physics/joint/JointMotor.ts
@@ -1,11 +1,10 @@
-import { deepClone } from "../../clone/CloneManager";
+import { DataObject } from "../../base/DataObject";
import { UpdateFlagManager } from "../../UpdateFlagManager";
/**
* The JointMotor is used to motorize a joint.
*/
-export class JointMotor {
- @deepClone
+export class JointMotor extends DataObject {
/** @internal */
_updateFlagManager = new UpdateFlagManager();
diff --git a/packages/core/src/physics/shape/BoxColliderShape.ts b/packages/core/src/physics/shape/BoxColliderShape.ts
index 12c32a0a4b..32dc75d1c0 100644
--- a/packages/core/src/physics/shape/BoxColliderShape.ts
+++ b/packages/core/src/physics/shape/BoxColliderShape.ts
@@ -2,13 +2,12 @@ import { ColliderShape } from "./ColliderShape";
import { IBoxColliderShape } from "@galacean/engine-design";
import { Vector3 } from "@galacean/engine-math";
import { Engine } from "../../Engine";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneDecorators";
/**
* Physical collider shape for box.
*/
export class BoxColliderShape extends ColliderShape {
- @deepClone
private _size: Vector3 = new Vector3(1, 1, 1);
/**
diff --git a/packages/core/src/physics/shape/ColliderShape.ts b/packages/core/src/physics/shape/ColliderShape.ts
index 2ac82319fd..b710d3a611 100644
--- a/packages/core/src/physics/shape/ColliderShape.ts
+++ b/packages/core/src/physics/shape/ColliderShape.ts
@@ -1,31 +1,34 @@
+import { DataObject } from "../../base/DataObject";
import { IColliderShape } from "@galacean/engine-design";
import { PhysicsMaterial } from "../PhysicsMaterial";
import { Vector3 } from "@galacean/engine-math";
import { Collider } from "../Collider";
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
-import { ICustomClone } from "../../clone/ComponentCloner";
+import { ignoreClone } from "../../clone/CloneDecorators";
+import type { ICloneHook } from "../../clone/ICloneHook";
import { Engine } from "../../Engine";
import { ColliderShapeChangeFlag } from "../enums/ColliderShapeChangeFlag";
/**
* Abstract class for collider shapes.
*/
-export abstract class ColliderShape implements ICustomClone {
+export abstract class ColliderShape extends DataObject implements ICloneHook {
private static _idGenerator: number = 0;
/** @internal */
+ @ignoreClone
_collider: Collider;
/** @internal */
@ignoreClone
_nativeShape: IColliderShape;
+ /** @internal */
+ @ignoreClone
+ _isShapeAttached: boolean = false;
@ignoreClone
protected _id: number;
protected _material: PhysicsMaterial;
private _isTrigger: boolean = false;
- @deepClone
private _rotation: Vector3 = new Vector3();
- @deepClone
private _position: Vector3 = new Vector3();
private _contactOffset: number = 0.02;
@@ -124,6 +127,7 @@ export abstract class ColliderShape implements ICustomClone {
}
protected constructor() {
+ super();
this._material = new PhysicsMaterial();
this._id = ColliderShape._idGenerator++;
@@ -162,9 +166,9 @@ export abstract class ColliderShape implements ICustomClone {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: ColliderShape) {
+ _onClone(target: ColliderShape): void {
target._syncNative();
}
diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts
index 51a46ab0e0..856533ad12 100644
--- a/packages/core/src/physics/shape/MeshColliderShape.ts
+++ b/packages/core/src/physics/shape/MeshColliderShape.ts
@@ -2,6 +2,7 @@ import { IMeshColliderShape } from "@galacean/engine-design";
import { Engine } from "../../Engine";
import { ModelMesh } from "../../mesh/ModelMesh";
import { Vector3 } from "@galacean/engine-math";
+import { ignoreClone } from "../../clone/CloneDecorators";
import { DynamicCollider } from "../DynamicCollider";
import { MeshColliderShapeCookingFlag } from "../enums/MeshColliderShapeCookingFlag";
import { ColliderShape } from "./ColliderShape";
@@ -10,12 +11,14 @@ import { ColliderShape } from "./ColliderShape";
* Collider shape based on mesh geometry, supporting both convex hull and triangle mesh modes.
*/
export class MeshColliderShape extends ColliderShape {
+ @ignoreClone
private _mesh: ModelMesh = null;
private _isConvex = false;
+ @ignoreClone
private _positions: Vector3[] = null;
+ @ignoreClone
private _indices: Uint8Array | Uint16Array | Uint32Array | null = null;
private _cookingFlags = MeshColliderShapeCookingFlag.Cleaning | MeshColliderShapeCookingFlag.VertexWelding;
- private _isShapeAttached = false;
/**
* Cooking flags for this mesh collider shape.
@@ -98,6 +101,14 @@ export class MeshColliderShape extends ColliderShape {
return super.getClosestPoint(point, outClosestPoint);
}
+ /**
+ * @inheritdoc
+ */
+ override _onClone(target: MeshColliderShape): void {
+ target.mesh = this._mesh;
+ super._onClone(target);
+ }
+
/**
* @internal
*/
@@ -117,9 +128,7 @@ export class MeshColliderShape extends ColliderShape {
private _destroyNativeShape(): void {
if (this._nativeShape) {
- if (this._isShapeAttached) {
- this._detachFromCollider();
- }
+ this._collider?._setNativeShapeAttached(this, false);
this._nativeShape.destroy();
this._nativeShape = null;
}
@@ -153,31 +162,13 @@ export class MeshColliderShape extends ColliderShape {
}
private _updateNativeShapeData(): void {
- if (
- (this._nativeShape).setMeshData(
- this._positions,
- this._indices,
- this._isConvex,
- this._cookingFlags
- )
- ) {
- // Re-add to collider if previously removed due to cooking failure
- if (this._collider && !this._isShapeAttached) {
- this._attachToCollider();
- }
- } else if (this._isShapeAttached) {
- this._detachFromCollider();
- }
- }
-
- private _detachFromCollider(): void {
- this._collider._nativeCollider.removeShape(this._nativeShape);
- this._isShapeAttached = false;
- }
-
- private _attachToCollider(): void {
- this._collider._nativeCollider.addShape(this._nativeShape);
- this._isShapeAttached = true;
+ const succeeded = (this._nativeShape).setMeshData(
+ this._positions,
+ this._indices,
+ this._isConvex,
+ this._cookingFlags
+ );
+ this._collider?._setNativeShapeAttached(this, succeeded);
}
private _createNativeShape(): void {
@@ -205,10 +196,6 @@ export class MeshColliderShape extends ColliderShape {
// Sync base class properties (position, rotation, contactOffset, isTrigger, material)
super._syncNative();
- // If already attached to a collider, add the newly created native shape to it
- if (this._collider) {
- nativeShape.setWorldScale(this._collider.entity.transform.lossyWorldScale);
- this._attachToCollider();
- }
+ this._collider?._setNativeShapeAttached(this, true);
}
}
diff --git a/packages/core/src/postProcess/PostProcess.ts b/packages/core/src/postProcess/PostProcess.ts
index 66521a02f1..0a926f49ee 100644
--- a/packages/core/src/postProcess/PostProcess.ts
+++ b/packages/core/src/postProcess/PostProcess.ts
@@ -1,5 +1,4 @@
import { Logger } from "../base";
-import { deepClone } from "../clone/CloneManager";
import { Component } from "../Component";
import { Layer } from "../Layer";
import { PostProcessEffect } from "./PostProcessEffect";
@@ -19,7 +18,6 @@ export class PostProcess extends Component {
blendDistance = 0;
/** @internal */
- @deepClone
_effects: PostProcessEffect[] = [];
private _priority = 0;
diff --git a/packages/core/src/postProcess/PostProcessEffect.ts b/packages/core/src/postProcess/PostProcessEffect.ts
index a2a29d382a..d976861d04 100644
--- a/packages/core/src/postProcess/PostProcessEffect.ts
+++ b/packages/core/src/postProcess/PostProcessEffect.ts
@@ -1,9 +1,10 @@
+import { DataObject } from "../base/DataObject";
import { PostProcessEffectParameter } from "./PostProcessEffectParameter";
/**
* The base class for post process effect.
*/
-export class PostProcessEffect {
+export class PostProcessEffect extends DataObject {
private _enabled = true;
private _parameters: PostProcessEffectParameter[] = [];
private _parameterInitialized = false;
@@ -56,7 +57,7 @@ export class PostProcessEffect {
private _getParameters(): PostProcessEffectParameter[] {
if (!this._parameterInitialized) {
this._parameterInitialized = true;
- for (let key in this) {
+ for (const key in this) {
const value = this[key];
if (value instanceof PostProcessEffectParameter) {
this._parameters.push(value);
diff --git a/packages/core/src/postProcess/PostProcessEffectParameter.ts b/packages/core/src/postProcess/PostProcessEffectParameter.ts
index 899860ba76..15e16ed442 100644
--- a/packages/core/src/postProcess/PostProcessEffectParameter.ts
+++ b/packages/core/src/postProcess/PostProcessEffectParameter.ts
@@ -1,3 +1,4 @@
+import { DataObject } from "../base/DataObject";
import { Color, MathUtil, Vector2, Vector3, Vector4 } from "@galacean/engine-math";
import { Texture } from "../texture";
@@ -6,7 +7,7 @@ import { Texture } from "../texture";
* @remarks
* The parameter will be mixed to a final value and be used in post process manager.
*/
-export abstract class PostProcessEffectParameter {
+export abstract class PostProcessEffectParameter extends DataObject {
/**
* Whether the parameter is enabled.
*/
@@ -27,6 +28,7 @@ export abstract class PostProcessEffectParameter {
}
constructor(value: T, needLerp = false) {
+ super();
this._needLerp = needLerp;
this._value = value;
}
diff --git a/packages/core/src/shader/ShaderData.ts b/packages/core/src/shader/ShaderData.ts
index a3bb7992b8..2a4833fd33 100644
--- a/packages/core/src/shader/ShaderData.ts
+++ b/packages/core/src/shader/ShaderData.ts
@@ -1,7 +1,10 @@
+import { DataObject } from "../base/DataObject";
import { IClone } from "@galacean/engine-design";
import { Color, Matrix, Vector2, Vector3, Vector4 } from "@galacean/engine-math";
import { IReferable } from "../asset/IReferable";
-import { CloneManager, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
+import { CloneUtil } from "../clone/CloneUtil";
+import type { ICloneHook } from "../clone/ICloneHook";
import { Texture } from "../texture/Texture";
import { ShaderMacro } from "./ShaderMacro";
import { ShaderMacroCollection } from "./ShaderMacroCollection";
@@ -12,7 +15,7 @@ import { ShaderPropertyType } from "./enums/ShaderPropertyType";
/**
* Shader data collection,Correspondence includes shader properties data and macros data.
*/
-export class ShaderData implements IReferable, IClone {
+export class ShaderData extends DataObject implements IReferable, IClone, ICloneHook {
/** @internal */
@ignoreClone
_group: ShaderDataGroup;
@@ -34,6 +37,7 @@ export class ShaderData implements IReferable, IClone {
* @internal
*/
constructor(group: ShaderDataGroup) {
+ super();
this._group = group;
}
@@ -561,7 +565,7 @@ export class ShaderData implements IReferable, IClone {
if (out) {
const macroMap = this._macroMap;
out.length = 0;
- for (var key in macroMap) {
+ for (const key in macroMap) {
out.push(macroMap[key]);
}
} else {
@@ -592,7 +596,7 @@ export class ShaderData implements IReferable, IClone {
const propertyValueMap = this._propertyValueMap;
const propertyIdMap = ShaderProperty._propertyIdMap;
- for (let key in propertyValueMap) {
+ for (const key in propertyValueMap) {
properties.push(propertyIdMap[key]);
}
@@ -608,7 +612,7 @@ export class ShaderData implements IReferable, IClone {
}
cloneTo(target: ShaderData): void {
- CloneManager.deepCloneObject(this._macroCollection, target._macroCollection, new Map());
+ CloneUtil._cloneObjectFields(this._macroCollection, target._macroCollection, new Map());
Object.assign(target._macroMap, this._macroMap);
const referCount = target._getReferCount();
const propertyValueMap = this._propertyValueMap;
@@ -624,7 +628,9 @@ export class ShaderData implements IReferable, IClone {
targetPropertyValueMap[k] = property;
referCount > 0 && property._addReferCount(referCount);
} else if (property instanceof Array || property instanceof Float32Array || property instanceof Int32Array) {
- targetPropertyValueMap[k] = property.slice();
+ const cloned = property.slice();
+ targetPropertyValueMap[k] = cloned;
+ referCount > 0 && this._addTexturesReferCount(cloned, referCount);
} else {
const targetProperty = targetPropertyValueMap[k];
if (targetProperty) {
@@ -639,6 +645,13 @@ export class ShaderData implements IReferable, IClone {
}
}
+ /**
+ * @inheritdoc
+ */
+ _onClone(target: ShaderData): void {
+ this.cloneTo(target);
+ }
+
/**
* @internal
*/
@@ -709,8 +722,17 @@ export class ShaderData implements IReferable, IClone {
for (const k in properties) {
const property = properties[k];
// @todo: Separate array to speed performance.
- if (property && property instanceof Texture) {
- property._addReferCount(value);
+ property && this._addTexturesReferCount(property, value);
+ }
+ }
+
+ private _addTexturesReferCount(property: ShaderPropertyValueType, count: number): void {
+ if (property instanceof Texture) {
+ property._addReferCount(count);
+ } else if (property instanceof Array) {
+ for (let i = 0, n = property.length; i < n; i++) {
+ const element = property[i];
+ element instanceof Texture && element._addReferCount(count);
}
}
}
diff --git a/packages/core/src/shader/state/BlendState.ts b/packages/core/src/shader/state/BlendState.ts
index 870526d0ab..443cd1acbe 100644
--- a/packages/core/src/shader/state/BlendState.ts
+++ b/packages/core/src/shader/state/BlendState.ts
@@ -1,8 +1,8 @@
+import { DataObject } from "../../base/DataObject";
import { IHardwareRenderer } from "@galacean/engine-design";
import { Color } from "@galacean/engine-math";
import { RenderStateElementMap } from "../../BasicResources";
import { GLCapabilityType } from "../../base/Constant";
-import { deepClone } from "../../clone/CloneManager";
import { ShaderData } from "../ShaderData";
import { ShaderProperty } from "../ShaderProperty";
import { BlendFactor } from "../enums/BlendFactor";
@@ -15,7 +15,7 @@ import { RenderTargetBlendState } from "./RenderTargetBlendState";
/**
* Blend state.
*/
-export class BlendState {
+export class BlendState extends DataObject {
private static _getGLBlendFactor(rhi: IHardwareRenderer, blendFactor: BlendFactor): number {
const gl = rhi.gl;
@@ -73,10 +73,8 @@ export class BlendState {
}
/** The blend state of the render target. */
- @deepClone
readonly targetBlendState: RenderTargetBlendState = new RenderTargetBlendState();
/** Constant blend color. */
- @deepClone
readonly blendColor: Color = new Color(0, 0, 0, 0);
/** Whether to use (Alpha-to-Coverage) technology. */
alphaToCoverage: boolean = false;
diff --git a/packages/core/src/shader/state/DepthState.ts b/packages/core/src/shader/state/DepthState.ts
index 9757e22fda..15b221efb9 100644
--- a/packages/core/src/shader/state/DepthState.ts
+++ b/packages/core/src/shader/state/DepthState.ts
@@ -1,3 +1,4 @@
+import { DataObject } from "../../base/DataObject";
import { IHardwareRenderer } from "@galacean/engine-design";
import { RenderStateElementMap } from "../../BasicResources";
import { ShaderData } from "../ShaderData";
@@ -9,7 +10,7 @@ import { RenderState } from "./RenderState";
/**
* Depth state.
*/
-export class DepthState {
+export class DepthState extends DataObject {
private static _getGLCompareFunction(rhi: IHardwareRenderer, compareFunction: CompareFunction): number {
const gl = rhi.gl;
diff --git a/packages/core/src/shader/state/RasterState.ts b/packages/core/src/shader/state/RasterState.ts
index 660abff343..ddebc3afa8 100644
--- a/packages/core/src/shader/state/RasterState.ts
+++ b/packages/core/src/shader/state/RasterState.ts
@@ -1,3 +1,4 @@
+import { DataObject } from "../../base/DataObject";
import { IHardwareRenderer } from "@galacean/engine-design";
import { RenderStateElementMap } from "../../BasicResources";
import { ShaderData } from "../ShaderData";
@@ -9,7 +10,7 @@ import { RenderState } from "./RenderState";
/**
* Raster state.
*/
-export class RasterState {
+export class RasterState extends DataObject {
/** Specifies whether or not front- and/or back-facing polygons can be culled. */
cullMode: CullMode = CullMode.Back;
/** The multiplier by which an implementation-specific value is multiplied with to create a constant depth offset. */
diff --git a/packages/core/src/shader/state/RenderState.ts b/packages/core/src/shader/state/RenderState.ts
index 502bfd7d83..9f67e4b37a 100644
--- a/packages/core/src/shader/state/RenderState.ts
+++ b/packages/core/src/shader/state/RenderState.ts
@@ -1,7 +1,7 @@
+import { DataObject } from "../../base/DataObject";
import { ShaderData, ShaderProperty } from "..";
import { RenderStateElementMap } from "../../BasicResources";
import { Engine } from "../../Engine";
-import { deepClone } from "../../clone/CloneManager";
import { RenderQueueType } from "../enums/RenderQueueType";
import { RenderStateElementKey } from "../enums/RenderStateElementKey";
import { BlendState } from "./BlendState";
@@ -12,18 +12,14 @@ import { StencilState } from "./StencilState";
/**
* Render state.
*/
-export class RenderState {
+export class RenderState extends DataObject {
/** Blend state. */
- @deepClone
readonly blendState: BlendState = new BlendState();
/** Depth state. */
- @deepClone
readonly depthState: DepthState = new DepthState();
/** Stencil state. */
- @deepClone
readonly stencilState: StencilState = new StencilState();
/** Raster state. */
- @deepClone
readonly rasterState: RasterState = new RasterState();
/** Render queue type. */
diff --git a/packages/core/src/shader/state/RenderTargetBlendState.ts b/packages/core/src/shader/state/RenderTargetBlendState.ts
index 88e3d3f62e..cdb32b6c17 100644
--- a/packages/core/src/shader/state/RenderTargetBlendState.ts
+++ b/packages/core/src/shader/state/RenderTargetBlendState.ts
@@ -1,3 +1,4 @@
+import { DataObject } from "../../base/DataObject";
import { BlendOperation } from "../enums/BlendOperation";
import { BlendFactor } from "../enums/BlendFactor";
import { ColorWriteMask } from "../enums/ColorWriteMask";
@@ -5,7 +6,7 @@ import { ColorWriteMask } from "../enums/ColorWriteMask";
/**
* The blend state of the render target.
*/
-export class RenderTargetBlendState {
+export class RenderTargetBlendState extends DataObject {
/** Whether to enable blend. */
enabled: boolean = false;
/** color (RGB) blend operation. */
diff --git a/packages/core/src/shader/state/StencilState.ts b/packages/core/src/shader/state/StencilState.ts
index a479c2fcde..9a77ec38cf 100644
--- a/packages/core/src/shader/state/StencilState.ts
+++ b/packages/core/src/shader/state/StencilState.ts
@@ -1,3 +1,4 @@
+import { DataObject } from "../../base/DataObject";
import { IHardwareRenderer } from "@galacean/engine-design";
import { RenderStateElementMap } from "../../BasicResources";
import { ShaderData } from "../ShaderData";
@@ -10,7 +11,7 @@ import { RenderState } from "./RenderState";
/**
* Stencil state.
*/
-export class StencilState {
+export class StencilState extends DataObject {
private static _getGLCompareFunction(rhi: IHardwareRenderer, compareFunction: CompareFunction): number {
const gl = rhi.gl;
diff --git a/packages/core/src/trail/TrailRenderer.ts b/packages/core/src/trail/TrailRenderer.ts
index 6485d658ca..941bbcb282 100644
--- a/packages/core/src/trail/TrailRenderer.ts
+++ b/packages/core/src/trail/TrailRenderer.ts
@@ -2,7 +2,7 @@ import { BoundingBox, Color, Vector2, Vector3, Vector4 } from "@galacean/engine-
import { Entity } from "../Entity";
import { RenderContext } from "../RenderPipeline/RenderContext";
import { Renderer, RendererUpdateFlags } from "../Renderer";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneDecorators";
import { Buffer } from "../graphic/Buffer";
import { Primitive } from "../graphic/Primitive";
import { SubPrimitive } from "../graphic/SubPrimitive";
@@ -47,20 +47,16 @@ export class TrailRenderer extends Renderer {
minVertexDistance = 0.1;
/** The curve describing the trail width from start to end. */
- @deepClone
widthCurve = new ParticleCurve(new CurveKey(0, 1), new CurveKey(1, 1));
/** The gradient describing the trail color from start to end. */
- @deepClone
colorGradient = new ParticleGradient(
[new GradientColorKey(0, new Color(1, 1, 1, 1)), new GradientColorKey(1, new Color(1, 1, 1, 1))],
[new GradientAlphaKey(0, 1), new GradientAlphaKey(1, 1)]
);
// Shader parameters
- @deepClone
private _trailParams = new Vector4(TrailTextureMode.Stretch, 1.0, 1.0, 0); // x: textureMode, y: textureScaleX, z: textureScaleY
- @deepClone
private _textureScale = new Vector2(1.0, 1.0);
@ignoreClone
private _distanceParams = new Vector2(); // x: headDistance, y: tailDistance
diff --git a/packages/core/src/utils/DisorderedArray.ts b/packages/core/src/utils/DisorderedArray.ts
index 81e7b8f8de..0049c9aea8 100644
--- a/packages/core/src/utils/DisorderedArray.ts
+++ b/packages/core/src/utils/DisorderedArray.ts
@@ -1,3 +1,4 @@
+import { CloneMode, registerDefaultCloneMode } from "../clone/CloneDecorators";
import { Utils } from "../Utils";
/**
@@ -197,3 +198,5 @@ export class DisorderedArray {
this._blankCount = 0;
}
}
+
+registerDefaultCloneMode(DisorderedArray, CloneMode.Ignore);
diff --git a/packages/core/src/utils/SafeLoopArray.ts b/packages/core/src/utils/SafeLoopArray.ts
index a5dbc7f7bf..f904a17c15 100644
--- a/packages/core/src/utils/SafeLoopArray.ts
+++ b/packages/core/src/utils/SafeLoopArray.ts
@@ -1,3 +1,5 @@
+import { CloneMode, registerDefaultCloneMode } from "../clone/CloneDecorators";
+
export class SafeLoopArray {
private _array: T[] = [];
private _loopArray: T[] = [];
@@ -84,3 +86,5 @@ export class SafeLoopArray {
return loopArray;
}
}
+
+registerDefaultCloneMode(SafeLoopArray, CloneMode.Ignore);
diff --git a/packages/math/src/Ray.ts b/packages/math/src/Ray.ts
index 80ba2e2f64..075a55bf6a 100644
--- a/packages/math/src/Ray.ts
+++ b/packages/math/src/Ray.ts
@@ -1,13 +1,15 @@
import { BoundingBox } from "./BoundingBox";
import { BoundingSphere } from "./BoundingSphere";
import { CollisionUtil } from "./CollisionUtil";
+import { IClone } from "./IClone";
+import { ICopy } from "./ICopy";
import { Plane } from "./Plane";
import { Vector3 } from "./Vector3";
/**
* Represents a ray with an origin and a direction in 3D space.
*/
-export class Ray {
+export class Ray implements IClone, ICopy {
/** The origin of the ray. */
readonly origin: Vector3 = new Vector3();
/** The normalized direction of the ray. */
@@ -60,4 +62,25 @@ export class Ray {
Vector3.scale(this.direction, distance, out);
return out.add(this.origin);
}
+
+ /**
+ * Creates a clone of this ray.
+ * @returns A clone of this ray
+ */
+ clone(): Ray {
+ const out = new Ray();
+ out.copyFrom(this);
+ return out;
+ }
+
+ /**
+ * Copy this ray from the specified ray.
+ * @param source - The specified ray
+ * @returns This ray
+ */
+ copyFrom(source: Ray): Ray {
+ this.origin.copyFrom(source.origin);
+ this.direction.copyFrom(source.direction);
+ return this;
+ }
}
diff --git a/packages/physics-physx/src/PhysXCollider.ts b/packages/physics-physx/src/PhysXCollider.ts
index 579d0f7576..3f3a475455 100644
--- a/packages/physics-physx/src/PhysXCollider.ts
+++ b/packages/physics-physx/src/PhysXCollider.ts
@@ -30,7 +30,9 @@ export abstract class PhysXCollider implements ICollider {
* {@inheritDoc ICollider.addShape }
*/
addShape(shape: PhysXColliderShape): void {
- this._pxActor.attachShape(shape._pxShape);
+ if (!this._pxActor.attachShape(shape._pxShape)) {
+ throw new Error("PhysXCollider: failed to attach shape to the native actor.");
+ }
this._shapes.push(shape);
this._scene?._addColliderShape(shape._id);
}
diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts
index a69cd846bf..27976dc580 100644
--- a/packages/ui/src/component/UICanvas.ts
+++ b/packages/ui/src/component/UICanvas.ts
@@ -14,11 +14,10 @@ import {
RenderElement,
Vector2,
Vector3,
- assignmentClone,
- deepClone,
dependentComponents,
ignoreClone
} from "@galacean/engine";
+import type { ICloneHook } from "@galacean/engine";
import { Utils } from "../Utils";
import { UIBatchSorter } from "./UIBatchSorter";
import { CanvasRenderMode } from "../enums/CanvasRenderMode";
@@ -36,7 +35,7 @@ import { UIInteractive } from "./interactive/UIInteractive";
* handling rendering and events based on it.
*/
@dependentComponents(UITransform, DependentMode.AutoAdd)
-export class UICanvas extends Component implements IElement {
+export class UICanvas extends Component implements IElement, ICloneHook {
/** @internal */
static _hierarchyCounter: number = 1;
private static _tempGroupAbleList: IGroupAble[] = [];
@@ -77,28 +76,21 @@ export class UICanvas extends Component implements IElement {
@ignoreClone
_realRenderMode: number = CanvasRealRenderMode.None;
/** @internal */
- @ignoreClone
_disorderedElements: DisorderedArray = new DisorderedArray();
@ignoreClone
private _renderMode = CanvasRenderMode.WorldSpace;
private _camera: Camera;
private _cameraObserver: Camera;
- @assignmentClone
private _resolutionAdaptationMode = ResolutionAdaptationMode.HeightAdaptation;
- @assignmentClone
private _sortOrder: number = 0;
- @assignmentClone
private _distance: number = 10;
- @deepClone
private _referenceResolution: Vector2 = new Vector2(800, 600);
- @assignmentClone
private _referenceResolutionPerUnit: number = 100;
@ignoreClone
private _hierarchyVersion: number = -1;
@ignoreClone
private _center: Vector3 = new Vector3();
- @ignoreClone
private _centerDirtyFlag: BoolUpdateFlag;
/**
@@ -425,9 +417,9 @@ export class UICanvas extends Component implements IElement {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: UICanvas): void {
+ _onClone(target: UICanvas): void {
target.renderMode = this._renderMode;
}
diff --git a/packages/ui/src/component/UIGroup.ts b/packages/ui/src/component/UIGroup.ts
index b876e05ec4..99b59f3d12 100644
--- a/packages/ui/src/component/UIGroup.ts
+++ b/packages/ui/src/component/UIGroup.ts
@@ -1,4 +1,4 @@
-import { Component, DisorderedArray, Entity, EntityModifyFlags, assignmentClone, ignoreClone } from "@galacean/engine";
+import { Component, DisorderedArray, Entity, EntityModifyFlags, ignoreClone } from "@galacean/engine";
import { Utils } from "../Utils";
import { IGroupAble } from "../interface/IGroupAble";
import { EntityUIModifyFlags, UICanvas } from "./UICanvas";
@@ -15,7 +15,6 @@ export class UIGroup extends Component implements IGroupAble {
/** @internal */
_rootCanvas: UICanvas;
/** @internal */
- @ignoreClone
_disorderedElements: DisorderedArray = new DisorderedArray();
/** @internal */
@@ -25,11 +24,8 @@ export class UIGroup extends Component implements IGroupAble {
@ignoreClone
_globalInteractive = true;
- @assignmentClone
private _alpha = 1;
- @assignmentClone
private _interactive = true;
- @assignmentClone
private _ignoreParentGroup = false;
/** @internal */
diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts
index 82af6480bd..57ff3520d4 100644
--- a/packages/ui/src/component/UIRenderer.ts
+++ b/packages/ui/src/component/UIRenderer.ts
@@ -13,8 +13,6 @@ import {
ShaderProperty,
Vector3,
Vector4,
- assignmentClone,
- deepClone,
dependentComponents,
ignoreClone
} from "@galacean/engine";
@@ -42,7 +40,6 @@ export class UIRenderer extends Renderer implements IGraphics {
* Custom boundary for raycast detection.
* @remarks this is based on `this.entity.transform`.
*/
- @deepClone
raycastPadding: Vector4 = new Vector4(0, 0, 0, 0);
/** @internal */
_rootCanvas: UICanvas;
@@ -70,9 +67,7 @@ export class UIRenderer extends Renderer implements IGraphics {
@ignoreClone
_subChunk;
- @assignmentClone
private _raycastEnabled: boolean = true;
- @deepClone
protected _color: Color = new Color(1, 1, 1, 1);
/**
diff --git a/packages/ui/src/component/UITransform.ts b/packages/ui/src/component/UITransform.ts
index 9144373472..b4ceb1e3dd 100644
--- a/packages/ui/src/component/UITransform.ts
+++ b/packages/ui/src/component/UITransform.ts
@@ -1,13 +1,4 @@
-import {
- Entity,
- MathUtil,
- Rect,
- Transform,
- TransformModifyFlags,
- Vector2,
- deepClone,
- ignoreClone
-} from "@galacean/engine";
+import { Entity, MathUtil, Rect, Transform, TransformModifyFlags, Vector2, ignoreClone } from "@galacean/engine";
import { HorizontalAlignmentMode } from "../enums/HorizontalAlignmentMode";
import { VerticalAlignmentMode } from "../enums/VerticalAlignmentMode";
@@ -19,7 +10,6 @@ export class UITransform extends Transform {
private _size = new Vector2(100, 100);
@ignoreClone
private _pivot = new Vector2(0.5, 0.5);
- @deepClone
private _rect = new Rect(-50, -50, 100, 100);
private _alignLeft = 0;
@@ -254,10 +244,11 @@ export class UITransform extends Transform {
this._updateWorldFlagWithParentRectChange(TransformModifyFlags.WmWpWeWqWsWus);
}
- // @ts-ignore
- override _cloneTo(target: UITransform): void {
- // @ts-ignore
- super._cloneTo(target);
+ /**
+ * @inheritdoc
+ */
+ override _onClone(target: UITransform): void {
+ super._onClone(target);
const { _size: size, _pivot: pivot } = target;
// @ts-ignore
size._onValueChanged = pivot._onValueChanged = null;
diff --git a/packages/ui/src/component/advanced/Button.ts b/packages/ui/src/component/advanced/Button.ts
index 73bf817562..c407c7a9cf 100644
--- a/packages/ui/src/component/advanced/Button.ts
+++ b/packages/ui/src/component/advanced/Button.ts
@@ -1,9 +1,8 @@
-import { deepClone, PointerEventData, Signal } from "@galacean/engine";
+import { PointerEventData, Signal } from "@galacean/engine";
import { UIInteractive } from "../interactive/UIInteractive";
export class Button extends UIInteractive {
/** Signal emitted when the button is clicked. */
- @deepClone
readonly onClick = new Signal<[PointerEventData]>();
override onPointerClick(event: PointerEventData): void {
diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts
index b364a2e303..3a3bd90a56 100644
--- a/packages/ui/src/component/advanced/Image.ts
+++ b/packages/ui/src/component/advanced/Image.ts
@@ -12,7 +12,6 @@ import {
SpriteModifyFlags,
SpriteTileMode,
TiledSpriteAssembler,
- assignmentClone,
ignoreClone
} from "@galacean/engine";
import { CanvasRenderMode } from "../../enums/CanvasRenderMode";
@@ -30,9 +29,7 @@ export class Image extends UIRenderer implements ISpriteRenderer {
private _drawMode: SpriteDrawMode;
@ignoreClone
private _assembler: ISpriteAssembler;
- @assignmentClone
private _tileMode: SpriteTileMode = SpriteTileMode.Continuous;
- @assignmentClone
private _tiledAdaptiveThreshold: number = 0.5;
/**
@@ -150,11 +147,10 @@ export class Image extends UIRenderer implements ISpriteRenderer {
}
/**
- * @internal
+ * @inheritdoc
*/
- _cloneTo(target: Image): void {
- // @ts-ignore
- super._cloneTo(target);
+ override _onClone(target: Image): void {
+ super._onClone(target);
target.sprite = this._sprite;
target.drawMode = this._drawMode;
}
diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts
index b534253a9a..dc07ea5ccb 100644
--- a/packages/ui/src/component/advanced/Text.ts
+++ b/packages/ui/src/component/advanced/Text.ts
@@ -17,7 +17,6 @@ import {
TextVerticalAlignment,
Texture2D,
Vector3,
- assignmentClone,
ignoreClone
} from "@galacean/engine";
import { CanvasRenderMode } from "../../enums/CanvasRenderMode";
@@ -37,27 +36,17 @@ export class Text extends UIRenderer implements ITextRenderer {
private _textChunks = Array();
@ignoreClone
private _subFont: SubFont = null;
- @assignmentClone
private _text: string = "";
@ignoreClone
private _localBounds: BoundingBox = new BoundingBox();
- @assignmentClone
private _font: Font = null;
- @assignmentClone
private _fontSize: number = 24;
- @assignmentClone
private _fontStyle: FontStyle = FontStyle.None;
- @assignmentClone
private _lineSpacing: number = 0;
- @assignmentClone
private _characterSpacing: number = 0;
- @assignmentClone
private _horizontalAlignment: TextHorizontalAlignment = TextHorizontalAlignment.Center;
- @assignmentClone
private _verticalAlignment: TextVerticalAlignment = TextVerticalAlignment.Center;
- @assignmentClone
private _enableWrapping: boolean = false;
- @assignmentClone
private _overflowMode: OverflowMode = OverflowMode.Overflow;
/**
@@ -265,14 +254,6 @@ export class Text extends UIRenderer implements ITextRenderer {
this._subFont && (this._subFont = null);
}
- // @ts-ignore
- override _cloneTo(target: Text): void {
- // @ts-ignore
- super._cloneTo(target);
- target.font = this._font;
- target._subFont = this._subFont;
- }
-
/**
* @internal
*/
diff --git a/packages/ui/src/component/interactive/UIInteractive.ts b/packages/ui/src/component/interactive/UIInteractive.ts
index 6811819585..43a7a36e47 100644
--- a/packages/ui/src/component/interactive/UIInteractive.ts
+++ b/packages/ui/src/component/interactive/UIInteractive.ts
@@ -1,12 +1,4 @@
-import {
- CloneUtils,
- Entity,
- EntityModifyFlags,
- Script,
- assignmentClone,
- deepClone,
- ignoreClone
-} from "@galacean/engine";
+import { Entity, EntityModifyFlags, Script, ignoreClone } from "@galacean/engine";
import { UIGroup } from "../..";
import { Utils } from "../../Utils";
import { IGroupAble } from "../../interface/IGroupAble";
@@ -48,9 +40,7 @@ export class UIInteractive extends Script implements IGroupAble {
@ignoreClone
_globalInteractiveDirty: boolean = false;
- @deepClone
protected _transitions: Transition[] = [];
- @assignmentClone
protected _interactive: boolean = true;
@ignoreClone
protected _state: InteractiveState = InteractiveState.Normal;
diff --git a/packages/ui/src/component/interactive/transition/SpriteTransition.ts b/packages/ui/src/component/interactive/transition/SpriteTransition.ts
index 4a20c1bc13..f90441236d 100644
--- a/packages/ui/src/component/interactive/transition/SpriteTransition.ts
+++ b/packages/ui/src/component/interactive/transition/SpriteTransition.ts
@@ -6,35 +6,6 @@ import { Transition } from "./Transition";
* Sprite transition.
*/
export class SpriteTransition extends Transition {
- /**
- * @internal
- */
- override destroy(): void {
- super.destroy();
- if (this._normal) {
- // @ts-ignore
- this._normal._addReferCount(-1);
- this._normal = null;
- }
- if (this._hover) {
- // @ts-ignore
- this._hover._addReferCount(-1);
- this._hover = null;
- }
- if (this._pressed) {
- // @ts-ignore
- this._pressed._addReferCount(-1);
- this._pressed = null;
- }
- if (this._disabled) {
- // @ts-ignore
- this._disabled._addReferCount(-1);
- this._disabled = null;
- }
- this._initialValue = this._currentValue = this._finalValue = null;
- this._target = null;
- }
-
protected _getTargetValueCopy(): Sprite {
return this._target?.sprite;
}
diff --git a/packages/ui/src/component/interactive/transition/Transition.ts b/packages/ui/src/component/interactive/transition/Transition.ts
index 5dd461e615..5441320a4d 100644
--- a/packages/ui/src/component/interactive/transition/Transition.ts
+++ b/packages/ui/src/component/interactive/transition/Transition.ts
@@ -1,14 +1,15 @@
-import { Color, ReferResource, Sprite } from "@galacean/engine";
+import { Color, DataObject, ReferResource, Sprite, ignoreClone } from "@galacean/engine";
+import type { ICloneHook } from "@galacean/engine";
import { UIRenderer } from "../../UIRenderer";
import { InteractiveState, UIInteractive } from "../UIInteractive";
/**
* The transition behavior of UIInteractive.
*/
-export abstract class Transition<
- T extends TransitionValueType = TransitionValueType,
- K extends UIRenderer = UIRenderer
-> {
+export abstract class Transition
+ extends DataObject
+ implements ICloneHook>
+{
/** @internal */
_interactive: UIInteractive;
@@ -18,10 +19,15 @@ export abstract class Transition<
protected _hover: T;
protected _disabled: T;
protected _duration: number = 0;
+ @ignoreClone
protected _countDown: number = 0;
+ @ignoreClone
protected _initialValue: T;
+ @ignoreClone
protected _finalValue: T;
+ @ignoreClone
protected _currentValue: T;
+ @ignoreClone
protected _finalState: InteractiveState = InteractiveState.Normal;
/**
@@ -119,9 +125,19 @@ export abstract class Transition<
destroy(): void {
this._interactive?.removeTransition(this);
+ this._addStateValuesReferCount(-1);
+ this._normal = this._pressed = this._hover = this._disabled = null;
+ this._initialValue = this._currentValue = this._finalValue = null;
this._target = null;
}
+ /**
+ * @inheritdoc
+ */
+ _onClone(target: Transition): void {
+ target._addStateValuesReferCount(1);
+ }
+
/**
* @internal
*/
@@ -170,6 +186,18 @@ export abstract class Transition<
this._target?.enabled && this._applyValue(this._currentValue);
}
+ private _addStateValuesReferCount(count: number): void {
+ const { _normal, _pressed, _hover, _disabled } = this;
+ // @ts-ignore
+ _normal instanceof ReferResource && _normal._addReferCount(count);
+ // @ts-ignore
+ _pressed instanceof ReferResource && _pressed._addReferCount(count);
+ // @ts-ignore
+ _hover instanceof ReferResource && _hover._addReferCount(count);
+ // @ts-ignore
+ _disabled instanceof ReferResource && _disabled._addReferCount(count);
+ }
+
private _getValueByState(state: InteractiveState): T {
switch (state) {
case InteractiveState.Normal:
diff --git a/tests/src/core/Clone.test.ts b/tests/src/core/Clone.test.ts
new file mode 100644
index 0000000000..1022b89a2e
--- /dev/null
+++ b/tests/src/core/Clone.test.ts
@@ -0,0 +1,2431 @@
+import {
+ BoolUpdateFlag,
+ Burst,
+ DataObject,
+ DisorderedArray,
+ Entity,
+ ICloneHook,
+ Logger,
+ MeshRenderer,
+ ParticleCompositeCurve,
+ ParticleCompositeGradient,
+ ParticleRenderer,
+ Script,
+ Signal,
+ Texture2D,
+ assignmentClone,
+ deepClone,
+ ignoreClone
+} from "@galacean/engine-core";
+import * as EngineCore from "@galacean/engine-core";
+import * as EngineMath from "@galacean/engine-math";
+import * as EngineUI from "@galacean/engine-ui";
+import { Color, Ray, Vector3 } from "@galacean/engine-math";
+import { WebGLEngine } from "@galacean/engine";
+import { describe, expect, it, vi } from "vitest";
+
+class TestScript extends Script {
+ targetEntity: Entity;
+ targetRenderer: MeshRenderer;
+ externalEntity: Entity;
+ externalRenderer: MeshRenderer;
+ deepChild: Entity;
+ selfRef: Entity;
+ speed: number;
+ name2: string;
+ flag: boolean;
+ data: object;
+}
+
+/** Script with multiple entity/component refs pointing to different nodes */
+class MultiRefScript extends Script {
+ entityA: Entity;
+ entityB: Entity;
+ rendererA: MeshRenderer;
+ rendererB: MeshRenderer;
+}
+
+/** Script where the same entity is referenced by multiple properties */
+class DuplicateRefScript extends Script {
+ ref1: Entity;
+ ref2: Entity;
+}
+
+/** Script with null/undefined entity/component refs */
+class NullRefScript extends Script {
+ nullEntity: Entity = null;
+ undefinedEntity: Entity;
+ nullRenderer: MeshRenderer = null;
+ someNumber: number = 0;
+}
+
+/** Script referencing a sibling entity (not parent/child, but sibling under clone root) */
+class SiblingRefScript extends Script {
+ sibling: Entity;
+ siblingRenderer: MeshRenderer;
+}
+
+/** Script with a mix of decorated and undecorated entity refs */
+class DecoratedRefScript extends Script {
+ // Undecorated — Entity type default (Remap) applies
+ autoRemapEntity: Entity;
+
+ // @assignmentClone — field decorator wins over the type default: shares the source reference
+ @assignmentClone
+ assignedEntity: Entity;
+
+ // @ignoreClone — field decorator wins over the type default: keeps the clone's own value
+ @ignoreClone
+ ignoredEntity: Entity;
+}
+
+/** Script with a @deepClone array of entities */
+class ArrayRefScript extends Script {
+ @deepClone
+ entities: Entity[] = [];
+}
+
+/** Script with Component self-reference */
+class SelfComponentRefScript extends Script {
+ selfScript: SelfComponentRefScript;
+}
+
+/** Script referencing another Script on a different entity */
+class CrossScriptRefScript extends Script {
+ otherScript: TestScript;
+}
+
+/** Script with a nested plain object containing entity refs */
+class NestedObjectScript extends Script {
+ @deepClone
+ config: { target: Entity; label: string } = { target: null, label: "" };
+}
+
+/** Script for testing multiple same-type components on one entity */
+class CounterScript extends Script {
+ value: number = 0;
+ partner: CounterScript;
+ targetEntity: Entity;
+}
+
+/** Script that references a CounterScript */
+class CounterRefScript extends Script {
+ counter: CounterScript;
+}
+
+/** Handler script used for Signal structured binding tests */
+class ClickHandler extends Script {
+ callCount = 0;
+ lastPrefix: string = "";
+
+ handleClick(): void {
+ this.callCount++;
+ }
+
+ handleClickWithPrefix(arg: number, prefix: string): void {
+ this.callCount++;
+ this.lastPrefix = prefix;
+ }
+}
+
+/** Script with a Signal property */
+class SignalScript extends Script {
+ @deepClone
+ readonly onFire = new Signal<[number]>();
+}
+
+/** Script with function-valued fields, standalone and inside containers */
+class HandlerScript extends Script {
+ onTick: () => void;
+ handlers: Array<() => void> = [];
+ handlerSet: Set<() => void> = new Set();
+ config: { onDone: (() => void) | null; x: number } = { onDone: null, x: 0 };
+}
+
+/** Data object counting how often the gate runs its post-clone hook */
+class HookCountPayload extends DataObject implements ICloneHook {
+ static runs = 0;
+ value = 0;
+ _onClone(): void {
+ HookCountPayload.runs++;
+ }
+}
+
+/** Script referencing one payload from two slots */
+class SharedPayloadScript extends Script {
+ slotA: HookCountPayload = null;
+ slotB: HookCountPayload = null;
+}
+
+/** Script opting a plain runtime container into a deep copy */
+class DeepContainerScript extends Script {
+ @deepClone
+ entries: DisorderedArray<{ id: number }> = new DisorderedArray<{ id: number }>();
+}
+
+/** Script asking for a deep copy the paired flag registry cannot honor */
+class DeepFlagManagerScript extends Script {
+ @deepClone
+ flagManager: any = null;
+}
+
+/** Script whose constructor establishes its own bound handler */
+class BoundHandlerScript extends Script {
+ tickCount = 0;
+ boundTick = this._tick.bind(this);
+
+ private _tick(): void {
+ this.tickCount++;
+ }
+}
+
+/** Base script decorating two Entity fields, one to be overridden by a subclass, one left inherited. */
+class BaseOverrideScript extends Script {
+ @assignmentClone
+ reDecorated: Entity;
+ @ignoreClone
+ inherited: Entity = null;
+}
+
+/** Subclass re-decorates `reDecorated` (Assignment → Ignore) but never mentions `inherited`. */
+class SubOverrideScript extends BaseOverrideScript {
+ @ignoreClone
+ reDecorated: Entity;
+}
+
+let fieldModesCollisionId = 0;
+
+class FieldModesCollisionScript extends BaseOverrideScript {
+ _fieldModes = "user-owned";
+ @ignoreClone
+ runtimeId = ++fieldModesCollisionId;
+}
+
+/** Script holding binary data views */
+class BinaryScript extends Script {
+ view: DataView;
+ bytes: Float32Array;
+}
+
+/**
+ * Script holding a shared ReferResource, honoring the slot-ownership contract the same way
+ * engine components do: acquire on assignment, release on destroy. The clone gate adds +1 for
+ * the cloned backing slot, which the same onDestroy release balances.
+ */
+class ResourceRefScript extends Script {
+ private _texture: Texture2D;
+
+ get texture(): Texture2D {
+ return this._texture;
+ }
+
+ set texture(value: Texture2D) {
+ if (this._texture !== value) {
+ (this._texture as any)?._addReferCount(-1);
+ (value as any)?._addReferCount(1);
+ this._texture = value;
+ }
+ }
+
+ override onDestroy(): void {
+ this.texture = null;
+ }
+}
+
+/** Script whose constructor presets an owned counted resource into a clonable slot. */
+class PresetTextureScript extends Script {
+ static created: Texture2D[] = [];
+
+ texture: Texture2D;
+
+ constructor(entity: Entity) {
+ super(entity);
+ const texture = new Texture2D(entity.engine, 1, 1);
+ (texture as any)._addReferCount(1);
+ PresetTextureScript.created.push(texture);
+ this.texture = texture;
+ }
+}
+
+/** Script with two fields aliasing one typed array (identity must survive the clone). */
+class AliasedBinaryScript extends Script {
+ a: Float32Array;
+ b: Float32Array;
+}
+
+/** Unregistered user value type without any counting API — must share, never count. */
+class SharedConfig {
+ value = 1;
+}
+
+/** Script holding a user Assignment-registered object. */
+class SharedConfigScript extends Script {
+ config: SharedConfig = null;
+}
+
+/** Script whose constructor presets a counted resource WITHOUT acquiring it (contract violation). */
+class UnownedPresetScript extends Script {
+ static created: Texture2D[] = [];
+
+ tex: Texture2D;
+
+ constructor(entity: Entity) {
+ super(entity);
+ const texture = new Texture2D(entity.engine, 1, 1);
+ UnownedPresetScript.created.push(texture);
+ this.tex = texture;
+ }
+}
+
+/** User DataObject whose constructor dereferences a required argument (contract violation). */
+class ParamDeepConfig extends DataObject {
+ target: string;
+
+ constructor(source: { id: string }) {
+ super();
+ this.target = source.id;
+ }
+}
+
+/** Script holding plain data whose payload happens to carry a `copyFrom` key. */
+class CopyFromDataScript extends Script {
+ config: any = null;
+}
+
+/** Script whose binary fields alias class-level shared default tables (preset === source). */
+class SharedDefaultTableScript extends Script {
+ static DEFAULT_WEIGHTS = new Float32Array([1, 2, 3]);
+ static DEFAULT_VIEW = new DataView(new ArrayBuffer(4));
+ weights: Float32Array = SharedDefaultTableScript.DEFAULT_WEIGHTS;
+ view: DataView = SharedDefaultTableScript.DEFAULT_VIEW;
+}
+
+/** Script whose Map / Set / Array fields carry constructor-built entries (the clone's preset). */
+class PresetContainerScript extends Script {
+ map = new Map([["preset", 1]]);
+ set = new Set(["preset"]);
+ list: number[] = [1, 2, 3];
+}
+
+/** Script whose Array / Map / Set fields alias class-level shared defaults (preset === source). */
+class SharedDefaultContainerScript extends Script {
+ static DEFAULT_LIST = [1, 2, 3];
+ static DEFAULT_MAP = new Map([["a", 1]]);
+ static DEFAULT_SET = new Set(["a"]);
+ list: number[] = SharedDefaultContainerScript.DEFAULT_LIST;
+ map: Map = SharedDefaultContainerScript.DEFAULT_MAP;
+ set: Set = SharedDefaultContainerScript.DEFAULT_SET;
+}
+
+/** Plain class with no deep-clone default (not DataObject / math / container). */
+class PlainConfig {
+ count = 0;
+ nested = { x: 1 };
+}
+
+/** Unregistered copyFrom class whose branded object tag keeps it outside the field-clone boundary. */
+class TaggedCopyValue {
+ value = 0;
+
+ get [Symbol.toStringTag](): string {
+ return "TaggedCopyValue";
+ }
+
+ copyFrom(source: TaggedCopyValue): void {
+ this.value = source.value;
+ }
+}
+
+/** Script sharing one container through an @assignmentClone field */
+class AssignedContainerScript extends Script {
+ @assignmentClone
+ shared: number[] = [];
+}
+
+/** Script pointing @deepClone at a function value */
+class DeepFnScript extends Script {
+ @deepClone
+ onTick: () => void = () => {};
+}
+
+/** Script pointing @deepClone at a value whose state may not be field-cloneable */
+class DeepPlatformObjectScript extends Script {
+ @deepClone
+ value: object = null;
+}
+
+/** Script whose @deepClone fields hold members with no deep default of their own */
+class DeepSubtreeScript extends Script {
+ @deepClone
+ configs: PlainConfig[] = [];
+ @deepClone
+ bag: any = null;
+ @deepClone
+ mixed: any[] = null;
+}
+
+/** Script whose ctor-built binary values stay observable through @ignoreClone aliases */
+class BinaryPresetScript extends Script {
+ weights = new Float32Array(3);
+ view = new DataView(new ArrayBuffer(4));
+ shortWeights = new Float32Array(2);
+ @ignoreClone
+ ownWeights = this.weights;
+ @ignoreClone
+ ownView = this.view;
+ @ignoreClone
+ ownShort = this.shortWeights;
+}
+
+/** Non-component class carrying its own @ignoreClone, to be reached through a field walk. */
+class Bag {
+ kept = 1;
+ @ignoreClone
+ runtime = 1;
+}
+
+/** Script forcing the walk into Bag with @deepClone. */
+class BagHolderScript extends Script {
+ @deepClone
+ bag: Bag = null;
+}
+
+/** Script @deepClone-ing a class that has no deep-clone default — must force a deep copy. */
+class ForcedDeepScript extends Script {
+ @deepClone
+ config: PlainConfig = null;
+}
+
+/** Two fields on one script pointing at the same default-less instance, one @deepClone'd. */
+class MixedIntentScript extends Script {
+ @deepClone
+ deep: PlainConfig = null;
+ shared: PlainConfig = null;
+}
+
+/** Same pair, declared the other way round — field order must not change either outcome. */
+class MixedIntentReversedScript extends Script {
+ shared: PlainConfig = null;
+ @deepClone
+ deep: PlainConfig = null;
+}
+
+/** Script holding the same class without @deepClone — the default path shares it. */
+class SharedPlainScript extends Script {
+ config: PlainConfig = null;
+}
+
+/** Script misusing @deepClone on an Entity ref — cloning it must throw. */
+class DeepEntityRefScript extends Script {
+ @deepClone
+ target: Entity;
+}
+
+/** Script misusing @deepClone on an engine-bound asset — cloning it must throw. */
+class DeepAssetRefScript extends Script {
+ @deepClone
+ texture: Texture2D;
+}
+
+/** Script with an @assignmentClone function field preset by the constructor */
+class AssignedHandlerScript extends Script {
+ @assignmentClone
+ handler: () => void = this._noop.bind(this);
+
+ private _noop(): void {}
+}
+
+/** Script with an @ignoreClone function field preset by the constructor */
+class IgnoredHandlerScript extends Script {
+ @ignoreClone
+ handler: () => void = this._noop.bind(this);
+
+ private _noop(): void {}
+}
+
+describe("Clone remap", async () => {
+ const engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
+ const scene = engine.sceneManager.activeScene;
+ engine.run();
+
+ describe("Basic Entity/Component remap", () => {
+ it("script undecorated Entity ref should remap to cloned entity", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(TestScript);
+ script.targetEntity = child;
+
+ const clonedParent = parent.clone();
+ const clonedScript = clonedParent.getComponent(TestScript);
+ const clonedChild = clonedParent.children[0];
+
+ expect(clonedScript.targetEntity).not.eq(child);
+ expect(clonedScript.targetEntity).eq(clonedChild);
+ expect(clonedScript.targetEntity.name).eq("child");
+
+ rootEntity.destroy();
+ });
+
+ it("script undecorated Component ref should remap to cloned component", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const meshRenderer = child.addComponent(MeshRenderer);
+ const script = parent.addComponent(TestScript);
+ script.targetRenderer = meshRenderer;
+
+ const clonedParent = parent.clone();
+ const clonedScript = clonedParent.getComponent(TestScript);
+ const clonedChild = clonedParent.children[0];
+ const clonedMeshRenderer = clonedChild.getComponent(MeshRenderer);
+
+ expect(clonedScript.targetRenderer).not.eq(meshRenderer);
+ expect(clonedScript.targetRenderer).eq(clonedMeshRenderer);
+
+ rootEntity.destroy();
+ });
+
+ it("script ref to entity outside hierarchy should keep original", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const external = rootEntity.createChild("external");
+ const script = parent.addComponent(TestScript);
+ script.externalEntity = external;
+
+ const clonedParent = parent.clone();
+ const clonedScript = clonedParent.getComponent(TestScript);
+
+ expect(clonedScript.externalEntity).eq(external);
+
+ rootEntity.destroy();
+ });
+
+ it("script ref to component outside hierarchy should keep original", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const external = rootEntity.createChild("external");
+ const externalMR = external.addComponent(MeshRenderer);
+ const script = parent.addComponent(TestScript);
+ script.externalRenderer = externalMR;
+
+ const clonedParent = parent.clone();
+ const clonedScript = clonedParent.getComponent(TestScript);
+
+ expect(clonedScript.externalRenderer).eq(externalMR);
+
+ rootEntity.destroy();
+ });
+
+ it("deep hierarchy entity ref should remap correctly", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const grandchild = child.createChild("grandchild");
+ const script = parent.addComponent(TestScript);
+ script.deepChild = grandchild;
+
+ const clonedParent = parent.clone();
+ const clonedScript = clonedParent.getComponent(TestScript);
+ const clonedGrandchild = clonedParent.children[0].children[0];
+
+ expect(clonedScript.deepChild).not.eq(grandchild);
+ expect(clonedScript.deepChild).eq(clonedGrandchild);
+ expect(clonedScript.deepChild.name).eq("grandchild");
+
+ rootEntity.destroy();
+ });
+
+ it("script ref to self entity (clone root) should remap", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(TestScript);
+ script.selfRef = parent;
+
+ const clonedParent = parent.clone();
+ const clonedScript = clonedParent.getComponent(TestScript);
+
+ expect(clonedScript.selfRef).not.eq(parent);
+ expect(clonedScript.selfRef).eq(clonedParent);
+
+ rootEntity.destroy();
+ });
+
+ it("primitive props copied by value; plain object deep cloned", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(TestScript);
+ const obj = { x: 1 };
+ script.speed = 42;
+ script.name2 = "test";
+ script.flag = true;
+ script.data = obj;
+
+ const clonedParent = parent.clone();
+ const clonedScript = clonedParent.getComponent(TestScript);
+
+ expect(clonedScript.speed).eq(42);
+ expect(clonedScript.name2).eq("test");
+ expect(clonedScript.flag).eq(true);
+ // plain object is deep cloned into an independent copy, not shared
+ expect(clonedScript.data).not.eq(obj);
+ expect(clonedScript.data.x).eq(1);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Multiple and duplicate refs", () => {
+ it("a shared object clones once, hook included", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(SharedPayloadScript);
+ const shared = new HookCountPayload();
+ shared.value = 42;
+ script.slotA = shared;
+ script.slotB = shared;
+
+ HookCountPayload.runs = 0;
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SharedPayloadScript);
+
+ expect(cs.slotA).not.eq(shared);
+ expect(cs.slotA).eq(cs.slotB);
+ expect(cs.slotA.value).eq(42);
+ // A second slot resolving to the same clone must not re-run the post-clone hook: hooks
+ // acquire references and register listeners, so a replay silently doubles both.
+ expect(HookCountPayload.runs).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("multiple entity/component refs on same script all remap independently", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const childA = parent.createChild("childA");
+ const childB = parent.createChild("childB");
+ const mrA = childA.addComponent(MeshRenderer);
+ const mrB = childB.addComponent(MeshRenderer);
+ const script = parent.addComponent(MultiRefScript);
+ script.entityA = childA;
+ script.entityB = childB;
+ script.rendererA = mrA;
+ script.rendererB = mrB;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(MultiRefScript);
+
+ expect(cs.entityA).not.eq(childA);
+ expect(cs.entityB).not.eq(childB);
+ expect(cs.entityA.name).eq("childA");
+ expect(cs.entityB.name).eq("childB");
+ expect(cs.entityA).eq(cloned.children[0]);
+ expect(cs.entityB).eq(cloned.children[1]);
+ expect(cs.rendererA).eq(cloned.children[0].getComponent(MeshRenderer));
+ expect(cs.rendererB).eq(cloned.children[1].getComponent(MeshRenderer));
+
+ rootEntity.destroy();
+ });
+
+ it("two properties referencing the same entity both remap to the same cloned entity", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(DuplicateRefScript);
+ script.ref1 = child;
+ script.ref2 = child;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DuplicateRefScript);
+
+ expect(cs.ref1).not.eq(child);
+ expect(cs.ref1).eq(cs.ref2);
+ expect(cs.ref1).eq(cloned.children[0]);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Null and undefined refs", () => {
+ it("null entity/component refs should not crash and remain null", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(NullRefScript);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(NullRefScript);
+
+ expect(cs.nullEntity).eq(null);
+ expect(cs.nullRenderer).eq(null);
+ expect(cs.someNumber).eq(0);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Sibling entity refs", () => {
+ it("ref to sibling entity under clone root should remap", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const childA = parent.createChild("childA");
+ const childB = parent.createChild("childB");
+ const mrB = childB.addComponent(MeshRenderer);
+ const script = childA.addComponent(SiblingRefScript);
+ script.sibling = childB;
+ script.siblingRenderer = mrB;
+
+ const cloned = parent.clone();
+ const clonedChildA = cloned.children[0];
+ const clonedChildB = cloned.children[1];
+ const cs = clonedChildA.getComponent(SiblingRefScript);
+
+ expect(cs.sibling).not.eq(childB);
+ expect(cs.sibling).eq(clonedChildB);
+ expect(cs.siblingRenderer).not.eq(mrB);
+ expect(cs.siblingRenderer).eq(clonedChildB.getComponent(MeshRenderer));
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Field decorators take priority over Entity/Component remap", () => {
+ it("@assignmentClone entity ref shares the source reference (decorator wins)", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(DecoratedRefScript);
+ script.assignedEntity = child;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DecoratedRefScript);
+
+ expect(cs.assignedEntity).eq(child);
+
+ rootEntity.destroy();
+ });
+
+ it("@ignoreClone entity ref keeps the clone's own value (decorator wins)", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(DecoratedRefScript);
+ script.ignoredEntity = child;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DecoratedRefScript);
+
+ expect(cs.ignoredEntity).eq(undefined);
+
+ rootEntity.destroy();
+ });
+
+ it("undecorated entity ref remaps correctly", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(DecoratedRefScript);
+ script.autoRemapEntity = child;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DecoratedRefScript);
+
+ expect(cs.autoRemapEntity).not.eq(child);
+ expect(cs.autoRemapEntity).eq(cloned.children[0]);
+
+ rootEntity.destroy();
+ });
+
+ it("@ignoreClone entity ref outside hierarchy is ignored the same way", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const external = rootEntity.createChild("external");
+ const script = parent.addComponent(DecoratedRefScript);
+ script.ignoredEntity = external;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DecoratedRefScript);
+
+ expect(cs.ignoredEntity).eq(undefined);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone on an Entity ref throws — the explicit intent can't be honored, so it's surfaced", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(DeepEntityRefScript);
+ script.target = child;
+
+ // A decorator is the developer's explicit intent; @deepClone on an engine-bound Entity is a
+ // mistake — thrown, never silently remapped (an undecorated Entity ref remaps by default).
+ expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone/);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone on an asset ref throws — assets are engine-bound and shared by reference", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepAssetRefScript);
+ const texture = new Texture2D(engine, 4, 4);
+ script.texture = texture;
+
+ expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone/);
+
+ rootEntity.destroy();
+ texture.destroy(true);
+ });
+
+ it("@deepClone forces a deep copy of a class that has no deep-clone default", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(ForcedDeepScript);
+ script.config = new PlainConfig();
+ script.config.count = 7;
+ script.config.nested.x = 42;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(ForcedDeepScript);
+
+ // @deepClone is an explicit request: the value is field-walked into an independent copy,
+ // even though PlainConfig is not DataObject / math / container (the default would share it).
+ expect(cs.config).not.eq(script.config);
+ expect(cs.config).instanceOf(PlainConfig);
+ expect(cs.config.count).eq(7);
+ expect(cs.config.nested).not.eq(script.config.nested);
+ expect(cs.config.nested.x).eq(42);
+
+ rootEntity.destroy();
+ });
+
+ it("the same class without @deepClone is shared by the default path", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(SharedPlainScript);
+ script.config = new PlainConfig();
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SharedPlainScript);
+
+ // No deep-clone default and no decorator — the clone shares the source instance.
+ expect(cs.config).eq(script.config);
+
+ rootEntity.destroy();
+ });
+
+ it("each field keeps its own intent when both point at one instance (@deepClone declared first)", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(MixedIntentScript);
+ const config = new PlainConfig();
+ script.deep = config;
+ script.shared = config;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(MixedIntentScript);
+
+ // The two fields ask for opposite things about the same instance, so each is honored on its
+ // own: the decorated one gets an independent copy, the undecorated one keeps sharing.
+ expect(cs.deep).not.eq(config);
+ expect(cs.shared).eq(config);
+
+ rootEntity.destroy();
+ });
+
+ it("each field keeps its own intent when both point at one instance (@deepClone declared last)", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(MixedIntentReversedScript);
+ const config = new PlainConfig();
+ script.deep = config;
+ script.shared = config;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(MixedIntentReversedScript);
+
+ // Same expectations as above: declaration order must not change the outcome.
+ expect(cs.deep).not.eq(config);
+ expect(cs.shared).eq(config);
+
+ rootEntity.destroy();
+ });
+
+ it("@assignmentClone function field shares the source function (decorator wins over reuse)", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(AssignedHandlerScript);
+ const custom = () => {};
+ script.handler = custom;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(AssignedHandlerScript);
+
+ expect(cs.handler).eq(custom);
+
+ rootEntity.destroy();
+ });
+
+ it("@ignoreClone function field keeps the clone's own constructor-built value (never the source)", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(IgnoredHandlerScript);
+ const custom = () => {};
+ script.handler = custom;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(IgnoredHandlerScript);
+
+ // Ignore means the field is untouched by the gate — the clone's constructor already bound
+ // its own handler to its own `this`, so it must be neither the overridden source value nor
+ // the source instance's original bound handler.
+ expect(cs.handler).not.eq(custom);
+ expect(cs.handler).not.eq(script.handler);
+ expect(typeof cs.handler).eq("function");
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Subclass field re-decoration shadows the base class's", () => {
+ it("the subclass's own decorator wins on a re-decorated field; an untouched field still inherits the base's", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const sibling = parent.createChild("sibling");
+ const script = parent.addComponent(SubOverrideScript);
+ script.reDecorated = sibling;
+ script.inherited = sibling;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SubOverrideScript);
+
+ // Base decorates `reDecorated` @assignmentClone (share); the subclass re-decorates it
+ // @ignoreClone — the subclass's own decoration must win, not the base's.
+ expect(cs.reDecorated).eq(undefined);
+ // `inherited` is never touched by the subclass — the base's @ignoreClone must still apply.
+ expect(cs.inherited).eq(null);
+
+ rootEntity.destroy();
+ });
+
+ it("does not leak the subclass's re-decoration back onto the base class", () => {
+ // A subclass re-decorating a field must leave the base class untouched.
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const sibling = parent.createChild("sibling");
+ const baseScript = parent.addComponent(BaseOverrideScript);
+ baseScript.reDecorated = sibling;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(BaseOverrideScript);
+
+ // The base class's own @assignmentClone on `reDecorated` must still share the source.
+ expect(cs.reDecorated).eq(sibling);
+
+ rootEntity.destroy();
+ });
+
+ it("does not let a user-owned _fieldModes field shadow clone metadata", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const sibling = parent.createChild("sibling");
+ const script = parent.addComponent(FieldModesCollisionScript);
+ script.reDecorated = sibling;
+ script.inherited = sibling;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(FieldModesCollisionScript);
+
+ expect(cs._fieldModes).eq("user-owned");
+ expect(cs.runtimeId).not.eq(script.runtimeId);
+ expect(cs.reDecorated).eq(sibling);
+ expect(cs.inherited).eq(null);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("@deepClone array of entities", () => {
+ it("deep cloned entity array should remap internal refs", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const childA = parent.createChild("childA");
+ const childB = parent.createChild("childB");
+ const script = parent.addComponent(ArrayRefScript);
+ script.entities = [childA, childB];
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(ArrayRefScript);
+
+ expect(cs.entities).not.eq(script.entities);
+ expect(cs.entities.length).eq(2);
+ expect(cs.entities[0]).not.eq(childA);
+ expect(cs.entities[1]).not.eq(childB);
+ expect(cs.entities[0]).eq(cloned.children[0]);
+ expect(cs.entities[1]).eq(cloned.children[1]);
+
+ rootEntity.destroy();
+ });
+
+ it("deep cloned entity array with external ref keeps original", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const external = rootEntity.createChild("external");
+ const script = parent.addComponent(ArrayRefScript);
+ script.entities = [child, external];
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(ArrayRefScript);
+
+ expect(cs.entities[0]).eq(cloned.children[0]);
+ expect(cs.entities[1]).eq(external);
+
+ rootEntity.destroy();
+ });
+
+ it("deep cloned empty entity array stays empty", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(ArrayRefScript);
+ script.entities = [];
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(ArrayRefScript);
+
+ expect(cs.entities).not.eq(script.entities);
+ expect(cs.entities.length).eq(0);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Component self and cross references", () => {
+ it("script referencing itself should remap to cloned script", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(SelfComponentRefScript);
+ script.selfScript = script;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SelfComponentRefScript);
+
+ expect(cs.selfScript).not.eq(script);
+ expect(cs.selfScript).eq(cs);
+
+ rootEntity.destroy();
+ });
+
+ it("script referencing another script on child entity should remap", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const childScript = child.addComponent(TestScript);
+ const script = parent.addComponent(CrossScriptRefScript);
+ script.otherScript = childScript;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(CrossScriptRefScript);
+ const clonedChildScript = cloned.children[0].getComponent(TestScript);
+
+ expect(cs.otherScript).not.eq(childScript);
+ expect(cs.otherScript).eq(clonedChildScript);
+
+ rootEntity.destroy();
+ });
+
+ it("script referencing external script should keep original", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const external = rootEntity.createChild("external");
+ const externalScript = external.addComponent(TestScript);
+ const script = parent.addComponent(CrossScriptRefScript);
+ script.otherScript = externalScript;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(CrossScriptRefScript);
+
+ expect(cs.otherScript).eq(externalScript);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Nested @deepClone object with entity refs", () => {
+ it("entity ref inside deep cloned plain object should remap", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(NestedObjectScript);
+ script.config = { target: child, label: "hello" };
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(NestedObjectScript);
+
+ expect(cs.config).not.eq(script.config);
+ expect(cs.config.label).eq("hello");
+ expect(cs.config.target).not.eq(child);
+ expect(cs.config.target).eq(cloned.children[0]);
+
+ rootEntity.destroy();
+ });
+
+ it("entity ref inside deep cloned object pointing outside keeps original", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const external = rootEntity.createChild("external");
+ const script = parent.addComponent(NestedObjectScript);
+ script.config = { target: external, label: "ext" };
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(NestedObjectScript);
+
+ expect(cs.config.target).eq(external);
+ expect(cs.config.label).eq("ext");
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Signal clone with structured bindings", () => {
+ it("@deepClone Signal should not copy closure listeners", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(SignalScript);
+ let called = false;
+ script.onFire.on(() => {
+ called = true;
+ });
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SignalScript);
+
+ expect(cs.onFire).not.eq(script.onFire);
+ cs.onFire.invoke(1);
+ expect(called).eq(false);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone Signal should remap structured binding target to cloned hierarchy", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const handlerEntity = parent.createChild("handler");
+ const handler = handlerEntity.addComponent(ClickHandler);
+ const script = parent.addComponent(SignalScript);
+ script.onFire.on(handler, "handleClick");
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SignalScript);
+ const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler);
+
+ cs.onFire.invoke(1);
+ expect(clonedHandler.callCount).eq(1);
+ expect(handler.callCount).eq(0);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone Signal should keep external structured binding target", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const external = rootEntity.createChild("external");
+ const externalHandler = external.addComponent(ClickHandler);
+ const script = parent.addComponent(SignalScript);
+ script.onFire.on(externalHandler, "handleClick");
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SignalScript);
+
+ cs.onFire.invoke(1);
+ expect(externalHandler.callCount).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone Signal should remap structured binding with pre-resolved args", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const handlerEntity = parent.createChild("handler");
+ const handler = handlerEntity.addComponent(ClickHandler);
+ const script = parent.addComponent(SignalScript);
+ script.onFire.on(handler, "handleClickWithPrefix", "myPrefix");
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SignalScript);
+ const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler);
+
+ cs.onFire.invoke(1);
+ expect(clonedHandler.callCount).eq(1);
+ expect(clonedHandler.lastPrefix).eq("myPrefix");
+ expect(handler.callCount).eq(0);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone Signal shares non-entity object args deterministically", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const handlerEntity = parent.createChild("handler");
+ const handler = handlerEntity.addComponent(ClickHandler);
+ const script = parent.addComponent(SignalScript);
+ const payload = { hp: 5 };
+ script.onFire.on(handler, "handleClickWithPrefix", payload);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SignalScript);
+ const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler);
+
+ cs.onFire.invoke(1);
+ // Non-entity object args are shared with the source, independent of field-walk order.
+ expect(clonedHandler.lastPrefix).eq(payload);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone Signal should preserve once flag on structured binding", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const handlerEntity = parent.createChild("handler");
+ const handler = handlerEntity.addComponent(ClickHandler);
+ const script = parent.addComponent(SignalScript);
+ script.onFire.once(handler, "handleClick");
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SignalScript);
+ const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler);
+
+ cs.onFire.invoke(1);
+ expect(clonedHandler.callCount).eq(1);
+ cs.onFire.invoke(2);
+ expect(clonedHandler.callCount).eq(1); // once: removed after first call
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Clone hierarchy integrity", () => {
+ it("clone preserves children count and names", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ parent.createChild("a");
+ parent.createChild("b");
+ parent.createChild("c");
+
+ const cloned = parent.clone();
+ expect(cloned.children.length).eq(3);
+ expect(cloned.children[0].name).eq("a");
+ expect(cloned.children[1].name).eq("b");
+ expect(cloned.children[2].name).eq("c");
+
+ rootEntity.destroy();
+ });
+
+ it("clone of deeply nested hierarchy preserves structure", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const a = rootEntity.createChild("a");
+ const b = a.createChild("b");
+ const c = b.createChild("c");
+ const d = c.createChild("d");
+
+ const cloned = a.clone();
+ expect(cloned.children[0].name).eq("b");
+ expect(cloned.children[0].children[0].name).eq("c");
+ expect(cloned.children[0].children[0].children[0].name).eq("d");
+
+ rootEntity.destroy();
+ });
+
+ it("script on child entity with ref to parent should remap", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = child.addComponent(TestScript);
+ script.targetEntity = parent;
+
+ const cloned = parent.clone();
+ const clonedChild = cloned.children[0];
+ const cs = clonedChild.getComponent(TestScript);
+
+ expect(cs.targetEntity).not.eq(parent);
+ expect(cs.targetEntity).eq(cloned);
+
+ rootEntity.destroy();
+ });
+
+ it("multiple scripts on different entities with cross-refs all remap correctly", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const childA = parent.createChild("childA");
+ const childB = parent.createChild("childB");
+
+ const scriptA = childA.addComponent(TestScript);
+ scriptA.targetEntity = childB;
+
+ const scriptB = childB.addComponent(TestScript);
+ scriptB.targetEntity = childA;
+
+ const cloned = parent.clone();
+ const clonedA = cloned.children[0];
+ const clonedB = cloned.children[1];
+ const csA = clonedA.getComponent(TestScript);
+ const csB = clonedB.getComponent(TestScript);
+
+ expect(csA.targetEntity).eq(clonedB);
+ expect(csB.targetEntity).eq(clonedA);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Function fields", () => {
+ it("@deepClone on a function throws", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ parent.addComponent(DeepFnScript);
+
+ expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone a function/);
+
+ rootEntity.destroy();
+ });
+
+ it("a function inside a @deepClone subtree is shared, not thrown on", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepSubtreeScript);
+ const fn = () => {};
+ script.bag = { onDone: fn };
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DeepSubtreeScript);
+
+ expect(cs.bag.onDone).eq(fn);
+
+ rootEntity.destroy();
+ });
+
+ it("plain function field is shared, not lost", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const fn = () => {};
+ script.onTick = fn;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript);
+
+ expect(cs.onTick).eq(fn);
+
+ rootEntity.destroy();
+ });
+
+ it("functions inside arrays / sets / plain objects survive cloning", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const fn = () => {};
+ script.handlers = [fn];
+ script.handlerSet = new Set([fn]);
+ script.config = { onDone: fn, x: 1 };
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript);
+
+ expect(cs.handlers).not.eq(script.handlers);
+ expect(cs.handlers.length).eq(1);
+ expect(cs.handlers[0]).eq(fn);
+ expect(cs.handlerSet).not.eq(script.handlerSet);
+ expect(cs.handlerSet.has(fn)).eq(true);
+ expect(cs.config).not.eq(script.config);
+ expect(cs.config.onDone).eq(fn);
+ expect(cs.config.x).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("constructor-bound function field keeps the clone's own binding", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(BoundHandlerScript);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(BoundHandlerScript);
+
+ expect(cs.boundTick).not.eq(script.boundTick);
+ cs.boundTick();
+ expect(cs.tickCount).eq(1);
+ expect(script.tickCount).eq(0);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Runtime-container type defaults (Ignore)", () => {
+ it("an undecorated DisorderedArray slot keeps the clone's own instance", async () => {
+ const { DisorderedArray } = await import("@galacean/engine-core");
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const runtimeList = new DisorderedArray();
+ runtimeList.add(1);
+ (script as any).runtimeList = runtimeList;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ // Type-level Ignore: the slot is neither shared nor deep-cloned — the clone keeps its
+ // own (absent) value instead of aliasing the source's runtime container.
+ expect(cs.runtimeList).not.eq(runtimeList);
+ expect(cs.runtimeList).eq(undefined);
+ expect(runtimeList.length).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("undecorated SafeLoopArray and UpdateFlag slots keep the clone's own value", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const loopList = (new Signal())._listeners;
+ loopList.push({ once: false });
+ (script as any).loopList = loopList;
+ (script as any).flag = new BoolUpdateFlag();
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ expect(cs.loopList).eq(undefined);
+ expect(cs.flag).eq(undefined);
+ expect(loopList.length).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone overrides the default on a plain container", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepContainerScript);
+ script.entries.add({ id: 1 });
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DeepContainerScript);
+
+ expect(cs.entries).instanceOf(DisorderedArray);
+ expect(cs.entries).not.eq(script.entries);
+ expect(cs.entries.length).eq(1);
+ expect(cs.entries._elements[0]).not.eq(script.entries._elements[0]);
+ expect(cs.entries._elements[0].id).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone on a paired flag registry throws", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepFlagManagerScript);
+ script.flagManager = (parent as any)._updateFlagManager;
+
+ expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone "UpdateFlagManager"/);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Null-prototype containers", () => {
+ it("Object.create(null) fields deep-clone as data containers, not shared references", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(HandlerScript);
+ const bag = Object.create(null);
+ bag.hp = 5;
+ bag.target = child;
+ (script as any).bag = bag;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ expect(cs.bag).not.eq(bag);
+ expect(Object.getPrototypeOf(cs.bag)).eq(null);
+ expect(cs.bag.hp).eq(5);
+ // Entity refs nested in the bag remap like in any other container.
+ expect(cs.bag.target).eq(cloned.children[0]);
+ cs.bag.hp = 9;
+ expect(bag.hp).eq(5);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Field-walk decorator awareness", () => {
+ it("respects @ignoreClone on a walked non-component class", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(BagHolderScript);
+ script.bag = new Bag();
+ script.bag.kept = 42;
+ script.bag.runtime = 42;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(BagHolderScript);
+
+ // @deepClone forces the field walk into Bag, which must still honor Bag's own @ignoreClone:
+ // `kept` is copied, `runtime` keeps the fresh instance's constructor value.
+ expect(cs.bag).not.eq(script.bag);
+ expect(cs.bag.kept).eq(42);
+ expect(cs.bag.runtime).eq(1);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Container member semantics", () => {
+ it("@assignmentClone on a container shares the instance with the source", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(AssignedContainerScript);
+ script.shared.push(1, 2);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(AssignedContainerScript);
+
+ expect(cs.shared).eq(script.shared);
+ cs.shared.push(3);
+ expect(script.shared.length).eq(3);
+
+ rootEntity.destroy();
+ });
+
+ it("Map keys are cloned along with values", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const key = { id: 7 };
+ (script as any).byObject = new Map([[key, 1]]);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ // The clone's key is a fresh object: lookups by the source key miss by design.
+ expect(cs.byObject.size).eq(1);
+ expect(cs.byObject.has(key)).eq(false);
+ const clonedKey = [...cs.byObject.keys()][0];
+ expect(clonedKey).not.eq(key);
+ expect(clonedKey.id).eq(7);
+ expect(cs.byObject.get(clonedKey)).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("entity Map keys remap to the cloned subtree", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(HandlerScript);
+ (script as any).byEntity = new Map([[child, 5]]);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ expect(cs.byEntity.get(cloned.children[0])).eq(5);
+ expect(cs.byEntity.has(child)).eq(false);
+
+ rootEntity.destroy();
+ });
+
+ it("a function held as a Map value is shared", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const fn = () => 1;
+ (script as any).handlerMap = new Map([["k", fn]]);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ expect(cs.handlerMap.get("k")).eq(fn);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("@deepClone subtree propagation", () => {
+ it("plain-class members of a @deepClone container are copied, not shared", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepSubtreeScript);
+ const config = new PlainConfig();
+ config.count = 5;
+ script.configs.push(config);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DeepSubtreeScript);
+
+ expect(cs.configs[0]).not.eq(config);
+ expect(cs.configs[0].count).eq(5);
+ expect(cs.configs[0].nested).not.eq(config.nested);
+ cs.configs[0].count = 1;
+ expect(config.count).eq(5);
+
+ rootEntity.destroy();
+ });
+
+ it("the force carries through nested plain objects", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepSubtreeScript);
+ const config = new PlainConfig();
+ script.bag = { cfg: config };
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DeepSubtreeScript);
+
+ expect(cs.bag.cfg).not.eq(config);
+ expect(cs.bag.cfg.nested).not.eq(config.nested);
+
+ rootEntity.destroy();
+ });
+
+ it("engine-bound members inside a @deepClone subtree keep their defaults", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const script = parent.addComponent(DeepSubtreeScript);
+ const texture = new Texture2D(engine, 1, 1);
+ script.mixed = [child, texture];
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DeepSubtreeScript);
+
+ expect(cs.mixed[0]).eq(cloned.children[0]);
+ expect(cs.mixed[1]).eq(texture);
+
+ rootEntity.destroy();
+ });
+
+ it("branded objects with internal state keep their default assignment inside a deep subtree", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepSubtreeScript);
+ const date = new Date(1234);
+ const buffer = new ArrayBuffer(8);
+ const error = new Error("boom");
+ new Uint8Array(buffer)[0] = 7;
+ script.bag = { date, buffer, error };
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(DeepSubtreeScript);
+
+ expect(cs.bag).not.eq(script.bag);
+ expect(cs.bag.date).eq(date);
+ expect(cs.bag.date.getTime()).eq(1234);
+ expect(cs.bag.buffer).eq(buffer);
+ expect(new Uint8Array(cs.bag.buffer)[0]).eq(7);
+ expect(cs.bag.error).eq(error);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("@deepClone capability boundary", () => {
+ it.each([
+ ["Date", () => new Date(1234)],
+ ["RegExp", () => /clone/gi],
+ ["ArrayBuffer", () => new ArrayBuffer(8)],
+ ["Error", () => new Error("boom")],
+ ["WeakMap", () => new WeakMap()]
+ ])("rejects %s because its state cannot be reproduced by a field walk", (typeName, createValue) => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepPlatformObjectScript);
+ script.value = createValue();
+
+ expect(() => parent.clone()).toThrowError(
+ new RegExp(`@deepClone cannot deep clone "${typeName}".*internal state`)
+ );
+
+ rootEntity.destroy();
+ });
+
+ it("does not infer clone capability from an unregistered copyFrom method", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(DeepPlatformObjectScript);
+ const value = new TaggedCopyValue();
+ value.value = 42;
+ script.value = value;
+
+ expect(() => parent.clone()).toThrowError(/@deepClone cannot deep clone "TaggedCopyValue"/);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("registered Copy value types via entity.clone", () => {
+ it("all registered math Copy types deep-clone through their type default", () => {
+ const typeNames = [
+ "BoundingBox",
+ "BoundingFrustum",
+ "BoundingSphere",
+ "Color",
+ "Matrix",
+ "Matrix3x3",
+ "Plane",
+ "Quaternion",
+ "Ray",
+ "Rect",
+ "SphericalHarmonics3",
+ "Vector2",
+ "Vector3",
+ "Vector4"
+ ] as const;
+ const types = typeNames.map((name) => EngineMath[name] as new () => any);
+ const spies = types.map((Type) => vi.spyOn(Type.prototype, "copyFrom"));
+ const values = types.map((Type) => new Type());
+ spies.forEach((spy) => spy.mockClear());
+
+ const rootEntity = scene.createRootEntity("root");
+ try {
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript) as any;
+ script.mathValues = values;
+
+ const clonedValues = (parent.clone().getComponent(HandlerScript) as any).mathValues;
+ for (let i = 0; i < values.length; i++) {
+ expect(clonedValues[i]).instanceOf(types[i]);
+ expect(clonedValues[i]).not.eq(values[i]);
+ expect(spies[i]).toHaveBeenCalledWith(values[i]);
+ }
+ } finally {
+ spies.forEach((spy) => spy.mockRestore());
+ rootEntity.destroy();
+ }
+ });
+
+ it("a Ray field deep-clones through the gate", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ (script as any).ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 1, 0));
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ expect(cs.ray).instanceOf(Ray);
+ expect(cs.ray).not.eq((script as any).ray);
+ expect(cs.ray.origin.x).eq(1);
+ cs.ray.origin.x = 9;
+ expect((script as any).ray.origin.x).eq(1);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Aliasing topology", () => {
+ it("one instance referenced three times clones into one instance referenced three times", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const vec = new Vector3(1, 2, 3);
+ (script as any).points = [vec, vec, vec];
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ // One NEW instance, shared by all three slots — the reference topology is preserved.
+ expect(cs.points[0]).not.eq(vec);
+ expect(cs.points[0]).eq(cs.points[1]);
+ expect(cs.points[1]).eq(cs.points[2]);
+ expect(cs.points[0].x).eq(1);
+
+ // Mutating through one slot is visible through the others, matching the source's behavior.
+ cs.points[0].x = 9;
+ expect(cs.points[2].x).eq(9);
+ expect(vec.x).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("a self-referencing plain object clones into a self-referencing clone", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(HandlerScript);
+ const node: any = { value: 1 };
+ node.self = node;
+ const ring: any[] = [node];
+ ring.push(ring);
+ (script as any).node = node;
+ (script as any).ring = ring;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(HandlerScript) as any;
+
+ expect(cs.node).not.eq(node);
+ expect(cs.node.self).eq(cs.node);
+ expect(cs.ring).not.eq(ring);
+ expect(cs.ring[1]).eq(cs.ring);
+ expect(cs.ring[0]).eq(cs.node);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Binary data fields", () => {
+ it("matching binary presets are written into, not replaced", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(BinaryPresetScript);
+ script.weights = new Float32Array([9, 8, 7]);
+ const view = new DataView(new ArrayBuffer(4));
+ view.setUint8(0, 42);
+ script.view = view;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(BinaryPresetScript);
+
+ // Same length / byteLength: the clone's ctor-built instance is reused as the target.
+ expect(cs.weights).eq(cs.ownWeights);
+ expect(Array.from(cs.weights)).deep.eq([9, 8, 7]);
+ expect(cs.view).eq(cs.ownView);
+ expect(cs.view.getUint8(0)).eq(42);
+
+ rootEntity.destroy();
+ });
+
+ it("a length-mismatched binary preset is replaced by a fresh copy", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(BinaryPresetScript);
+ script.shortWeights = new Float32Array([1, 2, 3]);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(BinaryPresetScript);
+
+ expect(cs.shortWeights).not.eq(cs.ownShort);
+ expect(Array.from(cs.shortWeights)).deep.eq([1, 2, 3]);
+
+ rootEntity.destroy();
+ });
+
+ it("DataView field clones by bytes without crashing", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(BinaryScript);
+ const buffer = new ArrayBuffer(8);
+ const view = new DataView(buffer);
+ view.setFloat32(0, 3.5);
+ view.setUint16(4, 42);
+ script.view = view;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(BinaryScript);
+
+ expect(cs.view).not.eq(view);
+ expect(cs.view.buffer).not.eq(buffer);
+ expect(cs.view.getFloat32(0)).eq(3.5);
+ expect(cs.view.getUint16(4)).eq(42);
+ cs.view.setUint16(4, 7);
+ expect(view.getUint16(4)).eq(42);
+
+ rootEntity.destroy();
+ });
+
+ it("typed array field clones into an independent copy", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(BinaryScript);
+ script.bytes = new Float32Array([1, 2, 3]);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(BinaryScript);
+
+ expect(cs.bytes).not.eq(script.bytes);
+ expect(Array.from(cs.bytes)).deep.eq([1, 2, 3]);
+ cs.bytes[0] = 9;
+ expect(script.bytes[0]).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("aliased typed arrays keep identity through the clone", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(AliasedBinaryScript);
+ const shared = new Float32Array([1, 2, 3]);
+ script.a = shared;
+ script.b = shared;
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(AliasedBinaryScript);
+
+ expect(cs.a).not.eq(shared);
+ expect(cs.a).eq(cs.b);
+ expect(Array.from(cs.a)).deep.eq([1, 2, 3]);
+
+ rootEntity.destroy();
+ });
+
+ it("typed-array preset aliasing the source value still yields a fresh copy", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(SharedDefaultTableScript);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SharedDefaultTableScript);
+
+ expect(cs.weights).not.eq(SharedDefaultTableScript.DEFAULT_WEIGHTS);
+ expect(Array.from(cs.weights)).deep.eq([1, 2, 3]);
+ cs.weights[0] = 9;
+ expect(SharedDefaultTableScript.DEFAULT_WEIGHTS[0]).eq(1);
+ expect(script.weights[0]).eq(1);
+ expect(cs.view).not.eq(SharedDefaultTableScript.DEFAULT_VIEW);
+
+ rootEntity.destroy();
+ });
+
+ it("array / map / set presets aliasing the source value are never written into", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ parent.addComponent(SharedDefaultContainerScript);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(SharedDefaultContainerScript);
+
+ // The clone's preset IS the source value (a class-level shared default), so it can't be
+ // filled in place — that would write through into the source.
+ expect(cs.list).not.eq(SharedDefaultContainerScript.DEFAULT_LIST);
+ expect(cs.map).not.eq(SharedDefaultContainerScript.DEFAULT_MAP);
+ expect(cs.set).not.eq(SharedDefaultContainerScript.DEFAULT_SET);
+ expect(cs.list).deep.eq([1, 2, 3]);
+ expect(SharedDefaultContainerScript.DEFAULT_LIST).deep.eq([1, 2, 3]);
+ expect(SharedDefaultContainerScript.DEFAULT_MAP.size).eq(1);
+ expect(SharedDefaultContainerScript.DEFAULT_SET.size).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("a reused map / set preset drops its own constructor-built entries", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(PresetContainerScript);
+ // Source diverges from what the clone's constructor will build.
+ script.map = new Map([["source", 9]]);
+ script.set = new Set(["source"]);
+ script.list = [7, 8, 9];
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(PresetContainerScript);
+
+ // The clone's own preset entries ("preset") must not survive next to the source's.
+ expect([...cs.map.keys()]).deep.eq(["source"]);
+ expect([...cs.set]).deep.eq(["source"]);
+ expect(cs.list).deep.eq([7, 8, 9]);
+ // ...and the source is untouched.
+ expect([...script.map.keys()]).deep.eq(["source"]);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Plain data classification", () => {
+ it("ignores an own constructor field when identifying plain objects", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(CopyFromDataScript);
+ const namedConstructor = { constructor: "payload", nested: { value: 1 } };
+ const undefinedConstructor = { constructor: undefined, nested: { value: 2 } };
+ script.config = [namedConstructor, undefinedConstructor];
+
+ const cloned = parent.clone();
+ const configs = cloned.getComponent(CopyFromDataScript).config;
+
+ expect(configs[0]).not.eq(namedConstructor);
+ expect(configs[0].constructor).eq("payload");
+ expect(configs[0].nested).not.eq(namedConstructor.nested);
+ expect(configs[1]).not.eq(undefinedConstructor);
+ expect(Object.getPrototypeOf(configs[1])).eq(Object.prototype);
+ expect(configs[1].constructor).eq(undefined);
+ expect(configs[1].nested).not.eq(undefinedConstructor.nested);
+
+ rootEntity.destroy();
+ });
+
+ it("recognizes a cross-realm plain object", () => {
+ const iframe = document.createElement("iframe");
+ document.body.appendChild(iframe);
+ const rootEntity = scene.createRootEntity("root");
+ try {
+ const foreignWindow = iframe.contentWindow;
+ const foreignObject = new foreignWindow.Object();
+ const foreignNested = new foreignWindow.Object();
+ foreignNested.value = 1;
+ foreignObject.nested = foreignNested;
+
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(CopyFromDataScript);
+ script.config = foreignObject;
+
+ const cloned = parent.clone();
+ const config = cloned.getComponent(CopyFromDataScript).config;
+
+ expect(config).not.eq(foreignObject);
+ expect(config.nested).not.eq(foreignNested);
+ expect(config.nested.value).eq(1);
+ } finally {
+ rootEntity.destroy();
+ iframe.remove();
+ }
+ });
+
+ it("plain object with a string copyFrom key deep-clones without crashing", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(CopyFromDataScript);
+ script.config = { copyFrom: "nodeA", other: 1 };
+
+ const cloned = parent.clone();
+ const cc = cloned.getComponent(CopyFromDataScript).config;
+ expect(cc).not.eq(script.config);
+ expect(cc.copyFrom).eq("nodeA");
+ expect(cc.other).eq(1);
+
+ rootEntity.destroy();
+ });
+
+ it("plain object with a function copyFrom key shares the function and clones the rest", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(CopyFromDataScript);
+ const fn = () => 42;
+ script.config = { copyFrom: fn, n: 2 };
+
+ const cloned = parent.clone();
+ const cc = cloned.getComponent(CopyFromDataScript).config;
+ expect(cc).not.eq(script.config);
+ expect(cc.copyFrom).eq(fn);
+ expect(cc.n).eq(2);
+
+ rootEntity.destroy();
+ });
+
+ it("null-prototype object with a copyFrom key deep-clones as null-prototype", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(CopyFromDataScript);
+ const data = Object.create(null);
+ data.copyFrom = "x";
+ data.v = 2;
+ script.config = data;
+
+ const cloned = parent.clone();
+ const cc = cloned.getComponent(CopyFromDataScript).config;
+ expect(cc).not.eq(data);
+ expect(Object.getPrototypeOf(cc)).eq(null);
+ expect(cc.copyFrom).eq("x");
+ expect(cc.v).eq(2);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Parameter-constructed Deep values as container elements", () => {
+ it("clones gradients and curves held in arrays / maps without a reusable preset", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(CopyFromDataScript);
+ const gradient = new ParticleCompositeGradient(new Color(1, 0, 0, 1));
+ const curve = new ParticleCompositeCurve(0.5);
+ script.config = { gradients: [gradient], curves: new Map([["a", curve]]) };
+
+ const cloned = parent.clone();
+ const cc = cloned.getComponent(CopyFromDataScript).config;
+ expect(cc.gradients[0]).not.eq(gradient);
+ expect(cc.gradients[0].mode).eq(gradient.mode);
+ expect(cc.gradients[0].constant.r).eq(1);
+ expect(cc.curves.get("a")).not.eq(curve);
+ expect(cc.curves.get("a").constant).eq(0.5);
+
+ rootEntity.destroy();
+ });
+
+ it("a Deep type that cannot construct bare fails with the contract named", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(CopyFromDataScript);
+ script.config = { items: [new ParamDeepConfig({ id: "a" })] };
+
+ expect(() => parent.clone()).toThrowError(/bare-construct "ParamDeepConfig"/);
+
+ rootEntity.destroy();
+ });
+
+ it("a host-bound instance in a container remaps when its engine slot clones first", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ // Renderer added first: its generator/module tree enters the identity map before the
+ // script's fields walk, so the container reference dedups onto the cloned module.
+ const renderer = parent.addComponent(ParticleRenderer);
+ const script = parent.addComponent(CopyFromDataScript);
+ script.config = { modules: [renderer.generator.main] };
+
+ const cloned = parent.clone();
+ const clonedModule = cloned.getComponent(CopyFromDataScript).config.modules[0];
+ expect(clonedModule).eq(cloned.getComponent(ParticleRenderer).generator.main);
+ expect(clonedModule).not.eq(renderer.generator.main);
+
+ rootEntity.destroy();
+ });
+
+ it("a host-bound instance in a container fails with the named error when no host precedes", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ // Script added first: the container walks before any engine slot registers the module,
+ // so the gate must bare-construct a host-bound structure — rejected with the contract
+ // named (sharing must be declared via @assignmentClone).
+ const script = parent.addComponent(CopyFromDataScript);
+ const renderer = parent.addComponent(ParticleRenderer);
+ script.config = { modules: [renderer.generator.main] };
+
+ expect(() => parent.clone()).toThrowError(/bare-construct "MainModule"/);
+
+ rootEntity.destroy();
+ });
+
+ it("every exported DataObject type constructs bare (gate contract)", () => {
+ // The gate creates container elements and preset-less slots with `new Type()` and then
+ // populates its fields, so a DataObject type MUST construct without arguments. Registered
+ // math Copy types are constructed and exercised by the test above.
+ // Exemptions are engine-bound structural types the gate only ever clones against a
+ // same-type constructor preset (`reusable`) — each entry states why it cannot be bare.
+ const exempt = new Set([
+ // Physics shapes construct a PhysicsMaterial and need an initialized physics backend —
+ // bare-constructible in a real runtime, just not in this physics-less test engine.
+ "ColliderShape",
+ "BoxColliderShape",
+ "SphereColliderShape",
+ "CapsuleColliderShape",
+ "PlaneColliderShape",
+ "MeshColliderShape",
+ // Host-bound structural types wired to their host at construction — in their engine
+ // slots the gate clones them against the component's same-type constructor preset; a
+ // preset-less occurrence (e.g. a user container) fails with the named bare-construction
+ // error by design (share explicitly via @assignmentClone instead).
+ "ParticleGenerator",
+ "MainModule",
+ "VelocityOverLifetimeModule",
+ "SizeOverLifetimeModule",
+ "LimitVelocityOverLifetimeModule",
+ "NoiseModule"
+ ]);
+ const failures: string[] = [];
+ const packages: [string, Record][] = [
+ ["core", EngineCore],
+ ["math", EngineMath],
+ ["ui", EngineUI]
+ ];
+ for (const [pkg, ns] of packages) {
+ for (const [name, exported] of Object.entries(ns)) {
+ if (typeof exported !== "function" || !exported.prototype) continue;
+ if (!(exported.prototype instanceof DataObject)) continue;
+ if (exempt.has(name)) continue;
+ try {
+ new exported();
+ } catch (e) {
+ failures.push(`${pkg}/${name}: ${(e as Error).message}`);
+ }
+ }
+ }
+ expect(failures).deep.eq([]);
+ });
+
+ it("orbital velocity fields deep-clone through the type default", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const renderer = parent.addComponent(ParticleRenderer);
+ const vol = renderer.generator.velocityOverLifetime;
+ vol.orbitalX = new ParticleCompositeCurve(1, 2);
+ vol.centerOffset.set(3, 4, 5);
+
+ const cloned = parent.clone();
+ const clonedVol = cloned.getComponent(ParticleRenderer).generator.velocityOverLifetime;
+ expect(clonedVol.orbitalX).not.eq(vol.orbitalX);
+ expect(clonedVol.orbitalX.constantMin).eq(1);
+ expect(clonedVol.orbitalX.constantMax).eq(2);
+ expect(clonedVol.centerOffset).not.eq(vol.centerOffset);
+ expect(clonedVol.centerOffset.z).eq(5);
+
+ rootEntity.destroy();
+ });
+
+ it("clones the emission bursts array through the engine path", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const renderer = parent.addComponent(ParticleRenderer);
+ renderer.generator.emission.addBurst(new Burst(0.5, new ParticleCompositeCurve(30)));
+
+ const cloned = parent.clone();
+ const clonedBursts = cloned.getComponent(ParticleRenderer).generator.emission.bursts;
+ expect(clonedBursts.length).eq(1);
+ expect(clonedBursts[0]).not.eq(renderer.generator.emission.bursts[0]);
+ expect(clonedBursts[0].time).eq(0.5);
+ expect(clonedBursts[0].count.constant).eq(30);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Script-held ReferResource", () => {
+ it("cloned slot owns one reference; the script's own contract releases it", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(ResourceRefScript);
+ const texture = new Texture2D(engine, 4, 4);
+ script.texture = texture;
+ expect(texture.refCount).eq(1);
+
+ const cloned = parent.clone();
+ const cs = cloned.getComponent(ResourceRefScript);
+
+ // Shared by reference; the cloned slot owns one count — same contract as engine components.
+ expect(cs.texture).eq(texture);
+ expect(texture.refCount).eq(2);
+
+ // Releasing on destroy is the script class's responsibility (onDestroy → setter -1).
+ cloned.destroy();
+ expect(texture.refCount).eq(1);
+
+ parent.destroy();
+ expect(texture.refCount).eq(0);
+
+ rootEntity.destroy();
+ texture.destroy();
+ });
+
+ it("a replaced owned preset releases its count even when the source slot is empty", () => {
+ PresetTextureScript.created.length = 0;
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(PresetTextureScript);
+ // The source empties the slot, releasing its own preset ownership first.
+ (script.texture as any)._addReferCount(-1);
+ script.texture = null;
+
+ const cloned = parent.clone();
+ expect(PresetTextureScript.created.length).eq(2);
+ const [sourcePreset, clonePreset] = PresetTextureScript.created;
+
+ // The clone's constructor preset was displaced by the empty slot — its owned count returns.
+ expect(cloned.getComponent(PresetTextureScript).texture).eq(null);
+ expect(clonePreset.refCount).eq(0);
+ expect(sourcePreset.refCount).eq(0);
+
+ rootEntity.destroy();
+ });
+
+ it("an unowned counted preset triggers the contract diagnostic and still releases", () => {
+ UnownedPresetScript.created.length = 0;
+ const errorSpy = vi.spyOn(Logger, "error");
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(UnownedPresetScript);
+ script.tex = null;
+
+ const cloned = parent.clone();
+ expect(cloned.getComponent(UnownedPresetScript).tex).eq(null);
+ const diagnostics = errorSpy.mock.calls.filter((c) => String(c[0]).includes("holds no owned reference"));
+ expect(diagnostics.length).eq(1);
+ // Pins the current semantics: the unconditional -1 drives the unowned preset negative.
+ expect(UnownedPresetScript.created[1].refCount).eq(-1);
+
+ errorSpy.mockRestore();
+ rootEntity.destroy();
+ });
+
+ it("a replaced owned preset releases its count when displaced by a deep-cloned value", () => {
+ PresetTextureScript.created.length = 0;
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(PresetTextureScript);
+ (script.texture as any)._addReferCount(-1);
+ // The source slot holds a container: the gate deep-clones it, displacing the clone's preset.
+ (script as any).texture = [1, 2, 3];
+
+ const cloned = parent.clone();
+ const [, clonePreset] = PresetTextureScript.created;
+ expect(cloned.getComponent(PresetTextureScript).texture as any).deep.eq([1, 2, 3]);
+ expect(clonePreset.refCount).eq(0);
+
+ rootEntity.destroy();
+ });
+
+ it("a user type registered Assignment without counting API shares safely", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const script = parent.addComponent(SharedConfigScript);
+ script.config = new SharedConfig();
+
+ const cloned = parent.clone();
+ expect(cloned.getComponent(SharedConfigScript).config).eq(script.config);
+
+ rootEntity.destroy();
+ });
+ });
+
+ describe("Single entity with multiple same-type components", () => {
+ it("clone preserves multiple same-type components with correct state", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const entity = rootEntity.createChild("entity");
+ const script1 = entity.addComponent(CounterScript);
+ const script2 = entity.addComponent(CounterScript);
+ script1.value = 10;
+ script2.value = 20;
+
+ const cloned = entity.clone();
+ const clonedScripts = cloned.getComponents(CounterScript, []);
+
+ expect(clonedScripts.length).eq(2);
+ expect(clonedScripts[0].value).eq(10);
+ expect(clonedScripts[1].value).eq(20);
+ expect(clonedScripts[0]).not.eq(script1);
+ expect(clonedScripts[1]).not.eq(script2);
+
+ rootEntity.destroy();
+ });
+
+ it("ref to second component of same type should remap correctly", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const counter1 = child.addComponent(CounterScript);
+ const counter2 = child.addComponent(CounterScript);
+ counter1.value = 1;
+ counter2.value = 2;
+
+ const refScript = parent.addComponent(CounterRefScript);
+ refScript.counter = counter2;
+
+ const cloned = parent.clone();
+ const clonedRef = cloned.getComponent(CounterRefScript);
+ const clonedCounters = cloned.children[0].getComponents(CounterScript, []);
+
+ expect(clonedRef.counter).not.eq(counter2);
+ expect(clonedRef.counter).eq(clonedCounters[1]);
+ expect(clonedRef.counter.value).eq(2);
+
+ rootEntity.destroy();
+ });
+
+ it("ref to first component of same type should remap correctly", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const counter1 = child.addComponent(CounterScript);
+ const counter2 = child.addComponent(CounterScript);
+ counter1.value = 100;
+ counter2.value = 200;
+
+ const refScript = parent.addComponent(CounterRefScript);
+ refScript.counter = counter1;
+
+ const cloned = parent.clone();
+ const clonedRef = cloned.getComponent(CounterRefScript);
+ const clonedCounters = cloned.children[0].getComponents(CounterScript, []);
+
+ expect(clonedRef.counter).not.eq(counter1);
+ expect(clonedRef.counter).eq(clonedCounters[0]);
+ expect(clonedRef.counter.value).eq(100);
+
+ rootEntity.destroy();
+ });
+
+ it("cross-references between multiple same-type components on same entity", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const entity = rootEntity.createChild("entity");
+ const script1 = entity.addComponent(CounterScript);
+ const script2 = entity.addComponent(CounterScript);
+ script1.value = 1;
+ script2.value = 2;
+ script1.partner = script2;
+ script2.partner = script1;
+
+ const cloned = entity.clone();
+ const clonedScripts = cloned.getComponents(CounterScript, []);
+
+ expect(clonedScripts[0].partner).eq(clonedScripts[1]);
+ expect(clonedScripts[1].partner).eq(clonedScripts[0]);
+ expect(clonedScripts[0].partner).not.eq(script2);
+ expect(clonedScripts[1].partner).not.eq(script1);
+
+ rootEntity.destroy();
+ });
+
+ it("self-reference among multiple same-type components remaps to correct clone", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const entity = rootEntity.createChild("entity");
+ const script1 = entity.addComponent(CounterScript);
+ const script2 = entity.addComponent(CounterScript);
+ script1.partner = script1;
+ script2.partner = script2;
+
+ const cloned = entity.clone();
+ const clonedScripts = cloned.getComponents(CounterScript, []);
+
+ expect(clonedScripts[0].partner).eq(clonedScripts[0]);
+ expect(clonedScripts[1].partner).eq(clonedScripts[1]);
+
+ rootEntity.destroy();
+ });
+
+ it("multiple same-type components with entity refs all remap independently", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const childA = parent.createChild("childA");
+ const childB = parent.createChild("childB");
+ const script1 = parent.addComponent(CounterScript);
+ const script2 = parent.addComponent(CounterScript);
+ script1.targetEntity = childA;
+ script2.targetEntity = childB;
+
+ const cloned = parent.clone();
+ const clonedScripts = cloned.getComponents(CounterScript, []);
+
+ expect(clonedScripts[0].targetEntity).eq(cloned.children[0]);
+ expect(clonedScripts[1].targetEntity).eq(cloned.children[1]);
+ expect(clonedScripts[0].targetEntity.name).eq("childA");
+ expect(clonedScripts[1].targetEntity.name).eq("childB");
+
+ rootEntity.destroy();
+ });
+
+ it("@deepClone array referencing specific component among same-type siblings", () => {
+ const rootEntity = scene.createRootEntity("root");
+ const parent = rootEntity.createChild("parent");
+ const child = parent.createChild("child");
+ const counter1 = child.addComponent(CounterScript);
+ const counter2 = child.addComponent(CounterScript);
+ const counter3 = child.addComponent(CounterScript);
+ counter1.value = 1;
+ counter2.value = 2;
+ counter3.value = 3;
+
+ const arrayScript = parent.addComponent(ArrayRefScript);
+ // Note: ArrayRefScript uses Entity[], but we test component indexing
+ // via direct component references instead
+ const refScript1 = parent.addComponent(CounterRefScript);
+ const refScript2 = parent.addComponent(CounterRefScript);
+ refScript1.counter = counter1;
+ refScript2.counter = counter3;
+
+ const cloned = parent.clone();
+ const clonedRefs = cloned.getComponents(CounterRefScript, []);
+ const clonedCounters = cloned.children[0].getComponents(CounterScript, []);
+
+ expect(clonedRefs[0].counter).eq(clonedCounters[0]);
+ expect(clonedRefs[0].counter.value).eq(1);
+ expect(clonedRefs[1].counter).eq(clonedCounters[2]);
+ expect(clonedRefs[1].counter.value).eq(3);
+
+ rootEntity.destroy();
+ });
+ });
+});
diff --git a/tests/src/core/CloneTextureRefCount.test.ts b/tests/src/core/CloneTextureRefCount.test.ts
new file mode 100644
index 0000000000..9480db9988
--- /dev/null
+++ b/tests/src/core/CloneTextureRefCount.test.ts
@@ -0,0 +1,120 @@
+import {
+ Animator,
+ AnimatorController,
+ Camera,
+ Entity,
+ Font,
+ MeshRenderer,
+ RenderTarget,
+ SkinnedMeshRenderer,
+ TextRenderer,
+ Texture2D
+} from "@galacean/engine-core";
+import { WebGLEngine } from "@galacean/engine";
+import { describe, beforeAll, expect, it } from "vitest";
+
+describe("Clone resource refCount", async function () {
+ let engine: WebGLEngine;
+ let rootEntity: Entity;
+
+ beforeAll(async function () {
+ engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
+ rootEntity = engine.sceneManager.activeScene.createRootEntity();
+ engine.run();
+ });
+
+ it("renderer-private joint texture is not propagated into clones", () => {
+ const entity = rootEntity.createChild("skinnedSrc");
+ const smr = entity.addComponent(SkinnedMeshRenderer);
+ // Simulate the state _update builds when the bone count exceeds the uniform budget.
+ const jointTexture = new Texture2D(engine, 4, 4);
+ jointTexture.isGCIgnored = true;
+ // @ts-ignore
+ smr._jointTexture = jointTexture;
+ smr.shaderData.setTexture("renderer_JointSampler", jointTexture);
+ expect(jointTexture.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ const clonedSmr = clone.getComponent(SkinnedMeshRenderer);
+ // The clone must not hold the source's private texture; it rebuilds its own on first update.
+ expect(clonedSmr.shaderData.getTexture("renderer_JointSampler") ?? null).eq(null);
+ expect(jointTexture.refCount).eq(1);
+
+ // With no stray reference, the source's one-shot destroy() in _onDestroy succeeds (refCount is zero).
+ entity.destroy();
+ expect(jointTexture.refCount).eq(0);
+
+ clone.destroy();
+ });
+
+ it("clone shares texture with balanced refCount (no leak)", () => {
+ const texture = new Texture2D(engine, 1, 1);
+ const entity = rootEntity.createChild("src");
+ entity.addComponent(MeshRenderer).shaderData.setTexture("u_tex", texture);
+ expect(texture.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ // The clone owns an independent shaderData that references the shared texture → +1.
+ expect(texture.refCount).eq(2);
+
+ clone.destroy();
+ // Destroying the clone releases that reference → back to baseline, no leak.
+ expect(texture.refCount).eq(1);
+
+ entity.destroy();
+ });
+
+ it("camera renderTarget refCount stays balanced across clone/destroy", () => {
+ const rt = new RenderTarget(engine, 4, 4, new Texture2D(engine, 4, 4));
+ const entity = rootEntity.createChild("cameraSrc");
+ entity.addComponent(Camera).renderTarget = rt;
+ expect(rt.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ // The clone holds exactly one additional reference (acquired by the clone gate on the shared `_renderTarget` slot).
+ expect(rt.refCount).eq(2);
+
+ clone.destroy();
+ expect(rt.refCount).eq(1);
+
+ entity.destroy();
+ expect(rt.refCount).eq(0);
+ });
+
+ it("text renderer font refCount stays balanced across clone/destroy", () => {
+ const font = Font.createFromOS(engine, "Arial-CloneTest");
+ const entity = rootEntity.createChild("textSrc");
+ entity.addComponent(TextRenderer).font = font;
+ const baseline = font.refCount;
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ // The clone holds exactly one additional reference (acquired by the clone gate on the shared `_font` slot).
+ expect(font.refCount).eq(baseline + 1);
+
+ clone.destroy();
+ expect(font.refCount).eq(baseline);
+
+ entity.destroy();
+ });
+
+ it("animator controller refCount stays balanced across clone/destroy", () => {
+ const controller = new AnimatorController(engine);
+ const entity = rootEntity.createChild("animatorSrc");
+ entity.addComponent(Animator).animatorController = controller;
+ const baseline = controller.refCount;
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ // The clone holds one additional reference (clone gate on the shared `_animatorController` slot; _onClone only re-registers the change flag).
+ expect(controller.refCount).eq(baseline + 1);
+
+ clone.destroy();
+ expect(controller.refCount).eq(baseline);
+
+ entity.destroy();
+ });
+});
diff --git a/tests/src/core/CloneUtils.test.ts b/tests/src/core/CloneUtils.test.ts
deleted file mode 100644
index 8cf3dd9d55..0000000000
--- a/tests/src/core/CloneUtils.test.ts
+++ /dev/null
@@ -1,870 +0,0 @@
-import { Entity, MeshRenderer, Script, Signal, assignmentClone, deepClone, ignoreClone } from "@galacean/engine-core";
-import { WebGLEngine } from "@galacean/engine";
-import { describe, expect, it } from "vitest";
-
-class TestScript extends Script {
- targetEntity: Entity;
- targetRenderer: MeshRenderer;
- externalEntity: Entity;
- externalRenderer: MeshRenderer;
- deepChild: Entity;
- selfRef: Entity;
- speed: number;
- name2: string;
- flag: boolean;
- data: object;
-}
-
-/** Script with multiple entity/component refs pointing to different nodes */
-class MultiRefScript extends Script {
- entityA: Entity;
- entityB: Entity;
- rendererA: MeshRenderer;
- rendererB: MeshRenderer;
-}
-
-/** Script where the same entity is referenced by multiple properties */
-class DuplicateRefScript extends Script {
- ref1: Entity;
- ref2: Entity;
-}
-
-/** Script with null/undefined entity/component refs */
-class NullRefScript extends Script {
- nullEntity: Entity = null;
- undefinedEntity: Entity;
- nullRenderer: MeshRenderer = null;
- someNumber: number = 0;
-}
-
-/** Script referencing a sibling entity (not parent/child, but sibling under clone root) */
-class SiblingRefScript extends Script {
- sibling: Entity;
- siblingRenderer: MeshRenderer;
-}
-
-/** Script with a mix of decorated and undecorated entity refs */
-class DecoratedRefScript extends Script {
- // Undecorated — should auto-remap via _remap
- autoRemapEntity: Entity;
-
- // @assignmentClone — should still auto-remap since _remap takes priority
- @assignmentClone
- assignedEntity: Entity;
-
- // @ignoreClone — should still auto-remap since _remap takes priority
- @ignoreClone
- ignoredEntity: Entity;
-}
-
-/** Script with a @deepClone array of entities */
-class ArrayRefScript extends Script {
- @deepClone
- entities: Entity[] = [];
-}
-
-/** Script with Component self-reference */
-class SelfComponentRefScript extends Script {
- selfScript: SelfComponentRefScript;
-}
-
-/** Script referencing another Script on a different entity */
-class CrossScriptRefScript extends Script {
- otherScript: TestScript;
-}
-
-/** Script with a nested plain object containing entity refs */
-class NestedObjectScript extends Script {
- @deepClone
- config: { target: Entity; label: string } = { target: null, label: "" };
-}
-
-/** Script for testing multiple same-type components on one entity */
-class CounterScript extends Script {
- value: number = 0;
- partner: CounterScript;
- targetEntity: Entity;
-}
-
-/** Script that references a CounterScript */
-class CounterRefScript extends Script {
- counter: CounterScript;
-}
-
-/** Handler script used for Signal structured binding tests */
-class ClickHandler extends Script {
- callCount = 0;
- lastPrefix: string = "";
-
- handleClick(): void {
- this.callCount++;
- }
-
- handleClickWithPrefix(arg: number, prefix: string): void {
- this.callCount++;
- this.lastPrefix = prefix;
- }
-}
-
-/** Script with a Signal property */
-class SignalScript extends Script {
- @deepClone
- readonly onFire = new Signal<[number]>();
-}
-
-describe("Clone remap", async () => {
- const engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
- const scene = engine.sceneManager.activeScene;
- engine.run();
-
- describe("Basic Entity/Component remap", () => {
- it("script undecorated Entity ref should remap to cloned entity", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const script = parent.addComponent(TestScript);
- script.targetEntity = child;
-
- const clonedParent = parent.clone();
- const clonedScript = clonedParent.getComponent(TestScript);
- const clonedChild = clonedParent.children[0];
-
- expect(clonedScript.targetEntity).not.eq(child);
- expect(clonedScript.targetEntity).eq(clonedChild);
- expect(clonedScript.targetEntity.name).eq("child");
-
- rootEntity.destroy();
- });
-
- it("script undecorated Component ref should remap to cloned component", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const meshRenderer = child.addComponent(MeshRenderer);
- const script = parent.addComponent(TestScript);
- script.targetRenderer = meshRenderer;
-
- const clonedParent = parent.clone();
- const clonedScript = clonedParent.getComponent(TestScript);
- const clonedChild = clonedParent.children[0];
- const clonedMeshRenderer = clonedChild.getComponent(MeshRenderer);
-
- expect(clonedScript.targetRenderer).not.eq(meshRenderer);
- expect(clonedScript.targetRenderer).eq(clonedMeshRenderer);
-
- rootEntity.destroy();
- });
-
- it("script ref to entity outside hierarchy should keep original", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const external = rootEntity.createChild("external");
- const script = parent.addComponent(TestScript);
- script.externalEntity = external;
-
- const clonedParent = parent.clone();
- const clonedScript = clonedParent.getComponent(TestScript);
-
- expect(clonedScript.externalEntity).eq(external);
-
- rootEntity.destroy();
- });
-
- it("script ref to component outside hierarchy should keep original", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const external = rootEntity.createChild("external");
- const externalMR = external.addComponent(MeshRenderer);
- const script = parent.addComponent(TestScript);
- script.externalRenderer = externalMR;
-
- const clonedParent = parent.clone();
- const clonedScript = clonedParent.getComponent(TestScript);
-
- expect(clonedScript.externalRenderer).eq(externalMR);
-
- rootEntity.destroy();
- });
-
- it("deep hierarchy entity ref should remap correctly", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const grandchild = child.createChild("grandchild");
- const script = parent.addComponent(TestScript);
- script.deepChild = grandchild;
-
- const clonedParent = parent.clone();
- const clonedScript = clonedParent.getComponent(TestScript);
- const clonedGrandchild = clonedParent.children[0].children[0];
-
- expect(clonedScript.deepChild).not.eq(grandchild);
- expect(clonedScript.deepChild).eq(clonedGrandchild);
- expect(clonedScript.deepChild.name).eq("grandchild");
-
- rootEntity.destroy();
- });
-
- it("script ref to self entity (clone root) should remap", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const script = parent.addComponent(TestScript);
- script.selfRef = parent;
-
- const clonedParent = parent.clone();
- const clonedScript = clonedParent.getComponent(TestScript);
-
- expect(clonedScript.selfRef).not.eq(parent);
- expect(clonedScript.selfRef).eq(clonedParent);
-
- rootEntity.destroy();
- });
-
- it("primitive and plain object props should not be affected", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const script = parent.addComponent(TestScript);
- const obj = { x: 1 };
- script.speed = 42;
- script.name2 = "test";
- script.flag = true;
- script.data = obj;
-
- const clonedParent = parent.clone();
- const clonedScript = clonedParent.getComponent(TestScript);
-
- expect(clonedScript.speed).eq(42);
- expect(clonedScript.name2).eq("test");
- expect(clonedScript.flag).eq(true);
- expect(clonedScript.data).eq(obj);
-
- rootEntity.destroy();
- });
- });
-
- describe("Multiple and duplicate refs", () => {
- it("multiple entity/component refs on same script all remap independently", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const childA = parent.createChild("childA");
- const childB = parent.createChild("childB");
- const mrA = childA.addComponent(MeshRenderer);
- const mrB = childB.addComponent(MeshRenderer);
- const script = parent.addComponent(MultiRefScript);
- script.entityA = childA;
- script.entityB = childB;
- script.rendererA = mrA;
- script.rendererB = mrB;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(MultiRefScript);
-
- expect(cs.entityA).not.eq(childA);
- expect(cs.entityB).not.eq(childB);
- expect(cs.entityA.name).eq("childA");
- expect(cs.entityB.name).eq("childB");
- expect(cs.entityA).eq(cloned.children[0]);
- expect(cs.entityB).eq(cloned.children[1]);
- expect(cs.rendererA).eq(cloned.children[0].getComponent(MeshRenderer));
- expect(cs.rendererB).eq(cloned.children[1].getComponent(MeshRenderer));
-
- rootEntity.destroy();
- });
-
- it("two properties referencing the same entity both remap to the same cloned entity", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const script = parent.addComponent(DuplicateRefScript);
- script.ref1 = child;
- script.ref2 = child;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(DuplicateRefScript);
-
- expect(cs.ref1).not.eq(child);
- expect(cs.ref1).eq(cs.ref2);
- expect(cs.ref1).eq(cloned.children[0]);
-
- rootEntity.destroy();
- });
- });
-
- describe("Null and undefined refs", () => {
- it("null entity/component refs should not crash and remain null", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const script = parent.addComponent(NullRefScript);
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(NullRefScript);
-
- expect(cs.nullEntity).eq(null);
- expect(cs.nullRenderer).eq(null);
- expect(cs.someNumber).eq(0);
-
- rootEntity.destroy();
- });
- });
-
- describe("Sibling entity refs", () => {
- it("ref to sibling entity under clone root should remap", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const childA = parent.createChild("childA");
- const childB = parent.createChild("childB");
- const mrB = childB.addComponent(MeshRenderer);
- const script = childA.addComponent(SiblingRefScript);
- script.sibling = childB;
- script.siblingRenderer = mrB;
-
- const cloned = parent.clone();
- const clonedChildA = cloned.children[0];
- const clonedChildB = cloned.children[1];
- const cs = clonedChildA.getComponent(SiblingRefScript);
-
- expect(cs.sibling).not.eq(childB);
- expect(cs.sibling).eq(clonedChildB);
- expect(cs.siblingRenderer).not.eq(mrB);
- expect(cs.siblingRenderer).eq(clonedChildB.getComponent(MeshRenderer));
-
- rootEntity.destroy();
- });
- });
-
- describe("Clone decorator interaction with _remap", () => {
- it("@assignmentClone entity ref still gets remapped via _remap priority", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const script = parent.addComponent(DecoratedRefScript);
- script.assignedEntity = child;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(DecoratedRefScript);
-
- expect(cs.assignedEntity).not.eq(child);
- expect(cs.assignedEntity).eq(cloned.children[0]);
-
- rootEntity.destroy();
- });
-
- it("@ignoreClone entity ref still gets remapped via _remap priority", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const script = parent.addComponent(DecoratedRefScript);
- script.ignoredEntity = child;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(DecoratedRefScript);
-
- expect(cs.ignoredEntity).not.eq(child);
- expect(cs.ignoredEntity).eq(cloned.children[0]);
-
- rootEntity.destroy();
- });
-
- it("undecorated entity ref remaps correctly", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const script = parent.addComponent(DecoratedRefScript);
- script.autoRemapEntity = child;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(DecoratedRefScript);
-
- expect(cs.autoRemapEntity).not.eq(child);
- expect(cs.autoRemapEntity).eq(cloned.children[0]);
-
- rootEntity.destroy();
- });
-
- it("@ignoreClone entity ref outside hierarchy stays original", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const external = rootEntity.createChild("external");
- const script = parent.addComponent(DecoratedRefScript);
- script.ignoredEntity = external;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(DecoratedRefScript);
-
- expect(cs.ignoredEntity).eq(external);
-
- rootEntity.destroy();
- });
- });
-
- describe("@deepClone array of entities", () => {
- it("deep cloned entity array should remap internal refs", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const childA = parent.createChild("childA");
- const childB = parent.createChild("childB");
- const script = parent.addComponent(ArrayRefScript);
- script.entities = [childA, childB];
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(ArrayRefScript);
-
- expect(cs.entities).not.eq(script.entities);
- expect(cs.entities.length).eq(2);
- expect(cs.entities[0]).not.eq(childA);
- expect(cs.entities[1]).not.eq(childB);
- expect(cs.entities[0]).eq(cloned.children[0]);
- expect(cs.entities[1]).eq(cloned.children[1]);
-
- rootEntity.destroy();
- });
-
- it("deep cloned entity array with external ref keeps original", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const external = rootEntity.createChild("external");
- const script = parent.addComponent(ArrayRefScript);
- script.entities = [child, external];
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(ArrayRefScript);
-
- expect(cs.entities[0]).eq(cloned.children[0]);
- expect(cs.entities[1]).eq(external);
-
- rootEntity.destroy();
- });
-
- it("deep cloned empty entity array stays empty", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const script = parent.addComponent(ArrayRefScript);
- script.entities = [];
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(ArrayRefScript);
-
- expect(cs.entities).not.eq(script.entities);
- expect(cs.entities.length).eq(0);
-
- rootEntity.destroy();
- });
- });
-
- describe("Component self and cross references", () => {
- it("script referencing itself should remap to cloned script", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const script = parent.addComponent(SelfComponentRefScript);
- script.selfScript = script;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(SelfComponentRefScript);
-
- expect(cs.selfScript).not.eq(script);
- expect(cs.selfScript).eq(cs);
-
- rootEntity.destroy();
- });
-
- it("script referencing another script on child entity should remap", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const childScript = child.addComponent(TestScript);
- const script = parent.addComponent(CrossScriptRefScript);
- script.otherScript = childScript;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(CrossScriptRefScript);
- const clonedChildScript = cloned.children[0].getComponent(TestScript);
-
- expect(cs.otherScript).not.eq(childScript);
- expect(cs.otherScript).eq(clonedChildScript);
-
- rootEntity.destroy();
- });
-
- it("script referencing external script should keep original", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const external = rootEntity.createChild("external");
- const externalScript = external.addComponent(TestScript);
- const script = parent.addComponent(CrossScriptRefScript);
- script.otherScript = externalScript;
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(CrossScriptRefScript);
-
- expect(cs.otherScript).eq(externalScript);
-
- rootEntity.destroy();
- });
- });
-
- describe("Nested @deepClone object with entity refs", () => {
- it("entity ref inside deep cloned plain object should remap", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const script = parent.addComponent(NestedObjectScript);
- script.config = { target: child, label: "hello" };
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(NestedObjectScript);
-
- expect(cs.config).not.eq(script.config);
- expect(cs.config.label).eq("hello");
- expect(cs.config.target).not.eq(child);
- expect(cs.config.target).eq(cloned.children[0]);
-
- rootEntity.destroy();
- });
-
- it("entity ref inside deep cloned object pointing outside keeps original", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const external = rootEntity.createChild("external");
- const script = parent.addComponent(NestedObjectScript);
- script.config = { target: external, label: "ext" };
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(NestedObjectScript);
-
- expect(cs.config.target).eq(external);
- expect(cs.config.label).eq("ext");
-
- rootEntity.destroy();
- });
- });
-
- describe("Signal clone with structured bindings", () => {
- it("@deepClone Signal should not copy closure listeners", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const script = parent.addComponent(SignalScript);
- let called = false;
- script.onFire.on(() => {
- called = true;
- });
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(SignalScript);
-
- expect(cs.onFire).not.eq(script.onFire);
- cs.onFire.invoke(1);
- expect(called).eq(false);
-
- rootEntity.destroy();
- });
-
- it("@deepClone Signal should remap structured binding target to cloned hierarchy", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const handlerEntity = parent.createChild("handler");
- const handler = handlerEntity.addComponent(ClickHandler);
- const script = parent.addComponent(SignalScript);
- script.onFire.on(handler, "handleClick");
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(SignalScript);
- const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler);
-
- cs.onFire.invoke(1);
- expect(clonedHandler.callCount).eq(1);
- expect(handler.callCount).eq(0);
-
- rootEntity.destroy();
- });
-
- it("@deepClone Signal should keep external structured binding target", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const external = rootEntity.createChild("external");
- const externalHandler = external.addComponent(ClickHandler);
- const script = parent.addComponent(SignalScript);
- script.onFire.on(externalHandler, "handleClick");
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(SignalScript);
-
- cs.onFire.invoke(1);
- expect(externalHandler.callCount).eq(1);
-
- rootEntity.destroy();
- });
-
- it("@deepClone Signal should remap structured binding with pre-resolved args", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const handlerEntity = parent.createChild("handler");
- const handler = handlerEntity.addComponent(ClickHandler);
- const script = parent.addComponent(SignalScript);
- script.onFire.on(handler, "handleClickWithPrefix", "myPrefix");
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(SignalScript);
- const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler);
-
- cs.onFire.invoke(1);
- expect(clonedHandler.callCount).eq(1);
- expect(clonedHandler.lastPrefix).eq("myPrefix");
- expect(handler.callCount).eq(0);
-
- rootEntity.destroy();
- });
-
- it("@deepClone Signal should preserve once flag on structured binding", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const handlerEntity = parent.createChild("handler");
- const handler = handlerEntity.addComponent(ClickHandler);
- const script = parent.addComponent(SignalScript);
- script.onFire.once(handler, "handleClick");
-
- const cloned = parent.clone();
- const cs = cloned.getComponent(SignalScript);
- const clonedHandler = cloned.findByName("handler").getComponent(ClickHandler);
-
- cs.onFire.invoke(1);
- expect(clonedHandler.callCount).eq(1);
- cs.onFire.invoke(2);
- expect(clonedHandler.callCount).eq(1); // once: removed after first call
-
- rootEntity.destroy();
- });
- });
-
- describe("Clone hierarchy integrity", () => {
- it("clone preserves children count and names", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- parent.createChild("a");
- parent.createChild("b");
- parent.createChild("c");
-
- const cloned = parent.clone();
- expect(cloned.children.length).eq(3);
- expect(cloned.children[0].name).eq("a");
- expect(cloned.children[1].name).eq("b");
- expect(cloned.children[2].name).eq("c");
-
- rootEntity.destroy();
- });
-
- it("clone of deeply nested hierarchy preserves structure", () => {
- const rootEntity = scene.createRootEntity("root");
- const a = rootEntity.createChild("a");
- const b = a.createChild("b");
- const c = b.createChild("c");
- const d = c.createChild("d");
-
- const cloned = a.clone();
- expect(cloned.children[0].name).eq("b");
- expect(cloned.children[0].children[0].name).eq("c");
- expect(cloned.children[0].children[0].children[0].name).eq("d");
-
- rootEntity.destroy();
- });
-
- it("script on child entity with ref to parent should remap", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const script = child.addComponent(TestScript);
- script.targetEntity = parent;
-
- const cloned = parent.clone();
- const clonedChild = cloned.children[0];
- const cs = clonedChild.getComponent(TestScript);
-
- expect(cs.targetEntity).not.eq(parent);
- expect(cs.targetEntity).eq(cloned);
-
- rootEntity.destroy();
- });
-
- it("multiple scripts on different entities with cross-refs all remap correctly", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const childA = parent.createChild("childA");
- const childB = parent.createChild("childB");
-
- const scriptA = childA.addComponent(TestScript);
- scriptA.targetEntity = childB;
-
- const scriptB = childB.addComponent(TestScript);
- scriptB.targetEntity = childA;
-
- const cloned = parent.clone();
- const clonedA = cloned.children[0];
- const clonedB = cloned.children[1];
- const csA = clonedA.getComponent(TestScript);
- const csB = clonedB.getComponent(TestScript);
-
- expect(csA.targetEntity).eq(clonedB);
- expect(csB.targetEntity).eq(clonedA);
-
- rootEntity.destroy();
- });
- });
-
- describe("Single entity with multiple same-type components", () => {
- it("clone preserves multiple same-type components with correct state", () => {
- const rootEntity = scene.createRootEntity("root");
- const entity = rootEntity.createChild("entity");
- const script1 = entity.addComponent(CounterScript);
- const script2 = entity.addComponent(CounterScript);
- script1.value = 10;
- script2.value = 20;
-
- const cloned = entity.clone();
- const clonedScripts = cloned.getComponents(CounterScript, []);
-
- expect(clonedScripts.length).eq(2);
- expect(clonedScripts[0].value).eq(10);
- expect(clonedScripts[1].value).eq(20);
- expect(clonedScripts[0]).not.eq(script1);
- expect(clonedScripts[1]).not.eq(script2);
-
- rootEntity.destroy();
- });
-
- it("ref to second component of same type should remap correctly", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const counter1 = child.addComponent(CounterScript);
- const counter2 = child.addComponent(CounterScript);
- counter1.value = 1;
- counter2.value = 2;
-
- const refScript = parent.addComponent(CounterRefScript);
- refScript.counter = counter2;
-
- const cloned = parent.clone();
- const clonedRef = cloned.getComponent(CounterRefScript);
- const clonedCounters = cloned.children[0].getComponents(CounterScript, []);
-
- expect(clonedRef.counter).not.eq(counter2);
- expect(clonedRef.counter).eq(clonedCounters[1]);
- expect(clonedRef.counter.value).eq(2);
-
- rootEntity.destroy();
- });
-
- it("ref to first component of same type should remap correctly", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const counter1 = child.addComponent(CounterScript);
- const counter2 = child.addComponent(CounterScript);
- counter1.value = 100;
- counter2.value = 200;
-
- const refScript = parent.addComponent(CounterRefScript);
- refScript.counter = counter1;
-
- const cloned = parent.clone();
- const clonedRef = cloned.getComponent(CounterRefScript);
- const clonedCounters = cloned.children[0].getComponents(CounterScript, []);
-
- expect(clonedRef.counter).not.eq(counter1);
- expect(clonedRef.counter).eq(clonedCounters[0]);
- expect(clonedRef.counter.value).eq(100);
-
- rootEntity.destroy();
- });
-
- it("cross-references between multiple same-type components on same entity", () => {
- const rootEntity = scene.createRootEntity("root");
- const entity = rootEntity.createChild("entity");
- const script1 = entity.addComponent(CounterScript);
- const script2 = entity.addComponent(CounterScript);
- script1.value = 1;
- script2.value = 2;
- script1.partner = script2;
- script2.partner = script1;
-
- const cloned = entity.clone();
- const clonedScripts = cloned.getComponents(CounterScript, []);
-
- expect(clonedScripts[0].partner).eq(clonedScripts[1]);
- expect(clonedScripts[1].partner).eq(clonedScripts[0]);
- expect(clonedScripts[0].partner).not.eq(script2);
- expect(clonedScripts[1].partner).not.eq(script1);
-
- rootEntity.destroy();
- });
-
- it("self-reference among multiple same-type components remaps to correct clone", () => {
- const rootEntity = scene.createRootEntity("root");
- const entity = rootEntity.createChild("entity");
- const script1 = entity.addComponent(CounterScript);
- const script2 = entity.addComponent(CounterScript);
- script1.partner = script1;
- script2.partner = script2;
-
- const cloned = entity.clone();
- const clonedScripts = cloned.getComponents(CounterScript, []);
-
- expect(clonedScripts[0].partner).eq(clonedScripts[0]);
- expect(clonedScripts[1].partner).eq(clonedScripts[1]);
-
- rootEntity.destroy();
- });
-
- it("multiple same-type components with entity refs all remap independently", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const childA = parent.createChild("childA");
- const childB = parent.createChild("childB");
- const script1 = parent.addComponent(CounterScript);
- const script2 = parent.addComponent(CounterScript);
- script1.targetEntity = childA;
- script2.targetEntity = childB;
-
- const cloned = parent.clone();
- const clonedScripts = cloned.getComponents(CounterScript, []);
-
- expect(clonedScripts[0].targetEntity).eq(cloned.children[0]);
- expect(clonedScripts[1].targetEntity).eq(cloned.children[1]);
- expect(clonedScripts[0].targetEntity.name).eq("childA");
- expect(clonedScripts[1].targetEntity.name).eq("childB");
-
- rootEntity.destroy();
- });
-
- it("@deepClone array referencing specific component among same-type siblings", () => {
- const rootEntity = scene.createRootEntity("root");
- const parent = rootEntity.createChild("parent");
- const child = parent.createChild("child");
- const counter1 = child.addComponent(CounterScript);
- const counter2 = child.addComponent(CounterScript);
- const counter3 = child.addComponent(CounterScript);
- counter1.value = 1;
- counter2.value = 2;
- counter3.value = 3;
-
- const arrayScript = parent.addComponent(ArrayRefScript);
- // Note: ArrayRefScript uses Entity[], but we test component indexing
- // via direct component references instead
- const refScript1 = parent.addComponent(CounterRefScript);
- const refScript2 = parent.addComponent(CounterRefScript);
- refScript1.counter = counter1;
- refScript2.counter = counter3;
-
- const cloned = parent.clone();
- const clonedRefs = cloned.getComponents(CounterRefScript, []);
- const clonedCounters = cloned.children[0].getComponents(CounterScript, []);
-
- expect(clonedRefs[0].counter).eq(clonedCounters[0]);
- expect(clonedRefs[0].counter.value).eq(1);
- expect(clonedRefs[1].counter).eq(clonedCounters[2]);
- expect(clonedRefs[1].counter.value).eq(3);
-
- rootEntity.destroy();
- });
- });
-});
diff --git a/tests/src/core/RefCountContract.test.ts b/tests/src/core/RefCountContract.test.ts
new file mode 100644
index 0000000000..c4634ed105
--- /dev/null
+++ b/tests/src/core/RefCountContract.test.ts
@@ -0,0 +1,266 @@
+import {
+ Animator,
+ AnimatorController,
+ AudioClip,
+ AudioSource,
+ Camera,
+ Entity,
+ MeshRenderer,
+ MeshShape,
+ ModelMesh,
+ ParticleRenderer,
+ RenderTarget,
+ Sprite,
+ SpriteMask,
+ SpriteRenderer,
+ Texture2D
+} from "@galacean/engine-core";
+import { Vector3 } from "@galacean/engine-math";
+import { WebGLEngine } from "@galacean/engine";
+import { beforeAll, describe, expect, it } from "vitest";
+
+/**
+ * Slot-ownership contract: a component top-level field sharing a ref-counted resource owns one
+ * reference — acquired by the setter (user path) or the clone gate (clone path), transferred by
+ * setter churn (-old/+new), and released by the component's destroy path.
+ * Every suite below asserts the full lifecycle: baseline → clone +1 → churn → destroy release.
+ */
+describe("RefCount slot-ownership contract", () => {
+ let engine: WebGLEngine;
+ let rootEntity: Entity;
+
+ beforeAll(async () => {
+ engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
+ rootEntity = engine.sceneManager.activeScene.createRootEntity();
+ engine.run();
+ });
+
+ describe("Camera.renderTarget", () => {
+ it("clone acquires, setter churn transfers, destroy releases", () => {
+ const rtA = new RenderTarget(engine, 4, 4, new Texture2D(engine, 4, 4));
+ const rtB = new RenderTarget(engine, 4, 4, new Texture2D(engine, 4, 4));
+ const entity = rootEntity.createChild("cameraSlot");
+ entity.addComponent(Camera).renderTarget = rtA;
+ expect(rtA.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(rtA.refCount).eq(2);
+
+ clone.getComponent(Camera).renderTarget = rtB;
+ expect(rtA.refCount).eq(1);
+ expect(rtB.refCount).eq(1);
+
+ clone.destroy();
+ expect(rtB.refCount).eq(0);
+ expect(rtA.refCount).eq(1);
+
+ entity.destroy();
+ expect(rtA.refCount).eq(0);
+ });
+ });
+
+ describe("Animator.animatorController", () => {
+ it("clone acquires, setter churn transfers, destroy releases", () => {
+ const controllerA = new AnimatorController(engine);
+ const controllerB = new AnimatorController(engine);
+ const entity = rootEntity.createChild("animatorSlot");
+ entity.addComponent(Animator).animatorController = controllerA;
+ expect(controllerA.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(controllerA.refCount).eq(2);
+
+ clone.getComponent(Animator).animatorController = controllerB;
+ expect(controllerA.refCount).eq(1);
+ expect(controllerB.refCount).eq(1);
+
+ clone.destroy();
+ expect(controllerB.refCount).eq(0);
+ expect(controllerA.refCount).eq(1);
+
+ entity.destroy();
+ expect(controllerA.refCount).eq(0);
+ });
+ });
+
+ describe("AudioSource.clip", () => {
+ it("clone acquires, setter churn transfers, destroy releases", () => {
+ const clipA = new AudioClip(engine, "clipA");
+ const clipB = new AudioClip(engine, "clipB");
+ const entity = rootEntity.createChild("audioSlot");
+ entity.addComponent(AudioSource).clip = clipA;
+ expect(clipA.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(clipA.refCount).eq(2);
+
+ clone.getComponent(AudioSource).clip = clipB;
+ expect(clipA.refCount).eq(1);
+ expect(clipB.refCount).eq(1);
+
+ clone.destroy();
+ expect(clipB.refCount).eq(0);
+ expect(clipA.refCount).eq(1);
+
+ entity.destroy();
+ expect(clipA.refCount).eq(0);
+ });
+ });
+
+ describe("SpriteRenderer.sprite", () => {
+ it("clone acquires, setter churn transfers, destroy releases", () => {
+ const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const entity = rootEntity.createChild("spriteRendererSlot");
+ entity.addComponent(SpriteRenderer).sprite = spriteA;
+ expect(spriteA.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(spriteA.refCount).eq(2);
+
+ clone.getComponent(SpriteRenderer).sprite = spriteB;
+ expect(spriteA.refCount).eq(1);
+ expect(spriteB.refCount).eq(1);
+
+ clone.destroy();
+ expect(spriteB.refCount).eq(0);
+ expect(spriteA.refCount).eq(1);
+
+ entity.destroy();
+ expect(spriteA.refCount).eq(0);
+ });
+ });
+
+ describe("SpriteMask.sprite", () => {
+ it("clone acquires, setter churn transfers, destroy releases", () => {
+ const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const entity = rootEntity.createChild("spriteMaskSlot");
+ entity.addComponent(SpriteMask).sprite = spriteA;
+ expect(spriteA.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(spriteA.refCount).eq(2);
+
+ clone.getComponent(SpriteMask).sprite = spriteB;
+ expect(spriteA.refCount).eq(1);
+ expect(spriteB.refCount).eq(1);
+
+ clone.destroy();
+ expect(spriteB.refCount).eq(0);
+ expect(spriteA.refCount).eq(1);
+
+ entity.destroy();
+ expect(spriteA.refCount).eq(0);
+ });
+ });
+
+ describe("MeshRenderer.mesh (setter-mode slot)", () => {
+ it("clone acquires via the setter in _onClone, churn transfers, destroy releases", () => {
+ const meshA = new ModelMesh(engine);
+ const meshB = new ModelMesh(engine);
+ const entity = rootEntity.createChild("meshRendererSlot");
+ entity.addComponent(MeshRenderer).mesh = meshA;
+ expect(meshA.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(meshA.refCount).eq(2);
+
+ clone.getComponent(MeshRenderer).mesh = meshB;
+ expect(meshA.refCount).eq(1);
+ expect(meshB.refCount).eq(1);
+
+ clone.destroy();
+ expect(meshB.refCount).eq(0);
+ expect(meshA.refCount).eq(1);
+
+ entity.destroy();
+ expect(meshA.refCount).eq(0);
+ });
+ });
+
+ describe("Particle MeshShape.mesh", () => {
+ function createShapeMesh(): ModelMesh {
+ const mesh = new ModelMesh(engine);
+ mesh.setPositions([new Vector3(0, 0, 0), new Vector3(1, 0, 0), new Vector3(0, 1, 0)]);
+ mesh.setNormals([new Vector3(0, 0, 1), new Vector3(0, 0, 1), new Vector3(0, 0, 1)]);
+ mesh.uploadData(false);
+ return mesh;
+ }
+
+ it("setter churn transfers the count", () => {
+ const meshA = createShapeMesh();
+ const meshB = createShapeMesh();
+ const shape = new MeshShape();
+ shape.mesh = meshA;
+ expect(meshA.refCount).eq(1);
+
+ shape.mesh = meshB;
+ expect(meshA.refCount).eq(0);
+ expect(meshB.refCount).eq(1);
+
+ shape.mesh = null;
+ expect(meshB.refCount).eq(0);
+ });
+
+ it("destroying the hosting renderer releases the shape's mesh", () => {
+ const mesh = createShapeMesh();
+ const shape = new MeshShape();
+ shape.mesh = mesh;
+ const entity = rootEntity.createChild("particleShapeSlot");
+ entity.addComponent(ParticleRenderer).generator.emission.shape = shape;
+ expect(mesh.refCount).eq(1);
+
+ entity.destroy();
+ expect(mesh.refCount).eq(0);
+ });
+
+ it("clone acquires via the shape's _onClone, destroy releases", () => {
+ const mesh = createShapeMesh();
+ const shape = new MeshShape();
+ shape.mesh = mesh;
+ const entity = rootEntity.createChild("particleShapeClone");
+ entity.addComponent(ParticleRenderer).generator.emission.shape = shape;
+ expect(mesh.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(mesh.refCount).eq(2);
+
+ clone.destroy();
+ expect(mesh.refCount).eq(1);
+
+ entity.destroy();
+ expect(mesh.refCount).eq(0);
+ });
+ });
+
+ describe("Template-marked source", () => {
+ it("each clone counts the template resource; the suppressed template itself never does", () => {
+ const template = engine.createEntity("template");
+ const templateResource = new Texture2D(engine, 1, 1);
+ (template as any)._markAsTemplate(templateResource);
+ expect(templateResource.refCount).eq(0);
+
+ const instA = template.clone();
+ rootEntity.addChild(instA);
+ expect(templateResource.refCount).eq(1);
+
+ const instB = template.clone();
+ expect(templateResource.refCount).eq(2);
+
+ instA.destroy();
+ instB.destroy();
+ expect(templateResource.refCount).eq(0);
+
+ template.destroy();
+ expect(templateResource.refCount).eq(0);
+ });
+ });
+});
diff --git a/tests/src/core/ShaderDataRefCount.test.ts b/tests/src/core/ShaderDataRefCount.test.ts
new file mode 100644
index 0000000000..1a7560e3d2
--- /dev/null
+++ b/tests/src/core/ShaderDataRefCount.test.ts
@@ -0,0 +1,183 @@
+import { BlinnPhongMaterial, Entity, MeshRenderer, Texture2D } from "@galacean/engine-core";
+import { WebGLEngine } from "@galacean/engine";
+import { beforeAll, describe, expect, it } from "vitest";
+
+/**
+ * ShaderData is the cascade hub of ref counting: a texture set on a ShaderData owns
+ * `host.refCount` references, and every change of the host's count propagates to its textures
+ * (`ShaderData._addReferCount`), as does `Material._addReferCount` to shaderData + shader.
+ */
+describe("ShaderData / Material refCount cascade", () => {
+ let engine: WebGLEngine;
+ let rootEntity: Entity;
+
+ beforeAll(async () => {
+ engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
+ rootEntity = engine.sceneManager.activeScene.createRootEntity();
+ engine.run();
+ });
+
+ it("setTexture swap / clear transfers counts by the host's refCount", () => {
+ const material = new BlinnPhongMaterial(engine);
+ const entity = rootEntity.createChild("swapHost");
+ entity.addComponent(MeshRenderer).setMaterial(material);
+ expect(material.refCount).eq(1);
+
+ const texA = new Texture2D(engine, 1, 1);
+ const texB = new Texture2D(engine, 1, 1);
+ material.shaderData.setTexture("u_custom", texA);
+ expect(texA.refCount).eq(1);
+
+ material.shaderData.setTexture("u_custom", texB);
+ expect(texA.refCount).eq(0);
+ expect(texB.refCount).eq(1);
+
+ material.shaderData.setTexture("u_custom", null);
+ expect(texB.refCount).eq(0);
+
+ entity.destroy();
+ expect(material.refCount).eq(0);
+ });
+
+ it("a texture set on a doubly-referenced material owns two counts; dropping one reference cascades", () => {
+ const material = new BlinnPhongMaterial(engine);
+ const other = new BlinnPhongMaterial(engine);
+ const entityA = rootEntity.createChild("hostA");
+ const entityB = rootEntity.createChild("hostB");
+ entityA.addComponent(MeshRenderer).setMaterial(material);
+ entityB.addComponent(MeshRenderer).setMaterial(material);
+ expect(material.refCount).eq(2);
+
+ const tex = new Texture2D(engine, 1, 1);
+ material.shaderData.setTexture("u_custom", tex);
+ expect(tex.refCount).eq(2);
+
+ // Dropping one material reference cascades -1 through shaderData to its textures.
+ entityB.getComponent(MeshRenderer).setMaterial(other);
+ expect(material.refCount).eq(1);
+ expect(tex.refCount).eq(1);
+
+ entityA.destroy();
+ expect(material.refCount).eq(0);
+ expect(tex.refCount).eq(0);
+ entityB.destroy();
+ expect(other.refCount).eq(0);
+ });
+
+ it("cloning a renderer keeps the shared material and its textures balanced", () => {
+ const material = new BlinnPhongMaterial(engine);
+ const tex = new Texture2D(engine, 1, 1);
+ const entity = rootEntity.createChild("cloneHost");
+ entity.addComponent(MeshRenderer).setMaterial(material);
+ material.shaderData.setTexture("u_custom", tex);
+ expect(material.refCount).eq(1);
+ expect(tex.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ // The clone shares the material (+1), which cascades +1 to its textures.
+ expect(material.refCount).eq(2);
+ expect(tex.refCount).eq(2);
+
+ clone.destroy();
+ expect(material.refCount).eq(1);
+ expect(tex.refCount).eq(1);
+
+ entity.destroy();
+ expect(material.refCount).eq(0);
+ expect(tex.refCount).eq(0);
+ });
+
+ it("instance materials release on destroy and stay balanced across clone", () => {
+ const material = new BlinnPhongMaterial(engine);
+ const entity = rootEntity.createChild("instanceHost");
+ const renderer = entity.addComponent(MeshRenderer);
+ renderer.setMaterial(material);
+
+ const instanced = renderer.getInstanceMaterial();
+ expect(instanced).not.eq(material);
+ // Instancing replaces the slot: the original material is released, the instance is owned.
+ expect(material.refCount).eq(0);
+ expect(instanced.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ expect(instanced.refCount).eq(2);
+
+ clone.destroy();
+ expect(instanced.refCount).eq(1);
+
+ entity.destroy();
+ expect(instanced.refCount).eq(0);
+ });
+
+ it("texture-array entries cascade with the host's refCount like single textures", () => {
+ const material = new BlinnPhongMaterial(engine);
+ const entityA = rootEntity.createChild("texArrHostA");
+ entityA.addComponent(MeshRenderer).setMaterial(material);
+ expect(material.refCount).eq(1);
+
+ const texA = new Texture2D(engine, 1, 1);
+ const texB = new Texture2D(engine, 1, 1);
+ material.shaderData.setTextureArray("u_texArr", [texA, texB]);
+ expect(texA.refCount).eq(1);
+ expect(texB.refCount).eq(1);
+
+ // A second owner of the material cascades +1 through the array entries.
+ const entityB = rootEntity.createChild("texArrHostB");
+ entityB.addComponent(MeshRenderer).setMaterial(material);
+ expect(texA.refCount).eq(2);
+ expect(texB.refCount).eq(2);
+
+ entityB.destroy();
+ expect(texA.refCount).eq(1);
+
+ entityA.destroy();
+ expect(texA.refCount).eq(0);
+ expect(texB.refCount).eq(0);
+ });
+
+ it("cloning a shaderData counts the shared entries of a texture array", () => {
+ const texA = new Texture2D(engine, 1, 1);
+ const texB = new Texture2D(engine, 1, 1);
+ const entity = rootEntity.createChild("texArrCloneSrc");
+ entity.addComponent(MeshRenderer).shaderData.setTextureArray("u_rendererTexArr", [texA, texB]);
+ expect(texA.refCount).eq(1);
+
+ const clone = entity.clone();
+ rootEntity.addChild(clone);
+ // The cloned array is a fresh container sharing the same counted entries.
+ expect(texA.refCount).eq(2);
+ expect(texB.refCount).eq(2);
+
+ clone.destroy();
+ expect(texA.refCount).eq(1);
+
+ entity.destroy();
+ expect(texA.refCount).eq(0);
+ expect(texB.refCount).eq(0);
+ });
+
+ it("cloneTo into a referenced, populated target keeps the displaced entry's count", () => {
+ const matA = new BlinnPhongMaterial(engine);
+ const matB = new BlinnPhongMaterial(engine);
+ const host = rootEntity.createChild("cloneToPopulated");
+ host.addComponent(MeshRenderer).setMaterial(matB);
+ const texA = new Texture2D(engine, 1, 1);
+ const texB = new Texture2D(engine, 1, 1);
+ matA.shaderData.setTexture("u_custom", texA);
+ matB.shaderData.setTexture("u_custom", texB);
+ expect(texB.refCount).eq(1);
+
+ matA.shaderData.cloneTo(matB.shaderData);
+
+ // Pins current semantics: the incoming entry gains the target's count, while the displaced
+ // entry keeps the count it held — cloneTo does not release what it overwrites.
+ expect(texA.refCount).eq(1);
+ expect(texB.refCount).eq(1);
+
+ host.destroy();
+ expect(texA.refCount).eq(0);
+ expect(texB.refCount).eq(1);
+ });
+});
diff --git a/tests/src/core/Signal.test.ts b/tests/src/core/Signal.test.ts
index 82a4fef4f4..07357705f2 100644
--- a/tests/src/core/Signal.test.ts
+++ b/tests/src/core/Signal.test.ts
@@ -1,4 +1,4 @@
-import { Script, Signal } from "@galacean/engine-core";
+import { Entity, Script, Signal } from "@galacean/engine-core";
import { WebGLEngine } from "@galacean/engine";
import { describe, expect, it, vi } from "vitest";
@@ -332,6 +332,17 @@ describe("Signal", async () => {
// ---- Clone ----
+ /** Build the identity map (source entity/component -> clone) the engine passes to `_onClone`. */
+ function buildCloneMap(src: Entity, target: Entity, map = new Map()): Map {
+ map.set(src, target);
+ // @ts-ignore
+ const srcComponents = src._components,
+ targetComponents = target._components;
+ for (let i = 0; i < srcComponents.length; i++) map.set(srcComponents[i], targetComponents[i]);
+ for (let i = 0; i < src.children.length; i++) buildCloneMap(src.children[i], target.children[i], map);
+ return map;
+ }
+
it("clone: closure-based listeners are not cloned", () => {
const signal = new Signal<[number]>();
const targetSignal = new Signal<[number]>();
@@ -341,7 +352,7 @@ describe("Signal", async () => {
const srcRoot = root.createChild("clSrc1");
const targetRoot = srcRoot.clone();
// @ts-ignore
- signal._cloneTo(targetSignal, srcRoot, targetRoot);
+ signal._onClone(targetSignal, buildCloneMap(srcRoot, targetRoot));
// Closure listeners should NOT be copied to clone
targetSignal.invoke(42);
@@ -362,7 +373,7 @@ describe("Signal", async () => {
const targetRoot = srcRoot.clone();
// @ts-ignore
- signal._cloneTo(targetSignal, srcRoot, targetRoot);
+ signal._onClone(targetSignal, buildCloneMap(srcRoot, targetRoot));
const clonedHandler = targetRoot.findByName("handler").getComponent(TestHandler);
targetSignal.invoke();
@@ -385,7 +396,7 @@ describe("Signal", async () => {
const targetRoot = srcRoot.clone();
// @ts-ignore
- signal._cloneTo(targetSignal, srcRoot, targetRoot);
+ signal._onClone(targetSignal, buildCloneMap(srcRoot, targetRoot));
targetSignal.invoke();
expect(externalHandler.callCount).toBe(1);
@@ -408,7 +419,7 @@ describe("Signal", async () => {
const targetRoot = srcRoot.clone();
// @ts-ignore
- signal._cloneTo(targetSignal, srcRoot, targetRoot);
+ signal._onClone(targetSignal, buildCloneMap(srcRoot, targetRoot));
const clonedHandler = targetRoot.findByName("handler").getComponent(TestHandler);
targetSignal.invoke();
@@ -431,7 +442,7 @@ describe("Signal", async () => {
const targetRoot = srcRoot.clone();
// @ts-ignore
- signal._cloneTo(targetSignal, srcRoot, targetRoot);
+ signal._onClone(targetSignal, buildCloneMap(srcRoot, targetRoot));
const clonedHandler = targetRoot.findByName("handler").getComponent(TestHandler);
targetSignal.invoke();
diff --git a/tests/src/core/SkinnedMeshRenderer.test.ts b/tests/src/core/SkinnedMeshRenderer.test.ts
index 6c29b24331..658e6abed7 100644
--- a/tests/src/core/SkinnedMeshRenderer.test.ts
+++ b/tests/src/core/SkinnedMeshRenderer.test.ts
@@ -128,4 +128,57 @@ describe("SkinnedMeshRenderer", async () => {
skinnedMeshRenderer.blendShapeWeights
);
});
+
+ it("clone skin", () => {
+ const entity = rootEntity.createChild("SkinCloneRoot");
+ const bone0 = entity.createChild("Bone0");
+ const bone1 = entity.createChild("Bone1");
+
+ const modelMesh = new ModelMesh(engine);
+ modelMesh.setPositions([new Vector3(0, 0, 0), new Vector3(0, 1, 0), new Vector3(1, 1, 0)]);
+
+ const skinnedMeshRenderer = entity.addComponent(SkinnedMeshRenderer);
+ skinnedMeshRenderer.mesh = modelMesh;
+
+ const skin = new Skin("CloneSkin");
+ skin.rootBone = bone0;
+ skin.bones = [bone0, bone1];
+ skin.inverseBindMatrices = [
+ new Matrix(),
+ new Matrix(2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 1, 2, 3, 1)
+ ];
+ skinnedMeshRenderer.skin = skin;
+
+ const cloneEntity = entity.clone();
+ const cloneSkin = cloneEntity.getComponent(SkinnedMeshRenderer).skin;
+
+ // The skin itself is deep cloned.
+ expect(cloneSkin).to.be.not.equal(skin);
+
+ // Entity references are remapped into the cloned subtree, not shared with the source.
+ const cloneBone0 = cloneEntity.findByName("Bone0");
+ const cloneBone1 = cloneEntity.findByName("Bone1");
+ expect(cloneSkin.rootBone).to.be.equal(cloneBone0);
+ expect(cloneSkin.rootBone).to.be.not.equal(bone0);
+ expect(cloneSkin.bones.length).to.be.equal(2);
+ expect(cloneSkin.bones[0]).to.be.equal(cloneBone0);
+ expect(cloneSkin.bones[1]).to.be.equal(cloneBone1);
+ expect(cloneSkin.bones[0]).to.be.not.equal(bone0);
+ expect(cloneSkin.bones[1]).to.be.not.equal(bone1);
+
+ // Inverse bind matrices are independent deep copies with equal values.
+ expect(cloneSkin.inverseBindMatrices).to.be.not.equal(skin.inverseBindMatrices);
+ expect(cloneSkin.inverseBindMatrices.length).to.be.equal(2);
+ expect(cloneSkin.inverseBindMatrices[0]).to.be.not.equal(skin.inverseBindMatrices[0]);
+ expect(cloneSkin.inverseBindMatrices[1]).to.be.not.equal(skin.inverseBindMatrices[1]);
+ expect(cloneSkin.inverseBindMatrices[0].elements).to.be.deep.equal(skin.inverseBindMatrices[0].elements);
+ expect(cloneSkin.inverseBindMatrices[1].elements).to.be.deep.equal(skin.inverseBindMatrices[1].elements);
+
+ // The clone keeps its own update flag manager.
+ // @ts-ignore
+ expect(cloneSkin._updatedManager).to.be.not.equal(skin._updatedManager);
+
+ entity.destroy();
+ cloneEntity.destroy();
+ });
});
diff --git a/tests/src/core/Trail.test.ts b/tests/src/core/Trail.test.ts
index 7d1e8be7e8..6dd55f0f3a 100644
--- a/tests/src/core/Trail.test.ts
+++ b/tests/src/core/Trail.test.ts
@@ -165,6 +165,62 @@ describe("Trail", async () => {
expect(trailRenderer.destroyed).to.eq(true);
});
+ it("clone", () => {
+ const rootEntity = scene.getRootEntity();
+ const trailEntity = rootEntity.createChild("trailCloneSrc");
+ const trailRenderer = trailEntity.addComponent(TrailRenderer);
+
+ trailRenderer.widthCurve = new ParticleCurve(new CurveKey(0, 0.5), new CurveKey(0.6, 2), new CurveKey(1, 0));
+ trailRenderer.colorGradient = new ParticleGradient(
+ [new GradientColorKey(0, new Color(1, 0, 0, 1)), new GradientColorKey(1, new Color(0, 0, 1, 1))],
+ [new GradientAlphaKey(0, 0.8), new GradientAlphaKey(1, 0.2)]
+ );
+ trailRenderer.textureScale = new Vector2(2.0, 0.5);
+
+ const cloneEntity = trailEntity.clone();
+ const cloneTrail = cloneEntity.getComponent(TrailRenderer);
+
+ // widthCurve is an independent deep copy with equal keys.
+ expect(cloneTrail.widthCurve).not.to.eq(trailRenderer.widthCurve);
+ expect(cloneTrail.widthCurve.keys.length).to.eq(3);
+ for (let i = 0; i < 3; i++) {
+ expect(cloneTrail.widthCurve.keys[i]).not.to.eq(trailRenderer.widthCurve.keys[i]);
+ expect(cloneTrail.widthCurve.keys[i].time).to.eq(trailRenderer.widthCurve.keys[i].time);
+ expect(cloneTrail.widthCurve.keys[i].value).to.eq(trailRenderer.widthCurve.keys[i].value);
+ }
+ cloneTrail.widthCurve.keys[0].value = 9;
+ expect(trailRenderer.widthCurve.keys[0].value).to.eq(0.5);
+
+ // colorGradient is an independent deep copy with equal color/alpha keys.
+ expect(cloneTrail.colorGradient).not.to.eq(trailRenderer.colorGradient);
+ expect(cloneTrail.colorGradient.colorKeys.length).to.eq(2);
+ expect(cloneTrail.colorGradient.alphaKeys.length).to.eq(2);
+ for (let i = 0; i < 2; i++) {
+ expect(cloneTrail.colorGradient.colorKeys[i]).not.to.eq(trailRenderer.colorGradient.colorKeys[i]);
+ expect(cloneTrail.colorGradient.colorKeys[i].color).not.to.eq(trailRenderer.colorGradient.colorKeys[i].color);
+ expect(
+ Color.equals(cloneTrail.colorGradient.colorKeys[i].color, trailRenderer.colorGradient.colorKeys[i].color)
+ ).to.eq(true);
+ expect(cloneTrail.colorGradient.colorKeys[i].time).to.eq(trailRenderer.colorGradient.colorKeys[i].time);
+ expect(cloneTrail.colorGradient.alphaKeys[i]).not.to.eq(trailRenderer.colorGradient.alphaKeys[i]);
+ expect(cloneTrail.colorGradient.alphaKeys[i].alpha).to.eq(trailRenderer.colorGradient.alphaKeys[i].alpha);
+ expect(cloneTrail.colorGradient.alphaKeys[i].time).to.eq(trailRenderer.colorGradient.alphaKeys[i].time);
+ }
+ cloneTrail.colorGradient.alphaKeys[0].alpha = 0.1;
+ expect(trailRenderer.colorGradient.alphaKeys[0].alpha).to.eq(0.8);
+
+ // textureScale is an independent copy with equal values.
+ expect(cloneTrail.textureScale).not.to.eq(trailRenderer.textureScale);
+ expect(cloneTrail.textureScale.x).to.eq(2.0);
+ expect(cloneTrail.textureScale.y).to.eq(0.5);
+ cloneTrail.textureScale.set(3.0, 3.0);
+ expect(trailRenderer.textureScale.x).to.eq(2.0);
+ expect(trailRenderer.textureScale.y).to.eq(0.5);
+
+ trailEntity.destroy();
+ cloneEntity.destroy();
+ });
+
it("bounds", () => {
const rootEntity = scene.getRootEntity();
const trailEntity = rootEntity.createChild("trail");
diff --git a/tests/src/core/Transform.test.ts b/tests/src/core/Transform.test.ts
index 980edd3572..80d9faef41 100644
--- a/tests/src/core/Transform.test.ts
+++ b/tests/src/core/Transform.test.ts
@@ -1,4 +1,4 @@
-import { deepClone, Entity, Scene, Script, Transform } from "@galacean/engine-core";
+import { deepClone, Entity, ICloneHook, Scene, Script, Transform } from "@galacean/engine-core";
import { Vector2, Vector3 } from "@galacean/engine-math";
import { WebGLEngine } from "@galacean/engine";
import { beforeAll, describe, expect, it } from "vitest";
@@ -154,15 +154,15 @@ describe("Transform test", function () {
});
it("clone keeps correct world position when a component reads worldMatrix during clone", () => {
- // A component that reads its (world) transform inside `_cloneTo` — exactly what the engine's own
- // DynamicCollider does (`_cloneTo` -> `_syncNative` -> `_addNativeShape` reads `lossyWorldScale`).
+ // A component that reads its (world) transform inside `_onClone` — exactly what the engine's own
+ // DynamicCollider does (`_onClone` -> `_syncNative` -> `_addNativeShape` reads `lossyWorldScale`).
// Clone fills children before the parent's own components, so this read resolves the PARENT's
// `_parentTransformCache` (and sets `_isParentDirty = false`) BEFORE the parent transform's
// `_parentTransformCache` is cloned. Without `@ignoreClone` that copy overwrites the resolved
// value with the source's `null`, leaving `cache === null` while `_isParentDirty === false`
// (a stale cache that is never revalidated), so after reparent the node renders at the origin.
- class WorldReaderOnClone extends Script {
- _cloneTo(target: any): void {
+ class WorldReaderOnClone extends Script implements ICloneHook {
+ _onClone(target: any): void {
target.entity.transform.worldMatrix;
}
}
diff --git a/tests/src/core/particle/CustomData.test.ts b/tests/src/core/particle/CustomData.test.ts
index 540f3b0461..2cd348297f 100644
--- a/tests/src/core/particle/CustomData.test.ts
+++ b/tests/src/core/particle/CustomData.test.ts
@@ -306,11 +306,9 @@ describe("CustomDataModule", function () {
});
it("clones deep — entries detached, internal caches rebuilt", function () {
- // Bug guard: CloneManager can't recurse into Map entries, so the default
- // field-by-field clone would leave `cloned.curves === source.curves`
- // (mutation aliasing) and an empty `_curveStreams` (silent no-op
- // _updateShaderData). The module's `_cloneTo` hook deep-clones each
- // entry and rebuilds the internal caches via addCurve / addGradient.
+ // The maps and the stream caches are all cloned by the gate: fresh Maps holding deep-cloned
+ // entries, and stream objects whose `curve` / `gradient` resolve through the identity map to
+ // those same clones (asserted below), so `_updateShaderData` uploads what the maps hold.
const scene = engine.sceneManager.activeScene;
const sourceEntity = scene.createRootEntity("source-particle");
const sourceRenderer = sourceEntity.addComponent(ParticleRenderer);
@@ -334,13 +332,44 @@ describe("CustomDataModule", function () {
expect(clonedCustomData.gradients.get("Tint")).to.not.eq(sourceCustomData.gradients.get("Tint"));
expect(clonedCustomData.gradients.get("Tint")!.constantMax.r).to.be.closeTo(1, 1e-6);
- // Internal caches are rebuilt — _updateShaderData would now upload uniforms.
- //@ts-ignore - inspecting private internal cache
- const clonedCurveStreams = (clonedCustomData as any)._curveStreams as { name: string }[];
- //@ts-ignore
- const clonedGradientStreams = (clonedCustomData as any)._gradientStreams as { name: string }[];
- expect(clonedCurveStreams.map((s) => s.name)).to.deep.eq(["Intensity"]);
- expect(clonedGradientStreams.map((s) => s.name)).to.deep.eq(["Tint"]);
+ // Internal caches are rebuilt — _updateShaderData would now upload uniforms. Every stream
+ // field is checked against the source's, each with the comparison its role demands.
+ const expectStreamsEquivalent = (key: string, entryKey: string, mapKey: string, names: string[]) => {
+ const sourceStreams = (sourceCustomData as any)[key];
+ const clonedStreams = (clonedCustomData as any)[key];
+ expect(clonedStreams.map((s: any) => s.name)).to.deep.eq(names);
+ expect(clonedStreams.length).to.eq(sourceStreams.length);
+
+ for (let i = 0; i < sourceStreams.length; i++) {
+ const sourceStream = sourceStreams[i];
+ const clonedStream = clonedStreams[i];
+ expect(clonedStream.name).to.eq(sourceStream.name);
+ expect(clonedStream.lastMode).to.eq(sourceStream.lastMode);
+
+ // A ShaderProperty is registered globally by name, so the clone must hold the very same
+ // instance. Identity, not deep equality — a structurally identical copy would pass the
+ // latter while breaking uniform lookup.
+ const propKeys = Object.keys(sourceStream).filter((k) => k.startsWith("prop"));
+ expect(propKeys.length).to.be.greaterThan(0);
+ for (const propKey of propKeys) {
+ expect(clonedStream[propKey]).to.eq(sourceStream[propKey]);
+ }
+
+ // The keyframe-count cache is per-instance scratch: same values, own vector.
+ if (sourceStream.keysCountCache) {
+ expect(Array.from(clonedStream.keysCountCache)).to.deep.eq(Array.from(sourceStream.keysCountCache));
+ expect(clonedStream.keysCountCache).to.not.eq(sourceStream.keysCountCache);
+ }
+
+ // The stream points at the very object its own map holds (the identity map collapses both
+ // references onto one clone), and that clone is distinct from the source's entry.
+ expect(clonedStream[entryKey]).to.eq((clonedCustomData as any)[mapKey].get(clonedStream.name));
+ expect(clonedStream[entryKey]).to.not.eq(sourceStream[entryKey]);
+ }
+ };
+
+ expectStreamsEquivalent("_curveStreams", "curve", "_curves", ["Intensity"]);
+ expectStreamsEquivalent("_gradientStreams", "gradient", "_gradients", ["Tint"]);
// Mutation isolation: bumping the clone does not bleed back into the source.
clonedCustomData.curves.get("Intensity")!.constantMax = 0.1;
diff --git a/tests/src/core/particle/ParticleShapeTransform.test.ts b/tests/src/core/particle/ParticleShapeTransform.test.ts
index d3ac6d2ac6..dd42b3d8da 100644
--- a/tests/src/core/particle/ParticleShapeTransform.test.ts
+++ b/tests/src/core/particle/ParticleShapeTransform.test.ts
@@ -196,7 +196,7 @@ describe("ParticleShapeTransform", function () {
});
describe("Clone", function () {
- // Simulate CloneManager: deepClone calls copyFrom, then _cloneTo
+ // Simulate clone traversal: deepClone calls copyFrom, then _onClone
function simulateClone(source: BoxShape): BoxShape {
const clone = new BoxShape();
// @deepClone step: copyFrom on existing Vector3 (preserves constructor-bound callbacks)
diff --git a/tests/src/core/physics/ColliderShape.test.ts b/tests/src/core/physics/ColliderShape.test.ts
index 008756681d..5b42fc15cc 100644
--- a/tests/src/core/physics/ColliderShape.test.ts
+++ b/tests/src/core/physics/ColliderShape.test.ts
@@ -356,4 +356,38 @@ describe("ColliderShape PhysX", () => {
expect((newCollider3.shapes[0] as CapsuleColliderShape).radius).to.eq(2);
expect((newCollider3.shapes[0] as CapsuleColliderShape).height).to.eq(3);
});
+
+ it("cloned shapes are independent instances owned by the cloned collider", () => {
+ const boxShape = new BoxColliderShape();
+ boxShape.size = new Vector3(2, 3, 4);
+ boxShape.position = new Vector3(1, 2, 3);
+ dynamicCollider.addShape(boxShape);
+
+ const srcEntity = dynamicCollider.entity;
+ const clonedEntity = srcEntity.clone();
+ srcEntity.parent.addChild(clonedEntity);
+ const clonedCollider = clonedEntity.getComponent(DynamicCollider);
+ const clonedShape = clonedCollider.shapes[0] as BoxColliderShape;
+
+ // Instance independence — not the source's shape shared by reference.
+ expect(clonedShape).not.to.eq(boxShape);
+ expect(clonedShape.id).not.to.eq(boxShape.id);
+ // @ts-ignore
+ expect(clonedShape._nativeShape).not.to.eq(boxShape._nativeShape);
+ // Ownership: each shape belongs to its own collider.
+ expect(clonedShape.collider).to.eq(clonedCollider);
+ expect(boxShape.collider).to.eq(dynamicCollider);
+ // Values copied.
+ expect(clonedShape.size).to.deep.include({ x: 2, y: 3, z: 4 });
+ expect(clonedShape.position).to.deep.include({ x: 1, y: 2, z: 3 });
+ // Mutating the clone must not affect the source.
+ clonedShape.size = new Vector3(9, 9, 9);
+ expect(boxShape.size).to.deep.include({ x: 2, y: 3, z: 4 });
+
+ // Destroying the clone must not destroy the source's shape / native handle.
+ clonedEntity.destroy();
+ expect(boxShape.collider).to.eq(dynamicCollider);
+ // @ts-ignore
+ expect(boxShape._nativeShape).not.to.eq(null);
+ });
});
diff --git a/tests/src/core/physics/MeshColliderShape.test.ts b/tests/src/core/physics/MeshColliderShape.test.ts
index 9506569526..9836ff9f46 100644
--- a/tests/src/core/physics/MeshColliderShape.test.ts
+++ b/tests/src/core/physics/MeshColliderShape.test.ts
@@ -5,12 +5,13 @@ import {
SphereColliderShape,
BoxColliderShape,
DynamicCollider,
+ HitResult,
StaticCollider,
PhysicsMaterial,
Script,
ModelMesh
} from "@galacean/engine-core";
-import { Vector3 } from "@galacean/engine-math";
+import { Ray, Vector3 } from "@galacean/engine-math";
import { WebGLEngine } from "@galacean/engine";
import { PhysXPhysics } from "@galacean/engine-physics-physx";
import { describe, beforeAll, beforeEach, expect, it, vi } from "vitest";
@@ -490,6 +491,34 @@ describe("MeshColliderShape PhysX", () => {
meshMaterial?.destroy();
});
+ it("should clone a kinematic triangle mesh that participates in raycasts", () => {
+ const entity = root.createChild("clonedKinematicMesh");
+ const dynamicCollider = entity.addComponent(DynamicCollider);
+ dynamicCollider.isKinematic = true;
+ dynamicCollider.automaticCenterOfMass = false;
+ dynamicCollider.automaticInertiaTensor = false;
+
+ const meshShape = new MeshColliderShape();
+ const meshMaterial = meshShape.material;
+ const mesh = createModelMesh(engine, [-5, 0, -5, 5, 0, -5, -5, 0, 5, 5, 0, 5], [0, 2, 1, 1, 2, 3]);
+ meshShape.mesh = mesh;
+ dynamicCollider.addShape(meshShape);
+
+ const clone = entity.clone();
+ root.addChild(clone);
+ const clonedCollider = clone.getComponent(DynamicCollider);
+ const clonedShape = clonedCollider.shapes[0];
+ entity.destroy();
+
+ const hit = new HitResult();
+ expect(physicsScene.raycast(new Ray(new Vector3(0, 2, 0), new Vector3(0, -1, 0)), hit)).toBe(true);
+ expect(hit.entity).toBe(clone);
+ expect(hit.shape).toBe(clonedShape);
+
+ clone.destroy();
+ meshMaterial?.destroy();
+ });
+
it("should NOT log error when adding convex mesh to non-kinematic DynamicCollider", () => {
const errorSpy = vi.spyOn(console, "error");
@@ -513,6 +542,32 @@ describe("MeshColliderShape PhysX", () => {
});
});
+ describe("Native Shape Attachment", () => {
+ it("should not record a shape when PhysX rejects the native attachment", () => {
+ const entity = root.createChild("rejectedNativeShape");
+ const collider = entity.addComponent(StaticCollider);
+ const shape = new BoxColliderShape();
+ const material = shape.material;
+ const nativeCollider = (collider as any)._nativeCollider;
+ const attachSpy = vi.spyOn(nativeCollider._pxActor, "attachShape").mockReturnValue(false);
+
+ try {
+ expect(() => collider.addShape(shape)).toThrowError(
+ "PhysXCollider: failed to attach shape to the native actor."
+ );
+ expect(collider.shapes).toHaveLength(0);
+ expect(nativeCollider._shapes).toHaveLength(0);
+ expect(shape.collider).toBeFalsy();
+ expect((shape as any)._isShapeAttached).toBe(false);
+ } finally {
+ attachSpy.mockRestore();
+ entity.destroy();
+ (shape as any)._destroy();
+ material?.destroy();
+ }
+ });
+ });
+
describe("Inaccessible Mesh Guard", () => {
it("should warn and not create native shape when mesh data is released", () => {
const warnSpy = vi.spyOn(console, "warn");
@@ -764,4 +819,34 @@ describe("MeshColliderShape PhysX", () => {
entity.destroy();
});
});
+
+ describe("mesh refCount (slot-ownership contract)", () => {
+ it("clone acquires via the setter, churn transfers, destroy releases", () => {
+ const meshA = createModelMesh(engine, [-1, 0, -1, 1, 0, -1, 0, 0, 1], [0, 1, 2]);
+ const meshB = createModelMesh(engine, [-2, 0, -2, 2, 0, -2, 0, 0, 2], [0, 1, 2]);
+ const entity = root.createChild("meshRefSlot");
+ const collider = entity.addComponent(StaticCollider);
+ const shape = new MeshColliderShape();
+ shape.mesh = meshA;
+ collider.addShape(shape);
+ expect(meshA.refCount).toBe(1);
+
+ const clone = entity.clone();
+ root.addChild(clone);
+ expect(meshA.refCount).toBe(2);
+
+ const clonedShape = clone.getComponent(StaticCollider).shapes[0] as MeshColliderShape;
+ expect(clonedShape).not.toBe(shape);
+ clonedShape.mesh = meshB;
+ expect(meshA.refCount).toBe(1);
+ expect(meshB.refCount).toBe(1);
+
+ clone.destroy();
+ expect(meshB.refCount).toBe(0);
+ expect(meshA.refCount).toBe(1);
+
+ entity.destroy();
+ expect(meshA.refCount).toBe(0);
+ });
+ });
});
diff --git a/tests/src/core/postProcess/PostProcess.test.ts b/tests/src/core/postProcess/PostProcess.test.ts
index 1d0178b549..cc6caba195 100644
--- a/tests/src/core/postProcess/PostProcess.test.ts
+++ b/tests/src/core/postProcess/PostProcess.test.ts
@@ -173,6 +173,57 @@ describe("PostProcess", () => {
expect(pp._effects.length).to.equal(0);
});
+ it("Post Process clone", () => {
+ const pp = postEntity.addComponent(PostProcess);
+ const bloomEffect = pp.addEffect(BloomEffect);
+ const dirtTexture = new Texture2D(engine, 1, 1);
+
+ bloomEffect.intensity.value = 1.5;
+ bloomEffect.threshold.value = 0.9;
+ bloomEffect.tint.value = new Color(0.5, 0.25, 0.1, 1);
+ bloomEffect.highQualityFiltering.value = true;
+ bloomEffect.dirtTexture.value = dirtTexture;
+ bloomEffect.enabled = false;
+
+ const refCount = dirtTexture.refCount;
+
+ const cloneEntity = postEntity.clone();
+ const clonePP = cloneEntity.getComponent(PostProcess);
+ const cloneBloom = clonePP.getEffect(BloomEffect);
+
+ // Effects, effect and parameters are all fresh instances.
+ expect(clonePP).to.not.equal(pp);
+ // @ts-ignore
+ expect(clonePP._effects).to.not.equal(pp._effects);
+ // @ts-ignore
+ expect(clonePP._effects.length).to.equal(1);
+ expect(cloneBloom).to.instanceOf(BloomEffect);
+ expect(cloneBloom).to.not.equal(bloomEffect);
+ expect(cloneBloom.intensity).to.not.equal(bloomEffect.intensity);
+ expect(cloneBloom.tint).to.not.equal(bloomEffect.tint);
+
+ // Values are preserved.
+ expect(cloneBloom.intensity.value).to.equal(1.5);
+ expect(cloneBloom.threshold.value).to.equal(0.9);
+ expect(cloneBloom.highQualityFiltering.value).to.true;
+ expect(cloneBloom.enabled).to.false;
+ expect(cloneBloom.tint.value).to.include(new Color(0.5, 0.25, 0.1, 1));
+
+ // Values are independent: mutating the clone leaves the source untouched.
+ expect(cloneBloom.tint.value).to.not.equal(bloomEffect.tint.value);
+ cloneBloom.tint.value.r = 0.9;
+ expect(bloomEffect.tint.value.r).to.equal(0.5);
+ cloneBloom.intensity.value = 3;
+ expect(bloomEffect.intensity.value).to.equal(1.5);
+
+ // Texture parameter shares the same texture reference without touching its refCount.
+ expect(cloneBloom.dirtTexture).to.not.equal(bloomEffect.dirtTexture);
+ expect(cloneBloom.dirtTexture.value).to.equal(dirtTexture);
+ expect(dirtTexture.refCount).to.equal(refCount);
+
+ cloneEntity.destroy();
+ });
+
it("Post Process", () => {
const ppManager = scene.postProcessManager;
diff --git a/tests/src/math/Ray.test.ts b/tests/src/math/Ray.test.ts
index 2a7eb2b5e4..21be16603a 100644
--- a/tests/src/math/Ray.test.ts
+++ b/tests/src/math/Ray.test.ts
@@ -31,4 +31,24 @@ describe("Ray test", () => {
expect(Vector3.equals(out, new Vector3(0, 10, 0))).to.eq(true);
});
+
+ it("ray-clone", () => {
+ const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 0, 1));
+ const out = ray.clone();
+
+ expect(out).not.to.eq(ray);
+ expect(out.origin).not.to.eq(ray.origin);
+ expect(Vector3.equals(out.origin, ray.origin)).to.eq(true);
+ expect(Vector3.equals(out.direction, ray.direction)).to.eq(true);
+ });
+
+ it("ray-copyFrom", () => {
+ const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 0, 1));
+ const out = new Ray();
+ const result = out.copyFrom(ray);
+
+ expect(result).to.eq(out);
+ expect(Vector3.equals(out.origin, ray.origin)).to.eq(true);
+ expect(Vector3.equals(out.direction, ray.direction)).to.eq(true);
+ });
});
diff --git a/tests/src/ui/Image.test.ts b/tests/src/ui/Image.test.ts
index eaa6bd026a..1f409fe447 100644
--- a/tests/src/ui/Image.test.ts
+++ b/tests/src/ui/Image.test.ts
@@ -68,4 +68,27 @@ describe("Image", async () => {
expect(cloneImage.raycastPadding.z).to.eq(1);
expect(cloneImage.raycastPadding.w).to.eq(1);
});
+
+ it("sprite refCount: clone acquires, setter churn transfers, destroy releases", () => {
+ const entity = canvasEntity.createChild("imageRefSlot");
+ const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1));
+ entity.addComponent(Image).sprite = spriteA;
+ expect(spriteA.refCount).to.eq(1);
+
+ const clone = entity.clone();
+ canvasEntity.addChild(clone);
+ expect(spriteA.refCount).to.eq(2);
+
+ clone.getComponent(Image).sprite = spriteB;
+ expect(spriteA.refCount).to.eq(1);
+ expect(spriteB.refCount).to.eq(1);
+
+ clone.destroy();
+ expect(spriteB.refCount).to.eq(0);
+ expect(spriteA.refCount).to.eq(1);
+
+ entity.destroy();
+ expect(spriteA.refCount).to.eq(0);
+ });
});
diff --git a/tests/src/ui/UIInteractive.test.ts b/tests/src/ui/UIInteractive.test.ts
index b44f7a50ec..5143251731 100644
--- a/tests/src/ui/UIInteractive.test.ts
+++ b/tests/src/ui/UIInteractive.test.ts
@@ -1,4 +1,4 @@
-import { Camera, PointerEventData, Script, SpriteDrawMode } from "@galacean/engine-core";
+import { Camera, PointerEventData, Script, Sprite, SpriteDrawMode, Texture2D } from "@galacean/engine-core";
import { Color, Vector3 } from "@galacean/engine-math";
import { WebGLEngine } from "@galacean/engine";
import {
@@ -6,6 +6,7 @@ import {
ColorTransition,
Image,
ScaleTransition,
+ SpriteTransition,
Text,
UICanvas,
UIGroup,
@@ -250,4 +251,98 @@ describe("Button", async () => {
testEntity.destroy();
});
+
+ it("cloned interactive gets independent deep-cloned transitions with remapped targets", () => {
+ const testEntity = canvasEntity.createChild("transitionClone");
+ const testImage = testEntity.addComponent(Image);
+ (testEntity.transform).size.set(100, 40);
+ const testButton = testEntity.addComponent(Button);
+
+ const transition = new ColorTransition();
+ transition.target = testImage;
+ transition.normal = new Color(1, 0, 0, 1);
+ transition.hover = new Color(0, 1, 0, 1);
+ testButton.addTransition(transition);
+
+ const cloneEntity = testEntity.clone();
+ canvasEntity.addChild(cloneEntity);
+ const cloneButton = cloneEntity.getComponent(Button);
+ const cloneImage = cloneEntity.getComponent(Image);
+
+ expect(cloneButton.transitions.length).to.eq(1);
+ const cloneTransition = cloneButton.transitions[0] as ColorTransition;
+ // Independent instance, wired to the clone's own components.
+ expect(cloneTransition).not.to.eq(transition);
+ expect(cloneTransition.target).to.eq(cloneImage);
+ // @ts-ignore
+ expect(cloneTransition._interactive).to.eq(cloneButton);
+ // State values deep cloned.
+ expect(cloneTransition.normal).not.to.eq(transition.normal);
+ expect(cloneTransition.normal.r).to.eq(1);
+ expect(cloneTransition.hover.g).to.eq(1);
+
+ // Destroying the clone must not strip the source button's transitions.
+ cloneEntity.destroy();
+ expect(testButton.transitions.length).to.eq(1);
+ expect(transition.target).to.eq(testImage);
+
+ testEntity.destroy();
+ });
+
+ it("cloned sprite transition keeps shared sprites' refCount balanced", () => {
+ const testEntity = canvasEntity.createChild("spriteTransitionClone");
+ const testImage = testEntity.addComponent(Image);
+ const testButton = testEntity.addComponent(Button);
+
+ const sprite = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const transition = new SpriteTransition();
+ transition.target = testImage;
+ transition.normal = sprite;
+ testButton.addTransition(transition);
+ const baseline = sprite.refCount;
+
+ const cloneEntity = testEntity.clone();
+ canvasEntity.addChild(cloneEntity);
+ // +1 by the cloned transition (acquired in _onClone), +1 by the cloned Image
+ // (transition._applyValue assigns the sprite through the Image.sprite setter on activation).
+ expect(sprite.refCount).to.eq(baseline + 2);
+
+ cloneEntity.destroy();
+ expect(sprite.refCount).to.eq(baseline);
+
+ testEntity.destroy();
+ expect(sprite.refCount).to.eq(baseline - 2);
+ });
+
+ it("cloned sprite transition setter churn transfers sprite counts", () => {
+ const testEntity = canvasEntity.createChild("spriteTransitionChurn");
+ const testImage = testEntity.addComponent(Image);
+ const testButton = testEntity.addComponent(Button);
+
+ const spriteA = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const spriteB = new Sprite(engine, new Texture2D(engine, 1, 1));
+ const transition = new SpriteTransition();
+ transition.target = testImage;
+ transition.normal = spriteA;
+ testButton.addTransition(transition);
+ const baselineA = spriteA.refCount;
+
+ const cloneEntity = testEntity.clone();
+ canvasEntity.addChild(cloneEntity);
+ // +1 cloned transition (_onClone) + 1 cloned Image (applied through its sprite setter).
+ expect(spriteA.refCount).to.eq(baselineA + 2);
+
+ const cloneTransition = cloneEntity.getComponent(Button).transitions[0] as SpriteTransition;
+ cloneTransition.normal = spriteB;
+ // Churn releases A from the cloned transition AND from the cloned Image (re-applied), B gains both.
+ expect(spriteA.refCount).to.eq(baselineA);
+ expect(spriteB.refCount).to.eq(2);
+
+ cloneEntity.destroy();
+ expect(spriteB.refCount).to.eq(0);
+ expect(spriteA.refCount).to.eq(baselineA);
+
+ testEntity.destroy();
+ expect(spriteA.refCount).to.eq(baselineA - 2);
+ });
});