diff --git a/docs/en/core/clone.mdx b/docs/en/core/clone.mdx
index 2453b0531d..1d9d8de532 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). |
+
+A class opts into the deep-clone default in one of two ways: extend [DataObject](/apis/core/#DataObject) (data classes — the engine clones them field by field), or expose a `copyFrom` method (value types like the math classes — the engine copies through it). Either way the type must support argument-less construction: when a slot has no reusable preset instance, the engine bare-constructs one with `new Type()` before populating it, and a constructor that cannot run bare throws.
+
+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 the field's whole subtree. The deep intent carries into members, while assets inside stay shared, entity references remap, and runtime state keeps the clone's own. Engine-bound values cannot be deep cloned: `@deepClone` on an `Entity` / `Component` reference, an asset, engine runtime state (such as `UpdateFlagManager`), or a function throws — remove the decorator to get the default behavior, or copy an asset via its own clone API. |
+
+
+ `@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,31 @@ 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.c[0]); // output is (2,2,2). bacause assignmentClone let c use the same array reference with cloneScript's c.
console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use the different reference with cloneScript's 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` deep-clones the whole subtree: the intent carries into members, so a member class instance with no deep default of its own is copied too (it must support argument-less construction). Assets inside stay shared, entity references are remapped, runtime state keeps the clone's own, and nested field decorators still win.
+ - `deepClone` cannot deep clone engine-bound objects: `@deepClone` on an `Entity` / `Component` reference, an asset, engine runtime state, or a function throws, because the explicit intent can't be honored. Remove the decorator to fall back to the default, or use the asset's own clone API for a real copy.
+ - If the clone decorators do not meet the requirements, you can implement the [\_cloneTo()](/apis/design/#IClone-cloneTo) method to add custom 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..dc5ca73704 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` 方法(数学类这样的值类型 —— 引擎经由它拷贝)。两种方式都要求类型支持无参构造:当槽位上没有可复用的预置实例时,引擎会先 `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` 引用、资产、引擎运行时状态(如 `UpdateFlagManager`)或函数使用 `@deepClone` 会直接报错 —— 请移除该装饰器以走默认行为,或使用资产自身的克隆 API 复制资产。 |
+
+
+ `@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,31 @@ 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.c[0]); // output is (2,2,2). bacause assignmentClone let c use the same array reference with cloneScript's c.
console.log(script.d[0]); // output is (1,1,1). bacause deepClone let d[0] use the different reference with cloneScript's d[0].
```
-- 注意:
- - `shallowClone` 和 `deepClone` 通常用于 *Object*、*Array* 和 *Class* 类型。
- - `shallowClone` 克隆后会保持自身引用独立,并使用赋值的方式克隆其内部所有字段(如果内部字段是基本类型则会拷贝值,如果内部字段是引用类型则会拷贝其引用地址)。
- - `deepClone` 为深克隆,会对属性进行深度递归,至于属性的子属性如何克隆,取决于子属性的装饰器。
- - 如果克隆装饰器不能满足诉求,可以通过实现 [_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。
+- 注意:
+
+ - 字段装饰器优先级最高,并且在克隆走字段遍历的任何地方对嵌套对象的字段同样生效。
+ - `deepClone` 会深克隆整棵子树:深意图会传播进成员,没有深克隆默认的成员类实例同样被拷贝(因此必须支持无参构造)。子树内的资产保持共享,实体引用被重映射,运行时状态保留克隆体自己的,嵌套字段上的装饰器依然优先。
+ - `deepClone` 无法深克隆引擎绑定的对象:对 `Entity` / `Component` 引用、资产、引擎运行时状态或函数使用 `@deepClone` 会直接报错,因为这个显式意图无法被满足。请移除该装饰器以回退到默认行为,或使用资产自身的克隆 API 得到真正的拷贝。
+ - 如果克隆装饰器不能满足诉求,可以通过实现 [\_cloneTo()](/apis/design/#IClone-cloneTo) 方法追加自定义克隆。
+
+## 克隆与资产引用计数
+克隆时,如果组件的顶层字段共享了一个引用计数资产(`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/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts
index 1f740081f9..96ac3451f1 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/CloneManager";
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;
/**
diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts
index 2a6986af6a..7d60f2fb71 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/CloneManager";
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;
/**
diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts
index 3649180c79..cdd4343715 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/CloneManager";
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..853ff61ad8 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/CloneManager";
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..c3fd1733dd 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 { ignoreClone } from "./clone/CloneManager";
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);
}
diff --git a/packages/core/src/Entity.ts b/packages/core/src/Entity.ts
index eab8ce5aa2..6f3c083ea6 100644
--- a/packages/core/src/Entity.ts
+++ b/packages/core/src/Entity.ts
@@ -10,7 +10,6 @@ import { Transform } from "./Transform";
import { UpdateFlagManager } from "./UpdateFlagManager";
import { ReferResource } from "./asset/ReferResource";
import { EngineObject } from "./base";
-import { CloneUtils } from "./clone/CloneUtils";
import { ComponentCloner } from "./clone/ComponentCloner";
import { ActiveChangeFlag } from "./enums/ActiveChangeFlag";
import { EntityModifyFlags } from "./enums/EntityModifyFlags";
@@ -421,8 +420,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 +434,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 +442,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 +450,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 +465,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);
}
}
diff --git a/packages/core/src/Renderer.ts b/packages/core/src/Renderer.ts
index 5af1e71750..096abbc822 100644
--- a/packages/core/src/Renderer.ts
+++ b/packages/core/src/Renderer.ts
@@ -7,7 +7,7 @@ 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/CloneManager";
import { SpriteMaskLayer } from "./enums/SpriteMaskLayer";
import { Material } from "./material";
import { ShaderMacro, ShaderProperty } from "./shader";
@@ -48,9 +48,7 @@ export class Renderer extends Component {
@ignoreClone
_renderFrameCount: number;
/** @internal */
- @assignmentClone
_maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None;
- @assignmentClone
_maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0;
@ignoreClone
@@ -65,7 +63,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 +72,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;
/**
diff --git a/packages/core/src/Signal.ts b/packages/core/src/Signal.ts
index e337e03d9d..6d34f69eb5 100644
--- a/packages/core/src/Signal.ts
+++ b/packages/core/src/Signal.ts
@@ -1,14 +1,15 @@
+import { DataObject } from "./base/DataObject";
+import { ignoreClone } from "./clone/CloneManager";
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 {
+ // Rebuilt by `_cloneTo`; must survive even a propagated @deepClone.
@ignoreClone
private _listeners: SafeLoopArray> = new SafeLoopArray>();
@@ -126,35 +127,30 @@ export class Signal {
/**
* @internal
- * Clone listeners to target signal, remapping entity/component references.
*/
- _cloneTo(target: Signal, srcRoot: Entity, targetRoot: Entity): void {
+ _cloneTo(target: Signal, cloneMap: Map): 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: Map): 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..e95e079850 100644
--- a/packages/core/src/Transform.ts
+++ b/packages/core/src/Transform.ts
@@ -2,7 +2,7 @@ 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/CloneManager";
/**
* Used to implement transformation related functions.
@@ -26,7 +26,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();
diff --git a/packages/core/src/UpdateFlag.ts b/packages/core/src/UpdateFlag.ts
index d926b47b8c..207a9cf1a6 100644
--- a/packages/core/src/UpdateFlag.ts
+++ b/packages/core/src/UpdateFlag.ts
@@ -13,7 +13,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.
diff --git a/packages/core/src/UpdateFlagManager.ts b/packages/core/src/UpdateFlagManager.ts
index 0e1e98ec90..f93c10700a 100644
--- a/packages/core/src/UpdateFlagManager.ts
+++ b/packages/core/src/UpdateFlagManager.ts
@@ -10,7 +10,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 +46,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 +54,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 +63,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;
diff --git a/packages/core/src/VirtualCamera.ts b/packages/core/src/VirtualCamera.ts
index 4c8598888e..2fc88b3c8f 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";
/**
* @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..451384da8d 100644
--- a/packages/core/src/animation/Animator.ts
+++ b/packages/core/src/animation/Animator.ts
@@ -5,7 +5,7 @@ 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/CloneManager";
import { AnimatorController } from "./AnimatorController";
import { AnimatorControllerLayer } from "./AnimatorControllerLayer";
import { AnimatorControllerParameter, AnimatorControllerParameterValue } from "./AnimatorControllerParameter";
@@ -37,10 +37,8 @@ export class Animator extends Component {
/** 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 +46,7 @@ export class Animator extends Component {
/** @internal */
_onUpdateIndex = -1;
- @assignmentClone
protected _animatorController: AnimatorController;
- @ignoreClone
protected _controllerUpdateFlag: BoolUpdateFlag;
@ignoreClone
protected _updateMark = 0;
@@ -353,7 +349,6 @@ export class Animator extends Component {
_cloneTo(target: Animator): void {
const animatorController = target._animatorController;
if (animatorController) {
- target._addResourceReferCount(animatorController, 1);
target._controllerUpdateFlag = animatorController._registerChangeFlag();
}
}
@@ -442,7 +437,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/audio/AudioSource.ts b/packages/core/src/audio/AudioSource.ts
index b149e5609e..c0f5efd980 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/CloneManager";
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..01d2343692
--- /dev/null
+++ b/packages/core/src/base/DataObject.ts
@@ -0,0 +1,6 @@
+/**
+ * Base class of data objects: wherever an instance is held — a component field, an array, a map —
+ * cloning produces an independent deep copy instead of a shared reference. A subclass must be
+ * constructible without arguments; a preset-less copy is created bare, then populated.
+ */
+export abstract class DataObject {}
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/CloneManager.ts b/packages/core/src/clone/CloneManager.ts
index be46462982..0165be9914 100644
--- a/packages/core/src/clone/CloneManager.ts
+++ b/packages/core/src/clone/CloneManager.ts
@@ -1,198 +1,50 @@
-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.
+ * 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 the
+ * field's members; engine-bound members keep their defaults (assets share, entity refs remap).
+ * A decorator is an explicit intent: if the decorated value itself can't be deep cloned (an
+ * entity reference, an asset, or a function), cloning throws rather than silently falling back.
*/
-export function ignoreClone(target: Object, propertyKey: string): void {
- CloneManager.registerCloneMode(target, propertyKey, CloneMode.Ignore);
+export function deepClone(target: object, propertyKey: string): void {
+ CloneManager._registerFieldMode(target, propertyKey, CloneMode.Deep);
}
/**
- * 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.
+ * Property decorator — assign (share the reference) this field, overriding the value type's default clone mode.
*/
-export function assignmentClone(target: Object, propertyKey: string): void {
- CloneManager.registerCloneMode(target, propertyKey, CloneMode.Assignment);
+export function assignmentClone(target: object, propertyKey: string): void {
+ CloneManager._registerFieldMode(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.
+ * Property decorator — ignore this field when cloning; keep the clone's own constructor-built value.
*/
-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);
+export function ignoreClone(target: object, propertyKey: string): void {
+ CloneManager._registerFieldMode(target, propertyKey, CloneMode.Ignore);
}
/**
* @internal
- * Clone manager.
+ * Field-level clone mode registry. Must import no engine class, directly or transitively: every
+ * class carrying a clone decorator imports this module while still being defined, and pulling a
+ * class in here would reorder module evaluation and break `extends` chains. Cloning itself lives
+ * in `CloneUtil`.
*/
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
+ * @internal
*/
- 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);
+ static _registerFieldMode(target: any, propertyKey: string, mode: CloneMode): void {
+ // Each class gets its own `_fieldModes`, prototypally chained to its parent's, so property
+ // lookup resolves inheritance: a subclass re-decorating a field shadows the ancestor's.
+ if (!Object.prototype.hasOwnProperty.call(target, "_fieldModes")) {
+ Object.defineProperty(target, "_fieldModes", {
+ value: Object.create(target._fieldModes ?? null),
+ configurable: true
+ });
}
+ target._fieldModes[propertyKey] = mode;
}
}
diff --git a/packages/core/src/clone/CloneUtil.ts b/packages/core/src/clone/CloneUtil.ts
new file mode 100644
index 0000000000..cd06d6b451
--- /dev/null
+++ b/packages/core/src/clone/CloneUtil.ts
@@ -0,0 +1,277 @@
+import { IReferable } from "../asset/IReferable";
+import { ReferResource } from "../asset/ReferResource";
+import { TypedArray } from "../base/Constant";
+import { DataObject } from "../base/DataObject";
+import { Logger } from "../base/Logger";
+import { Component } from "../Component";
+import { Entity } from "../Entity";
+import { UpdateFlag } from "../UpdateFlag";
+import { UpdateFlagManager } from "../UpdateFlagManager";
+import { DisorderedArray } from "../utils/DisorderedArray";
+import { SafeLoopArray } from "../utils/SafeLoopArray";
+import { ICustomClone } from "./ComponentCloner";
+import { CloneMode } from "./enums/CloneMode";
+
+/**
+ * @internal
+ * Split from `CloneManager`, which must stay free of engine imports.
+ */
+export class CloneUtil {
+ /**
+ * @internal
+ */
+ static _deepCloneObject(source: any, target: object, cloneMap: Map, forceDeepClone = false): void {
+ const fieldModes = source._fieldModes;
+ 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._cloneValue(source[k], target[k], cloneMap, fieldMode, forceDeepClone);
+ }
+ }
+
+ /**
+ * @internal
+ */
+ static _cloneValue(
+ source: any,
+ preset: any,
+ cloneMap: Map,
+ fieldMode?: CloneMode,
+ forceDeepClone = false
+ ): any {
+ if (fieldMode === CloneMode.Assignment) return source;
+ if (fieldMode === CloneMode.Deep) {
+ CloneUtil._assertDeepCloneable(source);
+ forceDeepClone = true;
+ }
+ return CloneUtil._cloneByDefault(source, preset, cloneMap, forceDeepClone);
+ }
+
+ /**
+ * @internal
+ */
+ static _cloneByDefault(source: any, preset: any, cloneMap: Map, forceDeepClone = false): any {
+ if (typeof source === "function") return forceDeepClone ? source : typeof preset === "function" ? preset : source;
+ if (source === null || typeof source !== "object") return source;
+ if (source instanceof Entity || source instanceof Component) return cloneMap.get(source) ?? source;
+ if (source instanceof ReferResource) return source;
+ if (source instanceof UpdateFlagManager || source instanceof UpdateFlag) return preset;
+ if (ArrayBuffer.isView(source)) return CloneUtil._deepCloneArrayBuffer(source, preset, cloneMap);
+ if (Array.isArray(source)) return CloneUtil._deepCloneArray(source, preset, cloneMap, forceDeepClone);
+ if (source instanceof Map) return CloneUtil._deepCloneMap(source, preset, cloneMap, forceDeepClone);
+ if (source instanceof Set) return CloneUtil._deepCloneSet(source, preset, cloneMap, forceDeepClone);
+ if (source instanceof DisorderedArray || source instanceof SafeLoopArray) {
+ if (!forceDeepClone) return preset;
+ const existing = cloneMap.get(source);
+ if (existing) return existing;
+ const dst = CloneUtil._createCloneTarget(source, preset, cloneMap);
+ CloneUtil._deepCloneObject(source, dst, cloneMap, true);
+ return dst;
+ }
+
+ const ctor = (source).constructor;
+ if (ctor && ctor !== Object && typeof (source).copyFrom === "function") {
+ const existing = cloneMap.get(source);
+ if (existing) return existing;
+ const dst = CloneUtil._createCloneTarget(source, preset, cloneMap);
+ (dst).copyFrom(source);
+ (source)._cloneTo?.(dst, cloneMap);
+ return dst;
+ }
+
+ if (source instanceof DataObject || ctor === Object || ctor === undefined || forceDeepClone) {
+ const existing = cloneMap.get(source);
+ if (existing) return existing;
+ const dst = CloneUtil._createCloneTarget(source, preset, cloneMap);
+ CloneUtil._deepCloneObject(source, dst, cloneMap, forceDeepClone);
+ (source)._cloneTo?.(dst, cloneMap);
+ return dst;
+ }
+ return source;
+ }
+
+ /**
+ * @internal
+ */
+ static _assertDeepCloneable(source: any): void {
+ 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 "${source.constructor.name}" — 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 "${source.constructor.name}" — 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 "${source.constructor.name}" — 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.`
+ );
+ }
+ }
+
+ /**
+ * @internal
+ */
+ static _createCloneTarget(source: any, preset: any, cloneMap: Map): any {
+ const ctor = (source).constructor;
+ const reusable = preset && preset !== source && preset.constructor === ctor ? preset : null;
+ let dst: any;
+ if (reusable) {
+ dst = reusable;
+ } else {
+ if (ctor) {
+ try {
+ dst = new ctor();
+ } catch (e) {
+ 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: ${e}`
+ );
+ }
+ } else {
+ dst = Object.create(null);
+ }
+ }
+ cloneMap.set(source, dst);
+ return dst;
+ }
+
+ /**
+ * @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);
+ }
+ }
+
+ /**
+ * @internal
+ */
+ static _deepCloneArrayBuffer(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;
+ }
+
+ /**
+ * @internal
+ */
+ static _deepCloneArray(source: any[], preset: any, cloneMap: Map, forceDeepClone = 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._cloneByDefault(source[i], undefined, cloneMap, forceDeepClone);
+ }
+ return dst;
+ }
+
+ /**
+ * @internal
+ */
+ static _deepCloneMap(
+ source: Map,
+ preset: any,
+ cloneMap: Map,
+ forceDeepClone = 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._cloneByDefault(entry[0], undefined, cloneMap, forceDeepClone),
+ CloneUtil._cloneByDefault(entry[1], undefined, cloneMap, forceDeepClone)
+ );
+ }
+ return dst;
+ }
+
+ /**
+ * @internal
+ */
+ static _deepCloneSet(source: Set, preset: any, cloneMap: Map, forceDeepClone = 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._cloneByDefault(v, undefined, cloneMap, forceDeepClone));
+ return dst;
+ }
+}
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..b871e4c799 100644
--- a/packages/core/src/clone/ComponentCloner.ts
+++ b/packages/core/src/clone/ComponentCloner.ts
@@ -1,42 +1,42 @@
import { Component } from "../Component";
-import { Entity } from "../Entity";
-import { CloneManager } from "./CloneManager";
+import { CloneUtil } from "./CloneUtil";
+import { CloneMode } from "./enums/CloneMode";
/**
- * Custom clone interface.
+ * Clone protocol read by the clone system; every member is optional.
*/
export interface ICustomClone {
/**
* @internal
+ * Post-clone hook; `cloneMap` maps every source object in the cloned subtree to its clone.
*/
- _remap?(srcRoot: Entity, targetRoot: Entity): Object;
- /**
- * @internal
- */
- _cloneTo?(target: ICustomClone, srcRoot?: Entity, targetRoot?: Entity): void;
+ _cloneTo?(target: ICustomClone, cloneMap?: Map): void;
/**
* @internal
+ * Value-type marker — the gate copies via this instead of walking fields.
*/
copyFrom?(source: ICustomClone): void;
}
export class ComponentCloner {
/**
- * Clone component.
+ * Clone component (opt-out: all fields cloned except @ignoreClone), then run its `_cloneTo` hook.
* @param source - Clone source
* @param target - Clone target
+ * @param cloneMap - Identity map of the cloned subtree (source object → clone)
*/
- 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)._fieldModes;
+ 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._cloneValue(sourceValue, preset, cloneMap, fieldMode));
+ CloneUtil._transferSlotOwnership(cloned, sourceValue, preset);
}
- ((source as unknown))._cloneTo?.(target, srcRoot, targetRoot);
+ ((source as unknown))._cloneTo?.(target, cloneMap);
}
}
diff --git a/packages/core/src/clone/enums/CloneMode.ts b/packages/core/src/clone/enums/CloneMode.ts
index 77158ce3ce..b4e41ad31a 100644
--- a/packages/core/src/clone/enums/CloneMode.ts
+++ b/packages/core/src/clone/enums/CloneMode.ts
@@ -1,13 +1,15 @@
/**
- * Clone mode.
+ * How a field is cloned when a clone decorator overrides the built-in default for its type.
*/
export enum CloneMode {
- /** Ignore clone. */
+ /** Skip — keep the clone's own constructor-built value (for runtime / transient state). */
Ignore,
- /** Assignment clone. */
+ /** Share the reference; a counted resource shared at a component's top-level slot is kept alive by the clone. */
Assignment,
- /** Shallow clone. */
- Shallow,
- /** Deep clone. */
+ /**
+ * Deep clone the whole subtree (fresh containers/instances, the intent carries into members);
+ * engine-bound members keep their defaults — assets stay shared, entity refs remap, runtime
+ * state keeps the clone's own.
+ */
Deep
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index be483eccbd..5e83b9259b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -69,8 +69,7 @@ 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/CloneManager";
export * from "./renderingHardwareInterface/index";
export * from "./physics/index";
export * from "./Utils";
diff --git a/packages/core/src/lighting/Light.ts b/packages/core/src/lighting/Light.ts
index 5406de6243..319a489d95 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/CloneManager";
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/mesh/Skin.ts b/packages/core/src/mesh/Skin.ts
index 18489805c5..673632acf4 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/CloneManager";
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..7059cb20f3 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/CloneManager";
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;
/**
@@ -146,12 +143,12 @@ export class SkinnedMeshRenderer extends MeshRenderer {
*/
override _cloneTo(target: SkinnedMeshRenderer): void {
super._cloneTo(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 e96070f56c..1a279f6e84 100644
--- a/packages/core/src/particle/ParticleGenerator.ts
+++ b/packages/core/src/particle/ParticleGenerator.ts
@@ -1,6 +1,7 @@
+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/CloneManager";
import { Primitive } from "../graphic/Primitive";
import { SubMesh } from "../graphic/SubMesh";
import { SubPrimitive } from "../graphic/SubPrimitive";
@@ -42,7 +43,7 @@ import { SubEmittersModule } from "./modules/SubEmittersModule";
/**
* Particle Generator.
*/
-export class ParticleGenerator {
+export class ParticleGenerator extends DataObject {
private static _tempVector20 = new Vector2();
private static _tempVector21 = new Vector2();
private static _tempVector22 = new Vector2();
@@ -63,40 +64,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 +199,7 @@ export class ParticleGenerator {
* @internal
*/
constructor(renderer: ParticleRenderer) {
+ super();
this._renderer = renderer;
const subPrimitive = new SubPrimitive();
subPrimitive.start = 0;
diff --git a/packages/core/src/particle/ParticleRenderer.ts b/packages/core/src/particle/ParticleRenderer.ts
index 47324c6e75..e73d8365f4 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/CloneManager";
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 */
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..53a9da55b1 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/CloneManager";
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..9e0cad5086 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/CloneManager";
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..ce7c533c0f 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/CloneManager";
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..5019273b03 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/CloneManager";
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..c736dd046a 100644
--- a/packages/core/src/particle/modules/MainModule.ts
+++ b/packages/core/src/particle/modules/MainModule.ts
@@ -1,6 +1,7 @@
+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 { ignoreClone } from "../../clone/CloneManager";
import { ICustomClone } from "../../clone/ComponentCloner";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -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 ICustomClone {
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);
@@ -332,8 +322,6 @@ export class MainModule implements ICustomClone {
* @internal
*/
_cloneTo(target: MainModule): void {
- target.maxParticles = this.maxParticles;
-
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..46d2a7b5ca 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/CloneManager";
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 00de9f0291..0e761f6ff0 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/CloneManager";
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..a84de05a57 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/CloneManager";
/**
* 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..4353b4bf8d 100644
--- a/packages/core/src/particle/modules/ParticleGeneratorModule.ts
+++ b/packages/core/src/particle/modules/ParticleGeneratorModule.ts
@@ -1,3 +1,4 @@
+import { DataObject } from "../../base/DataObject";
import { ignoreClone } from "../../clone/CloneManager";
import { ShaderData, ShaderMacro } from "../../shader";
import { ParticleGenerator } from "../ParticleGenerator";
@@ -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..6daf6dba6b 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/CloneManager";
/**
* 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..b9753b6b6e 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/CloneManager";
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 b8c9c6fb18..0242d2b5ec 100644
--- a/packages/core/src/particle/modules/SizeOverLifetimeModule.ts
+++ b/packages/core/src/particle/modules/SizeOverLifetimeModule.ts
@@ -1,4 +1,4 @@
-import { deepClone, ignoreClone } from "../../clone/CloneManager";
+import { ignoreClone } from "../../clone/CloneManager";
import { ShaderData } from "../../shader/ShaderData";
import { ShaderMacro } from "../../shader/ShaderMacro";
import { ShaderProperty } from "../../shader/ShaderProperty";
@@ -24,11 +24,8 @@ export class SizeOverLifetimeModule extends ParticleGeneratorModule {
static readonly _maxCurveZProperty = ShaderProperty.getByName("renderer_SOLMaxCurveZ");
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..a05a0a98c5 100644
--- a/packages/core/src/particle/modules/SubEmitter.ts
+++ b/packages/core/src/particle/modules/SubEmitter.ts
@@ -1,3 +1,4 @@
+import { DataObject } from "../../base/DataObject";
import { ignoreClone } from "../../clone/CloneManager";
import { ParticleRenderer } from "../ParticleRenderer";
import { ParticleSubEmitterInheritProperty } from "../enums/ParticleSubEmitterInheritProperty";
@@ -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..de22030aaa 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/CloneManager";
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..089b9191e2 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/CloneManager";
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..8e6abe3336 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/CloneManager";
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..c9184b66ee 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/CloneManager";
+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..2f4f6d6e82 100644
--- a/packages/core/src/particle/modules/shape/MeshShape.ts
+++ b/packages/core/src/particle/modules/shape/MeshShape.ts
@@ -1,7 +1,6 @@
import { Rand, Vector3, Vector4 } from "@galacean/engine-math";
import { TypedArray } from "../../../base";
import { ignoreClone } from "../../../clone/CloneManager";
-import { Entity } from "../../../Entity";
import { VertexElement } from "../../../graphic";
import { MeshModifyFlags } from "../../../graphic/Mesh";
import { ModelMesh, VertexAttribute } from "../../../mesh";
@@ -51,6 +50,13 @@ export class MeshShape extends BaseShape {
}
}
+ /**
+ * @internal
+ */
+ override _destroy(): void {
+ this.mesh = null;
+ }
+
/**
* @internal
*/
diff --git a/packages/core/src/physics/CharacterController.ts b/packages/core/src/physics/CharacterController.ts
index f3c9e6f048..1408e5cd6f 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/CloneManager";
/**
* 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..7a6f1b76b8 100644
--- a/packages/core/src/physics/Collider.ts
+++ b/packages/core/src/physics/Collider.ts
@@ -1,6 +1,6 @@
import { ICollider, IStaticCollider } from "@galacean/engine-design";
import { BoolUpdateFlag } from "../BoolUpdateFlag";
-import { deepClone, ignoreClone } from "../clone/CloneManager";
+import { ignoreClone } from "../clone/CloneManager";
import { ICustomClone } from "../clone/ComponentCloner";
import { Component } from "../Component";
import { DependentMode, dependentComponents } from "../ComponentsDependencies";
@@ -22,9 +22,7 @@ export class Collider extends Component implements ICustomClone {
/** @internal */
@ignoreClone
_nativeCollider: ICollider;
- @ignoreClone
protected _updateFlag: BoolUpdateFlag;
- @deepClone
protected _shapes: ColliderShape[] = [];
protected _collisionLayerIndex: number = 0;
@@ -183,17 +181,19 @@ export class Collider extends Component implements ICustomClone {
protected _addNativeShape(shape: ColliderShape): void {
shape._collider = this;
- if (shape._nativeShape) {
+ // A shape may already be attached by its own rebuild path (MeshColliderShape's mesh setter
+ // during clone) — attaching twice would duplicate it on the native actor.
+ if (shape._nativeShape && !shape._isShapeAttached) {
shape._nativeShape.setWorldScale(this.entity.transform.lossyWorldScale);
- this._nativeCollider.addShape(shape._nativeShape);
+ shape._attachToCollider();
}
}
protected _removeNativeShape(shape: ColliderShape): void {
- shape._collider = null;
- if (shape._nativeShape) {
- this._nativeCollider.removeShape(shape._nativeShape);
+ if (shape._nativeShape && shape._isShapeAttached) {
+ shape._detachFromCollider();
}
+ shape._collider = null;
}
private _setCollisionLayer(): void {
diff --git a/packages/core/src/physics/joint/HingeJoint.ts b/packages/core/src/physics/joint/HingeJoint.ts
index acffbae4b6..1212343e2f 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/CloneManager";
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..1f721d93a8 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/CloneManager";
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..2d9383aee1 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/CloneManager";
/**
* 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..1a630991d0 100644
--- a/packages/core/src/physics/shape/ColliderShape.ts
+++ b/packages/core/src/physics/shape/ColliderShape.ts
@@ -1,8 +1,9 @@
+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 { ignoreClone } from "../../clone/CloneManager";
import { ICustomClone } from "../../clone/ComponentCloner";
import { Engine } from "../../Engine";
import { ColliderShapeChangeFlag } from "../enums/ColliderShapeChangeFlag";
@@ -10,7 +11,7 @@ import { ColliderShapeChangeFlag } from "../enums/ColliderShapeChangeFlag";
/**
* Abstract class for collider shapes.
*/
-export abstract class ColliderShape implements ICustomClone {
+export abstract class ColliderShape extends DataObject implements ICustomClone {
private static _idGenerator: number = 0;
/** @internal */
@@ -18,14 +19,15 @@ export abstract class ColliderShape implements ICustomClone {
/** @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 +126,7 @@ export abstract class ColliderShape implements ICustomClone {
}
protected constructor() {
+ super();
this._material = new PhysicsMaterial();
this._id = ColliderShape._idGenerator++;
@@ -179,6 +182,22 @@ export abstract class ColliderShape implements ICustomClone {
delete Engine._physicalObjectsMap[this._id];
}
+ /**
+ * @internal
+ */
+ _attachToCollider(): void {
+ this._collider._nativeCollider.addShape(this._nativeShape);
+ this._isShapeAttached = true;
+ }
+
+ /**
+ * @internal
+ */
+ _detachFromCollider(): void {
+ this._collider._nativeCollider.removeShape(this._nativeShape);
+ this._isShapeAttached = false;
+ }
+
protected _syncNative(): void {
if (!this._nativeShape) return;
this._nativeShape.setPosition(this._position);
diff --git a/packages/core/src/physics/shape/MeshColliderShape.ts b/packages/core/src/physics/shape/MeshColliderShape.ts
index 51a46ab0e0..e540cc8738 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/CloneManager";
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);
}
+ /**
+ * @internal
+ */
+ override _cloneTo(target: MeshColliderShape): void {
+ target.mesh = this._mesh;
+ super._cloneTo(target);
+ }
+
/**
* @internal
*/
@@ -170,16 +181,6 @@ export class MeshColliderShape extends ColliderShape {
}
}
- private _detachFromCollider(): void {
- this._collider._nativeCollider.removeShape(this._nativeShape);
- this._isShapeAttached = false;
- }
-
- private _attachToCollider(): void {
- this._collider._nativeCollider.addShape(this._nativeShape);
- this._isShapeAttached = true;
- }
-
private _createNativeShape(): void {
// Non-convex MeshColliderShape is only supported on StaticCollider or kinematic DynamicCollider
if (!this._isConvex && this._collider instanceof DynamicCollider && !this._collider.isKinematic) {
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..c595c57737 100644
--- a/packages/core/src/shader/ShaderData.ts
+++ b/packages/core/src/shader/ShaderData.ts
@@ -1,7 +1,9 @@
+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/CloneManager";
+import { CloneUtil } from "../clone/CloneUtil";
import { Texture } from "../texture/Texture";
import { ShaderMacro } from "./ShaderMacro";
import { ShaderMacroCollection } from "./ShaderMacroCollection";
@@ -12,7 +14,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 {
/** @internal */
@ignoreClone
_group: ShaderDataGroup;
@@ -34,6 +36,7 @@ export class ShaderData implements IReferable, IClone {
* @internal
*/
constructor(group: ShaderDataGroup) {
+ super();
this._group = group;
}
@@ -561,7 +564,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 +595,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 +611,7 @@ export class ShaderData implements IReferable, IClone {
}
cloneTo(target: ShaderData): void {
- CloneManager.deepCloneObject(this._macroCollection, target._macroCollection, new Map());
+ CloneUtil._deepCloneObject(this._macroCollection, target._macroCollection, new Map());
Object.assign(target._macroMap, this._macroMap);
const referCount = target._getReferCount();
const propertyValueMap = this._propertyValueMap;
@@ -624,7 +627,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 +644,13 @@ export class ShaderData implements IReferable, IClone {
}
}
+ /**
+ * @internal
+ */
+ _cloneTo(target: ShaderData): void {
+ this.cloneTo(target);
+ }
+
/**
* @internal
*/
@@ -709,8 +721,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..a1fc73e28a 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/CloneManager";
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/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/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts
index a69cd846bf..22126d06d3 100644
--- a/packages/ui/src/component/UICanvas.ts
+++ b/packages/ui/src/component/UICanvas.ts
@@ -14,8 +14,6 @@ import {
RenderElement,
Vector2,
Vector3,
- assignmentClone,
- deepClone,
dependentComponents,
ignoreClone
} from "@galacean/engine";
@@ -77,28 +75,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;
/**
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..366ef3aaa2 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;
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..3d83716967 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;
/**
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..1b76932098 100644
--- a/packages/ui/src/component/interactive/transition/Transition.ts
+++ b/packages/ui/src/component/interactive/transition/Transition.ts
@@ -1,4 +1,4 @@
-import { Color, ReferResource, Sprite } from "@galacean/engine";
+import { Color, DataObject, ReferResource, Sprite, ignoreClone } from "@galacean/engine";
import { UIRenderer } from "../../UIRenderer";
import { InteractiveState, UIInteractive } from "../UIInteractive";
@@ -8,7 +8,7 @@ import { InteractiveState, UIInteractive } from "../UIInteractive";
export abstract class Transition<
T extends TransitionValueType = TransitionValueType,
K extends UIRenderer = UIRenderer
-> {
+> extends DataObject {
/** @internal */
_interactive: UIInteractive;
@@ -18,10 +18,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 +124,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;
}
+ /**
+ * @internal
+ */
+ _cloneTo(target: Transition): void {
+ target._addStateValuesReferCount(1);
+ }
+
/**
* @internal
*/
@@ -170,6 +185,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/CloneManager.test.ts b/tests/src/core/CloneManager.test.ts
new file mode 100644
index 0000000000..3d3d6a6363
--- /dev/null
+++ b/tests/src/core/CloneManager.test.ts
@@ -0,0 +1,2243 @@
+import {
+ BoolUpdateFlag,
+ Burst,
+ DataObject,
+ DisorderedArray,
+ Entity,
+ 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 {
+ static runs = 0;
+ value = 0;
+ _cloneTo(): 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;
+}
+
+/** 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 };
+}
+
+/** 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 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();
+ });
+ });
+
+ 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();
+ });
+ });
+
+ describe("copyFrom value types via entity.clone", () => {
+ 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 carrying copyFrom-shaped keys", () => {
+ 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 Deep-registered type constructs bare (gate contract)", () => {
+ // The gate creates container elements and preset-less slots with `new Type()` and then
+ // populates every field, so a Deep-registered type MUST construct without arguments.
+ // 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;
+ // math value types dispatch by their callable copyFrom; core/ui Deep types are the
+ // DataObject family
+ const isDeep =
+ pkg === "math"
+ ? typeof exported.prototype.copyFrom === "function"
+ : exported.prototype instanceof DataObject;
+ if (!isDeep) 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..f4eafdd53d
--- /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; _cloneTo 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..392aeb1520
--- /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 _cloneTo, 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 _cloneTo, 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..c3c255ab08 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 `_cloneTo`. */
+ 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._cloneTo(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._cloneTo(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._cloneTo(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._cloneTo(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._cloneTo(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/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/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..860efd9708 100644
--- a/tests/src/core/physics/MeshColliderShape.test.ts
+++ b/tests/src/core/physics/MeshColliderShape.test.ts
@@ -764,4 +764,36 @@ 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);
+ // The rebuilt native shape must be attached exactly once (setter attaches; _syncNative skips).
+ expect((clone.getComponent(StaticCollider) as any)._nativeCollider._shapes.length).toBe(1);
+ 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 ca13eef6a0..f0889f7d27 100644
--- a/tests/src/ui/Image.test.ts
+++ b/tests/src/ui/Image.test.ts
@@ -69,4 +69,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 0a11923bae..4bba12b37b 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,
@@ -251,4 +252,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 _cloneTo), +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 (_cloneTo) + 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);
+ });
});